Day 014 — 演算子オーバーロード(std::ops) — Add実装のムーブと所有権

2026-08-11 🔵 中級者 / Phase 2 デバッグ・コードレビュー 演算子オーバーロード(std::ops)

📚 背景知識(読んでから問題へ)

Rustでは+-*などの演算子は、実はstd::opsモジュールで定義されたトレイトAddSubMulなど)への糖衣構文(syntax sugar)です。自分の型にAddトレイトを実装すれば、+演算子をその型に対して使えるようになります。

use std::ops::Add;

#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point; // 演算結果の型(関連型)

    fn add(self, other: Point) -> Point {
        Point {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

Addトレイトの定義は次のようになっています(標準ライブラリより抜粋)。

pub trait Add<Rhs = Self> {
    type Output;
    fn add(self, rhs: Rhs) -> Self::Output;
}

ここで注目すべきはfn add(self, rhs: Rhs)両方の引数が参照ではなく値であることです。つまりa + bと書いた瞬間、abadd関数にムーブ(所有権が移動)します。これは他言語の演算子オーバーロード(C++のoperator+はデフォルトでconst&を取ることが多い、Javaは演算子オーバーロード自体が存在しない等)と比べて、Rustの所有権システムがここにも一貫して適用されている例です。

i32f64のようなCopy型であれば「ムーブ」と「コピー」が実質同じ動作になるため、この事実を意識しなくても問題は起きません。しかし、Stringやヒープ確保を伴う独自構造体のようなCopyでこれを行うと、a + bの後にaを再度使おうとしてムーブ済みの値を使おうとするエラー(E0382)が発生します。これは実際にString + &str(標準ライブラリのimpl Add<&str> for String)でも起きる、Rustではよく知られた挙動です。

let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // s1 はここでムーブされる(&s2 は借用なので s2 は生き残る)
// println!("{}", s1); // ← コンパイルエラー: s1 は既にムーブ済み

📝 問題

以下は「金額(円単位・整数)」を表すMoney型に+演算子を実装しようとしたコードです。このコードはコンパイルエラーになります。

use std::ops::Add;

#[derive(Debug)]
struct Money {
    yen: i64,
}

impl Add for Money {
    type Output = Money;

    fn add(self, other: Money) -> Money {
        Money {
            yen: self.yen + other.yen,
        }
    }
}

fn main() {
    let price_a = Money { yen: 1200 };
    let price_b = Money { yen: 3400 };

    let total = price_a + price_b;

    println!("price_a: {:?}", price_a); // ここでコンパイルエラー
    println!("price_b: {:?}", price_b); // ここでもコンパイルエラー
    println!("total: {:?}", total);
}

要件

  1. なぜこのコードがコンパイルエラーになるのか、Addトレイトのシグネチャに基づいて説明してください
  2. main関数の意図(price_aprice_btotalの3つをすべて表示したい)を保ったまま、コンパイルが通るように2通りの修正方法を実装してください
    • 修正案A: Money#[derive(Clone, Copy)]を追加し、値をコピーで扱えるようにする方法
    • 修正案B: Moneyをコピーせず、&Money同士の加算(impl Add for &Money)を追加し、参照のまま&price_a + &price_bで計算する方法
  3. 修正案Aと修正案Bのどちらが「実務でのMoney型」として適切か、トレードオフも含めて考察してください(Moneyのようなドメイン型は今後フィールドが増える可能性がある、という前提で検討してください)
  4. #[cfg(test)]のテストで、修正後のコードが正しく動くことを検証してください

🔍 ヒント(段階的開示)

ヒント1 — 方向性
  • エラーメッセージの中心は「price_aが既にムーブされている」という趣旨の文言です。price_a + price_bという式が、内部的にどんな関数呼び出しに展開されるかをAddトレイトの定義に立ち返って考えてください
  • Moneyderiveが何も付いていないただの構造体です。i32のような組み込みの数値型とは違い、暗黙のコピーは起きません
ヒント2 — アプローチ
  • 修正案A: #[derive(Debug, Clone, Copy)]Moneyに付けるだけで、price_a + price_bは「コピーをaddに渡す」動作になり、元のprice_aprice_bは生き残ります
  • 修正案B: impl Add for &Moneyself: &Moneyother: &Moneyを受け取りMoneyを返す)を追加で実装し、呼び出し側は&price_a + &price_bのように参照同士を足します。参照を渡すだけなので所有権は移動しません
  • 標準ライブラリのi32impl Add<i32> for i32(値渡し)とimpl Add<&i32> for i32のような複数の組み合わせを用意しています。1つの型に対して複数のimpl Add<...>を持たせることができます
ヒント3 — コード骨格
// 修正案A
#[derive(Debug, Clone, Copy)]
struct Money {
    yen: i64,
}

impl Add for Money {
    type Output = Money;
    fn add(self, other: Money) -> Money {
        Money { yen: self.yen + other.yen }
    }
}

// 修正案B(Aとは別のMoney定義として、またはAに追記する形で)
impl Add for &Money {
    type Output = Money;
    fn add(self, other: &Money) -> Money {
        Money { yen: self.yen + other.yen }
    }
}
// 呼び出し側: let total = &price_a + &price_b;

模範解答

use std::ops::Add;

// 修正案A: Copy を導出し、値渡しでもムーブが起きない(実質コピー)ようにする
#[derive(Debug, Clone, Copy, PartialEq)]
struct MoneyCopy {
    yen: i64,
}

impl Add for MoneyCopy {
    type Output = MoneyCopy;

    fn add(self, other: MoneyCopy) -> MoneyCopy {
        MoneyCopy {
            yen: self.yen + other.yen,
        }
    }
}

// 修正案B: Copy を導出せず、&Money 同士の加算を追加で実装する
#[derive(Debug, PartialEq)]
struct MoneyRef {
    yen: i64,
}

impl Add for &MoneyRef {
    type Output = MoneyRef;

    fn add(self, other: &MoneyRef) -> MoneyRef {
        MoneyRef {
            yen: self.yen + other.yen,
        }
    }
}

fn main() {
    // 修正案A: 値のまま + が使える(price_a, price_b は暗黙のコピーで生き残る)
    let price_a = MoneyCopy { yen: 1200 };
    let price_b = MoneyCopy { yen: 3400 };
    let total_a = price_a + price_b;
    println!("[案A] price_a: {:?}", price_a);
    println!("[案A] price_b: {:?}", price_b);
    println!("[案A] total: {:?}", total_a);

    // 修正案B: 参照同士で + を使う(price_a, price_b の所有権は移動しない)
    let price_c = MoneyRef { yen: 1200 };
    let price_d = MoneyRef { yen: 3400 };
    let total_b = &price_c + &price_d;
    println!("[案B] price_c: {:?}", price_c);
    println!("[案B] price_d: {:?}", price_d);
    println!("[案B] total: {:?}", total_b);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn copy_variant_keeps_operands_usable() {
        let a = MoneyCopy { yen: 500 };
        let b = MoneyCopy { yen: 700 };
        let total = a + b;
        // Copy のおかげで a, b はこの後も使える
        assert_eq!(a.yen, 500);
        assert_eq!(b.yen, 700);
        assert_eq!(total, MoneyCopy { yen: 1200 });
    }

    #[test]
    fn ref_variant_keeps_operands_usable() {
        let a = MoneyRef { yen: 500 };
        let b = MoneyRef { yen: 700 };
        let total = &a + &b;
        // 参照で渡したので a, b の所有権は移動していない
        assert_eq!(a.yen, 500);
        assert_eq!(b.yen, 700);
        assert_eq!(total, MoneyRef { yen: 1200 });
    }
}
▶ 実行結果を見る(cargo run / cargo test)
$ cargo run
[案A] price_a: MoneyCopy { yen: 1200 }
[案A] price_b: MoneyCopy { yen: 3400 }
[案A] total: MoneyCopy { yen: 4600 }
[案B] price_c: MoneyRef { yen: 1200 }
[案B] price_d: MoneyRef { yen: 3400 }
[案B] total: MoneyRef { yen: 4600 }

$ cargo test
running 2 tests
test tests::copy_variant_keeps_operands_usable ... ok
test tests::ref_variant_keeps_operands_usable ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

🪜 Step-by-Step 解説

1
なぜ元のコードがコンパイルエラーになるのかをAddトレイトの定義から突き止める
self&selfではなくself(値渡し)です。つまりprice_a + price_bという式は、コンパイラの内部ではAdd::add(price_a, price_b)のような呼び出しに展開されます。MoneyCopyを実装していない限り、price_aprice_bの所有権はadd関数の中に移動し、呼び出し元では二度と使えなくなります。
2
修正案A — Copyを導出する
Copyを実装した型は「値渡し=暗黙のコピー」になるため、price_a + price_bprice_aprice_baddに渡されても、渡されるのはビット単位のコピーであり、元の変数はそのまま有効であり続けます。Money { yen: i64 }のようにヒープを持たないシンプルな型はCopyにする典型的な候補です。
3
修正案B — &Money同士の加算を追加実装する
impl Add for &MoneyRef {
    type Output = MoneyRef;
    fn add(self, other: &MoneyRef) -> MoneyRef {
        MoneyRef { yen: self.yen + other.yen }
    }
}
ここでのSelf&MoneyRef(参照型そのもの)です。self: &MoneyRefother: &MoneyRefはどちらも借用なので、&price_c + &price_dと書いても所有権は一切移動しません。MoneyRef自体にはCopyを実装していない(=将来ヒープ確保フィールドを持たせても崩れない)まま、演算子オーバーロードだけを実現できます。
4
2つのテストで「所有権が生き残ること」自体を検証する
let total = a + b; assert_eq!(a.yen, 500);というテストの本質は「totalの値が正しい」ことだけでなく、「abが加算後も使える」こと自体を検証している点です。もし修正が不十分なら、このassert_eq!の行自体がコンパイルエラーになり、テスト実行前のcargo buildの段階で失敗が判明します。

💡 設計思想・なぜこう書くのか

📌
演算子オーバーロードも所有権システムの外側にある特別ルールではない: Addトレイトがselfを値渡しにしているのは、Rustが「+という一見特別に見える構文の裏側も、ただのトレイトメソッド呼び出しとして扱う」ことで、言語機能としての特別扱いを最小限にする設計哲学を貫いているためです。C++はoperator+をデフォルトでconst T&引数にすることが多く「暗黙に借用される」挙動が普通ですが、Rustは通常の関数呼び出しと同じムーブ規則を演算子にもそのまま適用しています。
⚠️
値渡しは「バグ」ではなく「意図された設計」: 標準ライブラリのString + &strがまさにこの挙動を採用しており、「文字列連結後は元のStringを使わせない」ことで、無駄なクローンを暗黙に発生させず、呼び出し側に「これは所有権を消費する操作である」ことを型システムのレベルで伝えています。今回の課題は「自分のドメイン型がどちらの挙動(消費か・参照のままか)を必要としているか」を設計判断として選び取る練習でもあります。

🛑 コンパイルエラーが出た場合

元のコード(deriveなし、参照実装なしのMoney)をコンパイルすると、次のようなエラーが出ます。

error[E0382]: borrow of moved value: `price_a` --> src/main.rs:24:31 | 19 | let price_a = Money { yen: 1200 }; | ------- move occurs because `price_a` has type `Money`, which does not implement the `Copy` trait ... 22 | let total = price_a + price_b; | ----------------- `price_a` moved due to usage in operator 23 | 24 | println!("price_a: {:?}", price_a); // ここでコンパイルエラー | ^^^^^^^ value borrowed here after move | note: if `Money` implemented `Clone`, you could clone the value --> src/main.rs:4:1 | 4 | struct Money { | ^^^^^^^^^^^^ consider implementing `Clone` for this type ... 22 | let total = price_a + price_b; | ------- you could clone this value note: calling this operator moves the left-hand side --> /rustc/.../library/core/src/ops/arith.rs:91:11 error[E0382]: borrow of moved value: `price_b` --> src/main.rs:25:31 | 20 | let price_b = Money { yen: 3400 }; | ------- move occurs because `price_b` has type `Money`, which does not implement the `Copy` trait 21 | 22 | let total = price_a + price_b; | ------- value moved here ... 25 | println!("price_b: {:?}", price_b); // ここでもコンパイルエラー | ^^^^^^^ value borrowed here after move | note: if `Money` implemented `Clone`, you could clone the value --> src/main.rs:4:1 | 4 | struct Money { | ^^^^^^^^^^^^ consider implementing `Clone` for this type ... 22 | let total = price_a + price_b; | ------- you could clone this value For more information about this error, try `rustc --explain E0382`.

読み方:

  • borrow of moved value: 'price_a': price_aは既に別の場所(price_a + price_bの式)でムーブ済みであり、それ以降の参照・使用はできないという意味です
  • move occurs because 'price_a' has type 'Money', which does not implement the 'Copy' trait: コンパイラが「なぜムーブが起きたか」の根本原因まで教えてくれています。MoneyCopyでないため、値渡しは即ムーブになったということです
  • `price_a` moved due to usage in operator: +演算子(=Add::addの呼び出し)が原因でムーブが起きたことを、コンパイラが演算子だと名指しで教えてくれています
  • note: calling this operator moves the left-hand side: 標準ライブラリのcore::ops::arith内の実装箇所を指し、「この演算子は左辺をムーブする」という事実そのものがRustの言語仕様であることを示しています
  • note: if 'Money' implemented 'Clone', you could clone the value: コンパイラは.clone()というその場しのぎの修正案も提示しますが、これはMoneyCloneが実装されていることが前提であり、かつ毎回クローンのコストが発生します。「そもそも安価な型ならCopyにする」「所有権を渡したくないなら&で演算する」という設計判断の方が根本的な解決になります

修正方法: 修正案Aまたは修正案Bのいずれかを適用する(本問題では両方を実装して比較する)。

🌐 他言語との比較

観点RustGoJavaC++TypeScript
演算子オーバーロードの可否std::opsの各トレイト(Add/Sub/Mul等)を実装すれば+/-/*が使える演算子オーバーロードという言語機能自体が存在しない(意図的に排除されている)演算子オーバーロードは存在しない(String+のみ言語組み込みの特例)operator+などのメンバ関数/フリー関数として自由に定義可能。歴史的に濫用されやすいと批判されることも多い演算子オーバーロードは存在しない(Symbol.iteratorなどプロトコルベースの部分的な代替のみ)
デフォルトの引数の受け方fn add(self, rhs: Rhs)値渡しがデフォルト。所有権が移動する該当なし該当なしT operator+(const T& other) const のように参照渡しがデフォルトの慣習(値渡しも可能だが非効率とされる)該当なし
「消費するか・借用するか」の型システムでの表現impl Add for T(消費)とimpl Add for &T(借用)を別々のimplとして両方定義できる。呼び出し側の書き方で選択該当なし該当なし参照渡しがデフォルトのため、通常は「借用」のみが選択肢になり、明示的に値渡しにしない限り所有権の概念自体が薄い該当なし

Rust以外の多くの言語では「演算子オーバーロードそのものを禁止する」(Go・Java・TypeScript)か、「参照渡しをデフォルトにして所有権の移動という概念自体を薄める」(C++)ことで、今回のような「演算後に元の変数が使えなくなる」という問題を回避しています。Rustが値渡しをデフォルトにしているのは、「所有権を移動させる操作なのか、借用で済む操作なのか」を演算子レベルでも型システムに正直に反映させるためであり、その代償として今回のようなムーブエラーに遭遇することになりますが、これは「バグを未然にコンパイル時に検出している」というRustの一貫した設計思想の表れでもあります。

🏆 実務での使いどころ

  • ドメイン型の四則演算: 金額(Money)・ベクトル演算(Vector3)・座標変換など、業務ロジック上「型どうしの演算」が頻出するドメインでは、std::opsの実装によってtotal = price_a + price_b + shipping_feeのような直感的な式がそのまま書けるようになります
  • Copy vs 参照実装の判断: 「フィールドが小さくコピーコストが無視できるならCopy、将来ヒープ確保フィールド(StringVec等)を持つ可能性があるなら参照実装」という判断基準は、実務でドメイン型を設計する際に毎回問われる問題です
  • AddAssign+=)との組み合わせ: 実務ではtotal += priceのような累積処理も頻出するため、Addと合わせてstd::ops::AddAssignfn add_assign(&mut self, rhs: Rhs)、こちらは&mut selfを取るため所有権の問題が起きにくい)を実装するケースも多くあります

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
a + bのあとにaが使えなくなるのを「バグ」だと思い込むC++/Java感覚では演算子は暗黙に借用されるものという先入観があるAdd::add(self, rhs: Rhs)は値渡しが標準シグネチャであり、Copyでない型に対して+を使えば所有権が移動するのはRustの一貫した仕様String + &strと同じ挙動)
.clone()をとりあえず付けて回避し、それで「解決した」と考えるコンパイラのhelpメッセージがそのまま.clone()を提案してくるため、一番手軽な回避策に見える.clone()は毎回コピーコストが発生する対症療法。型が小さいならCopy導出、参照のまま演算したいならimpl Add for &Tを追加する方が設計として筋が良い場合が多い
impl Add for Timpl Add for &Tは「同じ意味の実装を二重に書いている」と感じて片方しか実装しないどちらも同じ+という記号を使うため、区別する必要性を感じにくい呼び出し側がa + b(所有権消費)と書くか&a + &b(借用のみ)と書くかで、コンパイラはどちらのimplを使うか自動的に選択する。両方揃えておくことで、呼び出し側に選択肢を与えられる

🚀 次のステップ

  • 発展: MoneyRefAddAssign+=)を追加実装し、let mut total = MoneyRef { yen: 0 }; total += &delta;のような累積処理を書いてみてください(&mut selfを使うため、今回のムーブ問題がそもそも起きないことも確認してください)
  • 次回予告: Day 015ではDeref/DerefMutトレイトを扱います。スマートポインタが*演算子や自動参照外しでどのように「中身の型のように振る舞う」のかを学びます

🎯 自己評価

自分の回答

気づき・メモ