* メモリ管理#3
-(by [[K]], 2022.02.24)
** (1)
-私は C++ が結構好きです。・・・一方で、私は小さなプログラム(ここでいう小ささはソースコードの短さではなく、実行ファイルの小ささ)を書くのも好きなのですが、 C++ でちょっと凝ったことをすると、すぐにサイズが10KBとか20KBくらい増えてしまうことがわかりました。・・・それで、C言語をメインで使うようになりました。
-とはいえ、ここからは C++ の話です。
-あるとき、以下のような簡単なクラスを作りたくなりました。
class Test {
public:
const char *nam;
Test(const char *nam_) : nam(nam_) { }
~Test() { printf("%s.deinit\n", nam); }
void print(const char *s) { printf("%s.print: %s\n", nam, s); }
};
-本当に簡単ですね。コンストラクタは引数を一つ取って、それを自分の名前として記憶します。そしてprintするときとデストラクタが呼び出されたときに自分の名前を表示します。それだけのクラスです。
-(もともとは、このクラスはデストラクタが呼び出されるタイミングを確認するために作りました。)
-このTestクラスを使って、以下のようなTest2クラスを作ります。
class Test2 {
public:
Test a, b, c;
Test2(const char *nam) {
int l = strlen(nam);
char *aNam = new char[l + 3];
char *bNam = new char[l + 3];
char *cNam = new char[l + 3];
sprintf(aNam, "%s.a", nam);
sprintf(bNam, "%s.b", nam);
sprintf(cNam, "%s.c", nam);
a(aNam); // ←こんなことはできない.
b(bNam);
c(cNam);
};
(書き途中)
};
-ここまで書いて私は困りました。Test2のコンストラクタ内では、a, b, cに対するコンストラクタを呼べないのです。やりたいことは、渡されたnamをもとに、".a"や".b"などを付与した名前を作って、その名前でコンストラクタを呼びたいだけなのですが・・・。
-しょうがないので、Testクラスから書き換えて以下のようにしました。
class Test {
public:
const char *nam;
void init(const char *nam_) { nam = nam_; }
Test() { }
Test(const char *nam_) { init(nam_); }
~Test() { printf("%s.deinit\n", nam); }
void print(const char *s) { printf("%s.print: %s\n", nam, s); }
};
class Test2 {
public:
Test a, b, c;
Test2(const char *nam) {
int l = strlen(nam);
char *aNam = new char[l + 3];
char *bNam = new char[l + 3];
char *cNam = new char[l + 3];
sprintf(aNam, "%s.a", nam);
sprintf(bNam, "%s.b", nam);
sprintf(cNam, "%s.c", nam);
a.init(aNam);
b.init(bNam);
c.init(cNam);
};
void print(const char *s) {
a.print(s);
b.print(s);
c.print(s);
}
~Test2() {
delete[] a.nam; // ←実はこの書き方はまずい.
delete[] b.nam;
delete[] c.nam;
};
};
int main() // 簡単なテスト用プログラム.
{
Test2 t2("t2");
t2.print("hello");
return 0;
}
-書き直した部分の要点としては、Testクラスにinit()メソッドを追加して、実際の初期化処理はここでやることにしました。これなら、Test2のコンストラクタ内で、a.init()を使ってメンバ変数a, b, cを初期化することができます。
-しかし実行するとうまくいきません。
t2.a.print: hello
t2.b.print: hello
t2.c.print: hello
t2.c.deinit ←ここまでは期待通り、しかし下の2行はおかしい.
・f.deinit
・f.deinit
-なんでこんなことになるのかというと、Testのデストラクタが呼ばれる前に、Test2のデストラクタ内のdelete[]が実行されているためです。だからTestをもう一度書き直します。
class Test {
public:
const char *nam;
char f; // deinitが実行されたかどうかのフラグ.
void init(const char *nam_) { nam = nam_; }
Test() : f(0) { }
Test(const char *nam_) { init(nam_); }
void deinit() { printf("%s.deinit\n", nam); f = 1; }
~Test() { if (f == 0) deinit(); }
void print(const char *s) { printf("%s.print: %s\n", nam, s); }
};
class Test2 {
public:
Test a, b, c;
Test2(const char *nam) {
int l = strlen(nam);
char *aNam = new char[l + 3];
char *bNam = new char[l + 3];
char *cNam = new char[l + 3];
sprintf(aNam, "%s.a", nam);
sprintf(bNam, "%s.b", nam);
sprintf(cNam, "%s.c", nam);
a.init(aNam);
b.init(bNam);
c.init(cNam);
};
void print(const char *s) {
a.print(s);
b.print(s);
c.print(s);
}
~Test2() {
a.deinit();
b.deinit();
c.deinit();
delete[] a.nam;
delete[] b.nam;
delete[] c.nam;
};
};
-つまりコンストラクタの実体をinitに移し替えたのと同じように、デストラクタの実体をdeinitに移し替えてしまったわけです。
-これを実行すると、
t2.a.print: hello
t2.b.print: hello
t2.c.print: hello
t2.a.deinit
t2.b.deinit
t2.c.deinit
-となって、やっと期待通りになりました。
-しかしそれにしても、なぜこんな面倒なことになったのでしょうか。それはメンバオブジェクトに対するコンストラクタの呼び出しタイミングを指示できなくて、しかもデストラクタのタイミングも選べないためです。デストラクタに至っては、ついに本来なら必要のない変数(f)まで用意しなければいけなくなりました。
** (2)
-似たようなことをC言語でもやってみることにします。
typedef struct Test_ {
const char *nam;
} Test;
void Test_deinit(Test *t) { printf("%s.deinit\n", t->nam); }
void Test_init(Test *t, const char *nam, Clean *c) { t->nam = nam; Clean_set(c, Test_deinit, t); }
void Test_print(Test *t, const char *s) { printf("%s.print: %s\n", t->nam, s); }
typedef struct Test2_ {
Clean cln;
Test a, b, c;
} Test2;
void Test2_deinit(Test2 *t) { Clean_out(&t->cln); }
void Test2_init(Test2 *t, const char *nam, Clean *c) {
Clean_init(&t->cln);
int l = strlen(nam);
char *aNam = Clean_malloc(&t->cln, l + 3);
char *bNam = Clean_malloc(&t->cln, l + 3);
char *cNam = Clean_malloc(&t->cln, l + 3);
sprintf(aNam, "%s.a", nam);
sprintf(bNam, "%s.b", nam);
sprintf(cNam, "%s.c", nam);
Test_init(&t->a, aNam, &t->cln);
Test_init(&t->b, bNam, &t->cln);
Test_init(&t->c, cNam, &t->cln);
Clean_set(c, Test2_deinit, t);
};
void Test2_print(Test2 *t, const char *s) {
Test_print(&t->a, s);
Test_print(&t->b, s);
Test_print(&t->c, s);
}
int main() // 簡単なテスト用プログラム.
{
Test2 t2;
Test2_init(&t2, "t2");
Test2_print(&t2, "hello");
Test2_deinit(&t2);
return 0;
}
-C++版はmain以外で41行もありましたが、C版は31行で済みました。ここで、Clean系の関数定義を紹介しておきます。
typedef struct CleanSub_ {
void *f, *p, *next;
} CleanSub;
typedef struct Clean_ {
CleanSub *sub;
} Clean;
void Clean_init(Clean *c) { c->sub = 0; }
void Clean_out(Clean *c) {
CleanSub *s, *s0;
for (s = c->sub; s != 0; ) {
void (*fnc)(void *);
fnc = s->f;
fnc(s->p);
s0 = s;
s = s->next;
free(s0);
}
c->sub = 0;
}
void Clean_set(Clean *c, void *f, void *p) {
if (c != 0) {
CleanSub *s = malloc(sizeof (CleanSub));
s->next = c->sub;
c->sub = s;
s->f = f;
s->p = p;
}
}
void *Clean_malloc(Clean *c, int sz) {
void *p = malloc(sz);
Clean_set(c, free, p);
return p;
}
-もしCleanを全く使わない実装にすると、Test2_deinitはこうなります(C++版とそっくりです)。
void Test2_deinit(Test2 *t) {
Test_deinit(&t->a);
Test_deinit(&t->b);
Test_deinit(&t->c);
free(&t->a);
free(&t->b);
free(&t->c);
}
-そして、C++版とC言語版で実行ファイルサイズを比較するとこうなりました。
|C++版|RIGHT:21,504|
|C言語版(Clean使用)|RIGHT:6,144|
|C言語版(Cleanなし)|RIGHT:6,144|
-ただしC言語版(Clean使用)が無条件に優秀というわけではないです。Test2の中にCleanオブジェクトを持っていて、その分だけ余計にメモリを使います(C++版と比較して)。
* (3)
-[Q] これって、C++版のTestクラスのデストラクタ内で、delete[] nam;すればいいだけのことじゃないの?
-[A] そういう仕様にすることもできますが、それだと、Test t1("t1"); ってやったらまずいことになりますよね?newしてないポインタをdeleteすることになるので。これはたぶんスマートポインタでも解決しません。
-[Q] じゃあさあ、Testのコンストラクタは常に渡された名前のコピーを保持することにすればいいじゃないの?それならdeleteしても問題ないよね。
-[A] それで動作は問題なくなりますが、「不要な時でもコピーしなければいけない。しかもその理由はプログラミング言語のせいだ」っていうのは、なんか不本意ではないですか?・・・今の面倒な実装なら、「まず余計なコピーをしないTestクラスがあって、それを使いたいTest2クラスがあって、Test2クラスは必要に応じて文字列を新規に作って、不要になったら破棄している」だけです。それは、動作としてはとても素直なことだと思います。
-[A] それで動作は問題なくなりますが、「不要な時でもコピーしなければいけない。しかもその理由はプログラミング言語のせいだ」っていうのは、なんか不本意ではないですか?・・・今の面倒な実装なら、「まず余計なコピーをしないTestクラスがあって、それを使いたいTest2クラスがあって、Test2クラスは必要に応じて文字列を新規に作って、不要になったら破棄している」だけです。それは、動作としてはとても素直なことだと思います。それを素直に書けないC++は、なんかちょっと仕様が足りないのではないかと思ったのです。