Hello World & the Build
Hello, World
No entry point, no declaration, no parentheses — and no compilation step between writing this and seeing it run.
let () = print_endline "Hello, World!"puts "Hello, World!"OCaml's
let () = … binds the unit pattern, which runs the expression and asserts it produces nothing. A Ruby file is a sequence of statements executed top to bottom. puts is a private method on Object, so it is available everywhere without qualification, and its parentheses are optional — which is a stylistic choice Ruby makes constantly and which takes an OCaml programmer a while to stop reading as a syntax error.String Interpolation
Interpolation embeds any expression and calls
to_s on it, so nothing can mismatch and nothing is checked.let name = "OCaml"
let year = 1996
let () = Printf.printf "%s appeared in %d\n" name yearname = "Ruby"
year = 1995
puts "#{name} appeared in #{year}"OCaml's format string is a value of the built-in
format type, and the compiler reads its directives to demand exactly a string then an integer — a mismatch is a compile error. Ruby's #{} evaluates any expression and calls to_s on the result, so the arguments cannot be in the wrong order in a way the language notices; swapping them prints different text and nothing complains. In exchange the syntax embeds arbitrary expressions, including method calls and conditionals, which sprintf cannot.opam and dune vs Bundler
Configuration rather than code, so neither column runs. The absence in the Ruby column is the point.
(* dune-project *)
(lang dune 3.16)
(* bin/dune *)
(executable
(name main)
(libraries str))
(* Build and run:
dune build
dune exec bin/main.exe
Dependencies come from opam, into a switch. *)# Gemfile
# source "https://rubygems.org"
# gem "sinatra", "~> 4.0"
#
# Run:
# bundle install
# bundle exec ruby app.rb
#
# There is no build step. Bundler resolves versions into
# Gemfile.lock and puts them on the load path; ruby
# reads the source and runs it.There is no build. Ruby reads the source and executes it, so the feedback loop is as short as it can be and there is no artifact to ship — deployment means the source plus the right interpreter and gems. Bundler and opam solve the same problem, and Bundler pins versions in a lock file by default where opam's locking is opt-in. The consequence an OCaml programmer feels most is that a syntax error in a file nothing has loaded yet is discovered when something loads it, which may be in production.
Everything Is an Object
Integers Have Methods
The claim "everything is an object" is literal here, down to
+ being a method call.(* An int is a machine word. Operations on it are
functions and operators, not methods, and it has no
members to call. *)
let () =
Printf.printf "%d\n" (abs (-5));
Printf.printf "%d\n" (3 + 4);
Printf.printf "%b\n" (7 mod 2 = 0)# Every value is an object, integers included — and the
# operators are methods too.
puts(-5.abs)
puts 3.+(4)
puts 7.even?
puts 3.class
puts nil.classIn OCaml an
int is a tagged machine word and + is a primitive; there is nothing to call a method on. In Ruby 3 is an instance of Integer, 3 + 4 is 3.+(4), and even nil is an object — an instance of NilClass with methods of its own. That uniformity is why 5.times, 3.upto(7) and x.nil? read the way they do, and it is why any class including Integer can have methods added to it at run time, which the metaprogramming section returns to.Only nil and false Are Falsy
Ruby's truthiness rule is much cleaner than most dynamic languages', and it is worth knowing exactly where the line is.
(* Only bool is a condition. An empty list is not false;
using one as a condition does not compile. *)
let () =
let items = [] in
if List.length items = 0 then print_endline "empty" else print_endline "has items";
let text = "" in
if String.length text = 0 then print_endline "blank" else print_endline "has text"items = []
puts items.empty? ? "empty" : "has items"
# The truthiness rule is unusually strict: 0 and "" are
# both TRUTHY. Only nil and false are falsy.
puts 0 ? "zero is truthy" : "zero is falsy"
puts "" ? "empty string is truthy" : "empty string is falsy"In OCaml only
bool may be a condition, so emptiness is asked for explicitly. Ruby treats only nil and false as falsy — 0, "", [] and {} are all truthy, which is the opposite of Python, JavaScript and PHP and is far less error-prone. The idiom if value therefore means "is this present" and nothing else, which is close to what an OCaml programmer means by matching on an option. Emptiness gets its own predicate, empty?, and the trailing question mark is a naming convention the parser allows rather than syntax.Dynamic & Duck Typing
Nothing Is Checked Before It Runs
The defining difference, and Ruby goes further than most: even the existence of a method is decided at the moment of the call.
(* This does not compile. The error names the line, the
expected type and the actual one, before anything
runs. *)
let describe value = "value: " ^ string_of_int value
let () = print_endline (describe 42)def describe(value)
"value: " + value.to_s
end
puts describe(42)
# A method that does not exist is found when the line
# RUNS, not before — and never if the line never runs.
begin
describe(42).no_such_method
rescue NoMethodError => error
puts "NoMethodError at runtime: #{error.message[0, 30]}"
endOCaml checks every expression before the program starts. Ruby resolves a method name by looking it up on the receiver at the call, so a typo in a branch nobody exercised is a
NoMethodError waiting for the day that branch runs. There is no compiler to ask, and the language deliberately provides no way to declare the answer in advance — Sorbet and RBS exist as external type checkers, and neither is part of the language or widely required. Test coverage takes over the job the type checker was doing, which is why Ruby's testing culture is as strong as it is.Duck Typing
Ruby resolves a method by looking for it on the object, so unrelated classes are interchangeable with no declaration tying them together.
(* A closed variant: the compiler proves the set of
shapes and proves every one is handled. Adding a
third means editing the type. *)
type shape = Circle of float | Square of float
let area = function
| Circle radius -> 3.14159 *. radius *. radius
| Square side -> side *. side
let () =
List.iter (fun shape -> Printf.printf "%.2f\n" (area shape))
[ Circle 1.0; Square 2.0 ]# Any object with an area method works. Nothing relates
# these two classes and nothing declares the set.
class Circle
def initialize(radius) = @radius = radius
def area = 3.14159 * @radius * @radius
end
class Square
def initialize(side) = @side = side
def area = @side * @side
end
[Circle.new(1.0), Square.new(2.0)].each do |shape|
puts format("%.2f", shape.area)
endThe OCaml variant closes the set: three constructors, proved exhaustive, and a fourth breaks the build until it is handled. The Ruby version is open — any class with an
area method joins, from any library, with no coordination — and correspondingly unchecked, since nothing verifies the method exists until the call. Note the endless method definition (def area = …), which Ruby 3 added and which reads very close to OCaml's let area = …; and @radius, an instance variable that springs into existence on first assignment.Variables & Types
Assignment, Not Binding
These look alike and are not: one creates a new binding that hides the old, the other overwrites.
(* A second let SHADOWS: it creates a new binding and
the first still exists underneath. *)
let () =
let count = 10 in
let count = count * 2 in
Printf.printf "%d\n" countcount = 10
# This REBINDS the same variable. There is no earlier
# count left underneath, and any closure that captured
# the name sees the new value.
count = count * 2
puts countOCaml's
let introduces a fresh binding over a new scope, so the earlier count still exists and any closure that captured it still sees 10. Ruby's = assigns to the variable in the enclosing scope, so nothing sees the old value afterwards. Ruby's scoping is otherwise stricter than most dynamic languages: a method body cannot see local variables from outside it — only from the block or method it is defined in — which prevents a whole class of accidental capture that Python and JavaScript allow.Integers Have No Ceiling
A place where the dynamic language gives a guarantee the static one does not.
(* OCaml's int is 63 bits — one bit is the collector's
tag — and it wraps silently on overflow. *)
let () =
Printf.printf "max_int = %d\n" max_int;
Printf.printf "wraps to = %d\n" (max_int + 1)# Ruby integers grow to whatever size is needed. There
# is no maximum and nothing wraps.
big = 2 ** 62 - 1
puts "a big one = #{big}"
puts "still fine = #{big + 1}"
puts "and beyond = #{2 ** 200}"An OCaml
int is 63 bits and wraps silently, so a computation that exceeds it produces a wrong answer with no indication. Ruby integers are arbitrary precision, so 2 ** 200 is exact and overflow does not exist as a category — at the cost of every arithmetic operation going through an object. Ruby also has no separate integer division operator: 7 / 2 is 3 because both operands are integers, and 7.0 / 2 is 3.5, which is the C rule rather than OCaml's separate /..nil Instead of option
The habit this page most wants you to keep, because Ruby will not keep it for you.
(* Absence has a DIFFERENT TYPE from presence, so the
compiler forces the missing case open. *)
let find_even numbers = List.find_opt (fun number -> number mod 2 = 0) numbers
let () =
(match find_even [ 1; 3; 4 ] with
| Some number -> Printf.printf "found %d\n" number
| None -> print_endline "none found");
(match find_even [ 1; 3; 5 ] with
| Some number -> Printf.printf "found %d\n" number
| None -> print_endline "none found")def find_even(numbers)
numbers.find(&:even?) # returns nil when nothing matches
end
[[1, 3, 4], [1, 3, 5]].each do |candidates|
found = find_even(candidates)
if found
puts "found #{found}"
else
puts "none found"
end
endOCaml's
int option is a different type from int: the value cannot be used without deciding what to do about absence, and the compiler lists the case you forgot. Ruby's nil is an ordinary object the same variable might hold, so found + 1 is valid code that raises NoMethodError only when find_even happened to return nil. Ruby's partial mitigation is the safe-navigation operator &., which short-circuits to nil rather than raising — useful, and still not a type. Note &:even?, which turns a symbol into a block and is covered in the blocks section.Multiple Assignment and Splat
Destructuring assignment works without a match construct, and the splat is more flexible than OCaml's list pattern.
let () =
let (quotient, remainder) = (17 / 5, 17 mod 5) in
Printf.printf "%d remainder %d\n" quotient remainder;
match [ 1; 2; 3; 4 ] with
| first :: second :: rest ->
Printf.printf "%d %d, then %d more\n" first second (List.length rest)
| _ -> print_endline "too short"quotient, remainder = 17 / 5, 17 % 5
puts "#{quotient} remainder #{remainder}"
# * collects the rest, and can sit anywhere in the pattern.
first, second, *rest = [1, 2, 3, 4]
puts "#{first} #{second}, then #{rest.length} more"
head, *, tail = [1, 2, 3, 4]
puts "#{head} … #{tail}"OCaml destructures a tuple in a
let and a list in a match, and its list pattern can only bind a tail — first :: rest. Ruby's splat collects the remainder and can appear anywhere in the pattern, so head, *, tail takes the ends and discards the middle in one line. It also works in method parameters and at call sites. What OCaml has that Ruby does not is the guarantee: a list pattern that does not cover every shape is a warning, where Ruby quietly assigns nil to anything the right-hand side did not supply.Strings & Symbols
Symbols Are Almost Your Polymorphic Variants
The closest thing Ruby has to your polymorphic variants, and the difference is exactly what you would expect.
(* A polymorphic variant tag needs no declaration and
the inferred type records which tags may appear. *)
let describe value =
match value with
| `Pending -> "waiting"
| `Active -> "running"
let () =
print_endline (describe `Pending);
print_endline (describe `Active)# A symbol is an interned, immutable name — no
# declaration, compared by identity, and cheap.
def describe(status)
case status
when :pending then "waiting"
when :active then "running"
end
end
puts describe(:pending)
puts describe(:active)
puts :pending.equal?(:pending) # the same object, alwaysA symbol is an interned name:
:pending needs no declaration, is the same object everywhere it appears, and compares in constant time — the same properties an OCaml polymorphic variant tag has, and the same as an Erlang atom. What OCaml adds is that the type system tracks which tags a value may carry, so a function accepting [`Pending | `Active] rejects anything else and the match is checked. Ruby's case above returns nil for an unknown symbol and says nothing. Symbols carry a great deal of Ruby: hash keys, method names, and every option in every library API.Strings Know Their Encoding
Ruby distinguishes characters from bytes and asks which one you meant, which OCaml does not.
let () =
let text = "caffè" in
Printf.printf "length = %d\n" (String.length text);
print_endline (String.uppercase_ascii text)text = "caffè"
puts "length = #{text.length}"
puts "bytes = #{text.bytesize}"
puts text.upcaseAn OCaml
string is a byte sequence with no declared encoding, so String.length counts bytes — 6 for this five-letter word — and uppercase_ascii says in its own name that it leaves anything non-ASCII alone. A Ruby String carries its encoding, so length counts characters, bytesize counts bytes, and upcase is fully Unicode-aware. That is a straightforward improvement, and it is why Ruby has an Encoding class and OCaml needs a library such as Uutf.String Literals Are Chilled
Ruby is midway through making string literals immutable, and the transitional state is worth knowing before you write one.
(* An OCaml string is immutable. There is no way to
modify one, and Bytes is the mutable counterpart. *)
let () =
let text = "hello" in
let shouted = String.uppercase_ascii text in
print_endline text;
print_endline shouted# A literal is CHILLED in Ruby 4.0: mutating it works
# and warns that it will be frozen in a future release.
# Build a mutable string explicitly instead.
text = "hello"
mutable = +"hello"
mutable << " world"
puts text
puts mutable
puts text.frozen?OCaml strings are immutable outright, with
Bytes as the mutable counterpart. Ruby strings are mutable objects, and a literal in Ruby 4.0 is chilled: "hello".frozen? is false, mutating one works, and doing so warns that it will be frozen in a future release. Adding # frozen_string_literal: true to a file makes that mutation a FrozenError today. The safe way to build a mutable string is +"" or String.new, which neither warns now nor breaks when the default flips. Note <<, which appends in place — where + would allocate a new string.Regular Expressions Are Literals
Regular expressions are part of the language here, with their own literal syntax — and OCaml has none in its standard library at all.
(* OCaml's standard library has no regular expressions.
The Str library is a separate package, so this column
does it with String functions. *)
let () =
let text = "order-1234-shipped" in
let parts = String.split_on_char '-' text in
match parts with
| [ _; number; status ] -> Printf.printf "%s %s\n" number status
| _ -> print_endline "no match"text = "order-1234-shipped"
# A regex is a literal, =~ matches, and the groups land
# in $1, $2 … or come back from match.
if (found = text.match(/order-(d+)-(w+)/))
puts "#{found[1]} #{found[2]}"
else
puts "no match"
end
puts text.scan(/d+/).inspect
puts text.gsub(/-/, " ")A Ruby regex is written between slashes, is an object of class
Regexp, and is woven through the string API: match, scan, gsub, split and case/when all take one. OCaml has no regular expressions in its standard library — Str is a separate library with a stateful, non-reentrant API, and serious code reaches for re from opam. That is a real ergonomic gap for text-processing work and one of the clearer wins on this page, alongside the encoding-aware strings.Collections
Arrays, Not Lists
The default sequence is mutable, and Ruby has a naming convention for that which is worth learning immediately.
(* An immutable singly linked list. Consing shares
structure and the original is untouched. *)
let () =
let numbers = [ 1; 2; 3 ] in
let extended = 0 :: numbers in
Printf.printf "extended %d, original %d\n"
(List.length extended) (List.length numbers)# A mutable growable array. unshift modifies in place,
# and there is no original left afterwards.
numbers = [1, 2, 3]
numbers.unshift(0)
puts "the same array now has #{numbers.length}"
# The non-destructive form is a separate method, and the
# convention is that a trailing ! marks the mutating one.
base = [1, 2, 3]
extended = [0] + base
puts "extended #{extended.length}, base #{base.length}"OCaml's list is an immutable linked list, so
0 :: numbers builds a new list and both exist. Ruby's Array is a mutable dynamic array: unshift, push, sort! and map! modify in place. The convention that saves you is the bang suffix: sort returns a new array and sort! sorts in place, so a method ending in ! is announcing that it mutates. It is a convention rather than a rule — unshift mutates and has no bang — but it holds across the standard library and most gems.Hashes
Hashes with symbol keys are the backbone of Ruby APIs, and there are three ways to read one with different failure behavior.
let () =
let ages = Hashtbl.create 8 in
Hashtbl.replace ages "ada" 36;
(match Hashtbl.find_opt ages "ada" with
| Some age -> Printf.printf "ada is %d\n" age
| None -> print_endline "unknown");
(match Hashtbl.find_opt ages "alan" with
| Some age -> Printf.printf "alan is %d\n" age
| None -> print_endline "unknown")ages = { ada: 36 }
# Fetching with a default, and fetch raising when there
# is no sensible default — both better than bare [].
puts ages[:ada] ? "ada is #{ages[:ada]}" : "unknown"
puts ages.fetch(:alan, nil) ? "alan is …" : "unknown"
# The literal syntax with symbol keys is everywhere:
puts ages.inspectOCaml's
find_opt returns an option, so absence cannot be skipped. Ruby offers three: ages[:missing] returns nil silently, ages.fetch(:missing) raises KeyError, and ages.fetch(:missing, default) returns the default. Prefer fetch — the silent nil is where a typo in a key becomes a NoMethodError three call frames later. Hashes preserve insertion order, and the { ada: 36 } literal with symbol keys is so idiomatic that it doubles as keyword-argument syntax.Ranges
A range is an object rather than syntax, so it goes everywhere a collection goes.
(* No range type. A sequence of integers is built with
List.init or a recursive helper. *)
let () =
let numbers = List.init 5 (fun index -> index + 1) in
List.iter (Printf.printf "%d ") numbers;
print_newline ()# A Range is a first-class object, lazy, and usable as
# a collection, a case condition or a slice.
puts (1..5).to_a.inspect
puts (1...5).to_a.inspect # ... excludes the end
puts (1..10).select(&:even?).inspect
puts ("a".."e").to_a.inspect # not just integersOCaml has no range type: a sequence of integers comes from
List.init or a fold, and slicing a list means List.filteri or a helper. Ruby's Range is a real object built by .. (inclusive) or ... (exclusive), it works for anything comparable with a successor including strings and dates, it is lazy so (1..Float::INFINITY) is usable, and it can appear as a case condition or an array slice. It is a small feature that removes a surprising amount of boilerplate.Sorting by a Key
Ruby sorts by a key rather than by a comparison, which is the same improvement Python's
key= makes.let () =
let words = [ "banana"; "fig"; "apple" ] in
let by_length =
List.sort (fun left right ->
compare (String.length left) (String.length right)) words
in
print_endline (String.concat " " by_length);
print_endline (String.concat " " (List.sort compare words))words = ["banana", "fig", "apple"]
# sort_by computes the key once per element, which is
# both faster and much harder to get wrong.
puts words.sort_by(&:length).join(" ")
puts words.sort.join(" ")
puts words.max_by(&:length)OCaml's
List.sort takes a three-way comparison — the C convention, with its negative-zero-positive contract to remember. Ruby has both: sort with a block taking two arguments and <=>, and sort_by which takes a key function called once per element. sort_by is faster for an expensive key and has no convention to get wrong, and the same idea gives min_by, max_by and group_by. Note that sort returns a new array while sort! sorts in place — the bang convention again.Sets
A set needs no functor application, and the operators are the ones you would guess.
module IntSet = Set.Make (Int)
let () =
let first = IntSet.of_list [ 1; 2; 3 ] in
let second = IntSet.of_list [ 3; 4 ] in
Printf.printf "union has %d\n" (IntSet.cardinal (IntSet.union first second));
Printf.printf "has 3? %b\n" (IntSet.mem 3 first)require "set"
first = Set[1, 2, 3]
second = Set[3, 4]
puts "union has #{(first | second).size}"
puts "has 3? #{first.include?(3)}"
puts (first & second).to_a.inspectOCaml's
Set.Make (Int) builds a module specialized to integer elements and every operation goes through it. Ruby's Set is a class whose elements need only implement hash and eql?, with |, &, - and ^ for the set algebra. The trade is the familiar one: OCaml's set is persistent so union leaves both operands intact, while Ruby's is mutable and first |= second modifies first in place, visible through every other name for it.Blocks
Blocks Are the Signature Feature
Ruby's defining feature, and the reason its libraries read the way they do.
(* A higher-order function takes an ordinary function
value. There is no special syntax for the last
argument. *)
let with_logging name work =
Printf.printf "starting %s\n" name;
let result = work () in
Printf.printf "finished %s\n" name;
result
let () = Printf.printf "%d\n" (with_logging "job" (fun () -> 21 * 2))# A block is a chunk of code passed to a method, with
# syntax of its own. yield calls it.
def with_logging(name)
puts "starting #{name}"
result = yield
puts "finished #{name}"
result
end
puts(with_logging("job") { 21 * 2 })OCaml passes a function value like any other argument. Ruby gives the last argument special syntax: a block written in braces or
do…end after the call, invoked with yield, and not appearing in the parameter list at all. That one piece of syntax is why each, map, times, open and every DSL in Rails read as they do — the closure is visually part of the call rather than an argument inside it. The cost is that a block is not a value: it cannot be stored or returned without converting it, which the next row is about.Blocks, Procs and Lambdas
Ruby has three closure-ish things with different rules, and knowing which is which prevents real confusion.
(* One function type. A function is a value, stored and
passed like any other, with no distinction between
kinds of closure. *)
let () =
let double = fun value -> value * 2 in
let apply f value = f value in
Printf.printf "%d\n" (apply double 21);
Printf.printf "%d\n" (List.length (List.map double [ 1; 2; 3 ]))# A block becomes a value with &, and there are two
# kinds of function object with different semantics.
double = ->(value) { value * 2 } # lambda
loose = proc { |value| value * 2 } # proc
puts double.call(21)
puts [1, 2, 3].map(&double).inspect
# A lambda checks its arity; a proc does not.
puts loose.call(1, 2, 3)
puts double.lambda?OCaml has one function type and no distinctions. Ruby has a block (syntax, not a value), a proc and a lambda. A lambda checks its argument count and
return returns from the lambda; a proc ignores extra arguments, fills missing ones with nil, and return returns from the enclosing method — which is surprising the first time. The & operator converts between a block and a proc in either direction, which is what map(&:even?) is doing: Symbol#to_proc turns :even? into a one-argument proc.Blocks for Resource Safety
This pattern is why
File.open takes a block, and it is the same idea as Fun.protect with better syntax.(* Fun.protect runs the finalizer on every exit path,
including an exception. *)
let () =
Fun.protect
~finally:(fun () -> print_endline "released")
(fun () -> print_endline "using it")# The block form is how Ruby guarantees cleanup: the
# method acquires, yields, and ensures release.
def with_resource
puts "acquired"
yield
ensure
puts "released"
end
with_resource { puts "using it" }Both guarantee the cleanup runs however the body exits. OCaml's
Fun.protect takes the body as a function and a ~finally; Ruby's method takes the body as a block and uses ensure, which is the finally of its exception mechanism. The reason this matters in practice is that Ruby libraries expose the block form as the primary API — File.open(path) { |file| … } closes the file, and the non-block form that returns a handle is the one you have to remember to close. That makes the safe path the short path.Folding With a Block
Two things worth stealing: a hash with a default, and the fact that counting has its own method.
let () =
let words = [ "apple"; "fig"; "apple"; "banana" ] in
let counts = Hashtbl.create 8 in
List.iter
(fun word ->
let current = Option.value (Hashtbl.find_opt counts word) ~default:0 in
Hashtbl.replace counts word (current + 1))
words;
Printf.printf "apple=%d\n" (Hashtbl.find counts "apple")words = ["apple", "fig", "apple", "banana"]
counts = words.each_with_object(Hash.new(0)) do |word, tally|
tally[word] += 1
end
puts counts.inspect
# Or, because this is common enough to have a name:
puts words.tally.inspectHash.new(0) returns a hash whose missing keys read as 0, so tally[word] += 1 needs no initialization check — which is the fiddly part of the OCaml column. each_with_object is a fold that threads a mutable accumulator, and inject is the immutable fold that corresponds to fold_left. And tally exists because counting occurrences is common enough that the standard library named it, which is the kind of thing Ruby does constantly and OCaml does not.Enumerable
map, select and reduce
The same three operations chained as methods, and Ruby's version is shorter for a reason worth naming.
let () =
[ 1; 2; 3; 4; 5; 6 ]
|> List.filter (fun number -> number mod 2 = 0)
|> List.map (fun number -> number * 2)
|> List.fold_left ( + ) 0
|> Printf.printf "total = %d\n"total = [1, 2, 3, 4, 5, 6]
.select(&:even?)
.map { |number| number * 2 }
.sum
puts "total = #{total}"OCaml threads the value with
|> because its List functions are curried and take the data last. Ruby's methods live on the object, so chaining needs no operator. select is filter (both names work), reduce is fold_left, and sum is the named shortcut. The &:even? form is Symbol#to_proc again. Both versions build an intermediate array at each stage; Ruby's lazy equivalent is .lazy, which is exactly OCaml's Seq.One Method Buys Fifty
The best argument for Ruby's design, and it rests entirely on blocks.
(* A custom collection gets the functions you write for
it. There is no protocol that supplies the rest. *)
type countdown = { from : int }
let to_list countdown = List.init countdown.from (fun index -> countdown.from - index)
let () =
let countdown = { from = 3 } in
List.iter (Printf.printf "%d ") (to_list countdown);
print_newline ();
Printf.printf "%d\n" (List.fold_left ( + ) 0 (to_list countdown))# Define each, include Enumerable, and receive map,
# select, sort, sum, min, group_by, each_slice, take_while
# and about fifty more — for free.
class Countdown
include Enumerable
def initialize(from) = @from = from
def each
@from.downto(1) { |number| yield number }
end
end
countdown = Countdown.new(3)
puts countdown.to_a.inspect
puts countdown.sum
puts countdown.select(&:odd?).inspectEnumerable is a module defining about fifty methods in terms of one: each. Implement each, include the module, and your type gets map, select, sort_by, group_by, each_cons, lazy and the rest, all working correctly. OCaml's nearest equivalent is implementing a signature and applying a functor, which is checked and requires naming the module and instantiating it. Ruby's version is one include, needs no declaration, and is unchecked — if your each misbehaves, fifty methods misbehave. That trade is the whole language in miniature.Lazy Sequences
Both have lazy sequences, and Ruby's is the same collection protocol with one method call in front.
let () =
let naturals = Seq.ints 1 in
let squares = Seq.map (fun number -> number * number) naturals in
Seq.iter (Printf.printf "%d ") (Seq.take 5 squares);
print_newline ()squares = (1..Float::INFINITY).lazy.map { |number| number * number }
puts squares.first(5).inspectOCaml's
Seq is a separate type with its own module, so code must choose between List and Seq up front and converting costs a traversal. Ruby's .lazy returns an Enumerator::Lazy that responds to the same Enumerable methods, so a pipeline becomes lazy by inserting one call and stays otherwise identical. Float::INFINITY as a range endpoint is idiomatic and works because the range is never materialized. This is the same capability with materially less friction.group_by and Friends
Four one-liners, each of which is a fold in OCaml.
(* No group_by. Fold into a Hashtbl of lists by hand. *)
let () =
let words = [ "apple"; "fig"; "pear"; "kiwi" ] in
let groups = Hashtbl.create 8 in
List.iter
(fun word ->
let key = String.length word in
let current = Option.value (Hashtbl.find_opt groups key) ~default:[] in
Hashtbl.replace groups key (word :: current))
words;
Printf.printf "4-letter: %d\n" (List.length (Hashtbl.find groups 4))words = ["apple", "fig", "pear", "kiwi"]
puts words.group_by(&:length).inspect
puts words.partition { |word| word.length > 3 }.inspect
puts words.each_slice(2).to_a.inspect
puts words.min_by(&:length)Enumerable has around fifty methods, and the ones worth knowing on day one are group_by, partition, each_slice, each_cons, tally, sum, min_by/max_by, flat_map and zip. Every one is expressible as a fold in OCaml and none of them is in the standard library, so OCaml code writes the fold or reaches for a package such as Containers. This is the clearest everyday ergonomic difference between the two, and it costs nothing in safety — these are pure transformations.Control Flow
Everything Is an Expression
A place where Ruby agrees with OCaml and disagrees with Python, Go and JavaScript.
let () =
let temperature = 31 in
let advice =
if temperature > 30 then "stay inside"
else if temperature > 20 then "pleasant"
else "bring a coat"
in
print_endline advicetemperature = 31
# if produces a value, so it can be assigned directly —
# which most dynamic languages do not allow.
advice = if temperature > 30
"stay inside"
elsif temperature > 20
"pleasant"
else
"bring a coat"
end
puts adviceIn OCaml everything produces a value. Ruby is the same:
if, case, begin and even a method definition all evaluate to something, so the assignment above needs no temporary and no repeated variable name. A method returns its last expression with no return needed, exactly as in OCaml. What differs is that a missing branch yields nil rather than being a type error — OCaml requires an if without else to have type unit, and Ruby just hands back nil.Iteration Is a Method Call
Ruby has loop keywords and the idiom is to use methods instead — which is possible only because blocks are cheap.
let () =
for index = 1 to 3 do
Printf.printf "for %d\n" index
done;
List.iter (fun word -> print_endline word) [ "alpha"; "beta" ]# Ruby has for, and nobody uses it. Iteration is a
# method taking a block.
3.times { |index| puts "times #{index}" }
1.upto(3) { |index| puts "upto #{index}" }
["alpha", "beta"].each { |word| puts word }OCaml has
for and while as constructs and List.iter for collections. Ruby has for, while and until as keywords, and idiomatic code uses none of them: times, upto, step, each and each_with_index are methods taking a block. That is possible because the block syntax makes a closure per iteration feel free, and it is why a Ruby programmer rarely writes an index variable. Note 3.times — a method on an integer, which the everything-is-an-object section explains.Statement Modifiers and unless
Two pieces of syntax with no OCaml equivalent, and they change how Ruby code is shaped.
(* One conditional form, always prefix. There is no
unless and no trailing modifier. *)
let () =
let count = 0 in
if count = 0 then print_endline "nothing to do";
if not (count > 10) then print_endline "manageable"count = 0
# A trailing modifier reads as a sentence, and unless is
# a negated if with its own keyword.
puts "nothing to do" if count.zero?
puts "manageable" unless count > 10
# The guard-clause idiom this enables is everywhere:
def process(items)
return "nothing" if items.empty?
"processed #{items.length}"
end
puts process([])
puts process([1, 2])A statement modifier puts the condition after the expression, which reads as English and is idiomatic for short cases.
unless is if not with its own keyword and is preferred when the negative reads better — though unless … else is universally considered a mistake. Together they produce the guard clause idiom: return early for the exceptional cases and leave the body unindented, which is how most Ruby methods are shaped. OCaml has neither, and its equivalent is a match or a nested if.Safe Navigation
The safe-navigation operator collapses a chain of nil checks, and it is the closest Ruby gets to
Option.bind.(* Chaining through options means Option.bind at each
step, or a let* operator defined first. *)
let ( let* ) = Option.bind
let () =
let lookup key = if key = "known" then Some "value" else None in
let shouted =
let* found = lookup "known" in
Some (String.uppercase_ascii found)
in
print_endline (Option.value shouted ~default:"absent")def lookup(key) = key == "known" ? "value" : nil
# &. short-circuits to nil instead of raising, and ||
# supplies the fallback.
puts(lookup("known")&.upcase || "absent")
puts(lookup("other")&.upcase || "absent")a&.b&.c evaluates to nil the moment any link is nil, without evaluating the rest — so a deep chain needs one operator per link and no nesting. OCaml needs Option.bind at each step or a let* defined first, and each step costs a line. What Ruby does not give you is the discipline: nothing forces the check, and a value that is never nil looks identical to one that might be. Note also || for the fallback, which fires on false as well as nil — a real difference from Ruby's otherwise clean truthiness rule.Pattern Matching
Ruby Has Real Pattern Matching Now
A genuine surprise if you last looked at Ruby a decade ago:
case/in is real structural pattern matching.type shape =
| Circle of float
| Rectangle of float * float
| Point
let area = function
| Circle radius -> 3.14159 *. radius *. radius
| Rectangle (width, height) -> width *. height
| Point -> 0.0
let () =
List.iter (fun shape -> Printf.printf "%.2f\n" (area shape))
[ Circle 1.0; Rectangle (2.0, 3.0); Point ]# case/in destructures arrays, hashes and objects, binds
# sub-patterns, and supports guards — added in Ruby 3.0.
def area(shape)
case shape
in [:circle, radius] then 3.14159 * radius * radius
in [:rectangle, width, height] then width * height
in :point then 0.0
end
end
[[:circle, 1.0], [:rectangle, 2.0, 3.0], :point].each do |shape|
puts format("%.2f", area(shape))
endRuby 3.0 added
case/in, which destructures arrays and hashes, matches against classes, binds sub-patterns with =>, supports alternatives with | and guards with if, and can deconstruct any object that defines deconstruct or deconstruct_keys. It is closer to OCaml's match than anything else in a mainstream dynamic language. Two differences remain: there is no type to be exhaustive over, and an unmatched value raises NoMatchingPatternError rather than being caught at compile time — which is at least louder than Python's silent fall-through.Matching a Hash
Hash patterns match on the keys present and the types of their values, which OCaml has no syntax for at all.
(* A record pattern matches declared fields. There is no
way to match "a record that has at least these
fields". *)
type person = { name : string; age : int }
let describe { name; age } =
if age >= 18 then Printf.sprintf "%s, adult" name
else Printf.sprintf "%s, minor" name
let () = print_endline (describe { name = "Ada"; age = 36 })def describe(person)
case person
in { name: String => name, age: Integer => age } if age >= 18
"#{name}, adult"
in { name: String => name }
"#{name}, minor"
end
end
puts describe({ name: "Ada", age: 36 })
puts describe({ name: "Tom", age: 9 })A Ruby hash pattern is open by default:
in { name: } matches any hash that has a name key and ignores the rest, and in { name:, **nil } demands exactly that key. It can also assert the value's class inline — name: String => name matches only when the value is a String and binds it. OCaml can match a record's declared fields and cannot express "at least these fields" or "and this one is an int", because a record's type already settles both. For data arriving as JSON this is genuinely useful.Classes & Objects
Classes Are the Structuring Tool
Classes are how a Ruby program is organized, and
attr_reader is the first piece of metaprogramming you will meet.(* OCaml has an object system and almost nobody uses it.
A record plus functions is the idiomatic spelling. *)
type account = { owner : string; mutable balance : int }
let deposit account amount = account.balance <- account.balance + amount
let () =
let account = { owner = "Ada"; balance = 0 } in
deposit account 50;
Printf.printf "%s has %d\n" account.owner account.balanceclass Account
attr_reader :owner, :balance
def initialize(owner)
@owner = owner
@balance = 0
end
def deposit(amount)
@balance += amount
self
end
end
account = Account.new("Ada").deposit(50)
puts "#{account.owner} has #{account.balance}"OCaml's objects are structurally typed and genuinely interesting, and real OCaml uses records, variants and modules instead. Ruby has no alternative: a class is how you group data with behavior, and everything you call is built from them.
attr_reader :owner is not syntax — it is a method call that defines methods, executed when the class body runs, and that is the mechanism the whole metaprogramming section rests on. Instance variables start with @ and spring into existence on first assignment, so a misspelling creates a second one that reads as nil.Data Is Ruby's Record
The closest thing Ruby has to an OCaml record, and it is newer than most Ruby you will read.
type point = { x : int; y : int }
let () =
let origin = { x = 0; y = 0 } in
let shifted = { origin with x = 5 } in
Printf.printf "(%d, %d)\n" origin.x origin.y;
Printf.printf "(%d, %d)\n" shifted.x shifted.y;
Printf.printf "%b\n" ({ x = 1; y = 2 } = { x = 1; y = 2 })# Data.define builds an IMMUTABLE value class with
# readers, structural equality and a with method.
Point = Data.define(:x, :y)
origin = Point.new(x: 0, y: 0)
shifted = origin.with(x: 5)
puts "(#{origin.x}, #{origin.y})"
puts "(#{shifted.x}, #{shifted.y})"
puts Point.new(x: 1, y: 2) == Point.new(x: 1, y: 2)Data.define arrived in Ruby 3.2 and builds an immutable value class: readers for each member, structural equality, a readable inspect, and a with method that is exactly OCaml's { record with field = value }. That is a genuine record, and it is what to reach for when the thing is a value rather than an object with behavior. The older Struct does the same and is mutable. Note the keyword-argument construction — Point.new(x: 0, y: 0) — which is the modern convention and much clearer than positional at the call site.Keyword Arguments
This is the rare feature where Ruby and OCaml agree, and Ruby's version is the more comfortable of the two.
let greet ?(greeting = "Hello") ~name () =
Printf.sprintf "%s, %s!" greeting name
let () =
print_endline (greet ~name:"Ada" ());
print_endline (greet ~greeting:"Welcome" ~name:"Alan" ())def greet(name:, greeting: "Hello")
"#{greeting}, #{name}!"
end
puts greet(name: "Ada")
puts greet(greeting: "Welcome", name: "Alan")OCaml labels with
~ and defaults with ?, and needs a trailing () because a function with optional arguments cannot otherwise be known to be fully applied. Ruby needs no such marker: name: with no default is required and omitting it raises ArgumentError, while greeting: "Hello" is optional. Order does not matter at the call site. This is one of the few places where an OCaml habit transfers to a dynamic language and comes out cleaner.Inheritance and super
Single inheritance with
super, and it is a primary structuring tool rather than a vestigial one.(* Reuse by composition: a shared record and functions
over it, or a variant when the set of kinds is known. *)
type animal = { name : string; sound : string }
let speak animal = Printf.sprintf "%s says %s" animal.name animal.sound
let () =
print_endline (speak { name = "Rex"; sound = "Woof" });
print_endline (speak { name = "Tom"; sound = "Meow" })class Animal
def initialize(name) = @name = name
def speak = "#{@name} says #{sound}"
def sound = raise NotImplementedError
end
class Dog < Animal
def sound = "Woof"
end
class Cat < Animal
def initialize(name, loud: false)
super(name)
@loud = loud
end
def sound = @loud ? "MEOW" : "Meow"
end
puts Dog.new("Rex").speak
puts Cat.new("Tom", loud: true).speakOCaml has class inheritance in its object system and idiomatic OCaml does not use it — a variant when the kinds are closed, a record of functions when they are open. Ruby has single inheritance plus mixins, and both are ordinary.
super with no parentheses passes the same arguments along, and super() with empty parentheses passes none — a distinction that catches everyone once. The NotImplementedError in the base class is Ruby's abstract method: there is no way to declare one, so raising is the convention.Modules Are Mixins
The Word "Module" Means Something Else
A false friend worth clearing up early: the two languages use the same word for very different things.
(* An OCaml module is a namespace AND a unit of
abstraction: it can hide a type behind a signature,
be passed to a functor, and be packed into a value. *)
module Geometry = struct
let pi = 3.14159
let circle_area radius = pi *. radius *. radius
end
let () = Printf.printf "%.2f\n" (Geometry.circle_area 2.0)# A Ruby module is a namespace and a BAG OF METHODS to
# mix into classes. It cannot hide a type, be
# parameterized, or be passed as a value.
module Geometry
PI = 3.14159
def self.circle_area(radius) = PI * radius * radius
end
puts format("%.2f", Geometry.circle_area(2.0))An OCaml module is a first-class unit of abstraction — it can hide a type behind a signature, be parameterized by a functor, and be packed into a value. A Ruby module is a namespace and a collection of methods to mix in. It cannot hide anything (Ruby has no private types), cannot be parameterized, and is not a value you can pass. What it does instead is the subject of the next row, and it is genuinely useful — just not the same thing.
Mixins Instead of Functors
The same idea as a functor — supply an operation, receive a family of derived ones — arranged the other way round.
module type Comparable = sig
type t
val compare : t -> t -> int
end
module MakeSorted (Element : Comparable) = struct
let sorted items = List.sort Element.compare items
end
module IntCompare = struct
type t = int
let compare = compare
end
module SortedInt = MakeSorted (IntCompare)
let () =
List.iter (Printf.printf "%d ") (SortedInt.sorted [ 3; 1; 2 ]);
print_newline ()# Comparable is a MODULE that defines <, >, ==, between?
# and clamp in terms of one method you supply: <=>.
class Version
include Comparable
attr_reader :number
def initialize(number) = @number = number
def <=>(other) = number <=> other.number
end
versions = [Version.new(3), Version.new(1), Version.new(2)]
puts versions.sort.map(&:number).inspect
puts versions.min.number
puts Version.new(2).between?(Version.new(1), Version.new(3))An OCaml functor takes a module of operations and produces a new module. Ruby's
Comparable inverts it: the class includes the module and supplies <=>, and the module's methods are inserted into the class's lookup chain. The result is the same family of derived operations, obtained with one line and no instantiation to name. What is lost is everything the type system was checking: nothing verifies that <=> exists or returns a number until something calls a comparison. Comparable and Enumerable are the two mixins worth knowing by heart.Open Classes & Metaprogramming
Every Class Is Open
The single most powerful and most alarming thing in the language, and both halves are worth taking seriously.
(* A type is closed. Adding an operation means adding a
function beside it, and adding a case means editing
the type and every match over it. *)
let squared value = value * value
let () = Printf.printf "%d\n" (squared 7)# Any class can be reopened at any time, including
# Integer, and the new method is available everywhere.
class Integer
def squared = self * self
end
puts 7.squared
puts 3.squaredOCaml types are closed: an
int has the operations the standard library gave it, and adding one means a function that takes an int. In Ruby every class is open, including the built-in ones, and reopening Integer to add a method makes it available on every integer in the process — including inside gems that have never heard of you. That is how Rails gets 3.days.ago and "foo".pluralize, and it is how two gems can break each other by defining the same method. The modern discipline is refine, which scopes a change lexically, and it is used far less than it should be.Defining Methods at Run Time
A class body is code that runs, which is why
attr_reader can be an ordinary method rather than syntax.(* Code generation happens at build time through a ppx
preprocessor, which rewrites the parse tree. There is
nothing at run time. *)
type person = { name : string; age : int }
let name person = person.name
let age person = person.age
let () =
let person = { name = "Ada"; age = 36 } in
Printf.printf "%s %d\n" (name person) (age person)# A class body is ordinary code, executing at load time,
# so it can define methods in a loop.
class Person
[:name, :age].each do |field|
define_method(field) { instance_variable_get("@#{field}") }
end
def initialize(name, age)
@name = name
@age = age
end
end
person = Person.new("Ada", 36)
puts "#{person.name} #{person.age}"This is the mechanism behind almost everything that makes Ruby feel magical. A class body executes when the file loads,
define_method adds a method from a block, method_missing catches calls to methods that do not exist, and instance_variable_get reaches inside an object by name. Together they are how ActiveRecord gives you a method per database column without anyone writing them. OCaml's equivalent is a ppx rewriter operating on the parse tree at build time — more predictable, fully typed, and considerably harder to write.Blocks Plus Open Classes Equals a DSL
This is the trick behind Rails routes, RSpec, Rakefiles and every configuration block you have seen in Ruby.
(* A configuration API is a record, or a set of
functions taking a config value. There is no way to
make it read as a language. *)
type route = { verb : string; path : string }
let () =
let routes = [ { verb = "GET"; path = "/" }; { verb = "POST"; path = "/users" } ] in
List.iter (fun route -> Printf.printf "%s %s\n" route.verb route.path) routes# instance_eval runs a block with self set to another
# object, which is how a config block reads as a
# language of its own.
class Router
def initialize = @routes = []
def get(path) = @routes << ["GET", path]
def post(path) = @routes << ["POST", path]
def each(&block) = @routes.each(&block)
def self.draw(&block) = new.tap { |router| router.instance_eval(&block) }
end
routes = Router.draw do
get "/"
post "/users"
end
routes.each { |verb, path| puts "#{verb} #{path}" }instance_eval runs a block with self rebound to another object, so bare method calls inside the block dispatch to that object. That is the whole mechanism: the block is ordinary Ruby, and the "language" is just methods on a receiver you cannot see. It is genuinely expressive and it is also why an unfamiliar Ruby DSL can be impossible to navigate — the methods available inside the block are not visible from the block, and there is no type to ask. OCaml has no equivalent and would express the same thing as a data structure, which is duller and greppable.Calling a Method by Name
A method name is a value here, so dispatch can be computed rather than written out.
(* There is no way to call a function whose name is
computed at run time. Dispatch on a value instead. *)
let () =
let apply operation value =
match operation with
| "double" -> value * 2
| "square" -> value * value
| _ -> value
in
Printf.printf "%d\n" (apply "double" 21);
Printf.printf "%d\n" (apply "square" 7)class Calculator
def double(value) = value * 2
def square(value) = value * value
end
calculator = Calculator.new
# send calls a method by name, computed at run time.
[["double", 21], ["square", 7]].each do |name, value|
puts calculator.send(name, value)
end
puts calculator.respond_to?(:double)
puts calculator.methods.grep(/double|square/).sort.inspectsend calls a method whose name is a string or symbol computed at run time, respond_to? asks whether an object has one, and methods lists them all. That is how a serializer walks an unknown object, how a test framework finds every method starting with test_, and how ActiveRecord answers find_by_email. OCaml has no equivalent — a function is not addressable by a computed name — so the same job is a dispatch table or a variant, which is more verbose, fully checked, and greppable. Note that send ignores private; public_send does not.Error Handling
Exceptions Carry Everything
Exceptions are classes here, caught by type, and they carry a backtrace along with whatever you put on them.
exception Too_large of int
let check value =
if value > 100 then raise (Too_large value) else value
let () =
Printf.printf "%d\n" (check 50);
(try Printf.printf "%d\n" (check 500) with
| Too_large value -> Printf.printf "too large: %d\n" value)class TooLargeError < StandardError
attr_reader :value
def initialize(value)
@value = value
super("too large: #{value}")
end
end
def check(value)
raise TooLargeError, value if value > 100
value
end
puts check(50)
begin
puts check(500)
rescue TooLargeError => error
puts "too large: #{error.value}"
endOCaml declares an exception with a keyword and catches it by pattern, binding the payload. Ruby's exceptions are classes inheriting from
StandardError, caught by class with rescue, and they carry a message and a backtrace. Two conventions matter: always inherit from StandardError, not Exception, because a bare rescue catches the former and would otherwise miss yours; and a bare rescue => error is the idiom for "any ordinary error". Because Ruby has no result in general use, exceptions carry nearly all error handling — a missing key, a failed parse, a bad conversion.No result Type
OCaml can put the failure in the return type. Ruby has no idiomatic way to do that, and the difference in discipline is real.
(* A fallible function returns a value the caller cannot
use without handling the failure. *)
let parse_positive text =
match int_of_string_opt text with
| None -> Error "not a number"
| Some number when number <= 0 -> Error "must be positive"
| Some number -> Ok number
let () =
List.iter
(fun text ->
match parse_positive text with
| Ok number -> Printf.printf "%d\n" number
| Error message -> print_endline message)
[ "42"; "oops"; "-1" ]# Ruby raises instead. The caller is not forced to
# handle anything.
class NotPositiveError < ArgumentError; end
def parse_positive(text)
number = Integer(text) # raises ArgumentError on bad input
raise NotPositiveError, "must be positive" if number <= 0
number
end
["42", "oops", "-1"].each do |text|
begin
puts parse_positive(text)
rescue ArgumentError => error
puts error.message
end
endOCaml's
result means the caller cannot reach the value without deciding what to do about the error. Ruby raises: the standard library raises, gems raise, and a caller who writes no rescue lets the exception travel up. That is not always worse — an error nobody can handle locally is better propagated than threaded through every signature — but it does mean the set of exceptions a method may raise is documentation rather than type. Note Integer(text), the strict conversion that raises, versus text.to_i, which returns 0 for unparseable input and is a common source of silent wrong answers.ensure, retry and the Bare rescue
retry re-runs the whole begin block, which is a control-flow construct OCaml has no version of.(* Fun.protect gives the finally. There is no retry, and
an exception is caught by its constructor. *)
let () =
let attempts = ref 0 in
let rec attempt () =
incr attempts;
if !attempts < 3 then attempt () else Printf.printf "succeeded on %d\n" !attempts
in
Fun.protect ~finally:(fun () -> print_endline "cleaned up") attemptattempts = 0
begin
attempts += 1
raise "not yet" if attempts < 3
puts "succeeded on #{attempts}"
rescue RuntimeError
retry if attempts < 3 # re-runs the begin block
puts "gave up"
ensure
puts "cleaned up" # runs on every path
endensure is Fun.protect's ~finally and runs however the block exits. retry has no OCaml counterpart at all: it restarts the begin block from the top, which makes a bounded retry loop three lines with no recursion. Two cautions worth taking seriously. rescue with no class catches StandardError, not Exception — which is correct, because catching Exception would swallow Interrupt and SignalException. And a retry with no bound is an infinite loop that looks like error handling.Mutability
Assignment Never Copies
The most frequent source of confusion for anyone arriving from a language whose defaults are immutable.
(* An OCaml list is immutable, so an alias can never
surprise you: there is nothing to change. *)
let () =
let original = [ 1; 2; 3 ] in
let alias = original in
let extended = 0 :: original in
Printf.printf "original %d, alias %d, extended %d\n"
(List.length original) (List.length alias) (List.length extended)original = [1, 2, 3]
alias_name = original
original << 4
puts "original #{original.length}, alias #{alias_name.length}"
# A real copy has to be asked for.
copy = original.dup
original << 5
puts "original #{original.length}, copy #{copy.length}"In OCaml the question does not arise: a list cannot be modified, so two names for one list are indistinguishable from two lists. In Ruby
alias_name = original binds a second name to the same object, and every mutation through either is visible through both. dup makes a shallow copy and clone also copies the frozen state; a deep copy needs Marshal or a library. The rule worth internalizing is that assignment never copies in Ruby — it binds a name to an existing object — and this is invisible until something mutates.Getting Immutability Back
Ruby can be immutable on request, and the request is shallow and per-object.
(* Immutable by default. You opt IN to mutation with a
ref or a mutable record field. *)
type point = { x : int; y : int }
let () =
let origin = { x = 0; y = 0 } in
let shifted = { origin with x = 5 } in
Printf.printf "%d %d\n" origin.x shifted.x# Mutable by default; freeze is the opt-out, and it is
# shallow.
settings = { retries: 3 }.freeze
begin
settings[:retries] = 5
rescue FrozenError => error
puts "FrozenError: #{error.message[0, 24]}"
end
# Data.define is the better answer for a value object.
Point = Data.define(:x, :y)
origin = Point.new(x: 0, y: 0)
puts "#{origin.x} #{origin.with(x: 5).x}"OCaml is immutable by default and you opt in with
mutable or ref. Ruby is mutable by default and freeze is the opt-out — per object, shallow, and permanent for that object's lifetime. Freezing a hash prevents adding keys and does not freeze the values inside it. For a value type Data.define is the better tool, since instances are frozen from birth and it supplies the with that makes functional update pleasant. Reaching for Data where you would write an OCaml record is the single habit most worth carrying over.The Trade
What You Actually Gain
A page listing only what a type system gives up would not explain why anyone chooses this, so here is the other side.
(* OCaml's standard library is small on purpose. A web
application means choosing a framework — Dream,
Opium — and a concurrency library. *)
let () = print_endline "OCaml: pick a framework, pick a concurrency library"# The honest case, and it is not about the language:
# - Rails, which is still the fastest way to build a
# database-backed web application by a wide margin
# - a REPL that lets you inspect a live object graph
# - a testing culture that had to be strong
# - libraries for nearly everything, well documented
# - code that reads aloud
puts "Ruby: Rails, irb, and libraries for everything"The strongest argument for Ruby is not a language feature: it is Rails, which after twenty years is still the fastest route from nothing to a working database-backed application, and which is only possible because of open classes and blocks. Add
irb and binding.irb, which drop you into a live object graph mid-request; a testing culture forced into existence by the absence of a compiler and correspondingly excellent; and a standard library and gem ecosystem that cover almost everything with unusually good documentation. Against that, OCaml offers proof, and asks you to assemble the rest.Habits Worth Keeping
The last row, and the practical one: which OCaml instincts still pay off in a language that cannot enforce them.
(* Make illegal states unrepresentable, keep functions
total, and put the failure in the type. *)
type connection =
| Disconnected
| Connected of { session : string }
let describe = function
| Disconnected -> "disconnected"
| Connected { session } -> Printf.sprintf "connected as %s" session
let () =
List.iter (fun state -> print_endline (describe state))
[ Disconnected; Connected { session = "abc" } ]# You cannot make illegal states unrepresentable — but
# you can keep them unreachable behind constructors, use
# frozen value objects, and let case/in be exhaustive in
# practice even though nothing enforces it.
Disconnected = Data.define
Connected = Data.define(:session)
def describe(state)
case state
in Connected[session] then "connected as #{session}"
in Disconnected then "disconnected"
end
end
puts describe(Disconnected.new)
puts describe(Connected.new(session: "abc"))Four transfer directly. Use
Data.define for value objects — frozen, structurally equal, with a with method, and the closest thing to a record. Model states as classes and match with case/in, which gets you destructuring and a loud NoMatchingPatternError instead of a silent fall-through. Keep methods small and total, returning a value on every path rather than nil on some. And prefer fetch to [] so a missing key fails where it happened. None of it is checked; all of it still works, and it is what distinguishes Ruby written by someone who has used a type system from Ruby written by someone who has not.When Ruby Is the Wrong Answer
Both print the same number, and the honest limit is how long each takes to get there.
(* OCaml compiles to native code and is frequently
within a small factor of C on numeric work. *)
let () =
let total = ref 0 in
for index = 1 to 100000 do
total := !total + index * 2
done;
Printf.printf "%d\n" !total# Ruby is an interpreted, dynamically dispatched
# language. This loop is the same arithmetic and is
# roughly two orders of magnitude slower.
total = 0
(1..100_000).each { |index| total += index * 2 }
puts totalEvery operation goes through method dispatch on an object, and although YJIT has narrowed the gap considerably for real workloads, CPU-bound numeric code remains one to two orders of magnitude slower than native OCaml. The other constraint is the global VM lock: threads are real OS threads but only one executes Ruby at a time, so CPU-bound work gets no parallelism from them — the escapes are processes, Ractors (still experimental after several years), or a C extension. If the hard part of the problem is computation rather than expressing the domain, this is the wrong tool.