Vol. 2 · № 03 · Un atelier RubyA Ruby workshop

Closures & la capture du contexte Closures & capturing the context

Un bloc n'emporte pas que du code : il emporte les variables qui l'entouraient à sa naissance — la variable vivante, pas une copie figée. C'est ce qui fait les compteurs et les caches, et ce qui piège quand plusieurs closures partagent la même variable de boucle. A block carries more than code: it carries the variables that surrounded it at birth — the live variable, not a frozen copy. That's what makes counters and caches, and what traps you when several closures share one loop variable.

AudienceAudience
Dev à l'aise avec proc/lambda, prêt à voir ce qu'ils capturent Dev comfortable with proc/lambda, ready to see what they capture
Format
Self-paced
ChapitresChapters
5
Date
Jan 2026 Jan 2026
≈ 18 min ●●○○ BlocClosureTradeoff
SommaireContents ·
№ 01CadrageFraming3 min

Le bloc n'emporte pas que du code. Il emporte l'endroit où il est né.A block carries more than code. It carries the place it was born.

On a capturé un bloc dans un objet (proc, lambda). Voici ce qu'on n'avait pas dit : cet objet n'emporte pas que les instructions — il emporte les variables locales qui l'entouraient à sa naissance. La lambda renvoyée par make_counter continue de voir count, et de le modifier, longtemps après que make_counter a rendu la main. C'est une closure : un bloc qui se referme sur son environnement.We captured a block in an object (proc, lambda). Here's what we hadn't said: that object carries more than the instructions — it carries the local variables that surrounded it at birth. The lambda returned by make_counter keeps seeing count, and mutating it, long after make_counter has returned. That's a closure: a block that closes over its environment.

Une variable qui survit à sa méthodeA variable that outlives its method
def make_counter
  count = 0                 # une variable locale à make_counter
  -> { count += 1 }         # la lambda VOIT count, et le garde
end

c = make_counter            # make_counter a fini, count "devrait" disparaître…
c.call                      # => 1
c.call                      # => 2   — count survit, et se souvient
c.call                      # => 3

d = make_counter            # un AUTRE compteur, son propre count
d.call                      # => 1   — indépendant de c
Ce que la lambda emporteWhat the lambda carries
le codethe code
count += 1
+
l'environnementthe environment
count → 0, 1, 2…
=
une closurea closure
c

Deux appels à make_counter donnent deux count séparés. Chaque closure emporte son propre environnement — pas une copie : les variables elles-mêmes, vivantes, pas figées.Two calls to make_counter give two separate counts. Each closure carries its own environment — not a copy: the variables themselves, alive, not frozen.

Le réflexe du volumeThe volume's reflex

« Ce comportement, je le passe en bloc — et il emporte avec lui les variables qu'il voit. » Quand tu confies un bloc, demande-toi : de quoi se souvient-il ?. C'est ce qui le rend puissant (un compteur, un cache, une config capturée) et dangereux (une variable partagée, un gros objet retenu en mémoire)."This behavior, I pass it as a block — and it carries with it the variables it sees." When you hand over a block, ask: what does it remember?. That's what makes it powerful (a counter, a cache, a captured config) and dangerous (a shared variable, a big object retained in memory).

Pourquoi ça clôt le volume des blocs.Why this closes the blocks volume.

On a vu le bloc confié (№ 1) puis capturé en objet (№ 2). Reste sa vraie nature : un bloc n'est pas un bout de code isolé, c'est du code + son contexte. Comprendre la capture, c'est expliquer d'un coup la puissance des itérateurs, des callbacks et des DSL — et la classe de bugs la plus déroutante de Ruby.We've seen the block handed over (№ 1) then captured as an object (№ 2). What remains is its true nature: a block isn't an isolated piece of code, it's code + its context. Understanding capture explains, at once, the power of iterators, callbacks and DSLs — and Ruby's most baffling class of bugs.

№ 02La surpriseThe surprise4 min

La closure ne photographie pas une valeur. Elle tient la variable.A closure doesn't snapshot a value. It holds the variable.

L'intuition trompeuse : on imagine que le bloc « capture » la valeur de x au moment où il est créé, comme une photo. Faux. Il capture la variable elle-même — la case mémoire, pas son contenu. Si x change ensuite, le bloc voit la nouvelle valeur ; et s'il assigne x, c'est le x extérieur qu'il modifie. C'est exactement ce qui rend make_counter possible — et ce qui piège dans une boucle.The misleading intuition: you imagine the block 'captures' the value of x at creation time, like a photo. Wrong. It captures the variable itself — the memory slot, not its contents. If x changes afterward, the block sees the new value; and if it assigns x, it mutates the outer x. That's exactly what makes make_counter possible — and what traps you in a loop.

L'intuition « par valeur » — une photoThe 'by value' intuition — a photo
# Ce qu'on CROIT : la closure photographie la valeur à sa création
x = 1
f = -> { x }
x = 2
f.call
# attendu (à tort) : 1  — "la lambda a capturé x quand x valait 1"

On suppose que f a figé x = 1 à sa création, et qu'un changement ultérieur de x ne le concerne plus. C'est le modèle mental qu'on apporte d'ailleurs — et il est faux en Ruby.You assume f froze x = 1 at creation, and that a later change to x no longer concerns it. That's the mental model you bring from elsewhere — and it's wrong in Ruby.

Une closure ne dit pas « je me souviens que x valait 1 ». Elle dit « je sais où vit x » — et elle ira le relire.A closure doesn't say "I remember x was 1." It says "I know where x lives" — and it'll go re-read it.
№ 03La mécaniqueThe mechanics5 min

On mute ce qui existait. On garde dedans ce qu'on crée.You mutate what existed. You keep inside what you create.

La règle de portée d'un bloc tient en une phrase : il voit les variables locales qui l'entourent, donc il peut les lire et les modifier ; mais une variable créée à l'intérieur du bloc reste locale au bloc et ne fuit pas dehors. Les paramètres du bloc sont eux aussi locaux. Et si tu veux protéger une variable extérieure d'une écriture accidentelle, le ; la déclare explicitement locale.A block's scope rule fits in one sentence: it sees the local variables around it, so it can read and mutate them; but a variable created inside the block stays block-local and doesn't leak out. The block's parameters are local too. And if you want to protect an outer variable from accidental writes, the ; declares it explicitly local.

Lire/muter dehors, créer dedansRead/mutate outside, create inside
total = 0
[1, 2, 3].each do |n|       # n : paramètre, local au bloc
  total += n                # total existe DEHORS → on le mute
  doubled = n * 2           # doubled est NOUVELLE → locale au bloc
end

total      # => 6        — la mutation a traversé
doubled    # NameError   — doubled n'a jamais existé hors du bloc
# règle : on lit/mute ce qui existe déjà ; ce qu'on crée reste dedans
Protéger une variable : |n; x|Protect a variable: |n; x|
x = "dehors"
[1, 2].each { |n; x| x = n }   # le ; x déclare x LOCAL au bloc
x          # => "dehors"       — l'extérieur est protégé

# sans le ; x, le bloc aurait écrasé le x extérieur :
[1, 2].each { |n| x = n }
x          # => 2              — clobbered
Le binding, l'environnement réifié.The binding, the environment made real.

Ce que la closure tient implicitement, Ruby sait le rendre explicite : binding renvoie un objet Binding qui est l'environnement capturé — on peut y relire les variables, y évaluer du code. Une closure, c'est du code attaché à un binding.What a closure holds implicitly, Ruby can make explicit: binding returns a Binding object that is the captured environment — you can re-read its variables, eval code in it. A closure is code attached to a binding.

Un binding qu'on manipule comme un objetA binding you handle as an object
def snapshot
  secret = 42
  binding              # un objet Binding : l'environnement capturé, réifié
end

b = snapshot
b.local_variable_get(:secret)   # => 42  — on relit la variable à distance
b.eval("secret + 1")            # => 43  — on exécute DANS ce contexte
# c'est ce que toute closure tient implicitement : un binding

Le Binding renvoyé par snapshot garde secret en vie après le retour de la méthode — exactement comme make_counter gardait count. La closure n'a rien de magique : elle tient une référence vers un environnement, et tant qu'elle vit, cet environnement vit aussi. D'où la puissance — et la rétention mémoire qu'on verra au piège.The Binding returned by snapshot keeps secret alive after the method returns — exactly as make_counter kept count. A closure isn't magic: it holds a reference to an environment, and as long as it lives, that environment lives too. Hence the power — and the memory retention we'll see in the trap.

№ 04Le piègeThe trap4 min

Trois closures, une seule variable — et le résultat te surprend.Three closures, one variable — and the result surprises you.

Tout ce qui précède était un cadeau : la closure capture la variable vivante. Voici la facture. Si tu fabriques plusieurs closures dans une boucle while, elles capturent toutes la même variable de boucle — et à l'appel, elles lisent sa valeur finale, identique pour toutes. Le bug classique des closures, et il vient directement du « par référence » du № 2.Everything above was a gift: the closure captures the live variable. Here's the bill. If you build several closures in a while loop, they all capture the same loop variable — and at call time, they read its final value, identical for all. The classic closure bug, straight from the 'by reference' of № 2.

Le while qui fait tout pointer le même iThe while that makes everything point at one i
# Le réflexe : construire des closures dans une boucle while
handlers = []
i = 0
while i < 3
  handlers << -> { i }     # chaque lambda capture LE MÊME i
  i += 1
end

handlers.map(&:call)       # => [3, 3, 3]   (et non [0, 1, 2] !)
# il n'y a qu'un seul i ; à l'appel, il vaut 3. Les trois closures
# pointent la même variable — celle que la boucle a fait grimper.
Une variable fraîche par tourA fresh variable per iteration
# Le correctif : un itérateur à bloc donne un i FRAIS par tour
handlers = []
3.times { |i| handlers << -> { i } }   # i est un paramètre de bloc :
                                        # une nouvelle variable à chaque tour
handlers.map(&:call)       # => [0, 1, 2]   — chacune sa propre capture

# et si tu tiens à ton while ? repasse quand même par un paramètre de bloc.
# une locale posée DANS le while ne sauverait rien : while n'ouvre aucune
# portée, ce serait encore UNE variable pour trois closures.
handlers = []
i = 0
while i < 3
  i.then { |snapshot| handlers << -> { snapshot } }   # snapshot est un
  i += 1                                             # paramètre de bloc :
end                                                  # neuf à chaque tour
handlers.map(&:call)       # => [0, 1, 2]
Le contre-exemple obligatoire.The mandatory counter-example.

Capturer une variable de boucle partagée « comme si » chaque tour avait la sienne, c'est le piège que 3.times { |i| … } évite gratuitement : son paramètre i est une nouvelle variable à chaque appel du bloc. Et l'autre face du même couteau : une closure garde tout son environnement en vie — capturer incidemment un gros objet le retient en mémoire bien après qu'on croyait l'avoir lâché. Demande-toi toujours : cette closure, sur quelle variable se referme-t-elle, et combien de temps la garde-t-elle ?Capturing a shared loop variable "as if" each iteration had its own is the trap that 3.times { |i| … } avoids for free: its parameter i is a fresh variable on each block call. And the other edge of the same knife: a closure keeps its whole environment alive — incidentally capturing a big object retains it in memory long after you thought you'd let it go. Always ask: this closure, which variable does it close over, and for how long does it hold it?

« Par référence » est un cadeau tant qu'une variable = une intention. Le jour où plusieurs closures se partagent la même, c'est un bug."By reference" is a gift as long as one variable = one intent. The day several closures share one, it's a bug.
№ 05Bilan & éditoWrap-up & editorial2 min

Du code plus l'endroit où il est né.Code plus the place it was born.

Un bloc n'est pas un fragment de code isolé : c'est du code attaché à son environnement. Il capture les variables qui l'entourent — la variable elle-même, vivante, pas une copie figée. C'est ce qui fait les compteurs, les caches, les callbacks et les DSL ; c'est aussi ce qui piège quand plusieurs closures partagent une variable, ou qu'une closure retient un objet trop longtemps.A block isn't an isolated code fragment: it's code attached to its environment. It captures the surrounding variables — the variable itself, alive, not a frozen copy. That's what makes counters, caches, callbacks and DSLs; it's also what traps you when several closures share a variable, or a closure holds an object too long.

Le faitThe factCe que ça signifieWhat it meansLe réflexeThe reflex
Capture par référenceCapture by referenceLa closure tient la variable, pas sa valeur — elle voit les changements ultérieurs.The closure holds the variable, not its value — it sees later changes.Ne suppose jamais une « photo » : demande ce que la variable vaudra à l'appel.Never assume a 'snapshot': ask what the variable will hold at call time.
Portée du blocBlock scopeOn lit/mute l'extérieur ; ce qu'on crée dedans reste dedans.You read/mutate the outside; what you create inside stays inside.Protège une variable d'une écriture accidentelle avec |n; x|.Protect a variable from accidental writes with |n; x|.
Variable partagéeShared variableDes closures créées dans un while partagent la variable de boucle.Closures built in a while share the loop variable.Fabrique-les via un itérateur à bloc (i frais) ou fige une copie par tour.Build them via a block iterator (fresh i) or freeze a copy per pass.
Rétention mémoireMemory retentionTant que la closure vit, tout son environnement vit aussi.As long as the closure lives, its whole environment lives too.Ne capture pas incidemment un gros objet dont tu n'as plus besoin.Don't incidentally capture a big object you no longer need.
La puissance et le piège des closures sont la même propriété : elles se souviennent. À toi de savoir de quoi.The power and the trap of closures are the same property: they remember. It's on you to know what.
Mot de l'éditeurFrom the editor

Fin du Vol 2 : le bloc, jusqu'à sa mémoire.End of Vol 2: the block, down to its memory.

Trois numéros pour épuiser une seule construction. On a d'abord confié un bloc à une méthode qui le rappelle (№ 1) ; on l'a capturé dans un objet, fonction stricte ou fragment souple (№ 2) ; et on a vu qu'il n'emporte jamais que du code — il emporte aussi son contexte (№ 3). La leçon du volume tient dans le réflexe de départ : un bloc, c'est un comportement qu'on délègue, et déléguer un comportement, c'est déléguer avec lui tout ce qu'il voit. La puissance de Ruby — itérateurs, callbacks, DSL — vient de là, et ses bugs les plus déroutants aussi.Three issues to exhaust a single construct. First we handed a block to a method that calls it back (№ 1); we captured it in an object, strict function or loose fragment (№ 2); and we saw it never carries only code — it carries its context too (№ 3). The volume's lesson sits in the opening reflex: a block is a behavior you delegate, and delegating a behavior delegates everything it sees along with it. Ruby's power — iterators, callbacks, DSLs — comes from that, and so do its most baffling bugs.

Prochain volume : le duck typing. On a passé deux volumes à envoyer des messages et à confier des blocs sans jamais demander le type de quoi que ce soit. C'était délibéré. Le Vol 3 nomme ce principe — « ne demande pas qui tu es, je demande si tu réponds » — et en montre la force, le coût, et la frontière où il faut quand même se méfier.Next volume: duck typing. We've spent two volumes sending messages and handing over blocks without ever asking the type of anything. That was deliberate. Vol 3 names the principle — 'don't ask what you are, I ask whether you respond' — and shows its strength, its cost, and the boundary where you must still be wary.