Последняя важная тема, которую мы хотим затронуть, - это то, как работает синтаксис языка JavaScript (он же его грамматика). Вы можете думать, что знаете, как писать JS, но в различных частях грамматики языка ужасно много нюансов, которые приводят к путанице и неправильному пониманию, поэтому мы хотим углубиться в эти части и прояснить некоторые вещи.
Примечание: Термин "грамматика" может быть немного менее знаком читателям, чем термин "синтаксис". Во многих отношениях это схожие термины, описывающие правила того, как работает язык. У них есть небольшие различия, но они в основном не имеют значения для нашего здесь обсуждения. Грамматика для JavaScript - это структурированный способ описания того, как синтаксис (операторы, ключевые слова и т.д.) компонуется в правильно и корректно разработанные программы. Иными словами, обсуждение синтаксиса без грамматики упустило бы многие важные детали. Поэтому, в этой главе наше внимание акцентируется на том, что наиболее точно описывается как "грамматика", хотя разработчики непосредственно оперируют с синтаксисом языка.
Разработчики довольно часто предполагают, что термины "инструкция" и "выражение" примерно эквивалентны. Но здесь нам нужно разделить их, потому что в наших JS программах есть некоторые очень важные различия.
Чтобы провести различие, давайте позаимствуем терминологию, с которой вы, возможно, более знакомы: английский язык.
"Предложение" - это одно законченное высказывание из слов, выражающее мысль. Оно состоит из одной или нескольких "фраз", каждая из которых может быть связана знаками препинания или союзами ("и", "или" и т.д.). Сама фраза может состоять из более мелких фраз. Некоторые фразы являются неполными и сами по себе мало чего значат, в то время как другие фразы могут быть самостоятельными. Эти правила в совокупности называются грамматикой английского языка.
Так же обстоит дело с грамматикой JavaScript. Инструкции - это предложения, выражения - это фразы, а операторы - это союзы/знаки препинания.
Каждое выражение в JS может быть вычислено вплоть до одного конкретного значения. Например:
var a = 3 * 6;
var b = a;
b;
В этом фрагменте 3 * 6
является выражением (вычисляется до значения 18
). a
во второй строке также является выражением, как и b
в третьей строке. Оба выражения a
и b
вычисляются в соответствии со значениями, хранящимися в этих переменных в данный момент, которые равны 18
.
Более того, каждая из трех строк представляет собой инструкцию, содержащую выражения. var a = 3 * 6
и var b = a
называются "операторами объявления", потому что каждый из них объявляет переменную (и необязательно присваивает ей значение). Присваивания a = 3 * 6
и b = a
(откидывая var
) называются выражениями присваивания.
Третья строка содержит только выражение b
, и это также инструкция сама по себе (хотя и не очень интересная!). Обычно это называется "оператор выражения".
Это весьма малоизвестный факт, что все инструкции имеют значения завершения (даже если это просто undefined
).
Как бы вы отнеслись к тому, чтобы взглянуть на значение завершения?
Наиболее очевидный путь - ввести инструкцию в консоль разработчика вашего браузера, потому что, когда вы ее выполняете, консоль по умолчанию сообщает значение завершения самой последней инструкции, которую она выполнила.
Давайте рассмотрим var b = a
. Каково значение завершения этой инструкции?
Выражение присваивания b = a
возвращает значение, которое было присвоено (18
см. выше), но сама инструкция var
возвращает undefined
. Почему? Потому что так инструкция var
определена в спецификации. Если вы введете var a = 42;
в свою консоль, вы увидите сообщение undefined
вместо 42
.
Примечание: Технически всё немного сложнее. В спецификации ES5, раздел 12.2 "Инструкция переменной", алгоритм VariableDeclaration
действительно возвращает значение (string
, содержащая имя объявленной переменной - странно, да!?), но это значение обычно поглощается (кроме цикла for..in
) алгоритмом VariableStatement
, который выдает пустое (известное как undefined
) значение завершения.
На самом деле, если вы много экспериментировали с кодом в своей консоли (или в JS среде REPL -- read /evaluate /print / loop), вы, вероятно, видели сообщение undefined
после множества разных инструкций и, возможно, никогда не понимали, что бы это было. Проще говоря, консоль просто сообщает значение завершения инструкции.
Но то, что консоль выводит как значение завершения, - это не то, что мы можем использовать внутри нашей программы. Итак, как мы можем зафиксировать значение завершения?
Это гораздо более сложная задача. Прежде чем мы объясним как, давайте рассмотрим почему вам хотелось бы это сделать?
Нам нужно рассмотреть другие типы значений завершения инструкции. Например, любой обыденный блок { .. }
имеет значение завершения, равное значению завершения его последней инструкции/выражения.
Рассмотрим:
var b;
if (true) {
b = 4 + 38;
}
Если бы вы ввели это в своей REPL-консоли, вы, вероятно, увидели бы сообщение 42
, поскольку 42
- это значение завершения блока if
, который принял значение завершения своей последней инструкции выражения присваивания b = 4 + 38
.
Другими словами, значение завершения блока похоже на неявный return значения последней инструкции в блоке.
Примечание: Это концептуально известно в таких языках, как CoffeeScript, которые имеют неявные return
значения из function
, которые совпадают со значением последней инструкции в функции.
Но есть очевидная проблема. Такой код не работает:
var a, b;
a = if (true) {
b = 4 + 38;
};
Мы не можем зафиксировать значение завершения инструкции и присвоить его другой переменной каким-либо простым синтаксическим/грамматическим способом (по крайней мере, пока!).
Итак, что мы можем сделать?
Предупреждение: только для демонстрационных целей -- пожалуйста, не делайте нижеприведенное в вашем реальном коде!
Мы можем использовать сильно оклеветанную функцию eval(..)
(иногда произносится как "evil" - "злой"), чтобы зафиксировать это значение завершения.
var a, b;
a = eval( "if (true) { b = 4 + 38; }" );
a; // 42
Дааааааааа. Это ужасно некрасиво. Но это работает! И это иллюстрирует тот факт, что значения завершения инструкции - это реальная вещь, которая может быть зафиксирована не только в нашей консоли, но и в наших программах.
Есть предложение для ES7 под названием "выражение исполнения" (do-выражение). Вот как это может сработать:
var a, b;
a = do {
if (true) {
b = 4 + 38;
}
};
a; // 42
Выражение do { .. }
выполняет блок (с одним или несколькими операторами в нем), и значение завершения последней инструкции внутри блока становится значением завершения do
-выражения, которое, как показано, затем может быть присвоено a
.
Общая идея состоит в том, чтобы иметь возможность обрабатывать инструкции как выражения -- они могут отображаться внутри других инструкций -- без необходимости оборачивать их в функциональные выражения и делать явный return ..
.
На данный момент значения завершения инструкций - это не больше, чем пустяки. Но они, возможно, будут приобретать все большее значение по мере развития JS, и, надеюсь, выражения do { .. }
уменьшат соблазн использовать такие вещи, как eval (..)
.
Предупреждение: Повторяю мое предыдущее увещевание: избегайте eval (..)
. Серьезно. За дополнительными деталями обращайтесь к книге этой же серии Область Видимости и Замыкания.
Большинство выражений не имеют побочных эффектов. Например:
var a = 2;
var b = a + 3;
Выражение a + 3
не имело собственного побочного эффекта, как, например, изменение a
. У него был результат, равный 5
, и этот итог был присвоен b
в инструкции b = a + 3
.
Наиболее распространенным примером выражения с (возможными) побочными эффектами является выражение вызова функции:
function foo() {
a = a + 1;
}
var a = 1;
foo(); // результат: `undefined`, сторонний эффект: изменённое `a`
Однако есть и другие побочные эффекты выражений. Например:
var a = 42;
var b = a++;
Выражение a++
выполняет два отдельных действия. Сначала оно возвращает текущее значение a
, которое равно 42
(которое следом присваивается b
). Но затем оно изменяет само значение a
, увеличивая его на единицу.
var a = 42;
var b = a++;
a; // 43
b; // 42
Многие разработчики ошибочно полагают, что b
равно 43
так же, как и a
. Неразбериха возникает из-за того, что не полностью учитывается, когда происходит побочный эффект оператора ++
.
Операторы инкремента ++
и оператор декремента --
являются унарными операторами (см. Главу 4), которые могут использоваться либо в постфиксной ("после") позиции, либо в префиксной ("до") позиции.
var a = 42;
a++; // 42
a; // 43
++a; // 44
a; // 44
Когда ++
используется в префиксной позиции, как в ++a
, его побочный эффект (увеличение a
на единицу) происходит до возврата значения из выражения, а не после, как в случае a++
.
Примечание: Считаете ли вы, что ++a++
- это корректный синтаксис? Если вы попробуете, то получите ошибку ReferenceError
, но почему? Потому что операторы с побочным эффектом требуют ссылки на переменную, на которую нацелены их побочные эффекты. Для ++a++
сначала вычисляется часть a++
(из-за приоритета оператора - см. ниже), которая возвращает значение a
до инкремента. Но затем он пытается вычислить ++42
, что (если вы попытаетесь это сделать) даст ту же ошибку ReferenceError
, поскольку ++
не может иметь побочного эффекта непосредственно для значения 42
.
Иногда ошибочно полагают, что вы можете инкапсулировать побочный эффект после a++
, заключив его в пару скобок ( )
, например:
var a = 42;
var b = (a++);
a; // 43
b; // 42
К сожалению, ( )
сами по себе не определяют новое обёрнутое выражение, которое вычисляется следом за побочным эффектом выражения a++
, как мы могли бы надеяться. Фактически, даже если бы это произошло, a++
сначала возвращает 42
, и, если у вас нет другого выражения, которое повторно вычисляет a
после побочного эффекта ++
, вы не получите 43
из этого выражения, поэтому b
не будет присвоен 43
.
Однако есть вариант: ,
- инструкция серии операторов запятой. Этот оператор позволяет вам объединить несколько автономных выражений в одну инструкцию:
var a = 42, b;
b = ( a++, a );
a; // 43
b; // 43
Примечание: Здесь требуется ( .. )
вокруг a++, a
. Причина в приоритете операторов, о котором мы поговорим позже в этой главе.
Выражение a++, a
означает, что второе выражение инструкции a
вычисляется следом за побочным эффектом первого выражения инструкции a++
, что означает, что оно возвращает значение 43
для выполнения присваивания b
.
Другим примером оператора с побочным действием, является delete
. Как мы показали в Главе 2, delete
используется для удаления свойства из object
или ячейки из array
. Но обычно он просто вызывается как отдельный оператор:
var obj = {
a: 42
};
obj.a; // 42
delete obj.a; // true
obj.a; // undefined
Результирующее значение оператора delete
равно true
, если запрошенная операция является допустимой, или false
в противном случае. Но побочный эффект оператора заключается в том, что он удаляет свойство объекта (или ячейку массива).
Примечание: Что мы подразумеваем под допустимым? Несуществующие свойства или свойства, которые существуют и конфигурируемы (см. Главу 3 из книги этой серии This и Прототипы Объектов), вернут true
из оператора delete
. В противном случае результат будет false
или ошибка.
Последним примером оператора с побочным эффектом, который может быть одновременно и очевидным, и неочевидным, является оператор присваивания =
.
Взгляните:
var a;
a = 42; // 42
a; // 42
Может показаться, что =
в инструкции a = 42
не является оператором с побочным действием. Но, если мы посмотрим на результирующее значение инструкции a = 42
, то увидим - это только что присвоенное значение (42
), поэтому присвоение в a
значения по сути является побочным эффектом.
Совет: Те же рассуждения о побочных эффектах применимы к составным операторам присваивания, таким как +=
, -=
, и т.д. Например, a = b += 2
сначала обрабатывается как b += 2
(то есть b = b + 2
), а результат этого =
затем присваивается a
.
Такое поведение, при котором выражение присваивания (или инструкция) завершается присвоенным значением, в первую очередь полезно для цепочек присвоений, таких как:
var a, b, c;
a = b = c = 42;
Здесь c = 42
вычисляется как 42
(с побочным эффектом присваивания 42
в c
), затем b = 42
вычисляется как 42
(с побочным эффектом присваивания 42
в b
), и, наконец, вычисляется a = 42
(с побочным эффектом присваивания 42
в a
).
Предупреждение: Распространенная ошибка, которую разработчики допускают при выполнении цепочки присваиваний выглядит так: var a = b = 42
. Хотя это смотрится как одно и то же, но это не так. Если эта инструкция выполняется без наличия отдельной инструкции var b
(где-то в области видимости), которая официально декларирует переменную b
, то var a = b = 42
не объявляет переменную b
напрямую. В зависимости от strict
режима это приведёт либо к ошибке, либо к случайной глобальной переменной (см. книгу этой серии Область Видимости и Замыкания).
Другой пример для рассмотрения:
function vowels(str) {
var matches;
if (str) {
// выбрать все гласные
matches = str.match( /[aeiou]/g );
if (matches) {
return matches;
}
}
}
vowels( "Hello World" ); // ["e","o","o"]
Это работает, и многие разработчики предпочитают именно такой вариант. Но мы можем упростить функцию, объединив два оператора if
в один, если применим идиому, в которой побочный эффект присваивания будет работать на пользу:
function vowels(str) {
var matches;
// выбрать все гласные
if (str && (matches = str.match( /[aeiou]/g ))) {
return matches;
}
}
vowels( "Hello World" ); // ["e","o","o"]
Примечание: ( .. )
вокруг matches = str.match..
обязательны. Причина - приоритет операторов, который мы рассмотрим позже в разделе "Приоритет операторов" этой главы.
Я предпочитаю этот более короткий стиль, так как он, по-моему, проясняет, что два условных выражения на самом деле связаны, а не разделены. Но, как и в случае с большинством стилистических вариантов в JS, это лишь мнение, какой вариант лучше.
В правилах грамматики JavaScript есть довольно много мест, где один и тот же синтаксис означает разные вещи в зависимости от того, где / как он используется. Такого рода вещи сами по себе могут вызвать немалую путаницу.
Здесь мы не перечислим исчерпывающе все подобные случаи, а просто назовем несколько самых распространенных.
Есть два основных случая (их будет больше по мере развития JS!), когда в вашем коде появляется пара фигурных скобок { .. }
. Давайте взглянем на каждый из них.
Во-первых, как литерал object
:
// предположим, что определена функция `bar()`
var a = {
foo: bar()
};
Откуда мы знаем, что это литерал object
? Потому что пара { .. }
- это значение, которое присваивается a
.
Примечание: Эта ссылка a
называется "L-значение" (значение левой стороны), поскольку она является целью присваивания. Пара { .. }
является "R-значением" (значение правой стороны), поскольку она используется просто как значение (в данном случае как источник присваивания).
Что произойдет, если мы удалим var a =
из приведенного выше фрагмента?
// предположим, что определена функция `bar()`
{
foo: bar()
}
Многие разработчики предполагают, что пара { .. }
- это просто отдельный литерал object
, который ничему не присваивается. Но на самом деле все совсем по-другому.
Здесь { .. }
- это просто обычный блок кода. В JavaScript не очень идиоматично (тем более в других языках!) иметь такой автономный блок { .. }
, но это вполне допустимая грамматика JS. Это может быть особенно полезно в сочетании с объявлениями переменных через let
с областью видимости блока (см. книгу этой серии Область Видимости и Замыкания).
Блок кода { .. }
здесь в значительной степени функционально идентичен блоку кода, сопутствующему некой инструкции, такой как циклы for
/while
, условного ветвления if
и т.д.
Но, если это обычный блок кода, тогда, что это за странный синтаксис foo: bar()
и насколько это корректно?
Этот обескураживающе малоизвестный инструмент в JavaScript называется "инструкцией с меткой". Здесь foo
- это метка для инструкции bar()
(у которой опущена завершающая ;
- см. далее в этой главе "Автоматические точки с запятой"). Но в чем смысл инструкции с меткой?
Если бы в JavaScript был оператор goto
, вы теоретически могли бы написать goto foo
и заставить перейти сюда, продолжить выполнять код с этого места. Обычно goto
считаются ужасными идиомами программирования, поскольку они значительно затрудняют понимание кода (он же "спагетти-код"), поэтому очень хорошо, что в JavaScript нет привычного goto
.
Однако JS действительно поддерживает ограниченную, специальную форму goto
: переходы по метке. Обе инструкции continue
и break
могут опционально принимать указанную метку, и в этом случае поток программы "прыгает" подобно goto
. Рассмотрим:
// `foo` labeled-loop
foo: for (var i=0; i<4; i++) {
for (var j=0; j<4; j++) {
// всякий раз, когда встречается, продолжить внешний цикл
if (j == i) {
// перейти к следующей итерации
// цикла, помеченного `foo`
continue foo;
}
// пропускать нечетные числа
if ((j * i) % 2 == 1) {
// обычный (безметочный) `continue` внутреннего цикла
continue;
}
console.log( i, j );
}
}
// 1 0
// 2 0
// 2 1
// 3 0
// 3 2
Примечание: continue foo
не означает "перейти к позиции с меткой 'foo', чтобы продолжить", а "продолжить цикл с меткой "foo" со следующей итерации". Итак, это на самом деле не произвольное goto
.
Как вы видите, мы пропустили итерацию с нечетным произведением 3 1
, а переход с меткой также пропустил итерации 1 1
и 2 2
.
Возможно, немного более полезная форма перехода с меткой - это break __
из внутреннего цикла, чтобы выбраться из внешнего цикла. Эту логику довольно громоздко реализовывать с помощью break
без метки:
// `foo` labeled-loop
foo: for (var i=0; i<4; i++) {
for (var j=0; j<4; j++) {
if ((i * j) >= 3) {
console.log( "останавливаемся!", i, j );
// прервать цикл помеченный меткой `foo`
break foo;
}
console.log( i, j );
}
}
// 0 0
// 0 1
// 0 2
// 0 3
// 1 0
// 1 1
// 1 2
// останавливаемся! 1 3
Примечание: break foo
не означает "перейти к позиции с меткой 'foo', чтобы продолжить", а "выйти из цикла/блока с надписью 'foo' и продолжить после него". Не совсем goto
в традиционном понимании, да?
Безметочная альтернатива break
вышеописанному, вероятно, потребовала бы задействовать одну или несколько функций, доступ к переменной из общей области видимости и т.д. Скорее всего, это было бы более запутанным, чем break
с меткой, поэтому использование break
с меткой здесь, вероятно, является лучшим вариантом.
Метка может применяться к блоку без цикла, но только break
может сослаться на такую метку. Вы можете сделать break ___
из любого блока с меткой, но вы не можете использовать continue ___
или использовать break
без метки из блока.
function foo() {
// блок с меткой `bar`
bar: {
console.log( "Привет" );
break bar;
console.log( "никогда не выводится" );
}
console.log( "Мир" );
}
foo();
// Привет
// Мир
Циклы/блоки с метками крайне редки, и к ним часто относятся неодобрительно. Если это возможно, то лучше всего избегать их; например, используя вызовы функций вместо переходов из цикла. Но, возможно, есть редкие случаи, когда они могут быть полезны. Если вы собираетесь использовать переход по метке, обязательно задокументируйте то, что вы делаете, с подробными объяснениями!
Очень распространено мнение, что JSON является корректным подмножеством JS, поэтому строка JSON (например, {"a":42}
- обратите внимание на кавычки вокруг имени свойства, как того требует JSON!) считается допустимым кодом JavaScript. Неправда! Попробуйте ввести {"a":42}
в вашу консоль JS, и вы получите сообщение об ошибке.
Инструкция с меткой не может быть заключена в кавычки, поэтому "a"
не является допустимой меткой, и, следовательно, :
не может идти сразу после нее.
Итак, JSON действительно является подмножеством синтаксиса JS, но JSON сам по себе не является допустимой грамматикой JS.
Одно из чрезвычайно распространенных заблуждений из этой области заключается в том, что, если бы вы загрузили файл JS через тег <script src = ..>
, в котором содержится только содержимое JSON (например, результат вызова API), данные были бы прочитаны как действительный JavaScript, но просто были бы недоступны для программы. Обычно заявляют, что JSON-P (практика оборачивания JSON данных в вызов функции, например, foo({"a":42})
) решает проблему их недоступности, отправляя значение в одну из функций вашей программы.
Неправда! Полностью допустимое значение JSON {"a":42}
само по себе выдало бы ошибку JS, поскольку оно было бы интерпретировано как блок инструкций с недопустимой меткой. Но foo({"a":42})
является допустимым JS, потому что в нем {"a":42}
является литеральным значением object
, передаваемым в foo (..)
. Итак, правильно сказать, JSON-P превращает JSON в допустимую JS грамматику!
Другая часто цитируемая JS нелепица (связанная с приведением - см. Главу 4) - это:
[] + {}; // "[object Object]"
{} + []; // 0
Казалось бы, это говорит, что оператор +
дает разные результаты в зависимости от того, является ли первый операнд []
или {}
. Но на самом деле это не имеет к оператору никакого отношения!
В первой строке {}
появляется в выражении оператора +
и поэтому интерпретируется как фактическое значение (пустой object
). В главе 4 объясняется, что []
преобразуется в ""
и, следовательно, {}
также преобразуется в значение string
: "[object Object]"
.
Но во второй строке {}
интерпретируется как отдельный пустой блок {}
(который ничего не делает). Блокам не нужны точки с запятой, так что отсутствие точки с запятой здесь не является проблемой. Наконец, + []
- это выражение, которое явно приводит []
в number
(см. Главу 4), которое равно 0
.
Начиная с ES6, есть еще одна ситуация, когда вы увидите пару { .. }
, - это "деструктурирующее присваивание" (за дополнительной информацией обратитесь к книге этой серии ES6 и не только), в частности, к деструктурированию object
. Рассмотрим:
function getData() {
// ..
return {
a: 42,
b: "foo"
};
}
var { a, b } = getData();
console.log( a, b ); // 42 "foo"
Как вы, наверное, можете сказать, var { a , b } = ..
является формой деструктурирующего присваивания в ES6, что приблизительно эквивалентно:
var res = getData();
var a = res.a;
var b = res.b;
Примечание: в ES6 { a, b }
на самом деле является сокращенной формой деструктурирования { a: a, b: b }
, поэтому оба будут работать, но предполагается, что более краткая { a, b }
будет предпочтительным вариантом.
Деструктурирование объекта с помощью пары { .. }
также может использоваться для именованных аргументов функции, что является сахаром для такого же рода неявного присваивания свойств объекта:
function foo({ a, b, c }) {
// нет необходимости в:
// var a = obj.a, b = obj.b, c = obj.c
console.log( a, b, c );
}
foo( {
c: [1,2,3],
a: 42,
b: "foo"
} ); // 42 "foo" [1, 2, 3]
Итак, контекст, в котором мы используем пару { .. }
, полностью определяет, что она означает. Это иллюстрирует разницу между синтаксисом и грамматикой. Очень важно понимать эти нюансы, чтобы избежать неожиданных интерпретаций JS движком.
Это распространенное заблуждение, что в JavaScript есть условие else if
, потому что вы можете сделать так:
if (a) {
// ..
}
else if (b) {
// ..
}
else {
// ..
}
Но здесь есть скрытая особенность грамматики JS: здесь нет else if
. Но инструкциям if
и else
разрешается опускать { }
вокруг прикрепленного к ним блока, если они содержат только одну инструкцию. Несомненно, ранее вы видели это много раз:
if (a) doSomething( a );
Многие руководства по JS стилю будут настаивать на том, чтобы вы всегда использовали { }
вокруг блока с единственной инструкцией, например:
if (a) { doSomething( a ); }
Однако точно такое же грамматическое правило применяется к условию else
, поэтому форма else if
, которую вы, вероятно, всегда писали, на самом деле интерпретируется так:
if (a) {
// ..
}
else {
if (b) {
// ..
}
else {
// ..
}
}
if (b) { .. } else { .. }
- это единственная инструкция, которая следует за else
, поэтому вы можете либо вставить обрамляющие { }
, либо нет. Другими словами, когда вы используете else if
, вы технически нарушаете общее правило из руководства по стилю и просто определяете свое else
с помощью одного оператора if
.
Конечно, идиома else if
чрезвычайно распространена и приводит к уменьшению отступа на один уровень, поэтому она так привлекательна. Какой бы способ вы ни выбрали, просто пишите явно в своем собственном руководстве по стилю/правилах и не предполагайте, что такие вещи, как else if
, являются прямыми грамматическими правилами.
Как мы рассмотрели в Главе 4, &&
и ||
в Javascript интересны тем, что они выбирают и возвращают один из своих операндов, а не просто приводят к true
или false
. Это легко анализировать, если есть только два операнда и один оператор.
var a = 42;
var b = "foo";
a && b; // "foo"
a || b; // 42
А, если задействованы два оператора и три операнда, что тогда?
var a = 42;
var b = "foo";
var c = [1,2,3];
a && b || c; // ???
a || b && c; // ???
Чтобы понять, какой результат этих выражений, нам нужно понять, какие правила регламентируют обработку операторов, когда их в выражении более одного.
Эти правила называются "приоритетом операторов".
Бьюсь об заклад, большинство читателей считают, что они неплохо разбираются в приоритете операторов. Но, как и во всем остальном, что мы рассмотрели в этой серии книг, мы собираемся испытать это понимание на прочность, и, надеемся, узнать новое на этом пути.
Вспомним приведенный выше пример:
var a = 42, b;
b = ( a++, a );
a; // 43
b; // 43
А какой будет результат, если убрать ( )
?
var a = 42, b;
b = a++, a;
a; // 43
b; // 42
Погодите! Почему это изменило значение, присвоенное b
?
Потому что оператор ,
имеет более низкий приоритет, чем оператор =
. Поэтому, b = a++, a
интерпретируется как (b = a++), a
. Поскольку (как мы выяснили ранее) a++
имеет пост-побочные эффекты, b
присвоено значение 42
до того, как ++
изменит a
.
Это простейший пример необходимости понимать приоритет оператора. Если вы планируете использовать ,
в качестве инструкции серии операторов, то важно знать, что он имеет наименьший приоритет. Любой другой оператор будет использован раньше, чем ,
.
Теперь вспомним другой рассмотренный ранее пример:
if (str && (matches = str.match( /[aeiou]/g ))) {
// ..
}
Мы говорили, что ( )
вокруг присваивания обязательны, но почему? Потому что &&
имеет более высокий приоритет, чем =
. Поэтому без принудительной группировки ( )
выражение интерпретировалось бы как (str && matches) = str.match..
. И это инициировало бы ошибку, потому что результат (str && matches)
будет не переменной, а значением (в данном случае undefined
), и поэтому оно не может быть левой частью присваивания =
!
Хорошо, итак, вы, вероятно, думаете, что эта проблема с приоритетом оператора у вас решена.
Тогда, давайте перейдем к более сложному примеру (который мы рассмотрим в следующих разделах этой главы), чтобы действительно проверить ваше понимание:
var a = 42;
var b = "foo";
var c = false;
var d = a && b || c ? c || b ? a : c && b : a;
d; // ??
Ладно, я признаю, - это зло. Никто не стал бы писать подобную строку набора инструкций, верно? Вероятно, нет, но мы используем её для изучения различных проблем, связанных с объединением нескольких операторов в цепочку, что является очень распространенной задачей.
Результат выражения выше равен 42
. Но это не так интересно, как то, как мы нашли ответ, не запуская JS программу, дав ей его посчитать.
Давайте копнём.
Первый вопрос - который вам, возможно, даже не пришёл в голову - в том, ведет ли себя первая часть (a && b || c
) как (a && b) || c
или как a && (b || c)
? Вы уверены? Можете ли вы хотя бы себя убедить, что они действительно разные?
(false && true) || true; // true
false && (true || true); // false
Итак, доказано, что они разные. И все же, как ведет себя false && true || true
? Ответ:
false && true || true; // true
(false && true) || true; // true
Теперь у нас есть ответ. Оператор &&
вычисляется первым, а оператор ||
вычисляется вторым.
Но вызвано ли это обработкой слева направо? Давайте поменяем порядок операторов на обратный:
true || false && false; // true
(true || false) && false; // false -- нет
true || (false && false); // true -- победитель!
Теперь мы доказали, что сначала вычисляется &&
, а затем ||
, и в данном случае это шло явно в разрез с привычно ожидаемой обработкой слева направо.
Так что же вызвало такое поведение? Приоритет оператора.
Каждый язык определяет свой список приоритетов операторов. То, насколько уникален он, приводит в отчаяние, так как JS разработчикам приходится лезть в него и читать.
Если бы вы хорошо его знали, то приведенные выше примеры ничуть не сбили бы вас с толку, потому что вы бы уже знали, что &&
более приоритетен, чем ||
. Но, я уверен, изрядному числу читателей пришлось задуматься над этим.
Примечание: К сожалению, в спецификации JS нет списка приоритетов операторов в одном удобном списке. Вы должны прошерстить и понять все правила грамматики. Поэтому мы постараемся описать здесь наиболее типичные и полезные ситуации в более удобной подаче. Полный список приоритетов операторов см. в разделе "Приоритет операторов" на сайте MDN (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence).
В дополнительном примечании Главы 4 мы упоминали о "коротком цикле вычислений" (short circuiting) таких операторов, как &&
и ||
. Давайте вернемся к нему более подробно.
Для обоих операторов &&
и ||
правый операнд не будет вычислен, если для определения результата операции достаточно левого операнда. Отсюда и название "короткий цикл" (в том смысле, что возможно досрочное завершение).
Например, в a && b
значение b
не вычисляется, если a
ложное, потому что результат операнда &&
уже понятен, и нет смысла беспокоиться о проверке b
. Аналогично, с a || b
, если a
правдиво, результат операнда также понятен, и нет резона проверять b
.
Этот короткий цикл бывает очень полезным и широко используется:
function doSomething(opts) {
if (opts && opts.cool) {
// ..
}
}
opts
в проверке opts && opts.cool
действует как предохранитель, потому что, если opts
не установлен (или не object
), то opts.cool
выдаст ошибку. При отрицательном результате проверки opts
короткий цикл отменит проверку opts.cool
, и никакой ошибки не возникнет!
Аналогично, вы можете использовать короткий цикл ||
:
function doSomething(opts) {
if (opts.cache || primeCache()) {
// ..
}
}
Здесь мы сначала проверяем наличие opts.cache
, и если он есть, то не вызываем функцию prime Cache()
, избегая таким образом потенциально лишней работы.
Но давайте вновь обратим наше внимание на тот предыдущий пример инструкции с цепочкой операторов, в частности на тернарные операторы ? :
. Имеет ли оператор ? :
больший или меньший приоритет, чем &&
и ||
?
a && b || c ? c || b ? a : c && b : a
Идентичен ли он этому:
a && b || (c ? c || (b ? a : c) && b : a)
или этому?
(a && b || c) ? (c || b) ? a : (c && b) : a
Правильный ответ второй. Но почему?
Потому что &&
более приоритетный, чем ||
, а ||
более приоритетный, чем ? :
.
Поэтому первым вычисляется выражение (a && b || c)
, а затем ? :
, частью которого он является. Другой популярный вариант объяснения следующий: &&
и ||
"более тесно связаны", чем ? :
. Если бы было верно обратное, то c ? c...
было бы более тесно связано, и работал бы первый вариант - a && b || (c ? c..)
.
Итак, сначала группируются &&
и ||
, затем оператор ? :
. Но как быть в ситуации нескольких операторов с одинаковым приоритетом? Обрабатываются ли они всегда слева направо или справа налево?
Операторы обычно бывают либо левоассоциативными, либо правоассоциативными, в зависимости от этого группировка выполняется слева или справа.
Важно отметить, что ассоциативность - это не то же самое, что обработка слева направо или справа налево.
Почему же тогда так важно, выполняется ли обработка слева направо или справа налево? Потому что выражения могут иметь побочные эффекты, как, например, при вызове функций:
var a = foo() && bar();
Здесь сначала вычисляется foo()
, а затем в зависимости от результата foo()
, возможно, bar()
. Это безусловно могло бы сказаться на поведении программы, если бы bar()
вызывался перед foo()
.
Но здесь обработка идет просто слева направо (поведение по умолчанию в JavaScript!) - здесь не важна ассоциативность &&
. Поскольку в этом примере есть только одно &&
, то, следовательно, здесь нет места группировке, ассоциативность даже не вступает в игру.
Однако в выражении a && b && c
группировка будет происходить неявно, что означает, что сначала будет вычислено либо a && b
, либо b && c
.
Технически a && b && c
будет обрабатываться как (a && b) && c
, потому что &&
является левоассоциативным (как и ||
, кстати). Однако правоассоциативная альтернатива a && (b && c)
ведет себя, очевидно, точно также. Для одних и тех же значений одни и те же выражения вычисляются в одном и том же порядке.
Примечание: Гипотетически, если бы &&
был правоассоциативным, он был бы обработан аналогично ручной группировке a && (b && c)
. Но это все еще не означает, что c
будет вычислен перед b
. Правая ассоциативность не означает вычисления справа налево, это означает группировку справа налево. В любом случае, независимо от группировки / ассоциативности, строгий порядок вычисления будет a
, затем b
, затем c
(то есть слева направо).
Поэтому не так уж важно, что &&
и ||
являются левоассоциативными, кроме как для точности обсуждаемых определений.
Но это не всегда так. Некоторые операторы будут вести себя очень по-разному в зависимости от лево- и правоассоциативности.
Рассмотрим ? :
("тернарный" или "условный") оператор:
a ? b : c ? d : e;
? :
является правоассоциативным. Итак, какая группировка соответствует очерёдности выполнения?
a ? b : (c ? d : e)
(a ? b : c) ? d : e
Ответ таков: a ? b : (c ? d : e)
. В отличие от &&
и ||
выше, правая ассоциативность здесь действительно имеет значение, поскольку (a ? b : c) ? d : e
будет вести себя по-разному для некоторых (но не для всех!) комбинаций значений.
Один из таких примеров:
true ? false : true ? true : true; // false
true ? false : (true ? true : true); // false
(true ? false : true) ? true : true; // true
Еще более тонкие различия скрываются в других комбинациях значений, даже если конечный результат тот же. Взглянем:
true ? false : true ? true : false; // false
true ? false : (true ? true : false); // false
(true ? false : true) ? true : false; // false
В этом случае одинаковые результаты сеют сомнения, какой вариант группировки действует. Однако:
var a = true, b = false, c = true, d = true, e = false;
a ? b : (c ? d : e); // false, вычисляются только `a` и `b`
(a ? b : c) ? d : e; // false, вычисляются `a`, `b` и `e`
Итак, мы четко доказали, что ? :
является правоассоциативным, и что это действительно имеет значение в отношении того, как ведет себя оператор в цепочке с самим собой.
Другим примером правой ассоциативности (группировки) является оператор =
. Вспомните пример с цепочкой присваивания, рассмотренный выше в этой главе:
var a, b, c;
a = b = c = 42;
Ранее мы сказали, что обработка a = b = c = 42
ведётся, начиная с присваивания c = 42
, затем b = ..
и, наконец, a = ..
. Почему? Из-за правой ассоциативности, которая на самом деле трактует инструкцию следующим образом: a = (b = (c = 42))
.
Помните наш первый пример присваивания сложного выражения в этой главе?
var a = 42;
var b = "foo";
var c = false;
var d = a && b || c ? c || b ? a : c && b : a;
d; // 42
Вооружившись нашими знаниями о приоритете и ассоциативности, мы теперь способны разбить код на группы, описывающие обработку этой инструкции:
((a && b) || c) ? ((c || b) ? a : (c && b)) : a
Или с отступами, если это легче понять:
(
(a && b)
||
c
)
?
(
(c || b)
?
a
:
(c && b)
)
:
a
Давайте решим это сейчас:
(a && b)
равно"foo"
."foo" || c
равно"foo"
.- В первом
?
проверка"foo"
правдивая. (c || b)
равно"foo"
.- Во втором
?
проверка"foo"
правдивая. a
равно42
.
Вот и все, мы закончили! Ответ - 42
, как и раньше. На самом деле это было не так уж трудно, не правда ли?
Теперь вы гораздо лучше понимаете ассоциативность и приоритеты операторов, и чувствуете себя увереннее, понимая, как будет вести себя код с цепочкой операторов.
Но остается важный вопрос: должны ли мы все писать код, понимая и исключительно полагаясь на все правила ассоциативности / приоритета операторов? Следует ли нам использовать ручную группировку ( )
лишь тогда, когда необходимо применить другой порядок обработки?
Или, с другой стороны, принимая, что, хотя эти правила на самом деле поддаются изучению, но тут достаточно ловушек, чтобы не полагаться на автоматические приоритеты и ассоциативности? Если да, то должны ли мы тогда, всегда использовать ручную группировку ( )
и полностью отказаться от заложенного механизма поведения?
Эта дискуссия в высшей степени субъективна и аналогична дискуссии в Главе 4 о неявном приведении. Большинство разработчиков исповедуют единый подход в обоих случаях: либо они принимают оба варианта поведения и код, ожидающий их, либо они отбрасывают оба варианта поведения и придерживаются ручных / явных идиом.
Конечно, тут я не могу ответить читателю на вопрос более однозначно, чем в Главе 4. Но я показал вам плюсы и минусы и, надеюсь, помог достаточно глубоко понять тему, чтобы вы могли принять обоснованное, а не продиктованное ажиотажем решение.
На мой взгляд, есть важная золотая середина. Нам следует использовать и приоритет операторов/ассоциативность, и ручную группировку ( )
в нашем коде. Той же подход я предлагал в Главе 4, выступая за здоровое/безопасное использование неявного приведения, и против его повсеместного применения.
Например, if (a && b && c) ..
для меня норма, и я не стал бы писать if ((a && b) && c) ..
, чтобы явно задать ассоциативность, потому что я думаю, это излишне.
С другой стороны, если бы мне нужно было связать два оператора ? :
вместе, я бы, конечно, использовал ручную группировку ( )
, чтобы было абсолютно ясно, какую логику я заложил здесь.
Таким образом, мой совет здесь аналогичен совету из Главы 4: используйте приоритет операторов/ассоциативность там, где это приводит к более короткому и чистому коду, но используйте ручную группировку ( )
там, где это вносит ясность и уменьшает путаницу.
ASI (автоматическая вставка точки с запятой) - это когда JavaScript подразумевает ;
в определенных местах вашей JS-программы, даже если вы её туда не ставили.
Зачем ему это делать? Потому что, если вы опустите хотя бы одну обязательную ;
, ваша программа завершится сбоем. Не всегда всепрощающе. ASI позволяет JS быть терпимым к определенным местам, где обычно ;
не считается необходимой.
Важно отметить, что ASI работает только при наличии новой строки (она же разрыв строки). Точки с запятой не вставляются в середину строки.
По существу, если JS-парсер анализирует строку, в которой произошла бы ошибка синтаксического анализа (ожидаемое отсутствие ;
), и он может разумно вставить ее, он это делает. Что разумно для вставки? Только если между концом какого-либо оператора и новой строкой/разрывом строки нет ничего, кроме пробелов и/или комментариев.
Рассмотрим:
var a = 42, b
c;
Должен ли JS рассматривать c
в следующей строке как часть инструкции var
? Это, конечно, так и было бы, если бы где-нибудь была ,
(даже в другой строке) между b
и c
. Но поскольку её нет, JS вместо этого предполагает, что после b
есть подразумеваемая ;
(в новой строке). Таким образом, c;
остается как отдельный оператор выражения.
Аналогично:
var a = 42, b = "foo";
a
b // "foo"
Это все еще допустимая программа без ошибок, потому что выражения также признают действие ASI.
Есть определенные места, где ASI полезен, например:
var a = 42;
do {
// ..
} while (a) // <-- ; ожидается здесь!
a;
Грамматика требует ;
после цикла do..while
, но не после циклов while
или for
. Однако большинство разработчиков этого не помнит! Поэтому ASI услужливо вмешивается и вставляет её.
Как мы говорили ранее в этой главе, блоки операторов не требуют завершающей ;
, поэтому в ASI нет необходимости:
var a = 42;
while (a) {
// ..
} // <-- ; не ожидается здесь.
a;
Другой важный случай, когда ASI срабатывает, - это ключевые слова break
, continue
, return
и (ES6) yield
:
function foo(a) {
if (!a) return
a *= 2;
// ..
}
Оператор return
не включает выражение следующей строки a *= 2
, поскольку ASI предполагает завершающую ;
у оператора return
. Конечно, операторы return
можно легко разбивать на несколько строк, но только не тогда, когда после return
ничего нет, кроме новой строки/разрыва строки.
function foo(a) {
return (
a * 2 + 3 / 12
);
}
Аналогичные рассуждения применимы к break
, continue
и yield
.
Одна из самых горячих религиозных войн JS-сообщества (помимо табуляции и пробелов) ведётся о том, следует ли сильно/исключительно полагаться на ASI или нет.
Большинство точек с запятой необязательны, но не все. Две ;
требуется в заголовок цикла for ( .. ) ..
.
На стороне сторонников многие разработчики считают, что ASI - это полезный механизм, который позволяет им писать более лаконичный (и более "красивый") код, опуская все, кроме строго обязательных ;
(которых очень мало). Часто утверждается, что ASI делает многие ;
необязательными, поэтому правильно написанная программа без них ничем не отличается от правильно написанной программы с ними.
Из стана противников многие разработчики говорят, что существует слишком много мест, способных стать источниками случайных ошибок, особенно для молодых, менее опытных разработчиков, где непреднамеренная, вставленная волшебным образом ;
меняет смысл. Аналогично, некоторые разработчики утверждают, что, если они опускают точку с запятой, это явная ошибка, и они хотят, чтобы их инструменты (линтеры и т.д.) обнаружили ее до того, как движок JS исправит ошибку под капотом.
Позвольте мне просто поделиться своей точкой зрения. Строгое прочтение спецификации подразумевает, что ASI - это процедура "исправления ошибок". Что это за ошибка, спросите вы? В частности, ошибка синтаксического анализатора. Другими словами, в попытке уменьшить количество сбоев парсера ASI позволяет ему быть более терпимым.
Но терпимый к чему? На мой взгляд, единственная причина возникновения ошибки синтаксического анализа - это если для анализа дана неправильная программа с ошибками. Таким образом, хотя ASI строго исправляет ошибки синтаксического анализатора, единственный способ получить такие ошибки - это ошибки автора программы - пропуск точек с запятой там, где этого требуют правила грамматики.
Итак, честно говоря, когда я слышу, как кто-то утверждает, что они хотят опустить "необязательные точки с запятой", мой мозг переводит это утверждение в "Я хочу написать максимально неправильную для синтаксического анализатора программу, которая все же будет работать".
Я нахожу это нелепым, а аргументы в пользу сокращения нажатий клавиш и получения более "красивого кода" в лучшем случае слабыми.
Более того, я не согласен с тем, что это то же самое, что дебаты о пробелах и табуляциях - что это лишь вопрос оформления. Я считаю, это фундаментальный вопрос: написание кода, соответствующего требованиям грамматики, или код, который полагается на грамматические исключения, чтобы только-только пройти проверку.
Ещё один взгляд на проблему в том, что полагаться на ASI - это, по сути, рассматривать новые строки как значимые "пробелы". Другие языки, такие как Python, имеют действительно значимые пробелы. Но уместно ли думать о JavaScript как о языке, имеющем значимые новые строки в том виде, в каком он существует сегодня?
Мое мнение: используйте точки с запятой везде, где вы знаете, что они "обязательны", и по минимуму полагайтесь на ASI.
Однако не верьте мне на слово. Еще в 2012 году создатель JavaScript Брендан Эйх сказал (http://brendaneich.com/2012/04/the-infernal-semicolon /) следующее:
Мораль этой истории: ASI - это (формально говоря) процедура исправления синтаксических ошибок. Если вы начнете кодировать так, как если бы это было универсальное правило - значимых новый строк, то у вас будут проблемы. .. Жаль, что я не сделал новые строки более значимыми в JS в те десять дней мая 1995 года. .. Будьте осторожны, не используйте ASI так, как если бы у JS новые строки значимы.
Мало того, что JavaScript имеет различные подтипы ошибок (TypeError
, ReferenceError
, SyntaxError
и т.д.), но грамматика также определяет перечень ошибок, которые должны быть вызваны во время компиляции, в дополнение ко всем другим ошибкам, которые происходят во время выполнения кода.
В частности, уже давно существует ряд специфических условий, которые следует отлавливать и сообщать о них как о "ранних ошибках" (во время компиляции). Любая прямая синтаксическая ошибка является ранней ошибкой (например, a = ,
), но также грамматика определяет вещи, которые синтаксически допустимы, но, тем не менее, запрещены.
Поскольку выполнение вашего кода еще не началось, эти ошибки не могут быть перехвачены с помощью try..catch
; они просто ведут к сбою синтаксического анализа/компиляции вашей программы.
Совет: В спецификации нет требования о том, как именно браузеры (и инструменты разработчика) должны сообщать об ошибках. Поэтому вы видите расхождения в сообщениях разных браузеров о подтипе ошибки или её описании.
Один простой пример - синтаксис внутри литерала регулярного выражения. Здесь нет ничего плохого в синтаксисе JS, но недопустимое регулярное выражение выдаст раннюю ошибку:
var a = /+foo/; // Ошибка!
Целью присваивания должен быть идентификатор (или выражение деструктурирования ES6, которое создает один или несколько идентификаторов), поэтому значение 42
в этой позиции недопустимо и об этом сразу можно сообщить:
var a;
42 = a; // Ошибка!
strict
режим ES5 даёт еще больше ранних ошибок. Например, в strict
режиме имена параметров функции не могут дублироваться:
function foo(a,b,a) { } // всё хорошо
function bar(a,b,a) { "use strict"; } // Ошибка!
Другая ранняя ошибка strict
режима - это литерал объекта, имеющий несколько свойств с одинаковым именем:
(function(){
"use strict";
var a = {
b: 42,
b: 43
}; // Ошибка!
})();
Примечание: С семантической точки зрения, такие ошибки технически не являются синтаксическими, а скорее грамматическими ошибками - приведенные выше фрагменты являются синтаксически допустимыми. Но поскольку типа GrammarError
не существует, некоторые браузеры вместо этого используют SyntaxError
.
ES6 определяет новую (откровенно сбивающую с толку) концепцию, называемую TDZ ("Временная мертвая зона").
TDZ относится к местам в коде, где ещё нельзя использовать переменную, поскольку она ещё не была должным образом инициализирована.
Наиболее наглядный пример этого -- использование let
блоковой области действия в ES6:
{
a = 2; // ReferenceError!
let a;
}
Присвоение a = 2
обращается к переменной a
(которая ограничена областью действия блока { .. }
) до того, как она была объявлена let a
, поэтому a
находится в TDZ и появляется ошибка.
Интересно, что если для typeof
сделано исключение, он безопасно работает с необъявленными переменными (см. Главу 1), для TDZ такого исключения безопасности не сделано:
{
typeof a; // undefined
typeof b; // ReferenceError! (TDZ)
let b;
}
Другой пример ошибки TDZ можно увидеть в значениях по умолчанию ES6 для параметров функции (см. книгу этой серии ES6 и не только):
var b = 3;
function foo( a = 42, b = a + b + 5 ) {
// ..
}
Ссылка на b
в присваивании параметра b
будет в TDZ (ссылка на внешнюю b
не будет действовать), поэтому она выдаст ошибку. Однако с a
в присваивании всё в порядке, так как к этому моменту для параметра a
TDZ уже закончилась.
При использовании значений по умолчанию параметров (ES6) это значение применяется, если вы либо опускаете аргумент, либо передаете undefined
:
function foo( a = 42, b = a + 1 ) {
console.log( a, b );
}
foo(); // 42 43
foo( undefined ); // 42 43
foo( 5 ); // 5 6
foo( void 0, 7 ); // 42 7
foo( null ); // null 1
Примечание: null
в выражении a + 1
приводится к значению 0
. За дополнительной информацией обращайтесь к Главе 4.
С точки зрения значений по умолчанию параметров (ES6), нет никакой разницы между пропуском аргумента и передачей значения undefined
. Однако в отдельных случаях эту разницу можно обнаружить:
function foo( a = 42, b = a + 1 ) {
console.log(
arguments.length, a, b,
arguments[0], arguments[1]
);
}
foo(); // 0 42 43 undefined undefined
foo( 10 ); // 1 10 11 10 undefined
foo( 10, undefined ); // 2 10 11 10 undefined
foo( 10, null ); // 2 10 null 10 null
Несмотря на то, что параметры a
и b
используют значения по умолчанию, если для них не было передано никаких аргументов, массив arguments
не будет содержать записей.
И наоборот, если вы передадите аргумент undefined
явно, в массиве arguments
будет запись для этого аргумента, она будет undefined
и не обязательно будет совпадать со значением по умолчанию, которое было применено к данному именованному параметру.
Также как значения по умолчанию параметров могут расходиться с ячейками массива arguments
и соответствующей именованной переменной в ES6, такое же расхождение может возникать сложными способами и в ES5:
function foo(a) {
a = 42;
console.log( arguments[0] );
}
foo( 2 ); // 42 (связано)
foo(); // undefined (не связано)
Если вы передаете аргумент, слот arguments
и именованный параметр связаны, чтобы всегда иметь одно и то же значение. Если вы опустите аргумент, такая связь не возникнет.
Однако в strict
режиме такой связи нет в любом случае:
function foo(a) {
"use strict";
a = 42;
console.log( arguments[0] );
}
foo( 2 ); // 2 (не связано)
foo(); // undefined (не связано)
Определённо это плохая идея - когда-либо полагаться на любую такую привязку. На самом деле связка это хлипкая абстракция, которая показывает внутреннюю реализацию движка, а не правильно спроектированную возможность.
Использование массива arguments
объявлено устаревшим (в пользу остаточных параметров ES6 ...
- см. книгу этой серии ES6 и не только), но это не значит, что всё это неправильно.
До ES6 arguments
был единственным способом получить массив всех переданных аргументов для передачи другим функциям, что было весьма полезным. Вы также можете смешивать именованные параметры и массив arguments
и оставаться в безопасности, если будете следовать одному простому правилу: никогда не ссылайтесь на именованный параметр и соответствующий ему слот arguments
одновременно. Если вы избежите этой плохой практики, вы никогда не столкнётесь с потерей связи.
function foo(a) {
console.log( a + arguments[1] ); // безопасно!
}
foo( 10, 32 ); // 42
Вы, вероятно, знакомы с тем, как работает блок try..catch
. Но задумывались ли вы когда-нибудь над finally
, которое может быть вместе с ним? Действительно, знаете ли вы, что try
требуется только catch
или finally
, хотя при необходимости могут присутствовать и то, и другое.
Код в finally
выполняется всегда (что бы ни случилось), и он всегда выполняется сразу после try
(и catch
, если присутствует), перед запуском любого другого кода. В каком-то смысле вы можете думать о коде в finally
как о функции обратного вызова, которая всегда будет вызываться независимо от того, как ведет себя остальная часть блока.
Итак, что произойдет, если внутри предложения try
есть оператор return
? Очевидно, что это вернет значение, верно? Но выполняется ли код, который получает это значение, до или после finally
?
function foo() {
try {
return 42;
}
finally {
console.log( "Hello" );
}
console.log( "никогда не выполнится" );
}
console.log( foo() );
// Hello
// 42
return 42
выполняется сразу, устанавливая значение завершения вызова foo()
. Это действие завершает try
, поэтому следом немедленно выполняется finally
. И только тогда завершается foo()
, возвращая значение завершения, которое используется в инструкции console.log(..)
.
Точно такое же поведение верно для throw
внутри try
:
function foo() {
try {
throw 42;
}
finally {
console.log( "Hello" );
}
console.log( "никогда не выполнится" );
}
console.log( foo() );
// Hello
// Uncaught Exception: 42
Теперь, если инициируется исключение (случайно или намеренно) внутри finally
, оно переопределит завершение функции. Если вышестоящий return
в блоке try
установил значение завершения функции, то это значение будет отброшено.
function foo() {
try {
return 42;
}
finally {
throw "Oops!";
}
console.log( "никогда не выполнится" );
}
console.log( foo() );
// Uncaught Exception: Oops!
Неудивительно, что другие операторы нелинейного управления, такие как continue
и break
, действуют аналогично return
и throw
:
for (var i=0; i<10; i++) {
try {
continue;
}
finally {
console.log( i );
}
}
// 0 1 2 3 4 5 6 7 8 9
Инструкция console.log(i)
выполняется в конце итерации, который инициируется инструкцией continue
. Однако она по-прежнему длится до оператора обновления итерации i++
, поэтому печатаются значения 0..9
вместо 1..10
.
Примечание: ES6 добавляет оператор yield
в генераторах (см. книгу этой серии Асинхронность и Производительность), который в некотором смысле можно рассматривать как промежуточный оператор return
. Однако, в отличие от return
, yield
не завершен до тех пор, пока генератор не будет возобновлен, что означает, что try { .. yield .. }
не завершен. Таким образом, прикрепленное finally
не будет выполняться сразу после yield
, как это происходит с return
.
return
внутри finally
обладает специальной способностью переопределять предыдущий return
из предложения try
или catch
, но только если return
вызывается явно:
function foo() {
try {
return 42;
}
finally {
// здесь нет `return ..`, поэтому нет переопределения
}
}
function bar() {
try {
return 42;
}
finally {
// переопределяет вышестоящий `return 42`
return;
}
}
function baz() {
try {
return 42;
}
finally {
// переопределяет вышестоящий `return 42`
return "Hello";
}
}
foo(); // 42
bar(); // undefined
baz(); // "Hello"
Обычно пропуск return
в функции идентично return;
или даже return undefined;
, но пропуск return
внутри блока finally
не действует как переопределяющий return undefined
; он просто оставляет в силе вышестоящий return
.
На самом деле, мы действительно можем усилить безумие, если объединим finally
с break
(обсуждалось ранее в этой главе):
function foo() {
bar: {
try {
return 42;
}
finally {
// выйти из блока с меткой `bar`
break bar;
}
}
console.log( "Crazy" );
return "Hello";
}
console.log( foo() );
// Crazy
// Hello
Но... не делайте так. Серьезно. Используя finally
+ break
с меткой, чтобы отменить return
, вы делаете всё, чтобы создать максимально запутанный код. Я спорю, что никакое количество комментариев не сделает этот код понятнее.
Давайте кратко взглянем на оператор switch
, своего рода синтаксическое сокращение цепочки операторов if..else if..else..
.
switch (a) {
case 2:
// выполнить что-нибудь
break;
case 42:
// выполнить что-то другое
break;
default:
// здесь запасной вариант
}
Как вы видите, a
вычисляется один раз, а затем результирующее значение сопоставляется с каждым case
(здесь выражения только простых значений). Если совпадение найдено, выполняется код в этом case
и будет продолжаться либо пока не будет встречен break
, либо пока не встретится конец блока switch
.
Возможно, вас это не сильно удивит, но в switch
есть несколько особенностей, которые вы вероятно раньше не замечали.
Во-первых, сравнение, которое происходит между a
и каждым case
, идентично алгоритму ===
(см. Главу 4). Часто switch
используются с абсолютными значениями в операторах case
, как показано выше, поэтому строгое равенство уместно.
Однако вы можете захотеть разрешить равенство с приведением (оно же ==
, см. Главу 4), но для этого вам нужно немного "взломать" оператор switch
:
var a = "42";
switch (true) {
case a == 10:
console.log( "10 или '10'" );
break;
case a == 42:
console.log( "42 или '42'" );
break;
default:
// никогда сюда не попадёте
}
// 42 или '42'
Это работает, потому что case
может содержать любое выражение (не только простые значения), что означает, что оно будет строго сравнивать результат выражения и тестовое значение (true
). Поскольку a == 42
здесь превращается в true
, совпадение найдено.
Несмотря на ==
, сам поиск в switch
ведётся на строгое равенство, между true
и true
. Если выражение case
приводит к чему-то правдивому, а не к строго true
(см. Главу 4), то это не сработает. Это может ударить по вам, если, например, вы используете "логический оператор", такой как ||
или &&
в вашем выражении:
var a = "hello world";
var b = 10;
switch (true) {
case (a || b == 10):
// никогда сюда не попадём
break;
default:
console.log( "Oops" );
}
// Oops
Поскольку результатом (a || b == 10)
является "hello world"
, а не true
, строгое сравнение завершается неудачей. В данном случае решением проблемы было бы в том, чтобы заставить выражение стать явно true
или false
, например, case !!(a || b == 10):
(см. Главу 4).
Наконец, вариант default
опционален, поэтому он и не обязан присутствовать (хотя это рекомендуемая практика). Даже к default
применяются те же правила, что и при обнаружении break
или его отсутствии:
var a = 10;
switch (a) {
case 1:
case 2:
// никогда не окажемся здесь
default:
console.log( "default" );
case 3:
console.log( "3" );
break;
case 4:
console.log( "4" );
}
// default
// 3
Примечание: Как обсуждалось ранее о break
с меткой, break
внутри case
также может быть с меткой.
Этот фрагмент работает так: сначала обходятся все case
в поиске совпадения, и когда их не находит, возвращается к варианту default
и выполняет его. Но, поскольку в нём нет break
, он продолжает работу в ранее пропущенном case 3
до того, как встретит break
.
Хотя такого рода логика действительно возможна в JavaScript, нет почти никаких шансов, что это даст оправданный или понятный код. Будьте самокритичны, если поймаете себя на мысли воспользоваться такой завёрнутой логикой работы. И если вы всё же решитесь на это, не забудьте сопроводить код подробными комментариями, чтобы объяснить, что вы задумали!
Грамматика JavaScript имеет множество нюансов, на которые нам, разработчикам, следует потратить немного больше времени, уделяя им более пристальное внимание, чем мы обычно делаем. Лёгкое усилие имеет большое значение для укрепления наших знаний языка.
Инструкции и выражения имеют аналоги в английском языке - инструкции похожи на предложения, а выражения - на фразы. Выражения могут быть ясными/самодостаточными, или они могут иметь побочные эффекты.
Грамматика JavaScript накладывает правила семантического использования (они же контекст) поверх чистого синтаксиса. Например, пара { }
, используемая в различных местах вашей программы, может означать блоки инструкций, литералы object
, (ES6) деструктурирующие присваивание или (ES6) именованные аргументы функции.
Все операторы JavaScript имеют четкие правила приоритета (какие из них выполняются первыми, а какие следом) и ассоциативности (как операторы неявно группируются, если их несколько). Изучив эти правила, вы сможете сами решить, слишком ли неявен приоритет/ассоциативность, чтобы пользоваться ими для написания более короткого и понятного кода.
ASI (Автоматическая вставка точки с запятой) - это механизм исправления ошибок синтаксического анализа, встроенный в движок JS, который позволяет ему в определенных обстоятельствах вставлять ожидаемую ;
в местах, где она требуется, но была опущена, и, где вставка исправляет ошибку синтаксического анализа. Ведутся споры о том, подразумевает ли этот механизм, что большинство ;
являются необязательными (и могут/должны быть опущены ради более чистого кода), или это означает, что их пропуск ведёт к ошибкам, которые движок JS просто исправляет за вас.
В JavaScript есть несколько типов ошибок, но менее известно, что в нём есть две категории: "ранние" ошибки (неперехватываемые ошибки компилятора) и ошибки "выполнения" (перехватываемые try..catch
). Все синтаксические ошибки, очевидно, являются ранними, которые останавливают программу ещё до ее запуска, но есть и другие.
Аргументы функции имеют интересную взаимосвязь с их формально объявленными именованными параметрами. В частности, массив arguments
имеет ряд ловушек, связанных утечкой внутренней реализации языка, если вы не будете осторожны. Избегайте arguments
, если можете, но если вы должны его использовать, всеми способами избегайте одновременного использования ячейки arguments
и его именованного параметра.
Блок finally
, прикрепленный к try
(или try ..catch
), предлагает несколько очень интересных особенностей с точки зрения очерёдности выполнения. Некоторые из них могут быть полезны, но могут принести и много путаницы особенно в сочетании с блоками с метками. Общее правило: используйте finally
, чтобы сделать код лучше и понятнее, а не заумным или запутанным.
switch
предлагает приятную компактную замену для инструкций if..else if..
, но остерегайтесь распространенных упрощенных предположений о его поведении. Тут есть причуды, которые могут сбить вас с толку, если вы не будете осторожны, но есть также несколько искусно скрытых трюков, которые у switch
припасены в рукаве!