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?

お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

Rust 100 Ex 🏃【18/37】 Errorのネスト・慣例的な書き方 ~Rustらしさの目醒め~

Last updated at Posted at 2024-07-17

前の記事

全記事一覧

100 Exercise To Learn Rust 演習第18回になります!

今回の関連ページ

[05_ticket_v2/14_source] Errorのネスト

問題はこちらです。今回はソースファイルが複数に分割している点に注意が必要です。

lib.rs
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());
    }
}
status.rs
status.rs
#[derive(Debug, PartialEq, Clone)]
pub enum Status {
    ToDo,
    InProgress,
    Done,
}

impl TryFrom<String> for Status {
    type Error = ParseStatusError;

    fn try_from(value: String) -> Result<Self, Self::Error> {
        let value = value.to_lowercase();
        match value.as_str() {
            "todo" => Ok(Status::ToDo),
            "inprogress" => Ok(Status::InProgress),
            "done" => Ok(Status::Done),
            _ => Err(ParseStatusError {
                invalid_status: value,
            }),
        }
    }
}

#[derive(Debug, thiserror::Error)]
#[error("`{invalid_status}` is not a valid status. Use one of: ToDo, InProgress, Done")]
pub struct ParseStatusError {
    invalid_status: String,
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::convert::TryFrom;

    #[test]
    fn test_try_from_string() {
        let status = Status::try_from("ToDO".to_string()).unwrap();
        assert_eq!(status, Status::ToDo);

        let status = Status::try_from("inproGress".to_string()).unwrap();
        assert_eq!(status, Status::InProgress);

        let status = Status::try_from("Done".to_string()).unwrap();
        assert_eq!(status, Status::Done);
    }
}

source メソッドを呼んだ時に、 status.rs にて定義されている ParseStatusError を返すような Status 関連エラーのバリアントを列挙体 TicketNewError に追加してほしい、という問題です。

解説

lib.rs
// ...省略...

#[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::ParseStatusErrorsource として返し、かつこのエラーより変換して生成することができるようにした InvalidStatusError を定義します。 thiserror クレートめっちゃ便利...

そして呼び出し側でバリデーションを追加しています。 status.try_into()Result<Status, ParseStatusError> を返すようになっていますが、ここで便利なのが ? 演算子です!

? 演算子は try マクロ と呼ばれるもので、一種の糖衣構文みたいなもので、 status.try_into()? と書くと大体以下のような match 式に展開されます(実際に出力されるコードとは異なるかも...)。

Rust
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 関数 のような「ランタイム時にソースコードを受け取り、そのソースコード内で生成された構造体を事前に用意したコード側で扱う」みたいなことはできないような作りになっています。むしろそこがいい...

コンパイル時に型が解決するというのは、 ジェネリクスも そうです。故に、例えば以下のようなコードはコンパイルエラーになります(フライングして動的配列を使用していますがご容赦ください)。

Rust
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> で固定されておりコンパイルエラー!
}

「じゃあ FugaBar が混在した動的配列は作れないの...?」というと、これだけは例外的に可能なようにできており、それが「トレイトオブジェクト(動的ディスパッチ)」という機能です!

次のように書き直すことで FugaBar が混在している動的配列を記述可能です。

Rust
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があります。

lib.rs
// 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 がシンプルになっています!

description.rs
description.rs
// TODO: Implement `TryFrom<String>` and `TryFrom<&str>` for the `TicketDescription` type,
//   enforcing that the description is not empty and is not longer than 500 bytes.
//   Implement the traits required to make the tests pass too.

pub struct TicketDescription(String);

#[cfg(test)]
mod tests {
    use super::*;
    use std::convert::TryFrom;

    #[test]
    fn test_try_from_string() {
        let description = TicketDescription::try_from("A description".to_string()).unwrap();
        assert_eq!(description.0, "A description");
    }

    #[test]
    fn test_try_from_empty_string() {
        let err = TicketDescription::try_from("".to_string()).unwrap_err();
        assert_eq!(err.to_string(), "The description cannot be empty");
    }

    #[test]
    fn test_try_from_long_string() {
        let description = "At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat.".to_string();
        let err = TicketDescription::try_from(description).unwrap_err();
        assert_eq!(
            err.to_string(),
            "The description cannot be longer than 500 bytes"
        );
    }

    #[test]
    fn test_try_from_str() {
        let description = TicketDescription::try_from("A description").unwrap();
        assert_eq!(description.0, "A description");
    }
}
status.rs
status.rs
// TODO: Implement `TryFrom<String>` and `TryFrom<&str>` for the `Status` enum.
//  The parsing should be case-insensitive.

pub enum Status {
    ToDo,
    InProgress,
    Done,
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::convert::TryFrom;

    #[test]
    fn test_try_from_string() {
        let status = Status::try_from("ToDO".to_string()).unwrap();
        assert_eq!(status, Status::ToDo);

        let status = Status::try_from("inproGress".to_string()).unwrap();
        assert_eq!(status, Status::InProgress);

        let status = Status::try_from("Done".to_string()).unwrap();
        assert_eq!(status, Status::Done);
    }

    #[test]
    fn test_try_from_str() {
        let status = Status::try_from("ToDO").unwrap();
        assert_eq!(status, Status::ToDo);

        let status = Status::try_from("inproGress").unwrap();
        assert_eq!(status, Status::InProgress);

        let status = Status::try_from("Done").unwrap();
        assert_eq!(status, Status::Done);
    }

    #[test]
    fn test_try_from_invalid() {
        let status = Status::try_from("Invalid");
        assert!(status.is_err());
    }
}
title.rs
title.rs
// TODO: Implement `TryFrom<String>` and `TryFrom<&str>` for the `TicketTitle` type,
//   enforcing that the title is not empty and is not longer than 50 characters.
//   Implement the traits required to make the tests pass too.

pub struct TicketTitle(String);

#[cfg(test)]
mod tests {
    use super::*;
    use std::convert::TryFrom;

    #[test]
    fn test_try_from_string() {
        let title = TicketTitle::try_from("A title".to_string()).unwrap();
        assert_eq!(title.0, "A title");
    }

    #[test]
    fn test_try_from_empty_string() {
        let err = TicketTitle::try_from("".to_string()).unwrap_err();
        assert_eq!(err.to_string(), "The title cannot be empty");
    }

    #[test]
    fn test_try_from_long_string() {
        let title =
            "A title that's definitely longer than what should be allowed in a development ticket"
                .to_string();
        let err = TicketTitle::try_from(title).unwrap_err();
        assert_eq!(err.to_string(), "The title cannot be longer than 50 bytes");
    }

    #[test]
    fn test_try_from_str() {
        let title = TicketTitle::try_from("A title").unwrap();
        assert_eq!(title.0, "A title");
    }
}

解説

thiserror クレートを導入して演習開始です!

bash
cargo add thiserror

lib.rs の改変は必要ありません。

各フィールドについて、関心がファイルごとに分かれています!ファイルごとにTODOを満たしていきます。

description.rs では、文字列型から構造体 TicketDescription に変換する TryFrom を実装します。専用のエラー型も用意します!

description.rs (該当箇所のみ抜粋)
#[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 を実装します。

TodotoDO も受け入れられるよう、 to_lowercase を使用して小文字での比較をしています。 &strmatch にてパターンマッチ分岐できるので便利ですね。

status.rs (該当箇所のみ抜粋)
#[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 とエラー型を用意すれば完成です!

title.rs (該当箇所のみ抜粋)
#[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】 配列・動的配列 ~スタックが使われる配列と、ヒープに保存できる動的配列~

  1. 今回例として出したコードをコンパイルするまでに筆者もn敗しました

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?