in ECMAScript

Заметки ES6: значения параметров по умолчанию

Read this article in English, Chinese.

В этой небольшой заметке мы затронем еще одну тему ES6 — параметры функций со значениями по умолчанию. Как мы увидим, там есть некоторые тонкости.

Раньше значения параметров по умолчанию обрабатывались вручную несколькими альтернативными способами:

[js]
function log(message, level) {
level = level || ‘warning’;
console.log(level, ‘: ‘, message);
}

log(‘low memory’); // warning: low memory
log(‘out of memory’, ‘error’); // error: out of memory
[/js]

Для того, чтобы избежать обработку возможных “ложных значений” (“falsey values”), часто можно было встретить использование typeof для этих целей:

[js]
if (typeof level == ‘undefined’) {
level = ‘warning’;
}
[/js]

Иногда также можно встретить проверку arguments.length:

[js]
if (arguments.length == 1) {
level = ‘warning’;
}
[/js]

Данные подходы работали достаточно хорошо, однако все они являются слишком “ручными” и менее абстрактным. ES6 стандартизировал синтаксическую конструкцию для определения значений по умолчанию параметров, прямо в заголовке функции.

Значения параметров по умолчанию присутствуют во многих языках, поэтому базовая форма, вероятно, должна быть знакома большинству программистов:

[js]
function log(message, level = ‘warning’) {
console.log(level, ‘: ‘, message);
}

log(‘low memory’); // warning: low memory
log(‘out of memory’, ‘error’); // error: out of memory
[/js]

Вполне обычная обработка значений по умолчанию, и вместе с тем — удобная. Стоит рассмотреть детали реализации для уточнения и разъяснения некоторых неочевидных особенностей, возникающих при работе со значениями по умолчанию.

Ниже представлены несколько специфичных для ES6 деталей реализации, связанных с обработкой значений параметров по умолчанию.

В отличие от некоторых других языков (например, Python), которые могут вычислять значения по умолчанию единожды, во время создания функции, ECMAScript вычисляет эти значения при каждом очередном вызове функции. Данный выбор в дизайне языка был сделан во избежание путаницы, которая может возникнуть при обработке объектов в качестве значений по умолчанию. Рассмотрим следующий пример:

[py]
def foo(x = []):
x.append(1)
return x

# Видим, что значения по умолчанию вычислены единожды,
# когда функция создается, и хранятся в качестве
# обычного свойства функции.
print(foo.__defaults__) # ([],)

foo() # [1]
foo() # [1, 1]
foo() # [1, 1, 1]

# Причина этому, как мы уже отметили:
print(foo.__defaults__) # ([1, 1, 1],)
[/py]

Чтобы избежать подобной ситуации, Python-программисты часто прибегают к трюку хранения значения по умолчанию как None, и использованию затем явной ручной проверки:

[py]
def foo(x = None):
if x is None:
x = []
x.append(1)
print(x)

print(foo.__defaults__) # (None,)

foo() # [1]
foo() # [1]
foo() # [1]

print(foo.__defaults__) # ([None],)
[/py]

Однако данный подход вновь возвращает нас к неудобной ручной обработке фактического значения по умолчанию, а первоначальная версия — и попросту вызывает путаницу. Как уже было отмечено, во избежание подобных случаев, ECMAScript вычисляет значения по умолчанию каждый раз при вызове функции:

[js]
function foo(x = []) {
x.push(1);
console.log(x);
}

foo(); // [1]
foo(); // [1]
foo(); // [1]
[/js]

Все хорошо и интуитивно. Теперь, давайте, посмотрим, когда семантика ES также может вызывать путаницу, если не знать, как данный механизм работает.

Рассмотрим следующий пример:

[js]
var x = 1;

function foo(x, y = x) {
console.log(y);
}

foo(2); // 2, не 1!
[/js]

Здесь мы получаем значение 2 для y, а не 1, как визуально могло бы показаться. Причина этому в том, что x из параметров — не тот же, что и из глобальный области видимости. И поскольку вычисление значений происходит при вызове функции, то при присвоении = x, значение x уже вычисляется во внутренней области видимости, и относится к самому параметру x. Т.е., параметр x затенил (shadowed) глобальную переменную с таким же именем, и каждое обращение к имени x, уже относится к параметру.

ES6 отмечает, так называемую, временную “мертвую” зону (Temporal Dead Zone, сокр. TDZ) — это участок кода, где переменная или параметр не могут быть использованы до момента их инициализации (т.е. до того, как они получат значение).

Относительно параметров — параметр не может иметь значение по умолчанию самого себя:

[js]
var x = 1;

function foo(x = x) { // ошибка!

}
[/js]

Присваивание = x, как было отмечено, вычисляет x в области видимости параметров, которая затеняет глобальный x. Однако параметр x, будучи в “мертвой зоне”, не может быть вычислен до того, как ему будет присвоено начальное значение. Очевидно, параметр не может инициализировать сам себя.

Обратим внимание, что предыдущий пример выше с y корректен, поскольку x уже инициализирован (ему присвоено неявное значение по умолчанию undefined, при входе в контекст). Покажем еще раз:

[js]
function foo(x, y = x) { // OK

}
[/js]

Данное поведение доступно, поскольку ECMAScript инициализирует параметры в порядке слева направо, и мы имеем x уже доступным для использования.

Мы отмечали, что параметры связаны уже с некой “внутренней областью видимости”. Из ES5 мы могли бы предположить, что это область видимости тела функции. Однако, данный момент несколько усложнен: это может быть область видимости тела функции или же, это может быть промежуточная область видимости, созданная специально для хранения параметров. Давайте рассмотрим эти области видимости.

В сущности, если какой-либо (хотя бы один) из параметров содержит значение по умолчанию, ES6 создает промежуточную область видимости (intermediate scope) для хранения параметров, и эта область не разделяется с областью видимости тела функции. Это главное отличие от ES5 в этом отношении. Продемонстрируем на примере:

[js]
var x = 1;

function foo(x, y = function() { x = 2; }) {
var x = 3;
y(); // Параметр `x` разделяется с телом функции?
console.log(x); // Нет, по-прежнему 3, не 2
}

foo();

// И внешний `x` также остался нетронутым.
console.log(x); // 1
[/js]

В данном случае у нас имеется три области видимости: глобальная область, область параметров, и область функции:

[js]
: {x: 3} // внутренняя
-> {x: undefined, y: function() { x = 2; }} // параметры
-> {x: 1} // глобальная
[/js]

Нетрудно заметить, что когда запускается функция y, она находит x в ближайшей области видимости (т.е. в той же области), и даже не видит область видимости тела функции.

Если нам необходимо скомпилировать ES6 код в ES5, то данная промежуточная область видимости будет выглядеть следующим образом:

[js]
// ES6
function foo(x, y = function() { x = 2; }) {
var x = 3;
y(); // Параметр `x` разделяется с телом функции?
console.log(x); // Нет, по-прежнему 3, не 2
}

// Скомпилированный to ES5
function foo(x, y) {
// Установка значений по умолчанию.
if (typeof y == ‘undefined’) {
y = function() { x = 2; }; // теперь видим, что это `x` из параметров
}

return function() {
var x = 3; // теперь видим, что этот `x` из тела функции
y();
console.log(x);
}.apply(this, arguments);
}
[/js]

Однако, в чем реальное назначение данной промежуточной области видимости, выделяемой для параметров? Почему мы не могли так же, как и в ES5, дальше создавать параметры в той же области видимости, что и тело функции? Причина этому: переменные в теле функции с теми же именами не должны воздействовать на замкнутые переменные с таким же именами из внешний областей видимости.

Покажем на примере:

[js]
var x = 1;

function foo(y = function() { return x; }) { // замыкаем `x`
var x = 2;
return y();
}

foo(); // правильно 1, не 2
[/js]

Если бы мы создали функцию-параметр y в теле функции, она бы замкнула внутренний x, т.е. 2. Однако очевидно, что функция y должна захватить внешний x, т.е. 1 (но только в том случае, если он не затенен параметром с тем же именем, как мы отмечали).

В то же время, мы не можем создать функцию y во внешней области видимости, поскольку это означало бы невозможность обращения к параметрам из такой функции, а мы должны иметь эту возможность:

[js]
var x = 1;

function foo(y, z = function() { return x + y; }) { // можем видеть `x` и `y`
var x = 3;
return z();
}

foo(1); // 2, не 4
[/js]

Описанная выше семантика в корне отличается от тех случаев, когда мы обрабатываем значения по умолчанию вручную:

[js]
var x = 1;

function foo(x, y) {
if (typeof y == ‘undefined’) {
y = function() { x = 2; };
}
var x = 3;
y(); // Параметр `x` разделяется с телом функции?
console.log(x); // да! 2
}

foo();

// И внешний `x` вновь остался нетронутым.
console.log(x); // 1
[/js]

И это интересная особенность: в случае, если функция не содержит параметров со значениями по умолчанию, то промежуточная область видимости не создается. В подобном случае параметры находятся в области видимости тела функции, т.е. работают в режиме ES5.

Здесь может возникнуть ряд справедливых вопросов. Зачем нужны подобные усложнения? Почему бы всегда не создавать эту промежуточную область видимости для хранения параметров? Сделано ли это лишь с целью оптимизации? Не совсем. Причина здесь именно в обратной совместимость с ES5: пример выше с ручной обработкой значений по умолчанию должен изменять x из тела функции (который является параметром и находится в самой области видимости тела функции).

Обратите внимание также, что подобные повторные объявления переменных доступны лишь при использовании ключевого слова var и функций. При использовании let или const с таким же именем перезаписать параметр невозможно:

[js]
function foo(x = 5) {
let x = 1; // ошибка
const x = 2; // ошибка
}
[/js]

Другим интересным фактом является то, что принятие решения, использовать ли значение по умолчанию, осуществляется именно проверкой значения на undefined. Продемонстрируем на примере:

[js]
function foo(x, y = 2) {
console.log(x, y);
}

foo(); // undefined, 2
foo(1); // 1, 2

foo(undefined, undefined); // undefined, 2
foo(1, undefined); // 1, 2
[/js]

Обычно в языках программирования параметры со значениями по умолчанию идут после обязательных параметров. Однако факт, описанный выше, позволяет нам иметь следующие конструкции в JavaScript:

[js]
function foo(x = 2, y) {
console.log(x, y);
}

foo(1); // 1, undefined
foo(undefined, 1); // 2, 1
[/js]

Значения по умолчанию также участвуют в операциях деструктивного присваивания (destructuring assignment). Тема деструктивного присваивания подробно не рассматривается в данной статье, однако, давайте покажем несколько небольших примеров. Обработка значений по умоланию в случае деструктуризации параметра функции осуществляется по тому же правилу: “две области видимости, если необходимо”.

[js]
function foo({x, y = 5}) {
console.log(x, y);
}

foo({}); // undefined, 5
foo({x: 1}); // 1, 5
foo({x: 1, y: 2}); // 1, 2
[/js]

Также, значения по умолчанию компонентов при деструктуризации более общие и могут быть использованы не только в функциях:

[js]
var {x, y = 5} = {x: 1};
console.log(x, y); // 1, 5
[/js]

Надеемся, что данная небольшая заметка помогла объяснить детали реализации значений по умолчани в ES6. Обратите внимание, что на момент написания статьи (21 августа 2014 г.), ни одна из реализаций не обрабатывает значения по умолчанию корректно (все они создают лишь одну область видимости, которая разделяется с телом функции), поскольку данная “промежуточная область видимости” была добавлена в черновик спецификации лишь недавно. Значения по умолчанию, несомненно, являются полезным дополнением в ES, которое сделает наш код более кратким и элегантным.

Автор перевода: Дмитрий Сошников
Дата перевода: 25 сентября 2014 г.

Автор оригинала: Дмитрий Сошников
Дата публикации: 21 августа 2014 г.

(моему давнему Интернет-коллеге по JS, Zeroglif)

Write a Comment

Comment

  1. Спасибо большое за статью! Ну и за все предыдущие заодно, давно вас читаю 😉

    А за переводы отдельная благодарность, очень приятно видеть действительно качественный материал на родном языке. Даже непривычно как-то 🙂

  2. Да, согласен с Семёном, большое вам спасибо за статьи! Вы отлично пишете и всегда по делу! Многому у вас научился!