1. Phantom Type による状態の型表現

「データに『どんな処理を通ったか』というラベルを型として貼る」 — Rust 初心者向け詳細解説 · 作成 2026-05-17
目次
  1. それは何か(一言)
  2. なぜ必要か — Python 版で起きていた問題
  3. PhantomData とは
  4. 状態マーカ(中身が空の構造体)
  5. 状態遷移を impl で定義する
  6. 図解: 状態遷移グラフ
  7. ビフォーアフター — コンパイラに守らせる
  8. ScanDataNet での具体的な使い方
  9. 学習ステップ(初心者向け)
  10. 落とし穴
  11. まとめ

1. それは何か(一言)

「データに『どんな処理を通ったか』というラベルを型として貼る」仕組みです。

実行時にはメモリを 1 バイトも使いません(ゼロサイズ)。 ただしコンパイル時には強力で、
「この状態のデータには、この関数しか呼べない」
とコンパイラに教えることができます。

専門用語で type-state pattern または phantom type と呼ばれます。 Haskell や OCaml では普通の道具ですが、Rust では std::marker::PhantomData を使って実現します。

2. なぜ必要か — Python 版で起きていた問題

ScanDataPy の Modifier チェーンには 正しい順序があります:

Raw frames → Dark frame 減算 → チャネル分離 → ROI 平均 → BlComp → dF/F

ところが Python では型がないので、順序を間違えてもプログラムは動いてしまうのが問題です:

# Python: 順序を間違えても実行できる(結果は破滅的に間違う)
frames = tsm.full_frames           # まだ dark 減算してない!
dfof = scale.apply(frames)          # dF/F を勝手に計算(バグ)
dark_subtracted = frames - dark     # 後から減算(順序逆転)

実際に IMPROVEMENT_PLAN.md で報告されている問題:

これらは全部「状態の意味が型に現れない」せいで、関数名やドキュメントを信じて呼んだら違う動作をする、という性質の事故です。

人間の注意力で防ぐタイプのバグは、必ずいつか起こる。 コードレビューや習熟度を上げる対策には限界があります。 だから 型システムで防ぐのが本質的解決です。

3. PhantomData とは

3-1. 「型パラメータを使ったフリ」をする

Rust では、構造体にジェネリクス(型パラメータ)を書く場合、 そのパラメータをフィールドのどこかで使わないとコンパイルエラーになります:

struct Foo<S> {
    data: Vec<i16>,
    // S を使ってない!コンパイルエラー
}
// error[E0392]: type parameter `S` is never used

ここで登場するのが std::marker::PhantomData<T> です。 これは「型 T を覚えておくためだけの、実行時にはゼロバイトのマーカ」です。

use std::marker::PhantomData;

pub struct FramesData<S> {
    pub data: Vec<i16>,
    _state: PhantomData<S>,  // ← S を「使ったフリ」だけする
}
覚え方: PhantomData<S>
S という型がここに住んでいる気がする」というだけの幽霊フィールド。
コンパイラはそれで満足し、実行時のサイズは 0 バイト。

3-2. なぜフィールド名にアンダースコア?

_state のように _ で始めると、Rust は「使わなくても警告を出さない」変数として扱います。 PhantomData は値を読み書きしないので、警告抑制のために _ 始まりが慣習です。

4. 状態マーカ(中身が空の構造体)

次に「Raw」「DarkSubtracted」「DfOverF」などを表すマーカ型を作ります。 これらは値を持たない、純粋に「型として識別したい」だけの宣言です:

// すべて中身ゼロ(zero-sized type = ZST)
pub struct Raw;
pub struct DarkSubtracted;
pub struct ChannelSplit;
pub struct DfOverF;

struct Raw;セミコロンで終わる宣言は、フィールドゼロ個の構造体(=ユニット構造体)を意味します。 値を作ることもできますが(let r = Raw;)、値は作らず型としてだけ使います

なぜ enum じゃないの?
「状態」を表現するなら enum でもよさそうに見えますが、enum の variant は「値」であって「型」ではありません。 Phantom Type は「異なる型として区別したい」のが目的なので、 別々の struct 宣言で違う型を作る必要があります

5. 状態遷移を impl で定義する

Rust の impl ブロックは特定の型に対してのみメソッドを生やすことができます。 これを利用して「Raw のときだけ呼べる関数」「DarkSubtracted のときだけ呼べる関数」を書き分けます:

// === Raw のときだけ subtract_dark が呼べる ===
impl FramesData<Raw> {
    pub fn subtract_dark(self, dark: &[i16]) -> FramesData<DarkSubtracted> {
        let new_data = self.data.iter()
            .zip(dark.iter().cycle())
            .map(|(f, d)| f - d)
            .collect();
        FramesData {
            data: new_data,
            _state: PhantomData,  // 戻り値の型が違う!
        }
    }
}

// === DarkSubtracted のときだけ df_over_f が呼べる ===
impl FramesData<DarkSubtracted> {
    pub fn df_over_f(self, baseline: f64) -> FramesData<DfOverF> {
        let new_data = self.data.iter()
            .map(|&f| ((f as f64 - baseline) / baseline) as i16)
            .collect();
        FramesData { data: new_data, _state: PhantomData }
    }
}

ポイント:

6. 図解: 状態遷移グラフ

FramesData<Raw> 読んだ直後 呼べる関数: subtract_dark() FramesData<DarkSubtracted> 暗電流補正済み 呼べる関数: split_channels(), df_over_f() FramesData<ChannelSplit> ch 分離済み 呼べる関数: apply_roi(), df_over_f() FramesData<DfOverF> ΔF/F 計算済み 呼べる関数: save(), render() subtract_dark split_channels df_over_f ✕ FramesData<Raw> から df_over_f() を直接呼ぶことは コンパイラが拒否する(型が違うので存在しないメソッド扱い)
図 1: FramesData<S> の状態遷移。実線矢印は許可される遷移、点線(赤)は型エラーで拒否される操作。

7. ビフォーアフター — コンパイラに守らせる

正しい順序: コンパイル OK

let raw: FramesData<Raw> = load_tsm("file.tsm")?;

// Raw → DarkSubtracted
let subtracted = raw.subtract_dark(&dark);
// DarkSubtracted → DfOverF
let dfof = subtracted.df_over_f(1000.0);
// 普通にコンパイル成功 ✓

順序を間違える: コンパイルエラー!

let raw2: FramesData<Raw> = load_tsm("file2.tsm")?;
let dfof_wrong = raw2.df_over_f(1000.0);
//                    ^^^^^^^^^^
// error[E0599]: no method named `df_over_f` found for `FramesData<Raw>`
//   help: 利用可能なメソッド: `subtract_dark`
✓ 正しい順序 FramesData<Raw> .subtract_dark() FramesData<DarkSubtracted> .df_over_f() FramesData<DfOverF> ✕ 間違った順序 (省略) FramesData<Raw> .df_over_f() error[E0599] no method named `df_over_f` found for `FramesData<Raw>` help: try `subtract_dark` first
図 2: 同じソースコードでも、型が一致すればコンパイルが通り、ズレていれば 実行する前にコンパイラが拒否する。

8. ScanDataNet での具体的な使い方

8-1. どのデータに適用するか

データ型Phantom Type 候補適用する意味
FramesData Raw / DarkSubtracted / ChannelSplit / DfOverF Modifier の適用段階を型で表現
TraceData Raw / BlCompensated / DfOverF / Smoothed BlComp 前に dF/F を計算するなどの順序ミスを防ぐ
ImageData 不要 状態遷移がないので Phantom Type は過剰
Roi 不要 状態を持たない単純な値オブジェクト

8-2. 「どの状態でもいい」場合の書き方

「保存する」など、状態に関係なく動作する関数は impl<S> で書きます:

// どの状態でも tag は取得できる
impl<S> FramesData<S> {
    pub fn tag(&self) -> &DataTag {
        &self.tag
    }
}

8-3. 「ある状態以降ならどれでも」の書き方

「Dark 減算済み以降ならどの状態でも dF/F できる」のような包含関係を表現したい場合は、マーカトレイトを使います:

// マーカトレイト: 「Dark 減算後」のマーカ型は全部これを実装する
pub trait PostDarkSubtraction {}
impl PostDarkSubtraction for DarkSubtracted {}
impl PostDarkSubtraction for ChannelSplit {}

// PostDarkSubtraction を実装する任意の S なら df_over_f できる
impl<S: PostDarkSubtraction> FramesData<S> {
    pub fn df_over_f(self, baseline: f64) -> FramesData<DfOverF> {
        // ...
    }
}

9. 学習ステップ(初心者向け)

段階やること目安時間
1 struct Foo<T> というジェネリクス構文に慣れる
(rustlingsgenerics セクション全問)
30 分
2 PhantomData の公式ドキュメントを読む
(std::marker::PhantomData)
30 分
3 練習例題: Door<Locked> / Door<Unlocked> を自分で書く
unlock(key)lock() で状態遷移
1 時間
4 FramesData<Raw>FramesData<DarkSubtracted> の 2 状態だけ実装 1〜2 時間
5 状態を 4 つに増やしてチェーンを完成
(Raw → DarkSubtracted → ChannelSplit → DfOverF)
2〜3 時間
6 マーカトレイト (PostDarkSubtraction) で「ある状態以降ならどれでも」を表現 1〜2 時間
段階 3 の Door 練習例題が一番大事です。 小さいので頭に入りやすく、Phantom Type の本質を体感できます。

10. 落とし穴

10-1. 状態を増やしすぎると組み合わせ爆発

たとえば 5 状態 × 3 種類のデータ = 15 個の impl ブロックを手書きする羽目になります。 「順序の制約があるところ」だけに Phantom Type を使い、自由な操作が多いところでは普通の型で十分です。

10-2. PhantomData の variance(共変・反変)

高度な話題ですが、PhantomData<S> はデフォルトで S に対して共変になります。 ScanDataNet の単純な状態マーカ用途では気にしなくて OK ですが、 ライフタイムが絡む高度な使い方では PhantomData<fn() -> S> のような書き分けが必要になることがあります。

10-3. impl ブロックの書き分けを混同しない

書き方意味
impl<S> FramesData<S> { ... } どの状態でも呼べる
impl FramesData<Raw> { ... } Raw 限定
impl<S: PostDarkSubtraction> FramesData<S> { ... } PostDarkSubtraction を実装する S 限定

10-4. self vs &self vs &mut self

状態遷移を表現するメソッドは必ず self(値)を取るようにします:

// Good: self を消費するので、元の Raw データは消える
fn subtract_dark(self, dark: &[i16]) -> FramesData<DarkSubtracted> { ... }

// Bad: &self だと元の Raw が残ったまま、2 回 subtract_dark できてしまう
fn subtract_dark_bad(&self, dark: &[i16]) -> FramesData<DarkSubtracted> { ... }
所有権を移動する (self を取る) ことで、 「同じ Raw データを 2 回 subtract_dark する」ような誤りもコンパイル時に拒否されます。 これは Rust ならではの強み。

11. まとめ

次のページ: Newtype による識別子の型安全性。
Phantom Type が「データの状態」の型表現なら、Newtype は「値の意味」の型表現です。