Выражения, значения и неизменяемость

В Haskell нет переменных, которые меняются. Есть имена, привязанные к значениям раз и навсегда.
Слово «переменная» в Haskell обманчиво: на самом деле это неизменное имя для значения. Никакого x = x + 1 — это было бы уравнением без решения.

В императивных языках переменная — это ячейка, в которую можно класть разные значения по очереди. В Haskell имя привязывается к значению один раз и навсегда. Запись x = 5 — это не команда «положи 5 в x», а определение «x — это и есть 5».

Поэтому строка вроде x = x + 1 в Haskell — бессмыслица: она утверждает, что число равно самому себе плюс один. Чтобы «изменить» значение, вы не меняете старое, а создаёте новое имя или новое значение.

Всё — выражение

В Haskell нет инструкций (statements), есть только выражения, у которых есть значение. Даже условие — это выражение, возвращающее результат:

absVal :: Int -> Int
absVal n = if n < 0 then -n else n

Обратите внимание: if здесь обязан иметь и then, и else — потому что выражение всегда должно чему-то равняться. «If без else» невозможен: чему бы тогда было равно выражение, когда условие ложно?

Локальные имена: let и where

Чтобы дать имя промежуточному результату, используют let ... in или where:

circleArea :: Double -> Double
circleArea r = let pi' = 3.14159
                   r2  = r * r
               in pi' * r2

triangle :: Double -> Double -> Double
triangle base h = half * base * h
  where half = 0.5

И let, и where вводят локальные неизменяемые имена. Это не присваивание — это объявление того, что некое имя означает в данной области.

ИМПЕРАТИВНО (ячейка меняется)   HASKELL (новые значения)
x = 1                          let x = 1
x = x + 1   (перезапись)           y = x + 1   (новое имя)
x = x * 2                          z = y * 2
итог: x = 4                        итог: z = 4, x по-прежнему 1

В Python неизменяемость можно сымитировать: не перезаписывать, а создавать новые значения. Это и есть функциональный стиль.

# Та же идея на Python: не меняем, а создаём новое
def with_immutability(x):
    y = x + 1     # новое имя
    z = y * 2     # ещё новое имя
    return (x, y, z)

print(with_immutability(1))   # (1, 2, 4) — x остался прежним

# Кортеж неизменяем, как значения в Haskell
point = (3, 4)
moved = (point[0] + 1, point[1])   # новый кортеж, старый цел
print(point, moved)               # (3, 4) (4, 4)

Почему неизменяемость — это удобно

На первый взгляд запрет менять переменные кажется потерей, но на деле он убирает самый коварный источник багов — изменение данных «откуда-то ещё». Если значение не меняется, оно ведёт себя как надёжный факт: один раз вычислили и можете опираться на него где угодно, не перепроверяя, не «протух» ли он. Это особенно ценно в многопоточности: неизменяемые данные можно свободно читать из любого числа потоков без блокировок и гонок, потому что менять там нечего. Структуры данных в Haskell устроены так, что «новая версия» переиспользует большую часть старой, не копируя её целиком, — поэтому создавать новые значения дёшево, а не расточительно. Привыкнув мыслить определениями вместо пошаговых изменений, вы заметите, что код становится прямолинейнее: его можно читать сверху вниз как набор утверждений, каждое из которых верно само по себе.

Как это мыслить

Думайте уравнениями, а не шагами. Определение area = pi * r * r — это утверждение, верное всегда, а не команда, выполняемая однажды. Если нужно «обновить» данные, вы строите новую версию данных, а не портите старую.

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

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

  • Пытаться перезаписать имя. В одной области имя определяется один раз.
  • Писать if без else. Выражение обязано иметь значение во всех ветках.
  • Путать = с присваиванием. Это знак определения, ближе к математическому равенству.

Best practices

  • Разбивайте сложные выражения на именованные части через where — читается лучше.
  • Имена давайте осмысленные: код-определение хорошо читается сверху вниз.
  • Не бойтесь «создавать много значений» — компилятор и сборщик мусора справятся.

Итог. В Haskell имена неизменяемы, всё является выражением со значением, а if всегда имеет else. Вместо изменения данных вы создаёте новые — и это делает программы прозрачными и безопасными.

Проверьте себя
1. Почему запись x = x + 1 невозможна в Haskell?
AПотому что плюс запрещён
BПотому что = — это определение (равенство), а не присваивание, и уравнение не имеет смысла
CПотому что x слишком короткое имя
DПотому что нужно писать x := x + 1
2. Почему if в Haskell обязан иметь ветку else?
AТак требует форматтер
BПотому что if — это выражение и должно иметь значение при любом условии
Celse ускоряет вычисления
DЭто нужно только в GHCi