🍣

高階トレイト境界と少し仲良くなってみた

2024/04/06に公開

以前、Stack OverflowにてRustにおけるトレイト実装を用いた関数の多重定義とコンパイラの振る舞いに関してこのような質問をしたとき、高階トレイト境界(High-Rank Trait Border a.k.a HRTB)なる概念が存在していることを頂いた解答によって理解していたがこれが一体何者なのかいまいち理解できずに居た。

今般、何となくこいつが何者なのか理解できたのでまとめておこうかなって。

ここではライフタイムの基礎には踏み込まないので、その点ご了解の程何卒

助走

さて、本題に入る前に、以下のような関数を考えてみよう。

pub fn select_long(a:&str,b:&str)->&str{
	if a.len()>b.len(){a}else{b}
}

概略、abの長い方を返し、もし長さが一致したらaを返す関数になっている。これをコンパイルした場合、以下のようなコンパイルエラーが発生する

error[E0106]: missing lifetime specifier
 --> src\main.rs:6:36
  |
6 | pub fn select_long(a:&str,b:&str)->&str{
  |                      ----   ----   ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `a` or `b`
help: consider introducing a named lifetime parameter
  |
6 | pub fn select_long<'a>(a:&'a str,b:&'a str)->&'a str{
  |                   ++++    ++        ++        ++

要するに返却する&srtのライフタイムを確定できないということなので、helpに従って以下のように書き換えるとうまく動く

pub fn select_long<'a>(a:&'a str,b:&'a str)->&'a str{
	if a.len()>b.len(){a}else{b}
}

明示的なライフタイム注釈'aを用いることで、引数のいずれも少なくとも'aで境界されたライフタイム以上の寿命を持つことを要求し、返却値にも'aでライフタイム境界を与えることでコンパイラがライフタイムのトラッキングが可能になる。

この辺を大まかに観察した上で本題に入ってみよう

本題

いつも通り、作為的な例になってしまうけど、以下のような関数を考えたとしよう。先ほどのselect_longに当たる部分をclosureを使い外部定義可能にしている。

pub fn select<F:FnOnce(&str,&str)->&str>(a:String,b:String,selector:F)->String{
	let s=selector(&a,&b);
	format!("{s} is selected.")
}

概略文字列a及びbを受け取って、それに対してselectorを適用しその結果を基に文字列をこさえて返している。この作例は無理矢理HRTBを使おうとしてるので効率は良くないしなんか歪なのは解ってるけどまぁご了承の程。

さて、こいつをコンパイルすると以下のようなエラーが出てくる

error[E0106]: missing lifetime specifier
 --> src\main.rs:7:36
  |
7 | pub fn select<F:FnOnce(&str,&str)->&str>(a:String,b:String,selector:F)->String{
  |                        ---- ----   ^ expected named lifetime parameter
  |
  = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from argument 1 or argument 2
  = note: for more information on higher-ranked polymorphism, visit https://doc.rust-lang.org/nomicon/hrtb.html
help: consider making the bound lifetime-generic with a new `'a` lifetime
  |
7 | pub fn select<F:for<'a> FnOnce(&'a str,&'a str)->&'a str>(a:String,b:String,selector:F)->String{
  |                 +++++++         ++      ++        ++
help: consider introducing a named lifetime parameter
  |
7 | pub fn select<'a, F:FnOnce(&'a str,&'a str)->&'a str>(a:String,b:String,selector:F)->String{
  |               +++           ++      ++        ++

何が問題かというと、先の助走と同じ問題が起きている。即ち、closureで宣言した&strのライフタイム境界が欠落してしまっている。

んじゃ、先ほどと同様にライフタイム境界を付けりゃ良いのかと思って以下のように書いてみよう

pub fn select<'a,F:FnOnce(&'a str,&'a str)->&'a str>(a:String,b:String,selector:F)->String{
	let s=selector(&a,&b);
	format!("{s} is selected.")
}

残念ながらこれでもコンパイルすることは出来ない。

error[E0597]: `a` does not live long enough
  --> src\main.rs:12:17
   |
11 | pub fn select<'a,F:FnOnce(&'a str,&'a str)->&'a str>(a:String,b:String,selector:F)->String{
   |               -- lifetime `'a` defined here          - binding `a` declared here
12 |     let s=selector(&a,&b);
   |           ---------^^----
   |           |        |
   |           |        borrowed value does not live long enough
   |           argument requires that `a` is borrowed for `'a`
13 |     format!("{s} is selected.")
14 | }
   | - `a` dropped here while still borrowed

どういうことかというと、closureの外側で定義したライフタイム注釈を適用しても実際closureが使われたときにはライフタイム境界を、実装する側の意図通りに画定できずその結果としてコンパイルエラーが発生してしまっている。

解法

こうなると必要なのはclosureの実装と同じ土俵で扱えるライフタイム注釈と言うことになる。んでこれが本題の高階トレイト境界となる。高階トレイト境界は以下のようにclosureトレイトのトレイト境界に先行して、for<...>という形式で書く。

pub fn select<F:for<'a> FnOnce(&'a str,&'a str)->&'a str>(a:String,b:String,selector:F)->String{
	let s=selector(&a,&b);
	format!("{s} is selected.")
}

これによって、助走で検証したライフタイム境界と同じ構造になり問題なくコンパイル可能となる。

まとめ

当初、変なところにライフタイム注釈が存在してなんだこれは状態だった。そこを発起点として、高階トレイト境界というモノが存在することを理解した反面、当初どのように使い、どんなことが嬉しいのかいまいち理解できなかった。

今回の事例を通して相当作為的だけど必要な事例を元により理解を深めることが出来た。

そう多用するモノでもないし、さりとて無ければ書けない場面はあるのでいざ使おうと思ったとき忘れてる可能性が高いので備忘録を兼ねてまとめてみた。

参考にしたサイトとか

Discussion