Blog 2023‐08‐20 - qnighy/umo GitHub Wiki

近況

パーサーとloweringが(部分的に)実装できた。また、関数型を定義してクロージャの準備が途中までできている。

インタプリタのCLIは仮実装として特定のプログラムソースにのみ反応するようにしていたが、これで未知のプログラムも少しずつ実行できるようになる。

今後は以下のように進めていく予定:

  • パーサーを拡張してbranch, loop, 演算子などに対応する
  • capturingのない関数をパース・実行できるようにする
  • クロージャーのcapturingを実装
  • 原始的なモジュールシステムを実装
  • 原始的なユニットテストを実装

また、具象文法ができつつあるので、現時点での仕様もなるべくdocsとして整理していきたい。ただ限られたリソースをどちらに割り振るかは悩ましいところ。

式と文とブロックとifについて

手続き型言語では、プログラムの断片を評価すると「評価値」と「効果 (副作用)」の両方が発生しうる。形式的にはこの2つは排他的ではないとしたほうがプログラムを整理しやすい (e.g. Queue::pop, AtomicInteger::compare_and_swap) というのが現代的な了解である一方、プログラムを読むときはたいていどちらか一方に注目しながら読むことになる。そこで、多くの言語では以下の2つの断片を区別している:

  • 式 -- 評価値に重点を置いている
  • 文 -- 効果に重点を置いている

また構文上これらの区別がない言語(Ruby等)でも、式のように書くときと文のように書くときの慣習の違いを見出すことが可能な場合もある。

式と文の区別において、各言語はそれぞれに異なった困難を抱えている。ここではUmoの構文を考えるために、あらためて式と文の構文選択におけるトレードオフを整理したい。また、これらの検討にあたってはif式/if文の構文選択のトレードオフも関連してくるため、本稿で一緒に議論する。

ブロックの評価値

文はその評価によって発生する効果に重点を置くため、効果の発生順をあらわすために複数の文を並べて書けるようになっている。一連の文の並びは通常、ブロックと呼ばれる。ブロックもまた文の一種とみなすことが多い。

すでに述べた通り、プログラムの断片を評価するときはしばしばその「評価値」と「効果」の両方が必要になる。ほとんどの言語が文の中に式を書ける(式文)一方、一部の言語は式の中に文を書ける(文式/ブロック式)ようになっている。後者の文式/ブロック式を容認する形式では、手続き型パラダイムと宣言型パラダイムの混在がより行いやすくなり、流暢なプログラムを書ける点が好まれている (逆に、本来平易に書けるものを高度に書くことを許してしまうという批判的な見方もある)。

この、文式/ブロック式を容認する形式では、ブロックの評価値を定める必要がある。これには通常、ブロックの最後の文の評価値が採用される。いっぽう、作用のみを目的にしてブロックを記述する場合は評価値に興味がないこともある。このような場合、静的型付き言語では評価値がないことを明示して型を区別する必要性に迫られる。動的型付き言語ではそのような不便がないかわりに、ブロックの評価値が意図的であるのか否かが曖昧になってしまうという問題がある。Rubyはしばしばこの観点から批判される。

ブロックの評価値問題を解決しようとしている例のひとつがRustである。Rustではセミコロンの有無によって、ブロック中の文の役割を区別することができるようになっている。

{
  foo();
  bar(); // bar()は作用のみで、評価値は捨てられる
}

{
  foo();
  bar()  // bar()の評価値がブロックの評価値になる
}

この区別はリファクタリング耐性を高めるのにも役に立っている。ブロック中の文の順序はしばしばリファクタリングによって(あるいは意図的に作用の順序を変えるために)入れ替えられる。このコード変更では評価値を入れ替えることは意図されないことが多いが、Rubyのような言語ではその見落としが発生する可能性がある。Rustの場合、ブロック評価値が使われていれば、セミコロンのある文とセミコロンのない文を入れ替えることになり、その間違ったコード変更は構文エラーに帰結する。

// 上のソースを書き換えたとき……

// foo() と bar() の順序を逆にしている
{
  bar();
  foo();
}

// foo() と bar() の順序を逆にした結果、ブロックの評価値が変わりそうになるが、構文エラーによりこの間違いが事前に防がれている
{
  bar() // セミコロンがないためエラーになる
  foo();
}

これはなかなかよく出来た仕組みではあるが、それでもセミコロンを必須にするのは潜在的なリスクがあるような気がする。現にRuby, Python, Goなどは文をセミコロンで区切る必要はない。また、JavaScriptも仕組み上はそうなっていて、慣習上は両方の派閥がある。特にGoやJavaScriptなどがそうしているところを見ると、セミコロンを鬱陶しく思っていたり、セミコロンに何らかの偏見を持っている (たとえばセミコロンを持っている言語は堅苦しい言語だ、とか。Perlのような例もあるのだが) 人はそれなりにいるのかもしれない。

UmoはなるべくRubyのように気軽に書ける言語にしていきたいので、先行する言語がセミコロンを避けようとしているのであればなるべくその方法を検討してみたいところである。そこで、Umoでは以下のような書き方を検討している。

// 評価値なし
{
  foo()
  bar()
}

// bar()の評価値が使われる
{
  foo()
  then bar()
}

なお、これは return とは異なる。関数の末尾以外で利用したとき、 thenreturn と異なり関数からは脱出しない。

このへんはまだ迷っているので、やはりセミコロンを使うのがよいとなる可能性はある。本記事では then 方式を前提に話を進める。

リテラルとの曖昧性

多くの言語では、ブロックを文の一種と見なしており、ブロックを独立した形で出現させられるようになっている。ここではそれをC系の構文にならって { ... } で記載することを考える。

この { ... } には、多くの言語で別の役割が割り当てられている。それは複合リテラルである。Cの構造体初期化子やRubyのハッシュリテラル、JavaScriptのオブジェクトリテラルがそれに該当する。これはUmoでも必要だと考えているが、するとブロックとリテラルの曖昧性解消を行う必要が出てくる。たとえば、

  • Cの構造体初期化子は式ではないため式文位置には出現せず、この曖昧性が発生しない。
  • Rubyではブロックを ( ... ) または begin ... end で記載するため、この曖昧性が発生しない。
  • JavaScriptでは曖昧な場合(文開始位置、アロー関数本体開始位置)にブロックを優先することを明示している。
  • Rustでは匿名の構造体リテラルを持たず、必ず構造体名が先行する (Foo { foo: bar })。この構文では { は式の開始ではなく式の後続であるため、式文を開始することはない。

あまり詳細に検討できていないが、Umoでは匿名の構造体リテラルを導入したいと考えている。この前提を考えると、Rubyのような方法かJavaScriptのような方法で曖昧性を回避するのがまず考えられる。

Ruby方式の類似として、ブロックの場合の { ... } の手前に何か識別用のトークン (do) を前置させるということも考えられる。

do {
  // ...
}

これは制御構文以外の位置で独立して出てくるブロックに対してのみ適用されるのであればそれほど鬱陶しくはなく、また意図も理解しやすいのでこれ単独では悪くない選択肢かもしれない。ただ、他の制御構文でも同様の曖昧性が起きないのか、起きるとしたら同じように do を入れて鬱陶しくならないかというのを考える必要が出てくる。

この選択肢は以降で述べるトレードオフとも関わってくるので、今ここで結論を出すのが難しい……

ifについて

ifは文文脈でも式文脈でも必要になる。これについて各言語では異なる対応をとっている。

  • Rubyでは文と式の区別がないのでこの問題はないが、if型の構文と三項演算子型の構文を両方提供している。if型は文様様態と式様様態の両方に対して使われるが、三項演算子型は慣習的に式様様態に対してのみ使われる。
  • JavaScriptではif文が文を文でwrapし、条件演算子(三項演算子型の構文)が式を式でwrapしている。文を式にすることはできない (do-expressionsが提案はされている)
  • Rustではif式が必ずブロックを取るため、文を式でwrapする形になっている。三項演算子型の構文はない。

Rustの方式はさらに別の問題も解決している。if型の構文では通常以下の要素が必須となる:

  • if
  • 条件
  • 本文
  • else (elseがある場合)
  • elseの本文

これだけだと条件と本文を区切れないため、何らかの記号が必要になる。C系の構文では「条件」を強制的に丸括弧 () で囲むことで ) がその役割を担うようになっている。一方、RustやGoでは「本文」を強制的に波括弧 {} で囲むことで { がその役割を担うようになっている。こちらの方式ではdangling-elseが起きないほか、 goto fail; goto fail; 問題の回避にも繋がっていて、いくつかの問題を同時に解決できている。

なお、この方式では以下のような問題が派生的に発生するので、そこには注意が必要。

  • Rustでは構造体初期化の { が条件中に出てくると曖昧性が発生してしまうため、ifの条件文直下での構造体初期化の出現を禁止している。
  • Rust/Goではthenの本文に揃える形でelseの本文にも {} を強制しているが、この場合 else if のチェインがそのままだと書けない。そこで、elseの本文位置では別のif式/if文が来たときに限り {} を省略できるルールになっている。

さてこの方法はかなり合理的ではあるのだが、先ほどブロックの評価値のところで考えた then キーワードとの食い合わせが悪い。

// ブロックなので評価値を与えるためには `then` が必要だが、この書き方だと鬱陶しい
let x = if cond() {
  then foo()
} else {
  then bar()
}

then キーワードを維持するのであれば、この鬱陶しさは何とかして回避したい。たとえば、{ ... } のかわりに式を置いてもよいことにすれば簡潔に書けるが、その場合は条件部と本文の区切りをあらためて考えなおす必要がある。ここで先ほどの then キーワードを再利用することにすると、以下のような構文が考えられる。

let x = if cond() then
    foo()
  else
    bar()

これは一応OCamlで見られる構文に近く、そこまで悪くはなさそうである。ただ、 if cond { ... }if cond then ... が同じ言語に同居するのはちょっと不思議な気もするし、ちょっとムズムズする面もある。

というわけで、このあたりをどのように整理するかはまだ悩んでいる。とりあえずは上に書いた方法で進めてみるつもりだが、実際の書き味次第では見直すかも。