Tuple Unpacking¶
Sharpy supports destructuring tuples and lists into individual variables across multiple contexts: assignments, for loops, and comprehensions.
Simple Tuple Unpacking¶
Unpack a tuple into variables by matching the number of elements:
The number of targets must match the number of tuple elements:
Implementation
- ✅ Native - C# tuple deconstruction: var (x, y) = point;
Redundant Parentheses Around Targets¶
Parentheses around a binding name never change what it binds — on every store-target route:
plain and augmented assignment, annotated declarations, nested tuple elements, starred operands,
for and comprehension targets, and with … as targets. The parser canonicalizes the target once,
so (a) = 1 is exactly a = 1 (the canonical-form contract, #1170; Python accepts every spelling):
for (x) in [1, 2]:
print(x) # 1, 2
for ((a), b) in [(3, 4)]:
print(a, b) # 3 4
(c): int = 5
((d), e) = (6, 7)
(f), g = 8, 9
*(h), i = [10, 11, 12]
print(h, i) # [10, 11] 12
print([y for (y) in [13, 14]]) # [13, 14]
The shapes Python rejects stay rejected: (*a), b = xs ("cannot use starred expression here"),
except E as (e), and a parenthesized walrus target ((a) := 1).
List-Display Targets¶
A list display on the left side of an assignment is accepted as a tuple target — [a, b] = (1, 2)
is identical to (a, b) = (1, 2). The parser normalizes the list display to a tuple target at the
one store-target seam, so every downstream position (assignment, for, comprehension, with … as)
accepts it uniformly:
Augmented assignment onto a list display stays refused (Python SyntaxError for both tuple and
list displays):
Starred Targets in All Positions¶
A starred target (*rest) works in every unpacking position — assignment, for statement,
comprehension for clause, and with … as — with one arity rule: a star absorbs zero or more
remaining elements. The source can be a tuple or a list[T]:
def main() -> None:
# Assignment
first, *rest = [1, 2, 3]
print(first, rest)
# For statement
for a, *tail in [(1, 2, 3), (4, 5, 6)]:
print(a, tail)
# Comprehension
heads = [h for h, *_ in [(10, 20, 30), (40, 50, 60)]]
print(heads)
The with … as position takes the same starred target and the same arity rule, including the
parenthesized spelling:
class Triple:
vals: tuple[int, int, int]
def __init__(self, a: int, b: int, c: int) -> None:
self.vals = (a, b, c)
def __enter__(self) -> tuple[int, int, int]:
return self.vals
def __exit__(self) -> None:
pass
def main() -> None:
with Triple(1, 2, 3) as (a, *rest):
print(a, rest)
with Triple(1, 2, 3) as (a, b, *rest):
print(a, b, rest)
An arity error names the position as a suffix — … in with statement, … in for loop,
… in comprehension for clause — so one wording covers every position (SPY0239).
The sole-starred group¶
A group whose only target is the star is legal in its comma spelling (*a,) and its list-display
spelling [*a] — both bind the whole sequence to a list, in every position:
def main() -> None:
(*a,) = (1, 2)
print(a) # [1, 2]
[*b] = (3, 4)
print(b) # [3, 4]
for (*c,) in [(5, 6), (7, 8)]:
print(c) # [5, 6] then [7, 8]
The bare parenthesized form (*a) — no trailing comma, not a list display — is a Python
SyntaxError and is refused with Python's own wording, in every position (SPY0225):
def main() -> None:
xs: list[tuple[int, int]] = [(1, 2)]
(*a) = xs # error SPY0225: cannot use starred expression here
(*a), b = xs # error SPY0225: cannot use starred expression here
for (*a) in xs: # error SPY0225: cannot use starred expression here
pass
Nested Tuple Unpacking¶
Targets can themselves be tuple patterns, enabling nested destructuring:
Nesting can be arbitrarily deep:
t: tuple[tuple[int, tuple[int, int]], int] = ((1, (2, 3)), 4)
(a, (b, c)), d = t
print(a) # 1
print(b) # 2
print(c) # 3
print(d) # 4
Each nested target must match the structure and element count of the corresponding tuple element.
Stars at nested depth¶
The star rule holds at every depth: a star inside a nested target absorbs its slice exactly as a
top-level star does, in all four positions. Two stars at the same depth are refused with Python's
wording (SPY0356, multiple starred expressions):
def main() -> None:
a, (b, *c) = (1, (2, 3, 4))
print(a, b, c) # 1 2 [3, 4]
(a2, *rest), d = ((1, 2, 3), 4)
print(a2, rest, d) # 1 [2, 3] 4
a3, (b3, (c3, *d3)) = (1, (2, (3, 4, 5)))
print(a3, b3, c3, d3) # 1 2 3 [4, 5]
for x, (y, *z) in [(1, (2, 3, 4))]:
print(x, y, z) # 1 2 [3, 4]
Implementation
- 🔄 Lowered - Temporary variables with .Item1, .Item2, etc. access:
Rest Patterns (*rest)¶
A starred expression collects remaining elements into a list:
items: list[int] = [1, 2, 3, 4, 5]
# Collect tail
first, *rest = items
print(first) # 1
print(rest) # [2, 3, 4, 5]
# Collect head
*rest, last = items
print(rest) # [1, 2, 3, 4]
print(last) # 5
# Collect middle
first, *mid, last = items
print(first) # 1
print(mid) # [2, 3, 4]
print(last) # 5
Rules:
- Only one starred expression is allowed per unpacking
- The starred variable is always typed as list[T] where T is the element type of the source
- Works with both lists and tuples as the source
Implementation - 🔄 Lowered - Index access and slicing:
var __t0 = items;
var first = __t0[0];
var mid = __t0.GetSlice(new global::Sharpy.Slice((int?)1, (int?)-1));
var last = __t0[-1];
Tuple Unpacking in For Loops¶
Iterate over collections of tuples with destructuring:
pairs: list[tuple[str, int]] = [("alice", 1), ("bob", 2)]
for name, score in pairs:
print(f"{name}: {score}")
# alice: 1
# bob: 2
Nested unpacking is also supported in for loops:
items: list[tuple[tuple[int, int], str]] = [((1, 2), "a"), ((3, 4), "b")]
for (x, y), label in items:
print(f"{label}: {x + y}")
# a: 3
# b: 7
Implementation
- ✅ Native (simple case) - C# foreach (var (name, score) in pairs)
- 🔄 Lowered (nested case) - Temporary loop variable with .Item1, .Item2 access
Tuple Unpacking in Comprehensions¶
List, set, and dict comprehensions support tuple unpacking in their for clauses:
pairs: list[tuple[int, int]] = [(1, 2), (3, 4), (5, 6)]
sums = [a + b for a, b in pairs]
print(sums) # [3, 7, 11]
Nested unpacking works in comprehensions as well:
items: list[tuple[tuple[int, int], str]] = [((1, 2), "a"), ((3, 4), "b")]
result: list[str] = [name + ":" + str(x + y) for (x, y), name in items]
print(result) # ["a:3", "b:7"]
Implementation - 🔄 Lowered - Lambda with temporary variable destructuring:
Type Inference¶
Unpacking targets are automatically inferred from the source type:
| Source Type | Target Inference |
|---|---|
tuple[int, str] |
First target: int, second: str |
list[tuple[int, str]] (in for loop) |
Loop targets: int, str |
*rest from list[T] |
Starred target: list[T] |
*rest from tuple[T, ...] |
Starred target: list[T] (uses first element type) |
Nested tuple targets recurse into the corresponding element type and validate structure at each level.
Constructing Tuples: No tuple(iterable)¶
A tuple's arity is part of its type — tuple[int, str] lowers to a .NET ValueTuple with exactly
two fields. Python's tuple(iterable) produces a tuple as long as the iterable, which is a runtime
property, so there is no type to give the result. The form is rejected (SPY0338):
xs: list[int] = [1, 2, 3]
t = tuple(xs) # ❌ ERROR SPY0338: variable-length tuple(iterable) is not supported
Use list(...) when the length is a runtime value, or a tuple literal when the arity is known:
xs: list[int] = [1, 2, 3]
ys: list[int] = list(xs) # runtime length
t: tuple[int, int, int] = (xs[0], xs[1], xs[2]) # known arity
If the argument is already a tuple, the conversion is redundant — drop the call. Tuple literals, explicitly parameterized construction, and every unpacking form above are unaffected.
Deliberate CPython divergence (Axiom 1/3 over Axiom 2): explicitly parameterized construction converts elements to the written element types —
tuple[float, str]((1, "a"))is(1.0, 'a'). CPython'stuple[float, str]is a baretypes.GenericAliaswhose call ignores the parameters and would keep1anint. Sharpy treats the written annotation as the type authority, so the element widens exactly asx: float = 1does (#1200; pinned inParameterizedTupleConversionTests).
Element Stores Use the Target's Declared Type¶
When unpacking a tuple literal into targets that already have a declared type, each
element is a store into the target's declared slot. The element expression sees the
declared type as its expected type, so constructor shorthands like Some(v) and None()
can infer their type parameter from it:
class Box:
v: int? = None()
def main() -> None:
b: Box = Box()
n: int = 0
b.v, n = Some(5), 1 # Some(5) infers int? from b.v's declared type
print(b.v, n) # 5 1
Similarly, bare None into a nullable target adopts the target's type:
def main() -> None:
x: str | None = "hello"
x, n = None, 1 # None stores into str | None
print(x is None) # True
The R-T payload rule applies to identifier targets that are narrowed: inside a
narrowing block (if x is not None:), storing a payload value into a narrowed T?
variable wraps it — mirroring the behavior of plain assignments:
def main() -> None:
d: int? = Some(10)
if d is not None:
d, n = 5, 2 # 5 wraps to Some(5) — d stays int?
print(d, n) # 5 2
Refusals use the store seam's diagnostic codes: SPY0604 (strict Optional construction),
SPY0229 (None into non-nullable), not a generic type-mismatch.
When unpacking creates a fresh binding (a new variable, not a re-assignment to an existing one), a None element is refused — it has no type on its own:
Annotate the target or use a declared-slot variable:
Error Cases¶
| Scenario | Diagnostic |
|---|---|
| Element count mismatch | SPY0239: Cannot unpack N values into M variables |
| Unpacking a non-tuple type | SPY0239: Cannot unpack non-tuple type |
| Multiple starred expressions | SPY0356: Only one starred expression allowed |
tuple(iterable) construction |
SPY0338: variable-length tuple(iterable) is not supported |
See Also¶
- Spread Operator — Spreading collections with
*and** - Comprehensions — List, dict, and set comprehensions
- For Statement — For loop syntax