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::newでtitle: 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つ足したときです。StatusにPendingを追加すると、matchを書いた場所すべてでエラーが出ます——直すべき場所を、コンパイラが全部教えてくれます。
「機能を足したら、直すべき場所が自動で見つかる」——大きなプログラムほど、この価値は大きくなります。
impl Task { ... }はこの型にメソッドを足す書き方です。&selfを受け取っているので借用——読むだけで、書き換えません。
Rustではデータの定義とメソッドが分かれています。structで形を決め、implで振る舞いを足す。1つの型にimplブロックをいくつ書いても構いません。
[x] 設計をまとめる [-] 画面を作る [ ] テストを書く [ ] 資料を整理する [-] レビューを反映する 作業中: 2件
STEP 4Optionで「期限なし」を扱う
🎯 このステップのゴール: mapで中身だけを加工し、matchで場合分けする
期限までの残り日数を出します。ただし期限の無いタスクがあります——due_dayはOption<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で符号のある整数に変換しているのにも理由があります。u32は0以上しか表せません。10 - 16をu32のまま計算すると桁あふれ——デバッグビルドではその場で落ち、リリースビルドではとてつもなく大きな数になります。
引き算をするときは、負になりうるかを必ず考えてください。
表示はmatchで4つに分けます。Some(d) if d < 0のifが「マッチガード」——「Someに当たったうえで、さらに条件を見る」という書き方です。
順番が結果を決めます。Some(d) if d < 0をSome(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で確かめたことが、ここで効いています。
TODAYを25に変えてみてください。期限切れが増え、締めの一言も変わります。HOURS_PER_DAYを0.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++のレッスンへ進む