Пространство для разработки переменных в языках программирования велико, но до сих пор (на самом деле) было найдено только одно хорошее решение и много плохих решений. Я постараюсь сделать этот пост несколько объективным, предоставив разумные критерии того, как должны работать переменные, и показав, насколько плохой выбор — это плохо.

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

  • Безопасный. То есть избегайте футганов.
  • Предсказуемый. То есть избежать путаницы.

Вариативный дизайн должен отвечать как минимум на следующие вопросы:

  • (А). Какие виды затенения разрешить
  • (Б). Разрешить ли переназначение
  • (С). Следует ли отличать декларацию от использования

Я пройдусь по пунктам от А до В, а затем подведу итоги. 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, Swift var/let, Scala и Kotlin var/val, Rust let 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, но это даже хуже: правила для того, что такое присваивание и что такое переназначение, сильно различаются в языках, которые не делают этого различия.

Различие между объявлением и использованием лучше, чем объединение объявления и использования как для безопасности, так и для предсказуемости.

Заключение

Для языка программирования лучше всего:

  • Разрешить затенение переменных свободно.
  • Не разрешать переназначение переменных. Затенение и ссылки выполняют свою работу с меньшим количеством концепций и большей ясностью.
  • Отличайте объявление от использования. Это бесплатно, если у вас нет переназначения.