はじめに
クラス図って見るだけなら何となくわかるけど、「C++だとこれどう書くの?」ってなることがよくある。
継承くらいならピンとくるけど、「実現?集約?依存?何それ?」「書けるけど説明できない」みたいなこともあるあるだと思う。
UMLは設計を伝えるための標準的な図記法ですが、コードにどう落とし込むかまで踏み込んだ解説は意外と少ないと感じます。
本記事では、各UMLクラス図の関係とC++コードの対応を例で示す
概要
今回は以下の6種類の関係性について記述する。ざっくり概要を表にまとめる。
関係 | 概要 | C++での表現 |
---|---|---|
汎化(Generalization) | 上位クラスの性質を継承 | class A : public B |
実現(Realization) | インタフェースの実装 | class A : public B ※メンバ関数は純粋仮想関数 |
関連(Association) | 他クラスの存在を知っている | メンバ変数としてのポインタや参照 |
集約(Aggregation) | ライフサイクルは別 | ポインタ(deleteしない) |
コンポジション(Composition) | ライフサイクルの管理 | unique_ptr や直接メンバとして保持 |
依存(Dependency) | 一時的に利用するだけ | 関数の引数・ローカル変数 |
汎化(Generalization)
AはBである(is-a)関係。AnimalとDog、Catを例にすると下図になる。
共通の性質や振る舞いを上位クラスに抽出し、下位クラスがそれを継承する関係を表す。
コード例
継承を使うわけだが、「継承=汎化」とは言えない。継承は実装手段であり、継承する理由が「is-a関係」に基づいているときだけ汎化と呼べるらしい。
Animal(上位)クラスでは関数の中身は記述せず、純粋仮想関数にして、下位クラスで各々に合わせた関数の実装をする。Animal(上位)クラスが実装を持っていてもOK。
#include <iostream>
#include <vector>
#include <memory>
// 汎化のベース:共通の状態(name)と振る舞い(speak)を持つ
class Animal {
protected:
std::string name; // 共通の状態(データ)
public:
Animal(const std::string& name) : name(name) {}
virtual void speak() const = 0; // 共通のインタフェース
virtual void introduce() const {
std::cout << "私は " << name << " です。" << std::endl;
}
virtual ~Animal() = default;
};
// Dog is-a Animal:speakを実装、nameは継承して使う
class Dog : public Animal {
public:
Dog(const std::string& name) : Animal(name) {}
void speak() const override {
std::cout << "ワン!" << std::endl;
}
};
// Cat is-a Animal:speakを実装、nameは継承して使う
class Cat : public Animal {
public:
Cat(const std::string& name) : Animal(name) {}
void speak() const override {
std::cout << "ニャー!" << std::endl;
}
};
int main() {
std::vector<std::unique_ptr<Animal>> zoo;
zoo.push_back(std::make_unique<Dog>("ポチ"));
zoo.push_back(std::make_unique<Cat>("タマ"));
for (const auto& animal : zoo) {
animal->introduce(); // 汎化で継承された共通動作
animal->speak(); // オーバーライドされた具体動作
std::cout << "---" << std::endl;
}
return 0;
}
メリット
- 同じ関数やフィールド(例:name, speak() など)を1カ所にまとめられる
- 下位クラスで再定義せずに親クラスのコードを使える(例:introduce())
- Animal* で Dog, Cat を同一の形で扱える(ポリモーフィズム)
実現(Realization)
AはBを実装している関係。DrawableとCircle、Squareを例にすると下図になる。
インタフェース(仕様)をクラスが実装する関係
コード例
汎化との違いとして、Drawable(上位)クラスは純粋仮想関数のみとなる。
#include <iostream>
// インタフェース:仕様だけを定義(純粋仮想関数のみ)
class Drawable {
public:
virtual void draw() const = 0; // 純粋仮想関数
virtual ~Drawable() = default;
};
// 実装クラス:Drawableを「実現」する
class Circle : public Drawable {
public:
void draw() const override {
std::cout << "○を描く" << std::endl;
}
};
// 実装クラス:別の形を描く
class Square : public Drawable {
public:
void draw() const override {
std::cout << "□を描く" << std::endl;
}
};
// クライアントコード:Drawableに依存する(抽象に依存)
void render(const Drawable& shape) {
shape.draw(); // drawの具体的中身(CircleかSquareか?)は知らなくてよい
}
int main() {
Circle c;
Square s;
render(c); // ○を描く
render(s); // □を描く
}
メリット
- 実装の詳細を隠せる(カプセル化):拡張性が高く、疎結合になる。
- render()の役割は「描く(draw)」を使うことのみ。CircleとSquareがどうやって描くかはrenderは知らない。⇒新しい図形が増えてもrender()を変更しなくてOK。
関連(Association)
AがBを知っている関係。通常は矢印なしで表されるが、一方のみ知っている関係の場合は矢印が付く。
CarとEngineを例にすると下図になる。CarがEngineを知っているが、EngineはCarを知らないという意味。
コード例
class Engine {
public:
void start() {}
};
class Car {
private:
Engine* engine; // Engineの存在を知っているが、所有していない
public:
Car(Engine* e) : engine(e) {}
void drive() {
engine->start();
}
};
メリット
- 疎結合になる
- Car は Engine を使うけど、Engineをnew/deleteしない ⇒ Engineの生成方法に依存しない
集約(Aggregation)
AはBの集まりという関係。AはBのライフサイクルの管理は行わない(外部で生成・破棄される)。
TeamとMemberで例にすると下図になる。MemberはTeamの一員という関係を表す。
コード例
上でライフサイクルの管理と記述しているが、この場合、TeamクラスはMemberオブジェクトのメモリ解放責任を持たないという意味。つまり、TeamクラスではMemberを破棄しないことを表す。
class Member {
private:
std::string name;
public:
Member(const std::string& n) : name(n) {}
};
class Team {
private:
std::vector<Member*> members; // MemberはTeamに集約されるが、所有はしない
// 生ポインタでポインタ先を持っているだけ。
// deleteしてない = 所有していない。
public:
void addMember(Member* m) {
members.push_back(m);
}
};
メリット
- ライフサイクルの責任が分離される
- TeamがMemberを生成・破棄しないので、複数のTeamクラスが同じMemberオブジェクトを使用できる
- Memberに属性が新規追加されても、Memberへの影響は少ない
- MemberはTeamだけでなく、Officeとかにも属することができ、再利用性が高い
コンポジション(Composition)
AはBを所有するという関係。集約とは異なり、ライフサイクルの管理を行う。
BicycleとWheelを例にすると下図になる。BicycleがWheelを所有し、そのライフサイクルも支配する
コード例
スマートポインタを使用する場合が多いので、明示的にdeleteしないが、Bicycleの破棄と同時にWheelも自動で破棄される。これをライフサイクルの管理と表すことができる。
C++における「集約」と「コンポジション」の違いは、実装的には主に「delete(=破棄責任)を誰が持つか」に表れる。
#include <iostream>
#include <memory>
class Wheel {
public:
void rotate() {
std::cout << "Wheel rotating...\n";
}
};
class Bicycle {
private:
// BicycleはWheelを生成し、自身が破棄する
// → 所有関係がある(ライフサイクルを支配)
std::unique_ptr<Wheel> front;
std::unique_ptr<Wheel> rear;
public:
Bicycle()
: front(std::make_unique<Wheel>()),
rear(std::make_unique<Wheel>()) {}
void ride() {
front->rotate();
rear->rotate();
}
// Bicycleが破棄されると、unique_ptrによってWheelも自動で破棄される
};
メリット
- ライフサイクルを一元管理できる
- 他のクラスがBicycleについてdeleteなどを気にする必要がない ⇒ メモリリークの原因になりにくい
- 所有が明確で安全
- スマートポインタを使うことで所有権が明示でき、自動で破棄されるので比較的安全。new/delete忘れもなくなる
依存(Dependency)
Aが一時的にBを使う関係。使うけど所有はしない。
ServiceとLoggerを例にすると下図になる。ServiceはLoggerを引数などで一時的に使うだけ。
Loggerのライフサイクルの管理は行わないが、Loggerが生きていることは前提となる。
コード例
引数で渡すことで、Loggerの生成を呼び出し元に任せることができ、疎結合な設計が実現しやすい。例えば、のちにLoggerクラスではなく、FileLoggerクラスを使いたいとなった場合に呼び出し元の引数を変えるだけで事足りる。つまりServiceクラスを編集しなくていい。
(密結合の場合はServiceクラスで宣言しなければならない→Serviceクラスの中身を編集しないといけなくなる)
#include <iostream>
#include <string>
// 依存される側(Logger)
class Logger {
public:
void log(const std::string& message) {
std::cout << "[LOG] " << message << std::endl;
}
};
// 依存する側(Service)
class Service {
public:
// Logger を一時的に借りて使う(所有していない)
// Loggerの生成・管理はServiceではなく、呼び出し側(main)が行う
void doWork(Logger& logger) {
logger.log("Service is doing work."); // 一時的な依存関係
}
};
int main() {
Logger logger; // 呼び出し側でLoggerを作成(ライフサイクル管理)
Service service;
service.doWork(logger); // Loggerを渡して使わせる
return 0;
}
メリット
- 疎結合である
- Service は Logger を必要とするが、自分で生成・破棄しない
- 他のLogger(例:FileLogger, TestLoggerなど)に簡単に差し替え可能
- 再利用性が高い
- Loggerをほかのクラスでも簡単に使いまわせる
おわりに
今回は、UMLクラス図でよく出てくる6つの関係を、C++のコードでどう表現できるかを紹介してきました。
実際の開発では、「これって継承で書いてるけどUML的にはどうなるんだっけ?」とか、「クラス図書かないといけなくなったけど、どう表現するの?」みたいな場面に出くわすことが意外とあります。そんなとき、UMLの考え方を知っていると、自分の設計の立ち位置がクリアになったり、チーム内の共通認識も取りやすくなります。
メリットに関してはテストの視点でもたくさんあると思うがテストに関する知識が乏しいのでうまく書けそうになかったので書きませんでした。。。