skip to content

Abstract Class Mechanics

A non-instantiable base that holds state, supplies shared behavior, and leaves some operations for subclasses to fill in. Interviewers ask the mechanics before asking when to choose one.

on this pageshow

questions

2

Not every abstract type can be used as a runtime-polymorphic reference. Rust rejects `dyn Trait` for a trait whose method is generic or returns `Self`; Swift could not use a protocol with `Self` or associated-type requirements as an existential (`any Protocol`) at all until SE-0309; C# 11's static abstract interface members are reachable only through a constrained type parameter, never through an interface-typed variable; Java has no such rule but also cannot declare `abstract static`. What single underlying rule explains all four, and how do you split an abstract type that breaks it?

level: seniorimportance: should knowfreq 24%

answer

  1. erased reference = data ptr + fixed vtable, no type
  2. no receiver / Self return / generic method cannot cross
  3. Rust dyn compatibility; `where Self: Sized` opts a method out
  4. C# static abstract: only via `where T : I`, never a variable
  5. Java lacks the rule because `abstract static` is unstatable

basics

~20 s

An erased reference carries a value plus a fixed table of code addresses, but not the concrete type. Members needing the type — constructors, statics, constants, Self returns, generic methods — cannot cross. Rust, Swift and C# declare them and restrict use; Java forbids declaring them.

solid answer

~60 s

A polymorphic reference — `dyn Trait`, `any Protocol`, an interface-typed variable — erases the concrete type and keeps a data pointer plus a fixed table of code addresses. A member is reachable only if calling it needs nothing but the receiver and one address known when that table was built. Four things blow that budget: a member with no receiver (constructor, static, associated constant), a `Self` return the caller cannot size, a generic method needing one slot per instantiation, and anything else requiring the type token. Rust calls this dyn compatibility: `Clone` is not dyn-usable because `clone` returns `Self`; `Iterator` is, because its adapters carry `where Self: Sized` and are simply left out of the vtable. Swift banned such protocols outright before SE-0309 and now hides only the offending members. C# 11 reaches static abstracts through `where T : I` only. Java and Kotlin have no restriction because `abstract static` is unstatable — the requirement is what is missing, not the freedom. Fix: split the type-level members into a companion abstraction, or reify them as factory values.

code

text · 10 lines
text
reference: any Shape   ->   [ data ptr ][ vtable ptr ]

vtable (fixed array, built once per concrete type):
  slot 0 -> area(self) -> f64         OK   receiver + 1 address
  slot 1 -> name(self) -> String      OK
  ----- cannot be given a slot -----
           make() -> Self             needs the TYPE, no receiver
           SIDES (constant)           needs the TYPE, no receiver
           clone(self) -> Self        caller cannot size the result
           draw<T>(self, c: T)        one slot per T, set unbounded

go deeper

for a junior

Recall the core fact: a reference typed by an abstraction knows the object but not its concrete type, so constructors, static members and constants cannot be called through it — only instance methods can.

for a middle

Explain the vtable mechanism: one slot, one address, receiver passed in. Then name at least two members that do not fit (a static or constructor, a Self return) and say why each fails.

for a senior

Give the unifying rule — type-level versus value-level members across the erasure boundary — cite the concrete divergence in Rust, Swift and C#, and show the split-plus-reify fix you would apply in a real codebase.

for a principal

Frame it as an API design constraint: deciding which capabilities live on the type versus on the value determines whether your abstraction can ever be held in a heterogeneous collection or crossed over a plugin boundary. Discuss the cost of a registry of reified factories against generic-only APIs that lose runtime polymorphism entirely.

## The boundary and what crosses it When a value's concrete type is unknown at the point of use — `Box<dyn Shape>` in Rust, `any Shape` in Swift, `Shape s` in Java where `Shape` is an interface or abstract class, `Shape*` in C++ — the compiler erases the type and keeps two things: a pointer to the object's data, and a pointer to a **vtable**, a fixed array of code addresses built once per concrete type. Calling a member through that reference means: index a known slot, load an address, call it with the data pointer as the receiver. That mechanism sets a hard budget. A member is reachable through an erased reference only if invoking it needs nothing except (a) the receiver's data pointer and (b) exactly one code address that was known when the table was emitted. Everything else is a **type-level** member: it needs the identity of the concrete type, which erasure has just thrown away. Four kinds of member blow the budget: - **No receiver.** A constructor, a static method, an associated constant. These are properties of the type; an erased reference holds a value, not a type. - **`Self` outside the receiver position.** `clone(&self) -> Self`: the callee knows what it returns, but the caller holds only "some Shape" and cannot know the result's size or layout, so there is nowhere to put it. - **A generic method.** `draw<T: Canvas>(&self, c: T)` compiles to one machine-code body per `T`. The table would need one slot per instantiation, and that set is open-ended and unknown when the table is built. - **Anything else needing the type token at runtime**, including associated constants and a `Self: Sized` bound on the abstraction itself. Languages differ only in *when* they say no. ## Rust: dyn compatibility (formerly "object safety") Rust lets you declare all of the above in a trait, then decides per trait whether `dyn Trait` is legal. `Clone` is not dyn-compatible because `clone` returns `Self` — which is why heterogeneous collections of trait objects need a hand-written `clone_box(&self) -> Box<dyn Shape>` that returns an erased box instead. The escape hatch is `where Self: Sized` on the offending method: it stays callable on concrete types and is simply left out of the vtable, so the trait as a whole remains usable behind `dyn`. `Iterator` is the standard case — dozens of adapter methods carry that bound, which is exactly why `dyn Iterator<Item = u32>` works while calling `.map()` on it does not. ## Swift: existentials before and after SE-0309 Until Swift 5.7, a protocol with `Self` or associated-type requirements could not be used as a type at all; the compiler said it "can only be used as a generic constraint". SE-0309 turned the whole-protocol ban into a per-member one: you can now write `any Collection`, but members whose signatures use the associated type in a parameter position remain unavailable on the existential, while covariant returns are erased up to their bound. Initializers and `static` requirements stay unreachable either way. SE-0335 then made the `any` spelling explicit so the erasure is visible in source. The standard escape is to open the existential by passing it into a generic function, recovering a real type parameter. ## C# 11: static abstract interface members C# 11 lets an interface declare `static abstract T Zero { get; }` or `static abstract T Parse(string)` — the basis of generic math. The catch is the same budget: such a member is invocable only through a **type parameter** constrained by the interface (`where T : INumber<T>`, then `T.Zero`). An interface-typed variable can never reach it. A type parameter preserves the concrete type through to instantiation; an interface reference has erased it. ## Java, Kotlin, C++: the restriction is missing because the requirement is Java has no object-safety rule, and candidates often read that as Java being more permissive. It is the opposite: Java cannot state the requirement in the first place. `abstract static` is a compile error, constructors are neither inherited nor virtual, and generics erase — so a generic method really is one method with `Object`-shaped parameters occupying one vtable slot. Kotlin is the same: a companion object may implement an interface, but no interface can require its implementors' companions to do so. C++ makes it explicit — `static virtual` is ill-formed. The consequence is that every Java/Kotlin abstract type is trivially usable as a reference type, and a requirement like "every implementor must supply a zero-argument factory or a MAX constant" has to be expressed out-of-band: a separate factory interface, an enum registry, or reflection. ## Splitting a type that breaks the rule Partition members by which side of erasure they live on. Value-level, receiver-dispatched members stay in the abstraction that references are held as. Type-level members move into a companion abstraction that only generic code ever names — `trait ShapeKind: Shape` in Rust, a `where T : I` constraint in C#, a separate `ShapeFactory` interface in Java. If you genuinely need a type-level operation at runtime, **reify** it: store one closure or factory object per concrete type in a registry, converting "call the type" into "call a value", which fits the budget again. Rust's `where Self: Sized` opt-out is the lightweight form of the same split.

  • Rust's `Iterator` has generic methods like `map` and `filter`, yet `dyn Iterator<Item = u32>` compiles. How?
    Those adapter methods are declared with a `where Self: Sized` bound. That bound tells the compiler the method is not dispatchable and must be excluded from the vtable, so it no longer blocks dyn compatibility. The trait remains usable behind `dyn`; you simply cannot call `map` on the trait object, only on a concrete iterator. It is the standard way to keep a rich trait erasable.
  • Java lets you write an interface method with a type parameter, like `<T> T convert(T input)`, and still use the interface as a reference type. Why doesn't that hit the same wall Rust does?
    Java generics are erased, so a generic method compiles to exactly one body whose parameters are `Object`-shaped at the bytecode level. That is one method and one vtable slot regardless of how many types call it. Rust monomorphizes instead — one machine-code body per instantiation — so the slot count would be unbounded and unknown when the table is emitted.
  • You need a heterogeneous list of abstract values, but every implementor must also expose a zero-argument factory. How do you model that without breaking erasure?
    Keep the factory out of the erasable abstraction entirely and reify it as a value: a registry mapping a key to a closure or factory object that returns the erased type. Each concrete implementation registers one entry at startup. The list holds erased values, the registry holds the type-level capability, and neither one asks the vtable to do something it cannot.

Erasing the type is like being handed a finished appliance instead of its factory. You can press every button on the unit (receiver-dispatched members), but you cannot press "build another one" or read the model's spec sheet — those live in the factory, not in the unit you are holding.

saying these in an interview costs you the question

  • Saying Java is more flexible because it has no object-safety rule — Java simply cannot declare the type-level requirement at all.
  • Claiming a `Self`-returning method fails because of borrow checking or lifetimes; it fails because the caller holding an erased value cannot know the returned type's size or layout.
  • Assuming a C# static abstract interface member can be reached through an interface-typed variable, or by casting; it is reachable only through a constrained type parameter.
  • Believing Swift still bans protocols with associated types as types outright — SE-0309 replaced the whole-protocol ban with a per-member restriction on the existential.
  • Treating the restriction as a Rust quirk rather than a consequence of erasure that shows up, in some form, in every language with runtime polymorphism.

context

open as a page

An abstract class exposes shared state and protected hook methods to its subclasses. Explain why that creates a second API you must maintain, and compare how Kotlin's final-by-default classes, Swift's `open` versus `public`, C++'s non-virtual default and Python's convention-only access change what you can safely change later.

level: principalimportance: nice to knowfreq 27%

basics

~20 s

Every protected field, hook and self-call is a contract with subclasses you cannot see, and changing it breaks them like a public change would. Languages differ in the default: Kotlin classes and members are final unless open, Swift types are subclassable outside their module only if open, C++ methods are non-virtual unless virtual, Java and Python are open by default and Python enforces nothing.

open as a page