Vol. 3 · № 02 · Un atelier RubyA Ruby workshop

Les protocoles implicites Implicit protocols

Le duck typing dit « réponds au message » — mais lesquels ? Ruby en attend certains sans jamais les déclarer : to_s, <=>, each, hash. Fournis le bon hook et inclus le mixin, et tu obtiens des familles entières de méthodes. Une « interface » que personne n'a écrite, et un marché : un hook contre une famille. Duck typing says 'answer the message' — but which? Ruby expects some without ever declaring them: to_s, <=>, each, hash. Provide the right hook, include the mixin, and you get whole method families. An 'interface' nobody wrote, and a bargain: one hook for a family.

AudienceAudience
Dev à l'aise avec le duck typing, prêt à brancher ses objets sur la std Dev comfortable with duck typing, ready to wire objects into the stdlib
Format
Self-paced
ChapitresChapters
5
Date
Fév 2026 Feb 2026
≈ 18 min ●●○○ ProtocoleComparableEnumerable
SommaireContents ·
№ 01CadrageFraming3 min

Une seule méthode, et tout le langage te reconnaît.One method, and the whole language recognizes you.

Le duck typing disait : réponds au message. Mais à quels messages ? Ruby en attend certains sans jamais te les déclarer — ce sont ses protocoles implicites. Définis to_s sur ton objet, et d'un coup puts l'affiche, l'interpolation l'insère, print l'accepte. Tu n'as branché ton objet sur rien : tous ces endroits appellent to_s par convention, et ta classe vient de rejoindre une « interface » que personne n'a écrite.Duck typing said: answer the message. But which messages? Ruby expects certain ones without ever declaring them to you — its implicit protocols. Define to_s on your object, and suddenly puts prints it, interpolation inserts it, print accepts it. You wired your object into nothing: all those places call to_s by convention, and your class just joined an 'interface' nobody wrote.

Un seul to_s, branché partout sans effortA single to_s, wired everywhere effortlessly
class Temperature
  def initialize(celsius) = @c = celsius
  def to_s = "#{@c} °C"        # je définis UN seul message : to_s
end

t = Temperature.new(21)

puts t                        # 21 °C   — puts a appelé t.to_s
"il fait #{t}"                # "il fait 21 °C"   — l'interpolation aussi
print t, "\n"                # 21 °C   — print aussi
String(t)                     # "21 °C"
# je n'ai jamais "branché" t sur puts : ils appellent to_s par convention
Un hook, plusieurs points d'appelOne hook, many call sites
def to_s
le hookthe hook
→
puts"#{…}"printString()

Tu écris un message ; le langage en a quatre qui l'appellent. Le protocole to_s n'est écrit nulle part — c'est une promesse tenue par convention.You write one message; the language has four that call it. The to_s protocol is written nowhere — it's a promise kept by convention.

Le réflexe du volumeThe volume's reflex

« Réponds au message » — mais sache lesquels Ruby attend. Avant d'inventer ta propre méthode, demande-toi : existe-t-il un protocole implicite que ma classe pourrait remplir ?. Le bon hook (to_s, each, <=>) branche ton objet sur des dizaines de points d'appel gratuitement."Answer the message" — but know which Ruby expects. Before inventing your own method, ask: is there an implicit protocol my class could fulfill?. The right hook (to_s, each, <=>) wires your object into dozens of call sites for free.

La suite directe du duck typing.The direct sequel to duck typing.

Le № 1 a dit : on code contre un protocole, pas une classe. Restait la question : quels protocoles ? Ce numéro donne le catalogue tacite — les messages que la bibliothèque standard appelle sur tes objets sans te prévenir. Les connaître, c'est cesser de réinventer ce que Ruby t'offrait déjà.Issue № 1 said: code against a protocol, not a class. The question remained: which protocols? This issue gives the tacit catalog — the messages the standard library calls on your objects without warning. Knowing them means you stop reinventing what Ruby already offered.

№ 02La surpriseThe surprise4 min

Tu écris une méthode. Tu en récoltes une famille entière.You write one method. You reap a whole family.

La vraie force des protocoles implicites n'est pas l'élégance, c'est le levier. Sans eux, chaque opération de comparaison se réécrit à la main : older_than?, sort_by, min_by, et autant de variantes. Fournis le seul hook <=> et inclus Comparable, et tu obtiens d'un coup <, >, ==, between?, clamp — et sort, min, max sur des collections des tiens. Un message écrit, une famille débloquée.The real power of implicit protocols isn't elegance, it's leverage. Without them, every comparison is hand-rewritten: older_than?, sort_by, min_by, and as many variants. Provide the single hook <=> and include Comparable, and you instantly get <, >, ==, between?, clamp — plus sort, min, max on collections of yours. One message written, a family unlocked.

Sans protocole — chaque opération réinventéeWithout the protocol — every operation reinvented
# Sans protocole : chaque comparaison, à la main, encore et encore
class Version
  attr_reader :parts
  def initialize(str) = @parts = str.split(".").map(&:to_i)

  def older_than?(o) = (parts <=> o.parts) < 0
  def newer_than?(o) = (parts <=> o.parts) > 0
  def same_as?(o)    = parts == o.parts
end

a, b = Version.new("1.2.0"), Version.new("1.10.0")
a.older_than?(b)               # => true
[b, a].sort_by(&:parts)        # tri : encore une méthode ad hoc
[b, a].min_by(&:parts)         # min : et une autre…

Trois méthodes de comparaison maison, et toujours un sort_by / min_by ad hoc à traîner. Ça marche, mais tu réécris ce que Ruby savait déjà faire — et tes objets ne se comparent pas « naturellement » avec < ou sort.Three home-grown comparison methods, and always an ad hoc sort_by / min_by to carry around. It works, but you rewrite what Ruby already knew how to do — and your objects don't compare "naturally" with < or sort.

Un protocole implicite, c'est un marché : un seul hook contre une famille de méthodes. Ruby tient sa part dès que tu tiens la tienne.An implicit protocol is a bargain: a single hook for a family of methods. Ruby keeps its end the moment you keep yours.
№ 03La mécaniqueThe mechanics5 min

Le catalogue tacite : quel hook débloque quelle famille.The tacit catalog: which hook unlocks which family.

Ces protocoles ne sont écrits dans aucune interface — ils vivent dans le code de la bibliothèque standard, qui appelle un message convenu sur ce que tu lui donnes. En voici les plus rentables. Et une distinction de plus à connaître : Ruby a des conversions explicites (to_s, to_a, to_i — appelées quand tu le demandes) et des conversions implicites (to_str, to_ary, to_int — que le langage appelle tout seul dans certains contextes).These protocols are written in no interface — they live in the standard library's code, which calls an agreed message on whatever you hand it. Here are the most rewarding. And one more distinction to know: Ruby has explicit conversions (to_s, to_a, to_i — called when you ask) and implicit conversions (to_str, to_ary, to_int — which the language calls on its own in certain contexts).

Les protocoles implicites les plus utilesThe most useful implicit protocols
to_s→puts, interpolation, print, String()puts, interpolation, print, String()
<=> (+ Comparable)→<, >, ==, sort, min, max, clamp, between?<, >, ==, sort, min, max, clamp, between?
each (+ Enumerable)→map, select, reduce, to_a, include?, sort…map, select, reduce, to_a, include?, sort…
to_proc→la conversion &objet en blocthe &object → block conversion
hash + eql?→clé de Hash, appartenance à un SetHash key, Set membership
coerce→2 * mon_objet (nombre à gauche)2 * my_object (number on the left)
each → tout Enumerableeach → all of Enumerable
class Bag
  include Enumerable           # s'appuie sur each
  def initialize(*xs) = @xs = xs
  def each(&block) = @xs.each(&block)   # l'UNIQUE méthode à fournir
end

bag = Bag.new(3, 1, 2)
bag.map { |x| x * 10 }         # => [30, 10, 20]
bag.select(&:odd?)             # => [3, 1]
bag.sort                       # => [1, 2, 3]
bag.min                        # => 1
bag.include?(2)                # => true
# un seul each → des dizaines de méthodes de collection
coerce : le nombre à gauche de ton objetcoerce: the number to the left of your object
class Money
  attr_reader :cents
  def initialize(c) = @cents = c
  def *(n) = Money.new(@cents * n)

  def coerce(other)            # le protocole pour "nombre OP mon_objet"
    [self, other]              # * commute : Ruby évaluera money * other
  end
end

money = Money.new(500)
money * 3        # OK : Money#* est appelé
3 * money        # sans coerce → TypeError. Avec : coerce(3) → money * 3
Explicite vs implicite : qui appelle ?Explicit vs implicit: who calls?

to_s/to_a/to_i ne sont appelés que si tu le demandes. to_str/to_ary/to_int sont appelés par le langage lui-même : "x" + obj tente obj.to_str, l'affectation multiple a, b = obj tente to_ary. N'implémente les implicites que si ton objet est vraiment « une sorte de » chaîne ou de tableau.to_s/to_a/to_i are called only if you ask. to_str/to_ary/to_int are called by the language itself: "x" + obj tries obj.to_str, multiple assignment a, b = obj tries to_ary. Implement the implicit ones only if your object genuinely is "a kind of" string or array.

№ 04Le piègeThe trap4 min

Implémenter la moitié d'un protocole, c'est le casser en silence.Implementing half a protocol is breaking it silently.

Les protocoles implicites donnent beaucoup pour un seul hook — mais certains en exigent plusieurs, cohérents entre eux. L'égalité en est l'exemple piège : == n'est que la moitié visible. uniq, Set et les clés de Hash, eux, s'appuient sur hash et eql?. Définis == sans les deux autres, et tes objets « égaux » ne se dédupliquent pas, ne se retrouvent pas comme clés — sans la moindre erreur pour t'avertir.Implicit protocols give a lot for a single hook — but some demand several, consistent with each other. Equality is the trap case: == is only the visible half. uniq, Set and Hash keys rely on hash and eql?. Define == without the other two, and your 'equal' objects don't dedupe, don't match as keys — with no error to warn you.

== seul : l'égalité qui ne déduplique pas== alone: equality that doesn't dedupe
class Point
  attr_reader :x, :y
  def initialize(x, y) = (@x, @y = x, y)
  def ==(other)                       # j'implémente l'égalité… la moitié visible
    other.is_a?(Point) && x == other.x && y == other.y
  end
end

a, b = Point.new(1, 2), Point.new(1, 2)
a == b              # => true   — youpi, l'égalité marche

[a, b].uniq.size    # => 2 !    uniq s'appuie sur hash + eql?, PAS sur ==
Set[a, b].size      # => 2 !    un Set aussi
{ a => "ici" }[b]   # => nil    b ne retrouve pas la clé a
# le protocole d'égalité a DEUX moitiés ; tu n'en as écrit qu'une
Le protocole en entier — ou délégué à DataThe full protocol — or delegated to Data
# Correctif 1 : implémenter le protocole EN ENTIER, de façon cohérente
class Point
  attr_reader :x, :y
  def initialize(x, y) = (@x, @y = x, y)
  def eql?(other) = other.is_a?(Point) && x == other.x && y == other.y
  alias == eql?
  def hash = [x, y].hash             # deux objets égaux DOIVENT avoir le même hash
end

# Correctif 2 (mieux) : laisser Ruby le faire pour toi
Point = Data.define(:x, :y)          # ==, eql?, hash fournis et cohérents
Set[Point.new(1, 2), Point.new(1, 2)].size   # => 1
Le contre-exemple obligatoire.The mandatory counter-example.

Définir == et s'arrêter là, c'est remplir un protocole à moitié : la règle tacite « deux objets égaux ont le même hash » est violée, et Set/Hash/uniq se trompent sans broncher. Soit tu implémentes eql? + hash de façon cohérente, soit — bien mieux — tu laisses Data.define ou Struct le faire. La leçon : un protocole se remplit en entier, sinon il se retourne contre toi en silence.Defining == and stopping there fills a protocol halfway: the tacit rule "equal objects share a hash" is violated, and Set/Hash/uniq get it wrong without a peep. Either implement eql? + hash consistently, or — far better — let Data.define or Struct do it. The lesson: a protocol is fulfilled entirely, or it turns on you silently.

Un protocole implicite ne te prévient pas quand tu l'as à moitié rempli. Il te rend juste un résultat faux, plus tard, ailleurs.An implicit protocol doesn't warn you when you've half-filled it. It just hands you a wrong answer, later, elsewhere.
№ 05Bilan & éditoWrap-up & editorial2 min

Les « interfaces » de Ruby ne se déclarent pas. Elles se remplissent.Ruby's "interfaces" aren't declared. They're fulfilled.

Le duck typing demandait « réponds au message » ; ce numéro a nommé les messages que Ruby attend sans les déclarer. Fournir le bon hook — to_s, <=>, each — branche ton objet sur des familles entières de méthodes et sur la bibliothèque standard. Le contrat est tacite, le levier énorme, et la seule règle ferme : un protocole se remplit en entier.Duck typing said 'answer the message'; this issue named the messages Ruby expects without declaring them. Providing the right hook — to_s, <=>, each — wires your object into whole method families and the standard library. The contract is tacit, the leverage huge, and the one firm rule: a protocol is fulfilled in full.

Tu fournisYou provideRuby te donneRuby gives youLe réflexeThe reflex
to_sto_sputs, interpolation, print, String() — sans rien brancher.puts, interpolation, print, String() — wiring nothing.Cherche le hook conventionnel avant d'inventer ta méthode.Look for the conventional hook before inventing your method.
<=> + Comparable<=> + Comparable<, >, ==, between?, clamp, sort, min, max.<, >, ==, between?, clamp, sort, min, max.Un seul hook, une famille : c'est le marché du protocole.One hook, a family: that's the protocol's bargain.
each + Enumerableeach + Enumerablemap, select, reduce, to_a, include?, sort…map, select, reduce, to_a, include?, sort…Ta collection « est » itérable dès qu'elle sait each.Your collection 'is' iterable the moment it knows each.
hash + eql? (+ ==)hash + eql? (+ ==)Clés de Hash, dédup par Set/uniq qui marchent vraiment.Hash keys, Set/uniq dedup that actually works.Remplis le protocole en entier — ou délègue à Data/Struct.Fulfill the protocol in full — or delegate to Data/Struct.
Tu ne déclares pas « je suis Comparable ». Tu dis « voici comment je me compare » — et Ruby en déduit tout le reste.You don't declare "I am Comparable." You say "here's how I compare" — and Ruby derives all the rest.
Mot de l'éditeurFrom the editor

La convention est l'interface.The convention is the interface.

Un langage à types écrit ses contrats noir sur blanc : implements Comparable, et le compilateur vérifie. Ruby fait le pari de la convention : le contrat n'est nulle part déclaré, il vit dans le fait que la bibliothèque appelle to_s, <=>, each sur ce que tu lui donnes. C'est d'une souplesse rare — ta classe rejoint une « interface » sans la nommer — mais ça suppose de connaître les conventions, et de les honorer jusqu'au bout. Le coût d'un contrat tacite, c'est qu'il ne te rappelle pas à l'ordre : un protocole à moitié rempli ne lève pas d'erreur, il rend un résultat faux. La discipline, ici, remplace le compilateur.A typed language writes its contracts in black and white: implements Comparable, and the compiler checks. Ruby bets on convention: the contract is declared nowhere, it lives in the fact that the library calls to_s, <=>, each on what you hand it. That's rare flexibility — your class joins an 'interface' without naming it — but it assumes you know the conventions, and honor them all the way. The cost of a tacit contract is that it won't call you to order: a half-filled protocol raises no error, it returns a wrong answer. Discipline, here, replaces the compiler.

Prochain numéro : quand le canard trahit. Le duck typing et ses protocoles repoussent l'erreur à l'exécution — le NoMethodError au pire moment. On verra où regagner une garantie sans trahir l'esprit du langage : valider à la frontière au runtime (dry-struct), ou typer statiquement ce qui reste implicite (Steep, RBS). Une frontière, deux façons de la tenir.Next issue: when the duck betrays you. Duck typing and its protocols push the error to runtime — the NoMethodError at the worst moment. We'll see where to regain a guarantee without betraying the language's spirit: validate at the boundary at runtime (dry-struct), or statically type what stays implicit (Steep, RBS). One boundary, two ways to hold it.