skip to content

The substitution principle in SOLID (the "L") asks that a subtype be usable anywhere its supertype is expected without breaking callers written against the supertype. A type checker, however, only verifies shape: names, arity, parameter and return types. How much of substitutability can a language actually mechanise? Contrast Eiffel's inherited contracts with `require else` and `ensure then`, Ada 2012's split between the `Pre` aspect and the `Pre'Class` aspect, Java's and C#'s covariant arrays with their runtime `ArrayStoreException` / `ArrayTypeMismatchException`, and read-only interfaces such as Kotlin's `List` (versus `MutableList`) or C#'s `IReadOnlyList<T>`.

level: principalimportance: nice to knowfreq 26%

answer

  1. compiler checks shape; substitutability is behaviour
  2. Eiffel: require else = OR, ensure then = AND → tightening unwritable
  3. Ada: Pre not inherited, Pre'Class inherited and disjoined
  4. covariant arrays: unsound by design, runtime store check
  5. no setter ⇒ square/rectangle unstateable; no subclass ⇒ tests only

basics

~20 s

Type checkers verify shape only: names, arity, types. Behaviour is left to convention. Eiffel and Ada 2012 inherit contracts so a tightened precondition is unwritable; Java and C# instead mechanise an unsound rule, covariant arrays, and patch it with runtime store checks.

solid answer

~50 s

A type checker verifies shape — names, arity, parameter and return types. Substitutability is about behaviour, and only a few languages mechanise any of it. - **Eiffel** inherits contracts: a redefinition's `require else` is OR-ed with the inherited precondition and `ensure then` is AND-ed with the inherited postcondition, so a tightened precondition is unwritable. - **Ada 2012** splits them: a specific `Pre`/`Post` is not inherited and says nothing about descendants; `Pre'Class`/`Post'Class` are inherited by overridings and combine the same way (class-wide preconditions disjoined, postconditions conjoined). - **Java and C#** mechanise an unsound rule on purpose: arrays are covariant, so every element store carries a runtime check — `ArrayStoreException` / `ArrayTypeMismatchException`. - **Kotlin's `List` and C#'s `IReadOnlyList<T>`** delete the mutator, so the classic square-versus-rectangle conflict has no operation left to violate. Everything else — including Go and Rust, which have no subclassing at all — is documentation plus tests.

code

text · 11 lines
text
Base.withdraw
  require       amount > 0 and amount <= balance
  ensure        balance = old balance - amount

Derived.withdraw
  require else  amount > 0
  ensure then   audit_count = old audit_count + 1

-- effective precondition  = base_pre  OR  derived_pre   -> can only WIDEN
-- effective postcondition = base_post AND derived_post  -> can only NARROW
-- "amount <= daily_limit" as an extra demand on callers: unwritable

go deeper

for a junior

Recall that a compiler only checks the shape of an override — names, arity, types — while substitutability is about behaviour, and give one concrete example such as an override that throws where the parent never did.

for a middle

State the contract rule crisply: preconditions may only be weakened, postconditions and invariants only strengthened. Know that arrays in Java and C# are covariant and that a bad element store fails at run time, whereas generics are invariant.

for a senior

Explain where the mechanised checks stop — unstated behaviour, assertions disabled in production, a runtime check reported far from its cause — and what replaces them in practice: contract tests written against the abstraction, read-only interfaces, invariance by default.

for a principal

Frame it as choosing where violations surface. Contract inheritance moves them to a loud boundary, invariance moves a slice to compile time, unsound covariance defers them to a runtime check far from the cause, read-only interfaces remove the states entirely. Pick a policy per codebase and give it an enforcement mechanism, since a principle with no mechanism is a hope.

## Two different jobs both called "checking a subtype" When a language accepts one type as a subtype of another — a class extending a class, a type implementing an interface — it answers a **signature** question: does the subtype declare every operation the supertype declares, with compatible parameter and return types? That is shape, and compilers are excellent at it. The substitution principle asks a **behaviour** question: can code written against the supertype, handed an instance of the subtype, still keep every promise it was relying on? Behaviour is not in the signature. An override that returns the declared type and throws whenever the balance is odd type-checks perfectly and destroys its callers. So the useful comparison across languages is not "does language X support this principle" — it is a design obligation everywhere. It is: **how much of the obligation becomes a check the compiler or runtime performs, and how much stays a comment?** ## Eiffel: contracts that cannot be tightened Eiffel attaches a precondition (`require`), a postcondition (`ensure`) and a class invariant to routines and classes, and — the part people forget — *inherits* them. A redefined routine cannot restate its contract from scratch. Its new precondition clause is written `require else`, and the effective precondition is the **disjunction (OR)** of the inherited one with the new one. Its new postcondition is written `ensure then`, and the effective postcondition is the **conjunction (AND)**. Ancestor invariants are conjoined with the descendant's. The consequence is that the two commonest substitutability violations are not merely discouraged, they are unwritable. You cannot demand more of a caller than the parent did, because OR-ing a clause onto an existing precondition can only widen the accepted inputs. You cannot promise less than the parent did, because AND-ing can only narrow the delivered outcomes. What stays unchecked is everything the assertions do not state: ordering constraints across calls, exception behaviour, resource use, performance, thread safety. Contracts are also checked at run time, not proved, and are commonly compiled out in production builds — they convert silent misbehaviour into a loud failure at the boundary, they do not eliminate it. ## Ada 2012: two contract families, and only one makes a promise about descendants Ada 2012 introduced the `Pre` and `Post` aspects and, separately, `Pre'Class` and `Post'Class`. That split *is* the substitutability distinction, made syntactic. A specific `Pre` belongs to one subprogram. It is not inherited by an overriding, and it makes a claim about that one body. `Pre'Class` is inherited by every overriding of a primitive operation of a tagged type; when an overriding declares its own class-wide precondition, the condition actually checked is the **disjunction** of the class-wide preconditions in force, and `Post'Class` conditions are **conjoined**. Same algebra as Eiffel, but opt-in per aspect rather than global. That is worth internalising beyond Ada: a contract on an *implementation* and a contract on an *abstraction* are different artefacts with different inheritance semantics. Most languages provide only the first — a documentation paragraph on one method — and then hope readers treat it as the second. ## Mechanising the wrong rule: covariant arrays Java (since 1.0) and C# both declare that an array of a subtype is an array of the supertype: an array of strings is usable where an array of objects is expected. That is a substitutability claim the language enforces at compile time, and it is **unsound** — through the wider view you may store any object, but the array underneath accepts only strings. Neither language rejects the program. Both patch the hole at run time: every array element store carries a type check, and a bad store raises `ArrayStoreException` in Java or `ArrayTypeMismatchException` in C#. This is the most honest artefact in the whole discussion, because it exposes the cost side. The check runs on stores that could never fail (JITs elide many, not all), and the failure surfaces at the store — arbitrarily far in time and code from the widening assignment that created the mis-typed view. When generics arrived, both languages chose **invariance** by default instead, precisely to move that failure to compile time; that is the same reason a list of strings is not a list of objects. ## Deleting the states in which substitution can fail Kotlin splits `List` (no mutators) from `MutableList`; C# offers `IReadOnlyList<T>` next to `IList<T>`; many ecosystems offer persistent collections. The famous square-versus-rectangle conflict *requires a setter*: the parent promises independently settable width and height, the child cannot honour it. Through an interface with no mutating operation, there is no operation whose behaviour can diverge, so the conflict cannot even be stated. That is a real mechanism — not a proof, but a deliberate narrowing of the surface on which behaviour can differ. It is why immutability makes so much substitutability reasoning simply evaporate, and why the conflict reappears the moment you substitute through the mutable interface instead. ## Where there is no subclass at all Rust and Go have no implementation inheritance. Rust's only subtyping relation is over lifetimes, where variance is computed and checked precisely; there is nothing corresponding for behaviour. Substitutability degenerates to: does this implementation honour the documented contract of the trait or interface? Nothing verifies it — an ordering trait promising a total order, a hash agreeing with equality — and standard libraries document that violating such a contract yields wrong results or panics rather than memory unsafety. The obligation did not disappear with the subclass; only the mechanism did. ## The bridge back to the design layer The paradigm supplies encapsulation, dispatch and a subtype relation. A design principle asks for behavioural discipline on top. Languages differ in how much of that discipline they hand to a machine: contract inheritance moves violations to a loud runtime boundary, invariance moves a slice of them to compile time, unsound covariance defers them to a runtime check far from the cause, and read-only interfaces remove some of the states in which they could occur. Where the language mechanises nothing, the equivalent engineering artefact is a **contract test suite written against the abstraction and run against every implementation** — the executable form of `Pre'Class`.

  • If Eiffel makes a tightened precondition unwritable, why do Eiffel programs still break substitutability?
    Because contracts only constrain what they state. Ordering across calls, exception behaviour, resource consumption, thread safety and performance normally live outside the assertions, and a descendant can satisfy every one of them and still be unusable through the parent's interface. Contracts are also runtime-checked rather than proved, and are often compiled out of production builds, so they catch violations at a boundary in testing rather than preventing them.
  • Java patched array covariance with a runtime store check but made generics invariant. What did that buy, and what did it cost?
    It buys early failure: a bad element store becomes a compile error at the assignment instead of an exception thrown later, far from the widening that caused it. It costs expressiveness — a list of strings can no longer be passed where a list of objects is expected, even for read-only use — which is why the languages reintroduced controlled variance through wildcards in Java and `in`/`out` annotations in C# and Kotlin.
  • Your language has no inherited contracts at all. What is the practical substitute?
    Write the contract once against the abstraction and enforce it with a shared contract test suite that every implementation must pass, which is the executable equivalent of a class-wide precondition. Beyond that, prefer read-only interfaces and immutable values so fewer states exist in which behaviour can diverge, and keep the contract documented on the interface rather than duplicated on each implementation.

A signature check is airport security confirming your passport has the right fields; a contract check is confirming you actually intend to fly where the ticket says. Eiffel makes the second question part of the form; most languages leave it to trust.

saying these in an interview costs you the question

  • Claiming the compiler already enforces substitutability because it checks the override's signature — it checks shape, never behaviour.
  • Saying a subtype may tighten a precondition as long as it documents it; the caller was written against the supertype and never reads the subtype's docs.
  • Believing Java or C# reject storing the wrong element type into a widened array at compile time, or assuming generics behave like arrays.
  • Treating Design by Contract as static proof — Eiffel and Ada assertions are runtime checks and can be disabled in production builds.
  • Claiming languages without inheritance are immune: an interface or trait implementation can violate its documented contract just as badly, with nothing to catch it.

context