2.2 クラスの基礎
C++とCとの最大の違いはなんといってもクラスでしょう.この項では,C++のクラスについて,ひととおりの解説を行います.本書はC++そのものの入門書や解説書ではありませんので,完全な解説を行うことはできませんが,それでもC++を普通に使えるようになり,次項以降の内容が理解できる程度の内容は網羅したいと考えています.より詳しい解説や体系的な解説,あるいはリファレンスを希望される方は,他の書籍も併せて利用されることをお勧めします.
2.2.1 コンストラクタとデストラクタ
まず手始めに,クラスへの導入として,「コンストラクタ」と「デストラクタ」の話題を取り上げてみたいと思います.コンストラクタとは,オブジェクトの初期化時に自動的に呼び出される関数のことであり,デストラクタとは,オブジェクトの解体時に自動的に呼び出される関数のことです.説明を簡単にするために,Cと同様の構造体に,コンストラクタとデストラクタを追加することに限定して書いてみます.
それでは例として,バイナリーファイルを管理する次のような構造体を考えてみましょう.
struct binary_file
{
char* name;
FILE* stream;
};
構造体のメンバー(フィールド)を見れば,どんなデータ構造かだいたい想像が付くと思いますが,nameはファイル名を,streamはファイル記述子へのポインタを格納するためのものです.この構造体を使うには,オブジェクトを宣言した後,各メンバーの初期化が必要になります.
struct binary_file bfile;
bfile.name = (char*)malloc(FILENAME_MAX);
strcpy(bfile.name, "sample.bin");
bfile.stream = fopen(bfile.name, "rb");
そして,使い終わった後で,次のように,後始末をしなければなりません.
fclose(bfile.stream);
free(bfile.name);
この方法は,非常に面倒なうえに可読性も低く,また,間違いを犯す危険性がかなり高いといえます.そこで,コンストラクタやデストラクタの出番となります.上記の例をコンストラクタとデストラクタを使って書き換えてみましょう.
struct binary_file
{
char* name;
FILE* stream;
binary_file(const char* filename)
: name((char*)malloc(FILENAME_MAX))
{
strcpy(name, filename);
stream = fopen(name, "rb");
}
~binary_file()
{
fclose(stream);
free(name);
}
};
構造体の中に,binary_fileと~binary_fileという関数を定義しました.このうち,タグ名と同じ名前の関数(すなわち,binary_file)が「コンストラクタ」であり,タグ名の前にチルダ(~)を付けた名前の関数(すなわち,~binary_file)がデストラクタです.このチルダは,その関数がデストラクタであることを表すためのもので,補数演算子ではありません.コンストラクタは,binary_file構造体のオブジェクトを定義したときに自動的に呼び出されます.デストラクタは,オブジェクトが生存期間を終えるときに自動的に呼び出されます. それでは,この構造体の使い方を見てみましょう.
{
binary_file bfile("sample.bin");
// 何らかの処理
// スコープから抜けるので,ここでデストラクタが呼び出される
}
非常にすっきりとしたコードです.C++コンパイラはコンストラクタとデストラクタの呼び出しを暗黙的に挿入します.また,面倒な記述がなくなったことで,間違いを犯す可能性がほとんどなくなりました.
ところで,上記の例では,bfileの定義時に"sample1.bin"という引数を渡していますが,もしこの引数を渡さなければどうなるのでしょうか? 実は,引数を渡さなければコンパイルエラーになります.引数なしの宣言も行いたいのであれば,引数なしのコンストラクタ(デフォルトコンストラクタ)を多重定義するか,上記の例で書いたコンストラクタで,「filename = NULL」のように省略時実引数を設定する必要があります.
struct binary_file
{
…
binary_file()
または
binary_file(const char* filename = NULL)
…
};
もし,コンストラクタをまったく定義しないのであれば,コンパイラは何もしないデフォルトコンストラクタを暗黙的に定義します.これは,Cにおいて,構造体型の変数を初期化子なしで定義するのと同じことです.しかし,何らかのコンストラクタを明示的に定義した場合には,暗黙のデフォルトコンストラクタは定義されません.デフォルトコンストラクタが必要であれば,必ず明示的に定義を行わなければなりません.
デフォルトコンストラクタを用いて初期化を行う場合,原則として括弧は付けません.
binary_file bfile; // ← デフォルトコンストラクタ
ここで誤って括弧を付けてしまうと,引数を受け取らず,binary_file型の返却値を返すbfileという名前の関数宣言になってしまいます.この点に注意してください.
binary_file bfile(); // ← これは関数宣言
1つの引数を受け取るコンストラクタのことを「変換コンストラクタ」といいます.変換コンストラクタが定義されていると,その引数の型を持つ式から暗黙的に型変換することができるようになります.もちろん,明示的なキャストによって,変換コンストラクタを呼び出すこともできます.
これはどういうことかというと,たとえば,次のような関数があるとします.
extern int func(const binary_file& arg);
それに対し,次のように,const char*型のポインタを実引数に渡した場合でも,暗黙的にbinary_fileの変換コンストラクタが呼び出されることを意味しています.
func("sample1.bin");
また,次のようなキャストを行うことで,binary_file型の一時オブジェクトを生成することも可能になります.
(binary_file)"sample1.bin";
次のように,関数呼び出し形式で書いた場合も同じ意味になります*5.
binary_file("sample1.bin");
ただし,関数呼び出し形式のキャストを行うには「単純型名」でなければなりません.すなわち,型名が単一の識別子で構成される必要があります.そのため,unsigned int型やchar*型などでは関数呼び出し形式のキャストを行うことができません.しかし,typedefやテンプレート仮引数によって単純型名の別名を付けた場合は,そうした型でも関数呼び出し形式のキャストを行うことができます.
ところで,明示的にキャストする場合はともかく,暗黙的に変換コンストラクタが呼び出されるのは,多くの場合,不具合に繋がります.そのため,なんとかして暗黙的な変換コンストラクタの呼び出しを抑制しなければなりません.そのためには「explicit指定子」を使用します.
struct binary_file
{
…
explicit binary_file(const char* filename)
…
};
上記のように,コンストラクタの頭に「explicit」と記述すれば,暗黙的に変換コンストラクタが呼び出されることはなくなり,つねに明示的に呼び出さなければならないようにすることができます.2つ以上の引数を受け取るコンストラクタであっても,第2引数以降に省略時実引数が指定されているのであれば変換コンストラクタとしても機能しますから,暗黙的な呼び出しを抑止したいのであればexplicitを付けるようにしましょう.
int a = int(123.4); // (int)123.4と同義
2.2.2 データメンバーの初期化
コンストラクタでデータメンバーを初期化するには,次のように,データメンバーに値を代入するのが一番安直な方法です.
struct A
{
int a;
A()
{
a = 123;
}
};
しかし,これはあくまでも代入であり,データメンバーを初期化,すなわちデータメンバーのコンストラクタを呼び出しているわけではありません.
データメンバーが引数を受け取るコンストラクタを持っているのであれば,何らかの方法でコンストラクタに実引数を渡し,オブジェクトの初期化を行う必要があります.そうでなければ,引数なしのコンストラクタ(デフォルトコンストラクタ)が呼ばれることになります.
あるいは,データメンバーがconst修飾されていたり,参照型の場合には代入では初期化することができません.また,クラス型の場合には,いったんデフォルトコンストラクタが実行された後で代入を行うのは効率も悪くなります.
そこで,次のように,コンストラクタのブロックの直前にコロン(:)に続くコンストラクタ初期化子を記述します.
struct A
{
int a;
A()
: a(123)
{
}
};
コンストラクタ初期化子は,データメンバーの順序を自由に記述することができます.しかし,コンストラクタ初期化子で記述した順序にかかわらず,クラスを定義する際に記述した順になります.
例を挙げます.
struct A
{
int x;
int y;
A()
: y(1), x(0)
{
}
};
上記の例では,初期化子はy,xの順に記述されていますが,それとは無関係にx,yの順に初期化が行われます.上記のようにint型の場合にはどちらでも大差はありませんが,データメンバーがクラス型の場合には,コンストラクタの実行順序にかかわってくるため,重要な意味を持ちます.混乱の原因にもなるので,コンストラクタ初期化子を記述する際は,実際に初期化される順に記述することをお勧めします.
ところで,このようにして,クラスのコンストラクタはデータメンバーを動的に初期化します.つまり,初期化時にコンストラクタを呼び出すクラス,およびコンストラクタを呼び出すデータメンバーを持つクラスの場合,たとえば,const修飾された非局所オブジェクト(関数の外で宣言されたオブジェクト)として宣言したとしても,ROMに配置されることはなくなります.
通常,これらのクラス型を持つconstオブジェクトは,BSSセクション*6に配置され,プログラムの起動時にBSSセクションがゼロクリアされ,DATAセクションが転送された後,コンストラクタが呼び出されることになります.
COLUMN 集成体
C++では,メンバーの初期化はコンストラクタで行うのが基本です.しかし,一定の条件を満たすクラスは,Cの構造体と同じように,{}で囲まれた初期化子を用いてメンバーを初期化することができます.そのようなクラスは「集成体」と呼ばれます.集成体には配列も含まれます.
クラスが集成体となるには,以下の条件を満たさなければなりません.
- ユーザー宣言のコンストラクタを持たない.
- 非公開または限定公開の非静的データメンバを持たない.
- 基底クラスを持たない.
- 仮想関数を持たない.
以上の条件を満たすクラスは集成体ですので,共用体も集成体になることができます.ちなみにCでいう「集成体」は配列と構造体の総称ですので,共用体は含まれません.
2.2.3 メンバー関数
次に,メンバー関数について解説を行います.メンバー関数については第1章でも簡単に触れましたが,ここではより詳細な解説を行います.
C++では構造体のメンバーとして,データだけではなく,関数を定義することができます.それが「メンバー関数」です.メンバー関数は,ごく大ざっぱにいうと,それが属している構造体のデータメンバー(フィールド)を操作するための関数です.コンストラクタやデストラクタも,実はメンバー関数の一種です.
メンバー関数のことを「メソッド」と呼ぶ人もいますが,「メソッド」と聞いてメンバー関数のことだと思っていたら,実は別の意味だったということも少なからずあります.いいたいことを正確に相手に伝えるには,C++の場合,「メンバー関数」と呼べば間違いがありません.逆に,「メソッド」といわれた場合は,短絡的に「メンバー関数」と捉えずに,少し疑って文脈から判断するようにしたほうが無難です.もしかすると,アプリケーション特有の用語かもしれませんので.
メンバー関数は,普通の関数(非メンバー関数)と同様に,返却型および仮引数並びとともに宣言します.以下に簡単な宣言例を挙げます.
struct A
{
int value;
int func(int arg);
};
このように,データメンバーと同様,構造体のメンバーの一種としてメンバー関数の宣言を行うことができます.そして,メンバー関数の呼び出しは次のようにして行います.メンバー関数はあくまでも構造体のメンバーですので,構造体A型のオブジェクトaに対して,.(ドット)演算子を使って呼び出します.
【メンバー関数の呼び出し】
A a;
int x = a.func(123);
また,A型へのポインタに対しては,次のように->(矢印)演算子を使って呼び出します.
【->演算子による呼び出し】
A* p = &a;
int y = p->func(456);
メンバー関数が呼び出されると,内部的には次のようなことが起こります.
1 実引数とオブジェクトへのポインタ(ここでは&aまたはp)がコピーされる 2 戻り先番地を退避し,funcを呼び出す 3 func関数では,実引数のコピーを仮引数として受け取るとともに,オブジェクトへのポインタをthisとして受け取る 4 returnによって返却値がコピーされ,2で退避した戻り先番地に戻る
ここで普通の関数と異なるのは,オブジェクトへのポインタ(&aまたはp)がthisという名前のオブジェクトとしてメンバー関数に渡されることです.結果として,func関数の内部では,「this->value」のようにすることで,構造体のデータメンバーにアクセスすることができます.
なお,このthis->は省略してもよいことになっています.ただし,データメンバーと同名の識別子を関数内で宣言すると,そちらが優先されてしまうので注意が必要です. では,次にメンバー関数の定義についてです.定義の方法は2種類あります.1つ目は,次のように,宣言と同時に関数の定義も行ってしまう方法です.
struct A
{
int value;
int func(int arg)
{
return value += arg;
}
};
このように宣言と定義を同時に行った場合,原則としてインライン関数になるので,ヘッダファイルの中に記述しても,関数の定義が重複してしまうといった心配はありません.もう1つの方法は,次のように,宣言と定義を分離する方法です.
struct A
{
int value;
int func(int arg);
};
int A::func(int arg)
{
return value += arg;
}
この場合,普通は,宣言をヘッダファイルに記述し,関数の実体は(ヘッダファイルではない)ソースファイルに記述することになります.関数の実体を定義するときには,そのメンバー関数がA型に属していることを表すために,A::という接頭辞を付ける必要があります.
ここで登場する「::」というのは,「1.3.5 名前の衝突を防ぐ「名前空間」」でも紹介した「有効範囲解決演算子」です.C++では,構造体はその内部に有効範囲を形成するのです*7.
ところで,メンバー関数にはオブジェクトへのポインタがthisとして渡されると書きました.では,メンバー関数の呼び出しに使ったオブジェクトの型が,constやvolatileで修飾されている場合はどうでしょう.何らかの方法で,cv修飾(constまたはvolatileによる修飾)の情報を記述できる必要があります.そこで,メンバー関数では,thisのcv修飾の状態を表すために,次のような書き方ができます.
struct A
{
int value;
int func(int arg); // ← A
int func(int arg) const; // ← B
int func(int arg) volatile; // ← C
int func(int arg) const volatile; // ← D
};
上記のAはcv修飾なし,Bはconst修飾付き,Cはvolatile修飾付き,Dはconst volatile修飾付きを意味します.このようにメンバー関数は,オブジェクトのcv修飾子の有無によって多重定義することができます.
A a1;
const A a2;
volatile A a3;
const volatile A a4;
a1.func(0); // ← A
a2.func(0); // ← B
a3.func(0); // ← C
a4.func(0); // ← D
このサンプルコードでは,オブジェクトの修飾状態によって,実際にどのメンバー関数を呼び出すかが変わってくることを表しています.
メンバー関数は,普通の関数とは異なり,オブジェクトへのポインタがthisとして渡されます.すなわち,普通の関数とはコーリングコンベンションが異なるわけです.よくある間違いに,メンバー関数へのポインタを,普通の関数へのポインタに代入しようとして,コンパイルできずに悩むということがあります.
int (*pfnunc)(int) = &A::func; // ← エラー!
メンバー関数へのポインタを使用するには次のようにします.
【メンバー関数へのポインタを使用する方法】
int (A::*pfunc)(int) = &A::func; // ← メンバー関数へのポインタ
A a;
a.*pfunc(123); // ← ポインタ経由でメンバー関数を呼び出す
A* p = &a;
p->*pfunc(123); // ← オブジェクトへのポインタを用いる場合は,->*を使用
つまり,メンバー関数へのポインタは,普通の関数へのポインタとは型が異なるのです.そのため,明示的にA型のメンバー関数であることを表すため,A::*のように記述する必要があります.
また,通常の関数であれば,関数名は暗黙的に関数へのポインタに型変換されましたが,メンバー関数の場合には,必ずアンパサンド(&)を付けなければメンバー関数へのポインタ型にはなりませんので注意が必要です.さらに,メンバー関数へのポインタを用いてメンバー関数を呼び出す場合にも,「.*」および「->*」という専用の演算子を用いる必要があります.
2.2.4 アクセス制御
前項では,メンバー関数というのは,それが属している構造体のデータメンバー(フィールド)を操作するための関数だと書きました.しかし,実際には,構造体のデータメンバーの操作は,メンバー関数でなくても,構造体へのポインタさえ引数として渡せば,どんな関数でもできてしまいます.
このように,どこからでも構造体のデータメンバーを操作できるようにしていると,グローバル変数が抱える問題と同じようなことが,構造体のデータメンバーにも発生するわけです.そこで,メンバー関数以外からデータメンバーを操作できないようにする方法があると,そのような問題はなくなります.これは,ある意味で,オブジェクトの宣言時にstaticを付けて内部結合にするのと似たところがあります.
ここで,メンバー関数からしか呼び出されないメンバー関数というのもあってよいわけです.これも,staticを付けて内部結合にされた関数と似たところがあります.このように,メンバー関数以外からの操作を禁止したり,許可したりするのが「アクセス制御」です.
メンバー以外のオブジェクトや関数は,staticやexternといった記憶クラス指定子を使って,翻訳単位の外部からのアクセスを制御しますが,構造体の外部からのアクセスを制御するには「アクセス指定子」というC++特有のキーワードを使用します.
アクセス指定子には,次の3種類があります.
- public:構造体の外部からでも自由にアクセスできる.
- protected:構造体のメンバー関数,および派生した構造体のメンバー関数からのみアクセスできる.
- private:その構造体のメンバー関数からのみアクセスできる.
このうち,protectedに関しては後ほど詳しく説明します.ここでは,上記のうちpublicとprivateに限って説明することにします.
構造体の場合,アクセス指定子を何も使わなければ,デフォルトでpublicが指定されたのと同じ意味になります.すなわち,メンバー関数だけでなく,まったく関係のないところからでも,自由にメンバーを操作することができるわけです.これに対して,次のようにprivateアクセス指定子を使うと,構造体の宣言が終わるか,他のアクセス指定子が現れるまでの間に記述されたメンバーは,privateということになります.ここでは,valueがprivateなメンバーになっています.
struct A
{
int func(int arg);
private:
int value;
};
このようにすることで,データメンバーvalueは,メンバー関数func以外からアクセスされることがなくなります.A型へのポインタをint*型などにキャストして,むりやりアクセスすることは可能ですが,そうした行為はもちろん反則です*8.
アクセス制御を適切に使用すると,構造体のデータメンバーと,その操作を行うメンバー関数をひとまとめにして管理しやすくなります.データメンバーは,メンバー関数を介してしか操作されませんから,堅牢な設計が可能になるのです.
以上は“原則”です.現実の開発現場では,このアクセス制御の意味を十分に理解していないブログラマが多数存在することも事実です.彼らは,とにかくコンパイルを通し,動作させるという大義名分の前には,躊躇なくアクセス指定子をコメントアウトしたり,記述位置をずらしたりします.このようなアクセス制御の変更は,よほど注意深くやらないと,単に設計上の安全装置を外すだけの暴挙になってしまいます.
*8 staticを付けて内部結合にしても,デバッガやシンボルテーブルで調べて直接アドレスを記述すれば自由にアクセスできてしまいます.それと同じぐらいの反則です.
2.2.5 構造体からクラスへ
これまでは,話を簡単にするために構造体を使って解説してきましたが,C++では,このような言語機能のことを「クラス」と呼びます.少なくともC++においては,構造体とクラスはまったく別物ではなく,構造体はクラスの一種にすぎません.また,共用体もクラスの一種であり,共用体にもコンストラクタ/デストラクタやメンバー関数,それにアクセス指定子などを使用することができます.
オブジェクト指向を少しかじったことのある方なら,「クラス」と聞くとオブジェクト指向の「クラス」のことだと考えるかもしれません.確かにC++のクラスは,オブジェクト指向でいうところのクラスを実現するうえで便利ですが,まったく等価なわけではありません.同様に,C++で「オブジェクト」といえば,オブジェクト指向での「オブジェクト」と等価ではなく,おおむね「変数」と同義です.
C++は,手続き指向プログラミングやオブジェクト指向プログラミングなど,特定のパラダイムをプログラマーに強要するものではありません.必要に応じて,クラスの用途もいろいろと変わってくることでしょう.また,オブジェクト指向で設計するにしても,その設計をC++のコードにマッピングする際に,(オブジェクト指向による)設計上の「クラス」とC++の言語要素である「クラス」を対応させるかどうかは,また別の問題でもあります.
横道にそれましたが,言語機能としての「クラス」の話に戻ります.C++のクラスは,structなどの「クラスキー」の後にタグ名を記述することで宣言を行います.そして,クラスキーには,次の3種類があります.
- struct
- union
- class
このうち,structが付けば「構造体」,unionが付けば「共用体」になります.最後のclassが付いた場合の特別な呼び名はありませんが,classが付いた場合だけが「クラス」ではなく,structやunionが付いた場合でも「クラス」になります.
構造体と共用体の違いは,おおむねCの場合と同様です.ただし,共用体のデータメンバーには,コンストラクタやデストラクタを明示的に定義したクラスなどを含むことができません.共用体は,Cとほぼ同じ単純なデータ型のみデータメンバーとして持つことができます.
classは,基本的にはstructと同じなのですが,構造体のデフォルトのアクセス制御がpublicなのに対し,classの場合はprivateがデフォルトになります.structとclassの違いはその点だけです.もっとも,classはメンバー関数などを備えた本格的な「クラス」の場合に使用し,structはCと同じような構造体にのみ使用することが多いようです.
2.2.6 new演算子とdelete演算子
C++で新しく導入された演算子に,new演算子とdelete演算子があります.これらは,オブジェクトを動的に生成/解体するための演算子です.Cでは,オブジェクトを動的に割り付け/解放するにはmallocとfreeを使うので,new演算子とdelete演算子はmalloc関数とfree関数の置き換えであると誤解する方が少なからずおられるようです.
確かに,new演算子とdelete演算子は,malloc関数とfree関数を使って実装されていることが多いですし,それらと同等の働きをすることもあります.しかし,new演算子とdelete演算子の第一の目的は,メモリブロックの割り付けと解放ではなく,オブジェクトの生成と解体なのです.
では,オブジェクトの生成と解体とは具体的に何でしょうか? それは,コンストラクタやデストラクタの呼び出しにほかなりません.new演算子(とそれに対応するdelete演算子)には,実は複数のものが存在します.その中には,メモリブロックの割り付けを行わないものも存在するのです.
それでは,new演算子とdelete演算子を使った簡単なサンプルを見てみましょう.
struct A
{
A();
A(int a, int b);
~A();
};
A* p = new A(123, 456);
// 何らかの処理
delete p;
このように,new演算子によってA型のオブジェクトを動的に生成し,そのポインタをpに保持させています.解体するときは,delete演算子にオブジェクトへのポインタを渡すことになります.ここで,生成時には,A型のコンストラクタを呼び出すため,2つの引数を指定していることに注目してください.
もし,引数なしのデフォルトコンストラクタを呼び出したいのであれば,次のようにします.
【引数なしのデフォルトコンストラクタの呼び出し方】
A* p = new A;
つまり,引数がないのであれば括弧も不要になります*9.
new演算子とdelete演算子は,クラスだけでなく,int型のような基本型に対しても使用することができます.この場合,次のように,あたかもint型のコンストラクタを呼び出すかのような記述をしてやれば,生成されるint型のオブジェクトを,生成と同時に初期化することができます.これはmalloc関数にはなかった機能です.
【int型に対するnew演算子,delete演算子の使用例】
int* p = new int(123);
delete p;
初期化が必要ないのであれば,次のように記述することもできます.
【int型へのnew演算子の使用例(初期化なし)】
int* p = new int;
ここまでの説明は,配列ではない,単体のオブジェクトの生成と解体のやり方です.配列に対しては,次のように記述します.
【配列に対するnew演算子,delete演算子の使用例】
A* p = new A[10];
delete [] p;
ここでは,new演算子を使ってA型の10要素の配列を生成しています.配列の要素数には定数式以外(つまり,変数など)も指定することができます.なお,配列を生成する場合,引数付きのコンストラクタを呼び出すことはできません.
ここで注目すべきなのは,delete演算子のほうです.単体の場合のdelete演算子とは異なり,「delete []」と記述しています.これは,配列の全要素のデストラクタを呼び出す必要があるからで,ここで間違って通常のdeleteとしてしまうと,もはやまともな動作は期待できなくなります.
単体の場合も配列の場合も,動的に生成されたオブジェクトは単純なポインタで保持するので,後からではそれが単体なのか配列なのか,形式からだけでは判断できなくなります.生成するコードと解体するコードは,なるべく近い位置に記述するか,場合によってはハンガリアン記法*10を使うなどして,変数名から単体なのか配列なのかを区別する工夫をしておいたほうが無難です.
*10 変数名にその変数のタイプを接頭辞として付加する手法です.たとえば,表の行と列をそれぞれint型で表現する場合,行を表す変数にはrという接頭辞を付け,列を表す変数にはcを接頭辞として付けます.あるいは,その変数の型を表す接頭辞を付ける方法もあります.具体的には,int型であればiやnを,ポインタ型であればpを接頭辞として用います.前者(rやcなど)を「アプリケーションハンガリアン」,後者(iやpなど)を「システムハンガリアン」と呼ぶこともあります.
2.2.7 静的メンバー関数
クラスのメンバー関数には,暗黙的にthisを受け取る通常のメンバー関数のほかに,thisを受け取らない「静的メンバー関数」というものがあります.thisを受け取らないということは,特定のオブジェクトを指定する必要がないことを意味しています.つまり,静的メンバー関数は,特定のオブジェクトを操作するのではなく,クラスそのものを操作するためのメンバー関数であるといえます.
静的メンバー関数に対して,普通の(thisを受け取る)メンバー関数のことを「非静的メンバー関数」と呼びます.
静的メンバー関数を定義するには,キーワードstaticを付けます.staticが付くからといって,静的メンバー関数が内部結合になることはありません.
struct A
{
static void func();
};
上記の例では,funcが静的メンバー関数に当たります.関数の定義は,通常のメンバー関数と同様,宣言と一緒に記述することもできれば,分離して記述することもできます.
静的メンバー関数を呼び出すときは,有効範囲解決演算子を用いて,A::func()のように記述します.つまり,A型のオブジェクトを指定する必要がないわけです.そのため,A::funcは,非メンバー関数と同様,void (*)()型の関数へのポインタを使って間接参照することができます.
また,通常のメンバー関数と同様に,次のようにして呼び出すことも可能です.
A a;
a.func();
静的メンバー関数を使うことのメリットは,その関数が属している限定公開部(protected)や非公開部(private)のメンバーに関数の中からアクセスできることにあります.なお,通常のメンバー関数に比べて,thisを受け取らなくてもよい分,静的メンバー関数の呼び出しコストは若干少なくて済みます.
2.2.8 静的データメンバー
メンバー関数に静的メンバー関数があるように,データメンバーにも静的データメンバーというものがあります.静的データメンバーは,クラス型のオブジェクトが保持するメンバーではなく,静的記憶域期間を持つ非局所オブジェクトです.
静的メンバー関数と同様,静的データメンバーもキーワードstaticを付けることで宣言しますが,staticが付くからといって内部結合になるわけではありません.ある意味で,その振る舞いは大域的オブジェクトに近いといえますが,アクセス制御によって非公開(private)や限定公開(protected)にすることができます.
静的データメンバーは,宣言しただけでは実体が生成されません.宣言とは別に定義を行う必要があります.静的データメンバーの定義は原則としてクラスの外で行うことになります.
struct A
{
static int x;
};
int A::x = 123;
ここでは123という初期化子を与えましたが,(const修飾子がなければ)静的データメンバーの初期化子は省略することができます.このあたりは,静的データメンバーではないオブジェクトの定義と同じです.
先ほど,静的データメンバーの定義は「原則として」クラスの外で行うと書きましたが,例外もあります.それは,静的データメンバーが汎整数型のconstオブジェクトの場合です.
struct A
{
static const int x = 123;
};
上記のように,汎整数型のconstオブジェクトの場合に限り,クラスの中で直接初期化子を指定して定義を行うことができます.ただし,規格準拠度の低い古いコンパイラにはこうした記述ができないものも存在するので,その場合は通常どおり,クラスの外で定義を行わなければなりません.
2.2.9 演算子の多重定義
演算子の多重定義(オーバーロード)は,C++ならではの機能の1つです.一時期は“とある”方面から非難を受けた機能ですが,現在ではその有用性が見直され,なくてはならない機能になっています.なお,最近ではC#でも演算子の多重定義ができるということもあり,C++の専売特許ではなくなりました.
演算子の多重定義の具体的な使い方ですが,ストリームに対する入出力のためにシフト演算子を多重定義しているのが代表例のようにいわれることは多いのですが,現在ではそのような使い方はあまりされません.それより,C以来の言語組込み機能をもっと便利にした「スマート○○」の実装に使われることが多くなりました.
具体的には,動的に割り付けたオブジェクトの生存期間の管理を行う「スマートポインタ」などです.std::stringのような文字列クラスも,Cのchar配列による単純な文字列に対して,スマート文字列と呼ぶこともできるでしょうし,std::vectorなどのコンテナはスマート配列と呼ぶことができるでしょう.コンテナの要素を操作するためのポインタの代替は,(スマートポインタと呼ぶと生存期間管理用のものと紛らわしいので)「反復子」となります.また,関数呼び出し演算子を多重定義した関数オブジェクト(ファンクタ)は,スマート関数と呼ぶこともできるでしょう.
このあたりはやや高度な話題になってしまいますが,この場では,演算子の多重定義は無用の長物などではなく,立派な活躍の場があることだけ理解していただければよいかと思います.
C++では,演算子は関数の一種として扱われます.すなわち,演算対象(オペランド)を引数として受け取り,式の評価結果を返却値として返す関数として定義するわけです.そして,演算子関数の定義には,キーワードoperatorを用いて次のように記述します.
【キーワードoperatorを用いた演算子関数(多重定義)の定義例】
T operator+(T lhs, T rhs)
ここでTは型名になります.演算子を多重定義できるのは,少なくとも引数の1つがユーザー定義型(クラス型や列挙型)かその参照でなければなりません.また,いくつかの演算子は,クラスのメンバー関数としてのみ定義することができます.さらに,演算子の中には多重定義できないものもいくつかあります.
上記の例は,加算演算子+を多重定義するためのものです.他の演算子を多重定義するには,+の部分をその演算子に置き換えてください.すなわち,operatorの直後に演算子の記号が続くわけです.ただし,newやdeleteのように,英字で始まる演算子の場合,operatorとの間に空白類を挟むようにしないとコンパイラが字句解析することができなくなります.
【キーワードoperatorを用いた多重定義の例】
void* operator new(std::size_t size)
演算子の多重定義では,演算子の優先順位を変更することはできません.また,もともと存在しない演算子を追加することもできません.たとえば,@や$を演算子にしたり,**を累乗演算子として定義するようなことはできません.^を累乗演算子として多重定義することは可能ですが,優先順位が低いので,混乱のもとになるでしょう.
演算子の多重定義は,演算子ごとに特別なルールがあったりするので,ここで網羅的な説明をすることはできません.必要な方は,C++の入門書やリファレンスを参照してください.
2.2.10 コピーコンストラクタとコピー代入演算子
Cの構造体や共用体では,次のように,同じ型の他のオブジェクトを使って,初期化や代入を行うことができます.
struct A a;
struct A b = a;
struct A c;
c = a;
C++のクラスでも同じことができます.
class A a;
class A b(a); // ← class A b = a;と書いても同じ意味
class A c;
c = a;
この場合,bの定義時には「コピーコンストラクタ」という一種のコンストラクタが,cにaを代入するときには「コピー代入演算子」が呼び出されることになります.コピーコンストラクタもコピー代入演算子も,明示的に定義しなければ,暗黙的に各データメンバーを単純にコピーするものが定義されます.
しかし,クラスの内部で動的にオブジェクトを生成したり,どこかから別のリソースを獲得するような実装になっている場合には,単純にデータメンバーをコピーしただけでは満足のいく振る舞いにはなりません.そんな場合には,明示的にコピーコンストラクタやコピー代入演算子を定義することになります.
class A
{
public:
A(const A& src);
A& operator=(const A& src);
};
このように,コピーコンストラクタもコピー代入演算子も,そのクラスのconst参照を引数として受け取るように定義しなければなりません.そして,コピー代入演算子は自分自身への参照を返さなければなりません.コピー代入演算子の返却型をvoidにするといった実装も可能ですが,よほどの事情がないかぎり,そうした設計は避けるべきです. また,コピーコンストラクタとコピー代入演算子は,それぞれが呼び出される文脈が初期化時と代入時という違いはありますが,当然意味的には同じ振る舞いをするように設計すべきです.
2.2.11 クラスの中で宣言/定義できるもの
ここまでで解説したように,C++のクラスの有効範囲(「クラス有効範囲」といいます)内では,Cの構造体や共用体のようなデータメンバーのほかに,コンストラクタとデストラクタを含むメンバー関数,静的メンバー関数,静的データメンバーを宣言/定義することができます.
それ以外にも,次のような宣言/定義を行うことができます.
- 型(typedef/クラス/列挙体)
- 随伴関数
- 随伴クラス
逆に,クラスの中で宣言/定義できないものとしては,次のものがあります.
- メンバー以外の関数やオブジェクト
- 名前空間
- 名前空間に対するusing指令
それでは,クラスの中で宣言/定義可能なものについて,1つずつ順に見て いきましょう.
型(typedef/クラス/列挙体)
クラスの中では通常どおり型の定義を行うことができます.
class string
{
public:
typedef int size_t;
enum encoding_t
{
shift_jis,
euc_jp,
utf8
};
…
private:
struct data_t
{
char* buffer_;
size_t length_;
};
data_t* data_;
};
たとえば,上記のように,クラスの中で,typedef名を定義したり,列挙体を定義したり,クラスを入れ子にすることができるわけです.これらの型名や列挙定数をクラス有効範囲の外から参照するには,「1.3.5 名前の衝突を防ぐ「名前空間」」で説明した有効範囲解決演算子を用いて,string::size_tとかstring::shift_jisとします.
ところで,Cでも構造体や共用体は入れ子にすることができました.しかし,Cではクラス有効範囲という概念がありませんでしたので,入れ子に定義した構造体であっても,その型は入れ子にせずに定義したものと同じ意味になります.
たとえば,次のコードをみてください.
struct A
{
struct B
{
int value;
} b;
};
上記のように記述しても,struct B型はstruct Aの外で定義した場合と同じになります.すなわち,上記のコードは下記と等価になります.
struct B
{
int value;
};
struct A
{
struct B b;
};
しかし,C++では,入れ子に定義したクラスは,クラスの外で定義したクラスとは別の型になります.これもCとC++の非互換性ですので注意が必要です.
随伴関数(フレンド関数)
通常,クラスの限定公開部(protected)や非公開部(private)のメンバーにアクセスできるのは,そのクラスのメンバー関数だけです*11.
しかし,friend指定子を用いて「随伴関数(フレンド関数)に指定すれば,制限なくクラスのメンバーにアクセスすることができるようになります.随伴関数に指定するには次のように記述します.
class A
{
private:
int a;
friend int func(A& arg);
};
int func(A& arg)
{
return arg.a; // ← 非公開部(private)のメンバーにアクセス可能
}
随伴関数は,次のように,クラスの中で直接定義することも可能ですが,随伴関数そのものはメンバー関数ではなく,通常の関数です.クラス有効範囲の中にあるのは,あくまでもその関数が随伴関数として宣言をするためです.
class A
{
private:
int a;
friend int func(A& arg)
{
return arg.a; // ← 非公開部(private)のメンバーにアクセス可能 private)のメンバーにアクセス可能
}
};
随伴関数の宣言は,公開部(public)で行っても,限定公開部(protected)や非公開部(private)で行っても同じです.メンバー関数のように,随伴関数のアクセス指定を行うことはできません.
随伴関数は便利ですが,クラス設計においては裏技的に用いられる仕様です.どうしても必要な場合を除き,極力使用しないほうが無難です.
随伴クラス(フレンドクラス)
「随伴クラス」は,随伴関数のクラス版です.個々の関数ではなく,1つのクラスを丸ごと随伴に指定するためのものです.なお,クラスの特定のメンバー関数だけを随伴関数にすることはできませんので,そのような必要が生じた場合は,クラス全体を随伴クラスにするか,いったん随伴関数を用意して,それを目当てのメンバー関数から呼び出すようにしなければなりません.
随伴クラスは次のように記述します.
class B
{
public:
int g();
};
class A
{
public:
int f();
friend class B;
};
随伴関数と同様,次のように,随伴クラスに指定すると同時にクラスの定義を行うことも可能です.ただし,随伴関数とは異なり,このように記述した場合にはクラスBは入れ子の関数と見なされます.すなわち,Aのクラス有効範囲にあるA::Bとなるのです.
class A
{
public:
int f();
friend class B
{
public:
int g();
};
};
また,クラスBがクラスAの随伴クラスだからといって,クラスAもクラスBの随伴クラスになるわけではありません.さらに,クラスBがクラスCを随伴クラスに指定したとしても,クラスCはクラスAの随伴クラスになるわけではありません.C++における「お友達」(friend)は,片思いが原則であり,友達の友達は知らない人であるのが原則なのです.
2.2.12 クラステンプレート
関数のテンプレートについては第1章で解説しましたが,クラスもテンプレートにすることができます.Cの構造体でも,型や定数が異なるだけで,コードはまったく同じものを複数定義しなければならない状況というのを経験された方もおられると思います.C++のクラスでも,やはりそうした状況は多々あります.
第1章では,関数テンプレートの解説のために,複素数を表すためのcomplex構造体を例にしましたので,ここでも複素数クラスを例にすることにしましょう.
第1章で例にしたのは,次のような複素数型でした.
struct complex
{
dottoouble real;
double imag;
};
しかし,複素数の実部と虚部をdouble型に決め打ちにするのはあまり望ましくないでしょう.精度的にfloat型でよい場合も多々ありますし,逆にlong double型でなければならないこともあります.あるいは整数型で十分なことさえあるでしょう.そのような場合,次のようにすれば,double型だけでなく,float型やlong double型,long型,あるいは自分で定義した多倍長演算クラスなども指定することが可能になります.
template <typename T>
struct complex
{
T real;
T imag;
};
このようなクラステンプレートを使用するには,complex
template <typename T>
T abs(complex<T> arg)
{
return sqrt(arg.real * arg.real + arg.imag * arg.imag);
}
さらに,この複素数型の場合,complex
template <typename T>
struct complex
{
T real;
T imag;
complex(T r = T(0), T i = T(0))
: real(r), imag(i)
{
}
template <typename U>
complex(const complex<U>& src)
: real(src.real), imag(src.imag)
{
}
template <typename U>
complex& operator=(const complex<U>& src)
{
real = src.real;
imag = src.imag;
return *this;
}
};
このように,クラスのメンバー関数もテンプレートにすることができます.これを「メンバー関数テンプレート」といいます.メンバー関数テンプレートはC++規格でサポートされる仕様ですが,古いコンパイラの場合には完全に対応していない場合もあるので注意が必要です.