Repository navigation
Proposal: Load lhs after rhs is executed for assignment operators like += #25315
Description
Activity
- addedbreakingImplementing this issue could cause existing code to no longer compile or have different behavior.Implementing this issue could cause existing code to no longer compile or have different behavior.proposalThis issue suggests language modifications. If it also has the "accepted" label then it is planned.This issue suggests language modifications. If it also has the "accepted" label then it is planned.
on Sep 21, 2025 What's the use case/upside of the status-quo behavior?
Might be a lack of imagination, but I can't think of anything. To me, the current behavior is purely a footgun-enabler.
The current behaviour is elegant in the fact that it's all left-to-right in execution, it can informally be written as "
x op= yis the same asx = x op yaside fromxbeing evaluated just once". This change makes the execution flow jump between computing the left and the right-hand sides.I am not saying that the current behaviour is the best one, this is just some reasoning in favour of it (meaning, this decision isn't arbitrary).
Currently,
x += y();effectively works like this:const tmp = x; const inc = y(); x = tmp + inc;
The alternative is to instead behave like so:
const inc = y(); x = x + inc;
This desugaring is actually not quite right, it's a subtle point that's not mentioned in the proposal itself, I'd like to bring attention to it here, so it won't get missed if this change is accepted, and then gets written down in the change notes, language reference, and eventually the spec...
The current behaviour for compound assignment operations is:
lhs += rhs; // desugars into... // 1. compute the location of the lhs - only once! const place = &lhs; // 2. get the value in the computed location const lhs_val = place.*; // 3. compute the rhs const rhs_val = rhs; // 4. apply operation, assign place.* = lhs_val + rhs_val;
Here we see why the status-quo is the natural behaviour -
lhs_valis computed beforerhs_val.The proposed change, as I understand it, would be to switch
2and3:lhs += rhs; // under the proposal, will desugar into... // 1. compute the location of the lhs - only once! const place = &lhs; // 2. compute the rhs const rhs_val = rhs; // 3. get the value in the computed location const lhs_val = place.*; // 4. apply operation, assign place.* = lhs_val + rhs_val;
We definitely do not want the left-hand side to be computed twice (as is written in the proposal):
hasSideEffects().* += 1; // it would be very sad, and quite confusing, if this were transformed into const inc = 1; hasSideEffects().* = hasSideEffects().* + inc;
The equivalent C code, if that matters
The C code, to my understanding, exhibits Undefined Behaviour, so it should not be a point of comparison. Taken from the C23 N3301 working draft:
[Expressions, General - §6.5.1.2] If a side effect on a scalar object is unsequenced relative to either a different side effect on the same scalar object or a value computation using the value of the same scalar object, the behavior is undefined.
[Assignment operators, General, Semantics - §6.5.17.1.3] The side effect of updating the stored value of the left operand is sequenced after the value computations of the left and right operands.The evaluations of the operands are unsequenced.
Here are examples of some languages that produce the same result as current Zig behaviour:
// Java public static void main(String[] args) { i = 0; i += foo(); System.out.println(i); // prints `0` } // no pointers, we'll settle for a global static int i; static int foo() { i = 3; return 0; }
# Python def foo(): global i i = 3 return 0 i = 0 i += foo() print(i) # prints `0`
Go actually produces the value 3...
package main import "fmt" func main() { i := 0 i += foo(&i) fmt.Println(i) // prints `3` } func foo(p *int) int { *p = 3 return 0 }
Though I believe that's actually a compiler bug - according to the spec:
[§Assignment statements] An assignment operation x op= y where op is a binary arithmetic operator is equivalent to x = x op (y) but evaluates x only once.
[§Order of evaluation] ...Otherwise, when evaluating the operands of an expression [...] binary logical operations are evaluated in lexical left-to-right order.
My point in all this rambling
The existing behaviour is not arbitrary, and abides by Zig's left-to-right execution ordering - this change breaks that (perhaps assumed) execution order, and may itself be a footgun.
Whatever semantics are chosen for compound assignment operations, I believe the same should apply to simple assignment - e.g. if, for some reason, we'd like to compute the value of
rhsinlhs += rhs;as the very first operation, it should also be the very first operation to be computed inlhs = rhs;.Reacted by Danylo, Trevor-Strong, Ryan Liptak, Sam Atman, Rohlem, npc1054657282 and pancelorThis change doesn't really make sense too me. The problem to me isn't that
r.end += r.stream()doesn't work as expected, its thatr.end += r.stream()is basicallys.end = s.end + blk: { const n = ...; s.end = ...; break :blk n; };
which if you write it inline, makes it obvious why your getting the wrong result.
In this case it makes the most sense to me to just say "don't do that", the same way you would to writing something like
x += blk: { x = 3; break :blk 3; };Additionally, like @Fri3dNstuff mentioned, the purely left-to-right order of evaluation is pretty intuitive. I don't have anything to add to their argument, but I like it a lot more than the argument of "change the language because this API is sort of confusing".
Now, I haven't even used this new reader/writer API, so maybe this situation comes up in non-implementation code a lot more than I'm thinking it does, but if this only really happens in the implementation of a reader or writer, then I think we just fix those implementations and maybe document the weirdness.
This is really about the order of evaluation of operands (not to be confused with order of operations, precedence or associativity of operators) and it affects not only compound assignment operators like
+=but also the regular assignment operator=.For all operators (including assignment operators), Zig currently always evaluates operands in order from left to right as they appear in source code:
const std = @import("std"); var x: i32 = 0; fn xPtr(c: u8) *i32 { std.debug.print(" {c}\n", .{c}); return &x; } pub fn main() void { std.debug.print("a = b\n", .{}); xPtr('a').* = xPtr('b').*; std.debug.print("a = b + c\n", .{}); xPtr('a').* = xPtr('b').* + xPtr('c').*; std.debug.print("a += b\n", .{}); xPtr('a').* += xPtr('b').*; }
a = b a b a = b + c a b c a += b a bZig's current behavior is in line with how many contemporary languages define their order of evaluations, for example C#, JavaScript or Go. You can verify this by translating the above Zig program to the respective languages (e.g. Go Playground demo).
In C the order of evaluation of operands is unordered (except for operators like
&&) and thus unspecified behavior. In an expression likea = b + c, the compiler is free to evaluatea,bandcin any order.lhs += rhsis equivalent tolhs = (lhs + (rhs))except thatlhsis only evaluated once.Notably, Rust defines right-to-left operand evaluation order for assignment and compound assignment! (For all other operators, operands are evaluated left-to-right.) The compound assignment operators are a bit complicated because the order depends on whether the types of the operands are primitives or not (operator overloading 🙃). But for primitives, all assignment operators evaluate RHS before LHS, meaning that for
a += b + cthe evaluation order isb, c, a. Playground demo.
So the question is, should the evaluation order of an assignment like
a = bbea, borb, a?For regular assignments like
a = b, I think evaluating LHS last after RHS feels the most intuitive. You need a value before you can store it and the status quo- Compute the location of LHS
- Compute the location of RHS
- Read the value from RHS
- Store the value to LHS
feels a bit awkward, interleaved and less elegant compared to the proposed
- Compute the location of RHS
- Read the value from RHS
- Compute the location of LHS
- Store the value to LHS
which does everything related to LHS in one go.
For compound assignment like
a += b, I also think evaluating LHS last makes the most sense and agree that it's probably the least footgunny order for the reasons described in the proposal. The main concern I can think of is that some people might seea += bas a contraction ofa = a + band assume that one form can be trivially rewritten as the other (ignoring the double evaluation ofa) which would be incorrect because ina += bthe evaluation order isb, a, unlike ina = a + bwhere it isa, b.But keeping the evaluation order for regular and compound assignment consistent is probably more important and "for assignments, things on the right side of the equals sign are evaluated before things on the left" is a pretty easy rule to remember.
Reacted by Ryan Liptak, Justus, Simon A. Nielsen Knights and Andrew Harper@Fri3dNstuff Thank you for the clarification, you're absolutely right. Updated the OP with the more accurate current and proposed behaviors:
const lhs_ptr = &x; const lhs_val = lhs_ptr.*; const rhs_val = y(); lhs_ptr.* = lhs_val + rhs_val;
⬇️
const lhs_ptr = &x; const rhs_val = y(); const lhs_val = lhs_ptr.*; lhs_ptr.* = lhs_val + rhs_val;
It also makes sense that this proposal should apply to regular assignment as well.
@castholm I think your version of the proposal might be the more complete one, or at least it's a different one. My proposal as currently written wouldn't actually conform to:
- Compute the location of RHS
- Read the value from RHS
- Compute the location of LHS
- Store the value to LHS
For example, this:
const std = @import("std"); var x: i32 = 0; fn func(c: u8) *u8 { std.debug.print("{c}\n", .{c}); return &x; } test "example" { func('a').* += func('b').*; }
with my quick/dirty patch applied, still results in:
a bI think the
B, Abehavior might end up in conflict with result location semantics, but someone with more knowledge about that might need to confirm.@squeek502 I see the subtle difference, thank you for pointing it out. This proposal is about whether the operands should be evaluated before the loads take place. I was talking about in which orders operands should be evaluated.
There are six ways to process
func('a').* += func('b').*:1. Left-to-right evals, interleaved evals and loads:
const lhs_ptr = func('a'); // eval LHS const lhs_val = lhs_ptr.*; // load LHS const rhs_ptr = func('b'); // eval RHS const rhs_val = rhs_ptr.*; // load RHS const result = lhs_val + rhs_val; lhs_ptr.* = result;
2. Left-to-right evals, evals before loads, left-to-right loads:
const lhs_ptr = func('a'); // eval LHS const rhs_ptr = func('b'); // eval RHS const lhs_val = lhs_ptr.*; // load LHS const rhs_val = rhs_ptr.*; // load RHS const result = lhs_val + rhs_val; lhs_ptr.* = result;
3. Left-to-right evals, evals before loads, right-to-left loads:
const lhs_ptr = func('a'); // eval LHS const rhs_ptr = func('b'); // eval RHS const rhs_val = rhs_ptr.*; // load RHS const lhs_val = lhs_ptr.*; // load LHS const result = lhs_val + rhs_val; lhs_ptr.* = result;
4. Right-to-left evals, interleaved evals and loads:
const rhs_ptr = func('b'); // eval RHS const rhs_val = rhs_ptr.*; // load RHS const lhs_ptr = func('a'); // eval LHS const lhs_val = lhs_ptr.*; // load LHS const result = lhs_val + rhs_val; lhs_ptr.* = result;
5. Right-to-left evals, evals before loads, left-to-right loads:
const rhs_ptr = func('b'); // eval RHS const lhs_ptr = func('a'); // eval LHS const lhs_val = lhs_ptr.*; // load LHS const rhs_val = rhs_ptr.*; // load RHS const result = lhs_val + rhs_val; lhs_ptr.* = result;
6. Right-to-left evals, evals before loads, right-to-left loads:
const rhs_ptr = func('b'); // eval RHS const lhs_ptr = func('a'); // eval LHS const rhs_val = rhs_ptr.*; // load RHS const lhs_val = lhs_ptr.*; // load LHS const result = lhs_val + rhs_val; lhs_ptr.* = result;
So many fun ways to subtly screw yourself over!
Whether loads are left-to-right or right-to-left doesn't matter in the vast majority of cases, but it matters if you're doing MMIO and the loads have side effects (
volatile), so from a language spec point of view it's an important detail to nail down.Status quo is 1, and it's the only one that loads LHS before evaluating RHS and thus the only one that exhibits this footgunny behavior. Your suggestion is 3, but 2 is mostly equivalent.
My suggestion is either 4, 5 or 6, which all solve the footgun problem but would also change a different detail of the language and should probably be considered a separate proposal.
Edit: However, one last thing I would add is that if
a + bis processed aseval, load, eval, load, then wouldn't it be a bit inconsistent fora = bto beeval, eval, load, load? My gut feeling is that they should probably be processed the same way, but if you want interleaved evals and loads then the only way to patch the footgun is to switch to right-to-left operand evaluation.Reacted by Ryan Liptak and buzmeg@castholm, permutations 2, 5, 6 are not applicable in the general case: a load from RHS only happens if the RHS happens to be a Place-Expression ("l-value" in C terms - but I prefer the Rust terminology), which just isn't the case most of the time, like in here:
x += f() + 2;- no RHS load in sight!The operation sequence for
a += bis therefore (with current semantics) the following:const lhs_ptr = &a; // α const lhs_val = lhs_ptr.*; // β const rhs_val = b; // γ const result = lhs_val + rhs_val; // δ lhs_ptr.* = result; // ε
Clearly,
δ < ε(that is,δmust come beforeε),β < δ,γ < δ, andα < β- these give us three possible orderings:- A. Current semantics
const lhs_ptr = &a; // α const lhs_val = lhs_ptr.*; // β const rhs_val = b; // γ const result = lhs_val + rhs_val; // δ lhs_ptr.* = result; // ε
- B. Original proposal
const lhs_ptr = &a; // α const rhs_val = b; // γ const lhs_val = lhs_ptr.*; // β const result = lhs_val + rhs_val; // δ lhs_ptr.* = result; // ε
- C. Rust semantics (for built-in types)
const rhs_val = b; // γ const lhs_ptr = &a; // α const lhs_val = lhs_ptr.*; // β const result = lhs_val + rhs_val; // δ lhs_ptr.* = result; // ε
Notice how in the case of a simple assignment, operations
βandδdo not exist, making orders A and B coincide:- A, B
const lhs_ptr = &a; // α const rhs_val = b; // γ lhs_ptr.* = rhs_val; // ε
- C
const rhs_val = b; // γ const lhs_ptr = &a; // α lhs_ptr.* = rhs_val; // ε
Consistency between operators
Whichever ordering is accepted, I believe it is only sane to require all assignment operators to behave the same way: if ordering A is chosen for
+=, the same should be for=,-=, and all other assignment operators. Anything else would both be extremely confusing, and needlessly complicate the language.Pros and Cons of each
Ordering A is strictly left-to-right (or, as left-to-right as you can get), is the common behaviour across languages, and is easy to explain: "
x op= yis the same asx = x op yaside fromxbeing evaluated just once".Ordering B solves the issue that originally opened this proposal, which I'll dub the "stale value" problem: the value of the LHS is loaded, the RHS is then computed, changing the value at the LHS place, causing the computation to occur with a stale value. This ordering, however, jumps around between the LHS and RHS in non-obvious ways, and I think is harder to explain.
Ordering C is strictly right-to-left (and this time actually strict - the entirety of the RHS is computed first, and then might as well be completely ignored), it's easy to explain: "in
x op= y, all that is to the right of the=is computed first", and it also solves the stale value problem. Not only that, but ordering C also solves the stale place problem - *record scratch* - the stale place problem?The stale place problem
Though ordering B solves the stale value problem, exemplified below...
x += sneakilyMutateX(&x);
...the computation of the RHS can still change the place the LHS points to - consider:
var list: std.ArrayList(usize) = try .initCapacity(allocator, 1); list.appendAssumeCapacity(0); list.items[0] += try rugpull(&list, allocator);
fn rugpull(list: *std.ArrayList(usize), allocator: std.mem.Allocator) error{OutOfMemory}!usize { // we change the allocation under LHS's nose! >:) try list.append(allocator, 0); return 1; }
Ordering C is the only ordering in which this issue does not manifest.
It is worth noting that a stale value problem can appear in the other direction, where if the RHS is computed first, the LHS can then change variables that would have affected the computation of the RHS - however the LHSs of assignments tend to be very simple, virtually always consisting of just field accesses and indexing - considering the original stale value issue was very rare to encounter until writergate, I doubt this reverse-stale-value issue would ever come up in practice.
A note about MMIO
To my understanding, ordering A is incapable of expressing volatile memory access - if we'd like an operation like
+=to only touch LHS's memory once, it'll have to be done via a read-modify-write operation, which requires the load, computation, and write-back to all be next to one another (β δ εwithout any other operation in-between).Conclusion
In my opinion, the two choices we should debate are:
- keep to the status quo, with all operators using ordering A, or
- change all operators (including simple assignment!) to use ordering C.
Having different operators use different orderings is, I believe, insanely weird behaviour. We've seen that C's unspecified execution order is a footgun. Ordering B is a weird middle ground where it both does not completely solve the staleness problems, and also is not one-directional in execution (first computing the place of LHS, then the value of the RHS, and back to the LHS, getting its value).
Reacted by Carl Åstholm and buzmegJust a general comment: please bear in mind that this concept of "eval then load" is not an accurate mental model for rvalue semantics. In other words,
const a = expr;is not equivalent toconst ptr = &expr; const a = ptr.*;. After #25154, the differences are subtle, but for instance:var x: u32 = 0; const a = a: { defer x += 1; break :a x; }; std.debug.print("{d}\n", .{a}); // output: '0'
vs
var x: u32 = 0; const ptr = &a: { defer x += 1; break :a x; }; const a = ptr.*; std.debug.print("{d}\n", .{a}); // output: '1'
It's important to represent these as genuinely different operations. Let's call the "lvalue evaluation" operation
lval, and the "rvalue evaluation" operationrval. Then, for instance, you could say that the current semantics ofexpr_a += expr_bare:pa <- lval expr_a a <- load pa b <- rval expr_b store pa, a+bThe semantics originally proposed by this issue are:
pa <- lval expr_a b <- rval expr_b a <- load pa store pa, a+bThe current semantics of
expr_a = expr_bare:pa <- lval expr_a b <- rval expr_b store pa, betc.
IMO this way of writing things makes it clearer what's actually happening here.
Here are my current thoughts on this proposal:
- Left-to-right evaluation has the benefit of being an extremely simple rule, and IMO is very intuitive, because it is obeyed across all expressions in Zig (for instance, people sometimes rely on different fields of struct inits being evaluated left-to-right for correct error handling). I think changing this would require overwhelming justification which we are very far from. So, right now, I am strongly against swapping the evaluation order of
aandbina += bas proposed by @castholm. - The original proposal is much smaller, and I think I might be in favour of it. I like that it does "evaluate LHS (as an lvalue), then evaluate RHS (as an rvalue), then 'apply' the
+=operator", whereas the current semantics essentially insert a load between the operand evaluations. - Another possible argument in favour of this change (the small one originally proposed) is that it correlates better with machine code on CISC architectures; on x86_64 (Intel syntax), you can do
add rbx, [rax]to add the value inrbxinto the value at addressrax, which neatly corresponds to the+=operator ifraxis the result of evaluating the LHS as an lvalue andrbxis the result of evaluating the RHS as an rvalue.- Thinking about it a little more, I think the reason I like this isn't so much about how it maps to an ISA, and more about a simpler mental model: rather than thinking of a separate load and store, you can conceptualize the operation as adding the RHS "into" an in-memory value, as a sort of atomic operation (not in a concurrency sense, just conceptually!).
Reacted by Carl Åstholm and Sam AtmanReacted by Ryan Liptak and Jacob Sandlund- Left-to-right evaluation has the benefit of being an extremely simple rule, and IMO is very intuitive, because it is obeyed across all expressions in Zig (for instance, people sometimes rely on different fields of struct inits being evaluated left-to-right for correct error handling). I think changing this would require overwhelming justification which we are very far from. So, right now, I am strongly against swapping the evaluation order of
Left-to-right evaluation has the benefit of being an extremely simple rule, and IMO is very intuitive, because it is obeyed across all expressions in Zig [...] I think changing this would require overwhelming justification
Assuming "Left-to-right evaluation" here implicitly means "if all operators were postfix" (I don't see what other interpretation there could be for "left-to-right evaluation", surely the
+inx + ycannot be evaluated beforey- but I want to emphasise this point, just in case) Zig already diverges from this rule! Namely, because of Result Locations: forp = ethe expect evaluation would be:- evaluate
pas a place-expression - evaluate
eas a value-expression - assign
And for
p₀, p₁, p₂ = ethe expected evaluation would be:- evaluate
p₀as a place-expression - evaluate
p₁as a place-expression - evaluate
p₂as a place-expression - evaluate
eas a value-expression - assign
e[0]top₀ - assign
e[1]top₁ - assign
e[2]top₂
Examples like
pair = { pair[1], pair[0] };anda, b, c = .{ b, c, a };are not executed in the expected manner. I don't like Result Locations, and rather them be removed - but regardless of my opinion, it seems as if Zig is willing to break out of the left-to-right execution model, in ways that are agreeably much weirder than defining some operators to evaluate their operands right-to-left - I don't think, then, that an "overwhelming justification" (whatever that may be) is as necessary as it seems.The original proposal is much smaller, and I think I might be in favour of it. I like that it does "evaluate LHS (as an lvalue), then evaluate RHS (as an rvalue), then 'apply' the
+=operator"This is only left-to-right if your mental model has load-compute-store operations, if you think in terms of a load-store architecture, the original proposal (ordering B) is not at all left-to-right.
Another possible argument in favour of this change (the small one originally proposed) is that it correlates better with machine code on CISC architectures; on x86_64 (Intel syntax), you can do
add rbx, [rax]to add the value inrbxinto the value at addressrax, which neatly corresponds to the+=operator ifraxis the result of evaluating the LHS as an lvalue andrbxis the result of evaluating the RHS as an rvalue.@mlugg, (I assume the
add rbx, [rax]line is supposed to beadd [rax], rbx) note that ordering C, in the list I wrote, also acts this way: the load-compute-store instructions are right next to one another; if it's a point in favour of ordering B (original proposal), it's also in favour of ordering C (Rust semantics, @castholm's proposal).For convenience, and just so we're all on the same page, I'll also write down the different orderings in the form written by @mlugg:
- A
pa <- lval expr_a a <- load pa b <- rval expr_b store pa, a+b- B
pa <- lval expr_a b <- rval expr_b a <- load pa store pa, a+b- C
b <- rval expr_b pa <- lval expr_a a <- load pa store pa, a+b- evaluate
Thanks for picking up on my ASM typo; you're correct that I meant
add [rax], rbx.Assuming "Left-to-right evaluation" here implicitly means "if all operators were postfix"
No, you're misunderstanding me. I don't mean to imply that operators "happen" left-to-right; that concept is, depending on your perspective, either nonsensical or just useless, since it is irrelevant "when" a
+operation happens.My point is about the sequencing of sub-expressions, which is what actually typically matters when discussing evaluation order. In any(*) Zig expression with multiple sub-expressions, those expressions are evaluated left-to-right:
a + bevaluatesabeforeba(b, c)evaluatesabeforebbeforeca = bevaluatesabeforeb.{ a, b, c, d }evaluatesabeforebbeforecbefored
Separately, the operator describes some behaviour. Typically, that behavior cannot occur until after the operands are evaluated (it's hard to do
+if you don't yet know the numbers you're adding!), but it's fairly rare for that to actually impact language semantics. As you have identified, RLS is an example of a case where it does; but, far from Zig being "willing to break" intuition, the unintuitive order today is the biggest flaw with RLS, and the main reason why it is being strongly considered for removal (just the result pointer mechanism, not result types).But even so, RLS doesn't violate the point I was trying to make: in
a, b, c = d, the sequencing of expressions is stilla, thenb, thenc, thend. Side effects are guaranteed to be observed in strictly that order. Confusion comes from the fact that the side effects of the destructure assignment as a whole are observably interleaved with the evaluation / side effects ofd; this is known to be unintuitive, hence why we are considering changing the language to eliminate this problem, but it does not affect the sub-expression ordering.
(*): a couple of control flow constructs violate this; e.g. a while loop's "continue" expression is not evaluated until the end of the loop. It seems reasonable, and even necessary, for control flow constructs to have slightly more complex rules; after all, any loop violates left-to-right evaluation, since the loop condition will be re-evaluated after its body!
in
a, b, c = d, the sequencing of expressions is stilla, thenb, thenc, thendI see, so the guarantee is: "the evaluation of one operand finishes before the start of evaluation of the next operand" (with "next" defined as syntactically left-to-right) - not: "the evaluation of one operand finishes before the start of evaluation of the next operand - and the last operand finishes evaluation before the start of evaluation of the operator".
In
a, b = .{ b, a };there is no happens-before relationship between the assignment=and the aggregation.{ … }(according to that general rule), they can therefore be interleaved. I'm glad to hear that Result Locations are considered for removal - evaluation sequencing being a partial order (or have rules so complicated that it might as well be partial...) leads to so many subtle bugs!Thank you both for clarifying! I understand now how the "eval/load" model is completely wrong, I think I got too caught up on examples like
a.* = b.* + c.*and forgot that the dereferences are their own separate operations, completely unrelated to the evaluation of the binary operators.(Explaining to myself out loud; feel free to ignore)
There might be some exceptions, but for the general case, operators take storage locations (I suspect that in Zig even literals like
123can be considered read-only storage locations), but whether they read from, write to or take the address of the location depends on the operator.a + bloads from both locationsaandb.a = bloads from locationband stores to locationa.&atakes the address of locationawithout accessing memory. Justaon its own without the context of an operator has no meaning (there's no "implicit load/store" or anything like that).you can conceptualize the operation as adding the RHS "into" an in-memory value, as a sort of atomic operation (not in a concurrency sense, just conceptually!)
I think this is a strong point that the originally suggested change can be intuitive, you just need to have the right mental model. If you think of
a *= bas "in-place multiplication" or "multiply into" instead of "a shorthand for multiply then assign" then the behavior of taking the value of LHS last makes sense and is logically coherent. It's a user education issue.The langref currently doesn't use give any explicit name to this category of assignment operators but if this proposal (the original one, I don't think I'm a fan of my RTL one as much any more 😛) were to be implemented then I would strongly suggest avoiding terms like "compound assignment" or "shorthand assignment" in documentation and instead favor terms like "in-place assignment" or "add/multiply/etc. into" so that users don't carry preconceived notions from other languages into this one.
Reacted by Ryan Liptakrather than thinking of a separate load and store, you can conceptualize the operation as adding the RHS "into" an in-memory value, as a sort of atomic operation (not in a concurrency sense, just conceptually!).
I do approve of this semantic model as it's simple to grasp and has no footgunnery behaviour, as i think this is how most people think of that operation already.
Another strong point in favour of this is that
a += f():
and
const tmp = f(); a += tmp;
will behave consistently, even if
f()is mutating a.Also if we replace
f()withf() - aanda - f()we'll get different but consistent in both above snippets.Reacted by pancelorMy take is that strict LTR evaluation is the least-bad of the choices, and that all of the others have different ways to load the chamber and point the code at your foot.
Given that, the standard library should probably not have APIs which encourage this happening, although that's hard to avoid. To editorialize, the 'real' problem here is that in languages with object semantics, where everything is boxed and dereferenced at the last second, it's fairly safe to have the same entity on the right and left hand sides of an assignment. With value semantics, it is less safe, we have to think harder about what's going on to get correct code.
Here's an abstract example of a footgun bug using LHS-last semantics:
var cursor: usize = 0; while (cursor < other_array.len) { array[cursor] = mutatesCursor(other_array, &cursor); }
What the user is trying to do is move mutated 'stuff' from
arraytoother_array, andmutatesCursormoves the cursor the correct amount. With LHS-last, this doesn't work, becausearray[cursor]takes the final value ofcursor, not the initial one.I don't think this problem can be solved generally, not in a language which allows direct manipulation of memory. So I favor the status-quo as the least bad option.
Reacted by Meghan Denny and Binh TranReacted by Ryan Liptak and Binh TranI don't think this problem can be solved generally
We could say it's illegal behavior and add a safety check in debug builds:
- load and cache lhs before combined assignment operation
- evaluate rhs
- compare lhs now with lhs cached before (=>
@panicif they're unequal)
Since it's undefined behavior in C, we arguably already want exactly this behavior from translated C code.
Reacted by Littleote, Ryan Liptak, Matthias, EtienneParmentier, npc1054657282, Binh Tran and pancelorThis leads to potentially counter-intuitive behavior in cases where
y()modifies what is being incremented, like so:fn func(foo: i32) i32 {
foo. = 3;
return 0;
}test "example" {
var i: i32 = 0;
i += func(&i);// with status quo, `i` will now have a value of 0, // since it's equivalent to: // // const tmp = i; // 0 // const inc = func(&i); // 0 // i = tmp + inc; // 0 + 0}
My intuition tells me that this is the correct course of action.
Other results strike me as quite counter-intuitive. I automatically expand
i += func(&i);toi = i + func(&i), and then evaluate the expression from left to right.- marked Zig's += operator evaluates differently than C's #9386 as a duplicate of this issue
on Apr 24, 2026 Thanks all for the discussion (including those from #9386).
FWIW I've become partial to @rohlem's suggestion in #25315 (comment):
That is, instead of picking one behavior or the other, make this detectable illegal behavior.
Feel free to champion that suggestion as a new follow-up proposal. I think it deserves its own discussion.
Reacted by Matthew Lugg and Ryan Liptak
Currently,
x += y();effectively works like this:(and this is also true for all other similar assignment operators, e.g.
-=,+|=,*=, etc)Or, in ZIR:
(notice the
loadbefore thecall, and thataddreferences the result of thatload)In other words, the lhs is loaded before the rhs is executed, and then that loaded value is what is incremented in order to get the final value.
This leads to potentially counter-intuitive behavior in cases where
y()modifies what is being incremented, like so:This might seem like a contrived example, and until recently that seemingly was true. This behavior has been present in Zig since the old stage1 compiler written in C++ (I happened to test with 0.8.0), and it hasn't come up until now as far as I can tell (there is no existing issue or proposal about this particular behavior that I can find).
Enter Writergate
After writergate, though, this pattern exists in real code, where something like
r.end += try r.vtable.stream(r, ...);will cause problems due to this behavior ifr.vtable.streammodifiesr.end(which is common/encouraged by thestreamAPI), so instead this must be done in order to get correct behavior:What's the use case/upside of the status-quo behavior?
Might be a lack of imagination, but I can't think of anything. To me, the current behavior is purely a footgun-enabler.
The alternative
The alternative is to instead behave like so:
What would it take to implement that?
Right now, as far as I can tell, the
loadis done before the rhs is executed in order to use the result of theloadwhen determining the result type. As a very quick and dirty way to evaluate if the alternative behavior would cause any problems, this patch can be applied, which keeps the initial load for result type stuff, but adds a second load after the call that's used when getting the final result:Quick-and-dirty diff
(a better patch would likely remove the
redundant_lhsload and instead take a different approach for getting the result type; I'm not familiar enough with AstGen to know what that might look like, though)With that patch, this test case would pass:
and the ZIR would look like this:
(notice the second
load(%20)which is now used in theadd)Also, with this patch applied, all behavior tests and standard library tests still pass, so while this would technically be a breaking change, it seems unlikely that existing code relies on the current behavior.
The equivalent C code, if that matters
C exhibits the alternative behavior (i.e. it does not match the behavior of status-quo Zig):
EDIT: As pointed out in the comments below, this behavior in C is unspecified.