Выражения, значения и неизменяемость
В 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. Вместо изменения данных вы создаёте новые — и это делает программы прозрачными и безопасными.