2005年05月06日
川俣晶の縁側ソフトウェア技術雑記total 12796 count

C++→Java→C#という進化形路は本当に正当か? いまここで問う、プログラム言語のリソース管理論

Written By: 川俣 晶連絡先

 昨夜は寝ぼけながらC++/CLIはC#を凌駕するかも知れない…… usingステートメント不要のDisposeメソッド呼び出しの衝撃という文章を書きましたが、これについて、歴史を踏まえたより分かりやすい話を書いておく価値があると思い付きました。

 というか、これは私のようなプログラム言語ミーハーにとっては、ご馳走のような話題ですね。このような問題を重大視して喜ぶ人は滅多にいないのかもしれませんが、しかし多くの開発現場での生産性に直結しうるシビアな話題でもあります。

リソース管理とは何か §

 プログラムは様々なリソースを使いながら動作します。

 リソースとは、CPU、メモリ、ファイル、通信ポート(TCP/IPのポート番号)などです。

 これらのリソースは有限であるため、確保したまま解放しない場合、リソースが枯渇し、それ以上のプログラム、あるいはシステムの動作が継続できなくなります。

 従って、リソースは解放されねばなりません。

 問題は、いかにして、どのようなタイミングでリソースを解放するかです。

 これが、この論で扱うリソース管理です。

古典的BASICのリソース管理 §

 古典的BASIC、たとえばNEC PC-8001のN-BASICには、リソース管理の自動化に関する概念はありません。

 たとえばopenステートメントで開いたファイルハンドルは、closeステートメントで閉じて解放しなければなりませんが、このような処理を自動化したり、確実に実行する手段はありません。つまり、openステートメントでファイルを開いた後で発生したエラーをon error gotoでトラップしてcloseステートメントを実行せずに処理を継続した場合、ファイルは開いたまま放置されます。このような問題は、実行時のエラーをたよりにソースコードを見て問題の原因を突き止めるしかありません。

 ただし、文字列の保存領域(メモリ)だけはガベージコレクションが行われ、使い終えた領域は回収されて再利用されます。つまり、全ての瞬間の文字列の利用総量よりも多くの文字列領域を与えておけば、文字列処理に必要とされるメモリは充足されます。

Cのリソース管理 §

 古典的BASICと同様に、リソース管理の自動化に関する概念はありません。

 また、古典的BASICに見られた文字列保存領域のガベージコレクションの機能もありません。

 しかし、スタック上に確保される自動変数(ローカル変数)に関しては、スコープあるいは関数を抜ける時点で自動的に解放されるという特徴があります。この特徴はメモリの解放だけを行い、開いたハンドルを自動的に閉じるような機能は持ちません。

 これとは別に、Cでは動的にヒープ領域よりメモリを確保するテクニック(malloc等の使用)が多用されますが、確保したメモリは明示的に解放するコードを書かない限り解放されないため、容易にメモリリークが発生します。これが発生すると、プログラムの実行が続くにつれて使用メモリ量が増大し、システムの限界を超えた段階でプログラムの動作は停止します。

レガシーなC++のリソース管理 §

 C++はCのスーパーセットであるため、Cと同様に使用した場合には、Cの特徴が全て当てはまります。

 リソース管理という観点では、C++にはデストラクタによるリソース解放のチャンスが与えらるという点で特徴的です。

 これまで見た2つの言語では、資源の解放が自動化されるケースがメモリに限定されていたのに対して、C++ではハンドル等のメモリ以外の資源を自動的に解放するコードを記述できます。

 たとえば、クラスのメンバ変数であるとか、スタック上の自動変数(ローカル変数)としてデストラクタを持つオブジェクトを記述した場合、そのオブジェクトが解放される時にデストラクタが必ず呼ばれます。デストラクタにハンドル等を解放するコードを記述すれば、確実なリソースの解放が実現できます。

 しかし、この特徴は万全ではありません。new演算子によって確保されたオブジェクトは、delete演算子によって解放しない場合は、いつまでもメモリ上に残り続け、必然的にデストラクタも呼び出されません。つまり、メモリリークが発生すると共に、解放されないリソースが発生します。

C++のリソース管理の歴史的盲点 §

 この話題は単なる思いつきなので、妥当であるかは分かりません。

 上で見たように、C++には、確実にデストラクタが実行されるケースと、されないケースの双方があります。

 しかし、「確実にデストラクタが実行されるケース」については、あまり言及されているところを見た記憶がありません。一方、「されないケース」についてはJavaの優位性を説明する場面などでしばしば見かけます。

 このようなアンバランスさは、歴史的な経緯を踏まえると分かりやすいかもしれません。

 私の記憶が確かなら、C++の普及が始まった頃、クラスのメンバにオブジェクトを持つことは「悪いこと」「オブジェクト指向的に正しくないこと」とされており、それよりも継承を使うべきだとされていました。つまり、「確実にデストラクタが実行されるケース」が意図的に排斥されていた、という可能性があり得ることに気付きました。それゆえに、C++の話題ではメモリリークという問題ばかりが取り上げられているのかもしれません。

Javaのリソース管理 §

 Javaは、メモリの管理を完全に自動化することを特徴とします。

 確保された全てのメモリは、使い終わった後、ガベージコレクタによって回収されます。プログラマが明示的にメモリを解放するコードを記述する必要はない (書くことがそもそも許されない) という特徴により、参照の解放忘れのようなタイプのバグを除けば、メモリに関する限り、完璧なリソース管理が実現されていると言えます。

 また、ガベージコレクタによる使用済みメモリの回収は、使い終わるごとに明示的に解放するよりも、パフォーマンス的に有利であるようです。

 しかし、Javaが実現した輝ける栄光はここまでです。

 メモリ以外のリソース、たとえばファイルなどのリソース管理は自動化されていません。開いたファイルを閉じるために、closeメソッドを呼び出すのはプログラマの責任です。もし、それを呼び出さない場合、ファイルがいつまでも開いたまま放置されるリスクが発生します。そのファイルを扱うオブジェクトがガベージコレクタに回収されればその時点で閉じられますが、そのタイミングは予測できません。

 このような処理を支援するために、Javaにはtry-finally構文があり、あるtryブロックを抜ける際に必ず実行されるコードを記述できます。しかし、try-finally構文のスコープと、変数やオブジェクトの寿命は一致しておらず、正しいリソース解放を行うには、やはりプログラマの注意力を必要としました。

 また、同時に使用される複数のリソースを確実に解放しようと思うなら、try-finally構文をネストさせる必要が発生します。使用するリソースが多い場合、ネストが深くなり、ソースコードの読みやすさを損ないます。

C#のリソース管理 §

 大筋において、Javaのリソース管理と似ています。

 しかし、Javaの弱点であるtry-finally構文に置き換わるusingステートメントを持っている点で、特徴があります。

 usingステートメントは、オブジェクトの生成と、そのオブジェクトを使用するコード、そしてオブジェクトが持つリソースの解放を行うDisposeメソッドの呼び出しがワンセットになったリソース管理機能です。

 通常の使い方を行う場合、オブジェクトを参照する変数は、それが生まれてからDisposeメソッドが呼び出されるまでのスコープにのみ存在、それ以前、あるいはそれ以後にアクセスすることができません。つまり、Disposeメソッドによってリソースが破棄された後で参照できないことが構文的に保証されており、それが保証されないtry-finally構文と比較して安全性が向上しています。

 しかし、同時に使用するリソースが多い場合ネストが深くなるという問題は、usingステートメントでも完全に回避されません。1つのusingステートメントは複数のオブジェクトを扱うことができますが、全て同じ型の変数で受けることができる場合のみです。つまり、互換性のないクラスのオブジェクトを使う場合、ネストは深くなります。

C++/CLIのリソース管理 §

 大筋において、C#のリソース管理と似ています。

 これは、両者に共通の実行環境であるCLIの構造をある程度反映せざるを得ない事情から必然的に発生する状況と言えます。

 その他に、C++/CLIでは、レガシーなC/C++から継承した昔ながらのリソース管理も可能ですが、ここでは趣旨が曖昧になるので、(そして、私自身がまだ見切れていないので)、ここでは横に置いて話を進めます。

 C#とC++/CLIを比較した場合のリソース管理の相違点は、C#のusingステートメントに相当する機能が、スコープに統合されている点です。たとえば、ローカル変数にDisposeパターンを実装したオブジェクトを記述した場合、そのローカル変数が属するスコープを抜ける時に、Disposeメソッドが呼び出されます。

 つまり、usingステートメントも、usingステートメントの実行対象を記述するブロックも必要が無くなります。これにより、ソースコードが簡潔になり、たいていの場合ネストが1段階少なくなります。

 そして、異なる型のローカル変数はいくつでも1つのスコープ内に記述できるため、同時に使用するリソースが多い場合でもネストは深くなりません。

 またDisposeメソッドはデストラクタとして実装し、クラスの宣言でIDisposeインターフェースを実装することを明示的に示す必要もありません。

 以上の特徴から、ほとんど何のコストも掛けることなく、Disposeパターンを利用できることになります。

 そして何より、Disposeメソッドの呼び出す忘れというシチュエーションから解放されます。少なくとも、ローカル変数を宣言したスコープからはいつか抜けるわけで、その時点でDisposeメソッドは呼び出されます。C#ではusingステートメントを使うことを忘れるとDisposeメソッドは永遠に呼び出されないリスクがありますが、C++/CLIはいずれかの段階で必ず呼び出されます。

 従って、リソース管理の面において、C++/CLIはより少ない記述量で、より大きな安全を手に入れることができることになります。

常にDisposeパターンを発動することはオーバーヘッドの増加でないか? §

 C++/CLIは、ある条件を満たす場合に必ずDisposeメソッドを呼び出します。

 このことは、パフォーマンスの低下を懸念させます。

 つまり、資源の回収を後回しにするガベージコレクタの採用がパフォーマンスを向上させたとすれば、必ずDisposeメソッドを呼び出すアーキテクチャはパフォーマンスを低下させるのではないか、という懸念です。

 これについてはノーと言えます。

 なぜなら、Disposeパターンを発動させないことは容易であり、また、たいていの場合このパターンは発動されないと考えられるからです。

 まず、「発動させないことは容易」というのは、gcnew演算子も手軽に使えるためです。gcnew(レガシーなC++, Java, C#のnewに相当する)によって生成されたオブジェクトは、スコープに拘束されることはありません。

 また、「たいていの場合このパターンは発動されない」というのは、全てのクラスの中でIDisposeインターフェースを実装するクラスは少数派であるという事実によります。IDisposeインターフェースが実装されていないクラスに対してDisposeメソッドを呼び出すコードが生成されることはあり得ず、パフォーマンスの低下はあり得ません。

 そして、何より強調しなければならないのは、Disposeパターンの発動とメモリの解放はイコールではないと言うことです。Disposeパターンが発動し、Disposeメソッドが呼ばれたとしても、その時点でチマチマとこまめにメモリが回収されるわけではありません。メモリの回収は、ガベージコレクタによって一括で行われます。

懸念されることとしては、レガシーなC++に慣れ親しんでいるために、本来必要のないデストラクタを記述することを通じて意図せずしてIDisposeインターフェースを実装してしまうことが想定されます。

 ちなみに、以下のコードにおけるuseGcnew()とuseScope()は全く同じコードを生成します。(Visual C++ 2005 Express Beta2でDebugビルドしてRelfectorでdisassembleを見て確認)。従って、Disposeパターンを使用していない場合、性能差は無いと推測されます。

ref class Test

{

public:

    Test()

    {

        Console::WriteLine(L"Constructed");

    }

};

void useGcnew()

{

    Test ^ test = gcnew Test();

}

void useScope()

{

    Test test;

}

リソース管理の必要性を決定する主導権は誰にあるのか §

 C#のusingステートメントと、C++/CLIのスコープを比較した場合、そこには質的な大きな差が感じられます。

 あるクラスに、リソースの確実な解放が必要だとして、その必要性を意識するのは誰か。

 C#では、クラスを利用するプログラマが、リソースの確実な解放が必要であると認識して、usingステートメントを使わねばなりません。つまり、クラスを利用するプログラマが主導権を持ちます。

 しかし、C++/CLIのスコープによる解放を使う場合、Disposeメソッドの呼び出しの必要性は、コンパイラが判断を下します。誰がコンパイラに対して判断の根拠を与えているかといえば、クラスを記述したプログラマです。彼が、デストラクタを記述した場合のみ(つまり、IDisposeインターフェースを実装したときのみ)、解放処理のためのDisposeメソッド呼び出しコードが生成されます。つまり、クラスを記述するプログラマが主導権を持ちます。

 一般的に、あるクラスに終了処理が必要であるかを最も確実に把握しているのは、そのクラスを記述したプログラマであり、利用するプログラマではありません。ですので、主導権をクラス利用側が持つC#よりも、クラス記述側が持つC++/CLIの方が、よりトラブルが起きにくく、手間も掛からないと考えることができます。

まとめ §

 以上のように、リソース管理という観点から各言語を比較した場合、以下のような流れを見て取ることができます。

 C++までの歴史は、比較的連続性の高いものと言えます。

 しかし、Javaがその連続性を断ち切る形で、ガベージコレクションという全く新しいスタイルを導入します。

 Javaに始まり、C#, Visual Basic.NET, Managed C++等の多くのマイクロソフト系の言語も、このガベージコレクション スタイルの強い影響下にあったと言えます。

 そこで問題になるのが、メモリ以外のリソースを管理するために、ガベージコレクション スタイルはあまり便利ではない、と言うことです。これを改善するために、usingステートメントやDisposeパターンが導入されたわけです。

 しかし、Java誕生以前よりC++では、ローカル変数として記述されたオブジェクトのデストラクタは、スコープを抜けた時点で自動的に実行されていました。usingステートメントがなくとも、同じことが既に達成されていたと言えます。それにも関わらず、new演算子を使って確保すると容易にメモリリークを引き起こしうるという大きな問題の影で、そのような特徴は長らく忘れられていた感があります。

 そして、忘却されていた特徴が、ガベージコレクション スタイルの限界が認識されるにつれて再浮上してきたのでしょう。

 もちろん、それはガベージコレクション スタイルを否定するものではありません。あくまで、メモリというリソースに限っては、ガベージコレクション スタイルの有効性は認められています。しかし、その他のリソースを扱う場合には、スコープで区切った明示的な資源解放のパラダイムは有効かつ有益であると考えられます。

 つまり、Java、C#, Visual Basic.NET, Managed C++などと比較した時、C++/CLIはリソース管理に関するパラダイムシフトと呼ぶ値する大きな進歩を見せたのではないか、と考えることができます。

補足 §

 もちろん、プログラム言語の使い勝手や生産性は総合力で決まるものであり、仮に上記の主張が正しく、リソース管理の面でC++/CLIが優越しているとしても、総合力でC++/CLIが優越しているという主張には直結しません。

 ただし、閉塞的な現状を打破するためのブレイクスルーとして担ぎ上げてみる、あるいはプログラム言語ミーハー的にエキサイトする対象であると認識することは可能であると思います。

 ちなみに、少し前にC++は消去法でやむを得ず採用する言語であると書きましたが、これは訂正しなければなりません。C++は前向きに未来を切り開く手段になりうる可能性を感じさせる、という理由でぜひ取り組みたい言語です。

Facebook

このコンテンツを書いた川俣 晶へメッセージを送る

[メッセージ送信フォームを利用する]

メッセージ送信フォームを利用することで、川俣 晶に対してメッセージを送ることができます。

この機能は、100%確実に川俣 晶へメッセージを伝達するものではなく、また、確実に川俣 晶よりの返事を得られるものではないことにご注意ください。

このコンテンツへトラックバックするためのURL

https://mag.autumn.org/tb.aspx/20050506145337
サイトの表紙【技術雑記】の表紙【技術雑記】のコンテンツ全リスト 【技術雑記】の入手全リスト 【技術雑記】のRSS1.0形式の情報このサイトの全キーワードリスト 印刷用ページ

管理者: 川俣 晶連絡先

Powered by MagSite2 Version 0.36 (Alpha-Test) Copyright (c) 2004-2021 Pie Dey.Co.,Ltd.