Простой скрипт на PHP. Что будет – true или false?

$a = 12345678901234567890;
$b = $a + 1;
var_dump($a === $b);

Берем $a и $b, где $b равно $a +1. Проверяем равны ли $a и $b. Должно быть false – не равны. Но на самом деле – true! Они «равны» ?

Эта статья для тех, кого удивляет данное поведение.

Два разных литерала становятся одним и тем же значением. Почему так происходит где это ломает реальный код и что с этим делать — разбираемся спокойно и по шагам.

Что PHP делает с числами

В PHP есть два базовых числовых типа: целое (int) и число с плавающей точкой (float). На современных 64‑битных сборках int хранится в 64 битах со знаком и заканчивается на 9223372036854775807 (это PHP_INT_MAX). Float — это стандартный IEEE‑754 double: у него около 15–17 значимых десятичных цифр точности.

Ключевой момент: если литерал целого числа в коде не помещается в int, PHP автоматически превращает его в float. Без исключения, без предупреждения. В нашем примере оба литерала слишком большие для int, и PHP изначально парсит их как double. (Вы можете сами увидеть это в исходниках - https://github.com/php/php-src/blob/master/Zend/zend_language_scanner.l )

Почему два разных числа стали «одинаковыми»

$a = 12345678901234567890;
$b = 12345678901234567899;
var_dump($a === $b);

Представьте линейку. У double она очень «тонкая» возле нуля и всё грубее на больших значениях. Технически это выглядит так: у double фиксированная точность мантиссы — 53 бита. Шаг между двумя соседними представимыми числами (его называют ULP) растёт вместе со значением числа.

В районе 10^19 этот шаг — примерно 2048. Это не опечатка: на такой «масштабной линейке» нет делений меньше 2048. Разница между нашими a и b — всего 9. Она значительно меньше шага. Оба значения «округляются» к одному и тому же ближайшему представимому double.

Это легко увидеть по выводу:

  • var_dump(a) и var_dump(b) покажут одинаковый результат - float(1.2345678901234568E+19).

  • printf("%.0f\n", $a); и printf("%.0f\n", $b); печатают один и тот же «крупный» целочисленный вид, причем не равный ни одному из указанных нами чисел.

Строгое сравнение === в PHP сравнивает тип и значение. В нашем случае оба значения — float и оба имеют один и тот же двоичный образ, поэтому результат true.

Где безопасная граница

Есть ещё одна полезная «веха» — число 2^53, это 9007199254740992. Все целые, не превосходящие 2^53, в double представимы точно. А вот дальше начинаются «дырки».

$x = (float) 9007199254740992; // 2^53, float, последнее точно представимое целое (при 64-разрядной сборке)
$y = $x + 1;
$z = $x + 2;

var_dump($x, $y, $z);     // float(...) float(...) float(...)
var_dump($x === $y);      // true — $y стал равен $x
var_dump($z - $x);        // float(2) — границы точности

Здесь $y стал равен $x, потому что между 2^53 и 2^53+1 в double уже нет отдельной ступеньки.

Как PHP хранит значение внутри

Давайте спустимся на уровень движка Zend и посмотрим, что именно происходит с числами «внутри» PHP. Чем понятнее станет кухня интерпретатора, тем легче объяснить и неожиданные сравнения, и «слипание» больших чисел.

Начнём с конвейера: исходный код попадает в лексер, затем в парсер, потом компилятор строит таблицу переменных  и байткод (опкоды), и уже его исполняет виртуальная машина. Ключевой момент в нашей теме — ещё на этапе лексера и компиляции PHP решает, что это за числовой литерал: целое (int) или число с плавающей точкой (float). Если литерал влезает в системный целый тип, он становится int. Если нет — ещё до запуска кода он превращается в float и как float попадёт в таблицу переменных.

Это легко увидеть прямо из PHP при помощи встроенного токенайзера. Лексер отдаёт числовые литералы как T_LNUMBER (целые) или T_DNUMBER (вещественные). Большой «целый» в коде, который не помещается в int, вы увидите как T_DNUMBER, хоть точек и необычных суффиксов в нём нет.

$code = <<<'PHP'
<?php
$a = 42;
$b = 12345678901234567890;
$c = 1.5;
PHP;

foreach (token_get_all($code) as $t) {
    if (is_array($t) && ($t[0] === T_LNUMBER || $t[0] === T_DNUMBER)) {
        echo $t[1], ' => ', token_name($t[0]), PHP_EOL;
    }
}

 

На PHP 8 вы увидите, что 42 — это LNUMBER, 1.5 — DNUMBER, и огромный «целый» тоже окажется DNUMBER. Это значит, что ещё до выполнения код с такой переменной будет работать с float.

Теперь про то, где именно живёт значение во время исполнения. Внутри движка каждое значение — это zval. Это крошечная структура, в которой есть тип и «полезная нагрузка». Для чисел типы просты: IS_LONG для int и IS_DOUBLE для float. Важно, что сами числовые биты хранятся прямо внутри zval, без выделения памяти в куче. Для 64‑битного процесса zval укладывается в 16 байт; в этих байтах есть 8 байт под значение (либо 64‑битный long, либо 64‑битный double), плюс байт с типом и несколько служебных флагов.

Скаляры, такие как int и float, целиком находятся в zval. Строки, массивы и объекты, наоборот, содержат указатель на отдельный блок в куче (zend_string, zend_array, zobj), рядом с которым живут счётчики ссылок и заголовки для сборщика мусора. Так сделано из-за ограниченного размера zval. Строки и массивы могут занять много памяти, но для чисел отведено строго 8 байт.

Где это ломает реальный код

Первое и самое болезненное — большие ID. В JSON, в базах данных и в API встречаются BIGINT или ещё больше. Если бездумно принимать их как числа в PHP, вы теряете точность.

Например, json_decode без нужного флага может превратить большой идентификатор в float:

$json = '{"id": 12345678901234567890}';
var_dump(json_decode($json)); // stdClass->id = float(1.2345678901234568E+19)

Наружу всё ещё выглядит как «число», но уже неточное. Работать с таким ID опасно: сравнение и поиск будут вести себя не так, как ожидается.

Эта потеря точности срабатывает и в сравнениях. Нестрогое сравнение == между числом и «числовой строкой» приводит обе стороны к числу. Если строка очень длинная, получится float, и точность улетит:

var_dump("12345678901234567890" == 12345678901234567899); // true — оба сведены к одному double

Это может ударить даже по безопасности: проверка «совпадает ли переданный ID/токен» внезапно начинает считать разные значения равными.

Следующая ловушка — ключи массивов. В PHP ключ должен быть либо int, либо string. Если вы случайно используете большой числовой литерал как ключ, он станет float, а потом будет приведён к int. На PHP 8 вы получите предупреждение, а на PHP 7 – нет. Оба ключа слипнутся в один и перезапишут значение:

error_reporting(E_ALL);

$arr = [];
$arr[12345678901234567890] = 'A';
$arr[12345678901234567899] = 'B';

var_dump($arr); // одна запись: ключ — PHP_INT_MAX, значение 'B' перезаписало 'A'

Если ключи будут строками, то такой проблемы не возникнет.

Но здесь есть правило: строковые ключи, которые выглядят как целые и помещаются в диапазон int, приводятся к int, иначе остаются строками

$arr = [];
$arr["12345678901234567890"] = 'A';
$arr["12345678901234567899"] = 'B';
$arr["123"] = 'C';

var_dump(array_keys($arr));

 

Обратите внимание. Все ключи были заданы как строки. Первые два так и остались строками без изменения. Но ключ последнего элемента стал числом.

Похожая история случается и в базах данных. Допустим из БД пришло число как строка. Если вы где-то по пути привели это значение к числу — часть данных уже безвозвратно потеряна.

Особенно коварно это выглядит, когда вы гоняете данные через JSON между сервисами на PHP и JavaScript: в JS тоже double по умолчанию, и там также есть безопасная граница.

Как спокойно жить с этим

Вариант — не хранить большие идентификаторы как числа. Передавайте их строками: в PHP, в JSON, в HTTP-параметрах, в базах. В JSON у php есть флаг, который спасает от «превращения» больших чисел в float:

$json = '{"id": 12345678901234567890}';

var_dump(json_decode($json, false, 512, JSON_BIGINT_AS_STRING)); // объект, у которого id — строка "12345678901234567890"

 Если нужно сравнивать или сортировать действительно большие числа как числа, используйте арифметику произвольной точности. В PHP есть встроенные расширения bcmath и gmp, а также качественные библиотечные реализации на чистом PHP.

Простой пример на bcmath:

echo bccomp("12345678901234567890", "12345678901234567899"), PHP_EOL; // -1

echo bcadd("99999999999999999999", "1"), PHP_EOL; // 100000000000000000000

Для входящих данных из внешнего мира имеет смысл валидировать, что значение вообще помещается в нужный диапазон, прежде чем приводить к int. Это можно сделать аккуратно, не рискуя неожиданными автоконверсиями:

function toSafeInt(string $s): int 
{
    $v = filter_var(
        $s,
        FILTER_VALIDATE_INT,
        ['options' => ['min_range' => 0, 'max_range' => PHP_INT_MAX]]
    );

    if ($v === false) {
        throw new InvalidArgumentException("Out of range for int: $s");
    }

    return $v;
}

var_dump(toSafeInt("123"));     // int(123)
var_dump(toSafeInt("99999999999999999999")); // исключение

 

Отдельно подумайте о фронтенде и интеграциях. Если вы работаете с JavaScript, то даже «обычный» 64‑битный BIGINT может быть небезопасен. У чисел в JS безопасная целочисленная область — от −(2^53 − 1) до +(2^53 − 1). Всё, что больше, лучше отправлять строками:

$data = [
    'safe'   => 9007199254740991,        // последний безопасный для JS int
    'unsafe' => "9007199254740993",      // отправляем строкой
];

echo json_encode($data, JSON_UNESCAPED_SLASHES), PHP_EOL;

 

Чуть глубже: почему шаг ≈ 2048 около 10^19

Немного интуиции без формул. У double 53 бита на «значащую часть». Если число порядка 2^63 (а 10^19 примерно равно 2^63.1), то младшие 63−53=10 бит уже «не помещаются». Это и даёт шаг 2^10=1024 или около того. Точный шаг зависит от конкретной точки (он удваивается при каждом «щелчке» экспоненты), и в нашем диапазоне он как раз около 2048. Поэтому разница 9 не видна: её просто некуда записать.

Итоги и здравый смысл

  • В PHP большие числовые литералы, не помещающиеся в int, становятся float. Float — это double с ограниченной точностью. На больших значениях у него крупный шаг между соседними числами, поэтому «близкие» числа слипаются.

  • Из этого вырастают реальные баги: сравнения, ключи массивов, JSON, обмен с JS, работа с BIGINT из БД. Эти баги часто проявляются не как «ошибка», а как «странное поведение», и потому особенно неприятны.

  • Лечится всё просто и предсказуемо: идентификаторы — строками, арифметика — через bcmath или аналоги, входящие данные валидировать по диапазону, сравнивать строго и осознанно, JSON аккуратно настраивать, предупреждения не глушить.

Комментарии (0)