The new update operator in Zinc
The update operator#
Every systems language inherits the same bag of compound-assignment operators from C: +=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>=. Ten symbols, all doing exactly the same structural thing: read a value, transform it, write it back, differing only in which operator sits in the middle.
Zinc ships none of them. Instead it has one operator: ':'.
"Read the left-hand side, apply the right-hand expression using it as input, assign the result back."
That's the whole rule. What makes it interesting is what falls out of it.
The problem with compound assignment#
Compound assignment operators feel ergonomic until you realise they are a closed set. The language designers sat down, listed the operators they expected programmers to use for in-place updates, and hardcoded exactly those. If your operation isn't in the list, say, clamping a value, or normalising a vector, you fall back to the verbose form:
// C or any C-descendant
speed += acceleration * dt; // fine += exists
health -= damage; // fine -= exists
health = clamp(health, 0, MAX_HEALTH); // back to repetition
The : operator#
In Zinc, the update operator covers all of these cases with one rule. It has four syntactic forms:
| Form | Desugars to | Replaces |
|---|---|---|
x : op expr |
x = x op expr |
+=, -=, *=, /= ... |
x : unary_op |
x = unary_op x |
no equivalent in C |
x : f(args) |
x = f(x, args) |
no equivalent in C |
x : .method(args) |
x = x.method(args) |
no equivalent in C |
The : symbol was chosen because it was the only punctuation character not already carrying meaning in Zinc's grammar. No overloading, no ambiguity - the parser sees lvalue : and knows what's coming.
In practice#
Here are some uses of this operator:
count : + 1 // count = count + 1
price : * 1.08 // price = price * 1.08 (add tax)
mask : & 0xFF // mask = mask & 0xFF
flag : ! // flag = !flag
bits : ~ // bits = ~bits
offset : << 2 // offset = offset << 2
Now the part that's new are the method and function forms:
name : .to_lower() // name = name.to_lower()
buf : compress() // buf = compress(buf)
health : clamp(0, MAX_HEALTH) // health = clamp(health, 0, MAX_HEALTH)
value : abs() // value = abs(value)
config : merge(defaults) // config = merge(config, defaults)
The current value is always the first argument to a free function call, or the receiver for a dot-method call. There is no ambiguity about where it goes because the grammar only permits forms where the insertion point is structurally obvious.
The no-placeholder design decision#
Some languages with similar ideas - Elixir's pipe operator, Hack's |>, OCaml's @@ use an explicit placeholder token (often _ or %%) to mark exactly where the threaded value is inserted. Zinc deliberately does not.
The reason is that Zinc's update operator is not a pipe. A pipe is a value-producing expression that can appear anywhere an expression can. The update operator is an assignment form, it lives at statement level, it always writes back to a specific location, and its forms are restricted by grammar to the cases where insertion is unambiguous.
Note: If you need the current value to appear in a non-first position, for instance
x = 1 - xorx = f(a, x, b)then write a plain assignment. The update operator is not trying to handle every case, just the common one.
This restriction is enforced at parse time. The right-hand side of : must be one of the four forms in the table above. Anything else is a compile error, not a silent wrong result.
Before and after#
Here's the same game-loop fragment in C and in Zinc:
Without the update-operator
health -= damage;
health = clamp(health, 0, MAX_HP);
speed += accel * dt;
speed = fminf(speed, MAX_SPEED);
world->regions[idx].pop =
grow(world->regions[idx].pop, rate);
With
health : - damage
health : clamp(0, MAX_HP)
speed : + accel * dt
speed : min(MAX_SPEED)
world.regions[idx].pop : grow(rate)
The last example is the most telling. The C version names the nested path twice, once on the left, once as an argument, and if you ever rename a field or restructure the path, you have two places to update. The Zinc version names it once. The compiler guarantees it's the same location.
The evaluation guarantee#
This guarantee is load-bearing, and it's worth spelling out precisely. The left-hand side of : is evaluated exactly once, even when it contains a side-effecting sub-expression.
buf[next_slot()] : + 1
// Compiler lowers this to approximately:
__addr := &buf[next_slot()] // next_slot() called exactly once
*__addr = *__addr + 1
This matches the semantics of C's compound assignment operators, which also evaluate the left operand once. The difference is that in Zinc, this guarantee extends to the function and method forms too, including cases C had no syntax for.
The type rule#
The update operator is an in-place mutation, not a rebinding. The right-hand side must produce a value of the same type as the left-hand side. If it doesn't, the compiler rejects it:
i32 count = 0
count : .to_string()
// ERROR: update operator RHS produces *char, expected i32
// if you meant to rebind, use ':=' for a new declaration
This prevents a subtle class of bug where a transformation silently changes what a variable represents. If you want a new variable of a different type, you declare it with :=. The two syntaxes have distinct semantics and distinct error messages.
Why not a pipe operator instead?#
Pipe operators are popular in functional languages and increasingly in systems languages too. They solve a similar-looking problem (threading a value through a series of transformations) but they solve a different actual problem.
A pipe produces a new value. An update mutates an existing location. In a language with manual memory management, that distinction matters enormously. When you update a field in a struct, you're not producing a new struct and assigning it back, you're writing to a specific memory address. The update operator makes that intent explicit and structural.
More concretely: a pipe operator is useful when you're writing data flow. The update operator is useful when you're writing state machines. Systems code is mostly the latter.