Числовая башня: целые, дроби, комплексные

Изучаем, почему арифметика в Lisp устроена честнее, чем в большинстве языков, и что такое числовая башня.

Числовая башня (numeric tower) — иерархия числовых типов Common Lisp: целые → рациональные → вещественные → комплексные. Каждый следующий уровень включает предыдущий, а операции автоматически выбирают подходящий тип результата, сохраняя точность там, где это возможно.

В большинстве распространённых языков числа — это компромисс с аппаратурой: целые ограничены 32 или 64 битами и молча переполняются, а дроби представлены приближённо в плавающей точке, отчего 0.1 + 0.2 внезапно не равно 0.3. Lisp исторически создавался не под аппаратуру, а под математику, и его числовая система отражает это: целые числа не переполняются, а дроби могут быть точными. Эта «числовая башня» — одна из самых приятных и недооценённых черт языка.

Целые без ограничения разрядности

В Common Lisp целые числа бывают двух видов, но граница между ними прозрачна для программиста. Fixnum — это небольшие целые, помещающиеся в машинное слово, с ними процессор работает напрямую и очень быстро. Bignum — это сколь угодно большие целые, которые при необходимости занимают столько памяти, сколько нужно. Самое важное: переход от fixnum к bignum происходит автоматически. Когда результат вычисления перестаёт помещаться в машинное слово, Lisp молча переключается на bignum, и вы просто получаете правильный ответ, а не переполнение.

Это качественно отличается от языков вроде C или Java, где целое фиксированной разрядности при переполнении «заворачивается» и даёт бессмысленный отрицательный результат. В Lisp факториал большого числа или огромная степень двойки вычисляются точно, без всяких специальных библиотек «длинной арифметики».

;; Целые не переполняются — переход к bignum автоматический:
(defun factorial (n)
  (if (<= n 1) 1 (* n (factorial (- n 1)))))

(factorial 30)
;; => 265252859812191058636308480000000   (точно, без переполнения)

(expt 2 100)
;; => 1267650600228229401496703205376     (2 в сотой степени, точно)

Рациональные числа: точные дроби

Вот черта, которой нет почти нигде в мейнстриме: Common Lisp умеет работать с точными рациональными числами — обыкновенными дробями вида «числитель/знаменатель». Когда вы делите два целых, и они не делятся нацело, Lisp не переходит к приближённой плавающей точке, а возвращает точную дробь. 1/3 — это полноценное число, а не приближение 0.333.... Дроби автоматически сокращаются, а арифметика над ними остаётся точной.

;; Деление целых даёт ТОЧНУЮ дробь, а не приближение:
(/ 1 3)            ; => 1/3        (рациональное число, не 0.333...)
(/ 6 4)            ; => 3/2        (автоматически сократилось)
(+ 1/3 1/6)        ; => 1/2        (точная арифметика дробей)
(* 2/3 3/4)        ; => 1/2

;; Сумма третей даёт ровно единицу — без накопления ошибки:
(+ 1/3 1/3 1/3)    ; => 1

Сравните это с плавающей точкой, где (+ 1/3 1/3 1/3) в приближённом виде могло бы дать что-то вроде 0.9999999. Точные дроби незаменимы там, где важна абсолютная корректность: финансовые расчёты, символьная математика, точные пропорции. Целые и рациональные вместе образуют категорию точных (exact) чисел — для них результат арифметики математически верен, без округлений.

Числа с плавающей точкой: когда нужна аппроксимация

Разумеется, не всё можно выразить дробью: корень из двойки, число пи, синус угла — иррациональны. Для них есть числа с плавающей точкой — приближённые вещественные числа, как в других языках. Они помечаются десятичной точкой (3.14) или экспонентой. Важно понимать «заразность» плавающей точки: как только в вычислении появляется хоть одно неточное число, результат становится неточным — Lisp вынужден перевести всё в плавающую точку, потому что точное число и приближённое нельзя честно смешать, сохранив точность.

;; Точка делает число неточным (float), и это "заражает" результат:
(/ 1 3)            ; => 1/3        (точная дробь)
(/ 1.0 3)          ; => 0.33333334 (float — появилась 1.0)
(+ 1/2 0.5)        ; => 1.0        (float "победил" дробь)

;; Иррациональные величины — всегда приближённые:
pi                 ; => 3.141592653589793d0
(sqrt 2)           ; => 1.4142135623730951d0

Здесь важна осознанность: если вам нужна точность — держитесь целых и дробей и не вводите случайно .0; если нужна работа с иррациональными величинами или скорость — используйте плавающую точку, понимая, что результат приближённый. Common Lisp даёт выбор, тогда как многие языки навязывают плавающую точку для любого деления.

Стоит сказать пару слов и о разновидностях плавающей точки. Стандарт предусматривает несколько форматов разной точности — короткие, одинарные, двойные и длинные, — которые задаются буквой в экспоненте: 1.5e0 — одинарной точности, 1.5d0 — двойной (отсюда суффикс d0, который вы видели у числа пи). По умолчанию SBCL читает обычные десятичные литералы как числа двойной точности, что соответствует типу double в других языках. Точные правила формата редко нужны новичку, но полезно узнавать суффикс d0 в выводе и понимать, что это просто пометка «двойная точность», а не часть значения. Когда важна предсказуемость на разных реализациях, тип литерала фиксируют явным суффиксом, чтобы программа не зависела от настроек по умолчанию.

Особо отметим коварство сравнения чисел с плавающей точкой на точное равенство. Из-за того что многие десятичные дроби не представимы в двоичной плавающей точке абсолютно точно, выражение вроде (= 0.1 (- 0.3 0.2)) вполне может вернуть ложь — накопленная погрешность делает левую и правую части чуть-чуть разными. Это не особенность Lisp, а общее свойство плавающей точки во всех языках, но в Lisp оно особенно заметно на контрасте: ведь рядом есть точные дроби, лишённые этой беды. Практическое правило — никогда не сравнивать результаты вычислений с плавающей точкой на строгое равенство, а проверять, что разность по модулю меньше малого порога; либо, если задача терпит, вести расчёты в точных рациональных числах и забыть о погрешностях вовсе.

Комплексные числа: вершина башни

На вершине башни — комплексные числа, встроенные прямо в язык, без всяких библиотек. Комплексное число записывается через #c(вещественная мнимая). Что особенно элегантно: некоторые функции возвращают комплексный результат там, где математически это правильно, — например, квадратный корень из отрицательного числа. Многие языки в этом случае выдали бы ошибку или «не-число»; Common Lisp честно возвращает комплексное число.

;; Комплексные числа встроены в язык:
#c(3 4)            ; => #C(3 4)       (3 + 4i)
(+ #c(1 2) #c(3 4)); => #C(4 6)
(* #c(0 1) #c(0 1)); => -1            (i умножить на i = -1)

;; Корень из отрицательного даёт комплексное число, а не ошибку:
(sqrt -1)          ; => #C(0.0 1.0)  (мнимая единица i)
(sqrt -4)          ; => #C(0.0 2.0)

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

Сравнение чисел: = против eql

С числами связана ещё одна тонкость, на которой спотыкаются новички, — как их правильно сравнивать. В Lisp есть несколько разных предикатов равенства, и для чисел важно выбрать верный. Функция = сравнивает числа по математическому значению, игнорируя тип: для неё 1, 1.0 и 1/1 равны, потому что обозначают одно и то же число. А вот eql сравнивает строже — учитывая и значение, и тип: для неё целое 1 и вещественное 1.0 уже не равны, потому что это числа разных типов. Это различие принципиально: для арифметических сравнений почти всегда нужен =, а eql уместен, когда тип числа важен.

;; = сравнивает по значению, игнорируя тип:
(= 1 1.0)          ; => T    (математически равны)
(= 1/2 0.5)        ; => T    (одно и то же значение)
(= 3 3 3.0 6/2)    ; => T    (принимает много аргументов)

;; eql учитывает и тип, и значение:
(eql 1 1.0)        ; => NIL  (целое и float — разные типы)
(eql 1 1)          ; => T

;; Сравнения тоже принимают любое число аргументов:
(< 1 2 3 4)        ; => T    (строго возрастает)
(<= 1 1 2 3)       ; => T    (неубывает)

Заметьте попутно, что операторы сравнения <, >, <=, >= в Lisp — это обычные функции в префиксной записи, принимающие любое число аргументов. Запись (< 1 2 3 4) проверяет, что вся цепочка строго возрастает, — лаконичная замена нескольким сравнениям. И, разумеется, как любые имена-функции, эти символы можно цитировать и передавать как данные, что мы используем в разделе про функции высшего порядка.

Как это работает под капотом

За автоматическим выбором типа стоит механизм, называемый контагией (contagion) — правилами «заражения» типов. При операции над числами разных типов Lisp приводит их к общему «наименьшему охватывающему» типу: целое и дробь дают дробь; точное и неточное дают неточное (float); вещественное и комплексное дают комплексное. Результат возвращается в самом узком типе, который его точно представляет: например, (/ 6 3) даст целое 2, а не дробь 2/1, потому что деление вышло нацело и дробь автоматически «опустилась» до целого.

На уровне реализации fixnum хранятся прямо в машинном представлении и обрабатываются быстро, а bignum, рациональные и комплексные — это полноценные объекты в куче. Поэтому за гибкость и точность приходится платить: арифметика с bignum и дробями медленнее машинной. Когда критична скорость и известно, что числа малы, программист может с помощью объявлений типов подсказать компилятору SBCL использовать быстрые машинные операции. Но по умолчанию язык выбирает корректность, а не предельную скорость, — и это сознательное решение в духе математической честности Lisp.

Частые ошибки

Первая ошибка — случайно «уронить» точность, написав десятичную точку. Стоит в выражении появиться 2.0 вместо 2, и весь результат станет приближённым float, даже если остальные числа точные. Если вам нужна точная арифметика, следите, чтобы все литералы были целыми или дробями, без лишних .0.

Вторая ошибка — ждать дробь там, где деление выходит нацело. (/ 6 3) даст целое 2, а не 2/1: Lisp всегда опускает результат до самого узкого типа. Это не баг, а корректное поведение башни, но новичка может удивить, что «деление» иногда даёт целое.

Третья ошибка — забывать о цене больших чисел и дробей. Точность не бесплатна: операции с bignum и рациональными медленнее машинных, и в горячем цикле, перемалывающем миллионы чисел, это может быть заметно. В таких местах либо используют плавающую точку осознанно, либо ограничивают значения fixnum и объявляют типы. Знать о компромиссе «точность против скорости» полезно, чтобы выбирать осознанно, а не натыкаться на замедление случайно.

Итоги

  • Числовая башня Common Lisp: целые → рациональные → вещественные → комплексные; операции автоматически выбирают подходящий тип.
  • Целые не переполняются — переход fixnum → bignum происходит автоматически, факториалы и степени вычисляются точно.
  • Деление целых даёт точные рациональные дроби (1/3), которые сокращаются и считаются без потери точности; целые и дроби — это «точные» числа.
  • Десятичная точка делает число неточным (float) и «заражает» весь результат; иррациональные величины и скорость требуют плавающей точки.
  • Комплексные числа встроены в язык; корень из отрицательного честно возвращает комплексное число. Точность не бесплатна — bignum и дроби медленнее машинной арифметики.
Проверьте себя
1. Что вернёт (/ 1 3) в Common Lisp?
A0.333333, приближённое число с плавающей точкой
B1/3 — точное рациональное число (дробь)
C0, потому что целочисленное деление отбрасывает остаток
DОшибку деления
2. Что произойдёт при вычислении факториала 30 в Common Lisp?
AЦелое переполнится и даст отрицательное или мусорное значение
BБудет получен точный результат, потому что целые автоматически переходят от fixnum к bignum без ограничения разрядности
CРезультат будет приближённым float
DВозникнет ошибка переполнения стека
3. Почему (+ 1/2 0.5) даёт 1.0, а не 1?
AПотому что Lisp всегда округляет результаты
BИз-за контагии: неточное число 0.5 (float) «заражает» вычисление, и точная дробь приводится к плавающей точке
CПотому что 1/2 не равно 0.5
DЭто ошибка реализации SBCL