Pull to refresh

Всё, что надо знать о точке с запятой

Reading time 11 min
Views 48K
Original author: inimino
Автовставка точек с запятой (";") — одна из наиболее спорных особенностей яваскрипта, вокруг которой скопилось много непонимания.

Некоторые программисты ставят ";" в конце каждого оператора, некоторые — только там, где строго необходимо. Большинство же где-то посередине, хотя есть и такие, которые добавляют лишние ";" из стилистических соображений.

Даже если вы всегда ставите ";" в конце каждого оператора, некоторые конструкции парсятся неочевидным образом. Вне зависимости от ваших предпочтений касательно ";", правила такого парсинга надо знать, чтобы использовать язык профессионально. Запомнив несколько простых правил, приведённых ниже, вы поймёте, как будет парситься любая программа, и станете экспертом в автовставке ";" в яваскрипте.



Где допустимы ТЗ



В формальной грамматике, данной в спецификации ECMAscript, ";" имеются в конце каждого оператора, где они могут быть. Вот оператор do-while:

do Statement while ( Expression );

ТЗ также возникают в грамматике на конце операторов var, операторов-выражений (наподобие «4+4;» или «f();»), операторов continue, return, break, throw и операторов отладчика.

Пустой оператор это одна ";", и является корректным оператором в яваскрипте. По этой причине ";;;" является корректной программой, оно парсится как три пустых оператора, и выполняет ничего три раза.

Иногда пустые операторы полезные, хотя бы синтаксически. Например, для бесконечного цикла можно написать «while(1);», точка с запятой парсится как пустой оператор, делая оператор while синтаксически валидным. Без ТЗ оператор while был бы неполон, поскольку после условия цикла необходим оператор.

Наконец, ";" используются в циклах в форме «for ( Выражение; Выражение; Выражение ) Оператор», и разумеется, могут использоваться в строчных и регексповых литералах.

Где точку с запятой можно пропустить



В формальной грамматике из спецификаций ECMAscript ";" упомянуты, как описано выше. Однако спецификация также даёт правила, описывающие, как реальный парсинг отличается от формальной грамматики. Правила описаны через воображаемые ";", вставляемые во входной поток, но это всего лишь спецификационная модель, на практике парсерам не нужно генерировать псевдо-";", а можно воспринимать ";" как опциональные в определённых местах грамматике (см., например, грамматику парсинга в ECMAscript, в особенности правила Statement, EOS, EOSnoLB, и SnoLB). Везде, где спецификация говорит «вставляется »;"", имеется в виду, что текущий оператор заканчивается.

Правила автовставки ТЗ описаны в разделе 7.9 ECMA-262 [pdf].

Раздел даёт три правила и два исключения из них.

Правила таковы:

Когда программа встречает токен, недопустимый грамматикой, вставляется ";", если (а) в этом месте присутствует перенос строки, или (б) недопустимый токен является закрывающей фигурной скобкой. При достижении конце файла и невозможности иной интерпретации, вставляется ";". При появлении «ограниченного порождения» [restricted production], содержащего терминатор строки в месте, где грамматика говорит "[no LineTerminator here]", вставляется ";".

Иными словами, эти правила говорят, что оператор можно оканчивать без ";" (а) перед закрывающей фигурной скобкой, (б) в конце программы, (в) если следующий токен не может быть отпарсен иначе, и, кроме того, есть некоторые места в грамматике, где перенос строки завершает оператор безусловно. Во что это выливается на практике, обсуждается ниже.

Исключения: ";" никогда не вставляется в заголовок цикла вида «for ( Выражение; Выражение; Выражение ) Оператор», и ";" никогда не вставляется, если в результате получается пустой оператор.

Что это всё для нас означает?

Прежде всего, ";" опциональна только на конце строки, перед закрывающей фигурной скобкой, и в конце программы. Кром того, ";" не предполагается на конце строки, если первый токен следующей строки может быть отпарсен как часть предыдущего оператора.

«42; "hello!"» пример валидной программы, равно как и «42\n"hello!"» (где "\n" изображает перенос строки) но «42 "hello!"» уже нет, так как перенос строки вызывает автовставку ";", а пробел нет. «if(x){y()}» также валидно. Здесь «y()» есть оператор-выражение, который может оканчиватся ";", но поскольку следующий токен это закрывающая фигурная скобка, ";" опциональна, несмотря на отсутствие переноса строки.

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

for (node=getNode();
     node.parent;
     node=node.parent) ;


Цикл последовательно вызывает следующий родительский узел, пока не не найдётся узел без родителя. Всё это происходит в заголовке цикла, так что для тела цикла ничего не осталось. Однако синтаксис цикла требует оператор, и мы вставляем пустой оператор. Несмотря на то, что все три ";" в этом примере на концах строк, все три необходимы, так как ";" не вставляется в заголовках цикла или для создания пустого оператора.

Ограниченное порождение



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

В грамматике пять ограниченных порождений, это постфиксные операторы ++ и --, операторы continue, break и return. Операторы break и continue могут иметь опциональный идентификатор для передачи управления из конкретного цикла. При использовании этой возможности идентификатор обязан быть на той же строке. Это валидная программа:

var c,i,l,quitchars
quitchars=['q','Q']
charloop:while(c=getc()){
    for (i=0; i<quitchars.length; i++){
        if (c==quitchars[i]) break charloop
    }
    /* ... код для других символов ... */
}


Если getc() возвращает символ из входного потока, программа читает его, проверяет, не символ ли выхода это, и если да, передаёт управление за цикл. Помеченный оператор break нужен, чтобы вырваться за внешний цикл, а не только за внутренний. Такая же программа, различающаяюся лишь переносом строки, не даст тот же результат:

var c,i,l,quitchars
quitchars=['q','Q']
charloop:while(c=getc()){
    for (i=0; i<quitchars.length; i++){
        if (c==quitchars[i]) break
            charloop
    }
    /* ... код для других символов ... */
}


В этом случе токен charloop — не часть оператора break. Так как оператор break ограничен, перенос строки в этой позиции завершает оператор. Токен charloop парсится просто как переменная charloop, но управление это место так и не получит, а оператор break будет выходить из внутреннего цикла, а не из внешнего, как задумано.

Примеры остальных четырёх ограниченных порождений:

// PostfixExpression :                                            
//              LeftHandSideExpression [no LineTerminator here] ++
//              LeftHandSideExpression [no LineTerminator here] --
var i=1;
i
++;


Это выдаст ошибку, и не отпарсит как «i++». Терминатор не может отделять постфиксный оператор инкремента или декремента, так что "++" или "--" в начале строки никогда не отпарсится как часть предыдущей строки.

i
++
j


А это не ошибка, отпарсится как «i; ++j». Префисные инкремент и декремент не являются ограниченным порождением, поэтому перенос строки может встретиться между "++" или "--" и модифицируемым ими выражением.

// ReturnStatement: return [no LineTerminator here] Expressionopt ;
return
  {i:i, j:j}


Это отпарсится как пустой оператор return, вслед за которым идёт оператор-выражение, до которого управление никогда не дойдёт. А вот это отпарсится так, как задумано:

return {
  i:i, j:j}
return (
  {i:i, j:j})
return {i:i
       ,j:j}


Отметьте, что оператор return МОЖЕТ содержать переносы внутри выражения, только лишь не между токеном return и началом выражения. При намеренном пропуске ";", ограниченное порождение оператора return удобно, так как позволяет написать пустой return без того, чтобы случайно вернуть выражение со следующей строки:

function initialize(a){
  // если a уже инициализированно, вернуться
  if(a.initialized) return
  a.initialized = true
  /* ... инициализировать a ... */
}


Операторы continue и throw похожи на break и return:

continue innerloop // верно
 
continue
    innerloop;     // неверно
// ThrowStatement : throw [no LineTerminator here] Expression ;
throw                                          // ошибка разбора
  new MyComplexError(a, b, c, more, args);
// В отличии от return, break, continue, 
// выражение после throw обязательно, 
// поэтому вышеприведённое неотпарсится вообще.
throw new MyComplexError(a, b, c, more, args); // верно
throw new MyComplexError(
    a, b, c, more, args);                      // тоже верно
// любой вариант с throw и new на одной строке верен.


Отступы не играют роли для парсинга программ на ECMAscript, а наличие или отсутстсвие переносов строки играет. Тем самым, любой процессор исходного текста на яваскрипте может вырезать начальные пробелы (кроме строковых констант!) без влияния на семантику программы, но переносы строк нельзя произвольно вырезать или заменять пробелами или точками с запятой. Минификатор, меняющей семантику валидных программ есть плохой, негодный минификатор, и единственный способ это написать полный и корректный парсер.

Переносы строк после return, break, continue и перед ++ и — влияют на парсинг. Поскольку ограничены только эти порождения, то пробелы и переносы строк могут быть свободно использованы в любом другом месте для улучшения читабельности программы. В частности, логические, арифметические, операторы строчной конкатенации, тройной (либо условный) оператор, доступ к члену через точку или скобки, вызовы функций, циклы while, for, операторы switсh, и остальные контрольные структуры могут быть записаны с разрывом строк где угодно.

Спецификация гласит:

Практический совет для программистов ECMAScript: постфиксные операторы "++" и "--" должны быть на одной строке со своим операндом. Выражение в операторах return или throw должно начинаться на одной строке с токеном return или throw. Идентификатор в операторе break или continue должен быть на одной строке с токеном break или continue.


Наиболее частая ошибка программиста при ограниченных порождениях — ставить возвращаемое значение на строку после токена return, особенно если возвращается большой объект или литерал массива, или многострочная константа. Ошибки с постфиксными операторами, и операторами break, continue, throw на практике редки, в силу того, что такое разбиение строки выглядит неестественным для большинства программистов.

Последняя тонкость автовставки ";" вытекает из первого правила, требующего, чтобы программа содержала недопустимый токен для вставки ";". Если вы пропускаете опциональные ";", помните, что есть и неопциональные, которые пропускать нельзя. Это правило позволяет растягивать операторы на несколько строк:

return obj.method('abc')
          .method('xyz')
          .method('pqr')
 
return "длинная строка\n"
     + "растянулась\n"
     + "на несколько"
 
totalArea = rect_a.height * rect_a.width
          + rect_b.height * rect_b.width
          + circ.radius * circ.radius * Math.PI


Правило касается лишь первого токена в строке. Если этот токен может отпарсится как часть оператора, то оператор продолжается (даже если парсинг не удастся дальше). Если же первый токен не может продолжить оператор, начинается следующий (в этом месте спецификация говорит «вставляется »;"").

Потенциал для ошибок возникает, когда в паре операторов А и Б оба по отдельности валидны, но первый токен Б может также быть принят как продолжение А. В таких случаях, при остутствии ";", парсер не разберёт Б как отдельный оператор, и либо выдаст ошибку, либо отпарсит программу неожиданным образом. Таким образом, если ";" пропускаются, программисту нужно следить за любыми операторами А и Б, разделёнными переносом строки, не начинается ли Б с токена, который можно пристроить к концу А.

Большинство операторов в яваскрипте начинается идентификатором, а большинство остальных — ключевым словом наподобие «var», «function», «if». Для любого такого оператора Б, начинающего с идентификатора или ключевого слова, равно как и для любой строки, начинающейся со строковой константы, валидного оператора А не существует (доказательство сего из грамматики языка оставлено как упражнение для читателя).

A
function f(x){return x*x}
 
// для любого оператора А без ТЗ
// все эти примеры парсятся верно
 
A
f(7)
 
A
"a string".length


К сожалению, есть пять токенов, которые могут как начинать оператор, так и продолжать уже завершённый. Это "(", "[", "/", "+" и "-". На практике проблемы вызывают первые два.

Это значит, что не всегда перенос строки может заменить ";" между операторами.

Спецификация даёт пример:
                   a = b + c
                   (d + e).print()

не преобразуется автовставкой ";", так как выражение в скобках может быть отпарсено как аргумент вызова функции:
                   a = b + c(d + e).print()

Спецификация предлагает, «когда оператор присваивания должен начинаться левой скобкой, неплохо явно поставить точку с запятой на предыдущей строке». Более строгой альтернативой является практика постановки ТЗ в начале строке, непосредственно перед токеном, рискующим внести двусмысленность:
                   a = b + c
                   ;(d + e).print()

Операторы, начинающиеся круглой или квадратной скобкой, нечасты, но встречаются.

Примеры с квадратной скобкой более часты, так как «функциональные» операции наподобие map, filter, forEach более часты при массивах. Часто удобно записать массивный литерал с forEach, нужным для своих побочных эффектов:
[['January','Jan']
,['February','Feb']
,['March','Mar']
,['April','Apr']
,['May','May']
,['June','Jun']
,['July','Jul']
,['August','Aug']
,['September','Sep']
,['October','Oct']
,['November','Nov']
,['December','Dec']
].forEach(function(a){ print("The abbreviation of "+a[0]+" is "+a[1]+".") })
 
['/script.js'
,'/style1.css'
,'/style2.css'
,'/page1.html'
].forEach(function(uri){
   log('Looking up and caching '+uri)
   fetch_and_cache(uri)})

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

Последний проблемный токен это слеш, и он весьма неинтуитивен. Взгляните:
var i,s
s="here is a string"
i=0
/[a-z]/g.exec(s)

В строчках 1-3 мы заводим переменные, а на четвёртой мы вроде как пишем регекспный литерал "/[a-z]/g", который глобально находит a-z, а потом мы вызываем этот регексп со строкой методом exec. Так как возвращаемое значение exec() не используется, код не особо полезен, но мы бы ожидали, что он хотя бы скомпилируется. Однако же, слеш не только начинает регексп, но и является оператором деления. Это означает, что начальный слеш на строке 4 будет отпарсен как прдолжение оператора присваивания на предыдущей строке. Эти строки отпарсятся как «i равно 0 делить на [a-z] делить на g.exec(s)».

На практике эта проблема почти никогда не возникает, так как причин начинать оператор регекспом немного. В примере выше, значение вызова exec() обычно бы передавалось функции или присваивалось переменной, в любом из случаев строка бы не начиналась слешем. Возможное исключение это, опять же, метод forEach, который можно с пользой использовать [оригинал: usefully used] на значении, возвращённом вызовом exec().

Операторы "+" и "-" могут быть использованы как унарные, для преобразования значения к типу Number, и для реверсии знака в случае "-". При использовании в начале строки при пропущенных ";" они могут быть восприняты как соответствующие бинарные операторы, и продолжение предыдущего оператора. Но и это редко составляет проблему, так как начальный унарный оператор встречается ещё реже, чем регексп (и он, к тому же, не выглядит завершённо). Как и с регекспами, если бы программист хотел привести значение к числу, он бы это значение как-то использовал, присвоил бы переменной, или передал функции, и ни в каком из этих случаев унарный оператор не был бы в начале:
var x,y,z
x = +y;    // полезно
y = -y;    // полезно
print(-y); // полезно
+z;        // бесполезно

Во всех таких случаях, если вы пропускаете ";", безопасной практикой является начинать строки со скобкой как раз точкой с запятой. Тот же совет для маловероятных случаев операторов "+", "-", или слеша. Таким образом, даже если ТЗ не используются везде, строка будет защищена от неверного парсинга вне зависимости от того, как может измениться предыдущая строка.

Заблуждения



Многие начинающие программисты на яваскрипте получают советы ставить ";" везде, и полагают, что если они не используют правила автовставки ";", это свойство языка можно игнорировать. Это не так, из-за правил ограниченного порождения, приведённых выше, в особенности оператора return. А когда они знакомятся с ограниченным порождением, они начинаются боятся переносов строк, и избегают из даже когда они улучшают читабельность. Лучше всего освоить правила автовставки ";", чтобы мочь читать любой код, и мочь писать код наиболее ясным образом.

Ещё одно заблуждение гласит, что баги в броузерных движках яваскрипта означают, что ставить точки с запятой везде надёжнее, и что это повышает совместимость. Это просто не так. Все существующие броузеры реализуют спецификацию корректно в отношении автовставки ";", и любые баги, которые могли существовать, давно ушли во мрак ранней истории веба. Беспокоиться о бразуерной совместимости нет причин: все броузеры имплементируют эти правила так, так изложено выше.

Заключение



Ставить ли точки с запятой? Дело ваше. Просто выбор должен делаться на основании информации, а не туманных страхов по поводу неведомых синтаксических ловушек либо несуществующих багов броузеров. Если вы запомните эти правила, вы вооружены для верного выбора, и будет легко читать код на яваскрипте.

Если вы решили не ставить ";", советую вам ставить их перед открывающими скобками в операторах, которые ими начинаются, и в операторах, которые начинаются с "/", "+", "-", если вам доведётся такой оператор написать.

Вне зависимости от точек с запятой, помните правила ограниченного порождения (return, break, continue, throw, и постфиксные операторы инкремента и декремента), и можете разбивать строки в любых других местах для удобства и читаемости кода.
Tags:
Hubs:
+80
Comments 84
Comments Comments 84

Articles