Collateral behavior changes with PHP 8.0 switch()
PHP 8.0 introduced a modernisation of the comparison between integers and strings. It changes the result of comparisons when a string is compared to an integer. Obviously, it includes comparing 0 and ” (empty string). This means that some comparisons were true in PHP 7, and are not true anymore in PHP 8. Those are the switch() behavior changes with PHP 8.0.
This modernisation is a behavior change: an evolution, where the code doesn’t change, but its result do. Usually, there is no notice, nor no depreciation message. This must be caught with unit tests.
This behavior change is also a poster child of collateral damage in the code. The point of origin is the the comparison operator. Yet, it actually has impact in other features: sometimes very unexpected yet completely related changes.
Come with me down the rabbit hole, where today is one of your un-birthday. And probably tomorrow is too.
String to Number Comparison
The starting point is the following table, which explains that the relaxed comparison between strings and integers is changing. In particular, an integer compared to a non-integer string is now false. That makes total sense, and it is definitely a nice modernisation.
The details are in the migration guide, and also in the following table.
Comparison | PHP 7 | PHP 8 |
---|---|---|
0 == “0” | true | true |
0 == “0.0” | true | true |
0 == “foo” | true | false |
0 == “” | true | true |
42 == ” 42″ | true | true |
42 == “42foo” | true | false |
Collateral evolution
Obviously, the evolution of comparison between integers and strings impacts the == operator. On the other hand, the === is not affected: in fact, it is a good solution to avoid the migration headaches. Use it in PHP 7, whenever you can.
The comparison feature happens to have many other usages. They don’t always rely on the == operator. This is the case of in_array() and array_keys(), which search for values in an array; it is also the case with the inegality operators, such as >, <, <=, >=, which have now more values that are different, and so, inegal.
And there is the case of switch().
switch() identical cases
In PHP 7, switch() used a relaxed comparison between its operand and the values in the cases. This means that some cases were actually equal, even though they are different case values. For example:
<?php foreach([0, '', false] as $a) switch($a) { case 0: print 'Zero' . PHP_EOL; break; default: print 'Not zero' . PHP_EOL; break; } // Zero // Zero // Zero ?>
This displays ‘Zero’, three times in PHP 7. Everything is a zero, because the comparison between 0 and 0, empty string and false values is always true.
Amusingly, it works the same for 0, ” (empty string) and false, as long as they are the first case in the switch statement.
<?php foreach([0, '', false] as $a) switch($a) { case false: print 'False' . PHP_EOL; break; default: print 'Not zero' . PHP_EOL; break; } // False // False // False ?>
In PHP 7, all those cases are the same. They all are duplicate cases, and PHP will never read the later cases: only the first one. As a coding rule for PHP 7.4 and older, it is recommended to avoid using any two or more of those falsy values as cases. Otherwise, all but the first are dead code.
<?php foreach([0, '', false] as $a) switch($a) { case 0: print 'Zero' . PHP_EOL; break; // dead code in PHP 7 case '': print 'Empty string' . PHP_EOL; break; default: print 'Not zero' . PHP_EOL; break; } // Zero // Zero // Zero ?>
PHP 8 appearing new cases
When in doubt, it is always a good idea to follow best practices. In this case, it reduces the amount of dead code in the application. Yet, at PHP 8 migration time, it now creates a bug. In the previous example, we removed the empty string case on account that it is dead code. But it does come back as a full case in PHP 8.
Indeed, with the change of comparison results when using == in PHP 8, 0 and ” are not equal anymore.
<?php foreach([0, '', false] as $a) switch($a) { case 0: print 'Zero' . PHP_EOL; break; default: print 'Not zero' . PHP_EOL; break; } // Zero // Not zero // Zero ?>
In PHP 7, the above code displays ‘Zero’ for an empty string. In PHP 8, it displays ‘Not zero’. In both cases, we do not see the right result.
So, in PHP 8, the identical case rule change. Some special situation, which were dead code in PHP 7, are now fully fleshed case, and should be back in the command. Otherwise, default might actually take care of them, like in the example above.
Removing duplicate cases is easy enough, but creating cases to match special situations is a lot more difficult. The business case around the switch may or may not exist.
The special case of null
We have covered changes around 0, empty string and false. One other usual suspect is null
. What about it ?
Null has not changed its behavior in PHP 8, so all is safe.
Yet, null
has specific behavior, which makes is a bit special to handle. It is equal to everything that is empty, yet, this is not an associative relationship. So, we have:
<?php var_dump(0 == null); // true var_dump('' == null); // true var_dump(false == null); // true var_dump(0 == ''); // false var_dump(0 == null && null == ''); // true !! ?>
The case of null and switch()
Now, let’s go back to switch(). switch() can use null
as a case. It is a legit value.
<?php foreach([0, '', null] as $a) switch($a) { case 0: print 'Zero' . PHP_EOL; break; case '': print 'Empty string' . PHP_EOL; break; case null: print 'Null' . PHP_EOL; break; default: print 'Default' . PHP_EOL; break; } //Zero //Empty string //Zero ?>
This makes sense, because 0 is caught by the first case, empty string by the second, and null is caught by 0 again: 0 == null, remember?
Now, let’s move the cases inside the switch(), and put the null case in first position.
<?php foreach([0, '', null] as $a) switch($a) { case null: print 'Null' . PHP_EOL; break; case 0: print 'Zero' . PHP_EOL; break; case '': print 'Empty string' . PHP_EOL; break; default: print 'Default' . PHP_EOL; break; } //Null //Null //Null ?>
This time, everything is null. This means that the position of the null case has a direct impact on the performance of the switch(). Put it first, and it hides 0 and empty string. Put it last, or later, and it is hidden by 0 and empty string.
It is probably safer to process the null value before the switch() and avoid using it altogether in the switch command.
Simple switch?
PHP 7.2 introduced a notion of simple switch(): when the cases are all simple distinct literal values, PHP builds a lookup table for the cases, and jump directly to the correct case, instead of reviewing the cases one after each other. This is a nice performance trick.
If null was behaving like other literal values, we would not have seen the last behavior, where it handles most of the cases. So, the mere presence of a null case in a switch prevents the optimisation of the simple switch(). Another good reason to avoid using null in switch().
Conclusion
PHP 8.0 updated the comparison mechanism to compare strings and integers. It leads to a number of nuanced but breaking modernisations. More importantly, these changes are pervasive across the language, as comparison is a common operation used by many PHP features, such as switch().
Due to this evolution, the duplicate cases are changing from PHP 7 to PHP 8. Some were totally hidden in PHP 7, and should now be processed explicitely in PHP 8. And in the mean time, the special behavior of null has not been changing. It is often an unknown feature, which may bring special behavior to PHP scripts.
Those special situations are very rare, just like switch() usage is not common in PHP applications. Observing them is a good moment to remember the intricacies of the language, and enjoy special features such as simple switch().