まえがき
この記事は投稿者(NokonoKotlin)の個人サイトの記事から Qiita 用に移植 & 加筆したものです。
(この文言は記事が剽窃でないことを周知するためのものであり、個人サイトの宣伝ではないことをあらかじめご了承ください。)
この記事は??
この記事では、これからの C++ 記事に先立って C++ の最低限必要な知識を書いておきます。
なお、執筆者は天才見習い Qiita ライターで AtCoder Rating 青色の超絶秀才ですが、C++ に関しては凡才なので話半分で読んでください。指摘もバシバシしてください。
目次
- メモリと参照
- const について
- クラスと継承/ポリモーフィズム
- 名前空間/名前解決
- テンプレート
- キャスト
- 例外処理
メモリと参照
スタックとヒープと静的領域
これらはオブジェクトを保存するメモリ領域を指し、以下の様に使い分けられます。
- 静的領域 : 静的なオブジェクトを保存
- スタック : ローカルなスコープで宣言されたオブジェクトを保存
- ヒープ : グローバルなオブジェクトを保存
静的領域
static 修飾された変数はヒープやスタックではなく静的領域に置かれます。static な変数については考えるべきことがたくさんありますが、ここでは詳しく書きません。
スタック
{ ... }
で囲まれた部分をローカルなスコープとよびます。ローカルで宣言されたオブジェクトはスタック領域上に生成され、処理がそのスコープを抜けるとき ( .. }
に到達した時 ) に破棄されます。スコープのなかにオブジェクトやスコープを追加 (ネスト) で記述することで、スタック上にオブジェクトが積み上がっている様子がデータ構造のスタックの様だと覚えることができます。
ヒープ
ヒープ領域に生成されたオブジェクトはプログラムを通して生存します。ローカルなスコープ内でプログラム全体を通して使いたいオブジェクトを生成する場合は、プログラマがヒープ上の領域を確保し、直接オブジェクトを生成する必要があります。C 言語スタイルではヒープ上のメモリを確保/解放するのに malloc/free を用いていたのに対して、C++ スタイルでは new/delete を用います。
Obj* p = new Obj()
と書くことで Obj
型のオブジェクトをヒープ領域上の生成します。Obj のポインタ p は Obj が生成されたアドレスを示します。delete p
と書くことでポインタ p に格納されたアドレス上のオブジェクトを破壊してメモリを解放します。
また、delete p
と delete[] p
の使い分けに注意が必要です。delete はポインタ p が単独のオブジェクトを指している場合に適用し、delete [] はポインタ p が配列などを指している場合に適用します。この違いに注意しなければなりません。
ポインタ型と参照型
誤解を恐れずに言うと、ポインタ型と参照型の使い方は本質的に同じです。どちらもメモリ上のアドレスが格納されます。ここで誤解を解きます。ピピピ-!!! 誤解を検知しました!!! ポインタ型は示すアドレスを変更できるのに対して、参照型は初期化でアドレスを格納してから別のアドレスを格納し直すことができません。
ポインタ型の宣言は 参照オブジェクトの型名*
で行い、たとえばある int
型変数 x
のメモリ上のアドレスをポインタ型 p
に格納する際は以下の様に記述します。
int x = 1695; // 現在(2024/6/2)の AtCoder Rating
int* p = &x;
変数 x の前に & を付けることで、変数 x のアドレスを取り出します。
他方で、参照型は 参照オブジェクトの型名&
で宣言し、変数 x のアドレスを参照型 r
に格納する際は以下の様に記述します。
int x = 1695; // 現在(2024/6/2)の AtCoder Rating
int& r = x;
参照型に格納する際は変数 x に & を付ける必要はありません。
メモリ上のオブジェクトへのアクセス
ポインタ型または参照型に格納されているアドレス(参照)を通じて、メモリ上のオブジェクトを直接編集することができます。以下のコードを main() に書いてみましょう。
int x = 2800;
int y = x;
int* p = &x;
int& r = x;
*p = 1200;
std::cout << "中身 : " << x << " , " << *p << ", " << r << std::endl;
r = 400;
std::cout << "中身 : " << x << " , " << *p << ", " << r << std::endl;
x = 0;
std::cout << "中身 : " << x << " , " << *p << ", " << r << std::endl;
y = 3200;
std::cout << "中身 : " << x << " , " << *p << ", " << r << std::endl;
このプログラムの出力は以下になります。
中身 : 1200 , 1200, 1200
中身 : 400 , 400, 400
中身 : 0 , 0, 0
中身 : 0 , 0, 0
注目ポインツ
- ポインタ型の変数の前に "*" を付けることで、メモリ上のオブジェクトに直接アクセスできます。
- 参照型の変数はそれ自体がメモリ上のオブジェクトを直接示します。
- 変数 x もメモリ上のオブジェクトの参照です。
- y は x の指すオブジェクトを参照しません。代入時に x が指すオブジェクトのコピーがメモリ上に生成され、y は新たに生成されたコピーを参照します。
3 にある通り、変数 x も参照です。このことから参照型はあるオブジェクトを参照する変数を増やすことだと言えます。対して、4 のような代入を行うと、代入元が指すオブジェクトのコピーが生成され、そのコピーが代入先に代入されることになり、代入元と代入先は別の参照を持つことになります。
変数 r の様に同じオブジェクトの参照を増やすことを参照渡しと言い、変数 y の様に中身のコピーを新たに生成して渡すことを値渡しと言います。参照渡しはアドレス(参照)をコピーするだけなので O(1) 時間の処理ですが、値渡しはオブジェクトのコピーを伴うので処理が重たくなる場合があります。
オブジェクトの寿命
スタックに積まれたオブジェクトはいつか破棄されます。それはオブジェクトが宣言されたスコープから抜ける時です。よって、オブジェクトの参照は破棄される前に無効化されるべきです。たとえば以下のプログラムを考えましょう。
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 : 1
参照にそれぞれ 12 が格納された変数と、15 が格納された変数を渡したのに出力は 1 と 1 でした。これは変数の寿命が切れたため、参照先のオブジェクトが破棄されて未定義動作になったからです。
このように、オブジェクトの寿命には注意が必要です。
const について
プログラムを通して初期化以外で中身の変更がないオブジェクトを const
なオブジェクトと呼びます。
const で宣言する
const であることがわかっているオブジェクトには const 修飾子をつけて宣言を行うことで、コンパイラによる最適化やバグの抑止に繋がります。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 性を損なうプログラムとしてコンパイルエラーになります。これが「const 性はプログラマが明示的に担保する必要がある」ということです。
要するに、中身を変更しないことが明示的に担保された weight()
関数をプログラマが用意しておく必要があります。クラスのメンバ関数には以下のようにして const 修飾子を付けることができます。
double weight() const {
return this->w;
}
const 修飾された weight() 関数を用意したことで、コンパイラは const な Car オブジェクトに対して const な weight() 関数を呼び出すことができるようになりました。
(注) : const でない Car オブジェクトに const でない weight() を行う (重さをみるたびに車の塗装が剥げて軽くなる状況など) 場合に対しては const 修飾されていない weight() を用意する必要があります。
const 参照
Ball
型オブジェクトを受けとる関数を、Ball の const 参照 const ball&
を使って Size(const Ball& b)
と書きます。
class Ball{
int material[50000];// 重めのメンバ
int r;// 半径
}
void Size(const Ball& b){
std::cout << b.r << std::endl;
}
const 参照を使うことで、関数は const なオブジェクトの参照と値(一時オブジェクト)の両方を受け取ることができます (ただし const オブジェクトなので、引数を変更することはできない)。
クラスと継承/ポリモーフィズム
C++ にはクラスというものがあります。クラスとは、考えたい対象を機能や性質ごとに分類しておくといったお気持ちです。
クラスを宣言&定義するときは以下のように記述します。
class クラス名 {
メンバの宣言;
コンストラクタ;
デストラクタ;
etc.
};
例えば二次元平面上の点を表すクラスは以下のように記述できます。
template<typename T>// テンプレート (後述)
class Point{
public:
T x , y; // 座標
double rad; // x 軸とのなす角
Point(){}
Point(T x_, T y_) : x(x_) , y(y_) // 初期化子リストで初期化
{
this->rad = atan(double(y)/double(x));
}
~Point(){}
};
このように、ある特性を表すデータをまとめて一つのオブジェクトとして宣言しておくことで、よりわかりやすいコードを記述することができます。
コンストラクタ , デストラクタ , コピーコンストラクタ
詳細なことは別の記事に書くと思います。
クラスを生成するために呼び出す関数をコンストラクタと呼び、クラスを破壊するための関数をデストラクタと呼びます。これらはクラス名と同一の関数名を使用します。ただし、デストラクタは関数名の前に '~' (チルダ) がつきます。
デストラクタはスコープを抜ける際に呼び出され、オブジェクトが持つデータを破壊してメモリを解放します。
コピーコンストラクタは同じクラス間の代入(コピー)の役割を持ちます。
通常これら 3 つはユーザーが必ず記述するものですが、この記述がなかった場合はコンパイラが自動でそれらを用意してくれます。つまり、どのようなクラスにもこれら 3 つは付随します。よって、もしデストラクタやコピーコンストラクタで何の処理も行いたくない場合は、何も行わないコピーコンストラクタなどを自分で書く必要があります。
初期化子リスト
Point コンストラクタが呼び出されるとコンストラクタの引数が点の座標に代入され、その後 x 軸とのなす角が計算されます。このとき、コンストラクタに続く : x(x_) , y(y_)
は初期化子と呼ばれ、Point() コンストラクタのスコープ内の処理が行われる前にメンバ x や y のコンストラクタ (初期化) が実行されます。
Point p = {1,2};
と書いて Point(1,2) コンストラクタを呼び出すこともできます。ただし、{1,2}
に書いた値からメンバが初期化される順番は、初期化子を書いた順ではなく、メンバを宣言した順です。
初期化子はメンバを初期化するので、代入が禁止されているメンバ (const など) の初期化で使います (初期化は代入とは別です)。
クラスの継承 : 基底クラスと派生クラス
C++ のクラスには継承という概念があります。ベースとなるクラスを基底クラスと呼び、基底クラスから派生するクラスを派生クラスと呼びます。
例えば抽象構造の木を表すクラスを考えてみます。木構造は以下のようなクラスで表現できます。
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)
はともに隠蔽されます。
ポリモーフィズム
NokonoKotlin (執筆者) はアルゴリズムの勉強をしているので Star は一般の Tree クラスと比べて特殊なインスタンスだとわかりますが、一般の人が Tree と Star に大きな違いを見出す保証はありません。開発チームのメンバーに「木構造クラスを書いて」と言われて、RandomTree や Star や BinaryTree や Path といった特殊な属性の木構造をたくさん書いても微妙な顔をされるかもしれません。なぜなら、普通の人にはどれも全て同じ木構造に見えるからです。
共通のベース (基底クラス) を持ちつつ、さまざまな属性 (派生クラス) に分岐するようなオブジェクトを記述するとき、プログラマは派生クラスの中身は基底クラスを介してアクセスできるような設計をします。このような思想をポリモーフィズムと言います。
ポインタを使う
基底クラスのポインタは、派生クラスの領域を指すこともできます。例えば、Star は Tree の派生クラスなので以下のように記述してもプログラムは正常に動作しますし、これは C++ がサポートする動作です。
Tree* ptr = new Star();
これを利用して、派生クラスは全て基底クラスのポインタを介してアクセスするような設計にします。派生クラスをヒープ領域に生成し、生成した派生クラスを参照するポインタを返す関数をファクトリ関数と呼びます。ファクトリ関数は先述の通り、全て基底クラスのポインタを返すようにします。
Tree* makeTree(){
return new Tree();
}
Tree* makeStar(){
return new Star();
}
Tree* makeBinaryTree(){
return new BinaryTree();
}
virtual なメンバ関数
関数宣言の頭に virtual と付けた関数を仮想関数と言います。
基底クラスと派生クラスの説明のとき、基底クラスに同名の関数が存在するとき、基底クラスの関数は hiding (隠蔽) によってアクセスできなくなると説明しました。では、基底クラスのポインタで派生クラスのオブジェクトにアクセスするときはどうなるのでしょうか。これを Tree クラスと Star クラスの両方で宣言した identify() 関数の例で考えます。
結論から言うと、通常の関数であれば、どちらが呼ばれるかはポインタの型に依存します。つまり、Star* で Star オブジェクトにアクセスしているならば当然 Star.identify() が呼ばれ、Tree* で Star オブジェクトにアクセスしているならば Tree.identify() が呼ばれます。
この挙動は期待される挙動ではないため、プログラマは Tree* からでも Star.identify() が呼ばれるようにします。これは、基底クラス (Tree) で宣言する identify() の頭に virtual 修飾子をつけることで解決できます。
virtual をつけたならば、派生クラスに同名の関数が存在する場合にどちらが呼ばれるかは実際に生成されたオブジェクトに依存するので、Tree* でアクセスしていようと、Tree* makeStar() によって生成されるオブジェクトは Star なので Star.identify() にアクセスすることができます。
Tip1
デストラクタを仮想にしないと、オブジェクトを破棄するときに基底クラスのデストラクタしか呼ばれず、メモリリークにつながる。
Tip2
メンバを仮想化すると、仮想化しない場合に比べて多少余計にメモリを食うので、必要がなければしない方が良い。
名前空間/名前解決
ソースコード内でプログラマが記述した変数名や関数名などを識別子と言います。コンパイラのはじめの段階である字句解析では、ソースコードに登場する識別子を記号表に登録し、ソースコードをトークン列に変換します。
ソースコード内に識別子が登場したとき、単純なプログラミング言語では記号表 (ハッシュテーブル) を参照してその識別子が何かを特定しますが、C++ では同じ識別子を持つオブジェクトが複数種類存在する場合があり、それらの対応関係を特定する必要があります。これを名前解決と言います。
唐突ですが、以下のコードはいかがでしょうか。
test.cpp
#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++ で同じスコープ内に同じ識別子 (名前) を持つものを複数宣言するのは色々とダメです。自分の環境では以下の「ダメ」が発生します。
- [同じ変数名が複数個の場合] : コンパイルエラー
- [同じ関数名が複数個の場合] : ダメではない。適切に書けば OK。
- [変数名と関数名が同じ場合] : コンパイルエラー
- [変数名とクラス名が同じ場合] : コンパイルは通る。変数の宣言後の手続きでは変数の方が参照される
- [関数名とクラス名が同じ場合] : コンパイルは通る。関数の宣言後の手続きでは関数の方が参照される
逆に、スコープを跨いで同じ識別子を付けることは許されます。同じ識別子は、ネストが深いスコープで宣言されたものが優先して採用されます。許されるというだけで、できれば避けた方が良いです。
コンパイルは通るが、色々ダメなコード
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
こんなのどうみてもダメですよね。
混乱を避けるため、異なる 2 つの {変数,関数,クラス} に同じ名前を付けるのは基本的に避けるべきですが、以下のように同じ名前を付けて良い場合もあります。
- 異なる名前空間のメンバ
- 関数のオーバーロード
異なる名前空間を用意しよう
名前空間は識別子の宣言のために用意されたスコープのようなものです。識別子は、スコープをまたげば同名のものを使用して良いので、異なる名前空間では同名の識別子を使用することができます。
同じ名前の関数やクラスを、使う場面に応じて複数個用意することがあります。例えば、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;}
}
こうして名前空間別で宣言されたものは、名前空間名::識別子
と書くことで呼び出すことができます。
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::
名前空間の識別子だろう、とコンパイラが解釈してくれて、 Area
の前に my2dGeoLib::
と書くことを省略することができます。これを Argument Dependent Lookup (ADL , 実引数依存の名前探索 ) と言います。
using namespace ~ について
競技プログラミングの参加者の多くは using namespace std;
をしますが、これは名前空間の利点を崩壊させるので良くないです。とはいえ、std::vector
などといちいち書くのは時間の無駄というのもその通りです。
そこで、using namespace std;
の代わりにusing std::vector;
や using std::cout;
のように記述しておくことで、std::
名前空間の必要な識別子のみにアクセスしやすくなります。std::
名前空間の関数などは、ADT があるのでそもそも using
を付けなくて良いです。
関数のオーバーロード
引数が異なる同じ関数名の関数を宣言することを、関数のオーバーロードと言います。
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
これらの関数は引数の型から呼び出す関数を特定します。これはよくある実装であり、ダメな命名ではないです。
テンプレート
テンプレートとは、クラスや関数の型依存を抽象化したものです。関数を抽象化したものを関数テンプレート、クラスを抽象化したものをクラステンプレートと言います。
関数を抽象化してみよう
DoubleNumber()
は引数の数を 2 倍したものを返すものとします。引数と返り値は int
にも double
にも short
にもなり得ます。この引数/返り値のバリエーションを 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
型に特殊化した関数です。また、C++ においてはテンプレートが特殊化されたものを インスタンス と言います。
テンプレートの展開
テンプレートで抽象化されたクラスや関数は、必要になったインスタンスのみ、 T
の部分を特殊化したコードがソースコード内にインライン展開されます。これを実体化またはインスタンス化と言います。
実体化と関連する例をコードで提示してみます。まず、以下のコードがコンパイルエラーになることを確認してみます。
コード 1
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
に意味もなくテンプレートをつけてコンパイルしてみましょう。
コード 2
template<typename T>
struct TemplateInstance{
TemplateInstance(){
const int ConstInt = -1;
ConstInt = 1;
}
};
int main(){
return 0;
}
これはコンパイルが通ります。先の議論より TemplateInstance
が特殊化されなかったことで、インライン展開されなかったのだろうと推察できます。
テンプレートの名前解決
インライン展開のところで紹介したコードを少し変えてコンパイルしてみましょう。
コード 3
template<typename T>
struct TemplateInstance{
TemplateInstance(){
const int ConstInt = -1;
ConstInt = 1;
y = 2*x + 3;
}
};
int main(){
return 0;
}
コンパイルエラー
test.cpp: In constructor 'TemplateInstance::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 が無いことはエラーになりました。このことから、特殊化されないテンプレートであっても名前解決は特別な理由で行われることがあるのだろうと推察できます。
Two Phase name Lookup
Two Phase Name Lookup は、テンプレートに関する以下の 2 つの名前解決を行うことを指します。
- テンプレートパラメータで抽象化されていない記述は、即座に名前解決される。
- テンプレートパラメータで抽象化されている記述は、特殊化があったタイミングで名前解決される。
テンプレートを含むコードはこれらの 2 段階の名前解決が 1->2 の順で行われます。要するに、同じソースコード内でも名前解決の順番は必ずしも宣言/定義された順ではないという話です。
名前解決のタイミングのずれで、以下のような問題が発生することもあります。
template<typename T>
struct Tree{
int root;
};
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;
| ^~~~
Tree
はクラステンプレートなので、Two Phase Name Lookup により、特殊化が行われるタイミングまで名前解決が行われません。Star
に書かれた root
の記述は抽象化されていないので、コンパイラは Tree
の名前解決が行われる前にこの部分を読み込み、コンパイルエラーになります。
Star
に書かれた root
の名前解決が先行しないようにするためには、this
をつけてメンバにアクセスします。
template<typename T>
struct Tree{
int root;
};
template<typename T>
struct Star : public Tree<T>{
Star(){
this->root = 0;
}
};
this
は struct Star : public Tree<T>
のことなので、this
を含む記述は T
で抽象化された記述であり、root
の名前解決を特殊化のタイミングまで先延ばしにすることができます。これで、Tree
の名前解決とのずれがなくなり、無事コンパイルできます。
キャスト
あるオブジェクトの型を変えることをキャストと言います。例えば、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 種類のキャストに注意して設計を行う必要があります。
自作クラスのキャスト
自作クラスにキャストを設計するには、以下の 2 種類の方法があります。
1 : コンストラクタを書く
キャスト先の型のコンストラクタに、キャスト元の型の引数を受け取るコンストラクタを書くとキャストになります。
template<typename T>
struct MyInt{
T value;
MyInt(int v) : value(T(v)){}
};
2 : キャスト用オペレータを書く
C++ のクラスにはオペレータ ( +
,<<
,&
など ) を定義することができます。それと同じで、以下のように U
型へのキャストのオペレータを定義します。
template<typename T>
struct MyInt{
T value;
template<typename U>
operator U(){
// 自身の持つデータを U 型に整形して返す
return U(value);
}
};
暗黙的なキャスト
何も修飾しなければ暗黙的なキャストも可能な状態です。暗黙的なキャストが設計されたクラスはユーザーにとってのコードの書きやすさを向上させるので、このキャストを安全にできるようにクラスを設計することが大切です。
明示的なキャスト
キャストの宣言/定義を explicit
で修飾する事で、暗黙的なキャストを禁止することができます。
キャストが安全なコード
天下り的ですが、キャストに関して何も工夫がないコードを例示します。
以下の modint
型は、整数同士の計算を行いながら m
で割ったあまりを扱う剰余環クラスです。整数環 ( int
や long long
) に似たクラスなので、整数とキャストで繋げたいです。
#include<iostream>
// 自動で mod m の値 (あまり) をとる整数クラス
template<long long m>
class modint{
protected:
long long v;// 内部でもつ値
public:
// コンストラクタ 兼 キャスト
modint(long long v_){
v = v_%m;
if(v<0)v+=m;
}
// T 型へのキャスト
template<typename T>
operator T(){return T(this->v);}
// 自身に加算する演算
modint<m> operator +=(modint<m> x){
this->v += x.v;
this->v %= m;
return *this;
}
// 2 つの modint<m> オブジェクト同士の足し算
friend modint<m> operator +(modint<m> a, modint<m> b){
return (a+=b);
}
};
int main(){
modint<9> a = 8;
long long sum = a + 4;// ここに注目なのだ
std::cout << sum << std::endl;
}
メイン関数に a + 4
と書かれているのが見えますか。これがまずいです。
modint
は整数型と双方向に暗黙的なキャストができるように設計されているため、a + 4
は a + modint<9>(4)
なのか int(a) + 4
なのかがわかりません。実際、エディタなどには怒られます。
しかし、このコードは不本意ながらコンパイルが通ります。実はキャストにはコンパイラによって優先順位が決まっているからです。ですが、コンパイラによる優先順位に頼らない安全なキャストを書くのがプログラマの仕事なのです。
NokonoKotlin (執筆者) のプログラミング手法
2 つのクラス A と B にキャストを設計するとき、プログラマは A , B どちらを優先するかの順序を決める必要があります。優先したいクラスへのキャストは勝手に行われてほしいので、優先したいクラスへのキャストを暗黙的にします。また、優先しないクラスへのキャストを明示的にします。
例えば先に例示した int
と modint
の例では、modint
が使われている以上は int
よりも modint
を優先したいはずなので、modint
へのキャストを暗黙的にし、modint
-> int
のキャストは明示的にします。
こうすることで、a + 4
の部分は a + modint<9>(4)
を一意に指すようになって解決です。
基本的に C++ の組み込み型や標準ライブラリ ( int
や std::string
など ) の優先順位は最低にしておくことが多いです。また、優先順位がない場合は、どちらも明示的なキャストにすると良いです。
例外処理
例外処理とは、プログラム内で意図しない動作が行われたときにエラーを投げて、それを受け取ったプログラムにエラーを処理させることです。この場合のエラーとは、実行時エラーやコンパイルエラーではなく、あくまでプログラムは動作しているが自分の意図した動作と異なることを指しています。
C++ では、try
スコープの中で発生した例外(エラー)を、catch
スコープで受け取って処理する形になります。この時、throw
を使って catch
スコープに例外を投げます。
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
スコープ直後の catch(int er)
が int
型の例外をキャッチして、例外処理を行います。
このように例外が起こりその例外を投げると、投げられた例外は自身が発生したスコープから抜け出して、上位のスコープを遡って catch
スコープを探します。つまり、例外が投げられると、その例外はスタックに蓄えられた関数などのスコープを遡ります。
よって、現在実行しているスクープよりも上位のスコープに catch
が用意されているならば、今のスコープに try
や catch
を記述しなくても例外を投げることができます。投げた例外は自身よりも上位のスコープを遡って catch
を探すので、例外処理は上位スコープに丸投げできるからです。以下の例を見てみましょう。
void make0(int *p){
if(p == nullptr)throw int(0);
else *p = 0;
}
int main(){
try{
make0(ptr);
}
catch{
printf("例外が投げられました\n");
}
}
この場合、make0()
で投げられた例外は、自身のいるスコープに catch
がないので自分の 1 つ上位スコープである main()
の try
スコープに移ります。その後、try
スコープの直後で catch
されて例外処理されることになります。
また、例外を投げて良いのは try
スコープの中(下位のスコープも含む)のみです。例外処理の設計は、try
で例外を検出して、catch
で受け取るというものです。具体的には、try スコープ (及び下位スコープ)で発生した例外は上位スコープを遡り、最終的には try
スコープから抜け出して直後に記述された catch
スコープに渡されるという流れになります。ですから、例外が発生する(投げられる)なら try
スコープの中でという形になります。なお、catch
は try
の直後にしか書けません。
また、上位スコープに例外を投げるような関数が発生させる(上位に投げる)例外の種類(型)を制限する機能もあります。
void func() throw(std::string) { }
のように、関数宣言の直後に throw(投げる例外の種類)
と記述すると、func
が発生させる例外の種類を制限することができます。この場合だと func
関数は std::string
型以外の例外を投げることができません。もし func
が int
など他の型の例外を投げた場合、プログラムは unexpected
関数を呼び出して異常終了します。
おわりに
長々とお付き合いありがとうございました。間違ったこととか書いていればドシドシ指摘してください!!!