2014/01/09

C++Tips1 スマポと所有権

Tipsシリーズは言語仕様をある程度は把握した初心者のために入門書には載っていないかもしれない事柄を取り上げて実際の製作に役立つようにまとめたものです。今回はC++のTips1回目です。スマポを取り上げ、所有権の概念を身につけましょう。

スマポは、生ポインタ――通称、生ポ――よりも使う人に優しいポインタの機能を備えたクラスです。要するに、メモリ管理の一部を手伝ってくれる機能です。

プログラマは何らかのデータをメモリに確保するときに、スタックに入れるかヒープに入れるか選択できます。C++では、関数の中で変数を宣言するとスタックに確保され、newするとヒープに確保されます。スタックに確保された変数は関数が終了したときに全て開放されます。ところがnewされたデータはdeleteするまで残り続けます。使わなくなったらdeleteしなければなりません。そうでないと、他のアプリケーションの使えるメモリが減ってしまいます……。(タスクマネージャでメモリ使用量を確認してみて!)

さて、C#だとその辺をうまくやってくれます。つまり、newしてもプログラマがdeleteしないで勝手に裏でdeleteしてくれます。この勝手にやってくれる機能をガベージコレクション(GC)というのですが、C++にはGCがありません。しかし、C++には便利なライブラリ、スマポがあります。

■■■■スマポのいろは■■■■

スマポは、newしたポインタを保持しますが、このポインタが使われなくなったらdeleteしてくれるものです。スマポには次の3種類あります。

1. shared_ptr
2. unique_ptr
3. weak_ptr

include <memory>

でこれらを使うことができます。

1. shared_ptr
#include <iostream>
#include <memory>
using namespace std;

struct S {
public:
    int x;
    S(int x) : x(x) { }
    ~S() { cout << "delete(" << x << ")" << endl; }
};

struct C {
    shared_ptr<S> p;
};

int main(void) {
    shared_ptr<S> p2;

    {
        C c;
       
        {
            shared_ptr<S> p(new S(1)); // newしてそれをshared_ptrで包んであげる
            p2 = p; // ほかのスマポに代入
            c.p = p; // 他のスマポに代入
        } // pは消えたが、c.p及びp2がS(1)を参照している

        c.p = shared_ptr<S>(new S(2)); // c.pはS(2)を参照するようになった
    } // cが消えたためS(2)はどこからも参照されなくなって消える
   
    p2.reset(); // p2が消えたためS(1)はどこからも参照されなくなって消える
   
    cin.get();
    return 0;
}

shared_ptrは複数のshared_ptrに代入できて(今回はpとp2)、そのどれからも参照できなくなったときにオブジェクトが破棄されます。上記の例では、delete(2)の次にdelete(1)が表示されます。

スマポには所有権という概念があって、shared_ptrは所有権を共有するという意味合いがあります。つまり、shared_ptr同士で同じポインタを指すことができるわけです。参照先をコピーしたければ代入すればいいのです。(p2 = p)

所有権をコピーしないで移譲する場合、std::moveを使うことで実現できます。以下の例では、所有権を移動した場合としていない場合の違いが分かります。所有権については後半で詳しく触れます。

struct S {
public:
    int x;
    S(int x) : x(x) { }
    ~S() { cout << "delete(" << x << ")" << endl; }
};

struct C {
    shared_ptr<S> p;
};

int main(void) {
    C c;
    shared_ptr<S> p_a(new S(1));
    shared_ptr<S> p_b(new S(2));

    cout << "start" << endl;

    {
        shared_ptr<S> p1 = move(p_a); // 所有権の移譲
        shared_ptr<S> p2 = p_b;
    } // p1とp2は破棄され、p1はp_aから移譲されたのでp_a、すなわちS(1)が破棄

    cout << "end" << endl;

    cin.get();
    return 0; // S(2)が破棄
}

resetメソッドでスマポの強制開放ができます。つまりdeleteしてくれます。また、swapメソッドでスマポの交換ができます。

shared_ptr<S> p_a(new S(1));
shared_ptr<S> p_b(new S(2));

p_a.swap(p_b); // p_aはS(2)を指します
p_a.reset(); // S(2)が開放されます

生のポインタを取得するには、getメソッドを用います。

shared_ptr<S> p(new S(1));
S* row = p.get(); // 生ポを取得

row->x = 100; // 生ポを使ってメンバ変数に代入
cout << p->x << endl; // スマポを使ってメンバ変数の取得

2. unique_ptr
shared_ptrは1つの生ポを複数のスマポが参照できますが、unique_ptrではそうさせないようにできます。つまり、以下のように書くとエラーになります。

unique_ptr<S> p(new S(1));
unique_ptr<S> p2 = p; // コンパイルエラー

しかし、moveして移譲する場合はおkです。

unique_ptr<S> p2 = move(p); // pは使えなくなる

shared_ptrと同様にresetメソッドを使ってp.reset()とすれば破棄されます。同様にswapメソッドやgetメソッドを持っています。

配列のスマポ
newで複数のインスタンスを確保した場合、delete[]で開放しなければなりません。スマポでも内部で扱いを変えます。unique_ptrでは問題なく、以下のように配列を格納できます。

struct S {
public:
    ~S() { cout << "delete" << endl; }
};

に対して、

unique_ptr<S[]> p(new S[3]);
p.reset(); // 開放される

とできます。ところが、

unique_ptr<S*> p(new S[3]);

ではコンパイルエラーとなります。deleteの動作をポリシーによって規定することでshared_ptrでも配列を格納できます。std::default_deleteを使います。

shared_ptr<S> p(new S[3], default_delete<S[]>());

3. weak_ptr
複数のshared_ptrがあった場合、循環参照という有名な問題が発生して、リソースが開放されません。例えば、クラスS, Cがあって、クラスS, Cは互いにそれぞれC, Sのshared_ptrを持っているとしましょう。

struct C;

struct S {
public:
    shared_ptr<C> c;
    ~S() { cout << "delete" << endl; }
};

struct C {
public:
    shared_ptr<S> s;
    ~C() { cout << "delete" << endl; }
};

このとき、次のようなプログラムは意図したとおりに破棄されます。

{
    shared_ptr<S> p(new S()); // pはスコープを抜けると破棄され、S()も破棄されるはず
    p->c = shared_ptr<C>(new C()); // クラスSのメンバ変数cにCのスマポを代入
} // pが破棄され、S()も破棄され、伴ってC()も破棄される

ところがこのソースに一文、循環参照をするコードを加えると開放してくれなくなります。

{
    shared_ptr<S> p(new S());
    p->c = shared_ptr<C>(new C());
    p->c->s = p; // クラスS, Cが互いに参照し合って消えてなくならない!
}

もちろん、次のように生ポを使って循環参照を作り上げることもできます。やってることは同じですね。

{
    S* p_s = new S();
    C* p_c = new C();

    p_s->c = shared_ptr<C>(p_c);
    p_c->s = shared_ptr<S>(p_s);
}

このように循環参照となってしまうとshared_ptrでは参照していることを意識しすぎてリソースを開放できません。そこでweak_ptrの登場です。こちらは参照していることを意識しない、すなわちすまぽでありながら普通のポインタのように何もしないポインタとして振る舞います。

struct C;

struct S {
public:
    weak_ptr<C> c;
    ~S() { cout << "delete" << endl; }
};

struct C {
public:
    weak_ptr<S> s;
    ~C() { cout << "delete" << endl; }
};

int main(void) {
    {
        S* p_s = new S();
        C* p_c = new C();
        shared_ptr<S> smart_s(p_s);
        shared_ptr<C> smart_c(p_c);

        p_s->c = smart_c; // shareからweakへ
        p_c->s = smart_s; // shareからweakへ
    } // smart_s, smart_cの寿命が尽きる

    cin.get();
    return 0;
}

このようにweak_ptrはshared_ptrを保持するが、shared_ptrのように参照していることを記録しません。結果的にshared_ptrはスコープを抜けると開放され、S()もC()も開放されます。ところでこれ、

{
    S* p_s = new S();
    C* p_c = new C();
   
    p_s->c = shared_ptr<C>(p_c);
    p_c->s = shared_ptr<S>(p_s);
}

とかくとコンパイルは通りますが、実行時にメモリアクセス違反となってしまいます。なぜでしょう? というのも簡単で、shared_ptr<C>(p_c)自体は他の変数に格納していないため、一時オブジェクトで、代入演算子が終わると破棄されてしまいます。この時点でC()は破棄されます。(なぜならweak_ptrは参照していることを記録しないからです。)C()が破棄されたと言うことは、p_cはもう意味のないポインタとなってしまいます。従ってp_c->sでアクセス違反となります。

weak_ptrはshared_ptrを弱めたものと言うことです。weak_ptrはlockメソッドを持っています。こちらはshared_ptrを返します。weak_ptrはあくまでもshared_ptrを格納するためのものですから、こいつを直接使ってアクセスできません。

なお、swapとresetは使えます。swapは他のweak_ptrと入れ替えます。

スマポは、unique_ptrをできる限り使った方が高速らしいです。shared_ptrはweak_ptrと一緒に使い分けます。

ところで、スマポの元のオブジェクトを破棄する方法はdeleteだけとは限りません。というのも、例えばfopenで取得したFILE*はfreeしてもdeleteしてもいけません。fclose(fp)としなければならないのです。このように開放の仕方が異なるものもスマポは対応しています。これをカスタムデリータと言いますが、ここでは割愛します。(そろそろ寝たいです……)

あと、shared_ptrに格納する際に、std::make_shared<T>(Tのコンストラクタに渡す実引数)を使った方が動作が高速化します。

■■■■所有権のいろは■■■■
スマポを使う上で所有権という概念は必須です。上でstd::moveという関数を使いましたが、これは所有権の移動を明示するメソッドです。スマポに限らず使用できます。moveは移動を直接するわけではなく、あくまでも移動すると言うことを「move」という名前を使ってプログラム上で目で見てわかるようにするために存在しています。moveの実体は右辺値参照にキャストするだけです。(なんらオーバーヘッドになりません)

右辺値を取るコンストラクタがあります。これをムーブコンストラクタと言います。以下のコードではコンストラクタの呼び出しを確認するもので、実際にはムーブされません。当然コピーもされません。(実体は生成されますが)

struct C {
    C() { // コンストラクタ
        cout << "create" << endl;
    }

    C(C&) { // コピーコンストラクタ
        cout << "copy" << endl;
    }

    C(C&&) { // ムーブコンストラクタ
        cout << "move" << endl;
    }

    ~C() {
        cout << "delete" << endl;
    }
};

int main(void) {
    {
        C c; // create
        C c2 = c; // copy
        C c3 = move(c); // move
    } // delete ×3

    cin.get();
    return 0;
}

コピーコンストラクタは、コンストラクタの引数として自身のクラスのインスタンスを渡した場合に呼び出されます。そのため、本来は以下のように呼び出します。

C c2(c); // cを渡してc2を初期化、すなわちコピー

ところが、

C c2 = c;

と書いてもコンストラクタが呼び出される仕様となっています。

さて、ムーブとは、すなわち、以前のオブジェクトを無効化して、新しいオブジェクトに移す動作のことです。ところが、そのムーブの仕方はクラスの設計によるものです。従ってどうすべき、というのはないのですが、例としてあるint型のポインタを保持しているクラスCを考えましょう。

このクラスCはコピーコンストラクタを持っています。

struct C {
    int* p;
    C(int x) : p(new int(x)) {
        cout << "create" << endl;
    }

    C(C& it) : C(*it.p) { // もとのpの実体を使って初期化
        cout << "copy" << endl;
    }

    ~C() {
        delete p;
    }
};

もしもコピーコンストラクタを自前で定義しないとしたら、ポインタ自体がコピーされてしまい、int型の実体は1つとなってしまいます。コピーコンストラクタのおかげで以下のようにコピーが成功します。

C c(0);
*c.p = 10;
C c2 = c; // ここでコピー
cout << *c.p << ", " << *c2.p << endl;
*c2.p = 20; // c2のpの実体だけ書き換え
cout << *c.p << ", " << *c2.p << endl;

コピーでは元の実体を残しましたが、ムーブでは元の実体は必要なくなります。ということは、新しくint型をnewしないで、元のポインタをそのまま使ってしまえばいいことになります。これを実装するためにムーブコンストラクタを使います。

struct C {
    int* p;
    C(int x) : p(new int(x)) {
        cout << "create" << endl;
    }

    C(C&& it) : p(it.p) { // もとのpをそのまま流用
        it.p = nullptr; // もとのpは使えなくする
        cout << "move" << endl;
    }

    ~C() {
        delete p;
    }
};

int main(void) {
    C c(0);
    *c.p = 10;
    C c2 = move(c); // ここでムーブ
    cout << c.p << endl; // このポインタの実体はない
    cout << *c2.p << endl;

    cin.get();
    return 0;
}

ポイントは、元のpをnullptrで使えなくすることです。なお、nullptrは昔で言うNULLです。C++11から導入されましたので、対応していないコンパイラを使っている場合はNULLとか0とか入れる必要があります。nullptrをdeleteしても何も起こりませんので元のオブジェクトは安心して破棄できます。

このようにポインタを差し替えることを所有権の移動といいます。同じポインタを指す場合は所有権の共有、値をコピーして新しく実体を作る場合は関係ありません。

moveを使うと所有権が移動した感じがするでしょう? 別に使わなくても実現できますが、moveという名前のおかげで移動したように実感できてみんなハッピーになります。

0 件のコメント:

コメントを投稿