ワーサー王列伝

主にプログラミング?

初心者向けC++11機能の解説

2018年に初心者にC++を教える機会があったので、教えたことをまとめた資料を引っ張り出してきました。

最低限のC++11の機能に関してをまとめている初心者向けの記事になります。

auto型の話

auto型はC++11で登場した、変数の型をコンパイルのときに推論してくれる便利な型です。 (C#Javaでいうところのvarですね。)

例えば以下のコードだと、変数"i"の型はint型になります。

auto i = 7;

初期化のために代入された値をもとに、コンパイラが"auto"の部分にどんな型が入るのかを勝手に考えてくれるのです。すごい!

これがどういうときに便利かというと、例えば以下の場合が考えられます。

vector<int> array = {1, 2, 3, 4, 5};
    
for(vector<int>::iterator itr = array.begin(); itr != array.end(); itr++){
    cout << *itr;
}

典型的なイテレータを使ったコンテナの走査ですね。 この中で、あなたが一番タイプしたくないなーと思うのはどこでしょうか?

はい、"vector::iterator"ですね。間違いないでしょう。 何が楽しくてこんな長い型名を書かなければならないのでしょうか。こんなものはauto型にしてコンパイラに書かせてしまいましょう。

vector<int> array = {1, 2, 3, 4, 5};
    
for(auto itr = array.begin(); itr != array.end(); itr++){
    cout << *itr;
}

良いですね、とても賢いことをしている気分です。 ところで、"vector"も少し長い型名のように思われます。これもauto型に変えて、もっと良いコードに出来るのではないでしょうか?

手元でやって頂けると分かると思いますが、上述のコードの"vector"を"auto"に書き換えても、全く出力結果は変化しません。これは成功なのでしょうか?

次に、auto型で推論したarrayに"6"を追加してみましょう。コードは以下のようになるはずです。

auto array = {1, 2, 3, 4, 5};

//コンパイルエラー
array.push_back(6);

for(auto itr = array.begin(); itr != array.end(); itr++){
    cout << *itr;
}

はい、このコードはコンパイルできません。なぜでしょうか?

"array"の型を調べるために、6を追加する一行(上のコードの4行目)をコメントアウトし、以下のようなコードを挿入してみましょう

cout << typeid(array).name() << endl;

typeidは型の情報を取得するための演算子で、このコードでは型の名前を取得しています。出力結果はコンパイラにも依りますが、VisualStudio2017では以下のように出力されます。

class std::initializer_list<int>

見覚えのない型ですが、明らかにvector型ではないのは確かです。 (ちなみに、initializer_listは波括弧での配列の初期化に使われる型の名前です。)

このとき、auto型が何を表しているのかを推論するため、コンパイラが得られる情報は初期化部分の{1,2,3,4,5}だけです。これでは、vector型の初期化なのか、それともint型配列の初期化なのか、区別がつきません。よって、auto型はvectorを推論することが出来なかったのです。

このように、auto型は長い型名のすべてを滅ぼしてくれるものではありません。コンパイラが理解できるよう、初期化の値に気を付けることが大切です。

また、この性質からauto型が使えるのは、初期化のできる部分に限られます。使えない部分として挙げられるのは、 * メンバ変数 * 通常の関数の引数

などです。

C++14ではさらに機能が拡張され、関数の戻り値などでもauto型を利用できるようになりました。何と素晴らしい!

auto func(const vector<int>& array, int num) {
    return array.begin() + num;
}

int main()
{
    vector<int> array = { 1, 2, 3, 4, 5 };

    auto itr = func(array, 3);

    cout << *itr << endl;
}

enum classの話

列挙子enumは、定数に名前を付けて扱うことができ、コードの可読性を大きく高めてくれます。しかし、C++におけるenum名前空間を汚染し、さらには多くの暗黙的キャストを引き起こす扱いづらい存在でもあります。

例えば以下のコードは、名前が被るためにコンパイルできません。

enum scene {
    title,
    game,
    menu,
};

//コンパイルエラー gameは列挙子として既に定義されている
void game() {
    //ゲーム画面の処理
}

上記のコードでは、列挙子として"game"が定義されるため、関数としてのgameの定義がコンパイルエラーになっています。

enumでは、このように定義された列挙子の一つ一つがグローバル変数のように名前を独占しようとします。これを防ぐには、列挙子の命名規則を統一するなど、手間と対策が必要になってしまいます。

また、enumで定義された列挙子は暗黙的に整数型や浮動小数型にキャストされてしまいます。なんと、以下のコードも何のエラーもなく実行できてしまいます。

enum scene {
    title,
    game,
    menu,
};

int main()
{
    int i = menu;
    cout << i << endl; //2

    double d = abs(game);
    cout << d << endl; //1

    float f = (title + 0.1f) / 5.0f - 1.11f;
    cout << f << endl; //-1.09
}

この列挙子は、おそらくゲームのシーンを表すものなのですが、gameシーンの絶対数って、いったい何なのでしょうか?

このように、enumの列挙子の暗黙的キャストは、便利な時もありますが、一方で支離滅裂なコードを許容してしまうこともあるのです。

はい、ここでenum classの登場です。enum classの大きな特徴は以下の二つです。

まず一つ目に、enum classはスコープ付き列挙子とも呼ばれ、{}がしっかりとスコープとしての役割を果たし、名前被りを防ぎます。つまり、以下のコードが実行可能になります!

enum class scene {
    title,
    game,
    menu,
};

void game() {
    //ゲーム画面の処理
}

このコードは全く問題なく動作します。その代わり、列挙子としての"game"を使うには以下のようなコードを書く必要があります。

int main()
{
    scene gameScene = scene::game;
    switch (gameScene) {
    case scene::title:
        title();
        break;
    case scene::game:
        game();
        break;
    case scene::menu:
        menu();
        break;
    }
}

このように、列挙型sceneはスコープを持つので、名前空間やクラスの定数にアクセスするように、型名と二連コロン(::)をつけて呼び出します。

もう一つの特徴は、暗黙的な変換が行われないことです。enum classを使えば以下のコードはコンパイルエラーになります。

enum class scene {
    title,
    game,
    menu,
};

int main()
{
    int i = menu; //コンパイルエラー
    cout << i << endl;

    double d = abs(game); //コンパイルエラー
    cout << d << endl;

    float f = (title + 0.1f) / 5.0f - 1.11f; //コンパイルエラー
    cout << f << endl;
}

これで、列挙子が勝手に整数型や浮動小数型に変換されることはなくなりました。しかし、明示的な変換を行うことは出来ます。C++のキャスト演算子のひとつであるstatic_castを用いると、以下のように配列の要素数としてenum classの列挙子を用いることが出来ます。

enum class status {
    HP,
    MP,
    ATK,
    DEF,
};

int main()
{
    int PlayerStatus[4] = { 100, 10, 10, 10 };
    cout << PlayerStatus[static_cast<int>(status::HP)] << endl;
}

スマートポインタの話

メモリの動的確保によるメモリの解放漏れ(メモリリーク)はC++プログラマーの最大の敵の一つと言えるでしょう。

C++でメモリを動的に確保する場合、多くはnew演算子が用いられます。そして、new演算子で確保したメモリは必ずdelete演算子を用いて解放しなくてはなりません。

int main()
{
    int* array;
    array = new int[5];
    
    for (int i = 0; i < 5; i++)
        array[i] = i;

    for (int i = 0; i < 5; i++)
        cout << array[i] << endl;

    delete[] array;
}

上記のコードでは、配列を動的に確保しているため、メモリの解放にはdelete[]を用います。

delete演算子を適切に使わず、メモリリークが発生した場合、解放されないメモリはプログラムの終了まで残り、パフォーマンスの低下や、最悪の場合システムの停止に繋がる恐れがあります。

そこで、new演算子を使ってメモリを確保したときに、必ず対となるメモリの解放が行われるような仕組みを考える必要がありました。

C++では、ある処理を行ったときに必ず対となる処理を行う機構がすでに存在しています。それは、コンストラクタとデストラクタです。例えば、以下のようにすればメモリの解放漏れはなくなります。

class X {
public:
    X() { i = new int; }
    ~X() { delete i; }
private:
    int* i;
};

これを一般化して、既存のポインタの機能のほとんどを使えるようにしたものをスマートポインタと言います。

現在のC++11以降では、主に2つのスマートポインタが用いられています。どちらもヘッダをインクルードすることで使うことが出来ます。

shared_ptrは代表的なスマートポインタの一つです。"*"を使った参照外しや、コピーなど、通常のポインタと同じような操作でスマートポインタの恩恵を受けることが出来ます。メモリの確保にはmake_sharedを使います。コードは以下のようになります。

class X {
public:
    int i;
    X(int i) : i(i) {};
};

int main()
{
    shared_ptr<int> num;     //宣言
    num = make_shared<int>();    //メモリ確保
    cout << *num << endl;       //出力: 0

    shared_ptr<X> x1;     //宣言
    x1 = make_shared<X>(7);  //メモリ確保
    cout << x1->i << endl;       //出力: 7

    shared_ptr<X> x2;     //宣言
    x2 = x1;            //コピー
    cout << x2->i << endl;       //出力: 7
}

もう一つ、unique_ptrはコピーの挙動がshared_ptrと異なり、厳密にはコピーを行えません。unique_ptrは"="演算子とstd::moveを用いて、ポインタの中身を受け渡します。

class X {
public:
    int i;
    X(int i) : i(i) {};
};

int main()
{
    unique_ptr<int> num;     //宣言
    num = make_unique<int>();    //メモリ確保
    cout << *num << endl;       //出力: 0

    unique_ptr<X> x1;     //宣言
    x1 = make_unique<X>(7);  //メモリ確保
    cout << x1->i << endl;       //出力: 7

    unique_ptr<X> x2;     //宣言
    x2 = move(x1);          //ムーブ
    cout << x2->i << endl;       //出力: 7

    if (!x1) cout << "x1はnull" << endl;
}

この値の受け渡しをムーブと呼びます。ムーブに関しては、ここではあまり掘り下げませんので、詳しくはムーブセマンティクスで調べると、有益な結果が得られるのではと思います。

ここまで見ると、unique_ptrはshared_ptrより使い難いもののように感じるかもしれません。しかし、unique_ptrはコピーされないことが保証されているので、メモリを解放しなければならないタイミングを簡単に判断することが出来ます。一方で、shared_ptrはコピーを許可しているために、全てのコピーが消去されたかどうかを判断して、メモリを解放をしなくてはなりません。

この差は大きく、shared_ptrはコピーが消去されているかをカウンタを用意して追跡する必要があります。これは、メモリの観点からも、処理の観点からもコストの高いものになります。

また、unique_ptrの機能は全てshared_ptrに実装されているので、コピーが必要になったときに、今までunique_ptrだったものをshared_ptrに変更するのはとても簡単になります。

これらのことから、動的メモリの確保が必要になった場合、まずunique_ptrの使用を検討するのがよいと言えるでしょう。

ラムダ式の話

関数オブジェクトというものを聞いたことがあるでしょうか。自分で演算子を定義できる演算子オーバーロードの中には()演算子というものがあります。()演算子用いるとまるで関数のように呼び出せるクラスを作ることが出来ます。これを、関数オブジェクトと呼びます。

class Square {
public:
    int operator()(int x) { return x * x; }
};

int main()
{
    Square square;
    cout << square(7) << endl; //49
}

このコードでは、引数としてint型の整数を受け取り、二乗して返す関数オブジェクトを定義しています。

関数オブジェクトはただのクラスなので、もちろんメンバも持つことが出来ます。関数オブジェクトを用いればいいのようなコードが実現できます。

class Pow {
public:
    Pow(int exp) : exponents(exp) {}; //コンストラクタ

    int operator()(int x) {
        int ret = x;
        for (int i = 0; i < exponents - 1; i++) {
            ret *= x;
        }
        return ret;
    }
private:
    int exponents; //乗数
};

int main()
{
    Pow pow2(2);
    cout << pow2(7) << endl; //49

    Pow pow7(7);
    cout << pow7(7) << endl; //823543
}

この関数オブジェクトは、指定した乗数で整数を指数演算してくれるものです。コンストラクタの引数をメンバに格納しておき、()演算子が呼ばれたときに使用しています。

はい、ここまで長々と関数オブジェクトについて説明してきましたが、ラムダ式というのはこの関数オブジェクトを定義することの出来る構文です。そして、ラムダ式で定義された関数オブジェクトは、関数の戻り値と同じように名前がないことから、無名関数とも呼ばれます。

例えば、上記の整数を二乗する関数オブジェクトは以下のように定義できます。

int main()
{
    auto square = [](int x) { return x * x; };
    cout << square(7) << endl; //49
}

劇的にコードが短くなりましたね。感動すら覚えます。 ラムダ式は" { return x * x; }"の部分です。

始めの[]は一旦置いておいて、次の()には引数を記述します。最後の{}の中には通常の関数と同様に関数の定義を記述します。

ラムダ式で定義した関数オブジェクトは"="演算子で受け取るだけでインスタンス化され、今回のコードでは"square"に格納されます。ラムダ式インスタンスを受け取る変数の型には、auto型や、のstd::functionが使われます。std::functionを用いた例は以下のコードになります。

int main()
{
    function<int(int)> square = [](int x) { return x * x; };
    cout << square(7) << endl; //49
}

テンプレート引数に関数シグネチャを渡すことで、同じシグネチャ(引数と戻り値)の関数や関数オブジェクトを格納することが出来ます。

ラムダ式は関数オブジェクトなので、もちろんメンバを持つことが出来ます。 上記の任意の乗数で整数を指数演算する関数は、以下のように実装できます。

int main()
{
    int exp;
    cin >> exp;
    auto pow = [exponents = exp](int x) {
        int ret = x;
        for (int i = 0; i < exponents - 1; i++) {
            ret *= x;
        }
        return ret;
    };
    cout << pow(7) << endl;
}

先ほど置いておいたの中身では、ラムダ式で定義されるメンバを初期化することが出来ます。このコードでは乗数を表す"exponents"という変数をつくり、変数"exp"の値で初期化しています。

ラムダ式はに変数の名前をそのまま書くことも出来ます。ここで書かれた変数はラムダ式の中でそのまま使うことが出来ます。これをキャプチャと呼びます。上記のコードのラムダ式の部分を以下のようにしても、全く同じように動作します。

auto pow = [exp](int x) {
    int ret = x;
    for (int i = 0; i < exp - 1; i++) {
        ret *= x;
    }
    return ret;
};

また、[=]と記述することですべてのオブジェクトをコピーしてキャプチャし、[&]と記述することですべてのオブジェクトの参照をキャプチャすることが出来ます。

クラスの関数定義内でラムダ式を用いる場合、thisポインタをキャプチャすることでメンバ関数ラムダ式の中で使うことが出来ます。

class X {
public:
    int square(int x) { return x * x; }
    int func(int x) {
               //squareを呼び出すだけのラムダを定義
        auto doSquare = [this](int x) { return square(x); };
        return doSquare(x);
    }
};

int main()
{
    X x;
    cout << x.func(7) << endl; //49
}

そして、ラムダ式の真価はSTLと併用することで発揮されます。 STLには関数オブジェクトを引数にとるアルゴリズムが無数に存在します。代表的なものとして、ヘッダのstd::for_eachの使い方を以下に示します。

int main()
{
    vector<int> array = {1, 2, 3, 4, 5};
    for_each(array.begin(), array.end(), 
        [](int i) { cout << i << endl; });
}

for_eachは、イテレータ二つと関数を引数に取り、第一引数のイテレータから第二引数のイテレータまで順に、イテレータの中身を渡して関数を呼び出します。

ラムダ式を用いれば、このように簡潔なコードでSTLのアルゴリズムを使用することが出来ます。