🦍

Rustのクロージャーにおけるライフタイム推論について

2023/09/23に公開

Rustのクロージャーにおけるライフタイム推論について

はじめに

先日X(Twitter)でRustのライフタイムに関する次の質問について言及しているツイートが流れてきました。

https://ja.stackoverflow.com/questions/96277/rustにおけるトレイト実装を用いた関数の多重定義とコンパイラの振る舞いに関して

コンパイルエラーを見ると何が起きているのかさっぱり分からなかったので、ちょっと気になって色々調べてわかったことをまとめた記事になります。

なお、筆者はRustを書き始めてあんまり経っていないので、わかっていない部分も多々あります。
もし内容に誤りがありましたらコメントなどで教えていただけると助かります。

Rustのバージョン

今回記事内のコードは次のバージョンで動作確認しています。

$ rustc -V       
rustc 1.70.0 (90c541806 2023-05-31)

最小構成で問題点を明確にする

まず、コンパイルエラーを見てみると、どうやらトレイト境界が一致しないことが原因のようでした。

$ cargo check --tests 
    Checking hello v0.1.0 (/Users/skanehira/dev/github.com/skanehira/sandbox/rust/hello)
error: implementation of `FnOnce` is not general enough
  --> src/main.rs:74:9
   |
74 |         (0..10).winnow(|i| i & 1 == 0);
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ implementation of `FnOnce` is not general enough
   |
   = note: closure with signature `fn(&'2 i32) -> bool` must implement `FnOnce<(&'1 i32,)>`, for any lifetime `'1`...
   = note: ...but it actually implements `FnOnce<(&'2 i32,)>`, for some specific lifetime `'2`

error[E0308]: mismatched types
  --> src/main.rs:74:9
   |
74 |         (0..10).winnow(|i| i & 1 == 0);
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ one type is more general than the other
   |
   = note: expected trait `for<'a> Fn<(&'a i32,)>`
              found trait `Fn<(&i32,)>`
note: this closure does not fulfill the lifetime requirements
  --> src/main.rs:74:24
   |
74 |         (0..10).winnow(|i| i & 1 == 0);
   |                        ^^^
   = note: the lifetime requirement is introduced here

For more information about this error, try `rustc --explain E0308`.
error: could not compile `hello` (bin "hello" test) due to 2 previous errors

質問内のコードはちょっと複雑だった(自分がジェネリクスに慣れていないのもある)ので、まずは問題点を明確化するところから始めました。
結果、次のコードで同じエラーを再現できました。

fn thing(_: impl FnOnce(&u32)) {}

fn main() {
    let f = |_| ();
    thing(f);
}
error[E0308]: mismatched types
  --> src/main.rs:83:5
   |
83 |     thing(f);
   |     ^^^^^^^^ one type is more general than the other
   |
   = note: expected trait `for<'a> FnOnce<(&'a u32,)>`
              found trait `FnOnce<(&u32,)>`
note: this closure does not fulfill the lifetime requirements

ライフタイム推論の復習

ライフタイムが絡んでいるので、原因を追っていく前に少しライフタイム推論について復習していきます。
先ほどのコードをもう少しシンプルにしてみます。

fn thing(_: &u32) {}

fn main() {
    thing(&1);
    thing(&2);
}

このコードのライフタイムをデシュガーしてみると、次のようになります。

fn thing<'x>(_: &'x u32) {}

fn main() {
    'a: {
        thing(&1); // ①
    }
    'b: {
        thing(&2); // ②
    }
}

'xは①時点で'aとなり、②時点では'bになります。
'xはあくまで関数が呼ばれた時点のライフタイムを表現しているジェネリクスパラメータということですね。

基本的にコンパイラはライフタイムを推論してくれるので、上記のように<'x>というふうに、ライフタイムを省略できます。
ライフタイム推論について、もう少し細かく解説している記事を過去に書いたので、気になる方はそちらを参照してください。

https://zenn.dev/skanehira/articles/2022-12-18-rust-liftime-elision

HRTBsの復習

Rustには高階トレイト境界という機能があります。詳細はこちらを読むと一番わかり易いので、そちらを一度読んでみてください。

簡潔にまとめると、次になります。

  • Fクロージャーの引数は参照で、何かしらのライフタイムを使って表現する必要がある
  • ライフタイムはFが呼ばれた時点、つまりcall()が呼ばれた時点のものになるので、Closureの生存期間に依存していない
    たとえば、次の例では'???は①の時点では'b、②時点では'cとなる
    impl<F> Closure<F>
        where F: Fn(&'??? (u8, u16)) -> &'??? u8,
    {
        fn call<'a>(&'a self) -> &'a u8 {
            (self.func)(&self.data)
        }
    }
    
    ...
    
    fn main() {
        'a: {
            let clo = Closure {
                data: (0, 1),
                func: do_it,
            };
            'b: {
                let x = clo.call(); // ①
                println!("{}", x);
            }
            'c: {
                let x = clo.call(); // ②
                println!("{}", x);
            }
        }
    }
    
  • 呼ばれた時点のライフタイムだよ、ということを表現するためのシンタックスとして次のようにfor<'a>を使えば良い
    impl<F> Closure<F>
    where
        for<'a> F: Fn(&'a (u8, u16)) -> &'a u8,
    
  • なお、基本的に明示的に書かなくてもコンパイラがライフタイム推論してくれる

コンパイルエラーの原因

ライフタイムとHRTBsの復習をしたところで、あたらめてコンパイラエラーのコードを見ていきます。

fn thing(_: impl FnOnce(&u32)) {}

fn main() {
    let f = |_| ();
    thing(f);
}

こちらもライフタイムをデシュガーしてみます。

fn thing<'x>(_: impl FnOnce(&'??? u32)) {}

fn main() {
    'a {
        let f = |_| ();
        'b {
            thing(f);
        }
    }
}

'???のライフタイムはクロージャーが呼ばれた時点のライフタイムとして推論されるということをHRTBsの項で説明しました。
したがって、上記のコードもf = for<'a> |_: &'a u32| ();のような推論がされるはずです。
しかし、実際はライフタイムは推論されておらず、f = |&u32| ();となるため、thing()が要求しているトレイト境界と異なることにより、コンパイルエラーが起きます。

   = note: expected trait `for<'a> Fn<(&'a u32,)>`
              found trait `Fn<(&u32,)>`

その理由は、次の質問の回答にあります。

https://users.rust-lang.org/t/implementation-of-fnonce-is-not-general-enough/68294/3?u=skanehira

要約すると「クロージャーの型推論が優先され、クロージャ、型推論、高階シグネチャの組み合わせによってはライフタイム推論は動かないことがあるよ」ということでした。
つまり、f = |_| ();f = |_: &u32| ();として推論することが優先され、&u32のライフタイム推論は動かないということのようです。

対処法

原因について分かったので、ではどのようにすればこの問題を解消できるのかについて見ていきます。

1つ目は優先的にライフタイム推論してもらうため、型を明記することです。
次のように|_: &u32| ()と型を明記しコンパイラに型推論させないといういことですね。

fn thing(_: impl FnOnce(&u32)) {}

fn main() {
    let f = |_: &u32| ();
    thing(f);
}

2つ目は、ライフタイム推論しなくても動くようにライフタイムを明記することです。
次のようにFnOnce(&'a u32)とライフタイムを明記するということですね。

fn thing<'a>(_: impl FnOnce(&'a u32)) {}

fn main() {
    let f = |_| ();
    thing(f);
}

余談

今回ようなケースに対応するため、クロージャーにもfor<'a>を書けるようにしようという動きがあります。
つまり、HRTBsがうまく動かないなら明記しようということです。

https://github.com/rust-lang/rust/issues/97362

すでに本体に機能は実装されているようで、今回の例でいうと次のように書けばよさそうです。
※しかし、なぜか自分の環境ではなぜかうまく動かなかったので動作未確認です。

#![feature(closure_lifetime_binder)]

fn thing(_: impl FnOnce(&u32)) {}

fn main() {
    let f = for<'a> |_: &'a u32| ();
    thing(f);
}

RFCにはもっと具体的な例が他にも詳細に書かれているので、興味ある方はぜひ読んでみてください。

https://rust-lang.github.io/rfcs/3216-closure-lifetime-binder.html

さいごに

Rustむずすぎない?

参考文献

Discussion