Перевод статьи Кайла Симпсона “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!