Ряд языков программирования заявляют неизменяемость переменных (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 Score! Подробнее
1 год7690 р. |
1 месяц720 р. |
Я уже участник «Xakep.ru»
Читайте также
Последние новости