Skip to content
academia.sh

Lesson 10 / 14

String Handling

Formatting with sprintf and printf; the byte and character distinction between strlen and mb_strlen; malformed JSON coming back from json_decode as null; telling the cases apart with json_last_error and JSON_THROW_ON_ERROR; the behavior of implode and strlen on an array argument.

Published Updated

In this lesson

  • Formats numbers and strings with sprintf and measures that the width specifier works in bytes
  • Measures that malformed JSON returns null from json_decode and records it under the return channel
  • Separates a valid null from malformed input with json_last_error and JSON_THROW_ON_ERROR
Contents

The Array Functions lesson showed that the discriminating power of the return channel is limited by the discriminating power of the caller: once 0 and false were not told apart, the first element found counted as “absent”. String handling brings out the same limit in a harsher form. The null returned when a string is decoded can come from a valid null value or from malformed input, and the value itself does not say which one it is.

A string in PHP is a sequence of bytes; how many bytes a character takes depends on the function that reads the string. The lesson covers two subjects: the functions that format and measure a string, and the functions that encode and decode a string as JSON. The channel names are used in the sense they had in the previous lessons: silent, return, warning, halts.

Formatting and Byte Measurement

sprintf places values into a format string; printf does the same formatting and prints it directly. Width, alignment and the number of decimal places are given with a specifier.

<?php
// format.php — formatting with sprintf
echo sprintf('%05d', 42), "\n";
echo sprintf('%.2f', 3.14159), "\n";
echo sprintf('%-8s|', 'php'), "\n";
echo sprintf('%8s|', 'php'), "\n";
echo sprintf('%s book, %d pages', 'Lesson', 120), "\n";
printf("%x %o %b\n", 255, 8, 5);
echo sprintf('%1$s-%2$s-%1$s', 'a', 'b'), "\n";
00042
3.14
php     |
     php|
Lesson book, 120 pages
ff 10 101
a-b-a

The width specifier counts bytes, not characters. The difference shows in a string that contains a multibyte letter; the file below prints only lengths and does not build an aligned column.

<?php
// bytes.php — strlen counts bytes, mb_strlen counts characters
$name = 'señor';

echo 'strlen: ', strlen($name), "\n";
echo 'mb_strlen: ', mb_strlen($name), "\n";

$padded = sprintf('%-8s', $name);
echo 'padded bytes: ', strlen($padded), "\n";
echo 'padded characters: ', mb_strlen($padded), "\n";

echo 'ascii strlen: ', strlen('senor'), "\n";
echo 'ascii mb_strlen: ', mb_strlen('senor'), "\n";
strlen: 6
mb_strlen: 5
padded bytes: 8
padded characters: 7
ascii strlen: 5
ascii mb_strlen: 5

strlen('señor') gave six and mb_strlen('señor') gave five: ñ is two bytes and a single character. The string padded to eight bytes with sprintf('%-8s') looks only seven characters wide; in an aligned column, this shift pulls the next column to the left. For the all-ASCII senor, the two functions give the same number, so the difference appears only when multibyte data arrives. The problem is reported through no channel: silent. Before data that contains multibyte letters is placed in an aligned column, the width must be calculated over characters, not bytes.

AS13. This lesson uses only mb_strlen from the mb_ family and assumes the input is in a single encoding (UTF-8). Input whose encoding is unspecified or mixed lies outside this lesson and is not measured.

Malformed JSON

JSON is an interchange format that turns arrays and associative arrays into text. json_encode converts a PHP value to text, and json_decode converts text to a PHP value. The listener classifies what each does when it fails; channel.php is given below in full, and since it has no output block, it is only written to disk.

<?php
// channel.php — helpers that classify which channel a problem is heard through

// Level names of caught warnings collect here; the message text is not kept.
$GLOBALS['heard_warnings'] = [];

set_error_handler(function (int $level): bool {
    $names = [E_WARNING => 'E_WARNING', E_NOTICE => 'E_NOTICE', E_DEPRECATED => 'E_DEPRECATED'];
    $GLOBALS['heard_warnings'][] = $names[$level] ?? 'E_' . $level;
    return true;
});

// Turns a value into a short text together with its type.
function show(mixed $value): string
{
    return match (true) {
        $value === null => 'null',
        is_bool($value) => $value ? 'true' : 'false',
        is_array($value) => 'array(' . count($value) . ')',
        default => get_debug_type($value) . ' ' . $value,
    };
}

// Runs the job and asks, in order, which channel the problem is heard through:
// first a throw, then a warning, then a false/null return; if none of them, silent.
function listen(callable $job): array
{
    $GLOBALS['heard_warnings'] = [];
    try {
        $result = $job();
    } catch (Throwable $error) {
        return ['halts', get_class($error)];
    }
    if ($GLOBALS['heard_warnings']) {
        $levels = implode(',', array_unique($GLOBALS['heard_warnings']));
        return ['warning', $levels . ', result ' . show($result)];
    }
    if ($result === false || $result === null) {
        return ['return', 'result ' . show($result)];
    }
    return ['silent', 'result ' . show($result)];
}

// Runs the code in a separate PHP execution; exit code 255 means execution halted.
function run_separately(string $code): array
{
    exec('php -d display_errors=0 -r ' . escapeshellarg($code) . ' 2>&1', $lines, $exit);
    $channel = $exit === 255 ? 'halts' : 'silent';
    return [$channel, 'lines out ' . count($lines) . ', exit ' . $exit];
}
<?php
// json.php — problem channels in JSON and string operations
require 'channel.php';

$observations = [
    'malformed json'     => fn() => json_decode('{broken'),
    'valid json'         => fn() => json_decode('{"a":1}', true),
    'bad encoding'       => fn() => json_encode("\xB1"),
    'encoding exception' => fn() => json_encode("\xB1", JSON_THROW_ON_ERROR),
    'nested implode'     => fn() => implode(',', [1, [2, 3]]),
    'strlen on array'    => fn() => strlen([1, 2]),
];

foreach ($observations as $name => $observe) {
    [$channel, $evidence] = listen($observe);
    printf("%-19s%-9s%s\n", $name, $channel, $evidence);
}
malformed json     return   result null
valid json         silent   result array(1)
bad encoding       return   result false
encoding exception halts    JsonException
nested implode     warning  E_WARNING, result string 1,Array
strlen on array    halts    TypeError

The first line is the course’s malformed json observation: json_decode('{broken') returned null, and no level was reported. The class is return. The third line is the encoding direction of the same channel: encoding an invalid byte sequence returns false. Both say that an error occurred only through the return value.

The fourth line shows the channel changing. When the JSON_THROW_ON_ERROR flag is given, the same problem is thrown as JsonException; the class is halts. The flag moves a problem on the return channel to the exception channel and hands control to the caller: if the caller does not catch it, execution stops. The fifth line is the third channel of string handling: when a nested array is passed to implode, the inner array is converted to the string 'Array' and E_WARNING is reported, that is, warning. The last is the type channel: when strlen is called with an array argument, it throws TypeError and halts.

Text Search and Cutting

strpos returns 0 when the sought text is at the start and false when it is not found at all. This is the same channel and the same trap as array_search in the Array Functions lesson: a loose comparison does not tell the two apart. PHP offers str_contains for this job, which does not require looking at a return value.

<?php
// searching.php — string search and cutting functions
$text = 'php lesson';

echo 'strpos at start: ', var_export(strpos($text, 'php'), true), "\n";
echo 'strpos missing: ', var_export(strpos($text, 'js'), true), "\n";
echo 'loose at start: ', (strpos($text, 'php') == false ? 'not found' : 'found'), "\n";
echo 'str_contains at start: ', var_export(str_contains($text, 'php'), true), "\n";
echo 'substr: ', var_export(substr($text, 4), true), "\n";
echo 'substr beyond: ', var_export(substr($text, 20), true), "\n";
echo 'explode: ', json_encode(explode(' ', $text)), "\n";
echo 'empty separator: ', json_encode(explode(',', '')), "\n";
echo 'trim: ', var_export(trim("  a b \n"), true), "\n";
echo 'str_replace: ', str_replace('php', 'go', $text), "\n";
strpos at start: 0
strpos missing: false
loose at start: not found
str_contains at start: true
substr: 'lesson'
substr beyond: ''
explode: ["php","lesson"]
empty separator: [""]
trim: 'a b'
str_replace: go lesson

strpos returned 0 for the sought text at the start, and the loose comparison counted it as not found; str_contains answered the same question correctly with true. substr returns an empty string if the start is beyond the end of the string and gives no error. When explode splits an empty string, it gives not an empty list but a list with a single empty string: the one-element [""]. This result is a boundary condition, and it silently erases the distinction between “no data” and “empty data present”.

AS14. str_contains, str_starts_with and their relatives do not resolve the problem as a channel; they only turn the question into the right type. Writing a usage guide is not the job of this lesson, and no claim is made about behaviors that do not appear in the table above.

A function that cuts a multibyte letter at the byte level produces an invalid string, and that string causes a problem at the next encoding call. Converting to uppercase depends on the same byte and character distinction.

<?php
// cut.php — the effect of byte-level cutting and uppercasing
$name = 'señor';

$byteCut = substr($name, 0, 3);
$characterCut = mb_substr($name, 0, 3);

echo 'byte cut length: ', strlen($byteCut), "\n";
echo 'byte cut valid: ', var_export(mb_check_encoding($byteCut, 'UTF-8'), true), "\n";
echo 'byte cut json: ', var_export(json_encode($byteCut), true), "\n";
echo 'character cut length: ', mb_strlen($characterCut), "\n";
echo 'character cut valid: ', var_export(mb_check_encoding($characterCut, 'UTF-8'), true), "\n";
echo 'uppercase same: ', var_export(strtoupper($name) === mb_strtoupper($name), true), "\n";
byte cut length: 3
byte cut valid: false
byte cut json: false
character cut length: 3
character cut valid: true
uppercase same: false

substr($name, 0, 3) took three bytes and cut off the first byte of the letter ñ; the result is not a valid UTF-8 string, and json_encode rejected it by returning false. mb_substr made the same cut on a character boundary, and the result stayed valid. strtoupper uppercases only ASCII letters; mb_strtoupper converts the multibyte letter as well, so the two results did not come out equal. The choice between bytes and characters in a cutting or uppercasing function announces the problem through no channel: the cut string is noticed only afterwards, at the encoding stage, through a return value.

The Limits of Format Specifiers

sprintf matches the specifiers in the format string with the arguments; the number, type and syntax of the specifiers determine the channel of the call.

<?php
// specifiers.php — the limits of sprintf specifiers
require 'channel.php';

$observations = [
    'width and precision' => fn() => sprintf('%05.1f|%-4s|', 3.14159, 'ab'),
    'pad character'       => fn() => sprintf("%'*8s", 'php'),
    'signed number'       => fn() => sprintf('%+d', 5),
    'string as number'    => fn() => sprintf('%d', 'abc'),
    'missing argument'    => fn() => sprintf('%s %s', 'a'),
    'unknown specifier'   => fn() => sprintf('%y', 1),
];

foreach ($observations as $name => $observe) {
    [$channel, $evidence] = listen($observe);
    printf("%-21s%-9s%s\n", $name, $channel, $evidence);
}
width and precision  silent   result string 003.1|ab  |
pad character        silent   result string *****php
signed number        silent   result string +5
string as number     silent   result string 0
missing argument     halts    ArgumentCountError
unknown specifier    halts    ValueError

The first three lines show the specifiers working normally and are silent. The fourth line is the real point: the %d specifier converts a non-numeric string to 0 and reports no level; data of the wrong type is lost silently in formatting. When there are fewer arguments than specifiers, ArgumentCountError is thrown, and when an undefined specifier is written, ValueError is thrown; both halt. The same function is silent for one kind of error and halting for another: the channel depends on the kind of error in the call.

Padding, Number Format and Comparison

A few helper functions used to display numbers and strings move details such as alignment and separators into their arguments.

<?php
// helpers.php — padding, number format and string comparison
require 'channel.php';

echo number_format(1234567.891, 2, ',', '.'), "\n";
echo number_format(0.5), ' ', number_format(1.5), ' ', number_format(2.5), "\n";
echo str_pad('7', 3, '0', STR_PAD_LEFT), "|", str_pad('ab', 6, '-', STR_PAD_BOTH), "|\n";
echo ucwords('php string handling'), "\n";
echo strrev('abc'), ' ', str_word_count('one two three'), "\n";
echo 'strcmp: ', strcmp('a', 'b'), ' ', strcmp('b', 'a'), ' ', strcmp('a', 'a'), "\n";
echo 'spaceship: ', 'a' <=> 'b', ' ', 'b' <=> 'a', ' ', 'a' <=> 'a', "\n";

$observations = [
    'numeric loose equal'  => fn() => '10' == '1e1',
    'numeric strict equal' => fn() => '10' === '1e1',
    'leading numeric'      => fn() => '12abc' + 1,
    'wholly non-numeric'   => fn() => 'abc' + 1,
    'integer cast'         => fn() => (int) '12abc',
];

foreach ($observations as $name => $observe) {
    [$channel, $evidence] = listen($observe);
    printf("%-22s%-9s%s\n", $name, $channel, $evidence);
}
1.234.567,89
1 2 3
007|--ab--|
Php String Handling
cba 3
strcmp: -1 1 0
spaceship: -1 1 0
numeric loose equal   silent   result true
numeric strict equal  return   result false
leading numeric       warning  E_WARNING, result int 13
wholly non-numeric    halts    TypeError
integer cast          silent   result int 12

number_format rounds at the decimal (1 2 3 for 0.5, 1.5, 2.5), and the thousands and decimal separators are set with arguments. str_pad pads on the left, on the right or on both sides. strcmp and <=> give the same three values for sorting.

The main point to watch in comparison is numeric strings: '10' == '1e1' is true because both sides are converted to numbers, whereas === gives false. '12abc' + 1 produces 13 but gives E_WARNING, and 'abc' + 1 stops with TypeError. (int) '12abc' silently gives 12; for the same input, conversion is silent, arithmetic gives a warning, and arithmetic on a wholly non-numeric string halts.

The Bounding Measurement

The return channel cannot tell cases apart. Decoding a valid null value and decoding malformed input give the same value.

<?php
// distinguish.php — a null return can come from two separate cases
$broken = json_decode('{broken');
echo 'broken: ', var_export($broken, true), ' error ', json_last_error(), "\n";

$valid = json_decode('null');
echo 'valid null: ', var_export($valid, true), ' error ', json_last_error(), "\n";

echo 'equal: ', var_export($broken === $valid, true), "\n";
echo 'error state: ', json_last_error() === JSON_ERROR_NONE ? 'none' : 'present', "\n";

try {
    json_decode('{broken', false, 512, JSON_THROW_ON_ERROR);
    echo "broken: did not throw\n";
} catch (JsonException $e) {
    echo 'broken: ', get_class($e), ' code ', $e->getCode(), "\n";
}

$result = json_decode('null', false, 512, JSON_THROW_ON_ERROR);
echo 'valid null, with flag: ', var_export($result, true), "\n";
broken: NULL error 4
valid null: NULL error 0
equal: true
error state: none
broken: JsonException code 4
valid null, with flag: NULL

Both calls returned null and came out equal under ===. The distinction is made by json_last_error(): four (JSON_ERROR_SYNTAX) for malformed input, zero for a valid null. This function carries the error state of the last call; the next json_decode call resets it, so it must be read immediately afterwards. The more robust route is the JSON_THROW_ON_ERROR flag: malformed input throws JsonException, a valid null comes back as null, and the two cases now arrive through separate channels.

AS15. json_last_error() holds a global error state, and the lesson reads it only right after a json_decode call. Cases where another function intervenes are not measured. The lesson relies on no ini value; the JSON_THROW_ON_ERROR flag is passed to the call explicitly.

Summary

  • sprintf and printf place values according to a format string; the width specifier counts bytes, not characters.
  • strlen('señor') gives six and mb_strlen('señor') gives five; before data that contains multibyte letters is placed in an aligned column, the width must be calculated in characters.
  • json_decode('{broken') returns null; the measuring class of this lesson is return (malformed json). For an invalid byte sequence, json_encode returns false.
  • JSON_THROW_ON_ERROR throws the same problem as JsonException; the class is halts.
  • A valid null and malformed JSON both return null; the distinction is made by json_last_error() (zero or four) or by JSON_THROW_ON_ERROR.
  • implode on a nested array gives 'Array' and E_WARNING; strlen with an array argument throws TypeError.

Next Step

Problems in arrays and strings can be heard through four channels: silent, return, warning and halts. The next topic is the place where data comes not from inside the code but from outside. The Superglobals lesson measures which channel is heard when request data is missing.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close