🦀

The Rust Programing Language 10日目(12章 前半)

2023/11/14に公開
2

はじめに

いつの間にか数日経過していた
週末忙しかったので気を新たに再開、一度中断したものを再開できる勇気も大事

前回のあらすじ

前回はテストについてのさわりを知った
TDDがやりやすそうだなあという印象を覚えたのであった

本日の学び

12章 入出力プロジェクト: コマンドラインプログラムを構築するを読むぞ
今までで一番苦労しそうな予感があるのではじめに断っておくと、1章を複数日に分けて読むことも辞さない覚悟である

前提

ファイル名と文字列を受け取るgrepを作成する、とのこと
grepは何をするか想像できるのでとっつきやすいかもしれない

コマンドライン引数を受け付ける

引数の値を読み取る

標準ライブラリstd::env::argsを使ってコマンドライン引数を読み取れる
これはコマンドライン引数のイテレータを返す
イテレータに対してcollect関数を呼び出すことで、コレクションに変換可能

src/main.rs
use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    println!("{:?}", args);
}
$ cargo run
--snip--
["target/debug/minigrep"]

$ cargo run needle haystack
--snip--
["target/debug/minigrep", "needle", "haystack"]

ベクタの最初の要素は"target/debug/minigrep"になっているが、これはバイナリの名前
バイナリの名前を取れると嬉しいことも稀によくあるらしいがよくわかりません!
変数に入れるなどするときは添字1から始めるとコマンドライン引数を取得可能

ファイルを読む

filenameコマンドライン引数で指定されたファイルを読み込む

src/main.rs
use std::env;
use std::fs::File;
use std::io::prelude::*;

fn main() {
    let args: Vec<String> = env::args().collect();
    let query = &args[1];
    let filename = &args[2];

    println!("In file {}", filename);

    // ファイルが見つかりませんでした
    let mut f = File::open(filename).expect("file not found");

    let mut contents = String::new();
    f.read_to_string(&mut contents)
        // ファイルの読み込み中に問題がありました
        .expect("something went wrong reading the file");

    // テキストは\n{}です
    println!("With text:\n{}", contents);
}

リファクタリング

以下の4点を改善する

  1. main関数が引数の解析・ファイルの展開の2つの責務を負っている
  2. 設定用変数が多くなると複雑化するため、構造体にしまう
  3. ファイルを開き損ねたとき、開けない理由に関わらず同じエラーメッセージを返却している
  4. 引数が不足した時のエラーが不親切

バイナリプロジェクトの責任の分離

mainの肥大化を防ぐための工程

  • プログラムをmain.rsとlib.rsに分け、ロジックをlib.rsに移動する。
  • コマンドライン引数の解析ロジックが小規模な限り、main.rsに置いても良い。
  • コマンドライン引数の解析ロジックが複雑化の様相を呈し始めたら、main.rsから抽出してlib.rsに移動する。

この工程の後にmain関数に残る責任は以下に限定される:

  • 引数の値でコマンドライン引数の解析ロジックを呼び出す
  • 他のあらゆる設定を行う
  • lib.rsのrun関数を呼び出す
  • runがエラーを返した時に処理する

main関数は読めばわかるくらい簡潔・小規模にする

引数解析器の抽出

src/main.rs
fn main() {
    let args: Vec<String> = env::args().collect();

    let (query, filename) = parse_config(&args);

    // --snip--
}

fn parse_config(args: &[String]) -> (&str, &str) {
    let query = &args[1];
    let filename = &args[2];

    (query, filename)
}

設定値をまとめる

現在はparse_configから受け取った値をタプルで受け取っているが、それをすぐ分解している
上記のような動作は正しい抽象化を行えていないサイン
configの2値も相互に関係するにも関わらず別の値になっているので、構造体に置き換える

src/main.rs
fn main() {
    let args: Vec<String> = env::args().collect();

    let config = parse_config(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    let mut f = File::open(config.filename).expect("file not found");

    // --snip--
}

struct Config {
    query: String,
    filename: String,
}

fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let filename = args[2].clone();

    Config { query, filename }
}

元々はargsStringを参照する文字列スライスを返却していたが、Configでは所有するString値を含むようにしている
cloneを使用するのは非効率だが、コードの単純化の観点から合理的

Configのコンストラクタを作成する

parse_configの責務はConfigのインスタンス生成なので、コンストラクタによって単純化する

src/main.rs
fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args);

    // --snip--
}

// --snip--

impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let filename = args[2].clone();

        Config { query, filename }
    }
}

シンプルになった
読んでいてこっちの方が良くない?と思ったが、どうなのだろうか。。

src/main.rs
fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(args[1].clone(), args[2].clone());

    // --snip--
}

// --snip--

impl Config {
    fn new(query: String, filename: String) -> Config {
        Config { query, filename }
    }
}

上記の方がいいと思ったのは何となくだが、言語化するとしたらConfigのコンストラクタにした時点で引数解析器の責務からはズレるので、コンストラクタの中で引数を読むのはおかしいのでは?ということだと思う
これは自分ならこうすると思うので、元の例の方が明らかに良いという観点があるなら教えてください

エラー処理を修正する

引数チェックが不十分なので追加する

src/main.rs
// --snip--
fn new(args: &[String]) -> Config {
    if args.len() < 3 {
        // 引数の数が足りません
        panic!("not enough arguments");
    }
    // --snip--

panicを呼ぶ代わりにnewからResultを返す

src/main.rs
use std::process;

fn main() {
    let args: Vec<String> = env::args().collect();

    let config = Config::new(&args).unwrap_or_else(|err| {
        // 引数解析時に問題
        println!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    // --snip--
}

impl Config {
    fn new(args: &[String]) -> Result<Config, &'static str> {
        if args.len() < 3 {
            return Err("not enough arguments");
        }

        let query = args[1].clone();
        let filename = args[2].clone();

        Ok(Config { query, filename })
    }
}

unwrap_or_elsepanic!ではないエラーを定義できる
クロージャについては後ほど、、、
process::exitでシステムを停止

$ cargo run
   Compiling minigrep v0.1.0 (file:///projects/minigrep)
    Finished dev [unoptimized + debuginfo] target(s) in 0.48 secs
     Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments

余計な出力が消えて見やすいね

mainからロジックを抽出する

src/main.rs
fn main() {
    // --snip--

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    run(config);
}

fn run(config: Config) {
    let mut f = File::open(config.filename).expect("file not found");

    let mut contents = String::new();
    f.read_to_string(&mut contents)
        .expect("something went wrong reading the file");

    println!("With text:\n{}", contents);
}

// --snip--

設定値のセットアップやエラー処理以外の部分を外に切り出したことで、main関数は簡潔になった
run関数はファイル読み込みから始まる全てのロジックを含むようになった

run関数からエラーを返す

Config::newにしたように、エラー処理を改善する

src/main.rs
use std::error::Error;

// --snip--

fn run(config: Config) -> Result<(), Box<dyn Error>> {
    let mut f = File::open(config.filename)?;

    let mut contents = String::new();
    f.read_to_string(&mut contents)?;

    println!("With text:\n{}", contents);

    Ok(())
}

Box<dyn Error>はいろんなエラー型を取れる。dynはdynamic
Ok(())を返却するのは、runを副作用のためにのみ呼ぶことを示す慣習

mainでrunから返却されたエラーを処理する

src/main.rs
fn main() {
    // --snip--

    println!("Searching for {}", config.query);
    println!("In file {}", config.filename);

    if let Err(e) = run(config) {
        println!("Application error: {}", e);

        process::exit(1);
    }
}

if letrunErrを返したかどうかを確認する

コードをライブラリクレートに分割する

テストをするのと、main.rsファイルの責任を減らすために一部のコードをlib.rsに移動する

src/lib.rs
use std::error::Error;
use std::fs::File;
use std::io::prelude::*;

pub struct Config {
    pub query: String,
    pub filename: String,
}

impl Config {
    pub fn new(args: &[String]) -> Result<Config, &'static str> {
        // --snip--
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
    // --snip--
}

pubを使用して、テスト可能な公開APIを持つライブラリクレートを作成
これをmain.rsのバイナリクレートのスコープに持ち込む

src/main.rs
extern crate minigrep;

use std::env;
use std::process;

use minigrep::Config;

fn main() {
    // --snip--
    if let Err(e) = minigrep::run(config) {
        // --snip--
    }
}

これでテストが書けるぞ!勝った!第一部完!

本日のまとめ

作業量が多い!!!
結論として1日で読むのは諦めた
ただ、構造化すべき部分を見極めたりライブラリクレートの具体的な分割方法など学びは多かった印象
Result返すようにする辺りとか正直ちゃんとわかってない部分もあるので、後日また戻ってきて読みたい内容だった
一旦は完全に理解するよりも先に進むことと続けることを優先する
次回、12.4章

Discussion

kanaruskanarus

これは自分ならこうすると思うので、元の例の方が明らかに良いという観点があるなら教えてください

のところで思ったことを書いてみます.


このプログラムにおいて Config が外部に見せるべきことは

  1. filenamequery というフィールドがある
  2. args を参照して作られる
  3. メインロジックである run に move される

であり,

args の ( バイナリ名除く )1つめの要素を query, 2つめの要素を filename として構成される

というのは Config が ( 内部で ) 管理すべき ( なんなら, なにかの都合である日突然順番を入れ替えたりしてもいい ) もの, という観点からすると,

impl Config {
    fn new(query: String, filename: String) -> Self {}
}

fn main() {
    let args: Vec<String> = env::args().collect();
    let config = Config::new(args[1].clone(), args[2].clone());
}

だと Config::new を呼び出す側が を知っていなければならないことになり, 不親切な API な感じがあります.

Config::new の中に閉じ込めて, 呼び出し側には 2. ( args を参照して作られる ) だけが分かるようにした

impl Config {
    fn new(args: &[String]) -> Self {}
}

fn main() {
    let args: Vec<String> = env::args().collect();
    let config = Config::new(&args);
}

という形のほうが, Config の責務 (:コマンドライン引数をパースしてメインロジックに引き渡す ) がはっきりしていてよさそうに思いました.


もっと言うと, 他で args を使わないのであれば, それ自体 Config に任せて

impl Config {
    fn from_cli_args() -> Result<Self, &'static str> {
        let mut args = std::env::args().skip(1);

        if args.len() < 2 {
            return Err("Not enough arguments");
        }

        Ok(Self {
            query:    args.next().unwrap(),
            filename: args.next().unwrap(),
        })
    }
}

みたいにしてしまう手もありそうです ( この場合, 初期化ロジックが複雑になってもテストを書きづらいというデメリットがありそうですが ) .

M.sekineM.sekine

コメントありがとうございます!!(そもそも読んでくださってありがとうございます)
おかげで腑に落ちました、私はそもそもこの場合のCongfigの責務を勘違いしていました。

  • コマンドライン引数をパースしてメインロジックに引き渡す
    Configの責務ではなく、
  • ファイルに関する情報を保持する
    くらいに考えていました

そもそものgrepという目的や実装全体を見ても、この構造体はプログラム引数を管理する責務を持っていて、それを考えると引数の解釈はConfigの中で完結すべきですね
コンストラクタはあくまでConfigの初期化以外のことはすべきではないので引数を解釈すべきでは無いと思っていましたが、Configが引数から渡されるという前提に立てばそれは自然なことと理解しました
重ねてありがとうございます!