プログラミング言語の構文とセミコロン

C言語の各文末にセミコロンが必須な構文は書いていてめんどくさいといわれる。 Cの構文を踏襲しつつセミコロンを省略可能にした言語がいくつかあるので、その手法と性質、課題をみていく。

  • C言語の構文とセミコロンが必須な箇所はどこか
  • JavaScriptのセミコロン省略により生じた問題とは何か
  • Goのセミコロン省略ルールはどう定められているか
  • Haskellのレイアウト依存構文はセミコロンの省略にどう生かされているか
  • Rustの構文からセミコロンを取り除くとしたらどんな問題が生じるか
  • F# のリストがセミコロン区切りである利点と欠点

C言語の構文とセミコロンが必須な箇所はどこか

C言語の構文要素は大きく分けて式、文、宣言がある。 式は数式や関数呼び出し。 文は分岐や繰り返し。 宣言は変数や型の定義・宣言。

一般に「C言語の文の末尾にセミコロンは必須」といわれるが、構文規則にそう書いてあるわけではない。 それぞれの文の構文規則に「この位置に ; があること」と定められている。

例えば式文と do-while 文の構文規則は次のようになっている。(ここではラベルと属性は無視する。) それぞれ末尾に ; が必須なことが明記されている。

// 式文
expression ;

// do-while 文
do statement while ( expression ) ;

一方、while文の構文規則にはセミコロンが含まれていない。

while ( expression ) statement

最後のstatementがセミコロンを含む場合、while文もセミコロンで閉じられているようにみえる。

    while (true) printf("yes\n");
    //           ^^^^^^^^^^^^^^^^ 式文

構文規則を眺めると、最後が ; でもstatementでもない文は複文だけである。

// 複文
{ statement | declaration...(optional) }

そのため「{} の後ろにはセミコロンはいらない」と思いがちだが、構造体を定義するときは ; が必須。

struct A {
    int n;
}; // このセミコロンは必須

これは構造体の宣言と変数の宣言を1つの構文規則に詰め込んだせい。 構造体の定義と変数の定義を同時に行えるので、おそらくこのセミコロンがないと構文的に曖昧になる。

struct A {
    int n;
} a = {0};

参考:

JavaScriptのセミコロン省略により生じた問題とは何か

JavaScriptは動的言語だが、構文はC言語に似ている。

JavaScriptの構文には自動セミコロン挿入という機能がある。 厳密なルールはリンク先を参照。 雑にいえば行末のセミコロンを書かなくて済む。

#自動セミコロン挿入 字句文法 - JavaScript | MDN

例を引用:

return
a + b

// 上記の文は、 ASI によって次のように変換されます

return;
a + b;

上の文は a + b を返すつもりが、実際には返り値の式を省略した return; と解釈されてしまう。 後ろにある a + b; は通常の式文なので構文エラーとして指摘されない。 到達不能な文なので、Lintツールにより警告される。 Lintツールがあれば、実用上、問題が生じることはないはず。

もう1つは関数呼び出しのカッコが前の文にひっつく問題。

const delay = require("util").promisify(setTimeout)

(async () => {
    await delay(1000)
    console.log("...hey")
})()

delayという変数の宣言の後に即時実行関数があるようにみえる。 実際にはこういう構文になってしまっている:

const delay = require("util").promisify(setTimeout)(async () => ...)()
//                                                 ^ ここにあった空行は関係ない

添字のカッコでも同様の問題が起こる。 この問題は構文エラーとして報告されないし、意味的な問題を静的に検出するのも難しいはず。 通常は実行時エラーとして現れる。 あまり詳しくないけど、動かしてみればすぐ気づきそうなものだし、それほど問題視されていないような雰囲気を感じる。

TypeScriptだとたいてい型エラーになるので、実用上問題ない。

回避する場合は ; を行頭または文末に書くか、ブロックで囲む。

    const delay = ...
    ;(async () => {...})()
//  ^

    const delay = ...
    { (async () => {...})() }
//  ^                       ^

省略可能な ; を書くか否かは、どちらの派閥も少なからず存在している気がする。

参考:

Goのセミコロン省略ルールはどう定められているか

Goの構文は個人的にCの構文の進化系だと思っている。

Goでもセミコロンを省略できる。 Goは使ったことがないが、仕様書によると次のようなルールらしい:

  • 行末に特定の種類のトークンがあったら、その後ろにセミコロンを挿入する。
  • カッコを閉じる直前のセミコロンがなくてもよいことにする。

セミコロン挿入の対象となるトークンの種類は仕様にいろいろ書かれているが、要するに文の末尾になりうるトークンのようだ。

例えば次の文の1行目は + で終わっているのでセミコロンは挿入されない。 2行目は 2 で終わっているので挿入される。

    a = 1 +
        2

このように複数行の二項演算は演算子を行末におく必要がある。

また、リストが複数行に渡るとき、最後に ; が入ってしまうのを避けるため末尾のカンマが必須になる。

    a := []int{
        1,
        2, // このカンマは必須
    }

この規則の場合、関数呼び出しのカッコが前の文にひっついてしまう問題は起きない。

参考: #Semicolons The Go Programming Language Specification - The Go Programming Language

Haskellのレイアウト依存構文はセミコロンの省略にどう生かされているか

Haskellの構文はレイアウト依存 (layout-sensitive) な部分があり、セミコロンや波カッコは一定の規則で自動挿入される。

Haskellも詳しくないが、以下の(やや古い)ページによると、こういう規則でトークンが補われるらしい:

  • 一部のキーワードの直後に { がなかったら挿入する
  • 同じ深さに字下げされている行が続いたらセミコロンを挿入する
  • 字下げが浅い行が出てきたら } を挿入して閉じる
    • また、} によって構文エラーを回復できるときも挿入する

参考

} によって構文エラーが回復される例はこれ:

    let a = 1; b = 2 in a + b

レイアウトだけみるとbの右辺が 2 in a + b になりそうだが、構文的に有効な式ではない。inはこの位置に出現できない。そのため } が挿入される。

    let {a = 1; b = 2} in a + b

実際のHaskellのコードを眺めていると、波カッコやセミコロンはほとんど出現しない。

Rustの構文からセミコロンを取り除くとしたらどんな問題が生じるか

RustはCの構文を進化させたような構文を持つが、式指向になっている。 Rustの「ブロック式」はCの複文と同様に文の並びを {} で囲んだものだが、最後に1つ式を置くことができて、その値がブロック式の値になる。

ブロック末尾のセミコロンの有無は大きな違いを生む。

    {
        x;
        y;
//       ^ セミコロンがあるから y; は文
    }
//  ^ このブロックの値はユニット
    {
        x;
        y
//       ^ セミコロンがない。このyはブロックの最後の式
    }
//  ^ このブロックの値はyに等しい

セミコロンを省略可能にしてしまうと、最後の式文の値を捨てるために余分な式を書くことになる。

    {
        x
        y
        () // ブロックの値をユニットにする
    }

ブロックの型が () だったら値を捨てる規則にしたら省略可能にできそうに思えるが、型推論と相性が悪いかもしれない。

(他にも理由があるかも。)

参考: (ただの議論であり公式回答ではない。)

F# のリストがセミコロン区切りである利点と欠点

F# のリストの要素はセミコロンで区切る:

    [ 1; 2; 3 ]

複数行に分けて書くとき ; は省略できる。 末尾のセミコロンだけでなく、末尾のカンマのわずらわしさからも解放される。(末尾カンマ問題も消滅)

    [ 1
      2
      3 ]

リスト内包表記もある:

    [ for n in 0..4 -> n * n ]
//=>
    [ 0; 1; 4; 9; 16 ]

内包表記の中で条件分岐やループが使える:

    [ for n in 0..4 do
        if n % 3 = 0 then
          "fizz"
        else
          string n ]
//=>
    [ "fizz"; "1"; "2"; "fizz"; "4" ]

よくみると内包表記の中の構文は外の構文に似ている。 式文の値が捨てられる代わりにリストの要素になる、という感じで読める。

    [ 1; 2; 3 ]
//    ^^ ^^ ^ 式文の並びとみなせる

デメリットはカンマと取り違えやすいこと。

    [ 1, 2, 3 ]
//     ^ セミコロンではなくカンマを書いてしまう誤り

F# では 1, 2, 3 も構文的に有効な式なので、この誤りは構文エラーとして指摘されない。 1, 2, 3 は3項のタプルを作る式であり、[ 1, 2, 3 ] はタプルを1個含むようなリストを作る式とみなせる。 これによる型エラーをみて混乱する初学者がしばしばみられる。 この問題は F# 自身の欠陥というより環境との軋轢であり、言語の設計は難しい。

関連記事