L1.4 — Functions, Lambdas & Callable¶
What you'll learn
- Declare functions with typed parameters, default arguments, and return types (
-> Type,-> void). - Recognize
staticfunctions and variables that belong to the class, not an instance. - Write a lambda (anonymous function) and know that it captures locals by value.
- Treat a function as a value via
Callable, the type signals connect to.
How it applies
- A typed signature defends the function at its door. Untyped parameters let any value in, so a bad argument fails somewhere deep inside the body, far from the caller that supplied it. Typed parameters reject the bad call at the call site, with a message pointing at the actual mistake. The type is an input-domain contract — equivalence partitioning written into the signature.
- A function with no declared return type hides its own contract. Callers cannot tell whether it returns a number, a node, or nothing. Annotating the return makes the function self-documenting and lets the compiler check that callers use the result correctly.
- Lambda capture freezes a snapshot, not a live link. GDScript lambdas copy the locals they reference at creation time, so a lambda built before a value changes keeps the old value and never tracks later updates — code that expects a captured local to reflect its later state silently runs on stale data, with no error. (The same rule means the famous JavaScript "closure captured the loop variable by reference" bug does not occur here: each lambda gets its own copy.)
- Passing a reference type hands over the original. A function that takes an
Arrayand mutates it changes the caller's array, because the argument is a shared handle, not a copy. Knowing which arguments are shared and which are copied prevents action-at-a-distance bugs (the full rule is L1.5).
Concepts¶
Declaring a function¶
func declares a function; parameters and the return value can be typed:
func take_damage(amount: int) -> void:
current = max(current - amount, 0)
func distance_to_origin(p: Vector2) -> float:
return p.length()
-> void says the function returns nothing; -> float promises a float, and the compiler checks
that every return matches and that callers treat the result as a float. Type your parameters and
returns for the same reason you type variables (L1.2): the signature becomes a contract enforced
before the game runs. You know how to write functions; the GDScript habit is the func keyword, the
-> return type, and typing the parameters.
Default arguments¶
A parameter can have a default, used when the caller omits it:
func heal(amount: int = 1) -> void:
current = min(current + amount, max_health)
heal() # amount = 1
heal(25) # amount = 25
The default expression is evaluated on each call that omits the argument, so there is no "shared mutable default" trap. Parameters with defaults must come after parameters without them.
static — belonging to the class, not the instance¶
A static function or variable belongs to the class itself; it needs no instance and cannot touch
instance members:
Call it as MyClass.clamp01(value). Use static for pure helpers (math, conversions) that depend
only on their arguments. A static var is one shared value across all instances — useful, and easy
to overuse; reach for it only when the data genuinely belongs to the class as a whole.
Lambdas — functions as values¶
A lambda is an unnamed function written inline. Store it in a variable and call it with .call():
The trap worth internalizing now: a lambda captures the local variables it uses by value, at the moment the lambda is created. It does not see later changes to those locals.
Example
Capture-by-value, made concrete:
var n := 1
var show := func() -> void: print(n)
n = 99
show.call() # prints 1 — the lambda captured the value 1, not the variable n
show froze n's value (1) when it was created; reassigning n afterward does not reach into
the lambda. The classic version of this bug is building several lambdas inside a loop and expecting
each to remember a different counter value — they each capture whatever the counter was when that
lambda was made, which may not be what you assumed. When you need the current value, pass it as an
argument instead of capturing it.
Callable — the type of "a function you can pass around"¶
A reference to a function — named or lambda — is a Callable. This is the type signals connect to
(you declare signals in L2.3 and wire them in G2.6):
func _on_pressed() -> void:
print("clicked")
# elsewhere: button.pressed.connect(_on_pressed)
# _on_pressed (no parentheses) is a Callable — a reference to the function,
# not a call of it. The signal will call it later.
Writing _on_pressed (no parentheses) yields the Callable; writing _on_pressed() calls it now.
That distinction — reference versus call — is the whole basis of connecting behavior to events, so it
is worth fixing in your mind here, before signals make it load-bearing.
Arguments: copied or shared¶
How an argument is passed depends on its type. Value types (int, float, bool, String,
Vector2, …) are copied in — the function gets its own copy, and changing the parameter does not
touch the caller's variable. Reference types (Array, Dictionary, objects) are passed as a
shared handle — the function and the caller point at the same object, so mutations are visible on both
sides. This is the single most important semantics rule in the language, and L1.5 gives it a
chapter; for now, know that handing an Array to a function hands over the original.
Walkthrough¶
Use your L1.1 scene and script.
- Write a typed function
func add(a: int, b: int) -> int:that returnsa + b, and call it from_ready, printing the result. Then call it with aStringargument (add("x", 2)) and read the parse-time error — the typed parameter rejecting the bad call. - Give
adda default forb(b: int = 0) and call it with one argument. Confirm the default is used. - Reproduce the lambda capture trap: set
var n := 1, create a lambda that printsn, reassignn = 99, then call the lambda. Confirm it prints1. Then rewrite the lambda to takenas an argument and pass99at call time; confirm it prints99. - Write
func _on_thing() -> void: print("ran"). In_ready, storevar c := _on_thing(no parentheses) and thenc.call(). Confirm "ran" prints — you held the function as aCallableand invoked it later.
Optional sanity check
Add a function func grow(a: Array) -> void: a.append(99). In _ready, make var nums := [1, 2],
call grow(nums), then print nums. The 99 is there — the function mutated your array, because
arrays are shared, not copied. Do the same with an int parameter and confirm the caller's int is
untouched. That contrast is the whole of L1.5 in two experiments.
Self-check quiz¶
Q1 — What does -> void declare about a function?
A. That it takes no parameters. B. That it returns nothing. C. That it is a static function. D. That it cannot be called from other scripts.
Reveal answer
B. -> void is the return-type annotation meaning "returns no value." A confuses the return
type with the parameter list (an empty () declares no parameters). C is static, a separate
keyword. D invents an access restriction GDScript's return type does not impose.
Q2 — var n := 1, then var f := func(): print(n), then n = 99, then f.call(). What prints?
A. 99, because the lambda reads n when called.
B. 1, because the lambda captured n's value when it was created.
C. 0, because lambdas reset captured variables.
D. Error — lambdas cannot use outer variables.
Reveal answer
B. GDScript lambdas capture locals by value at creation time, so f holds 1 and the later
n = 99 does not reach it. A describes capture-by-reference, which GDScript lambdas do not do.
C and D invent behaviors — captures are neither zeroed nor forbidden.
Q3 — button.pressed.connect(_on_pressed) — why is there no () after _on_pressed?
A. It is a typo; it should be _on_pressed().
B. _on_pressed without () is a Callable (a reference); the signal calls it later, rather than
you calling it now.
C. The parentheses are optional and make no difference.
D. connect strips the parentheses automatically.
Reveal answer
B. Bare _on_pressed is a Callable — a handle to the function — which is what connect
stores to invoke when the signal fires. Writing _on_pressed() would call the function
immediately and pass its return value instead. A and C miss that reference-versus-call is the
point; D invents behavior connect does not have.
Integration question¶
Q4 — open
You want each of three buttons to print its own index (0, 1, 2) when clicked, and you write:
for i in range(3): buttons[i].pressed.connect(func(): print(i)). A teammate coming from
JavaScript warns that this is the classic "closure captures the loop variable" bug and that every
button will therefore print 2. Using this chapter's rule about how GDScript lambdas capture, say
what actually happens when the buttons are clicked, why, and what it implies about porting closure
idioms from other languages.
Reveal expected answer
It works as intended: the buttons print 0, 1, and 2 respectively, and there is no bug to
fix. GDScript lambdas capture the locals they use by value, at the moment each lambda is
created, so the lambda built when i is 0 carries its own copy of 0, the next carries
1, and the last 2. Each Callable holds an independent snapshot, and clicking a button later
reads that frozen copy — func(): print(i) is a valid Callable the signal stores and invokes
on click. The teammate's warning describes JavaScript's var, which captures the variable by
reference, so all closures share one binding that ends at the loop's final value — which is why
the JS version prints 2, 2, 2. GDScript's by-value capture is exactly the semantics the
n = 99 example showed, applied once per iteration. The lesson: capture-by-value is a real
difference between languages, so a closure idiom that is buggy in one can be correct in another —
verify the capture rule of the language you are actually in rather than importing a remembered
gotcha. (If you genuinely wanted every lambda to share one value, you would have to share state
deliberately — capture a single array or object and mutate that.)