paild tech blog

法人カードのクラウド型発行・管理サービスpaild

Rust 2024 Editionがやってくる!

お手伝いの@helloyukiです。今日はRust 2024エディションの話についてまとめておきたいと思います。ちなみに注意点ですが、Rust 2024エディションはまだリリースされていません。したがって、この記事の情報(2024年4月ごろ執筆開始)はリリース時点で変更されている可能性があります。

エディションとは

Rustには「エディション」(あえて日本語にするなら「版」?)という概念があります。エディションは、後方互換性を保ちながらも、新しいキーワードの追加をはじめとする言語全体に影響を与えるような変更を加えるものです。3年に一度エディションは改定されることになっており、前回は2021年に改定がありました。これまでのエディションとしては、2015、2018、2021の3つが現状あります。

エディションの仕組みは2018年に導入されたものでした。2018年当時、Rustは非同期関連の言語機能の安定化を目標として作業が進められており、その一環として2018エディションが導入されることになりました。現在のエディションの仕組み事態も2018年に導入されたもので、Rust v1.0時点ではなかった枠組みでした。このことは、それくらいasync/.awaitの導入が大きな変更だったことを示唆しています。

2021エディションは2018エディションと比べるとかなり小ぶりな変更になっています。2021エディションで大きかったのは、TryFromなどの一部のトレイトがpreludeに入ったというものでした。これにより、他のOptionFromトレイトなどと同じように、明示的なインポートなしでトレイトを使用できるようになりました。

2024エディションについてもこれから説明しますが、やはり2018エディションと比較すると小ぶりな変更になっています。

2024エディション

2024エディションで予定されている変更のリストは下記のアナウンスに記載されています。下記アナウンスは2024年3月時点での進捗状況も記録されています。

blog.rust-lang.org

あるいは、Tracking Issueを追う手もあります。

https://github.com/rust-lang/rust/issues?q=label%3AA-edition-2024+label%3AC-tracking-issue+github.com

最終的な成果はこのページにまとめられるはずです。

doc.rust-lang.org

この記事では大きいと思われる変更を説明したいと思います。下記をこれから説明します。

  • FutureIntoFutureがpreludeに入る
  • gen予約語になる
  • RPIT のライフタイムキャプチャ改善

FutureIntoFutureがpreludeに入る

FutureならびにIntoFutureは、これまでは明示的にインポートして利用する必要がありました。

トレイトのpreludeへの追加は、たとえばOptionをはじめとする単なる型のpreludeの追加とは異なり、破壊的な変更を既存のコードにもたらす可能性があります。Option型が仮にexampleというモジュールの配下にあったとすると、use example::OptionとすればそのモジュールのOptionを優先的に読み込みますし、ない場合は標準ライブラリのOptionをpreludeから読み出します。一方でトレイトはシグネチャ上、メソッドのみで使用されることがあるため、x.poll()のようなメソッドを持つ任意のトレイトが仮にあったとすると、現在のFutureをpreludeに入れた瞬間に衝突しうる可能性があります。これは結果的に、既存のコードベースに対して変更が加わることを意味します。

仮に次のような定義を持つトレイトがあったとします。Timerというトレイトはpollというメソッドを持ちます。

trait Timer {
    // pollというメソッドは、std::future::Futureトレイトも持ちうる
    fn poll(&self) {
        println!("timer set");
    }
}

impl<T> Timer for T {}

fn main() {
    // preludeにFutureトレイトが入ってくるとすると、
    // この`.poll`の呼び出しは、どのトレイトのものかが曖昧になる。
    core::pin::pin!(async {}).poll();
}

Futureトレイトも定義上はpollメソッドを持ちうるため、仮にstd::future::Futureをインポートして使用したとすると、Future側の情報が優先された旨のコンパイルエラーが出力されます。preludeFutureが入ると、実質的に常に下記の状況が再現されることになります。

use std::future::Future;

// 次のようなコンパイルエラーになる。
// error[E0061]: this method takes 1 argument but 0 arguments were supplied
//   --> src/main.rs:15:31
//     |
// 15  |     core::pin::pin!(async {}).poll();
//     |                               ^^^^-- an argument of type `&mut Context<'_>` is missing
//     |
// note: method defined here
//   --> /playground/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/future/future.rs:103:8
//     |
// 103 |     fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
//     |        ^^^^
// help: provide the argument
//     |
// 15  |     core::pin::pin!(async {}).poll(/* &mut Context<'_> */);
//     |                                   ~~~~~~~~~~~~~~~~~~~~~~~~

trait Timer {
    // pollというメソッドは、std::future::Futureトレイトも持ちうる
    fn poll(&self) {
        println!("timer set");
    }
}

impl<T> Timer for T {}

fn main() {
    // preludeにFutureトレイトが入ってくるとすると、
    // この`.poll`の呼び出しは、どのトレイトのものかが曖昧になる。
    core::pin::pin!(async {}).poll();
}

これを回避するためには、次のようにどのトレイト経由のものかを書き添える必要があります。これはつまり既存のコードに対して破壊的な変更が必要になることを意味しています。

use std::future::Future;

trait Timer {
    fn poll(&self) {
        println!("timer set");
    }
}

impl<T> Timer for T {}

fn main() {
    // Timerトレイトのpollを呼び出していることを明示した。
    <_ as Timer>::poll(&core::pin::pin!(async {}));
}

私は実装したことはありませんが、たとえばポーリング処理を実装する中でpollというメソッドを持つ自前のトレイトを定義している、ということはありえない話ではないでしょう。preludeにトレイトが入ることで上記で見てきたような修正がユーザー側のコードに必要になるため、このような修正はエディションを変えて対応するしか方法がありません。

gen予約語になる

genというキーワードが予約語として登録されるようになります。つまり、変数名や関数名にgenを利用している場合はr#genと修正しなければなりません。

genキーワードはイテレータ生成に使用するキーワードです。これまでのRustでは、イテレータを生成するのがわりと大変でした。Iteratorはトレイトであるため、専用の構造体を用意してそれに対してトレイトを実装させるなどの方策をとる必要がありました。一方でたとえばPythonのようなプログラミング言語には、すでにyieldなどのキーワードが導入されており、これを利用すると比較的楽にRustのイテレータに近い実装を行うことができました。

少し実例を使って説明してみたいと思います。最近イテレータを使って問題解決した事例があったかを思い返してみたところ、Project Eulerを解いた際に使ったのを思い出したので、これを例にとって順を追って説明します。

projecteuler.net

この問題をイテレータを使って素直に解くと、次のような実装になると思います。filterの中身は偶数の取り出し、take_whileは4,000,000以下の数を列挙しています。実際の数の取り出され方はIteratorトレイトのnextメソッドで定義されています。最後にsumで最後に合計を算出する、という具合です。

fn main() {
    struct Fibonacci {
        a: i64,
        b: i64,
    }

    impl Iterator for Fibonacci {
        type Item = i64;
        fn next(&mut self) -> Option<i64> {
            let x = self.b;
            self.a = self.b;
            self.b += x;
            Some(x)
        }
    }

    let ans: i64 = Fibonacci { a: 0, b: 1 }
        .filter(|&f| f % 2 == 0)
        .take_while(|&f| f <= 4_000_000)
        .sum();
    println!("{}", ans);
}

上記のコードでは、イテレータを生成させるためにFibonacciという構造体を用意し、その構造体に対してIteratorを実装するという流れを取ります。genは、この部分を不要にするというわけです。

genブロックを用いると、これを下記のように実装し直すことができます。実際のコードはこちらのPlayground上で動作させることができます。(2024エディションがリリース後には#![feature(gen_blocks)]等の注釈が不要になり、1.80以上のnightlyで走らせる必要はなくなりますが、現時点では両者の設定が必要な点に注意してください。)

#![feature(gen_blocks)]
fn main() {
    let fibonacci = gen {
        let mut a = 0;
        let mut b = 1;
        loop {
            yield a;
            a = b;
            b = b + a;
        }
    }
    .into_iter();

    let ans: i64 = fibonacci
        .filter(|&f| f % 2 == 0)
        .take_while(|&f| f <= 4_000_000)
        .sum();
    println!("{}", ans);
}

genブロック利用の注意点ですが、ブロックの返り値はユニット型(())ないしは発散して(!)いなければなりません。その時点での値を返すためにはyieldというキーワードを用います。上記の例では、genブロック内の最終的な返り値はloop式つまり!型になります。また、ループが回るたびにyieldを使ってその時点でのaの値を返させています。返したあとは、フィボナッチ数列の計算を行うのみです。

ちなみに今回実装した例ですと、自分でnextを呼び出して値の取得を制御する必要がほぼないため、実装量がちょっと減った以上の恩恵はないかもしれません。もう少し激しい例として、RFCで紹介されているRun-Length Encodingの実装があります。これくらいになってくると、genの利用で厄介なイテレータの制御が軒並み不要になりそうな感じがするため、より恩恵を感じられるはずです。

RPIT (返しの位置のImpl Trait) のライフタイムキャプチャ改善

この記事を執筆している時点では、Rustのimpl Traitはそれ自体は少々直感に反するところがあるというか、未完成に近いと思われる機能でした。「ここにimpl Traitを利用できるはずだ」と思って実装したものの、「その機能はまだ使えないよ」という警告を目にして泣く泣く諦めた経験のある方も多いはずです。たとえば、型エイリアスimpl Traitを指定できると便利だなと思う場面があると思いますが、2021エディション以前ではそれはできません。などです。

2024エディションでは、impl Trait周りの直感に反する機能を整合的なものにしていくという目標が掲げられています。その一つとして、いわゆる「RPIT("R"eturn "P"osition "I"mpl "T"rait)」[*1]が予定されています。ここで直したい不整合というのは、RPITのライフタイムのキャプチャにまつわるものです。

ここで「Return Position Impl Trait」という言葉について一度確認しましょう。Return Position Impl Traitはfn () -> impl Traitのような返しの位置にあるimpl Traitを指します。たとえばクロージャのように無名で型付けが難しい型があったとき、動的ディスパッチを利用しBox<dyn Trait>などとしてしまうのは一つの手ではありますが、静的ディスパッチを利用しながらそうした型の情報を「覆い隠す」ために利用されるものです。

たとえば次のようなコードを書くとき、返り値の型を明示的に書くのはほぼ困難であると言えます。そういう場合に、「覆い隠す型 = opaque type」[*2]としてimpl Traitが利用できるというわけです。ちなみにですが、覆い隠される側の型のことを「隠される型 = hidden type」と呼びます。関数の外から見たとき、実装の詳細はimpl Traitによって隠されているということになります。

fn only_true<I>(iter: I) -> impl Iterator<Item = bool> // <- "opaque type"
where
    I: Iterator<Item = bool>,
{
    iter.filter(|&x| x) // <- "hidden type"
}

Opaque Type側ではライフタイムのキャプチャを指定することはできず、ライフタイムパラメータの名前などはHidden Type側に記述される必要があります。記述された情報が、関連するOpaque Type側にキャプチャされるという関係性です。

今回の修正は、impl Traitのキャプチャするライフタイムが、同期関数(fn)と非同期関数(async fn)とで食い違っている点があり、非同期関数側に挙動を合わせるというものです。まず、簡単にfooという関数を考えてみましょう。恣意的な例ではありますが、ユニット型の参照と任意の型を受け取ることのできるTを引数としてとる簡単な関数を考えます。

async fn foo<'a, T>(x: &'a (), y: T) {
    _ = (x, y)
}

async fnコンパイラにおける取り扱いを一度思い出してみましょう。async fnは、返り値にimpl Future<...>を持つ通常の同期関数として脱糖されるのでした。ここでRPITの登場です。つまり、直感的には次のように脱糖されてコンパイラ内では扱われそうに見えますが、ライフタイムのキャプチャを忘れている旨のコンパイルエラーが出ます。

fn foo<'a, T>(x: &'a (), y: T) -> impl Future<Output = ()> {
    async move {
        _ = (x, y);
    }
}

下記2つのコンパイルエラーが出力されます。

error[E0700]: hidden type for `impl Future<Output = ()>` captures lifetime that does not appear in bounds
  --> src/main.rs:15:5
   |
14 |   fn foo<'a, T>(x: &'a (), y: T) -> impl Future<Output = ()> {
   |          --                         ------------------------ opaque type defined here
   |          |
   |          hidden type `{async block@src/main.rs:15:5: 17:6}` captures the lifetime `'a` as defined here
15 | /     async move {
16 | |         _ = (x, y);
17 | |     }
   | |_____^
   |
help: to declare that `impl Future<Output = ()>` captures `'a`, you can add an explicit `'a` lifetime bound
   |
14 | fn foo<'a, T>(x: &'a (), y: T) -> impl Future<Output = ()> + 'a {
   |                                                            ++++

一旦コンパイルエラーに従ってimpl Future<Output = ()> + 'aとすると、さらに次のようにコンパイルエラーが出ます。

error[E0309]: the parameter type `T` may not live long enough
  --> src/main.rs:15:5
   |
14 |   fn foo<'a, T>(x: &'a (), y: T) -> impl Future<Output = ()> + 'a {
   |          -- the parameter type `T` must be valid for the lifetime `'a` as defined here...
15 | /     async move {
16 | |         _ = (x, y);
17 | |     }
   | |_____^ ...so that the type `T` will meet its required lifetime bounds
   |
help: consider adding an explicit lifetime bound
   |
14 | fn foo<'a, T: 'a>(x: &'a (), y: T) -> impl Future<Output = ()> + 'a {
   |             ++++

もう一度コンパイルエラーに従って直すと、次のようになるはずです。これは実際コンパイルが通ります。

fn foo<'a, T: 'a>(x: &'a (), y: T) -> impl Future<Output = ()> + 'a {
    async move {
        _ = (x, y);
    }
}

しかし実際には、次のように脱糖されていますしそうされなければなりません。こう脱糖されるべき理由はこのドキュメントに詳しいです。Opaque Typeでimp Future<Output = ()>に加えて'aのライフタイムを持つ型を要求しているという意味になっています。しかし本当に我々が欲しかったのは、Opaque Typeが'aをキャプチャすることでした。Rustの現状のコンパイラはこれを通してしまうものの、ここには微妙に意味にズレが生じているといえます。解消するためにはたとえば下記のように実装しなければなりません。実はasync fnの方はこうなるように実装されているわけですが、通常のfnは自前でこのように用意してやる必要があるという点に不整合があるというのが、このRPITの改善の肝です。

trait Captures<U> {}
impl<T: ?Sized, U> Captures<U> for T {}

fn foo<'a, T>(x: &'a (), y: T) -> impl Future<Output = ()> + Captures<&'a ()> {
    async move {
        _ = (x, y);
    }
}

この問題が解決されると、他に付随するいくつかのimpl Traitにまつわる問題も解決されていくようです。たとえば、これまではAssociated Typesにimpl Traitを指定することはできませんでしたが、それが可能になるなどです。個人的な経験からだと、この手の話が解決されるとcombineなどのパーサーコンビネータを使った実装の抽象化を簡単にできるようになるので、そもそもの実装量が大幅に減らせるか専用のマクロを使わなくて良くなり、とても嬉しい機能かなと思っています。

まとめ

今回は私が気になった2024エディションの新機能や変更点について紹介しました。他にもCargo関連の細かい修正も入っているなど、いくつか変更点が追加されそうな雰囲気です。リリースが楽しみですね。

*1:ワードの頭文字を取って省略したがるのは英語圏の文化みたいなものです。

*2:ちなみにですが、Opaqueは「オペーク」ないしは「オペイク」と読みます。スペル的にフランス経由の言葉っぽいですね。余談でした。