🧲

JavaScriptの演算子の優先順位と「禁止ルール」の一覧

2022/02/06に公開

JavaScriptには他の多くのプログラミング言語と同じく、演算子の優先順位 (operator precedence)があります。ただし、シンプルな前置・後置・中置演算でないケースや、特別な禁止ルールがあるため、一言で説明できない部分もあります。本稿ではECMAScript仕様書の構文 (2022年2月時点) の内容をもとに、JavaScriptにおける演算子の優先順位の詳細の説明を試みます。

まず基本となる優先順位表が以下です。

順位 種別 演算子 非終端記号
0 最上位 後述 PrimaryExpression, SuperProperty, MetaProperty, NewTarget, ImportMeta, SuperCall, ImportCall
1 postfix . [] ` new() () LeftHandSideExpression (MemberExpression, CallExpression, OptionalChain, SuperProperty, MetaProperty, NewTarget, ImportMeta, SuperCall, ImportCall)
2 prefix new (括弧をともなわないもの) LeftHandSideExpression (NewExpression)
3 postfix ?. LeftHandSideExpression (OptionalExpression)
4 postfix once ++ -- UnaryExpression, UpdateExpression
5 prefix delete void typeof await + - ~ ! ++ -- UnaryExpression, UpdateExpression
6 infixR ** ExponentiationExpression
7 infixL * / % MultiplicativeExpression
8 infixL + - AdditiveExpression
9 infixL << >> >>> ShiftExpression
10 infixL < > <= >= instanceof in RelationalExpression
11 infixL == != === !== EqualityExpression
12 infixL & BitwiseANDExpression
13 infixL ^ BitwiseXORExpression
14 infixL | BitwiseORExpression
15 infixL && LogicalANDExpression
16 infixL || ShortCircuitExpression (LogicalORExpression)
17 infixL ?? ShortCircuitExpression (CoalesceExpression)
18 infixR / prefix ?: yield => = *= /= %= += -= <<= >>= >>>= &= ^= |= **= &&= ||= ??= AssignmentExpression, ConditionalExpression
19 infixL , Expression

ただし、種別は以下の通りです。

  • prefix (前置演算子) …… もとの式の手前に何個でもつけられる演算子。
    • 例: -~-~x
  • postfix (後置演算子) …… もとの式の直後に何個でもつけられる演算子。
    • 例: x.foo()`bar`[0]
  • postfix once …… もとの式の直後に1個だけつけられる演算子。
    • 例: x++ は可能だが x++-- はパースされない。
      • 逆に ++--x はパースされるが、構文とは別のルールで禁止される。 (後述)
  • infixL …… 中置演算子で左結合 (演算子の優先度が同じ場合は左側にあるほうが優先される)
    • 例: 0.1 + 1.0 - 1.0(0.1 + 1.0) - 1.0 になる
  • infixR …… 中置演算子で左結合 (演算子の優先度が同じ場合は右側にあるほうが優先される)
    • 例: 2 ** 2 ** 32 ** (2 ** 3) になる

以降ではいくつか注意すべきケースについて説明します。なお、コーナーケース的な状況についても議論しているため、実用的には出てこないような例が出てくることもありますのでご了承ください。

PrimaryExpression とその亜種

中置・前置・後置記法のどれにも属さない構文はほとんどの場合優先度管理の必要がなく、常に最優先で解釈されます。これには以下の構文が含まれます。

LeftHandSideExpression

LeftHandSideExpressionNewExpression, CallExpression, OptionalExpression の非交和です。

複合後置記法

foo.bar は一見すると中置演算子のようにも見えますが、 . の右側には IdentifierReferencePrivateIdentifier などの非常に限定的な内容しか置けません。「式」を基準にすると後置演算子とみなすほうが自然なため、本稿では後置演算子の一種として扱っています。同様に x(...)x[...], x`...` なども後置演算子の一種として扱っています。

ただし後述するようにOptional chainingとの関係では中置記法と同様の問題が発生します。これは MemberExpression が「式」と「.x[i] などの後置演算子」を結合する仮想的な中置演算子であると考えるとわかりやすいでしょう。Optional expressionも同等の中置演算子と見なせるため、優先度の問題が発生します。

2種類の new 演算子

new には括弧をともなうものとともなわないものがあります。

new String(); // 括弧をともなうnew演算子
new String; // 括弧をともなわないnew演算子

この2つの new と関数呼び出し構文を組み合わせると、そのままでは曖昧性があることがわかります。

new String(); // 括弧をともなうnew演算子
new String; // 括弧をともなわないnew演算子
String(); // 関数呼び出し

// 優先度を無視すると、 new String(); は次のようにも解釈できる。
(new String)(); // 括弧をともなわないnew演算子 + 関数呼び出し
new (String()); // 括弧をともなわないnew演算子 + 関数呼び出し

これは、 new() を内側から貪欲に対応させて、余った分は単独で解釈するというルールになっています。

   new new f()()();
//              ^^ これは単独で使われる
// ^^^--------^^ この2つが対応する
//     ^^^--^^ この2つが対応する

   new new new f()();
// ^^^ これは単独で使われる
//     ^^^--------^^ この2つが対応する
//         ^^^--^^ この2つが対応する

ここで、 () は後置記法なので他の後置記法との優先順位の問題は生じません。一方 new は前置記法のため優先順位の問題が生じます。そこで、括弧をともなわない new. などの後置記法より低い優先度で解決されます

new foo().bar; // (new foo()).bar
new foo.bar; // new (foo.bar)

括弧をともなう new は括弧の位置にもとづいて優先度が解決されるため、本稿では new() を後置演算子として分類しています。

ここまでの仕様を表現するために、規格中では以下の3つのよく似た非終端記号が登場します。

  • MemberExpression …… new() の釣り合いが取れているケース。
  • CallExpression …… () のほうが多いケース。
  • NewExpression …… new() の釣り合いが取れているか、または new のほうが多いケース。

Optional chainingとnew

本稿の表では便宜上 ?.new より低い優先度としていますが、この2つは共存が禁止されているため実際には優先度に上下関係はありません。具体的には以下の禁止ルールがあります。

  • 括弧をともなう new() 演算子が ?. を挟むのは禁止。
    • OptionalChainnew の規則がないことで実現されている。
  • 括弧をともなわない new 演算子が ?. と並ぶのは禁止。
// new foo?.bar(); // Error
// new foo?.bar; // Error
new foo()?.bar; // OK

Optional chainingとMemberExpression/CallExpression

?. は後置記法なので、一見すると同じ後置記法である .() などと同じ優先度でも問題ないように見えます。しかしOptional chainingは名前の通りチェーンを構成する (そろそろJavaScriptに採用されそうなOptional Chainingを今さら徹底解説 - Qiita / オプショナルチェーンの短絡評価 などを参照) ため、意図的に優先度が下げられています。

// foo に ?.bar.baz が適用されているものとして扱われる
foo?.bar.baz

これを実現するために、 MemberExpressionCallExpression 中の演算子と同じ内容 (new() を除く) が OptionalChain でも繰り返されています。

new, import, super

new, import, super は変数のように使える場合がありますが、これは構文レベルで制限があります。

このうち new.target, import.meta, super., super[] については MemberExpression 内に、 import()super() については CallExpression 内に分岐を追加することで実現されています。そのため、 new() の対応づけルールは既に説明したものから変わりません。

// import, superのときだけ対応づけが変化する、ということはない。
// new import(); // Error (importは new() できない)
// new super(); // Error (superは new() できない)

UnaryExpression, UpdateExpression

2つに分かれている理由

ExponentiationExpression (**) 内の禁止ルールのために2つに分かれています。 (後述)

前置インクリメントと後置インクリメントの優先順位解決

UnaryExpression/UpdateExpressionには前置演算子と後置演算子が混在しています。しかし後置演算子 (x++x--) は左再帰的ではない形で定義されています。

// 後置インクリメント・デクリメントは左再帰的ではないため、次のような書き方は構文レベルで除外されている
// x++--; // Error

これにより同じ非終端記号内でも自動的に後置演算子のほうが優先度が高くなり、曖昧性が解決されます。

// パーサーはこれを ++(x++) としてパースする
// (ただし、実際には構文解析後のEarly Error検査でエラーになってしまう)
++x++;

ExponentiationExpression

** の禁止ルール

ExponentiationExpression において、 ** の右辺に前置演算子 delete, void, typeof, +, -, ~, !, await が出現するのは禁止されています。

// -2 ** 2 // Error

同じ優先度でも ++, -- の出現は許されています。

++x ** 2 // OK

これはあらかじめ UnaryExpression の一部を UpdateExpression として切り出しておき、 ExponentiationExpression** 規則の右辺で UnaryExpression のかわりに UpdateExpression を使うことで実現されています。

ShortCircuitExpression

ShortCircuitExpressionLogicalOrExpressionCoalesceExpression の非交和です。

?? の禁止ルール

CoalesceExpression において、 ?? で区切られた部分のいずれかに && または || が出現するのは禁止されています。

// true && true ?? true // Error (括弧が必要)
// true ?? true || true // Error (括弧が必要)

本稿の表では便宜上、 ??&& / || よりも低い優先度として表記していますが、実際には優先度の上下関係はありません。

これを実現するために、以下のような構文上の工夫が行われています。

AssignmentExpression

前置記法と中置記法の混在

前置記法と右結合な中置記法はどちらも右再帰的である (→左再帰的ではない) ため、同じ優先度で混在できます。

代入式の禁止ルール

優先度管理の観点からは、代入式 x = y や複合代入式 x += y の左辺には || / ?? までの式を書けてもよいところですが、ほとんどの演算子は左辺には不要のため構文的に禁止されています。

// x + y = 42; // Error

括弧なしで書けるのは LeftHandSideExpression 以上の優先度の演算子のみです。 (それがこの名前の由来だとも言えますが、逆に LeftHandSideExpression だから左辺式だとは言えないところが難しいところです)

どうせ括弧を使えば任意の式をパースさせることができるわけですが、その後の早期エラーの検査で多くが弾かれます。 (後述)

yield

yield には引数ありのバージョンとなしのバージョンがあります。

function* f() {
  yield; // 引数なし
  yield 42; // 引数あり
}

引数のない yield は一見すると PrimaryExpression に入れてもよさそうですが、もし PrimaryExpression に入れてしまうと以下のような曖昧性が生じてしまいます。

function* f() {
  yield+0; // +0をyieldしているのか、無引数のyieldの戻り値に0を足しているのかわからない
}

無引数の yieldAssignmentExpression になっていることで、後続するトークンは ), ], }, ,, ;, : に限定されます。これらはいずれも式を開始しないため曖昧性が解消されます。

代償として、曖昧性が無さそうなケースでも無引数の yield を括弧なしに置けない箇所が多数存在します。

function* f() {
  // yield.foo; // Error
  // new yield(); // Error
  // new yield; // Error
  // yield?.foo; // Error
}

ブロック形式のアロー関数

アロー関数の本体には式とブロックの2種類があります。式の場合、アロー関数式は前置演算子と同等とみなせます。

// 構文上は「42 に対して (x) => が前置されている」とみなせる
(x) => 42

ブロックの場合は両端から挟まれているため、原理的には PrimaryExpression としても扱えるはずです。しかし実際には AssignmentExpression に分類されているため、曖昧性が無さそうなケースでも実際にはパースできないことがあります。

// (x) => { return 42; }(); // Error (括弧が必要)

Expression

, の禁止ルール

多くの箇所でコンマ演算子 , が禁止されています (つまり、 Expression のかわりに AssignmentExpression が使われています) 。

  • 他の目的で , を使っている箇所
    • 配列リテラル内 [1, 2, 3]
    • オブジェクトリテラル内 ({ foo: 42, bar: 100 })
    • 実引数リスト内 foo(1, 2, 3)
    • 配列パターン内 const [x = 1, y = 2] = [];
    • オブジェクトパターン内 const { foo = 42, bar = 100 } = {};
    • 仮引数リスト内 (x = 42, y = 100) => 0
  • 曖昧性はないが , を禁止している箇所
    • Computed property name ({ [foo, bar]: 42 }) /* error */
    • 条件式のtrue側分岐 true ? 42, 100 : 0 /* error */
    • for-of 文内のコレクション式 for (const x of 0, []) {} /* error */
    • export default 宣言 export default 42, 100; /* error */

自然に発生する禁止ルール

ここまでは人工的な禁止ルールを紹介してきましたが、優先順位表から自然に発生する禁止ルールもあります。これは以下の2つです。

  • 前置演算子と、それより優先度の高い前置または中置演算子の組み合わせ。
  • 後置演算子と、それより優先度の高い後置または中置演算子の組み合わせ。

上記の組み合わせでは構文木の回転ができないために禁止ルールが生じます。

Optional chainingはやや特殊で、既に紹介したので省略します。それ以外にたとえば以下のような例があります。

// ++/-- と、それより優先度の高い後置演算子の組み合わせ
// x++(); // Error

// delete void typeof await + - ~ ! ++ -- のいずれかと、
// それより優先度の高い前置演算子 (newのみ) の組み合わせ
// new await x; // Error

// yield または => と、
// それより優先度の高い前置演算子
// (delete void typeof await + - ~ ! ++ -- new) の組み合わせ
// async function* f() { await yield x; } // Error

// yield または => と、
// それより優先度の高い中置演算子 (ほぼ全ての中置演算子) の組み合わせ
// null && () => 0; // Error

早期エラー (Early Error)

JavaScriptでは構文解析のあとに静的検査が行われます。これを早期エラー (Early Error) といいます。構文定義上は許されていても、早期エラーで弾かれるために実質的に禁止されている組み合わせがあるため、特に「演算子の優先順位」の議論と関係する部分を紹介します。

なお、早期エラーで弾かれた場合もエラーは SyntaxError という扱いになります。

Optional chainingとタグ付きテンプレートリテラル

Optional chaining内でのタグ付きテンプレートリテラルはパースされますが、早期エラーで禁止されます。このような手順になっているのはASI (自動セミコロン挿入) の挙動を制御するためです。

foo?.`bar`
foo?.bar`baz`

代入可能性

全ての式が代入可能 (= 左辺に置ける) なわけではありません。代入可能かどうかは最終的には実行時に決定されますが、早期エラーでもある程度のチェックが行われます。以下の式で代入可能性がチェックされます。

代入可能かどうかはAssignmentTargetTypeというルールで決められています。以下の式だけが代入可能です。

  • 識別子 (strict mode中の eval / arguments を除く)
  • x[...], x.foo (x.#foo, super[...], super.foo も含む)
  • 代入可能な式を括弧 (...) で括ったもの
x = 42; // OK
x.y = 42; // OK
(f()[0]) += 42; // OK
// (x + y) = 42; // Error
// (0, x) = 42; // Error
// (cond ? low : high) = mid; // Error

なおV8やSpiderMonkeyでは独自拡張として、関数呼び出し f() も構文上は代入可能としているようです。ES5.1までホスト定義関数が参照を返すことが許されていたので、これらの挙動はその名残りかもしれません。

ともあれ、この早期エラーによって以下のような式は実際には禁止されます。

// ++--x; // Error (早期エラー)
// ++x++; // Error (早期エラー)
// ++delete foo.bar; // Error (早期エラー)

これを踏まえると、 ++ / -- は実質的には UnaryExpression の他の演算子よりも優先度が高く、再帰的ではない (++ / -- は複数回連続して適用できない) とも考えられます。

なお delete も引数として参照を期待しますが、この早期エラーの対象にはなっていません (参照以外を渡した場合は何もせずtrueを返す)。strict modeでは特定の delete の使い方が禁止されますが、演算子の優先順位とは関連が薄いため省略します。

その他の主要な構文的曖昧性

ECMAScriptでは演算子の優先順位については文脈自由文法の範囲内で解決しますが、それ以外の曖昧性の多くを先読み指定によって解決しています。

  • dangling else
    • else は内側優先で対応づけられます。
  • in 演算子
    • for 文の特定位置では in が禁止されています。これは演算子の優先順位の問題ではなく、 for(;;)for-in を先読みなく区別するためのものだと考えられます。
  • 文・宣言優先ルール
    • { は文頭では曖昧性がありますが、式文よりもブロック文が優先されます。
    • {=> の直後では曖昧性がありますが、式よりもブロックが優先されます。
    • function, function*, async function, async function*, class は文頭や export default の直後では曖昧性がありますが、式文よりも宣言が優先されます。
    • 文頭や for( の頭などでは let[ で始まる場合の曖昧性がありますが、 let 宣言が優先されます。

Discussion