はじめに

Effective Rustを読んで、個人的に良かったところを備忘録として残す。

https://www.oreilly.co.jp/books/9784814400942/

トレイト制約の捉え方

オブジェクト指向言語から来たプログラマは、トレイト制約とインターフェイスを混同しがちだ。
このようなトレイト制約を「Shape is-a Draw」(ShapeはDrawの一種)だと間違って捉えてしまう。
この場合の2つの型の関係は、「Shape also-implements Draw」(ShapeはDrawも実装している)という意味だと思ったほうがいい。

trait Draw { ... }
trait Shape: Draw { ... }

自分はこれまで無意識的に継承的な感じで捉えていたようで、「ShapeはDrawの関数も実装する必要があるから、ShapeはDrawでもある」と考えていた。
これがまさに「Shape is-a Draw」という捉え方をしていたので、なるほどなと思った。

「ShapeはDrawも実装している」という考えのが思考が柔軟になると思うので、今後は意識していきたい。

builtin lintのmissing_copy_implementationsについて

型のすべての構成要素がCopyなら、多くの場合はCopyを自動導出したほうがいいだろう。
コンパイラには、このような機会を指摘するlint項目missing_copy_implementationsが組み込まれているが、デフォルトではオフになっている。

missing_copy_implementationsをはじめて知った。
なぜデフォルトオフなのかの説明がなかったのでドキュメントを読んだら次のことが書かれてあった。

Historically (before 1.0), types were automatically marked as Copy if possible. This was changed so that it required an explicit opt-in by implementing the Copy trait. As part of this change, a lint was added to alert if a copyable type was not marked Copy.
This lint is “allow” by default because this code isn’t bad; it is common to write newtypes like this specifically so that a Copy type is no longer Copy. Copy types can result in unintended copies of large data which can impact performance.

どうやら、大規模なデータが意図しないコピーされてしまってパフォーマンスに影響を及ぶす可能性があるためオフになった様子。

Copyはメモリのビット単位のコピー

Copyの場合は、アイテムを保持するメモリのビット単位のコピーで、新しいアイテムが正しく作れることを意味する。
要するに、このトレイトはある型が昔ながらのただのデータ型(plain old data:POD)であることを示すマーカなのだ。

これは意外だった。
CopyCloneが必要という前提なので、てっきり内部でCloneを使っているかと思ったが、そうではなかった。

次の例ではclonedは一度だけ出力される。

struct X(i32);

impl Clone for X {
    fn clone(&self) -> Self {
        println!("cloned");
        Self(self.0)
    }
}

impl Copy for X {}

fn main() {
    let x = X(10);
    // clone
    let _ = x.clone();
    // copy
    let _ = x;
}

またPODという単語をはじめて知った。
要はメモリレイアウトが連続してmemcopyでメモリコピーできるデータ型を指している様子。

https://learn.microsoft.com/ja-jp/cpp/cpp/trivial-standard-layout-and-pod-types?view=msvc-170

ワイルドカードインポートは避ける

使うものだけインポートしようというのはよくいわれる話。
本書を読むまではひとつずつ丁寧にインポートするの面倒だし、そこに時間を割きたくないという思いがあったので、ワイルドカードインポートは割と肯定派だった。

しかし、本書では次のように書かれていた。

残念ながら、衝突が起こってしまう場合もある。例えば、依存ライブラリが新しいトレイトを追加し、それをある型に対して実装したとしよう。

use bytes::*;

trait BytesLeft {
    // Name clashes with the `remaining` method on the wildcard-imported
    // `bytes::Buf` trait. ワイルドカードでインポートされる`bytes::Buf`トレイトの`remaining`メソッドと衝突する
    fn remaining(&self) -> usize;
}

impl BytesLeft for &[u8] {
    // Implementation clashes with `impl bytes::Buf for &[u8]`. この実装は`impl bytes::Buf for &[u8]`と衝突する
    fn remaining(&self) -> usize {
        self.len()
    }
}

たしかにbytes::Bufとクレート内のBytesLeftのトレイトの実装が衝突して、コンパイル通らなくなる可能性はあるんだなと思ったので、今後はワイルドカードをなるべく使わないでおこうと思った。

クレートの互換性のグレーゾーン

前提としてCargoはSemVerにしたがって依存クレートを選択している。
クレート作者視点からみても、APIは基本的にはSemVerにしたがって更新すべきと思っている。

たとえば、新しい型を追加したりする分には互換性は壊れないので、マイナーバージョンを上げるなど。
しかし、次のように一見互換性が壊れないような変更でも互換性が壊れることがあるとのこと。

新しいアイテムを追加するのは「通常は」安全。ただし、そのクレートを使用しているコードで、新しく追加したアイテムと同じ名前をたまたま使っていると衝突する。

  • これは、ユーザがクレートからのワイルドカードインポートを用いていると特に危険だ。クレートのすべてのアイテムがユーザのメイン名前空間に自動的に取り込まれるからだ。[項目23]でこれを行わないようにアドバイスしている。
  • ワイルドカードインポートをしていない場合でも(デフォルト実装[項目13]を持つ)新しいトレイトメソッドや、新しい固有メソッドを追加すると、既存の名前と衝突する可能性がある。

トレイトに対する新たなブランケット実装の追加は破壊的変更となる。そのトレイトをすでに実装していたユーザにとっては、2つの実装が衝突する状態になる。

オープンソースクレートにおけるライセンスの変更は互換性のない変更となる。受入可能なライセンスに関して厳密な制約を持つユーザは、変更後は使用できなくなるかもしれない。「ライセンスはAPIの一部だと考えよう」。

特にワイルドカードインポートの話や、ライセンスの話はたしかになぁと思ったので意識していきたい。

ファズテストのタイミング

ファズテストは関数に対してランダムな入力を与えて、クラッシュするバグを見つけるのが目的だけど、それをCIで回さなくていいよねという話。

一般にはファズテストですぐに障害が発見されることはない。
したがって、CIの一部としてファズテストを実行することには「意味がない」。
テストがいつ終わるかわからず、したがって計算コストがかかることを考えると、ファズテストをいつどのように実行するか、よく検討する必要がある。
新しいリリースを出す際、もしくは大きな変更を行った場合、一定の期間に一度だけ、などが考えられる

これは合っているかわからないけど…
高い頻度でCIでファズテストをしないといけない品質が低い関数はそもそも通常のテストが不十分なので、十分なテストをしたうえでさらにクラッシュするバグがないかを検知するのが筋がよいという解釈をしている。

ベンチマークのクレート

Rustの標準ベンチマークはnightlyじゃないと使えないが、安定版でもベンチマークを図れるcriterionクレートを知った。

https://crates.io/crates/criterion

ちなみにベンチマーク機能は2015年ころにはすでに実装済みのようで、なぜ安定化できないのかなと思って次のissueを軽く読んでみたが、よくわからなかったので時間あるときに深堀りしたい。

https://github.com/rust-lang/rust/issues/66287
https://github.com/rust-lang/rust/issues/29553

ベンチマークのコンパイラ最適化の回避

次のfactorial()はコンパイラが最適化してしまい実行時間が0 ns/iterとおかしいことになるらしい。

#![feature(test)]
extern crate test;

pub fn factorial(n: u128) -> u128 {
    match n {
        0 => 1,
        n => n * factorial(n - 1),
    }
}

#[bench]
fn bench_factorial(b: &mut test::Bencher) {
    b.iter(|| {
        let result = factorial(15);
        assert_eq!(result, 1_307_674_368_000);
    });
}
test bench_factorial             ... bench:           0 ns/iter (+/- 0)

このような意図しないコンパイラ最適化を回避するためstd::hint::black_boxを使うとよいとのこと。

#[bench]
fn bench_factorial(b: &mut test::Bencher) {
    b.iter(|| {
        let result = factorial(std::hint::black_box(15));
        assert_eq!(result, 1_307_674_368_000);
    });
}

マクロの使う・使わないの判断基準

マクロを使った方がよい場合をうまく説明できないなと思ったところ、本書では次のように書かれてよい説明かもと思った。

マクロは、定型的なコードをコンパクトに潰すためのツールとしても使用できる。
関数やジェネリクスではまとめられないような、「繰り返し書かなければならない定型的なコードにはマクロを使おう」。

上記以外にも、個人的に「可変長引数を受け取る処理」場合もマクロを使うひとつの場面かなと思う。

余談だけど、次のブログで書かれていたHListを使った可変長引数の実現方法はなるほどなぁと思って面白かった。

https://cipepser.hatenablog.com/entry/rust-variadic-and-hlist

マクロのインターフェイス

呼び出しコードから見えないような制御フロー操作をマクロ内でやるのはなるべく避けた方がよいという話。
これはたしかに使う側が制御できたほうが嬉しいよねと思ったので今後気をつけていきたい。

たとえば次のマクロは呼び出し側が所見returnしているかどうかわからないので、それに気づかずバグを生んでしまう可能性がある。

macro_rules! check_successful {
    { $e:expr } => {
        if $e.group() != Group::Successful {
            return Err(MyError("HTTP operation failed"));
        }
    }
}

それを次のようにするとResultが返ってくるので、ハンドリングする必要があるということが分かるし?もかける。

macro_rules! check_success {
    { $e:expr } => {
        match $e.group() {
            Group::Successful => Ok(()),
            _ => Err(MyError("HTTP operation failed")),
        }
    }
}

let rc = perform_http_operation();
check_success!(rc)?;

さいごに

全体通して、初学者でも勉強になる内容だったかなと思うし、自分も色々と勉強になったので大変よかった。
具体的な例も交えつつ、こういうときはこうした方がよいという説明の仕方がわかりやすかったし、まさに「Effective Rust」だったなと思った。

興味ある人はぜひ読んでみてほしい。

Discussion