MFG:行頭のパイプライン演算子を継続行扱いした話
先日、行頭のパイプライン演算子を特別扱いして継続行とみなす、という変更を入れました。 MEP 27がそれに該当します。
MEP 27: パイプライン演算子だけは次の行に書ける例外とするルール
少しその関連の話をブログにしてみます。
改行を区切りとみなす場所
MFGは改行をスペースとみなす場所と区切りとみなす場所があります。 技術的な事を言えばその改行でExprの終わりとして見て矛盾がなければExprの終わりとします。 Expr以外は基本的には改行は空白とみなします。
詳細は以下のノートに書いてあります。
ここでは、他の言語との比較なども兼ねて、この話題を掘り下げてみたいと思います。
改行は区切りか空白か
世の中には改行が何らかの区切りになる言語と、単なる空白として扱う言語の2つがあります。 その違いはどこからくるのか?という事を考えます。
多くのケースで改行してもそのまま式として続く方が、長くなった行を好きに改行出来て便利です。 ですが、たまに区切って欲しい場合があります。
典型的には、以下のようなコードが問題になります。
let a = 5
-3 + a
これは、2つの解釈の可能性が考えられます。
- 改行区切り: letの文と-3+aという式
- 改行を空白:
let a = 5 - 3 + aという文
これはなぜこういう曖昧さが出るかというと、-という記号には単項演算子のマイナスと、二項演算子の引き算の2つの役割があるからです。
どちらとも解釈出来るため、-だけでは判定出来ません。
returnやセミコロンがあれば区切りは明白
このような問題が出るのは、最後の式がreturnだと解釈されるからです。
明示的にreturnというキーワードが必須な言語ではこういう問題はありません。
# 1つの文
let a = 5
-3 + a
# 2つの文
let a = 5
return -3 + a
また、文の最後にセミコロンなどの区切りがある言語でも両者の違いは明白です。
# 1つの文
let a = 5
-3 + a;
# 2つの文
let a = 5;
-3 + a;
つまりこれは、
- セミコロンやreturnを必要とする代わりに自由に改行出来るようにするか
- 改行を区切りとする代わりにセミコロンやreturnは無しで書けるようにするか
という選択の問題となります。
1は式の区切りが明快で、意図が明示的なため、意外な挙動をする事がありません。 特別なルールなどを覚える必要もなく、この話題がスッキリと解決出来ます。
一方で長い行を改行して継続行としたい、というのは、例外的な状況でもあります。 簡単なケースでも全てにセミコロンをつけなくてはいけない、というのは、 簡単なケースを簡潔に書けるようにした方が良さそう、という直感とは反します。
またreturnは文となります。 なるべく多くを式で統一したい言語では、 文が増えてくると面倒もあります。
例えばPythonのlambdaで文が使えないがdefの関数の中では使える、 という違いが、不自然に感じられるシチュエーションはあるでしょう。
また、sortなどにラムダ式を渡す時に、いちいちreturnをつけるのが面倒、 というのはC++などの言語では面倒を感じる所でしょう。
v.sort( [](int a, int b){ return a<b; } );
returnがあるのか、とか、セミコロンがあるのか、といった事は、 式や文に対するその言語の姿勢を決めるポイントとなります。
メソッドチェーン
MFGでは問題になりませんが、いわゆるメソッドチェーンでも同じ問題が起こります。
以下のように、メソッド呼び出しを並べてコレクションの処理をするのは、昨今の言語では良くやりたくなります。
col
.filter( { $1 >= 0 } )
.sort( { $1 < $2 } )
これもcolの所で文の終わりとみなされるとうまくいきません。
この
-をどうするか- ラムダ式のreturnを必要とするか
- メソッドチェーンで.を次の行に書けるか
というあたりが、改行を区切りとするかどうかという選択と大きく関わってくる所です。
Go言語の場合
Go言語は文の最後にセミコロンをつけるけれど、このセミコロンが省略出来る、というルールによってこのバランスを取ろうとしています。
以下の仕様の「Semicolons」のセクションにこの辺の記述があります。
The Go Programming Language Specification#Semicolons
雑に要約すると、「改行の時点で終わってそうならセミコロンを自動で挿入する」という挙動になります。
例えば以下のようなメソッドチェーンを考えると、
以下はエラーになります。
a := A{}
b := a
.Method1()
.Method2()
行末に.を置かないといけません。
a := A{}
b := a.
Method1().
Method2()
セミコロンを省けるようにするとメソッドチェーンが書きにくくなる、というのはシンタックスのややこしい問題ですね。
Kotlinの場合
Kotlinはかなり複雑なルールでこの問題に対処します。
additiveExpressionは以下のように、
Expressions#additive-expressions
additiveOperatorのあとにだけNLを許しています(NLはline breakの事です)。
multiplicativeExpression {additiveOperator {NL} multiplicativeExpression}
つまり、以下は2つの式と解釈されます。
a
+3
一方で、論理演算子などは両方に改行を許します。
Expressions#logical-disjunction-expression
つまり、以下は一つの式になります。
a
&& b
+と&&でルールが変わるのは難しさがありますね。
メンバーアクセスは二箇所にまたがってますが、
memberAccessOperator:
({NL} '.')
| ({NL} safeNav)
| '::'
navigationSuffix:
memberAccessOperator {NL} (simpleIdentifier | parenthesizedExpression | 'class')
ようするに.の前にも後ろにも改行を許します。つまりメソッドチェーンは下に書けるという事です。
val b = a
.method1()
.method2()
Swiftの場合
Swiftは改行は空白と同じとして扱われます。 ですから、以下のコードは1行と同じとなります。
let a = 3
- 5
Swiftでは基本的にはreturnがあるので、これらに曖昧正はありません。
ただしlambdaの中でreturnを書くのは面倒なため、 式が一つの時にはreturnを省く事が出来る、という特殊ルールがあります。
なお、-が単項演算子か二項演算子かの曖昧さは、Swiftでは-の前後に空白があるか無いかで判断されます。
// 単項演算子
-a
// 2項演算子
- a
単項演算子に空白を許さない、というのは他の言語には見られない特徴ですね。
ここに限らず、Swiftは既存の言語ではシンタックス的に曖昧性がある所を、 何かしら決めて曖昧性をなくす、という選択が多い印象を受けます。 言語の性格ですね。
F#とオフサイドルール
F#の場合、原則としては改行は空白と同じと扱われるのですが、そこにオフサイドルールが絡まるので事情は複雑です。
F#には、Pythonと同様にインデントでブロックを表すLightweight Syntax(通称オフサイドルール)というシンタックスシュガーが、
あとから追加されました。
これはLieghtweight Synataxでない以前からのシンタックスにパース時に変換されて実行されますが、
これがインデントを特別扱いし、それに合わせて改行も特別扱いされる事になります。
結論としては、以下は2つの文と解釈されます。(下はunary operator)
let a = 3
-1
一方以下は一つの文です。
let a = 3
-1
このように、インデントの違いで両者が違った結果となります。 これは本質的には式の区切りをインデントで明示的にしている事になるため、 文末にセミコロンが必須の言語と同じような事になります。
さらにこのオフサイドルールにはinfix operatorの例外があって、二項演算子で始まる行は、通常のルール(イコールと同じ位置)よりも2スペース分だけインデントを下げても一つの式とみなされます。 つまり以下も正しく一つの文と解釈されます。
let a = 3
- 1
この行頭のinfix operatorの例外扱いは、今回話すMFGの行頭のパイプライン演算子の例外扱いに大きく影響を与えた仕様でもあります。
ちなみにこのインデントの無いF#の挙動はMFGとほぼ同じです。
MFGのセミコロンや式に関してはF#を参考にした部分が多いです。
MFGでの挙動とパイプライン演算子の例外扱い
以上の話題がMFGではどうなっているかを見てみましょう。
MFGでは改行は式の区切り
MFGでは基本的には改行は式の区切りと解釈されます。 つまり、以下は2つの文と解釈されます。
let a = 3
-2
一つの式にしたい場合は、GolangやKotlinの場合と同様に、二項演算子で終わらせる必要があります。
# これは一つの式
let a = 3 -
2
また、改行はバックスラッシュでエスケープする事も出来ます。
なお、改行が区切りになるのは式だけです。それ以外の部分は空白と同様に扱われます。 以下のように書いても正しくパース出来ます。
def
result_u8
|
x
,
y
|
{
u8[0, 0, 0, 1]
}
また、式が終わっていない場合でも改行は使えます。
let a = u8[
0
, 1
,
2
, 3
]
パイプライン演算子の例外扱い
例外扱いをしない場合、パイプライン演算子は前の行の行末に書かないといけない事になってしまっていました。
input_u8(x, y) |>
to_lbgra(...)
これだと、コメントアウトもしづらいし、挙動を確認したあとに次の処理を足す、とやっていきたい時に、いちいち前の行に行かないといけないのも煩わしく、いまいちだなぁ、と思っていました。
そこでF# のオフサイドルールの例外扱いをヒントに、「次の行頭のトークンがパイプライン演算子の時は式は終わっていないとみなす」という例外を実装する事にしました。 これは以前、F#のオフサイドルールと似たシンタックスルールのパーサーを実装した事があり、 あまり変なケースが起きない事も経験的に知っていたため、採用してみました。
ただし、以下のケースは2つの文と解釈されて欲しいので、
let a = 3
-5
全ての二項演算子を継続行とはみなしたくない、と思っていました。
単項演算子としての用途が無い二項演算子は全て例外扱いしても理論的には問題はありませんが、
-だけが違う、というのはルールとしてわかりにくく、意図せぬ挙動になる事も多いと思ったため、
算術演算子は全て同じルールにしたいな、とは思っていました。
そこでとりあえずパイプライン演算子のみを特別扱いする事にしました。 メソッドチェーンも特別扱いしたい所ですが、 MFGでは今の所メソッドチェーンとして使いたいメソッドが存在しないため、 必要になるまではやらない、という方針に従ってパイプライン演算子だけ特別扱いしました。
その結果、v1.0.06以降では以下のように書く事ができます。
input_u8(x, y)
|> to_lbgra(...)
使ってみての感想
実装してしばらく使ってみましたが、かなりいい感じの仕様だと思っています。 パイプライン演算子は感覚的にも他の算術演算子とは大きく違うと感じられるので、 パイプライン演算子だけ例外扱いというのは感覚的にも自然に感じられて、 混乱も特に無かったです。
また、実際に表示させて結果を見てからパイプラインを追加していく、 という時に、次の行で直接続きを開始出来ると、思考の順番と編集の順番が一致して、とても快適になりました。 前の行の行末に戻る、というのは、考えている流れと違う事なので、 いちいち気が散っていた事が、この修正を行ってからより実感出来ました。
また、やはり次の行に書ける方がコメントアウトがまとめて出来るので、 実際に表示させて確認して、コメントアウトしたり戻したり、とやりたいCGプログラミングという文脈では重要だったんだな、 とも思います。
まとめ
- 改行を式の区切りとするかは言語ごとの特徴が出るよ
- MFGは改行を式の区切りとするよ
- でも次の行頭の最初のトークンがパイプ演算子の時だけ区切りじゃなくて空白とするよ(v1.0.06から)