Ряд языков программирования заявляют неизменяемость переменных (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 "%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»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.


  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    3 комментариев
    Старые
    Новые Популярные
    Межтекстовые Отзывы
    Посмотреть все комментарии