状態の扱い: OO の mutate vs FP の不変 struct

「30 個近いインスタンス変数を mutate」と「不変な struct を組み立てて 1 回返すだけ」の違いを掘り下げる · 作成 2026-05-17
目次
  1. この資料の目的
  2. OO 版で何が起きているか
  3. 6 つの病巣
  4. FP 版で同じ処理がどうなるか
  5. 「1 回返すだけ」の本質
  6. テストの違いで一番効く
  7. まとめ

1. この資料の目的

docs/fileio_oo_to_fp.html §9 対比表の「状態」の行で簡単に触れた以下の対比を、 図と具体例を使って詳しく解説します。

観点現状 (Python OO)提案 (Rust FP)
状態 30 個近いインスタンス変数を mutate 不変な struct を組み立てて 1 回返すだけ
この資料を読むと、なぜ「不変 struct を 1 回返すだけ」が単なる文体の違いではなく テスト容易性・失敗の安全性・並行性・メモリ効率に直結するのかが分かります。

2. OO 版で何が起きているか

ScanDataPy/model/file_io.pyTsmFileIo.__init__ は以下のような構造です:

class TsmFileIo:
    def __init__(self, filename_obj, num_fluo_ch=2):
        # ━━━ ステップ 1: 30 個近くのフィールドをダミー値で初期化 ━━━
        self.filename     = filename_obj.name
        self.file_path    = filename_obj.path
        self.full_filename= filename_obj.fullname
        self.header       = 0                            # ← 後で bytes になる
        self.full_frames  = np.array([0,])             # ← 後で 3D 配列になる
        self.dark_frame   = np.array([0,])             # ← 後で 2D 配列になる
        self.ch_frames    = np.array([0,])             # ← 後で 4D 配列になる
        self.num_fluo_ch  = num_fluo_ch
        self.full_frame_interval = 0                  # ← 後で ms 値になる
        self.ch_frame_interval   = 0                  # ← 後で ms 値になる
        self.data_pixel   = np.empty([0, 0])           # ← 後で [X, Y] になる
        self.num_full_frames = np.empty([0,])         # ← 後で int になる
        self.num_ch_frames   = np.empty([0,])         # ← 後で int になる
        self.full_3D_size = 0                         # ← 後で配列になる
        self.ch_3D_size   = 0                         # ← 後で配列になる
        # ... 計 30 個近く

        # ━━━ ステップ 2: ファイル読んで上書き (mutate) ━━━
        self.read_infor()        # self.data_pixel, num_full_frames を上書き
        self.read_data()         # self.full_frames, dark_frame を上書き
        self.elec_data_obj = TbnFileIo(...)  # さらに別オブジェクトを生やす

時系列で見ると 4 つの状態を通過する

時刻 t0 __init__ 直後 filename = "a.tsm" file_path = "/path" header = 0 ⚠ full_frames = [0,] ⚠ dark_frame = [0,] ⚠ ch_frames = [0,] ⚠ data_pixel = [] ⚠ num_full_frames= [] ⚠ num_ch_frames= [] ⚠ full_3D_size = 0 ⚠ full_frame_interval=0 ⚠ ch_frame_interval=0 ⚠ elec_data_obj= None ⚠ ⚠ = ダミー値 時刻 t1 read_infor() 完了 filename = "a.tsm" file_path = "/path" header = b"..." ✓ full_frames = [0,] ⚠ dark_frame = [0,] ⚠ ch_frames = [0,] ⚠ data_pixel = [256,256]✓ num_full_frames=1000 ✓ num_ch_frames= 500 ✓ full_3D_size = [...] ✓ full_frame_interval=10 ✓ ch_frame_interval=20 ✓ elec_data_obj= None ⚠ 半完成 時刻 t2 read_data() 完了 filename = "a.tsm" file_path = "/path" header = b"..." ✓ full_frames = ndarr ✓ dark_frame = ndarr ✓ ch_frames = ndarr ✓ data_pixel = [256,256]✓ num_full_frames=1000 ✓ num_ch_frames= 500 ✓ full_3D_size = [...] ✓ full_frame_interval=10 ✓ ch_frame_interval=20 ✓ elec_data_obj= None ⚠ ほぼ完成 (elec まだ) 時刻 t3 完成 filename = "a.tsm" file_path = "/path" header = b"..." ✓ full_frames = ndarr ✓ dark_frame = ndarr ✓ ch_frames = ndarr ✓ data_pixel = [256,256]✓ num_full_frames=1000 ✓ num_ch_frames= 500 ✓ full_3D_size = [...] ✓ full_frame_interval=10 ✓ ch_frame_interval=20 ✓ elec_data_obj= TbnFileIo✓ 完成 read_infor read_data TbnFileIo() 呼び出し側は t0, t1, t2, t3 のどの状態でもオブジェクトにアクセスできてしまう
図 1. OO 版の状態遷移。4 つの異なる状態がコード実行中に存在し、いずれも呼び出し側から観測可能。
問題の本質: 「t1 や t2 の状態が観測される」というのは抽象的な話ではありません。例えば __init__ の途中で 例外が起きると、t1 や t2 の半完成オブジェクトが self として残り、 デバッガで触ったり、別スレッドが読みに来たり、別経路でアクセスされたりします。 「ありえない状態」が実際に存在するのがこの設計の致命傷です。

3. 6 つの病巣

#症状具体的な事故シナリオ
中間状態が型上見えてしまう read_data() 途中で例外。tsm.data_pixel は本物、tsm.full_frames[0,]。呼び出し側は型からは判らず誤動作
失敗時にゴミが残る __init__ 中の raise 後、ローカル変数 tsm は消えるが、デバッグ経路や別オブジェクトの参照が残るパターンで「半完成」オブジェクトが生き残る
後からの上書きを誰も止められない 500 行後に tsm.full_frames = modified_array でうっかり上書き。後で plot(tsm.full_frames) したら「生データのつもりが加工後」
TBN を独立してテストできない TbnFileIo(filename, full_frame_interval, num_full_frames) は TSM の値を要求。TBN 単独で __init__ できない
ダミー値分のメモリが無駄 __init__ で 30 個確保 → read_data で破棄 → 本物の配列で再確保。確保・解放サイクルが二重
並行アクセスでデータ競合 2 スレッドが同じ tsm を共有して片方が tsm.full_frames = ... していたら、他方の読み取りが壊れる

4. FP 版で同じ処理がどうなるか

pub struct TsmData {
    pub header:      TsmHeader,
    pub full_frames: Array3<i32>,  // dark 減算済み (Y, X, N)
    pub dark_frame:  Array2<i16>,  // (Y, X)
}
// 注: channels (4D 配列) は持たない。OO 版 builder.py が各 Ch を対等な
//     FramesData として登録していた設計を踏襲し、チャネル分離は domain 層 (builder) で行う。

pub fn open_tsm(path: impl AsRef<Path>) -> Result<TsmData, ScanIoError> {
    // ━━━ 副作用 (1 回だけ) ━━━
    let bytes = std::fs::read(path.as_ref())?;

    // ━━━ 純粋関数の合成 (let で名前を付けるだけ。self.* への代入は一切なし) ━━━
    let header   = parse_tsm_header(bytes.get(..2880).ok_or(...)?)?;
    let raw      = raw_i16_from_bytes(&bytes[2880..], &header)?;
    let arr      = reshape_fortran_3d(raw, header.shape_with_dark())?;
    let arr      = axis_transform(arr);
    let (full, dark) = split_dark(arr);
    let full     = subtract_dark_frame(full.view(), dark.view());
    // チャネル分離 (split_channels) は scandata-io ではなく builder 側で必要時に呼ぶ

    // ━━━ ★ 全フィールドが揃ったタイミングで 1 回だけ struct を構築 ━━━
    Ok(TsmData { header, full_frames: full, dark_frame: dark })
}
path "a.tsm" open_tsm() の内部 ⚡ fs::read (副作用 1 回) parse_tsm_header (純粋) raw_i16_from_bytes (純粋) ↓ reshape ↓ axis_transform ↓ subtract_dark_frame (純粋) (channels は builder 側で必要時に計算) TsmData (完成形) header: TsmHeader full_frames: Array3<i32> dark_frame: Array2<i16> (channels は持たない) 全フィールド完成済み 中間状態を経由しない 呼び出し側に観測できるのは入力 (path) と出力 (TsmData) の 2 状態だけ 👁 観測可能 ⛔ 観測不可能 👁 観測可能
図 2. FP 版のフロー。内部の中間状態 (raw, arr, full, dark) は 関数の中で完結し、呼び出し側からは見えない。channels はここでは作らず builder 側で必要時に split_channels を呼ぶ。

6 つの病巣がどう解消されるか

#OO で何が起きるFP でどう解消
ダミー値や半完成状態が観察可能 TsmData は関数戻り値。呼び出し側は完成形しか見られない
部分的に埋まったオブジェクトが残る ? で早期 return すると TsmData 自体が作られない (まだ Ok(...) していないので)
tsm.full_frames = ... が成功 let tsm = open_tsm(...)?; は immutable。mut を付けない限り上書きはコンパイルエラー
TSM 経由でしか TBN を作れない open_tbn(path) が独立関数。open_tsm_pair(tsm_path) は副作用層で 2 ファイル合成するだけ
30 個確保 → 破棄 → 再確保 本物の値を作ってから 1 回だけ struct に埋め込む。ダミー値領域なし
mutate なので競合可能 不変なので Arc<TsmData> で何スレッドにも安全に配布可能

5. 「1 回返すだけ」の本質

両者の時系列を並べると、観測可能な状態数の違いが鮮明です。

観測可能な状態の数 (呼び出し側から見て) OO 版 t0: 全ダミー 👁 t1: 半完成 (infor) 👁 t2: ほぼ完成 (data) 👁 t3: 完成 👁 👁 = 呼び出し側から観測可能な状態。OO 版は 4 状態すべてに「目」が当たる FP 版 input 👁 open_tsm() の内部 — 観測不可能 TsmData (完成) 👁 呼び出し側から観測可能なのは「未着手」と「完成形」の 2 状態のみ 「中間状態」という概念そのものが API から消える ← これが FP の本質
図 3. OO は 4 状態が観測可能、FP は 2 状態のみ観測可能。中間状態の 不可視化が FP の核心。

6. テストの違いで一番効く

「不変 struct を返すだけ」設計の最大の恩恵は、ファイル無しでオブジェクトをリテラルで組み立てられること。 これは Modifier テストの書きやすさに直結します。

OO 版: BlComp のテストを書きたい def test_blcomp(): tsm = TsmFileIo(???) result = BlComp().request(tsm.full_frames) ❌ TsmFileIo は __init__ でファイル I/O ❌ テスト用にダミーオブジェクトを作れない ❌ MagicMock で偽の filename_obj を渡すと __init__ の read_data() で失敗する 結果: テスト用に実ファイルを用意 tests/fixtures/tiny.tsm (バイナリ生成スクリプト必要) テストが I/O に依存。遅い、壊れやすい FP 版: BlComp のテストを書きたい #[test] fn test_blcomp() { let tsm = TsmData { header: dummy_header(), full_frames: Array3::zeros((4,4,2)), dark_frame: Array2::zeros((4,4)), // channels は不要 (持たない設計) }; let result = apply_blcomp(&tsm, ...); assert!(...); } ✓ ファイル不要、リテラルで構築可能 ✓ テストが純粋関数のロジックだけを検証 ✓ Modifier テストが I/O から完全に独立
図 4. テスト作成の難易度比較。OO 版はファイル必須、FP 版はリテラルで構築可能。
FUNCTIONAL_PROGRAMMING_GUIDELINES.md §4 との対応: 「Domain 層は GUI/IO なしでテスト可能」を実現するには、TraceData や TsmData のようなデータ型が 「ファイル無しでリテラル生成できる」ことが前提です。OO 設計だとここが破綻するため、 FP 強寄りの設計が Hexagonal の Domain 層を支える基盤になります。

7. まとめ

観点OO (mutate)FP (不変 struct)
フィールド数30+ (一部ダミー初期化)必要なものだけ (4〜5)
初期化タイミング段階的 (read_infor → read_data → TbnFileIo 生成)1 回だけ
中間状態の可観測性4 状態すべて観測可能観測不可能 (関数戻り値が完成形)
失敗時の挙動一部フィールドが埋まったゴミが残るオブジェクト自体が作られない
並行性データ競合の危険完全に安全 (不変なので Arc<T> 配布可)
テスト容易性実ファイルが必要リテラルで作れる
メモリダミー値分の無駄最適
不変性保証なし (後から上書き可)コンパイラが保証
一言で言うと: OO 版は「内部状態の遷移」を時系列で管理する設計、FP 版は「入力 → 完成形」の関数として表現する設計。 後者は中間状態を API から消去するため、テスト・並行性・失敗安全性が同時に改善します。

これは単なる文体の違いではなく、「観測可能な状態の数」を減らすことが ソフトウェア品質の根幹に効くという、FP の最も重要なメッセージの一つです。

作成 2026-05-17 · fileio_oo_to_fp.html §9 対比表「状態」行の詳細解説