Следующая новость
Предыдущая новость

Immutable. Постигаем хитрости неизменяемых структур данных в функциональных языках

19.02.2020 13:22

Содержание статьи

  • Краткая история переменных
  • Константы
  • Связывание имен со значениями и области видимости
  • Замыкания
  • Замыкания как форма инкапсуляции
  • Структуры данных
  • Заключение

Ряд языков программирования заявляют неизменяемость переменных (immutability) как одну из своих главных фич. Среди них семейство ML (OCaml, F#, Standard ML) и Haskell, а также молодые Clojure и Rust. Если ты незнаком с ними, то наверняка удивлялся: а чем это отличается от const в C и C++? Давай разложим все по полочкам.

Примеры мы будем писать на OCaml и Rust, чтобы продемонстрировать сходство и различия реализации этой идеи в разных языках. Выполнить примеры на OCaml можно в онлайне на сайте try.ocamlpro.com, а примеры на Rust — на play.rust-lang.org.

Краткая история переменных

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

Чуть позже появились ассемблеры, которые позволяли указывать символьные метки вместо числовых адресов. Возьмем пример на условном языке ассемблера и посмотрим, как будет выглядеть вывод строки hello world в бесконечном цикле.

msg:   .ascii "hello world"  foo:   push msg   call print   jmp foo 

Любой современный ассемблер за нас придумает, как разместить в памяти строку hello world и машинные инструкции, а метку foo в jmp foo заменит реальным адресом инструкции push msg в памяти. Затем компоновщик (linker) подставит вместо названия функции print ее реальный адрес в библиотеке, но это другая история. Это первый уровень абстракции по сравнению с машинным кодом.

Первые версии фортрана и прочих ранних языков были скорее развитыми макроассемблерами, чем компиляторами в современном понимании. Даже С на момент своего появления транслировал каждый оператор языка в одну-две машинные команды PDP-11.

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

Неизменным оставалось одно: каждое имя переменной связано с определенным участком памяти или как минимум одними и теми же данными. Присваивание нового значения в императивном программировании всегда затирает старые данные в памяти и заменяет их новыми.

Наибольшие сложности это вызывает, когда компилятор начинает применять к переменным оптимизации. Если содержимое памяти может измениться в любой момент, судить о том, можно ли заинлайнить значение переменной, непросто.

Еще сложнее становятся задачи вроде undo и redo. Если ты пишешь текстовый или графический редактор с возможностью отменить изменения, в языке вроде C есть только два варианта: хранить каждую версию данных либо явно хранить список выполненных операций вроде DeleteLineRange(10,11) и ApplyFilter(Blur, radius=2).

Даже в более простых задачах может оказаться, что функции из библиотеки модифицируют существующие данные, и, если оригинальные данные еще понадобятся, их приходится копировать целиком. Популярность copy.copy() и copy.deepcopy() в коде на Python — яркое тому подтверждение.

Константы

Механизм констант в языках вроде C — первый маленький шаг к неизменяемым переменным. Если мы пишем const int foo = 17, у компилятора есть гарантия, что значение, связанное с именем foo, никогда не изменится во время выполнения. Это позволяет безопасно оптимизировать код таким образом, что ассоциации имени foo или значения 17 с каким-то адресом в памяти там не останется — во всех выражениях вроде bar = foo*2 слово foo будет просто заменено на значение 17. С данными большей сложности и размеров такая наивная оптимизация уже не работает, но простор для оптимизаций все равно больше, чем с изменяемыми переменными.

Остается одно главное ограничение — имена констант связаны с определенными значениями для всей программы или модуля. Именно это ограничение и снимают неизменяемые переменные.

Связывание имен со значениями и области видимости

Возможности языков обычно работают не в изоляции, а вместе. Не делать постоянной связь имен со значениями можно, если создание новых областей видимости (scope) будет простым и «дешевым».

Часто для связывания (binding) имени со значением используют синтаксис вроде let name = value и его вариации. Каждое связывание открывает новую область видимости. Посмотрим пример на OCaml.

(* Scope 0 *) let x = "hello" let () = Printf.printf "%s" x  let x = " world" (* Scope 1 *) let () = Printf.printf "%sn" x 

Или похожий пример на Rust.

fn main() {     // Scope 0     let x = 5;     println!("The value of x is: {}", x);      let x = x + 1;     // Scope 1     println!("The value of x is: {}", x); } 

Это очень простой пример, который отличается от const в C только тем, что нам не пришлось выдумывать новое имя для каждого нового значения. В обоих случаях компилятору понятно, что за пределами области видимости Scope 0 (после второго let) старое значение x никем не используется и выделенную под него память можно безопасно освободить или вовсе не выделять под него память динамически.

Гораздо интереснее случаи, когда имена используются заново, а старые данные остаются жить в памяти.

Продолжение доступно только участникам

Материалы из последних выпусков становятся доступны по отдельности только через два месяца после публикации. Чтобы продолжить чтение, необходимо стать участником сообщества «Xakep.ru».

Присоединяйся к сообществу «Xakep.ru»!

Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», увеличит личную накопительную скидку и позволит накапливать профессиональный рейтинг Xakep Score! Подробнее

1 год

7690 р.

1 месяц

720 р.

Я уже участник «Xakep.ru»

Источник

Последние новости