Ruby and SICP

SICP ideas in practical Ruby

How SICP design ideas appear in Ruby through blocks, higher-order methods, data barriers, streams, state, and small languages.

SICP as habits

Structure and Interpretation of Computer Programs is not a Ruby book, but many of its habits translate well to Ruby: build abstractions, pass behavior as values, separate representation from use, and treat programs as layers of meaning.

Ruby's object model differs from Scheme, yet the practical lessons survive. Blocks, lambdas, Enumerable, Enumerator, and small internal DSLs all make it possible to write Ruby that composes behavior rather than only listing steps.

The point is not to imitate Scheme syntax. The point is to notice the design moves.

Higher-order processes

SICP's early examples are not only about recursion. They show how to name a general process, then pass in the pieces that vary. Ruby blocks and lambdas make that move ordinary.

A retry helper is a practical example. The caller supplies the operation. The method supplies the process shape: try, catch a selected error, wait if needed, and retry a bounded number of times.

def retrying(times:, wait: 0, rescue_from: StandardError)
  attempts = 0

  begin
    yield
  rescue *Array(rescue_from) => error
    attempts += 1
    raise error if attempts >= times

    sleep(wait) if wait.positive?
    retry
  end
end

data = retrying(times: 3, wait: 0.25, rescue_from: IOError) do
  File.read("cache.txt")
end

The same idea appears in accumulation. When a loop initializes a counter, filters records, and adds a value, the specific business condition is less important than the repeated computational pattern.

def sum_by(records, select_by:, value:)
  records.sum do |record|
    select_by.call(record) ? value.call(record) : 0
  end
end

Order = Struct.new(:status, :currency, :total_cents, keyword_init: true)

orders = [
  Order.new(status: :paid, currency: "USD", total_cents: 1000),
  Order.new(status: :pending, currency: "USD", total_cents: 2000),
  Order.new(status: :paid, currency: "EUR", total_cents: 3000)
]

paid_usd_revenue = sum_by(
  orders,
  select_by: ->(order) { order.status == :paid && order.currency == "USD" },
  value: ->(order) { order.total_cents }
)

paid_usd_revenue
# => 1000

This is SICP's higher-order procedure idea in Ruby clothing: separate the reusable method from the particular case.

Data abstraction barriers

SICP repeatedly asks what one part of a program must know about another. The answer should be: as little as possible. Ruby objects can enforce that barrier when they protect raw representation from leaking everywhere.

Money as plain cents is easy until currencies, formatting, comparison, taxes, and rounding enter the system. A small object gives the rest of the program a money interface instead of integer arithmetic.

class Money
  include Comparable

  attr_reader :cents, :currency

  def initialize(cents, currency:)
    @cents = Integer(cents)
    @currency = currency
  end

  def self.usd(cents)
    new(cents, currency: "USD")
  end

  def +(other)
    ensure_same_currency!(other)
    Money.new(cents + other.cents, currency: currency)
  end

  def -(other)
    ensure_same_currency!(other)
    Money.new(cents - other.cents, currency: currency)
  end

  def <=>(other)
    ensure_same_currency!(other)
    cents <=> other.cents
  end

  def to_s
    "#{currency} #{format('%.2f', cents / 100.0)}"
  end

  private

  def ensure_same_currency!(other)
    return if currency == other.currency

    raise ArgumentError, "currency mismatch: #{currency} vs #{other.currency}"
  end
end

price = Money.usd(1999)
discount = Money.usd(500)

(price - discount).to_s
# => "USD 14.99"

The same barrier can hide collection representation. Callers should not need to know whether users are stored in an array, hash, database, cache, API, or file.

User = Struct.new(:email, :active, keyword_init: true) do
  def active?
    active
  end
end

class UserDirectory
  def initialize(users)
    @by_email = users.to_h do |user|
      [normalize_email(user.email), user]
    end
  end

  def find_by_email(email)
    @by_email[normalize_email(email)]
  end

  def active_users
    @by_email.values.select(&:active?)
  end

  private

  def normalize_email(email)
    email.to_s.strip.downcase
  end
end

directory = UserDirectory.new([
  User.new(email: "Ada@Example.com", active: true),
  User.new(email: "Grace@Example.com", active: false)
])

directory.find_by_email(" ada@example.com ")
# => #<struct User email="Ada@Example.com", active=true>

The point is not ceremony. The point is locality. If lookup later moves from a hash to SQL, the callers should not all change.

Conventional interfaces

SICP emphasizes conventional interfaces: shared protocols that let different structures participate in the same operations. Ruby's Enumerable is a clean everyday version of that idea.

Entry = Struct.new(:description, :amount_cents, keyword_init: true)

class Ledger
  include Enumerable

  def initialize(entries = [])
    @entries = entries
  end

  def each(&block)
    @entries.each(&block)
  end

  def add(description, amount_cents)
    @entries << Entry.new(
      description: description,
      amount_cents: amount_cents
    )
  end

  def balance_cents
    sum(&:amount_cents)
  end
end

ledger = Ledger.new
ledger.add("invoice paid", 2_000)
ledger.add("hosting bill", -500)
ledger.add("domain renewal", -200)

ledger.balance_cents
# => 1300

ledger.select { |entry| entry.amount_cents.negative? }
ledger.map(&:description)

Ledger only supplies each. Ruby gives it map, select, find, sum, group_by, any?, and all?. That is a conventional interface doing real work.

The same idea appears as duck typing. A caller can depend on a protocol instead of a class.

class EmailDestination
  def deliver(message)
    puts "Sending email: #{message}"
  end
end

class AuditLogDestination
  def initialize(io)
    @io = io
  end

  def deliver(message)
    @io.puts("[audit] #{message}")
  end
end

def broadcast(message, destinations)
  destinations.each do |destination|
    destination.deliver(message)
  end
end

broadcast(
  "invoice paid",
  [
    EmailDestination.new,
    AuditLogDestination.new($stdout)
  ]
)

broadcast does not care whether the destination is email, a log, a webhook, a queue, or a test double. It only requires deliver.

Streams and recursive data

SICP streams show how delayed computation can model large or infinite sequences. Ruby's lazy enumerators bring that idea into normal file and data processing.

require "json"

def parse_json_line(line)
  JSON.parse(line, symbolize_names: true)
rescue JSON::ParserError
  nil
end

errors = File.foreach("app.log")
             .lazy
             .map { |line| parse_json_line(line) }
             .reject(&:nil?)
             .select { |event| event[:level] == "error" }
             .take(20)
             .force

This pipeline does not load the whole file. It reads lines as demanded and stops after twenty errors.

Laziness also makes infinite sequences practical when the consumer asks for a finite prefix.

naturals = Enumerator.new do |yielder|
  n = 1

  loop do
    yielder << n
    n += 1
  end
end

naturals
  .lazy
  .select(&:odd?)
  .map { |n| n * n }
  .take(5)
  .force
# => [1, 9, 25, 49, 81]

SICP's tree-recursion examples also map directly to nested API data. When the data is recursive, the program often should be recursive too.

def collect_key(node, target_key)
  case node
  when Hash
    values_here = node.key?(target_key) ? [node[target_key]] : []

    values_below = node.values.flat_map do |value|
      collect_key(value, target_key)
    end

    values_here + values_below
  when Array
    node.flat_map { |element| collect_key(element, target_key) }
  else
    []
  end
end

payload = {
  order: {
    id: 1,
    customer: { id: 7, email: "ada@example.com" },
    line_items: [
      { id: 100, sku: "BOOK", price_cents: 2999 },
      { id: 101, sku: "PEN", price_cents: 499 }
    ]
  }
}

collect_key(payload, :id)
# => [1, 7, 100, 101]

State and identity

SICP treats assignment as serious because it introduces time. Once state exists, the result of an operation can depend on history, not only on the current arguments.

A closure can model private state directly:

def make_account(opening_balance_cents)
  balance = opening_balance_cents

  lambda do |message, amount_cents = nil|
    case message
    when :balance
      balance
    when :deposit
      balance += amount_cents
    when :withdraw
      raise "insufficient funds" if amount_cents > balance

      balance -= amount_cents
    else
      raise ArgumentError, "unknown message: #{message.inspect}"
    end
  end
end

account = make_account(1_000)
account.call(:deposit, 500)
account.call(:withdraw, 300)
account.call(:balance)
# => 1200

The idiomatic Ruby version is a class, but the model is the same: state plus an interface.

class Account
  attr_reader :balance_cents

  def initialize(opening_balance_cents)
    @balance_cents = opening_balance_cents
  end

  def deposit(amount_cents)
    @balance_cents += amount_cents
  end

  def withdraw(amount_cents)
    raise "insufficient funds" if amount_cents > balance_cents

    @balance_cents -= amount_cents
  end
end

Mutation should be visible at the boundary. Ruby's ! convention is useful because it tells callers that identity is being preserved while contents change.

def normalized_tags(tags)
  tags.map { |tag| tag.strip.downcase }
end

def normalize_tags!(tags)
  tags.map! { |tag| tag.strip.downcase }
end

tags = [" Ruby ", " SICP ", " Abstraction "]
normalized = normalized_tags(tags)

tags
# => [" Ruby ", " SICP ", " Abstraction "]

normalized
# => ["ruby", "sicp", "abstraction"]

The SICP-informed rule is simple: use mutation when identity matters; avoid it when transformation is enough.

Small languages and evaluators

SICP's metalinguistic abstraction says that when a problem has its own vocabulary, a program can define a small language for that problem. Ruby's internal DSL style makes this idea familiar.

class Contract
  def initialize
    @rules = []
  end

  def required(key)
    @rules << lambda do |data|
      "#{key} is required" unless data.key?(key) && !data[key].nil?
    end
  end

  def numeric(key)
    @rules << lambda do |data|
      value = data[key]
      "#{key} must be numeric" unless value.nil? || value.is_a?(Numeric)
    end
  end

  def min(key, floor)
    @rules << lambda do |data|
      value = data[key]
      "#{key} must be at least #{floor}" unless value.nil? || value >= floor
    end
  end

  def validate(data)
    @rules.map { |rule| rule.call(data) }.compact
  end
end

def contract(&block)
  contract = Contract.new
  contract.instance_eval(&block)
  contract
end

signup_contract = contract do
  required :email
  required :age
  numeric :age
  min :age, 18
end

signup_contract.validate(email: "ada@example.com", age: 16)
# => ["age must be at least 18"]

This has primitives such as required, numeric, and min. It has an evaluator: validate(data).

A more explicit SICP-style evaluator can represent formulas as data, then interpret them against an environment.

module Formula
  OPS = {
    :+ => ->(a, b) { a + b },
    :- => ->(a, b) { a - b },
    :* => ->(a, b) { a * b },
    :/ => ->(a, b) { a.to_f / b },
    :> => ->(a, b) { a > b }
  }

  def self.evaluate(expr, env)
    case expr
    when Numeric
      expr
    when Symbol
      env.fetch(expr)
    when Array
      op, *args = expr

      if op == :if
        condition, consequent, alternative = args
        evaluate(condition, env) ? evaluate(consequent, env) : evaluate(alternative, env)
      else
        OPS.fetch(op).call(*args.map { |arg| evaluate(arg, env) })
      end
    else
      raise ArgumentError, "unknown expression: #{expr.inspect}"
    end
  end
end

price_formula = [
  :if,
  [:>, :quantity, 10],
  [:*, :unit_price, 0.90],
  :unit_price
]

Formula.evaluate(price_formula, unit_price: 100, quantity: 12)
# => 90.0

That is a tiny interpreter. Similar shapes appear in pricing systems, authorization rules, workflow engines, query builders, template engines, and configuration languages.

Practical refactoring cues

The useful Ruby translation of SICP is not a single style. It is a set of questions to ask while changing code.

  • When repeated control flow appears, consider a method that takes a block: retrying, transactionally, with_lock, or with_timing.
  • When raw hashes or primitive values travel everywhere, consider a data abstraction: Money, UserDirectory, EmailMessage, Order, or Ledger.
  • When a case statement checks classes or types, consider a protocol, a dispatch table, or a small object with one clear message.
  • When large arrays are built only to take a few results, consider lazy enumerators.
  • When repeated domain declarations appear, consider a small internal language.
  • When mutation appears, ask whether identity matters.
  • When nested loops are slow, ask whether the representation is wrong.

SICP's core habit is still practical in Ruby: name the process, hide the representation, depend on protocols, make state explicit, use streams for delayed work, and build small languages only when the domain deserves one.

Sources

  1. SICP, Structure and Interpretation of Computer Programs. https://mitpress.mit.edu/9780262510875/structure-and-interpretation-of-computer-programs/
  2. Ruby documentation, Enumerable. https://docs.ruby-lang.org/en/master/Enumerable.html
  3. Ruby documentation, Enumerator. https://docs.ruby-lang.org/en/master/Enumerator.html
  4. Ruby documentation, Proc. https://docs.ruby-lang.org/en/master/Proc.html