Lesson 06 / 14
Functions
Argument and return behavior in PHP functions: silent swallowing of extra arguments, errors for missing and unknown arguments, default values and static, passing by value and by reference, return types, and making the surplus visible with ...$x.
Published Updated
In this lesson
- Shows by measurement that an extra argument given to a user function is swallowed silently, while built-in functions reject it
- Tells apart which error a missing, unknown or colliding named argument produces
- Verifies the behavioral difference between a default value and a static variable across calls
- Shows by counting that ...$x can make the surplus visible, and shows how return types coerce
Contents
The Control Flow lesson measured that an unmatched branch passes silently in switch and halts execution in match. This lesson asks the same question of calls: when a function is given more arguments than it expects, where does the surplus go, and what happens when too few are given? The channel of this lesson is silent: an argument given in excess to a function the user wrote is ignored without producing any notice.
In PHP, a function declared with the function keyword returns a value with return; its parameters can carry types and default values. The concept of a function was established in the Programming Fundamentals course (M01/K02); here only how PHP behaves toward that concept is measured. The question of whether a change is visible to the caller and to others was measured separately for objects in M11/K03; this lesson mentions it only as far as needed for scalar and array values.
Extra Arguments
If a user function is given more arguments than it has parameters, PHP does not count the surplus as an error: the function still runs and the extra arguments are ignored. Built-in functions are strict on this. The file below gives three arguments to a two-parameter function and tries the same thing with the built-in strlen(); the error handler keeps a counter.
<?php // extra.php — a user function swallows the extra argument, a built-in rejects it $notices = 0; set_error_handler(function (int $level) use (&$notices): bool { $notices++; return true; }); function add_two($a, $b) { return $a + $b; } echo 'add_two(1, 2, 3): ', add_two(1, 2, 3), "\n"; try { $length = strlen('abc', 'x'); echo 'strlen(abc, x): ', $length, "\n"; } catch (ArgumentCountError $error) { echo 'strlen(abc, x): ', get_class($error), "\n"; } echo 'notices: ', $notices, "\n";
add_two(1, 2, 3): 3 strlen(abc, x): ArgumentCountError notices: 0
LE43. The expression add_two(1, 2, 3) returned 3, that is, the third argument took no part in the sum and nothing was said about it: the counter stayed at zero. The built-in strlen() answered the same surplus with an ArgumentCountError. This asymmetry is a design decision: not counting extra arguments as an error in user functions allows a function to later gain an additional optional parameter. The cost is that a typo in a call can be swallowed.
Missing and Unknown Arguments
Unlike the surplus, a missing argument is an error: if a required parameter is not filled, an ArgumentCountError is thrown. Besides passing arguments by position, PHP allows passing them by name (a named argument). The file below makes five calls: a missing one, a complete one, a named one, one with an unknown name, and one that renames a parameter already filled by position.
<?php // missing.php — ArgumentCountError rejects a missing argument, Error rejects an unknown name function greet(string $name, string $title = 'mr'): string { return $title . ' ' . $name; } $calls = [ 'greet()' => fn() => greet(), "greet('Ada')" => fn() => greet('Ada'), "greet(title: 'dr', name: 'Ada')" => fn() => greet(title: 'dr', name: 'Ada'), "greet('Ada', surname: 'X')" => fn() => greet('Ada', surname: 'X'), "greet('Ada', name: 'Ece')" => fn() => greet('Ada', name: 'Ece'), ]; foreach ($calls as $label => $call) { try { $text = var_export($call(), true); } catch (Error $error) { $text = get_class($error); } printf("%-34s%s\n", $label, $text); }
greet() ArgumentCountError
greet('Ada') 'mr Ada'
greet(title: 'dr', name: 'Ada') 'dr Ada'
greet('Ada', surname: 'X') Error
greet('Ada', name: 'Ece') Error
LE44. The missing argument gave an ArgumentCountError. When $title, which has a default value, was omitted, 'mr Ada' was produced. Named arguments worked independently of order: 'dr Ada' came out although title was written first. The name surname, which is not defined in the function, and the name parameter already filled by position each threw an Error. So an extra positional argument is silent and an extra named argument is loud; naming gives the user a stricter contract than the swallowing of extra arguments.
Default Values and static
A default value is recomputed on every call; that is, an empty array written as the default of a parameter is a new empty array on every call and does not carry the elements added by the previous call. A variable declared with static inside a function, on the other hand, lives across calls. The file below puts the two behaviors side by side.
<?php // defaults.php — the default is renewed on every call, static lives across calls function append(int $item, array $list = []): array { $list[] = $item; return $list; } function count_calls(): int { static $counter = 0; $counter++; return $counter; } echo 'append(1): ', count(append(1)), "\n"; echo 'append(2): ', count(append(2)), "\n"; count_calls(); count_calls(); echo 'count_calls(): ', count_calls(), "\n";
append(1): 1 append(2): 1 count_calls(): 3
LE45. append returned one element in both calls: the default empty array was renewed on every call. count_calls gave 3 on the third call: the static variable was not erased each time the function finished. The two show the same distinction from two directions: parameters and local variables belong to the call, a static variable belongs to the function. Neither produces a notice.
By Value and by Reference
PHP passes arguments by value by default: the function works on its own copy and does not touch the caller’s variable. If a parameter is declared with &, the function changes the caller’s variable. The file below makes the same change with two declarations and counts the caller’s array.
<?php // by_value.php — value copies, reference changes the caller's variable function by_value(array $list, int $number): void { $list[] = 99; $number++; } function by_reference(array &$list, int &$number): void { $list[] = 99; $number++; } $items = [1, 2]; $n = 10; by_value($items, $n); echo 'by_value: ', count($items), ' elements, n: ', $n, "\n"; by_reference($items, $n); echo 'by_reference: ', count($items), ' elements, n: ', $n, "\n";
by_value: 2 elements, n: 10 by_reference: 3 elements, n: 11
LE46. After the call passed by value, the array stayed at two elements and the number at ten: the function changed the copy. After the call passed by reference, the array had three elements and the number was eleven. The consequence of this distinction for objects was measured in M11/K03; what is observed here is that unless & is written, the language does not make a change visible to the caller. A reference parameter is read from the function’s signature: there is no mark at the call.
Return Values
A function that has no return returns null. If a return type is declared, PHP coerces the returned value to that type, just as with parameters: the coercion rule in the Variables and Data Types lesson works here too. The file below shows return behavior in four cases.
<?php // returns.php — null without return, the return type coerces or rejects function nothing() { } function to_int(): int { return '5'; } function broken(): int { return 'five'; } function blank(): void { } echo 'nothing(): ', var_export(nothing(), true), "\n"; echo 'blank(): ', var_export(blank(), true), "\n"; $result = to_int(); echo 'to_int(): ', var_export($result, true), ' ', get_debug_type($result), "\n"; try { broken(); } catch (TypeError $error) { echo 'broken(): ', get_class($error), "\n"; }
nothing(): NULL blank(): NULL to_int(): 5 int broken(): TypeError
LE47. The function without return returned null, and so did the one declared void. The function declared : int silently converted the string '5' to 5, while 'five' gave a TypeError because it could not be coerced. The return type therefore follows the same rule as the parameter type: a numeric string is coerced, otherwise it is rejected.
The Moment a Function Is Defined
PHP can know a function before a file starts executing, but not every function. A function declared unconditionally at the top level of the file is recognized while the file is read and can be called on a line before its declaration. A function declared inside an if block exists only when that block is executed. A call to a function that was never declared throws an Error. The file below measures the three cases with function_exists().
<?php // definition.php — a top-level function is known beforehand, a conditional one is defined when it runs echo 'before: ', var_export(function_exists('top_level'), true), "\n"; echo 'conditional: ', var_export(function_exists('conditional'), true), "\n"; echo 'call: ', top_level(), "\n"; function top_level(): string { return 'ran'; } if (true) { function conditional(): string { return 'x'; } } echo 'after: ', var_export(function_exists('conditional'), true), "\n"; try { no_such_thing(); } catch (Error $error) { echo 'undefined: ', get_class($error), "\n"; }
before: true conditional: false call: ran after: true undefined: Error
LE48. top_level, declared unconditionally, existed before its declaration line and the call worked. conditional, declared conditionally, did not exist before the block was executed and did exist afterward. An undefined name gave an Error, so it belongs to the halts class. What is silent is that, depending on the line on which the call is made, the same name works in one place and not in another: the existence of a function depends on the structure of the file, and the existence of a conditional one depends on execution order.
Closures and Captured Variables
An anonymous function (closure) cannot reach a variable in the outer scope on its own; it has to capture it explicitly with use. use ($x) copies the value at declaration time, while use (&$x) binds the variable itself. The arrow function fn captures the outer variable by value at the moment it is created. The file below measures the three capture forms by calling them after the captured variable has been changed.
<?php // closure.php — use captures the value, & binds the variable, an arrow function takes the value at creation $x = 1; $by_value = function () use ($x) { return $x; }; $by_reference = function () use (&$x) { return $x; }; $arrow = fn() => $x; $x = 2; echo 'use ($x): ', $by_value(), "\n"; echo 'use (&$x): ', $by_reference(), "\n"; echo 'fn: ', $arrow(), "\n"; $increase = fn() => $x++; $increase(); echo 'x after increment in fn: ', $x, "\n";
use ($x): 1 use (&$x): 2 fn: 1 x after increment in fn: 2
LE49. After $x was set to two, the two forms that capture by value saw the old value, 1; only the one bound with & saw 2. The increment inside the arrow function did not change the outer $x: the variable entered the arrow function as a copy. The distinction is the counterpart of passing by value and by reference in closures, and it produces no notice: code that picks the wrong form does not fail, it works with an old value.
A Parameter That Accepts Null
Being able to give null to a parameter is two separate things: the parameter’s type accepting null, and the parameter being omittable. ?int $x accepts null but is required; ?int $x = null both accepts null and can be omitted. A union type (int|string), in loose mode, chooses by an order which type to coerce to. The file below measures seven calls in a table.
<?php // nullable.php — ?int is required, = null can be omitted, a union type picks the coercion order function required(?int $x) { return var_export($x, true); } function optional(?int $x = null) { return var_export($x, true); } function union(int|string $x) { return get_debug_type($x); } $calls = [ 'required(null)' => fn() => required(null), 'required()' => fn() => required(), 'optional()' => fn() => optional(), "union('5')" => fn() => union('5'), 'union(5.0)' => fn() => union(5.0), 'union([])' => fn() => union([]), 'union(true)' => fn() => union(true), ]; foreach ($calls as $label => $call) { try { $text = $call(); } catch (Error $error) { $text = get_class($error); } printf("%-20s%s\n", $label, $text); }
required(null) NULL
required() ArgumentCountError
optional() NULL
union('5') string
union(5.0) int
union([]) TypeError
union(true) int
LE50. required(null) worked but required() gave an ArgumentCountError: ? provides null acceptance, not omittability. optional() returned NULL. In the union type a value with an exact match stayed as it was; where there was no exact match PHP followed an order and chose the result silently; and a value such as an array, which cannot be converted to any member, gave a TypeError. This is the counterpart in union types of the loose mode observation in the type declaration lesson.
A Called Function
A function given to another function as an argument (a callback) can be called by the caller with more arguments than its declaration expects. A user function swallows this surplus silently again; a built-in function does not. The file below runs array_filter() in the mode that sends two arguments with a single-parameter closure, and makes the same test with the built-in strlen through array_map().
<?php // callback.php — a called user function swallows the extra argument, a built-in does not $numbers = [1, 2, 3, 4]; $single = array_filter($numbers, fn($v) => $v > 2, ARRAY_FILTER_USE_BOTH); echo 'single parameter: ', implode(',', $single), "\n"; $double = array_filter($numbers, fn($v, $k) => $k % 2 === 0, ARRAY_FILTER_USE_BOTH); echo 'two parameters: ', implode(',', $double), "\n"; try { $result = array_map('strlen', ['a'], ['b']); echo 'strlen: ', count($result), "\n"; } catch (ArgumentCountError $error) { echo 'strlen: ', get_class($error), "\n"; }
single parameter: 3,4 two parameters: 1,3 strlen: ArgumentCountError
LE51. The single-parameter closure, called with two arguments, ran without ever seeing the key and gave 3,4. The two-parameter one used the key and selected the two elements with even indexes, 1,3. array_map(), called with two arrays, sent two arguments to strlen and received an ArgumentCountError. The same extra argument was silent in one place and halting in another, depending on the kind of callback.
The Bounding Measurement
The limit of silent swallowing in a user function is that making the surplus visible is possible. There are two ways: func_num_args() says how many arguments were actually given inside the function, while the ...$x syntax gathers all remaining arguments into an array, and the surplus shows up in the length of that array. The file below measures the ways with the same call.
<?php // visible.php — func_num_args and ...$x make the surplus visible function add_two($a, $b) { return $a + $b; } function count_args($a, $b) { return func_num_args(); } function sum(int ...$numbers): int { return array_sum($numbers); } function count_all(...$x): int { return count($x); } echo 'add_two(1, 2, 3): ', add_two(1, 2, 3), "\n"; echo 'count_args(1, 2, 3): ', count_args(1, 2, 3), "\n"; echo 'sum(1, 2, 3): ', sum(1, 2, 3), "\n"; echo 'count_all(1, 2, 3): ', count_all(1, 2, 3), "\n";
add_two(1, 2, 3): 3 count_args(1, 2, 3): 3 sum(1, 2, 3): 6 count_all(1, 2, 3): 3
LE52. add_two swallowed the third argument again and gave 3. func_num_args() counted 3 in the same call: the swallowed argument can be measured from inside the function. The function written with ...$numbers added all three numbers and gave 6; ...$x delivered the surplus as an array and count said 3. The silent channel is therefore not unavoidable: the author of a function can choose a signature in which the surplus will be visible.
Summary
- The channel of functions is silent: an extra positional argument given to a user function is ignored without a notice, while a built-in function throws an
ArgumentCountError. - A missing required argument gives an
ArgumentCountError, and an unknown or colliding named argument gives anError; naming is stricter than position. - A default value is renewed on every call, and a
staticvariable lives across calls. - Arguments are passed by value; the caller’s variable changes only if
&is written in the signature. - A function without
returnreturnsnull; a return type coerces a numeric string and rejects one that is not. func_num_args()and...$xmake the swallowed surplus visible.
Next Step
Where the extra argument goes was seen: it was ignored or gathered into an array. The Indexed and Associative Arrays lesson looks at that array: when two arrays are merged, how do the keys combine for elements that carry the same key?
To keep your progress and take notes, Log in
My notes
Log in to take notes.