以下、論文調でちょっといかめしいですが、ある程度C++の使用経験がある方を対象に、 背景にある問題意識とその解決方針をできるだけ明確にまとめておくために書いたものです。
Cをマスターし、これからC++に挑戦しようとされている方や、 何度か挑戦したけれど、どうも良いコードが書けず悩んでいる方向けには、別途ドキュメントを作成する予定です。
C++は非常に複雑な仕様をもつ言語です。
1998年にISO/ANSIによる言語とライブラリの標準化[1] が成り、 その後数年(!)かかって主な処理系の対応が終わった後も、アプリケーション開発の現場にC++が使用される機会は増えたようには思えません。
むしろJavaによる開発がJ2EEの仕様の複雑化と実行速度の問題で混乱した場合でも、それらをC++によって解決しようという動きにはならず、POJOに代表されるようなJava内部からの解決を図るといったことは、C++の今後に大きな課題を示しているように思えます。
たとえば、現在ニーズの高いWeb環境やデータベースを活用したアプリケーションの開発を考えた場合、プログラミング言語については、プロジェクトの要件によって、それぞれの長所を活かす形で、
本来ならC++の特長が活かせるケースで、開発者が育たず実装も困難であるため、開発プロジェクトが成立しないのは、大変残念な情況です。
しかし、こうした情況の中でも、C++の標準ライブラリは、過剰な実装を強いるというような批判には有効な対策を打ち出さすことはできませんでした。むしろテンプレートによるメタ・プログラミングなど、新たな要素を取り入れ、さらにスタイルのバリエーションを増やすといった、現場のプログラマーからはますます遠いところを目指してしまっているようです。
言語やライブラリの設計者の立場からすれば、バリエーションを増やすことは適用の可能性を広げる望ましいことになるのかもしれません。しかしプログラマーの立場からすると、過度の機能や複雑さは開発を困難にするだけのものです。
さらに問題を難しくしているのは、こうしたライブラリの拡張が標準という形で提供されることです。
標準化されることで重複する努力が集約され、多くの人々が関わることで高品質のものが出来ていくことは素晴らしいことです。しかし一方で、標準化が、商業的な利益が大きいことから通商上の戦略に使われたり、標準以外の可能性の芽を摘み取ってしまう危険性をもつことも意識しておく必要があります。
また、標準化された技術そのものも情況に応じて工夫改良をしていくような流動性を失いがちになります。利用者側が、標準だけを視野にいれ、問題が生じてもそれを克服できない状況は避けなければなりません。
標準を尊重する一方で、適切なオルターナティブを育てる努力をするということも、(特に日本のように過度に標準に依存する傾向がある環境では)重要なことだと思います。
多少話しを大きくしすぎましたが、問題の目立つようになってきた標準ライブラリを使ったプログラミングに対して、より現場の要求を汲みいれることができるオルターナティブを確立し、C++の持つ長所を引き出して、もっと活用できる機会を増やすことが必要だと考えます。
以下では、C++の複雑さに戻り、C++がCを出発点に様々な仕様を徐々に加えながら進化してきたことに加え、「値指向」と「ポインタ指向」の2つのプログラミング・スタイルの軸をもったことが問題の根元にあることを確認した上で、有効な解決策を探っていきます。
言語仕様が複雑で、標準ライブラリの肥大化がしていくなかで、多くのプログラマーが短い期間で理解できる良いC++コードの作成方法を確立する、これが課題です。
そこでまず、C++の言語仕様にある本質的な難しさについて、代入を例にとって他の言語と比べて検討し、その中から有効な方法を探ることにします。
a = b
Cの場合、たとえばBasicのような型づけの甘い言語に比べて、プログラマーは変数の型を強く意識する必要があります。特に変数がポインタであった場合、
const char* a; /* A. */
const char* b = "hello, world!"; /* B. */
a = b; /* 1. */
if ( b ) {
a = ( char* )malloc( strlen( b ) + 1 ); /* 2-1. */
strcpy( a, b ); /* 2-2. */
}
Cを学習中のプログラマーが最初に躓(つまず)くのがこの 1. と 2-2. の違いでしょう。
Basicでは 1. の構文で a も "hello, world!"という値を持つと考えることができたのが、Cでは「ポインタ」と「指し先の領域」をはっきりイメージすることが求められます。
こうした混乱は、B. の初期化構文が 1. と同じように見える点が原因になっているのかもしれません。
B. の"hello, world!"が const char* 型の14バイトの領域を指すポインタだと意識でき、2-2. の前に 2-1. の領域確保が必要であることが理解できるようになってはじめて、正しいCのプログラムを書くことができるわけです。
しかし、アプリケーションがGUIやDBを取り込んで、ある程度複雑なデータを扱うようになると、文字列の処理にすら「型」や「領域の管理」を意識しなければならないのは、普通のアプリケーション・プログラマーには酷な要求になってきます。
C++では、クラスの導入によってこの問題に応えることができるように思えます。
string a;
string b = "hello, world!"; /* 1. */
string c = b; /* 2. */
a = b; /* 3. */
stringクラスの変数は領域へのポインタでなく、 int などのCの基本型と同じように値を持つと考えてプログラムできます。
しかし、この「同じように」扱うために、上記 1. ~ 3. には仕組みが必要になります。
string( const char* );
string( const string& );
string& operator=( const string& );
実際に良いC++プログラムを書こうと思うと、プログラマーは、代入といった極めて基本的な処理でさえ、 パフォーマンスの維持や、例外への対応のために、こうした暗黙のうちに動く仕組みを意識する必要にせまられます。(たとえば1.のコードは、const char* からstringを作って、さらにコピー代入演算子による代入を行います)
さらに、その仕組を実現するために、リファレンスやオペレータ・オーバーロードといった新しい機能が導入され、仕様の肥大化が始まりました。Cに慣れたプログラマーがC++でクラスを自ら作成する場合は、こうした仕組みをC++に導入された機能を使って作りこむ必要があるため、アプリケーションとは関係のない処理を無理やり書かされている感覚がぬぐえません。
クラスだけでなく、継承関係、仮想関数、テンプレートといった機能はたいへん強力なものですが、仕様の肥大化は指数的にプログラマーの負担を増す結果となっています。
実際 B.Stroustrup のC++の教科書[4]は英文で1000ページを越え、標準ライブラリも仕様だけで700ページを越えています。[5]
加えて、言語仕様にもライブラリにも、単純に組み合わせて使うと陥る落とし穴や、素直には推測できない実装上の詳細が数多くあるため、良いC++のコードを作ろうと思うと、少なくともS.Meyersの3部作[6][7][8] 程度には目を通して、それらを理解する必要があります。
こうした情況下で、日々実務に追われるアプリケーション・プログラマーがC++を使うには、膨大な仕様を絞り込んで使いこなせるようにするためのプログラミング・スタイルが必要になります。
しかし、現在市販されているC++の解説書のほとんどが、こうしたスタイルの絞込みには触れることなく、膨大な機能の説明に終始しており、しかも落とし穴や詳細まで解説することもできていません。
貴重な時間を削って解説書を読みきったにもかかわらず、まともなプログラムを書くためのスタートラインにも立てずに、あまりCと変わらないコードをC++コンパイラにかけて、依頼主にC++を使っていますというのが精一杯では、やりきれません。
これに対してJavaでは、クラスの変数は、はじめからC/C++のポインタに相当するものとして実装されます。これによりポインタと参照先の区別を常に意識する必要がなくなり、ガベージ・コレクションを採用することで領域の管理もシステム側で担うようにしています。
フレームワークやパターンの議論に目を奪われがちですが、クラス・ライブラリの肥大化やパフォーマンスに問題を抱えながらも、大規模なアプリケーション・システムの構築でJavaが採用される背景には、こうしたプログラマーにとっての負担の軽さが大きく影響しているように思えます。
では、システム・プログラマーの場合はどうでしょうか?
Cの場合、型の意識と領域の管理の問題さえしっかり押さえれば、非常にシンプルな仕様で、アセンブラに近いレベルの動作を想定しながら、関数や構造体を使って抽象度の高いプログラムが作成できるといった特長をもちます。
オブジェクト指向が広く普及してもCが使用されつづけるのは、C++の肥大した仕様がCのシンプルさに劣るからにほかなりません。
では、利用しやすいプログラミング・スタイルを確立するために、複雑なC++の仕様をどう絞り込めばよいのでしょうか?
まず、可能な絞り込みを見極めるため、C++の発展に応じた可能なスタイルのレベル分けをしてみます。
C++で可能なスタイル
スタイル 値指向 ポインタ指向 備考 Cプログラミング ○ 構造体やクラスの関数への引渡しは、ポインタを使用する クラスの利用 ○ コピー・コンストラクタとコピー代入演算子の導入で、インスタンスを直接関数に引渡し、代入することを可能にした 仮想関数の利用 ○ 仮想関数は派生元のクラスへのポインタかレファレンスを使用して呼び出すが、レファレンスは必ず初期化が必要で参照先も変更できないため、ポインタを使用しないプログラムは現実的ではない テンプレートの利用 ○ ○ テンプレートは型の一致による機能であるため、値かポインタか、には依存しない STLの利用 ○ STLのコンテナは、ポインタを格納した状態でコピー・コンストラクタやコピー代入演算子が呼び出されると、リソース・リークや不正な参照が起こるため、通常は値を格納して使用するか、boostライブラリなどが提供する共用ポインタ・クラスを介して使用する必要がある 標準ライブラリの拡張 △ STLは値指向のスタイルを貫いていたが、テンプレートを使ったメタ・プログラミングの登場で、スタイルが多様化してしまった
Cのスタイルでコーディングするレベルをはじめとして、その機能を取り入れることでスタイルが変更されることがあるものを順に取り出してみても、少なくとも6レベルあることが分かります。
さらに、それぞれのレベルに、前節の例でとりあげたCの char* を使うケースのように、ポインタを活かす「ポインタ指向(pointer-based)」スタイルか、C++の std::string を使うケースのように、値を活かす「値指向(value-based)」スタイルかを当てはめてみると、それらが混在していて、スタイルを2分していることが分かります。
まず、標準ライブラリをベースに「値指向」でスタイルを絞り込むことを考えてみましょう。
絞り込みによって、たとえばSTLコンテナにstd::auto_ptrを格納できないことなど、プログラマーを惑わせる落とし穴のいくつかを回避するようなスタイルを作ることは可能でしょう。しかし、落とし穴が散在しているため、軸になるスタイルを作っていろいろな場面で適用するというより、スタイルが例外への対応リストになってしまい、プログラマーの習熟速度を上げることができるかどうか、疑問が残ります。
さらに以下のような問題があり、実際に長期にわたって検討をしましたが実現には至っていません。
仮想関数は派生元のクラスへのポインタかレファレンスを使用して呼び出しますが、レファレンスは初期化時に参照先を決める必要があり、参照先を変更することもできません。
ポリモルフィックなクラスのインスタンスは、その派生元のクラスのポインタに格納してはじめて、自由な受け渡しが可能になります。特に、コンテナにポリモルフィックなクラスのインスタンスを格納する場合、値ではなくポインタでなければなりません。そのため、ポインタ指向のスタイルを取り入れないと、継承による仮想関数の使用というオブジェクト指向の利点を活かすことができなくなります。
データベースやユーザー・インターフェースなどに対しては、すこしづつC++によるインターフェースが整備されてきましたが、そうでない場合や、実行効率やリソースの節約のためにあえてCによるインターフェースを使用するケースも非常に多いように思えます。しかし、値指向で書かれたプログラムから、Cのインターフェースへのデータの受渡しにはポインタが用いられることになります。そのため、ポインタへの変換や指し先の領域確保など、ポインタ指向スタイルを取り入れる必要が生じます。
例として、STLの「コンテナ-アルゴリズム-イテレータ」モデルをとりあげてみましょう。
このモデルは、一見整合性があり、ケースの組み合わせ数を積から和に変える効果があるようですが、当然ながら異なるコンテナに対するアルゴリズムはそのシグネチャが異なることも多く、無意味な組み合わせが多数存在するため、思ったほど効果が得られません。
しかも、問題は、そうしたあまり効果のない整合性を維持するために、「イタレータ」とポインタ、「アルゴリズム」と関数ポインタ、という互換性の維持が必要になることです。
たとえばイタレータを実装してみると明らかですが、アプリケーションと直接関係のないコードをたくさん作り、その検証に多くの労力を割くことになります。
このモデルを採用する場合、標準ライブラリやboost[3]のような実績のあるライブラリを使うのが精一杯で、そうしない場合、生産性が極端に低下することになります。
しかし、一般的に使用されるライブラリをこのモデルにもとづいて作成しようとする場合、どんなに頑張っても個々のアプリケーションの多様性を標準ライブラリだけでカバーできないケースがでてきます。
標準ライブラリの拡張が可能とはいいながらここまで難しいことは、その設計思想そのものに欠陥があるといってもよいのではないでしょうか。
結局、「値指向」スタイルによる方法では仕様の絞り込みはむずかしく、膨大な仕様を使いこなせるまでの時間と才能をもつプログラマーにしか対応できないように思えます。
ポインタ指向スタイルは、標準ライブラリが追求してきた値指向スタイルへのオルターナティブとして、Cのポインタを使うスタイルにオブジェクト指向の機能を取り込むものです。
ポインタと指し先の領域を意識して、領域を管理しなければなりませんが、この2点さえ押さえてしまえば落とし穴が少なくシンプルなプログラミングを可能にする、そういったスタイルを目指します。
具体的な検討に入る前に、その前提として、データクラスと処理クラスの区別にふれておきましょう。
オブジェクト指向といっても、結果が決められていないシミュレーションを行うようなアプリケーション以外では、純粋にオブジェクトがメッセージを交換しあうモデルは採用できないでしょう。要件で決められた目的を達成するアプリケーション・プログラムでは、その処理手順をなんらかの方法で制御することになります。
C++の場合、Cの手続き型言語の特徴をそのまま継承しているので、main() 関数から始まるトップダウンの関数呼び出しが、そのまま処理手順の制御になります。
こうした手続き型言語では、プログラムの変更に対する強度が、変数のスコープをできるだけ局所化することによって決まります。
Cでは、ファイル(ヘッダ・ファイルとその実装)と関数(宣言と実装)の2つのレベルでインターフェースと実装を分離することが可能で、さらにブロックによってスコープを制限することができました。
C++のクラスは、処理手順の制御の観点から見ると、Cのファイルと関数の間に位置する抽象データ型を作る仕組みと考えることができます。
実際、Cではファイル内にスコープを制限したグローバル変数とすることしかなかったデータを、C++ではそれらをクラスのメンバ変数としてより局所化することが可能です。
このように、クラスを処理で使用される変数の局所化ツールとして使うものを、処理クラスと呼ぶことにします。
処理クラスは、通常その処理が必要となるブロックで1つ宣言され、コンストラクタで初期化処理を行い、メンバ関数がメンバ変数をクラス内でグローバルな変数として処理し、デストラクタで終了処理をおこないます。
これに対して、Cの構造体として扱われるデータに、初期化、参照、更新、終了などの処理を加えてクラス化したものをデータクラスと呼ぶことにします。
データクラスは、通常、処理の過程でヒープ内に置かれるため、0個の場合も含め、その数は一定しないのが普通です。リストなどのコンテナに格納されてまとめて処理されることも少なくありません。
クラスをその使用方法から処理クラスとデータクラスに分けて考えることで、ポインタ指向のプログラミング・スタイルをより明解に示すことが可能になります。
以下にその主な特徴を列挙してみましょう。
C++はCの拡張なので、当然のことであるはずですが、値指向スタイルではこの当然がしばしば成立しませんでした。
ポインタ指向スタイルでは、
などのお馴染みのスタイルを、無理なく応用していくことができます。
クラスの宣言の際、コピー・コンストラクタとコピー代入演算子をプライベート宣言することで、値のコピーによる初期化と代入を防ぐのは広く知られた技法ですが、ポインタ指向スタイルでプログラムを記述する場合はこれを積極的に利用して、暗黙の値渡しや値指向スタイルの混入を防止します。
コピー・コンストラクタ等をプライベート化することで、ポインタの指し先は明示的に関数を呼び出してコピーをすることになります。
ポインタ指向スタイルでは、テンプレートによってコンテナなどで使用される標準的なコピーの方法を提供して、ライブラリの自然な拡張を可能とすると同時に、値指向で問題となるプログラマーが意識しないところで動作してしまう暗黙のコピーを防止します。
データクラスは通常 new オペレータ(あるいはファクトリー・メソッドなど)でヒープに作成され、そのポインタを保持して、使用後に delete を確実に実行することが必要になります。したがって、ポインタの領域管理機能をもったコンテナに発生時点で収納し、受け渡しなどもコンテナごと行うようにすれば、プログラマーの領域管理の負荷を大幅に減らすことが可能です。
また、ファクトリー・メソッドやアブストラクト・ファクトリーなどのパターンを使用する場合は、それぞれ、クラスにスタティック・メンバとして、およびファクトリーのメンバとして、コンテナを置くことで、領域管理をより明確に行うことができます。
処理クラスは、メンバ関数によるメンバ変数の共有が目的であるため、通常は1つのインスタンスを生成し、引渡しなどは行われません。
そのため、処理を渡すブロックに処理クラスの変数を宣言し、ブロックを抜ける際にデストラクタで終了処理を行う形で使用します。
ポインタによって領域管理をしている場合、例外によってその領域へのデストラクタの適用ができないことが問題になります。
しかし、ポインタ指向スタイルでも、上記のようにデータクラスのインスタンスをコンテナで管理し、処理クラスをブロックに宣言していれば、値指向スタイルと同じようにデストラクタが適用されるため問題ありません。
むしろ課題は、例外処理の使用によって、その発生をプログラマーが意識しながらプログラムを書かなければならず、しかもテストケースを指数的に増やしてしまうということにあります。
値指向スタイルでは、コンストラクタでのエラーに対応するために、例外を使わざるをえないケースが存在します。これに対して、ポインタ指向スタイルでは、ファクトリー・メソッドを使用してエラー値(ヌル・ポインタ)として返すようにすることが可能です。
このことは、ポインタ指向スタイルによって、例外の発生を、プログラムを終了させるエラーに限定して使用するような、よりシンプルなコードの作成が可能になることを意味しています。
こうした特徴に加えて、値指向スタイルで問題になったCによるインターフェースを提供している既存のライブラリとの親和性も高く継承関係と仮想関数を使ったコードにも素直に対応できるため、実際のプログラム開発に有効なポインタ指向プログラミング・スタイルの確立は、十分に可能であると考えられます。
そのためには、値指向スタイルに軸を置く標準ライブラリ、特にコンテナを含むSTLへのオルターナティブが必要になります。
今回、「道具箱」として開発していくライブラリは、将来的に、標準ライブラリのオルターナティブとして受け入れられるものの核になることを、目指したいと思います。
参考文献
第3版では、従来のC++標準の普及を目指すことから、 複雑化したC++標準を使いこなすにはどうすればよいかという、姿勢の変更が見られる。
STLが極めて特殊なライブラリであるとする記述などから、ポインタ指向プログラミングのヒントを得ることができた。
2006/04に第3版の邦訳が出版された。
システム・プログラマーの立場からみたプログラミング言語の評価の中で、C++の複雑さに手厳しい批判を浴びせている