「裸の数値・文字列を、意味を持った型で包む」パターンです。
u8 ではなく Channel(u8)usize ではなく FrameIndex(usize)String ではなく Filename(String)たった 1 行の宣言で、意味の取り違えをコンパイル時に弾くことができます。 パフォーマンスのオーバーヘッドはゼロ(中身は元の型と同じ)。
std ライブラリ自体も std::time::Duration
や std::process::ExitCode など随所で採用しています。
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 のどれが「チャネル」で「フレーム番号」で「チャネル総数」か、
呼ぶたびに思い出さないといけません。
IMPROVEMENT_PLAN.md で報告されている関連問題print_infor / expriments / undefineded などの typo が公開 API・JSON キーに混入data_tag が dict[str, str]。文字列の in 部分一致 (if "Roi" in name) で分岐Repository.find_by_keys が set(values()) で集合一致比較していて、key-pair を見ていないこれらは「裸の文字列・整数」を使っているせいで、コンパイラが助けてくれない典型例です。
Rust のタプル構造体(フィールドに名前をつけない構造体)を使うのが最もシンプルです:
pub struct Channel(pub u8);
pub struct FrameIndex(pub usize);
pub struct NumFluoCh(pub usize);
pub struct Filename(pub String);
これだけで Channel と FrameIndex は別の型になり、混ぜて使うとコンパイルエラーになります。
pub が 2 つあるのはなぜ?pub struct Channel(pub u8);
// ^^^ ^^^
// 外側 pub 内側 pub
| 位置 | 意味 |
|---|---|
外側 pub | 型 Channel 自体を他のモジュールから使える |
内側 pub | フィールド .0(中身の u8)を外から直接読み書きできる |
バリデーションを強制したいときは内側を pub にしないことで、
「Channel の値を作るには必ず Channel::new(...) を通せ」と強制できます(後述)。
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);
| derive | 何ができる | 例 |
|---|---|---|
Debug | {:?} で表示 | println!("{:?}", ch) |
Clone | .clone() で複製 | let ch2 = ch.clone(); |
Copy | 値渡しが暗黙にコピー(軽量型のみ) | let ch2 = ch; で元も使える |
PartialEq, Eq | == 比較 | if ch == Channel(0) { ... } |
Hash | HashMap/HashSet のキーに使える | HashMap<Channel, T> |
PartialOrd, Ord | <, >, sort() | channels.sort(); |
Copy を付けるかどうかの目安: 中身が整数・bool・char などの軽量型なら Copy 推奨。
String や Vec を包む newtype には Copy を付けられません(ヒープ確保があるため)。
f64 特有の罠
f64 は NaN != NaN という性質があるため、Rust は Eq, Hash, Ord
を自動で実装しません。TimeMs を HashMap のキーにしたければ、別途 wrapper や ordered-float
クレートを使うことになります。
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」で爆発
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`
let ch = Channel(2);
// タプル構造体なので .0 でアクセス
let inner: u8 = ch.0;
// メソッドを定義しておくと意図が明確
impl Channel {
pub fn value(&self) -> u8 { self.0 }
}
let n: u8 = ch.value();
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 を使い、Deref は String から &str
のような「明らかに親子関係がある型」でだけ使うのが安全です。
内側のフィールドを 非 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
Channel 型の値が存在するだけで「正しい範囲」が保証される。
Phase L1 / L2 の着手時に最初に定義しておくと、後の全コードがクリーンになります:
| Newtype | 内側の型 | 用途 | バリデーション |
|---|---|---|---|
Channel | u8 | 蛍光・電気生理のチャネル番号 | 0..8 |
FrameIndex | usize | フレーム番号 | 不要(usize で十分) |
NumFluoCh | u8 | 蛍光チャネル総数 | 1..=2 |
TimeMs | f64 | 時刻 (ミリ秒) | 非負・有限 |
SampleRate | f64 | サンプリング周波数 (Hz) | 正の有限値 |
Filename | String | ファイル名(パス分解後) | 空でない |
FilePath | PathBuf | フルパス | 不要 |
RoiId | u32 | ROI の識別子 | 不要 |
ModifierName | &'static str または Enum | Modifier の名前 | 有効な値のみ |
enum ModifierKind { TimeWindow, Roi, Average, ... } にすれば、
ScanDataPy の if "Roi" in name のような部分一致分岐が match m { ModifierKind::Roi => ... }
に置き換わり、新種追加時にコンパイラが網羅性をチェックします。
| 段階 | やること | 目安時間 |
|---|---|---|
| 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 時間 |
Copy を付けるかどうか| 状況 | Copy |
|---|---|
u8, usize, f64, bool を包む | 付ける |
String, Vec<T>, PathBuf を包む | 付けない(不可能) |
&'static str を包む | 付ける |
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 同士は足してはいけない(意味がない)→ 実装しない
From / Into の自動変換に注意
impl From<u8> for Channel を実装すると、暗黙の変換が起こりやすくなり、
バリデーションを通さずに値が作れてしまう可能性があります。
バリデーションを強制したいときは From を実装しないのが安全です。
// バリデーションを書いたが…
pub struct Channel(pub u8); // ← pub のまま!
impl Channel {
pub fn new(n: u8) -> Result<Self, ...> { ... }
}
// 外から普通に作れてしまう(バリデーション意味なし)
let ch = Channel(99); // なんと通る!
cargo clippy でも検出されないので、レビューで意識して見る必要があります。
derive マクロで Debug, Clone, Copy, Eq, Hash 等を自動実装Deref や From の濫用で型壁を緩めないChannel, FrameIndex, TimeMs, Filename 等を最初に揃えるim クレートによる永続データ構造。im は「コレクション全体」を不変にして、Repository 全体を関数型に書き直す道具です。