12
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

C++勉強メモ ~仮想関数~

Last updated at Posted at 2021-10-03

仮想関数とは

メンバ関数の前に"virtual"をつけると**"仮想関数"**と呼ばれるものになります.これを使うと,基底クラスで定義したメンバ関数を派生クラスでオーバライドして再定義することができ,ポリモーフィズムを意識した設計が可能になります.

class Hoge {
public:
virtual void func(); // 仮想関数
void func(); // 非仮想関数
}

とは言っても,他の言語には余り見られないので馴染みがなく1,モチベーションや使いどころが分からなかったので,ここで解説します.

(注): この記事の中のサンプルプログラムでは,コードがあまり長くなって見にくくなるのを避けるため,クラスのメンバ関数の定義をクラス定義の中に書いていたりします.しかし実際は,宣言と定義は分け,クラス定義の外に書くべきです.

仮想関数の非仮想関数の違い

仮想関数はオーバライドされる前提のものではありますが,オーバーライドされるためには仮想関数にしなくてはいけない訳ではありません.C++を書いた人であれば,virtualをつけなかった非仮想関数でも問題なくオーバーライドできてコンパイルが通ることを知っていると思います.
じゃあ何のためにあるか?という疑問が湧いてきますが,下のような違いがあります.

非仮想関数では、実体が派生クラスだろうが基底クラスだろうが、その入れ物を示す ポインタや参照の型を見て、コンパイラがどちらのクラスの関数を呼び出すかを決めます。 これを静的結合といいます。
これに対して仮想関数では、コンパイラがどちらのクラスの関数を呼び出すのか決める のではなく、実行時にポインタや参照が、その実体は派生クラスなのか基底クラスなのかを調べ、 動的にどちらの関数を呼び出すかを決定します。これを動的結合といいます。 (http://programmer.main.jp/cpp/06_07.html より)

これだけでは分からないので,以下のサンプルを実行します.func()は非仮想関数,v_func()は仮想関数です.

class Parent {
public:
  void func() { std::cout << "Parent" << std::endl; } // 非仮想関数
  virtual void v_func() { std::cout << "Parent" << std::endl; } // 仮想関数
};

class Children : public Parent {
public:
  void func() { std::cout << "Children" << std::endl; }
  void v_func() { std::cout << "Children" << std::endl; }
};

次の例を見て,結果にどのような違いが出るのか見ていきます.

インスタンス経由で仮想関数を呼び出した場合

まずは,インスタンスを通じてメンバ関数を呼び出した場合です.仮想関数だろうが非仮想関数だろうがオーバーライドが機能しています.

Parent p;
Children c;
p.func(); // "Parent"
c.func(); // "Children"
p.v_func(); // "Parent"
c.v_func(); // "Children"

ポインタ経由で仮想関数を呼び出した場合

次に,ポインタを通じてメンバ関数を呼び出した場合です.c1はParentへのポインタ型,それが指す実体はChildren型,c2はChildrenのポインタ型,それが指す実体はChildren型です.
非仮想関数func()の方では,ポインタの型に従ってそのクラスの関数が呼び出されています.逆に仮想関数v_func()の方ではポインタが指す実体の方の型(つまりどちらもChildren型)に従って関数が呼び出されています.
このように,ポインタがあるオブジェクトを指しているとき,ポインタの型とオブジェクト実体の型が異なっていることがありますが(c1がそのケース),実行時に実体の型を調べて動的に関数を選択することを可能にするのが仮想関数なのです.

Parent *c1 = new Children();
Children *c2 = new Children();
c1->func(); // "Parent"
c2->func(); // "Children"
c1->v_func(); // "Children"
c2->v_func(); // "Children"

そして,上で引用した「静的結合」と「動的結合」の意味は以下の通りです.「静的結合」とは,コンパイラがコンパイル時にどちらの関数を呼ぶかを決定してしまうことを意味し,「動的結合」とは,実行時にポインタや参照の指すオブジェクトの型に従って関数を決定できるようにコンパイルしておくことを意味します.この仕組みによって,仮想関数という機能が実現されています.

間違いやすい例

少し気をつけなくてはいけないのが,下のようにインスタンスを作成した場合です.すぐ上の考えに釣られると,「c1はParent型の変数で実体はChildren型」だななどと考えてしまって2,仮想関数の方ではChildrenクラスでオーバーライドされた方が呼ばれると考えてしまいますが,それは違います.

Parent c1 = Children();
Children c2 = Children();
c1.func(); // "Parent"
c2.func(); // "Children"
c1.v_func(); // "Parent"
c2.v_func(); // "Children"

このインスタンスの生成方法は,右辺がキャストされてコピーコンストラクタに渡されることによって実現しているので(参照),c1は名実ともにParent型であるからです.typeid()で見てみても,Parent型と表示されます.

Parent c1 = Children(); // 内部では Parent c1 = Parent(Children());
std::cout << typeid(c1).name() << std::endl; // "6Parent"

つまり,これは「インスタンス経由で仮想関数を呼び出した場合」に相当します.

動的に関数を決定している」ということをさらに理解するために,次の例を見てみましょう.
先程のParentクラスの中に,wrap_func()というメンバ関数を新たに追加しました.この中では,func(), v_func()を呼んでいます.Childrenクラスではオーバーライドしていないので,Parentクラスのwrap_func()がそのまま継承されます.
そしてこれを,インスタンス経由で関数を呼び出してみましょう.

#include <iostream>

class Parent {
public:
  void wrap_func() {
    func();
    v_func();
  }
  void func() { std::cout << "Parent" << std::endl; }
  virtual void v_func() { std::cout << "Parent" << std::endl; }
};

class Children : public Parent {
public:
  void func() { std::cout << "Children" << std::endl; }
  void v_func() { std::cout << "Children" << std::endl; }
};
  

int main() {
  Children c;
  c.func(); // "Children"
  c.v_func(); // "Children"
  c.wrap_func(); // "Parent" "Children"
  return 0;
}

結果は,wrap_func()の中で,func()はParentクラスのそれが,v_func()はChildrenクラスのそれが呼び出されています.
これが,仮想関数による効果です.すなわち,c.wrap_func()を呼ぶとParentクラスの中でwrap_func()が呼ばれるのですが,非仮想関数func()は動的に実体の型を見て判断しないのでそのままParentクラスのfunc()を呼び出しますが,仮想関数v_func()は動的にオブジェクトがChildren型であると判断して,Childrenクラスの方のfunc()を呼び出します.

そして,これこそが仮想関数が導入するモチベーションでした.すなわち,仮想関数が存在しなければ,基底クラスから派生クラスのメンバ関数を呼び出すことができないという点です.基底クラスのwrap_func()の中で派生クラスのv_func()を呼び出すような設計にしたかったら(これこそがポリモーフィズムを意識した設計だと思いますが),このように動的結合をする必要があります.

(参考サイト)

基底クラスのデストラクタは仮想にすべき

Effective C++ (第3版)の7項にはこのように書いてあります.また,多くの場所で「virtualなメンバ関数を持っているクラスのデストラクタはvirtualにすべき」というプラクティスが叫ばれており,コンパイラによっては警告を出してきたりするようです(参考).この理由は何でしょう.
下の例を見ましょう.

#include <iostream>

class Parent {
public:
  ~Parent() { std::cout << "Parent's destructor called" << std::endl; }
  virtual void v_func() { std::cout << "Parent" << std::endl; }
};

class Children : public Parent {
public:
  ~Children() { std::cout << "Children's destructor called" << std::endl; }
  void v_func() { std::cout << "Children" << std::endl; }
  static Parent* getParent();
};

/* factory function */
Parent* Children::getParent(){
  return new Children();
}

int main() {
  Parent *p = Children::getParent();
  delete p;
  return 0;
}

ここでは,Childrenクラスの中でgetParent()という静的メンバ関数3を宣言しています.ちなみにこのように「ヒープ領域に派生クラスのオブジェクトを生成し,その基底クラスのポインタを返す関数」を**ファクトリ関数(Factory Function)**と呼んだりします.(ファクトリ関数を使ってインスタンスを作成するようにすると,必ず基底クラスのポインタで受け取ることを強制する(派生クラスのポインタでは受け取れない)ので,ポリモーフィズムを意識したコードになる.そのメリットは,Effective C++の第40項にも書いてあります).

さて,このコードを実行するとどうなるでしょう.答えはParentクラスのデストラクタのみが呼ばれます.

output
Parent's destructor called

こうなる理由は先ほど説明した「ポインタ経由で仮想関数を呼び出した場合」に相当するからですが,インスタンスの派生クラス部分が動的に破棄されないというのは,部分的に破棄されたオブジェクトができてしまうので,リソース漏れを引き起こします.
よってこれは避けるべきで,解決策はデストラクタをvirtualにしておくということです.

...
  virtual ~Parent() { std::cout << "Parent's destructor called" << std::endl; }
...

そうすると,動的にインスタンスのクラスであるChildrenクラスのデストラクタから呼ばれ,インスタンスが完全に開放されます.

output
Children's destructor called
Parent's destructor called

「virtualなメンバ関数を持っているクラスのデストラクタはvirtualにすべき」という文言を詳しく解釈すると,「virtualなメンバ関数を持っているということは,派生クラスが存在し今回のように基底クラスのポインタを使ってdeleteしようとする場面も想定されるのだから,デストラクタはvirtualにしておけ」ということなのでしょう.

(参考サイト)

純粋仮想関数

基底クラスとしては呼び出すつもりが全くなく,派生クラスとしてしか呼び出すつもりがないものは,下のように=0とすることで実装を省くことができます.このようなものを純粋仮想関数と呼びます.

virtual void pv_func() = 0;

純粋仮想関数が定義されているクラスは抽象クラスとなります.抽象クラスとは,インスタンスを作成することができないクラスです.
例えば,鳥という基底クラスがあって,派生クラスとしてカラス,すずめなどの派生クラスとして継承することを考えます.鳥というのは概念ですから,鳥というインスタンスは作りません.このような場合は,鳥は抽象クラスとして定義します.「純粋仮想関数があれば抽象クラスになる」と書きましたが,逆に,抽象クラスとして定義したいんだけども,純粋仮想関数とできるメンバ関数がない場合というのがあります.そのようなときは,デストラクタを純粋仮想デストラクタにしてしまうというテクニックがあります.
しかし,一つ注意が必要で,純粋仮想デストラクタの定義も書かなくてはいけません.直前の例に見れるように,派生クラスのインスタンスを破棄するときは,派生クラスのデストラクタが呼ばれた後に,基底クラスのデストラクタが呼ばれます.つまり,純粋仮想デストラクタとして=0とだけ書いてあって定義が書かれていないと,リンクエラーが起きてしまうのです.
下のように,中身は何もなくていいので,定義を書いておく必要があります.

class Bird {
public:
  virtual ~Bird() = 0;
}
Bird::~Bird() {}; // 定義しないとリンクエラーになる

コンストラクタやデストラクタ内では決して仮想関数を呼び出さない

基底クラスのデストラクタは仮想にすべき」と混同しそうですが,「コンストラクタやデストラクタでは決して(他の)仮想関数を呼び出さない」という意味です.これはEffective C++の9項の教えです.
次の例を見てみましょう.

#include <iostream>

class Parent {
public:
  Parent() {
    std::cout << "Parent's constructor called: ";
    v_func(); }; 
  virtual ~Parent() {
    std::cout << "Parent's destructor called: ";
    v_func(); };
  virtual void v_func() {std::cout << "Parent's v_func()" << std::endl; };
};

class Children final : public Parent {
public:
  Children() { std::cout << "Children's constructor called" << std::endl; }
  ~Children() { std::cout << "Children's destructor called" << std::endl; }
  void v_func() override { std::cout << "Children's v_func()" << std::endl; }
};

int main() {
  Children c;
  return 0;
}

Parentクラスのコンストラクタとデストラクタ内で,仮想関数であるv_func()を呼び出しています.Childrenクラス内でそれをオーバーライドしていますから,今までの話を理解していれば,動的にChildrenクラスのv_func()が呼ばれると思われます.

しかし,結果は下のようになり,Parentクラスのv_func()が呼ばれていることがわかります.4

output
Parent's constructor called: Parent's v_func()
Children's constructor called
Children's destructor called
Parent's destructor called: Parent's v_func()

これは,オブジェクトの生成順に答えがあります.派生クラスのオブジェクトを生成するときには,その基底クラスの部分が先に生成されることになっているからです.逆もまた然りで,派生クラスのオブジェクトを削除するときには,まず派生クラスの部分が先に削除され,その後に基底クラスの部分が削除されるからです.つまり,Parentクラスのコンストラクタ/デストラクタが呼ばれるタイミングでは,Parentクラスの部分のみが存在するオブジェクトになっており,この中途半端なオブジェクトはプログラムからはParentクラスのオブジェクトだと認識されます
よって,そのときに動的に呼ばれるv_func()はParentクラスのそれになるのです.

これは,プログラマからしたら意図しない振る舞いでしょう.よって,このようなバグを起こさないために,コンストラクタやデストラクタの中では他の仮想関数は呼ばない方がいいのです.

ベストプラクティスは何か

仮想関数の仕組みについて書いてきましたが,結局ベストプラクティスは何なのでしょうか.
c++11からoverrideとfinalというキーワードが追加され,プログラマが継承や,それに伴うオーバーライドについて明示的に記述し,プログラマが予期しない場所で予期しない継承が起こったときはコンパイラがエラーが吐く書き方が推奨されます.また,overrideの,「virtualでないメンバ関数をオーバーライドしようとするとエラーが出る」という役割が言わんとすることは,オーバーライドされる想定の基底クラスのメンバ関数はvirtualをつけて仮想関数にするべきだということでしょうね.逆に,上のパターン②で見たような,ポインタ経由でメンバ関数を呼び出そうとしたら派生クラスのメンバ関数が呼ばれないと現象を意図して起こしたいことはほぼないと思うので,やはりオーバーライドされる想定のものは仮想関数としておくべきなのでしょう.
しかしながら,何でもかんでも仮想関数にするのはよくありません.仮想関数を実行時に動的に判断するという動的結合のためには,余計な情報量が必要になり,そのために余計なデータ構造が必要になるからです5

まとめると,

  1. オーバーライドされる想定のメンバ関数はvirtualをつけて仮想関数とする.
  2. オーバーライドには明示的にoverrideをつける.
  3. オーバーライドされたくないメンバ関数orクラス全体にはfinalをつける.
  4. 継承される予定のクラス(仮想関数を持つクラス)のデストラクタは仮想にする.
  5. コンストラクタやデストラクタ内では決して仮想関数を呼び出さない.
  6. 継承されない予定のクラスのメンバ関数・デストラクタはむやみに仮想にしない.

のが良いということです.下のような構成です.

class Parent {
public:
  virtual ~Parent() { std::cout << "Parent's destructor called" << std::endl; } // このクラスが派生クラスを持つかもしれない(仮想関数を持っているのはそのため)ので,仮想デストラクタとする
  virtual void v_func() { std::cout << "Parent" << std::endl; } // オーバーライドする想定
  void func() final {}; // これ以上オーバーライドされない想定
};

class Children final : public Parent { // このクラス自体が継承されない想定なのでfinalをつける.メンバ関数は仮想にしない
public:
  ~Children() { std::cout << "Children's destructor called" << std::endl; }
  void v_func() override { std::cout << "Children" << std::endl; }
  // void func() override {}; // finalなメンバ関数はoverrideできないので,こうするとコンパイルエラーが出る(間違えてこうしないために,基底クラス内でfinalをつけている)
};
  1. 他の言語では存在しない訳ではなく,例えばJavaでは全てがここでいう仮想関数として実装されています.C++では明示的に使い分けなければいけないということです.

  2. というか,そもそも「c1はParent型の変数で実体はChildren型」という文章自体が間違っています.これはポインタの考え方に引っ張られています.ポインタはそれ自体がオブジェクトで,そのポインタが指すオブジェクトとは別物です(よって,メモリ上で別々の位置にあります).そうではなくて,c1はオブジェクトそれ自体を指す"名前"です.この違いは,参照とポインタの違いそのものに当たります.

  3. 静的メンバ関数というのは,インスタンスを作成しなくても利用することのできるメンバ関数のことです(静的メンバ変数というものもあります).staticという修飾子をつけることで宣言できます.コンパイル時に静的領域にメモリが確保されます.(参考)

  4. ちなみに,Parentクラスのv_func()が純粋仮想関数だった場合は,コンパイルエラーが出ます.それは,リンカがv_func()の定義を見つけられず,リンクができないからです.

  5. vptr(仮想テーブルポインタ)やvtbl(仮想テーブル)といったデータが使われる.

12
7
5

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?