More than 1 year has passed since last update.
Rust 100 Ex 🏃【18/37】 Errorのネスト・慣例的な書き方 ~Rustらしさの目醒め~
前の記事
- 【0】 準備 ← 初回
- ...
- 【17】 thiserror・TryFrom ~トレイトもResultも自由自在!~ ← 前回
- 【18】 Errorのネスト・慣例的な書き方 ~Rustらしさの目醒め~ ← 今回
100 Exercise To Learn Rust 演習第18回になります!
今回の関連ページ
[05_ticket_v2/14_source] Errorのネスト
問題はこちらです。今回はソースファイルが複数に分割している点に注意が必要です。
use crate::status::Status;
// We've seen how to declare modules in one of the earliest exercises, but
// we haven't seen how to extract them into separate files.
// Let's fix that now!
//
// In the simplest case, when the extracted module is a single file, it is enough to
// create a new file with the same name as the module and move the module content there.
// The module file should be placed in the same directory as the file that declares the module.
// In this case, `src/lib.rs`, thus `status.rs` should be placed in the `src` directory.
mod status;
// TODO: Add a new error variant to `TicketNewError` for when the status string is invalid.
// When calling `source` on an error of that variant, it should return a `ParseStatusError` rather than `None`.
#[derive(Debug, thiserror::Error)]
pub enum TicketNewError {
#[error("Title cannot be empty")]
TitleCannotBeEmpty,
#[error("Title cannot be longer than 50 bytes")]
TitleTooLong,
#[error("Description cannot be empty")]
DescriptionCannotBeEmpty,
#[error("Description cannot be longer than 500 bytes")]
DescriptionTooLong,
}
#[derive(Debug, PartialEq, Clone)]
pub struct Ticket {
title: String,
description: String,
status: Status,
}
impl Ticket {
pub fn new(title: String, description: String, status: String) -> Result<Self, TicketNewError> {
if title.is_empty() {
return Err(TicketNewError::TitleCannotBeEmpty);
}
if title.len() > 50 {
return Err(TicketNewError::TitleTooLong);
}
if description.is_empty() {
return Err(TicketNewError::DescriptionCannotBeEmpty);
}
if description.len() > 500 {
return Err(TicketNewError::DescriptionTooLong);
}
// TODO: Parse the status string into a `Status` enum.
Ok(Ticket {
title,
description,
status,
})
}
}
#[cfg(test)]
mod tests {
use common::{valid_description, valid_title};
use std::error::Error;
use super::*;
#[test]
fn invalid_status() {
let err = Ticket::new(valid_title(), valid_description(), "invalid".into()).unwrap_err();
assert_eq!(
err.to_string(),
"`invalid` is not a valid status. Use one of: ToDo, InProgress, Done"
);
assert!(err.source().is_some());
}
}
source メソッドを呼んだ時に、 status.rs にて定義されている ParseStatusError を返すような Status 関連エラーのバリアントを列挙体 TicketNewError に追加してほしい、という問題です。
解説
// ...省略...
#[derive(Debug, thiserror::Error)]
pub enum TicketNewError {
#[error("Title cannot be empty")]
TitleCannotBeEmpty,
#[error("Title cannot be longer than 50 bytes")]
TitleTooLong,
#[error("Description cannot be empty")]
DescriptionCannotBeEmpty,
#[error("Description cannot be longer than 500 bytes")]
DescriptionTooLong,
+ #[error("{0}")]
+ InvalidStatusString(#[from] status::ParseStatusError),
}
// ...省略...
impl Ticket {
pub fn new(title: String, description: String, status: String) -> Result<Self, TicketNewError> {
// ...省略...
// TODO: Parse the status string into a `Status` enum.
+ let status = status.try_into()?;
Ok(Ticket {
title,
description,
status,
})
}
}
// ...省略...
列挙型の方には、 status::ParseStatusError を source として返し、かつこのエラーより変換して生成することができるようにした InvalidStatusError を定義します。 thiserror クレートめっちゃ便利...
そして呼び出し側でバリデーションを追加しています。 status.try_into() は Result<Status, ParseStatusError> を返すようになっていますが、ここで便利なのが ? 演算子です!
? 演算子は try マクロ と呼ばれるもので、一種の糖衣構文みたいなもので、 status.try_into()? と書くと大体以下のような match 式に展開されます(実際に出力されるコードとは異なるかも...)。
match status.try_into() {
Ok(s) => s,
Err(e) => return Err(TicketNewError::from(e)),
}
-
Okの時は持っている値を返す -
Errの時は返り値のError型にfrom/intoでキャストし、returnする
頻出構文である match を短く書くための演算子というわけです!正体不明な処理をするわけではないのでお手軽です。トレイトにしろ列挙体にしろこういう糖衣構文にしろ、 Rustの文法は型システムを絡めて比較的簡単に説明できる/理解しやすい なと思います。筆者がRustを好む理由の一つです。
トレイトオブジェクト
今回 source メソッドの返り値型としてサラッとトレイトオブジェクト( &(dyn Error + 'static) )が登場していますが、本エクササイズのBook以外で登場していなかったため軽く言及しておきたいと思います(Bookでも後述すると書いておきながらその後詳細は未登場...?)。
まず前提として、Rustでは 基本的にコンパイル時に全ての型が解決します。言い換えると、 "リフレクション" と呼ばれるような、 「ランタイム時に型を決定する」機能に関してはかなり貧弱 です。
具体的には(マクロのようにRustコード自体を入出力とするプログラムを書くことはありますが)、いわゆる eval 関数 のような「ランタイム時にソースコードを受け取り、そのソースコード内で生成された構造体を事前に用意したコード側で扱う」みたいなことはできないような作りになっています。むしろそこがいい...
コンパイル時に型が解決するというのは、 ジェネリクスも そうです。故に、例えば以下のようなコードはコンパイルエラーになります(フライングして動的配列を使用していますがご容赦ください)。
trait Hoge {}
#[derive(Clone, Copy)]
struct Fuga;
impl Hoge for Fuga {}
#[derive(Clone, Copy)]
struct Bar;
impl Hoge for Bar {}
fn create_vec<T: Hoge + Clone + Copy>(v: T) -> Vec<T> {
vec![v; 5]
}
fn main() {
let mut t = create_vec(Fuga);
// t は Vec<impl Hoge> であることを期待して
// 以下のように書いても、
t.push(Bar);
// expected `Fuga`, found `Bar`
// すでに動的配列の型は Vec<Fuga> で固定されておりコンパイルエラー!
}
「じゃあ Fuga と Bar が混在した動的配列は作れないの...?」というと、これだけは例外的に可能なようにできており、それが「トレイトオブジェクト(動的ディスパッチ)」という機能です!
次のように書き直すことで Fuga と Bar が混在している動的配列を記述可能です。
trait Hoge {}
#[derive(Clone, Copy)]
struct Fuga;
impl Hoge for Fuga {}
#[derive(Clone, Copy)]
struct Bar;
impl Hoge for Bar {}
fn create_vec<T: Hoge + Clone + Copy + 'static>(v: T) -> Vec<Box<dyn Hoge>> {
(0..5).map(|_| -> Box<dyn Hoge> { Box::new(v) }).collect()
}
fn main() {
let mut t = create_vec(Fuga);
// t は Vec<Box<dyn Hoge>> であるため
// Box<Bar> は要件を満たし追加可能
t.push(Box::new(Bar));
}
Box<dyn T> という型が出てきました。 Box は「コンパイル時サイズ不定でも、包むことでサイズを確定したことにする」ために使用しています。中身の dyn T は、「あるトレイトを実装したある型(ただしコンパイル時未確定)」を表しています!
Box<U> の U は ?Sized 、つまりコンパイル時サイズ不明でも指定できます。これは U 型の値をヒープ上に置き、代わりにポインタ(コンパイル時サイズ確定!)を値として持つことで実現しています。
なぜ「コンパイル時サイズ不定」となってしまうかというと、(今回の例だったらソースコード全体を読めばわかりそうな気もしますが)一般的に「あるトレイトを実装したある型」という記述だけではコンパイル時にはサイズがわからないためです!
Rustの一機能でしかないようなトレイトオブジェクトをちょっと回りくどく説明していたり、これをメインテーマとしたエクササイズがない理由ですが、トレイトオブジェクトは できれば使用を避けたい 機能だからです。コンパイル時に型が確定しないとそれなりのオーバーヘッドがあったり、またそもそもトレイトオブジェクトとして扱うにはvtableが作れなければならない、みたいな制約が多かったりと1、使いにくい機能になっており滅多に出てきません。
「記述場所が異なると違う型扱いされるクロージャを、同時に複数種類扱いたい」とか、「面倒な型パズルを回避したい」等、どうしても必要な場合というのはなくはないのですが、例えば列挙型を使うことでトレイトオブジェクトを使用せずに目的を達成できるなら、無理に使うべき機能ではないという感じです。
[05_ticket_v2/15_outro] 慣例的なコードへとリファクタ!
問題のソースコードは複数ファイルに分割されており、各ファイルにTODOがあります。
// TODO: you have something to do in each of the modules in this crate!
mod description;
mod status;
mod title;
// A common pattern in Rust is to split code into multiple (private) modules
// and then re-export the public parts of those modules at the root of the crate.
//
// This hides the internal structure of the crate from your users, while still
// allowing you to organize your code however you like.
pub use description::TicketDescription;
pub use status::Status;
pub use title::TicketTitle;
#[derive(Debug, PartialEq, Clone)]
// We no longer need to make the fields private!
// Since each field encapsulates its own validation logic, there is no risk of
// a user of `Ticket` modifying the fields in a way that would break the
// invariants of the struct.
//
// Careful though: if you had any invariants that spanned multiple fields, you
// would need to ensure that those invariants are still maintained and go back
// to making the fields private.
pub struct Ticket {
pub title: TicketTitle,
pub description: TicketDescription,
pub status: Status,
}
Ticket 構造体の各フィールドが専用の型になっており、そちらで正規の値を作れる上、カプセル化により不正な値を作れなくなっています。そして lib.rs がシンプルになっています!
解説
thiserror クレートを導入して演習開始です!
cargo add thiserror
lib.rs の改変は必要ありません。
各フィールドについて、関心がファイルごとに分かれています!ファイルごとにTODOを満たしていきます。
description.rs では、文字列型から構造体 TicketDescription に変換する TryFrom を実装します。専用のエラー型も用意します!
#[derive(Debug, PartialEq, Clone)]
pub struct TicketDescription(String);
#[derive(thiserror::Error, Debug)]
pub enum InvalidDescriptionError {
#[error("The description cannot be empty")]
Empty,
#[error("The description cannot be longer than 500 bytes")]
TooLong,
}
use InvalidDescriptionError::*;
fn str2description(value: &str) -> Result<TicketDescription, InvalidDescriptionError> {
if value.is_empty() {
return Err(Empty);
}
if value.len() > 500 {
return Err(TooLong);
}
Ok(TicketDescription(value.to_string()))
}
impl TryFrom<String> for TicketDescription {
type Error = InvalidDescriptionError;
fn try_from(value: String) -> Result<Self, Self::Error> {
str2description(&value)
}
}
impl TryFrom<&str> for TicketDescription {
type Error = InvalidDescriptionError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
str2description(value)
}
}
同じく、 status.rs では文字列型から Status 型へ変換する TryFrom を実装します。
Todo や toDO も受け入れられるよう、 to_lowercase を使用して小文字での比較をしています。 &str は match にてパターンマッチ分岐できるので便利ですね。
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Status {
ToDo,
InProgress,
Done,
}
use Status::*;
#[derive(thiserror::Error, Debug)]
#[error("{0}")]
pub struct InvalidStatusError(String);
fn str2status(value: &str) -> Result<Status, InvalidStatusError> {
match value.to_lowercase().as_str() {
"todo" => Ok(ToDo),
"inprogress" => Ok(InProgress),
"done" => Ok(Done),
s => Err(InvalidStatusError(s.to_string())),
}
}
impl TryFrom<String> for Status {
type Error = InvalidStatusError;
fn try_from(value: String) -> Result<Self, Self::Error> {
str2status(&value)
}
}
impl TryFrom<&str> for Status {
type Error = InvalidStatusError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
str2status(value)
}
}
最後に title.rs にも同様の TryFrom とエラー型を用意すれば完成です!
#[derive(Debug, PartialEq, Clone)]
pub struct TicketTitle(String);
#[derive(thiserror::Error, Debug)]
pub enum InvalidTitleError {
#[error("The title cannot be empty")]
Empty,
#[error("The title cannot be longer than 50 bytes")]
TooLong,
}
use InvalidTitleError::*;
fn str2title(value: &str) -> Result<TicketTitle, InvalidTitleError> {
if value.is_empty() {
return Err(Empty);
}
if value.len() > 50 {
return Err(TooLong);
}
Ok(TicketTitle(value.to_string()))
}
impl TryFrom<String> for TicketTitle {
type Error = InvalidTitleError;
fn try_from(value: String) -> Result<Self, Self::Error> {
str2title(&value)
}
}
impl TryFrom<&str> for TicketTitle {
type Error = InvalidTitleError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
str2title(value)
}
}
第5回 の時と見比べると、かなりRustの慣例に従ったプログラムになっていて、読みやすくなっています!従い過ぎなぐらい...
ここまででRustの基本的な道具であるトレイトと列挙型について見てきました。Rustの言語慣例に従うことで読みやすいコードになおしていこうという方針でした。
次章からは配列や並列処理・非同期処理と進んでいきます。新しい機能の紹介、というエクササイズが増えて風向きが変わっていくようです。配列が後半に来ているのってなかなかファンキー...?
では次の問題に行きましょう!
次の記事: 【19】 配列・動的配列 ~スタックが使われる配列と、ヒープに保存できる動的配列~
-
今回例として出したコードをコンパイルするまでに筆者もn敗しました ↩
Register as a new user and use Qiita more conveniently
- You get articles that match your needs
- You can efficiently read back useful information
- You can use dark theme
