1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Rust完全ガイド】構造体・列挙型・Option ― 独自の型を定義し、null なしで安全に書く

1
Posted at

はじめに

Rust の基本的な型と制御構文を覚えたあと、次に立ちはだかるのが「自分でデータ型を作る」段階です。ここで登場するのが構造体列挙型で、Rust ではこの2つがデータ設計の柱になります。

そしてもうひとつ、他の言語から来た人が必ず驚くのが「Rust には null がない」という事実です。その代わりに使われるのが Option 型で、これは列挙型の一種にすぎません。つまり、列挙型を理解すると Option が理解でき、Option を理解すると Rust の安全性の勘所が掴めます

この記事では、**構造体 → impl ブロック → 列挙型 → Option → パターンマッチ(match / if let / マッチガード)**までを1本にまとめました。最後に練習問題も用意しています。

このシリーズでは、制御構文(式と文・ifmatch・ループ)や所有権と参照も別記事で解説しています。あわせて読むと全体像がつかめます。

この記事で分かること

  • 構造体の定義・インスタンス化・フィールドの更新方法が分かる
  • impl ブロックにおけるメソッドと型関連関数の違い(self を取るかどうか)が分かる
  • 列挙型のバリアントにデータを持たせる3つの形(データなし/タプル型/構造体)を使い分けられる
  • Option が null の代わりに何を保証しているのかを説明できる
  • matchif let、マッチガードで値を安全に取り出せる

第1章:構造体(struct)

構造体は、関連する複数の値をまとめて名前を付けた独自のデータ型です。C++ の構造体、Java や Python のクラスに近い役割を担います。

定義

定義には struct キーワードを使います。

struct Rectangle {
    width: u32,
    height: u32,
}

構造体の中のデータはフィールドと呼び、フィールド名: 型 の形で並べます。数はいくつでも構いません。

命名には慣習があります。

対象 記法
構造体名 アッパーキャメルケース RectangleUserProfile
フィールド名 スネークケース widthcreated_at

インスタンス化とフィールドアクセス

定義しただけでは「型の形」があるだけなので、実際の値を作ります。これをインスタンスと呼びます。

fn main() {
    let rect = Rectangle {
        width: 10,
        height: 5,
    };

    println!("{} x {}", rect.width, rect.height);
}

フィールドには インスタンス.フィールド名 でアクセスします。

事前に用意した変数から値をセットすることもできます。このとき変数名とフィールド名が同じなら、フィールド名: 値フィールド名 だけに省略できます(フィールド初期化の省略記法)。

let width = 10;
let height = 5;

let rect = Rectangle { width, height };   // width: width, height: height の省略形

値を書き換える

インスタンスを mut で宣言すれば、生成後にフィールドを書き換えられます。

let mut rect = Rectangle { width: 10, height: 5 };

rect.height = 20;
println!("{}", rect.height);   // 20

mutインスタンス単位でしか付けられません。「このフィールドだけ可変」という指定はできないので、一部だけ書き換えたい場合もインスタンス全体を mut にします。

覚えておくと便利な2つの記法

まず、構造体をそのまま println!("{}", rect) で表示することはできません。デバッグ表示を使います。

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect = Rectangle { width: 10, height: 5 };

    println!("{:?}", rect);    // Rectangle { width: 10, height: 5 }
    println!("{:#?}", rect);   // 改行付きで見やすく表示
}

もうひとつ、既存のインスタンスから一部だけ変えた新しいインスタンスを作る構造体更新記法です。

let rect2 = Rectangle { height: 20, ..rect };   // height 以外は rect と同じ

構造体には他に、フィールド名を持たないタプル構造体struct Point(i32, i32);)と、フィールドを一切持たないユニット構造体struct Marker;)があります。前者は座標や ID のラッパーとして、後者はトレイトの実装先として使われます。


第2章:impl ブロック ― メソッドと型関連関数

構造体に振る舞いを持たせるには impl ブロックを使います。ここに書ける関数は2種類あり、第1引数に self を取るかどうかで区別されます。

メソッド

self を取る関数がメソッドです。インスタンスに対して インスタンス.メソッド名() で呼び出します。

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

fn main() {
    let rect = Rectangle { width: 10, height: 5 };
    println!("{}", rect.area());   // 50
}

self の受け取り方は3種類あり、所有権の考え方がそのまま反映されます。

書き方 意味 使いどころ
&self 借用して読むだけ 値を参照するだけのメソッド(最も多い)
&mut self 借用して書き換える フィールドを変更するメソッド
self 所有権を受け取る インスタンスを消費して別の値に変換するメソッド

型関連関数

self を取らない関数が型関連関数です。特定のインスタンスではなく型そのものに紐づくため、インスタンス化せずに呼び出せます。他の言語でいう静的メソッドに相当します。

用途はさまざまですが、最もよく使われるのはコンストラクタです。

impl Rectangle {
    fn new(width: u32, height: u32) -> Self {
        Self { width, height }
    }

    fn area(&self) -> u32 {
        self.width * self.height
    }
}

fn main() {
    let rect = Rectangle::new(10, 5);   // 構造体から直接呼ぶ
    println!("{}", rect.area());
}

ポイントが3つあります。

  • 呼び出しは インスタンス.関数名() ではなく 型名::関数名()
  • impl ブロックの中では Self が対象の型(ここでは Rectangle)を指す。戻り値を -> Rectangle と書いても同じだが、Self のほうが型名の変更に強い
  • コンストラクタの名前を new にするのは慣習で、言語機能ではない。Rectangle::square(10) のように用途別の生成関数を複数用意しても構わない

第3章:列挙型(enum)

列挙型は、取り得る値を列挙して型を定義する仕組みです。取り得る値のことをバリアント(構成子)と呼びます。

enum Shape {
    Circle,
    Square,
    Triangle,
}

fn main() {
    let s = Shape::Circle;   // 型は Shape
}

Shape は「CircleSquareTriangle のいずれか」を表す独自の型になり、構造体のフィールドの型、関数の引数や戻り値の型として使えます。

構造体との使い分け

両者の違いは一言で表せます。

「複数の値をまとめて持つ」なら構造体、「複数の候補から1つを選ぶ」なら列挙型です。

バリアントにデータを持たせる

Rust の列挙型が強力なのは、バリアントごとにデータを持てる点です。持たせ方は3通りあり、1つの列挙型の中で混在できます。

enum Shape {
    Circle,                              // データなし
    Square(u32),                         // タプル型バリアント
    Triangle { base: u32, height: u32 },  // 構造体バリアント
}

fn main() {
    let s1 = Shape::Circle;
    let s2 = Shape::Square(10);                        // 関数のように括弧で渡す
    let s3 = Shape::Triangle { base: 4, height: 3 };   // ブロックでキーと値を渡す
}
定義 生成
データなし Circle Shape::Circle
タプル型バリアント Square(u32) Shape::Square(10)
構造体バリアント Triangle { base: u32, height: u32 } Shape::Triangle { base: 4, height: 3 }

タプル型バリアントは複数の値も持てます(Point(i32, i32) のようにカンマ区切り)。

列挙型にもメソッドを定義できる

構造体と同じく impl ブロックが使えます。定義したメソッドは、どのバリアントからでも呼び出せます。

impl Shape {
    fn name(&self) -> &str {
        match self {
            Shape::Circle => "円",
            Shape::Square(_) => "正方形",
            Shape::Triangle { .. } => "三角形",
        }
    }
}

fn main() {
    println!("{}", Shape::Circle.name());
    println!("{}", Shape::Square(10).name());
    println!("{}", Shape::Triangle { base: 4, height: 3 }.name());
}

データを使わないときは、タプル型バリアントなら _、構造体バリアントなら .. で中身を無視できます。


第4章:Option 型 ― null の代わり

ここからが本題です。Rust には、値が何もないことを表す null が存在しません。null は便利な反面、参照するとクラッシュする、チェック漏れに気付けないなど、非常に多くのバグの原因になってきました。その代わりに採用されたのが Option 型です。

定義はただの列挙型

Option は標準ライブラリに、こう定義されています。

enum Option<T> {
    None,
    Some(T),
}

型名の横の <T>ジェネリック引数です。詳細は別の話題になりますが、ここでは「T はどんな型にもなれる任意の型」と理解しておけば十分です。この1つの定義で、Option<i32> でも Option<String> でも扱えるようになっています。

バリアントは2つだけです。

  • Some(T):値が存在する(タプル型バリアントなのでデータを持つ)
  • None:値が存在しない

Option は非常によく使われるため、他の列挙型と違って Option:: を省略して Some / None だけで書けます(プレリュードに含まれているため)。

let a = Some(1);           // Option<i32> と推論される
let b = Some("hello");     // Option<&str> と推論される
let c: Option<i32> = None; // None だけでは T が決まらないので型注釈が必要

None は保持するデータがなく型推論の手がかりがないため、明示的に型を示す必要がある点に注意してください。

値のチェックが強制される

Option の本質は、中身を使う前にチェックを強制されることにあります。Some に入った値は、そのままでは取り出せません。

Vecget メソッドで確認してみましょう。get はインデックスを受け取り、要素があれば Some(値)、なければ None を返します。

let v = vec![1, 2, 3];

let value = v.get(2);    // 型は Option<&i32>
// println!("{}", value + 1);   // エラー:Option のままでは計算できない

戻り値が Option である以上、書き手は「存在しない場合」を必ず意識することになります。これによって、想定外に値が存在しないという事態が起こらなくなるわけです。


第5章:match / if let / マッチガード

チェックの方法をいくつか見ていきます。

match ― すべての分岐を網羅する

fn main() {
    let v = vec![1, 2, 3];
    let value = v.get(2);

    match value {
        Some(x) => println!("value exists {}", x),
        None => println!("no value"),
    }
}

Some(x)x は任意の変数名で、この腕の中では Some が保持する値にアクセスできます。上の例ではインデックス2に値 3 があるので Some(x) にマッチし、value exists 3 と表示されます。

matchあり得るパターンをすべて網羅する必要があるため、None の腕を書き忘れるとコンパイルが通りません。これがチェック漏れを防いでくれます。

if let ― 必要な分岐だけ書く

match との違いは、必要な分岐だけを書けることです。

if let Some(x) = value {
    println!("value exists {}", x);
}

if let パターン = 値 という形で、マッチしなかった場合は単に無視されます。もちろん elseelse if で分岐を足すこともできます。

if let Some(x) = value {
    println!("value exists {}", x);
} else {
    println!("no value");
}

「値があるときだけ処理したい」場面では if let、「すべての場合を漏れなく処理したい」場面では match、と使い分けるのが基本です。

特定の値にマッチさせる

パターンには変数名だけでなく、具体的な値も書けます。

match value {
    Some(1) => println!("value is 1"),
    Some(x) => println!("value exists {}", x),
    None => println!("no value"),
}

インデックス0(要素が 1)なら Some(1) に、それ以外の値なら Some(x) にマッチします。| を使って Some(1) | Some(2) のように複数条件を書くこともできます。

v.get(0) の型は Option<&i32> なので、本来なら Some(&1) と書くべきところですが、Some(1) でもマッチします。これはマッチエルゴノミクス(デフォルト束縛モード)という仕組みによるもので、参照が非参照パターンとマッチするとき自動的に参照外しが行われるためです。一方 Some(x)x は束縛パターンなので &i32 として束縛されます。この非対称さが、次のマッチガードで *x が必要になる理由です。

マッチガード ― パターンに条件を足す

パターンの後ろに if を書くと、追加の条件を指定できます。これをマッチガードと呼びます。

match value {
    Some(x) if *x == 1 => println!("value is 1"),
    Some(x) => println!("value exists {}", x),
    None => println!("no value"),
}

x&i32 なので、* で参照外しをして実体と比較しています。値そのものを列挙するより、範囲や計算結果を条件にしたいときに便利です(Some(x) if *x % 2 == 0 など)。

ここで注意点として、マッチガードを書いても網羅性のチェックは緩まりません。ガード付きの腕は「条件次第で外れる」とみなされるため、上の例のように Some(x) の腕も別途必要です。

マッチガードは match でのみ使える構文で、if let では使えません。ただし Rust 1.88 以降・エディション2024では let チェーンが使えるようになり、if let Some(x) = value && *x == 1 { ... } のように && で条件を繋げられます。エディション2021以前ではコンパイルエラーになるので、Cargo.tomledition を確認してください。

実務で使う Option のメソッド

毎回 match を書くとは限りません。よく使うものだけ挙げておきます。

メソッド 動作
unwrap_or(既定値) Some なら中身、None なら既定値を返す
map(f) Some の中身に関数を適用し、None はそのまま
is_some() / is_none() 値の有無を bool で返す
unwrap() / expect("メッセージ") 中身を取り出す。None ならパニックして異常終了する

unwrap() は手軽ですが、null 参照エラーを避けるための Option を台無しにしかねません。使うのは「絶対に None にならない」と確信できる場面か、書き捨てのコードに留めるのが安全です。


第6章:練習問題

cargo new で作ったプロジェクトの src/main.rs を書き換えて、cargo run で確かめてみてください。解答例は折りたたんであります。

問題1(第1章:構造体)

書籍を表す構造体 Book を定義してください。フィールドはタイトル(String)、著者(String)、価格(u32)の3つです。main 関数でインスタンスを1つ生成し、すべてのフィールドを表示してください。

解答例
struct Book {
    title: String,
    author: String,
    price: u32,
}

fn main() {
    let book = Book {
        title: String::from("The Rust Programming Language"),
        author: String::from("Steve Klabnik"),
        price: 4200,
    };

    println!("{} / {} / {}円", book.title, book.author, book.price);
}

#[derive(Debug)] を付けて println!("{:#?}", book) で丸ごと表示するのもおすすめです。

問題2(第2章:impl ブロック)

問題1の Book に、impl ブロックで次の2つを追加してください。

  • 型関連関数 new:タイトル・著者・価格を受け取ってインスタンスを返す
  • メソッド discounted_price:割引率(u32、パーセント)を受け取り、割引後の価格を返す
解答例
impl Book {
    fn new(title: String, author: String, price: u32) -> Self {
        Self { title, author, price }
    }

    fn discounted_price(&self, percent: u32) -> u32 {
        self.price * (100 - percent) / 100
    }
}

fn main() {
    let book = Book::new(
        String::from("The Rust Programming Language"),
        String::from("Steve Klabnik"),
        4200,
    );

    println!("{}円", book.discounted_price(20));   // 3360円
}

self.price * (100 - percent) を先に計算しているのは、整数の除算で小数が切り捨てられるのを避けるためです。self.price / 100 * (100 - percent) の順にすると誤差が出ます。

問題3(第3章:列挙型)

支払い方法を表す列挙型 Payment を定義してください。バリアントは次の3つです。

  • Cash(データなし)
  • CreditCard(String)(カード番号)
  • BankTransfer { bank: String, branch: String }(銀行名と支店名)

さらに impldescribe メソッドを定義し、バリアントごとに説明文を返してください。

解答例
enum Payment {
    Cash,
    CreditCard(String),
    BankTransfer { bank: String, branch: String },
}

impl Payment {
    fn describe(&self) -> String {
        match self {
            Payment::Cash => String::from("現金払い"),
            Payment::CreditCard(number) => format!("カード払い({})", number),
            Payment::BankTransfer { bank, branch } => {
                format!("銀行振込({} {}支店)", bank, branch)
            }
        }
    }
}

fn main() {
    let payments = vec![
        Payment::Cash,
        Payment::CreditCard(String::from("1234-5678")),
        Payment::BankTransfer {
            bank: String::from("〇〇銀行"),
            branch: String::from("△△"),
        },
    ];

    for p in &payments {
        println!("{}", p.describe());
    }
}

&self で借用しているため、match の中の numberbank&String として束縛されます(マッチエルゴノミクス)。format! は参照のまま使えるので、そのまま書けます。

問題4(第4章:Option 型)

整数のベクタと探したい値を受け取り、最初に見つかったインデックスを返す関数 find_index を定義してください。見つからない場合は None を返します。main では if let を使って結果を表示してください。

解答例
fn find_index(v: &[i32], target: i32) -> Option<usize> {
    for (i, value) in v.iter().enumerate() {
        if *value == target {
            return Some(i);
        }
    }
    None
}

fn main() {
    let v = vec![10, 20, 30];

    if let Some(i) = find_index(&v, 20) {
        println!("インデックス {} で見つかりました", i);
    } else {
        println!("見つかりませんでした");
    }
}

引数を &Vec<i32> ではなく &[i32] にしておくと、配列やスライスからも呼べて便利です。なお標準ライブラリには同じことをする iter().position(|x| *x == target) があります。

問題5(第5章:マッチガード)

Vec<i32> の要素を get で取得し、match で次のように分岐させてください。

  • 値が存在し、かつ偶数のとき:偶数です: 値
  • 値が存在し、それ以外のとき:奇数です: 値
  • 値が存在しないとき:値がありません
解答例
fn main() {
    let v = vec![1, 2, 3];

    for i in 0..4 {
        match v.get(i) {
            Some(x) if *x % 2 == 0 => println!("偶数です: {}", x),
            Some(x) => println!("奇数です: {}", x),
            None => println!("値がありません"),
        }
    }
}

get(3) は範囲外なので None が返り、パニックせずに最後の腕へ入ります。v[3] と添字でアクセスした場合はパニックするので、この違いが Option を返すメソッドの利点です。

総合問題:タスク管理

ここまでの内容をすべて使って、簡単なタスク管理を書いてみましょう。

  • 状態を表す列挙型 Status を定義する。バリアントは Todo(データなし)、InProgress(u32)(進捗率)、Done { by: String }(担当者)の3つ
  • タスクを表す構造体 Task を定義する。フィールドは idtitlestatus
  • Task に型関連関数 new と、状態に応じた説明文を返すメソッド describe を実装する
  • ID からタスクを探す関数 find_task を定義する。見つからない場合は None を返す
  • main で全タスクを表示したあと、存在する ID と存在しない ID の両方で検索し、matchif let で結果を出し分ける
解答例
enum Status {
    Todo,
    InProgress(u32),
    Done { by: String },
}

struct Task {
    id: u32,
    title: String,
    status: Status,
}

impl Task {
    fn new(id: u32, title: &str, status: Status) -> Self {
        Self {
            id,
            title: String::from(title),
            status,
        }
    }

    fn describe(&self) -> String {
        match &self.status {
            Status::Todo => format!("[{}] {} : 未着手", self.id, self.title),
            Status::InProgress(percent) => {
                format!("[{}] {} : 進行中({}%)", self.id, self.title, percent)
            }
            Status::Done { by } => {
                format!("[{}] {} : 完了(担当 {})", self.id, self.title, by)
            }
        }
    }
}

fn find_task(tasks: &[Task], id: u32) -> Option<&Task> {
    for task in tasks {
        if task.id == id {
            return Some(task);
        }
    }
    None
}

fn main() {
    let tasks = vec![
        Task::new(1, "設計", Status::Done { by: String::from("佐藤") }),
        Task::new(2, "実装", Status::InProgress(60)),
        Task::new(3, "テスト", Status::Todo),
    ];

    for task in &tasks {
        println!("{}", task.describe());
    }

    match find_task(&tasks, 2) {
        Some(task) => println!("見つかりました → {}", task.describe()),
        None => println!("該当するタスクはありません"),
    }

    if let Some(task) = find_task(&tasks, 99) {
        println!("見つかりました → {}", task.describe());
    } else {
        println!("ID 99 のタスクはありません");
    }
}

ポイントは3つです。

  • バリアントごとに持てるデータの形を変えられるので、Todo はデータなし、InProgress はタプル型、Done は構造体バリアントにしています
  • describe&self で借用しているため match &self.status とし、percentby は参照として束縛されます
  • find_task の戻り値を Option<&Task> にすることで、「見つからなかった」をエラーではなく型で表現できます

まとめ:構造体・列挙型・Option チェックリスト

  • struct でフィールドを定義し、インスタンス化してフィールドにアクセスできる
  • フィールド初期化の省略記法と、mut がインスタンス単位である点を理解した
  • impl の中で、self を取ればメソッド、取らなければ型関連関数だと区別できる
  • Selfnew の慣習を理解し、コンストラクタを書ける
  • 列挙型の3つのバリアントの形(データなし/タプル型/構造体)を使い分けられる
  • 「まとめて持つなら構造体、いずれか1つなら列挙型」と説明できる
  • Option が null の代替であり、Some / None の2バリアントであることを理解した
  • match(網羅が必要)と if let(必要な分岐だけ)を使い分けられる
  • マッチガードで条件を追加でき、それでも網羅性チェックは緩まないと分かっている

構造体と列挙型は、この先のトレイトやジェネリクスの土台になります。まずは「自分が扱いたいデータをどちらで表現すべきか」を考える癖をつけておくと、後の章がぐっと楽になります。

最後に

プログラミングの初級レベルの問題に取り組めるドリルアプリを作りました。
Rustも取り扱っているので、ぜひアウトプットに活用してみて下さい!

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?