Skip to content
academia.sh

Lesson 02 / 09

Inheritance and `super`

An empty subclass taking a method from its superclass; the ancestor chain with superclass and ancestors; super in an overriding method; super inside initialize; the difference between bare super and super(...).

Published Updated

In this lesson

  • Measure that an empty subclass takes its method from an ancestor, using superclass, ancestors and instance_methods
  • Show with super_method that a super call in an overriding method descends to the definition in the superclass
  • Tell apart the argument difference between bare super and super(...) through ArgumentError
Contents

The previous lesson said that balance_text is written in the Account class, yet an empty subclass named TermDeposit can also call the same method and get an answer. There is not a single line of definition in the subclass. Who brought the method there, and what changes when the same method is rewritten in the subclass?

The author of this lesson is the ancestor: the definition sits away from the receiver, in a superclass higher in the chain. The lesson first measures an empty subclass, then looks at how overriding with super makes a definition change hands.

Where an Empty Subclass Gets Its Method

The line class TermDeposit < Account makes Account the superclass of TermDeposit. The methods of the superclass are open to the objects of the subclass. This relation is called inheritance (M08/K02). The file below rebuilds the superclass observation of the core for TermDeposit. The file author.rb is the rule given in the first lesson and is loaded unchanged.

# author.rb — a helper that classifies who wrote a method

# Classification asks in order: is there a definition in the receiver's chain at all,
# then does the receiver's own class carry it, then how was the definition produced.
def author(receiver, name)
  chain = receiver.singleton_class
  unless chain.method_defined?(name) || chain.private_method_defined?(name)
    return ["nobody", "no definition"]
  end

  definition = chain.instance_method(name)
  owner = definition.owner
  place = owner == chain ? "singleton class" : owner.to_s
  return ["ancestor", place] unless owner == receiver.class || owner == chain

  iseq = RubyVM::InstructionSequence.of(definition)
  return ["code", "#{place}, no ruby body"] if iseq.nil?
  return ["code", "#{place}, block body"] if iseq.label.start_with?("block")

  ["programmer", "#{place}, def body"]
end

author.rb adds nothing new in this lesson; it stands as the source. The measurement is in the file that calls it.

# superclass.rb — an empty subclass takes the method from an ancestor
require_relative "author"

class Account
  def initialize(owner, balance)
    @owner = owner
    @balance = balance
  end

  def balance_text = "#{@owner} #{@balance}"
end

class TermDeposit < Account
end

term = TermDeposit.new("tom", 50)
p term.balance_text
p author(term, :balance_text)
p TermDeposit.instance_methods(false)
"tom 50"
["ancestor", "Account"]
[]

term.balance_text gave tom 50. author found the ancestor author, with Account as the evidence: the owner of the definition is not TermDeposit, the receiver’s class, but Account. TermDeposit.instance_methods(false) returned an empty array, so TermDeposit carries no method of its own. The call TermDeposit.new("tom", 50) worked too, because initialize also came from Account by the same route. Even when the subclass is empty, the object carries the complete behavior of an Account.

CM4. In this lesson the “superclass” is the class written to the right of the < sign in the line class TermDeposit < Account; the “subclass” is the one written on the left. An “ancestor” is every class or module that comes after a class in its chain. The rule counts the author as an ancestor when the owner of the method is not the receiver’s class.

The Ancestor Chain

A superclass points at a single class. But Account has a superclass as well, and the chain continues from there. The file below asks the chain in two ways: superclass gives one step, ancestors gives the whole order.

# chain.rb — superclass and ancestors
module Reportable
  def report = "report: #{balance_text}"
end

class Account
  include Reportable
end

class TermDeposit < Account
end

p TermDeposit.superclass
p Account.superclass
p TermDeposit.ancestors.take(5)
p Integer.ancestors.take(4)
Account
Object
[TermDeposit, Account, Reportable, Object, Kernel]
[Integer, Numeric, Comparable, Object]

TermDeposit.superclass gave Account, and Account.superclass gave Object. The ancestors list carries classes and modules together: the receiver’s class at the very front, then Account, then the Reportable module that Account mixed in, then Object and Kernel. The place of modules in the chain is the subject of the next lesson; here it is enough to notice that Reportable stands between Account and Object. The core classes have a chain in the same way: Integer links first to Numeric, then to the Comparable module, then to Object. So the between? in the call 1.between?(0, 2) is also a method written not by Integer but by an ancestor in the chain.

super in an Overriding Method

A subclass can rewrite a method of the superclass under the same name. This operation is called overriding. Overriding does not delete the definition of the superclass; because the subclass comes first in the receiver’s chain, the message reaches it first. super, in turn, descends from this definition to the next definition of the same name in the chain. The file below rewrites balance_text in the class TermDeposit2 and leaves half of the body to super.

# overriding.rb — super descends to the next definition in the chain
class Account
  def initialize(owner, balance)
    @owner = owner
    @balance = balance
  end

  def balance_text = "#{@owner} #{@balance}"
end

class TermDeposit2 < Account
  def balance_text = "term #{super}"
end

t2 = TermDeposit2.new("tom", 50)
puts t2.balance_text
p t2.method(:balance_text).owner
p t2.method(:balance_text).super_method.owner
p t2.method(:balance_text).super_method.super_method
term tom 50
TermDeposit2
Account
nil

The output term tom 50 is the combination of two parts: TermDeposit2 wrote the prefix term , and Account wrote the tom 50 part through the super call. method(:balance_text).owner gave TermDeposit2, and super_method.owner gave Account. super_method gives the next definition in the chain as a method object. Because no ancestor that defines balance_text remained after Account, super_method.super_method returned nil. So a super call is a hop that keeps walking down the chain, but only as long as there is a definition of the same name.

Telling the Owner from the Copy

TermDeposit.instance_methods(false) came out empty, yet TermDeposit still answers balance_text. Two questions have to be kept apart: is the method written in this class, or can it be called by the objects of this class? instance_methods(false) asks only the first. method_defined? asks the second, and instance_method(...).owner says where the definition actually sits. The file below puts the three questions to the same class, and then to the overriding TermDeposit2.

# owner_question.rb — being written and being callable are separate questions
class Account
  def initialize(owner, balance)
    @owner = owner
    @balance = balance
  end

  def balance_text = "#{@owner} #{@balance}"
end

class TermDeposit < Account
end

class TermDeposit2 < Account
  def balance_text = "term #{super}"
end

p TermDeposit.method_defined?(:balance_text)
p TermDeposit.instance_methods(false)
p TermDeposit.instance_method(:balance_text).owner
p TermDeposit2.instance_methods(false)
p TermDeposit2.instance_method(:balance_text).owner
p Account.instance_method(:balance_text).owner
true
[]
Account
[:balance_text]
TermDeposit2
Account

TermDeposit.method_defined?(:balance_text) gave true, while TermDeposit.instance_methods(false) stayed empty; the two do not contradict. The first looks at the whole chain, the second only at the body of the class. instance_method(...).owner gave Account for TermDeposit: the definition that runs when the objects of TermDeposit call it is written in Account, and there is no copy in TermDeposit. For TermDeposit2 the picture changes: instance_methods(false) now carries balance_text, and the owner is TermDeposit2. The definition of Account stays in place and still comes out with Account as its owner. Overriding is not an overwrite; it puts a second definition at the front of the chain.

super Inside initialize

initialize is also a method and can be overridden in a subclass. While the subclass sets up its own extra variable, it asks with super for the superclass to set up its variables. The file below defines two subclasses; the initialize methods of both carry the same signature, but super is written differently.

# initialize_super.rb — super(...) and bare super
class Account
  def initialize(owner, balance)
    @owner = owner
    @balance = balance
  end

  def balance_text = "#{@owner} #{@balance}"
end

class Interest < Account
  def initialize(owner, balance, rate)
    super(owner, balance)
    @rate = rate
  end
end

class Faulty < Account
  def initialize(owner, balance, rate)
    super
    @rate = rate
  end
end

p Interest.new("amy", 100, 5).instance_variables
begin
  Faulty.new("amy", 100, 5)
rescue ArgumentError => e
  puts e.class
end
[:@owner, :@balance, :@rate]
ArgumentError

Interest passed two arguments to the superclass with super(owner, balance), and instance_variables listed three variables in order: the two variables of the superclass, then the @rate of the subclass. In the Faulty class a bare super was written and an ArgumentError arose. A bare super passes on all the arguments of the calling method unchanged; because Faulty#initialize took three arguments, three arguments went to Account#initialize, which expects two.

CM5. In this lesson super(...) means the call with parentheses, and “bare super” means the call written without parentheses and without arguments. The call with parentheses passes to the superclass only the arguments that are written; the bare call passes the method’s own arguments as they are. When the signature of the subclass differs from that of the superclass, a bare super may pass the wrong arguments; in that case the call with parentheses is written.

The difference between the forms with and without parentheses is completed by a third spelling: super() with empty parentheses. A bare super passes the method’s own arguments, while super() passes no argument at all. The difference becomes visible only when the method in the superclass has a parameter with a default value. The file below puts the three forms side by side in three subclasses.

# super_forms.rb — bare super, super() and super(argument)
class Account
  def initialize(owner, balance)
    @owner = owner
    @balance = balance
  end

  def label(prefix = "account") = "#{prefix}: #{@owner}"
end

class Bare < Account
  def label(prefix = "bare") = super
end

class Empty < Account
  def label(prefix = "empty") = super()
end

class Explicit < Account
  def label(prefix = "explicit") = super("fixed")
end

puts Bare.new("tom", 1).label
puts Empty.new("tom", 1).label
puts Explicit.new("tom", 1).label
puts Bare.new("tom", 1).label("manual")
bare: tom
account: tom
fixed: tom
manual: tom

The bare super carried the prefix parameter of the subclass to the superclass; the default account in the superclass was never used. super() passed nothing, so the superclass returned to its own default. super("fixed") passed the written value and ignored the parameter of the subclass. The fourth line shows that what the bare super passes is the current value of the parameter: when manual was given to the call, manual went to the superclass. This is what makes the bare super useful, and it is also what makes it dangerous; the moment the signature of the subclass departs from that of the superclass, what is passed departs as well.

What happens when a super call finds no definition of the same name in the chain? The measurement below asks this.

# orphan_super.rb — super with no definition in the ancestors
class Account
  def initialize(owner, balance)
    @owner = owner
    @balance = balance
  end
end

class Orphan < Account
  def missing = super
end

begin
  Orphan.new("a", 1).missing
rescue NoMethodError => e
  puts e.class
end
NoMethodError

super found no definition named missing in the chain and raised NoMethodError. So super is not magic but a search query: it looks for the next definition of the same name in the chain, and if it does not find one, it meets the error that meets every message.

The chain is not limited to two steps. Each subclass descends to the next with its own super call, and the calls return in order. The file below builds a chain of three classes and walks the chain from start to end with super_method, collecting the owners.

# three_steps.rb — the super_method chain is walked to its end
class Account
  def initialize(owner, balance)
    @owner = owner
    @balance = balance
  end

  def balance_text = "#{@owner} #{@balance}"
end

class TermDeposit2 < Account
  def balance_text = "term #{super}"
end

class Gold < TermDeposit2
  def balance_text = "gold #{super}"
end

g = Gold.new("tom", 50)
puts g.balance_text

owners = []
current = g.method(:balance_text)
while current
  owners << current.owner
  current = current.super_method
end
p owners
gold term tom 50
[Gold, TermDeposit2, Account]

The text was assembled from the innermost definition outward: Account wrote the tom 50 part, TermDeposit2 wrote the term prefix, and Gold wrote the gold prefix. The definition closest to the receiver is the first to run, yet it is the one waiting for the next in the middle of its body. The super_method loop gave the owners in the same order and stopped when it returned nil. This confirms that a super call is not an invisible mechanism but a path read over the order of ancestors.

The Chain Is Searched at Call Time

When the TermDeposit class was defined, it did not copy the definitions of Account into itself; when a call arrives, it looks at the chain. This has an observable consequence: a method added to Account after TermDeposit was set up can be called at once on a TermDeposit object as well. The file below puts the same question to the same object, before and after Account is reopened.

# late_addition.rb — a method an ancestor gains later is visible in the subclass
class Account
  def initialize(owner, balance)
    @owner = owner
    @balance = balance
  end
end

class TermDeposit < Account
end

t = TermDeposit.new("tom", 50)
p t.respond_to?(:interest)

class Account
  def interest = @balance / 10
end

p t.respond_to?(:interest)
p t.interest
p TermDeposit.instance_methods(false)
false
true
5
[]

The first respond_to? gave false, and after Account was reopened it gave true, and t.interest returned 5. In the meantime t was never rebuilt. TermDeposit.instance_methods(false) is still empty: interest was not written to the subclass; it only became a definition of an ancestor in the chain. The definition that is searched for is found according to the current state of the chain at call time.

CM13. In this lesson the “chain” is the search order that starts at the receiver’s class and runs as far as BasicObject. The members of the chain are not copied into the class; the search is made from scratch on every call. A method an ancestor gains later is therefore visible in the existing objects of the subclass too.

The Bounding Measurement

TermDeposit wrote nothing, and its author came out as an ancestor. TermDeposit2 took half of the body from the ancestor and wrote the other half itself. How does the rule classify this second one?

# half_ancestor.rb — an overriding definition that calls super counts as the programmer
require_relative "author"

class Account
  def initialize(owner, balance)
    @owner = owner
    @balance = balance
  end

  def balance_text = "#{@owner} #{@balance}"
end

class TermDeposit < Account
end

class TermDeposit2 < Account
  def balance_text = "term #{super}"
end

p author(TermDeposit.new("tom", 50), :balance_text)
p author(TermDeposit2.new("tom", 50), :balance_text)
["ancestor", "Account"]
["programmer", "TermDeposit2, def body"]

The result was an ancestor for TermDeposit and the programmer for TermDeposit2. Although the body of TermDeposit2 took half from the ancestor, the rule does not measure that. The rule counts the first definition found in the chain and compares the owner of that definition with the receiver’s class; it does not look at the rest of the chain.

CM6. In this lesson the “first definition found” is the first definition met in the search order that starts at the receiver. instance_method(name) gives it. The rule does not see that a super call descends to another definition further along the chain, because it does not look at the body of the first definition.

The consequence runs both ways. Writing a single def in the subclass turns the author from an ancestor into the programmer; how much of the body is handed over to the superclass does not change. On the other hand, the body of a method whose author is the programmer need not belong wholly to that programmer. The author category says the place of the first definition in the chain, not the source of the body.

Summary

  • An empty subclass takes its methods from the superclass: TermDeposit.instance_methods(false) is empty and TermDeposit.new("tom", 50).balance_text comes from an ancestor.
  • superclass gives one step and ancestors gives the whole chain; TermDeposit.ancestors.take(5) shows the order of subclass, superclass, mixed-in module, Object and Kernel.
  • Overriding does not delete the definition of the superclass; super descends to the next definition of the same name in the chain, and super_method.owner gives the owner of that definition.
  • Inside initialize, super(owner, balance) passes only the arguments that are written; in the same signature a bare super passes the arguments unchanged and so raises ArgumentError.
  • The author of this lesson is the ancestor: in the superclass row Account wrote balance_text, and TermDeposit wrote nothing.
  • A super with no definition in the ancestors raises NoMethodError; in an overriding method that calls super the author comes out as the programmer, because the rule counts the first definition found.

Next Step

The method report was never written in the Account class, yet account.report worked and gave report: amy 100. The method comes neither from a superclass nor from the body of the class itself. How does Reportable, which stands between Account and Object in the chain, carry that method? The next lesson, Modules and Mixins, measures this.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close