ポリモーフィズムの概要
ポリモーフィズム(多態性)とは、同一のインターフェースに対して、異なるオブジェクトがそれぞれ独自の振る舞いを提供する概念である。例えば、「ボーナスを計算する」という操作に対して、正社員は標準ボーナスを、インターンは固定手当を計算するといった違いが挙げられる。
C++においてポリモーフィズムを構成するための条件は以下の2つである:
- 基底クラスのポインタまたは参照を通して仮想関数を呼び出すこと
- 呼び出される関数が仮想関数であり、派生クラスが基底クラスの仮想関数をオーバーライドしていること
仮想関数とオーバーライド
virtualキーワードで修飾されたメンバ関数を仮想関数と呼ぶ。
class Employee {
public:
virtual void calculateBonus() { std::cout << "Standard Bonus" << std::endl; }
};
オーバーライドと隠蔽(シャドウイング)の違い
派生クラスで基底クラスと同名の関数を定義した場合、その性質によって「隠蔽」と「オーバーライド」に分かれる。
- 隠蔽: 基底クラスと派生クラスに同名の関数があり、仮想関数のオーバーライド条件を満たさない場合、基底クラスの関数は隠蔽される。静的バインディング(コンパイル時に呼び出し先が決定)される。
- オーバーライド: 基底クラスの仮想関数と派生クラスの関数が、戻り値の型・関数名・引数リスト(シグネチャ)すべて一致しており、基底クラスのポインタまたは参照から呼び出された場合、動的バインディング(実行時に実際のオブジェクト型に基づいて呼び出し先が決定)される。
動的バインディングによるオーバーライドの効果が発現するのは、ポリモーフィズムの条件を満たした場合のみである。
隠蔽の例1:引数が異なる場合
class Device {
public:
virtual void operate(int mode) { std::cout << "Device::operate(int)" << std::endl; }
};
class Smartphone : public Device {
public:
virtual void operate() { std::cout << "Smartphone::operate()" << std::endl; }
};
隠蔽の例2:基底クラス側にvirtualがない場合
class Device {
public:
void operate() { std::cout << "Device::operate()" << std::endl; }
};
class Smartphone : public Device {
public:
void operate() { std::cout << "Smartphone::operate()" << std::endl; }
};
オーバーライドの例
class Device {
public:
virtual void operate() { std::cout << "Device::operate()" << std::endl; }
};
class Smartphone : public Device {
public:
virtual void operate() { std::cout << "Smartphone::operate()" << std::endl; }
};
基底クラスの参照を使って呼び出す例:
class Employee {
public:
virtual void calculateBonus() { std::cout << "Employee::Standard" << std::endl; }
virtual ~Employee() { std::cout << "~Employee" << std::endl; }
};
class Intern : public Employee {
public:
void calculateBonus() override { std::cout << "Intern::Fixed" << std::endl; }
~Intern() { std::cout << "~Intern" << std::endl; }
};
void processBonus(Employee& emp) {
emp.calculateBonus();
}
int main() {
Employee e;
Intern i;
processBonus(e);
processBonus(i);
return 0;
}
オーバーライドの3つの例外
- 共変戻り値型: 基底クラスが基底クラスのポインタ・参照を返し、派生クラスが派生クラスのポインタ・参照を返す場合、戻り値が異なっていてもオーバーライドとみなされる。
class Shape {};
class Circle : public Shape {};
class Renderer {
public:
virtual Shape* create() { return new Shape(); }
};
class CircleRenderer : public Renderer {
public:
virtual Circle* create() { return new Circle(); }
};
- デストラクタのオーバーライド: デストラクタはコンパイル時に
destructorという名前に統一されるため、基底クラスのデストラクタが仮想であれば、派生クラスのデストラクタは自動的にオーバーライドされる。基底クラスのポインタを通じて派生クラスのオブジェクトを削除する際、正しく派生クラスのデストラクタが呼ばれるようにするため、基底クラスのデストラクタは必ず仮想にすべきである。
class Session {
public:
virtual ~Session() { std::cout << "~Session" << std::endl; }
};
class UserSession : public Session {
public:
virtual ~UserSession() { std::cout << "~UserSession" << std::endl; }
};
int main() {
Session* s1 = new Session;
Session* s2 = new UserSession;
delete s1;
delete s2;
return 0;
}
- 派生クラスのvirtual省略: 基底クラスの関数が
virtual宣言されていれば、派生クラス側の関数でvirtualを省略してもオーバーライドとして扱われる。
class Animal {
public:
virtual void bark() { std::cout << "Animal::bark" << std::endl; }
};
class Dog : public Animal {
public:
void bark() { std::cout << "Dog::bark" << std::endl; } // virtual省略可能
};
ポリモーフィズムの実装原理
ポリモーフィズムは動的バインディングによって実現される。コンパイル時にはポインタや参照が指す実際のオブジェクト型が不明でも、実行時にその型に応じたメソッドが呼び出される。
ポリモーフィズムの条件を満たさない場合(オブジェクトを直接使った呼び出しなど)は静的バインディングとなり、コンパイル時に呼び出し先の関数アドレスが直接決定される。
動的バインディング時のアセンブリコードの挙動では、オブジェクトの先頭にある仮想関数テーブルポインタ(vfptr)を取得し、さらにそのポインタが指す仮想関数テーブル(vtable)から対象の関数アドレスを取得して間接呼び出し(call eaxなど)を行う。これにより、実行時に適切な関数へジャンプする仕組みが機能する。
仮想関数テーブルの構造
仮想関数を持つクラスのオブジェクトには、メンバ変数の他に仮想関数テーブルへのポインタ(vfptr)が格納される。これによりオブジェクトのサイズは通常のポインタ分(32bit環境では4バイト、64bitでは8バイト)増加する。仮想関数テーブル(vtable)は、そのクラスの仮想関数のアドレスを格納した配列である。
単一継承におけるオブジェクトモデル
class Plugin {
public:
virtual void process() { std::cout << "Plugin::process" << std::endl; }
virtual void initialize() { std::cout << "Plugin::initialize" << std::endl; }
private:
int id;
};
class FilterPlugin : public Plugin {
public:
void process() override { std::cout << "FilterPlugin::process" << std::endl; }
virtual void apply() { std::cout << "FilterPlugin::apply" << std::endl; }
virtual void validate() { std::cout << "FilterPlugin::validate" << std::endl; }
private:
int level;
};
class BlurFilter : public FilterPlugin {
public:
void apply() override { std::cout << "BlurFilter::apply" << std::endl; }
};
派生クラスのオブジェクトを生成すると、基底クラスの仮想関数テーブルをコピーし、オーバーライドされた関数のアドレスを派生クラスのものに置き換え、さらに派生クラス独自の仮想関数のアドレスを末尾に追加した新たなvtableが形成される。デバッガの監視ウィンドウでは一部の仮想関数が表示されないことがあるが、メモリウィンドウなどで確認することで、オーバーライドによって関数ポインタが正しく置き換わっていることが確認できる。
多重継承におけるオブジェクトモデル
class Controller {
public:
virtual void handleEvent() { std::cout << "Controller::handleEvent" << std::endl; }
virtual void update() { std::cout << "Controller::update" << std::endl; }
private:
int state;
};
class View {
public:
virtual void handleEvent() { std::cout << "View::handleEvent" << std::endl; }
virtual void render() { std::cout << "View::render" << std::endl; }
private:
int color;
};
class App : public Controller, public View {
public:
void handleEvent() override { std::cout << "App::handleEvent" << std::endl; }
virtual void shutdown() { std::cout << "App::shutdown" << std::endl; }
private:
int config;
};
多重継承を行うと、派生クラスのオブジェクト内には基底クラスの数だけ仮想関数テーブルポインタが存在する。派生クラスで新たに定義された仮想関数(上記のshutdownなど)のアドレスは、最初の基底クラスの仮想関数テーブルの末尾に追加されるのが一般的である。
純粋仮想関数と抽象クラス
純粋仮想関数は、関数の宣言末尾に= 0を付加した仮想関数であり、基底クラスでの実装を省略する。純粋仮想関数を持つクラスは抽象クラスと呼ばれ、オブジェクトを直接インスタンス化できない。派生クラスが純粋仮想関数をオーバーライドしない限り、派生クラスもまた抽象クラスとなりインスタンス化が不可能である。これにより、派生クラスへのオーバーライドを強制することができる。
class Logger {
public:
virtual void writeLog() = 0;
void reset() { std::cout << "Logger::reset" << std::endl; }
};
class FileLogger : public Logger {
public:
void writeLog() override { std::cout << "FileLogger::writeLog" << std::endl; }
};
class ConsoleLogger : public Logger {
public:
void writeLog() override { std::cout << "ConsoleLogger::writeLog" << std::endl; }
};
void executeLog(Logger* log) {
log->writeLog();
}
int main() {
executeLog(new FileLogger);
ConsoleLogger cl;
cl.writeLog();
return 0;
}