The Familiar Surface
Hello, World
IO.puts("Hello, World!") puts "Hello, World!" Ruby’s
puts needs no module prefix — and no parentheses, which idiomatic Ruby omits when a call reads like a command. Much of what feels familiar on this page is not coincidence: Elixir deliberately borrowed Ruby’s surface syntax.Interpolation & %w — shared heritage
name = "Ada"
count = 3
IO.puts("#{name} has #{count} items")
words = ~w(alpha beta gamma)
IO.inspect(words) name = "Ada"
count = 3
puts "#{name} has #{count} items"
words = %w[alpha beta gamma]
p words The
#{} interpolation syntax is identical because Elixir took it from Ruby — and Elixir’s ~w sigil is Ruby’s %w word-array literal, generalized. p is the everyday IO.inspect: it prints the value’s literal representation and returns it.Atoms → symbols
status = :active
IO.inspect(status)
IO.inspect(status == :active)
IO.inspect(%{name: "Ada", role: :admin}) status = :active
p status
p status == :active
p({ name: "Ada", role: :admin }) Symbols are atoms under another name — lightweight, interned, identity-compared constants, used for the same jobs: statuses, option keys, hash/map keys. Even the shorthand map/hash literal with
key: labels looks the same.Everything Is an Object
Behavior moves onto the data
greeting = "hello world"
IO.puts(String.upcase(greeting))
IO.puts(String.length(greeting))
IO.inspect(String.split(greeting, " ")) greeting = "hello world"
puts greeting.upcase
puts greeting.length
p greeting.split(" ") The fundamental inversion of this whole page: in Elixir, functions live in modules and data is passed in; in Ruby, methods live on the object.
String.upcase(greeting) becomes greeting.upcase — the receiver comes first, and there is no module to name.Even literals receive messages
# Elixir integers are plain values — Integer functions take them
# as arguments, and repetition is a range + Enum.
IO.inspect(abs(-3))
Enum.each(1..2, fn _index -> IO.puts("again") end)
IO.puts(nil == nil) p(-3.abs) # integers are objects with methods
2.times { puts "again" }
p nil.to_s # even nil is an object (NilClass)
p 5.class
p nil.class There are no plain values: integers,
nil, true, classes themselves — everything is an object that responds to methods. 2.times reads oddly for about a day and then becomes the most natural loop you know.defstruct → class
defmodule Person do
defstruct [:name, :age]
def greeting(%Person{name: name}) do
"Hello, #{name}!"
end
end
person = %Person{name: "Ada", age: 36}
IO.puts(Person.greeting(person)) class Person
attr_reader :name, :age
def initialize(name:, age:)
@name = name
@age = age
end
def greeting = "Hello, #{@name}!"
end
person = Person.new(name: "Ada", age: 36)
puts person.greeting A struct plus its module of functions becomes a class holding both state and behavior. Instance variables (
@name) are private to the object — attr_reader generates the getter. Person.new allocates and calls initialize, and the one-line def greeting = … is Ruby 4’s endless method, a near-twin of def greeting, do: ….Pattern-match dispatch → duck typing
# Dispatch on the SHAPE of the data, chosen at the function head:
defmodule Describe do
def call(%{type: :circle, radius: radius}), do: "circle r=#{radius}"
def call(%{type: :square, side: side}), do: "square s=#{side}"
end
IO.puts(Describe.call(%{type: :circle, radius: 2}))
IO.puts(Describe.call(%{type: :square, side: 3})) class Circle
def initialize(radius) = @radius = radius
def describe = "circle r=#{@radius}"
end
class Square
def initialize(side) = @side = side
def describe = "square s=#{@side}"
end
[Circle.new(2), Square.new(3)].each do |shape|
puts shape.describe # whatever responds to #describe, works
end Where Elixir picks a function clause by matching the data’s shape, Ruby picks a method by asking the object itself — polymorphic dispatch. Nothing declares the shared interface: any object that responds to
describe qualifies ("if it quacks like a duck"). respond_to?(:describe) is the runtime check when you need one.Mutation & Aliasing
Rebinding → real mutation
numbers = [1, 2, 3]
more = numbers ++ [4] # a NEW list; numbers is untouched
IO.inspect(numbers)
IO.inspect(more) numbers = [1, 2, 3]
numbers << 4 # the SAME array, changed in place
p numbers
numbers.push(5).unshift(0)
p numbers Elixir rebinds names to new values; Ruby objects genuinely change in place.
<< appends to this array — no copy, no rebinding — and mutating methods chain because they return the receiver. Methods ending in ! (like sort!) conventionally flag the more surprising in-place variants.Aliasing — the impossible bug
# This phenomenon cannot exist in Elixir: data is immutable, so
# two names can never watch each other's edits.
first = [1, 2, 3]
second = first
second = second ++ [4] # rebinds second only
IO.inspect(first) # [1, 2, 3] — always
IO.inspect(second) first = [1, 2, 3]
second = first # both names point at ONE array
second << 4
p first # [1, 2, 3, 4] — first changed too!
p second
p first.equal?(second) # true — the very same object
copied = first.dup
copied << 5
p first # unaffected by the copy's edit The bug class Elixir structurally deleted comes back: assignment copies the reference, so edits through one name are visible through every alias.
equal? tests object identity, and dup makes the (shallow) copy that restores Elixir-style independence.Ruby froze its strings
# Elixir strings (binaries) were always immutable:
greeting = "hello"
shouted = String.upcase(greeting) # a new binary
IO.puts(greeting)
IO.puts(shouted) greeting = "hello"
# greeting << " world" # FrozenError — string LITERALS are frozen in Ruby 4
p greeting.frozen?
editable = +greeting # +string makes a mutable copy
editable << " world"
puts editable Ruby moved a step toward Elixir: as of Ruby 4.0, string literals are frozen by default. Mutable strings still exist —
+string or String.new produce unfrozen copies — but the default now matches the immutability Elixir never compromised on. Arrays and hashes remain mutable.Opt-in immutability: freeze & Data
# Immutability is the only mode — no opt-in needed.
point = %{x: 1, y: 2}
moved = %{point | x: 99} # update syntax returns a NEW map
IO.inspect(point)
IO.inspect(moved) Point = Data.define(:x, :y) # an immutable value class
point = Point.new(x: 1, y: 2)
moved = point.with(x: 99) # returns a NEW Point
p point
p moved
settings = { theme: "dark" }.freeze
p settings.frozen? Ruby’s immutability is opt-in:
freeze locks any object, and Data.define (Ruby 3.2+) creates genuinely immutable value classes whose with is Elixir’s %{struct | field: value} update. Idiomatic modern Ruby reaches for Data exactly where Elixir reaches for a struct.= Is Just Assignment
No match operator, no pin
count = 1
count = count + 1 # rebinding
IO.inspect(count)
{:ok, value} = {:ok, 42} # = MATCHES — and can fail
IO.inspect(value) count = 1
count = count + 1 # plain reassignment — nothing is matched
p count
# There is no destructuring-with-failure: = never raises.
# Multiple assignment is positional, and extra values are dropped:
status, value = [:ok, 42]
p [status, value] Ruby’s
= only assigns. Multiple assignment (a, b = list) destructures positionally but never fails — missing positions become nil, extras are discarded. The match-or-crash behavior of Elixir’s = has no equivalent outside case/in (next rows).head | tail → splats
[head | tail] = [1, 2, 3, 4]
IO.inspect(head)
IO.inspect(tail) head, *tail = [1, 2, 3, 4]
p head
p tail
first, *middle, last = [1, 2, 3, 4, 5]
p [first, middle, last] The splat
* collects the rest — Ruby’s head, *tail is Elixir’s [head | tail], and the splat can sit in the middle, something cons-cell patterns cannot express. Because Ruby arrays are not linked lists, none of this carries Elixir’s head-vs-tail performance asymmetry.case/in — the real cousin
response = {:ok, %{name: "Ada", age: 36}}
case response do
{:ok, %{name: name}} when is_binary(name) ->
IO.puts("hello, #{name}")
{:error, reason} ->
IO.puts("failed: #{inspect(reason)}")
end response = [:ok, { name: "Ada", age: 36 }]
case response
in [:ok, { name: String => name }]
puts "hello, #{name}"
in [:error, reason]
puts "failed: #{reason}"
end Ruby 3 added real pattern matching, and it lives in
case/in: array and hash patterns destructure, String => name both type-checks and binds (playing the role of Elixir’s when is_binary(name) guard), and an unmatched value raises NoMatchingPatternError — the match-or-crash semantics Elixir developers expect, opt-in.nil & Truthiness
The same truthiness rule
# Only nil and false are falsy — everything else is truthy:
IO.puts(if 0, do: "0 is truthy", else: "0 is falsy")
IO.puts(if "", do: "empty string is truthy", else: "falsy")
IO.puts(if nil, do: "truthy", else: "nil is falsy") puts(0 ? "0 is truthy" : "0 is falsy")
puts("" ? "empty string is truthy" : "falsy")
puts(nil ? "truthy" : "nil is falsy") A rule you already know, because Elixir inherited it from Ruby verbatim: only
nil and false are falsy — 0 and "" are truthy, unlike most mainstream languages. Ruby adds a ternary operator Elixir lacks.Handling absence
inventory = %{apples: 5}
IO.inspect(inventory[:pears]) # nil for a missing key
IO.inspect(Map.get(inventory, :pears, 0)) # with a default
IO.inspect(inventory[:pears] || 0) inventory = { apples: 5 }
p inventory[:pears] # nil for a missing key
p inventory.fetch(:pears, 0) # with a default
p inventory[:pears] || 0
owner = nil
p owner&.upcase # &. — safe navigation, nil if receiver is nil The idioms rhyme (
|| defaults, nil for missing keys, fetch as Map.get with a default) — plus &., the safe-navigation operator: where a nil receiver would raise NoMethodError, &. short-circuits to nil, one link at a time.Blocks, Not fn
fn arguments → blocks
Enum.each([1, 2, 3], fn number ->
IO.puts(number * 10)
end) [1, 2, 3].each do |number|
puts number * 10
end
[1, 2, 3].each { |number| puts number * 10 } The block is Ruby’s signature construct: a closure passed outside the argument list, in
do…end (multiline) or braces (one-liner), with parameters between pipes. It is not a value being passed — it is syntax every method can receive, and it is how all iteration works.Writing methods that take blocks
defmodule Timer do
def measure(work) do
IO.puts("starting")
result = work.()
IO.puts("finished")
result
end
end
IO.inspect(Timer.measure(fn -> 6 * 7 end)) def measure
puts "starting"
result = yield # invoke the block the caller attached
puts "finished"
result
end
p(measure { 6 * 7 }) yield calls the block attached to the current method — no parameter is declared, no function object handled. block_given? tests for one, and an explicit &work parameter captures the block as a Proc when it must be stored or forwarded.fn & capture → lambdas & &:
double = fn number -> number * 2 end
IO.inspect(double.(21))
IO.inspect(Enum.map([1, 2, 3], &(&1 * 2)))
IO.inspect(Enum.map(["a", "b"], &String.upcase/1)) double = ->(number) { number * 2 }
p double.call(21)
p double.(21) # same call, Elixir-style dot
p [1, 2, 3].map { |number| number * 2 }
p ["a", "b"].map(&:upcase) # &:symbol — the capture shorthand When a closure must be a value, the lambda literal
->(x) { } is Elixir’s fn — and Ruby even accepts the .() call syntax Elixir requires. The &:upcase shorthand converts a symbol to a block, playing the role of &String.upcase/1.No |> — chaining is the pipeline
"hello world elixir"
|> String.split(" ")
|> Enum.map(&String.capitalize/1)
|> Enum.join(" ")
|> IO.puts() puts "hello world ruby"
.split(" ")
.map(&:capitalize)
.join(" ")
p 42.then { |number| number * 2 } # .then pipes a value into a block Ruby has no pipe operator — and rarely misses it, because methods living on objects means every call already returns a receiver for the next one.
.then covers the leftover case of piping a value into arbitrary code, the one-step |>.Collections: Enum → Enumerable
Enum → Enumerable
[1, 2, 3, 4, 5, 6]
|> Enum.filter(fn number -> rem(number, 2) == 0 end)
|> Enum.map(fn number -> number * 10 end)
|> Enum.sum()
|> IO.inspect() total = [1, 2, 3, 4, 5, 6]
.select(&:even?)
.map { |number| number * 10 }
.sum
p total The vocabulary shifts more than the ideas:
filter is select (with reject as its complement), reduce exists under both reduce and inject, and predicate methods end in ? (even?). All of it lives on the collection, so the chain needs no module names.Maps → Hashes
inventory = %{apples: 5, pears: 2}
updated = Map.put(inventory, :plums, 7)
IO.inspect(updated[:apples])
IO.inspect(Map.keys(updated) |> Enum.sort())
nested = %{config: %{theme: "dark"}}
IO.inspect(get_in(nested, [:config, :theme])) inventory = { apples: 5, pears: 2 }
inventory[:plums] = 7 # mutates in place, of course
p inventory[:apples]
p inventory.keys.sort
nested = { config: { theme: "dark" } }
p nested.dig(:config, :theme) # get_in Hashes are maps you can mutate:
hash[key] = value writes in place. dig is get_in, iteration order is insertion order (guaranteed, as in Elixir maps up to 32 keys — but here always), and symbol keys with the key: literal shorthand dominate real code just as atom keys do.Stream → lazy
1..1_000_000
|> Stream.filter(fn number -> rem(number, 7) == 0 end)
|> Stream.map(fn number -> number * 2 end)
|> Enum.take(3)
|> IO.inspect() first_three = (1..1_000_000)
.lazy
.select { |number| (number % 7).zero? }
.map { |number| number * 2 }
.first(3)
p first_three .lazy converts any Enumerable into Elixir’s Stream: deferred, element-at-a-time evaluation where eager methods would materialize each intermediate array. A terminal call like first(3) plays the Enum.take/2 role of forcing the pipeline.for comprehensions → chains
squares = for number <- 1..5, rem(number, 2) == 1 do
number * number
end
IO.inspect(squares)
pairs = for letter <- ~w(a b), number <- 1..2 do
{letter, number}
end
IO.inspect(pairs) squares = (1..5).select(&:odd?).map { |number| number * number }
p squares
pairs = %w[a b].product([1, 2])
p pairs Ruby has no comprehension syntax; a filter-then-map chain covers the everyday case and
product covers the multi-generator cross join. (Ruby’s for loop exists but is shunned — it leaks its variable into the surrounding scope, and everyone writes each instead.)Methods
do: → endless def
defmodule Math do
def square(number), do: number * number
def cube(number), do: number * number * number
end
IO.inspect(Math.square(7))
IO.inspect(Math.cube(3)) def square(number) = number * number
def cube(number) = number * number * number
p square(7)
p cube(3) The languages converged from both ends: Elixir’s one-line
def f(x), do: expr and Ruby 4’s endless method def f(x) = expr are near-twins. Ruby methods also need no enclosing module — top-level def is fine — and the last expression is the return value, just as in Elixir.Keyword lists → real keyword arguments
# Options arrive as a keyword list — a plain list of tuples,
# with defaults merged by hand:
defmodule Resizer do
def resize(width, height, options \\ []) do
preserve = Keyword.get(options, :preserve_aspect, true)
"#{width}x#{height} preserve=#{preserve}"
end
end
IO.puts(Resizer.resize(800, 600))
IO.puts(Resizer.resize(800, 600, preserve_aspect: false)) def resize(width:, height:, preserve_aspect: true)
"#{width}x#{height} preserve=#{preserve_aspect}"
end
puts resize(width: 800, height: 600)
puts resize(width: 800, height: 600, preserve_aspect: false) Ruby’s keyword arguments are a real language feature, not a trailing-list convention:
width: with no default is required (omitting it raises ArgumentError), defaults sit in the signature, and no Keyword.get plumbing is needed. The call sites look identical — Elixir’s sugar was modeled on Ruby’s.Guards & clauses → early returns
defmodule Classify do
def call(number) when number < 0, do: "negative"
def call(0), do: "zero"
def call(number) when number > 0, do: "positive"
end
IO.puts(Classify.call(-5))
IO.puts(Classify.call(0))
IO.puts(Classify.call(9)) def classify(number)
return "negative" if number.negative?
return "zero" if number.zero?
"positive"
end
puts classify(-5)
puts classify(0)
puts classify(9) One method body replaces the clause list, and
return — which Elixir deliberately lacks — handles the early exits, usually as trailing-if guard lines. The trailing conditional (return … if …) is idiomatic Ruby’s answer to when guards; unless is its negated sibling.Mixins & Inheritance
Modules do two jobs
# An Elixir module is a namespace for functions — nothing more.
defmodule Text.Formatter do
def shout(text), do: String.upcase(text) <> "!"
end
IO.puts(Text.Formatter.shout("hello")) module Text
module Formatter
def self.shout(text) = text.upcase + "!"
end
end
puts Text::Formatter.shout("hello") As a namespace, a Ruby module works like Elixir’s (with
:: as the separator and self. marking module-level functions). But Ruby modules have a second job Elixir modules never do: being mixed into classes — the next row.The mixin superpower
# The nearest Elixir gets is implementing a protocol per type —
# but nothing hands you a pile of derived functions for free the
# way Comparable does. (Runnable protocol definitions are also
# unsupported on this page's AtomVM runtime.)
defmodule Coffee do
defstruct [:strength]
def compare(%Coffee{strength: left}, %Coffee{strength: right}) do
cond do
left < right -> :lt
left > right -> :gt
true -> :eq
end
end
end
espresso = %Coffee{strength: 10}
latte = %Coffee{strength: 3}
IO.inspect(Coffee.compare(espresso, latte)) class Coffee
include Comparable # implement <=>, inherit <, >, ==, between?, clamp…
attr_reader :strength
def initialize(strength) = @strength = strength
def <=>(other) = strength <=> other.strength
end
espresso = Coffee.new(10)
latte = Coffee.new(3)
p espresso > latte
p latte.between?(Coffee.new(1), Coffee.new(5))
p [espresso, latte].min.strength Mixins are Ruby’s flagship reuse mechanism:
include Comparable and one <=> definition buy every comparison operator; include Enumerable and one each buy map, select, sort, and dozens more. Elixir has no equivalent — protocols dispatch, but they do not donate implementations.Inheritance exists
# Elixir has no inheritance — composition and delegation only:
defmodule Animal do
def speak(_animal), do: "..."
end
defmodule Dog do
def speak(_dog), do: "Woof"
def sniff(_dog), do: "sniffing"
end
IO.puts(Dog.speak(:rex))
IO.puts(Dog.sniff(:rex)) class Animal
def speak = "..."
def describe = "I say #{speak}"
end
class Dog < Animal
def speak = "Woof" # overrides; describe is inherited
end
puts Dog.new.describe
puts Animal.new.describe Single inheritance (
<) is ordinary Ruby: subclasses inherit and override methods, super calls up the chain, and inherited methods (like describe) see the subclass’s overrides — classic late binding. Modern Ruby style still prefers composition and mixins for sharing behavior across unrelated classes.Runtime Metaprogramming
Open classes
# Elixir modules are closed at compile time. Adding a "method"
# to String means defining your own module:
defmodule StringExtras do
def shout(text), do: String.upcase(text) <> "!"
end
IO.puts(StringExtras.shout("hello")) class String # reopen the built-in class
def shout = upcase + "!"
end
puts "hello".shout # every string everywhere now responds Any class can be reopened and extended at runtime — including core classes, in what Rubyists call monkey-patching. It is how Rails makes
3.days.ago work. Powerful and dangerous in equal measure; Module#refine offers a lexically-scoped alternative when the blast radius matters.method_missing — ghost methods
# Elixir metaprograms at COMPILE time with macros; there is no
# runtime hook for calls to functions that do not exist —
# an undefined function is just an error.
defmodule Finder do
def find_by(field, value), do: "SELECT * WHERE #{field} = '#{value}'"
end
IO.puts(Finder.find_by(:name, "Ada")) class Finder
def method_missing(name, *arguments)
if name.to_s.start_with?("find_by_")
field = name.to_s.delete_prefix("find_by_")
"SELECT * WHERE #{field} = '#{arguments.first}'"
else
super
end
end
def respond_to_missing?(name, include_private = false)
name.to_s.start_with?("find_by_") || super
end
end
puts Finder.new.find_by_name("Ada") # a method that was never defined method_missing intercepts calls to undefined methods at runtime — the mechanism behind Rails’ classic dynamic finders. Where Elixir metaprograms ahead of time with macros generating real functions, Ruby can improvise the response the moment the message arrives.define_method — generating methods at runtime
# Generating functions is a compile-time macro affair (use/quote).
# The everyday stand-in: one function over data.
defmodule Status do
@statuses [:active, :archived]
def status?(record_status, status) when status in @statuses do
record_status == status
end
end
IO.inspect(Status.status?(:active, :active))
IO.inspect(Status.status?(:active, :archived)) class Record
[:active, :archived].each do |status|
define_method("#{status}?") do
@status == status
end
end
def initialize(status) = @status = status
end
record = Record.new(:active)
p record.active? # generated in the loop above
p record.archived? define_method writes real methods from ordinary runtime code — a loop over symbols here generates active? and archived?. This is the workhorse behind attr_accessor (itself just a method that defines methods) and most of Rails’ generated API.Exceptions Are the Norm
Tagged tuples → raise/rescue
defmodule Parser do
def parse(text) do
case Integer.parse(text) do
{value, ""} -> {:ok, value}
_other -> {:error, "not a number: #{text}"}
end
end
end
case Parser.parse("42") do
{:ok, value} -> IO.puts("parsed #{value}")
{:error, reason} -> IO.puts(reason)
end def parse(text)
Integer(text) # raises ArgumentError on bad input
end
begin
puts "parsed #{parse("42")}"
puts "parsed #{parse("many")}"
rescue ArgumentError => error
puts "failed: #{error.message}"
end The convention inverts: failure in Ruby normally raises, unwinding the stack until a
rescue catches it — not a tagged tuple threaded through return values. begin/rescue/end is try/rescue, the error binds with =>, and a bare method body can rescue without the begin.Custom errors & ensure
defmodule QuotaError do
defexception message: "quota exceeded"
end
try do
raise QuotaError
rescue
error in QuotaError -> IO.puts("caught: #{error.message}")
after
IO.puts("cleanup")
end class QuotaError < StandardError
def initialize(message = "quota exceeded") = super
end
begin
raise QuotaError
rescue QuotaError => error
puts "caught: #{error.message}"
ensure
puts "cleanup"
end A custom error is a class inheriting from
StandardError (rescue’s default reach — inherit from it, not Exception). ensure is Elixir’s after: it runs on every exit. Ruby also has retry, which re-runs the whole begin block — a control-flow move Elixir deliberately omits.The softer failure: nil returns
# Elixir splits these as find (nil) vs find! conventions too,
# but the tagged tuple dominates library APIs:
inventory = %{apples: 5}
case Map.fetch(inventory, :pears) do
{:ok, quantity} -> IO.puts("have #{quantity}")
:error -> IO.puts("none in stock")
end inventory = { apples: 5 }
# Two flavors, chosen by the CALLER:
p inventory[:pears] # soft: nil when missing
begin
inventory.fetch(:pears) # hard: raises KeyError
rescue KeyError => error
puts "none in stock (#{error.class})"
end Ruby APIs conventionally come in pairs — a soft version returning
nil ([], find) and a hard version raising (fetch, find! in Rails) — where Elixir would return {:ok, _} | :error and let the caller match. The !/fetch naming is the signal to watch for.The Concurrency Downgrade
Processes → threads (shared memory, GVL)
# Each process has its OWN heap — the counter cannot be shared,
# only messaged:
counter = spawn(fn ->
receive do
{:add, amount, caller} -> send(caller, {:total, amount})
end
end)
send(counter, {:add, 5, self()})
receive do
{:total, total} -> IO.puts("total #{total}")
end total = 0
workers = 2.times.map do
Thread.new do
total += 5 # threads SHARE memory — this is a race in general
end
end
workers.each(&:join)
puts "total #{total}" The single biggest capability loss on this page: Ruby threads share one heap, so mutation from two threads is a data race the language does nothing to prevent — where BEAM processes are structurally isolated. In standard Ruby (MRI) a Global VM Lock serializes execution (this example is "safe" only by that accident), so threads help with I/O waiting, not CPU parallelism. On this site’s in-browser runtime, threads are faked synchronously.
Mailboxes → Queue
worker = spawn(fn ->
receive do
{:job, payload} -> IO.puts("processing #{payload}")
end
end)
send(worker, {:job, "invoice-42"})
# Give the worker a beat to print before the script ends:
receive do
after
50 -> :ok
end jobs = Queue.new # a thread-safe FIFO
worker = Thread.new do
payload = jobs.pop # blocks until something arrives
puts "processing #{payload}"
end
jobs.push("invoice-42")
worker.join Queue (thread-safe, blocking pop) is the closest stdlib analog to a process mailbox — but it is a shared object threads pull from, not a per-process inbox with selective receive. There is no supervision tree, no restart strategy, and no OTP: a crashed thread is simply gone unless you check on it.Ractors — Ruby borrows the actor
# The model Ractor borrows from: isolated heaps, message passing.
parent = self()
spawn(fn ->
send(parent, {:result, 6 * 7})
end)
receive do
{:result, value} -> IO.puts("got #{value}")
end # Ractor — Ruby's BEAM-inspired isolated actors (experimental):
worker = Ractor.new do
answer = Ractor.receive
answer * 2
end
worker.send(21)
puts worker.value Ruby is reaching toward the BEAM: Ractors (Ruby 3.0+, API revised in 3.5) get isolated object spaces, share almost nothing, communicate by message — and can run in true parallel, each holding its own lock. They remain experimental and library support is thin, so this cell is display-only: the in-browser runtime cannot run Ractors.
Tooling & Ecosystem
mix & Hex → Bundler & RubyGems
# mix.exs — project + dependencies in one file
defp deps do
[
{:jason, "~> 1.4"},
{:req, "~> 0.5"}
]
end
# $ mix deps.get && mix test # Gemfile — dependencies only (project config lives elsewhere)
source "https://rubygems.org"
gem "json", "~> 2.7"
gem "faraday", "~> 2.9"
# $ bundle install && bundle exec rake test The mapping is direct — Hex packages become gems,
mix deps.get becomes bundle install, and the ~> pessimistic version operator is another thing Elixir took from Ruby unchanged. bundle exec pins commands to the Gemfile’s versions. Both cells are configuration fragments, shown display-only.iex → irb
# $ iex
# iex(1)> greeting = "hello"
# "hello"
# iex(2)> String.upcase(greeting)
# "HELLO"
# iex(3)> h String.upcase # built-in docs # $ irb
# irb(main):001> greeting = "hello"
# => "hello"
# irb(main):002> greeting.upcase
# => "HELLO"
# irb(main):003> greeting.methods.grep(/case/)
# => [:casecmp, :upcase, :downcase, ...] irb is the REPL iex was modeled on. Instead of h for docs, the Ruby move is asking the object itself — greeting.methods, greeting.class, and ri for documentation. Both cells are terminal transcripts, shown display-only.