rustを学んで④

·
#CLI#Rust#Terminal#rustlings

Rustの学習記録:エラー処理からトレイトまで

はじめに

Rustの学習を進める中で、いくつか疑問に思ったことや「なるほど!」と思った瞬間があったので、備忘録としてまとめておきます。初学者目線での気づきなので、同じような疑問を持った方の参考になれば幸いです。

1. dead_code警告について

疑問: この警告って何?

Rustでコードを書いていると、こんな警告が出ることがあります。なんというか、せっかく明示的に書いたとしても…:

warning: fields `x` and `y` are never read
 --> src/main.rs:5:12
  |
5 |     Move { x: i32, y: i32 },
  |     ----   ^       ^

答え: 使われていないフィールドを教えてくれる

これはdead code warningと呼ばれるもので、「定義されているけど実際には使われていないフィールド」をコンパイラが検出して教えてくれています。

例えば、以下のコードではMessageのバリアント内のフィールドを定義していますが、それらの値を読み取っていません:

#[derive(Debug)]
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

impl Message {
    fn show_this_message(&self) {
        println!("{:?}", self);  // Debugで表示しているだけ
    }
}

Debugトレイトを使って全体を表示しているだけで、個々のフィールド(x, y, Stringなど)にアクセスしていないため、警告が出ます。

解決方法

実際にフィールドの値を使うコードを書けば警告は消えます:

impl Message {
    fn show_this_message(&self) {
        match self {
            Message::Quit => println!("Quit"),
            Message::Move { x, y } => println!("Move to ({}, {})", x, y),
            Message::Write(text) => println!("Write: {}", text),
            Message::ChangeColor(r, g, b) => println!("Color: RGB({}, {}, {})", r, g, b),
        }
    }
}

このようにmatchでパターンマッチングして各フィールドを使えば、「読み取られていない」という警告は消えます。

2. stdって何? C言語との違い

疑問: use stdって何? C言語のstdinと関係ある?

RustでI/Oやコレクションを使うとき、よくuse std::という記述を見かけます。C言語でもstdinとか出てきますよね。しっかりやったわけじゃなくて、なんか見かけるじゃないですか?

答え: stdは標準ライブラリ全体

stdは standard library(標準ライブラリ)の略です。Rustに最初から組み込まれている、プログラミングでよく使う機能がまとまっています。

use std::io;           // 入出力機能
use std::fs;           // ファイルシステム操作
use std::collections;  // HashMap, VecDequeなどのデータ構造
use std::time;         // 時間関連

C言語との違いはこうです:

C言語の場合:

  • stdin = standard input(標準入力)そのもの
  • stdout = standard output(標準出力)そのもの

Rustの場合:

  • std = standard library(標準ライブラリ全体)
  • std::io::stdin() = 標準入力を扱う関数

つまり、stdは「Rustが提供する標準的な機能の集まり」で、その中に入出力(io)やファイル操作(fs)などが含まれているイメージです。

3. 参照外し(dereferencing)とイテレータ

疑問: for &number in list&って何?

イテレータを使うとき、よくこんなコードを見かけます:

let mut numbers = vec![1, 2, 3];

for num in numbers.iter_mut() {
    *num += 10;  // *で参照を外している
}

答え: 参照を外して実体にアクセスする

&で参照を作ったら、*で参照を外して実体にアクセスできます:

let mut x = 5;
let y = &mut x;  // 可変参照を作る
*y += 1;         // *で参照を外して、実体を変更
println!("{}", x);  // 6

イテレータでは、iter_mut()が可変参照のイテレータを返すので、値を変更するには*が必要です:

// num は &mut i32 型(i32への可変参照)
// *num で参照を外すと i32 型(実体)になる
for num in numbers.iter_mut() {
    *num += 10;  // 実体を変更するには*が必要
}

シンタックスシュガー: &mut numbers

実は、こう書くこともできます:

// この2つは同じ意味!
for num in numbers.iter_mut() { *num += 10; }
for num in &mut numbers { *num += 10; }

for num in &mut numbersは、Rustが内部的に自動でnumbers.iter_mut()を呼んでくれる糖衣構文(シンタックスシュガー)です。後から流入してきた人は、この2つが同じ意味だと知らないと混乱しがちですね。jsでもそうだけど、後方互換で今でも使えて残っている構文と、わかりやすくなった構文では大体わけわからなくなる。シュガーだよなぁ…。

4. Rustのエラーハンドリング - 例外がない言語

疑問: ResultOptionって、try-catchみたいなもの?

RustにはJavaScriptのtry-catchのような例外処理がありません。代わりにResultOption型を使います。

答え: エラーを型システムで表現する

Rustは例外(exception)を持たない言語です。エラーを型システムで表現して、コンパイラが「エラー処理忘れてますよ!」って教えてくれます:

// Result型で成功/失敗を明示的に返す
fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        Err("ゼロ除算".to_string())
    } else {
        Ok(a / b)
    }
}

// 使う側は必ずエラー処理を書かされる
match divide(10, 0) {
    Ok(result) => println!("{}", result),
    Err(e) => println!("エラー: {}", e),
}

JavaScriptのtry-catchとの比較

JavaScriptのtry-catch最初からあった機能ではありません。ES3(1999年)で追加されました。

RustJavaScript
方式Result/Option型(値として返す)try-catch(例外を投げる)
チェックコンパイル時に強制実行時まで分からない
哲学エラーは予測可能な値エラーは例外的な出来事

Rustの2種類のエラー処理

1. 回復可能なエラー → Result

// ファイルが見つからない? → 別のファイルを試せばいい
match File::open("config.txt") {
    Ok(file) => { /* 処理 */ },
    Err(_) => { /* 代替ファイルを開く */ },
}

2. 回復不可能なエラー → panic!

// プログラムのバグ → 即座に停止すべき
let v = vec![1, 2, 3];
v[99];  // パニック! これは明らかにバグ

本質的な気づき

エラーありきでプログラムって作られているんだな

当たり前なことで、プログラミングの世界では:

  • ファイルが存在しないかもしれない
  • ネットワークが切れるかもしれない
  • ユーザーが変な入力をするかもしれない

エラーは例外的なことではなく、普通に起こることなんです。良い設計とは「エラーが起きないプログラム」ではなく、「エラーが起きても適切に対処できるプログラム」を作ることです。

5. 参照とコピー - largest関数の所有権

疑問: この関数、新しい値を返してるの?

fn largest(list: &[i32]) -> i32 {
    let mut largest = list[0];
    for &number in list {
        if number > largest {
            largest = number;
        }
    }
    largest
}

全て参照を持ってきているのに、なぜコピーした値を返せるの?

答え: i32はCopyトレイトを実装している

ステップごとに見ると:

  1. list: &[i32] - リストへの参照を借りる(所有権は移動しない)
  2. list[0] - 参照を通じて最初の値にアクセスし、i32をコピー
  3. for &number in list - &numberでパターンマッチして各要素をコピー
  4. largestを返す - コピーした値を返す

i32Copyトレイトを実装しているため、代入や関数の引数渡しで自動的にコピーされます。

let x = 5;
let y = x;  // i32はコピーされる。xもyも使える

もしStringだったら?

StringはCopyトレイトを持たないので、参照を返す必要があります:

fn largest(list: &[String]) -> &String {
    let mut largest = &list[0];  // 参照のまま
    for item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

まとめ: 参照して比較してコピーで新しいもの

  • 引数は参照で受け取る → 所有権を奪わない
  • i32は小さいのでコピーして返す → 新しい値
  • 参照で受け取っても、中の値がCopyなら気軽にコピーできる

i32のコピーは超高速(スタック上の4バイトをコピーするだけ)なので、パフォーマンスを気にする必要はありません。

6. トレイト - 型をまたいだ共通の能力

疑問: トレイトって何?

トレイト(trait)は、最初は分かりにくい概念です。でも、他の言語の概念に例えると理解しやすくなります。

答え: 「この型はこういう機能を持っている」という約束

他の言語でいうと:

  • JavaやTypeScriptのインターフェースに近い
  • Goのインターフェースにも似ている

具体例: Summaryトレイト

// トレイト = 「要約できる能力」を定義
pub trait Summary {
    fn summarize(&self) -> String;
}

// ニュース記事に「要約できる能力」を実装
pub struct NewsArticle {
    pub headline: String,
    pub content: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}: {}", self.headline, self.content)
    }
}

// ツイートにも「要約できる能力」を実装
pub struct Tweet {
    pub username: String,
    pub content: String,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

何が嬉しいの?

異なる型でも、同じインターフェースで扱えます:

// どんな型でも「Summaryトレイトを持っていれば」受け取れる
fn notify(item: &impl Summary) {
    println!("速報! {}", item.summarize());
}

fn main() {
    let article = NewsArticle { /* ... */ };
    let tweet = Tweet { /* ... */ };
    
    notify(&article);  // OK!
    notify(&tweet);    // OK!
}

Copyトレイトとの関係

i32Copyトレイトを実装しているという話がありましたね。これも同じ仕組みです:

  • Summaryトレイト = 「summarize()メソッドを持っている」という約束
  • Copyトレイト = 「コピーできる」という約束

どちらもトレイトという同じ仕組みで、「この型はこういう能力を持っている」という目印なんです。

implとの違い - 重要なポイント

疑問: それって普通のimplでメソッドとして作れるのでは?

これは重要な質問です! 違いはこうです:

普通のimpl(トレイトなし)

impl NewsArticle {
    fn summarize(&self) -> String {
        self.headline.clone()
    }
}

NewsArticle専用のメソッド

impl トレイト

trait Summary { /* ... */ }
impl Summary for NewsArticle { /* ... */ }
impl Summary for Tweet { /* ... */ }

// どんな型でも受け取れる!
fn notify(item: &impl Summary) {
    println!("{}", item.summarize());
}

複数の型で共通のインターフェース

トレイト = implのジェネリック という理解がピッタリです!

7. ジェネリクスとトレイト境界

疑問: なぜいきなりV, Wが出てくるの?

こんなコードを見て混乱しました:

struct Point<T, U> {
    x: T,
    y: U,
}

impl<T, U> Point<T, U> {
    fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

型でT, Uとしているのに、なぜいきなりV, WT, Wが出てくるの?

答え: 型パラメータの拡張

型パラメータには範囲があります:

impl<T, U> Point<T, U> {
    // ↑ ここで宣言したT, Uは、このimplブロック全体で使える
    
    fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
        // ↑ さらにV, Wを追加で宣言
        // このメソッド内では: T, U, V, W の4つ全部使える
    }
}

つまり、「T, U, V, Wを受け取って、T, Wを返す」メソッドなんです!

具体例

let p1: Point<i32, f64> = Point { x: 5, y: 10.4 };
//              T    U

let p2: Point<&str, char> = Point { x: "Hello", y: 'c'};
//              V     W

let p3 = p1.mixup(p2);
//       ^^^^^^^^^^^^
//       i32とcharを取って → Point<i32, char>を返す
//         T       W              T     W

所有権はどうなった?

重要な気づき: 所有権は移動(ムーブ)しています!

fn mixup(self, other: Point<V, W>) -> Point<T, W>
//       ^^^^  ^^^^^
//       &selfじゃない! 所有権を奪う!

selfと書くと所有権がメソッドに移動します:

let p3 = p1.mixup(p2);  // p1とp2の所有権がmixupに移動

// println!("{}", p1.x);  // ❌ エラー! p1はもう使えない
// println!("{}", p2.x);  // ❌ エラー! p2ももう使えない
println!("{}", p3.x);     // ✅ OK! p3だけ使える

新しいPointを作って返すので、元のp1p2もう不要だからです。

8. トレイト境界(Trait Bounds)の実践

最後に、トレイトとジェネリクスを組み合わせた実践的な例を見てみましょう:

use std::cmp::PartialOrd;

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
    let mut largest = list[0];
    for &number in list {
        if number > largest {
            largest = number;
        }
    }
    largest
}

コードの意味

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T
//         ^^^^^^^^^^^^^^^^^^^^^^
//         Tは「比較できて」「コピーできる」型じゃないとダメ

なぜこの2つのトレイトが必要?

1. PartialOrd - 比較するため

if number > largest {  // ← >で比較してる!
    // PartialOrdトレイトがないと比較できない
}

2. Copy - コピーするため

let mut largest = list[0];  // ← 値をコピー
for &number in list {       // ← 値をコピー
    largest = number;       // ← 値をコピー
}
largest  // ← コピーした値を返す

所有権の動き

fn largest<T: PartialOrd + Copy>(list: &[T]) -> T
//                                ^^^^^^    ^^
//                                借用      値を返す(Copyだから安全)
  • list: &[T] - リストは借用(所有権を奪わない)
  • -> T - コピーした値を返す(元のリストは無傷)

なぜi32もf64も動く?

// i32は PartialOrd + Copy を両方実装してる
let numbers = vec![34, 50, 25, 100];
largest(&numbers);  // ✅ OK

// f64も PartialOrd + Copy を両方実装してる
let floats = vec![100.2, 34.5, 6000.9];
largest(&floats);   // ✅ OK

まとめ

  1. ジェネリック <T> - どんな型でもOK
  2. トレイト境界 : PartialOrd + Copy - ただし比較できてコピーできる型だけ
  3. 借用 &[T] - 所有権は奪わない
  4. 値を返す -> T - Copyだから安全にコピーして返せる

おわりに

Rustは難しい!けど、なんかわかりそうな気もする:

  1. : 基本的な型で動くコードを書く
  2. 慣れてから: ジェネリクスとトレイトを組み合わせる
  3. もっと後: 複雑なトレイト境界を書く

「これはこれで覚える」で十分なのかと思います。実際にコードを書いてるうちに、自然と「ああ、あの時のトレイトってこういうことか」ってなります。と、claudeには言われるけど、そうだといいなと半信半疑。ただ圧倒的に慣れてない感をすごく感じさせられる。


この記事は実際のRust学習中の疑問と気づきをまとめたものです。間違いや改善点があればフィードバックをいただけると嬉しいです。