Lesson 03 / 14
Variables and Data Types
How the type of a value in PHP can change without a signal: assignment, the integer limit, floating-point numbers, casts, the undefined variable, and how type declarations behave under strict types mode.
Published Updated
In this lesson
- Shows by measurement that a variable's type changes with the assigned value without a signal
- Verifies by counting how exceeding the integer limit and division change the type
- Tells apart that reading an undefined variable gives a warned null, not a zero value
- Shows with a two-file setup that type declarations follow the strict types mode of the caller's file
Contents
The Syntax and Tags lesson measured that a file either never runs or is cut while running; in both cases there was a signal: an exit code, a status code, an exception. This lesson goes down to where the signal never arrives: the value runs, execution continues, but the type of the value may have changed. The question is whether the type changes without a signal.
In PHP a variable is a name that starts with $ and needs no type declaration. The Programming Fundamentals course (M01/K02) established the concepts of variable and type; here only the way PHP treats those concepts is measured. This arrangement is called loose typing. To read the type, get_debug_type() is used: it gives names such as int, float, string, bool, array and null. The same method is followed in every measurement: the file sets up an error handler, the handler increases a counter at every notice, and the counter is printed at the end of the file. If the counter is zero, the language has said nothing.
Type Depends on the Value
In PHP the type is a property not of the variable but of the value it carries at that moment. The same name $x can carry an integer, a string, an array and a null one after another, and the language says nothing about it. The file below makes four assignments and reads the type after each one.
<?php // assignment.php — the same name carries four different types in a row, no signal arrives $notices = 0; set_error_handler(function (int $level) use (&$notices): bool { $notices++; return true; }); $x = 5; echo get_debug_type($x), "\n"; $x = 'five'; echo get_debug_type($x), "\n"; $x = [1, 2]; echo get_debug_type($x), "\n"; $x = null; echo get_debug_type($x), "\n"; echo 'notices: ', $notices, "\n";
int string array null notices: 0
LE16. Four assignments produced four different types and the notice counter stayed at zero. This is the most basic consequence of loose typing: to say the type of a value by looking at a variable, every assignment up to that line has to be traced. What is measured is not the freedom itself but that it is marked by no signal. The channel of this lesson is silent.
The Integer Limit
An int value has an upper limit, and the constant PHP_INT_MAX gives it. The value of the constant depends on the build and is not printed in this lesson; what is measured is what happens when the limit is exceeded. The file below adds one to the limit and reads the type before and after the result.
<?php // overflow.php — exceeding the integer limit silently changes the type $warnings = 0; set_error_handler(function (int $level) use (&$warnings): bool { $warnings++; return true; }); $limit = PHP_INT_MAX; $next = $limit + 1; echo get_debug_type($limit), ' -> ', get_debug_type($next), "\n"; echo 'reported warnings: ', $warnings, "\n";
int -> float reported warnings: 0
LE17. One more than the limit did not throw an error, did not report a warning and was not truncated: the value became a float and the operation continued. The line reported warnings: 0 in the output shows that no signal arrived. The result is not wrong, it is of another kind: a value stored as a float can no longer tell all integers apart. The integer overflow row in the core table had placed this result in the silent channel; the measurement confirmed it.
Floating-Point Numbers and Division
A change of type does not happen only at the limit. When the division operator / divides two integers, the type of the result depends on whether the quotient comes out exact. Floating-point numbers, on the other hand, cannot store most decimal fractions exactly. The file below measures three things: the result type of division, whether the sum 0.1 + 0.2 equals 0.3, and the type of the integer division intdiv().
<?php // fraction.php — division changes the type, floating point does not store exactly $divisions = [ '6 / 3' => 6 / 3, '7 / 2' => 7 / 2, 'intdiv(7, 2)' => intdiv(7, 2), ]; foreach ($divisions as $expression => $result) { printf("%-14s%-7s%s\n", $expression, get_debug_type($result), var_export($result, true)); } $sum = 0.1 + 0.2; echo '0.1 + 0.2 === 0.3: ', var_export($sum === 0.3, true), "\n"; echo 'difference within 1e-9: ', var_export(abs($sum - 0.3) < 1e-9, true), "\n";
6 / 3 int 2 7 / 2 float 3.5 intdiv(7, 2) int 3 0.1 + 0.2 === 0.3: false difference within 1e-9: true
LE18. 6 / 3 gave an int and 7 / 2 a float: the result type of the same operator changed with the values of the operands. The sum 0.1 + 0.2 did not come out strictly equal to 0.3, but the difference between them is very small. Both are silent; using a threshold instead of equality in a floating-point comparison is the programmer’s job. If exact division is wanted, there is intdiv(), whose type is always int.
Boolean Conversion
Which values count as false when a value is converted to a boolean in a condition or with (bool) is another face of the subject of types. The file below converts eleven values.
<?php // truthiness.php — which values count as false $values = [ '0' => 0, '0.0' => 0.0, "''" => '', "'0'" => '0', "'0.0'" => '0.0', "' '" => ' ', "'false'" => 'false', '[]' => [], '[0]' => [0], 'null' => null, '-1' => -1, ]; foreach ($values as $name => $value) { printf("%-9s%s\n", $name, var_export((bool) $value, true)); }
0 false 0.0 false '' false '0' false '0.0' true ' ' true 'false' true [] false [0] true null false -1 true
LE19. Zero, the empty string, '0', the empty array and null counted as false; '0.0', a single space, the string 'false', an array holding one element and -1 counted as true. The difference between '0', which counts as false, and '0.0', which counts as true, shows that the conversion looks at the form of the value, not its meaning. None of them produces a notice.
Number Notations
The same integer can be written in several forms in source code: hexadecimal, binary, octal, and with underscores for readability. All of them produce the same type, int. The file below puts six notations side by side and prints their types and values; what is measured is whether the notation affects the type.
<?php // notation.php — number notations produce the same type $notations = [ '0x1F' => 0x1F, '0b101' => 0b101, '0o17' => 0o17, '017' => 017, '1_000_000' => 1_000_000, '1e3' => 1e3, ]; foreach ($notations as $notation => $value) { printf("%-10s%-6s%s\n", $notation, get_debug_type($value), var_export($value, true)); }
0x1F int 31 0b101 int 5 0o17 int 15 017 int 15 1_000_000 int 1000000 1e3 float 1000.0
LE20. Five notations gave int; only 1e3 became a float, because exponent notation always produces a floating-point number and its type does not change even when its value equals an integer. 017, which starts with 0, was read as octal, that is, it became 15; someone who takes the leading zero for decoration silently produces a wrong value. This class of notation is also silent.
Null Checks
There are three ways to tell that a variable has no value, and the three give different results: isset(), empty() and ??. To measure the differences, the same five values are given to all three. The fifth variable has never been defined; none of the checks produces a warning because of it, which is a result worth measuring in itself.
<?php // null_checks.php — isset, empty and ?? answer different questions $notices = 0; set_error_handler(function (int $level) use (&$notices): bool { $notices++; return true; }); $zero = 0; $blank = ''; $none = null; $filled = 'a'; printf("%-7s%-7s%-7s%s\n", 'name', 'isset', 'empty', '??'); foreach (['zero', 'blank', 'none', 'filled', 'never'] as $name) { $value = $$name ?? null; printf( "%-7s%-7s%-7s%s\n", $name, var_export(isset($$name), true), var_export(empty($$name), true), var_export($value, true) ); } echo 'notices: ', $notices, "\n";
name isset empty ?? zero true true 0 blank true true '' none false true NULL filled true false 'a' never false true NULL notices: 0
LE21. isset() answers only the question “defined and not null”: true for 0 and the empty string, false for null and for undefined. empty() asks the opposite question and also counts 0 and the empty string as empty. ?? carries the value of the left side or null. The undefined $never produced no notice in any of the three checks; only a direct read produces the warning. Choosing the wrong check leads to logic errors such as counting 0 as “missing”, and these errors are reported to no one.
Casts
The type can be changed at the programmer’s wish just as it changes as the result of an operation. Writing a type name in parentheses converts the value to that type; this is called a cast. The file below runs six conversions and prints their results together with their types. The inputs include a partly numeric string, a string with no number at all, and a fractional number.
<?php // cast.php — casts change the result and give no signal $casts = [ '(int) "12abc"' => (int) '12abc', '(int) "abc"' => (int) 'abc', '(int) 3.99' => (int) 3.99, '(bool) "0"' => (bool) '0', '(string) false' => (string) false, '(float) "1e3"' => (float) '1e3', ]; foreach ($casts as $expression => $result) { printf("%-18s%-8s%s\n", $expression, get_debug_type($result), var_export($result, true)); }
(int) "12abc" int 12 (int) "abc" int 0 (int) 3.99 int 3 (bool) "0" bool false (string) false string '' (float) "1e3" float 1000.0
LE22. The numeric prefix of the string was taken, the rest was thrown away, and nothing was said about it. "12abc" became 12, "abc" became 0, and 3.99 became 3 by truncation. There is no warning on any line of the output; a cast is a way of saying “I know what I am asking”, and the language adds no signal to it. The programmer wrote the conversion, but the language does not say whether the result is lossless.
Undefined Variable
Reading a variable before any value has been assigned to it is a separate case. M09/K01 measured the question of what stands in for what was not written: there, a zero value took the place of an unassigned value. In PHP an undefined variable does not give a zero value: it gives the null value null and reports an E_WARNING. The file below prints only the level name in the error handler; the message text is not printed because it depends on the version.
<?php // undefined.php — an undefined variable gives null and reports E_WARNING set_error_handler(function (int $level): bool { echo 'reported: ', $level === E_WARNING ? 'E_WARNING' : 'other', "\n"; return true; }); $result = $missing; echo 'value: ', var_export($result, true), "\n"; echo 'type: ', get_debug_type($result), "\n";
reported: E_WARNING value: NULL type: null
LE23. The value is null, the type is null and the operation continued; but this time a warning arrived. The channel of the undefined variable is warning: it is heard, it does not halt. A variable that carries null instead of 0 or the empty string leaves a path open to another surprise in later operations; this leads to the conversion rules that are the subject of the Operators lesson.
Type Declarations
Loose typing can be limited by writing a type on a parameter. Writing int $n asks for the parameter to be an integer. The language meets this request in two ways: if the value fits, it accepts it; for one that does not fit, it either coerces or rejects it. The file below tests three inputs: a string that looks like an integer, a fractional number, and a string that carries a non-numeric suffix.
<?php // type.php — int parameter: '5' is coerced, 1.5 gives E_DEPRECATED, '5x' gives TypeError set_error_handler(function (int $level): bool { echo 'reported: ', $level === E_DEPRECATED ? 'E_DEPRECATED' : 'other', "\n"; return true; }); function double_it(int $n): int { return $n * 2; } foreach (['5', 1.5, '5x'] as $input) { try { $result = double_it($input); echo var_export($input, true), ' -> ', $result, "\n"; } catch (TypeError $error) { echo var_export($input, true), ' -> ', get_class($error), "\n"; } }
'5' -> 10 reported: E_DEPRECATED 1.5 -> 2 '5x' -> TypeError
LE24. Three inputs were heard through three different channels. '5' silently became 5 and the result was 10. Because 1.5 is fractional, E_DEPRECATED was reported and the value ran truncated. '5x' carries a non-numeric suffix, so it threw TypeError. A type declaration is therefore a rule, not a guarantee: the rule coerces the values inside its scope, rejects those outside it, and warns for those in between.
The Bounding Measurement
The mechanism that turns coercion off is the declare(strict_types=1) declaration, and the real limit of the measurement is whom it binds. LE25. Strict types mode applies to the caller’s file, not to the file of the called function. A two-file setup shows this: two function files, one with the strict declaration and one without, carry the same function with an int parameter; two caller files send '5' to it.
<?php // function_loose.php — a function with an int parameter defined in a file without the mode function double_loose(int $n): int { return $n * 2; }
<?php // function_strict.php — a function defined in a file that declares strict types mode declare(strict_types=1); function double_strict(int $n): int { return $n * 2; }
<?php // caller_strict.php — calls the function in the file without the mode from a strict file declare(strict_types=1); require 'function_loose.php'; try { echo double_loose('5'), "\n"; } catch (TypeError $error) { echo get_class($error), "\n"; }
TypeError
<?php // caller_loose.php — calls the function in the strict file from a file without the mode require 'function_strict.php'; echo double_strict('5'), "\n";
10
When '5' was sent from the strict file to a function in a file without the mode, TypeError arrived; when the same value went from a file without the mode to a function declared strict, the output was 10. The rule works in the file where the call is made, not in the file where the function is located. For this reason, looking at the parameter of a function does not tell how the call will behave: the same function coerces or rejects depending on which file it is called from.
Summary
- In PHP the type depends on the value: the same variable can carry four types in a row and no notice arrives; the channel of this lesson is silent.
- When the integer limit is exceeded the value becomes a
float, the/operator gives anintor afloatdepending on whether the quotient is exact, and none of them produces a warning. - Casts take the numeric prefix of a string such as
"12abc", throw the rest away and do not announce it. - An undefined variable gives
null, not a zero value, and reports anE_WARNING. - A type declaration gives coercion for
'5',E_DEPRECATEDfor1.5andTypeErrorfor'5x'on anintparameter. declare(strict_types=1)binds the caller’s file:'5'is rejected from a strict file, and the same value is coerced to a strict function from a file without the mode.
Next Step
It was seen that the type changes silently in assignment, in division and in casts. The Operators lesson asks the same question of operators: what do arithmetic and comparison operators say when they bring two different types together?
To keep your progress and take notes, Log in
My notes
Log in to take notes.