Каждая машина, на которой запускается современный код, обладает строго конечным объёмом памяти, и тем не менее программисты ежедневно оперируют бесконечными величинами, бесконечными последовательностями и числами, которые физически нельзя записать целиком ни на один носитель. Это не парадокс, а результат долгой инженерной работы: бесконечность в вычислениях всегда появляется в виде компактного представления, специального кода, ленивой структуры или честного сигнала о том, что точный ответ недостижим. Статья разбирает основные техники такого представления, от аппаратных флагов IEEE 754 до символьной алгебры и криптографии, где бесконечность выступает не числом, а практическим пределом вычислительной мощности.

Специальные значения IEEE 754 и арифметика с плавающей точкой

Стандарт IEEE 754, принятый в 1985 году и до сих пор определяющий поведение почти всей арифметики с плавающей точкой, решил проблему бесконечности предельно прагматично. Вместо того чтобы аварийно останавливать программу при делении на нуль или при переполнении диапазона, стандарт ввёл зарезервированные кодировки: +Inf, -Inf и NaN. В бинарном представлении double это достигается экспонентой, целиком состоящей из единиц: если мантисса равна нулю, значение трактуется как бесконечность со знаком, если мантисса ненулевая, получается NaN, not a number.

Бесконечность в этом стандарте ведёт себя почти как настоящая бесконечная величина из математического анализа. Сложение конечного числа с Inf даёт Inf, умножение положительного Inf на отрицательное конечное число даёт -Inf, деление конечного ненулевого числа на нуль даёт Inf соответствующего знака. Но стоит операции потерять однозначный смысл, как на сцену выходит NaN: Inf минус Inf, нуль умножить на Inf, Inf разделить на Inf, нуль разделить на нуль. Все эти выражения порождают значение, которое не равно даже самому себе, и это единственный случай в программировании, где проверка x == x законно возвращает false. Дисциплинированные библиотеки пользуются этим поведением для диагностики: функция isnan существует в каждом стандартном математическом пакете именно потому, что сравнение напрямую бесполезно.

Практический смысл Inf в том, что вычислительный конвейер не прерывается. Программа численного моделирования может до конца досчитать миллион итераций, а инженер потом проверит, где в массиве результатов впервые всплыло нечисловое значение. Это дешевле, чем исключение на каждом шаге, и надёжнее, чем молчаливая подмена ответа нулём.

Целочисленное переполнение и оборачивание по модулю

Целые числа в классических процессорах хранятся в регистрах фиксированной ширины, и их переполнение устроено принципиально иначе, чем у чисел с плавающей точкой. Знаковый int32 вмещает значения от -2 147 483 648 до 2 147 483 647. Если к максимуму прибавить единицу, в арифметике по модулю 2^32 результат оборачивается в минимум: знаменитый переход двух с лишним миллиардов в глубокий минус. Математически это вычисление в кольце вычетов, и процессор выполняет его честно, просто отбрасывая старший перенос.

Языки разошлись в отношении к этой честности. В C и C++ неопределённое поведение знакового переполнения позволяет компилятору считать, что оно вообще не случается, и оптимизировать код исходя из этого допущения, что порождало уязвимости в проверках размеров буферов. В Java переполнение определено строго как оборачивание, и программист обязан помнить об этом сам. В Rust по умолчанию в отладочной сборке переполнение вызывает панику, а в выпускной сборке происходит оборачивание, но в распоряжении разработчика есть целое семейство явных методов: wrapping_add, saturating_add, checked_add, overflowing_add. Это редкий пример того, как язык заставляет программиста выбрать семантику бесконечного поведения конечного типа.

Исторически оборачивание ломало реальные системы счётчиков. Счётчик миллисекунд в 32-битном поле переполняется примерно за 49,7 суток, что не раз останавливало оборудование, рассчитанное на непрерывную работу месяцами.

Насыщающая арифметика вместо оборачивания

Альтернатива оборачиванию называется насыщающей, или saturating, арифметикой. Её идея заимствована из аналоговой электроники: когда сигнал упирается в предел усилителя, он не переворачивается в противоположную полярность, а застревает на максимуме. При переполнении saturating-операция возвращает граничное значение типа: 127 для знакового байта, 255 для беззнакового, INT_MAX для целого слова.

Эта семантика идеальна там, где число изображает физическую величину. Яркость пикселя не может стать отрицательной от избытка света, громкость звука не должна щёлкать в противофазу при усилении, уровень заряда батареи не обязан обнуляться от перезаряда. Поэтому saturating-инструкции встроены в мультимедийные расширения процессоров: в наборы MMX и SSE для x86, в NEON для ARM. Цифровая обработка сигналов, обработка изображений и аудиокодеки живут на насыщающей математике десятилетиями, и пользователь, смотрящий видео, пользуется ею тысячи раз в секунду, не подозревая об этом.

Сравнение двух подходов показывает, что бесконечность в конечной машине всегда является проектным решением:

  1. wraparound дешёв аппаратно и математически чист как арифметика по модулю, но опасен семантически;
  2. saturating сохраняет физический смысл величины и предсказуем для человека, но теряет информацию о том, насколько сильно был превышен предел;
  3. checked-вариант сообщает о переполнении явным флагом и передаёт ответственность вызывающему коду;
  4. расширение разрядности до int64 или BigInteger отодвигает проблему, но не отменяет её в принципе.

Произвольная точность и длинная арифметика

Когда конечность регистра мешает, память компьютера позволяет собрать число любой разумной длины. BigInteger в Java и .NET, тип int в Python, библиотеки GMP и Boost.Multiprecision реализуют то, что традиционно называют длинной арифметикой. Число хранится как динамический массив цифр в основании 2^32 или 2^64, а операции исполняют школьный алгоритм столбика, только невероятно быстро: переносы идут по массиву, умножение длинных блоков опирается на алгоритмы Карацубы, Тоома-Кука и быстрое преобразование Фурье для действительно гигантских операндов.

Разрядность такого числа ограничена только объёмом памяти и терпением. Python спокойно вычисляет 2 в степени 1 000 000 и факториал 100 000, выдавая результаты из сотен тысяч цифр. Криптография RSA оперирует модулями длиной 2048 и 4096 бит, и вся процедура шифрования есть длинная арифметика по модулю. Вычисление триллионов знаков числа π, которым занимаются энтузиасты на обычных рабочих станциях, тоже построено на библиотеках произвольной точности с дисковой подкачкой промежуточных массивов.

Важная инженерная деталь состоит в том, что произвольная точность всё же конечна, а значит, подвержена тем же переполнениям, просто на уровне памяти. Программа, запросившая число из миллиарда цифр, упирается не в разрядность процессора, а в страницы подкачки и дисциплину операционной системы.

Символьные вычисления и ленивые бесконечные структуры

Самый элегантный способ работы с бесконечно точными величинами состоит в том, чтобы вообще не вычислять их численно. Системы компьютерной алгебры хранят π, e, квадратный корень из двух как символы с правилами преобразования. Когда Maxima, Mathematica или SymPy упрощает выражение, π участвует в нём как буква: sin(π) сворачивается в нуль символически, без всякого округления, а интеграл, зависящий от e, решается аналитически и остаётся точным навсегда. Бесконечная десятичная запись здесь просто не требуется: достаточно конечного имени и конечного набора законов.

Родственный приём работает на стороне структур данных. Ленивые вычисления позволяют описать бесконечный объект рецептом, а не содержимым. В Haskell выражение [1..] обозначает бесконечный список натуральных чисел, но память расходуется только на те элементы, которые программа реально потребовала; take 10 из бесконечности отлично материализуется в обычный массив из десяти значений. В C# генератор с yield return порождает последовательность по одному элементу за вызов MoveNext, и IEnumerable сам по себе не материализуется: пока никто не вызвал ToList или не проитерировал его до конца, бесконечный источник мирно существует в виде конечного автомата из нескольких локальных переменных. Потоки в Java 8 и итераторы в Python устроены ровно так же: бесконечность представлена состоянием машины, а не данными.

Расплата за такую красоту известна каждому, кто случайно применил материализующую операцию к бесконечной последовательности: программа честно начинает наполнять память и не остановится, пока не исчерпает её. Бесконечная лень требует конечного любопытства.

Символьные и ленивые подходы удачно дополняют друг друга в гибридных системах. Алгебраическое ядро держит выражение точным до последнего момента, а затем передаёт его численному ускорителю, который вычисляет приближение с заданной точностью по требованию. Точность здесь тоже ленивая: пользователь просит сто знаков, получает сто, просит миллион, и система честно досчитывает, не храня заранее ничего лишнего. Вся бесконечно подробная информация о величине существует исключительно как возможность пересчёта.

Бесконечность как сигнал превышения определённости и криптографические пределы

Физический и инженерный взгляд на вопрос добавляет ещё один оттенок. Появление Inf или резкого роста погрешности в численной модели обычно означает, что модель вышла за границы собственной определённости: шаг интегрирования слишком крупный, схема неустойчива, физические допущения нарушены. Опытный вычислитель читает бесконечность в результатах как диагностическое сообщение, а не как число, и именно такой подход отличает зрелую численную математику от наивной подстановки формул.

Другая практическая бесконечность живёт в криптографии. Ключ длиной 128 бит задаёт пространство из примерно 3,4 на 10^38 вариантов. Полный перебор такого пространства недостижим для любой обозримой вычислительной мощности: даже гипотетическая машина, проверяющая триллион триллионов ключей в секунду, потратила бы на задачу времена, далеко превосходящие любые инженерные горизонты. Ключ в 256 бит возводит этот барьер в ещё более недоступную степень. Математически перебор конечен, но практически он эквивалентен бесконечному труду, и на этой асимметрии построена вся современная защита данных. Предел грубой силы здесь играет ту же роль, что бесконечность в анализе: он обозначает недостижимое, но строго описанное.

Гигантские числа против реальных максимумов памяти

Популярные исполинские числа наглядно показывают, где кончается любая инженерия. Googol, единица со ста нулями, ещё записывается: это всего 101 знак, влезающий в строку экрана. Googolplex, единица с googol нулей, уже не записывается нигде: количество элементарных частиц в наблюдаемой вселенной оценивается величиной порядка 10^80, так что не хватило бы материи под чернила даже при записи по одной цифре на частицу. Тем не менее googolplex прекрасно живёт внутри программы как выражение 10^(10^100): ещё один триумф представления над содержимым.

На другом полюсе стоят твёрдые архитектурные максимумы. Шестидесятичетырёхбитное адресное пространство допускает 2^64 байта, то есть 16 эксабайт адресуемой памяти. Это колоссальный запас, в который годами не упирается даже серверное железо, но он конечен, и файловые системы, рассчитанные на десятилетия вперёд, закладывают уже 128-битные идентификаторы. Практический урок прост: битность адреса задаёт горизонт вычислительной эпохи так же, как разрядность счётчика миллисекунд задавала срок службы встроенных систем.

Табличные процессоры добавляют к картине человеческий интерфейс бесконечности. Когда формула в электронной таблице делит на нуль, пользователь видит #DIV/0!, а когда аргумент выходит за область допустимых значений, появляется #NUM!. За этими короткими кодами стоят те самые механизмы IEEE 754 и проверок области определения, переведённые на язык, понятный человеку без инженерного образования. Программа электронных таблиц фактически выполняет роль переводчика между суровой машинной семантикой особых значений и повседневной работой с цифрами, и это ещё одно подтверждение главной мысли: бесконечность внутри конечного компьютера всегда существует как знак, сигнал или рецепт, но никогда как само бесконечное содержимое.