1. サイトトップ
  2. ブログ
  3. C++
  4. C++の配列newを深掘りする

C++の配列newを深掘りする

こんにちは。情熱開発部プログラム3課の角谷瑠晟です!
普段から当たり前に使っている物をよく知らない状態で使っていたりする場合はプログラムに限らずあるかなと思います。

今回のブログではC++を書いている人は当たり前に使っているであろうC++の配列newの動作について深掘りしていこうと思います。

※本記事では以下のバージョンを使用しています。
VisualStudio:2022
C++:17

※この記事は推測を多く含みます

配列newの動作を確認する

ご存じの方が多いでしょうが、まずは配列newの動作を確認しようと思います。
以下のようなコードを書きます。

class Test
{
public:
    ~Test(){}
private:
    int val = 0x1b1b1b1b;
};

int main()
{
    Test* test = new Test[0xa];
    delete[] test;
}

実行する前にdelete[] testの行にブレークポイントを貼って実行を行い動作を止めてみます。
動作を止めた後visualstudio上部のウィンドウ→メモリ→メモリ 1を選択し、メモリウィンドウでメモリの中身を確認してみます。

アドレスの欄にtestと入力することでtest変数のアドレスに飛ぶことができます。
確認してみると0x1b1bが並んでいるので問題なく配列分のTestクラスが生成されてそうです。

ここで一つ手前のアドレスを見てみましょう、マウスのホイールでスクロールする事で自分の好きなアドレスを確認できます。
確認してみると…

0x0a00000000000000の値が入っているのが確認できます。
こちらは配列newで確保した数と同じ値です。
本当にそうなのか確かめるために0xaaaa分の配列を確保するように変更してみます。

int main()
{
    Test* test = new Test[0xaaaa];
    delete[] test;
}

先ほどと同じようにブレークポイントを貼ってメモリウィンドウで確認してみます。
すると…

やはりtestの変数の手前の8バイトに配列数が入っていそうなことを確認出来ると思います。
こちらが今回伝えたい自分が配列newで確保した分とは別に言語側で配列数が埋め込まれているということです。
次の項目からはこの動作を更に深掘りして行きます。

配列newの動作を更に深掘りしていく

先ほどの配列newでの動作の確認で配列newを行うと変数の直前のアドレスに配列の数が埋め込まれているのが確認できたと思います。
ここからは更に動作を深く確認して行きます。

32bit環境での動作を確認

まずは、手前のアドレスの8バイトに書かれるというのが条件によって変わらないのかを確認してみます。
C++で特定の変数の型は64bit環境では8バイトと32bit環境では4バイトに変わります。
上記のような事を知っていると32bit環境では動作がなるか気になりますよね?
x86でコンパイルしてメモリウィンドウで確認してみましょう。

確認したところ上記のような結果となりました。
32bit環境だと手前に書き込まれる配列数の数が8バイトで書かれるのではなく4バイトで埋め込まれるのが分かるかと思います。
上記で分かる通り使っているコンパイラーやbitによって埋め込まれるバイトの幅は変わる可能性があります。
こちらも注意する必要があります。

デストラクタがない場合の動作を確認

デストラクタがないクラスを配列newした場合はどうなるのでしょうか。
一度デストラクタをコメントアウトして動作を確認してみます。

class Test
{
//public:
//    ~Test(){}
private:
    int val = 0x1b1b1b1b;
};

実行する環境によって赤枠の値が変わっているとは思いますが、配列数は埋め込まれていなさそうなのを確認できると思います。
つまりデストラクタを定義していないと配列数が手前のアドレスに埋め込まれる事はないというのが確認出来るかと思います。

こちらは自分の想像になりますが、デストラクタが定義されていない場合デストラクタを明示的に呼び出す必要がないので配列数を記憶しておく必要がないのでその場合は配列数が埋め込まれる事がないと考えています。
コードのイメージだとdeleteの中身は以下のようになっていると思います。

template<typename T>
void delete(T* test)
{
    if constexpr (std::is_trivially_destructible_v<T>)// デストラクタが定義されているかを判断
    {
        free(test);// デストラクタが定義されていない場合は呼び出す必要がないのでメモリを解放するだけで良い
    }
    else
    {
        // 定義されていたらデストラクタを呼び出してからメモリ解放を行う(コンパイル環境によって手前のアドレスが変わります)
        uint64_t num = *(uint64_t*)(((char*)test - 8));
       for (int i = 0; i < num; i++) 
       {
           test->~T();
       }
       free(test);
    }
}

※余談ですがDebugの際は0xfdfdfdfdで使用メモリの手前と後が囲われるようです。
 ただ、Releaseの際は0xfdfdfdfdが書き込まれなくなるので要素数のない状態でどうやって使用範囲のメモリを解放しているのかは分かりません…

配置newを使用している場合

配置newで確保した場合はどうなるでしょうか。
配置newについては以下の弊社のブログで紹介しているので知らない方は是非確認してみてください。
【C++】配置newを使ってみる

事前にTestを確保する分のメモリを確保して配置newで配列の動的確保を行ってみます。

class Test
{
public:
    ~Test(){}
private:
    int val = 0x1b1b1b1b;
};

int main()
{
    static const uint32_t AllocSize = sizeof(Test) * 0xa;
    void* pPool = malloc(AllocSize);
    Test* test = new(pPool) Test[0xa];
    free(pPool);
}

確認したところ分かりますが、手前のアドレスには配列数が入っていないのが確認出来ると思います。
自分で確保したので当たり前かと思いますが配置newで確保した物は自分で数を覚えておきデストラクタを呼ぶ必要がありそうです。

実際に困る場合

ここまで配列newの色々な場面での動作を確認してきましたが、具体的にどのような時に困りそうでしょうか。
ほとんどの場合で困る事はなさそうですが、特定の時に困りそうなためそれを紹介しておきます。

Heapクラスを自作している場合

以下のコードのように自分でHeapクラスを自作していて尚且つ配置newのように事前にメモリを確保してから配列newでメモリを確保した場合に問題が発生します。

class Test
{
public:
    ~Test(){}
private:
    int val = 0x1b1b1b1b;
};
class Heap
{
    bool create(uint32_t size)// 事前にメモリを確保しておく
    {
        m_pTop = malloc(size);
        memset(m_pTop, 0xbbbbbbbb, size);
        return true;
    }
    void* alloc(size_t size)// 確保していたメモリを割り当てる
    {
        void* ptr = m_pTop;
        return ptr;
    }
}
void* operator new[](size_t size, Heap* heap)
{
    return heap->alloc(size);
}
int main()
{
    Heap heap;
    heap.create(sizeof(Test)*0x0a);
    Test* test = new(&heap) Test[0xa];
    delete[] test;
}

メモリウィンドウでcreateが終わった直後のメモリを確認してみましょう。
create関数内でメモリを確保した後に0xbbbbbbbbで埋めているのでm_pTopのアドレスから配列分0xbbbbbbbbで埋め込まれているのが確認出来ると思います。

newが終わった後のメモリの状況も確認してみます。
以下の画像のように赤の部分が先ほどこちらが事前に確保したメモリですが、
青の部分は配列数が埋め込まれてしまったせいで余分に確保してしまっています。

自分が作成したHeapクラスをnewの引数として渡した場合事前にメモリを確保していようが配置newとはみなされません。
ですので通常時の配列newと同じように配列の数を直前のアドレスに記入してしまう分意図したメモリの範囲に収まらない状態となってしまいます。
この場合も配列数が埋め込まれるというのを念頭に置いて余分にメモリを確保したりする必要がありそうです。

終わりに

今回は配列newの挙動について深掘りしていきました。
実際自分が困る事があったので細かく調べる機会がありましたが、困る機会がないとよく確認する事はないだろうと思ってご紹介させていただきました。
今回の話に限らず自分が普段使っている物でも疑問に思う事があればどうしてそうなっているのか調べてみるのをお勧めします!


【免責事項】

本サイトでの情報を利用することによる損害等に対し、
株式会社ロジカルビートは一切の責任を負いません。