まえがき
この記事は投稿者(NokonoKotlin)の個人サイトの記事から Qiita 用に移植 & 加筆したものです。 (この文言は記事が剽窃でないことを周知するためのものであり、個人サイトの宣伝ではないことをあらかじめご了承ください。)
また、この記事と同様の記事を過去に投稿していました。これはその記事のアップデート版です。
はじめに
この記事は自分の学習記録 & 備忘録を兼ねて、自分の感性でちょうど良いボリュームかつ、最低限の C++ 理解を得られる程度のレベルを意識して書きました。実際この記事には、ぼくどんが参考書を読み進めていく中で疑問に思ったことなどを補足して書いてあるので、初心者が単独で参考書を読むよりはすんなりと読んでいけるのではないかと思います。
というわけで、Effective C++ を読み終えた去年の秋あたりにこの記事を書いたのですが、修正などを重ねていくうちに冬真っ只中になってしました。一旦自分の中で怪しいと思っていた場所も埋めたので、ひとまずこれにて最終版です。ええ、本当です。
目次
-
基本事項おさらい
-
名前
- 宣言と定義
- 変数とアドレス
- 初期化、代入
-
メモリ上の領域
- 静的領域
- スタック
- ヒープ
-
オブジェクト
- 左辺値と右辺値
- 参照
- 参照の活用
- オブジェクトの寿命
-
名前
-
オブジェクト指向
-
クラスの定義
- メンバ
- コンストラクタ
- デストラクタ
- コピー
- 右辺値参照とムーブ
- 二つの初期化子リスト
- オブジェクトの初期化と代入
- 不要な機能の制限
- const オブジェクト
- 継承
-
ポリモーフィズム
- ポインタを使おう
- ポインタの使用を強制しよう
- virtual なメンバ関数
-
演算子オーバーロード
- 複合代入演算
- 二項演算
-
キャスト
- 変換コンストラクタ (convert constructor)
- キャストオペレータ
- 明示的か暗黙的か
-
クラスの定義
-
名前解決
- 同名の識別子の使用
- 名前空間を定義しよう
- 関数をオーバーロードしよう
-
ジェネリクス
-
テンプレート
- 関数を抽象化してみよう
- テンプレートの展開
- クラステンプレートで確かめる実体化の様子
- Two Phase Name Lookup (テンプレートの名前解決)
-
modint で学ぶテンプレートソリューション
- コンパイラによる最適化
- 定義内でのテンプレートパラメータの省略
- メンバ関数テンプレート
- テンプレートの罠
- 危険なハック
-
テンプレート
- 例外処理
- おわりに
1. 基本事項おさらい
1.1 名前
C++ に限らず、プログラムというのはコンピュータのメモリ (RAM) 上の領域を書き換えたり読み込んだりして動作します。
メモリ領域上での位置をアドレスと呼び、例えば上の図ではこれから実行するプログラムに物理メモリの $\mathrm{0x02}$ から $\mathrm{0x0B}$ までの領域が割り当てられています。
基本的にプログラムの実行はメモリ上でのアドレスの指定、アドレス内の要素の読み書きの処理で行われます。よってプログラムを実行するには、コンピュータにメモリ上での読み書きを指示する必要があります。
ところが、以下の自前定義のソースコード (説明のため簡略化済み) を見るに、そのような指示がされているようには見えません。
uint_4 y = 12;
std::cout << y << std::endl;
// & をつけるとアドレスを確認できる
std::cout << &y << std::endl;
なぜなら、メモリ上の操作を人間が指示するのはあまりに大変なため、C++ の文法はメモリ上の具体的なアドレスを、プログラマがソースコード中で識別子 (名前) を与えた変数に割り振ることで、人間が理解しづらい部分を違和感なく隠蔽しているのです。
1.1.1 宣言と定義
変数名、関数名といった風に、ソースコードにはプログラマが自分で決めた名前を使用する場面が多くあります。このような名前は識別子とも呼ばれます。識別子を使うために、プログラマはその識別子に対して宣言を行います。
宣言とは、ある識別子を使用することを宣言する命令のことです。例えば関数の宣言は、その関数の返り値や引数の型のリストと合わせて以下のように書くことができます。
// 型名 識別子(パラメータの型リスト);
int multiply(int , int);
これで、$2$ つの int を受け取り、int を返す関数の名前として multiply
が使われていることが宣言されました。なお、一つの (型などが同じ属性である) 識別子に対する宣言がソースコード中に複数回登場しても問題はありません。
宣言した識別子がプログラムの処理に関わる場合、つまりソースコードに登場する場合、その識別子が指す対象の定義が記述されている必要があります。
例えば以下のように関数の処理を記述することで、関数を定義することができます。なお、C++ では定義は宣言を兼ねるものと考えて問題ありません。
// 宣言 & 定義
int multiply(int a, int b){
return a*b;
}
ただし、定義は各識別子に対して、その識別子が有効なスコープ内に $1$ つしか書くことができません。一つの識別子に対して定義が $2$ つ以上存在するとコンパイルエラーになり、使用されている識別子に対して定義が一つもなければリンクエラーになります。
変数の宣言と定義についても概ね同じような話です。変数に関しては、基本的に宣言のみの記述は存在せず、uint_4 x;
のように、宣言のみに見える場合でもしっかり定義はされていることに注意が必要です。変数の宣言のみが行われる場合として extern
が絡む場合などがありますが、この記事では気にしないこととします。
1.1.2 変数とアドレス
uint_4 y = 12;
という命令では、uint_4
型 (4 bit整数とする) を表現できるサイズのメモリ領域が確保され、y
という識別子にその領域の先頭のアドレスが割り当てられます。
先に説明した通り、識別子とはプログラマが人間目線で使用する名前のことであり、コンピュータはプログラムを動かすために名前にメモリ上の領域と結びつけます。このように、識別子をメモリ上の領域などの実体と結びつけるとき、識別子を束縛すると言います。
例えば変数 y
がアドレス $\mathrm{0x03}$ と結びついたとき、変数 y
を $\mathrm{0x03}$ に束縛すると言います。またこの時、はじめに示したソースコードは以下のように読み替えることができます。
-
uint_4 y = 12;
$\rightarrow$ 4 bit 整数を表現するメモリ領域の先頭のアドレス $\mathrm{0x03}$ を識別子y
に割り当て、割り当てられた領域のデータを $12$ に書き換える。 -
std::cout << y << std::endl;
$\rightarrow$ メモリ上のアドレス $\mathrm{0x03}$ から $4$ ビットを読み込み、出力する (出力の命令の詳細は割愛)。
std::cout << y << std::endl;
が $\mathrm{0x03}$ を出力しないように、ソースコード上では変数は基本的に自身が持つアドレスが指す領域に格納されたデータとして振る舞います。
以降、変数について言及する際は、変数が持つアドレスについてではなく、その変数に割り当てられたメモリに格納されているデータについて言及しているものとして話を進めます。
1.1.3 初期化、代入
変数を活用するためには、その中にデータを格納する必要があります。格納するデータを決める命令には、初期化と代入 の $2$ つがあります。それぞれを以下で説明します。
-
初期化 $\rightarrow$ まだ中身が決まっていない変数の中身を決めること。変数の宣言&定義時に
uint_4 y = 12;
のように=
を用いて中身を決めることができる。ただし、uint_4 x;
のように=
で明示的に中身を決めない場合も変数x
は自身の型uint_4
のデフォルトの方法で初期化される。 -
代入 $\rightarrow$ 初期化よりも後で
=
を用いて変数の中身を変更すること。
1.2 メモリ上の領域
プログラムで使用されるメモリは、その用途によって以下の $3$ 種類に大別されます。
- 静的領域 : プログラムの動作に依存しない値を保存
- スタック : ローカルなオブジェクトを保存
- ヒープ : プログラムを通して生存するオブジェクトを保存
1.2.1 静的領域
プログラムの動作に依存しない値は、宣言&定義の直前に static
と書くことで、static
な変数として定義することができます。static
修飾された変数はヒープやスタックではなく静的領域上に置かれます。static な変数についてはここでは詳しく書きません。
1.2.2 スタック
for 文や関数の定義などに登場する { ... }
で囲まれた部分をスコープとよびます。スコープ内で生成されたオブジェクトはスタック領域上に生成され、処理がそのスコープを抜けるとき ( .. }
に到達した時 ) に破棄されます。
1.2.3 ヒープ
ヒープ領域上に生成されたオブジェクトはプログラムを通して生存します。
先に述べた通り、スコープ内で生成されたオブジェクトは、そのスコープを抜ける際に破棄されます。スコープを抜けても破棄されないオブジェクトをスコープ内で生成したい場合、自分で直接ヒープ上の領域を確保し、そこに直接オブジェクトを生成する必要があります。
T* p = new T()
と書くことで T
型のオブジェクトをヒープ領域上に生成します。このとき、T
型のポインタ p
は、T
型オブジェクトが生成されたアドレスを指します。また、delete p
と書くことでポインタ p
に格納されたアドレス上のオブジェクトを破壊してメモリを解放します。ただし、ポインタ p
が配列の先頭を指している場合は delete [] p
でメモリを解放する必要があります。
1.3 オブジェクト
C++ においては、変数やデータなど、ソースコードに登場する主体をオブジェクトと言います。オブジェクトは C++ の主役でもあるので、コードを書く際にはきちんとした理解と注意が求められます。
1.3.1 左辺値と右辺値
変数のように、メモリ上のアドレスが割り当てられているオブジェクトを左辺値と呼びます。それに対して、3.14
や "okoteiyu"
のように自分自身がデータであるものを右辺値ないし一時オブジェクト、あるいは単に値と呼びます。
右辺値の特徴として、役割を終えるとすぐに破棄されることなどがあります。
1.3.2 参照
変数そのものに束縛されたオブジェクトを参照あるいは左辺値参照といいます。参照は型名の後ろに &
をつけて uint_4& z = y;
のように宣言&定義できます。すると
、変数 z
は変数 y
そのものとなります。
この時、変数 z
を変数 y
そのものに束縛するといい、変数 z
は変数 y
そのものとして振る舞います。つまり、変数 z
は変数 y
の別名のオブジェクトと言えます。
1.3.3 参照の活用
uint_4 w = y;
のように通常の初期化によって変数の中身を決めると、代入元のデータのコピーを生成するコストがかかってしまいます。このように宣言&定義された変数 w
は、変数 y
とは別のアドレスに領域が確保され、そこに y
のデータがコピーされます。
それに対して、型名の直後に &
をつけて宣言すると、先述の参照によって変数の別名を作ることができ、データのコピーのコストをなくすことができます。以下は参照を使用する様子です。
int x = 0;
int& ref_x(){
return x;
}
int main(){
int y = 12;
int& z = y;// y の参照
y = 2;
std::cout << y << " == " << z << std::endl;
int nx = ref_x();// 新たな変数を ref_x のデータで初期化
int& x2 = ref_x();// 新たな参照の作成
ref_x() = -1;// 参照元のアドレスのデータの変更
std::cout << "nx == " << nx << std::endl;
std::cout << x << " == " << x2 << std::endl;
return 0;
}
以下は出力です。
2 == 2
nx == 0
-1 == -1
std::vector
のように、複製のコストが重いものを複製したい場合には、そのオブジェクトの別名を増やすことで大きいデータの複製を省略できます。ただし、増やした変数の中身を書き換えると当然元の変数の中身も変わるので一層注意が必要です。
1.3.4 オブジェクトの寿命
スタック領域に生成したオブジェクトはいつか破棄されます。よって、そのようなオブジェクトの参照は、そのオブジェクトが破棄される前に無効化されるべきです。たとえば以下のプログラムを考えましょう。
int& refer_int(int i){
int& r = i;
return r;
}
int main(){
int& r = refer_int(15);
std::cout << "中身チェック : " << r <<std::endl;
}
以下は自分の環境での出力です。
中身チェック : 1
refer_int(15)
は $15$ を指す変数を参照しているつもりなのに、出力は $1$ でした。参照元の変数がスコープを抜けて無効化され、参照元のオブジェクトが破棄されてしまったからです。参照を使用する場合、参照元の寿命に注意する必要があります。
また、別名の変数がスコープの終端で無効化されても参照元が破棄されたりはしません。影響を持つのは参照元の寿命です。
int I = 998244353;
if(I > 0){
int& ref = I; // I を参照
} // ref の有効なスコープが終了
// I はまだ生存中
printf("%d\n" , I);
2 オブジェクト指向
普通、プログラムは一連手続きを複数の関数の実行や変数のやり取りによって処理を行います。C++ のようなオブジェクト指向言語では、一連の手続きなどをクラスと呼ばれるモデルで定義し、プログラマはそのモデルから生成されたオブジェクトを介して処理を命令します。
2.1 クラスの定義
例えば二次元平面上の点は全て $(x,y)$ 座標という共通のデータを持つので、以下のように Point クラスとして定義することで、よりわかりやすいコードを記述することができます。
#include<cmath>
class Point{
protected:
// メンバ変数 (1e200 で初期化)
double x = 1e200;
double y = 1e200;
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 デストラクタ
デストラクタは delete
によってメモリが解放される時や、オブジェクトが宣言されたスコープを抜ける時に破棄される (1.2.2 項参照) 際の処理を定義します。デストラクタは ~
(チルダ) の後ろにクラス名を書いて定義します。
~Point(){ /*メンバを破棄する実装*/ }
2.1.4 コピー
オブジェクトの複製 (コピー) を定義するメンバは二種類存在します。
まず $1$ つ目は、以下のように同じクラスのオブジェクトの参照を受け取るコピーコンストラクタです。コピーコンストラクタの用途は初期化です。
以下のように定義します。コピーの実装をこれからするのですから、パラメータは値渡し(コピー)ではなく参照渡しで受け取ります。
Point(const Point& p){ /*メンバのコピーの実装*/ }
$2$ つ目は、後述する演算子オーバーロードを用いて定義される、同じクラスの参照を受け取る =
(コピー代入演算子) です。コピー代入演算子の用途は代入です。
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 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$ の方法が優先されます。このことによって、コピーとは別に右辺値のムーブを定義することができます。
Point
クラスのコピーの実装を見ると、パラメータ p
のデータをメンバに複製しています。
もしパラメータ p
が右辺値を参照しているなら、右辺値は一時的な用途しかないので、p
のデータを複製するのではなく p
のデータの参照と自身のデータの参照 swap をすることで複製のコストをなくすことができます。例えば、C++ の STL コンテナは、そのデータサイズに関わらず $O(1)$ 時間の swap が定義されています。
そこで、上記の $2$ の方法で 右辺値のみを パラメータとして受け取る、ムーブコンストラクタとムーブ代入演算を定義します。ムーブは特別な事情がない限りデフォルトのものに任せておいて構いません。
// ムーブコンストラクタ
Point(Point&& p) = default;
// ムーブ代入演算
Point& operator=(Point&& p) = default;
なお、右辺値参照とムーブは別の概念であることに注意してください。右辺値参照とムーブはどちらも右辺値を受け取ることができますが、右辺値参照が 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_) {} // メンバのコンストラクタを呼んでいる
ところで、Point
クラスの定義の中で、メンバ変数の宣言&定義は x
と y
を 1e200
で初期化されるように指示されています。しかし、コンストラクタの初期化子リストで初期化を指示したメンバ変数に対しては変数の宣言&定義で指示した初期化が行われず、初期化子リストに記述した初期化のみ行われます。
そしてなんとびっくり、初期化子リストは別の概念を指す場合があります。それは、C++ のコンテナである std::initializer_list<T>
です。<T>
はテンプレートパラメータです ($4$ 章で説明します)。
std::initializer_list<T>
は T
型オブジェクトを複数個保存するデータ構造です。このリストを受け取るコンストラクタを定義することで、波括弧 {}
によるサイズに制限が無いパラメータリストを受け取る初期化を行うことができます。
// メンバは double なので、double の初期化子リストを受け取る
Point(std::initializer_list<double> iList){
// 今回のデータメンバは 3 つしか無いので、サイズが 4 以上はエラ-とする
assert(iList.size() <= 3);
// 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);// 変数 D を A に束縛
これまでの話から概ね理解ができる文法ですが、$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
の中身を変更しないかどうかというのは、コンパイラの知るところではないのです。
ここでは、中身を変更しないことが明示的に担保された 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
宣言しておき、makeStar()
関数に Star
クラスの private
なメンバへのアクセスを与えます。
関数の friend
宣言をする場合、アクセスを許可したい関数の宣言を、先頭に friend
修飾をつけてクラスの内部に記述します。クラスの friend
として宣言された関数は、その関数の実装がクラスの定義の外に書かれていても、そのクラスの private
なメンバへのアクセスが許可されます。
また、以下のように関数宣言の後に直接定義を書くこともできます。
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()
が呼ばれます。
2.5 演算子オーバーロード
特殊な関数として、C++ の演算子 + , *= , / , &&
などと同名の関数を定義することを演算子オーバーロードと言います。通常の関数と異なり、演算子の定義では関数名(演算子)の直前に operator
をつけて宣言します。
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 二項演算
剰余環の要素にも整数と同じく二項演算 + , - , *
などを定義したいので、自作クラスに演算を定義する方法をこれから説明します。
二項演算はメンバ関数として定義することができるので、以下のように +
を定義できます。
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);
}
}
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)
に暗黙的にキャストさせることもできます。
また代入の場合、ムーブ代入演算の右辺の型が 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 名前解決
ソースコードに識別子が登場した時、その識別子が何を指しているのかを特定する必要があります。ソースコードに識別子が登場した時、コンパイラはソースコードから識別子の宣言を探索して識別子が何かを特定します。この作業を名前解決と言います。
3.1 同名の識別子の使用
異なるスコープをまたぐ場合などでは、複数の異なるオブジェクトに同じ識別子を宣言&定義することができます。例えば、同一の識別子が宣言&定義されているスコープ同士がネストの関係にある場合、ソースコードに登場する識別子の名前解決は、その識別子が登場した時点で有効な宣言&定義のうち直近のものが採用されます。
このように、同名の使用は言語仕様的には可能ですが、以下のコードのように分かりづらいコードになってしまう可能性もあります。
struct TestObject{
TestObject(){
std::cout << "TestObject has been generated" << std::endl;
}
};
TestObject gen_obj(){
TestObject Obj;
return Obj;
}
// これ以降、TestObject 識別子は int 型変数を参照する
int TestObject = 12;
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.2 名前空間を定義しよう
名前解決で名前を探索する際に、ソースコードのどこを探索して欲しいかをコンパイラに指示することができます。識別子 Y
が、X::Y
のような構文で登場した時、コンパイラは X
によって定められる範囲から識別子 Y
の宣言を探します。
この時、探索を指示する範囲を名前空間として記述することができます。名前空間は識別子の宣言のために用意された個別のスコープのようなものであり、異なる名前空間では同名の識別子を使用することができます。名前空間は 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.3 関数をオーバーロードしよう
数値を出力する 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 ジェネリクス
C++ では、テンプレートと呼ばれるものを用いて、汎用性/再利用性の高いコードを記述する仕組みがあります。この仕組みをジェネリクスと言います。多くの場合では、ジェネリクスを活用することでコードの記述量を大きく減らすことができます。
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
型に特殊化した関数です。
ちなみに、関数テンプレートにパラメータリストを渡さなかった場合、型推論によって関数の引数に適合するテンプレートパラメータが自動で決定されます。例えば、DoubleNumber((long long)1e15);
は型推論によって DoubleNumber<long long>((long long)1e15);
になります。
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
関数を生成するからです。
このように、テンプレートで抽象化されたクラスや関数は、特殊化によって必要になったもののみ、テンプレートパラメータの部分を具体的に定めたコードがソースコード内に展開されます。これを実体化またはインスタンス化と言います。
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;
}
コンパイラによっては、実体化しないクラステンプレート内での const チェックを行わない場合があります。実際、自分の環境ではこのコードのコンパイルが通ります。
TemplateInstance
はプログラムの実行に必要ないためクラステンプレートが実体化されなかったのだろうと推察できます。試しに main
関数に TemplateInstance
を特殊化したクラスを記述してみたら、見事コンパイルエラーになりました。ちなみに、クラステンプレートは関数テンプレートと異なり、テンプレートパラメータの型推論が行なわれないため、テンプレートパラメータを省略することはできません。
int main(){
TemplateInstance<int> I(2);
return 0;
}
以下はコンパイル結果です。
test.cpp: In instantiation of 'TemplateInstance::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;
| ~~~~~~~~~^~~~~
4.1.4 Two Phase Name Lookup (テンプレートの名前解決)
Two Phase Name Lookup は、テンプレートの定義に関する以下の $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::Star()':
test.cpp:9:9: error: 'root' was not declared in this scope
9 | root = 0;
| ^~~~
Star<T>
の基底クラスである Tree<T>
は、Star
のテンプレートパラメータ T
によって抽象化された記述です。Tree<T>
はテンプレートパラメータに依存しているため、Two Phase Name Lookup により、T
を具体的に定めた記述が登場するタイミングまで Tree<T>
という識別子が何を指すのかが解決されません。
その結果、root
が Star
の定義に登場しても、root
を宣言してある Tree<T>
の名前解決がされていないため、Tree<T>
で宣言された root
を検索できずにコンパイルエラーになります。
そこで、root
の名前解決が先行しないようにするために、this
をつけて root
にアクセスすることにします。
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;
}
こうすることで、このコンストラクタは、コンストラクタの実装に登場する I
$\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>
の実体化と同時に operator+
が特殊化され、const modint<998244353> operator+
が実体化されようとします。
ところが、テンプレートの実体化が行われるためには、テンプレートが特殊化された場所よりも前にテンプレートの宣言が書かれている必要があるのです。operator+
は関数テンプレートの宣言が、クラス内の 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);
}
または、friend
にしたい関数の関数テンプレートの宣言を modint
クラスより前で前方宣言することでも解決できます。
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
されて例外処理されることになります。
おわりに
疲れました......... 間違ってたらドシドシ指摘してください。