このブログ記事では、C++におけるコンポジションを紹介します。

抽象クラス
概念的に共通点を持つクラスを作りたいが、その概念自体が抽象的である場合、抽象クラスを作成することができます。 この良い例として、異なる形状のオブジェクトを使って、それぞれ同じ関数で面積を求めたい場合が挙げられます。
int main () {
Shape *shapes = {
new Square(5),
new Rectangle(3,10);
new Circle(3);
};
for (int i = 0; i < 3; i++) {
cout << shapes[i] -> area() << endl;
}
for (int i = 0; i < 3; i++) {
delete shapes[i];
}
return 0;
}
四角形や長方形、円など、すべての形は「面積」を持っていますが、「形」という概念そのものは、 具体的な面積の計算方法が定義されているオブジェクトではありません。このような場合、次のように抽象クラスを使用できます。
class Shape {
public:
virtual float area () = 0;
};
class Square : public Shape {
public:
float side;
float area () {
return side * side;
};
Square (float side) : side(side) {};
}
...
クラスに純粋仮想関数(仮想関数が 0 に設定されたもの)が含まれている場合、そのクラスは抽象クラスとなり、 オブジェクトとしてインスタンス化することはできません。抽象クラスを継承したクラスが純粋仮想関数をオーバーライドしない場合、 エラーが発生し、ランタイム多態性が確保されます。
インターフェース
抽象クラスでは、純粋仮想関数以外の属性やメソッドを自由に持つことができます。しかし、多くのプログラマーは、 抽象クラスに純粋仮想関数のみを持たせ、明確にすることを好みます。これをインターフェースと呼び、 他の言語ではインターフェースのネイティブ実装が用意されている場合もあります。C++では、 次のように抽象クラスをインターフェースとして使用することができます。
class iShape {
public:
virtual double area () = 0;
virtual void print () = 0;
}
インターフェースは、抽象クラスがランタイム多態性を保証するためだけに使用され、 属性やメソッドの再利用はコンポジションによって行われる場合に役立ちます。
コンポジション
コンポジションとは、クラスオブジェクトを他のクラスの属性として使用することです。 クラス同士が「持っている (has-a)」関係を持つ場合に使うのが一般的です。以下の例を見てみましょう:
class Date {
public:
int year;
int month;
int day;
void print (string format) {
if (format == "us") {
cout << month << "/" << day << "/" << year << endl;
}
else if (format == "uk" || format == "eu") {
cout << day << "/" << month << "/" << year << endl;
} else {
cout << year << "/" << month << "/" << day << endl;
}
};
Date (int y, int m, int d) : year(y), month(m), day(d) {};
};
class Person {
public:
string name;
Date birthday;
void print (string date_format) {
cout << "Name: " << name << endl;
cout << "Birthday: ";
birthday.print(date_format);
}
Person (string name, int y, int m, int d) : name(name), Date(y,m,d) {};
};
人は「誕生日 (date)」を「持っている」ため、Person
クラスの属性として Date
オブジェクトを含めるのが理にかなっています。
コンポジションは主に「has-a」関係に使われますが、継承の代わりに「is-a」関係にも技術的には使うことができます。
class Bird {
public:
string name;
int age;
void chirp () {...};
};
class Crow {
public:
Bird bird;
void chirp () {
bird.chirp();
};
};
継承 vs コンポジション
「is-a」関係を実装する際には、継承の方が自然に感じられるかもしれませんが、多くの場合、開発者はコンポジションを好みます。 その主な理由は、継承は子クラスにすべての属性やメソッドを共有させるためです。
class Bird {
public:
string name;
int age;
virtual void chirp () {...};
virtual void fly () {...};
};
class Penguin : public Bird {
void chirp () {
...
};
void fly () {
return 0;
};
};
継承を使うと、親クラスのメソッドの実装を持たないことを選択できません。上記の例では、Penguin
における fly
メソッドの実装が不自然です。
もし Penguin
に fly
メソッドを持たせたくない場合、中間クラスとして FlightlessBird
や FlyableBird
を作成し、
適切な子クラスがその中間クラスを継承する必要があり、これは非常に手間のかかるリファクタリングです。一方、コンポジションでは、
子クラスが他のクラスの属性やメソッドの使い方を自由に選択できます。
class Bird {
public:
string name;
int age;
void chirp () {...};
void fly () {...};
};
class Penguin {
Bird bird; // still has access to the name and age
stirng get_name () {
return bird.name;
};
stirng get_age () {
return bird.age;
};
void chirp () {
// we can decide whether to use default bird.chirp here
// or to implement new one
...
};
};
ゲッターやセッター関数を増やす必要があるかもしれませんが、この方法では、Penguin
がどの属性やメソッドを使用するかを決定できます。
また、コンポジションを使用しつつランタイム多態性を確保したい場合、インターフェースを使用することができます。
class iBird {
public:
virtual void chirp () = 0;
};
class Crow : public iBird {
Bird bird;
void chirp () {
...
}
void fly () {
bird.fly();
}
};
class Penguin : public iBird {
Bird bird;
void chirp () {
bird.chirp();
}
};
このように、コンポジションは多くの開発者に好まれています。しかし、場合によっては継承の方が適していることもあるため、 どちらがより適切かを常に慎重に分析する必要があります。
リソース
- CodeAesthetic. 2023. The Flaws of Inheritance. YouTube.
- Portfolio Courses. 2022. Abstract Classes And Pure Virtual Functions | C++ Tutorial. YouTube.