C++入門|インターフェースとアップデート

接続方法が同じなら、中身が進化しても使い方は変わらない。インターフェースは、更新に強いソフトウェアを支える共通規格です。

インターフェースには、利用できる機能を制限するだけでなく、プログラムの一部を新しい実装へ交換しやすくする役割もあります。

パソコンやスマートフォンでは、アプリやOSのアップデートが行われます。アップデートによって、見つかった問題が修正されたり、新しい機能が追加されたりします。

大きなソフトウェアは、1つの巨大なプログラムだけで作られているわけではありません。多くの場合、役割ごとに分けられた複数のコンポーネントを組み合わせて作られています。

コンポーネントとは、ソフトウェアを構成する部品です。

ドラゴンボール風にたとえるなら、神殿の修行システムには、重力制御モジュール、戦闘力測定モジュール、回復モジュール、通信モジュールなどが組み込まれています。

重力制御モジュールに不具合が見つかったとき、神殿の修行システム全体を作り直すのは大変です。

重力制御モジュールだけを新しいバージョンへ入れ替え、ほかの装置はそのまま利用できれば、少ない変更で修行システムを更新できます。

このような交換をしやすくするために、インターフェースが役立ちます。

旧型と新型のモジュールが同じインターフェースを実装していれば、利用する側は同じ操作で両方を扱えます。

用語意味ドラゴンボール風のイメージ
コンポーネントソフトウェアを構成する部品重力修行モジュール
インターフェースコンポーネントが提供する共通の操作規格神殿の共通接続端子
実装インターフェースの具体的な処理固定重力制御、自動重力制御
アップデート古い実装を新しい実装へ交換する修行モジュールの換装
利用側コンポーネントを呼び出す処理神殿の中央制御装置

インターフェースが更新を支える仕組み

今回のプログラムでは、重力修行を実行する2種類のコンポーネントを作ります。

GravityTrainingV1は、旧型の重力修行モジュールです。固定された重力で修行を行います。

GravityTrainingV2は、新型の重力修行モジュールです。戦士の状態を解析し、重力を自動調整します。

2つのクラスは処理内容が異なりますが、どちらもITrainingModuleという共通のインターフェースを実装します。

ITrainingModuleには、修行を開始するactivateTrainingを用意します。

virtual void activateTraining() = 0;

利用する側は、具体的なクラス名ではなく、ITrainingModule型のポインタを使います。

ITrainingModule* module;

moduleがGravityTrainingV1を指していても、GravityTrainingV2を指していても、呼び出し方は同じです。

module->activateTraining();

実際に実行される処理は、moduleが指しているオブジェクトによって変わります。

図:共通インターフェースによるモジュール交換

この図が示していること

GravityTrainingV1とGravityTrainingV2は、内部の処理が異なる別のクラスです。

しかし、どちらもITrainingModuleを実装しているため、共通のactivateTrainingで操作できます。

利用する側は、旧型か新型かを細かく意識せず、ITrainingModuleとして修行を実行できます。

今回作成するクラス

今回のプログラムは、次のクラスとファイルで構成します。

クラス親クラス役割ファイル
ITrainingModuleなし修行モジュールの共通インターフェースitraining_module.h
GravityTrainingV1ITrainingModule固定重力で修行する旧型モジュールgravity_training_v1.h、gravity_training_v1.cpp
GravityTrainingV2ITrainingModule重力を自動調整する新型モジュールgravity_training_v2.h、gravity_training_v2.cpp

main.cppには、指定されたバージョンに応じて、どちらかのモジュールを生成するcreateTrainingModuleを用意します。

共通インターフェースを定義する

ITrainingModuleは、修行モジュールが提供する共通の操作を定めるインターフェースです。

プロジェクト/ファイル名: Chap7_07/itraining_module.h

#ifndef CHAP7_07_ITRAINING_MODULE_H
#define CHAP7_07_ITRAINING_MODULE_H

// 修行モジュールの共通インターフェース
class ITrainingModule {
public:
    // 基底クラスのポインタから安全に削除するためのデストラクタ
    virtual ~ITrainingModule() = default;

    // 修行を開始する
    virtual void activateTraining() = 0;
};

#endif

activateTrainingの末尾には、= 0が付いています。

virtual void activateTraining() = 0;

これは純粋仮想関数です。

ITrainingModuleを継承するクラスは、activateTrainingを実装しなければなりません。

ITrainingModuleは、具体的な重力制御方法を持っていません。

固定重力にするのか、自動調整にするのかは、派生クラス側で決めます。

仮想デストラクタを用意する理由

ITrainingModuleには、仮想デストラクタを用意しています。

virtual ~ITrainingModule() = default;

今回のプログラムでは、ITrainingModule型のポインタを使って、GravityTrainingV1やGravityTrainingV2のオブジェクトを扱います。

ITrainingModule* module;

最後に、次のように基底クラス型のポインタからオブジェクトを削除します。

delete module;

基底クラスのデストラクタがvirtualであれば、実際に生成された派生クラスのデストラクタも正しい順序で呼び出されます。

インターフェース型のポインタからオブジェクトを削除する可能性がある場合は、仮想デストラクタを用意しておくことが大切です。

旧型の重力修行モジュールを作る

GravityTrainingV1は、ITrainingModuleを継承する旧型の修行モジュールです。

固定された10倍重力で修行を開始します。

プロジェクト/ファイル名: Chap7_07/gravity_training_v1.h

#ifndef CHAP7_07_GRAVITY_TRAINING_V1_H
#define CHAP7_07_GRAVITY_TRAINING_V1_H

#include "itraining_module.h"

// 固定重力で修行する旧型モジュール
class GravityTrainingV1 : public ITrainingModule {
public:
    // 固定重力の修行を開始する
    void activateTraining() override;
};

#endif

ITrainingModuleをpublic継承しています。

class GravityTrainingV1 : public ITrainingModule

activateTrainingにはoverrideを付けています。

void activateTraining() override;

overrideを付けることで、ITrainingModuleの仮想関数を正しく実装しているか、コンパイラが確認してくれます。

プロジェクト/ファイル名: Chap7_07/gravity_training_v1.cpp

#include "gravity_training_v1.h"
#include <iostream>

using namespace std;

// 旧型の重力修行を開始する
void GravityTrainingV1::activateTraining() {
    cout << "--- 重力修行モジュール ver1.0 ---" << endl;
    cout << "固定10倍重力で修行を開始しました。" << endl;
}

GravityTrainingV1のactivateTrainingでは、固定10倍重力で修行を開始します。

旧型としては十分に利用できますが、戦士の状態に合わせた細かな調整はできません。

新型の重力修行モジュールを作る

GravityTrainingV2も、ITrainingModuleを継承します。

ただし、activateTrainingの処理内容は旧型と異なります。

新型では、戦士の気力を解析して重力を自動調整します。

プロジェクト/ファイル名: Chap7_07/gravity_training_v2.h

#ifndef CHAP7_07_GRAVITY_TRAINING_V2_H
#define CHAP7_07_GRAVITY_TRAINING_V2_H

#include "itraining_module.h"

// 重力を自動調整する新型モジュール
class GravityTrainingV2 : public ITrainingModule {
public:
    // 自動調整型の重力修行を開始する
    void activateTraining() override;
};

#endif

プロジェクト/ファイル名: Chap7_07/gravity_training_v2.cpp

#include "gravity_training_v2.h"
#include <iostream>

using namespace std;

// 新型の重力修行を開始する
void GravityTrainingV2::activateTraining() {
    cout << "--- 重力修行モジュール ver2.0 ---" << endl;
    cout << "戦士の気力を解析しました。" << endl;
    cout << "適切な重力へ自動調整して修行を開始しました。" << endl;
}

GravityTrainingV1とGravityTrainingV2は、同じactivateTrainingを持っています。

ただし、関数内の処理は異なります。

クラスactivateTrainingの処理
GravityTrainingV1固定10倍重力で修行を開始する
GravityTrainingV2戦士の気力を解析し、重力を自動調整する

共通インターフェースを守る

新型のGravityTrainingV2では、内部処理が大きく変わっています。

しかし、外部から呼び出す関数はactivateTrainingのままです。

void activateTraining() override;

この関数の名前、戻り値、引数がITrainingModuleの定義と一致しているため、GravityTrainingV2もITrainingModuleとして扱えます。

インターフェースは、利用する側と実装する側の約束です。

実装の中身は変更できますが、利用する側が必要としている操作は維持します。

変更内容利用側への影響
関数内部の処理を改善する影響を抑えやすい
表示内容を変更する呼び出し方は変わらない
アルゴリズムを高速化する呼び出し方は変わらない
activateTrainingの名前を変更する利用側の修正が必要になる
引数や戻り値を変更する利用側の修正が必要になる可能性がある

アップデートしやすい設計にするには、共通インターフェースをできるだけ安定させることが大切です。

バージョンに応じてコンポーネントを生成する

main.cppには、createTrainingModuleという関数を用意します。

この関数は、引数versionに応じて、旧型または新型のオブジェクトを生成します。

戻り値の型はITrainingModule*です。

ITrainingModule* createTrainingModule(int version);

呼び出し側は、実際にどのクラスが生成されたかに関係なく、ITrainingModule型のポインタとして受け取ります。

プロジェクト/ファイル名: Chap7_07/main.cpp

#include <iostream>
#include "itraining_module.h"
#include "gravity_training_v1.h"
#include "gravity_training_v2.h"

using namespace std;

// 指定されたバージョンの修行モジュールを生成する
ITrainingModule* createTrainingModule(int version);

int main(int argc, char** argv) {
    // 修行モジュールを指すインターフェース型のポインタ
    ITrainingModule* module = nullptr;

    // 旧型モジュールを生成して実行する
    module = createTrainingModule(1);
    module->activateTraining();

    // 生成した旧型モジュールを解放する
    delete module;
    module = nullptr;

    cout << endl;

    // 新型モジュールを生成して実行する
    module = createTrainingModule(2);
    module->activateTraining();

    // 生成した新型モジュールを解放する
    delete module;
    module = nullptr;

    return 0;
}

// バージョンに応じた修行モジュールを生成する
ITrainingModule* createTrainingModule(int version) {
    if (version == 1) {
        // 旧型の修行モジュールを返す
        return new GravityTrainingV1();
    }

    // 新型の修行モジュールを返す
    return new GravityTrainingV2();
}

実行結果

--- 重力修行モジュール ver1.0 ---
固定10倍重力で修行を開始しました。

--- 重力修行モジュール ver2.0 ---
戦士の気力を解析しました。
適切な重力へ自動調整して修行を開始しました。

createTrainingModuleの役割

createTrainingModuleは、どの具体的なクラスを生成するかを決める関数です。

ITrainingModule* createTrainingModule(int version)

versionが1なら、GravityTrainingV1を生成します。

if (version == 1) {
    return new GravityTrainingV1();
}

それ以外の場合は、GravityTrainingV2を生成します。

return new GravityTrainingV2();

どちらのクラスもITrainingModuleをpublic継承しているため、ITrainingModule*として返せます。

明示的なC言語形式のキャストは必要ありません。

return new GravityTrainingV1();

派生クラス型のポインタから基底クラス型のポインタへの変換は、安全なアップキャストとして暗黙に行われます。

オブジェクトの生成場所を集める利点

main関数の中で、GravityTrainingV1やGravityTrainingV2を直接生成する方法もあります。

GravityTrainingV1* module =
    new GravityTrainingV1();

ただし、この書き方では、利用側が具体的なクラス名を知る必要があります。

新しいバージョンへ変更するときは、生成部分を直接書き換えなければなりません。

createTrainingModuleへ生成処理を集めると、どのクラスを使うかという判断を1か所にまとめられます。

役割担当する場所
どのバージョンを生成するか決めるcreateTrainingModule
修行を実行するmain関数
実際の修行処理GravityTrainingV1、GravityTrainingV2

main関数は、返されたITrainingModuleを使ってactivateTrainingを呼び出すだけです。

具体的なクラスの違いを、main関数から切り離せます。

図:生成されるクラスと実行される処理

この図が示していること

versionが1の場合はGravityTrainingV1が生成され、versionが2の場合はGravityTrainingV2が生成されます。

生成されるオブジェクトは異なりますが、main関数が受け取る型は、どちらもITrainingModule*です。

そのため、main関数は同じactivateTrainingを呼び出すだけで、異なるバージョンの処理を実行できます。

同じ呼び出しで結果が変わる理由

main関数では、旧型と新型の両方に対して、同じ処理を記述しています。

module->activateTraining();

1回目のmoduleは、GravityTrainingV1のオブジェクトを指しています。

そのため、GravityTrainingV1::activateTrainingが実行されます。

2回目のmoduleは、GravityTrainingV2のオブジェクトを指しています。

そのため、GravityTrainingV2::activateTrainingが実行されます。

moduleが指すオブジェクト実行される関数
GravityTrainingV1GravityTrainingV1::activateTraining
GravityTrainingV2GravityTrainingV2::activateTraining

ITrainingModuleのactivateTrainingはvirtualです。

そのため、ポインタの型ではなく、実際に生成されたオブジェクトに対応する関数が呼び出されます。

この仕組みがポリモーフィズムです。

利用側は具体的なクラスを意識しない

main関数で重要なのは、moduleがITrainingModuleとして扱われていることです。

ITrainingModule* module = nullptr;

main関数は、GravityTrainingV1の固定重力処理を詳しく知る必要がありません。

GravityTrainingV2の自動調整方法を知る必要もありません。

main関数が知っているのは、ITrainingModuleにはactivateTrainingがあるということです。

module->activateTraining();

具体的な処理は、それぞれの派生クラスへ任せています。

これにより、利用側と実装側の役割を分けられます。

コンポーネントをアップデートする

神殿の修行システムに、次の3つのコンポーネントがあるとします。

コンポーネント現在のバージョン
重力修行モジュール1.0
戦闘力測定モジュール1.2
回復支援モジュール2.1

重力修行モジュールに不具合が見つかり、新しいGravityTrainingV2へ更新することになったとします。

インターフェースを使わず、利用側がGravityTrainingV1へ直接依存している場合は、GravityTrainingV1を使っている場所を探して修正する必要があります。

一方、利用側がITrainingModuleへ依存していれば、生成する具体的なクラスをGravityTrainingV2へ変更することで、影響を抑えやすくなります。

return new GravityTrainingV2();

利用側の呼び出しは変わりません。

module->activateTraining();

戦闘力測定モジュールや回復支援モジュールは、重力修行モジュールとは別の部品なので、そのまま残せます。

共通インターフェースが保たれていることが重要

コンポーネントを交換しやすいのは、新旧のコンポーネントが同じインターフェースを実装しているからです。

class GravityTrainingV1
    : public ITrainingModule

class GravityTrainingV2
    : public ITrainingModule

どちらもactivateTrainingを実装しています。

void activateTraining() override;

もし、新型で関数名を完全に変えてしまったとします。

void startAdaptiveGravity();

ITrainingModuleのactivateTrainingを実装していなければ、GravityTrainingV2は具体的なクラスとして完成しません。

また、利用側のmodule->activateTraining()から呼び出すこともできません。

新しい機能を追加するときも、既存のインターフェースとの互換性を考えることが大切です。

インターフェースが同じでも確認は必要

同じインターフェースを実装していれば、文法上は同じ方法で呼び出せます。

ただし、インターフェースが同じだからといって、必ず何も確認せず交換できるわけではありません。

たとえば、旧型と新型で次のような違いがある場合は注意が必要です。

確認する内容
処理結果重力の強さが大きく変わっていないか
実行時間新型の解析に長い時間がかからないか
エラー処理失敗時の動作が変わっていないか
使用する資源メモリや通信量が増えていないか
前提条件新しい装置や設定が必要ではないか

関数名や引数が同じであることを、形式的な互換性と考えられます。

実際の動作や意味が期待どおりであることも重要です。

コンポーネントを更新するときは、共通インターフェースを守るだけでなく、テストによって動作を確認します。

図:一部のコンポーネントだけを更新する構成

この図が示していること

この図では、複数のコンポーネントで構成されたシステムのうち、重力修行モジュールだけを新しいバージョンへ交換しています。

戦闘力測定モジュールと回復支援モジュールは変更していません。

新旧の重力修行モジュールが同じインターフェースを持っているため、ほかの部分への影響を抑えながら更新できます。

インターフェースへ依存する設計

利用側が具体的なクラスへ直接依存すると、交換時の修正箇所が増えやすくなります。

GravityTrainingV1* module;

この宣言では、利用側がGravityTrainingV1という具体的なクラスを知っています。

新型へ変更する場合は、型名も修正する必要があります。

GravityTrainingV2* module;

一方、インターフェース型を使えば、宣言は変わりません。

ITrainingModule* module;

生成される具体的なクラスだけを変更できます。

module = new GravityTrainingV1();

module = new GravityTrainingV2();

このように、具体的な実装ではなく抽象的なインターフェースに依存すると、変更の影響を抑えやすくなります。

新しいバージョンを追加する場合

将来、GravityTrainingV3を追加することも考えられます。

GravityTrainingV3もITrainingModuleを継承し、activateTrainingを実装します。

class GravityTrainingV3
    : public ITrainingModule {
public:
    void activateTraining() override;
};

createTrainingModuleへ、新しいバージョンの生成処理を追加できます。

ITrainingModule* createTrainingModule(int version) {
    if (version == 1) {
        return new GravityTrainingV1();
    }

    if (version == 2) {
        return new GravityTrainingV2();
    }

    return new GravityTrainingV3();
}

main関数の利用方法は変わりません。

ITrainingModule* module =
    createTrainingModule(3);

module->activateTraining();

新しいクラスが追加されても、利用側はITrainingModuleとして扱えます。

アップデートしやすい設計の利点

インターフェースを利用してコンポーネントを分けると、次のような利点があります。

利点内容
部品を交換しやすい共通インターフェースを持つ新しい実装へ変更できる
影響範囲を抑えやすい利用側のコードを大きく変更せずに済む
テストしやすい同じインターフェースを持つテスト用クラスを用意できる
役割を分けやすい利用側と実装側を別々に開発できる
複数の実装を選べる設定や環境に応じて使用するクラスを変更できる
不具合を切り分けやすい問題のあるコンポーネントを特定しやすくなる

ただし、クラスを細かく分ければ必ずよい設計になるわけではありません。

交換する可能性がある機能や、複数の実装を持つ機能に対して、適切なインターフェースを設計することが大切です。

インターフェースとポリモーフィズムの関係

今回のプログラムでは、ITrainingModule型のポインタからactivateTrainingを呼び出しています。

module->activateTraining();

実行される関数は、moduleが実際に指しているオブジェクトによって変わります。

この仕組みを利用することで、利用側は具体的なクラスごとのif文を何度も書く必要がありません。

次のように、呼び出す場所でクラスごとの処理を分ける必要はありません。

if (version == 1) {
    version1.activateTraining();
}
else {
    version2.activateTraining();
}

どのクラスを作るかはcreateTrainingModuleが判断します。

実際の修行処理は、それぞれのクラスが担当します。

main関数は、ITrainingModuleのactivateTrainingを呼び出すだけです。

newとdeleteの対応

createTrainingModuleでは、newを使ってオブジェクトを生成しています。

return new GravityTrainingV1();

newで生成したオブジェクトは、使用後にdeleteで解放する必要があります。

delete module;

解放後は、使用済みのアドレスを誤って使わないよう、nullptrを代入しています。

module = nullptr;

今回のプログラムでは、インターフェース型のポインタからdeleteしています。

そのため、ITrainingModuleには仮想デストラクタを用意しています。

virtual ~ITrainingModule() = default;

実際の開発では、所有権を安全に管理するため、std::unique_ptrなどのスマートポインタを使う方法もあります。

ただし、今回の中心は、インターフェースを通して異なるコンポーネントを交換する仕組みです。

まずは、newとdeleteの対応、仮想デストラクタの必要性を確認しておきましょう。

インターフェースとアップデートで身につけたい感覚

インターフェースは、異なるクラスへ共通の操作方法を与えます。

class ITrainingModule {
public:
    virtual ~ITrainingModule() = default;
    virtual void activateTraining() = 0;
};

GravityTrainingV1とGravityTrainingV2は、処理内容が異なります。

それでも、どちらもITrainingModuleを実装しているため、同じ型として扱えます。

ITrainingModule* module;

利用側の呼び出しも同じです。

module->activateTraining();

旧型が生成されていれば旧型の処理が実行され、新型が生成されていれば新型の処理が実行されます。

ドラゴンボール風にたとえるなら、ITrainingModuleは神殿が定めた修行モジュールの共通接続規格です。

旧型の重力装置も、新型の自動調整装置も、同じ接続規格に対応しています。

そのため、装置を入れ替えても、中央制御室は同じ修行開始命令を送れます。

インターフェースを安定させ、具体的な処理を派生クラスへ分けることで、ソフトウェアの一部分だけを交換しやすくなります。

すべての変更が自動的に無影響になるわけではありませんが、依存関係を整理し、変更の影響範囲を抑えるうえで、インターフェースはとても重要な役割を持っています。

利用側は共通の操作に依存し、具体的な実装は交換できるようにする。

この考え方を身につけると、不具合の修正、機能追加、バージョンアップに対応しやすい、柔軟なC++プログラムを設計できるようになります。

旧型と新型の差し替えが分かりやすいように、生成処理と利用処理を分離した構成にしています。