プログラミングに興味のある方ならば、オブジェクト指向という言葉は聞いたことがあると思います。C++はオブジェクト指向プログラミングができるようにC言語を拡張したものですが、度重なる機能追加により複雑な言語仕様になってしまいました。このため、初心者がオブジェクト指向を学ぶには適していないと言われています。
Java のオブジェクト指向はC++よりも簡単だといわれていますが、C++と同じようにバージョンアップするたびに新しい機能が追加されるので、Java のオブジェクト指向機能もかなり複雑になりつつあります。初心者 (M.Hiroi を含む) からみると、どちらのオブジェクト指向も大変難しい、と思われている方が多いのではないでしょうか。オブジェクト指向を学ぶには Python や Ruby のようなスクリプト言語のほうが適しているのかもしれません。
C++はオブジェクト指向プログラミング言語なので、オブジェクト指向を避けて通るわけにはいきません。そこで、簡単なプログラムを作りながら、少しずつステップアップしていきましょう。まずは最初に、一般的なオブジェクト指向について簡単に説明します。
プログラムを作る場合、全体を小さな処理に分割して、一つ一つの処理を作成し、それらを組み合わせて全体のプログラムを完成させます。このとき、基本的な部品となるのが関数です。処理を関数単位で分割して、それらを組み合わせてプログラムを作るわけです。もともと関数の役割は、入力されたデータを処理してその結果を返すことです。つまり、関数は機能を表しているのです。このため、全体を小さな処理に分割するにしても、機能単位で行われることが普通です。
オブジェクト指向プログラミングでは、関数ではなく「オブジェクト (object)」を部品として扱います。たとえば、えんぴつを考えてみましょう。えんぴつには、色、長さ、固さ、などいろいろな性質があります。そして、えんぴつを使って文字を書いたり、絵を描いたりすることができます。プログラムでは、このような性質をデータで表し、機能を関数で表すことになります。そしてオブジェクトとは、このデータと関数を結び付けたものなのです。
いままでのプログラミング言語では、データと関数を別々に定義するため、それを一つのオブジェクトとして表すことができません。えんぴつで文字を書くにも、えんぴつの種類をチェックして文字を書くようにプログラムしなければいけません。ところが、オブジェクトはデータと関数を結び付けたものなので、自分がなにをしたらよいかわかっています。えんぴつオブジェクトに文字を書けと命じれば、それが赤えんぴつのオブジェクトであれば文字は赤に、黒えんぴつのオブジェクトであれば黒い文字になるのです。
このように、オブジェクトはデータと関数を一つにまとめたものです。従来のプログラミングが全体を機能単位で分割するのに対し、オブジェクト指向プログラミングでは全体をオブジェクト単位に分割して、それを組み合わせることでプログラムを作成します。
ところで、データと関数を結び付けることは、従来のプログラミング言語でも可能です。オブジェクト指向はプログラミングの考え方の一つであり、C++のようなオブジェクト指向言語を使わなくても、たとえばC言語でもその考え方にしたがってプログラムを作れば、オブジェクト指向プログラミングになります。
実際、オブジェクト指向には様々な考え方があり、いろいろなオブジェクト指向プログラミング言語が存在します。ですが、データと関数を一つにまとめたものをオブジェクトとして扱うという基本的な考え方は、オブジェクト指向言語の元祖と言われる Smalltalk でも、C++や Java でも同じです。
次は、一般的なオブジェクト指向機能について簡単に説明します。
「クラス (class)」はオブジェクトの振る舞いを定義したものです。ここでデータを格納するための変数や、それを操作する関数が定義されます。C++ではこの変数を「メンバ変数 (member variable)」といいます。他の言語ではフィールド (field)、スロット (slot)、インスタンス変数などと呼ばれます。そして、クラスの中で定義された関数を「メソッド (method)」といい、C++では「メンバ関数」といいます。メソッドはあとで説明します。
クラスはオブジェクトの設計図にあたるもので、オブジェクトの「雛形」と呼ぶこともあります。クラスはオブジェクトの振る舞いを定義するだけで、アクセスできる実体はなにも生み出していない、ということに注意してください。ただし、プログラミング言語によってはクラスに実体を持たせていることもあります。
このクラスから実体として作り出されるのが「インスタンス (instance)」です。このインスタンスを「オブジェクト」と考えてください。インスタンスを生成する方法は、当然ですがプログラミング言語によって違います。
たとえば、Java は new を使います。C++の場合、クラスはデータ型を表すので、局所変数や大域変数として宣言すれば、インスタンスを生成することができます。また、new を使ってインスタンスを動的に生成することもできます。下図を見てください。
┌─ class Foo ─┐ ┌─ instance ─┐ │ │ │ │ │ 設計図 │─ インスタンスの生成 →│ 実体 │ │ │ │ │ └────────┘ └───────┘ │ │ │ ┌─ instance ─┐ │ │ │ └───── インスタンスの生成 →│ 実体 │ │ │ └───────┘ 図 : クラスとインスタンスの関係
クラスはオブジェクトの定義を表すものですから、Foo というクラスは一つしかありません。これに対し、インスタンスはクラスから生み出されるオブジェクトです。たとえば、クラス Foo に new を適用することで、いくつでもインスタンスを生み出すことができるのです。クラスは設計図であり、それに従って作られるオブジェクトがインスタンスと考えるとわかりやすいでしょう。
メソッドはオブジェクトと結びついた関数です。オブジェクト指向プログラミングでは、ほかの関数から直接オブジェクトを操作することはせず、メソッドを呼び出すことで行います。メソッドは、クラスが異なっていれば同じ名前のメソッドを定義することができます。たとえば、クラス Foo1 にメソッド bar が定義されていても、クラス Foo2 に同名のメソッド bar を定義することができます。
そして、ここからが重要なのですが、あるオブジェクトに対してメソッド bar を呼び出した場合、それが Foo1 から作られたオブジェクトであれば、Foo1 で定義されたメソッド bar が実行され、Foo2 から作られたオブジェクトであれば、Foo2 で定義されたメソッド bar が実行されるのです。このように、オブジェクトが属するクラスによって実行されるメソッドが異なります。これを「ポリモーフィズム(polymorphism)」と呼びます。この機能により、オブジェクトは自分が行うべき適切な処理を実行できるわけです。
クラス、インスタンス、メソッドの関係は下図のようになります。クラスという設計図が中心にあり、そこからインスタンスが生み出され、メソッドを使ってインスタンスを操作する、という関係になります。
┌─ class Foo1 ─┐ ┌─ instance ─┐ │ │ │ │ │ 設計図 │─── 生成 ───→│ 実体 │ │ │ │ │ │ │ └───────┘ │┌─ method ─┐│ ↑ ││ ││ │ ││ bar()←─┼┼─── アクセス ─────┘ ││ ││ │└──────┘│ └────────┘ 図 : クラス、インスタンス、メソッドの関係
さて、一般的な話はここまでにして、C++のオブジェクト指向機能に目を向けてみましょう。C++は class 文でクラスを定義します。class 文の構文を下図に示します。
class クラス名 : 継承修飾子 スーパークラス名, ... { ... }; 図 : class 文の構文
class の次にクラス名を指定し、そのあとのブロック { } の中でメンバ変数やメンバ関数を定義 (またはプロトタイプを宣言) します。クラス名の後ろにコロン : を付けて、継承修飾子 (public, protected, private) と他のクラスを指定すると、そのクラスを「継承」することができます。C++は複数のクラスを継承することができます。これを「多重継承」といいます。継承については次回以降に詳しく説明する予定です。
それでは、一番簡単なクラス定義を示しましょう。次のリストを見てください。
リスト : クラス定義 (sample110.cpp) #include <iostream> using namespace std; class Foo { }; Foo a; // 大域変数 int main() { Foo b; // 局所変数 Foo* c = new Foo; // メモリの動的割り当て cout << &a << endl; cout << &b << endl; cout << c << endl; }
一般に、クラス名は英大文字から始めることが多いので、名前は Foo としました。Foo はクラス名しかありませんが、これでも立派なクラスなのです。クラス名はユーザーが定義したデータ型として扱われるので、main の外側で Foo a; とすればインスタンスは静的なデータ領域に確保され、main の中で Foo b; とすればスタック領域に確保され、Foo* c = new Foo; とすればインスタンスは動的に生成されて、そのポインタ (先頭アドレス) がポインタ変数 c にセットされます。
実行結果は次のようになります。
$ clang++ sample110.cpp $ ./a.out 0x558cc011e052 0x7ffc80ddbd38 0x558cc0e1eeb0
C++はクラス内で宣言された変数を「メンバ変数」として扱います。他の言語ではフィールド、スロット、インスタンス変数などと呼ばれています。メンバ変数の実体はインスタンスに割り当てられます。static を付けると「静的メンバ変数」として扱われるため、インスタンスには割り当てられません。静的メンバ変数についてはあとで説明します。
C++のメンバ変数とメンバ関数 (以降メンバと表記) は、public, protected, private という 3 通りのアクセス制御を行うことができます。public はどこからでもアクセスすることできます。protected は同じクラスとそれを継承したクラス (サブクラス) からアクセスすることができ、private は同じクラスからしかアクセスすることができません。
アクセス制御の指定は次のように行います。
リスト : アクセス制御 class Foo { int a; // デフォルトは private ... public: int b; // public になる int c; ... private: int d; // private になる int e; ... };
デフォルトでは private に設定されています。public: を指定すると、それ以降に定義されたメンバは public になります。次に、private: を指定すると、それ以降に定義されたメンバは private になります。このように、アクセス制御は何度でも切り替えることができます。
メンバ変数のアクセスは次の形式で行います。
object.variable
インスタンス object の後ろにドット ( . ) を付けて、その後ろにメンバ変数 variable を指定します。同じクラスのメンバ関数であれば、object を省略してメンバ変数 variable だけでアクセスすることができます。
ポインタ変数にインスタンスを保持している場合、メンバ変数は次のようにアクセスします。
2 のように記述することもできますが、一般的には 1 の間接メンバ演算子 (->) を使います。
簡単な例を示しましょう。
リスト : メンバ変数のアクセス (sample111.cpp) #include <iostream> using namespace std; class Foo { public: int x = 1; // C++11 }; int main() { Foo a; Foo *b = new Foo; cout << a.x << endl; cout << b->x << endl; a.x = 10; b->x = 100; cout << a.x << endl; cout << b->x << endl; }
$ clang++ sample111.cpp $ ./a.out 1 1 10 100
クラス Foo にメンバ変数 x を定義します。x は public なので、main 関数からでもアクセスすることができます。C++の場合、メンバ変数の初期化は「コンストラクタ」という特別なメンバ関数で行いますが、最近の規格 (C++11) では int x = 1; のように初期値を指定することができるようになりました。コンストラクタはあとで詳しく説明します。
クラス Foo のインスタンスを生成して変数 a とポインタ変数 b にセットします。インスタンス a, b にあるメンバ変数 x は、それぞれ a.x, b->x でアクセスすることができます。a.x = 10 とすると a の x に 10 が代入され、b->x = 100 とすると b の x に 100 が代入されます。
ただし、オブジェクト指向でプログラムを作る場合、メンバ変数に直接アクセスすることはあまり行われません。メンバ変数の値を参照するメンバ関数 (リーダー: reader) と値を更新するメンバ関数 (ライター: writer) を用意し、それらのメンバ関数を経由してアクセスするのが一般的で、お行儀の良いプログラミングスタイルとされています。
C++はクラス内で定義された関数を「メンバ関数」として扱います。static を付けると「静的メンバ関数」になります。静的メンバ関数はあとで説明します。通常のメンバ関数を定義する場合は static を付けないよう注意してください。
簡単な例として、クラス Foo にメンバ変数 x のアクセス関数 (リーダー、ライター) を定義しましょう。次のリストを見てください。
リスト : リーダーとライター (sample112.cpp) #include <iostream> using namespace std; class Foo { int x = 1; // C++11 public: // アクセスメソッド int get_x() const { return x; } void put_x(int n) { x = n; } }; int main() { Foo a; Foo *b = new Foo; cout << a.get_x() << endl; cout << b->get_x() << endl; a.put_x(10); b->put_x(100); cout << a.get_x() << endl; cout << b->get_x() << endl; }
$ clang++ sample112.cpp $ ./a.out 1 1 10 100
メンバ関数 get_x がリーダーで、put_x がライターです。関数名の付け方は適当でかまいませんが、リーダーの場合は変数名の前に get_ や read_ を、ライターの場合は put_, set_, write_ などを付けるとわかりやすいと思います。また、_ を省略して変数名の先頭を大文字にする、たとえば getX や putX のように定義する場合もあります。
get_x の後ろに const が付いていることに注意してください。これは、このメンバ関数がインスタンスの状態 (メンバ変数の値) を変更しないことを表しています。これを「const メンバ関数」といいます。
これらのメンバ関数は static を付けていないので、インスタンスを操作するメンバ関数になります。これを他のプログラミング言語では「インスタンスメソッド」と呼ぶことがあります。メンバ関数の呼び出しはメンバ変数のアクセスと同じです。
インスタンス object の後ろにドット ( . ) を付けて、関数名と引数を続けて書きます。同じクラスのメンバ関数であれば、object を省略して関数名だけで呼び出すことができます。インスタンスをポインタ変数に保持している場合は、2 のように間接メンバ演算子 (->) を使います。
リーダーとライター (アクセス関数) を定義したので、メンバ変数 x を private に設定します。これで main 関数から直接メンバ変数 x にアクセスすることはできず、アクセス関数を経由して x にアクセスすることになります。
インスタンスを生成するとき、メンバ変数の初期値を指定できると便利です。C++はクラスと同じ名前のメンバ関数を「コンストラクタ(constructor)」として特別扱いします。コンストラクタは「生成関数」とか「構築子」と呼ばれることがあります。C++はインスタンスを生成するときコンストラクタを呼び出して、インスタンスの初期化処理を行います。
コンストラクタの構文を次に示します。
クラス名(データ型 引数, ...) : メンバ変数(初期値), ... { 処理; ... }
コンストラクタは値を返さないので、返り値のデータ型を指定する必要はありません。メンバ変数の初期化はコロン ( : ) の後ろの「初期化リスト」で行います。メンバ変数(初期値) でメンバ変数を指定した値で初期化することができます。複数のメンバ変数を初期化する場合はカンマ ( , ) で区切ってください。
C++の場合、変数の宣言と同時に値を指定することを「初期化」、変数を宣言したあと演算子 = で値をセットすることを「代入」といって、この 2 つを明確に区別しています。コンストラクタ本体でメンバ変数に値をセットすることはできますが、それはあくまでも「代入」という操作であり、メンバ変数を「初期化」したい場合は初期化リストを使用してください。具体例はあとで示します。
あとは通常のメンバ関数と同じです。もちろん、多重定義も可能です。次のリストを見てください。
リスト : コンストラクタ (sample113.cpp) #include <iostream> using namespace std; class Foo { int x; public: // コンストラクタ Foo() : x(1) {} Foo(int n) : x(n) {} // アクセスメソッド int get_x() const { return x; } void put_x(int n) { x = n; } }; int main() { Foo a; Foo *b = new Foo(2); cout << a.get_x() << endl; cout << b->get_x() << endl; a.put_x(10); b->put_x(100); cout << a.get_x() << endl; cout << b->get_x() << endl; }
$ clang++ sample113.cpp $ ./a.out 1 2 10 100
引数なしのコンストラクタと引数が一つのコンストラクタを定義します。引数なしのコンストラクタでは、メンバ変数 x を 1 に初期化します。引数が一つのコンストラクタでは、引数 n の値でメンバ変数 x を初期化します。
メンバ関数の中では this という特別な変数を使用することができます。this はそのメンバ関数を呼び出したインスタンスを指し示すポインタです。"this->メンバ変数名" でクラスのメンバ変数にアクセスすることができます。
たとえば、メンバ関数の局所変数とメンバ変数が同じ名前の場合、C++は局所変数のアクセスを優先します。もし、メンバ関数 put_x の仮引数を x と宣言すると、メンバ変数 x と同一になります。この場合、引数の値をメンバ変数 x に代入するには this->x = x; とします。
引数のあるコンストラクタは次のように呼び出します。
大域変数や局所変数にインスタンスを生成する場合、1 のように変数名の後ろにカッコを付けて、その中に引数を記述します。これで引数のあるコンストラクタを呼び出すことができます。動的にメモリを取得する場合、2 のように new クラス名(引数, ...) とすれば、ヒープ領域からメモリを取得して、その先頭アドレスがポインタ変数にセットされます。なお 1 の場合は、クラス名 変数名 = クラス名(引数, ...); と記述してもコンパイルすることができます。
main 関数の中で、変数 a のインスタンスは、引数無しのコンストラクタで生成されるので x の値は 1 になります。つまり、Foo a = Foo() と同じです。変数 b のインスタンスはコンストラクタ Foo(2) で生成されるので x の値は引数で渡された 2 になります。
引数を指定しないで呼び出すことができるコンストラクタを「デフォルトコンストラクタ」といいます。コンストラクタが未定義の場合、コンパイラは何も処理をしないデフォルトコンストラクタを自動的に生成します。コンストラクタが一つでも定義されていると、デフォルトコンストラクタは自動的に生成されません。たとえば、コンストラクタ Foo(int n){ ... } を一つだけを定義すると、デフォルトコンストラクタは未定義になるので、変数 a の定義 Foo a; はコンパイルエラーになります。ご注意ください。
構造体と同様に、インスタンスは変数の初期値に指定したり、他の変数に代入することができます。関数の引数に渡すこともできますし、インスタンスを返すこともできます。次のリストを見てください。
リスト : 初期化と代入 (sample114.cpp) #include <iostream> using namespace std; class Foo { int x; public: Foo() : x(0) { } Foo(int n) : x(n) { } int get_x() const { return x; } }; Foo foo(Foo a) { return Foo(a.get_x() * 10); } int main() { Foo a(123); Foo b = foo(a); cout << a.get_x() << endl; cout << b.get_x() << endl; a = b; cout << a.get_x() << endl; }
$ clang++ sample114.cpp $ ./a.out 123 1230 1230
関数 foo は Foo のインスタンスを受け取り、メンバ変数 x を 10 倍したインスタンスを生成して返します。関数 foo を呼び出すとき、実引数 a は foo の仮引数 a にコピーされます。また、Foo の返り値は変数 b の初期値になりますが、このときも値がコピーされます。a = b; のように代入することもできます。この場合も b の値が a にコピーされます。C++は「初期化」と「代入」を厳密に区別しています。a = b; は代入で、それ以外は初期化になります。
なお、構造体と同様にクラスのサイズが大きくなると、コピーするのに時間が少々かかるようになります。少しの時間とはいえ、塵も積もれば山となるので、インスタンスは参照またはポインタを使って受け渡しを行うのが一般的です。
ところで、C++では参照や定数を定義するとき、初期値の指定が必要になります。たとえば、const int a = 10; のように初期値 10 を指定しないとコンパイルエラーになります。これはクラスのメンバ変数でも同じです。次のリストを見てください。
リスト : 定数の初期化 (sample115.cpp) #include <iostream> using namespace std; class Foo { const int x; public: Foo() : x(0) {} // Foo(int n) : x(n) {} コンパイル OK Foo(int n) { x = n; } // コンパイル NG void print_x() { cout << x << endl; } }; int main() { Foo a; Foo b(1); a.print_x(); b.print_x(); }
メンバ変数 x は const で宣言されているので、インスタンスを生成するときに整数値で初期化する必要があります。この場合、コンストラクタ Foo(int n) の本体で x = n; とすると、次のようなコンパイルエラーになります。
$ clang++ sample115.cpp sample115.cpp:9:3: error: constructor for 'Foo' must explicitly initialize the const member 'x' Foo(int n) { x = n; } // コンパイル NG ^ sample115.cpp:5:13: note: declared here const int x; ^ sample115.cpp:9:18: error: cannot assign to non-static data member 'x' with const-qualified type 'const int' Foo(int n) { x = n; } // コンパイル NG ~ ^ sample115.cpp:5:13: note: non-static data member 'x' declared const here const int x; ~~~~~~~~~~^ 2 errors generated.
実をいうと、C++はコンストラクタ本体を実行する前に、メンバ変数の初期化が行われます。このとき、const メンバ変数に初期値がないので、コンパイルエラーになるのです。初期化リストによる初期値の指定は、メンバ変数の初期化のときに実行されるので、このような場合でも正常にコンパイルすることができます。
なお、初期化と代入の違いは「コピーコンストラクタ」と代入演算子の「多重定義」にも影響します。詳しい説明は次回以降に行います。
それでは簡単な例として、点を表すクラスを作ってみましょう。名前は Point にしました。x 座標をメンバ変数 x に、y 座標を変数 y に格納します。次のリストを見てください。
リスト : Point クラス (sample116.cpp) #include <iostream> #include <cmath> using namespace std; class Point { double x, y; public: Point() : x(0), y(0) { } Point(double x0, double y0) : x(x0), y(y0) { } double distance(const Point&) const; }; // 距離を求める double Point::distance(const Point& p) const { double dx = x - p.x; double dy = y - p.y; return sqrt(dx * dx + dy * dy); } int main() { Point p0; Point p1(1, 1); Point p2(2, 2); cout << p0.distance(p1) << endl; cout << p0.distance(p2) << endl; cout << p1.distance(p2) << endl; }
メンバ関数 distance は Point クラスのインスタンス p を参照で受け取り、自分自身と p の距離を計算します。C++の場合、小さなメンバ関数 [*1] はクラス内で定義し、それ以外のメンバ関数はプロトタイプを宣言します。実際の関数定義はクラスの外側で行います。このとき、スコープ解決演算子 :: を使って関数が属するクラスを指定します。const メンバ関数の場合、関数名(...) の後ろに const を付けることを忘れないでください。
distance はクラス Point のメンバなので、private なメンバ変数 x, y にアクセスすることができます。自分自身のメンバ変数の値は x, y で、引数 p のメンバ変数の値は p.x, p.y で求めることができます。sqrt() は平方根を求める関数で、標準ライブラリ <cmath> に定義されています。
あとは Point のインスタンスを生成して、変数 p0, p1, p2 にセットして p0.distance(p1) で p0 と p1 の距離を計算します。実行結果は次のようになります。
$ clang++ sample116.cpp $ ./a.out 1.41421 2.82843 1.41421
ここで、メンバ関数の呼び出しは、インスタンスによって適切な関数が選択されることに注意してください。たとえば、3 次元の座標を表す Point3d クラスを考えてみましょう。次のリストを見てください。
リスト : Point3d クラス (sample117.cpp) #include <iostream> #include <cmath> using namespace std; class Point { double x, y; public: Point() : x(0), y(0) { } Point(double x0, double y0) : x(x0), y(y0) { } double distance(const Point&) const; }; double Point::distance(const Point& p) const { double dx = x - p.x; double dy = y - p.y; return sqrt(dx * dx + dy * dy); } class Point3d { double x, y, z; public: Point3d() : x(0), y(0), z(0) { } Point3d(double x0, double y0, double z0) : x(x0), y(y0), z(z0) { } double distance(const Point3d&) const; }; double Point3d::distance(const Point3d& p) const { double dx = x - p.x; double dy = y - p.y; double dz = z - p.z; return sqrt(dx * dx + dy * dy + dz * dz); } int main() { Point p0; Point p1(1, 1); Point3d p2; Point3d p3(1, 1, 1); cout << p0.distance(p1) << endl; cout << p2.distance(p3) << endl; }
クラス Point3d は Point を 3 次元に拡張しただけです。Point でも Point3d でも距離を計算するメンバ関数 distance が定義されていることに注目してください。それでは、実行してみましょう。
$ clang++ sample117.cpp $ ./a.out 1.41421 1.73205
ドットの左側のインスタンス p0, p2 によって適切なメンバ関数が呼び出され、ポリモーフィズムが働いているようにみえます。Perl, Python, Ruby などのように、変数を使用するときデータ型の宣言が不要なプログラミング言語を「動的型付け言語」といい、C++や Java などのように変数を使用するときデータ型の宣言が必要なプログラミング言語を「静的型付け言語」といいます。
たとえば Ruby の場合、p1.distance(p3) で呼び出されるメソッド distance は、プログラムを実行するとき変数 p1 に格納されているオブジェクトの型 (クラス) によって決定されます。p1 が Point のインスタンスであれば、Point で定義されたメソッド distance が呼び出されます。つまり、動的型付け言語のメソッド呼び出しはプログラムの実行時にポリモーフィズムが働いている、と考えることができます。
これに対して、C++や Java などの静的型付け言語では、コンパイルの時点で呼び出すメソッドを可能な限り決定します。たとえば、p0.distance(p1) の呼び出しは Point クラスのメンバ関数で、p2.distance(p3) は Point3d クラスのメンバ関数と決定することができます。この場合、動的型付け言語のようにプログラムの実行時にポリモーフィズムが働くわけではありませんが、オブジェクトのクラスによって呼び出すメンバ関数が決定される、ということにかわりはありません。
C++でプログラムの実行時にポリモーフィズムを働かせるには「継承」と「仮想関数」という機能を使います。これは次回以降で詳しく説明します。