Содержание статьи
Примеры мы будем писать на 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 "%s\n" 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
никем не используется и выделенную под него память можно безопасно освободить или вовсе не выделять под него память динамически.
Гораздо интереснее случаи, когда имена используются заново, а старые данные остаются жить в памяти.
Продолжение доступно только участникам
Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
Вариант 2. Открой один материал
Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.
Я уже участник «Xakep.ru»