オブジェクト指向言語の特徴
ある言語がオブジェクト指向と見なされるためにどのような機能を 備えていなければならないかについては、プログラミングコミュニティの 中で合意がありません。Rust は OOP を含む多くのプログラミング パラダイムの影響を受けています。たとえば、第 13 章では関数型 プログラミングに由来する機能を見てきました。少なくとも、OOP 言語には いくつかの共通した特徴、すなわちオブジェクト、カプセル化、継承がある と言えるでしょう。それぞれの特徴が何を意味するのか、そして Rust が それをサポートしているかどうかを見ていきましょう。
オブジェクトはデータと振る舞いを含む
Erich Gamma、Richard Helm、Ralph Johnson、John Vlissides による書籍 Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994)は、通称 The Gang of Four 本として知られており、 オブジェクト指向の設計パターンをまとめたカタログです。この本では OOP を 次のように定義しています。
オブジェクト指向プログラムはオブジェクトで構成されます。オブジェクトは、 データと、そのデータに対して動作する手続きをひとまとめにします。手続きは 通常、メソッドまたは操作と呼ばれます。
この定義に従えば、Rust はオブジェクト指向です。構造体と列挙型はデータを
持ち、impl ブロックは構造体や列挙型に対するメソッドを提供します。
メソッドを持つ構造体や列挙型はオブジェクトと 呼ばれる わけでは
ありませんが、Gang of Four によるオブジェクトの定義に従えば、同じ
機能を提供しています。
実装の詳細を隠すカプセル化
OOP と一般的に結び付けられるもう 1 つの側面は カプセル化 という考え方で、 これはオブジェクトの実装詳細に、そのオブジェクトを使うコードからは アクセスできないことを意味します。したがって、オブジェクトとやり取りする 唯一の方法はその公開 API を通すことです。オブジェクトを使うコードは、 オブジェクトの内部に立ち入ってデータや振る舞いを直接変更できるべきでは ありません。これにより、プログラマはそのオブジェクトを利用するコードを 変更せずに、オブジェクト内部を変更したりリファクタリングしたりできます。
第 7 章では、カプセル化をどのように制御するかを説明しました。pub
キーワードを使って、コード中のどのモジュール、型、関数、メソッドを公開
するかを決められ、デフォルトではそれ以外はすべて非公開になります。
たとえば、i32 値のベクタを保持するフィールドを持つ
AveragedCollection 構造体を定義できます。この構造体はさらに、ベクタ内の
値の平均を保持するフィールドも持てるため、誰かが必要とするたびに平均を
その場で計算する必要はありません。言い換えると、AveragedCollection は
計算済みの平均をキャッシュしてくれます。リスト 18-1 に
AveragedCollection 構造体の定義を示します。
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
この構造体には、他のコードから使えるように pub が付いていますが、
構造体の内部にあるフィールドは非公開のままです。この場合これが重要なのは、
リストに値が追加または削除されるたびに、平均も更新されることを保証したい
からです。これを実現するために、リスト 18-2 に示すように、この構造体に
add、remove、average メソッドを実装します。
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
impl AveragedCollection {
pub fn add(&mut self, value: i32) {
self.list.push(value);
self.update_average();
}
pub fn remove(&mut self) -> Option<i32> {
let result = self.list.pop();
match result {
Some(value) => {
self.update_average();
Some(value)
}
None => None,
}
}
pub fn average(&self) -> f64 {
self.average
}
fn update_average(&mut self) {
let total: i32 = self.list.iter().sum();
self.average = total as f64 / self.list.len() as f64;
}
}
公開メソッド add、remove、average は、AveragedCollection の
インスタンス内のデータにアクセスしたり変更したりする唯一の手段です。
add メソッドを使って項目が list に追加されたり、remove メソッドを
使って削除されたりすると、それぞれの実装は非公開の update_average
メソッドを呼び出し、average フィールドの更新も行います。
list フィールドと average フィールドは非公開のままにしているため、
外部コードが list フィールドに対して直接項目を追加したり削除したりする
方法はありません。そうしないと、list が変更されたときに average
フィールドの値が同期しなくなる可能性があるからです。average メソッドは
average フィールドの値を返すので、外部コードは平均を読み取れますが、
それを変更することはできません。
AveragedCollection 構造体の実装詳細をカプセル化しているので、将来、
データ構造のような要素を簡単に変更できます。たとえば、list
フィールドには Vec<i32> の代わりに HashSet<i32> を使うこともできます。
公開メソッド add、remove、average のシグネチャが同じままである限り、
AveragedCollection を使うコードは変更する必要がありません。これに対して
list を公開にしていた場合は、必ずしもそうとは限りません。
HashSet<i32> と Vec<i32> では項目を追加・削除するためのメソッドが
異なるため、外部コードが list を直接変更していたなら、そのコードも
おそらく変更しなければならないでしょう。
ある言語がオブジェクト指向と見なされるためにカプセル化が必須の要素で
あるなら、Rust はその要件を満たしています。コードの各部分に対して pub
を使うかどうかを選べることにより、実装詳細のカプセル化が可能になります。
型システムとしての継承とコード共有としての継承
継承 とは、あるオブジェクトが別のオブジェクトの定義から要素を 受け継ぎ、それによって親オブジェクトのデータや振る舞いを、あらためて 定義しなくても獲得できる仕組みです。
もしある言語がオブジェクト指向であるために継承を備えていなければ ならないのなら、Rust はそのような言語ではありません。マクロを使わずに、 親構造体のフィールドとメソッド実装を継承する構造体を定義する方法は ありません。
とはいえ、プログラミングの道具箱の中に継承があることに慣れているなら、 そもそもなぜ継承を使いたいのかに応じて、Rust では別の解決策を使えます。
継承を選ぶ主な理由は 2 つあります。1 つはコードの再利用です。ある型に
特定の振る舞いを実装し、継承によってその実装を別の型でも再利用できます。
Rust のコードでは、デフォルトのトレイトメソッド実装を使うことで、これを
限定的に行えます。これは、リスト 10-14 で Summary トレイトに
summarize メソッドのデフォルト実装を追加したときに見たものです。
Summary トレイトを実装するあらゆる型は、追加のコードなしで
summarize メソッドを利用できます。これは、親クラスがメソッドの実装を
持ち、それを継承する子クラスもそのメソッド実装を持つことに似ています。
また、Summary トレイトを実装する際に summarize メソッドのデフォルト
実装をオーバーライドすることもできます。これは、子クラスが親クラスから
継承したメソッドの実装をオーバーライドすることに似ています。
継承を使うもう 1 つの理由は、型システムに関係しています。つまり、子型を 親型と同じ場所で使えるようにするためです。これは ポリモーフィズム とも 呼ばれ、複数のオブジェクトがある特性を共有していれば、実行時にそれらを 互いに置き換えて使えることを意味します。
ポリモーフィズム
多くの人にとって、ポリモーフィズムは継承と同義です。しかし、実際にはこれはより一般的な概念であり、 複数の型のデータを扱えるコードを指します。継承においては、それらの型は一般に サブクラスです。
その代わりに Rust は、ジェネリクスを使ってさまざまな可能性のある型を抽象化し、 トレイト境界を使ってそれらの型が提供しなければならないものに制約を課します。これは 境界付きパラメトリックポリモーフィズム と呼ばれることもあります。
Rust は、継承を提供しないことで、異なる一連のトレードオフを選択しています。 継承では、必要以上のコードを共有してしまう危険がしばしばあります。サブクラスは 親クラスのすべての特性を常に共有すべきとは限りませんが、継承ではそうなってしまいます。 これにより、プログラムの設計の柔軟性が低くなる可能性があります。また、 そのメソッドがサブクラスに適用されないために意味をなさなかったり、エラーを 引き起こしたりするメソッドをサブクラスに対して呼び出してしまう可能性も生じます。 さらに、一部の言語では 単一継承(つまり、サブクラスが継承できるのは 1 つのクラス からだけということ)しか許されず、プログラム設計の柔軟性がさらに制限されます。
これらの理由から、Rust は、実行時にポリモーフィズムを実現するために、 継承の代わりにトレイトオブジェクトを使用するという別のアプローチを取っています。 トレイトオブジェクトがどのように機能するのかを見ていきましょう。