PhantomData とライフタイムのサブタイピング(ブランディング 2/4)

アイデア:

  • 各トークンに対してライフタイムを一意のブランドとして使う。
  • ライフタイム同士が暗黙に相互変換されないよう、十分に区別できるようにする。
// Copyright 2025 Google LLC
// SPDX-License-Identifier: Apache-2.0

use std::marker::PhantomData;

#[derive(Default)]
struct InvariantLifetime<'id>(PhantomData<&'id ()>); // 主なポイント

struct Wrapper<'a> { value: u8, invariant: InvariantLifetime<'a> }

fn lifetime_separator<T>(value: u8, f: impl for<'a> FnOnce(Wrapper<'a>) -> T) -> T {
    f(Wrapper { value, invariant: InvariantLifetime::default() })
}

fn try_coerce_lifetimes<'a>(left: Wrapper<'a>, right: Wrapper<'a>) {}

fn main() {
    lifetime_separator(1, |wrapped_1| {
        lifetime_separator(2, |wrapped_2| {
            // これはコンパイルされないようにしたい
            try_coerce_lifetimes(wrapped_1, wrapped_2);
        });
    });
}
  • Rust では、ライフタイム同士にサブタイプ関係が成り立つことがあります。

    この種の関係により、あるライフタイムが別のライフタイムより長く存続するかどうかをコンパイラが判断できます。

    あるライフタイムが別のライフタイムより長く存続するかを判断できるということは、最も短い共通ライフタイムは最初に終わるものだ と言えることでもあります。

    これは多くの場合に有用です。というのも、2 つの異なるライフタイムが重なっている領域では、それらを同じものとして扱えるからです。

    通常、これは望ましい挙動です。しかしここでは、ライフタイムを値の区別に使いたいので、宣言する変数ごとに毎回 newtype を作らなくても、トークンが 1 つの変数にしか適用されないようにしたいのです。

  • 目標: Rust コンパイラが、一方が他方より長く存続するかどうかを判断できない 2 つのライフタイムがほしい。

    ここでは try_coerce_lifetimes をコンパイル時チェックとして使い、ライフタイムに共通のより短いライフタイムが存在するかどうか(つまりサブタイプ化されるかどうか)を見ています。

  • 注: このスライドは現時点ではコンパイルできますが、このスライドの終わりまでには、try_coerce_lifetimes をコメントアウトしたときにのみコンパイルできるようになるはずです。

  • このコードには重要な部分が 2 つあります:

    • lifetime_separator に渡しているクロージャの impl for<'a> 制約。
    • PhantomData のパラメータでライフタイムを使っている方法。

クロージャに対する for<'a> 制約

  • ここでは for<'a> を、関数型にライフタイムのジェネリックパラメータを導入し、その関数本体が可能なすべてのライフタイムで動作することを要求する手段として使っています。

    これによって、関数引数のその特定のライフタイムについてコンパイラが立てられる仮定も一部取り除かれます。というのも、引数が実際にはどのライフタイムを持つことになるとしても、Rust の borrow checking ルールを満たさなければならないからです。実際のライフタイムを代入するのは呼び出し側であり、関数自身ではありません。

    これは数学における全称量化子(Ɐ)や、型変数として <T> を導入するやり方に似ていますが、トレイト境界におけるライフタイムに対してだけ適用されます。

    T に対してジェネリックな関数を書くとき、関数の内部からその型を判断することはできません。たとえば、同じ型の 2 つの引数で関数 fn foo<T, U>(first: T, second: U) を呼び出したとしても、この関数の本体から TU が同じ型かどうかは判断できません。

    これにより、API の利用者 が自分でライフタイムを定義して、こちらが課したい制約を回避できてしまうことも防げます。

PhantomData とライフタイム変性

  • PhantomData はすでに知っています。これは、そうでなければ未使用になる型やライフタイムパラメータを、形式上だけ使用していることにできます。

  • 質問: PhantomData で何ができますか?

    想定される回答: Typestate パターン、所有される値同士のライフタイムを結び付けること。

  • 質問: 他の言語におけるサブタイピングとは何ですか?

    想定される回答: 継承、BA の「サブタイプ」なので、型 A の値が求められる場所で型 B の値を使えること。

  • Rust にもサブタイピングはあります! ただしライフタイムに対してだけです。

    質問: あるライフタイムが別のライフタイムのサブタイプだとしたら、それは何を意味するでしょうか?

    あるライフタイムが別のライフタイムの「サブタイプ」であるのは、そのライフタイムが相手のライフタイムより 長く存続するとき です。

  • PhantomData で使われるライフタイムの振る舞いは、そのライフタイムがどこから「来る」かだけでなく、参照がどのように定義されているかにも依存します。

    これがコンパイルできてしまう理由は、InvariantLifetime の中にあるライフタイムの 変性 が緩すぎるからです。

    注: ここで受講者に変性を完全に理解してもらえるとは期待しないこと。ここでは、ライフタイムがサブタイプ関係を確立できる度合いに対する、制約の強さのはしごのようなものとして扱ってください。

  • 質問: これをもっと制約の強いものにするにはどうすればよいでしょうか? Rust では参照型をどうすればより制約的にできますか?

    想定される回答または実演: &'id mut () にすること。ただし、これだけでは十分ではありません!

    必要なのは、ライフタイムについて、同一のライフタイム である場合を除いてサブタイピングを推論できない 変性 を使うことです。つまり、コンパイラが 'a のサブタイプとして認識できるのは 'a 自身だけです。

    注: 繰り返しますが、クラス全体に変性を理解させようとはしないでください。今のところは、これも制約の強さのはしごとして扱ってください。

    実演: &'id ()(ライフタイムと型の両方で共変)、&'id mut ()(ライフタイムでは共変、型では不変)、*mut &'id mut ()(ライフタイムと型の両方で不変)、最後に *mut &'id ()(ライフタイムでは不変だが、型については不変ではない)へと変えていく。

    最後の 2 つはコンパイルできないはずであり、これによって、この文脈では互いに比較できないようライフタイムを PhantomData に結び付ける候補をついに見つけたことになります。

    理由: *mut可変生ポインタ を意味します。Rust には可変ポインタがあります! しかし safe Rust ではそれについて推論できません。ライフタイムを持つ参照への可変生ポインタにすることで、borrow checker の中では可変生ポインタについて推論できないため、コンパイラがサブタイプ関係を判断するのが難しくなります。

  • まとめ: コンパイラがライフタイムを「十分似ている」と判断しないようにする方法を導入しました。具体的には、このスライドがコンパイルできないようにするのに十分制約の強い、PhantomData におけるライフタイムの変性を選ぶということです。

    つまり、同じスコープ内に同時に存在できる変数を、あまり多くのボイラープレートなしに、変数ごとに自動的に互いに異なる型にできるようになったのです。

さらに探ること

  • for<'a> 量化子は関数型のためだけのものではありません。これは 高階トレイト境界 です。