メモリリークしない安全なプログラムの書き方

作成:2006年6月14日

補遺:2006年7月2日

吉田誠一のホームページ   >   ソフトウェア工学   >   技術コラム   >   プログラミング

使い終わったメモリを解放するのを忘れると、メモリリークが起きてしまいます。

プログラマーは、メモリリークを起こさないように、メモリの確保と解放には細心の注意を払います。しかし、プログラムを書く時は、さまざまなエラーが起こる場合にも備えておかなくてはなりません。

どんなエラーが起きたとしても、きちんとメモリが解放されるプログラムを書くのは、かなり大変です。思いがけないところで関数を抜けてしまい、メモリを解放し損ねることがあります。

ここでは、いつ、どこでエラーが起きても、メモリリークが発生しない、安全なプログラムの書き方を紹介します。

目次

  1. エラーに備えたプログラムの書き方
    1. とりあえず動くプログラムを書く
    2. エラーが起きたら、NULLを返す
    3. NULLを返す前に、メモリを解放する
    4. 最後にまとめてメモリを解放する
    5. gotoの代わりに例外を使う
  2. 安全なプログラムを書くためのコーディング規約
  3. 使用上の注意
    1. 使い終わった変数には、直ちにNULLをセットする
    2. 解放するのは、メモリだけではない
  4. 問題点
    1. 同じ後始末のコードを二重に書かなくてはいけない
    2. エラーが起きた時の後始末を catch の中に書けない

エラーに備えたプログラムの書き方

とりあえず動くプログラムを書く

ここでは例として、Fooという構造体の2次元配列を、引数で指定された幅と高さで作る、createFooMatrixという関数を作ってみます。

なお、配列の各要素を、initializeFoo関数で初期化しています。

Foo **createFooMatrix ( int width, int height ) {
        Foo **ptr = NULL;
        int w, h;

        ptr = (Foo **)malloc(height * sizeof(Foo *));

        for (h = 0 ; h < height ; h++) {
                ptr[h] = (Foo *)malloc(width * sizeof(Foo));

                for (w = 0 ; w < width ; w++) {
                        initializeFoo(&ptr[h][w]);
                }
        }

        return ptr;
}
          

このプログラムには、2次元配列を作る処理が、正しく書かれています。試しに実行してみても、おそらく、まず問題なく動くことでしょう。

しかし、このプログラムは、ちゃんと2次元配列を作ることができる場合しか、うまく動きません。

例えば、もしも何らかの理由でメモリが確保できなかったら、おそらく、アプリケーションエラーやSegmentation faultが起こり、プログラムが唐突に異常終了してしまいます。

エラーが起きたら、NULLを返す

そこで、さきほどのプログラムに、エラーに備えた処理を書き加えてみましょう。

createFooMatrix関数は、ループの中で繰り返しmalloc関数を呼んで、メモリを確保しています。もしも途中で、malloc関数がNULLを返したら、2次元配列を作るのを中止して、createFooMatrix関数はNULLを返すように直します。

Foo **createFooMatrix ( int width, int height ) {
        Foo **ptr = NULL;
        int w, h;

        ptr = (Foo **)malloc(height * sizeof(Foo *));
        if (ptr == NULL)
                return NULL;

        for (h = 0 ; h < height ; h++) {
                ptr[h] = (Foo *)malloc(width * sizeof(Foo));
                if (ptr[h] == NULL)
                        return NULL;

                for (w = 0 ; w < width ; w++) {
                        initializeFoo(&ptr[h][w]);
                }
        }

        return ptr;
}
          

これで、途中でエラーが起きても、無理に処理を続けて、暴走してしまうことはなくなりました。

しかし、このプログラムにはまだ問題があります。それは、エラーが起きた時に、やりかけていた処理の後始末をしていない点です。

エラーが起きた時には、すでに何度かmalloc関数を呼んで、メモリを確保していたはずです。そこで、NULLを返す前に、それまでに確保していたメモリを解放して、元の状態に戻さなくてはなりません。でないと、途中まで確保したメモリは、ゴミとして残ってしまい、誰も使えなくなってしまいます。つまり、メモリリークが発生してしまいます。

NULLを返す前に、メモリを解放する

そこで、さきほどのプログラムに、NULLを返す前に、メモリを解放する処理を書き加えてみましょう。

Foo **createFooMatrix ( int width, int height ) {
        Foo **ptr = NULL;
        int w, h;

        ptr = (Foo **)malloc(height * sizeof(Foo *));
        if (ptr == NULL)
                return NULL;
        memset(ptr, 0, height * sizeof(Foo *));

        for (h = 0 ; h < height ; h++) {
                ptr[h] = (Foo *)malloc(width * sizeof(Foo));
                if (ptr[h] == NULL) {
                        int i;
                        for (i = 0 ; i < h ; i++) {
                                if (ptr[i] != NULL)
                                        free(ptr[i]);
                        }
                        free(ptr);

                        return NULL;
                }

                for (w = 0 ; w < width ; w++) {
                        initializeFoo(&ptr[h][w]);
                }
        }

        return ptr;
}
          

これで、途中でエラーが起きても、メモリリークが起きることは無くなりました。

しかし、関数の本来の処理の中に、後始末の処理を書き加えると、プログラムが分かりにくくなってしまいます。

この例では、後始末をしなくてはいけないのは1ヶ所だけで済みましたが、エラーが起きそうな箇所がたくさんある場合は、いちいちメモリを解放してリターンする処理を書き加えていくと、プログラムが長くなってしまいます。

最後にまとめてメモリを解放する

そこで、さきほどのプログラムを、最後にまとめてメモリを解放するように、書き直してみましょう。

Foo **createFooMatrix ( int width, int height ) {
        Foo **ptr = NULL;
        int w, h;

        /* 本来の処理 */

        ptr = (Foo **)malloc(height * sizeof(Foo *));
        if (ptr == NULL)
                goto ERROR;
        memset(ptr, 0, height * sizeof(Foo *));

        for (h = 0 ; h < height ; h++) {
                ptr[h] = (Foo *)malloc(width * sizeof(Foo));
                if (ptr[h] == NULL)
                        goto ERROR;

                for (w = 0 ; w < width ; w++) {
                        initializeFoo(&ptr[h][w]);
                }
        }

        return ptr;

        /* エラーが起きた時の後始末 */
ERROR:
        if (ptr != NULL) {
                for (h = 0 ; h < height ; h++) {
                        if (ptr[h] != NULL)
                                free(ptr[h]);
                }
                free(ptr);
        }

        return NULL;
}
          

こうすると、この関数の途中でエラーが起きた時にやらなくてはならない後始末が、関数の最後にまとめられて、分かりやすくなります。

gotoの代わりに例外を使う

C++言語には、エラーが起きたことを知らせる、例外という仕組みがあります。そこで、さきほどのプログラムを、例外を使って書き直してみましょう。

typedef enum {
        FooException_OutOfMemory,
        FooException_Unknown
} FooException;

Foo **createFooMatrix ( int width, int height ) {
        Foo **ptr = NULL;
        int w, h;

        try {
                /* 本来の処理 */

                ptr = (Foo **)malloc(height * sizeof(Foo *));
                if (ptr == NULL)
                        throw FooException_OutOfMemory;

                for (h = 0 ; h < height ; h++) {
                        ptr[h] = (Foo *)malloc(width * sizeof(Foo));
                        if (ptr[h] == NULL)
                                throw FooException_OutOfMemory;

                        for (w = 0 ; w < width ; w++) {
                                initializeFoo(&ptr[h][w]);
                        }
                }

                return ptr;
        } catch (...) {
                /* エラーが起きた時の後始末 */

                if (ptr != NULL) {
                        for (h = 0 ; h < height ; h++) {
                                if (ptr[h] != NULL)
                                        free(ptr[h]);
                        }
                        free(ptr);
                        ptr = NULL;
                }
        }

        return NULL;
}
          

このように、例外を使うと、この関数の本来の処理が try の中に、エラーが起きた時の後始末が catch の中に、きちんと区切られるので、さらに分かりやすくなります。

安全なプログラムを書くためのコーディング規約

ここでは、安全なプログラムを書くために、次のようなコーディング規約を推奨します。

  • 関数の中で使う変数をすべて、冒頭で宣言し、NULL等で初期化しておく。
  • 関数の本来の処理全体を、try 〜 catch で囲む。
  • エラーが起きた時は、例外を throw する。
  • エラーが起きた時の後始末は、すべて catch の中に書く。

この方法では、関数の1つ1つが、それぞれ、巨大な try 〜 catch で囲まれることになりますので、奇妙な感じがするかもしれません。しかし、この方法を採ることで、いつ、どこでエラーが起きても、メモリリークが発生しない、安全なプログラムを書くことができます。

C言語の場合は例外が使えませんが、さきほどの例のように、エラーが起きた時はgoto文で関数の末尾にジャンプし、そこで後始末をまとめて行うようにします。

この方法では、関数の途中で処理を抜けることはありません。エラーが起きたらすぐにリターンするプログラムを書く人も多いですが、この方法では、return文は、関数の最後に1つだけしか書きません。

使用上の注意

使い終わった変数には、直ちにNULLをセットする

プログラムの中で、使い終わったメモリを解放した時には、必ず、使い終わった変数にNULLをセットします。しかも、間髪を入れず、直ちに変数をNULLにしなければなりません。

例えば、次のプログラムをご覧下さい。このプログラムは、一見、何も問題がないように思えるかもしれません。

Foo *foo = NULL;
Bar *bar = NULL;

try {
        foo = new Foo();
        bar = new Bar();
                :
        delete foo;
        delete bar;
        foo = NULL;
        bar = NULL;
} catch (...) {
        if (foo != NULL)
                delete foo;
        if (bar != NULL)
                delete bar;
}
          

しかし、このプログラムでは、変数fooのメモリを解放した後で、NULLをセットするまでの一瞬の間に、隙があります。具体的には、Barクラスのデストラクタで例外が投げられると、問題が起こります。これでは、たとえ try 〜 catch で囲んでいても、安全なプログラムとは言えません。

きちんとエラーに備えるためには、次のように書くようにしましょう。

Foo *foo = NULL;
Bar *bar = NULL;

try {
        foo = new Foo();
        bar = new Bar();
                :
        delete foo;
        foo = NULL;
        delete bar;
        bar = NULL;
} catch (...) {
        if (foo != NULL)
                delete foo;
        if (bar != NULL)
                delete bar;
}
          

解放するのは、メモリだけではない

エラーが起きた時には、確保したメモリを解放するだけでなく、他にもさまざまな後始末をしなければなりません。

例えば、ファイルを開いている時にエラーが起きれば、閉じなければなりません。また、ロックをかけて排他制御をしている時にエラーが起きれば、ロックを解除しなければなりません。こういったメモリ以外のものを解放する処理も、catch の中に忘れずに書かなくてはいけません。

一般的に、最後に何かやらなくてはいけない決まり事があれば、すべて、エラーに備えて、catch の中で行うようにする必要があります。例えば、StartFoo() という関数を呼んだ後は、必ず EndFoo() という関数を呼ぶ必要があるのなら、catch の中で EndFoo() を呼ぶようにします。

メモリであれば、変数がNULLかどうかによって、解放する必要があるかどうかが分かります。しかし、場合によっては、後始末をする必要があるかどうかを示す、別の変数を用意しなければならないこともあります。

メモリを解放する場合と違って、こうした処理はうっかり忘れてしまいやすいので、注意して下さい。

例えば、次のプログラムをご覧下さい。このプログラムは、一見、何も問題がないように思えるかもしれません。

FILE *fp = NULL;

try {
        StartFoo();

        fp = fopen("ファイル名", "r");
        if (fp != NULL) {
                        :
                fclose(fp);
        }

        EndFoo();
} catch (...) {
}
          

しかし、このプログラムでは、ファイルや、StartFoo関数の呼び出しについて、エラーが起きた時の後始末を忘れてしまっています。これでは、たとえ try 〜 catch で囲んで、メモリを解放していても、安全なプログラムとは言えません。

きちんとエラーに備えるためには、次のように書かなくてはなりません。

FILE *fp = NULL;
bool foo_started = false;

try {
        StartFoo();
        foo_started = true;

        fp = fopen("ファイル名", "r");
        if (fp != NULL) {
                        :
                fclose(fp);
                fp = NULL;
        }

        EndFoo();
        foo_started = false;
} catch (...) {
        if (fp != NULL)
                fclose(fp);
        if (foo_started)
                EndFoo();
}
          

問題点

同じ後始末のコードを二重に書かなくてはいけない

ここでは、エラーが起きた時の後始末の処理を、catch の中にまとめて書く方法を紹介しました。

しかし、エラーが起きなかった時にも、同じように後始末をしなくてはいけないことがあります。すると、同じ後始末のコードを、二重に書かなくてはいけないことになります。

例として、下記のプログラムを見て下さい。ここでは、funcという関数の中でオブジェクトを生成し、使い終わったら削除しています。

void func ( ) {
        Foo *foo = new Foo();
        Bar *bar = new Bar();
                :
        delete foo;
        delete bar;
}
          

このプログラムを、エラーに備えるように書き直します。ここで、func関数は、もしもエラーが起きたら、受け取った例外をそのまま投げることにします。

書き直したプログラムは、次のようになります。

void func ( ) {
        Foo *foo = NULL;
        Bar *bar = NULL;

        try {
                foo = new Foo();
                bar = new Bar();
                        :
                delete foo;
                foo = NULL;
                delete bar;
                bar = NULL;
        } catch (...) {
                if (foo != NULL)
                        delete foo;
                if (bar != NULL)
                        delete bar;

                throw;
        }
}
          

このように、fooとbarというオブジェクトを削除する同じコードを、2ヶ所に書かなくてはいけなくなってしまいます。

ちなみに、Java言語の場合は、finally を使って、後始末の処理を1ヶ所にまとめることができます。

void func ( ) throws Exception {
        Foo foo = null;
        Bar bar = null;

        try {
                foo = new Foo();
                bar = new Bar();
                        :
        } catch (Exception e) {
                throw e;
        } finally {
                foo.delete();
                bar.delete();
        }
}
          

エラーが起きた時の後始末を catch の中に書けない

ここでは、エラーが起きた時の後始末の処理を、catch の中にまとめて書く方法を紹介しました。

しかし、現実には、エラーが起きた時の後始末の処理を、try 〜 catch の中に収められないこともあります。

さきほどのfunc関数の例を、今度は、どのようなエラーが起きても、いつもFuncExceptionというオブジェクトを生成して投げるように変えてみましょう。

ここで、FooとBarのコンストラクタは、それぞれ、FooException、BarExceptionという、異なるオブジェクトを例外で投げるものとします。

すると、書き直したプログラムは、次のようになります。

void func ( ) {
        Foo *foo = NULL;
        Bar *bar = NULL;

        try {
                foo = new Foo();
                bar = new Bar();
                        :
                delete foo;
                foo = NULL;
                delete bar;
                bar = NULL;

                return;
        } catch (FooException *e) {
                delete e;
        } catch (BarException *e) {
                delete e;
        } catch (...) {
        }

        if (foo != NULL)
                delete foo;
        if (bar != NULL)
                delete bar;

        throw new FuncException();
}
          

ここでは、FooException、BarExceptionのオブジェクトを受け取った場合は、それを削除してから、新たにFuncExceptionのオブジェクトを投げています。

このように、受け取る例外の種類がいくつかあり、しかも、受け取った例外に対してもそれぞれ後始末をしなければならない場合は、例外ごとに、それぞれcatch を書かなくてはいけません。

ここで、後始末の処理を catch の中に書こうとすると、同じコードを何箇所にも書かなくてはいけなくなってしまいます。このような場合は、try 〜 catch の外で書いた方が良いでしょう。

この場合は、try の中の最後で、必ずreturn文を書くようにします。関数の戻り値が無い場合は、returnを忘れがちなので、注意しましょう。

ちなみに、Java言語の場合は、finally を使って、後始末の処理を try 〜 catch の中に書くことができます。

void func ( ) throws FuncException {
        Foo foo = null;
        Bar bar = null;

        try {
                foo = new Foo();
                bar = new Bar();
                        :
        } catch (FooException e) {
                e.delete();
                throw new FuncException();
        } catch (BarException e) {
                e.delete();
                throw new FuncException();
        } catch (Exception e) {
                throw new FuncException();
        } finally {
                foo.delete();
                bar.delete();
        }
}
          

補遺:2006年7月2日

高橋一郎氏、佐久間智貴氏から、次の記事を紹介して頂きました。

ここでは、デストラクタで例外を投げることは禁じ手とされており、次のような書き方が推奨されています。

例外が起こりそうな部分に関しては,そこから外に例外が飛び出さないように,try-catchで「蓋」をしておくとよいでしょう。

しかし、これだけでは不十分です。

デストラクタで例外を投げることを禁じ手とするならば、「蓋」をしなかった部分についても、例外が投げられないことを保証しなくてはなりません。ですが、デストラクタから呼び出している関数の中では、どのような実装になっているか分かりません。呼び出している関数の、さらにその中から呼び出している関数の、さらにその先の…、と考えると、「絶対に例外が投げられない」と断言するのは無理です。

結局、「例外が起こりそうな部分」だけでなく、このコラムで推奨しているように、デストラクタ内のすべての処理を try 〜 catch で蓋をするしかありません。むしろ、その方が簡単でもあります。

補遺:2006年7月2日

「使い終わった変数には、直ちにNULLをセットする」の例について、次のようなご指摘を頂きました。

そもそもC++ではコンストラクタやデストラクタでの例外のスローは御法度です。

デストラクタでの例外のスローは、コンストラクタでのそれよりも重罪ですね。なので、記事の後半は例が適切でないように思います。

Barクラスを作るのなら、デストラクタで例外を投げないように作るべし、というのは、その通りです。

しかし、それと、『Barクラスのデストラクタでは例外が投げられないことを前提にコーディングしてしまうこと』とは、別の話です。

この例のように、Barクラスを利用する側では、もしかしたらBarクラスのデストラクタから例外が投げられてしまうかもしれない、と備えたコーディングをしておくべきだと思います。

補遺:2006年7月2日

「同じ後始末のコードを二重に書かなくてはいけない」の件については、高橋一郎氏、佐久間智貴氏から、auto_ptr等のスマートポインタを紹介して頂きました。

確かに、メモリの解放については、スマートポインタを使うことで、二重に書かなくて済みます。しかし、それ以外の後始末の処理については、やはり、二重に書くしかありません。

また、auto_ptrは使用上の制約が多く、導入すると、却って混乱を招くことがあります。例えば、ポインタ変数をコピーしたり受け渡したりする場合は、予期しないバグが発生しやすくなります。私は、そのリスクの高さに比べて、auto_ptrを使うことで得られるメリットは少ないと考えています。

Copyright(C) Seiichi Yoshida ( comet@aerith.net ). All rights reserved.