Chapter 14

Inheritance and Polymorphism

Chapter 13 established that a class should model one concept well. Sometimes concepts form families — a Savings_Account and a Current_Account are both bank accounts; a Circle and a Rectangle are both shapes. These families share common behaviour while differing in specifics. Inheritance is the mechanism for expressing that relationship in code.

What Inheritance Is

Inheritance allows one class — the subclass — to extend another — the superclass. The subclass inherits all the fields and methods of the superclass and can add new ones or override existing ones.

The relationship is is-a: a Circle is a Shape. This is not just a programming convenience — it reflects a genuine conceptual relationship. If the is-a relationship does not hold, inheritance is probably the wrong tool.

In Nex, inheritance is declared with inherit:

nex> class Shape
       create
         make(c: String) do
           colour := c
         end
       feature
         colour: String
         area: Real do result := 0.0 end
         describe(): String do
           result := "A " + colour + " shape"
         end
     end

nex> class Circle inherit Shape
       create
         make(c: String, r: Real) do
           Shape.make(c)
           radius := r
         end
       feature
         radius: Real
         area(): Real do
           result := 3.14159 * radius * radius
         end
         describe(): String do
           result := "A " + colour + " circle with radius " + radius.to_string
         end
     end

nex> class Rectangle inherit Shape
       create
         make(c: String, w, h: Real) do
           Shape.make(c)
           width := w
           height := h
         end
       feature
         width: Real
         height: Real
         area(): Real do
           result := width * height
         end
         describe(): String do
           result := "A " + colour + " rectangle (" + width.to_string + " x " + height.to_string + ")"
         end
     end

Both Circle and Rectangle inherit the colour field from Shape. Each has its own additional fields and overrides area and describe.

Public class constants are inherited as well. If a parent class defines:

class Shape
  feature
    DEFAULT_COLOUR = "black"
end

then child classes may use DEFAULT_COLOUR directly inside their own features, and external code may still refer to it with a class name such as Shape.DEFAULT_COLOUR.

This is useful for shared limits, default labels, configuration values, and other facts that belong to the class definition rather than to each object.

The super-class Calls

When a subclass constructor runs, it must also initialise the fields inherited from the superclass. The SuperClass.constructor_name( ) call delegates to the superclass constructor:

make(c: String, r: Real) do
  Shape.make(c)   -- initialises colour in Shape
  radius := r     -- initialises radius in Circle
end

Shape.make(c) runs Shape’s make constructor, setting colour := c. Then the Circle constructor sets radius := r. Both fields are properly initialised.

super can also call an overridden superclass method from within an override:

describe(): String do
  result := Shape.describe + ", area: " + area.to_string
end

This builds on Shape’s describe rather than duplicating it.

Overriding Methods

When a subclass defines a feature with the same name as a superclass feature, the subclass version overrides it. Calling describe on a Circle runs Circle’s version, not Shape’s:

nex> let c := create Circle.make("red", 5.0)
nex> c.describe
"A red circle with radius 5.0"

nex> let r := create Rectangle.make("blue", 4.0, 3.0)
nex> r.describe
"A blue rectangle (4.0 x 3.0)"

Polymorphism

The most powerful consequence of inheritance is polymorphism: the ability to treat objects of different subclasses through a common superclass type.

An Array[Shape] can hold circles, rectangles, or any other shape subclass:

nex> let shapes: Array[Shape] := []
nex> shapes.add(create Circle.make("red", 5.0))
nex> shapes.add(create Rectangle.make("blue", 4.0, 3.0))
nex> shapes.add(create Circle.make("green", 2.0))

nex> across shapes as s do
       print(s.describe)
    end
"A red circle with radius 5.0"
"A blue rectangle (4.0 x 3.0)"
"A green circle with radius 2.0"

When s.describe is called, Nex dispatches to the correct describe for the actual runtime type of each object. This is dynamic dispatch: the method called is determined by the object’s type at runtime, not by the declared type of the variable.

Polymorphism means code written against the superclass type works correctly with any subclass — including subclasses not yet written. Adding a Triangle that inherits Shape and overrides describe would work with the loop above without changing it.

This idea is closely related to the Liskov Substitution Principle: if a routine expects a Shape, it should be able to work with a Circle or Rectangle without needing special-case knowledge. In practical terms, a subclass should behave like a valid, more specific version of its superclass. It may extend behaviour, but it should not violate the expectations established by the parent type.

When Inheritance Is the Right Tool

Inheritance is appropriate when:

The is-a relationship is genuine. A Circle is a Shape. A Savings_Account is a Bank_Account. The subclass is a more specific version of the superclass concept.

The subclass shares and specialises superclass behaviour. It uses the inherited methods and overrides some to provide specialised behaviour. It does not ignore or neutralise what it inherits.

You need polymorphism. You want to write code against the superclass type and have it work correctly on any subclass instance.

Inheritance is not appropriate when:

The relationship is has-a, not is-a. A Car has an Engine — it does not extend Engine. Using inheritance to share code between conceptually unrelated classes produces fragile designs.

You only want to reuse code. If the goal is to avoid duplicating a few methods, composition — giving a class a field of another class type and delegating to its methods — is usually better.

The subclass needs to override most of what it inherits. A subclass that overrides everything is not a specialisation — it is a different class wearing a misleading name.

Feature Override

A superclass can provide a default implementation for a method that subclasses override with specialised behaviour. This is useful when the superclass defines a general structure and the subclass fills in the details.

Every shape has an area, but “the area of a shape” has no general formula. The superclass provides a safe default:

nex> class Shape
       create
         make(c: String) do
           colour := c
         end
       feature
         colour: String
         area(): Real do result := 0.0 end
         describe(): String do
           result := "A " + colour + " shape with area " + area.to_string
         end
     end

area returns 0.0 by default. The describe method in Shape calls area, and subclasses override area with their own formula:

nex> class Circle inherit Shape
       create
         make(c: String, r: Real) do
           Shape.make(c)
           radius := r
         end
       feature
         radius: Real
         area(): Real do
           result := 3.14159 * radius * radius
         end
     end

nex> let c := create Circle.make("red", 5.0)
nex> c.describe
"A red shape with area 78.53975"

describe in Shape calls area — which runs Circle’s area because c is a Circle. The superclass defines the structure; the subclass fills in the detail. This is the template method pattern: a superclass method calls an overridable method whose behaviour varies by subclass.

A Worked Example: An Account Hierarchy

nex> class Account
       create
         make(name: String, initial: Real) do
           owner := name
           balance := initial
         end
       feature
         owner: String
         balance: Real
         deposit(amount: Real) do
           balance := balance + amount
         end
         withdraw(amount: Real): Boolean do
           if amount <= balance then
             balance := balance - amount
             result := true
           else
             result := false
           end
         end
         set_balance(new_balance: Real) do
           balance := new_balance
         end
         get_balance(): Real do
           result := balance
         end
         describe(): String do
           result := owner + ": " + balance.to_string
         end
     end

nex> class Savings_Account inherit Account
       create
         make(name: String, initial, rate: Real) do
           Account.make(name, initial)
           interest_rate := rate
         end
       feature
         interest_rate: Real
         apply_interest() do
           Account.set_balance(balance + balance * interest_rate)
         end
         describe(): String do
           result := Account.describe + " (savings, rate: " + interest_rate.to_string + ")"
         end
     end

nex> class Overdraft_Account inherit Account
       create
         make(name: String, initial, limit: Real) do
           Account.make(name, initial)
           overdraft_limit := limit
         end
       feature
         overdraft_limit: Real
         withdraw(amount: Real): Boolean do
           if balance - amount >= -overdraft_limit then
             Account.set_balance(balance - amount)
             result := true
           else
             result := false
           end
         end
         describe(): String do
           result := Account.describe + " (overdraft limit: " + overdraft_limit.to_string + ")"
         end
     end
nex> let accounts: Array[Account] := []
nex> accounts.add(create Account.make("Alice", 500.0))
nex> accounts.add(create Savings_Account.make("Bob", 1000.0, 0.02))
nex> accounts.add(create Overdraft_Account.make("Carol", 200.0, 500.0))

nex> across accounts as acc do
       print(acc.describe)
    end
"Alice: 500.0"
"Bob: 1000.0 (savings, rate: 0.02)"
"Carol: 200.0 (overdraft limit: 500.0)"

Each account type inherits deposit and get_balance from Account. Savings_Account adds apply_interest. Overdraft_Account overrides withdraw to permit negative balances within the limit. Both override describe using Account.describe to build on the base description. The array holds all three as Account; describe dispatches polymorphically.

Deferred Classes

Sometimes a class exists to define a common interface that subclasses must fill in. The superclass itself has no meaningful implementation for certain methods — only its subclasses do. Nex captures this with the deferred modifier.

A deferred class cannot be instantiated directly. It exists to be inherited:

deferred class Shape
  feature
    colour: String
    area(): Real deferred
    perimeter(): Real deferred
    describe(): String do
      result := "A " + colour + " shape with area " + area.to_string
    end
end

area and perimeter are declared with the deferred keyword in place of a body. These are deferred features: the class announces what every shape can do, but leaves the how to each subclass. describe has a body and is inherited as-is; it calls area, which will dispatch to the subclass implementation at runtime.

Any concrete subclass must implement all deferred features:

class Circle
  inherit Shape
  create
    make(c: String, r: Real) do
      colour := c
      radius := r
    end
  feature
    radius: Real
    area(): Real do
      result := 3.14159 * radius * radius
    end
    perimeter(): Real do
      result := 2.0 * 3.14159 * radius
    end
end

nex> let c := create Circle.make("red", 5.0)
nex> c.describe
"A red shape with area 78.53975"

Circle provides area and perimeter, so it is concrete and can be instantiated. If it omitted either, the compiler would report an error. A subclass that does not implement all deferred features must itself be declared deferred.

Attempting to instantiate a deferred class directly is a compile-time error:

let s: Shape := create Shape.make(...)   -- error: cannot instantiate deferred class

The value of deferred is polymorphism without a misleading default. Compare it to the feature-override approach from section 15.6: there, area returned 0.0 as a fallback — a silent wrong answer that compiles and runs. With deferred, the compiler enforces that every concrete subclass provides a real implementation. A missing override is a compile-time error, not a runtime surprise.

deferred is the right choice when:

The concept is meaningful but has no implementation at the abstract level. A shape has an area; "the area of a shape in general" has no formula.

Every subclass must override a method, and a default would be misleading or dangerous. A default withdraw that always succeeds, a default authenticate that always returns true — these are traps. deferred removes the trap.

You want the compiler to enforce completeness. Each new subclass is checked at compile time. No subclass can slip through without satisfying the contract.

When the set of subclasses is also fixed and known, deferred can be combined with sealed to close the hierarchy entirely — that is the subject of the next section.

Sealed Classes and Closed Hierarchies

Ordinary inheritance is open: anyone can add a new subclass. Sometimes that is exactly right. Other times, a hierarchy is meant to be complete — there are exactly two kinds of result, exactly three kinds of network event — and allowing arbitrary extension would break every routine that handles them.

The sealed modifier closes a hierarchy. A sealed deferred class cannot be instantiated and cannot be extended outside the scope where it and its known subclasses are defined:

sealed deferred class Result
end

class Ok
  inherit Result
  feature value: Integer
  create make(v: Integer) do value := v end
end

class Err
  inherit Result
  feature msg: String
  create make(m: String) do msg := m end
end

Result is now a closed family: Ok and Err are the only variants that can ever exist.

The payoff is the match statement. Because the typechecker knows every subclass of a sealed type, it can verify that a match covers all of them. A missing branch is a compile-time error, not a runtime surprise:

let r: Result := create Ok.make(42)

match r of
  when Ok as ok then
    print(ok.value)       -- ok is typed as Ok here
  when Err as err then
    print(err.msg)        -- err is typed as Err here
end

If you added a third variant — say Pending — to the hierarchy but forgot to add a when Pending clause, the program would not compile. No silent missed case.

An else branch suppresses the exhaustiveness check and covers anything not matched by a preceding clause:

match r of
  when Ok as ok then
    print(ok.value)
  else
    print("not ok")
end

Sealed hierarchies work well for any situation where the set of cases is fixed by design: operation results, state machine transitions, event tags, or domain types with a known, finite structure.

Once Fields

A class field is normally writable throughout the lifetime of an object: any method can reassign it. Sometimes that is too permissive. An identifier assigned once in the constructor and never changed afterward is a common design — think of a database primary key, a file path, or a UUID. Nex captures this intent with the once field modifier.

class Point
  feature
    once x: Integer
    once y: Integer
  create
    make(px: Integer, py: Integer) do
      x := px
      y := py
    end
  feature
    show do
      print(x)
      print(y)
    end
end

let p: Point := create Point.make(3, 7)
p.show   -- 3, then 7

After make returns, x and y are fixed. Any attempt to reassign them — in a method, or anywhere outside a constructor — is a compile-time error:

class Box
  feature
    once value: Integer
  create
    make(v: Integer) do value := v end
  feature
    overwrite(v: Integer) do
      value := v     -- error: 'value' is a once field
    end
end

Multiple constructors are permitted; each may set the once field independently:

class Box
  feature
    once value: Integer
  create
    make(v: Integer) do value := v end
    empty do value := 0 end
end

once is weaker than a constant: a constant requires its value at the point of declaration; a once field is computed by the constructor and fixed from that moment. This makes once the right choice whenever the value is not known until the object is created.

The modifier interacts naturally with invariants. An invariant expressing that a field never changes is a runtime assertion at every method boundary; a once modifier is a structural, compile-time guarantee. Prefer the modifier when immutability is the design intent, and use the invariant to express additional value constraints on top of it.

Summary

  • class SubClass inherit SuperClass declares the relationship; the subclass inherits all fields and methods
  • SuperClass.constructor_name( ) calls the superclass constructor from the subclass constructor
  • SuperClass.method_name( ) calls an overridden superclass method from within an override
  • Overriding replaces a superclass method with a subclass-specific version; dynamic dispatch calls the correct version at runtime
  • Polymorphism allows objects of different subclasses to be treated through a common superclass type
  • The template method pattern: a superclass method calls an overridable method; the superclass defines structure, subclasses fill in details via override
  • Overriding methods must honour the superclass contract; preconditions should not be strengthened, postconditions should not be weakened
  • Use inheritance for genuine is-a relationships and polymorphism; use composition for has-a relationships or code reuse without a conceptual relationship
  • A deferred class cannot be instantiated; subclasses must implement all deferred features or themselves be declared deferred — the compiler enforces completeness
  • A sealed deferred class closes the hierarchy — only defined subclasses may extend it; the typechecker enforces exhaustive handling in match
  • A once field can be assigned in any constructor but never reassigned afterward — a structural, compile-time immutability guarantee

Exercises

1. Define a class Animal with a field name: String, a constructor make, and a method sound(): String that returns "..." by default. Define subclasses Dog, Cat, and Cow each overriding sound. Create an Array[Animal], add one of each, and print each animal’s name and sound.

2. Add a method perimeter(): Real to the Shape class with a default return of 0.0. Override it in Circle (2 * 3.14159 * radius) and Rectangle (2 * (width + height)). Update describe in Shape to report both area and perimeter.

3. The withdraw method in Overdraft_Account overrides the one in Account. Does the override honour the Liskov Substitution Principle — that is, can an Overdraft_Account be used anywhere an Account is expected without surprising the caller? Does it accept the same inputs? Does it make the same kind of promise — returning true on success and false on failure? What does a caller of Account need to know about Overdraft_Account.withdraw?

4. Define a class Vehicle with fields make: String and speed: Real, and methods fuel_type(): String and max_speed(): Real with sensible defaults. Define Electric_Car and Petrol_Car overriding those methods. Add can_reach(distance, fuel: Real): Boolean to each — Petrol_Car uses 10 litres per 100 km; Electric_Car uses 20 kWh per 100 km.

5.* Define a Logger base class with a method log(message: String) that prints with a prefix. Define File_Logger that also appends to a log_history: String field, and Silent_Logger that discards all messages. Create an Array[Logger] with one of each and call log("test") on each. What does this demonstrate about polymorphism and swappable implementations?