PONYλM2Modula-2

OCaml.CodeCompared.To/Ruby

An interactive executable cheatsheet comparing OCaml and Ruby

OCaml 5.2 Ruby 4.0
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 year
name = "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.class
In 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]}" end
OCaml 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) end
The 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" count
count = 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 count
OCaml'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 end
OCaml'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, always
A 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.upcase
An 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.inspect
OCaml'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 integers
OCaml 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.inspect
OCaml'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.inspect
Hash.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?).inspect
Enumerable 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).inspect
OCaml'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 advice
temperature = 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 advice
In 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)) end
Ruby 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.balance
class 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).speak
OCaml 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.squared
OCaml 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.inspect
send 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}" end
OCaml 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 end
OCaml'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") attempt
attempts = 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 end
ensure 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 total
Every 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.