7
7

C++ 特別講義 - これだけでスーパー C++ プログラマになれるのだ

Last updated at Posted at 2024-06-02

まえがき

この記事は投稿者(NokonoKotlin)の個人サイトの記事から Qiita 用に移植 & 加筆したものです。
(この文言は記事が剽窃でないことを周知するためのものであり、個人サイトの宣伝ではないことをあらかじめご了承ください。)

この記事は??

この記事では、これからの C++ 記事に先立って C++ の最低限必要な知識を書いておきます。

なお、執筆者は天才見習い Qiita ライターで AtCoder Rating 青色の超絶秀才ですが、C++ に関しては凡才なので話半分で読んでください。指摘もバシバシしてください。

目次

  1. メモリと参照
  2. const について
  3. クラスと継承/ポリモーフィズム
  4. 名前空間/名前解決
  5. テンプレート
  6. キャスト
  7. 例外処理

メモリと参照

スタックとヒープと静的領域

これらはオブジェクトを保存するメモリ領域を指し、以下の様に使い分けられます。

  • 静的領域 : 静的なオブジェクトを保存
  • スタック : ローカルなスコープで宣言されたオブジェクトを保存
  • ヒープ : グローバルなオブジェクトを保存

静的領域

static 修飾された変数はヒープやスタックではなく静的領域に置かれます。static な変数については考えるべきことがたくさんありますが、ここでは詳しく書きません。

スタック

{ ... } で囲まれた部分をローカルスコープとよびます。ローカルで宣言されたオブジェクトはスタック領域上に生成され、処理がそのスコープを抜けるとき ( .. } に到達した時 ) に破棄されます。スコープのなかにオブジェクトやスコープを追加 (ネスト) で記述することで、スタック上にオブジェクトが積み上がっている様子がデータ構造のスタックの様だと覚えることができます。

ヒープ

ヒープ領域に生成されたオブジェクトはプログラムを通して生存します。ローカルなスコープ内でプログラム全体を通して使いたいオブジェクトを生成する場合は、プログラマがヒープ上の領域を確保し、直接オブジェクトを生成する必要があります。C 言語スタイルではヒープ上のメモリを確保/解放するのに malloc/free を用いていたのに対して、C++ スタイルでは new/delete を用います。

Obj* p = new Obj() と書くことで Obj 型のオブジェクトをヒープ領域上の生成します。Obj のポインタ p は Obj が生成されたアドレスを示します。delete p と書くことでポインタ p に格納されたアドレス上のオブジェクトを破壊してメモリを解放します。

また、delete pdelete[] 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

注目ポインツ

  1. ポインタ型の変数の前に "*" を付けることで、メモリ上のオブジェクトに直接アクセスできます。
  2. 参照型の変数はそれ自体がメモリ上のオブジェクトを直接示します。
  3. 変数 x もメモリ上のオブジェクトの参照です。
  4. 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 つの名前解決を行うことを指します。

  1. テンプレートパラメータで抽象化されていない記述は、即座に名前解決される。
  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;
    }
};

thisstruct 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 で割ったあまりを扱う剰余環クラスです。整数環 ( intlong 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 + 4a + modint<9>(4) なのか int(a) + 4 なのかがわかりません。実際、エディタなどには怒られます。

しかし、このコードは不本意ながらコンパイルが通ります。実はキャストにはコンパイラによって優先順位が決まっているからです。ですが、コンパイラによる優先順位に頼らない安全なキャストを書くのがプログラマの仕事なのです。

NokonoKotlin (執筆者) のプログラミング手法

2 つのクラス A と B にキャストを設計するとき、プログラマは A , B どちらを優先するかの順序を決める必要があります。優先したいクラスへのキャストは勝手に行われてほしいので、優先したいクラスへのキャストを暗黙的にします。また、優先しないクラスへのキャストを明示的にします。

例えば先に例示した intmodint の例では、modint が使われている以上は int よりも modint を優先したいはずなので、modint へのキャストを暗黙的にし、modint -> int のキャストは明示的にします。

こうすることで、a + 4 の部分は a + modint<9>(4) を一意に指すようになって解決です。

基本的に C++ の組み込み型や標準ライブラリ ( intstd::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 が用意されているならば、今のスコープに trycatch を記述しなくても例外を投げることができます。投げた例外は自身よりも上位のスコープを遡って 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 スコープの中でという形になります。なお、catchtry の直後にしか書けません。

また、上位スコープに例外を投げるような関数が発生させる(上位に投げる)例外の種類(型)を制限する機能もあります。

void func() throw(std::string) { } のように、関数宣言の直後に throw(投げる例外の種類) と記述すると、func が発生させる例外の種類を制限することができます。この場合だと func 関数は std::string 型以外の例外を投げることができません。もし funcint など他の型の例外を投げた場合、プログラムは unexpected 関数を呼び出して異常終了します。

おわりに

長々とお付き合いありがとうございました。間違ったこととか書いていればドシドシ指摘してください!!!

7
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
7