ちょっと先のプログラミング言語を予想する

先日κeen@blackenedgoldさんのこちらのブログを読んで、 自分もプログラミング言語について考えていることを書いてみたいと思ったので久しぶりに更新します。

https://keens.github.io/blog/2021/01/04/future_of_proguramming_languages/

半年ほど前からプログラミング言語を作ってみたいと思っていて、 言語デザインを考える過程で色々な言語を触っているので、それを元にプログラミング言語のごく近い未来を予測してみたいと思います。

基本的には元記事の内容に概ね賛成です。 あまり型レベルプログラミングを突き詰めてしまうと抽象度が増して初心者にとっての参入障壁になってしまわないか心配ですが それもうまく隠蔽されながら進化していくんだと思います。

次にこんな言語がくるぞ!というよりもこうあってほしいという願望に近いので、そのつもりで読んでもらえるとありがたいです。 また自分がWebプログラマなので関心領域が偏っていることにも注意してください。

プログラミング言語を取り巻く環境

プログラミング言語はそれが動く環境に強く影響を受けるので、環境の変化によって最適な言語デザインも変わってくると思います。 近年の特に影響の大きそうな変化には以下のようなものがあります。

クラウドサービスによるインフラ層の抽象化によって、サーバーのプログラムは必要リソースに合わせて柔軟に起動・停止を行う必要が出てきました。 特にAWSLambdaやGCPのCloudFunctionのようにリクエストごとに1インスタンスを立ち上げるようなサービスでは、 プログラムの起動時間が無視できないオーバーヘッドになります。

そのため、こういった環境で動くプログラムはJavaのようなVM上で動く言語よりもGoやRustのように直接実行可能なバイナリを吐き出す言語の方が有利になりやすいです。

IoTやWASMのような限られたリソースで動く環境では、効率的に動作する小さなバイナリを成果物とする言語の方が好まれます。 Dockerなどで仮想化する場合もあまりモリモリと環境にものをインストールする必要があるものは避けられがちですね。 RustやC++がこういったユースケースで好まれているのは、速度以上に必要リソースの少なさがあると思います。

とはいえ環境構築の容易さや実行の手軽さなどからスクリプト言語にもまだまだ需要がありそうです。

メモリ管理

メモリ管理は言語の書き味にも相当影響するので、どういう選択をするかによって言語の個性が大きく左右されます。 ざっくり分けると、プログラマが責任を持つもの、コンパイラが自動で行うもの、ランタイムで行うものに分けられます。

それ以外にも、例えばzigという言語はそもそもデフォルトのアロケーターがなく、ヒープメモリを利用するかどうかすらプログラマが選べるらしいですが、 全てのプログラムをヒープ領域を使わずに書くのは現実的ではないですね。(zigもCライクなメモリ管理をオプションで使うことができます)

プログラマが責任を持つものは、Cのようにコードに直接メモリの確保と開放を行うコードを書くものです。効率はいいですが熟練のプログラマでも 全てのリソースを正しく開放することは難しく、簡単にメモリリークしてしまいます。

コンパイラが自動で行うものは、例えばRustの所有権システムやC++のスマートポインタなどです。 メモリ確保と開放のコードをコンパイラが自動で挿入してくれるため、メモリリークの恐れもなくパフォーマンスもいいですが、 コンパイラがメモリの寿命を判断するために構文上の制約が多くなります。

しかしLobsterやvlangに採用されているメモリ管理ではこのような制限があまりなくほとんどのメモリを 自動的に開放するコードを埋め込める(解放できない分はランタイムで行う)らしいので、将来的にもっと簡単にかけて安全な言語が出てくるかもしれません。

ランタイムで行うものの代表例はガベージコレクション(GC)です。プログラマがメモリを意識しなくていいので楽ですが、 プログラムの速度か使えるメモリのサイズにコストがかかります。また、GC自体をプログラムに組み込む必要があるため バイナリのサイズが大きくなってしまうデメリットがあります。

GC以外にもSwiftなどが採用している自動参照カウント(ARC)もありますが、 これもパフォーマンスに影響したり循環参照を解決できなかったりと銀の弾丸とは呼べません。

どの方式も一長一短で何に使うかによって最適なものは変わってくると思いますが、少なくとも今後はGC全盛の時代からコンパイル時に メモリ管理をする方式に移っていきそうだなと思っています。メモリ管理以外にも言えることですが現在ランタイムに払っているコストをできるだけコンパイルタイムに 寄せるようになっていくんじゃないでしょうか。

エラー処理

ここも言語によって個性が出やすいところです。例外を送出して大域脱出するもの、エラーを値として返すもの、Resultを返すものなどさまざまです。

例外による大域脱出は個人的にベストな選択肢とは思えません。 そもそもある事象が正常系であるのか異常系であるのかの境界は曖昧で、ある程度の規約の中で恣意的に決められるものだと思います。 それに対していわゆるtry-catchは例外を特別に扱いすぎていて取り回しがしづらいと感じることが多々あります。

かといってエラーを値として扱う方式がいいかというと今度はエラーを上流に渡して呼び出し元で処理したい時に無駄なコードが増えてしまいます。 Goのエラーハンドリングなんかだと顕著ですが、モナドを使ったやり方でも同様ですね。

それとκeenさんの記事でも述べられていますが、エラーした地点から再開できないのも考えものです。 例外を送出した関数はそこで処理を止めて終わってしまうので、上流にエラーが伝播してきた時にはすでに再開できない状態になっています。

エラーが起こった時の状況、例えばどこまで処理が進んだか、どのデータまでDBに書き込んだかなどは処理を行なっている関数が一番よく知っているはずなのに、 それらの情報を全て捨てて例外だけ返していなくなってしまうのはどうなんだという話です。報連相ができない部下みたいな感じですね。

それを解決するために、関数内部の処理をアトミックにしたりリトライアブルなエラーとそうでないものを分けて呼び出し元で場合分けしたりするのですが、 そもそも例外を送出するタイミングからそのまま関数を再開できる仕組みがあればいいわけです。

ここまで書くとじゃあどうすればいいんだという話ですが、まだ自分の中でも正解に近そうなものが見つかっていません。

考えたものの中で条件を満たしていそうなものを挙げると、 例えば呼び出し元から呼び出し先の関数にコンテキストのようなものを渡して、そこにエラーを登録させる方法があります。 既存の例外を投げたりエラーを返したりするものがボトムアップのエラー処理だとすると、 逆にトップダウンで例外を処理するためのホットラインを垂らしていこうというものです。

実際に処理を行う関数はエラーをその場で受け取ったコンテキストを使って上流に流し、 関数を再開するかその場で終了するかは呼び出し側が決めれば再開も可能だし、 受け取ったエラーをさらに上流に流すのもコンテキストからエラーを取り出さなければいいだけなので楽ちんじゃんという発想なんですが、どうでしょうか。 自分一人だと精査しきれないので意見がある方は教えてもらえると助かります。

非同期処理

非同期処理も重要です。大きく分けて以下の二つがあります。

これからの言語にはどちらも必須の機能だと思いますが、さらに言えばこれらの機能の違いを気にせずに済むようになればいいなと思います。 共通のインターフェースで簡単に非同期処理を開始できて、CPUに負荷の高い処理とアイドリングが長い処理をうまくスレッドに振り分けながら 並列処理と並行処理を同時にやってくれるような仕組みが理想ですね。

GoのgoroutineやRustのasync/await構文はこの辺をうまくやってくれるようです。Rustは非同期のランタイムによります。

Kotlinのコルーチンはプログラマが明示的にどんなスレッドを使いたいか選ぶ必要があります。 これはこれで便利なこともあるのかもしれませんが、理想を言えば自動でやってもらいたいです。

Javascriptのイベントループを使った並行処理はC10K問題への対応策として画期的でしたが、 並列処理をうまく扱えないのでマルチスレッドを扱える環境だともったいないです。

文法に関して

最近は急にいろんな言語にasync/await構文が実装されました。Kotlin、Rustなどはすでに実装ずみで、Swiftも次のバージョンから入るらしいです。

async/awaitを採用していない言語でも、GitHubのissueを見にいくと大体プロポーザルが出ています。みんな大好きですね。僕も好きです。

非同期処理のコルーチンと通常のサブルーチンをコンテキストの切り替えなく書くことができるのがとても便利だと思います。

他には限定継続を言語としてサポートすることでも同様の機能を実装することができそうです。 限定継続を使った方が応用が効きそうなので、適切なラッパーを被せてasync/await相当の単純な非同期処理が簡単にできるのであればこちらの方が有利かもしれないです。

動的型付け言語と静的型付け言語どちらがいいかという議論をよく目にしますが、 個人的には静的型付け言語の方がコンパイル時に解析できることが多くて好きです。 動的型付け言語の方がシンプルで書きやすいという意見もありますが、 型推論の導入によって静的型付けでもさほど面倒ではなくなったと思います。

型を導入することで得られるメリットとして、メモリ利用の最適化、静的解析の強化、開発効率の向上の三つがあり それぞれ別の話として考えた方がわかりやすいです。

メモリ利用の最適化は、型情報から確保すべきメモリがわかるためにリソースを上手に使えるようになることです。

静的解析の強化によって型のルール外の利用を事前に検査することで、ランタイムでエラーが起こる可能性を減らすことができます。 また静的解析によって大規模なチーム開発でもコードが破綻しづらく、テキストエディタなどの支援も受けやすくなります。

最近では動的型付け言語に部分的に型を導入するといった事例も出てきていますが、これは特に後者2つのメリットに重きを置いています。

Typescriptなどはランタイムに型を持ち込まない方針なので、後者2つのうちでも特に開発効率の向上にフォーカスしていますね。

さてそうなると、解析とエディタの支援強化をしたいなら別に型の導入にこだわる必要はないじゃん!という意見も出てきます。 実際にそういう方針で作られているのが、RubyのTypeprofやClojureのspecです。

https://github.com/ruby/typeprof

https://clojure.org/guides/spec

確かに型システムは静的解析に関してもっともポピュラーで安全性の高い手法であるとは思いますが、 こういった新しい取り組みによって同程度の開発体験が得られるのであれば画期的ですね。

エディタもどんどん賢くなっているので、テストを書くだけで関数の引数の型の境界を割り出してくれたり、 ドキュメントを書くようにコンパイラに対して静的検査の指示ができたりすると便利そうです。

とはいえ自分が作る言語には初めから静的な型を導入すると思います。 型推論も必須だと思いますが、高度な型システムをどこまで採用するかが難しいところです。 静的解析できる範囲が広くなることは素晴らしいことですが、抽象度が上がりすぎると型への習熟度に関係なくコードが読みづらくなってしまいます。

メタプログラミング

必須ではないですが、あると便利な機能だと思います。

メタプログラミングの方法としてマクロとコード生成がありますが、 個人的にコード生成を使って言語の足りない機能を補うのはあまり好みではないのでしたくないです。

もちろんAPIやDBスキーマからの自動生成はこの限りではないですが、 任意のフォーマットの文字列を解析してコードを生成できるマクロの仕組みがあればそのようなケースでもマクロとして実装できそうです。

マクロを実装するならば理想はLispとして言語を作ることかと思います。 一方でNimはS式を使わずに同図像性を持ってたりRustのマクロは抽象構文木を扱えたりするなど、 S式以外のやり方も出てきていますが、やはりLispに一日の長がありますね。

関数型と手続き型

これもよく言われていることですが、個人的には手続き型が主流を占めながら関数型のエッセンスを取り入れていく流れがしばらくは続くと思います。

関数型言語がもたらした参照透過性や不変性はコードの見通しをよくし、保守性を高める素晴らしいアイデアだと思いますが、それだけをもって 一律に手続き型よりも優れているわけではありません。

手続き型のデメリットとしてあげられるのは主に副作用がコード上で追えなくなってしまうことですが、 例えば関数に型を使って内部で起こりうる副作用をメタ情報として表現できたりしないでしょうか。

既存の言語では関数の型を定義するときにInputとOutputを指定しますが、それに加えて横穴として空いている副作用も型情報に含めてしまえば 解析できる可能性はありそうです。KotlinのContract機能をもっと前進させたイメージです。

このアイデアは今思いついてそのまま書いているので大きな穴がある可能性もありますが、 関数型のアイデアで手続き型言語の安全性を高めていくのは悪くない手法だと思います。

シンタックス

僕はLispが好きなので、直接抽象構文木として扱えるS式は機械と人間をつなぐシンタックスとしてとてもいいと思いますが、 カッコの多い変わった文面やどこまでも自己増殖できる言語の姿は2021年現在に一般的ではないです。 (もちろん、過去に一般的だったとか未来にその可能性がないというわけではないです)

しかしそうでなくても、プログラミング言語の本質が抽象構文木である以上、そこから大きく外れた言語はあまり出てきづらいんじゃないかなと考えています。 また言語のシンタックスのパースのしやすさは、コンパイル時間やスクリプト言語の実行時間に少なからず影響します。

実際、コンパイル時間に重きを置いているgolangなどは、パーサが先読みしやすい(キーワードが早く出てきてコードの種類が早めに判断できる)文法になっていると思います。

またコンパイラがマルチコアを有効利用したり差分コンパイルが容易にできるように、モジュールシステムも変わってくるんじゃないでしょうか。 現在でも細かくモジュールを分けることでコンパイル時間を削減している言語は多いですが、僕の知る限りはそこにかかるプログラマの負担が結構大きいです。

今後は特別な考慮をしなくても、ファイルごと、もしくは行ごとに差分検知をしつつ分散コンパイルができるような言語が出てくるかもしれません(もうあったら教えてください)。

まとめ

ちょっと先のプログラミング言語を予想すると書いておきながら結局現状の知識を並べて妄想を書いただけになってしまいましたが、 まだ自分でも悩んでいるところがあって文章にすることでそれが整理できた部分もあったのでよかったです。

プログラミング言語の変遷は多くの改善の歴史ですが、現在もまだその歴史の真っ只中なので、 読んでくれた皆さんも自分の思うところについて話してもらえると楽しいのかなと思います。