読者です 読者をやめる 読者になる 読者になる

Rust トレイトオブジェクトの生存期間境界の既定値

概要: トレイトオブジェクト型には生存期間を指定できるが、省略した場合既定値が適用される。この既定値を決定する規則について説明する。

トレイトオブジェクトの生存期間境界

トレイトオブジェクトは正確には以下の構文を持つ。

// obligation 1: only Send, Sync, or lifetimes are allowed in TraitObjectBound.
//               (multiple occurences of Send or Sync are allowed)
// obligation 2: at most one occurence of lifetime is allowed in TraitObjectBound.

TyTraitObject ::= PolyTraitRef ("+" TraitObjectBound)* "+"?

TraitObjectBound ::= PolyTraitRef | Lifetime

PolyTraitRef ::= ("for" "<" LifetimeParams ">")? TraitRef

LifetimeParams ::= ( )
                 | LifetimeParam ("," LifetimeParam)* ","?

LifetimeParam ::= Lifetime (":" LifetimeBounds)?

LifetimeBounds ::= ( )
                 | Lifetime ("+" Lifetime)* "+"?

文章で説明すると、以下のようになる。

  • 原則として、 Foo のようにトレイト名を書くと、これがそのままトレイトオブジェクト型になる。
  • for<'a> Foo<'a> のように、全称量化を書くことができる。引数には生存期間のみ指定できる。
  • Foo + Send + 'a のように、 + で複数の境界を繋ぐことができる。この時最初の要素はトレイトでなければならない。
  • 最初以外の要素にトレイトを指定する場合は、 Send または Sync を指していないといけない。それ以外のトレイトを指定した場合はエラーになる。
  • 生存期間は最大1個だけ指定できる。(Foo + 'a + 'a のように同じものを書くのも禁止されているようだ)

例えば、行儀は良くないが、以下のようなコードが書ける。

trait Foo {}

fn f<'a>(_: &(for<'b: 'a +> Foo + std::marker::Send + 'a + Send + Sync + for<'b:, 'c> Send +)) {}

fn main() {
}

トレイトオブジェクトの生存期間境界の既定値

上記のように、トレイトオブジェクトには生存期間を最大1個指定できるが、指定されなかった場合は既定値が適用される。

トレイトオブジェクトの生存期間境界の既定値は、Rust RFC 1156 にて (Rust RFC 0599を上書きする形で)規定されている。

Rust RFC 1156によると、これは以下の規則に基づく。

  • トレイトオブジェクトの生存期間境界は、たとえ省略されていても、生存期間の省略規則(lifetime elision)の適用対象外となる。つまり生存期間の省略規則 (lifetime elision) の解決後も、未解決のまま残っている。本規則は、生存期間の省略規則 (lifetime elision) の解決後に適用される。
  • 該当トレイトオブジェクトの生存期間境界は、その外側の型に応じて決定される。
    • 外側が参照の場合は、その参照の生存期間が採用される。
    • 外側がユーザー定義型(構造体、列挙型、共用体)の場合は、以下の規則に基づいて決定される。
      • 該当型引数に生存期間による境界 T: 'a がただ1つある場合は、それが採用される。
      • 生存期間による境界 T: 'a が2つ以上ある場合は、コンパイルエラーとなる(プログラマは生存期間を明示しなければならない)。
      • 生存期間による境界がない場合は、構文上の位置に依存して、さらに以下の規則に従う。
        • 関数本体では、新たな生存期間変数が生成される。
        • それ以外(関数シグネチャや構造体定義など)では、 'static が採用される。
    • それ以外の型や、外側がない場合は、 'static が採用される。

実際のコンパイラではastconvの関数内 でこの処理が行われている。これを読み解くと、より正確な規則は以下のようになる。

  • トレイトオブジェクトの生存期間境界は、生存期間の省略規則(lifetime elision)の適用対象外となる。 (GatherLifetimesの該当部分)
  • Rust RFC 1156に従って生存期間の解決を試み、 map (named_region_map) に追加する。 resolve_object_lifetime_default
    • 生存期間が明示されていれば、以下の処理は実行されない。 (LifetimeContextの該当部分)
    • スコープを内側から外側に走査し、 Scope::ObjectLifetimeDefault という指示が最初に見つかったところで終了する。
    • みつからなかった場合は 'static が採用される。
    • Scope::ObjectLifetimeDefault は以下の2つの位置に生成される。
    • 1つ目は参照の内側である。 (LifetimeContextの該当部分)
    • 2つ目はパス(識別子)で表される名前全般(構造体、共用体、列挙型、型別名、トレイト、関連型、列挙型のバリアント)の型実引数の内側である。 (LifetimeContextの該当部分)
    • 2つ目については、対応する仮引数に対する境界 (T: 'a)が検索される。 (object_lifetime_defaults_for_item)
    • 検索された境界に対して、以下のような判定が行われる。 (LifetimeContextの該当部分)
      • ひとつもないとき: 関数本体なら、この時点では生存期間を割り当てない。それ以外の文脈の場合は 'static を割り当てる。
      • ちょうど1つあるとき: その生存期間引数が採用される。
      • 2つ以上あるとき: 「曖昧」とする。この時点では生存期間を割り当てない。
  • 上記で部分的に解決された named_region_map を用いて、 hir::TyTraitObjectty::TyDynamic に変換する (astconvの関数内)
    • 生存期間が明示されている場合は、それを採用する。
    • それ以外の場合は、該当トレイトの Self: 'a 境界を(おそらく再帰的に)検索する。 (compute_object_lifetime_bound)
      • 推測だが、これは「生存期間」ではなく「リージョン」の境界を検索している。したがって関数本体の外では'static以外の境界が実際には列挙されないものと思われる。
      • Self: 'static 境界がある場合、 'static が採用される。
      • それ以外に、 Self: 'a 境界がちょうど1つあるとき(Self: 'a + 'a のような重複も1つと数える)、その生存期間が採用される。
      • Self: 'a 境界が2つ以上あるとき、「曖昧」でエラーとなる。
      • Self: 'a 境界がない場合は、次の手段を試す。
    • 上の方法でうまくいかない場合は、先ほど生成した named_region_map から解決済みの生存期間を取り出す。
    • それも見つからない場合は、リージョン推論を試みる(新しいリージョン変数を生成する)。
    • それもできない場合(関数本体ではなく、外側のユーザー定義型に2個以上の境界が指定されていた場合)は、エラーになる。
    • 解決済みでない場合は、「関数本体内かつ、外側のユーザー定義型に境界が指定されていなかった」場合か、「外側のユーザー定義型に2個以上の境界が指定されていた」場合のいずれかである。

まとめ

トレイトオブジェクトは生存期間境界を1つ指定できるが、省略された場合は一定の規則にしたがって既定値が与えられる。既定値はRFC1156に指定されているように、原則として型の構造とその型に指定された境界によって決められるが、関数本体で使われている型についてはさらに複雑な規則が与えられている。Rustの細かい仕様を追っているといつも思うが、比較的若い言語にしてはかなり複雑な仕様(あるいはコンパイラ実装上生じてしまった複雑性)を抱えている部分が多く、綺麗な見た目と比較して驚きを覚える。