This is my writeup of issue 28160. Discussion is (and may still be) split between the locations.
Currently, the order of evaluation in Rust is undefined, and even inconsistent between borrow-checking an translation (that is unsound, of course). As part of the MIR work, we have an opportunity to define a consistent order of evaluation.
The order-of-evaluation of expressions is one of the ugliest corners in imperative language design. To these who have not seen it before, the issue is: in a complicated expression, for example
*foo[bar()].baz(widget(), gadget()) = getit();
in which order do the functions run (thatās it, suppose each function printed its name, what will gets printed?). Note that this also includes overloaded autoderefs (these should not have side-effects, but they still can). C famously leaves this unspecified, even leaving many cases (like the infamous i = i++
) as UB. This is not an option in Rust - program execution should be deterministic. Nondeterminism is Not An Option.
Pure left-to-right evaluation does not work
In Rust, the borrow checker makes the issue more annoying. For example, the simple expression
a[i] += a[j];
is naiĢvely desugared into the code
*IndexMut::index_mut(&mut a, i) += a[j];
If we evaluate left-to-right, a
is borrowed mutably for the evaluation of the RHS, which conflicts with the access of a[j]
. This is obviously not an acceptable situation.
Even if we didnāt have the borrow-checker, Rustās expression-orientation can combine with this to cause subtle bugs. One example basically encountered by @eddyb is:
self.balance -= self.data_cost(packet_len);
where data_cost
is defined as
fn data_cost(&mut self, packet_len: u64) -> u64 {
self.do_surcharges();
self.packet_len * self.cost_per_byte()
}
fn do_surcharges(&mut self) {
if self.data_sent > self.low_limit {
self.balance -= self.packet_surcharge();
}
}
This code is desugared to
self.balance = self.balance - self.data_cost(packet_len);
While this code has an effect that is probably better for humanity than the alternative, the operatorās accountants may have a different view of things.
Solutions
Given that the problem is left-to-right evaluation, switching to right-to-left evaluation may seem like an obvious solution. However, function chaining foo.bar().baz()
is done left-to-right, which will result in evaluation order alternating between right-to-left and left-to-right. While this is the most common order of technical writing in my language, I must admit it has significant understandability disadvantages ;-).
One potential solution I have thought of is based on the fact that the problems occur when one of the arguments of an operator is an lvalue. Now lvalues in Rust take the form of a base, which is a local or an rvalue, followed by field accesses, indexing, and dereferences (Rust regards dereferences as lvalue-to-lvalue operations - see my comment on github for more explanation). Indexing (built-in as well as overloaded) also takes an additional rvalue argument (the index).
Now, given its component rvalues, evaluating an lvalue does not really have side effects - dereferencing and indexing can be overloaded, but side effects in them are discouraged, and anyway they donāt have annoying borrow-checker effects. My rule handles most of the examples given to it excellently, and all others I have seen decently.