🎯 最終演習

Rustでタスク管理を作ってみよう(総合演習)

ここまでの Rust レッスンで学んだことを全部つないで、動くものを1つ完成させます。新しい構文は出てきません。struct・enum・match・Vec・所有権・借用・Option・Result・クロージャ・トレイト——すでに練習したものだけで組み立てます。

作るのはタスク管理です。残り時間を合計し、期限を過ぎたものを見つけ、進捗を出して、一覧にまとめます。

この演習の主題は3つです。——所有権(渡すと持ち主が変わる)、Option(値が無いかもしれない)、Result(失敗するかもしれない)。Rustを学ぶ意味は、ほぼこの3つに詰まっています。

そしてこの3つは、他の言語から来た人が必ず一度はぶつかる壁でもあります。だからステップ2では、わざとコンパイルが通らなくなる状況を作って、なぜ止められるのかを見ます。

このページではコードを実行していません。Rustはコンパイルが必要で、ブラウザの中では走らせられないためです。かわりにお手本と同じコードを書けたかを、その場で判定します(判定はあなたのブラウザの中だけで行われ、どこにも送信されません)。

表示している実行結果は本物です。このページを作るときに、実際にrustcでコンパイルして走らせた出力をそのまま載せています。

完成イメージ

最後まで進むと、このコードが完成します。下の実行結果は、このページを作るときに開発機で実際にコードを走らせて得たものです(このページ上では実行していません)。

===== タスク一覧(16日時点) =====
[x] 設計をまとめる (6.5h, 10日まで)  6日超過
[-] 画面を作る (12.0h, 18日まで)  あと2日
[ ] テストを書く (8.0h, 20日まで)  あと4日
[ ] 資料を整理する (2.5h, 期限なし)
[-] レビューを反映する (4.0h, 15日まで)  1日超過
----------------------------------------------
タスク数  : 5件
完了      : 6.5時間
残り      : 26.5時間
進捗      : 20%
残りの見込み: 約3.5日
1件が期限を過ぎています。
(集計後もタスクは 5 件あります)

STEP 1タスクをstructとenumで表す

🎯 このステップのゴール: 状態をenumに限定し、期限をOptionで持つ

まずデータの形を決めます。タスク1件が持つのは「題名・状態・見積もり時間・期限」の4つです。

状態はenum Status { Todo, Doing, Done }——この3つ以外は存在できません。文字列で持つと"done""Done"が混ざりますが、enumならその心配がありません

#[derive(Debug, PartialEq)]を付けています。Debug{:?}で表示できるように、PartialEq==で比べられるように——Rustは何もしなければ比較すらできません。必要な能力を明示的に付けていく言語です。

そしてdue_day: Option<u32>が、この演習の要です。

Rustにはnullがありません。「値が無いかもしれない」を表したければ、型でそう書くしかありませんOption<u32>Some(20)Noneのどちらかという意味です。

この設計の効き目は、使うときに出ます。Option<u32>そのままでは数値として使えません。中身を取り出す手続きを踏まないと、計算にも比較にも使えない——「値が無いかもしれない」を無視できないのです

Task::newtitle: title.to_string()としているのは、&strからStringを作っているからです。&strは借りている文字列、Stringは自分が持っている文字列——この区別は次のステップで効いてきます。

📖 お手本コード
✍️ あなたのコード
コードを書いたら「答え合わせ」を押してください
実行結果(開発機で実行したもの)
タスク数: 5件
1件目: 設計をまとめる
状態: Done
期限あり: true
4件目の期限: None

STEP 2所有権にぶつかって、借用で解決する

🎯 このステップのゴール: 渡すと持ち主が変わることを確かめ、&で借りる形に直す

ここがRustで最初の壁です。

タスクの数を数える関数を、fn count(tasks: Vec<Task>) -> usizeと書いたとします。呼ぶこと自体はできます。問題はそのあとです。

もう一度tasksを使おうとすると、コンパイルが通りません。——borrow of moved value: `tasks`と怒られます。

何が起きたのか。Rustでは値を関数に渡すと、持ち主がその関数に移ります。これをムーブと呼びます。関数が終わるとtasksは捨てられ、呼び出し元に残っているのは「もう無いもの」への名前だけです。

だからRustは、使わせません。C言語なら、この状況は解放済みメモリへのアクセス——運が良ければおかしな値、悪ければプログラムごと落ちます。Rustはそれをコンパイル時に止めます。

解決は&を1つ付けるだけです。fn count(tasks: &[Task]) -> usize——これが借用。「見せてもらうだけで、持ち主は変えない」という意味になります。

読むだけの関数は、必ず借用で受け取ってください。これがRustの基本の書き方です。

&[Task]&Vec<Task>のどちらでも動きますが、&[Task]のほうが受け入れる幅が広い——配列でもVecでも渡せます。引数はできるだけ緩く受けるのが作法です。

下のコードでは、ムーブする関数と借用する関数を両方書いて、そのあとどうなるかを確かめます。

📖 お手本コード
✍️ あなたのコード
コードを書いたら「答え合わせ」を押してください
実行結果(開発機で実行したもの)
借用で数える: 5件
もう一度:     5件
借りただけなので、まだ使える: 設計をまとめる
ムーブして数える: 5件
(この行より上で tasks の持ち主は移っています)

STEP 3matchで状態を漏れなく扱う

🎯 このステップのゴール: すべての場合を書かせる網羅性検査を体験する

状態から記号を決めます。使うのはmatchです。

match self.status { Status::Todo => ' ', ... }——3つの状態それぞれに、返す文字を書きます

ここでRustが他と違うのは、書き漏らしを許さないことです。

試しにStatus::Done => 'x',の行を消してみてください。non-exhaustive patterns: `Status::Done` not coveredとエラーになります

これを網羅性検査と呼びます。他の多くの言語では、switchの書き漏らしは実行してみるまで分かりません。しかも「たまたまその状態のデータが来なかっただけ」で、長い間気づかれないことがあります。

Rustはコンパイルの時点で止めます。そして本当に効いてくるのはあとで状態を1つ足したときです。StatusPendingを追加すると、matchを書いた場所すべてでエラーが出ます——直すべき場所を、コンパイラが全部教えてくれます。

「機能を足したら、直すべき場所が自動で見つかる」——大きなプログラムほど、この価値は大きくなります。

impl Task { ... }この型にメソッドを足す書き方です。&selfを受け取っているので借用——読むだけで、書き換えません。

Rustではデータの定義とメソッドが分かれています。structで形を決め、implで振る舞いを足す。1つの型にimplブロックをいくつ書いても構いません

📖 お手本コード
✍️ あなたのコード
コードを書いたら「答え合わせ」を押してください
実行結果(開発機で実行したもの)
[x] 設計をまとめる
[-] 画面を作る
[ ] テストを書く
[ ] 資料を整理する
[-] レビューを反映する
作業中: 2件

STEP 4Optionで「期限なし」を扱う

🎯 このステップのゴール: mapで中身だけを加工し、matchで場合分けする

期限までの残り日数を出します。ただし期限の無いタスクがあります——due_dayOption<u32>です。

task.due_day.map(|day| day as i64 - TODAY as i64)

mapは「中身があるときだけ、その中身を加工する」という意味です。Some(20)ならSome(4)に、Noneなら何もせずNoneのまま

だから戻り値もOption<i64>になります。「期限が無ければ、残り日数も無い」——0日ではありません

ここを0で埋めないでください。「期限が今日」と「期限が無い」を同じ0にした瞬間、2つの違う状態が区別できなくなりますOptionを返すのが面倒に見えても、情報を失わないほうが結局は楽です

as i64符号のある整数に変換しているのにも理由があります。u320以上しか表せません10 - 16u32のまま計算すると桁あふれ——デバッグビルドではその場で落ち、リリースビルドではとてつもなく大きな数になります。

引き算をするときは、負になりうるかを必ず考えてください。

表示はmatchで4つに分けます。Some(d) if d < 0ifが「マッチガード」——Someに当たったうえで、さらに条件を見る」という書き方です。

順番が結果を決めます。Some(d) if d < 0Some(d)より先に書かないと、全部が最後のSome(d)に吸い込まれますmatchは上から順に見るからです。

📖 お手本コード
✍️ あなたのコード
コードを書いたら「答え合わせ」を押してください
実行結果(開発機で実行したもの)
設計をまとめる: 6日超過
画面を作る: あと2日
テストを書く: あと4日
資料を整理する: 期限なし
レビューを反映する: 1日超過

STEP 5Resultで失敗を値として返す

🎯 このステップのゴール: OkとErrを返し、呼ぶ側にmatchで受けさせる

残り時間を「あと何日ぶんか」に直します。1日の作業時間で割るだけ——ただし0で割ることはできません

ここでRustは例外を投げません。かわりにResult<f64, String>を返します。Ok(値)Err(理由)のどちらかという型です。

Optionとの違いをはっきりさせておきます。Optionは「あるか、無いか」だけ。Resultは「成功したか、失敗したか——失敗ならその理由も」を運びます。

だから使い分けは単純です。「値が無い」のが普通のことならOption「失敗した理由を伝えたい」ならResultです。

受ける側はmatchです。Ok(days) => ...Err(message) => ...——両方書かないとコンパイルが通りません

これがRustのエラー処理の核心です。例外なら、書かなくても勝手に上へ飛んでいきます。便利ですが、どの行が失敗しうるのか、コードを読んでも分かりません

Rustでは失敗が戻り値の型に書いてあります。Resultと書いてあれば失敗しうる。書いていなければ失敗しない。読めば分かります。

そして受け取ったまま放っておくと警告が出ます——unused `Result` that must be used「エラーを黙って捨てた」が見つかるようになっています。

実務ではStringではなく専用のエラー型を作ることが多いのですが、仕組みはまったく同じです。まずはこの形で掴んでください。

📖 お手本コード
✍️ あなたのコード
コードを書いたら「答え合わせ」を押してください
実行結果(開発機で実行したもの)
残り: 約3.5日
計算できません: 1日の作業時間が不正です: 0
失敗したときの既定値: 0.0日

STEP 6クロージャとトレイトでまとめて仕上げる

🎯 このステップのゴール: 条件を関数に渡し、表示のしかたを型に教える

最後の仕上げです。2つの仕組みで、まとめ上げます

1つ目はクロージャです。sum_hours(tasks, |t| t.status != Status::Done)——|t| ...がその場で書いた小さな関数です。

fn sum_hours<F>(tasks: &[Task], keep: F) -> f64 where F: Fn(&Task) -> bool——whereのところが「Fはタスクを受け取って真偽を返すもの」という条件です。

これで「完了したものの合計」も「残りの合計」も、同じ関数で書けます。条件だけを差し替えればよいのです。集計の仕組みと、何を集計するかを分けられました。

2つ目はトレイトです。impl fmt::Display for Task——「この型を{}で表示する方法」を教えています

これを書くと、println!("{}", task)そのまま書けるようになります。表示のしかたが型のところに集まるので、使う側は何も考えなくて済みます

Rustではこの形があらゆる場面に出てきます。比較したければPartialOrd、複製したければClone、表示したければDisplay——「この型に、この能力を足す」という積み上げ方です。

matches!(days_left(t), Some(d) if d < 0)matchを1つの条件式に縮めた書き方です。真偽だけがほしいときに使えます。

完成です。mainの最後の行に注目してください。report(&tasks)借用で渡しているので、そのあともtasksが使えます。ステップ2で確かめたことが、ここで効いています。

TODAY25に変えてみてください。期限切れが増え、締めの一言も変わります。HOURS_PER_DAY0.0にすれば、ステップ5で書いたErrの側が表示されます——書いておいた分岐が、実際に働くところが見られます。

📖 お手本コード
✍️ あなたのコード
コードを書いたら「答え合わせ」を押してください
実行結果(開発機で実行したもの)
===== タスク一覧(16日時点) =====
[x] 設計をまとめる (6.5h, 10日まで)  6日超過
[-] 画面を作る (12.0h, 18日まで)  あと2日
[ ] テストを書く (8.0h, 20日まで)  あと4日
[ ] 資料を整理する (2.5h, 期限なし)
[-] レビューを反映する (4.0h, 15日まで)  1日超過
----------------------------------------------
タスク数  : 5件
完了      : 6.5時間
残り      : 26.5時間
進捗      : 20%
残りの見込み: 約3.5日
1件が期限を過ぎています。
(集計後もタスクは 5 件あります)

よくある質問

このページではコードを実行しないのですか?

していません。Rustはコンパイルが必要で、ブラウザの中では走らせられないためです。このページではお手本と同じコードを書けたかどうかを判定する形にしています(判定はあなたのブラウザの中だけで行われ、コードはどこにも送信されません)。

ただし表示している実行結果は本物です。このページを作るときに実際にrustcでコンパイルして走らせた出力を、そのまま載せています。

borrow of moved value と出て進めません

関数に値を渡したことで、持ち主が移っています。Rustでは値を渡すと所有権も一緒に移り、呼び出し元では使えなくなります

直し方はたいてい1つ——引数に&を付けて借用にすることです。fn count(tasks: Vec<Task>)fn count(tasks: &[Task])にして、呼ぶときもcount(&tasks)とします。

読むだけの関数は、必ず借用で受け取ってください。所有権をもらう必要があるのは、その値を保存したり、作り変えて返したりするときだけです。

Option と Result は、どう使い分けますか

失敗の理由を伝えたいかどうかで決まります。

Optionは「あるか、無いか」だけです。無いことが普通に起こる——期限を決めていない、検索して見つからなかった——そういう場面に向きます。

Resultは「成功か、失敗か」に加えて失敗の理由を運びます。ファイルが開けない、値が不正、通信に失敗——呼び出し元が理由を知りたい場面で使います。

迷ったら「呼んだ人は、なぜ失敗したか知りたいか」を考えてみてください。

match で全部の場合を書くのが面倒です

その面倒が、Rustのいちばんの利点です。

いま面倒なのは、状態が3つしかなくて全部自明だからです。効いてくるのは、あとで状態を1つ足したとき——matchを書いた場所すべてでエラーが出て、直すべき場所を全部教えてくれます

他の言語なら、書き漏らしはそのデータが来るまで気づけません

どうしても省きたいときは_ => ...でまとめられますが、使うと網羅性検査が効かなくなります。「本当にそれ以外は全部同じ扱いでよいか」を考えてから使ってください。

🚩 次はこのカテゴリへ

Rustが解決した問題は、C++で長く付き合ってきた問題です。解放済みメモリへのアクセス、初期化し忘れ、後片付けの書き忘れ——C++はデストラクタやスマートポインタで対処し、Rustは所有権としてコンパイラに検査させました。C++カテゴリの最終演習では、喫茶店の売上集計を通してその対処のしかたを見られます。

C++のレッスンへ進む
広告スペース(バナー)
広告スペース(記事内)