Skip to content
academia.sh

Lesson 05 / 14

Control Flow

How unmatched branches behave in PHP: the difference between switch and match, loose and strict comparison, the lifetime of loop variables, the foreach and reference trap, and a default arm in match turning a halt into silence.

Published Updated

In this lesson

  • Tells apart, by measurement, the result of an unmatched switch and an unmatched match branch
  • Shows with the same inputs that switch compares loosely and match compares strictly
  • Verifies how the levels of break and continue change the result in nested loops
  • Measures the residue that foreach with a reference leaves in the array and shows how it is cleared
Contents

The Operators lesson measured what operators that bring types together report and what they silence. This lesson asks the same question of branches: when none of a condition, a case or a match arm matches, that is, when an unmatched branch is left, what does the language do? The answer differs between the two statements, and the channel of this lesson is halts: at least one statement cuts execution if there is no match.

PHP has if, elseif and else for conditional branching, switch and match for multiple selection, and for, while, do while and foreach for repetition. The logic of these structures was established in the Programming Fundamentals course (M01/K02); here only PHP’s matching and exit behavior is measured. Each file sets up an error handler, and the handler makes a signal visible if one arrives.

The Unmatched Branch: switch and match

switch compares a value with the case labels in order, and if none matches and there is no default, it passes on without doing anything. match does the same job as an expression and returns a value; if no arm matches, it throws an UnhandledMatchError. The file below passes three values through the two statements. The $result variable starts each round with a previous value; what that value is in the unmatched branch is what is measured.

<?php
// unmatched.php — switch passes silently, match halts
$notices = 0;
set_error_handler(function (int $level) use (&$notices): bool {
    $notices++;
    return true;
});

foreach ([1, 2, 3] as $code) {
    $result = 'previous';
    switch ($code) {
        case 1:
            $result = 'one';
            break;
        case 2:
            $result = 'two';
            break;
    }
    echo "switch($code): $result\n";

    $result = 'previous';
    try {
        $result = match ($code) {
            1 => 'one',
            2 => 'two',
        };
        echo "match($code):  $result\n";
    } catch (UnhandledMatchError $error) {
        echo "match($code):  ", get_class($error), ' (result: ', $result, ")\n";
    }
}
echo 'notices: ', $notices, "\n";
switch(1): one
match(1):  one
switch(2): two
match(2):  two
switch(3): previous
match(3):  UnhandledMatchError (result: previous)
notices: 0

LE34. In the third round switch did nothing: the variable stayed at its previous value and the notice counter did not rise. On the same input match threw UnhandledMatchError, and because the assignment did not take place, $result again stayed at its previous value; the difference is that in this case the language no longer stays silent. The match expression defines a value that does not match as an error; switch counts it as a normal execution path.

Loose and Strict Comparison

The second difference between the two is the tightness of the comparison. switch compares labels with ==, that is, loose equality; match compares arms with ===, that is, strict equality. The same input can go to different arms in the two statements. The file below gives five inputs to the two statements; the label and arm lists are the same.

<?php
// comparison.php — switch compares loosely, match compares strictly
$inputs = [
    ["'1'", '1'],
    ['null', null],
    ["''", ''],
    ['0', 0],
    ["'a'", 'a'],
];
printf("%-6s%-13s%s\n", 'input', 'switch', 'match');
foreach ($inputs as [$name, $input]) {
    switch ($input) {
        case 1:
            $s = 'case 1';
            break;
        case false:
            $s = 'case false';
            break;
        case 'a':
            $s = "case 'a'";
            break;
        default:
            $s = 'default';
    }
    $m = match ($input) {
        1 => 'arm 1',
        false => 'arm false',
        'a' => "arm 'a'",
        default => 'default',
    };
    printf("%-6s%-13s%s\n", $name, $s, $m);
}
input switch       match
'1'   case 1       default
null  case false   default
''    case false   default
0     case false   default
'a'   case 'a'     arm 'a'

LE35. The string '1' went to case 1 in switch, while in match it did not fit the 1 => arm and fell to default. null, the empty string and 0 went to case false in switch, because in loose equality they are equal to false; in match none of them fit the false => arm. Only 'a' went to the same place in both statements. This is the reflection in control flow of the equality table in the Operators lesson: switch inherits all the surprises of loose equality without producing a notice.

Exits in Nested Loops

break ends a loop, continue moves to the next round, and both can take a number: break 2 leaves two nested loops at once, continue 2 moves to the next round of the outer loop. The language does not accept this number outside a valid level limit, but what the number skips is a detail the programmer has to read. The file below runs three nested cases and also prints the value of the loop variables after the loops end.

<?php
// loop_exit.php — break 2 and continue 2 affect the outer loop
$pairs = [];
for ($i = 1; $i <= 3; $i++) {
    for ($j = 1; $j <= 3; $j++) {
        if ($j === 2) {
            continue;
        }
        if ($i === 2) {
            continue 2;
        }
        if ($i === 3 && $j === 3) {
            break 2;
        }
        $pairs[] = $i . $j;
    }
}
echo implode(',', $pairs), "\n";
echo "i: $i j: $j\n";
11,13,31
i: 3 j: 3

LE36. The three pairs in the output show the effect of each exit: when j equals two the inner round was skipped, when i equals two the rest of the outer round was skipped, and when i is three and j is three both loops ended at once. The last line shows that in PHP a loop variable lives on after the loop ends: i and j are still defined and keep the values they had at the moment of exit. The scope of a loop variable is not the loop but the function that contains it; this can leave residue for code that later uses the same name.

foreach and the Reference Trap

foreach copies the elements of an array one by one into a variable; if the copy is changed, the array is not affected. If the variable is bound as a reference with &, the array changes, and after the loop ends this reference stays alive. A second foreach that reuses the same name without clearing the reference overwrites the last element of the array. The file below measures three cases.

<?php
// reference.php — a foreach reference lives on after the loop
$a = [1, 2, 3];
foreach ($a as $v) {
    $v *= 10;
}
echo 'copy:           ', implode(',', $a), "\n";

$b = [1, 2, 3];
foreach ($b as &$w) {
    $w *= 10;
}
foreach ($b as $w) {
}
echo 'reference:      ', implode(',', $b), "\n";

$c = [1, 2, 3];
foreach ($c as &$x) {
    $x *= 10;
}
unset($x);
foreach ($c as $x) {
}
echo 'with unset:     ', implode(',', $c), "\n";
copy:           1,2,3
reference:      10,20,20
with unset:     10,20,30

LE37. The foreach that ran with a copy did not change the array. The one that ran with a reference multiplied every element by ten. Then the second loop, which used the same name as a copy, silently overwrote the last element of the array because the reference was still bound to the last element, and 10,20,20 came out. unset($x) cut the reference and 10,20,30 remained. This behavior is silent: no notice, no exception, only a wrong array.

Assignment Inside a Condition

A difference of a single character turns a condition into an assignment: = and ==. PHP does not count this as a syntax error, because an assignment is an expression and its value is the assigned value. The condition decides according to that value. The file below writes the same condition in three forms; the error handler keeps a counter.

<?php
// condition_assignment.php — inside if, = assigns and its value counts as the condition
$notices = 0;
set_error_handler(function (int $level) use (&$notices): bool {
    $notices++;
    return true;
});

$state = 'empty';
if ($state = 'full') {
    echo "assignment: branch true, state: $state\n";
}

$state = 'empty';
if ($state == 'full') {
    echo "comparison: branch true\n";
} else {
    echo "comparison: branch false, state: $state\n";
}

$value = 5;
if ($value = 0) {
    echo "zero assignment: branch true\n";
} else {
    echo "zero assignment: branch false, value: $value\n";
}
echo 'notices: ', $notices, "\n";
assignment: branch true, state: full
comparison: branch false, state: empty
zero assignment: branch false, value: 0
notices: 0

LE38. When 'full' was assigned the condition came out true and $state changed; the comparison kept the value. When 0 was assigned the condition came out false and $value dropped from five to zero. In all three cases the counter stayed at zero: the language counts assignment inside a condition as legitimate and gives no signal. The error is understood only from the fact that the value of the variable has changed after the condition.

How Many Times a Loop Runs

The number of rounds a loop makes can be worked out from the condition, but in three cases expectation and reality diverge. do while tests the condition after the round, so even with a false condition it runs once. A floating-point counter can make one round more than expected when it reaches 1 in steps of 0.1. Adding an element to the array during the loop does not change the number of iterations of foreach, because foreach walks the array as it was at the start. The file below counts the three.

<?php
// iteration_count.php — do while runs once, a floating-point counter runs extra
$count = 0;
do {
    $count++;
} while (false);
echo "do while:    $count\n";

$count = 0;
for ($x = 0; $x < 1; $x += 0.1) {
    $count++;
}
echo "for 0.1 step: $count\n";

$items = [1, 2];
$count = 0;
foreach ($items as $item) {
    $items[] = $item * 10;
    $count++;
}
echo "foreach:      $count iterations, array: ", count($items), " elements\n";
do while:    1
for 0.1 step: 11
foreach:      2 iterations, array: 4 elements

LE39. do while made one round even with a false condition. The floating-point counter made eleven rounds, not ten, because at the tenth addition the counter was not exactly 1 but stayed just below 1; this is the loop side of the 0.1 + 0.2 measurement in the Variables and Data Types lesson. foreach made two rounds and the array grew to four elements: the array it walked is the copy from the first moment. All three are silent.

foreach and a Value That Is Not an Array

foreach expects an array or an object. If another value is given, the loop does not run at all and the language reports an E_WARNING; that is, unlike the channel of this lesson, this case does not halt but is not silent either. The file below gives null, a string and an empty array to the same loop.

<?php
// not_an_array.php — foreach warns on a value that is not an array and does not run
set_error_handler(function (int $level): bool {
    echo $level === E_WARNING ? "  [E_WARNING]\n" : "  [other]\n";
    return true;
});

$inputs = [
    'null'      => null,
    "'abc'"     => 'abc',
    '[]'        => [],
    '[1, 2]'    => [1, 2],
];
foreach ($inputs as $name => $input) {
    $count = 0;
    foreach ($input as $item) {
        $count++;
    }
    echo "$name: $count iterations\n";
}
  [E_WARNING]
null: 0 iterations
  [E_WARNING]
'abc': 0 iterations
[]: 0 iterations
[1, 2]: 2 iterations

LE40. null and the string did not enter the loop and each reported an E_WARNING; the empty array and the two-element array ran without a notice. This difference between the empty array and null turns what a function returns for “no result” into a contract: returning null and giving it to foreach produces a warning on every call.

An Array That Changes During the Loop

When foreach works by value, it walks the array as it was at the start; even if the array is enlarged inside the loop, the number of rounds does not change. for, on the other hand, recomputes the condition every round; if the condition is count(), the rounds lengthen as the array grows. The file below counts the same enlargement in two loops.

<?php
// growth.php — foreach walks the starting array, for recomputes its condition every round
$a = [1, 2, 3];
$rounds_a = 0;
foreach ($a as $v) {
    if ($rounds_a < 5) {
        $a[] = $v;
    }
    $rounds_a++;
}
echo 'foreach: ', $rounds_a, ' iterations, ', count($a), " elements\n";

$b = [1, 2, 3];
$rounds_b = 0;
for ($i = 0; $i < count($b); $i++) {
    if ($rounds_b < 3) {
        $b[] = 9;
    }
    $rounds_b++;
}
echo 'for:     ', $rounds_b, ' iterations, ', count($b), " elements\n";
foreach: 3 iterations, 6 elements
for:     6 iterations, 6 elements

LE41. foreach ran three rounds and the array grew to six elements: what it walked was the starting copy with three elements. for ran six rounds, because count($b) gave the new length every round. One does not see the growth, the other does; neither produces a notice. If the array a loop walks changes inside the loop, the number of iterations depends on which loop structure was chosen.

The Bounding Measurement

Because match halts on an unmatched input, an arm that carries default turns the halt into silence. This is the limit of this lesson’s channel: the same statement passes from the halts channel to the silent channel when a single arm is added. The file below runs two functions, one with a default arm and one without, on the same inputs. The result is computed first and printed afterward.

<?php
// fallback.php — a default arm turns the halt of match into silence
function name_strict(int $code): string
{
    return match ($code) {
        1 => 'one',
        2 => 'two',
    };
}

function name_default(int $code): string
{
    return match ($code) {
        1 => 'one',
        2 => 'two',
        default => 'unknown',
    };
}

foreach ([1, 7] as $code) {
    try {
        $strict = name_strict($code);
    } catch (UnhandledMatchError $error) {
        $strict = get_class($error);
    }
    $default = name_default($code);
    printf("%d  strict: %-20s default: %s\n", $code, $strict, $default);
}
1  strict: one                  default: one
7  strict: UnhandledMatchError  default: unknown

LE42. On the input 1 both functions returned 'one'. On the input 7 the function without a default arm threw UnhandledMatchError, while the function with one returned 'unknown' and reported nothing. In this case default does two things at once: it silently accepts the input that does not match, and it hides from the program that the input did not match. Which choice is right depends on whether the unmatched input is expected or an error; the measurement shows only the result of the choice.

Summary

  • The channel of control flow is halts: match throws UnhandledMatchError on an unmatched arm, while switch passes silently and leaves the variable at its previous value.
  • switch labels compare with loose equality and match arms with strict equality: '1' goes to case 1 in switch and falls to default in match.
  • break 2 and continue 2 affect the outer loop in nested loops; loop variables stay defined after the loop.
  • A foreach that works with a reference leaves the reference behind after the loop; without unset, a second loop with the same name overwrites the last element of the array.
  • Adding a default arm to match turns the halt into silence and hides the input that did not match.

Next Step

It was seen what an unmatched branch does: switch passes, match halts. The Functions lesson asks the same question of calls: when a function is given more arguments than it expects, where does the surplus go?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close