Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

型付けの "duplicate duck" 落とし穴を踏んだ話(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。
文中に登場するsynはRustのパーサーです。

型付けの "duplicate duck" 落とし穴を踏んだ話(翻訳)

みんなももっと型を書けばいいのに、そうしていないのはなぜでしょうか?おそらくですが、うまくいかないパターンを中級・上級開発者たちが取り除いたことで、初心者が学ぶ道すじが残されていないからなのでしょう。

本記事では、最近私が削除したパターン(私はこれを「duplicate duck」と呼んでいます)についていくつかのコードを詳しく説明しており、このような型を私が作り込んでしまうまでの経緯と、それを削除した理由について学べます。さらにRust開発者には、この間違いをドキュメント化して共有し、誰でも学べるようにして欲しいと思います。

🔗 duplicate duckって何?

「duplicate duck」とは、よく使われる型のトレイト(trait)のサブセットを実装したのに、元の型と何も違っていない型のことです。
私の場合は、自分が書いたMultiErrorという型が、後になってsyn::Error型のダックタイピングになっていて、構造体に何も追加されていないことに気づいたのでした。この型を消し去っても機能は何ひとつ失われなかったので、世界がよりよいものになりました。

そのコードを消し去る前に取っておきました。私の設計プロセスと、そこで最終的に何が起きたかについてこれからお話しします。

あなたは誰?

私はHerokuでRustを書いており、そこでRuby Cloud Native Buildpackをメンテナンスしています。他にもCodeTriageという無料サービスをメンテし、『How to Open Source』という本を書いてコーダーをコントリビュータに育てる方法を解説しています。

🔗 背景について

最近procマクロのハックに取り組んでいます(最近の調査結果については以下の記事をどうぞ)。

参考: A Daft proc-macro trick: How to Emit Partial-Code + Errors

私はprocマクロの作者に、最初のエラー出力だけでおしまいにするのではなく、できるだけ多くの累積エラーを出力して欲しいと思っています。さらに私は単体テストのファンでもあり、自分の関数に「多数の累積エラーを返す」戻り値型を追加して、戻り値型を単体テスト可能にしたいと思っています。

さて、私のコードではVecDeque<syn::Error>にエラーを蓄積していました。こうすることで、累積エラーを単一のsyn::Errorにまとめやすくなります。

if let Some(mut error) = errors.pop_front() {
    for e in errors {
        error.combine(e);
    }
    Some(error)
} else {
    None
}

しかし、エラー状態が空でないことが保証されていないため、自分の関数からResult<T, VecDeque<syn::Error>>の結果を返したくありませんでした。適切な型というものは、無効なステートを表現不可能であるべきです。

🔗 データ構造から設計する

この型には常にエラーが1つ以上存在することを保証するために、最初のエラーをコレクションの残りの部分から分離しました。これなら、コンテナが空の場合でも常にsyn::Errorにできることが型定義によって保証されるようになります。

/// [`syn::Error`]を1つ以上持つことを保証する
///
/// [`syn::Error`]は[`syn::Error::combine()`]を通じて
/// 複数のエラーを保持可能だが、受信側が2つのケースを区別できないため、
/// テストの精度が低下する。この型を使うことで
/// 関数が複数のエラーを蓄積していることが強調される
///
#[derive(Debug, Clone)]
pub(crate) struct MultiError {
    first: syn::Error,
    rest: VecDeque<syn::Error>,
}

impl MultiError {
    pub(crate) fn from(mut errors: VecDeque<syn::Error>) -> Option<Self> {
        if let Some(first) = errors.pop_front() {
            let rest = errors;
            Some(Self { first, rest })
        } else {
            None
        }
    }
}

警告

ドキュメントに書かれていることが常に真とは限りません。

コードの可視性に注意すること。私は、構造体やそれに関連する関数はデフォルトでpub(crate)を使っていますが、フィールド(ここではfirstrest)ではそうしていません。すべてのアクセスを関数経由にしておけば、設計に不安がある場合でも後で変更しやすくなります。

この型を定義したことで、以下のようなヘルパー関数を導入できるようになりました。

pub(crate) fn parse_attrs<T>(
        attrs: &[syn::Attribute]
    ) -> Result<Vec<T>, MultiError>
where
    T: syn::parse::Parse,
{
    let mut errors = VecDeque::new();
    // ...
    if let Some(error) = MultiError::from(errors) { // <== ここ
        Err(error)
    } else {
        Ok(
        // ...
        )
    }
}

このコードは「syn::Attributeの任意のスライスを受け取ってその属性を解析し、Tのベクタか、1つ以上のsynエラーを返す」ことがわかります。ここまでは順調ですね。

しかしマクロではエラートークン生成用のsyn::Errorが必要なのですが、関数が返すのはMultiErrorです。つまりこの型をsyn::Errorに変換する方法が必要です。

🔗 振る舞いに追加する

型が持つプロパティに応じて、いつでもsyn::Errorに確実に変換可能であることがわかっているので、Into<syn::Error>を実装することで公開できます。

impl From<MultiError> for syn::Error {
    fn from(value: MultiError) -> Self {
        let MultiError { mut first, rest } = value;
        for e in rest {
            first.combine(e);
        }
        first
    }
}

try演算子(?)を使うとinto()を暗黙で呼び出すので、以下のような使い方が可能になるというボーナスもあります。

fn check_logic(...) -> Result<(), syn::Error> {
  // ...
  let result: Result<(), MultiError> = logic();
  let _ = result?; // <=== MultiErrorを暗黙でsyn::Errorに変換
  // ...
}

この他に、複数のエラーを確実にキャプチャするためのロジックをテストする方法も必要でした。

🔗 表示を追加する

失敗時にエラーを表示するために、std::fmt::Displayを実装する必要があります。

impl std::fmt::Display for MultiError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        Into::<syn::Error>::into(self.clone()).fmt(f)
    }
}

見た目は今ひとつですが、うまく動作しますし、簡単です。このコードパスはテスト時にのみ呼び出されます。

🔗 イテレーションを追加

テストで複数のエラーを公開するため、IntoIteratorトレイトを実装する方法を選びました。

impl IntoIterator for MultiError {
    type Item = syn::Error;
    type IntoIter = <VecDeque<syn::Error> as IntoIterator>::IntoIter;

    fn into_iter(self) -> Self::IntoIter {
        let MultiError { first, mut rest } = self;
        rest.push_front(first);
        rest.into_iter()
    }
}

このコードは「構造体を、syn::Errorの連続を生成するものに変換できる」ことがわかります。既にVecDequeがありますし、そこには同じトレイトが実装されていることもわかっているので、ロジックもそこに配置することにしました。
これによって、以下のテストのようなことが可能になりました。

    #[test]
    fn test_captures_many_field_errors() {
        let field: syn::Field = syn::parse_quote! {
            #[cache_diff(unknown)]
            #[cache_diff(unknown)]
            version: String,
        };
        let result: Result<Vec<ParseAttribute>, MultiError> =
            crate::shared::parse_attrs::<ParseAttribute>(&field.attrs);

        assert!(
            result.is_err(),
            "Expected {result:?} to be err but it is not"
        );
        let error = result.err().unwrap();
        assert_eq!(2, error.into_iter().count()); // <== ここでinto_iter()する
    }

    enum ParseAttribute {
        //...
    }
    impl syn::parse::Parse for ParseAttribute {
        // ...
    }

このコードは、syn::Attributeを複数持つ単一のフィールドを解析します。ここでcache_diff(unknown)は無効な属性なので、最初のcache_diff(unknown)だけで終わらないことをアサーションしたいと思います。このコードが結果をイテレータに変換し、要素が2個あることをアサーションする、順調ですね!

🔗 ところがイテレーションでコケた

上のコード例はうまく動いたのですが、このパターンを適用していくうちにコードの問題を踏んでしまい、エラーが発生するようになったのです。

    #[test]
    fn test_captures_many_field_errors() {
        let result = ParseContainer::from_derive_input(&syn::parse_quote! {
            struct Metadata {
                #[cache_diff(unknown)]
                #[cache_diff(unknown)]
                version: String,

                #[cache_diff(unknown)]
                #[cache_diff(unknown)]
                name: String
            }
        });

        assert!(
            result.is_err(),
            "Expected {result:?} to be err but it is not"
        );
        let error = result.err().unwrap();
        assert_eq!(4, error.into_iter().count()); // <== ここで失敗した
    }

エラーの内容は、私が返したエラーの個数は4個のはずなのに2個だったというものですが、このコードを統合テストに移動してみると、発生したエラーは4個だったので混乱しました。
この時点で、複数のエラーを1個のsyn::Errorに保存してから、結合したエラーをMultiErrorに配置していたことに気づきました。つまり、MultiErrorが複数あったのです。

この説明でわかりにくい場合は、以下の擬似コードをご覧ください。

let mut errors: VecDeque<syn::Error> = VecDeque::new();

match call_fun() { // MultiErrorを返す
    Ok(_) => todo!(),
    // 結合して1個の`syn::Error`に保存する
    Error(error) => errors.push_back(error.into())
}
// ...

if let Some(error) = MultiError::from(errors) {
    Err(error)
} else {
    Ok(
    // ...
    )
}

私が定義したMultiError型では、私がインスペクト不可なステートだと思っていたものが許されていたのです。個別のsyn::ErrorがN個のエラーを持つことが本質的に可能になっていました。

🔗 鳥類は舞い降りた

せっかく作った型に根本的な欠陥があったという悲しみを乗り越えた頃に、syn::Errorからの内部的な複合エラーを公開する変更をRust本家に提供してはどうかと思いつきました。IntoIteratorインターフェイスは本家への追加候補としてよさそうでした。

しかし何ということでしょう、impl IntoIterator for syn::Errorはとっくに本家に存在していたのです。単に自分が見落としていたのでした。

自分が必要としていたトレイトはsyn::Errorで全部実装済みであることがわかると、これまで書いたMultiErrorを全部syn::Errorに置き換え、MultiError::from_errorOption<syn::Error>を返す関数に全部置き換えることに成功しました。すると、本編コードのロジックを一切変更せずにコンパイルが通りました。このことから、自分が書いたものは、構造体で一般的に利用可能な機能とダックタイプの形で丸かぶりしていたのではという疑いが裏付けられました。

私が書いたMultiError型は、その関数がエラー集約を返すことを念頭において書かれたことが型名でわかるという価値しかなく、エラー集約のロジックが正しいことを保証できていませんでした。この程度のささいなヒント情報でコードの追加を正当化するのは苦しいでしょう(型エイリアスを使えば同じことができるのですから)。

🔗 悪いダックタイピングとは

ある型に新しい機能や制約を導入しなくても、そのまま既存の安定した型に置き換え可能であれば、その型は捨てて既存の一般的な型を使うべきでしょう。

🔗 よいダックタイピングとは

ある型が少々異臭(=foul、あるいは鳥類(fowl)というべきか)を発するようになってきたら、臭いを元から断つべきなのでしょうか?
新しい型を定義すれば、その型と既存の型を取り違えないことが保証されますし、操作を一般型のサブセットに限定することも可能です。これはどちらも制約の追加に相当します。

アヒルを殺さずに生かしておく3つ目の理由は、インターフェイスの安定性でしょう。自作の型をライブラリで公開する予定があり、今後型を変更する可能性があるのが心配なら、型をラップしておけば、背後のロジックや実装が変更されたときにライブラリのユーザーがコードを変更せずに済むので有用です。

🔗 ダックタイピングをドキュメント化する

アヒルを生かすかどうか迷ったら、自分のダックタイピングをドキュメント化してみて、新しい型が元の型にどんな制約を追加しているかを説明することを検討しましょう。ドキュメントを書いたら、既存の型に同じ振る舞いが既に存在するかどうかを探してみましょう。その型がニーズに合わない理由もドキュメント化すること。

そうした違いをうまくドキュメント化できないようなら、そのダックタイプは捨てた方がいいというサインかもしれません。

私の場合、syn::Errorを明示的に呼び出し、さらにInto<syn::Error>を実装するところまで進めました。これは、自分の意図を精査して、同じ機能がトレイトの実装で提供されているかどうか探しておくべきだったという強いサインでした。

🔗 アヒルの鳴き真似を練習する

私のニーズにぴったりのsyn::Errorを見落としてしまった理由の1つは、作業前に立ち止まって、そうしたトレイトが構造体で実装されている理由を検討したり、そこで自分の必要なデータがどのように公開されるかについて考えていなかったためでした。
Rustをやっているうちに、トレイトの名前と提供する機能を脳内で対応付けて理解するスキルは向上しましたが、まだ他にも修練が必要です。
今回の経験によって、既存の型をうっかりダックタイピングとして再実装しているという強力なヒントを得られたので、今後は自分に必要なトレイトの実装が既にあるかどうかを忘れずチェックするようにいたします。

単に「もっと頑張ります」とか「二度と繰り返さないためにブログ記事で懺悔します」で終わらせるよりも、syn::Error::combineにこの振る舞いをうまく説明するいくつかのサンプルを追加するプルリクをsynに送信する方が前向きだろうと考えました(#1855)。

dtolnay/syn - GitHub

さすがに、あらゆるトレイトのあらゆるユースケースを徹底的にドキュメント化する必要はないと思いますが、この非常に便利なイテレーション機能は、combineの振る舞いをわかりやすく説明するのにとても有用です。
このドキュメント追加のプルリクが負担(=アホウドリ1)にならずにマージされますように。

どうか皆さんにも、自分で定義した型や、それによってどんな苦痛をもたらされるかに注意を怠らないでいただきたいと思います。自分の書いた型が後になってリファクタリングで消えてしまったことに気づいたら、どうか一度立ち止まって、そんな型を書いてしまった理由や、それがなくても何も困らない理由を考えてみてください。
それにしても、「悪い型」には他にどんなパターンがあるのでしょうか?今後やってくる初心者が楽にそれを見つけて回避するには、どうしたらよいでしょうか?

関連記事

Rustの手続きマクロに関する知見をまとめてみた


  1. このアホウドリ(albatross)は精神的負担を表す比喩で、コールリッジの『老水夫の歌』が出典です。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。