🤔

Rustで検討されているKeyword Genericsについての現状

2023/03/01に公開

TL; DR

  • Keyword Generics という新しい言語機能が検討されている。
  • ある関数などがあったとして、それが async かどうかを自動判別できるようキーワードを新しく追加したいというのが大まかな目的。できればいくつかのキーワードを横断して判定できるような包括的なキーワードも追加したいかも。
  • これから RFC を書こうとしている段階で、まだ RFC にはなっていない。
  • ただこれ、うーん、どうなんでしょうね。
    • マクロでいいような気がする。
    • プログラミング言語としての哲学が問われている感じがする。

経緯

先日次のようなアナウンスが「Inside Rust」から行われました。

https://blog.rust-lang.org/inside-rust/2023/02/23/keyword-generics-progress-report-feb-2023.html

これは昨年の7月末にアナウンスされた「The Keyword Generics Initiative」チームによる続きの報告です。当時のアナウンスは下記です。

https://blog.rust-lang.org/inside-rust/2022/07/27/keyword-generics.html

先日の2月の発表は、この7月の発表の進捗報告に当たります。新しいキーワードが導入される可能性がありもし導入されるとなると影響が大きく、意見がある方はぜひ意見をしておくべき話題だと思いました。日本語圏での解説はまだなさそうでしたので、自分の思考の整理のついでに記事としてまとめておきたいと思います。

Rust 本体の Zulip の #t-lang/keyword-generics にて提案者らに意見を伝えることができます。

動機と目的

後ほど詳しい解説を加えますが、「Keyword Generics」はキーワードにジェネリクスのような一般性をもたせようという議論のようです。この抽象性は次の2つの観点から必要であると提案者らは主張しています。

  1. 現状は同期処理と非同期処理とを切り替える際には、同期用あるいは非同期用の feature を明示的にスイッチオンする必要がある。これに伴い、内部実装では同期処理用、非同期処理用でそれぞれ API と実装を用意する必要がある。
  2. map などのアダプタを定義する際、同期処理用のmap、非同期処理用のmap、…というようにそれぞれ用途別に定義する必要がある。ところが、扱いたい「副作用」が増えると、これらの組合せ爆発が起こる。

1については私も経験があります。以前 connpass 向けの SDK を Rust で実装した際に、非同期処理向けと同期処理向けの API を提供したくなりました。その際ほとんど同じ内容の構造体を同期向けと非同期向けの2つ提供する必要がありました。具体的には、下記のコードのようにです。コピー&ペーストして、必要な関数の async を切り替えたただけで、中身はほとんど同じです。

https://github.com/yuk1ty/connpass-rs/blob/dce6d73d5d7d387bcad028d6091877348bb0c2b3/src/client.rs

このように同期処理・非同期処理ごとに feature として機能を別々で提供するクレートは多いです。そのようなクレートの中で利用されるのは「block_on」のようなブロッキング用の特別な関数を呼び出しているだけ…ちょっとレイヤーが増えて手間が増えているという事情があります。

2はアナウンスの中の例をそのまま流用して説明します。たとえばイテレータ等で利用できる map という関数の拡張を考える際に、「同期処理用のmap」「非同期処理用のmap」とが考えられるでしょう。ひとまず「非同期処理用のmap」を async_map と名付けたとします。

この2つの軸に対してさらに「その処理がエラーを返すかどうか(処理が失敗するかどうか)」の観点を追加して、map 関数を拡張したいとなった場合について考えてみましょう。「同期処理かつ処理が失敗しない = map」「同期処理かつ処理が失敗する = try_map」「非同期処理かつ処理が失敗しない = async_map」「非同期処理かつ処理が失敗する = try_async_map」の4つの枝分かれが生まれることになります。さらに分岐を追加したくなった場合…を考えていくと、組み合わせが爆発しそうなことがわかると思います。[1]Keyword Generics によってこの問題も解消される、と提案者は考えているようです。

たとえば、同期処理か非同期処理かの切り替えについては次のように実装されるはずです。なお補足ですが、以降はすべて擬似 Rust コードなので現在のコンパイラではコンパイルが通りません。

// 同期処理のケース
fn call_sync_function() -> Vec<AnotherType> {
    let example = vec![1, 2, 3];
    example.into_iter().map(|elem| func(elem)).collect()
}

// 非同期処理のケース
async fn call_async_function() -> Vec<AnotherType> {
    let example = vec![1, 2, 3];
    example.into_iter().async_map(|elem| func_async(elem).await).collect()
}

fn func(elem: SomeType) -> AnotherType { /* 省略 */ }
async func_async(elem: SomeType) -> AnotherType { /* 省略 */ }

この手の切り替え処理は、すでにマクロ実装したクレートが存在しているようです。maybe-async というクレートで、このクレートを利用すれば現状の Rust でも重複実装を幾分抑制することができるようです。

https://docs.rs/maybe-async/latest/maybe_async/index.html

解決手法

たとえばですが、「同期処理か非同期処理か」をスイッチするために ?async というキーワードを導入します。あとは前後の処理文脈から自動的に「同期・非同期」を判断させられるため、map という一つのシグネチャのみで済むようになります。

// Rust 標準ライブラリの map 関数の実装より、全体の把握のために必要な部分を抜粋しています。
pub struct Map<I, F> { /* 省略 */ }

pub trait Iterator {
    type Item;
    
//...省略...
    
    ?async fn map<B, F>(self, f: F) -> Map<Self, F>
    where
        Self: Sized,
        F: FnMut(Self::Item) -> B,
    {
        Map::new(self, f)
    }
}

これを使う際には、次のようにして呼び出すだけでよくなります。?async キーワードのおかげで同期処理・非同期処理を問わず、同じ map を呼び出しするだけで済むようになりました。

// 同期処理のケース
fn call_sync_function() -> Vec<AnotherType> {
    let example = vec![1, 2, 3];
    example.into_iter().map(|elem| func(elem)).collect()
}

// 非同期処理のケース
async fn call_async_function() -> Vec<AnotherType> {
    let example = vec![1, 2, 3];
    // この場合は非同期処理向けの map の呼び出しに自動で切り替える
    example.into_iter().map(|elem| async_func(elem).await).collect()
}

fn func(elem: SomeType) -> AnotherType { /* 省略 */ }
async func_async(elem: SomeType) -> AnotherType { /* 省略 */ }

Rust 公式のアナウンスでは、ここにさらに定数文脈かどうかを判定する ?const を増やす提案をしています。

少し思考実験をしてみましょう。今度は非同期処理かどうか、処理が失敗するかしないかも考慮した関数を試しに定義してみましょう。処理が失敗するかしないかの判定には ?fallible というキーワードを導入したものとします。?fallible は裏で try ブロックないしは Result 型に包むかそうでないかを判定するものとします。そして、これを使って適当なトレイトを定義してみます。

trait ?async ?fallible Read {
    ?async ?fallible fn read(&mut self, buf: &mut [u8]) -> usize;
}

?async ?fallible fn read_file_content(reader: &mut ?effect Read) -> usize {
    let mut file_content: &[u8] = { /* 省略 */ };
    // これで、async であり fallible であり、という意味を示すものとする
    reader.read(file_content).await?
}

なんだかキーワードが増えてきてしまいました。ここに仮に定数文脈の判定も加わったとすると、?async ?fallible ?const のような長い長いキーワードが追加されてしまいます。これは少し見た目がよくなくなりますよね。

できればさらに抽象化をして、?async?fallible をまたいで自動判別してくれるものが欲しいですね。これらは一般に「副作用」という概念にグルーピングできることから、どうやら提案者は ?effect にまとめてみてはどうかと考えているようです。上記の実装は下記のようにまとまります(妄想です)。

trait ?effect Read {
    ?effect fn read(&mut self, buf: &mut [u8]) -> usize;
}

// 使う際には .do キーワードをつけておく。
?effect fn read_file_content(reader: &mut ?effect Read) -> usize {
    let mut file_content: &[u8] = { /* 省略 */ };
    reader.read(file_content).do;
}

// たとえば、非同期の文脈で呼ぶ場合にはこうなる??
async fn read_file_content() {
    let mut reader = { /* Read トレイトを使った何かを生成する */ };
    // .await をつければ、async 文脈であると判断する
    read_file_content(&mut reader).await;
    // ...処理が続くかも
}

// try ブロックの文脈で呼ぶ場合こうなる??
fn try_read_file_content() {
    let mut reader = { /* Read トレイトを使った何かを生成する */ };
    // try ブロックの中で呼び出すと fallible 文脈であると判断する
    let result: Result<usize> = try {
        read_file_content(&mut reader)?
    };
    // ...処理が続くかも
}

現状のステータス

どうやら RFC 化を目指していてもうすぐ提案できそうだという段階で、まだ RFC にはなっていないようです。今後の進捗は注視しておきたいところです。

まずは ?async を nightly に導入するところからはじめていくつもりのようです。その後 ?effect の導入の是非について考えることができるのでは、と提案者は企図しているようです。

個人的に思うところ

一見すると便利そうな機能に見えますが、どうなんでしょうか。私は過度な抽象化をしていないかは気になっています。本質的に異なる処理を無理くり同じキーワードで押し込めようとしていないかという点と、複数の処理をまとめすぎるとシグネチャを見ただけでは実質何の処理を追加でしているのかわからなくなりそうだという懸念があります。

たとえば同期処理と非同期処理は概念上のグルーピングとしては一見同じもののように見えるのですが、とくにランタイム上での処理の観点から見た場合、内実やっていることは全く異なります。定数文脈の処理は非定数文脈 (non-const) な処理をより厳密化した関係 (strict-subset) にある、という関係とは大きく異なります。これらを同じキーワードに押し込めて、安直にスイッチングしてもよいものなのか?というのは疑問として残ります。

複数の副作用の切り替えを自動で行って欲しい関数などを ?effect のようなキーワードで定義できるのは一見すると便利そうには見えます。しかし、抽象化するとその分具象実装を1から追って考えないと、その関数が本当にどういう副作用をもっているのかを把握するのが難しくなってしまう状況を生み出してしまいそうです。

Reddit では、Rust を始めた Graydon Hoare 氏が強い懸念を示しています。氏の主張は、先ほども説明したように同期処理と非同期処理という本質的に処理構造が異なるものを無理にまとめている点と、そもそも Rust は難しい複雑な言語であるという前評判を受けているのに、キーワードの導入によってさらに複雑性と認知負荷を増すことになるのではないかというものです。

https://www.reddit.com/r/rust/comments/119y8ex/comment/j9pt1h3/?utm_source=reddit&context=3

ただ、提案者らはこうした批判はおそらく想定済みで、Keyword Generics に関する最初のアナウンスで次のように弁明をしているようです

まず、そもそもこれはソフトウェア一般を作る際に利用することを意図しているわけではなく、ライブラリの作者向けの機能であると言っています。ライブラリの作成者が先ほど紹介したような同期処理・非同期間でコードが重複するのを避けられるような、いわばハッチを提供したいのだと思われます。

提案者らの想定では、おそらくですが、そこまで一般の Rust を使って何かしらのアプリケーションを作ることを目的とした「利用者側」がこの機能自体を多く使うことはないだろうと思っているのでしょう。利用者が得られる便益としては、依然としてたとえばあるクレートの「同期処理」か「非同期処理」かを選択できるという点のみです。

ここからは提案者らの主張からの私の推論ですが、したがって一般ユーザーがこの機能を覚えておく必要はないのだから、Rust の構文や文法の複雑性をいたずらに上げることはないはずだ、と思っている気がします。

同期処理と非同期処理に関してもはやあまり特別な区別をつけていない言語が増えていることも指摘しています。具体的には Go の非同期プログラミング周りの設計が例として挙げられています。たしかに Go には関数の頭につけるような async はありません。これは非同期プログラミングによる実装時の複雑さをたしかに低減できているようには見えます。[2]

提案者らはこの機能について、「Rust というプログラミング言語の複雑性」を低減することを目的としているのではなく、「Rust によるプログラミングの複雑性」を低減することを目的としているのだといいます。具体的には、やはり先ほどから紹介している同期処理・非同期処理間でコードの重複がなくなることにより、ライブラリ作成者たちにとっての開発者体験が向上するという狙いがあるようです。

この辺りは Rust の基礎文法でどこまでを保証し、定義づけするかの議論が必要そうには思います。個別のケースに飛びついて、こうした目的や定義付けを見失っていないかが心配になっています。正直 Rust 本体のチャットなどは追っていないのでどういう議論がなされているかは把握していません。

ただ、この機能はライブラリ作成者としての私の立場に立ったとしても、やはりプラスアルファの機能な気がしています。ソフトウェア実装に際する抽象化においては必ずしも求められるものではないかなと思っています。こうしたプラスアルファなものについては、マクロを通じて言語拡張を個々人がして追加するでよいと思っており、たとえばすでに行われているようなマクロによるクレートの提供で十分なのではないかと思っています。

また Rust 入門者の目線に立ったとき、あまり情報や経験がない中で、どのキーワードが重要でどのキーワードが重要でないかの判別はおそらく難しい気がしています。全部が重要に見えてしまうのではないでしょうか?私の少ない経験からキーワードが多い言語として思い浮かぶのは Swift でしたが、Swift に入門した際にも似たようなことを思いました。そもそも予約語として用意され、エディタでキーワードとして色付けされてしまっている時点で、その予約語がライブラリ作者向けであると判別するのは難しい気がします。こうした観点から、そもそもの言語としての哲学を検討せずにキーワードを追加し続けるのはどうなのかな…と思ってはいます。

その他

免責事項

最後に言い訳になりますが、まだ RFC 前ということで体系的な情報が少ないかったです。したがって、とくに説明のための簡単な実装例を考えるにあたり、いまいち的確なサンプルになっている気がしていません。サンプルのコードについては厳密な議論をするためのものではなく、雰囲気を掴む程度のものと思っていただければ幸いです。

脚注
  1. Rust の標準ライブラリを見ても実際に、たとえば fold に対する try_foldfrom に対する try_from のような状況にはなっていますね。 ↩︎

  2. が、筆者はあまり Go には詳しくないので本当のところはよくわかりません。数少ない Go の経験からいくと、たしかに HTTP サーバーを実装している際には、ほとんど goroutine がどう動くかについて意識することはなかった気はします。一方で、完全になくなっているというわけではなく、提案者らが go キーワードや context.Context を関数の引数を通じて伝播させていく手法などをどう考えているかは詳しく知りたいものです)。 ↩︎

Discussion