Vol. 9 · № 01 · Un atelier RubyA Ruby workshop

La résolution de méthode, ou le coût que le cache amortit Method resolution, or the cost the cache amortizes

Chaque message se résout en remontant la chaîne d'ancêtres (Vol 5), mais Ruby met le résultat en cache : la profondeur ne se paie qu'au premier appel. Le coût ne reparaît qu'hors du cache — method_missing, ou la modification de classes en boucle. La règle : mesure avant de croire, jamais l'inverse. Every message resolves by walking the ancestor chain (Vol 5), but Ruby caches the result: depth is paid only on the first call. The cost reappears only outside the cache — method_missing, or modifying classes in a loop. The rule: measure before believing, never the reverse.

AudienceAudience
Dev qui imagine le dynamisme coûteux et n'a pas encore mesuré Dev who imagines dynamism costly and hasn't yet measured
Format
Self-paced
ChapitresChapters
5
Date
Juil 2026 Jul 2026
≈ 19 min ●●●○ Résolution de méthodemethod cacheBenchmark
SommaireContents ·
№ 01CadrageFraming3 min

Résoudre un message coûte une remontée de chaîne — que le cache amortit.Resolving a message costs a chain walk — that the cache amortizes.

Le dernier volume boucle sur le premier. Si tout est un message (Vol 1), et qu'envoyer un message, c'est remonter la chaîne d'ancêtres jusqu'au premier qui répond (Vol 5), alors chaque appel a, en théorie, un coût : celui du parcours. Mais Ruby ne paie ce coût qu'une fois. Un cache mémorise, pour chaque site d'appel, la méthode trouvée ; les appels suivants la touchent directement, sans reparcourir la chaîne. C'est pourquoi des hiérarchies profondes ou des mixins multiples ne ruinent pas la performance en régime normal : la résolution est amortie. Le coût existe, mais il est presque toujours payé une seule fois — et le savoir, c'est savoir où il redevient visible.The last volume loops back to the first. If everything is a message (Vol 1), and sending a message means walking the ancestor chain to the first that answers (Vol 5), then each call has, in theory, a cost: the walk. But Ruby pays that cost only once. A cache memorizes, per call site, the method found; subsequent calls hit it directly, without re-walking the chain. That's why deep hierarchies or multiple mixins don't ruin performance in normal operation: resolution is amortized. The cost exists, but it's almost always paid once — and knowing that is knowing where it becomes visible again.

Résolution une fois, cache ensuiteResolution once, cache after
# chaque appel RÉSOUT le message en parcourant la chaîne d'ancêtres (Vol 5)
obj.value
#   → singleton class → classe → modules inclus → superclasse → … → BasicObject
#     jusqu'au premier ancêtre qui définit value

# mais Ruby n'arpente pas la chaîne à CHAQUE appel : il met en cache
obj.value   # 1er appel : résolution complète, puis mise en cache
obj.value   # appels suivants : cache touché, O(1) — la chaîne n'est pas reparcourue

String.instance_method(:upcase).owner   # => String  (résolu une fois, mémorisé)
Le coût, et son amortissementThe cost, and its amortization
1er appel1st call
parcourt la chaînewalks the chain
O(profondeur)O(depth)
→
mise en cachecached
méthode mémoriséemethod memoized
—
→
appels suivantsnext calls
cache touchécache hit
O(1)

La profondeur de la chaîne ne se paie qu'au premier appel d'un site donné. Ensuite, le cache rend la résolution constante. D'où une vérité contre-intuitive : une chaîne d'ancêtres longue coûte rarement en régime établi — sauf si quelque chose invalide le cache.The chain's depth is paid only on a call site's first call. After that, the cache makes resolution constant. Hence a counter-intuitive truth: a long ancestor chain rarely costs in steady state — unless something invalidates the cache.

Le réflexe du volumeThe volume's reflex

« Ai-je mesuré ce coût, ou je l'imagine ? » C'est la règle de tout le Vol 9 : on n'entre dans la performance qu'avec un profil — benchmark, benchmark/ips — jamais avec une intuition. La résolution de méthode est le premier endroit où l'on croit « savoir » que c'est lent, alors que le cache a déjà réglé la question."Have I measured this cost, or am I imagining it?" It's the rule of all of Vol 9: enter performance only with a profile — benchmark, benchmark/ips — never with an intuition. Method resolution is the first place you think you "know" it's slow, when the cache already settled the question.

Le volume du coût mesuré.The volume of measured cost.

Le Vol 9 vient en dernier, pas en premier, et ce n'est pas un hasard. On a passé huit volumes à goûter la liberté de Ruby — messages, mixins, métaprog, blocs paresseux. Chacune de ces libertés a un prix, et ici on le nomme. Mais la discipline est stricte : le prix se mesure, il ne s'imagine pas. La plupart des « optimisations » qu'on dégaine par réflexe corrigent un coût que le cache, le GC ou l'interpréteur avaient déjà absorbé.Vol 9 comes last, not first, and that's no accident. We spent eight volumes savoring Ruby's freedom — messages, mixins, metaprogramming, lazy blocks. Each of these freedoms has a price, and here we name it. But the discipline is strict: the price is measured, not imagined. Most "optimizations" you reach for by reflex fix a cost the cache, the GC or the interpreter had already absorbed.

№ 02La surpriseThe surprise4 min

Le cache rend les appels gratuits — method_missing en sort, et le paie.The cache makes calls free — method_missing opts out, and pays.

Deux choses contournent l'amortissement du cache. La première : method_missing (Vol 6). Par construction, il ne s'exécute qu'après l'échec du parcours complet de la chaîne — Ruby cherche partout, ne trouve rien, puis appelle ton handler. Ce double trajet se rejoue largement à chaque appel, là où une vraie méthode est résolue une fois puis touchée en O(1). La seconde : toute modification de classe à l'exécution — monkey-patch, define_method tardif, extend d'un objet — invalide des entrées du cache, forçant de nouvelles résolutions complètes. Le coût n'est donc pas « la chaîne est longue » ; c'est « je suis sur un chemin que le cache ne couvre pas ». Et la seule façon de le savoir, c'est de mesurer.Two things bypass the cache's amortization. First: method_missing (Vol 6). By construction, it runs only after the full chain walk fails — Ruby searches everywhere, finds nothing, then calls your handler. This double trip largely replays on every call, where a real method is resolved once then hit in O(1). Second: any runtime class change — monkey-patch, late define_method, extending an object — invalidates cache entries, forcing fresh full resolutions. So the cost isn't 'the chain is long'; it's 'I'm on a path the cache doesn't cover'. And the only way to know is to measure.

Chaîne complète parcourue, puis le handlerFull chain walked, then the handler
# method_missing : le chemin LENT, par construction
class Ghost
  def method_missing(name, *args)
    name == :value ? 42 : super
  end
  def respond_to_missing?(n, _ = false) = n == :value || super
end

Ghost.new.value
#   → parcourt TOUTE la chaîne d'ancêtres (ne trouve rien)
#   → PUIS appelle method_missing
# ce double trajet se rejoue à chaque appel : peu « cachable » comme une vraie méthode

method_missing est le chemin lent par nature : il suppose l'échec d'abord. Chaque appel paie le parcours raté plus l'indirection du handler. C'est précisément le coût que le № 1 du Vol 6 nommait « difficile à debug » — ici on ajoute « et mesurablement plus lent ». Pour un proxy appelé rarement, aucune importance ; sur un chemin chaud, ça se voit au profil.method_missing is the slow path by nature: it assumes failure first. Each call pays the failed walk plus the handler's indirection. It's exactly the cost Vol 6's № 1 called "hard to debug" — here we add "and measurably slower". For a rarely-called proxy, it doesn't matter; on a hot path, the profile shows it.

Le coût de résolution n'est visible que hors du cache — method_missing, le monkey-patch tardif. Ailleurs, tu optimises une ombre.Resolution cost is visible only outside the cache — method_missing, late monkey-patching. Elsewhere, you optimize a shadow.
№ 03La mécaniqueThe mechanics5 min

Ce qui invalide le cache — et comment mesurer au lieu de croire.What invalidates the cache — and how to measure instead of believe.

Le cache tient tant que les classes ne bougent pas. Le faire bouger à l'exécution — définir ou retirer une méthode, monkey-patcher, étendre un objet, créer une méthode singleton — invalide des entrées et force de nouvelles résolutions. En régime normal, on charge ses classes au boot puis on n'y touche plus : le cache reste chaud, tout va vite. Le coût réapparaît si on modifie des classes en boucle chaude — un define_method par requête, un patch répété. Et pour savoir si l'un de ces coûts pèse vraiment, un seul outil : la mesure. benchmark/ips compare des itérations par seconde, Benchmark.realtime chronomètre — on tranche sur des nombres, jamais sur une intuition.The cache holds as long as classes don't move. Moving them at runtime — defining or removing a method, monkey-patching, extending an object, creating a singleton method — invalidates entries and forces fresh resolutions. In normal operation, you load classes at boot then leave them alone: the cache stays warm, everything's fast. The cost reappears if you modify classes in a hot loop — a define_method per request, a repeated patch. And to know whether any of these costs truly weighs, one tool: measurement. benchmark/ips compares iterations per second, Benchmark.realtime times — you decide on numbers, never on intuition.

Modifier une classe à l'exécution invalide le cacheChanging a class at runtime invalidates the cache
# ce qui INVALIDE le cache : modifier des classes À L'EXÉCUTION
class String
  def shout = upcase   # un monkey-patch tardif (Vol 6)…
end
# … invalide des caches de résolution : les sites d'appel doivent re-résoudre

# le piège n'est pas le patch lui-même, c'est de le faire EN BOUCLE :
loop do
  obj.define_singleton_method(:tick) { ... }  # redéfinit à chaque tour
  obj.tick                                      # → cache jamais stable
end
Mesurer : benchmark/ips et realtimeMeasure: benchmark/ips and realtime
require "benchmark/ips"

Benchmark.ips do |x|
  x.report("appel direct")     { user.name }
  x.report("via send")         { user.send(:name) }
  x.compare!
end
# on COMPARE des chiffres, on ne tranche pas à l'intuition

require "benchmark"
puts Benchmark.realtime { 1_000_000.times { user.name } }
# le temps réel d'un million d'appels : un nombre, pas une impression
Charger au boot, figer ensuite.Load at boot, freeze after.

La bonne hygiène découle de la mécanique : définis tes classes au démarrage, puis ne les modifie plus. Le cache reste chaud, la résolution est gratuite, les chaînes profondes ne coûtent rien. Ce qui fait mal, c'est la métaprog en boucle chaude — un define_method par requête, un extend par objet traité. Et avant de réécrire quoi que ce soit « pour la perf », lance benchmark/ips : neuf fois sur dix, le coût qu'on imaginait dominant est noyé sous le reste. On optimise ce que le profil montre, pas ce qu'on suppose.Good hygiene follows from the mechanics: define your classes at startup, then stop modifying them. The cache stays warm, resolution is free, deep chains cost nothing. What hurts is metaprogramming in a hot loop — a define_method per request, an extend per processed object. And before rewriting anything "for perf", run benchmark/ips: nine times out of ten, the cost you imagined dominant is drowned by the rest. You optimize what the profile shows, not what you assume.

Le bouclage avec le Vol 1 est complet : « tout est message » a un corollaire de coût — résoudre un message demande un travail, que le cache amortit presque toujours. Comprendre ça, c'est cesser de craindre les mixins (Vol 5) et la métaprog (Vol 6) pour de mauvaises raisons, et ne s'en méfier que là où un profil le justifie. La flexibilité de Ruby n'est pas « lente » ; elle a des coûts localisés, que la mesure révèle un par un.The loop back to Vol 1 is complete: "everything is a message" has a cost corollary — resolving a message takes work, which the cache almost always amortizes. Grasping that is ceasing to fear mixins (Vol 5) and metaprogramming (Vol 6) for the wrong reasons, and distrusting them only where a profile justifies it. Ruby's flexibility isn't "slow"; it has localized costs that measurement reveals one by one.

№ 04Le piègeThe trap4 min

Réécrire « pour la perf » un coût qu'aucun profil n'a montré.Rewriting "for perf" a cost no profile ever showed.

Le contre-exemple obligatoire du numéro, et la faute que le volume entier cherche à désamorcer. On a entendu que method_missing est lent (chapitre 2 : c'est vrai), alors on le remplace par deux cents méthodes générées « pour la perf ». Sauf qu'on n'a rien mesuré. Ces accesseurs sont appelés douze fois par requête, pas douze millions ; le gain par appel se compte en nanosecondes ; et il disparaît dans le bruit d'une requête HTTP qui dure des millisecondes. On a échangé un objet lisible (un method_missing avec son jumeau) contre deux cents méthodes muettes et un boot plus lourd — pour un gain qu'on n'a jamais constaté. Le coût était réel en théorie, négligeable en pratique, et seul un profil pouvait le dire.The issue's mandatory counter-example, and the fault the whole volume aims to defuse. You heard method_missing is slow (chapter 2: true), so you replace it with two hundred generated methods 'for perf'. Except you measured nothing. These accessors are called twelve times per request, not twelve million; the per-call gain is in nanoseconds; and it vanishes in the noise of an HTTP request lasting milliseconds. You traded a readable object (a method_missing with its twin) for two hundred mute methods and a heavier boot — for a gain you never observed. The cost was real in theory, negligible in practice, and only a profile could tell.

Générer par crainte, sans avoir profiléGenerating out of fear, without profiling
# « method_missing est lent » → on génère 200 méthodes, SANS profil
class Settings
  CONFIG.each_key do |key|
    define_method(key) { CONFIG[key] }   # 200 define_method au boot
  end
end

# ce qu'on n'a pas mesuré :
#   · ces accesseurs sont appelés ~12 fois par requête, pas 12 millions
#   · le gain par appel se compte en NANOsecondes → invisible bout à bout
#   · on a alourdi le boot et dispersé la logique en 200 méthodes muettes
# on a « optimisé » un coût qu'aucun profil n'a jamais montré
Mesurer d'abord, décider ensuiteMeasure first, decide after
# D'abord MESURER — l'accès config est-il seulement dans le profil ?
require "benchmark/ips"
Benchmark.ips do |x|
  x.report("method_missing") { settings.timeout }
  x.report("generated")      { settings_gen.timeout }
  x.compare!
end
# si l'écart est réel ET sur un chemin chaud → générer se justifie, mesuré.
# sinon → on GARDE le method_missing lisible (avec respond_to_missing?),
# et on optimise ce que le profil a réellement pointé.
Le contre-exemple obligatoire.The mandatory counter-example.

« method_missing est lent » est vrai par appel et faux au total si l'appel est rare. L'optimisation prématurée prend une vérité de micro-benchmark et la projette sur un code où elle ne pèse rien. La règle du volume : profile, puis optimise ce que le profil pointe — pas ce qu'un article t'a appris à craindre. Si la mesure montre un vrai goulot sur un chemin chaud, génère, en connaissance de cause. Sinon, garde le code lisible : tu as sacrifié la clarté du Vol 6 pour des nanosecondes que personne ne sentira."method_missing is slow" is true per call and false in total if the call is rare. Premature optimization takes a micro-benchmark truth and projects it onto code where it weighs nothing. The volume's rule: profile, then optimize what the profile points to — not what an article taught you to fear. If measurement shows a real bottleneck on a hot path, generate, knowingly. Otherwise, keep the readable code: you sacrificed Vol 6's clarity for nanoseconds no one will feel.

« Lent par appel » n'est pas « lent ». Sans profil, optimiser method_missing c'est payer en lisibilité un gain qu'on n'a jamais vu."Slow per call" isn't "slow". Without a profile, optimizing method_missing is paying in readability for a gain you never saw.
№ 05Bilan & éditoWrap-up & editorial3 min

Le coût de résolution est réel — mais presque toujours déjà amorti.Resolution cost is real — but almost always already amortized.

Chaque message se résout en remontant la chaîne d'ancêtres (Vol 5), mais Ruby met le résultat en cache : la profondeur ne se paie qu'au premier appel d'un site. Le coût ne redevient visible qu'en sortant du cache — method_missing, qui parcourt toute la chaîne avant son handler, et la modification de classes en boucle chaude, qui invalide le cache. Charge tes classes au boot, n'y touche plus, et la résolution est gratuite. Surtout : ne réécris rien « pour la perf » sans profil. La règle du volume commence ici — mesure avant de croire.Every message resolves by walking the ancestor chain (Vol 5), but Ruby caches the result: depth is paid only on a site's first call. The cost becomes visible again only outside the cache — method_missing, which walks the whole chain before its handler, and runtime class modification in a hot loop, which invalidates the cache. Load your classes at boot, leave them alone, and resolution is free. Above all: rewrite nothing 'for perf' without a profile. The volume's rule starts here — measure before believing.

La situationThe situationLe coût réelThe real costLe réflexeThe reflex
Appels répétés, classes figéesRepeated calls, frozen classesQuasi nul : le cache rend la résolution O(1).Near zero: the cache makes resolution O(1).Chaînes profondes, mixins : ne crains rien.Deep chains, mixins: fear nothing.
method_missing sur un chemin chaudmethod_missing on a hot pathChaîne entière + handler, peu cachable.Whole chain + handler, hardly cacheable.Mesure ; génère seulement si le profil le montre.Measure; generate only if the profile shows it.
Modifier des classes en boucleModifying classes in a loopInvalidation du cache → re-résolutions.Cache invalidation → re-resolutions.Définis au boot, fige ensuite.Define at boot, freeze after.
« je crois que c'est lent »'I think it's slow'Inconnu tant qu'on n'a pas mesuré.Unknown until measured.benchmark/ips avant toute réécriture.benchmark/ips before any rewrite.
« Tout est message » a un prix — que le cache paie pour toi. Ne le réclame pas une seconde fois sans l'avoir mesuré."Everything is a message" has a price — that the cache pays for you. Don't claim it twice without having measured.
Mot de l'éditeurFrom the editor

Ouverture du Vol 9 : la boucle se referme sur le Vol 1.Opening Vol 9: the loop closes on Vol 1.

Ce numéro n'est pas un hasard en tête du dernier volume : il referme la série sur sa première phrase. « Tout est message » a tenu huit volumes durant comme une promesse de souplesse ; ici on en lit le revers comptable — résoudre un message demande un travail. Mais la chute n'est pas « donc Ruby est lent ». C'est l'inverse : ce travail est presque toujours amorti par le cache, et le coût ne reparaît qu'à des endroits précis, nommables, mesurables. Le dernier volume n'apprend pas à se méfier du dynamisme ; il apprend à le mesurer — pour cesser de l'imaginer coûteux là où il ne l'est pas, et le payer en connaissance de cause là où il l'est.This issue isn't accidentally at the head of the last volume: it closes the series on its first sentence. 'Everything is a message' held for eight volumes as a promise of flexibility; here we read its accounting flip side — resolving a message takes work. But the punchline isn't 'so Ruby is slow'. It's the opposite: that work is almost always amortized by the cache, and the cost reappears only at precise, nameable, measurable places. The final volume doesn't teach you to distrust dynamism; it teaches you to measure it — to stop imagining it costly where it isn't, and to pay it knowingly where it is.

Prochain numéro : les allocations cachées & le GC. La résolution n'est qu'un coût ; l'autre, plus insidieux, est la mémoire. Chaque littéral — une chaîne, un tableau, un hash — alloue un objet, et le ramasse-miettes paie pour les libérer. On verra le réflexe des frozen string literals, l'allocation dans une boucle chaude, et les outils qui la révèlent : GC.stat, ObjectSpace, memory_profiler. Toujours la même règle : on ne devine pas une allocation, on la compte.Next issue: hidden allocations & the GC. Resolution is only one cost; the other, more insidious, is memory. Every literal — a string, an array, a hash — allocates an object, and the garbage collector pays to free them. We'll see the frozen string literals reflex, allocation in a hot loop, and the tools that reveal it: GC.stat, ObjectSpace, memory_profiler. Always the same rule: you don't guess an allocation, you count it.