Skip to content

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 a new name or an annotated declaration (x: T = ...) to shadow instead. 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:

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:

# Walrus in if-statement: variable persists in containing scope
if (match := pattern.search(text)) is not None:
    print(match)  # OK
print(match)      # OK - walrus assigned in containing scope

# 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:

x = 0
for i in range(5):      # 'i' is block-scoped
    x += i              # Modifies outer 'x'
print(x)                # 10
print(i)                # ERROR: 'i' is block-scoped

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 an annotated declaration:

def main():
    x: int = 1
    def shadow_it():
        x: str = "hello"  # New local — does NOT modify outer 'x'
        print(x)          # "hello"
    shadow_it()
    print(x)              # 1 — outer 'x' unchanged

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
        count: int = 99       # OK — annotated declaration creates a new local
        print(count)          # 99

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 typed shadowing local is still one annotation 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:
        v: int = 7            # OK — a typed declaration is 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

# 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

Variables can be redeclared in the same scope with a different type using explicit type annotation:

x: int = 5              # Initial declaration
x = 10                  # Assignment (same type)
x: str = "hello"        # Shadowing (new type, requires annotation)

# With auto keyword for type inference
x: int = 5
x: auto = "hello"       # Shadowing with inferred type

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. The emitter reads CodeGenInfo and TargetBinding and owns no local-slot state.