Function Default Parameters¶
Functions can specify default values for parameters. Parameters with defaults must come after required parameters.
def greet(name: str, greeting: str = "Hello") -> str:
return f"{greeting}, {name}!"
def connect(host: str, port: int = 8080, timeout: float = 30.0) -> Connection:
# ...
Compile-Time Constant Requirement¶
Default parameter values must be compile-time constants, matching C# semantics. This eliminates the "mutable default argument" pitfall from Python; the pattern simply isn't expressible in Sharpy.
Allowed default values¶
| Type | Examples | Notes |
|---|---|---|
| Numeric literals | 42, 3.14, 0xFF, 1_000_000 |
Any numeric literal |
| String literals | "hello", 'world', r"path\to\file" |
Including raw strings |
| Boolean literals | True, False |
|
None |
None |
Only for nullable parameter types (T \| None) |
None() |
None() |
Only for Optional parameter types (T?) |
| Enum values | Color.RED, HttpMethod.GET |
|
| Constant references | MAX_SIZE, DEFAULT_NAME, Holder.A |
Must reference a const declaration that is itself a compile-time constant, at any host |
| Negated literals | -1, -3.14 |
|
| Conditional of constants | 1 if DEBUG else 0 |
Both branches must be constants |
What is not a compile-time constant¶
A const declaration is a compile-time constant only when its declared type is C#-const-eligible
(a numeric primitive, char, str, bool, or an enum) and its initializer folds to a
constant expression without runtime calls.
The answer does not depend on where the const is declared. One analysis decides it for every host —
the module body, a function body or nested block, a class field, a struct field, and the body of a
nested type — so A, Holder.A and Outer.Holder.A all read the same fact, and the declaration
emits C# const exactly when the fact is true.
The following are not compile-time constants:
Call-lowered operators. The floor-division (//), floor-modulo (%), float exponentiation
(**), and string repetition (*) operators lower to runtime helper calls (FloorDiv, FloorMod,
Math.Pow, Repeat), not to C# constant operators. A default that uses one is refused:
# ❌ Call-lowered operators are not compile-time constants
def f(x: int = 7 // 2) -> None: ... # ERROR SPY0401: '//' lowers to a call
def g(x: int = 7 % 3) -> None: ... # ERROR SPY0401: '%' lowers to a call
def h(x: float = 2.0 ** 3.0) -> None: ... # ERROR SPY0401: float '**' lowers to Math.Pow
def k(x: str = "ab" * 2) -> None: ... # ERROR SPY0401: str '*' lowers to Repeat
# ✅ Native operators that fold at compile time
def ok(x: int = 7 * 2 - 1) -> None: ... # OK: arithmetic on int is a C# constant expression
def ok2(x: int = 2 ** 3) -> None: ... # OK: integer '**' folds to a constant
def ok3(x: str = "a" + "b") -> None: ... # OK: string concatenation folds
Call initializers. A const whose initializer is a function call (e.g. max(...)) is not a
compile-time constant. Referencing it from a default is refused:
const FM: float = max(4.0, 1.0) # legal declaration, but emits 'static readonly'
def f(t: float = FM) -> None: ... # ERROR SPY0401: 'FM' is not a compile-time constant:
# its initializer is a call
The same holds through a qualified spelling, and a plain (non-const) field is never a constant:
class Holder:
const A: int = max(1, 4) # legal declaration, emits 'static readonly'
@static
v: int = 1 # a field, not a const
def f(x: int = Holder.A) -> None: ... # ERROR SPY0401: 'A' is not a compile-time constant:
# its initializer is a call
def g(x: int = Holder.v) -> None: ... # ERROR SPY0401: 'v' is a field, not a const, so it is
# not a compile-time constant
Optional (T?) types. An Optional is a struct with case constructors (Some/None()), not a
C# primitive. A const of type T? is never a compile-time constant:
const O: int? = Some(1)
def g(o: int? = O) -> None: ... # ERROR SPY0401: 'O' is not a compile-time constant: an Optional
# ('int?') cannot be a compile-time constant;
# declare the parameter 'int? = None()' and
# coalesce in the body
The pattern for T? defaults is None() with a coalesce in the body (see
Pattern for Optional Mutable Arguments):
Examples¶
# ✅ Valid default parameters
def process(
name: str = "default",
count: int = 0,
factor: float = 1.0,
enabled: bool = True,
mode: Mode = Mode.NORMAL,
callback: Callable | None = None
) -> None:
pass
# ✅ Using None for optional parameters (recommended pattern)
def search(query: str, limit: int | None = None, offset: int | None = None) -> list[Result]:
actual_limit = limit ?? 100
actual_offset = offset ?? 0
# ...
# ✅ Referencing constants
const DEFAULT_TIMEOUT: float = 30.0
const DEFAULT_RETRIES: int = 3
def fetch(url: str, timeout: float = DEFAULT_TIMEOUT, retries: int = DEFAULT_RETRIES) -> Response:
# ...
# ❌ Invalid: mutable default values
def broken(items: list[int] = []) -> int: # ERROR: [] is not a compile-time constant
return sum(items)
def also_broken(config: dict[str, str] = {}) -> None: # ERROR: {} is not a compile-time constant
pass
def still_broken(point: Point = Point(0, 0)) -> None: # ERROR: constructor call is not constant
pass
# ❌ Invalid: tagged union case constructors are not compile-time constants
def bad_opt(x: int? = Some(42)) -> None: # ERROR SPY0401: use None() default, assign with ??=
pass
def bad_result(r: int!str = Ok(1)) -> None: # ERROR SPY0401
pass
def bad_tuple(t: tuple[int, int] = (1, 2)) -> None: # ERROR SPY0401
pass
The pattern for Some/Ok/Err defaults is to declare the parameter with None() and assign
inside the body:
def f(x: int? = None()) -> None:
x ??= Some(42) # assigns Some(42) only when x is None()
print(x)
def main():
f() # 42
f(Some(1)) # 1
Implementation
- A future lowering (option C: forward-overload synthesis) may lift this restriction for
Some/Ok/Err defaults; the ??= pattern is the stable idiom.
Pattern for Optional Mutable Arguments¶
Declare the parameter as an optional (T?, see Optional Type) with
None() as its default, and build the mutable object in a local — the optional parameter is
the input, the local is the list you work with:
def append_to(item: int, target: list[int]? = None()) -> list[int]:
result: list[int] = []
if target is not None:
result = target
result.append(item)
return result
def main():
print(append_to(1)) # [1]
print(append_to(2)) # [2] — a separate list, not [1, 2]
shared: list[int] = [0]
print(append_to(3, Some(shared))) # [0, 3]
print(shared) # [0, 3] — the caller's list, mutated in place
Each call that omits target gets a fresh list, which is the point of the pattern; a caller that
passes one gets it mutated, exactly as in Python.
Do not rebind the parameter itself. target = [] inside the function does not turn target
into a list: the name keeps its declared type list[int]? for the rest of the body, so the
following target.append(item) is SPY0229 (Type 'list[int32]?' has no member 'append'), and the
bare [] is not an Optional value either. Rebinding it as target = Some(fresh) does not help —
a store never re-narrows the name, so len(target) after it is SPY0326. Read the optional into a
local of the payload type instead, as above; the is not None branch narrows on the read.
Callers pass Some(value), not a bare value, because T? is constructed only by Some(…)/None()
(see Creating Optional Values). Use
target: list[int] | None = None instead only when the parameter exists to talk to C# nullable
APIs — Nullable Types has that split.
Implementation - ✅ Native - Direct mapping to C# optional parameters.
See Also¶
- Function Parameters - Overview of all parameter types
- Function Variadic Arguments - Variable-length argument lists (*args)
- Function Definition - Basic function syntax