In my previous post on Ruby’s building blocks, I covered when to use Struct, Data, Class, and Module. But I glossed over something important: many of these constructs don’t exist in other languages – or exist in such diminished forms that they barely count.
Ruby isn’t just another object-oriented language with different syntax. It has genuinely unique structures that change how you think about code. If you’re coming from Python, Java, JavaScript, or C#, these are the things that will make you say “wait, you can do that?”
This isn’t about Ruby being “better” in some abstract sense. It’s about understanding what Ruby offers that others don’t – and why those differences matter for writing expressive, maintainable code.
Struct: Class Generation as a First-Class Feature
Most languages make you write classes by hand. Ruby lets you generate them.
Person = Struct.new(:name, :email, :age)Code language: PHP (php)
That single line creates a complete class with:
- A constructor accepting three arguments
- Getter and setter methods for each attribute
- Equality comparison based on values
- A sensible
to_srepresentation - Hash and array-like access (
person[:name],person[0])
What Other Languages Offer
Python has namedtuple and @dataclass:
from dataclasses import dataclass
@dataclass
class Person:
name: str
email: str
age: intCode language: CSS (css)
This is close, but it’s a decorator that modifies a class you still have to define. You need type annotations (even if you don’t want them). And it was added in Python 3.7 – Struct has been in Ruby since the beginning.
JavaScript has nothing built-in. You write classes or use plain objects:
class Person {
constructor(name, email, age) {
this.name = name;
this.email = email;
this.age = age;
}
}Code language: JavaScript (javascript)
Java added Records in Java 14:
public record Person(String name, String email, int age) {}Code language: JavaScript (javascript)
Clean, but it took until 2020 to arrive, and records are immutable – you can’t choose.
C# has records too (C# 9.0):
public record Person(string Name, string Email, int Age);Code language: PHP (php)
Same story – late addition, immutable by default.
Why Ruby’s Approach Is Better
Ruby’s Struct isn’t just syntax sugar. It’s a class factory – a method that returns a new class. This is possible because Ruby classes are themselves objects.
# Struct.new returns a class
PersonClass = Struct.new(:name, :email)
PersonClass.class # => Class
# You can subclass it
class Employee < Struct.new(:name, :department)
def formatted
"#{name} (#{department})"
end
end
# You can generate classes dynamically
def create_model(fields)
Struct.new(*fields) do
def valid?
members.none? { |m| send(m).nil? }
end
end
end
User = create_model(:id, :name, :email)Code language: HTML, XML (xml)
This meta-programming capability – generating classes at runtime based on data – is natural in Ruby and awkward or impossible in most other languages.
Data: Immutable Value Objects Done Right
Ruby 3.2 introduced Data – like Struct but immutable. This might seem minor until you realize how other languages handle immutability.
Point = Data.define(:x, :y)
origin = Point.new(0, 0)
origin.x = 5 # NoMethodError - no setter existsCode language: PHP (php)
The Problem Data Solves
In most languages, making something immutable requires discipline or verbosity.
Java (pre-records):
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
@Override
public boolean equals(Object o) { /* ... */ }
@Override
public int hashCode() { /* ... */ }
}Code language: PHP (php)
That’s 20+ lines for what Ruby does in one.
Python:
from dataclasses import dataclass
@dataclass(frozen=True)
class Point:
x: int
y: intCode language: CSS (css)
Better, but frozen=True is opt-in and easy to forget. And “frozen” dataclasses can still contain mutable objects – the immutability is shallow.
JavaScript:
const point = Object.freeze({ x: 0, y: 0 });Code language: JavaScript (javascript)
Object.freeze is shallow, doesn’t prevent reassignment of the variable, and provides no structural guarantees.
Why Ruby’s Data Is Better
Ruby’s Data makes immutability the point of the construct, not an option you enable. When you see Data.define, you know immediately that instances won’t change. It’s semantic clarity through distinct types.
# The type itself communicates intent
Config = Data.define(:api_key, :timeout, :retries)
# Compare to Struct where you have to read the code
# to know if anyone mutates it
Config = Struct.new(:api_key, :timeout, :retries)Code language: PHP (php)
And like Struct, Data is a class factory with full meta-programming support:
Point = Data.define(:x, :y) do
def distance_from_origin
Math.sqrt(x**2 + y**2)
end
def +(other)
Point.new(x + other.x, y + other.y)
end
endCode language: JavaScript (javascript)
Blocks: Code as a Casual Parameter
Blocks are Ruby’s most distinctive feature. They let you pass code to methods as naturally as passing data.
[1, 2, 3].map { |n| n * 2 }
File.open("data.txt") do |file|
file.each_line { |line| puts line }
end
3.times { puts "Hello" }Code language: JavaScript (javascript)
What Other Languages Offer
Python has comprehensions and lambdas:
[n * 2 for n in [1, 2, 3]] # Comprehension - limited to specific patterns
list(map(lambda n: n * 2, [1, 2, 3])) # Lambda - verboseCode language: PHP (php)
Python lambdas are single expressions only. Multi-line logic requires a named function.
JavaScript has arrow functions:
[1, 2, 3].map(n => n * 2)Code language: JavaScript (javascript)
Close to Ruby’s elegance, but JavaScript doesn’t have the yield pattern for implicit blocks.
Java has lambdas (since Java 8):
Arrays.asList(1, 2, 3).stream().map(n -> n * 2).collect(Collectors.toList());Code language: CSS (css)
Functional, but verbose. And you need to explicitly create streams.
C# has LINQ and lambdas:
new[] {1, 2, 3}.Select(n => n * 2);Code language: JavaScript (javascript)
Quite elegant, actually. C# learned from Ruby.
Why Ruby’s Blocks Are Better
Ruby blocks aren’t just anonymous functions – they’re integrated into the language’s control flow. The yield keyword makes blocks feel like native syntax extensions.
def with_timing
start = Time.now
result = yield # Execute the block
puts "Took #{Time.now - start} seconds"
result
end
# Use it like a language construct
with_timing do
expensive_operation
endCode language: PHP (php)
This pattern is awkward in other languages. Python would need decorators or context managers. JavaScript would need callbacks or explicit function calls.
Ruby also distinguishes between blocks, procs, and lambdas – giving you control over how code-as-data behaves:
# Block: implicit, tied to method call
[1, 2, 3].each { |n| puts n }
# Proc: stored code, flexible return behavior
my_proc = Proc.new { |n| n * 2 }
# Lambda: stored code, strict argument checking
my_lambda = ->(n) { n * 2 }Code language: PHP (php)
The ability to capture a block as a proc (&block) and convert between these forms gives you precise control that other languages lack.
Modules: Namespacing and Mixins in One Construct
Most languages separate namespacing from code sharing. Ruby combines them in modules.
module Searchable
def search(query)
# Shared search logic
end
end
class Article
include Searchable
end
class Product
include Searchable
endCode language: PHP (php)
What Other Languages Offer
Python uses files as modules (namespacing) and multiple inheritance or mixins (code sharing):
# Namespacing: just use the file/package system
from myapp.utils import helper
# Code sharing: multiple inheritance or mixins
class SearchMixin:
def search(self, query):
pass
class Article(Model, SearchMixin):
pass
Python’s approach works, but conflates namespacing with file structure. And multiple inheritance is famously problematic (diamond problem).
Java has packages (namespacing) and interfaces with default methods (since Java 8):
public interface Searchable {
default void search(String query) {
// Default implementation
}
}
public class Article implements Searchable {
}Code language: PHP (php)
Interfaces with defaults are Java playing catch-up with Ruby’s mixins – and they’re limited because interfaces can’t have state.
JavaScript has ES6 modules (namespacing) and no built-in mixin pattern:
// Namespacing: import/export
import { helper } from './utils';
// Code sharing: typically Object.assign or class extends
const SearchMixin = {
search(query) { /* ... */ }
};
Object.assign(Article.prototype, SearchMixin);Code language: JavaScript (javascript)
Awkward and doesn’t integrate with the class system.
C# has namespaces and no mixins:
namespace MyApp.Utils { }
// Code sharing requires inheritance or extension methods
// No direct equivalent to mixinsCode language: JavaScript (javascript)
Why Ruby’s Modules Are Better
Ruby modules serve triple duty:
- Namespace: Group related code under a name
- Mixin: Share behavior across unrelated classes
- Singleton container: Hold module-level methods
module Payments
# Namespace for related classes
class Processor; end
class Refund; end
# Module-level utility
def self.default_currency
"USD"
end
end
module Auditable
# Mixin for sharing behavior
def audit_trail
@audit_trail ||= []
end
def record_change(change)
audit_trail << { change: change, at: Time.now }
end
end
class Invoice < Payments::Processor
include Auditable # Mix in auditing capability
endCode language: CSS (css)
The key insight: include inserts the module into the class’s ancestor chain, making method lookup work naturally. This is cleaner than multiple inheritance because modules don’t carry their own initialization or identity – they’re pure behavior packages.
Open Classes: Extending Anything, Anytime
Ruby classes are never closed. You can add methods to any class – including built-in ones – at any time.
class String
def palindrome?
self == self.reverse
end
end
"radar".palindrome? # => true
"hello".palindrome? # => false
What Other Languages Offer
Python allows monkey-patching but discourages it:
def palindrome(self):
return self == self[::-1]
str.palindrome = palindrome # Works, but frowned uponCode language: PHP (php)
It’s possible but feels like a hack. Python’s culture strongly discourages this.
JavaScript allows extending prototypes:
String.prototype.palindrome = function() {
return this === this.split('').reverse().join('');
};Code language: JavaScript (javascript)
Possible, but universally considered bad practice in JavaScript.
Java and C#: Not possible. Classes are closed after compilation.
Why Ruby’s Open Classes Are Better
Ruby doesn’t just permit open classes – it embraces them. The ecosystem is built around this capability.
ActiveSupport extends core classes tastefully:
# From ActiveSupport
1.day.ago
"hello_world".camelize
[1, 2, 3].sumCode language: CSS (css)
Refinements provide scoped modifications (since Ruby 2.0):
module StringExtensions
refine String do
def palindrome?
self == reverse
end
end
end
class MyClass
using StringExtensions # Only active in this scope
def check(word)
word.palindrome?
end
end
"radar".palindrome? # NoMethodError - refinement not active here
This gives you the power of open classes with the safety of scoped changes.
method_missing: The Ultimate Flexibility
When you call a method that doesn’t exist, Ruby doesn’t just fail – it gives you a chance to handle it.
class Flexible
def method_missing(name, *args)
puts "You called #{name} with #{args.inspect}"
end
end
obj = Flexible.new
obj.anything_at_all(1, 2, 3)
# => "You called anything_at_all with [1, 2, 3]"
What Other Languages Offer
Python has __getattr__:
class Flexible:
def __getattr__(self, name):
def method(*args):
print(f"You called {name} with {args}")
return method
Similar capability, but the syntax is clunkier.
JavaScript has Proxy (ES6):
const handler = {
get(target, prop) {
return (...args) => console.log(`Called ${prop} with`, args);
}
};
const obj = new Proxy({}, handler);
obj.anything(1, 2, 3);Code language: JavaScript (javascript)
Powerful but verbose and not commonly used.
Java and C#: Not possible without reflection hacks that don’t integrate with normal method calls.
Why Ruby’s method_missing Is Better
Ruby’s method_missing enables some of the language’s most elegant patterns.
Dynamic finders in ActiveRecord:
User.find_by_email("alice@example.com")
User.find_by_email_and_status("alice@example.com", "active")Code language: CSS (css)
These methods don’t exist until you call them – ActiveRecord generates them on the fly.
Builder patterns:
class XMLBuilder
def initialize
@xml = ""
end
def method_missing(tag, content = nil, &block)
@xml += "<#{tag}>"
if block
@xml += instance_eval(&block)
else
@xml += content.to_s
end
@xml += ""
end
end
builder = XMLBuilder.new
builder.html do
head do
title "My Page"
end
body do
p "Hello, world!"
end
endCode language: JavaScript (javascript)
This creates a domain-specific language that reads like the output format itself.
Symbols: Identity Without the Overhead
Symbols look like strings but behave like identifiers. They’re immutable, interned, and compared by identity rather than content.
:name.object_id == :name.object_id # => true (same object)
"name".object_id == "name".object_id # => false (different objects)Code language: PHP (php)
What Other Languages Offer
Python has no direct equivalent. You use strings everywhere:
{"name": "Alice", "email": "alice@example.com"}Code language: JSON / JSON with Comments (json)
JavaScript added Symbols in ES6, but they’re used differently:
const name = Symbol('name');
obj[name] = "Alice"; // Private-ish propertyCode language: JavaScript (javascript)
JavaScript Symbols are for hiding properties, not for readable keys.
Java and C#: Use strings or enums. No equivalent concept.
Why Ruby’s Symbols Are Better
Symbols are perfect for identifiers that aren’t data – hash keys, method names, options, states.
# Hash keys (overwhelmingly idiomatic)
user = { name: "Alice", email: "alice@example.com" }
# Options
redirect_to root_path, notice: "Welcome back!"
# States and types
status = :pending
case status
when :pending then handle_pending
when :approved then handle_approved
end
# Method references
users.map(&:name) # Same as users.map { |u| u.name }Code language: PHP (php)
The distinction between symbols and strings makes intent clearer. When you see a symbol, you know it’s an identifier. When you see a string, you know it’s data that might be displayed or manipulated.
Everything Is an Object (And Everything Is Executable)
In Ruby, there are no primitives. Numbers, booleans, even nil – all objects with methods.
42.times { puts "hello" }
true.to_s # => "true"
nil.nil? # => trueCode language: PHP (php)
But it goes further. Classes are objects. Methods are objects. Code blocks can become objects.
# Classes are objects of class Class
String.class # => Class
String.ancestors # => [String, Comparable, Object, Kernel, BasicObject]
# Methods can be extracted as objects
method_obj = "hello".method(:upcase)
method_obj.call # => "HELLO"
# Code becomes objects
my_block = -> { puts "I'm a lambda" }
my_block.call
What Other Languages Offer
Python is close – most things are objects:
(42).__add__(8) # Works
type(42) # Code language: PHP (php)
But Python has some non-object constructs (None is not quite as object-like, there are statement-vs-expression distinctions).
JavaScript has object wrappers for primitives, but the distinction exists:
typeof 42 // "number" - primitive
typeof new Number(42) // "object" - wrapperCode language: JavaScript (javascript)
Java and C# have primitives vs objects, requiring boxing/unboxing.
Why Ruby’s Consistency Is Better
When everything is an object, you don’t have mental mode-switching. The same tools (method calls, introspection, extension) work everywhere.
# Extend integers
class Integer
def factorial
(1..self).reduce(1, :*)
end
end
5.factorial # => 120
# Introspect anything
42.methods.grep(/div/) # => [:div, :divmod, :fdiv]
# Pass methods around
[1, 2, 3].map(&1.method(:+)) # => [2, 3, 4]
This consistency reduces cognitive load. You learn one set of patterns and apply them everywhere.
The Eigenclass: Object-Level Customization
Every Ruby object can have its own private class called the eigenclass (or singleton class). This lets you add methods to individual objects.
alice = "Alice"
def alice.shout
upcase + "!"
end
alice.shout # => "ALICE!"
"Bob".shout # NoMethodError - only alice has this methodCode language: PHP (php)
What Other Languages Offer
Python allows adding methods to instances:
alice = "Alice"
def shout(self):
return self.upper() + "!"
import types
alice.shout = types.MethodType(shout, alice) # Doesn't work - strings are immutableCode language: PHP (php)
Python can do this with custom classes but not built-in types.
JavaScript can add properties to any object:
const alice = { name: "Alice" };
alice.shout = function() { return this.name.toUpperCase() + "!"; };Code language: JavaScript (javascript)
Works, but there’s no formal concept of “this object’s private class.”
Java and C#: Not possible without creating a subclass.
Why Ruby’s Eigenclass Is Better
The eigenclass makes Ruby’s object model complete. Class methods are actually methods on the eigenclass of the class object:
class User
def self.count # This is actually defined on User's eigenclass
# ...
end
end
# Equivalent to:
class User
class << self # Open the eigenclass
def count
# ...
end
end
end
This unifies instance methods and class methods under one model – both are just methods, defined on different classes (the regular class vs. the eigenclass).
Understanding the eigenclass explains why include adds instance methods while extend adds class methods – extend is actually including into the eigenclass.
Why This Matters: Expressiveness and Joy
These features aren’t academic curiosities. They enable a style of programming that’s hard to achieve elsewhere:
Domain-Specific Languages that read like natural language:
describe User do
it "validates email presence" do
expect(User.new(email: nil)).not_to be_valid
end
endCode language: PHP (php)
Configuration that’s just Ruby:
Rails.application.configure do
config.cache_classes = true
config.eager_load = true
endCode language: JavaScript (javascript)
APIs that feel native:
class Article < ApplicationRecord
belongs_to :author
has_many :comments
validates :title, presence: true
scope :published, -> { where(published: true) }
endCode language: HTML, XML (xml)
None of this is magic – it’s blocks, method_missing, open classes, and modules working together.
The Trade-Offs
Ruby’s flexibility has costs:
Performance: Dynamic method dispatch is slower than static. Ruby trades speed for expressiveness.
Predictability: When anything can be modified, you can’t always be sure what code will do. Discipline matters.
Tooling: Static analysis is harder when methods might not exist until runtime. IDEs have more trouble helping you.
But these trade-offs are conscious choices. Ruby optimizes for programmer happiness and productivity, trusting developers to use flexibility responsibly.
Conclusion: Different, Not Just Different Syntax
Ruby isn’t just Python with different syntax or Java without types. Its unique structures – Struct as a class factory, blocks with yield, modules as mixins, open classes, method_missing, symbols, the eigenclass – create a fundamentally different programming experience.
These features work together. Blocks enable DSLs. Modules enable clean code sharing. Open classes enable tasteful extensions. method_missing enables dynamic interfaces. The eigenclass unifies the object model.
Understanding these Ruby-specific concepts is key to writing idiomatic Ruby. Don’t just translate patterns from other languages – embrace what makes Ruby different.
That’s where the joy is.
Coming from another language and finding Ruby’s flexibility disorienting? Or have you found Ruby constructs that made you wish other languages had them? I’d love to hear your perspective.