Думать как JavaScript

Перевод статьи Кайла Симпсона “Thinking JavaScript

На одном из занятий во время перерыва студент задал мне головоломку, которая заставила меня по-настоящему задуматься. Он, конечно, сказал, что наткнулся на неё случайно, но, по-моему, это один из тех самых WTF трюков!

Так или иначе, пытаясь разобраться, я дважды потерпел неудачу. Мне даже пришлось пропустить код через парсер и обратиться к спецификации (и одному JS-гуру!), чтобы понять, что там происходит. И, так как в процессе я кое-что понял, хочу поделиться результатом с вами.

Не думаю, что вам когда-нибудь придется писать (а читать, я надеюсь, тем более не придется!) код, подобный этому, но умение думать как JavaScript будет полезным.

Задача

Вопрос звучал следующим образом: почему первая строчка кода “работает” (обрабатывается, исполняется), тогда как вторая — нет?

[[]][0]++;

[]++;

Хитрость в том, что может показаться, будто [[]][0] — то же самое, что и [], а значит оба примера должны приводить к ошибке.

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

Я был не прав, хотя пытался думать как JavaScript. К сожалению, в моей голове царила неразбериха. Не важно как много мы знаем. Наткнуться на то, что за пределами наших знаний — всегда просто.

Именно поэтому я повторяю: “Вы не знаете JS” и призываю людей принять это. Не существует человека, знающего столь комплексные вещи, какими являются языки программирования. Мы изучаем их какие-то части, затем еще что-то, но продолжаем учиться. Это не цель, которую можно достичь. Это — перманентный процесс.

Мои ошибки

В первую очередь я обратил внимание на использование оператора ++. Инстинкт подсказал, что оба примера приведут к ошибке, так как унарный постфикс, как в случае с x++, в общем-то эквивалентен x = x + 1, и этот условный x должен быть валидным, чтобы находиться в левой части присваивания через =.

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

Я некорректно допустил, что x++ — это что-то вроде x = x + 1: в этом случае исходный код []++ превращается [] = [] + 1, что приводит к ошибке. На самом деле, это по большому счету допустимо, хоть и выглядит странно. В ES6, выражение [] = .. соответствует синтаксису деструктуризации массива.

Думать о x++, как о x = x + 1 — значит идти на поводу у ложной предпосылки. Это пример “ленивого” мышления. Не стоит удивляться, что оно сбило меня с пути.

Кроме того, все мои рассуждения о первой строке кода также были неверными. Я думал следующим образом: [[]] создаёт массив (внешний литерал []), а внутренний — попытка обращения к свойству. Приведенный к строке, он дает результат что-то вроде [""]. Какой вздор. Откуда в моей голове такая каша?

Конечно же, чтобы это была попытка обращения к свойству массива, запись должна быть x[[]], где x — объект к которому мы обращаемся, а не простое [[]]. В любом случае, я был не прав. Дуралей.

Правильное рассуждение

Давайте начнем с самого простого. Почему []++ — невалидный код?

За ответом обратимся к настоящему авторитету в таких вещах — спецификации!

В терминах спеки, ++ в выражении x++ относится к типу Update Expressions и называется постфиксным оператором инкремента (Postfix Increment Operator). Чтобы это работало, часть “x” должна быть валидным Left-Hand Side Expression, грубо говоря — валидным выражением слева от =. На самом деле, более точным будет рассматривать его не просто как выражение слева от =, а как валидную цель операции присваивания.

Смотрим на список выражений, для которых работает присваивание. Среди них — Primary Expression и Member Expression.

Заглянем в раздел Primary Expression: мы видим, что литерал массива, а значит и наш [], — допустимы, по крайней мере с точки зрения синтаксиса.

Стоп! Так если [] — валидное выражение слева от оператора ++, почему []++ приводит к ошибке?

Потому что вы упускаете то же, что упустил я. Это вовсе не SyntaxError! Это ошибка во время исполнения — ReferenceError.

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

2 = 3;

Очевидно, мы не можем присвоить что-либо литералу числа. В этом не смысла.

Вместе с тем, синтаксически выражение приемлемо. Ошибка в самой логике исполнения.

Итак, какая часть спецификации объяснит, почему 2 = 3 — не работает? Ведь причина, по которой []++ приводит к ошибке, будет точно такой же.

Оба примера используют абстрактный алгоритм, названный в спецификации PutValue. Шаг №3 алгоритма гласит:

Если тип(V) — не ссылка, сгенерировать исключение ReferenceError.

Спецификация определяет Reference как особый тип спецификации (special specification type), который может содержать любой тип выражения, представляющего, в свою очередь, область памяти, в которую может быть записано значение. Другими словами, чтобы быть целью операции присваивания, нужно относиться к типу Reference.

Очевидно, что ни 2 ни [] к такому типу не относятся, а значит выполнение кода приводит к ошибке; они не являются валидными целями присваивания.

Но как же…?

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

[[]] вовсе не является доступом к массиву. Это просто массив, содержащий другой массив в качестве единственного элемента. Думайте о нем так:

var a = [];
var b = [a];
b;  // [[]]

Видите?

Итак, теперь перед нами [[]][0], что с ним делать? Давайте повторим этот трюк с переменными.

var a = [];
var b = [a];
var c = b[0];
c; // [] -- aka, `a`!

То есть, моя изначальная догадка оказалась верной. [[]][0] — это вроде бы тоже самое что и [].

Тогда, возвращаясь к нашему вопросу, почему первая строчка кода работает, а вторая нет?

Как мы выяснили, Update Expression подразумевает наличие LeftHandSideExpression, а для последнего валидным типом будет Member Expression. [0] внутри x[0] — как раз пример Member Expression!

Напоминает наш случай, верно? [[]][0] — это Member Expression.

Что же, в синтаксисе разобрались… [[]][0]++ — валидный код.

Но как же так, подождите!

Если [] — не Reference, как выражение [[]][0], результатом которого является [] может рассматриваться в качестве Reference, так что PutValue(..) не выбрасывает ошибку?

Это тонкий момент. Спасибо моему другу Аллену Вирфс-Броку, бывшему редактору спецификации JS, за то, что помог собрать все кусочки воедино.

Результатом выражения типа Member Expression должно быть не значение как таковое ([]), а Reference на это значение — смотрите шаг №8. По факту, [0] в нашем примере дает ссылку на элемент массива в нулевой позиции, а не на значение этого элемента.

Именно поэтому выражение [[]][0] в конце концов оказывается Reference!

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

var a = [[]];
a[0]++;
a;  // [1]

Выражение a[0] возвращает массив [], а ++ как математическая операция приводит его к примитивному типу — сначала к строке "", затем к числу 0. После этого увеличивает число на единицу и присваивает её a[0]. Так же, как это происходит в примере a[0] = a[0] + 1.

Только заметьте: запустив выражение [[]][0]++ в консоли браузера, вы получите 0, а не 1. Почему?

Дело в том, что ++ возвращает “изначальное” значение (original value). Ну, на самом деле после приведения, но тем не менее. Смотрите шаги №2 и №5. Таким образом, возвращается 0, а 1 помещается в массив через Reference.

Конечно, если мы не храним наш внешний массив в переменной (а мы этого не делаем), такое обновление неочевидно, так как значение исчезает. Но, без сомнения, оно обновилось. Таков Reference. Круто!

Еще замечания

Я не знаю, хорошо вы относитесь к JS, или подобные нюансы выводят вас из себя. Лично меня они заставляют уважать язык ещё сильнее и мотивируют к дальнейшему погружению в его особенности. Думаю, в каждом языке есть свои уловки и хитрости. Некоторые из них нас забавляют, некоторые — сводят с ума.

Так или иначе, независимо от того, какой инструмент вы используете, способность “думать” как этот инструмент делает ваши навыки лучше. Успехов в познании JavaScript!