Blog 2023‐08‐06 - qnighy/umo GitHub Wiki
前回からだいぶ間が空いてしまった。
ティアキンのストーリー進行も落ち着いてきたので、そろそろ開発を再開したい。
当初のロードマップでは早めにセルフホストするつもりでいたが、これは撤回する。理由は以下の通り:
- セルフホストを優先すると大量のyak shavingが待っている可能性が高い。Umoはパッケージマネージャも含めた大きなエコシステムに対する構想の実験場としてスタートしているが、セルフホストしてしまうとパッケージマネージャを含めた様々なエコシステムのPoCのためにあらゆるインフラの整備をしなければいけなくなってしまう。それよりもとりあえずRustで書いたほうが効率よく実験できる。
- 2種類の実装を並行させる合理的な戦略がありそう。
- Rustで書かれたインタプリタ。これはインタプリタを自己参照させられないことから必要だが、高機能である必要はない。
- Umo自身で書かれた静的解析器。インタプリタ機能を持っている必要はない。
- Umoでは、コンパイラが統一的で強力な静的解析APIを提供することを前提としたエコシステムを構想している。そのための言語設計の検証はUmo自身で書かれた静的解析器があれば十分そう。
- LSPもここに入る
これらの前提を踏まえた上で、以下のような順番で機能を実現していきたい。
- シェルスクリプト代替として使える最低限の機能を作る
- UmoはRustの思想を受け継ぐ言語でRubyの利用用途を置き換えることを目指している。RubyはRailsより前にまずスクリプト言語として始まっているので、まずはこの歴史をなぞるのが妥当そうだという発想。
- とりあえず動くものがないとやっぱり書いていて辛いので……
- ユニットテストの仕組みを最低限整える
- Algebraic Effectなどのeffect systemの構想に乗り、アプリケーションコードの全てをsmallなユニットテストでテストできるという言語を目指したい。
- そのPoCを早めに行いたい。
- モジュールシステムとパッケージシステムのPoC
- 以降は1-3の状況次第だが、汎用言語として十分な機能性を持つこととセルフホスコンパイラを持つことを目指す
また実装レベルでは、いきなりセルフホストする必要がなくなったのでここまでの実装は一旦破棄した。先に命令セットのレベルでfeature-completeにした結果身動きがとれなくなっていたので、一旦Turing-incompleteなところからやり直している。
パーサーの実装をサボって生存解析から書き始めた話は周りにちょっとウケていた。
やり直すだけでは進まないことは承知しつつも、現段階ではまだ手戻りも大きくないし実装経験にはなっていると思うので、意味はあるはず。
ECMAScriptのtagged templateの設計はかなりイケていると思っている。たとえばJSXがなくても、以下のようにして安全なDOM組み立てをする余地はある:
const Greeting = (props) => {
const { name } = props;
return dom`
<div class="foo">
Hello, ${name}!
</div>
`;
};
上の仮想的なコードで dom
tagはtemplate部分を初回だけ解析しておいて、あとはパース済みのDOM ASTに置換を当てはめるだけという実装をすることができる。あるいはBabelなどのトランスパイラフレームワークのプラグインを書いてコンパイル時に変換してやってもよい。
実際にそうなっていないのは、まあ若干構文的にうるさい点もあるし、TypeScriptがそのように進化しなかった (これは鶏卵問題的な側面もあるが) という点もある。
何にせよ、tagged templateはeDSLの新しい形を提案していると思っていて、うまく組み入れたい。そこでここでは理想的な文字列の構文とセマンティックスについてちょっと考えてみた。
実はJavaScriptでは、`
さえあれば事足りる。既存のコードに慣れている人から見れば見た目が派手派手しくてたまらないだろうから、実際にそうする人はあまりいないだろうけれど。
というわけで、ここでは1つの記号 (ダブルクオート) だけを使うことを考えたい。他の言語でよく見る文字列リテラルの亜種は、全部tagを入れ替えることで事足りる。
// 文字列
"Hello, world!"
// 文字列置換
"Hello, ${name}!"
// 文字
char"H"
char"\x61"
// バイト
byte"6"
byte"\xA0"
// 正規表現
re"(?:foo|bar)*"
// raw文字列
raw"\(^o^)/"
// Rustのformat_args!(...) 相当
puts(format"Hello, ${name}!")
ポイントは、構文解析の第一段階では必ずしも全てのエスケープを解釈する必要はないという点である。文字列の終端と置換の開始だけ正しく検出できればよく、それには以下のルールがあれば十分である。
-
\
の偶数個の並びのあとに"
が来たら、そこは文字列の終端。 -
\
の偶数個の並びのあとに${
が来たら、そこは置換の開始。
残りはtagごとの解釈に任せればいいので、たとえば char".."
と byte".."
では異なる構文ということもできる。またrawもこの仕組みでそのまま実現できる。
もちろん、raw文字列は全ての文字列を表現できるわけではない。 \
で終わる文字列は表現できないし、 ${
を中に含む文字列はそのままでは表現できない。そもそもrawの一番よくある用途は正規表現を含めた他のsyntaxにテキストを流し込むというものなので、それはそれぞれの構文に適したtagを作るのが正しい、と言える。
フォーマッティングではprintf型のインターフェース (専用のフォーマットテンプレートに、引数を流し込む) が使われることもある。色々な言語にあるsprintf関数やRustの print!
はこの形である。これには以下の利点がある:
- 桁数や形式などの指定が簡潔に行える。
- テンプレート自体の差し替えができる(できるやつもいる)。
前者に関しては別に "Amount: ${decimal(d, fill: " ", min: 3)}"
とかで良さそうだし (decimal
は文字列を返す必要はなくformat thunkを返せばよい、念の為)、後者のユースケースはほぼi18nに絞られると思うので、ICUとかのまともなやつを使えという話になる。
そういうわけで、Umoではprintf型のインターフェースではなく、置換型のインターフェースだけでうまくやっていくほうがいいのではないかと思っている。
tagの評価タイミングはいくつか考えられる。
- 名前解決の後くらい (一種のマクロとして処理)
- 型推論の後くらい (型レベルプログラミングに近いものとして処理)
- 実行時
個人的には型推論の後くらいになるような言語設計がいいんじゃないかと思っている。これは以下の2つの理由からである:
- tagが真価を発揮するのはDSLを組み立てた時であり、DSLでは異なる型を異なる目的で受け入れたいことがある。
- しかし、あまり早すぎると今度は型情報をパース時に利用できない。これは構文的な曖昧性につながる可能性がある。
後者については少し説明を補足したい。たとえば以下のようにJavaScriptのASTを組み立てるtagged literalを考える:
some_js_code = js"function f() { ${part} + 1 }";
このtagged literalの意味は、 part
が文断片であるか式断片であるかによって異なったものになる。 part
の型が先に決まっていれば、この問題は解消できる。
複数行リテラルの構文には大きく以下のような流儀が考えられる:
- 通常の文字列リテラルがそのまま使える。
- クオートを3つ並べることで複数行が可能になる。
- ヒアドキュメント (
<<END
)
ここでは2. のトリプルクオート方式を考えたい。Pythonで (実際には主にコメントとして) 使われているので、見覚えがある人も多いかもしれない。
const x = """
# Welcome to my homepage!
- One
- Two
- Three
""";
トリプルクオート方式の利点には以下のようなものがある。
- クオート記号を再利用するので、意味を取りやすい。
- クオート記号を再利用するので、他の記号空間を節約することができる。
- 1文字クオートは1行のみという制約があることにより、編集途中のソースコードに対する構文解析の復帰がより正確になる。
- 1文字クオートで複数行を可能にしてしまうと、編集途中でunmatched quoteがあったときに、クオート部と地の文が数十行にわたって反転してしまうようなことも起こりえる。
トリプルクオート方式は """"""
のようにクオートがたくさん並んだものを "" "" ""
のように通常の1文字クオートの並びとして解釈する必要がないことを根拠としている。この想定はUmoでも成り立つことが期待される。
Rustでは r####"..."####
のように #
を重ねることで、クオート内部の似たような構文との衝突を避けることができる。
トリプルクオート方式の場合も、ダブルクオートの個数を3つに制限する必然性はないため、同じようなことは実現できる。
text = """"
text = """
foo
""";
"""";
ただし、Rustのraw literalと違い、置換の開始 ${
は同じ方法で衝突回避できない。これは諦める。
複数行リテラルでは、だいたい改行とインデントで困ることが多い。もう眠くなってきたので適当に済ませてしまうが、複数行リテラルの趣旨を踏まえれば、
- 開きクオート
"""
の直後は改行強制で、最初の改行は除去 - 最初の行からインデントを推論して、以降の行からインデントを除去
としても問題なく、メリットのほうが大きそう。そのように実装したい。
また閉じクオート """
についても行頭であることを強制してもよい気がする。すると開きクオートと閉じクオートの区別もつけやすくなる。その場合末尾改行が強制されてしまうが、それが必要なら直前の行末でline-continuation (行末に \
を書くことで続く改行を除去する仕組み) を行うべしとしればよさそう。