Пространство для разработки переменных в языках программирования велико, но до сих пор (на самом деле) было найдено только одно хорошее решение и много плохих решений. Я постараюсь сделать этот пост несколько объективным, предоставив разумные критерии того, как должны работать переменные, и показав, насколько плохой выбор — это плохо.
Надеюсь, бесспорно, что дизайн переменных языка программирования должен быть:
- Безопасный. То есть избегайте футганов.
- Предсказуемый. То есть избежать путаницы.
Вариативный дизайн должен отвечать как минимум на следующие вопросы:
- (А). Какие виды затенения разрешить
- (Б). Разрешить ли переназначение
- (С). Следует ли отличать декларацию от использования
Я пройдусь по пунктам от А до В, а затем подведу итоги. TLDR заключается в том, что то, как это делают OCaml и подобные языки, является лучшим из того, что было обнаружено до сих пор.
A: Какие виды затенения разрешить
Затенение - это когда вводится новая переменная s.t. предыдущая переменная с тем же именем больше не видна. Вот два примера затенения:
// javascript let x = true; function foo() { let x = 3; // the first `x` is not accessible here } foo(); console.log(x) // true (* ocaml *) let () = let x = true in let x = 3 in (* the first `x` is not accessible here *) x
Редко можно найти язык с переменными, который не допускает какой-либо формы затенения. Там, где языки различаются, разрешено затенение.
Если мы портируем пример OCaml на JavaScript, мы получаем ошибку:
// javascript let x = true; let x = 3; // SyntaxError: redeclaration of let x
Не разрешать затенение везде менее безопасно, чем разрешать затенение. Проблема чаще всего возникает при выполнении «функциональных обновлений», которые могут создавать множество промежуточных переменных. Обычно код Erlang можно увидеть следующим образом:
% erlang example() -> State1 = init(), State2 = foo(State1), State3 = bar(State2), io:format("State3 is ~s~n", State3), State2. % implicit return. The author probably meant to return `State2`
Довольно легко случайно сослаться на неправильный State
. Я видел такую ошибку как в Erlang, так и в Scala.
Затенение повышает безопасность, поскольку переменные, которые вам больше не нужны, становятся недоступными, поэтому нет опасности их случайного использования. Вот пример, напрямую перенесенный на OCaml:
(* ocaml, but not very idiomatic *) let example () = let state = State.init () in let state = foo state in let state = bar state in Printf.printf "state is %s\n" (State.show state); state
Разрешить затенение везде, кажется, лучше, чем безопасность.
B: Разрешить ли переназначение
В некоторых языках переназначение очень похоже на затенение, поэтому сначала я проиллюстрирую разницу.
Здесь x
в цикле отличается от x
вне цикла. Это затенение:
// javascript let x = 1; for (const i of [1, 2, 3]) { let x = 20 + i; } console.log(x); // 1
Здесь цикл переназначается на x
, который находится вне цикла. Это переназначение:
// javascript let x = 1; for (const i of [1, 2, 3]) { x = 20 + i; } console.log(x); // 23
Переназначение тонкое. Это мутация сопоставления имен со значениями. Непонимание переназначения встречается на удивление часто:
- Это приводит к иллюзии, что языки без передачи по ссылке имеют передачу по ссылке. См. Статья Geeks For Geeks об иллюзорном различии и мое объяснение иллюзии.
- Многие языки имеют как переназначаемые, так и непереназначаемые переменные: JS
let
/const
, Swiftvar
/let
, Scala и Kotlinvar
/val
, Rustlet mut
/let
. Пользователи таких языков часто путают изменчивость привязок переменных с изменчивостью значений, на которые указывают переменные (см. Статья Дэна Абрамова).
Если в языке уже есть изменяемые значения, ему необходимо также иметь изменяемые сопоставления имен со значениями внутри областей. Одной концепции достаточно, и гораздо меньше путаницы. Вот два приведенных выше примера JS, перенесенных на OCaml:
(* ocaml: shadowing *) let () = let x = 1 in for i = 1 to 3 do let x = 20 + i in let _ = x in (* avoid unused variable warning *) () done; print_int x (* prints 1 *)
По сравнению с JS-версией примера с теневым копированием, гораздо более очевидно, что происходит в OCaml, поскольку нет переназначения.
Вот как получить ту же выразительность и удобство переназначения, но без футганов:
(* ocaml: references *) let () = let x = ref 1 in for i = 1 to 3 do x := 20 + i; done; print_int !x (* prints 23. `!` is the dereferencing operator *)
Ссылки OCaml не имеют специального регистра, это просто изменяемые записи. Развернув определения :=
и !
, мы получим:
(* ocaml: desugared references *) type 'a ref = { mutable contents: 'a } let ref a = { contents = a } let () = let x = ref 1 in for i = 1 to 3 do x.contents <- 20 + i done; print_int x.contents (* prints 23 *)
Refs
настолько полезны, что отображаются даже в языках с переназначением. Например, (Hack) и (JavaScript).
Не иметь переназначения лучше w.r.t. избегая путаницы. Это также проще: мы можем получить ту же выразительность с меньшим количеством понятий.
C: Следует ли отличать декларацию от использования
Объявление переменной создает новую переменную в области видимости. Использование переменной включает ссылку на переменную и переназначение, если язык позволяет переназначение.
Это различие предоставляется «бесплатно», если в языке нет переназначения и запрещены ссылки на необъявленные переменные, поэтому, если вы разрабатываете язык и уже сделали правильный выбор для (B), вы можете остановиться здесь.
Не отличать декларацию от использования опасно и сбивает с толку. Поясню на примере. Для каждой из следующих программ Python попытайтесь выяснить, что печатается. Я дам ответы ниже.
v = [1] def foo(): v.append(2) v = [] foo() print(v) # python q1: what is printed? def foo(): x = 1 def bar(): x = 2 bar() print(x) # python q2: what is printed? foo() def foo(): x = 1 def bar(): nonlocal x x = 2 bar() print(x) # python q3: what is printed? foo() x = 1 def bar(): nonlocal x x = 2 bar() print(x) # python q4: what is printed? from random import choice if choice([True, False]): x = 3 print(x) # python q5: what is printed? from random import choice # adapted from https://twitter.com/elfprince13/status/1572944786763530241 def main(): e = None while e is None: try: if choice([True, False]): raise Exception("") except Exception as e: pass main() x = 3 print(x) # python q6: what is printed?
Вот ответы, протестированные с использованием Python 3.8.9:
- Q1: ничего. Есть
UnboundLocalError
. Несмотря на то, что Python обычно ведет себя как построчный интерпретатор,v = []
в последней строке функции приводит к возврату назад во времени и изменению значения первой строки функции. - q2: “1”
- q3: “2”
- Q4: ничего. Существует
SyntaxError
: «привязка для нелокального x не найдена». Перемещение кода между областью действия вложенной функции и областью действия функции верхнего уровня может привести к ошибке времени выполнения, если вы забудете изменитьnonlocal
наglobal
. - q5: Либо «5», либо
NameError
, в зависимости от вашей удачи. - Q6: ничего. Перед назначением есть ссылка на
UnboundLocalError
:e
Отсутствие различия между объявлением и использованием не подразумевает точно такое же странное поведение, как в Python, но это даже хуже: правила для того, что такое присваивание и что такое переназначение, сильно различаются в языках, которые не делают этого различия.
Различие между объявлением и использованием лучше, чем объединение объявления и использования как для безопасности, так и для предсказуемости.
Заключение
Для языка программирования лучше всего:
- Разрешить затенение переменных свободно.
- Не разрешать переназначение переменных. Затенение и ссылки выполняют свою работу с меньшим количеством концепций и большей ясностью.
- Отличайте объявление от использования. Это бесплатно, если у вас нет переназначения.