4. 3 つを統合した完成イメージ

Newtype + Phantom Type + im を組み合わせた ValueObject + Repository 設計の青写真 · 作成 2026-05-17
目次
  1. 3 層の重ね合わせ
  2. アーキテクチャ図
  3. 完成コード(一式)
  4. データフローと型の流れ
  5. Modifier チェーンの関数合成
  6. ScanDataPy との対応表
  7. 学習ロードマップ
  8. Phase L1 への接続
  9. まとめ

1. 3 層の重ね合わせ

ここまで見てきた 3 つの道具は、互いに直交する独立した役割を持ちます:

道具表現するもの
Newtype「値」の意味Channel(u8), FrameIndex(usize)
Phantom Type「データ」の状態FramesData<Raw>, <DfOverF>
im クレート「コレクション」の不変性im::HashMap<DataTag, ValueObj>

3 つを組み合わせると、次の 3 つの恩恵が同時に得られます:

2. アーキテクチャ図

FP 化された ValueObject + Repository の 3 層構造 層 1: Newtype(値の意味) Channel(u8) 0..7 の chan 番号 FrameIndex(usize) フレーム番号 TimeMs(f64) 時刻 [ms] Filename(String) ファイル名 RoiId(u32) ROI 識別子 構成要素として使う 層 2: Phantom Type を持つ ValueObject(データの状態) FramesData<S> data: Arc<Array3<i16>> tag: DataTag (Newtype 集約) S = Raw | DarkSubtracted | ... TraceData<S> data: Arc<Vec<f64>> time: Arc<Vec<TimeMs>> S = Raw | BlComp | DfOverF ImageData data: Arc<Array2<i16>> tag: DataTag (状態遷移なし) enum ValueObj FramesRaw(...) FramesDfOverF(...) Image(...) / ... 値として格納する 層 3: im::HashMap による Repository(コレクションの不変性) Repository { items: im::HashMap<DataTag, ValueObj> } insert / remove は新しい Repository を返す (非破壊) 構造共有で O(log n) コスト RepoHistory { states: Vec<Repository> } undo / redo / タイムトラベル 無料で実装可能 3 層とも純粋関数のみ — 副作用ゼロ (fs / tokio / Qt を import しない)
図 7: Newtype + Phantom Type + im の 3 層構造。下の層が上の層の構成要素になる。

3. 完成コード(一式)

3 層を組み合わせた具体的な Rust コード例:

use std::sync::Arc;
use std::marker::PhantomData;
use im::HashMap;
use ndarray::{Array2, Array3};

// ─────────────── 層 1: Newtype ───────────────
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Channel(pub u8);

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Filename(pub String);

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct DataTag {
    pub filename: Filename,
    pub channel: Channel,
}

// ─────────────── 層 2: Phantom Type + ValueObject ───────────────
pub struct Raw;
pub struct DarkSubtracted;
pub struct ChannelSplit;
pub struct DfOverF;

#[derive(Clone)]
pub struct FramesData<S> {
    pub data: Arc<Array3<i16>>,   // 巨大データは Arc 共有
    pub tag:  DataTag,
    _state: PhantomData<S>,
}

// Raw のときだけ呼べる遷移
impl FramesData<Raw> {
    pub fn subtract_dark(self, dark: &Array2<i16>) -> FramesData<DarkSubtracted> {
        // 純粋関数として実装
        let new_data = /* 引き算 */ self.data;
        FramesData { data: new_data, tag: self.tag, _state: PhantomData }
    }
}

// DarkSubtracted 以降ならどれでも
pub trait PostDark {}
impl PostDark for DarkSubtracted {}
impl PostDark for ChannelSplit {}

impl<S: PostDark> FramesData<S> {
    pub fn df_over_f(self, baseline: f64) -> FramesData<DfOverF> { /* ... */
        FramesData { data: self.data, tag: self.tag, _state: PhantomData }
    }
}

// 統一型: Repository に入れるためには enum で型消去
#[derive(Clone)]
pub enum ValueObj {
    FramesRaw(FramesData<Raw>),
    FramesDark(FramesData<DarkSubtracted>),
    FramesSplit(FramesData<ChannelSplit>),
    FramesDfOverF(FramesData<DfOverF>),
    // Image / Trace / Text なども同様に...
}

// ─────────────── 層 3: im::HashMap で Repository ───────────────
#[derive(Clone)]
pub struct Repository {
    items: HashMap<DataTag, ValueObj>,
}

impl Repository {
    pub fn new() -> Self { Self { items: HashMap::new() } }

    pub fn insert(&self, tag: DataTag, v: ValueObj) -> Self {
        Self { items: self.items.update(tag, v) }
    }

    pub fn remove(&self, tag: &DataTag) -> Self {
        Self { items: self.items.without(tag) }
    }

    pub fn get(&self, tag: &DataTag) -> Option<&ValueObj> {
        self.items.get(tag)
    }
}
これだけ書けば、Phase L2 のドメイン層の骨組みは完成。 あとは Modifier の関数合成と、各 ValueObject の実装を増やしていくだけです。

4. データフローと型の流れ

TSM ファイル読込から Repository 格納までの型の流れ .tsm ファイル バイナリ (FITS ヘッダ + i16) 副作用領域 (Adapter) read_tsm() TsmFile (Phase L1) full_frames, dark_frame 形式専用の中間表現 to_value_obj() FramesData<Raw> Arc<Array3<i16>>, DataTag ドメイン層 (Phase L2) .subtract_dark() FramesData<DarkSubtracted> 暗電流補正済み .split_channels() FramesData<ChannelSplit> ch 分離済み ValueObj に enum 包む ValueObj::FramesSplit(...) 型消去された統一表現 repo.insert() Repository (im::HashMap) 非破壊更新で新しい Repository を返す undo / redo 用途で履歴 Vec に追加 層 3: コレクション 層 2: 状態遷移 (Phantom Type)
図 8: TSM ファイルを読み込み、Modifier チェーンで状態遷移させ、最後に Repository に格納する流れ。型が段階的に変化していくのが見える。

5. Modifier チェーンの関数合成

ScanDataPy の Modifier チェーンは「fold」で美しく表現できます:

// Modifier は「FramesData<S> → FramesData<S'>」の純粋関数
// 型が変わるので Box<dyn ...> ではなく具体的に並べる

pub fn load_and_process(path: &std::path::Path)
    -> Result<FramesData<DfOverF>, DomainError>
{
    let tsm = read_tsm(path)?;        // 副作用は最外層
    let raw = tsm.to_frames_raw();    // FramesData<Raw>
    let dark_sub = raw.subtract_dark(&tsm.dark_frame);
    let split = dark_sub.split_channels(NumFluoCh(2));
    let dfof = split.df_over_f(1000.0);
    Ok(dfof)
}

あるいは同種のモディファイア(同じ型同士の変換)が並ぶ場合は fold で:

// TraceData<BlComp> に複数の smoothing を適用
let smoothers: Vec<Smoother> = vec![...];
let smoothed = smoothers.iter()
    .try_fold(trace, |acc, m| m.apply(&acc))?;
関数合成のポイント:
状態が変わる Modifier (型が変わる) は順次明示的に書く。 状態が変わらない Modifier (同じ型の変換) は fold で 1 行に。

6. ScanDataPy との対応表

ScanDataPy(現状)ScanDataNet(FP 強寄り)使う道具
dict[str, str] 型の data_tag DataTag { filename: Filename, channel: Channel } Newtype
FramesData.data: np.ndarray (mutable) FramesData<S> { data: Arc<Array3<i16>> } Phantom Type + Arc
Modifier が data_tagupdate() で破壊変更 Modifier が新 FramesData<S'> を返す純粋関数 Phantom Type
Repository._data: listappend/remove Repository::insert/remove(&self) -> Self im::HashMap
find_by_keysset(values()) で比較 (バグあり) get(&DataTag) で型一致を保証 Newtype + HashMap
undo / redo なし RepoHistory: Vec<Repository> で実装可 im の構造共有
Visitor を accept(visitor) で実装 match value_obj { ... } でパターン Rust の enum

7. 学習ロードマップ

3 つの道具を ScanDataNet に組み込むまでの推奨順序:

Phase L0 Rust 基礎 rustlings 完走 所有権・借用 ジェネリクス Phase L1 (TSM パーサ) Newtype 導入 → Channel, Filename, FrameIndex, TimeMs .npy bit-perfect 一致 Phase L2-a (ValueObject) Phantom Type 導入 → FramesData<Raw> etc → enum ValueObj Modifier 純粋関数化 Phase L2-b (Repository) im クレート導入 → Repository → RepoHistory (undo) 関数合成で Modifier 鎖 Phase L3 以降 (副作用層を追加) tokio + axum で WebSocket サーバ追加 React + TypeScript フロント追加 ドメイン層は無修正で再利用可能 (Hexagonal の効果) 3 つの道具を Phase L1 → L2 に組み込むロードマップ
図 9: 学習ロードマップ。Newtype を L1 で先行投入、Phantom Type と im は L2 でドメイン層に組み込む。

8. Phase L1 への接続

現状の PHASE_L1_TSM_PARSER.mdTsmFile 設計を、本ガイドの方針で見直すと:

現プラン (L1)本ガイドでの推奨備考
pub fn open(path: impl AsRef<Path>) 同じで OK Newtype FilePath を使うとさらに型安全
full_frames: Array3<i32> 同じで OK(L1 では生の ndarray L2 で Arc<Array3> 包む
fn split_channels(&self, num_fluo_ch: usize) fn split_channels(&self, num: NumFluoCh) Newtype に置換
Phantom Type の導入 L1 では導入しない L1 は bit-perfect 一致が最優先。L2 で TsmFile → FramesData<Raw> 変換層を追加
注意: Phase L1 で Phantom Type を急いで導入すると、 .npy との bit-perfect 一致の検証が複雑化します。 L1 は素朴な TsmFile 構造体で完結させ、L2 で本ガイドの設計に変換するのが安全です。

9. まとめ

ここまで読了したら、実際に小さな練習リポジトリを作って手を動かすのが次のステップです。 推奨は cargo new fp-play --bin で 100〜200 行のサンプルから始め、 動いてから ScanDataNet/server/crates/scandata-domain/ の本実装に転用するパターン。

関連リンク