* メモリ管理#5 -(by [[K]], 2022.03.10) ** (1) -C言語はC++言語とは異なり、クラスに対して強制的にコンストラクタやデストラクタが呼び出されるような仕組みがありません。だからプログラム内で明示的に呼び出す必要があります。・・・これにより、呼び出しタイミングの工夫などをする余地が生まれるものの、ほとんどの場合は単に面倒なだけです。これを改善しようと思いました。 -いや、それならそもそもCなんか使わずにC++を使えばいいじゃんって思うかもしれませんが、C++で適当にクラスを使うとなんか専用のランタイムライブラリが追加されるのか、サイズが15KBくらい大きくなってしまいます。私は実行ファイルサイズが小さいプログラムを作るのがすごく好きなので、このよくわからない15KB程度の増加は受け入れられません。それでCだけでプログラムを書きたいと考えました。 -いや、それならそもそもCなんか使わずにC++を使えばいいじゃんって思うかもしれませんが、C++で適当にクラスを使うとなんか専用のランタイムライブラリが追加されるのか、サイズが15KBくらい大きくなってしまいます。私は実行ファイルサイズが小さいプログラムを作るのがすごく好きなので、このよくわからない15KB程度の増加は受け入れられません。それでCだけでプログラムを書きたいと考えます。 ** (2) -私がCでクラスっぽいことをやりたいときは、structで構造体を作って、それでその構造体名で始まる関数をいくつか作ります(ここではその構造体名を仮にClassNameとします)。この関数群はメンバ関数の代用です。ClassName_init()はコンストラクタで、ClassName_deinit()はデストラクタです。 -Cでクラス処理の真似をすると、最初に面倒に感じるのは、デストラクタの呼び出しです。プログラムを書いていると、いつの間にか呼び出すのを忘れてしまうのです。いや、一つか二つくらいだったら間違えないのですが、5個とか10個とかになってくると、抜けが出てくることがあります。・・・しかも、仮にデストラクタを呼び忘れてもたいていはメモリリークを起こす程度で、なかなか発見できません。 -そこで私は、デストラクタを直接呼び出さなくてもいい仕組みを作りました。 ABegin(c1); // ACleanクラスを宣言して初期化するマクロ Test a, b, c; Test_init(&a, c1); // こうすることで、AClean_out(c1)したときにdeinit()が自動で呼ばれるようになる. Test_init(&b, c1); Test_init(&c, c1); Test_say(&a, "hello"); ABegin(c1); Test a[1], b[1], c[1]; Test_init(a, c1); // こうすることで、AClean_out(c1)したときにdeinit()が呼ばれるようになる. Test_init(b, c1); Test_init(c, c1); Test_say(a, "hello"); ... AClean_out(c1); // これでa,b,cはdeinit()される(c,b,aの順に). -mallocしたのにfreeし忘れるというバグも時々あります。だからfreeもAClean_out()に任せられるようにしてあります。 ABegin(c1); Test a[1], b[1], c[1]; Test_init(a, c1); // こうすることで、AClean_out(c1)したときにdeinit()が呼ばれるようになる. Test_init(b, c1); Test_init(c, c1); char *d = aMalloc(1234, c1); Test_say(a, "hello"); ... AClean_out(c1); // ここでfree(d)もしてくれる. -これならAClean_outだけを忘れないようにすればいいことになります。それなら忘れずにやるのはずっと楽です。 -このclean-out方式は、コードサイズ削減の観点でもメリットがあります。なぜならデストラクタを呼び出すコードがいらなくなっているからです。もしclean-outの仕組みがなく、自前でデストラクタやfreeを呼び出すのだとしたら、その数だけ関数呼び出しを書かなくてはいけません。それを1つのclean-outの呼び出しで代用できているので、コードが少し小さくなるというわけです。 -ただしABegin()の分や、ACleanクラスのメンバ関数の分のコードサイズ増加はありますし、mallocやコンストラクタの引数が1つ増えているというオーバーヘッドもあります。だからある程度の大規模プログラムでないと、コードサイズはむしろ増えると思います。 ** (3) -このclean-outの仕組みがあることで、free忘れやデストラクタ呼び出し忘れなどをほぼなくせます。これは気持ちのいいことです。 -このclean-outの仕組みがあることで、free忘れやデストラクタ呼び出し忘れなどをほぼなくせるかもしれません。また二重freeや二重デストラクタ呼び出しも避けられるかもしれません。 -しかしまだ防げないものがあります。それは開放タイミングを間違えるというバグです。・・・つまりclean-outしたあとに、またそのオブジェクトを誤って使ってしまうかもしれないわけです。 -でも大丈夫です。そんなポカをやってしまっても、直ちにエラーになって止まってくれます(そういう仕組みを持っています)。だから適当にやっても平気です。もうこのことに気を使う必要はありません。 -でも大丈夫です。そんなポカをやってしまっても、直ちにエラーになって止まってくれます(そういう仕組みを持っています)。だからもうそんなことに気を使う必要はありません。 -それだけではありません。この仕組みはinit忘れも検出します。つまりオブジェクトのポインタとして有効ではないものを使おうとしたらすぐにエラーになるのです。 ** (4) -ACleanのおかげで、オブジェクトの寿命管理がすごく簡単になりました。そこでこんなxsprintf()を考えました。 char *xsprintf(AClean *c, const char *f, ...) { char s[1024 * 1024]; va_list ap; va_start(ap, f); int l = vsnprintf(s, sizeof s, f, ap); char *t = aMalloc(l + 1, c); strcpy(t, s); va_end(ap); return t; } -このxsprintf()があれば、こんなことができます。 char *s = ""; ABegin(c1); for (i = 0; i < 100; i++) s = xsprintf("%d %s", i, s); puts(s); AClean_out(c1); -これを実行して得られるのはこんな結果です。 99 98 97 96 95 94 93 92 ... 9 8 7 6 5 4 3 2 1 0 -プログラムでは、文字列のメモリ上に遠慮なく作って、そのまま使い捨てにしています。それでもAClean_out()で全部きれいに片付くので問題なしです。 -xsprintf()を使ったプログラムは、sの長さが何バイトくらい必要だとかそういうことを一切気にしていません。freeのことも気にしていません。ただ文字列をぺたぺたとつないでいるだけです。 -このスタイルでプログラムを書くときは、strcpy()はまず使いません。aに入っている文字列をbに代入したければ、 b = a; で済みます。 -このclean-out方式は、コードサイズ削減の観点でもメリットがあります。なぜならデストラクタを呼び出すコードがいらなくなっているからです。もしclean-outの仕組みがなく、自前でデストラクタやfreeを呼び出すのだとしたら、その数だけ関数呼び出しを書かなくてはいけません。それを1つのclean-outの呼び出しで代用できているので、コードが少し小さくなるというわけです。 -ただしABegin()の分や、ACleanクラスのメンバ関数の分のコードサイズ増加はありますし、mallocやコンストラクタの引数が1つ増えているというオーバーヘッドもあります。だからある程度の大規模プログラムでないと、コードサイズはむしろ増えると思います。 ** (4) -プログラムがどのくらいメモリを使っているのか調べたくなることはまあまああります。特にメモリリークがないかどうかを調べる時は重宝します。何か大きな処理をする前と後でメモリ消費量を表示させて、もし差がなければその処理ではメモリリークはなかったというわけです。 AMemAlc0_report1(); // これで消費量がわかる. ... AMemAlc0_report1(); // これで消費量がわかる. -これでメモリリークがありそうだとわかっても、さて原因は何だろうかということになります。initしたままdeinitし忘れたオブジェクトがあるかもしれません。先のclean-out方式を使っていればそんなミスはないはずなのですが、例えばAClean_outそのものをやり忘れているかもしれません。・・・それで、そういう時はアクティブオブジェクト一覧表示を使います。 -そうすると、initされていてまだdeinitされていないオブジェクトの一覧が見えます(=生きているオブジェクト)。心当たりのないものが残っていたら、おそらくそれが原因でしょう。これでどこで何をやり忘れているのかのヒントが得られるはずです。 --なお、この「生きているオブジェクトの一覧」の情報を使って、(3)のエラーチェックをやっています。 ** (5) -今はメモリをどのくらい使っているのかとか、どのオブジェクトが生きていてどのオブジェクトが片付けられたのかなど、詳細な情報を管理しているとやはりそれなりには遅くなりますし、チェックのためのコードも増えてしまいます。 -そうすると、「じゃあそんな機能は使いたくない」になってしまいます。そこで「デバッグレベル」という仕組みを導入しました。通常はデバッグレベル=0です。この場合、デバッグ支援のためのデータ管理は一切しませんし、チェックもしません。エラーは起きないという前提で動きます。 -デバッグレベル=1でコンパイルすると、簡単かつ低オーバーヘッドで検証できる場合のみチェックするようになります。 -デバッグレベル=2でコンパイルすると、妥協せずにバグ探しのために最大限の努力をするようになり、すべての実行時エラーチェックが有効になります。 -プログラムをリリースするときはデバッグレベル=0にする前提で設計しています。つまり最終的にオーバーヘッドはなくなるのです。オーバーヘッドなしでいろんなデバッグ支援をしてくれるなんて、なんて気の利くライブラリでしょうか(自画自賛)。 ** (6) -さてあれこれと機能を盛り込んでしまいましたが、こんなにいっぱいあったらもしかしたらC++で書いたのと同じくらいに大きくなってしまったかもしれません。もしそうなったら(私にとっては)本末転倒です。だから確認しなければいけません。 -軽く調べてみた感じでは、C言語だけで頑張って書いた場合と比べて、2KBくらい大きくなっているようです。まあ2KBくらいなら許せる気はします。 ** (7) -一般にC言語では、ヒープメモリに対するメモリアロケータはmalloc()一つだけです。 -しかし私の作ったライブラリでは、アプリ用の一般アロケータのほかに、システム用のアロケータとデバッグ用のアロケータがあります。なぜこうなっているのかといえば、OSやコンパイラなど環境に依存するメモリと、デバッグ用に使われるメモリと、アプリが本来の目的のために使うメモリを別々に集計できるようにしたかったからです。 -こうすることで、(5)で紹介したデバッグレベルをいくつに設定しても一般用のメモリアロケータの挙動は変わらないので、メモリ消費量を簡単に確認できるようになっています。デバッグのためにメモリを雑に使っても、一般用のメモリアロケータには影響しません(だから気を使わなくていいのです)。 ** (8) まとめ・感想 -結局C++の仕組みに勝ったかどうかで言うと、勝っていないと思います。サイズにこだわりたい私ならこのclean-outの仕組みは魅力的ですが、それ以外の普通の人にとっては、C++のオブジェクト管理の劣化コピーにしか見えないでしょう。 -ただ、もし私がこれを何十年も前に作れていたら、C++は今のような仕組みをわざわざ作らなかったかもしれないとは思います(でもそもそもclean-outの仕組みを作ろうと思った動機がC++の仕組みなので、先に私が作れる道理はないのですが・・・)。 ** (9) -まだ細かいところはできていないのですが、私が隠し持っていてもいいことはないので、ここにアップロードしておきます。 --http://k.osask.jp/files/acl02c_win.zip --http://k.osask.jp/files/acl02c_sdl2.zip // ASP. カスタムアロケータ. longjmp.