Goは必ずしもEBNFに則しているとは限らない
Goは好きな言語の1つで普段から愛用しているのですが、先日Twitterで次のような不自然な挙動を目にしました。
このTweetのスレッドを読んでいくと、どうやらIf文の条件式中でstruct
(anonymous struct)を初期化した場合はコンパイルに成功するが、foo
(defined type)を初期化した場合はコンパイルに失敗するようです。
type foo struct { v int }
if struct{ v int }{1}.v == 1 {} // コンパイル成功
if foo{1}.v == 1 {} // コンパイル失敗
そこでGoの言語仕様であるThe Go Programming Language Specification(Version of Jan 14, 2020)を参照すると、該当するEBNF(Extended Backus-Naur Form)であるIfStmt
は、
IfStmt = "if" [ SimpleStmt ";" ] Expression Block [ "else" ( IfStmt | Block ) ] .
となっていて、条件式にセミコロンはないので... == 1
の部分はExpression
としてパース[1]されます。Expression
をさらに辿っていくと
Expression
→ UnaryExpr
→ PrimaryExpr
→ Operand
→ Literal
→ CompositeLit
→ LiteralType LiteralValue
となり、次のLiteralType
のパースで、struct
(正確にはstruct{ v int }
)がStructType
に、foo
がTypeName
にそれぞれパースされるはずです(そして共通して続く{1}
はもちろんLiteralValue
に当たります)。
はずと言ったのは、上記のコードのASTを見るとfoo
のほうはそのようになっていないからです[2]。
-
struct
を含むIfStmt
-
foo
を含むIfStmt
まずは意図通りパースされているstruct
を含むIfStmt
のほうから見てみましょう。ASTを見て分かる通り条件式は==
で結ばれるCond : *ast.BinaryExpr (Op: ==)
としてパースされています。そして、その下の階層には==
の左辺であるX : *ast.SelectorExpr
と右辺であるY : *ast.BasicLit (Kind: INT)
があります。X
のほうをさらに深掘っていくとコードと対応するようにパースされています。
一方でfoo
を含むIfStmt
のほうはどうでしょう。ASTを見ると条件式は==
ではなくただのfoo
(Cond : *ast.Ident (Name: foo)
)になっていますね。
なぜこのようにパースされたかを知るにはパーサー自体を見ていくしかありません。幸にしてGoのパーサーはGoで書かれているので、コードを根気よく読んでいけばいつかこの謎を解くことができます。それでは見ていきましょう。
IfStmt
のパーサーは文字通りparseIfStmt
関数が担っています。特にIf文の初期化式と条件式はまとめてparseIfHeader
関数でパースされます。パーサーを読み進めると問題のコードにはセミコロンがないので、1875行目からSimpleStmt
をパースするparseSimpleStmt
関数に入ります。実はEBNF上ではSimpleStmt
とExpression
は分かれていますが、実際のパーサーでは両方ともparseSimpleStmt
関数でパースします。
さらにEBNFとパーサー、問題のコードを対比させながら読み進めると、PrimaryExpr
をパースするparsePrimaryExpr
関数に辿り着きます。この関数の中でx := p.parseOperand(lhs)
にてOperand
(foo
のことですね)を取得した後、次のtokenが何であるかで場合分けの処理が入るのですが、foo
の後に来るtokenはもちろん{
なのでtoken.LBRACE
の条件に入ります。そしてここで問題の根本原因である1510行目にようやく辿り着くことになります。
if isLiteralType(x) && (p.exprLev >= 0 || !isTypeName(x)) {
このtoken.LBRACE
はLiteralType
のものなのでisLiteralType
関数の戻り値がtrue
かどうかだけ判断すれば良いと一見思うかもしれません。しかし、何やらp.exprLev >= 0 || !isTypeName(x)
と続いていますね。1つ目のp.exprLev >= 0
の意味するところですが、これがtrue
のとき、すなわちp.exprLev
が0以上であるとき、パーサーは式(expression)を処理中であることを表しています。2つ目の!isTypeName(x)
は、文字通り今処理中のノードがTypeName
ではないことを判定しています。
なぜこのような条件が付加されているのか?GitHubにあるコミット履歴を2009年まで遡ってみましたが明示的に理由を説明しているコミットは見つかりませんでした。そこで推測になるのですが、この条件が付加されたのはGoはパース処理を簡略化する設計思想を持つ言語である(変数定義時に変数名の後に型を持ってくるのはそのため[3])からだと思いました。
例えばif foo{v: 1} {}
をパースするとき、foo
が変数名(identifier
)なのかユーザー定義型(TypeName
)なのかは{v: 1}
をパースした時点では判断できません。それは{v: 1}
単体だとLiteralValue
とも、LabeledStmt
in Block
とも取れるからです。その後の{}
をパースして初めてTypeName
であると分かります[4]。
-
LabeledStmt
inBlock
{
v:
1
}
この記事の「struct
を含むIfStmt
」の図をもう1度見ていただくと、{}
がBlock
と分かった後にBlock
の兄弟ノードに当たるCompositeLit
の下の階層まで戻ってEBNFを決めていかなければならず、パース処理が複雑になることが容易に想像できます[5]。
以上をまとめると、LiteralType
の定義は
LiteralType = StructType | ArrayType | "[" "..." "]" ElementType |
SliceType | MapType | TypeName .
ですが、TypeName
を取ることは現時点ではできず、EBNFを満たしていません。また今回はIfStmt
の例を見てきましたが、他のcontrol structuresであるSwitchStmt
, ForStmt
でも同様にEBNFを満たしていません[6]。
-
この記事で言うパース、パーサーは構文解析、構文解析器のことを指しています。なので意味解析でエラーになってもパースに成功するか否かはEBNFに従った構文的に正しかだけで判断します。 ↩︎
-
可視化にはGoAst Viewer を使わせていただきました🙏 ↩︎
-
Why are declarations backwards? - Go Frequently Asked Questions (FAQ) ↩︎
-
@dqneo さんより。複数ファイルがある場合は、別のファイルに
foo
があるかもしれないため、この時点ではTypeName
かどうかはまだ判別できない。字句解析や構文解析後、ファイルごとの AST をマージする処理および型チェックによって初めて分かる。 ↩︎ -
{v: 1}
をパースした後にくるtokenが.
、つまりtoken.PERIOD
であれば{v: 1}
はLiteralValue
に絞られるので、問題のコードはパースに成功しても良いのになぁと思ったりもします。 ↩︎ -
@dqneo さんより。specには
A parsing ambiguity arises when a composite literal using the TypeName form of the LiteralType appears as an operand between the keyword and the opening brace of the block of an "if", "for", or "switch" statement, and the composite literal is not enclosed in parentheses, square brackets, or curly braces. In this rare case, the opening brace of the literal is erroneously parsed as the one introducing the block of statements. To resolve the ambiguity, the composite literal must appear within parentheses.
とある通り意図した挙動で、解決策としては括弧でくくるべきだと書かれています。 ↩︎
Discussion
今回参考にしているパーサーが go/parser でコンパイラのパーサーではないらしいので、要調査。
コンパイラのパーサーはこっち https://github.com/golang/go/blob/master/src/cmd/compile/internal/syntax/parser.go
go/parser には spec と挙動が違うことが明記されている。
一応このケースは仕様書に書かれてありますね。
isTypeName()は、実は TypeName かどうかまでは見ていません。ノードが identifierだと 真 になってしまうので。
確かに、上の
parseOperand
や次のtokenがtoken.LBRACE
かどうかなど、特定のコンテキスト下でしかisTypeName()
がTypeName
であると判定できませんね。識別子 foo が TypeName であるかどうかは、ファイルを全部パースしても決定できないケースがあります。(今読んでるファイルじゃない方のファイルに
type foo
宣言がある場合)構文解析や意味解析を全部終わった後に再度チェックすればこのケースもカバーできそうな気もしますが、そうすると構文解析が複雑になったり並列コンパイルがしにくくなったりするので、このように妥協しているのかなと思います。
Go発表時資料の "parsable without a symbol table." というのがおそらくそれかなと。
結局のところ、
「公式GoコンパイラはEBNFで定義した文法の部分集合だけをサポートしている」
EBNFで定義された文法 ⊂ 公式Goコンパイラがvalidとして扱える文法
ということになると思います。
逆に言うと、EBNF違反のコードはすべて文法エラーとして正しく検知していると思います。(反例をみたことがない)
たぶん自分が go/parser を元にこの記事を書いてるせいで議論がごっちゃになっている気がしますね。
ファイルを字句解析や構文解析した時点ではファイルごとに構文木が作られていますが、その後それら構文木を1つのASTにmergeして型チェックをするのでその際に識別子のEBNFが決定するのですよね。
どちらかと言うと「EBNFで定義された文法 ⊃ 公式Goコンパイラ」な気がします!
すいません、部分集合の向きを間違えてました。
そこはそのとおりですね。
了解です。記事を更新しました。