【Rust】mapとand_thenの違い

この記事は Rust Advent Calendar 2019 の9日目の記事です。

最近、すごいHaskellを読み終わりました。

Rustに戻ってきたので、改めてmapand_thenの違いを整理したいと思います。モナドで一般化されますが、本記事ではOption型を例に考えます。

環境

❯ rustup --version
rustup 1.20.2 (13979c968 2019-10-16)

❯ cargo version
cargo 1.38.0 (23ef9a4ef 2019-08-20)

実装の確認

まずはmapand_thenがどのように実装されているか見てみましょう。以下は、libcore/option.rsの実装です。

pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> Option<U> {
    match self {
        Some(x) => Some(f(x)),
        None => None,
    }
}

pub fn and_then<U, F: FnOnce(T) -> Option<U>>(self, f: F) -> Option<U> {
    match self {
        Some(x) => f(x),
        None => None,
    }
}

クロージャFシグネチャに注目します。
FnOnceを無視すると以下のようになっています。

  • map: T -> U
  • and_then: T -> Option<U>

例として、値を2倍にするようなクロージャを考えると、以下のような感じでしょうか。 mapは値をそのまま返し、and_thenSomeで包んで返します。

fn main() {
    let m = Some(1).map(|x| x * 2);
    println!("{:?}", m); // Some(2)

    let a = Some(1).and_then(|x| Some(x * 2));
    println!("{:?}", a); // Some(2)
}

上記の例のように単純に値を写像させたいだけの場合は、mapが便利なのですが、複数の(Optionを返す)関数をチェインさせたい場合は、and_thenが真価を発揮します。1

複数の関数をチェインさせる例として、以下のようなDBに対して、idからnameを引き、nameからpriceを得ることを考えます。

PRODUCTSテーブル

id name
0 desk
1 chair
2 whiteboard
3 pencil

PRICEテーブル

name price
desk 300
chair 100
whiteboard 20

実装

DBの代替にHashMapを使います。

lazy_static! {
    static ref PRODUCTS: HashMap<u32, &'static str> = {
        let mut m = HashMap::new();
        m.insert(0, "desk");
        m.insert(1, "chair");
        m.insert(2, "whiteboard");
        m.insert(3, "pencil");
        m
    };

    static ref PRICE: HashMap<&'static str, u32> = {
        let mut m = HashMap::new();
        m.insert("desk", 300);
        m.insert("chair", 100);
        m.insert("whiteboard", 20);
        m
    };
}

idからnameを得る関数をget_productnameからpriceを得る関数をget_priceとします。 DB上にレコードが存在しない可能性があるので返り値がOption型であることに注意してください。

fn get_product(id: u32) -> Option<&'static str> {
    match PRODUCTS.get(&id) {
        Some(u) => Some(u),
        None => None,
    }
}

fn get_price(product: &'static str) -> Option<u32> {
    match PRICE.get(&product) {
        Some(u) => Some(*u),
        None => None,
    }
}

get_productget_priceをチェインさせる

まずはmapを使った場合です。

fn main() {
    let product_id = 1;
    let price = get_product(product_id)
        .map(get_price);
    println!("{:?}", price); // Some(Some(100))
}

Someが二重に包まれてしまいました。値を取り出したいときに何度もunwrap()しないといけないですね。

一方でand_thenはどうでしょう。

fn main() {
    let product_id = 1;
    let price = get_product(product_id)
        .and_then(get_price);
    println!("{:?}", price); // Some(100)
}

Someに包まれるのは一回だけです。

失敗するパターン

もう一度DBのテーブルたちを再掲します。

PRODUCTSテーブル

id name
0 desk
1 chair
2 whiteboard
3 pencil

PRICEテーブル

name price
desk 300
chair 100
whiteboard 20

PRODUCTSテーブルには、pencilが存在しますが、PRICEテーブルには存在しません。

id=3mapand_thenの挙動を比較してみましょう。

fn main() {
    let product_id = 3;
    let price = get_product(product_id)
        .map(get_price);
    println!("{:?}", price); // Some(None)
}
fn main() {
    let product_id = 3;
    let price = get_product(product_id)
        .and_then(get_price);
    println!("{:?}", price); // None
}

mapでは、Some(None)が、and_thenではNoneとなりました。

まとめ

関数をチェーンさせる場合、mapを使うと多重にOptionで包まれてしまうことを確認しました。 mapを連続で使っていると最終的に何回包まれたのか覚えておかなくてはなりません。

さらに、何かしらの理由でget_productget_priceの間に、別のOptionを返す関数を実装しなくてはならなくなった場合はどうでしょう。 コンビネータを差し込むだけではなく、包みを解くコードにも変更が必要です。 差し込む関数を実装するときも、前の関数が何回Optionを包んだのか意識しながら実装することになり、変更に弱くなります。

References


  1. Rust by Exampleにも書いてあります。