In Ruby 4.0, what happens to ancestors when a module is included twice, re-included in a subclass, or prepended after already being included?
answer
- one entry per chain, mostly
- second include is a no-op
- no move to the front
- superclass already has it: skipped
- prepend after include: twice, since 3.1
basics
~20 sIncluding a module already in the chain is a no-op: it is neither added again nor moved to the front, even when a superclass included it. Since Ruby 3.1, prepending a module the class already includes adds a second entry in front of the class.
solid answer
~40 s`include` first checks whether the module is already somewhere in the class's chain, including the superclasses' part. If it is, nothing happens: `include ConsoleLog; include FileLog; include ConsoleLog` leaves `[Job, FileLog, ConsoleLog, ...]`, so re-including does **not** make `ConsoleLog` win again, and `class ImportJob < Job; include ConsoleLog` adds nothing when `Job` already includes it. `prepend` of the same module twice is also a no-op. The exception is `prepend` of a module the class already **includes**: since Ruby 3.1 it is inserted in front of the class as well, so the module appears twice, `[ConsoleLog, Job, ConsoleLog, ...]`, and a `super` from the first copy can run the second copy's method.
code
ruby · 16 linesmodule ConsoleLog
def log(msg) = (defined?(super) ? super : []) + ["console: #{msg}"]
end
module FileLog
def log(msg) = ["file: #{msg}"]
end
class Job
include FileLog
include ConsoleLog
prepend ConsoleLog
end
Job.ancestors.take(4) # => [ConsoleLog, Job, ConsoleLog, FileLog]
Job.ancestors.count(ConsoleLog) # => 2
Job.new.log("row 7") # => ["file: row 7", "console: row 7", "console: row 7"]go deeper
Remember that including the same module again does nothing, so it cannot be used to change which method wins.
Explain that include checks the whole chain including superclasses, show the ancestors after a repeated include, and name the prepend-after-include exception.
Diagnose a method that runs twice or a module that will not take priority by reading ancestors, and relate it to load order and the Ruby 3.1 prepend change.
Set rules for how shared modules are attached, for example one place per module and prepend only for wrappers, so the chain does not depend on which file happens to load first.
## Ruby keeps one copy for include When you call `include`, Ruby walks the class's current chain, including its superclasses, looking for the module. If the module is already there, it skips it. Three consequences surprise people: 1. **Re-including does not move a module forward.** The "last included, first searched" rule applies only to modules not already in the chain. 2. **A subclass cannot re-include a module its superclass includes** to get it in front of the superclass's methods; the module is found in the superclass's part of the chain and skipped. 3. **No error or warning** is raised; the call simply returns the class. ```ruby class Job include ConsoleLog include FileLog include ConsoleLog # already in the chain: skipped end Job.ancestors.take(3) # => [Job, FileLog, ConsoleLog] class ImportJob < Job include ConsoleLog # already in Job's part: skipped end ImportJob.ancestors.take(4) # => [ImportJob, Job, FileLog, ConsoleLog] ``` If the intent was "make `ConsoleLog#log` win in `ImportJob`", re-including cannot do it. The options are to define `ImportJob#log` and delegate explicitly, or to prepend. ## prepend after include: two entries `prepend` places a module in front of the class. Its duplicate check only looks at the modules already prepended to that class, not at included ones. Since Ruby 3.1 this is documented behaviour: `Module#prepend` modifies the ancestor chain if the receiver already includes the argument, and still does nothing if the receiver already prepended it. ```ruby class Report include ConsoleLog prepend ConsoleLog prepend ConsoleLog # already prepended: no change end Report.ancestors.take(3) # => [ConsoleLog, Report, ConsoleLog] ``` The same module now appears twice, and both entries share one method table. If `ConsoleLog#log` calls `super`, the first entry's call continues past `Report` to the second entry and runs the same method again. | Sequence in one class | Result | |---|---| | `include M` twice | one `M` after the class | | `include M` in a subclass of a class that includes `M` | no change | | `prepend M` twice | one `M` before the class | | `include M` then `prepend M` | `M` before and after the class (Ruby 3.1 and later) | | `prepend M` then `include M` | one `M` before the class; the include is skipped | ## Why this matters in real code - Plugins that defensively `include` a module "to make sure it is there" are harmless, but they cannot change priority, which is often what the author was trying to achieve. - A module that calls `super` and appears twice after an include-then-prepend runs twice per call. For a logger, every line is written twice; for a counter, every event counts double. - Order-dependent loading (two files each including or prepending the same module) can produce different chains depending on which file loads first. ## Writing modules that tolerate duplication Because a module can end up in a chain twice, or can be skipped when a class tries to re-include it, shared modules are safer when they do not depend on their exact position: - Make wrappers **idempotent** where possible, for example by marking the message as already processed, so running twice does no harm. - Attach each module in **one place**, ideally the base class, rather than in every subclass "to be safe". - Use `prepend` only for modules written as wrappers, and `include` only for modules that provide methods or defaults; mixing both for one module is what produces duplicates. - When a subclass needs a different behaviour, define the method in the subclass and call the module's logic explicitly, instead of re-including the module and hoping it moves. ## Checking the chain - `ancestors.count(M)` tells you whether a module appears more than once. - `ancestors.index(M)` shows where the first copy sits relative to the class. - Printing the chain in a test for a class that is assembled from many modules turns a silent ordering change into a failing assertion.
- Why does include ConsoleLog in ImportJob not change anything when Job already includes it?Before inserting, `include` searches the whole existing chain, superclasses included, for the module. It finds `ConsoleLog` in `Job`'s part and skips it, so `ImportJob.ancestors` has only the one copy after `Job`, and `Job`'s own methods still come before it.
- What did prepend do before Ruby 3.1 when the class already included the module?It left the ancestors unchanged, so the module stayed only in its included position and a prepend meant as a wrapper had no effect. Ruby 3.1 changed `prepend` to insert the module in front of the class in that case too.
saying these in an interview costs you the question
- Including a module again moves it to the front of the includes
- A subclass can re-include its superclass's module to give it priority
- Including the same module twice raises an ArgumentError
- Prepending a module the class already includes is ignored in Ruby 4.0
- A module can never appear twice in one ancestors list