Diseñar un DSL en Ruby: el ejercicio de RSpec

Notas de un ejercicio de entrevista técnica que no supe resolver en vivo, reconstruido con el razonamiento que me faltaba. Todo el código de aquí está corrido y verificado en Ruby 3.4.7 con rspec-expectations 3.13.5.

Dónde estaba parado

Medición previa al ejercicio, según la metodología de la araña. El hueco que explica por qué no salió en vivo está en el eje Feynman: podía escribir el DSL sin poder explicar qué hace instance_eval con self.

Definición formal — 0.45 Explicación Feynman — 0.30 Mapeo analógico — 0.55 Implementación — 0.60 Troubleshooting — 0.35 definición 0.45 feynman 0.30 analogía 0.55 implementación 0.60 troubleshooting 0.35
Metaprogramación en Ruby · línea punteada: barra senior (0.80).

El ejercicio

Hacer que esto funcione, desde cero:

expect("string").to(contain("s"))    # => true
expect([1, 2, 3]).to have_size(10)   # => false

Suena simple. Me bloqueé, y no por sintaxis. Me bloqueé porque no sabía qué iba en una clase, qué iba afuera, y a quién pertenecía .to. Empecé a escribir clases sin saber cuáles, y desde ahí no hay piso: nada te dice qué es correcto.

El error de fondo fue el punto de partida. Empecé por las clases. Hay que empezar por el otro lado.


La regla: el call site es la especificación

El código que quieres que funcione te dice el diseño. No es intuición ni talento: es una lectura. Cada pieza de expect("string").to(contain("s")) obliga algo.

expect("string") se llama sin receptor

No escribes algo.expect(...). Entonces expect es un método sobre self — alcanzable por cualquier ruta: definido en el contexto actual, heredado, o mezclado por un módulo.

Ojo con el matiz que yo mismo enuncié mal la primera vez: “sin receptor explícito” no significa “método global de top-level”. Significa “método sobre self”. En el RSpec real, expect es un método de instancia del módulo RSpec::Matchers, que rspec-core incluye en cada example group. Ahí está el salto de diseño: sin receptor no implica top-level, implica mixin.

Lo único que el call site sí garantiza: al no haber punto ni .(), no puede ser una variable local que contenga un callable. Es una llamada a método.

.to se llama sobre el resultado de expect(...)

Entonces expect tiene que devolver algo cuya cadena de lookup responda a to.

Aquí es donde yo sobrevendí la derivación. Dije “to pertenece a ese objeto, es lo único que puede recibirlo, está forzado”. Falso. El call site solo obliga a que algo en la cadena responda a to. Dónde lo defines es decisión de diseño, y la gema real lo demuestra: to vive en el módulo RSpec::Expectations::ExpectationTarget::InstanceMethods — precisamente para poder incluirlo en Minitest::Expectation. La cadena real:

[ValueExpectationTarget, ExpectationTarget, ExpectationTarget::InstanceMethods, Object]

Y la sintaxis legacy should ponía el equivalente directamente en BasicObject, sin wrapper alguno. O sea que expect podía devolver el valor crudo.

El valor entra en expect(...) y se usa en .to(...)

Son dos llamadas separadas, así que algo tiene que recordar el valor entre ambas. Estado → objeto.

Ésa es la única razón por la que hay una clase aquí. No “porque los DSL usan clases”. Porque hay que recordar algo. Si no necesitaras recordar nada, no necesitarías el objeto.

contain("s") va como argumento y se consume después

Ésta es la pieza que no vi, y es el corazón del ejercicio.

Cuando Ruby ejecuta contain("s"), tiene el "s" — pero el "string" no está ahí. Vive en otro objeto que contain no puede ver. La restricción es temporal, no de tipos: en ese momento falta la mitad de la información.

Así que contain no puede devolver el resultado de la comparación. Tiene que devolver una computación diferida: una caja que haga closure sobre lo esperado y sepa terminar el trabajo cuando reciba el resto.

(Yo lo enuncié como “no puede devolver un booleano, tiene que devolver un valor”. Impreciso: un booleano es un valor. La restricción es el diferimiento.)

to recibe esa caja y tiene que usarla

Necesitan un contrato compartido. En el ejercicio: que la caja responda a call(actual).


Entender call, que es lo que de verdad no entendía

call no es magia

Es un nombre de método cualquiera. La prueba: renómbralo.

class Expectation
  def to(matcher) ; matcher.verificar(@actual) ; end   # ya no se llama call
end
class ContainMatcher
  def verificar(actual) ; actual.include?(@expected) ; end
end
# => true    funciona idéntico

call es solo una convención. Podría ser verificar, evaluar, ejecutame.

El problema real: los datos llegan en momentos distintos

Puse trazas en cada paso y corrí expect("string").to(contain("s")):

[paso 1] expect('string') → guardo el valor: "string"
[paso 2] contain('s') corre AHORA. Tengo esperado="s"
         pero NO tengo el valor actual. No puedo comparar todavía.
[paso 3] to() recibe el matcher. AHORA tengo las dos piezas.
[paso 4] el cuerpo del lambda corre DESPUÉS, cuando to lo invoca.
         ahora sí tengo AMBOS: esperado="s" actual="string"
[paso 5] to() devuelve: true

Ésta es toda la lección. expected llega en el paso 2; actual en el paso 4. call es el botón de la caja, y el paso 4 es cuando alguien lo aprieta.

Y fíjate quién lo aprieta: to. Porque to es el único punto del programa donde las dos piezas están juntas. El matcher no se invoca solo; lo dispara quien tiene el contexto completo.

El lambda, mientras espera, mantiene vivo el "s". Eso es un closure: la caja se lleva consigo las variables del lugar donde nació.

El premio de llamarle call

->(x) { x * 2 }.respond_to?(:call)   # => true

Proc ya define #call. Todos los lambdas traen ese botón de fábrica. Así que si eliges call como contrato, los lambdas entran gratis:

expect("string").to(ConCall.new("s"))            # => true   objeto con #call
expect("string").to(->(a) { a.include?("s") })   # => true   lambda

El mismo to, sin cambiar una línea. Eso es duck typing: no importa qué es, importa que responda a call. Si le hubiera puesto verificar, tendría que escribir clases siempre.

Las formas de invocarlo

f = ->(x) { x.upcase }
f.call("hola")   # => "HOLA"   explícita, la más clara
f.("hola")       # => "HOLA"   azúcar. NO es un método: Ruby lo traduce a .call
f["hola"]        # => "HOLA"   estilo indexado
f === "hola"     # => "HOLA"   por esto un lambda funciona en case/when

Métodos reales de Proc que invocan el cuerpo: [:yield, :===, :[], :call]. El .() no aparece porque es sintaxis, no método.

Donde ya usaba esto sin saberlo: Rack

Todo middleware de Rack es un objeto que responde a call(env):

class MiMiddleware
  def initialize(app) ; @app = app ; end
  def call(env)                                # el contrato de Rack
    status, headers, body = @app.call(env)     # llamo al siguiente de la pila
    [status, headers, body]
  end
end
 
app_final = ->(env) { [200, {}, ["ok"]] }      # una app que es un LAMBDA
MiMiddleware.new(app_final).call(path: "/login")

Corrido: el middleware es una clase, la app final es un lambda, y se encadenan sin problema porque ambos responden a call. Rack eligió ese nombre por la misma razón que el ejercicio: así una app puede ser una clase, un lambda o un Sinatra completo.

Otros lugares del mismo patrón: un bloque (arr.each { } — each invoca tu bloque), Sidekiq (defines perform, Sidekiq lo invoca después), un job encolado (guardas el trabajo, otro proceso lo dispara). En todos: alguien guarda trabajo, y otro lo ejecuta más tarde.


Las 4 implementaciones

El núcleo no cambia en ninguna:

class Expectation
  def initialize(actual) ; @actual = actual ; end
  def to(matcher)        ; matcher.call(@actual) ; end
end
 
def expect(actual) = Expectation.new(actual)

1 · Clases

class ContainMatcher
  def initialize(expected) ; @expected = expected ; end
  def call(actual)         ; actual.include?(@expected) ; end
end
def contain(expected) = ContainMatcher.new(expected)

2 · Lambda

def contain(expected)
  ->(actual) { actual.include?(expected) }   # closure captura `expected`
end

Expectation no cambia ni una línea. Ahí está la prueba de que el contrato era duck-typed y no dependía de las clases.

3 · Curry

CONTAIN = ->(expected, actual) { actual.include?(expected) }.curry
expect("string").to(CONTAIN["s"])

Dar 1 de 2 argumentos devuelve un lambda esperando el que falta.

4 · define_method + extend

module Matchers
  SPECS = { contain: ->(e, a) { a.include?(e) }, have_size: ->(e, a) { a.size == e } }
  SPECS.each { |name, fn| define_method(name) { |e| ->(a) { fn.call(e, a) } } }
end
extend Matchers

La escalera método → lambda

Ésta es la conversión que me pidieron y no pude hacer:

def have_size_m(expected)
  ->(actual) { actual.size == expected }
end
 
method(:have_size_m)             # => Method,  arity 1
method(:have_size_m).to_proc     # => Proc,    lambda? true
->(e) { ->(a) { a.size == e } }  # => Proc,    arity 1
# los tres:  .call(3).call([1,2,3])  ·  .(3).([1,2,3])  ·  [3][[1,2,3]]

Lo que el call site NO determina

La derivación da la forma, no el contrato completo. No determina:

  • La negación (not_to, alias to_not).
  • La forma con bloque (expect { }.to raise_error), que en la gema hace dispatch vía ExpectationTarget.for(value, block) → BlockExpectationTarget vs ValueExpectationTarget, y lanza ArgumentError si pasas ambos o ninguno.
  • Qué devuelve to (la gema devuelve el match result truthy).
  • El protocolo de mensajes de fallo.
  • La composabilidad (Composable#=== delega a matches?, lo que permite matchers en case/when y anidados).

Encuadre honesto: el call site deriva la forma; el contrato sale de los requisitos, no de la sintaxis.

El contrato real de RSpec NO es #call

Verificado contra rspec-expectations 3.13.5:

eq('string').respond_to?(:call)      # => false
eq('string').respond_to?(:matches?)  # => true
 
expect("string").to ->(a) { a.include?("s") }
# => NoMethodError: undefined method 'matches?' for an instance of Proc

El .call del ejercicio era una simplificación didáctica. El protocolo real obligatorio son solo dos métodos: matches?(actual) y failure_message. Verificado: una clase con solo esos dos pasa RSpec::Matchers.is_a_matcher? y funciona sin warnings.

Opcionales: does_not_match?, failure_message_when_negated (obligatorio de facto solo si soportas not_to — sin él, una negación que falla revienta con NoMethodError crudo), description (necesaria para el one-liner is_expected y para matchers compuestos), supports_block_expectations?, diffable?.

El análogo real de una lambda-matcher es satisfy { |x| ... } — el bloque va dentro del matcher, no como el matcher. Y satisfy{}.respond_to?(:call) es false.

Para matchers custom, la API oficial:

RSpec::Matchers.define :be_in_zone do |zone|
  match { |player| player.in_zone?(zone) }
end

Alias oficial RSpec::Matchers.matcher. Devuelve un DSL::Matcher que ya trae description autogenerada del nombre y mensajes de fallo por default.


Las trampas verificadas

curry — dos fallan en silencio

->(a, b) { }.curry.arity           # => -1   BORRA la introspección (era 2)
->(a, b = 9) { [a,b] }.curry["s"]  # => ["s", 9]  NO devuelve parcial: invoca de una
                                   #   curry cuenta SOLO parámetros requeridos
->(*a) { a }.curry[1][2]           # => nil  FALLA EN SILENCIO
->(*a) { a }.curry(2)[1][2]        # => [1, 2]  con aridad explícita sí

Un lambda currificado sigue aplicando la aridez (ArgumentError con 3 args); un proc currificado los ignora en silencio.

lambda vs proc — la diferencia que muerde en un DSL

No son solo aridez y return. La tercera es auto-splat:

proc { |a, b| [a, b] }.call([1, 2])   # => [1, 2]   destructura el array
->(a, b) { [a, b] }.call([1, 2])      # => ArgumentError

Importa si el matcher va a recibir arrays o hashes. Y: return en lambda retorna solo del lambda; en proc retorna del método envolvente; un proc que escapa de su definidor lanza LocalJumpError. Además lambda(&mi_proc) ya no existe en Ruby 3.x — no hay forma limpia de promover un proc a lambda.

define_method

  • Es público en Module desde Ruby 2.5 (no hace falta send).
  • Sí existe a top level, pero define un método público de instancia en Object (contamina), a diferencia de un def top-level que crea uno privado.
  • Tiene aridez estricta tipo lambda aunque le pases un block: define_method(:m) { |a, b| } con un solo arg lanza ArgumentError. No hay auto-splat.
  • La razón real de usarlo: el cuerpo es un closure sobre las locales del scope circundante, algo que def no puede hacer. Si no necesitas capturar nada, def es más simple.

El otro tema del mismo ejercicio: include, prepend, extend

class A; end
module Mod1; end
module Mod2; end
class B < A
  include Mod1
  prepend Mod2
end
 
B.ancestors   # => [Mod2, B, Mod1, A, Object, Kernel, BasicObject]
              #     ^^^^ prepend ANTES     ^^^^ include DESPUÉS

Ruby busca de izquierda a derecha y usa el primero. Si la clase y ambos módulos definen el mismo método, gana el prependido, y el incluido nunca corre porque la clase está antes que él.

Para qué sirve prepend: envolver un método existente

module Auditoria
  def save
    puts "[audit] antes"
    resultado = super      # llama al save ORIGINAL de la clase
    puts "[audit] después"
    resultado
  end
end
class Registro
  prepend Auditoria
  def save ; :ok ; end
end

Cuidado con enunciar la regla demasiado amplia. “include no puede envolver con super” es refutable en un segundo — sí puede, cuando el método viene de la superclase:

module Wrap;  def greet; "wrap(" + super + ")"; end; end
class Base;   def greet; "base"; end; end
class Child  < Base; include Wrap; end                             # => "wrap(base)"  ✅
class Child2 < Base; include Wrap; def greet; "propio"; end; end    # => "propio"      ❌

Enunciado correcto: include no puede interceptar un método que la clase define en su propio cuerpo; prepend se inserta antes de la clase y por eso su super cae en el método propio.

Dos trampas del lookup

  1. Las singleton classes no están en ancestors. Para obj.foo la búsqueda arranca en obj.singleton_class, no en obj.class.
  2. include/prepend nunca aportan métodos de clase. Verificado: B.singleton_class.ancestors no contiene Mod1 ni Mod2. Ése es el detalle que suelen buscar al preguntar include vs extend.

Y extend(M) es include M en la singleton class del receptor — misma resolución, pero distinto hook: extend dispara M.extended(obj); el otro dispara M.included(obj.singleton_class).

Sobre extend Matchers a top level: funciona, pero solo alcanza call sites donde self es main — dentro de una clase falla con NameError. La gema real usa include, porque el DSL se usa dentro de los example groups.


Dos teóricas que tenía desactualizadas

El GC sí colecta símbolos

“El garbage collector no libera símbolos de memoria” es la respuesta pre-Ruby 2.2 (2014). Verificado en 3.4.7: con GC.disable creé 200,000 símbolos dinámicos, Symbol.all_symbols creció exactamente 200,000, y tras GC.start la fuga neta fue cero.

  • Static symbols (literales :foo en el código, method tables, ivars, constantes) → inmortales, los pinnea el bytecode compilado.
  • Dynamic symbols ("x".to_sym sobre un string calculado, send con nombre interpolado) → sí se colectan desde 2.2. Ése era el vector de DoS por symbol exhaustion, y está cerrado.

Respuesta de una oración: todos los símbolos son frozen e idénticos entre sí, y desde Ruby 2.2 el GC sí colecta los creados dinámicamente; los literales son inmortales porque los retiene el bytecode.

String literals

“Dos literales iguales son objetos distintos” solo es cierto sin el magic comment. Con # frozen_string_literal: true se deduplican y están frozen — verificado: a.equal?(b) # => true. Y casi todo gem moderno y app Rails lo lleva.

Y: no existe “Ruby 3.5” — el sucesor de 3.4 es Ruby 4.0, liberado el 25-dic-2025, y no congeló los literales por defecto. Los chilled strings de 3.4 siguen siendo un warning de deprecation apagado por default (solo aparece con -w).


El error de proceso, que importa más que el de código

Me quedé callado mientras me bloqueaba. En una entrevista de coding en vivo el razonamiento en voz alta ES el deliverable, no el código terminado.

El protocolo, 90 segundos:

  1. Escribe el call site objetivo como comentario, arriba.
  2. Narra la derivación: “expect no tiene receptor, entonces es un método sobre self. .to viene del resultado, entonces expect devuelve algo que responde a to. El valor tiene que sobrevivir entre las dos llamadas, entonces ese algo guarda estado…”
  3. Escribe los stubs vacíos primero — solo las firmas.
  4. Recién entonces llena los cuerpos.

Aunque te equivoques en el cuerpo, con los pasos 1–3 el entrevistador ya vio lo que buscaba. Un bloqueo silencioso no le da nada.


Lo que me llevo

El hueco no era Ruby. Era elegir el seam: decidir qué es un objeto, qué es un método, y quién le habla a quién, sin que nadie te lo haya dado ya resuelto. En un monolito heredado las abstracciones ya existen y trabajas dentro de ellas; en una hoja en blanco de 45 minutos, no.

Y la herramienta contra eso no es memorizar patrones: es leer el call site como especificación. Escribir primero cómo se usa, y derivar hacia atrás qué obliga cada pieza.