Assignment Operator Does Not Update Its Left Operand
ID |
swift.assignment_operator_updates_left |
Severity |
high |
Remediation Complexity |
trivial |
Remediation Risk |
low |
Remediation Effort |
low |
Resource |
Reliability |
Language |
Swift |
Tags |
reliability, suspicious-code |
Description
Reports a custom infix operator whose name ends in =, so every call site reads it as a
compound assignment, but whose implementation cannot or does not update its left operand.
Rationale
struct Counter {
var value: Int
}
func += (lhs: Counter, rhs: Int) -> Counter {
return Counter(value: lhs.value + rhs)
}
var counter = Counter(value: 1)
counter += 5 // counter.value is still 1
a += b carries one meaning wherever it appears: update a. A reader does not consult the
operator’s definition before believing that, and neither does a reviewer looking at the
diff. An implementation that takes its left operand by value cannot update anything the
caller can observe, so the statement is silently a no-op. One that takes the operand inout
and never writes to it is the same no-op with the type system’s blessing.
Either way the caller’s variable keeps its old value, and nothing at the call site says so. The defect surfaces later and elsewhere, as a total that never grew or a buffer that stayed empty, with the accumulating loop looking perfectly correct.
Notes
There are two triggers, both unambiguous:
-
The left parameter is not
inout. Swift value semantics then make the update physically impossible, whatever the body does — the clearest positive there is. -
The left parameter is
inout, and nothing in the body can write to it. A write is any assignment or compound assignment whose left side names the parameter, any method call on it (which may bemutating), and any&lhshanded to another function. Plain reads —lhs.value,lhs[0],lhs + rhs— are not writes.
The second check is deliberately generous about what counts as a write, so an operator that mutates through a route the rule does not model stays silent rather than being reported as a defect.
The comparison operators end in = without being assignments and never fire: ==, !=,
⇐, >=, ===, !==, the pattern-match operator ~=, and their point-wise .== forms
are all excluded by name. A bare = is excluded too — Swift does not allow it to be
overloaded.
Those names are the standard library’s, and a program may spell a comparison any way it likes.
So the file’s own infix operator declaration is read first and outranks the name:
infix operator ?== : ComparisonPrecedence says the operator compares, and the overload below
it is not reported however it treats its left operand. Only a declaration naming
AssignmentPrecedence, or no declaration in the file at all, leaves the spelling to answer.
Remediation
Take the left operand inout and assign to it. A compound-assignment operator returns
nothing; the update is its result:
func += (lhs: inout Counter, rhs: Int) {
lhs.value += rhs
}
If the operation is genuinely a pure function that computes a new value, it should not be
named as an assignment. Drop the = and return the result:
func + (lhs: Counter, rhs: Int) -> Counter {
return Counter(value: lhs.value + rhs)
}
For a type that already implements the non-assigning form, the compound form is one line:
func += (lhs: inout Counter, rhs: Int) {
lhs = lhs + rhs
}