PONYλM2Modula-2

Swift.CodeCompared.To/Ruby

An interactive executable cheatsheet comparing Swift and Ruby

Swift 6.3 Ruby 4.0
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 * 3
Both 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 letter
There 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 }.inspect
The 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, always
The 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 collectedString#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}" end
Everything 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}" end
Ruby 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).inspect
The 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 maybe
That 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.inspect
Without 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.class
The "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 end
A 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).greet
A 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::PI
So 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}" end
Ruby 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.inspect
Read 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.missing
This 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)" end
retry 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}" end
Swift 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.

Thank you — anything else?