Basics & Syntax
Hello, World
One line each, and the difference is everything that is absent on the right: no
import, no type, no semicolon, and no parentheses required around the argument.print("Hello, World!")puts "Hello, World!"
# No parentheses needed on a call. No semicolons. No braces around a method body.
# Ruby drops nearly every piece of syntax that is not carrying meaning.
puts 1 + 2 * 3Both languages let the file body be the program. Ruby goes further and drops the parentheses too —
puts "x" is a method call with the parentheses omitted, which is legal on every call and is the house style. It takes about a week to stop feeling like something is missing.No let, no var, no types
let and var both become a bare name. Nothing distinguishes a constant from a variable, and nothing declares a type.let fixed = 1
var counter = 0
counter += 1
// fixed = 2 ← will not compile
let name = "Ada"
let age = 36
print("\(name) is \(age)")
let letter = """
Dear \(name),
Regards
"""
print(letter)counter = 0 # no keyword, no type, always reassignable
counter += 1
FIXED = 1 # SCREAMING_CASE makes a CONSTANT — and reassigning it merely
# WARNS. Ruby still lets it happen.
name = "Ada"
age = 36
puts "#{name} is #{age}" # interpolation needs the full #{...}
puts 'single quotes do NOT interpolate: #{name}'
# The squiggly heredoc is Swift's multi-line string: the ~ strips the indent.
letter = <<~HEREDOC
Dear #{name},
Regards
HEREDOC
puts letterThere is no
let: a capitalized name makes a "constant", and Ruby's idea of constant is a warning printed to stderr rather than an error. The immutability discipline is entirely on you. What Ruby does freeze, as of 4.0, is string literals — "hello" << " world" now raises FrozenError, which is the language quietly conceding the point. Note that the quote style matters: single-quoted strings do no interpolation and no escape processing at all.Everything Is an Object, Including a Number
Swift has value types with methods, which already feels object-like. Ruby goes further:
3 is an instance of a class you can reopen, and so is nil, and so is the class itself.let count = 3
print(count.description)
print(type(of: count))
// Int is a struct with methods, but the literal 3 is not a
// reference and Int is not subclassable.
print((1...3).map { $0 * 2 })count = 3
puts count.to_s
puts count.class
# 3 is an instance of Integer, Integer is a class, and Class is
# itself an object. There is no primitive anywhere.
puts 3.times.to_a.inspect
puts (1..3).map { |n| n * 2 }.inspectThe practical consequence is that a method call is the only thing that ever happens —
3 + 4 is 3.+(4), and if, while and case are the short list of things that are not. That uniformity is what makes 3.times, 5.downto(1) and 1.step(10, 2) possible, and it is why the answer to "can I add a method to that" is always yes.Symbols Are Not Strings
A symbol is an interned name written with a leading colon. There is nothing to declare and nothing to import, which is why Ruby APIs take
:active where a Swift API would define an enum.// Swift's nearest equivalent is a case of an enum, or a static
// string constant — both of which need declaring.
enum Status {
case active
case archived
}
let status = Status.active
print(status)
print(status == .active)# A symbol needs no declaration and no enum. It is an interned
# name, compared by identity, and it is what a Ruby API uses
# where Swift would use an enum case.
status = :active
puts status
puts status == :active
puts status.class
puts :active.equal?(:active) # the SAME object, alwaysThe trade is exactly the one this page keeps finding: a symbol costs nothing to introduce and nothing checks that you spelled it right, so
:activ is a perfectly good symbol that simply never matches. Two Ruby-specific facts worth knowing early: symbols are compared by identity so == on them is a pointer comparison, and 🚨 symbols created from user input are never garbage collected — String#to_sym on untrusted data is a memory leak, and to_sym on a fixed set of names is fine.Dynamic Typing
No types, and no compiler
The same function called with two unrelated types. Swift needs a generic or an overload to express this; Ruby needs nothing, because the check happens when the method is called.
struct User {
let name: String
let age: Int
}
func describe(_ user: User) -> String {
"\(user.name) is \(user.age)"
}
let ada = User(name: "Ada", age: 36)
print(describe(ada))
// print(ada.nmae) ← compile error: no such property
// print(describe("nope")) ← compile error: wrong type
// Both are caught BEFORE the program runs.# No declarations, no annotations, no compiler. A method takes whatever it is
# given and fails when it fails.
def describe(user)
"#{user.name} is #{user.age}"
end
User = Struct.new(:name, :age) # the quickest data type Ruby has
ada = User.new("Ada", 36)
puts describe(ada)
# DUCK TYPING: anything with .name and .age works. No protocol, no conformance.
Robot = Struct.new(:name, :age)
puts describe(Robot.new("R2", 5))
# A misspelled method is an error only when that LINE RUNS.
begin
puts ada.nmae
rescue NoMethodError => error
puts "NoMethodError: #{error.message}"
endEverything the Swift compiler does for you is gone: no type checking, no exhaustiveness, no null safety, no arity checking of a block you passed along. A typo is a
NoMethodError at the moment that line executes, which is why Ruby culture is so test-obsessed — the test suite is the type checker, and it is not optional. What you get back is duck typing: describe above accepts anything with a name and an age, with no protocol declared and no conformance to write, which is why mocking and adapting in Ruby take no ceremony at all.Duck Typing Replaces the Protocol
The protocol, both conformance declarations and the parameter type all disappear. If the object has the method, the call works; if it does not, it raises when the line runs.
protocol Describable {
func describe() -> String
}
struct Dog: Describable {
func describe() -> String { "a dog" }
}
struct Cat: Describable {
func describe() -> String { "a cat" }
}
func announce(_ value: Describable) {
print(value.describe())
}
announce(Dog())
announce(Cat())# No protocol, no conformance, no declaration. Having the method
# is the whole requirement.
class Dog
def describe = "a dog"
end
class Cat
def describe = "a cat"
end
def announce(value)
puts value.describe
end
announce(Dog.new)
announce(Cat.new)What Swift gets from the declaration is a compiler that can list every conforming type, refuse a non-conforming one at the call site, and rename the method across all of them. What Ruby gets is that a type written years later by someone else satisfies your function without either side knowing — and that a test double needs only the one method you actually call, which is why Ruby testing needs no mocking framework to substitute a collaborator.
respond_to?(:describe) is the run-time check when you need one.No Generics, Because Nothing Needs Them
A generic function with a
Comparable constraint becomes a method with no type information at all — and, in this case, a method that already exists.func largest<Element: Comparable>(_ values: [Element]) -> Element {
var best = values[0]
for value in values where value > best { best = value }
return best
}
print(largest([3, 9, 2]))
print(largest(["pear", "apple", "plum"]))# The generic parameter and its constraint both disappear: the
# method works for anything that responds to <=>.
def largest(values)
values.max
end
puts largest([3, 9, 2])
puts largest(["pear", "apple", "plum"])Generics exist in Swift to let the compiler check a function it will stamp out per type. With no compile-time checking there is nothing to parameterize, so the whole feature is unnecessary rather than missing. What you lose with it: the constraint is no longer written down anywhere, so "what does this method require of its argument" is answered by reading the body. The Ruby convention that partly replaces it is to document the duck type and to raise early —
raise ArgumentError unless value.respond_to?(:<=>).nil Instead of Optionals
nil is an object, and nothing checks it
nil is an ordinary value that any expression may produce, so there is no Optional wrapper and nothing to unwrap.func findName(_ id: Int) -> String? {
id == 1 ? "Ada" : nil
}
// The compiler refuses to let you use it unwrapped.
print(findName(1)?.uppercased() ?? "(none)")
if let name = findName(1) {
print(name.count)
}
func shout(_ id: Int) -> String {
guard let name = findName(id) else { return "(nobody)" }
return name.uppercased()
}
print(shout(99))def find_name(id) = id == 1 ? "Ada" : nil # an endless method (Ruby 3+)
# &. is the safe-navigation operator — Swift's ?. exactly.
puts find_name(1)&.upcase || "(none)"
puts find_name(99)&.upcase || "(none)"
# || is the nil-coalescing operator... with a catch: it triggers on FALSE too,
# because only nil and false are falsy. (0 and "" are TRUTHY.)
puts (find_name(99) || "(none)")
def shout(id)
name = find_name(id)
return "(nobody)" if name.nil? # the guard-let equivalent: a trailing if
name.upcase
end
puts shout(99)
# nil is an OBJECT with methods. That is what makes it survivable.
puts nil.to_a.inspect # []
puts nil.to_s.inspect # ""
puts nil.nil? # true
# But call a method it does not have, and you get Ruby's nil crash:
begin
find_name(99).upcase
rescue NoMethodError => error
puts "NoMethodError: #{error.message}"
endRuby has
&. (safe navigation) and || (nil-coalescing), so the operators feel familiar — but nothing makes you use them, and undefined method 'upcase' for nil is the error you will meet most often. Two Ruby-specific notes. nil is a real object with methods (nil.to_a is [], nil.to_s is ""), which makes a surprising amount of code survive it. And only nil and false are falsy — 0 and "" are truthy — so value || default is not quite ??: it also fires when the value is legitimately false.Optional Chaining Becomes &.
&. is optional chaining and || stands in for ??. The syntax corresponds almost exactly; what differs is that nothing requires you to use it.struct Address { let city: String }
struct User { let address: Address? }
let user = User(address: nil)
// The compiler knows this may be nil and forces you to say so.
print(user.address?.city ?? "unknown")
let present = User(address: Address(city: "Cambridge"))
print(present.address?.city ?? "unknown")class Address
attr_reader :city
def initialize(city) = @city = city
end
class User
attr_reader :address
def initialize(address) = @address = address
end
user = User.new(nil)
puts(user.address&.city || "unknown")
present = User.new(Address.new("Cambridge"))
puts(present.address&.city || "unknown")🚨 The difference that bites:
|| is not ??. It falls back on false as well as nil, so enabled || true is wrong for a boolean that is legitimately false. Ruby's exact equivalent is the safe-assignment form value = other if value.nil?, or value.nil? ? fallback : value. And because only nil and false are falsy in Ruby — 0 and "" are truthy — || is safer here than the same operator is in JavaScript or Python.What a nil Mistake Looks Like
The same empty array. Swift hands back an
Optional the compiler will not let you ignore; Ruby hands back nil and lets you add one to it.let values: [Int] = []
// first is Optional<Int>, and the compiler will not let you use
// it as an Int without handling the empty case.
if let first = values.first {
print(first + 1)
} else {
print("empty")
}values = []
# first returns nil, and nothing stops you adding one to it.
begin
puts values.first + 1
rescue NoMethodError => error
puts "NoMethodError: #{error.message[0, 30]}"
end
# So the check is yours to write:
puts values.first ? values.first + 1 : "empty"NoMethodError: undefined method '+' for nil is the single most common Ruby error in production, and it is what Swift's optionals exist to make impossible. The defenses are ordinary and worth adopting: fetch rather than [] on a hash so a missing key raises immediately, Array#dig and Hash#dig for nested access, guard clauses at the top of a method, and &. where nil is genuinely expected. Ruby 3's NoMethodError also now suggests the method you probably meant.Blocks & Enumerable
Trailing closures become blocks
Swift's trailing closure is Ruby's block — but a block is not a value, it is a parameter of the call itself, and that difference is the foundation of the whole language.
let numbers = [1, 2, 3, 4]
print(numbers.map { $0 * 2 })
print(numbers.filter { $0 % 2 == 0 })
print(numbers.reduce(0, +))
// A function taking a closure.
func twice(_ body: () -> Void) {
body()
body()
}
twice { print("hello") }
// A closure stored in a value.
let double: (Int) -> Int = { $0 * 2 }
print(double(21))numbers = [1, 2, 3, 4]
puts numbers.map { |number| number * 2 }.inspect
puts numbers.select { |number| number.even? }.inspect # filter is 'select'
puts numbers.sum
puts numbers.reduce(0) { |total, number| total + number }
# &: is the symbol-to-proc shorthand — Swift's \.keyPath, roughly.
puts numbers.map(&:to_s).inspect
# A method taking a block. 'yield' calls it — there is no parameter to declare.
def twice
yield
yield
end
twice { puts "hello" }
# 3.times { } — because Integer has a 'times' method that takes a block.
3.times { |index| print index }
puts
# A block CAN be captured as a value (a proc/lambda), but that is the exception.
double = ->(number) { number * 2 }
puts double.call(21)
puts double.(21)A block is passed to every method invisibly, and
yield calls it — so a method that takes a block declares nothing, and control structures that look built-in (3.times, each, File.open) are just methods with blocks. That is why Ruby needs no defer: File.open(path) { |file| … } closes the file when the block ends, which is Swift's defer and with expressed as an ordinary method. The idioms to pick up are |parameters| (Swift's $0 is |number|, named), &:symbol (map(&:to_s)), and ->() { } for the rare case where you need a closure as a value.Enumerable: the standard library you already know
The method names are close enough to read directly —
map is map, filter is select, reduce is reduce.let names = ["Ada", "Grace", "Alan", "Barbara"]
print(names.filter { $0.count > 3 }.map { $0.uppercased() })
print(names.map(\.count).reduce(0, +))
print(names.sorted())
print(names.first { $0.hasPrefix("G") } ?? "(none)")
print(Dictionary(grouping: names, by: \.count).keys.sorted())
print(names.contains { $0.count > 5 })
let ages = ["Ada": 36, "Alan": 41]
print(ages.mapValues { $0 + 1 })names = ["Ada", "Grace", "Alan", "Barbara"]
puts names.select { |name| name.length > 3 }.map(&:upcase).inspect
puts names.sum(&:length)
puts names.sort.inspect
puts (names.find { |name| name.start_with?("G") } || "(none)")
puts names.group_by(&:length).keys.sort.inspect
puts names.any? { |name| name.length > 5 }
ages = { "Ada" => 36, "Alan" => 41 }
puts ages.transform_values { |age| age + 1 }.inspect
# And the ones Swift makes you build: each_slice, each_cons, partition, tally,
# each_with_object, zip, flat_map, chunk_while, lazy...
puts names.partition { |name| name.length > 3 }.inspect
puts names.map(&:length).tally.inspect
puts (1..Float::INFINITY).lazy.map { |number| number * 2 }.first(3).inspectThe mapping is almost a rename —
filter is select, first(where:) is find, contains is any?, mapValues is transform_values — and then Enumerable keeps going, with tally, partition, each_cons, each_slice, chunk_while, each_with_object and dozens more. This is the part of Ruby that will actually make you happy: it is the richest collection library of any language on this site, and any class becomes a full member of it by defining each and include Enumerable. .lazy even means the same thing it does in Swift.yield, and the Invisible Parameter
A block is passed to every method whether or not it is declared, and
yield calls it. Nothing in the signature says a method takes one.// A closure parameter is declared, named and called explicitly.
func repeatTimes(_ count: Int, _ action: (Int) -> Void) {
for index in 0..<count { action(index) }
}
repeatTimes(3) { index in
print("step \(index)")
}# The block is an INVISIBLE extra parameter — not in the
# signature, called with yield.
def repeat_times(count)
count.times { |index| yield index }
end
repeat_times(3) do |index|
puts "step #{index}"
end
# block_given? asks whether one was passed at all:
def maybe
block_given? ? yield : "no block"
end
puts maybeThat invisibility is the single biggest structural difference from Swift closures, and it is what makes Ruby read the way it does:
each, times, File.open and every Rails DSL are one method plus one block. Two things worth knowing: block_given? tests whether a block was passed, and prefixing a parameter with & (def each(&block)) captures it as a real object you can store or pass on — which is the point where it becomes a Swift closure again.A Block Brackets a Resource
Swift's
defer registers cleanup at the call site. Ruby puts it in the method that owns the resource, which then takes a block — so the caller cannot forget.// Swift uses defer, which runs at the end of the enclosing scope.
func work() {
print("opened")
defer { print("closed") }
print("working")
}
work()
print("after work()")# Ruby has no defer. The method that hands out the resource takes
# a block and brackets it with ensure — which is why File.open,
# Dir.chdir and every transaction API take one.
def with_resource
puts "opened"
yield
ensure
puts "closed"
end
with_resource { puts "working" }
puts "after work()"That is the better arrangement of the two for anything a library hands out, and it is why
File.open(path) { |file| … } is the idiomatic form rather than open plus close. ensure runs on every path out including an exception, exactly as defer does. Where Swift's version wins is a local resource in the middle of a long method — Ruby has no equivalent short of restructuring, and the usual answer is that the method was too long anyway.Lazy Sequences
Both languages make an infinite sequence finite by being lazy, and both spell it
lazy. The chain reads the same way in each.let firstThree = (1...).lazy
.filter { $0 % 3 == 0 }
.prefix(3)
print(Array(firstThree))first_three = (1..Float::INFINITY).lazy
.select { |n| (n % 3).zero? }
.first(3)
puts first_three.inspectWithout
lazy, select on an infinite range never returns — the same trap as leaving .lazy off a Swift sequence. Ruby's Enumerator::Lazy supports map, select, reject, take_while and first, and Enumerator.new lets you write a producer of your own. A generator-shaped method is Enumerator plus yielder << value, which is close to Swift's AsyncStream in feel and entirely synchronous.Classes & Open Classes
Classes, and everything is one
No
init keyword, no explicit member declarations, and no access control by default — attr_reader generates the getter.class Rectangle {
let width: Double
var height: Double
var area: Double { width * height } // computed property
init(width: Double, height: Double) {
self.width = width
self.height = height
}
func scaled(by factor: Double) -> Rectangle {
Rectangle(width: width * factor, height: height * factor)
}
}
let rectangle = Rectangle(width: 3, height: 4)
print(rectangle.area)
rectangle.height = 10
print(rectangle.area)class Rectangle
# attr_reader generates the getters; attr_accessor also generates setters.
attr_reader :width
attr_accessor :height
def initialize(width, height)
@width = width # @ marks an INSTANCE VARIABLE. It is private, always:
@height = height # there is no way to read it from outside except a method.
end
# A "computed property" is just a method. Ruby has no property syntax, and
# needs none — a getter IS a method, so the caller cannot tell them apart.
def area = width * height
def scaled(by:) # a keyword argument, and it is REQUIRED
Rectangle.new(width * by, height * by)
end
end
rectangle = Rectangle.new(3, 4)
puts rectangle.area
rectangle.height = 10 # calls the generated setter, height=
puts rectangle.area
puts rectangle.scaled(by: 2).area
# Everything is an object, including classes and nil.
puts 3.times.to_a.inspect
puts Rectangle.class
puts nil.classThe "uniform access principle" is doing quiet work here: a Ruby getter is a method, so
rectangle.area and rectangle.width look identical to a caller and either can be swapped for the other without changing a single call site — which is what Swift's computed properties buy you, arrived at from the other direction. attr_reader/attr_accessor generate the accessors (and they are themselves ordinary method calls that write methods — see Metaprogramming). Instance variables (@width) are always private; there is no way to reach one from outside except through a method.Extensions become open classes — with no guardrails
🚨 A class you did not write, reopened and given a method, globally and retroactively. There is no Swift equivalent that reaches a type's own dispatch this way.
// An extension can add methods and computed properties to a type you do not own.
extension String {
var initials: String {
split(separator: " ").compactMap { $0.first }.map(String.init).joined()
}
func shout() -> String { uppercased() + "!" }
}
print("Ada Lovelace".initials)
print("hello".shout())
// What it CANNOT do: change an existing method. 'uppercased()' is safe from you,
// and from every library you import.# There are no extensions, because there is no need: a class is never closed.
# Reopening String adds methods to EVERY string in the program, forever.
class String
def initials = split(" ").map { |word| word[0] }.join
def shout = upcase + "!"
end
puts "Ada Lovelace".initials
puts "hello".shout
# And you can REDEFINE an existing method. Globally. Including one in the
# standard library. Nothing stops you, and nothing warns the code that depended
# on the old behavior.
class Integer
def even? = "I have been redefined" # a terrible idea, and perfectly legal
end
puts 2.even?
# The sanctioned version: a refinement, which is scoped to the file that
# activates it — Ruby's answer to exactly this danger.
module Shouting
refine String do
def polite = "#{self}, please"
end
endA Swift extension can add to a type but never change it:
uppercased() means what it always meant, no matter what any library does. Ruby has no such protection — reopening Integer and redefining even? is legal, global, and instantly affects every gem in the process. This is "monkey patching", it is genuinely powerful (Rails is built on it), and it is how a language gets a reputation for spooky action at a distance. refine is the scoped, sanctioned version, and almost nobody uses it. Treat the ability the way you treat unsafeBitCast.attr_accessor Replaces the Property
attr_reader and attr_accessor generate the accessor methods, and @value is an instance variable — the @ is what makes it one, with no declaration anywhere.class Counter {
private(set) var value: Int
var label: String
init(value: Int = 0, label: String = "counter") {
self.value = value
self.label = label
}
func increment(by amount: Int = 1) -> Int {
value += amount
return value
}
}
let counter = Counter(value: 10)
_ = counter.increment()
print(counter.increment(by: 5), counter.label)class Counter
attr_reader :value # generates the getter only
attr_accessor :label # getter and setter
def initialize(value: 0, label: "counter")
@value = value
@label = label
end
def increment(by: 1)
@value += by
end
end
counter = Counter.new(value: 10)
counter.increment
puts "#{counter.increment(by: 5)} #{counter.label}"Two conveniences transfer directly: keyword arguments with defaults line up with Swift's argument labels, and a method returns its last expression so
return is usually omitted. The difference underneath is that an instance variable is created on first assignment, so a typo in @vlaue makes a second variable rather than an error — and reading an unset one returns nil rather than failing. That is the same trade as everywhere else on this page, in the place it costs most.There Is No Value Type
🚨
Struct looks like Swift's struct and behaves like a class: assignment shares the object, so modifying second modifies first.struct Point {
var x: Int
var y: Int
}
var first = Point(x: 1, y: 2)
var second = first // a COPY: struct is a value type
second.x = 99
print(first.x, second.x)Point = Struct.new(:x, :y)
first = Point.new(1, 2)
second = first # a REFERENCE: there is no value type
second.x = 99
puts "#{first.x} #{second.x}" # both 99
# Data.define is the immutable one, and it is what you want:
Frozen = Data.define(:x, :y)
origin = Frozen.new(x: 1, y: 2)
moved = origin.with(y: 5)
puts "#{origin.y} #{moved.y}"There are no value types in Ruby at all — every object is a reference, and the copy-on-assignment reasoning a Swift developer relies on does not hold anywhere.
Data.define, added in Ruby 3.2, is the closest thing and is the one to reach for: it is frozen, compares by value, and has a with method that returns a modified copy, which together give you most of what a Swift struct guarantees. Reaching for it wherever you would have written a struct is the single habit most worth carrying across.Protocols Become Mixins
Protocols and protocol extensions become modules
A module is a bag of methods mixed into a class with
include. It is closest to a protocol extension, with one large difference the afterword names.// Conform to Comparable, implement <, and get <=, >, >=, sort, max, min free.
struct Money: Comparable, CustomStringConvertible {
let cents: Int
var description: String { "$\(Double(cents) / 100)" }
static func < (left: Money, right: Money) -> Bool {
left.cents < right.cents
}
}
let prices = [Money(cents: 300), Money(cents: 100), Money(cents: 200)]
print(prices.sorted().map(\.description))
print(prices.max()!)
print(Money(cents: 100) < Money(cents: 200))class Money
include Comparable # a MIXIN — the module's methods become this class's
attr_reader :cents
def initialize(cents)
@cents = cents
end
# Define ONE method, the spaceship operator, and Comparable gives you
# < <= > >= == between? clamp sort max min — exactly like conforming to
# Comparable in Swift, and by the same logic.
def <=>(other) = cents <=> other.cents
def to_s = "$#{cents / 100.0}"
end
prices = [Money.new(300), Money.new(100), Money.new(200)]
puts prices.sort.map(&:to_s).inspect
puts prices.max.to_s
puts Money.new(100) < Money.new(200)
# A module is also how you share behavior WITH state, which a Swift protocol
# extension cannot do — and it can hook into the class as it is included.
module Greetable
def greet = "Hello from #{self.class}"
end
class Money
include Greetable
end
puts Money.new(1).greetA module is a protocol and a protocol extension in one:
include Comparable mixes its methods into your class, and — exactly as in Swift — you implement one primitive (<=>, the "spaceship" operator) and inherit every comparison operator, sort, max, min and clamp for free. include Enumerable works the same way from each. The difference is that modules are dynamic: nothing declares conformance at compile time, nothing checks that you implemented <=>, and a missing method surfaces as a NoMethodError the first time someone sorts your objects.One Method Buys Six
Both languages get comparison operators from one definition. Ruby's
<=> returns −1, 0 or 1, and Comparable builds the six operators from it.struct Version: Comparable {
let number: Int
static func < (left: Version, right: Version) -> Bool {
left.number < right.number
}
}
let versions = [Version(number: 3), Version(number: 1)]
print(versions.sorted().map(\.number))
print(Version(number: 1) < Version(number: 3))class Version
include Comparable # the mixin
attr_reader :number
def initialize(number) = @number = number
# Define ONE method and the mixin supplies <, <=, >, >=,
# ==, between? and clamp.
def <=>(other) = number <=> other.number
end
versions = [Version.new(3), Version.new(1)]
puts versions.sort.map(&:number).inspect
puts Version.new(1) < Version.new(3)The same pattern is the whole of
Enumerable: define each, include the module, and you have map, select, reduce, sort_by, group_by, min, max and about forty more. That is considerably more leverage than a Swift protocol extension, because the protocol requirement is a single method rather than a set — and it costs the thing this page keeps naming: nothing checks that your class defines <=> until something calls it.A Module Is Also a Namespace
A module with no
include is a namespace holding constants and module methods — which is what a caseless enum is doing on the Swift side.enum Geometry { // a caseless enum as a namespace
static let pi = 3.14159
static func area(radius: Double) -> Double {
pi * radius * radius
}
}
print(Geometry.area(radius: 2))module Geometry
PI = 3.14159
def self.area(radius)
PI * radius * radius
end
end
puts Geometry.area(2)
puts Geometry::PISo
module does two unrelated jobs: mixed into a class it adds instance methods, and used on its own it groups names. The :: separator reaches a constant, and constants are any name starting with a capital — which means a class name is a constant, and reassigning one is a warning rather than an error. Ruby has no private at file scope, so a module is also how you keep a helper out of the global namespace.Pattern Matching
switch becomes case/in — without exhaustiveness
case/in is real structural matching, added in Ruby 3.0 — it destructures arrays and hashes and binds sub-patterns, which case/when does not.enum Shape {
case circle(radius: Double)
case rectangle(width: Double, height: Double)
}
func area(_ shape: Shape) -> Double {
switch shape { // EXHAUSTIVE: a missing case will not compile
case .circle(let radius):
return 3.14159 * radius * radius
case .rectangle(let width, let height):
return width * height
}
}
print(area(.circle(radius: 1)))
print(area(.rectangle(width: 2, height: 3)))Circle = Struct.new(:radius)
Rectangle = Struct.new(:width, :height)
def area(shape)
# case/in is structural pattern matching (Ruby 3). It DECONSTRUCTS as it matches.
case shape
in Circle[radius]
3.14159 * radius * radius
in Rectangle[width, height]
width * height
end
end
puts area(Circle.new(1))
puts area(Rectangle.new(2, 3))
# It also matches the shape of hashes and arrays — which Swift cannot do, and
# which is exactly what you want for decoded JSON.
message = { type: "click", x: 3, y: 4 }
case message
in { type: "click", x:, y: }
puts "click at #{x},#{y}"
in { type: "key", key: }
puts "key #{key}"
end
case [1, 2, 3, 4]
in [first, *rest]
puts "#{first} then #{rest.inspect}"
end
# NO exhaustiveness: an unmatched value raises NoMatchingPatternError at RUN TIME.
begin
case 42
in String then puts "never"
end
rescue NoMatchingPatternError => error
puts "NoMatchingPatternError: #{error.message}"
endRuby 3's
case/in is a real structural pattern match: it deconstructs objects (any class can opt in with deconstruct/deconstruct_keys), and it matches the shape of hashes and arrays, which switch cannot do. What it does not have is exhaustiveness — nothing tells you a case is missing, and an unmatched value raises NoMatchingPatternError at run time. That is louder than Python's silent fall-through, and it is still not the compile-time guarantee that makes Swift enums safe to extend. (Note also the older case/when, which is a different construct: it tests with === and does no destructuring.)case/when Matches by ===
case/when is the older form and it matches with ===, which every class defines for itself — a class matches an instance, a range matches a member, a regular expression matches a string.func classify(_ value: Any) -> String {
switch value {
case let number as Int where number < 0: return "negative"
case is Int: return "an integer"
case let text as String: return "text of length \(text.count)"
default: return "something else"
}
}
print(classify(-5))
print(classify("hello"))
print(classify(3.5))def classify(value)
case value
when Integer then value.negative? ? "negative" : "an integer"
when String then "text of length #{value.length}"
when 1..10 then "in range"
else "something else"
end
end
puts classify(-5)
puts classify("hello")
puts classify(3.5)That makes
when extensible in a way a Swift switch is not: define === on your own class and it can appear in a when. 🚨 Notice the two columns disagree about 3.5: Swift falls through to the default, while Ruby answers in range because (1..10) === 3.5 is true — a Range covers a float, which is === doing exactly what it is documented to do and not what a reader skimming the branches expects. What is missing is exhaustiveness — a case with no else that matches nothing returns nil rather than failing to compile, which is the single most consequential difference from switch. Use case/in, in the row above, when you want structure rather than class membership.Metaprogramming
Methods can be written at run time
The capability with no Swift equivalent, and the reason Rails looks the way it does. A class body is ordinary code that runs, so it can define methods while it runs.
// Swift's dynamism is limited and deliberate. There is no way to define a method
// at run time, no method_missing, and no eval. What you have instead:
// - @dynamicMemberLookup, for a fixed protocol of dynamic access
// - Mirror, for read-only reflection
// - macros (Swift 5.9+), which generate code at COMPILE time
struct Person {
let name: String
let age: Int
}
let ada = Person(name: "Ada", age: 36)
// Reflection: read-only, and it cannot add anything.
for child in Mirror(reflecting: ada).children {
print(child.label ?? "?", child.value)
}class Person
# attr_accessor is NOT syntax. It is a method call, running right now, in the
# class body — and what it does is DEFINE METHODS. You can write it yourself:
def self.my_attr_accessor(*names)
names.each do |name|
define_method(name) { instance_variable_get("@#{name}") }
define_method("#{name}=") { |value| instance_variable_set("@#{name}", value) }
end
end
my_attr_accessor :name, :age
def initialize(name, age)
@name = name
@age = age
end
# method_missing catches calls to methods that do not exist — which is how
# Rails gives you find_by_name_and_age without defining it.
def method_missing(method_name, *args)
if method_name.to_s.start_with?("shout_")
field = method_name.to_s.delete_prefix("shout_")
public_send(field).to_s.upcase
else
super
end
end
def respond_to_missing?(method_name, include_private = false)
method_name.to_s.start_with?("shout_") || super
end
end
ada = Person.new("Ada", 36)
puts ada.name
ada.age = 37
puts ada.age
puts ada.shout_name # never defined — method_missing built it on the fly
puts ada.respond_to?(:shout_name)
puts Person.instance_methods(false).sort.inspectRead the left column again: Swift gives you read-only reflection and compile-time macros, and that is deliberate — the compiler must be able to know every method that exists. Ruby gives you
define_method (write a method at run time), method_missing (intercept a call to a method that does not exist), public_send (call a method by name), and instance_variable_get. That is not a dark corner of the language; it is how attr_accessor itself works, and it is why an ActiveRecord model gets a method per database column without anyone writing one. The price is exactly what you would guess: your editor cannot autocomplete it, your reader cannot grep for it, and the compiler that would have caught the typo does not exist.method_missing Has No Equivalent
method_missing is called when nothing else matched, and it receives the name that was tried — so an object can answer to methods nobody wrote.// Swift's dynamic member lookup is the nearest thing, and it is
// deliberately narrow: the member name is still checked against a
// subscript you wrote, and it cannot invent methods.
@dynamicMemberLookup
struct Settings {
let values: [String: String]
subscript(dynamicMember key: String) -> String {
values[key] ?? "unset"
}
}
let settings = Settings(values: ["level": "info"])
print(settings.level)
print(settings.missing)class Settings
def initialize(values) = @values = values
# Called when no method matches. This is how ActiveRecord's
# find_by_name_and_email and most Ruby DSLs work.
def method_missing(name, *args)
@values.fetch(name.to_s, "unset")
end
def respond_to_missing?(name, include_private = false) = true
end
settings = Settings.new("level" => "info")
puts settings.level
puts settings.missingThis is the mechanism behind most of Ruby's famous libraries, and it is also how a typo becomes a plausible-looking answer instead of an error. Two obligations that go with it: always define
respond_to_missing? alongside it, or respond_to? lies and method(:name) fails; and prefer define_method when the set of names is known in advance, because a real method is faster, appears in methods, and can be found by anyone reading the code. Swift's @dynamicMemberLookup is the deliberately narrow version of the same idea.Defining Methods at Run Time
define_method creates a real method from a name and a block, so a family of similar methods is written once as a loop rather than repeated.// There is no way to add a method to a type at run time. The
// nearest equivalents are a macro (compile time) or a protocol
// extension (also compile time), and both are checked.
struct Flags {
var active = true
var archived = false
}
let flags = Flags()
print(flags.active, flags.archived)class Flags
# Three predicate methods, written once and generated.
%i[active archived pinned].each do |name|
define_method("#{name}?") { @states.fetch(name, false) }
end
def initialize(states) = @states = states
end
flags = Flags.new(active: true)
puts flags.active?
puts flags.archived?
# And send calls a method by name, computed at run time:
puts flags.send(:active?)This is where a Swift developer should be most careful, because it is genuinely powerful and genuinely hard to read: the methods do not appear in the source, so grepping for
active? finds nothing. Ruby's own answer is that the loop should be short and obvious, and that send — which calls any method by a computed name, including a private one — should never be handed a name from user input. Macros are Swift's equivalent and run at compile time, which is the whole difference.Errors & Exceptions
throws vanishes; rescue takes its place
No
throws in the signature, no try at the call site, and no compiler check that anyone handles it.enum ParseError: Error {
case notANumber(String)
case notPositive(Int)
}
// 'throws' is in the signature; 'try' is required at every call site.
func parsePositive(_ text: String) throws -> Int {
guard let number = Int(text) else { throw ParseError.notANumber(text) }
guard number > 0 else { throw ParseError.notPositive(number) }
return number
}
do {
print(try parsePositive("42"))
print(try parsePositive("-1"))
} catch ParseError.notPositive(let number) {
print("not positive: \(number)")
} catch {
print("failed: \(error)")
}
print((try? parsePositive("nope")) ?? -1)class NotANumber < StandardError; end
class NotPositive < StandardError
attr_reader :number
def initialize(number)
@number = number
super("not positive: #{number}")
end
end
# No 'throws', no 'try'. Nothing in the signature says this can fail.
def parse_positive(text)
number = Integer(text, exception: false)
raise NotANumber, text if number.nil?
raise NotPositive, number if number <= 0
number
end
begin
puts parse_positive("42")
puts parse_positive("-1")
rescue NotPositive => error
puts "not positive: #{error.number}"
rescue StandardError => error
puts "failed: #{error.message}"
ensure
puts "done" # ensure is 'finally' — and it is also Swift's defer
end
# The whole method body can be a begin block: 'rescue' at method level.
def safe_parse(text)
parse_positive(text)
rescue StandardError
-1
end
puts safe_parse("nope")
# And the nil-returning alternative, which needs no exception at all.
puts (Integer("nope", exception: false) || -1)The
throws annotation is gone, so nothing in a signature warns a caller that a method can raise — the information Swift puts in the type system lives in the documentation. rescue is catch, ensure is finally (and doubles as Ruby's defer), and a method body is implicitly a begin block, so rescue can appear at method level with no wrapper. Two conventions worth knowing: rescue StandardError, never bare Exception (which would swallow SignalException and NoMemoryError); and many standard-library methods offer a nil-returning alternative (Integer(text, exception: false)) so that expected failure needs no exception at all.ensure, and retry
retry re-runs the whole begin block from the top, and ensure runs on every path out. Swift has neither — a retry loop is written by hand.enum Failure: Error { case transient }
func attempt(_ number: Int) throws -> String {
if number < 2 { throw Failure.transient }
return "succeeded on attempt \(number)"
}
// Swift has no retry: the loop is written by hand.
var result = "gave up"
for number in 1...3 {
if let value = try? attempt(number) { result = value; break }
}
print(result)class Transient < StandardError; end
attempts = 0
def attempt(number)
raise Transient if number < 2
"succeeded on attempt #{number}"
end
begin
attempts += 1
puts attempt(attempts)
rescue Transient
retry if attempts < 3 # re-runs the begin block
puts "gave up"
ensure
puts "checked #{attempts} time(s)"
endretry is genuinely useful and genuinely easy to misuse: with no counter it is an infinite loop, so the guard is not optional. ensure is defer scoped to the block rather than the function. Two more differences worth knowing: rescue with no class catches StandardError rather than everything, which is what you want — catching Exception also catches Interrupt and SystemExit — and a custom error should subclass StandardError for the same reason.Nothing Declares What It Raises
Ruby has no
throws clause and no try. Any method may raise anything, and the only way to know is documentation or reading the source.enum ParseError: Error { case notANumber(String) }
// throws is in the signature, and the caller must write try.
func parse(_ text: String) throws -> Int {
guard let value = Int(text) else { throw ParseError.notANumber(text) }
return value
}
do {
print(try parse("42"))
print(try parse("oops"))
} catch {
print("caught: \(error)")
}class NotANumber < StandardError; end
# No throws, no try at the call site, and no compiler check that
# anybody handles it.
def parse(text)
raise NotANumber, "not a number: #{text}" unless text.match?(/\A-?\d+\z/)
Integer(text)
end
begin
puts parse("42")
puts parse("oops")
rescue NotANumber => error
puts "caught: #{error.message}"
endSwift 6 added typed throws, which moves in exactly the opposite direction — so this is one of the widest gaps on the page. What Ruby offers instead is convention: raise a subclass of
StandardError so rescue catches it, define one error class per library so callers can rescue a namespace, and use the bang suffix where it exists (Integer(text) raises, text.to_i returns 0). That last pair is worth internalizing early — "oops".to_i is 0, silently, which is exactly the kind of thing Swift's optionals exist to prevent.