C++のインターフェースについて

AI実装検定のご案内

C++におけるインターフェースとは、クラスが外部に対して「どのような操作を提供するか」を定めるための設計方法です。

JavaやC#には interface という専用の構文がありますが、標準C++には同じ役割を持つ interface キーワードはありません。

そのためC++では、主に純粋仮想関数を持つ抽象基底クラスを使って、インターフェースに相当する仕組みを実現します。

たとえば、次のようなクラスです。

class IPrinter
{
public:
    virtual void print() = 0;
    virtual ~IPrinter() = default;
};

この例では、print() が純粋仮想関数です。

IPrinter は「printという操作を提供する」というルールを定め、具体的な処理内容は派生クラスに任せています。

目次

C++では抽象クラスをインターフェースとして利用する

C++には、言語仕様上「インターフェースクラス」という独立した種類が存在するわけではありません。

一般的には、状態をほとんど持たず、純粋仮想関数を中心に構成された抽象基底クラスを設計上「インターフェース」と呼びます。

純粋仮想関数とは

純粋仮想関数は、次のように = 0 を付けて宣言します。

virtual void print() = 0;

このような純粋仮想関数が最終オーバーライドとして残っているクラスは抽象クラスとなり、直接インスタンス化できません。

たとえば、次のコードはエラーになります。

IPrinter printer;

一方、派生クラスで純粋仮想関数をオーバーライドすれば、具体的なクラスとして利用できます。

#include <iostream>

class ConsolePrinter : public IPrinter
{
public:
    void print() override
    {
        std::cout << "Console output" << std::endl;
    }
};

純粋仮想関数を実装しない派生クラスも作れる

純粋仮想関数は、必ずすべての派生クラスがすぐに実装しなければならないわけではありません。

たとえば、次のような派生クラスもC++として有効です。

class PrinterBase : public IPrinter
{
};

ただし、print() をオーバーライドしていないため、PrinterBase も抽象クラスになります。

最終的にインスタンス化できる具象クラスにするには、純粋仮想関数をオーバーライドする必要があります。

インターフェースの基本的な使い方

C++のインターフェースは、基底クラスへのポインタや参照を通して利用するのが一般的です。

インターフェース型の参照を使う例

たとえば、次のように記述できます。

#include <iostream>

class IPrinter
{
public:
    virtual void print() = 0;
    virtual ~IPrinter() = default;
};

class ConsolePrinter : public IPrinter
{
public:
    void print() override
    {
        std::cout << "Console output" << std::endl;
    }
};

void executePrint(IPrinter& printer)
{
    printer.print();
}

int main()
{
    ConsolePrinter printer;
    executePrint(printer);
}

executePrint()ConsolePrinter そのものを受け取っているわけではありません。

IPrinter という共通のインターフェースだけを利用しています。

そのため、別の実装クラスでも同じように扱えます。

複数の実装を同じ方法で扱える

たとえば、コンソール出力とファイル出力の2種類を用意できます。

class FilePrinter : public IPrinter
{
public:
    void print() override
    {
        std::cout << "File output" << std::endl;
    }
};

すると、次のように同じ関数へ渡せます。

ConsolePrinter consolePrinter;
FilePrinter filePrinter;

executePrint(consolePrinter);
executePrint(filePrinter);

呼び出し側は、それぞれのクラス内部の実装方法を知る必要がありません。

このように、具体的な実装を共通の型として扱えることがインターフェースの大きな特徴です。

virtualを付ける理由

インターフェースでは、通常、メンバ関数に virtual を付けます。

virtual void print() = 0;

virtual を付けることで、基底クラスへのポインタや参照を通して呼び出した場合でも、実際のオブジェクトに対応する派生クラスの関数が実行されます。

動的ポリモーフィズムが利用できる

たとえば、次のコードを見てみましょう。

ConsolePrinter consolePrinter;
IPrinter* printer = &consolePrinter;

printer->print();

printer の型は IPrinter* ですが、実際には ConsolePrinter のオブジェクトを指しています。

そのため、ConsolePrinter::print() が呼び出されます。

この仕組みは、動的ポリモーフィズムや実行時ポリモーフィズムと呼ばれます。

overrideを付ける理由

派生クラスで仮想関数をオーバーライドするときは、override を付けることが推奨されます。

void print() override
{
    // 処理
}

override を付けると、本当に基底クラスの仮想関数をオーバーライドしているかをコンパイラが確認してくれます。

関数の定義間違いを検出できる

たとえば、基底クラスが次のようになっているとします。

class Base
{
public:
    virtual void print(int value) = 0;
};

派生クラスで誤って次のように書いた場合、

class Derived : public Base
{
public:
    void print() override
    {
    }
};

print(int)print() は一致しないため、コンパイルエラーになります。

override を付けることで、こうした実装ミスを早い段階で発見できます。

インターフェースには仮想デストラクタを用意する

インターフェースとして利用する基底クラスには、通常、仮想デストラクタを用意します。

virtual ~IPrinter() = default;

基底クラス型から安全に削除するため

たとえば、次のようなコードを考えます。

IPrinter* printer = new ConsolePrinter();

delete printer;

このように基底クラスへのポインタを使って派生クラスのオブジェクトを削除する場合、基底クラスのデストラクタは通常 virtual である必要があります。

そのため、インターフェースでは次の形がよく使われます。

class IPrinter
{
public:
    virtual void print() = 0;
    virtual ~IPrinter() = default;
};

ただし、基底クラス型から削除させない設計などでは、protectedな非仮想デストラクタを利用する方法もあります。

一般的なインターフェース設計では、publicな仮想デストラクタを用意する方法が分かりやすいでしょう。

インターフェースにはメンバ変数を持たせるべきか

C++では、抽象クラスにメンバ変数を持たせること自体は可能です。

たとえば、次のように記述できます。

class PrinterBase
{
protected:
    int count = 0;

public:
    virtual void print() = 0;
    virtual ~PrinterBase() = default;
};

しかし、純粋なインターフェースとして利用する場合は、基本的に状態を持たせない設計がよく使われます。

インターフェースは操作の契約に集中させる

たとえば、次のような形です。

class IPrinter
{
public:
    virtual void print() = 0;
    virtual ~IPrinter() = default;
};

インターフェース側では「何ができるか」だけを定義し、「どのように実現するか」は派生クラスに任せます。

このように設計すると、クラス同士の依存関係を小さくしやすくなります。

C++では複数のインターフェースを実装できる

C++は多重継承をサポートしているため、1つのクラスが複数のインターフェースを継承することもできます。

プリンターとスキャナーを実装する例

たとえば、次の2つのインターフェースがあるとします。

class IPrinter
{
public:
    virtual void print() = 0;
    virtual ~IPrinter() = default;
};

class IScanner
{
public:
    virtual void scan() = 0;
    virtual ~IScanner() = default;
};

両方の機能を持つクラスは、次のように定義できます。

class MultiFunctionPrinter :
    public IPrinter,
    public IScanner
{
public:
    void print() override
    {
        // 印刷処理
    }

    void scan() override
    {
        // スキャン処理
    }
};

このクラスは IPrinter としても IScanner としても利用できます。

状態を持つ複雑なクラス同士の多重継承は設計を難しくすることがありますが、純粋なインターフェースを複数継承する方法は比較的利用しやすい設計です。

インターフェースを利用するメリット

インターフェースを導入すると、具体的な実装と、それを利用する側のコードを分離しやすくなります。

実装を交換しやすくなる

たとえば、データ保存を表すインターフェースを定義します。

class IRepository
{
public:
    virtual void save() = 0;
    virtual ~IRepository() = default;
};

データベースへ保存するクラスを作る場合は、次のようにできます。

class DatabaseRepository : public IRepository
{
public:
    void save() override
    {
        // データベースへ保存
    }
};

ファイルへ保存する場合は、別のクラスを作れます。

class FileRepository : public IRepository
{
public:
    void save() override
    {
        // ファイルへ保存
    }
};

利用側では、具体的なクラスではなく IRepository に依存します。

void saveData(IRepository& repository)
{
    repository.save();
}

これにより、保存方法を変更するときでも利用側のコードを大きく変更する必要がなくなります。

単体テストをしやすくなる

インターフェースを利用すると、本番用の実装とテスト用の実装を分けることもできます。

たとえば、テスト用クラスを次のように用意できます。

class MockRepository : public IRepository
{
public:
    void save() override
    {
        // テスト用処理
    }
};

実際のデータベースへアクセスせずに処理をテストできるため、単体テストを作りやすくなります。

依存性注入とインターフェース

インターフェースは、依存性注入(Dependency Injection)でもよく利用されます。

コンストラクタでインターフェースを受け取る

たとえば、次のようなクラスを考えます。

class UserService
{
private:
    IRepository& repository;

public:
    explicit UserService(IRepository& repository)
        : repository(repository)
    {
    }

    void registerUser()
    {
        repository.save();
    }
};

利用するときは、次のように具体的な実装を外部から渡します。

DatabaseRepository repository;
UserService service(repository);

service.registerUser();

UserServiceDatabaseRepository そのものではなく、IRepository に依存しています。

そのため、別の実装へ切り替えやすくなります。

この考え方は、SOLID原則に含まれる依存性逆転の原則とも関係しています。

スマートポインタとインターフェース

現代的なC++では、newdelete を直接扱うより、スマートポインタを利用する方法が一般的です。

unique_ptrで管理する例

たとえば、次のように記述できます。

#include <iostream>
#include <memory>

class IPrinter
{
public:
    virtual void print() = 0;
    virtual ~IPrinter() = default;
};

class ConsolePrinter : public IPrinter
{
public:
    void print() override
    {
        std::cout << "Console output" << std::endl;
    }
};

int main()
{
    std::unique_ptr<IPrinter> printer =
        std::make_unique<ConsolePrinter>();

    printer->print();
}

std::unique_ptr<IPrinter> を利用すれば、オブジェクトの所有権を明確に管理できます。

スコープを抜けると自動的にオブジェクトが破棄されるため、手動で delete を呼び出す必要もありません。

このように基底クラス型のスマートポインタから派生オブジェクトを破棄する場合も、通常は基底クラスに仮想デストラクタを用意します。

インターフェースと抽象クラスの違い

C++では、インターフェースと抽象クラスが文法上別の種類として定義されているわけではありません。

抽象クラスはC++の言語上の概念

純粋仮想関数が最終オーバーライドとして残っているクラスは抽象クラスです。

たとえば、次のクラスです。

class Shape
{
public:
    virtual double area() const = 0;
    virtual ~Shape() = default;
};

このクラスは直接インスタンス化できないため、抽象クラスです。

インターフェースは設計上の呼び方

一方、次のように状態を持たず、操作の契約だけを定義するクラスは、設計上インターフェースと呼ばれることがあります。

class IShape
{
public:
    virtual double area() const = 0;
    virtual ~IShape() = default;
};

つまり、C++では一般的に、

「抽象クラス」は言語上の性質を表す言葉であり、「インターフェース」はその抽象クラスをどのような目的で使うかを表す設計上の呼び方と考えると分かりやすいでしょう。

Javaのinterfaceとの違い

Javaでは、次のように専用の interface 構文があります。

interface Printer {
    void print();
}

一方、標準C++では専用の interface キーワードを使わず、抽象基底クラスで表現します。

class IPrinter
{
public:
    virtual void print() = 0;
    virtual ~IPrinter() = default;
};

大まかに整理すると、次のような違いがあります。

Java・C#:interface専用の言語機能がある
標準C++:主に抽象基底クラスでインターフェースを表現する

なお、一部のコンパイラには独自拡張としてインターフェース関連の構文が用意されていることがありますが、標準C++とは区別して考える必要があります。

インターフェース名にIを付ける必要はあるのか

C++では、インターフェース名の先頭に I を付ける命名方法があります。

たとえば、次のような名前です。

IPrinter
ILogger
IRepository
IStorage

ただし、これはC++の文法上のルールではありません。

Iを付けるかどうかは命名規則による

プロジェクトによっては、次のように I を付けない場合もあります。

Printer
Logger
Repository
Storage

どちらが正しいというわけではなく、プロジェクト内で命名規則を統一することが重要です。

constを適切に付ける

インターフェースのメンバ関数がオブジェクトの状態を変更しない場合は、const を付けることも重要です。

たとえば、次のように記述します。

class IUser
{
public:
    virtual int getId() const = 0;
    virtual ~IUser() = default;
};

派生クラス側でも const を合わせます。

int getId() const override
{
    return id;
}

メンバ関数の const の有無はオーバーライドの判定に影響します。

基底クラスが const なのに派生クラスで const を付け忘れると、同じ仮想関数をオーバーライドしたことにはなりません。

そのため、ここでも override を付けておくとミスを発見しやすくなります。

インターフェースは小さく分けることが重要

1つのインターフェースに大量の機能を詰め込むと、実装クラスが必要のない関数まで実装しなければならなくなる場合があります。

そのため、役割ごとに小さく分ける方法が有効です。

機能ごとにインターフェースを分割する

たとえば、次のように分けられます。

class IPrinter
{
public:
    virtual void print() = 0;
    virtual ~IPrinter() = default;
};

class IScanner
{
public:
    virtual void scan() = 0;
    virtual ~IScanner() = default;
};

class IFax
{
public:
    virtual void sendFax() = 0;
    virtual ~IFax() = default;
};

必要なクラスだけが必要なインターフェースを継承できるため、依存関係を小さくできます。

この考え方は、SOLID原則の一つであるインターフェース分離の原則にもつながります。

C++20のconceptとインターフェースの違い

C++20以降では、concept という機能も利用できます。

concept も「ある型が特定の操作を行えること」を表現できますが、従来の抽象基底クラスによるインターフェースとは仕組みが異なります。

conceptの例

たとえば、次のように定義できます。

template <typename T>
concept Printable = requires(T value)
{
    value.print();
};

そして、次のようにテンプレートへ制約を加えられます。

template <Printable T>
void executePrint(T& value)
{
    value.print();
}

この場合、print() を呼び出せる型であることがコンパイル時に確認されます。

抽象基底クラスとは用途が異なる

抽象基底クラスによるインターフェースは、主に実行時ポリモーフィズムで利用します。

たとえば、次のような型として扱えます。

IPrinter&
IPrinter*
std::unique_ptr<IPrinter>

一方、concept は主にテンプレートに対して型の条件を指定するための仕組みです。

そのため、両者は似た目的で利用できる場合はありますが、concept が従来のインターフェースをそのまま置き換えるわけではありません。

純粋仮想関数にも実装を持たせることができる

少し高度な内容ですが、C++では純粋仮想関数にも関数本体を定義できます。

たとえば、次のように宣言します。

class IPrinter
{
public:
    virtual void print() = 0;
};

そして、クラスの外側で定義できます。

void IPrinter::print()
{
    // 共通処理
}

= 0 が付いていても、関数本体そのものを持てないわけではありません。

ただし、純粋仮想関数であることに変わりはなく、そのクラスは抽象クラスとして扱われます。

初心者の場合は、まず「純粋仮想関数は派生クラスに操作の実装を要求するために利用する」と理解しておけば十分です。

C++のインターフェースを使うときの基本形

C++で一般的なインターフェースを作る場合は、次のような形になります。

class IService
{
public:
    virtual void execute() = 0;
    virtual ~IService() = default;
};

そして、具体的な処理を派生クラスで実装します。

class Service : public IService
{
public:
    void execute() override
    {
        // 具体的な処理
    }
};

基本的なポイント

C++のインターフェースでは、次のような点を意識するとよいでしょう。

  • 標準C++にはJavaやC#のような interface キーワードはない
  • 主に抽象基底クラスをインターフェースとして利用する
  • 純粋仮想関数には = 0 を付ける
  • 派生クラスでは override を付ける
  • 一般的なインターフェースには仮想デストラクタを用意する
  • 状態を持たせず、操作の契約に集中させる
  • 必要に応じてインターフェースを小さく分割する

まとめ

C++のインターフェースとは、クラスが提供する操作のルールを定め、具体的な処理を実装クラスに任せるための設計方法です。

標準C++にはJavaやC#のような専用の interface キーワードがないため、一般的には純粋仮想関数を持つ抽象基底クラスをインターフェースとして利用します。

代表的な形は次のとおりです。

class IPrinter
{
public:
    virtual void print() = 0;
    virtual ~IPrinter() = default;
};

具体的なクラスでは、このインターフェースを継承して処理を実装します。

class ConsolePrinter : public IPrinter
{
public:
    void print() override
    {
        // 具体的な処理
    }
};

インターフェースを適切に利用すると、具体的な実装と利用側のコードを分離しやすくなります。

その結果、処理の差し替え、機能追加、単体テスト、依存関係の整理などがしやすくなります。

特に規模の大きなC++プログラムでは、保守性や拡張性を高めるために重要な設計手法の一つです。

以上、C++のインターフェースについてでした。

最後までお読みいただき、ありがとうございました。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次