A renderer reads rows from a source and writes lines to a sink — which one may take a more specific element type?
answer
- ask which way the value travels
- outgoing values only need to be enough
- incoming values must accept everything written
- producers go with the subtype direction
- consumers go against it
basics
~20 sThe source: because it only hands rows out, a source over a more specific row type is safe to pass. The sink only takes lines in, so it goes the other way and accepts one declared over a more general type.
solid answer
~40 sDerive it from the positions rather than recalling a rule. The row source mentions its placeholder only as output, so every row it hands back is at least what the renderer expects — passing a source over a more specific row type cannot surprise the renderer, and that is the safe direction there. The line sink mentions its placeholder only as input, so it has to swallow everything the renderer writes; a sink declared over a more general line type swallows strictly more, so that is its safe direction, and a sink over a more specific type is the unsafe one. Producer positions travel with the subtype direction, consumer positions against it. A type that did both would give the renderer neither move.
code
pseudocode · 16 linestype RowSource<T>:
function hasMore() returns boolean
function nextRow() returns T
type LineSink<T>:
function accept(value T)
function renderNightly(source RowSource<ReportRow>, sink LineSink<RenderedLine>):
while source.hasMore():
sink.accept(format(source.nextRow()))
// SalesRow is a kind of ReportRow.
// Every RenderedLine is a kind of TextChunk; HeaderLine is a kind of RenderedLine.
renderNightly(salesRowSource, textChunkSink) // safe: narrower source, wider sink
renderNightly(reportRowSource, headerLineSink) // unsafe: sink cannot take every line writtengo deeper
Remember the shape of the answer: a source you only read from may be more specific, and a sink you only write to may be more general. Say which one you mean.
Derive both directions out loud from where the placeholder appears, and name the call that breaks in the unsafe case rather than asserting that it is unsafe.
In review, notice a parameter declared exactly when its position licenses more freedom, and notice the sink that was widened by symmetry with the source it sits next to.
Set the convention that shared pipeline types are declared by position, so teams downstream can pass what they already hold instead of converting at every boundary.
## Two parameters, read separately The renderer's signature takes a row source and a line sink. It is tempting to answer the question with one rule for both, and that is exactly the mistake the question is set to catch. The two parameters have opposite positions, so they have opposite safe directions, and the only way to get both right is to derive each one from where the placeholder appears. - The **row source** mentions its placeholder only as a result. The renderer never hands it a row; it only takes rows from it. - The **line sink** mentions its placeholder only as a parameter. The renderer never takes a line from it; it only hands lines to it. ## Why a more specific source is safe Suppose sales rows are a kind of report row, and the renderer is written against report rows. Hand it a source of sales rows and ask what could go wrong: 1. The renderer calls the member that hands back a row. 2. It gets a sales row. 3. Everything the renderer does with it is something defined on report rows. 4. A sales row is a report row, so every one of those operations is defined on the value it actually holds. Nothing breaks, and nothing can, because values only ever travel **outward**. An outgoing value merely has to be *at least* what the receiver expects, and a more specific type is more than enough. That is the whole argument, and it is the argument an interviewer wants to hear — not a remembered rule about which annotation goes where. ## Why the sink is the mirror image Now the sink. The renderer writes rendered lines into it. Hand it a sink of header lines — header lines being a narrower kind of rendered line — and step through it: 1. The renderer formats a row into some rendered line that is not a header line. 2. It calls the sink's accepting member with that value. 3. The sink was written expecting only header lines. That one breaks, and it breaks inside the sink, far from the call that caused it. The safe direction is the opposite: a sink over a **more general** type — say one that accepts any text chunk, of which every rendered line is one — can swallow everything the renderer will ever write. An incoming value has to be *at most* what the receiver can handle, so generality, not specificity, is what buys safety on this side. ## The table to carry into the interview | parameter | placeholder's position | safe argument | unsafe argument | |---|---|---|---| | row source | output only | a source over a more specific row type | a source over a more general row type | | line sink | input only | a sink over a more general line type | a sink over a more specific line type | | read-write buffer | both | the exact type only | any other instantiation | The third row is the one people forget. A type that both hands values out and takes them in gets neither freedom at its declaration, because each direction is unsafe for one of its two halves. ## The whiteboard test When you are unsure, do not reach for a mnemonic — run the substitution and look for the break: - Name a concrete subtype relation ("sales rows are report rows"). - Substitute the instantiation you are asking about for the declared one. - Walk each member the caller will actually call and ask which one can now be handed, or can now hand back, a value of the wrong type. - If you can name the call that breaks, the substitution is unsafe. If you walk every member and none can break, it is safe. This test is worth more than the rule, because it still works in a type system whose notation you have never seen, and because it is what convinces an interviewer that you did not just memorise a phrase. ## What goes wrong in real code The common failure is applying the source's rule to the sink: a developer who has internalised "more specific is fine" widens the source parameter correctly, then accepts a narrow sink for symmetry, and the pipeline fails at the first line whose type falls outside what that sink expects. The second failure is the opposite over-caution — demanding the exact type on both parameters, which compiles and is safe but forces every caller to convert perfectly good arguments for no reason. Reading the positions first gives you both answers at once, and gives each parameter the freedom its direction actually licenses.
- Why does the unsafe sink argument fail inside the sink rather than at the call?Because the mismatch only shows up when a value the sink was not written for actually arrives. The call itself looks reasonable — a sink is a sink — so a type system that did not check the position would let it through and leave the failure to the first line written, far from the argument that caused it.
- Does the renderer gain anything from declaring its parameters this way rather than exactly?Yes, and it is the caller's gain. Exact parameters force every caller to hold or convert to precisely the declared instantiation. Deriving the direction from the positions lets a caller pass a source it already has over a narrower row type, and a sink it already has over a broader line type, with no conversion at all.
- What if one parameter were a buffer the renderer both read from and wrote to?Then neither direction would be available on that parameter at its declaration: its placeholder appears as both a result and an incoming value, so a more specific argument breaks the writes and a more general one breaks the reads. The caller would have to hold exactly that instantiation.
saying these in an interview costs you the question
- Applies one rule to both parameters and widens the sink like the source
- Says any source is fine because reading a value cannot fail
- Thinks a narrower sink is safer because it is more precise
- Derives the direction from a remembered keyword instead of the positions
- Claims a type that both reads and writes can still take a different argument