Assignment Operators¶
| Operator | Description |
|---|---|
= |
Simple assignment |
+=, -=, *=, /=, //=, %=, **= |
Augmented arithmetic |
&=, \|=, ^=, <<=, >>= |
Augmented bitwise |
In the current version of Sharpy, user definitions of assignment operators like += via dunder methods (e.g. __iadd__) are not supported.
Augmented Narrowing Rule¶
When the result of x op= y is wider than the integer type of x, Sharpy
applies C#'s own compound-assignment rule (C# spec §12.21.4): the statement
is evaluated as x = (T)(x op y), where T is the type of x, when
- the result is explicitly convertible to
T— always true between integer types — and yis implicitly convertible toT: a narrow-or-equal width (int8 += int8) or an in-range integer constant (int8 += 1, including a folded constant expression and aconstreference) — or the operator is a shift (<<=,>>=), whose right operand is a shift count and is never converted to the target type.
Otherwise the augmented assignment is refused with SPY0220, naming the
promoted result type. The rule is scoped to integer targets: f: float32
with f += 1.0 stays refused.
| Target | RHS | Result |
|---|---|---|
int8 |
int8 (narrow-or-equal width) |
Accepted |
int8 |
1 (in-range constant) |
Accepted — §10.2.11 constant conversion |
int8 |
300 (out-of-range constant) |
Refused (SPY0220) |
int8 |
i, an int variable |
Refused (SPY0220) |
int8 |
any int value with <<= / >>= |
Accepted — a count, not a value of the target |
uint8 |
1 |
Accepted — u -= 1 on 0 wraps to 255 |
uint32 |
uint32 |
Accepted — //=, %=, **= produce int64 and narrow back |
float32 |
1.0 |
Refused (SPY0220) — the rule covers integer targets only |
x: int8 = 5
y: int8 = 3
x += y # OK: narrows (both operands are int8)
print(x) # 8
x2: int8 = -7
y2: int8 = 2
x2 //= y2 # OK: narrows (both operands are int8)
print(x2) # -4
x3: int8 = -7
x3 %= 2 # OK: 2 is an in-range int8 constant
print(x3) # 1
u: uint8 = 200
v: uint8 = 50
u += v # OK: narrows (both operands are uint8)
print(u) # 250
z: uint8 = 0
z -= 1 # OK: 1 is in range; the cast wraps
print(z) # 255
p: int8 = 127
p += 2 # OK; unchecked narrowing wraps
print(p) # -127
s: int8 = 5
n: int = 3
s <<= n # OK: a shift COUNT is never converted to int8
print(s) # 40
# i: int = 3
# x += i # SPY0220: 'int' is not implicitly convertible to 'int8'
# x += 300 # SPY0220: 300 is not in int8's range
Targets¶
The rule is one decision, taken once, and it does not consult what is being
assigned to. Identifier, attribute (b.n, self.n), nested attribute
(o.inner.n), index (xs[0]) and dict-value (d[k]) targets narrow
identically:
class Box:
n: int8 = 7
b: Box = Box()
y: int8 = 2
b.n += y # attribute
print(b.n) # 9
seven: int8 = 7
xs: list[int8] = [seven]
xs[0] //= y # index
print(xs[0]) # 3
Shifts¶
<<= and >>= are the one carve-out in the rule above, and it is C#'s: the
right operand of a shift is a count, not a value of the target type, so it is
never required to fit the target. x8 <<= 2, x8 <<= i and x8 <<= 300 are
all accepted; the count is masked exactly as C# masks it (& 31 for a 32-bit
promoted left operand), and the result is narrowed back into the target.
A plain store of the arithmetic result into a narrow target is still refused,
because the expression result is promoted to int32:
a: int8 = 5
b: int8 = 3
# c: int8 = a + b # SPY0220: 'int32' is not assignable to 'int8'
c: int = a + b # OK: store in int
Storing an Integer Constant¶
An integer constant whose value fits the destination converts implicitly at every store position — not only at a declaration (C# spec §10.2.11):
class Box:
n: int8 = 0
property p: int8 = 0 # property default
def take(v: int8) -> None:
print(v)
def make() -> int8:
return 120 # return
def gen() -> int8:
yield 1 # yield
def main() -> None:
x: int8 = 7 # declaration
x = 120 # plain store
print(x) # 120
b: Box = Box()
b.n = 120 # attribute store
xs: list[int8] = [x]
xs[0] = 120 # index store
d: dict[str, int8] = {}
d["k"] = 120 # dict-value store
take(120) # positional argument
take(v=120) # keyword argument
print(make()) # 120
for v in gen():
print(v) # 1
ys: list[int8] = [1, 2] # collection-literal elements
f: () -> int8 = lambda: 7 # lambda body under a typed target
if (x := 7) > 0: # walrus
print(x) # 7
r: int8 = 7 if True else 8 # conditional-of-constants
print(r) # 7
# x = 300 # SPY0220: 300 is not in int8's range
The value is checked, not the literal's spelling: folded expressions
(1 << 6) and const references fold to their value first.
Augmented Assignment on Collections¶
When the target of +=, |=, &=, -=, ^=, or *= is a mutable collection
(list, set, dict), the operation mutates the receiver in place — every
alias sees the change. This matches CPython's __iadd__/__ior__/etc. semantics.
| Operator | Collection | Method called |
|---|---|---|
+= |
list[T] |
Extend(other) |
*= |
list[T] |
InPlaceRepeat(n) |
\|= |
set[T] |
Update(other) |
&= |
set[T] |
IntersectionUpdate(other) |
-= |
set[T] |
DifferenceUpdate(other) |
^= |
set[T] |
SymmetricDifferenceUpdate(other) |
\|= |
dict[K,V] |
Update(other) |
xs: list[int] = [1, 2]
ys = xs # alias
xs += [3, 4]
print(xs) # [1, 2, 3, 4]
print(ys) # [1, 2, 3, 4] — same object, mutated in place
print(xs is ys) # True
Right-Hand Side Types¶
The RHS is accepted exactly when the mutator's Python contract admits it:
| Operator | Collection | Accepted RHS | Refused RHS |
|---|---|---|---|
+= |
list[T] |
any iterable whose element is assignable to T (list, set, tuple, range, str into list[str], dict iterates keys) |
non-iterable |
*= |
list[T] |
int |
non-int |
\|= |
set[T] |
set[T] or frozenset[T] only |
list, tuple (SPY0222 — use s.update(xs) to update from any iterable) |
&= -= ^= |
set[T] |
set[T] or frozenset[T] only |
same as \|= |
\|= |
dict[K,V] |
dict[K,V] or an iterable of tuple[K,V] pairs |
non-mapping non-pair iterable |
xs: list[int] = [1]
xs += {2} # set is iterable — [1, 2]
xs += (3, 4) # tuple is iterable — [1, 2, 3, 4]
xs += range(2) # range is iterable — [1, 2, 3, 4, 0, 1]
d: dict[str, int] = {}
d |= [("a", 1)] # iterable of pairs — {'a': 1}
s: set[int] = {1}
# s |= [2] # SPY0222 — python3 raises TypeError here too
s |= {2} # OK — set
Targets¶
Augmented collection assignment works on identifier, attribute (self.xs), and
index (d[k]) targets. The index expression is evaluated exactly once.
Narrowed receivers¶
If the receiver was narrowed via isinstance, augmented assignment is refused
(SPY0276) because the mutation may invalidate the narrowing. The steer suggests
rebinding through a temporary:
Immutable collections¶
For immutable types like frozenset, +=/|= etc. rebind the target (creating
a new object) rather than mutating — aliases are not updated.
Implementation
- ✅ Native - Direct mapping (except **= and //= which are lowered).
- 🔄 Augmented narrowing: the checker records NarrowTo on the assignment when
§12.21.4 admits the narrowing (narrow-or-equal RHS, in-range constant RHS, or
a shift), and the emitter casts the desugared value from that record — one
decision for every operator and every target kind.
- 🔄 In-place collection mutation: the classifier routes mutable collection
receivers to the corresponding mutation method instead of rebinding.