Skip to content

Latest commit

 

History

History
366 lines (294 loc) · 19.4 KB

File metadata and controls

366 lines (294 loc) · 19.4 KB

メモリ

オブジェクトはプログラム上を実行するときに何らかの形でメモリに配置されます。 ここでは、メモリの扱い、特にヒープメモリについての話を中心にまとめました。 C++ においてヒープメモリとは、何も考えずに扱うとすぐにリークしてしまうものですが、 便利な道具を使ってリークさせないように上手に扱いましょう。

new と delete

C 言語だとヒープメモリは malloc 関数を使って確保し、free 関数を使って解放するのが一般的です。 malloc/free 関数は C++ でも使えますが、それとは別に new 演算子と delete 演算子が用意されています。 new/delete はヒープオブジェクトの確保と開放を行う操作で、ヒープメモリの確保解放だけではなくコンストラクタ/デストラクタの呼び出しもするという点が malloc/free とは異なります。

最も大事な点は、newdelete を素で呼んではいけないことです。 要するに以下のようなコードを書いてはいけません:

{
    A *p = new A;
    // p を使った操作
    delete p;
}

なぜいけないのか。 このようなコードを書くと我々はメモリリークするバグをいとも簡単に埋めこんでしまうからです。 メモリを確保してから開放するまでの間に例外が飛ぶ可能性を忘れていたり、開放を忘れて return などしてしまいます。 mallocnew で確保したヒープメモリやヒープオブジェクトは、スマートポインタを使って管理するか、管理のための専用ラッパークラスを定義すべきです。 C 言語では注意深くコードを書かないとすぐにメモリリークしますが、C++ では上記の注意を守っていれば、まずリークしません。 RAII がメモリを含むリソースのリークから我々を守ってくれるからです。

RAII によるリソースリークの防止

RAII とは、Resource Acquisition Is Initialization の略で、リソースの寿命をオブジェクトの寿命に合わせることで、リソースリークを防ぐことができる手法です。 具体的には、リソースの確保をコンストラクタで行い、開放をデストラクタ内で行います。 例外が飛んでも対応するスコープを抜けて寿命が尽きたオブジェクトのデストラクタは必ず呼ばれるので、開放し忘れることがないという仕組みです。 リソースとして、メモリだけでなく、ロック、ファイルディスクリプタ、ネットワークコネクション、などなど様々なものを対象に出来ます。 特に、長時間動作するサーバープロセスなどのプログラムでは、プログラムが使う全てのリソースに対して RAII を使うことを強くオススメします。 必要があれば自分でラッパークラスを作ってください。 これを徹底するだけで、我々は自動的にほぼリソースリークしないプログラムを手にいれることができます。 例えば以下のコードを見てください:

void bad_code()
{
    A* p = new A;
    try {
        // 例外を投げるかも知れない操作
        delete p;
    } catch (std::exception&) {
        delete p;
        throw;
    }
}

struct SafeHeapA
{
    A* ptr;
    SafeHeapA() : ptr(new A) {}
    ~SafeHeapA() { delete ptr; }
};

void good_code()
{
    SafeHeapA s;
    // 例外を投げるかも知れない操作
}

bad_code() では、ヒープオブジェクトを作成して p から指すようにした後、常に例外発生の可能性に気を配ってコードを書き、最後にヒープオブジェクトを開放する責任が bad_code() 関数に生じます。 しかし、good_code() のように RAII に従った SafeHeapA というラッパークラスを用意すれば、例外が投げられても s のデストラクタは必ず実行され、メモリリークは起きないですし、コードも読みやすくなります。

デストラクタ自身は例外を投げられないので、リソース開放処理中に発生した例外を検知、処理したい場合は、リソース開放を実行するメンバ関数(close() など) を用意して、try-catch 節内で明示的に呼び出す必要があります。 そのようにクラスを設計したとしても、close() を明示的に呼び忘れたときのために、デストラクタでも close() を呼ぶようにして、万全を期すアプローチが有効でしょう。 もちろん close() は複数回実行しても問題ないように設計するか、フラグ等を用いて実質一度しか呼ばれないようにしておく必要はあります。

struct A
{
    bool closed_;
    Resource resource_;
    A() : closed_(false), resourece(open_resource()) {}
    ~A() try {
        close();
    } catch (...)
    }
    void close() {
        if (closed_) return;
        close_resource(resource_);
        closed_ = true;
    }
};

スマートポインタ

C++11 以降で #include <memory> によってスマートポインタが使えます。 std::unique_ptrstd::shared_ptr です。 これらはヒープオブジェクトを RAII に従って管理するためのクラスです。 簡単に説明すると、new 演算子で確保されたオブジェクトをスマートポインタに入れて管理すると、スマートポインタの寿命が来たときに、自動的にデストラクタが呼ばれ、その中で delete が呼ばれて指していたヒープオブジェクトが開放されます。

{
    std::unique_ptr<A> p(new A);
    // p を使う
}  // p の寿命。p が管理していた A 型のヒープオブジェクトは p のデストラクタによって自動的に delete される。

スマートポインタは、変数の寿命とヒープオブジェクトの開放タイミングを一対一対応させ、開放忘れを防いでくれます。 我々がコードを書くときに、特に何も注意しなくても、関数内ローカル変数の寿命はブロックの終わりで尽きることが多いですから、注意深く delete を実行するコードを手動で書くのに比べて圧倒的に安全です。

std::unique_ptrstd::shared_ptr は、オブジェクトをひとつの変数で占有するか、複数の変数で共有するかよって使い分けますが、std::shared_ptr を使う場面は多くないでしょう。 ありがたいことに、生ポインタに比べて std::unique_ptr を使うために追加で必要なオーバーヘッドはありません。 64bit アーキテクチャの場合、sizeof(std::unique_ptr<A>) は 8 です。 追加のコストなしに使えるので、ヒープオブジェクトを自分で確保する必要が生じた場合、ほとんどの場面で std::unique_ptr を使うべきです。 一方、std::shared_ptr はオーバーヘッドなしとはいかず、ヒープオブジェクト毎にコントロールブロックを保持管理する必要があり、それへのポインタを追加で持つため、sizeof(std::shared_ptr<A>) は大抵 16 です。

std::unique_ptr はコピーできませんがムーヴできます。 一時的に関数などに渡して使う場合はムーヴせずに std::unique_ptr それ自身の lvalue 参照渡しもしくは中身のポインタを生で渡せば良いですし、ムーヴを使うことで他の変数や rvalue 参照渡しされた引数などに管理を移譲することもできます。 所有と借用の概念については別途説明しますが、std::unique_ptr が所有者で、ヒープオブジェクトを指している生ポインタや参照を持っているオブジェクトは借用者だと考えれば、ヒープオブジェクトの開放に責任を負っているのは所有者で、所有者の寿命が一番長くなるようにするか、所有者の寿命がもうないかもしれない場面で借用者がアクセスを避けるように気をつかえば、問題にはなりません。

スマートポインタを使いづらい場面があるかも知れません。 生ポインタを使うことは確かに危険を伴いますが、利便性と安全性のトレードオフをうまく取るのが良い選択です。 オブジェクトの寿命を意識し、ポインタの dereference (間接参照によるアクセス) を使うのはオブジェクトが生きている間だけ、という鉄則を守ってください。 プログラムの中で、そのような特別なケアをしなければならないオブジェクトはごく一部なので、気をつかうのもその周辺のコードだけで済むのは有り難いことです。 問答無用で全てのヒープオブジェクトの寿命を手動管理させられる C の世界に比べれば天国です。

std::shared_ptr の使いどころがあるとすれば、対象のヒープオブジェクトを複数人が指した状態で使いたいが、誰が一番寿命が長いのか自明ではなく、かつリファレンスカウント方式の garbage collection を採用したいケースに限られるでしょう。 たとえばマルチスレッドプログラムで、ふたつのスレッドがあるヒープオブジェクトを std::shared_ptr で指していて、どちらかが先に寿命を迎えるか分からないようなときです。

ここまで説明しておいてなんですが、同一型の複数のオブジェクトをまとめて管理したいときは、STL のコンテナライブラリが役に立ちます。 多くの場合、自分でヒープオブジェクトを確保してスマートポインタで管理しなくても、コンテナを使えば事足ります。 コンテナはその内部で要素を格納する領域をヒープメモリから確保し、寿命が来たらデストラクタで必要な開放処理を行います。 それらの挙動はもちろん RAII に従っています。

動的確保した配列より std::vector

配列の動的確保をするのにも new 演算子を使えますが、ほとんどの場合代わりに std::vector を使うべきです。

たとえば int 型の配列を動的に確保して使うことを考えてみましょう。 new を使う場合は、次のように書きます。

{
    // size_t n;
    std::unique_ptr<int[]> v(new int[n]);
    // v を使う。サイズが n であることは別途覚えておく必要がある。
}   // std::unique_ptr<int[]> のデストラクタが delete[] を呼ぶ。

配列 new を用いて確保したメモリはコンパイル時に要素数やサイズが決まる配列とは違うことに注意が必要です。 配列 new 演算子は先頭要素へのポインタを返すため、サイズ情報は自分で管理する必要があります。 各要素は class 型の場合デフォルトコンストラクタで初期化されますが、int など算術型の場合は new int[n]; では初期化されず、new int[n](); でゼロ初期化されます。 デフォルトコンストラクタを持たない型の動的配列はこの方法では確保できません。

std::vector を使う場合は、

{
    std::vector<int> v(n);  // 内部的に連続するヒープメモリを確保
    // v を使う。v.size() でサイズも分かる。
} // std::vector<int> のデストラクタがヒープメモリを開放

と書きます。 デフォルトコンストラクタを持たない型でも、

{
    std::vector<A> v(n, A(arg0, arg1));
}

と書けば初期化できます。 ただ、このコンストラクタを使った場合、各要素は初期化後コピーされてしまうので算術型以外の要素だったり、n が大きいときに使うのはオススメしません。 少々手間ですが、

{
    std::vector<A> v;
    v.reserve(n);
    for (size_t i = 0; i < n; i++) {
        v.emplace_back(arg0, arg1);
    }
}

とすれば、ヒープメモリの確保は 1 回だけに抑えて、無駄なく要素毎に好きなコンストラクタを呼べます。 他にも std::vector は便利なメンバ関数を色々と供えていますので、是非使ってください。

std::vector を使うデメリットがあるとすれば、std::vector は要素を格納するヒープメモリ以外に見かけ上のサイズと実サイズの 2 つのデータを保持しているため、同一サイズの動的配列を大量に作るときは、それらがメモリ容量のオーバーヘッドになるかも知れないことでしょうか。 とはいえそのオーバーヘッドは一次元配列であれば無視できる程度だと思います。

二次元配列が作りたいなら、次のようにします。

size_t size0 = 10, size1 = 100;
std::vector<std::vector<int>> vv(size0);
for (size_t i = 0; i < size0; i++) {
    vv[i].resize(size1);
}

resize による実質的なメモリ確保操作を複数回実行するのが面倒ですね。 new を使う方法はさらに面倒なので省略します。 次元が増えていくと、std::vector を使ったとしてもどんどん面倒になるので、サイズ固定の多次元配列を動的に確保したいなら、ひとつの std::vector の一次元配列を多次元配列に見立て、アクセス用のメンバ関数を用意した方が良さそうです。

class TwoDimensionalIntArray
{
    const size_t s0_, s1_;
    std::vector<int> v_;
    TwoDimensionalIntArray(size_t s0, size_t s1) : s0_(s0), s1_(s1), v_(s0 * s1) {}
    int get(size_t i, size_t j) const {
        assert(i < s0_); assert(j < s1_);
        return v_[i * s1 + j];
    }
    void set(size_t i, size_t j, int value) {
        assert(i < s0_); assert(j < s1_);
        v_[i * s1 + j] = value;
    }
};

このようなクラスは STL にはないですが、そこらのライブラリには用意されていますので、それを使うのも手です。

アラインメント

アラインメントとは、オブジェクトやメモリ断片の先頭アドレスが指定されたサイズの倍数になっていることを求める制約です。 C++11 以降では alignas キーワードでオブジェクトのアラインメントを制御することができます。 具体的に alignas を使うのは、デフォルトのアラインメントサイズよりも大きくしたい場合です。

{
    alignas(64) int i;
    assert(uintptr_t(&i) % 64 == 0);
}

スタックオブジェクトの場合はこのように簡単に制御できます。 ヒープオブジェクトの場合は、C++17 以降であれば alignof(T) の値が考慮されます。

struct alignas(64) A
{
};

{
    A* a = new A;
    assert(uintptr_t(a) % 64 == 0);
}

alignof(std::max_align_t) より大きなアラインメント指定は実装依存らしいので、実際に確認してから使ってください。

64bit 環境において malloc() で保証される 8 bytes よりも大きな単位で、より柔軟にアラインメントサイズを設定してヒープオブジェクトを確保したい場合は、std::aligned_alloc() (C++17 以降) や、 posix_memalign() もしくは mmap() を使えます。 これらのヒープメモリを C++ オブジェクトとして振る舞わせたい、すなわちコンストラクタとデストラクタを呼びたいときはどうしたら良いでしょうか。 ひとつの手は、ラッパークラスを作って、そのコンストラクタ内でメモリを確保して placement new 演算子を呼び、ラッパークラスのデストラクタ内で元のクラスのデストラクタを明示的に呼んだ後に free()munmap() を呼んでメモリを開放すれば良いです。

#include <cstdlib>
#include <new>

struct A
{
    A() { /* ... */ }
    ~A() { /* ... */ }
};

A* alloc_and_cstr(size_t alignment)
{
    void *p;
    if (::posix_memalign(&p, alignment, sizeof(A)) != 0) {
        throw std::bad_alloc();
    }
    try {
        return ::new(p) A;
    } catch (...) {
        ::free(p);
        throw;
    }
}

struct B0
{
    A *a0_;
    B0() : a0_(alloc_and_cstr(ALIGNMENT)) {}
    ~B0() {
        a0_->~A();
        ::free(a0_);
    }
};

::new(p) A が placement new 演算子を使って初期化する操作です。 Try-catch 節は A のコンストラクタが例外を投げるケースをカバーしています。 new(p) A ではなく ::new(p) A を使う理由は説明すると長くなるので割愛します。 気になる人は A 用の operator new を自分で定義して挙動を見てみると良いでしょう。

ヒープオブジェクトは出来るだけ早く std::unique_ptr に格納することをお薦めします。 B0 はこのままでも動きますが、もしコンストラクタ内で例外が飛ぶような実装だった場合、a0_ が指しているヒープオブジェクトはリークします。

struct B1
{
    struct Deleter {
        void operator()(A* a) { ::free(a); }
    };
    std::unique_ptr<A, Deleter> a1_;
    B1() : a1_(alloc_and_cstr(ALIGNMENT)) {}
};

B1 はそのような場合でも a1_ のデストラクタが呼ばれてリークは発生しません。

alloc_and_cstr() を使う方法以外に、 最近の C++ では operator new および operator delete を自分で定義することで、 アラインメントを指定するなどのカスタム動作をさせることができます。

#include <cstdio>
#include <new>
#include <memory>

struct A
{
    A() { /* ... */ }
    ~A() { /* ... */ }

    static void* operator new(size_t s, std::align_val_t alignment = std::align_val_t(alignof(A))) {
        void *p;
        if (::posix_memalign(&p, size_t(alignment), s) != 0) {
            throw std::bad_alloc();
        }
        return p;
    }
    static void operator delete(void* p, std::align_val_t) {
        ::free(p);
    }
};

int main()
{
    const size_t ALIGNMENT = 64;
    {
        std::unique_ptr<A> a(new(std::align_val_t(ALIGNMENT)) A);
        // a を使う
    }
};

上のコードは C++17 で動作します。C++11 から C++17 までの間にこのあたりの仕様はどんどん変化していますので、 便利ではあるけれどまだ枯れていないといえるでしょう。使うなら注意してください。

コンテナの要素にアラインメントを指定したいときは、素直に要素型を定義するときに alignas を指定するのが無難です。 もしくは B1 のような型を要素として格納するようにしましょう。 コンテナのカスタムアロケータを使ってアラインメントを実現しようとするのはお薦めしません。 何故なら std::vectorstd::deque は先頭要素か一部の要素しかアラインされないし、std::liststd::map などでは内部で付加情報のついた型をアロケートするようになっていて、要素のアドレスがアロケートされたメモリのアドレスとずれるので、うまくいきません。