ADR-066: ?. Reaches a Member, and a Method Is One

Status: Superseded by ADR-278 — its D1-D3 are D10-D12, D4 is D15, D5 is part of D13, D6 is D14 there Extends: ADR-052 D6, which built the field half, and answers that record’s §4 first bullet — which named this as the owner’s question and not its own Related: ADR-028 (the emitter has no types, which is why the checker says which of two shapes to write), ADR-023 D8 (the ? a call that can fail carries), ADR-055 D2 (the .await a call that can pause carries — the two together are why the lowering is not the field’s)

1. Context

Part I 3.5 is one sentence:

Safe Navigation (?.): Accesses a member only if the receiver is not null.

ADR-052 D6 built it for a field, on the reading that the section’s worked example writes one — repo.find_user(id)?.full_name — and that a form the specification does not name is not the compiler’s to add. ?.m() was refused with a sentence, and that record’s §4 wrote down what the refusal rested on: “whether the language should have it is the owner’s question and not this record’s.”

The word in the section is “member”, and a method is a member. That is the reading the owner settled, and this record is the answer rather than the question.

And one thing more was wrong beside it, found while building this. ?? would not chain: a ?? b ?? c was a parse error naming the second ??, in a language whose page says the operator provides a fallback and nowhere says a value may have only one. Both halves of 3.5 are in this record because a reader meets them in one expression.

2. Decisions

D1 — ?. reaches a method, with everything a call has.

let greeting = find(1)?.greet("Hallo") ?? "nobody".to_string()

The receiver must be a T?; the call happens only where there is something to call it on; the whole expression is a U? where U is what the method hands back. Arguments reach it, Kap 5.1’s ; zone reaches it, and a trailing lambda reaches it — the three shapes the plain . has, because a ?. decides whether the call happens and never what a call is.

Everything a method call is checked for is therefore unchanged: what it may throw, whether it pauses, what its arguments have to be, what the receiver’s own type binds in its signature (ADR-031). One function in the checker answers both spellings, so there are not two copies of those rules to drift apart.

D2 — The result flattens, exactly as a field of the same shape does.

A method whose own result is a T? is reached with and_then and not map, for ADR-052 D6’s reason and no other: map would give an Option<Option<T>>, and a?.b()?.c would come out reaching through a nullable of a nullable.

Which of the two is a question about the declared type, so the checker decides and the emitter writes the word — the same arrangement D6 has, keyed the same way, and recorded in the same set. The result is a T? either way, which is what lets ?? end a chain and ?. continue one.

D3 — The lowering is a match and not the field’s map, because a method may pause and may fail.

match find(1).as_ref() {
    Some(__nikaia_it) => Some(__nikaia_it.read("note.txt").await?),
    None => None,
}

(The as_ref() is ADR-113’s: the reach is through a view, and the receiver stays usable.)

A field access can neither pause nor fail, so a closure is somewhere it can happen and D6’s map is right for it. A method call can do both — and inside a closure an .await does not compile and a ? has nowhere to go. Keeping map would have bought a ?.m() that works for the easy half of the language and refuses the rest, in a record whose whole point is that a method is an ordinary member.

So the reach is written out. It is the one shape that lets the call be whatever a call is, and it costs a reader a match in the generated Rust where a map would have read more like the source — which is the trade ADR-004 D2 asks to be made deliberately rather than by default.

Two shapes and not three: the flattened case drops the Some(…) around the arm and keeps the None => None, so there is one lowering with one conditional in it rather than a second form to keep right.

D4 — ?? is right-associative, so a value may have more than one fallback.

let name = nickname() ?? given_name() ?? "anonymous".to_string()

a ?? b ?? c is a ?? (b ?? c). The direction is what the types ask for: the last fallback is the plain value that ends the chain, and every ?? before it takes the T? on its left. Coalescing is associative, so the other direction would give the same answer — and right is the reading every language with the operator has, which is worth more than a coin toss.

It was not a decision before; it was a precedence accident. The tail of the rule was parsed one level below the rule itself, so it could not hold another ?? at all.

D5 — The refusal keeps the spelling the reader wrote.

NK1121 — ?. through something that cannot be absent — is unchanged in what it refuses (ADR-052 D7) and now says the way out as a call where a call is what was written: write .name(…)rather than `write `.name. A reader who is told to drop one character should be told which.

D6 — A plain . on a T? is refused, and the guarded form is the way out.

error[NK1125]: `Inner?` may be absent, so it has no field to reach
  13 |     let x = find(1)?.b.c
           ^
     = a `T?` is a type of its own and not a `T` that might be missing
       (Part I, 2.3), so a member of `T` is not a member of it - which is why
       there is no null reference to fail on here
     help: write `?.c`, which answers `null` where there is nothing to reach on
           - or end the chain with `??` and reach into the value it gives

a?.b.c guards a and nothing else. That is what safe navigation means wherever it exists, and it is worth stating because the opposite is easy to assume: the short-circuit decides whether what follows is evaluated when the guard fails, not whether what follows is itself safe. If a is absent the whole expression is null and .c is never reached; if a is there and a.b is absent, .c is reached with nothing to reach on.

In the languages this idiom comes from, that second case is a crash — a TypeError, a NullReferenceException — because null inhabits every reference type. This language has no such value. Types are non-nullable by default and T? is a separate type (Part I 2.3), so .c on one is a member the type does not have: the same kind of mistake as .c on an i64, and answerable where it is written rather than where it runs.

So the refusal is not a new rule about ?.. It is Part I 2.3 applied to a position that had escaped it — and it escaped in both directions, which is how narrow the hole was: maybe().c, with no ?. anywhere, was unchecked too. Both used to reach the emitter and come out as a field read off an Option, which rustc then refused about a file nobody wrote (Part III C.1).

NK1125 is reaches_through_a_plain_value’s mirror: NK1121 refuses a ?. where there is nothing to reach through, and this refuses a plain . where there is.

3. Consequences

Nothing that compiled stops compiling. ?. on a field is untouched — the same map/and_then over the same recorded set — and a ?? b is the same expression it was. What changes is that two programs that were refused are now programs.

Part I 3.5 needs no new sentence, which is the test that this is a reading and not an addition: the section already said “member”, and the status note under it is what changes.

A ?. still takes the value it reaches through (ADR-052 D8). The match moves the receiver exactly as map did, so using it again afterwards is the same move error, on the same line, about the same variable.

4. What this does not decide

5. What is built

All six decisions.

Expr::SafeMethod is its own AST variant and not a flag on Expr::MethodCall, for ADR-052 D2’s reason: every analysis has to say what it does with a call that may not happen, and a flag is the shape that lets one forget. Nine of them were asked and answered — the touch sets, the sync walk, the sharing analysis, the view analysis, Kap 5.1’s DSL check, and four in the emitter.

Checker::call_on is the one place a method call is checked, for both spellings. Emitter::method_call is the one place it is written, with the receiver as an Option — None meaning the name the enclosing match already bound, so the value is reached exactly once whatever the receiver cost to produce.

Checker::reaches_into_a_nullable is D6, asked at a field access and at a plain method call — and not inside call_on, because the safe form reaches that same function with the type from inside the T?, which is the whole of the difference between the two spellings.

tests/nullable.rs runs what it lowers for each of the programs this record is about, because short-circuiting is not a question a type check can settle: a reach that went through a null and one that did not both compile, and only what the program prints tells them apart. They are a call with an argument that short-circuits, a reached method that is both fallible and pausing, one whose result is a T? and does not nest, a chain of fallbacks, and D6’s guarded chain beside the refusal that asks for it.

This site is open source. Improve this page.