🛞

Rustではクロージャを型制約にせず、別のtraitを挟んでほしい。

2024/11/13に公開3

以下のLazyなどクロージャを型引数に持つ場合、Fのようにクロージャのトレイトを型制約にせず

struct Lazy<T, F: FnOnce() -> T> { /* 略 */ }

次のように、traitを一枚挟み、それを型引数の制約にしてほしい。

struct BetterLazy<T, F: Initializer<T>> { /* 略 */ }

trait Initializer<T> {
    fn init(self) -> T;
}

impl<T, F: FnOnce() -> T> Initializer<T> for F {
    fn init(self) -> T {
        (self)()
    }
}

理由

理由は型に名前がつかないからです。

クロージャのトレイトを使う場合

クロージャはコンパイラが型を生成しているため、プログラマがクロージャの型を書くことはできません。そのため、クロージャを型引数に持つ型も、クロージャを直接受け取る場合は、型を書くことができなくなります。

let cannot_write_type: Lazy<usize, /* ??? */> = Lazy::new(|| 0);

型が書けないと、その型を再利用することが難しくなります。例として、型の内部でLazyを使いたい場合を考えます。

struct UseLazy {
    inner: Lazy<usize, /* ??? */>
}

始めの例と同様に、クロージャの型は直接記述できないため、Boxなどでくるむか関数ポインタをつかう必要があります。

// Boxなどでくるむか
struct UseLazy {
    inner: Lazy<usize, Box<dyn FnOnce() -> usize>>
}
// もしくは関数ポインタを使う
struct UseLazy {
    inner: Lazy<usize, fn() -> usize>
}

しかし、Boxなどを使う場合は仮想関数呼び出しになってしまい、関数ポインタを使う場合は実行時の値が使えません。これでは、Rustの売りであるゼロコスト抽象化ができていません。

traitを一枚挟む場合

ここで、traitを一枚挟んだバージョンを考えます。

struct BetterLazy<T, F: Initializer<T>> { /* 略 */ }

trait Initializer<T> {
    fn init(self) -> T;
}

型を書く必要があるときにはInitializerを実装した型を作ればよくなります。

// 単に定数を持つだけのInitializer
struct ConstInit<T>(T);

impl<T> Initializer<T> for F {
    fn init(self) -> T {
        self.0
    }
}

struct UseBetterLazy {
    inner: BetterLazy<usize, ConstInit<usize>>
}

クロージャにblanket実装を与えれば、クロージャを使うこともできます。

impl<T, F: FnOnce() -> T> Initializer<T> for F {
    fn init(self) -> T {
        (self)()
    }
}

fn use_closure() {
    let lazy = BetterLazy::new(|| { 0 });
}

余談

今回の話は、クロージャのトレイトを自前で実装することが出来れば問題になりません。クロージャのトレイトを自前実装できるようにしたいというIssueは既にあり、nightlyであれば試すこともできるようです。
https://github.com/rust-lang/rust/issues/29625

Discussion

TaroTaro

maemonさんの実装とほとんど同じですが、以下のような実装はいかがでしょうか?
Initializerトレイトに関する記述がより簡潔になり、読みやすいのではないでしょうか。

#![allow(unused)]
use std::cell::Cell;

trait Initializer<T>: FnOnce() -> T {}

impl <T, F: FnOnce() -> T> Initializer<T> for F {}

struct MyLazy<T, F: Initializer<T>> {
    cell: Option<T>,
    init: Cell<Option<F>>,
}

impl <T, F: Initializer<T>> MyLazy<T, F> {
    pub fn new(init: F) -> MyLazy<T, F> {
        MyLazy {
            cell: None,
            init: Cell::new(Some(init)),
        }
    }
}

fn main() {
    let _ = MyLazy::new(|| 0usize);
}
maemonmaemon

コメントありがとうございます。
TaroさんのInitializerの定義ですと、Initializerを実装できるのはFnOnceを実装している型のみになります。いまのRustではFnOnceを自前実装できないため、Initializerも実装できなくなってしまいます。結果、Initializerを実装するのはクロージャだけとなるため、型に名前がつかない問題が解決しなくなります。

もちろん、TaroさんのInitializerの定義のような、Supertraitを実装する型にblanket実装を与える方法にも使い道はあり、Extensionとして利用できます。

trait FnOnceExt<T>: FnOnce() -> T {
    fn map<U>(self, f: impl FnOnce(T) -> U) -> impl FnOnce() -> U {
        || { f(self()) }
    }
}

impl<F: FnOnce() -> T> FnOnceExt<T> for F {}

このパターンは、traitの本質的なメンバと利便性のためのメンバを切り離すことができ、必要な場合のみuseすればいいため割とよく使いますね。

TaroTaro

返信ありがとうございます。
解決したい問題を私が勘違いしていました。

// 感覚的にはこうしたいがエラーになる
type Init<T> = impl FnOnce() -> T;

のようにただ別名をつけたいだけではなく、適切にtraitを実装すればなんでも渡せるようにしたいのであれば、確かに記事のような実装になりますね。