2. Newtype による識別子の型安全性

「裸の数値・文字列を、意味を持った型で包む」 — Rust の鉄板パターン · 作成 2026-05-17
目次
  1. それは何か(一言)
  2. なぜ必要か — Python 版で起きていた問題
  3. 基本構文 — タプル構造体
  4. 便利な derive マクロ
  5. ビフォーアフター — 型壁の効果
  6. 中身の取り出し方
  7. バリデーション付きコンストラクタ
  8. ScanDataNet で定義する Newtype 一覧
  9. 学習ステップ(初心者向け)
  10. 落とし穴
  11. まとめ

1. それは何か(一言)

「裸の数値・文字列を、意味を持った型で包む」パターンです。

たった 1 行の宣言で、意味の取り違えをコンパイル時に弾くことができます。 パフォーマンスのオーバーヘッドはゼロ(中身は元の型と同じ)。

専門用語で newtype pattern と呼ばれます。Haskell 由来の名前ですが、 Rust では非常によく使われる慣用句で、std ライブラリ自体も std::time::Durationstd::process::ExitCode など随所で採用しています。

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

2-1. 引数の順序ミス

ScanDataPy では dict[str, str] ベースの data_tag があり、 こんな関数があちこちに登場します:

def get_frames(self, ch: int, frame_num: int, num_fluo_ch: int) -> np.ndarray:
    ...

# 呼び出し側
frames = repo.get_frames(2, 100, 1)  # 引数の順序、合ってる?

2, 100, 1 のどれが「チャネル」で「フレーム番号」で「チャネル総数」か、 呼ぶたびに思い出さないといけません

2-2. IMPROVEMENT_PLAN.md で報告されている関連問題

これらは「裸の文字列・整数」を使っているせいで、コンパイラが助けてくれない典型例です。

「動くけど意味が間違っている」コードは、テストで全パターンを網羅しない限り発見できません。 型システムで「意味の取り違え」を防ぐのが newtype の価値です。

3. 基本構文 — タプル構造体

Rust のタプル構造体(フィールドに名前をつけない構造体)を使うのが最もシンプルです:

pub struct Channel(pub u8);
pub struct FrameIndex(pub usize);
pub struct NumFluoCh(pub usize);
pub struct Filename(pub String);

これだけで ChannelFrameIndex は別の型になり、混ぜて使うとコンパイルエラーになります。

3-1. pub が 2 つあるのはなぜ?

pub struct Channel(pub u8);
// ^^^               ^^^
// 外側 pub         内側 pub
位置意味
外側 pubChannel 自体を他のモジュールから使える
内側 pubフィールド .0(中身の u8)を外から直接読み書きできる

バリデーションを強制したいときは内側を pub にしないことで、 「Channel の値を作るには必ず Channel::new(...) を通せ」と強制できます(後述)。

4. 便利な derive マクロ

Rust では #[derive(...)] で trait を自動実装できます。 よく使う組み合わせ:

// 整数ベースの newtype(軽量、よく使う)
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Channel(pub u8);

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FrameIndex(pub usize);

// 文字列ベースの newtype(Copy 不可)
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Filename(pub String);

// f64 ベース(NaN があるので Eq/Hash/Ord 不可)
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct TimeMs(pub f64);

4-1. 各 derive の役割

derive何ができる
Debug{:?} で表示println!("{:?}", ch)
Clone.clone() で複製let ch2 = ch.clone();
Copy値渡しが暗黙にコピー(軽量型のみ)let ch2 = ch; で元も使える
PartialEq, Eq== 比較if ch == Channel(0) { ... }
HashHashMap/HashSet のキーに使えるHashMap<Channel, T>
PartialOrd, Ord<, >, sort()channels.sort();
Copy を付けるかどうかの目安: 中身が整数・bool・char などの軽量型なら Copy 推奨。 StringVec を包む newtype には Copy を付けられません(ヒープ確保があるため)。

4-2. f64 特有の罠

f64NaN != NaN という性質があるため、Rust は Eq, Hash, Ord を自動で実装しません。TimeMsHashMap のキーにしたければ、別途 wrapper や ordered-float クレートを使うことになります。

5. ビフォーアフター — 型壁の効果

Before: 裸の数値

fn get_frames(ch: u8, frame: usize, num_ch: usize) -> Array2<i16> { ... }

get_frames(2, 100, 1);   // どの引数がどれ?
get_frames(100, 2, 1);   // ← 順序ミス、でもコンパイルOK
                            //   実行時に「無効な channel = 100」で爆発

After: Newtype で意味を持たせる

fn get_frames(ch: Channel, frame: FrameIndex, num_ch: NumFluoCh) -> Array2<i16> { ... }

get_frames(Channel(2), FrameIndex(100), NumFluoCh(1));   // 意図が明確 ✓
get_frames(FrameIndex(100), Channel(2), NumFluoCh(1));   // ← コンパイルエラー!
// error[E0308]: mismatched types
//   expected `Channel`, found `FrameIndex`
Before: 裸の u8 / usize u8: 2 usize: 100 usize: 1 get_frames(_, _, _) どれがどれか型では分からない ✕ 順序ミスを検出できない 実行時に運が悪ければ気付かないまま動く After: Newtype で型壁 Channel(2) FrameIndex(100) NumFluoCh(1) get_frames( ch: Channel, frame: FrameIndex, num: NumFluoCh) ✓ 順序ミスはコンパイルエラー FrameIndex を Channel の口に入れようとすると即拒否 実行時のメモリレイアウトは完全に同じ。型壁はコンパイラだけが見ている世界。 zero-cost abstraction
図 3: 裸の数値 (Before) と Newtype (After) で、関数引数への接続が型でフィルタされる様子。

6. 中身の取り出し方

let ch = Channel(2);

// タプル構造体なので .0 でアクセス
let inner: u8 = ch.0;

// メソッドを定義しておくと意図が明確
impl Channel {
    pub fn value(&self) -> u8 { self.0 }
}

let n: u8 = ch.value();

6-1. Deref を実装するとさらに自然

std::ops::Deref を実装すると、*ch や暗黙の参照解決で中身にアクセスできるようになります:

use std::ops::Deref;

impl Deref for Channel {
    type Target = u8;
    fn deref(&self) -> &u8 { &self.0 }
}

let ch = Channel(2);
let n: u8 = *ch;  // 暗黙の deref で u8 を取得
Deref の濫用注意: Deref を実装すると、中身の型のメソッドが あたかも newtype 自身のメソッドのように呼べてしまい、newtype の壁が緩くなります。 原則として .value() メソッドや .0 を使い、DerefString から &str のような「明らかに親子関係がある型」でだけ使うのが安全です。

7. バリデーション付きコンストラクタ

内側のフィールドを 非 pub にすると、Channel::new() を通さないと値が作れなくなります。 これでバリデーションを強制できます:

// フィールドは pub にしない(外から構築できない)
pub struct Channel(u8);

impl Channel {
    pub fn new(n: u8) -> Result<Self, &'static str> {
        if n < 8 {
            Ok(Channel(n))
        } else {
            Err("Channel must be 0..7")
        }
    }

    pub fn value(&self) -> u8 { self.0 }
}

使う側:

let ch = Channel::new(2)?;     // OK
let bad = Channel::new(99);   // Err("Channel must be 0..7")

let raw = Channel(99);
//        ^^^^^^^^^^^^
// error[E0603]: tuple struct constructor `Channel` is private
u8: 2 u8: 99 Channel::new(n) バリデーション関門 if n < 8 { Ok(Channel(n)) } else { Err("Channel must be 0..7") } ↓ 通過判定 ↓ Ok(Channel(2)) バリデーション済みの値 Err("Channel must be 0..7") 値は作られない Channel 型の値は「常にバリデーション済み」と型で保証される フィールドを pub にしない (= Channel(99) を直接書けない) のがポイント
図 4: バリデーション付きコンストラクタの関門イメージ。Channel 型の値が存在するだけで「正しい範囲」が保証される。

8. ScanDataNet で定義する Newtype 一覧

Phase L1 / L2 の着手時に最初に定義しておくと、後の全コードがクリーンになります:

Newtype内側の型用途バリデーション
Channelu8蛍光・電気生理のチャネル番号0..8
FrameIndexusizeフレーム番号不要(usize で十分)
NumFluoChu8蛍光チャネル総数1..=2
TimeMsf64時刻 (ミリ秒)非負・有限
SampleRatef64サンプリング周波数 (Hz)正の有限値
FilenameStringファイル名(パス分解後)空でない
FilePathPathBufフルパス不要
RoiIdu32ROI の識別子不要
ModifierName&'static str または EnumModifier の名前有効な値のみ
ModifierName は Enum 推奨enum ModifierKind { TimeWindow, Roi, Average, ... } にすれば、 ScanDataPy の if "Roi" in name のような部分一致分岐が match m { ModifierKind::Roi => ... } に置き換わり、新種追加時にコンパイラが網羅性をチェックします。

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

段階やること目安時間
1 struct Channel(u8) を 1 個作って Channel(2) で値を作る 15 分
2 derive(Debug, Clone, Copy) を付けて println!("{:?}", ch) で表示 15 分
3 関数の引数を全部 newtype に書き換える(小さな関数で練習) 30 分
4 故意に順序を入れ替えてコンパイルエラーを出してみる 10 分
5 Channel, FrameIndex, TimeMs, Filename を ScanDataNet の scandata-domain/src/types/ に定義 1 時間
6 バリデーション付き new() を書く (Result 返却) 1 時間
7 ModifierKind を Enum で定義し、match で網羅性チェック 1 時間

10. 落とし穴

10-1. Copy を付けるかどうか

状況Copy
u8, usize, f64, bool を包む付ける
String, Vec<T>, PathBuf を包む付けない(不可能)
&'static str を包む付ける

10-2. 演算は付かない

Channel(2) + Channel(3)書けません。 本当に必要な演算だけを明示的に実装します:

use std::ops::Add;

// FrameIndex 同士の足し算には意味がある
impl Add<usize> for FrameIndex {
    type Output = FrameIndex;
    fn add(self, rhs: usize) -> FrameIndex {
        FrameIndex(self.0 + rhs)
    }
}

// Channel 同士は足してはいけない(意味がない)→ 実装しない
意味のある演算だけ」を実装するのが newtype の真価。 「全部足し算できる方が便利」と思って何でも実装すると、せっかくの型壁が緩みます。

10-3. From / Into の自動変換に注意

impl From<u8> for Channel を実装すると、暗黙の変換が起こりやすくなり、 バリデーションを通さずに値が作れてしまう可能性があります。 バリデーションを強制したいときは From を実装しないのが安全です。

10-4. 内部 pub の閉じ忘れ

// バリデーションを書いたが…
pub struct Channel(pub u8);  // ← pub のまま!
impl Channel {
    pub fn new(n: u8) -> Result<Self, ...> { ... }
}

// 外から普通に作れてしまう(バリデーション意味なし)
let ch = Channel(99);  // なんと通る!
バリデーションを強制したいときは内側 pub を外すのを忘れずに。 これは cargo clippy でも検出されないので、レビューで意識して見る必要があります。

11. まとめ

次のページ: im クレートによる永続データ構造。
Phantom Type が「データの状態」、Newtype が「値の意味」を型表現するのに対して、 im は「コレクション全体」を不変にして、Repository 全体を関数型に書き直す道具です。