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, orwith_timing. - When raw hashes or primitive values travel everywhere, consider a data abstraction:
Money,UserDirectory,EmailMessage,Order, orLedger. - When a
casestatement 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
- SICP, Structure and Interpretation of Computer Programs. https://mitpress.mit.edu/9780262510875/structure-and-interpretation-of-computer-programs/
- Ruby documentation, Enumerable. https://docs.ruby-lang.org/en/master/Enumerable.html
- Ruby documentation, Enumerator. https://docs.ruby-lang.org/en/master/Enumerator.html
- Ruby documentation, Proc. https://docs.ruby-lang.org/en/master/Proc.html