Orphan Ruleよありがとう ~Rustを採用したおかげでリファクタリングが捗った話~

おはようございます、CADDiでバックエンドエンジニアをしているagate-prisです。本記事は キャディ Advent Calendar 2020 - Qiita の4日目の記事です。昨日の記事は飯迫の Argo Rollouts で Blue-Green Deployment でした。

本記事は、すでにRustを使っている人向けではありません。まだRustを使っていない人向けの「Rustにはこんな機能があるよ。この機能のおかげでこんないいことがあったよ」とRustを勧める記事です。

Cargoについて

CargoはRustのビルドツール兼パッケージマネージャです。Cargoを含むRustの様々なツールチェインは、Rustインストーラおよびバージョン管理ツールであるRustupを使うことでインストールできます。

cargo コマンドによって、新規パッケージの作成、ビルド、コードフォーマット、テストなどができます。 Cargo.toml でパッケージの情報を管理します。ここにはパッケージの基本的な情報や、依存関係などを記述します。 Cargo.toml は丁度、Rubyなら Gemfile に、Goなら Gomfile に相当します。

Cargoはその存在だけでもRustの採用を勧めたくなる程に強力なツールです。「Rustは豊かな型システムと所有権モデルによる強力なメモリ安全性を備える」という謳い文句は多くの方が聞いたことがあると思いますが、これに加えてCargo(と crates.io )の存在が、RustにnpmやRubyGemsの様なパッケージマネジメントシステム、エコシステムを標準で付加します。

さらに、サブコマンド fmt で呼び出すことのできるフォーマッタであるRustfmtの強力さも特筆に値します。Rustはその性質上しばしばC++と比較されますが、C++でコードフォーマッタを利用した結果、悲惨なことになった経験がある方は少なくないと思います。況や最新のC++をや、といったところです。

RustにはC++の様な「バーリトゥード、何でもありの強力さ」は(意図的に制限されていることもあり、基本的には)ありませんが、Mozillaが主導となって旗を振りながら、標準化とツールチェインの開発を、それも非常にオープンな形で進めているおかげで足並みが揃ってる、と言えます。

ワークスペース、クレート、そしてOrphan Rule

閑話休題

弊社では実際のプロダクト開発でRustを利用しています。その過程で段々とパッケージが肥大化していき、見通しが悪くなってきたため、複数のクレート(Rustにおけるコンパイル単位)に分割することにしました。

Cargoの機能の一つである「ワークスペース」を利用することで、一つのプロジェクトを複数のパッケージ、クレートに分割することができます。

その過程で、以下のようなエラーに遭遇しました。

Compiling y v0.1.0 (/home/agate-pris/aaa/y)
error[E0117]: only traits defined in the current crate can be implemented for arbitrary types
 --> y/src/lib.rs:1:1
  |
1 | impl From<i32> for x::Foo {}
  | ^^^^^---------^^^^^------
  | |    |             |
  | |    |             `Foo` is not defined in the current crate
  | |    `i32` is not defined in the current crate
  | impl doesn't use only types from inside the current crate
  |
  = note: define and implement a trait or new type instead

error: aborting due to previous error

For more information about this error, try `rustc --explain E0117`.
error: could not compile `y`

To learn more, run the command again with --verbose.

実際のコードは以下のようなものです。

impl From<i32> for x::Foo {}

エラーメッセージの内容は「任意の型に対するトレイトの実装は、現在のクレート内に定義されたトレイトに対してのみ、これを許可する」というものです。

トレイトは、それをある方に対して実装することで型の振る舞いを定義し、ジェネリックプログラミングの手段を提供します。

上記のエラーメッセージを素直に解釈すると、 Fromy にないためエラーになっている、ということになります。これは間違いではなく、実際に y にユーザがトレイトを定義したのであれば、 x::Foo に対するその実装を y の中に書くことはできます。しかし、 From は標準ライブラリが提供しているトレイトなので、この方法では解決できません(し、解決方法としても誤っています)。ここでは x 側に Foo に対する From の実装を書くのが正解です。

この制限は、ワークスペースではなく、外部クレートの場合でも同じです。ワークスペース内か否かに関わらず、「あるクレートの中で、異なるクレートが提供する型に対する異なるクレートが提供するトレイトの実装を定義することはできない」ということです。

これは言い換えれば「ある型に対する、あるトレイトの実装は、そのトレイトを定義するクレート自身か、その型を定義するクレート自身が定義するべきであり、異なるクレートがそれを定義することは容易にメンテナンス性を破壊してしまうため、これを許可しない」と言えます。

これは rust-lang/rust#23086 でIssue化され、rust-lang/rfcs#1023 で取り入れられた制限です。

この制限は coherence と呼ばれるプログラムの特性の一部で、より具体的には orphan rule と呼ばれます。

今回、社内プロダクトをワークスペース機能によって整理していった結果、上記のエラーが起きたことで「本来トレイトの実装によって解決すべきでない問題を、トレイトの実装によって解決してしまっていた」ことに気づくことができました。実際のプロダクトでは、トレイトを実装する代わりに、単なる関数の呼び出しで置き換えました。必要であれば、新しく型を定義してラップすることで解決しても良いでしょう。

Rustは豊かな型システム、強力なメモリ安全性、優れたツールチェインを備えるだけでなく、このような「言語の機能と制限によって、プログラマが誤ったコードが書くことを未然に防止し、自然により良いコードを書かせる仕組み」が随所に散りばめられています。 crates.io にも様々なパッケージが揃っており、特定の言語の特定のライブラリやフレームワークを利用する強い動機がなければ、業務用アプリケーションを書くにあたって十分におすすめできるものになっていると思います。