まえがき
この記事は投稿者(NokonoKotlin)の個人サイトの記事から Qiita 用に移植 & 加筆したものです。
(この文言は記事が剽窃でないことを周知するためのものであり、個人サイトの宣伝ではないことをあらかじめご了承ください。)
はじめに
C++ といえば巷では難しい言語であると囁かれている印象です。この度、実際に C++ を少し勉強してみて、その理由がわかった気がします。またそれと同時に、C++ の面白さも改めて気付かされたのであるヨ。
ところで、世の中のプログラミング学習者は、一体どの様にして言語を学習しているのでしょうか。ぼくどんに言わせてみれば、入門用参考書は冗長すぎで、それなりの難易度の参考書は端折りすぎであると大声で枕に叫びたい気持ちなのです。
この記事は、自分の学習記録 & 備忘録を兼ねて、自分の感性でちょうど良いボリュームかつ、最低限の C++ 理解を得られる程度のレベルを意識して書きました。実際この記事には、ぼくどんが参考書を読み進めていく中で疑問に思ったことなどを補足して書いてあるので、初心者が単独で参考書を読むよりは (ぼくどんと同じであれば) すんなりと読んでいけるのではないでしょうか。
さて、「誰でも絶対に理解できる」は半ばタイトル詐欺です。ちゃんと集中して読んだ人でなければ、C++ を理解することはできません。甘い話はないのです。とはいえ、C++ は難しいからという理由で C++ を選ばないのは勿体無いことだと思うので、完全理解までは行かずとも、単純に読み物として楽しんでいただけると幸いなのであるよ。
目次
-
基本事項おさらい
-
変数/識別子
- 宣言と定義
- 参照
-
メモリ上の領域
- 静的領域
- スタック
- ヒープ
- 参照渡し
- オブジェクトの寿命
-
変数/識別子
-
オブジェクト指向
-
定義
- メンバ
- コンストラクタ
- デストラクタ
- コピー
- 右辺値参照とムーブ
- 二つの初期化子リスト
- オブジェクトの初期化と代入
- const オブジェクト
- 継承
-
ポリモーフィズム
- ポインタを使おう
- ポインタの使用を強制しよう
- virtual なメンバ関数
-
演算子オーバーロード
- 複合代入演算
- 二項演算
-
キャスト
- 変換コンストラクタ
- キャスト演算
- 明示的か暗黙的か
-
定義
-
名前解決
- 名前空間を定義しよう
- 関数をオーバーロードしよう
-
テンプレート
-
テンプレートの基礎
- 関数を抽象化してみよう
- テンプレートの展開
- クラステンプレートで確かめる実体化の様子
- Two Phase Name Lookup (テンプレートの名前解決)
-
modint で学ぶテンプレートソリューション
- コンパイラによる最適化
- 定義内でのテンプレートパラメータの省略
- メンバ関数テンプレート
- テンプレートの罠
- 危険で無意味なハック
-
テンプレートの基礎
- 例外処理
- おわりに
1. 基本事項おさらい
1.1 変数/識別子
C++ に限らず、プログラムというのはコンピュータのメモリ (RAM) 上の領域を書き換える/読み込むことで動作します。
メモリ領域上での位置をアドレスと呼び、例えば上の図ではこれから実行するプログラムに物理メモリの $\mathrm{0x02}$ から $\mathrm{0x0B}$ までの領域が割り当てられています。
基本的にプログラムの実行はメモリ上でのアドレスの指定、アドレス内の要素の読み書きの処理で行われます。よってプログラムを実行するには、コンピュータにメモリ上での読み書きを指示する必要があります。
ところが、以下のソースコード (自前定義) を見るに、そのような指示がされている様には見えません。
int_4 y = 12;
std::cout << y << std::endl;
int_4& z = y;
なぜなら、メモリ上の操作を人間が指示するのはあまりに大変なため、C++ の文法はメモリ上の具体的なアドレスを、プログラマがソースコード中で識別子 (名前) を与えた変数に割り振ることで、人間が理解しづらい部分を違和感なく隠蔽しているのです。
1.1.1 宣言と定義
宣言とは、ある識別子を使用することを宣言する命令のことです。例えば関数の宣言は以下のように書きます。
// 型名 識別子(パラメータの型リスト);
int multiply(int , int);
これで、$2$ つの int を受け取り、int を返す関数の名前として、multiply
が使われていることが宣言されました。
また、実際に関数を使用するためには、識別子を有効にするだけではなく、その識別子が指す関数の中身を定義する必要があります。
// 宣言
int multiply(int , int);
// 定義
int multiply(int a, int b){
return a*b;
}
1.1.2 参照
int_4 y = 12;
という命令では、y
という識別子が int_4
型 ($4$ bit整数とする) として、メモリ上のアドレスと自動で対応づけられます。
変数 y
に割り振られたアドレスを $\mathrm{0x03}$ とすると、はじめに示したソースコードは以下の様に読み替えることができます。
-
int_4 y = 12;
$\rightarrow$ メモリ上のアドレス $\mathrm{0x03}$ から $4$ ビットを、$4$ ビット整数 $12$ に書き換える。 -
std::cout << y << std::endl;
$\rightarrow$ メモリ上のアドレス $\mathrm{0x03}$ から $4$ ビットを読み込み、出力する (出力の命令の詳細は割愛)。 -
int_4& z = y;
$\rightarrow$ 識別子z
に対応するアドレスをy
と同じものとする。
つまり変数というのは人間が理解しづらい部分を記号にしているだけで、メモリ上のアドレスと対応しているという意味では、実は皆さんが恐れているポインタと変わりはないのです。
ただし、ソースコード内でメモリを操作するポインタ p
と異なり、変数による参照は後から参照先のアドレスを変更することはできません。
1.2 メモリ上の領域
プログラムで使用されるメモリは、その用途によって以下の $3$ 種類に大別されます。
- 静的領域 : プログラムの動作に依存しない値を保存
- スタック : ローカルなオブジェクトを保存
- ヒープ : プログラムを通して生存するオブジェクトを保存
1.2.1 静的領域
プログラムの動作に依存しない値は、宣言&定義の直前に static
と書くことで、static
な変数として定義することができます。static
修飾された変数はヒープやスタックではなく静的領域上に置かれます。static な変数についてはここでは詳しく書きません。
1.2.2 スタック
{ ... }
で囲まれた部分をスコープとよびます。スコープ内で生成されたオブジェクトはスタック領域上に生成され、処理がそのスコープを抜けるとき ( .. }
に到達した時 ) に破棄されます。
1.2.3 ヒープ
ヒープ領域上に生成されたオブジェクトはプログラムを通して生存します。
先に述べた通り、スコープ内で生成されたオブジェクトは、そのスコープを抜ける際に破棄されます。スコープを抜けても破棄されないオブジェクトをスコープ内で生成したい場合、自分で直接ヒープ上の領域を確保し、そこに直接オブジェクトを生成する必要があります。
T* p = new T()
と書くことで T
型のオブジェクトをヒープ領域上に生成します。このとき、T
型のポインタ p
は、T
型オブジェクトが生成されたアドレスを指します。また、delete p
と書くことでポインタ p
に格納されたアドレス上のオブジェクトを破壊してメモリを解放します。ただし、ポインタ p
が配列の先頭を指している場合は delete [] p
でメモリを解放する必要があります。
1.3. 参照渡し
int_4 w = y
という命令は、変数 w
に対応するアドレスに「y
が指すオブジェクトの複製 (コピー)」を書き込むという命令です。y
は $\mathrm{0x03}$ を指していますが、この命令では y
とは別のアドレスに、y
に格納されているオブジェクトを複製しています。
それに対し、int_4& z = y;
のように変数名の前に &
をつけて命令することで、y
が指すアドレスと同じアドレスを参照する変数 z
を作ることができます。
std::vector
のように、複製のコストが重いものを複製したい場合には、そのオブジェクトのメモリ上のアドレスを参照する変数を増やすことで複製のコストを省略できます。ただし、増やした変数 z
の中身を書き換えると当然 y
の中身も変わるので一層注意が必要です。
1.4 オブジェクトの寿命
スタック領域に生成したオブジェクトはいつか破棄されます。よって、そのようなオブジェクトの参照は、そのオブジェクトが破棄される前に無効化されるべきです。たとえば以下のプログラムを考えましょう。
int* p;
void make_int(int i){
p = &i;
}
int& refer_int(int i){
int& r = i;
return r;
}
int main(){
make_int(12);
int& r = refer_int(15);
std::cout << "中身チェック : " << (*p) << " : " << r <<std::endl;
}
ちなみに、この例は後述する右辺値参照と関連しており、1.2.2 項で述べたオブジェクトの寿命を単純に当てはめた場合とは異なります。
以下は自分の環境での出力です。
中身チェック : 1 : 1
$12$ と $15$ を参照しているはずなのに、出力は $1$ と $1$ でした。これは、オブジェクトの寿命切れによって参照先のオブジェクトが破棄されてしまったからです。寿命が切れたオブジェクトにはアクセスしないように意識しましょう。
また、変数を自身から見て自身よりもローカルなスコープで別の変数に参照渡しした場合、参照渡しをした変数の寿命は、元の変数が指し示すオブジェクトの寿命に影響しません。
int I = 998244353;
if(I > 0){
int& ref = I; // I の参照をコピー
} // ref の有効なスコープが終了
// I の中身 998244353 はまだ生存中
printf("%d\n" , I);
2 オブジェクト指向
2.1 定義
C++ にはクラスというものがあります。クラスとは、考えたい対象を機能や性質ごとに分類しておくといったお気持ちです。先ほどから使っていたオブジェクトという単語は、あるクラスに則して生成されたひとまとまりのデータを指します。
例えば二次元平面上の点は全て $(x,y)$ 座標という共通のデータを持つので、以下のように Point クラスとして定義することで、よりわかりやすいコードを記述することができます。
class Point{
protected:
// (メンバ変数)
double x;
double y;
double rad_ = 1e200; // 横軸とのなす角 (データ)
public:
// コンストラクタ
Point() : x(0) , y(0) {}
Point(double x_, double y_) : x(x_) , y(y_) // 初期化子リストでデータの初期化
{
if(x != 0)this->rad_ = atan2(y/x);
}
// デストラクタ
~Point(){}
// コピーコンストラクタ
Point(const Point& p){
assert(this != &p);
this->x = p.x;
this->y = p.y;
this->rad_ = p.rad_;
}
// コピー代入演算
Point& operator=(const Point& p){
if(this == &p)return (*this);
this->x = p.x;
this->y = p.y;
this->rad_ = p.rad_;
return (*this);
}
// ムーブコンストラクタ
Point(Point&& p) = default;
// ムーブ代入演算
Point& operator=(Point&& p) = default;
// 横軸とのなす角 (インターフェース)
double rad(){
if(x != 0)this->rad_ = atan2(y/x);
return this->rad_;
}
};
詳細は後で説明しますが、Point A(1,2) , B(5,5) ;
のように書くことで、Point
クラスのオブジェクトを宣言&定義できます。A
と B
はそれぞれ異なる $2$ 点 $(1,2)$ と $(5,5)$ を表していますが、これらはいずれも Point
型オブジェクトの定義通りに $(x,y)$ 座標のデータをもつ同種の存在であると言えます。
2.1.1 メンバ
クラス内で定義される変数や関数を、そのクラスのメンバと言います。上の例では変数 x , y , rad_
や関数 rad()
などがメンバです。例えば、オブジェクト A
の rad()
関数を呼びたい場合は、A
の後ろに .
で続けて A.rad()
のようにして呼び出すことができます。ただし、protected
や private
なメンバは、クラスの定義の外からアクセスすることができません (ここでは詳細は割愛)。
オブジェクト A
から A.rad()
を呼び出すと、A
のデータ $(1,2)$ をもとに関数が実行されます。当然ですが、オブジェクト B
から B.rad()
を呼び出すと、B
のデータ $(5,5)$ をもとに関数が実行されます。
また this
というキーワードは 自分自身を参照するポインタです。クラスの定義でメンバにアクセスする場合は this->
を用いてアクセスします。ただし、省略してもいいです。
2.1.2 コンストラクタ
あるクラスのオブジェクトを生成する際はデータの初期化を行う必要があります。初期化を定義する機能をコンストラクタといい、クラス名と同じ名前で宣言します。初期化時に渡すパラメータに応じて、複数種類のコンストラクタを書くこともできます。
// パラメータを取らないコンストラクタ
Point() : x(0) , y(0) { /*メンバへの代入などの実装*/ }
// 座標をパラメータにとるコンストラクタ
Point(double x_, double y_) : x(x_) , y(y_) { /*メンバへの代入などの実装*/ }
コンストラクタを呼び出す際は、基本的に Point A(1,2);
のように クラス名 変数名(パラメータリスト);
の構文で呼び出しますが、さまざまな亜種が存在します。例えば、Point A = {1,2};
や Point A{1,2};
のように、波括弧でパラメータリストを囲うことで、そのパラメータリストに対応したコンストラクタが呼び出される場合があります。「場合」とあるように、以下の状況ではその限りではありません。
- 波括弧の中身に対応するパラメータをとるコンストラクタがない場合
- `std::initializer_list` を受け取るコンストラクタが存在する場合
std::initializer_list
については後述します (2.1.6 項)。
2.1.3 デストラクタ
オブジェクトが宣言されたスコープを抜ける時、そのオブジェクトは破棄されます (1.2.2 項参照)。デストラクタはその際の処理を定義します。デストラクタは ~
(チルダ) の後ろにクラス名を書いて宣言します。
~Point(){ /*メンバを破棄する実装*/ }
2.1.4 コピー
オブジェクトの複製 (コピー) を定義する方法は二種類存在します。
まず $1$ つ目は、以下のように同じクラスのオブジェクトの const 参照を受け取るコンストラクタ (コピーコンストラクタ) を定義することです。ただし、コピーコンストラクタによる初期化は代入ではなく初期化です。つまり、const なオブジェクトの初期化でコピーコンストラクタを使うことができます (後述)。
Point(const Point& p){ /*メンバのコピーの実装*/ }
$2$ つ目は、後述する演算子オーバーロードを用いて、同じクラスの const 参照をパラメータにとる代入演算子 =
(コピー代入演算子) を定義することです。
Point& operator=(const Point& p){ /*メンバのコピーの実装*/ }
代入演算は自分自身の参照を返すように実装するのが典型的です。こうすることで、C++ の int のように A = (B = C)
と代入を結合することができます。ちなみに、A = B = C
と書いてもプログラムは A = (B = C)
と同じ動作をします。これは、自前定義した演算も言語の組み込み演算子 (int
の a = b*2
など) と同じ結合規則を持つと決められているからです。
また、コピーは自分自身をコピーする場合に気をつけて実装する必要があります。例えば、Point
のコピー代入演算では、パラメータ p
のアドレス &p
が this
(自身) を参照している場合、何もせず終了するようしています。
最後に、クラスがクラス外のリソースにアクセスする場合は、コピーを禁止するべきです。例えばメンバに外部へのポインタをもつオブジェクトが複製されてしまうと、リソースにアクセスするオブジェクトは増えたのに、リソース自体は増えないという状況になります。
このような場合、Point(const Point& p) = delete;
と書いたり、コピーを private
なメンバにしておくことで、コピーを禁止することができます。
2.1.5 右辺値参照とムーブ
変数のようにメモリ上のデータを参照しているものを左辺値と呼ぶのに対して、Point(1,2)
のように自分自身がデータであるものを右辺値ないし一時オブジェクト、あるいは単に値と呼びます。
前の項で説明したコピー機能の定義では、コピーのパラメータとして自身と同じクラスの const 参照を受け取ります。ここで、パラメータが参照を受け取るので、以下のような右辺値の代入はできないように思えます。
Point A;
A = Point(5,9); // コピー代入演算
しかし、実際は右辺値参照という機能のおかげで cosnt 参照は右辺値を参照することができます。つまり、上記のコードは有効なプログラムです。
右辺値参照によって、一時オブジェクトの寿命は、その一時オブジェクトを参照する変数の寿命が切れるタイミング以降まで先延ばしにされます。例えばコピー演算のパラメータ const Point& p
が Point(1,2)
を受け取る場合、変数 p
の寿命までは Point(1,2)
が破棄されないことが保証されます。こうすることで、変数がその一時オブジェクトを参照して良いことが保証されるのです (参照なので一時オブジェクトの複製は行われません)。
なお、右辺値を参照する方法は先述の const 参照を含めて $2$ 種類存在します。
-
const Point&
のように const 参照する -
Point&&
のように&
を $2$ つ書く
これら $2$ つの方法どちらも有効なとき、右辺値を参照する場合は $2$ の方法が優先されます (後述する「ムーブ」が区別される)。
ところで、なぜ方法が $2$ つ存在するのでしょうか。左辺値参照も一時オブジェクトも受け取れる const 参照だけではダメなのでしょうか。その理由は、コピーとは別に右辺値のムーブを定義したかったからです。
Point
クラスのコピーの実装を見ると、パラメータ p
のデータをメンバに複製しています。
もしパラメータ p
が右辺値を参照しているなら、右辺値は一時的な用途しかないので、p
のデータを複製するのではなく p
のデータの参照と自身のデータの参照 swap することで複製のコストをなくすことができます。例えば、C++ の STL コンテナは、そのデータサイズに関わらず $O(1)$ 時間の swap が定義されています。
そこで、上記の $2$ の方法で 右辺値のみを
パラメータとして受け取る、ムーブコンストラクタとムーブ代入演算を宣言します。ムーブは特別な事情がない限りコンパイラによるデフォルトの実装に任せておいても大丈夫です。
// ムーブコンストラクタ
Point(Point&& p) = default;
// ムーブ代入演算
Point& operator=(Point&& p) = default;
右辺の一時オブジェクトの中身は代入以降どのようになっていても良いので、ムーブでは右辺を複製せず、代入先のメンバの参照と p
のメンバの参照を swap
してすげ変えるなど、複製を回避する工夫を行えます。
なお、右辺値参照とムーブはどちらも右辺値を受け取ることができますが、右辺値参照が C++ の言語機能であることに対して、ムーブはクラスごとにプログラマが定義する機能です。よって右辺値参照とムーブは別の概念であることに注意してください。
例えば Point A = std::move(B);
では、変数 B
を一時オブジェクトとして扱うことで B
を受け取るムーブが呼び出され、B
の中身が変数 A
に移譲されます。この結果、B
の中身は未定義になります。
対して、Point&& A = std::move(B);
と書いた場合、B
を一時オブジェクトとして扱うことで言語機能の右辺値参照が働き、変数 A
が一時オブジェクト std::move(B);
を参照します。この場合、A
は B
の中身を参照するだけなので、ただの参照渡しと同じ結果になります。これ以降 A
は単なる左辺値として扱われます。
2.1.6 二つの初期化子リスト
コンストラクタの後ろに :
で続けて記述した部分を初期化子リストと言います。ここではメンバのコンストラクタを呼び出して、メンバの初期化を行うことができます。例えば Point(x_,y_)
コンストラクタでは、Point
コンストラクタの処理が行われる前に、:
以降の部分でメンバ x
と y
のコンストラクタ x(x_)
, y(y_)
が呼び出されます。
初期化子はメンバを初期化するので、代入が禁止されているメンバ (const など) の初期化で使います (初期化は代入とは別です)。このとき、:
以降の部分で初期化されるメンバの順番は、クラス内で宣言されたのが早い順であることに注意が必要です。
// 引数がなければ座標は原点とする
Point(double x_, double y_) : x(x_) , y(y_) {} // メンバのコンストラクタを呼んでいる
そしてなんとびっくり、初期化子リストは別の概念を指す場合があります。それは、C++ のコンテナである std::initializer_list<T>
です。<T>
はテンプレートパラメータです ($4$ 章で説明します)。
std::initializer_list<T>
は T
型オブジェクトを複数個保存するデータ構造です。このリストを受け取るコンストラクタを定義することで、波括弧 {}
によるサイズに制限が無いパラメータリストを受け取る初期化を行うことができます。
// メンバは double なので、double の初期化子リストを受け取る
Point(std::initializer_list<double> iList){
assert(iList.size() <= 3); // 今回のデータメンバは 3 つしか無いので、サイズが 4 以上はエラ-とする
// C++ スタイルの実装をしますが、気にしないでください
std::initializer_list<double>::const_iterator itr = iList.begin();
if(itr == iList.end())return;
// iList に要素が 1 つ以上あるので、x に代入する
this->x = *(itr++);
if(itr == iList.end())return;
// iList に要素が 1 つ以上あるので、y に代入する
this->y = *(itr++);
if(itr == iList.end())return;
// iList に要素が 1 つ以上あるので、rad_ に代入する
this->rad_ = *(itr++);
return;
}
2.1.2 項で述べた通り、std::initializer_list
を受け取るコンストラクタを作ってしまうと、波括弧 {}
でパラメータリストを囲って、そのパラメータリストと対応するコンストラクタを呼び出すことができなくなります。ただし、{}
の中身が空の場合はデフォルトコンストラクタが呼ばれることに注意してください。
Point a = {-1 , 0 , 3.14}; // rad_ まで定義される (initializer_list)
Point b{}; // (デフォルトコンストラクタ)
Point c{2,3}; // y まで定義される (initializer_list)
Point d(2,2); // コンストラクタ
Point e = {}; // デフォルトコンストラクタ
2.1.7 オブジェクトの初期化/代入
C++ の組み込み型 (int など) と同様に、クラス名を使って以下の様にオブジェクトを宣言できます。また、コンストラクタに応じたパラメータを渡すか、=
でコピー初期化を行うことでオブジェクトの初期化を行うことができます。コピー初期化は代入ではなく、あくまで初期化であることに注意してください。
// コンストラクタ呼び出し
Point default_point;
Point A(0,0);
// コピーコンストラクタによる初期化 (代入ではない)
Point B = A;
// 不思議シリーズ
Point C = Point(2,1);// コピーでもムーブでもないかもしれない。キャストの節で紹介
// コピー
B = A;
// ムーブ
A = std::move(C);// 以降、C の中身は未定義
// 右辺値参照
Point&& D = std::move(A);// 変数 A が参照するアドレスを D にコピー!!!
これまでの話から概ね理解ができる文法ですが、$1$ つだけ謎な初期化が含まれていますね。
Point C = Point(2,1);
これは何でしょうか。これまでの話を踏まえると、右辺の Point(2,1)
が一時オブジェクトを生成して、それを左辺 C
のコピーかムーブのコンストラクタが受け取ると考えるでしょう。
しかし実際は、この初期化ではコンパイラによる最適化が働く場合があり、ムーブコンストラクタは呼ばれず、コピーコンストラクタの呼び出しさえ省略される可能性があります。また、この命令がどのような挙動になるかはコードを見ただけでは不明です。
なお、コンパイラの最適化によってコピーやムーブが呼ばれなくても、左辺 C
はちゃんと右辺 Point(2,1)
を参照するようになります。
2.1.8 不要な機能の制限
デストラクタ、コピーコンストラクタ、コピー代入演算子、ムーブコンストラクタ、ムーブ代入演算子の $5$ つは、自分で宣言&定義しなければ、コンパイラが勝手に用意してくれる場合があります。
よって、自前定義のもの以外を禁止したい場合は、以下のように関数を delete
して明示的に禁止する必要があります。
// コピーコンストラクタを禁止する場合
Point(const Point& p) = delete;
2.2 const オブジェクト
プログラムを通して初期化以外で変更がないオブジェクトを const
なオブジェクトと呼びます。不変であることがわかっているオブジェクトには const
修飾子をつけて宣言を行うことで、コンパイラによる最適化やバグの抑止に繋がります。
プログラムを通して、const オブジェクトの const 性はプログラマが明示的に担保する必要があります。例えば、車を表す const なオブジェクト Car
と、車の重さを表すメンバ関数 weight()
があるとします。
class Car{
private:
double w;
public:
Car(double w_):w(w_){}
double weight(){
return this->w;
}
}
int main(){
const Car Demio(1020.0);// 初期化
// コンパイルエラー : const オブジェクトに対して 非 const メンバ関数の呼び出し
std::cout << Demio.weight() << std::endl;
}
weight()
関数は Car
クラスの中身を変更することはなく、const 性を損ねないので const な Car
オブジェクトに対する処理として適当に思えます。しかし、weight()
関数が Car
の中身を変更しないかどうかというのは、コンパイラの知るところではないので、このプログラムは const 性を損なうプログラムとしてコンパイルエラーになります。
要するに、中身を変更しないことが明示的に担保された weight()
関数をプログラマが用意しておく必要があります。クラスのメンバ関数宣言の後ろに const 修飾子を付けることで、const なメンバ関数として宣言することができます。
double weight() const {
return this->w;
}
2.3 クラスの継承
C++ のクラスには継承という概念があります。ベースとなるクラスを基底クラスと呼び、基底クラスの性質を継承したクラスを派生クラスと呼びます。例えばグラフ理論における木を表すクラスを考えてみます。
Tips : 木構造とは
番号が割り振られた抽象的な点を線で結んだ構造をグラフと言います。このとき、点のことを頂点と言い、点同士を結ぶ線のことを辺と言います。特に、どの $2$ 点間の経路も一意に定まるグラフを木と言い、ある点が他の全ての点と隣接している木をスターと言います。
ここで、整数のペアを辺として列挙することでグラフを表現できるので、木構造のクラスは以下の様に定義できます。
class Tree{
private:
int V; // 頂点数
vector<pair<int,int>> E; // 辺集合
public:
Tree(){}
// テスト用
void identify(){
printf("this is Tree\n");
}
void identify(int x){
printf("this is Tree # %d\n" , x);
}
};
このように宣言した Tree クラスは一般の木構造を表現するクラスです。しかし実際のところ木構造にもさまざまな種類があり、例えば特殊な木構造の例として、先述のスターがあります。この様な特殊なインスタンスを表現するときに、例えば Tree クラスから派生した Star クラスを以下のように宣言します。
class Star : public Tree{
private:
int center; // スターの中心の頂点の番号
public:
Star(){}
void identify(){
printf("this is Star\n");
}
};
このとき Star クラスは Tree クラスのメンバを持ちながら Star クラス特有のメンバを追加で持つことができます。例えば int center;
という記述は Tree クラスにはない、Star 特有の記述です。
では、Tree(基底クラス) と Star(派生クラス) に同一のメンバ関数が存在するとどうなるでしょうか。
Tree と Star を確認するとどちらにも identify()
という関数があります。お察しかもしれませんが、Tree オブジェクトから identify()
を呼び出すと Tree クラスの identify()
が呼び出され、Star オブジェクトから identify()
を呼び出すと Star クラスの identify()
が呼び出されます。
派生クラスで同名の関数を宣言した場合、基底クラスの同名の関数は隠蔽され、派生クラスを通してその関数にアクセスされなくなります。これを hiding(ハイディング , 隠蔽) と言います。
隠蔽は基底クラスの同名の関数全てに対して行われます。Star に identify()
を宣言したなら、Tree の identify(), identify(int x)
はともに隠蔽されます。
2.4 ポリモーフィズム
NokonoKotlin (執筆者) はアルゴリズムの勉強をしているので Star は一般の Tree クラスと比べて特殊なインスタンスだとわかりますが、一般の人が Tree と Star に大きな違いを見出す保証はありません。普通の人にはどれも全て同じ木構造に見えるのです。
共通のベース (基底クラス) を持ちつつ、さまざまな属性 (派生クラス) に分岐するようなクラスを記述するとき、それら全てに基底クラスのポインタを介してアクセスできるような設計をします。このような思想をポリモーフィズムと言います。
2.4.1 ポインタを使おう
基底クラスのポインタは、派生クラスの領域を指すこともできます。例えば、Star は Tree の派生クラスなので以下のように記述してもプログラムは正常に動作します。
Tree* ptr = new Star();
このことを利用し、基底または派生クラスのオブジェクトをヒープ領域に生成し、そのアドレスを指すポインタを返す関数を作ります。この関数は、生成するオブジェクトの種類に関わらず基底クラスのポインタを返すようにします。
Tree* makeTree(){
return new Tree();
}
Tree* makeStar(){
return new Star();
}
Tree* makeBinaryTree(){
return new BinaryTree();
}
2.4.2 ポインタの使用を強制しよう
ヒープ上のオブジェクトにポインタを介してアクセスすることの利点として、オブジェクトの破棄のタイミングを自由に決定できることなどがあります。このように、メモリの効率的な管理の観点から、前項で紹介した関数だけにオブジェクトの生成を許可することで、ポインタ以外でのオブジェクトへのアクセスを禁止したい場合があります。
そこで、コンストラクタを private
にしてクラス外からのコンストラクタの呼び出しを制限します。
class Star : public Tree{
private:
int center; // スターの中心の頂点
Star(){}
public:
// 外部で定義する makeStar 関数に private メンバへのアクセスを許可
friend Tree* makeStar();
};
これで、Star の生成を制限することができました。
しかしクラス外でのコンストラクタ呼び出しを一切禁止してしまうと、makeStar()
関数でのオブジェクト生成ができなくなるので、Star
クラス内で makeStar()
関数を friend
宣言しました。friend
宣言しておくことで、makeStar()
関数に Star
クラスの private
なメンバへのアクセスを与えます。
関数の friend
宣言をする場合、アクセスを許可したい関数の宣言を、先頭に friend
修飾をつけてクラスの内部に記述します。また、関数宣言の後に直接定義を書くこともできます。
class Star : public Tree{
private:
int center; // スターの中心の頂点
Star(){}
public:
// 定義を書いても OK
friend Tree* makeStar(){
return new Star();
}
};
ただし friend
な関数をクラス内で定義していても、この関数はクラスのメンバではありません。よって関数呼び出しの際は、単に Tree* p = makeStar();
のように通常の関数スタイルで呼び出します。
2.4.3 virtual なメンバ関数
関数宣言の頭に virtual と付けた関数を仮想関数と言います。
class Tree{
virtual void identify(){
printf("this is Tree\n");
}
}
基底クラスと派生クラスに同名の関数が存在するとき、基底クラスの関数は隠蔽によって派生クラスからアクセスできなくなると説明しました。では、基底クラスのポインタで派生クラスのオブジェクトにアクセスする場合はどうなるのでしょうか。これを Tree クラスと Star クラスの両方で宣言した identify()
関数の例で考えます。
結論から言うと、通常の関数であれば、どちらが呼ばれるかはポインタの型に依存します。つまり、Star*
で Star オブジェクトにアクセスしているならば当然 Star.identify()
が呼ばれ、Tree*
で Star オブジェクトにアクセスしているならば Tree.identify()
が呼ばれます。しかし、この挙動は期待される挙動ではないため、Tree*
からでも Star.identify()
が呼ばれるようにしたいです。
そこで、基底クラス (Tree) で宣言する identify()
の頭に virtual 修飾子をつけることで解決できます。こうすることで、呼ばれる関数は実際に生成されたオブジェクトに依存するようになるので、Tree*
でアクセスしていようと、Star オブジェクトにアクセスしていれば Star::identify()
が呼ばれます (この表記は、Star
の後に ::
をつけることで、Star
のスコープで宣言された関数であることを明示している)。
2.5 演算子オーバーロード
クラスの特殊なメンバ関数として、C++ の演算子 + , *= , / , &&
などと同名の関数を宣言,定義することを演算子オーバーロードと言います。通常のメンバ関数の宣言に対して、演算子の宣言では関数名(演算子)の直前に operator
をつけて宣言します。
例として $\mathrm{mod}$ $131$ の剰余環 $M$ に属する整数のクラス modint131
を自作する場合を考えます。
- $\mod{131}$ の剰余環では、整数 $x$ の代わりに $x \mod 131$ を考えます。
2.5.1 複合代入演算
以下の実装は、複合代入演算子の $1$ つである +=
を宣言,定義した場合です。
struct modint131{
protected:
int v;
public:
modint131(int v_):v(v_){}
modint131& operator+=(const modint131& x){
this->v += x;
this->v %= 131;
return (*this);
}
}
modint131
オブジェクト P
に対して、演算子として宣言された関数 operator+=
を呼び出す際、operator
の部分を省略して単に P += 314;
のように書くことができます。これは実際のところ P.operator+=(314);
と同じです。
なお、複合代入演算子も 2.1.4 で述べたコピーの注意点に気をつけて定義してください。
2.5.2 二項演算
剰余環 $M$ の要素にも整数と同じく二項演算 + , - , *
などを定義したいので、自作クラスに演算を定義する方法をこれから説明します。
二項演算はメンバ関数として定義することができるので、以下のように +
を定義できます。
struct modint131
const modint131 operator+(const modint131& m){
return modint131(this->v + m.v);
}
}
また、より直感的な書き方として modint131
を $2$ つパラメータにとる、通常の (メンバでない) 関数をグローバルに定義するという方法があります。
const modint131 operator+(const modint131& a , const modint131& b){
return modint131(a.v + b.v);
}
ただし、このグローバルな関数では modint131
の protected
なメンバ v
にアクセスできる必要があるので、この関数 が modint131
のメンバにアクセスする権限を持つということを、friend
を用いて以下のように modint131
の中で明示的に宣言しておく必要があります。
struct modint131
// friend 指定した関数は、クラス外からも private メンバにアクセスできる
friend const modint131 operator+(const modint131& , const modint131&);
}
前に述べた通り、friend
宣言の直後に関数の定義を書くこともできます。実は、このように直接定義を書くことが効果的な場面があります (後に紹介)。
struct modint131
friend const modint131 operator+(const modint131& a, const modint131& b){
return modint131(a.v + b.v);
}
}
また二項演算は const な値を返すのが良いです。なぜなら、代入演算は通常の場合 const ではないので、(a+b) = c;
のような未定義な命令を防ぐことができるからです。
2.6 キャスト
あるオブジェクトを、別のクラスのオブジェクトに変換することをキャストと言います。例えば、bool
型オブジェクトは以下のように書くことで int
型オブジェクトにキャストできます。
bool t = true;
int y = (int)t;// 1
int n = int(!t);// 0
このコードでは int()
や (int)
と付けることで、int
型へのキャストを明示しました。これを明示的なキャストと言います。
別の例として、関数にパラメータを渡す際に行われるキャストを例示します。
void printReal(double x){
std::cout << x << std::endl;
}
int main(){
long long i = 314;
printReal(i);
}
printReal()
関数の引数は double
ですが、long long
型 (整数型) を渡してもコンパイルエラーしません。これは関数宣言で引数の型を見て、long long
型から double
に勝手にキャストを行なっているからです。これを暗黙的なキャストと言います。
2.6.1 変換コンストラクタ (convert constructor)
パラメータをちょうど $1$ つ取るコンストラクタを、変換コンストラクタ (convert constructor) と言います。パラメータのデータを自身に変換すると考えると、その名の通り変換 (キャスト) を行なっていることが理解できます。
例として U
型のオブジェクトから T
型のオブジェクトへのキャストを定義する場合を考えます。
// キャスト元
struct U{
double value;
U(double v) : value(v) {}
};
// キャスト先
struct T{
int value;
T(int v) : value(v) {}
// 変換コンストラクタ
T(U v){
this->value = int(v.value); // v のデータをこのクラス用に変換
}
// ムーブ代入演算
T& operator=(T&& v) = default;
};
2.1.7 で述べた通り、以下のように int
から T
型オブジェクトを初期化することができます。
int main(){
T obj = T(2); // 初期化
obj = U(0.4); // 代入
}
ここで、初期化の右辺は T
型を明示しているので、これは明示的な変換コンストラクタ呼び出しです。この初期化は T obj = 2;
のように書いて右辺の 2
を T(2)
に暗黙的にキャストさせることもできます (なお、2.1.7 の議論よりコピー自体は省略される可能性がある)。
また代入の場合、ムーブ代入演算の右辺の型が T
型で宣言されているので、右辺の U
型オブジェクト U(0.4)
は T
型へのキャストを要求され、T
型に定義した変換コンストラクタによって、U
型 $\rightarrow$ T
型に暗黙にキャストされることになります。
2.6.2 キャストオペレータ
少し特殊な演算子オーバーロードによって、U
型から T
型へのキャスト演算を定義できます。キャスト演算は、operator 変換先型名(){ /* 中身 */ }
で宣言&定義できます。
// キャスト元
struct U{
double value;
U(double v) : valuue(v) {}
// キャスト
operator T() {
/* 自身のデータを T 型に整形し、T 型の値を return する */
}
}
2.6.3 明示的か、暗黙的か
上記 $2$ つの実装は暗黙的なキャストの実装ですが、紹介した $2$ つのキャストの方法はいずれも、宣言を explicit
で修飾することで明示的なキャストにすることができます。
キャストが設計されたクラスはユーザーにとってのコードの書きやすさを向上させる一方、暗黙のキャストよって望まない動作が引き起こされる可能性もあります。天下り的ですが、modint131
に関してキャストに工夫がないコードを例示します。
#include<iostream>
// 自動で mod m の値 (あまり) をとる整数クラス
class modint131{
protected:
int v;// 内部でもつ値
public:
// コンストラクタ 兼 キャスト
modint131(int v_){
v = v_%131;
if(v<0)v+=131;
}
// int 型へのキャスト
operator int() const& {return int(this->v);}
// 2 つの modint131 オブジェクト同士の足し算
friend const modint131 operator+(const modint131& a, const modint131& b){
return modint131(a.v + b.v);
}
};
int main(){
modint131 a = 8;
std::cout << a + 4 << std::endl;
}
メイン関数に a + 4
と書かれているのが見えますか。これがまずいです。
modint131
は整数型と双方向に暗黙的なキャストができるように設計されているため、a + 4
は a + modint131(4)
なのか int(a) + 4
なのかがわかりません。このコードはコンパイルエラーになります。
このように、$2$ つのクラス A と B にキャストを設計するとき、プログラマは A , B どちらを優先するかの順序を決める必要があります。case by case ですが、基本的に優先したいクラスへのキャストは勝手に行われてほしいので、優先したいクラスへのキャストを暗黙的にし、優先しないクラスへのキャストを明示的にします。また、優先順位がない場合はどちらも明示的なキャストにすると良いです。
例えば先に例示した int
と modint131
の例では、modint131
が使われている以上は int
よりも modint131
を優先したいはずなので、modint131
へのキャストを暗黙的にし、modint131
$\rightarrow$ int
のキャストは明示的にします。こうすることで、a + 4
の部分が a + modint131(4)
を一意に指すようになって解決です。
また、テンプレートの章で、この問題のダメな解決法に少しだけ触れます。
3 名前解決
ソースコード内でプログラマが記述した変数名や関数名などを識別子と言います。コンパイルのはじめの段階である字句解析では、ソースコードに登場する識別子を記号表に登録し、ソースコードをトークン列に変換します。
ソースコード内に識別子が登場したとき、単純なプログラミング言語では記号表 (ハッシュテーブル) を参照してその識別子が何かを特定しますが、C++ では同じ識別子を持つオブジェクトが複数種類存在する場合があり、それぞれが何を指すのかを特定する必要があります。これを名前解決と言います。
唐突ですが、以下のコードはいかがでしょうか。
#include<cmath>
int y0 = 0;
int main(){
return 0;
}
以下はコンパイル結果です。
test.cpp:2:5: error: 'int y0' redeclared as different kind of entity
160 | int y0 = 0;
| ^~
In file included from /opt/homebrew/Cellar/gcc/13.2.0/include/c++/13/cmath:47,
from test.cpp:1:
/opt/homebrew/Cellar/gcc/13.2.0/lib/gcc/current/gcc/aarch64-apple-darwin21/13/include-fixed/math.h:696:15: note: previous declaration 'double y0(double)'
696 | extern double y0(double) __API_AVAILABLE(macos(10.0), ios(3.2));
| ^~
自分の環境がバレましたが、コンパイルがうまくいかないことを伝えることができました。y0
という名前は cmath
の関数名として既に使われているので、使用できないと怒られてしまいました。C++ で同じスコープ内に同じ識別子 (名前) を持つものを複数宣言するのは色々とダメです。
逆に、スコープを跨いで同じ識別子を付けることは許されます。同じ識別子は、ネストが深いスコープで宣言されたものが優先して採用されます。許されるというだけで、できれば避けた方が良いです。以下はコンパイルは通るものの、色々ダメなコードです。
struct TestObject{
TestObject(){
std::cout << "TestObject has been generated" << std::endl;
}
};
TestObject gen_obj(){
TestObject Obj;
return Obj;
}
int TestObject = 12;// これ以降、TestObject 識別子は int 変数を参照する
int main(){
auto obj = gen_obj();
std::cout << "TestObject = " << TestObject << std::endl;
auto TestObject = [&]() -> void {std::cout << "TestObject is used as function" << std::endl;};
TestObject();
}
以下は出力です。
TestObject has been generated
TestObject = 12
TestObject is used as function
こんなのどうみてもダメですよね。しかし実際のコーディグでは、ちゃんと綺麗に書けば同じ名前をつけることは良いことにもなり得ます。
3.1 名前空間を定義しよう
名前空間は識別子の宣言のために用意された個別のスコープのようなものです。識別子は、スコープをまたげば同名のものを使用して良いので、異なる名前空間では同名の識別子を使用することができます。名前空間は namespace 名前空間名
に続けて定義できます。
同じ名前の関数やクラスを、使う場面に応じて複数個用意することがあります。例えば、Point
クラスは二次元座標用に特化した場合と三次元座標用に特化した場合を用意すると便利です。
// 二次元幾何ライブラリ
namespace my2dGeoLib{
using Real = double;
struct Point{
Real x , y;
Point(Real x_,Real y_):x(x_),y(y_){}
};
// 三点 A,B,C を結ぶ三角形の面積
Real Area(Point A,Point B,Point C){return 1;}
}
// 三次元幾何ライブラリ
namespace my3dGeoLib{
using Real = double;
struct Point{
Real x , y;
Point(Real x_,Real y_,Real z_):x(x_),y(y_),z(z_){}
};
// 三点 A,B,C を結ぶ三角形の面積
Real Area(Point A,Point B,Point C){return 1;}
}
こうして名前空間の中で宣言&定義したものには、名前空間名
の後ろに ::
をつけることで、名前空間を指定してアクセスします。以下は my2dGeoLib
名前空間に書いた関数を呼び出す様子です。
int main(){
std::cerr <<
Area(
my2dGeoLib::Point(1,4),
my2dGeoLib::Point(0,0),
my2dGeoLib::Point(5,5))
<< std::endl;
return 0;
}
ここで、Area()
に my2dGeoLib::
を付けずに呼び出せていることに注目してください。Area()
関数の引数に my2dGeoLib::
名前空間の Point
が渡されているので、Area()
関数も my2dGeoLib::
名前空間の識別子だろうとコンパイラが解釈してくれます。その結果、Area
の my2dGeoLib::
を省略することができます。これを Argument Dependent Lookup (ADL , 実引数依存の名前探索 ) と言います。
また、using namespace 名前空間名;
と書くことで名前空間の指定を省略できますが、これは名前空間の利点を崩壊させるので良くないです。とはいえ、std::vector
などといちいち書くのは時間の無駄というのもその通りです。そこで、全ての機能で名前空間の指定を省略する代わりに、using 名前空間名::使いたい機能 ;
のように省略したい機能だけ using
で宣言しておくことで、名前空間の恩恵をある程度残したまま、名前空間の指定を省略することができます。
3.2 関数をオーバーロードしよう
数値を出力する printNumber(x)
関数はパラメータ x
の型によって出力が異なります (小数点以下など)。printInt(x)
や printDouble(x)
の様に関数を分ければ解決なのですが、数値を出力するという目的を考えると、これらの関数の区別を意識する必要はなく、やはり全て printNumber(x)
であることがベストプラクティスに思えます。
そこで、引数の型それぞれに対して、printNumber(x)
関数を定義することにします。同じスコープに同名の関数を複数個宣言&定義し、それらの関数をパラメータリストの違いによって区別することを関数のオーバーロードと言います。
void printNumber(int x){
std::cout << x << std::endl;
}
void printNumber(double x){
std::cout.precision(20);// 有効数字 20 桁
std::cout << x << std::endl;
}
int main(){
printNumber(14);
printNumber(7.0/9);
return 0;
}
以下は出力です。
14
0.77777777777777779011
4 テンプレート
4.1 テンプレートの基礎
クラスや関数の、型や値などのパラメータへの依存を抽象化したものをテンプレートと言います。関数を抽象化したものを関数テンプレート、クラスを抽象化したものをクラステンプレートと言います。
4.1.1 関数を抽象化してみよう
DoubleNumber()
は引数の数を $2$ 倍したものを返すものとします。3.2 で述べた様に、引数と返り値は int
にも double
にも short
にもなり得ます。この様な引数や返り値のバリエーションをテンプレートパラメータで抽象化した関数宣言&定義を関数テンプレートと呼びます。
以下は、DoubleNumber()
の方をテンプレートパラメータ T
で抽象化した関数テンプレートです。
template<typename T>
T DoubleNumber(T x){
return x*2;
}
int main(){
std::cout << DoubleNumber<int>(3) << std::endl;
std::cout << DoubleNumber<double>(1.82) << std::endl;
return 0;
}
// 出力
6
3.64
テンプレート引数 (テンプレートパラメータ) T
の部分を具体的に定めることを、特殊化 と言います。例えば DoubleNumber<int>(3)
は DoubleNumber<T>
を int
型に特殊化した関数です。
4.1.2 テンプレートの展開
例えば、テンプレートを使わずに 4.1.1 で例示したプログラムと同じ処理を行おうとすると、関数オーバーロードを用いて以下の様に書くことになります。
int DoubleNumber(int x){
return x*2;
}
double DoubleNumber(double x){
return x*2;
}
int main(){
std::cout << DoubleNumber(3) << std::endl;
std::cout << DoubleNumber(1.82) << std::endl;
return 0;
}
4.1.1 のコードが上記のプログラムと同様に動作できるのは、コンパイラが main
関数内で DoubleNumber<int>
DoubleNumber<double>
の必要性を確認し、DoubleNumber<T>
関数テンプレートから、プログラムの実行に必要な int
ver. と double
ver. の DoubleNumber
関数を生成するからです。
この様に、テンプレートで抽象化されたクラスや関数は、特殊化によって必要になったもののみ、テンプレートパラメータの部分を具体的に定めたコードがソースコード内にインライン展開されます。これを実体化またはインスタンス化と言います。
ちなみに、関数テンプレートにパラメータリストを渡さなかった場合、型推論によって関数の引数に適合するインスタンスが自動で生成されます。例えば、DoubleNumber((long long)1e15);
は、型推論によって DoubleNumber<long long>((long long)1e15);
になります。
4.1.3 クラステンプレートで確かめる実体化の様子
他にも実体化と関連する例をコードで例示してみます。まず、以下のコードがコンパイルエラーになることを確認してみます。
struct TemplateInstance{
TemplateInstance(){
const int ConstInt = -1;
ConstInt = 1;
}
};
int main(){
return 0;
}
以下はコンパイル結果です。
test.cpp: In constructor 'TemplateInstance::TemplateInstance()':
test.cpp:4:18: error: assignment of read-only variable 'ConstInt'
4 | ConstInt = 1;
| ~~~~~~~~~^~~
const
な変数に代入を行うので、これは当然コンパイルエラーです。次は TemplateInstance
に意味もなくテンプレートをつけてコンパイルしてみましょう。
template<typename T>
struct TemplateInstance{
TemplateInstance(T val){
const int ConstInt = -1;
ConstInt = val;
}
};
int main(){
return 0;
}
これはコンパイルが通ります。TemplateInstance
はプログラムの実行に必要ないためクラステンプレートが実体化されず、インライン展開されなかったのだろうと推察できます。試しに main
関数に TemplateInstance
を特殊化したクラスを記述してみたら、見事コンパイルエラーになりました。ちなみに、クラステンプレートは関数テンプレートと異なり、テンプレートパラメータの型推論が行なわれないため、テンプレートパラメータを省略することはできません。
int main(){
TemplateInstance<int> I(2);
return 0;
}
以下はコンパイル結果です。
test.cpp: In instantiation of 'TemplateInstance<T>::TemplateInstance(T) [with T = int]':
test.cpp:10:30: required from here
test.cpp:5:18: error: assignment of read-only variable 'ConstInt'
5 | ConstInt = val;
| ~~~~~~~~~^~~~~
さらにコードを少し変えてコンパイルしてみましょう。
template<typename T>
struct TemplateInstance{
TemplateInstance(T val){
const int ConstInt = -1;
ConstInt = val;
y = 2*x + 3;
}
};
int main(){
return 0;
}
以下はコンパイル結果です。
test.cpp: In constructor 'TemplateInstance<T>::TemplateInstance()':
test.cpp:6:9: error: 'y' was not declared in this scope
6 | y = 2*x + 3;
| ^
test.cpp:6:15: error: 'x' was not declared in this scope
6 | y = 2*x + 3;
| ^
const な変数への代入はエラーにならないのに、識別子 x
, y
が無いことはエラーになりました。このことから、特殊化されないテンプレートであっても名前解決は特別な理由で行われることがあるのだろうと推察できます。
4.1.4 Two Phase Name Lookup (テンプレートの名前解決)
Two Phase Name Lookup は、テンプレートに関する以下の $2$ つの名前解決を指します。
- テンプレートパラメータで抽象化されていない記述は、即座に名前解決される。
- テンプレートパラメータで抽象化されている記述は、実体化があったタイミングで名前解決される。
テンプレートを含むコードはこれらの $2$ 段階の名前解決が $1\rightarrow 2$ の順で行われます。よって、名前解決はインスタンス化されないテンプレートにも適用される可能性があり、しかも同じソースコード内であっても名前解決の順番は必ずしも宣言や定義された順ではないのです。
名前解決のタイミングのずれで、以下のような問題が発生することもあります。以下は、T
で抽象化された Tree
クラステンプレートと、その派生クラスの Star
クラステンプレートです。
template<typename T>
struct Tree{
int root;
T value;
};
template<typename T>
struct Star : public Tree<T>{
Star(){
root = 0;
}
};
以下はコンパイル結果です。
test.cpp: In constructor 'Star<T>::Star()':
test.cpp:9:9: error: 'root' was not declared in this scope
9 | root = 0;
| ^~~~
まず、Star
が Tree<T>
を基底クラスとして取るとき、Tree<T>
は抽象化された記述であるため、Two Phase Name Lookup により、Tree<T>
のメンバに対しては実体化のタイミングまで名前解決が行われません。
その結果、root
が Star
の定義に登場しても、コンパイラは Tree<T>
のメンバ root
を検索できず、宣言されていない識別子としコンパイルエラーになります。
そこで、Star
に書かれた root
の名前解決が先行しないようにするために、this
をつけてメンバにアクセスすることにします。
template<typename T>
struct Tree{
int root;
T value;
};
template<typename T>
struct Star : public Tree<T>{
Star(){
this->root = 0;
}
};
this
は struct Star : public Tree<T>
のことなので、this
を含む記述は T
で抽象化された記述であり、root
の名前解決を特殊化のタイミングまで先延ばしにすることができます。これで、Tree
の名前解決とのずれがなくなり、無事コンパイルできます。
4.2 modint で学ぶテンプレートソリューション
2.6 節で少し触れた modint
(剰余環) クラスのテンプレートを実際に作ってみましょう。2.6 節では $\mathrm{mod}\space 131$ に特化した剰余環でしたが、今回の modint
は $\mathrm{mod}$ をとる値をパラメータ $M$ として抽象化した (簡易的な) modint
を作ることにします。
4.2.1 コンパイラによる最適化
まず、クラスのテンプレートパラメータには型名だけでなく、実行時の処理に依存しない値を渡すことができます。実行時の処理に依存しない値を用いた計算はコンパイラによる最適化が期待できるので、抽象化できるパラメータはテンプレートパラメータにしておくのが良いです。特に、実行時に決まる値で割る計算とコンパイル時点で決まっている定数で割る計算では、コンパイラの最適化によって処理速度に大きな差が出ます。
template<long long M>
class modint{
protected:
long long m_val;
public:
modint(){}
// これから中身を定義していく
};
4.2.2 定義内でのテンプレートパラメータの省略
これから作るクラスは modint<M>
なのですが、クラスの定義の実装ではテンプレートパラメータの部分 <M>
を省略して単に modint
と書くことができます。もちろん、$M$ 以外で $\mathrm{mod}$ をとる modint<X>
が登場するなら、<X>
は省略してはいけません。
4.2.3 メンバ関数テンプレート
クラスのメンバ関数をテンプレート化したものを、メンバ関数テンプレートと言います。ここでは、modint
の変換コンストラクタのパラメータを、I
で以下の様に抽象化しておきます。
template<typename I>
modint(I v_){
this->m_val = (long long)(v_)%M;
if(m_val<0)m_val+=M;
}
こうすることで、このコンストラクタは、コンストラクタの実装に登場する T
$\rightarrow$ long long
のキャストが定義されてさえいるなら、そのような任意の I
型オブジェクトを引数にとることができます。
また、modint
から別の型へのキャストも同様に抽象化します。
template<typename I>
explicit operator I(){
return I(this->m_val);
}
これで modint
は、long long
$\rightarrow$ I
のキャストが定義されている任意の型 I
にキャストできる様になりました。
4.2.4 テンプレートの罠
あとは代入演算子やキャスト演算を作って、足し算や出力ストリーム (std::cout <<
とかで見るやつ) の二項演算を friend
で登録 & 定義して、、、、大体こんなもんでしょうか。
template<long long M>
class modint{
protected:
long long m_val;
public:
modint(){}
// コンストラクタ and I 型からのキャスト
template<typename I>
modint(I v_){
this->m_val = (long long)(v_)%M;
if(m_val<0)m_val+=M;
}
// I 型へのキャスト
template<typename I>
explicit operator I(){
return I(this->m_val);
}
// 複合代入演算
modint& operator+=(const modint& g){
this->m_val += g.m_val;
return *this;
}
// 出力ストリーム
friend ostream& operator<<(ostream& , const modint&);
// 足し算
friend const modint operator+(const modint& , const modint&);
};
template<long long M>
ostream& operator<<(ostream& ostrm , const modint<M>& x){
ostrm << x.m_val;
return ostrm;
}
template<long long M>
const modint<M> operator+(const modint<M>& a, const modint<M>& b){
return modint<M>(a.m_val + b.m_val);
}
残念、これは罠です。問題は friend
宣言した関数にあります。modint
定義の中で、確かに +
や <<
演算が宣言されていますが、定義が別にあることが問題です。
例えば modint<998244353>
がソースコードに登場した場合、modint<998244353>
のインスタンス化と同時に const modint<998244353> operator+(const modint<998244353>& , const modint<998244353>&);
などが friend 宣言されます。ところが、クラス外での関数テンプレートの宣言と定義が friend 宣言よりも後で記述されているので、modint<998244353>
の実体化の際、friend 宣言した関数の特殊化によって展開される関数テンプレートを見つけることができず、リンクエラーとなります。
よって、抽象化されたクラスの friend
関数は宣言と定義をクラス内で直接行う必要があります。
friend ostream& operator<<(ostream& ostrm, const modint& x){
ostrm << x.m_val;
return ostrm;
}
friend const modint operator+(const modint& a, const modint& b){
return modint(a.m_val + b.m_val);
}
4.2.5 危険で無意味なハック
2.6.3 項の例でキャストによる問題点について触れました。2.6.3 項では、キャストの暗黙,明示をきちんと定義することで問題を回避しましたが、実はテンプレートによる危険なソリューションが存在します。
modint<M>
からのキャスト演算は I
で抽象化されており、I
型への explicit
(明示的) なキャストを定義します。explicit
であることは、2.6.3 項で述べた解決法に従っているからです。ここで試しに explicit
を外して、このキャストを暗黙にしてみましょう。
template<typename I>
operator I(){
return I(this->m_val);
}
残念、エディタさんに怒られて警告が出てしまいました。しかしコンパイルは通ります。実は、C++ にはキャストの優先順位なるものが存在しているため、キャストを抽象化したことでなぜかうまい具合に仕様とマッチした様です。非常に不思議ですね。これは非常に危ういハックですので、使ってはいけません。詳しいことがわかったらまた追記しようと思います。
5 例外処理
最後に、これまでの章とは別方向の話ですが、C++ で追加された機能について紹介して終わります。
例外処理とは、プログラム内で意図しない動作が行われたときにエラーを投げて、それを受け取ったプログラムにエラーを処理させることです。この場合のエラーとは、実行時エラーやコンパイルエラーではなく、あくまでプログラムは動作しているが自分の意図した動作と異なることを指しています。
C++ では、try
スコープの中で発生した例外 (エラー) を、catch
スコープで受け取って処理する形になります。この時、throw
を使って try
スコープから例外を投げ出します。これを送出と言います。
try{
if(意図しない結果){
throw int(0); //try スコープを抜けて、catch スコープを探す
}
}
catch(int er){ //int型の例外を受け取るcatchスコープ
std::cerr<<"例外番号 : " << er << std::endl;
}
catch(...){//デフォルトの例外を受け取るcatchスコープ
std::cerr<<"例外が起きました" << std::endl;
}
この例では try
スコープの中で例外が発生した場合、throw
を用いて int
型の例外を送出します。try
スコープから送出された int
型の例外は、直後の catch(int er)
にキャッチされ、処理されます。
ここで、もし int
型以外の例外が投げられたなら、catch(int er)
の次の catch(...)
が例外を処理します。
また、例外が送出されたスコープで catch
が行なわれなかった場合、例外は自身が発生したスコープから抜け出して、上位のスコープを遡って catch
スコープを探します。
よって、現在実行しているスクープよりも上位のスコープに catch
が用意されているならば、今のスコープに try
や catch
を記述しなくても例外を投げることができます。
void make0(int *p){
if(p == nullptr)throw int(0);
else *p = 0;
}
int main(){
try{
make0(ptr);
}
catch{
printf("例外が投げられました\n");
}
}
この場合、make0
で投げられた例外は make0
のスコープでは catch
されず、その $1$ つ上位スコープである main()
の try
スコープに移ります。その後、try
スコープの直後で catch
されて例外処理されることになります。
おわりに
疲れました......... 間違ってたらドシドシ指摘してください。