[Rust 入門] #05 構造体・enum・match — 値のまとめ方と場合分けを動かして確かめる

[Rust 入門] #05 構造体・enum・match — 値のまとめ方と場合分けを動かして確かめる

はじめに

第3回と第4回で、次の形の型を使ってきました。

struct Noisy(&'static str);

&'static str を1つ持つだけの型です。今回は、この struct と、その仲間の enum、そして enum を場合分けする match を扱います。

道具役割
structいくつかの値を、1つにまとめる
implその型に、動作(メソッド)を付ける
enum「いくつかの選択肢の、どれか1つ」を表す
matchenum などを、場合分けする

構造体(struct)

名前を付けた値(フィールド)を、まとめます。

struct Item {
    name: String,
    qty: u32,
}

fn main() {
    let mut item = Item {
        name: String::from("りんご"),
        qty: 3,
    };
    println!("{} × {}", item.name, item.qty);
    item.qty += 2;
    println!("{} × {}", item.name, item.qty);
}
りんご × 3
りんご × 5
部分意味
struct Item { ... }型の定義。名前: 型 を並べる
Item { name: ..., qty: ... }値を作る。全部のフィールドを、書く
item.qtyフィールドを読む・書く

フィールドを書き換えるには、変数が mut である必要があります。mut なしで書き換えると、エラーです。

error[E0594]: cannot assign to `item.qty`, as `item` is not declared as mutable
  --> a2.rs:11:5
   |
11 |     item.qty += 2;
   |     ^^^^^^^^^^^^^ cannot assign
   |
help: consider changing this to be mutable
   |
 7 |     let mut item = Item {
   |         +++

タプル構造体

フィールドに名前を付けない形もあります。冒頭の Noisy が、この形です。取り出すときは、.0、.1 と書きます。

この形は、同じ中身でも別の型にするときにも使えます。

struct Meters(f64);
struct Feet(f64);

fn show(m: Meters) {
    println!("{} m", m.0);
}

fn main() {
    let a = Meters(3.5);
    show(a);
    let b = Feet(3.5);
    show(b);
}
error[E0308]: mismatched types
  --> a6.rs:12:10
   |
12 |     show(b);
   |     ---- ^ expected `Meters`, found `Feet`
   |     |
   |     arguments to this function are incorrect

どちらも中身は f64 ですが、Meters を求める関数に Feet は渡せません。

{:?} で中身を表示する

println!("{:?}", item) と書くと、エラーになります。

error[E0277]: `Item` doesn't implement `Debug`
  --> a3.rs:11:22
   |
11 |     println!("{:?}", item);
   |               ----   ^^^^ `Item` cannot be formatted using `{:?}` because it doesn't implement `Debug`
   |
   = note: add `#[derive(Debug)]` to `Item` or manually `impl Debug for Item`
help: consider annotating `Item` with `#[derive(Debug)]`
   |
 1 + #[derive(Debug)]
 2 | struct Item {
   |

help のとおり、struct の前に #[derive(Debug)] を付けます。

#[derive(Debug)]
struct Item {
    name: String,
    qty: u32,
}
Item { name: "りんご", qty: 3 }
Item {
    name: "りんご",
    qty: 3,
}

1つ目が {:?}、2つ目が {:#?}(整形して表示)です。#[derive(...)] は、「その機能を、自動で付けてください」という指定です。Debug が何なのか(トレイト)は、第7回で扱います。

impl: メソッドと関連関数

型に動作を付けるのが、impl です。

struct Item {
    name: String,
    qty: u32,
}

impl Item {
    fn new(name: &str, qty: u32) -> Item {
        Item {
            name: String::from(name),
            qty,
        }
    }

    fn describe(&self) -> String {
        format!("{} × {}", self.name, self.qty)
    }

    fn add(&mut self, n: u32) {
        self.qty += n;
    }

    fn into_name(self) -> String {
        self.name
    }
}

fn main() {
    let mut item = Item::new("りんご", 3);
    println!("{}", item.describe());
    item.add(2);
    println!("{}", item.describe());
    println!("{}", Item::describe(&item));
    let name = item.into_name();
    println!("{name}");
}
りんご × 3
りんご × 5
りんご × 5
りんご
書き方種類意味
fn new(name: &str, qty: u32) -> Item関連関数self を取らない。Item::new(...) で呼ぶ
fn describe(&self)メソッドself を借りる(第4回の &)。読むだけ
fn add(&mut self, n: u32)メソッドself を可変で借りる(&mut)。書き換える
fn into_name(self)メソッドself の所有権を受け取る(第3回のムーブ)

第3回・第4回の内容が、そのまま、self の書き方になっています。

  • qty, は、qty: qty, の省略です(同名の変数があるとき)
  • item.describe() と Item::describe(&item) は、同じ結果になりました
  • new は、ただの関数名です。fn make(...) -> Self と書き換えても、動きました(型の名前の代わりに Self とも書けます)

self を取るメソッドを呼ぶと、第3回と同じく、呼んだ後にその変数は使えません。

let item = Item::new("りんご", 3);
let name = item.into_name();
println!("{name} {}", item.qty);
error[E0382]: borrow of moved value: `item`
  --> a8.rs:22:27
   |
20 |     let item = Item::new("りんご", 3);
   |         ---- move occurs because `item` has type `Item`, which does not implement the `Copy` trait
21 |     let name = item.into_name();
   |                     ----------- `item` moved due to this method call
22 |     println!("{name} {}", item.qty);
   |                           ^^^^^^^^ value borrowed here after move
   |
note: `Item::into_name` takes ownership of the receiver `self`, which moves `item`

構造体のムーブとコピー

String を持たない、整数だけの構造体でも、そのままではムーブされます。

struct P {
    x: i32,
    y: i32,
}

fn main() {
    let p = P { x: 1, y: 2 };
    let q = p;
    println!("{} {}", q.x, q.y);
    println!("{}", p.x);
}
error[E0382]: borrow of moved value: `p`
  --> a9.rs:10:20
   |
 7 |     let p = P { x: 1, y: 2 };
   |         - move occurs because `p` has type `P`, which does not implement the `Copy` trait
 8 |     let q = p;
   |             - value moved here

第3回では、整数やタプルが Copy でした。自分で作った型は、そのままでは Copy になりません。 #[derive(Clone, Copy)] を付けると、コピーされるようになります。

#[derive(Clone, Copy)]
struct P {
    x: i32,
    y: i32,
}

fn main() {
    let p = P { x: 1, y: 2 };
    let q = p;
    println!("{} {}", q.y, p.x);
}
2 1

Clone と Copy の中身も、第7回で扱います。

フィールドは、宣言した順に drop される

第3回の Noisy を、フィールドにして、drop の順番を見ます。

struct Pair {
    first: Noisy,
    second: Noisy,
}

fn main() {
    let p = Pair {
        second: Noisy("second"),
        first: Noisy("first"),
    };
    println!("{} と {}", p.first.0, p.second.0);
    println!("main の終わり");
}

(Noisy と impl Drop は、第3回と同じです)

first と second
main の終わり
drop: first
drop: second

値を作った順は second、first でしたが、drop されたのは、構造体の定義でフィールドを書いた順(first、second)でした。

Java と並べる(struct と class)

場面JavaRust
値をまとめるclass Item { String name; int qty; }struct Item { name: String, qty: u32 }
動作を付けるクラスの中にメソッドを書くimpl Item { ... } に、別に書く
b = a参照のコピー。a も b も同じ物を指すムーブ(Copy でない型の場合)。a は使えない

Java で、Item b = a; b.qty += 2; を実行すると、a.qty も変わります(a == b は true)。

5 true

Rust の let b = a; は、第3回のとおり、a を使えなくします。

enum

「いくつかの選択肢の、どれか1つ」を表すのが、enum です。場合分けには、match を使います。

enum Direction {
    North,
    East,
    South,
    West,
}

fn turn_right(d: Direction) -> Direction {
    match d {
        Direction::North => Direction::East,
        Direction::East => Direction::South,
        Direction::South => Direction::West,
        Direction::West => Direction::North,
    }
}

fn name(d: &Direction) -> &'static str {
    match d {
        Direction::North => "北",
        Direction::East => "東",
        Direction::South => "南",
        Direction::West => "西",
    }
}

fn main() {
    let mut d = Direction::North;
    for _ in 0..5 {
        print!("{} ", name(&d));
        d = turn_right(d);
    }
    println!();
}
北 東 南 西 北
部分意味
enum Direction { North, ... }選択肢(バリアント)を並べる
Direction::North値の作り方。型名::バリアント名
match d { 模様 => 値, ... }d を、上から順に模様と比べ、合ったものの右側の値になる

match は、式です(第2回)。=> の右側の値が、match 全体の値になります。

全部の場合を書かないと、通らない

バリアントを1つ書き忘れると、コンパイルが止まります。

fn name(d: &Direction) -> &'static str {
    match d {
        Direction::North => "北",
        Direction::East => "東",
        Direction::South => "南",
    }
}
error[E0004]: non-exhaustive patterns: `&Direction::West` not covered
  --> b2.rs:9:11
   |
 9 |     match d {
   |           ^ pattern `&Direction::West` not covered
   |
note: `Direction` defined here
  --> b2.rs:1:6
   |
 1 | enum Direction {
   |      ^^^^^^^^^
...
 5 |     West,
   |     ---- not covered

West が、書かれていない、と指摘しています。整数の match でも、同じです。

let n = 3;
let s = match n {
    1 => "one",
    2 => "two",
};
error[E0004]: non-exhaustive patterns: `i32::MIN..=0_i32` and `3_i32..=i32::MAX` not covered

この検査があるので、あとで enum にバリアントを足すと、match を直すべき場所が、コンパイルエラーとして出ます。

Java の switch と並べる

Java(21)で、同じ形を書いて比べます。

enum Direction { NORTH, EAST, SOUTH, WEST }

static String name(Direction d) {
    String s = "?";
    switch (d) {
        case NORTH: s = "北"; break;
        case EAST: s = "東"; break;
        case SOUTH: s = "南"; break;
    }
    return s;
}

WEST を渡すと、コンパイルは通り、? が返ります。switch 文は、全部の場合を書いているか、検査されません。

switch を式にすると、検査が効きます。

static String name(Direction d) {
    return switch (d) {
        case NORTH -> "北";
        case EAST -> "東";
        case SOUTH -> "南";
    };
}
J2.java:5: error: the switch expression does not cover all possible input values
        return switch (d) {
               ^

Rust の match は、常に、この検査がかかります。

データを持つ enum

Rust の enum は、バリアントごとに、別の形のデータを持てます。

enum Shape {
    Circle(f64),
    Rect { w: f64, h: f64 },
    Point,
}

fn area(s: &Shape) -> f64 {
    match s {
        Shape::Circle(r) => std::f64::consts::PI * r * r,
        Shape::Rect { w, h } => w * h,
        Shape::Point => 0.0,
    }
}

fn main() {
    let shapes = [Shape::Circle(1.0), Shape::Rect { w: 2.0, h: 3.0 }, Shape::Point];
    for s in &shapes {
        println!("{:.3}", area(s));
    }
    println!("Shape = {} バイト", std::mem::size_of::<Shape>());
}
3.142
6.000
0.000
Shape = 24 バイト
バリアント持つデータ
Circle(f64)半径を1つ(タプル構造体と同じ形)
Rect { w, h }幅と高さ(名前付きフィールド)
Pointなし

match の模様で、データを取り出して、名前を付けられます(Shape::Circle(r) の r)。match の対象が参照(&Shape)なので、取り出した r、w、h も、借りた値です。

同じ形を Java で書くと、sealed interface と record になります。

sealed interface Shape permits Circle, Rect, Point {}
record Circle(double r) implements Shape {}
record Rect(double w, double h) implements Shape {}
record Point() implements Shape {}

static double area(Shape s) {
    return switch (s) {
        case Circle c -> Math.PI * c.r() * c.r();
        case Rect r -> r.w() * r.h();
        case Point p -> 0.0;
    };
}
3.142
6.000
0.000

Point の行を消すと、Java も、the switch expression does not cover all possible input values で止まりました。

Java(21)Rust
型の宣言sealed interface と record を、3つ並べるenum 1つ
場合分けswitch 式(パターンマッチ)match
書き忘れの検査式のときだけ常に

大きさも測りました。データを持たない Direction は 1 バイト、Shape は 24 バイトでした。Rect が、f64 を2つで 16 バイトです。

match の中で起きる所有権の移動

String を持つバリアントを、match で取り出すと、第3回のムーブが起きます。

enum Cmd {
    Add { name: String, qty: u32 },
    Clear,
}

fn main() {
    let cmd = Cmd::Add { name: String::from("りんご"), qty: 2 };
    match cmd {
        Cmd::Add { name, qty } => println!("追加 {name} {qty}"),
        Cmd::Clear => println!("クリア"),
    }
    match cmd {
        Cmd::Add { .. } => println!("また追加"),
        Cmd::Clear => println!("クリア"),
    }
}
error[E0382]: use of partially moved value: `cmd`
  --> b9.rs:12:11
   |
 9 |         Cmd::Add { name, qty } => println!("追加 {name} {qty}"),
   |                    ---- value partially moved here
...
12 |     match cmd {
   |           ^^^ value used here after partial move
   |
   = note: partial move occurs because value has type `String`, which does not implement the `Copy` trait

1つ目の match で、name(String)が、cmd から取り出されてムーブしました。qty(u32)は Copy なので、問題になりません。

match &cmd と、参照にして借りれば、通ります。

match &cmd {
    Cmd::Add { name, qty } => {
        let n: &String = name;
        println!("追加 {n} {qty}");
    }
    Cmd::Clear => println!("クリア"),
}
match &cmd {
    Cmd::Add { .. } => println!("また追加"),
    Cmd::Clear => println!("クリア"),
}
追加 りんご 2
また追加

name の型は、&String になります(let n: &String = name; が通ることで確認)。第4回の「借りるか、取るか」が、match でも同じように働きます。

match のいろいろ

整数にも、範囲や条件を付けて使えます。

fn grade(score: u32) -> &'static str {
    match score {
        90..=100 => "A",
        70..=89 => "B",
        0 | 1 | 2 => "ほぼゼロ",
        n if n < 40 => "要注意",
        _ => "C",
    }
}
100 -> A
90 -> A
89 -> B
70 -> B
69 -> C
39 -> 要注意
1 -> ほぼゼロ
0 -> ほぼゼロ
模様意味
90..=10090 以上 100 以下(..= は、終わりを含む)
0 | 1 | 2どれか
n if n < 40値を n と呼び、n < 40 のときだけ合う(ガード)
_何でも。残り全部

模様は、上から順に比べられます。1 は n if n < 40 にも合いますが、先に 0 | 1 | 2 に合うので、「ほぼゼロ」でした。

=> の右側は、全部同じ型でなければなりません。

let s = match n {
    1 => "one",
    2 => 2,
    _ => "many",
};
error[E0308]: `match` arms have incompatible types
 --> b6.rs:5:14
  |
4 |         1 => "one",
  |              ----- this is found to be of type `&str`
5 |         2 => 2,
  |              ^ expected `&str`, found integer

1つだけ調べる: if let

1つのバリアントだけを調べたいときは、if let が短く書けます。

enum Cmd {
    Add(u32),
    Clear,
}

fn main() {
    for cmd in [Cmd::Add(5), Cmd::Clear] {
        if let Cmd::Add(n) = cmd {
            println!("追加 {n}");
        } else {
            println!("追加ではない");
        }
    }
}
追加 5
追加ではない

if let 模様 = 値 { ... } は、値が模様に合ったときだけ、中を実行します。match と違って、残りの場合を書かなくても、通ります。

サンプル: 買い物リストを、struct と enum で書き直す

第4回の買い物リストを、品物(struct)、種類(enum)、操作(データを持つ enum)で書き直します。

cargo new shop3
cd shop3

src/main.rs を、次のようにします。

enum Category {
    Fruit,
    Drink,
    Other,
}

struct Item {
    name: String,
    qty: u32,
    category: Category,
    bought: bool,
}

impl Item {
    fn new(name: &str, qty: u32, category: Category) -> Item {
        Item {
            name: String::from(name),
            qty,
            category,
            bought: false,
        }
    }

    fn describe(&self) -> String {
        let mark = if self.bought { "済" } else { "未" };
        let kind = match self.category {
            Category::Fruit => "果物",
            Category::Drink => "飲み物",
            Category::Other => "その他",
        };
        format!("[{mark}] {} × {}({kind})", self.name, self.qty)
    }
}

enum Command {
    Add {
        name: String,
        qty: u32,
        category: Category,
    },
    Buy(usize),
    Clear,
}

fn apply(list: &mut Vec<Item>, cmd: Command) {
    match cmd {
        Command::Add {
            name,
            qty,
            category,
        } => list.push(Item::new(&name, qty, category)),
        Command::Buy(i) => list[i].bought = true,
        Command::Clear => list.clear(),
    }
}

fn print_list(list: &[Item]) {
    if list.is_empty() {
        println!("(空)");
    }
    for item in list {
        println!("{}", item.describe());
    }
    println!("---");
}

fn main() {
    let mut list = Vec::new();
    let commands = [
        Command::Add {
            name: String::from("りんご"),
            qty: 3,
            category: Category::Fruit,
        },
        Command::Add {
            name: String::from("お茶"),
            qty: 2,
            category: Category::Drink,
        },
        Command::Add {
            name: String::from("電池"),
            qty: 1,
            category: Category::Other,
        },
        Command::Buy(1),
    ];
    for cmd in commands {
        apply(&mut list, cmd);
    }
    print_list(&list);
    apply(&mut list, Command::Clear);
    print_list(&list);
}
cargo run
[未] りんご × 3(果物)
[済] お茶 × 2(飲み物)
[未] 電池 × 1(その他)
---
(空)
---

第4回の版との違いは、次のとおりです。

第4回今回
品物は String名前・個数・種類・買ったかを持つ struct Item
品物を足す関数だけ「足す・買う・空にする」を、enum Command で表す
操作ごとに、関数を呼び分けるapply の中の match で、操作ごとに場合分けする

apply の match cmd は、cmd を値で受けています。Command::Add の name は、ここでムーブされ、Item::new(&name, ...) には、借りて渡しています(第4回の &String から &str)。for cmd in commands で、配列の要素が1つずつ、apply にムーブされます(第3回の for s in v)。

Command::Buy(i) の list[i] は、範囲の外を指すと、実行時に止まります。

thread 'main' panicked at c1.rs:4:9:
index out of bounds: the len is 3 but the index is 5

(終了コード 101。要素3つのリストに、list[5] を指定した場合)失敗を、止めずに扱う方法は、第6回です。

cargo clippy と cargo fmt --check は、指摘なしで通りました。

歴史

年できごと
2006〜2011年Rust の最初のコンパイラは、OCaml で書かれた(約38,000行)
2012年〜コンパイラは、Rust 自身で書かれるようになった(自己ホスト)。切り替えの正確な日付は、不明とされている

OCaml は、関数型言語の1つです。Wikipedia の Rust の記事は、enum を、「関数型言語にある代数的データ型に似た機能を持つ」ものと説明しています。また、Rust の機能の多くが、OCaml などの関数型言語から影響を受けた、とも書いています。

動作確認

確認できたこと

  • 上のコードと出力は、すべて、Linux(Rust 1.97.0、Cargo 1.97.0、edition 2024)で、実際に実行した結果です
  • Java の switch、sealed interface、record のコードは、同じ環境の Java(OpenJDK 21)で、実際に実行した結果です
  • 9つのエラー(E0594、E0308 を2種類の形、E0277、E0382 を3種類の形、E0004 を2種類の形)のメッセージ
  • #[derive(Debug)] を付けると {:?} と {:#?} で表示できること
  • 自作の struct は、整数だけでも、そのままではムーブされ、#[derive(Clone, Copy)] でコピーになること
  • フィールドの drop は、定義で書いた順になること(作った順と逆にして確認)
  • new は、ただの名前であること(make に変えても動く)
  • Java の switch 文は、WEST を書き忘れても、コンパイルが通り ? を返すこと。switch 式は、コンパイルエラーになること
  • enum にバリアントを足すと、既存の match が E0004 になること(Up を足して確認)
  • match cmd は String をムーブし、match &cmd は借りること
  • 買い物リストのサンプルが、cargo run で動くこと。cargo clippy と cargo fmt --check が、指摘なしで通ること

歴史の出典

  • 最初のコンパイラが OCaml で書かれたこと(約38,000行、2006〜2011年)、自己ホストへの切り替え日が不明とされていること、enum が代数的データ型に似ていること、OCaml などから影響を受けたことは、Wikipedia の「Rust (programming language)」の記述です。1つの出典で、ページの全文ではなく、取得ツールが返した要約を通して確認しました。他の資料とは、突き合わせていません

確認できていないこと

  • Shape が 24 バイトになる内訳(バリアントを区別する印がどう置かれるか)。Rect が 16 バイトで、全体が 24 バイトという測定結果だけを書いています
  • Direction が 1 バイトになる理由
  • Rust の enum の設計が、OCaml など、どの言語から、どの程度引き継がれたか。Wikipedia の記述以上のことは、確かめていません
  • 自作の型を、フィールドごとに mut にできるかどうか。この記事では、変数全体が mut でないと書き換えられない場合のエラーだけを確認しました
  • Windows と macOS での実行結果。出力の内容は、同じになるはずですが、実行はしていません

次回

次回は、Result / Option とエラー処理です。今回の match の例にあった「範囲外を指すと止まる」問題を、止めずに扱う方法を見ます。Option は、Rust の標準ライブラリにある enum で、今回の match と同じ書き方で場合分けできます(Some(3) と None の match で、None を書き忘れると、今回と同じ E0004 になることを確認しました)。

連載の目次(予定)

入門編(8回)

  1. Rust とは / 環境構築 / Hello World
  2. 変数・型・関数(Java との対比)
  3. 所有権(ムーブ)
  4. 借用と参照
  5. 構造体・enum・match(今回)
  6. Result / Option とエラー処理
  7. トレイトとジェネリクス(Java の interface との違い)
  8. ライフタイム入門と、総まとめの CLI(ファイル一覧ツール)