Chapter 03

Modern C++時代の動的な変数アロケーション

dec9ue
dec9ue
2021.12.30に更新

ムーブセマンティクスについて、ざっくり説明が終わりました。土台が固まったので、Modern C++時代の変数のアロケーションの考え方について説明していきます。

Modern C++では基本的にアプリケーションコードに new キーワードを書くことはありません。次の4パターンのいずれかを使ってアロケーション/構築を行います。

  • ローカル変数として構築する
  • make_unique を使用して構築する
  • make_shared を使用して構築する
  • ターゲットのオブジェクトの中に構築する

変数アロケーションの選択肢

ローカル変数として構築する

オブジェクトを構築するのであれば、ローカル変数として構築するのが第一の選択肢です。

.ローカル変数として構築する

MovableObj function(){
    MovableObj mc;
    return mc;
}

ローカル変数として宣言しても、ムーブのコストが小さいのであればそのままあちこちに引き回せることになります。

代表的な動的構造である std::stringstd::vectorstd::map のようなオブジェクトはムーブのコストが小さいのでそのまま引き回すことになります。

代入やオブジェクトの引き渡しが発生した際に、オブジェクトがムーブされたのかコピーされたのか確実に把握して有効なデータのありかを管理しましょう。

make_unique で構築する

ムーブできなかったり、ムーブの効率の悪いオブジェクトを引き回したいときは unique_ptr を使って引き回しをすればよいでしょう。

かつて new で行っていたオブジェクト生成は unique_ptr を生成する make_unique 関数で代替するのが第一候補になります。実行効率的には生のポインタをハンドリングすることとほぼ同等とされています。

.make_uniqueを使う1

std::unique_ptr<ImmobilizedObj> foo(){
    return std::make_unique<ImmobilizedObj>(100);
    // 内部では動的な領域が確保され、ImmobilizedObj(100)相当の初期化が実施される
}

unique_ptr で確保したオブジェクトを明示的に delete する必要はありません。他のポインタを代入して参照が切れたり、 unique_ptr ポインタの寿命が切れた(unique_ptr のデストラクタが呼ばれた)際にオブジェクトを自動開放してくれます。スコープ外にオブジェクトを持ち出したければ unique_ptr 自体をスコープ外に持ち出すことで寿命を延ばせます。

unique_ptr はコピーに対応せず、ムーブのみに対応しています。代入を行えますが、常にムーブ代入が行われるため、代入元の unique_ptr は無効になり nullptr 相当に初期化されます。これにより、unique_ptr に対してはその名の通り、単一性を確保されます。つまり、make_unique で生成されたオブジェクトと unique_ptr は常に一対一の関係にあります。同一のオブジェクトが複数の unique_ptr の管理下に入ることはありません。unique_ptr の管理対象から外れたオブジェクトはデストラクタが呼ばれ領域は開放されます。

unique_ptr が管理するオブジェクトにアクセスする場合は通常、-> 演算子を経由します。このあたりは旧来のポインタに似ています。

unique_ptr から * 演算子により参照を取り出すこともできます。これにより unique_ptr 自体を受け渡すことなく、効率よく処理を移譲できますが、管理下のオブジェクトの生存期間が確実に確保されている状況でのみ実施すべきです。

下の例では foo 関数で生成されたオブジェクトを boo 関数が受け取ったあとは boo 関数が unique_ptr を保持し、bar 関数に渡すことなく握り続けています。つまり、f の生存期間を boo 関数が管理しているということです。 このような状況を「 boof の所有権を握っている」などと表現したりします。

.make_uniqueを使う2

std::unique_ptr<ImmobilizedObj> foo(){
    return std::make_unique<ImmobilizedObj>(100);
}

void bar(ImmobilizedObj& m){
    // なにか処理
}

void boo(){
    auto f = foo(); // fはfooからムーブされる
    std::cout << f->getNum() << std::endl; // fのメンバ値を出力
    bar(*f); // オブジェクトの生存期間が確保できていれば参照渡ししても良い
    // ここでやっとImmobilizedObjのデストラクタが呼ばれる
}

次の例では親オブジェクトである ParentObjunique_ptr を保持させる例を挙げます。ParentObj のデストラクタで unique_ptr のデストラクタが呼ばれ、その処理の中で ImmobilizedObj のインスタンスについてもデストラクタコールされます。

.make_uniqueを使う3(オブジェクトに対してムーブする例)

std::unique_ptr<ImmobilizedObj> foo(){
    return std::make_unique<ImmobilizedObj>(100);
}

class ParentObj
{
  private:
    std::unique_ptr<ImmobilizedObj> ptr = nullptr;
  public:
    void setImmobilized(unique_ptr<ImmobilizedObj> && ptr){
        // unique_ptrを受け取るときはムーブなので右辺値参照で受けるのが行儀が良い
        this.ptr = std::move(ptr); // ムーブ代入
    }
    ~ParentObj()
    {
        this.ptr = nullptr; // unique_ptrのデストラクタが呼び出される
        // 実は明示的にnullptrを代入しなくても~ParentObjの最後でデストラクタが呼ばれる
    }
};

void boo(){
    auto f = foo(); // fはfooからムーブされる
    {
        ParentObj p;
        p.setImmobilized(std::move(f)); // fはpに対してムーブされる
        // fはもはやムーブ済みでnullptr相当
        // fのデストラクタはpのデストラクタ経由で呼び出される
    }
    // fのスコープ内ではあるがfooが生成したImmobilizedObjのオブジェクトはこの時点ではすでに存在しない
}

覚えたばかりのムーブでポインタをハンドリングするのは多少苦しむかもしれませんが、unique_ptr の使用は基本的なイディオムになるので積極的に使用して慣れてゆくのが良いと思われます。

make_shared で構築する

unique_ptr は実行効率は良いのですが、所有者が一つに限られるため、複数のオブジェクトから参照されるような構造には向きません。このような場合に対応するため、C++では参照カウンタを使用した共有ポインタ shared_ptr を用意しています。

基本的な使用方法は unique_ptr とほぼ同じですが、 shared_ptr にはコピー系の演算も実装されており、コピーするたびに参照数を増加させ、shared_ptr のインスタンスのデストラクタが呼ばれるたびに減少させます。オリジナルや複製されたすべての shared_ptr が破棄されたとき、管理対象のオブジェクトのデストラクタを呼び、領域を開放します。

とても便利ですが unique_ptr よりは実行効率が劣るとされます。

.make_sharedを使う

std::shared_ptr<ImmobilizedObj> foo(){
    return std::make_shared<ImmobilizedObj>(100);
}

class ParentObj
{
  private:
    std::shared_ptr<ImmobilizedObj> ptr = nullptr;
  public:
    void setImmobilized(const shared_ptr<ImmobilizedObj> & ptr)
    {
        // shared_ptrをコピーするのであれば左辺値参照で受ける、
        // ムーブするのであれば右辺値参照で受けるのが行儀
        this.ptr = ptr; // ここでshared_ptrがコピーされ、参照数が増える
    }
    ~ParentObj()
    {
        this.ptr = nullptr; // shared_ptrのデストラクタが呼び出される
        // 実は明示的にnullptrを代入しなくても~ParentObjの最後でデストラクタが呼ばれる
    }
};

void boo(){
    auto f = foo(); // fはfooからムーブされる
    {
        ParentObj p1, p2;
        p1.setImmobilized(f); // fをp1に対してコピー
        p2.setImmobilized(f); // fをp2に対してコピー
        f = nullptr; // 全部で3つのコピーができたが、オリジナルはいらないので破棄
    }
    p1.setImmobilized(nullptr);
    // この時点ではImmobilizedObjのインスタンスは生きている
    p2.setImmobilized(nullptr);
    // すべてのshared_ptrのインスタンスが消えたので
    // ImmobilizedObjのデストラクタが呼ばれ破棄される

    // ここでfのスコープが切れるが、shared_ptrもImmobilizedObjも残っていない
}

ターゲットオブジェクトの中に構築する

大まかなアロケーション方法については説明してきたのですが、他のオブジェクトに含まれるオブジェクトを構築する方法について説明していませんでした。これについて少し説明します。

たとえば、コンテナなどの構造を利用する際にはコンテナに含まれるオブジェクトを初期化する必要があります。ひとたびコンテナ側にオブジェクトを構築すれば、あとの開放処理などはコンテナ側で呼び出してくれます。

コンテナの初期化を例にとって初期化のパターンを見てみましょう。

デフォルト構築する

配列などの初期化でよく発生するパターンです。デフォルトコンストラクタで構築してしまうので、その後改めてデータを上書きする必要があることが多いです。

.デフォルト構築の例

std::unique_ptr<MovableObj[]> mv;

void initializer(std::size_t size)
{
    mv = std::make_unique<MovableObj[]>(size); // この時点で配列要素はデフォルトコンストラクタで初期化

    for(int i = 0 ; i < size ; i++){
        mv[i].setString("I am " + std::to_string(i) + "th Object!"); // 追加で初期化処理を行う
    }
}

コピー構築する

初期化に使用するオブジェクト自体を使いまわしたかったり、コピーコストが安いときの手抜きとして使用するときに使用する手段です。

左辺値参照版のコンテナ追加関数 std::vector::push_back(const T&) はデータをコピーして格納します。

.コピー構築の例

std::vector<MovableObj> mv;

void initializer(std::size_t size)
{
    for(int i = 0 ; i < size ; i++){
        MovableObj m;
        m.setString("I am " + std::to_string(i) + "th Object!"); // 何やら動的な初期化をしてみた
        mv.push_back(m); // push_back(const T&)は左辺値参照を受け取りコピーして格納
        // この時点でもmは意味のあるデータを格納している
    }
}

ムーブ構築する

複雑なオブジェクトを入れ込みたいときに通常使用する方法です。汎用性が高くコピーコストを回避しながら複雑な初期化を行えます。ただし、ムーブコストも高いようなオブジェクトにはこの方法は向きません。

右辺値参照版の追加関数 std::vector::push_back(T&&) は右辺値参照を受け取り、ムーブしてデータを格納します。

.ムーブ構築の例

std::vector<MovableObj> mv;

void initializer(std::size_t size)
{
    for(int i = 0 ; i < size ; i++){
        MovableObj m;
        m.setString("I am " + std::to_string(i) + "th Object!"); // 何やら動的な初期化をしてみた
        mv.push_back(std::move(m)); // push_back(T&&)は右辺値参照を受け取り内部でムーブする
        // この時点でmは破壊済みでありデータ上の意味を持たない
    }
}

unique_ptr / shared_ptr をムーブして構築する

直接ムーブできない、ムーブ効率の悪いオブジェクトの場合、unique_ptr / shared_ptr でラップしてムーブする方法もあります。unique_ptr についてはコンテナから直接取り出してしまうとコンテナ側の unique_ptrnullptr になってしまうので注意する必要があります。

.unique_ptr をムーブする例

std::vector<std::unique_ptr<ImmobilizedObj>> iv;

void initializer(std::size_t size)
{
    for(int i = 0 ; i < size ; i++){
        auto io_ptr = std::make_unique<ImmobilizedObj>("I am " + std::to_string(i) + "th Object!"); // 何やら動的な初期化をしてみた
        iv.push_back(std::move(io_ptr)); 
    }
}
// 参照なら取得できる。
ImmobilizedObj& getImmobilizedObj(std::size_t index)
{
    return *(iv[index]);
}
// unique_ptr自体を取り出すとvectorの外側にムーブされてしまうのでこれは危険
// std::unique_ptr<ImmobilizedObj> getImmobilizedPtr(std::size_t index)
// {
//     return iv[index];
// }

直接構築する

コンテナの中には直接構築といってコンストラクタ引数を渡すと中間のアロケーションをせずコンストラクタを呼び出して構築してくれるものがあります。ムーブコストすら回避できる性能劣化の少ない初期化の方法です。ただし、対象のコンテナが直接構築をサポートしている必要があります。

直接構築を使う場合、ライブラリのI/F記述やシグニチャを確認して直接構築の機能があるかを確認することがまず第一歩になります。たとえば、 std::vector には emplace_back というメンバー関数があります。

.直接構築の例

std::vector<MovableObj> mv;

void initializer()
{
    for(int i = 0 ; i < 19999 ; i++){
        MovableObj& m = mv.emplace_back("I am " + std::to_string(i) + "th Object!");
        // MovableObj("I am " + std::to_string(i) + "th Object!")相当の処理で直接構築された
        m.setAdditionalAttribute(i); // C++14以降のemplace_backは参照を返すので追加で初期化処理することも可能
    }
}

#個人的にはムーブ構築を基本として直接構築があればそれを使うか〜、くらいの感覚でいます。

アロケーション選択方法まとめ

アロケーション、初期化の手段の選択について大まかな判断チャートを作ってみました。

  • ローカルでライフサイクル管理可能 → ローカルで宣言
  • ライフサイクルがスコープ外に出る
    • 共有不要でムーブコストが小さい → ローカルで宣言してムーブで管理する
    • 共有不要でムーブコストが大きい → unique_ptrを使用する
    • 共有が必要 → shared_ptrを使用する
  • 親になるオブジェクトがある
    • 共有不要でムーブコストが小さい → 親オブジェクト内に直接構築
    • 共有不要でムーブコストが大きい → 親オブジェクト内にunique_ptrで保持
    • 共有が必要 → 親オブジェクト内にshared_ptrで保持

アロケーションされた変数の引き回し方

アロケーション後のオブジェクトをどのように引き回すかについて簡単に説明します。

コピーして引き回す

コピーが可能で、ロジック上複製しても問題なく、かつコピーしても効率の良いものはコピーして引き回すと良いでしょう。
深いことは考えなくてよくなります。ただ、あまりこういうのが歓迎されるシチュエーションは少ないのではと思います。。。

LightObj getLightObj()
{
    return LightObj; // 戻り値もコピーで返しちゃえ
}
void eatLightObj(LightObj lo)
{
    // コピーして何かする
}
int main()
{
    LightObj lo = getLightObj();
    eatLightObj(lo);
    return 0;
}

参照を引き回す

呼び出し元となる関数がオブジェクトの生存期間を管理できているなら、参照で引き回すのが効率が良いです。この場合、呼び出された側は参照を保持し続けたり非同期処理に受け渡すなどは基本的にNGだと考えるべきです。呼び出された側が左辺値参照で渡された引数をムーブするなんてのももちろん避けるべきです。

void eatNormalObj(NormalObj& no)
{
    // データを使って何かする

    // 左辺値参照でうけとったということは壊していい約束ではないので
    // ムーブは絶対にしない
}
int main()
{
    NormalObj no;
    eatNormalObj(no); // 確実に生存範囲内
    return 0;
}

ムーブして引き回す

関数間で所有権を引き回すときにムーブで引き回すのが効率が良いことがあります。右辺値参照や戻り値を使ってムーブをつないでいくイメージになります。この場合でも生存期間を所有している関数がライフサイクルを管理できるのであれば、その期間内では左辺値参照を使って引き回すことが可能です。

MovableObj foo(){
    rerurn MovableObj(100);
}

void bar(MovableObj& m){
    // なにか処理
}

void boo(){
    auto m = foo(); // fはfooからムーブされる
    bar(m); // オブジェクトの生存期間が確保できていれば参照渡ししても良い
    // ここでやっとImmobilizedObjのデストラクタが呼ばれる
}

unique_ptr を引き回す

上記のムーブのイメージに近いですが、 unique_ptr で宣言されたものはオブジェクト自体をムーブする代わりに、 unique_ptr をムーブして引き回すことになります。ムーブで引き回している場合同様、生存期間を所有している関数がライフサイクルを管理できるのであれば、その期間内では左辺値参照を使って引き回すことが可能です。

.unique_ptr を引き回す例

std::unique_ptr<ImmobilizedObj> foo(){
    return std::make_unique<ImmobilizedObj>(100);
}

void bar(ImmobilizedObj& m){
    // なにか処理
}

void boo(){
    auto f = foo(); // fはfooからムーブされる
    std::cout << f->getNum() << std::endl; // fのメンバ値を出力
    bar(*f); // オブジェクトの生存期間が確保できていれば参照渡ししても良い
    // ここでやっとImmobilizedObjのデストラクタが呼ばれる
}

shared_ptr を引き回す

shared_ptrunique_ptr と異なり、コピーに融通が効くので適当に管理したくなります。しかし、頻繁にコピーを繰り返すことは性能劣化の要因になりえます。shared_ptr 自体の参照を渡したり、ムーブするなどして必要最小限のコピーにとどめるべきだとされます。また、ムーブや unique_ptr で引き回している場合同様、生存期間を所有している関数がライフサイクルを管理できるのであれば、その期間内では左辺値参照を使って引き回すことが可能です。

.make_sharedの引き回し方

std::shared_ptr<ImmobilizedObj> foo()
{
    return std::make_shared<ImmobilizedObj>(100);
}

void bar(ImmobilizedObj& m)
{
    // なにか処理
}

class ParentObj
{
    private:
    std::shared_ptr<ImmobilizedObj> ptr = nullptr;
    public:
    void setImmobilized(const shared_ptr<ImmobilizedObj> & ptr)
    :ptr(ptr)
    {
    }
    void someOperation()
    {
        // ここではImmolizedObjのインスタンスの所有権を確実に握れているので
        // 参照で引き回せる
        bar(*(this->ptr));
    }
    ~ParentObj()
    {
        this.ptr = nullptr;
    }
};

void boo()
{
    ParentObj p1, p2;
    {
        auto f = foo(); // fはfooからムーブされる
        p1.setImmobilized(f); // fをp1に対してコピー
        p2.setImmobilized(f); // fをp2に対してコピー
    }
    p1.someOperation();
    p1.setImmobilized(nullptr);
    // この時点ではImmobilizedObjのインスタンスは生きている
    p2.someOperation();
    p2.setImmobilized(nullptr);
    // すべてのshared_ptrのインスタンスが消えたので
    // ImmobilizedObjのデストラクタが呼ばれ破棄される

    // ここでfのスコープが切れるが、shared_ptrもImmobilizedObjも残っていない
}

setImmolibizedObj の引数は shared_ptr への参照になっていますが、この実装方法には2つの考慮があります。一つは shared_ptr の不用意な複製を防ぐ意味です。もう一つは、ムーブでの受け渡しが基本である unique_ptr の引き回しの記述とのバランスを取る意味です。