Variable Scoping Rules¶
No global or nonlocal Keywords¶
Sharpy does not support Python's global or nonlocal keywords. This aligns with C# scoping semantics:
# ❌ Invalid - these keywords don't exist in Sharpy
global x # ERROR: unexpected 'global'
nonlocal y # ERROR: unexpected 'nonlocal'
Assignment to a name bound in an enclosing scope — including an enclosing function's local — writes through to that binding; use let (let x = ...) or a new name to shadow instead. In Stage 1 an annotated declaration (x: T = ...) also shadows (#1974). This applies uniformly to blocks and nested functions (C# closure semantics).
Block Scoping¶
Sharpy uses C#-style block scoping: all compound statement bodies introduce a new scope. Variables declared inside a block are not visible outside it. This is a deliberate departure from Python, where variables leak out of most blocks.
Block-Scoped Compound Statements (variables declared inside don't leak):
- if / elif / else bodies
- while body
- for body (including the loop variable itself)
- try body
- except body (including the as binding)
- else body (in try/except/else)
- finally body
- with body
- Comprehensions (including walrus assignments inside comprehensions)
Note on try/except/else/finally: Variables declared in the try body are not visible in except, else, or finally handlers. If a variable must be accessible across all clauses, declare it before the try statement:
def risky_operation() -> int:
return int("42")
result: int = 0
try:
result = risky_operation()
except ValueError as e:
print(f"Failed: {e}")
finally:
print(f"Result was: {result}")
Containing-Scope Constructs (variable persists):
- Declarations in a function body or module top-level (outside any compound statement)
- Walrus operator (x := value) in non-block contexts - see Walrus Operator
Walrus Operator Scoping:
The walrus operator (:=) assigns to the containing scope. In most cases this is the enclosing function or module. However, inside block-scoped constructs like comprehensions, the walrus variable is scoped to that block:
import re
def main() -> None:
pattern = re.compile("[0-9]+")
text = "order 66"
# Walrus in if-statement: variable persists in containing scope
if (m := pattern.search(text)) is not None:
print(m) # OK
print(m) # OK - walrus assigned in containing scope
def transform(v: int) -> int:
return v - 1
def main() -> None:
items = [1, 2, 3]
# Walrus in comprehension: variable is comprehension-local
results = [y * 2 for x in items if (y := transform(x)) > 0]
print(y) # ERROR: 'y' does not exist in this scope
Note: This differs from Python 3.8+, where walrus in comprehensions leaks to the outer scope. In Sharpy, the syntactic boundary equals the semantic boundary—comprehension delimiters ([...], {...}) which fully contain all variables declared within.
Example¶
x = "outer"
for x in range(5): # New 'x' shadows outer, block-scoped
print(x) # Prints 0, 1, 2, 3, 4
print(x) # Prints "outer", 'x' was shadowed only
# in the for-loop, and not modified.
Write-Through Assignment¶
Assignment to a name that already exists in an enclosing scope writes through to it — no nonlocal keyword needed:
This applies to nested functions too (C# closure semantics — captured by reference):
def main():
n: int = 1
def double_it():
n = n * 2 # Writes through to outer 'n'
double_it()
print(n) # 2
count: int = 0
adder = lambda: count + 1 # Reads outer 'count'
count = 10
print(adder()) # 11 — lambda sees the updated value
To create a new local that shadows an outer name, use let:
def main():
let x = 1
def shadow_it():
let x = 2 # New local — does NOT modify outer 'x'
print(x) # 2
shadow_it()
print(x) # 1 — outer 'x' unchanged
A bare x = 2 in shadow_it would instead write through and print 2 twice. In Stage 1 an
annotated declaration (x: int = 2) also creates the new local (#1974).
Class-Body Names Are Not Visible by Bare Name Inside Methods¶
Class and struct bodies define their own scope, but this scope is not a closure scope for methods. Methods cannot read or write class-body names by bare name — they must use self.name, or ClassName.name for a const or @static member. This matches Python's class-scope semantics. The rule is about members, not about the lines of the body in front of you: a field or property the class inherits, and a property the body declares, are members too and get the same treatment (see Inherited members and properties).
class Counter:
count: int = 0
def increment(self) -> None:
count = count + 1 # ERROR (SPY0606) — bare store to class attribute
print(count) # ERROR (SPY0200) — bare read of class attribute
def correct(self) -> None:
self.count += 1 # OK — instance attribute via self
let count = 99 # OK — 'let' creates a new local
print(count) # 99
The SPY0606 refusal steers to the member spelling first (self.count = ... for an instance
attribute, ClassName.count = ... for a const or @static one), then to let count = ... for a
new local, then to the Stage 1 annotated count: <type> = ....
ClassName.name is the spelling for a type-level member — a const or an @static field. An
instance field reached through the type name is a different error (SPY0290), so the diagnostic
offers ClassName.name only when it compiles:
class Registry:
@static
total: int = 0
@static
def bump() -> None:
total += 1 # ERROR (SPY0606) — "Use 'Registry.total = ...'"
Registry.total += 1 # OK — @static field via the class name
Every store form is refused, not only the plain one¶
A bare name in a class body is a member in every write position, so each is refused by name rather than silently declaring a local:
class Counter:
count: int = 0
def forms(self) -> None:
count = 1 # SPY0606 — plain store
count += 1 # SPY0606 — augmented store
print(count := 1) # SPY0606 — walrus
count, n = 1, 2 # SPY0606 — tuple-unpacking element
count ??= 1 # SPY0606 — null-coalescing store
A bare store to a class const says so, and offers only the shadowing local — a constant cannot be
assigned through any spelling:
class C:
const K: int = 1
def m(self) -> None:
K = 2 # SPY0606 — "'C.K' is a constant and cannot be assigned"
Which bodies the rule applies to¶
Every function-like body: methods, property accessors, event accessors, property observers, nested
defs, and lambdas — including a lambda in a class-field initializer, which has no self at all.
class Config:
name: str = "default"
property get label(self) -> str:
return self.name # OK
# return name # SPY0200 — bare read of a class attribute
def make_greeter(self) -> () -> str:
return lambda: self.name # OK — the lambda reaches the field through self
Inherited members and properties are members too¶
A member is a member whether the enclosing body declares it or a base class's body does, and
whether it is a field or a property. The bare read is SPY0200 with the same self. steer, and the
bare store is SPY0606 in every store form — the refusal names the inheritance so the reader is not
sent looking for a declaration in the body in front of them. In C# a bare v = 7 in a method
writes the inherited field; in Python it declares a local; a Sharpy program that spells it is a
mistake either way, and the shadowing local is still one let away:
class Base:
v: int = 5
property get label(self) -> str:
return "base"
class Derived(Base):
def m(self) -> int:
v = 7 # SPY0606 — "Cannot assign to inherited class attribute 'v' by bare name"
label = "x" # SPY0606 — a property is a member too
print(v, label) # SPY0200 — bare reads, steered to 'self.v' and 'self.label'
return self.v # OK
def shadow(self) -> int:
let v = 7 # OK — 'let' declares a new local
print(v) # 7
return self.v # 5 — the inherited field is untouched
An inherited const or @static field is steered to the declaring class's name, which is the
spelling that compiles: Base.K, not Derived.K.
Parameter defaults still see the class body¶
A parameter default belongs to the signature, which is resolved in the scope that declares the method — the class body — not in the method body:
class Grid:
const SIZE: int = 8
def resize(self, n: int = SIZE) -> None: # OK — the default is resolved in class scope
print(n)
The same holds for decorator arguments and type annotations. Names in the method's body are resolved by the rule above.
Pattern heads are not reads¶
A case head naming a class const matches that constant; it does not degrade into a capture:
class C:
const A: str = "x"
def m(self, v: str) -> None:
match v:
case A: # matches C.A, with SPY0468 noting the constant
print("hit")
case _:
print("miss")
Nested types and module names remain visible inside methods — the rule applies only to variable and constant bindings in a class or struct body, not to type declarations or to names from the enclosing module or global scope. When a module-level variable and a class attribute share a name, a bare use inside a method binds the module variable, exactly as Python does.
Assignment Statement¶
# Simple assignment
x = 10
count = 1
# Multiple assignment (unpacking)
x, y = 10, 20
# Augmented assignment
x += 5
count *= 2
Sibling-Block Redeclaration¶
Each block scope is independent — the same name can be reused across sibling blocks without collision:
def main():
try:
x = 1
print(x) # 1
except Exception:
x = 2
print(x) # 2 (different 'x')
finally:
x = 3
print(x) # 3 (different 'x')
# After the try, 'x' from any block is not visible:
x = 99 # New outer declaration
print(x) # 99
Variable Shadowing¶
let always introduces a new variable, so a name can be redeclared in the same scope, with the same
or a different type; the new variable shadows the old one for the rest of the scope:
def main():
let x = 5 # Initial declaration
x = 10 # Assignment (same type)
let x = "hello" # Shadowing: a new variable of type str
print(x) # hello
Stage 1 also accepts the keywordless shadowing forms, which Stage 2 retires (#1974): an annotated
declaration shadows, and x: auto = e is the deprecated spelling of let x = e:
def main():
x: int = 5
x: str = "hello" # Stage 1: an annotated declaration shadows
x: auto = [1, 2, 3] # Deprecated: write 'let x = [1, 2, 3]'
print(x) # [1, 2, 3]
Implementation:
- 🔄 Lowered — LocalNameAllocator assigns C# spellings with monotonic integer versioning (x, x_1, x_2) during CodeGenInfoComputer.ComputeForModule. Rebinding chains link each redefinition to its predecessor; chain members share the root's spelling. A let target never joins a chain: let re-points the trigger that starts a new binding (formerly only the annotation), and the allocator is unchanged — a let shadow is versioned exactly like an annotated one. The emitter reads CodeGenInfo and TargetBinding and owns no local-slot state.