skip to content

Your library publishes both an abstract base class that outside teams subclass and an interface they implement. Set aside anything to do with default method bodies on the interface: what does shipping the base class commit you to that the interface does not, and how do the languages you know differ when you later add a concrete method or a data member to that base?

level: seniorimportance: should knowfreq 42%

answer

  1. one inheritance slot, spent at publication
  2. capture: Java silent override / C# CS0108 hides / Kotlin+Swift error
  3. C++ new virtual = vtable relayout = recompile the world
  4. constructors + protected = API; Swift has no protected
  5. Go and Rust charge nothing: no implementation inheritance

basics

~20 s

Publishing a base spends every subclass's one inheritance slot and turns its constructors and protected members into API. Later adding a concrete method can silently capture a subclass's own method — silent in Java, a hiding warning in C#, a compile error in Kotlin and Swift.

solid answer

~50 s

An interface only constrains its implementers; a base class is physically installed inside each one, so publishing it commits more and retracts worse — none of which depends on default bodies. - **Slot.** Java, C#, Kotlin and Swift classes extend exactly one class, so the base spends the subclass's only slot at publication. Go and Rust never charge it — no implementation inheritance exists there, so there is no base to publish. - **Surface.** Constructors/initializers and `protected` members become contract. Swift has no `protected` at all: the subclass hook is public-and-`open` or nothing. - **Capture.** Add a concrete method a subclass already declares: Java silently turns it into an override, C# *hides* it with warning CS0108 (base-typed calls still run your new body), Kotlin and Swift refuse to compile without `override`. - **Layout.** In C++ a new virtual function or data member relayouts vtable and object — recompile everything. JVM/CLR relink by name; Swift gets that resilience only under library evolution.

code

text · 18 lines
text
// v1 of the published library
abstract class Repo { /* no validate() */ }

// downstream, compiled against v1
class UserRepo extends Repo {
    validate() { ...local helper, meant only for UserRepo... }
}

// v2 of the library adds a concrete, overridable method
abstract class Repo {
    validate() { ...new framework precondition checks... }
}

// downstream source unchanged; after upgrade:
//   Java    -> UserRepo.validate silently OVERRIDES; framework checks never run
//   C#      -> warning CS0108; it HIDES; base-typed calls DO run the framework checks
//   Kotlin  -> compile error: hides a supertype member, needs 'override'
//   Swift   -> compile error: overriding declaration requires 'override'

go deeper

for a junior

Know the core recall: a class can implement many interfaces but extend only one class, so publishing a base class uses up that one slot for everyone who inherits it. Say that adding a required method to either one breaks existing code.

for a middle

Add the mechanics: constructors and protected members of a published base are part of its contract, and a newly added base method can collide with a method a subclass already has — name at least one language that reports it and one that does not.

for a senior

Give the divergence precisely — Java silently overrides, C# hides with CS0108, Kotlin and Swift are compile errors — plus the binary dimension (C++ vtable/layout ABI versus JVM/CLR name-based linking), and conclude with what you would actually publish.

for a principal

Frame it as a versioning and blast-radius decision: what you publish determines what you can retract, so scope the extendable surface at design time (sealed/final-by-default, interface plus optional skeleton, pimpl or factory in C++), and pick the language's loud-failure mode over its silent one when the two conflict.

## The asymmetry An interface is a *constraint*: it names operations and largely disappears at runtime. An abstract base class is *installed*: its fields sit inside every subclass object, its constructor runs inside every subclass construction, and its method table is the one the subclass extends. Publishing it hands outside code far more of your internals than an interface does, and every one of those handles becomes something you can no longer change. This comparison holds even in languages where interfaces can carry method bodies, because none of the costs below come from bodies. ## Cost 1 — the inheritance slot, spent at publication Java, C#, Kotlin and Swift classes may extend exactly one class. The moment a downstream type extends your base, its single implementation-inheritance slot is gone forever: it can never extend anything else, including a base from another library it may later want to adopt. Interfaces cost nothing here — a type may implement many. Not every language charges this. Go has no classes; reuse is **embedding**, which is delegation with automatic method promotion, and a type may embed many things. Rust has traits and structs and no way to inherit an implementation at all. In both, "should this be an abstract class?" cannot even be asked, so the slot is never spent — which is exactly why Go and Rust libraries publish narrow interfaces/traits plus free functions. C++ charges differently: multiple inheritance makes the slot non-scarce, but two bases sharing an ancestor force virtual inheritance, with its own layout and construction-order costs. ## Cost 2 — constructors and the subclass-only surface become API A subclass must call one of your initializers, so every constructor signature is a public contract; adding a required parameter breaks every subclass, and no default-body mechanism can rescue that. Anything `protected` is likewise API you can never remove or rename. Swift makes the awkwardness visible by not having `protected` at all — a Swift base either exposes a hook publicly (and `open`, since cross-module subclassing requires that keyword) or has no subclass hook. Java's `protected` additionally leaks to the whole package; Kotlin's does not. ## Cost 3 — adding a concrete method can capture a member the subclass already has This is the failure people do not expect, and it is where languages genuinely diverge. Suppose v2 of your base adds `validate()`, and a subclass compiled against v1 already declares its own helper named `validate()` with the same signature: - **Java** — the subclass method silently becomes an override. It compiles, and every base-typed call site that expected your new framework check now runs the subclass helper instead: a silent behavioural change with no diagnostic. It becomes an error only if the signatures are incompatible (narrower access, incompatible return type, broader checked exceptions, or one being `static`). - **C#** — members are non-virtual by default, so the subclass method *hides* rather than overrides. You get warning CS0108 asking for `new`, and calls through a base-typed reference keep running your new body. Same edit, opposite runtime outcome from Java. - **Kotlin** — a compile error. Members are final unless `open`, and a subclass member colliding with a supertype's must say `override`, so the accident is reported at the subclass. - **Swift** — a compile error as well: an overriding declaration requires the `override` keyword. So "adding a method to a base is safe" is fully true only in the two languages that refuse to compile the accident. And adding an **abstract** member is exactly as breaking as adding an interface method everywhere: every subclass stops compiling. The folklore that abstract bases evolve better covers concrete additions only. ## Cost 4 — binary fragility Source compatibility is not the whole bill when the library ships as a binary. - **C++** — object layout and vtable layout are part of a class's ABI. Adding a data member changes offsets; adding a virtual function changes vtable slots. Derived classes and callers compiled against the old header are then wrong at runtime, often without a crash at the edit site. The whole dependency tree must be recompiled, which is why C++ libraries hide layout behind the pimpl idiom or behind pure-abstract interfaces with factory functions. - **JVM and CLR** — members are resolved by name and descriptor at link time, so adding fields and methods to a published base is binary compatible; only the capture problem above applies. - **Swift** — resilience is an opt-in build mode. Under library evolution, stored-property offsets and class dispatch go through indirection so members can be added compatibly; without it, Swift behaves like C++ and clients must be rebuilt. - **Objective-C** — the 64-bit runtime has non-fragile ivars precisely so adding storage to a framework base class does not break subclasses, a problem the 32-bit runtime genuinely had. ## The design conclusion Publish the **interface** as the contract and, when a skeleton genuinely helps, an *optional* abstract base beside it that implements that interface. Callers, tests and mocks depend on the interface; implementers who want the shared code opt into the base, and everyone else keeps their slot and composes instead. If you do publish a base, shrink the extendable surface deliberately — final/non-`open` by default, sealed hierarchies where the language offers them, the smallest possible protected surface — so that future additions cannot capture anything and the layout stays yours.

  • You still want to ship a reusable skeleton. How do you do it without spending the implementer's inheritance slot?
    Publish the interface as the contract and an optional abstract skeleton class that implements it, the way the JDK pairs List with AbstractList. Callers, tests and mocks depend only on the interface, so nobody is forced to extend anything. Implementers who want the shared code opt into the base; everyone else composes or delegates and keeps the slot for their own hierarchy.
  • You control the base and want the freedom to add methods later without accidentally capturing a subclass member. What do you do at publication time?
    Make the extendable surface an explicit, minimal opt-in: Kotlin classes and members are final unless marked `open`, Swift requires `open` for cross-module subclassing and overriding, Java can seal the hierarchy with `sealed ... permits` or mark methods `final`. The smaller and more deliberate the overridable set, the fewer names a future addition can collide with, and in the languages that demand `override` the collision is a compile error rather than a silent behaviour change.
  • How does adding an abstract, bodyless member to a published base compare with adding a method to a published interface?
    They are equally breaking: every existing subclass or implementer fails to compile until it supplies the member. The claim that abstract classes evolve more gracefully applies only to concrete additions, and even then only where the language reports accidental capture. Treat a new abstract member as a major-version change in both cases.

An interface is a job description; an abstract base is a landlord's furniture already bolted into the flat. You can rewrite a job description; moving the furniture means every tenant rearranges their life.

saying these in an interview costs you the question

  • "Abstract classes are the safe choice because you can always add to them later" — adding an abstract member breaks every subclass, and adding a concrete one can capture an existing member.
  • "Adding a method to a base class can never change existing subclass behaviour."
  • "Java, C# and Kotlin all behave the same when the subclass already declares that method" — Java silently overrides, C# hides with a warning, Kotlin refuses to compile.
  • "Adding a virtual function to a C++ base is binary-compatible because dispatch is dynamic" — the vtable layout itself is ABI.
  • "Go's embedding is inheritance, so the base's method dispatches back into the outer type's version."
  • "An interface and an abstract base cost the same to publish" — only the base spends the implementer's single inheritance slot and freezes its constructors.

context