Rust勉強手記ーTraitはinterfaceなのか
Note: Traits are similar to a feature often called interfaces in other languages, although with some differences.
公式ではtraitの節の冒頭に、このような内容を残しつつ、結局何が違うか明言していなかった。。
で、この問題について少し考えてみたいと思います。
インターフェースと言っても、正直自分が接したことのある言語で言えば、TypeScript, PHP, Javaあたりだけですが、この中でも言語によって多少違いがあったりします。TSのデータの型定義に使う使い方は少し独特かもしれないが、結局インターフェースは契約のガードと考える観点から、クラスにimplementするのみに限らず、型定義に使うのも違和感がない気もする。JavaとPHPのインターフェースの使い方がむしろ近いかなと。
その細かい比較はさておき、コード間の契約、再利用可能なコード、コードの抽象化と分割といった目的・機能から言う「インターフェース」と、Rustのトレイトについて、何が近いか、何が違うか、を少しまとめてみようと。
まず近いところから見てみると、やはり目的・機能がかなり一致しているところかな。
traitにしても、interfaceにしても、前述のコード間の契約、再利用可能なコード、コードの抽象化と分割と言う目的に一致している。また、OOPの継承で導入された結合度を下げる点でも近いかと。
継承では、子クラスの実装がどうしても親クラスの実装に依存・影響される問題があるが、trait/interfaceは実装なし(もしくはデフォルト実装のみ)の状態で、シグネチャーのみを定義することで、この具体的な実装(concrete implementation}への依存性を無くしている。それと同時に、コード再利用の観点からは継承には劣らない。
もちろん、このメソッドの中身といった具体的な実装に関心を持たないのは、コードの抽象化として考えられる。pythonのようなインターフェースのない言語では、abstract classの形である意味でインターフェースの 実装 になっているような気もする。まさに「抽象的」そのもの。ただ、インターフェースとトレイトには基本、ステートを持たせないので、この意味で言えばabstract classの「クラス」=インスタンスを作ってステートを持たせる、と言う観点とはかなり異なる。Javaのインターフェースのフィールドはすべてpublic static final
でもあるし、rustのトレイトにもメソッドと定数以外の追加ができないみたい。
インターフェースを小さいパーツとして考えて、コードの分割にもかなり役立つ。多くの本で言われているが、複数のメソッドを一つのインターフェースに入れたい時は、機能は直交するかが、別のインターフェースに分ける判断基準となる。
「契約」と言うのも、xxインターフェース実装しているクラスのみとか、xxトレイト実装しているストラクトのみが引数として入れられるよ、という「コード実行の前置き条件バリデーション」機能を持っている。後置き条件、例えばリターン値のタイプ制限も同様可能。
このような若干ハイレベルからの鳥瞰ですが、両者は一致するところが多く考えられるでしょう。上記をまとめてみると:
- 具体的な実装はなく、関数・メソッドの定義のみ
- もしくはデフォルト実装のみ
- 行為・機能のみ定義し、属性(変数!=定数)は定義しない(インスタンス化、ステート持ちはできない)
- 複数のメソッドを一つのtrait/IFaceに定義可能
- 関数のパラメーターの型定義としてつけられる
- リターン値の型定義としてつけられる
次は違うところを見てみたい。相違点はRustの設計哲学・仕様に関わる部分が多い気がする。
一つは既存のタイプに機能を追加・上書きすることができるところかと思う。この「既存のタイプ」と言うのは、自分が定義したものだけでなく、ビルドインのものや外部クレート(ライブラリー)のものにも有効。これは、トレイトで定義したメソッドは、トレイトが存在するスコープに限られるからだ。
fn main() {
{
trait Hash {
fn hash(&self) -> u64;
}
impl Hash for bool {
fn hash(&self) -> u64 {
if *self { 0 } else { -1 }
}
}
println!("{}", true.hash());
}
// println!("{}", true.hash()); -> エラーとなる
}
例えば上記の例では、ビルトインのブールタイプにhash
メソッドを追加し、しかもその働きを逆にしてみた。もちろん、Hash
トレイとは上のブロック内に定義しているので、ブロック外になるとスコープ切りで無効になって、2個目のprintlnはパニックする。このような機能拡張の利便性は、インターフェースには持たない。これはやはりRustの所有権管理の仕様と関わっていて、スコープを超えたタイミングで所有権がなくなる(RAII)。
ただ、これはタイプまたはトレイトのいずれかがローカルに所有しておかないといけない(orphan rule)。例えば外部ライブラリーAのタイプXに外部ライブラリーBのトレイトYを実装することができない。この時はnewtype patternの解決法がいるらしい。
もう一つは、ジェネリックタイプと同時に使うときに、静的と動的なdispatchが両方できるところ(参照)。
Rustはzero-overhead原則に従っている。
What you don't use, you don't pay for. And further: What you do use, you couldn't hand code any better.
これはまさに静的と動的な二つの側面と合致する。
ジェネリックタイプと使うときに、コンパイラーは具体的に分かるタイプにそれぞれの インスタンス を作っている。
impl Hash for i64 {
fn hash(&self) -> u64 {
*self as u64
}
}
fn print_hash<T: Hash>(t: &T) {
println!("The hash is {}", t.hash())
}
// The compiled code:
__print_hash_bool(&true); // invoke specialized bool version directly
__print_hash_i64(&12_i64); // invoke specialized i64 version directly
このように、print_hash
関数は実際に抽象的なタイプT
ではなく、コンパイル時に具体的なタイプにそれぞれバージョンができ、実行時に「どれを使えば良いか」との判断手間が不要となる。
ただ、この抽象化を具体化する働きが状況によって変わることもある。例えば、GUIアプリにあるボタンに対して、複数のクリックコールバック関数を登録したい場面がある。それぞれのコールバックの機能が違う可能性が高い。
struct Button<T: ClickCallback> {
listeners: Vec<T>,
...
}
と言うときに、このような定義はT
を一種類に制限してしまい、Button
とバイディングする形で一つButton
に対して一つのコールバックがあると。もちろんこれは意図から外れている。直すには:
struct Button {
listeners: Vec<Box<ClickCallback>>,
...
}
ゲネリックタイプのT
と、Button
のバイディングをまず消す。次にBox
を用いて、ベクターに入っているデータのタイプというのは、ClickCallback
を実装しているtrait objectだと、表している。
これで実際にコンパイルするときは具体的な実装した インスタンス に頼らず、そのインスタンスに指すポインタのベクターとなっているため、抽象化のままで実行時判断するようになる。