Variance — Tasks & Exercises¶
Topic: Variance
Introduction¶
These exercises build variance intuition by doing: predicting compiler decisions, reproducing the array bug, deriving the function rule, writing PECS signatures, and diagnosing real unsoundness in Java, C#, Scala, Kotlin, and TypeScript. Each task has a clear self-check. Hints are collapsed-by-intent (read them only if stuck). Solutions are sparse — given for the trickiest items only; for the rest, the self-check is enough to confirm you got it.
Work top to bottom: the warm-ups establish the four variances, the middle tier drills function variance and PECS, and the final tier puts you in the reviewer's seat diagnosing shipped unsoundness.
Table of Contents¶
- Tier 1 — Foundations
- Tier 2 — Function Variance & PECS
- Tier 3 — Declaration-Site & Use-Site
- Tier 4 — Real-World Unsoundness
- Tier 5 — Design Challenges
- Hints
- Solutions
Tier 1 — Foundations¶
Task 1.1 — Classify the variance¶
For each, state whether it should be covariant, contravariant, or invariant in T, and justify in one sentence using the producer/consumer test:
Iterator<T>(onlynext(): T)Comparator<T>(onlycompare(T, T): int)MutableList<T>(getandadd)Supplier<T>(onlyget(): T)Consumer<T>(onlyaccept(T))Function<A, B>(inA? inB?)
Self-check: 1 covariant, 2 contravariant, 3 invariant, 4 covariant, 5 contravariant, 6 contravariant in A, covariant in B. Each follows from "read out = covariant, write in = contravariant, both = invariant."
Task 1.2 — Predict the compiler¶
For each line, will it compile in Java? Why?
List<Cat> cats = new ArrayList<>();
List<Animal> a1 = cats; // (a)
Animal[] a2 = new Cat[1]; // (b)
List<? extends Animal> a3 = cats; // (c)
List<? super Cat> a4 = new ArrayList<Animal>(); // (d)
Self-check: (a) no — generics invariant. (b) yes — arrays covariant. (c) yes — covariant wildcard view. (d) yes — contravariant wildcard view.
Task 1.3 — Reproduce the array bug¶
Write a Java (or C#) program that compiles cleanly but throws ArrayStoreException (ArrayTypeMismatchException) at runtime, using array covariance. Then rewrite it with an invariant List and confirm it fails to compile instead.
Self-check: Your array version throws at the assignment-into-array line; your List version is rejected by the compiler at the upcast line. You've moved the error from runtime to compile time.
Task 1.4 — The contravariance direction¶
A Sink<T> only has accept(T). Decide which is the subtype: Sink<Animal> or Sink<Cat>. Then explain it with the garbage-chute analogy in your own words.
Self-check: Sink<Animal> <: Sink<Cat>. A sink that accepts any animal can stand in wherever a cat-sink is needed — the broader consumer is the subtype. If you said the opposite, re-read the contravariance section in junior.md.
Tier 2 — Function Variance & PECS¶
Task 2.1 — Derive the function rule¶
Without looking it up, fill in the blanks: (A1 -> B1) <: (A2 -> B2) iff A__ <: A__ and B__ <: B__. Label which is contravariant and which is covariant, and state the one-line slogan.
Self-check: A2 <: A1 (param, contravariant), B1 <: B2 (return, covariant). Slogan: "accept more, return less."
Task 2.2 — Which assignments are sound?¶
Given Persian <: Cat <: Animal, mark each function assignment sound or unsound:
Cat -> Animalassigned to aCat -> AnimalvariableAnimal -> Persianassigned to aCat -> AnimalvariablePersian -> Animalassigned to aCat -> AnimalvariableCat -> Objectassigned to aCat -> Animalvariable
Self-check: 1 sound (identical). 2 sound (param widened Cat<:Animal ✓, return narrowed Persian<:Animal ✓ — accept more, return less). 3 unsound (param narrowed: Persian->Animal can't take a plain Cat). 4 unsound (return widened: Object isn't a subtype of Animal).
Task 2.3 — Covariant return override¶
In your language, write a base class AnimalShelter with Animal adopt(), and a subclass CatShelter overriding it with Cat adopt(). Confirm it compiles. Then try overriding with Object adopt() and confirm it's rejected. Explain both outcomes.
Self-check: Cat adopt() compiles (covariant return — narrower is OK). Object adopt() is rejected (widening a return is unsound — callers expect at least an Animal).
Task 2.4 — Write the PECS signature¶
Write a generic transfer method that moves every element from a source into a destination, as flexibly as the type system allows. Then verify it compiles for transfer(catList, animalList) and transfer(catList, objectList).
Self-check:
static <T> void transfer(List<? extends T> src, List<? super T> dst) {
for (T x : src) dst.add(x);
}
? extends; destination consumes → ? super. Both calls compile. (See Solutions for why swapping the wildcards fails.) Task 2.5 — The nested flip¶
Compute the variance of T in each, showing sign multiplication:
(T) -> Int(T -> Int) -> Int((T -> Int) -> Int) -> Int
Self-check: 1: − (contravariant). 2: −×− = + (covariant). 3: −×−×− = − (contravariant). Each extra parameter-position nesting flips the sign.
Tier 3 — Declaration-Site & Use-Site¶
Task 3.1 — Trigger the position check¶
In Scala or Kotlin, write class Box[+T] (Scala) / class Box<out T> (Kotlin) and add a method set(x: T). Compile it. Record the exact error. Then remove set and confirm it compiles. Explain why the setter broke it.
Self-check: The compiler reports that the covariant T appears in a contravariant (input) position. The setter consumes T, which is incompatible with out/+. Removing it leaves T output-only, so covariance is sound.
Task 3.2 — Split a read/write type¶
Take the invalid covariant Box from Task 3.1 and refactor into three types: a covariant Readable<out T> (get only), a contravariant Writable<in T> (set only), and an invariant Box<T> extending both. Confirm each compiles and that Readable<Cat> is assignable to Readable<Animal> while Box<Cat> is not assignable to Box<Animal>.
Self-check: All three compile. The covariant Readable view upcasts; the invariant Box doesn't. You've recovered covariance for the read-only slice without compromising the writable type.
Task 3.3 — Use-site projection¶
In Kotlin, write a function printAll(items: Array<out Any>) and confirm you can pass an Array<String> to it but cannot write into items inside the function. Explain why out makes the array read-only here.
Self-check: printAll(arrayOf("a", "b")) compiles; items[0] = ... inside is rejected. The out projection gives a covariant read-only view — writing would be the unsound direction, so it's forbidden.
Task 3.4 — Wildcard capture¶
Explain, in writing, why this fails to compile, using the word "capture":
Self-check: The compiler captures ? as some specific unknown subtype of Animal; it can't prove a Cat matches that unknown type (the list might really be List<Dog>), so every non-null add is rejected. It's not about cats failing to be animals.
Tier 4 — Real-World Unsoundness¶
Task 4.1 — TypeScript: method vs function param¶
With strictFunctionTypes: true, write the same callback two ways:
interface A { cb(x: Animal): void; } // method
interface B { cb: (x: Animal) => void; } // function property
Assign a (c: Cat) => void handler to each. Record which one the compiler rejects and which it silently accepts. Explain the one-character syntactic difference that flips the safety.
Self-check: B (function property) is rejected — sound contravariant check. A (method) is accepted — bivariant method parameters, an intentional unsoundness. The difference is method-syntax vs :-function-property syntax.
Task 4.2 — Trace the silent failure¶
For the accepted (method) case in Task 4.1, write code that calls cb with a plain Animal and show that the Cat-only handler then calls a method that doesn't exist on Animal. What happens at runtime, and why is there no exception from the type system?
Self-check: At runtime the Cat handler runs .meow() on an Animal, producing a runtime error (meow is not a function). There's no type-system exception because TS types are erased — the bivariant method param let an unsound assignment through and nothing checks types at runtime.
Task 4.3 — Varargs and the array hole¶
Explain why this Java method produces an "unchecked generic array creation" warning and how it relates to array covariance:
Self-check: T... compiles to a T[], an array — which is covariant and erased, so its element type can't be checked at runtime, reopening the array-store hazard. @SafeVarargs asserts you don't misuse the array (no wrong-type store, no leaking it).
Task 4.4 — Diagnose the production incident¶
A service crashes with ArrayStoreException in this method:
static void fill(Object[] out, Supplier<?> s) {
for (int i = 0; i < out.length; i++) out[i] = s.get();
}
fill(new String[10], () -> 42). Explain the root cause as a variance issue and give a fix that catches it at compile time. Self-check: String[] is passed as Object[] via array covariance; storing an Integer (42) into a real String[] throws. Root cause: covariance + mutation. Fix: take a List<T> / List<Object> (invariant) instead of Object[], so the mismatched store is rejected at compile time. (See Solutions.)
Task 4.5 — C# variant interface vs array¶
Show, in C#, one covariance that's sound (a variant interface) and one that's unsound (an array). Name the runtime exception the unsound one throws. Explain why one is checked at compile time and the other at runtime.
Self-check: IEnumerable<Cat> → IEnumerable<Animal> is sound (read-only, declaration-site out). Cat[] → Animal[] then writing an Animal throws ArrayTypeMismatchException. The interface is verified by the position check at compile time; the array defers to a runtime store check.
Tier 5 — Design Challenges¶
Task 5.1 — Design a contravariant event bus¶
Design an event-handler registry so that a single broad handler (e.g., Handler<Object> / a logger) can be registered for a specific event type's stream. Specify the variance annotation and show a registration that exploits it.
Self-check: interface Handler<in E> { fun handle(e: E) } (contravariant). A Handler<Any> can be registered where a Handler<ClickEvent> is expected because a broader consumer subtypes a narrower one. If your design forced exact-type handlers, you missed the contravariance.
Task 5.2 — Expose a safe covariant view¶
You have an internal MutableList<Cat>. Design a public API that lets callers read it as a List<Animal> (covariant) but never mutate your internals. Name the exact return type in two languages.
Self-check: Return Kotlin List<out Animal> / C# IReadOnlyList<Animal> / TS ReadonlyArray<Animal>. The read-only interface has no write method, so covariance is sound and your internal list is protected. Returning the mutable list as a covariant alias would be the array bug reborn.
Task 5.3 — Design merge¶
Design merge(a, b, combine) that merges two sources into a sink. Sources produce, the sink consumes, and combine is a function. Write the most flexible signature you can, annotating every variance.
Self-check (sketch):
static <T> void merge(List<? extends T> a, List<? extends T> b,
List<? super T> out, BinaryOperator<T> combine) { ... }
? extends (produce); sink → ? super (consume); the combine function follows function variance. Bonus if you noted BinaryOperator<T> is invariant in T because T is both input and output. Task 5.4 — Audit a hierarchy¶
You inherit these overrides. Mark each sound or unsound and say why:
- Base
Number compute(); OverrideInteger compute() - Base
void handle(Animal a); Overridevoid handle(Cat c)(claimed as an override) - Base
Animal make(); OverrideObject make() - Base
void set(Cat c); Overridevoid set(Animal a)(in a language allowing contravariant params)
Self-check: 1 sound (covariant return). 2 unsound as an override (narrowed param; most OO treats it as an overload). 3 unsound (widened return). 4 sound (widened/contravariant param — accept more), though most mainstream OO won't treat it as an override.
Task 5.5 — Decide the variance of a cache¶
Design a Cache<K, V> with get(K): V? and put(K, V). State the soundest variance for K and for V and justify. Then design a read-only ReadOnlyCache view and state how its variance differs.
Self-check: Cache is invariant in both K and V (K is consumed by both get and put — contravariant tendency, but reified key types and put make invariance safest; V is both produced by get and consumed by put → invariant). A ReadOnlyCache<K, out V> exposing only get can be covariant in V (output-only). If you marked the writable cache covariant in V, recheck put.
Hints¶
- 1.1 / 1.4: One question only: does the type let you read
Tout, writeTin, or both? Out → covariant, in → contravariant, both → invariant. - 1.3: Upcast a
String[]toObject[], then store a non-StringObject. For theListversion, try to upcastList<String>toList<Object>. - 2.1 / 2.2: Remember "accept more, return less." Wider parameter = subtype; narrower return = subtype.
- 2.4: PECS — Producer Extends, Consumer Super. The source is the producer; the destination is the consumer.
- 2.5: Tag each parameter position
−, each return/output position+, and multiply through the nesting. - 3.1: The error mentions a covariant type in a contravariant position — that's the setter.
- 3.4: The key word is capture:
?becomes a fresh unknown type the compiler can't match your value against. - 4.1 / 4.2: Watch whether you wrote the callback with method syntax
cb(x)or property syntaxcb: (x) =>. Only one is checked soundly. - 4.4: The hazard is passing a specific array type through an
Object[]parameter. Replace the array with an invariant collection. - 5.1: Consumers want
in/ contravariance so a broad consumer subtypes a narrow one. - 5.2: You need a type with no write method. Read-only interfaces are covariant and safe.
Solutions¶
Solution 2.4 — Why swapping the PECS wildcards fails¶
static <T> void transfer(List<? extends T> src, List<? super T> dst) {
for (T x : src) dst.add(x); // read from src (works: ? extends), write to dst (works: ? super)
}
If you swap to transfer(List<? super T> src, List<? extends T> dst): - Reading from src (? super T) gives you Object, not T — the for (T x : src) loop fails to compile. - Writing to dst (? extends T) is rejected by wildcard capture — you can't add to an upper-bounded list.
So both halves break. PECS isn't a convention; it's the only assignment of wildcards under which both the read and the write type-check. The producer must be ? extends (you only read it) and the consumer must be ? super (you only write it).
Solution 4.4 — Fixing the ArrayStoreException¶
Root cause: fill(new String[10], () -> 42) passes a String[] where Object[] is expected (array covariance), then stores Integer values into a real String[], throwing ArrayStoreException. The unsoundness is covariance + mutation, deferred to a runtime store check.
Compile-time-safe fix — make the container invariant and generic:
static <T> void fill(List<? super T> out, Supplier<? extends T> s) {
for (int i = 0; i < out.size(); i++) out.set(i, s.get());
}
// fill(new ArrayList<String>(...), () -> 42) // now a COMPILE error: 42 isn't a String
With an invariant List and PECS bounds, the type system rejects the mismatched element at compile time instead of crashing in production. The general lesson: replace covariant-array parameters at API boundaries with invariant generic collections.
Solution 5.4 — The override audit, reasoned¶
- Sound.
Integer <: Number; narrowing the return is covariant return, and callers expecting aNumberare satisfied by anInteger. - Unsound as an override. Narrowing the parameter to
Catmeans a caller holding the base type could pass aDog, which the override can't handle. Java/C# treat the differing signature as an overload, not an override — the language is protecting you. - Unsound.
Objectis a supertype ofAnimal; widening the return breaks callers who expected at least anAnimal. Rejected by the compiler. - Sound (where supported). Widening the parameter to
Animalis contravariant — the override accepts more than required, so any value the base accepts is still handled. Scala/Eiffel allow this; Java/C# won't recognize it as an override.
The single rule behind all four: promise more (narrower return), demand less (wider parameter) — anything else is unsound.
In this topic
- interview
- tasks