skip to content

You need to add an operation to an abstraction that code outside your control already implements, without breaking those implementations. Compare the mechanisms offered by Java's default methods, C#'s default interface members, Swift's protocol extensions and Go's optional-interface upgrade.

level: seniorimportance: must knowfreq 42%

answer

  1. Adding a required method breaks every outside implementer
  2. Java default: virtual, implementer's override always wins, no state
  3. C# default interface member: only through an interface-typed reference
  4. Swift: extension-only member dispatches statically; requirement restores dynamic
  5. Go: new narrow interface plus type assertion (io.WriterTo, http.Flusher)

basics

~20 s

Either add it with a default body or do not add it to the abstraction at all. Java default methods dispatch virtually and an implementer's own method always wins; C# default interface members are visible only through an interface-typed reference; Swift extension members that are not protocol requirements dispatch statically; Go publishes a second small interface and type-asserts for it.

solid answer

~60 s

Four genuinely different answers, and three of them change what a call means. - **Java 8 default methods** — the body lives on the interface, dispatch stays virtual, and an implementer's own method always wins. Diamond conflicts must be resolved by an explicit override. Limitation: no state, so the new operation must be derivable from existing members. - **C# 8 default interface members** — implemented on the interface only. A call through a *class*-typed reference does not see the member; you must up-cast to the interface. Same feature name as Java's, opposite ergonomics. - **Swift protocol extensions** — if the member is not declared as a protocol requirement, a call through a protocol-typed value runs the extension body even when the conforming type defines its own. Behaviour splits on the static type. Declaring it as a requirement restores dynamic dispatch. - **Go** — never widen a published interface. Publish a second one-method interface and type-assert for it (`io.WriterTo`, `http.Flusher`), so old implementers are untouched and new capability is opt-in. - **Rust** — default trait methods are non-breaking; sealing the trait keeps the right to add later.

code

swift · 9 lines
swift
protocol Greeter { }                 // no requirement declared
extension Greeter { func hello() -> String { "hello from extension" } }

struct Loud: Greeter { func hello() -> String { "HELLO" } }

let a: Loud = Loud()
let b: Greeter = Loud()
print(a.hello())   // HELLO
print(b.hello())   // hello from extension   <-- same object, other answer

go deeper

for a junior

Know that adding a required method to an interface breaks existing implementers, and that some languages allow a default body to avoid that.

for a middle

Explain the default-method mechanism and its no-state limitation, and know Go's alternative of a second narrow interface plus a type assertion.

for a senior

Contrast the dispatch rules across languages — Java virtual, C# interface-reference only, Swift static for non-requirements — and pick a strategy based on whether the operation is derivable and whether it is universal or optional capability.

for a principal

Treat it as an API versioning policy: decide up front who may implement (sealing in Rust, unexported methods in Go), keep interfaces narrow, and define how optional capability is discovered and what the fallback path guarantees.

## Why adding a method is a breaking change at all An abstraction is a promise about what implementers provide. Adding a required operation raises the bar for every existing implementer, and all of them fail to compile — or, in dynamic languages, fail the first time the new operation is called. When the implementers are inside your repository this is a mechanical edit. When they are in code you do not control, it is a breaking release, and the mechanism your language gives you determines whether you can avoid one. ## Java: a default body, dispatched normally Java 8 added default methods so that `Collection` could gain `stream()` and `forEach()` without breaking every implementation ever written. A default method has a body on the interface; a class that does not override it inherits that body, and a class that does override it wins, always, because dispatch is ordinary virtual dispatch. If a class inherits conflicting defaults from two interfaces, the compiler refuses and demands an explicit override — the diamond is surfaced rather than silently resolved. The real limitation is state: interfaces cannot hold fields, so a default method can only be written in terms of operations the interface already has. That is a genuine design constraint, and it is why some evolutions cannot be done with defaults at all. ## C#: the same words, a different rule C# 8 introduced default interface members with a deliberately different model. The member is implemented *on the interface*, and it is only accessible through an interface-typed reference. Given `class C : I` where the default body lives on `I`, `new C().M()` does not compile unless `C` itself declares `M`; `((I)new C()).M()` does. The reasoning is that the default body is an implementation detail of the interface, not part of the class's public surface, and it keeps the class's API from silently growing when a library adds a default member. For an interviewer this is the sharpest contrast available: two mainstream languages shipped a feature under nearly the same name and made opposite calls about whether the member joins the implementing class's surface. Code and reviewers cannot carry intuitions from one to the other. ## Swift: static dispatch for non-requirements Swift's protocol extensions can supply implementations too, but dispatch depends on something subtle: whether the member is declared in the protocol body as a *requirement*. - Declared in the protocol and implemented in an extension as a default: dispatch is dynamic through the protocol witness table, so a conforming type's own implementation wins. - Declared *only* in the extension: there is no witness table entry, so the call is resolved statically. Through a protocol-typed value the extension body runs; through a concrete-typed value the type's own method runs. The same object answers differently depending on how you are holding it. This is the famous Swift gotcha, and it is directly relevant to evolution: adding a convenience to a protocol extension without also declaring it as a requirement means conforming types cannot meaningfully customise it, and any who tried get a split behaviour instead of an override. ## Go: do not widen; publish another interface Go has no default bodies, and adding a method to an interface breaks every type that must satisfy it. The idiomatic answer is structural rather than syntactic: keep interfaces one or two methods wide, and when new capability appears, publish a *new* interface and detect it at runtime. if wt, ok := src.(io.WriterTo); ok { return wt.WriteTo(dst) } // otherwise fall back to the generic copy loop That is exactly how `io.Copy` uses `io.WriterTo` and `io.ReaderFrom`, and how `net/http` exposes `Flusher` and `Hijacker` on a response writer. Old implementers are untouched, new capability is opt-in, and the fallback path defines behaviour for everyone else. Because Go interfaces are satisfied implicitly and are usually declared by the consumer, this pattern is cheap in a way it would not be in a nominal language. ## Rust: defaults plus the right to change your mind Rust trait methods may have default bodies, so adding one with a default is not a breaking change for implementers. The complementary trick is *sealing*: give the public trait a supertrait in a private module, so only your crate can implement it. That converts every future addition — default body or not — into a non-breaking change, at the cost of forbidding outside implementations entirely. It is the explicit version of a decision other languages make implicitly. ## How to choose Ask whether the new operation can be expressed in terms of existing members. If yes, a default body is the least disruptive route — but check your language's dispatch rule before assuming an implementer can override it, because Swift's answer for non-requirements and C#'s answer for class-typed references are both surprising. If no, or if the operation is genuinely optional capability rather than something every implementer should have, publish a second narrow interface and detect it, which is Go's default posture and a perfectly good one everywhere else. And if you are designing the abstraction today, keep it narrow and decide up front whether outsiders may implement it at all.

  • How does a Swift author avoid the split-behaviour trap when adding a member to a protocol extension?
    Declare the member in the protocol body as a requirement, and put the default implementation in the extension. That creates a protocol witness table entry, so calls dispatch dynamically and a conforming type's own implementation wins through both protocol-typed and concrete-typed references. If the member is left out of the protocol declaration, there is no witness entry, the call is resolved from the static type, and a conforming type that defines its own version gets two different behaviours instead of an override.
  • Java's default methods cannot hold state. When does that limitation actually block an evolution, and what do you do instead?
    It blocks any addition whose implementation needs per-instance data the interface does not already expose — caching, an identity counter, a lazily built index. The default body can only call existing members, so if the new operation cannot be derived from them, the default is either impossible or has to throw. The usual answers are to publish a second interface that only capable implementers adopt and detect it at the call site, or to move the operation to an abstract base class where state is allowed, accepting that implementers must then extend rather than implement.
  • Why did C# make default interface members invisible through a class-typed reference?
    Because the default body is treated as an implementation detail of the interface rather than part of the implementing class's public surface. It means a library adding a default member cannot silently widen the API of every class that implements the interface, and it avoids new ambiguities against members the class already has. The cost is that consumers must hold the value as the interface type to reach the member, which is the opposite of Java's ergonomics for a similarly named feature.

saying these in an interview costs you the question

  • Assuming default interface members behave identically in Java and C#
  • Believing every Swift protocol extension member can be overridden by a conforming type
  • Adding a method to a published interface and calling it non-breaking because 'implementers can just add it'
  • Reaching for a default body when the operation needs per-instance state an interface cannot hold
  • Designing wide interfaces and then trying to evolve them, instead of publishing narrow optional capability interfaces

context