「データに『どんな処理を通ったか』というラベルを型として貼る」仕組みです。
実行時にはメモリを 1 バイトも使いません(ゼロサイズ)。
ただしコンパイル時には強力で、
「この状態のデータには、この関数しか呼べない」
とコンパイラに教えることができます。
std::marker::PhantomData を使って実現します。
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 で報告されている問題:
TagMaker.set_data が ValueObject を mutateKeyManager.set_tag がメソッド名「set」と裏腹に toggle 動作Observer.set_observer も toggleこれらは全部「状態の意味が型に現れない」せいで、関数名やドキュメントを信じて呼んだら違う動作をする、という性質の事故です。
PhantomData とは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> は
_state のように _ で始めると、Rust は「使わなくても警告を出さない」変数として扱います。
PhantomData は値を読み書きしないので、警告抑制のために _ 始まりが慣習です。
次に「Raw」「DarkSubtracted」「DfOverF」などを表すマーカ型を作ります。 これらは値を持たない、純粋に「型として識別したい」だけの宣言です:
// すべて中身ゼロ(zero-sized type = ZST)
pub struct Raw;
pub struct DarkSubtracted;
pub struct ChannelSplit;
pub struct DfOverF;
struct Raw; のセミコロンで終わる宣言は、フィールドゼロ個の構造体(=ユニット構造体)を意味します。
値を作ることもできますが(let r = Raw;)、値は作らず型としてだけ使います。
struct 宣言で違う型を作る必要があります。
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 }
}
}
ポイント:
impl FramesData<Raw> と書くと、その中のメソッドは FramesData<Raw> 型の値からしか呼べませんFramesData<DarkSubtracted> なので、元の型は消えて新しい型に変わりますself(参照ではなく値)を取ることで、元のデータは消費される(誤って 2 回 subtract_dark できない)FramesData<S> の状態遷移。実線矢印は許可される遷移、点線(赤)は型エラーで拒否される操作。
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`
| データ型 | Phantom Type 候補 | 適用する意味 |
|---|---|---|
FramesData |
Raw / DarkSubtracted / ChannelSplit / DfOverF |
Modifier の適用段階を型で表現 |
TraceData |
Raw / BlCompensated / DfOverF / Smoothed |
BlComp 前に dF/F を計算するなどの順序ミスを防ぐ |
ImageData |
不要 | 状態遷移がないので Phantom Type は過剰 |
Roi |
不要 | 状態を持たない単純な値オブジェクト |
「保存する」など、状態に関係なく動作する関数は impl<S> で書きます:
// どの状態でも tag は取得できる
impl<S> FramesData<S> {
pub fn tag(&self) -> &DataTag {
&self.tag
}
}
「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> {
// ...
}
}
| 段階 | やること | 目安時間 |
|---|---|---|
| 1 | struct Foo<T> というジェネリクス構文に慣れる( rustlings の generics セクション全問) |
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 時間 |
たとえば 5 状態 × 3 種類のデータ = 15 個の impl ブロックを手書きする羽目になります。
「順序の制約があるところ」だけに Phantom Type を使い、自由な操作が多いところでは普通の型で十分です。
PhantomData の variance(共変・反変)
高度な話題ですが、PhantomData<S> はデフォルトで S に対して共変になります。
ScanDataNet の単純な状態マーカ用途では気にしなくて OK ですが、
ライフタイムが絡む高度な使い方では PhantomData<fn() -> S> のような書き分けが必要になることがあります。
impl ブロックの書き分けを混同しない| 書き方 | 意味 |
|---|---|
impl<S> FramesData<S> { ... } |
どの状態でも呼べる |
impl FramesData<Raw> { ... } |
Raw 限定 |
impl<S: PostDarkSubtraction> FramesData<S> { ... } |
PostDarkSubtraction を実装する S 限定 |
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 ならではの強み。
PhantomData<S> で「型 S を使ったフリ」をしてジェネリクスを満たすstruct Raw; 等で宣言impl FramesData<Raw> のように書くと、その状態でしか呼べないメソッドを定義できる