Why a change that cannot be seen is an error
A var that is changed and never read again, or the discarded result of a method that takes self, is a compile error rather than a lint, because with value semantics such a change is always a mistake and never a defensive copy.
Diese Seite gibt es noch nicht auf Deutsch, deshalb steht sie hier auf Englisch. Zur englischen Seite
Most languages let a program write to a place and never look at it again, and call it merely wasteful. TorbScript calls it a compile error. This page is the argument for treating a class of bug most linters merely warn about as one the compiler refuses to accept.
The decision#
A change that cannot have an effect is a compile error, at the top level of a script exactly as inside a function. Two shapes of it are caught:
- A
varbinding that is changed and never read afterwards. - The discarded result of a method that reads its receiver and returns a value - the call ran for its result, and nothing used it.
type Counter {
var count: Int = 0
var fn increment() {
count = count + 1
}
}
var counters = [Counter(), Counter()]
counters[0].increment()
print counters[0].countWhy#
Because under value semantics a change nobody reads is never a defensive copy, it is always a mistake. In a language with references, writing to a place and not reading it back can still matter - something else might hold the same reference and observe the change. Under mutable value semantics nothing is aliased, so if a change is not read through the same binding, it is not read at all, anywhere, ever. There is no legitimate reason left for it, which is exactly what turns a lint into a compile error: a lint is for a pattern that is often a mistake, a compile error is for one that always is.
Because this is the same rule the copy trap needs to be catchable at all. Taking an element out of a collection
copies it (var first = counters[0]), and changing the copy compiles under every rule that only looks at the
assignment itself. What makes the mistake visible is the read: first.increment() followed by nothing that reads
first again is a dead change, and the compiler reports it at the line where the value stopped mattering rather than
letting the program run and print the wrong number silently.
type Counter {
var count: Int = 0
var fn increment() {
count = count + 1
}
}
var counters = [Counter(), Counter()]
var first = counters[0]
first.increment()
print "{first.count} {counters[0].count}"This program is not rejected: first is read on the last line, so the change has an effect - on the copy. The
mistake is that the copy was the wrong thing to change, and the fix is to reach through the path
(counters[0].increment()), not to add a read.
Because a discarded method result is the same mistake from the other direction. A method that reads its receiver and
returns a value either produces information the caller asked for or it does not need to be called at all; discarding
its result silently is the same "wrote something nobody looks at" shape as an unread var. An expression statement
must have type Void or Never unless the call has a var receiver or a var argument - which is exactly the
condition under which a call can be effectful without returning anything. parser.bump() is fine as a statement
because bump is a var fn; numbers.sorted { _ } as a bare statement is not, because sorted changes nothing
and its whole point is the value it returns.
Because an unreachable match arm is rejected for the identical reason. Value semantics rule out the class of bug
where a later branch depends on state a hidden earlier branch already changed, so an arm nothing can reach is not a
defensive fallback either - it is dead code the same way a change nothing reads is a dead write. See
Why every match is exhaustive.
What was rejected#
- A lint instead of a compile error. Rejected because, unlike in a language with aliasing, there is no case where
the pattern is intentional: a
varthat is never read again can never be observed through another binding, so warning instead of rejecting would keep a bug compiling for no benefit. - Requiring every call to use its result (as an unconditional rule, without the
var-receiver exception). Rejected because it would makeparser.bump(),list.append(1)and every other mutating call that returns nothing but changes the receiver into ordinary, useful statements that a blanket rule would have no reason to reject - the rule is about values wasted, not about results ignored.
Consequences#
The fix a dead change points at is almost always "reach through the path instead of taking a copy."
items[index].increment(), world.entities[id].health = 5, and items.update(index) { ... } all change the original
in place; none of them introduce a var binding that would need to be read back.
A var binding captured by a closure is the one exception, and it needs to be. The closure shares the binding
instead of copying it, so the read that justifies a change to it can happen anywhere the call runs the closure - there
is no single line after which "never read again" could be decided. See
Why values instead of references for why such a closure never
outlives the binding.
A temporary is never a valid base for a change, which is a related but different error. iterate().next() fails
before the dead-change rule is even relevant, because the change would be lost with the temporary it was made in - see
Mutation and var paths for the rule about the base of a path, as opposed to this
page's rule about what happens after the path is used.
Related#
- Why values instead of references - the copy trap this rule catches.
- Mutation and var paths - what has to be a
varpath before this rule even applies. - Exclusivity - the other compiler check that comes from the same value model.
- What a model trained on other languages gets wrong - the copy trap as a diagnostic, in full.