Stanley B. Lippman
Microsoft Corporation
August 2004
日本語版最終更新日 2005 年 6 月 6 日
適用対象 :
C++/CLI Version 2
ISO-C++
概要 : C++/CLI は、ISO-C++ 標準言語に対する動的プログラミング パラダイムの拡張です。このドキュメントでは、Version 1 言語の機能を列挙し、それらを Version 2 言語にマップします。また、マップできない要素がどれかも説明します。
目次
はじめに
1. 言語キーワード
2. マネージ型
3. クラスまたはインターフェイス内でのメンバ宣言
4. 値型とその動作
5. 言語の修正の概要
付録: 修正を施した言語デザインの勧め
謝辞
はじめに
C++/CLI は、ISO-C++ 標準言語に対する動的プログラミング パラダイムの拡張です。元の言語のデザイン (Version 1) には重要な欠点がいくつかありましたが、改訂された言語のデザイン (Version 2) ではそれらを修正できたと私たちは考えています。このドキュメントでは、V1 言語の機能を列挙し、それらを V2 言語にマップします。また、マップできない要素がどれかも説明します。さらに、関心のある読者のために、新しい言語デザインの論理的根拠に関する詳しい説明も付録として用意してあります。このほか、現在、C++/CLI 言語のリリースと共に提供するべくソース変換ツール (mscfront) が開発されています。このツールは、V1 のコードを新しい言語デザインに自動的に移行するのに役立ちます。
このドキュメントは 5 つのセクションと 1 つの付録に分かれています。セクション 1 では、二重のアンダースコアの廃止や、コンテキスト キーワードとスペース区切りキーワードという 2 種類のキーワードの導入など、言語キーワードの問題について大まかに説明します。セクション 2 では、マネージ型の変更について、マネージ参照型と配列を中心に見ていきます。確定的ファイナライゼーションのセマンティクスについての詳しい議論もここに含まれます。プロパティ、インデックス プロパティ、演算子などのクラス メンバに関連する変更については、セクション 3 で紹介します。セクション 4 では、CLI の列挙型、内部ポインタ、および固定ポインタの構文の変更について確認します。そのほか、暗黙的なボックス化の導入、CLI の列挙型の変更、値クラスにおける既定のコンストラクタのサポートの廃止など、いくつかの重要なセマンティクスの変更についても説明します。セクション 5 は、よくその他として括られるような雑多な内容になっています。キャストの表記、リテラル文字列の動作、パラメータ配列などについての議論はここに含まれます。
1. 言語キーワード
元の言語のデザインと改訂された言語のデザインの間の全般的な変化の 1 つとして、すべてのキーワードから二重のアンダースコアが取り除かれています。たとえばプロパティは、__property ではなく property として宣言されるようになりました。元の言語のデザインで二重のアンダースコアがプレフィックスとして使用されていたのには、主に 2 つの理由があります。
- 二重のアンダースコアは、ISO-C++ 標準にローカル拡張を提供するための標準の方法です。元の言語のデザインでは、新しいキーワードやトークンなど、標準の言語と互換性のない要素を導入しないことが第 1 の目標とされていました。マネージ参照型のオブジェクトの宣言にポインタ構文が採用されたのも、主にそのためです。
- 二重のアンダースコアが使用されたのは、標準に準拠するためだけでなく、ユーザーの既存のコード ベースに干渉しないようにするためでもありました。このことは、元の言語のデザインの第 2 の目標でした。
それでは、二重のアンダースコアはどうして取り除かれることに (また、いくつかの新しいトークンが導入されることに) なったのでしょうか。もちろん、標準への準拠が考慮されなくなったわけではありません。
標準への準拠は今も変わらず重要な課題です。しかしその一方で私たちは、CLI の動的オブジェクト モデルのサポートを新しい強力なプログラミング パラダイムとして認識しています。元の言語をデザインした経験と、C++ 言語自体をデザインし、進化させてきた経験から、私たちは、この新しいパラダイムをサポートするためには独自の高レベルのキーワードやトークンが必要だと確信しました。ここで私たちが目指したのは、この新しいパラダイムの最高レベルの表現と、その統合および標準言語のサポートとの両立です。改訂された言語のデザインは、これら 2 つのまったく異なるオブジェクト モデルについて最高レベルのプログラミング エクスペリエンスを実現していると認めていただけるものになっていると思っています。
同じく大きな課題となったのが、そうした新しい言語キーワードによる干渉をできる限り少なくすることでした。そのために使用されているのが、コンテキスト キーワードとスペース区切りキーワードです。改訂された言語の実際の構文を見る前に、これらの特殊なキーワードの特徴について説明しておきます。
コンテキスト キーワードは、プログラムの特定のコンテキストの中で特別な意味を持ちます。たとえば sealed は、プログラム全体の中では通常の識別子として扱われます。しかし、マネージ参照クラス型の宣言の中で使用されると、そのクラス宣言のコンテキスト内のキーワードとして扱われます。これにより、言語に新しいキーワードを導入した場合の影響を最小限に抑えることができます (これは、既存のコード ベースを持つユーザーにとって非常に重要であると私たちが感じていたことです)。その一方で、新しい機能を使用するユーザーに対しては、追加の言語機能についての最高レベルのエクスペリエンスを提供できます (これは、元の言語のデザインに欠けていたと私たちが感じていたことです)。sealed については、セクション 1.1.2 で使用例を紹介します。
スペース区切りキーワードは、コンテキスト キーワードの特殊なケースです。このキーワードは、既存のキーワードとコンテキスト修飾子を、その名のとおりスペースで区切って組み合わせたものです。このキーワードのペアは、2 つの別々のキーワードとしてではなく 1 つのキーワードとして扱われます (value class など。例については、セクション 1.1 を参照してください)。実際の問題として見てみましょう。value のマクロ再定義を次のように記述したとします。
#ifndef __cplusplus_cli
#define value
この場合、value はクラス宣言から取り除かれません。そうするには、次のように記述して、キーワードのペアを再定義する必要があります。
#ifndef __cplusplus_cli
#define value class class
この変更は実際的な理由から絶対に必要です。なぜなら、そうでないと、スペース区切りキーワードのコンテキスト キーワード部分に対して既存の #define の変換が行われてしまうからです。
2. マネージ型
マネージ型の宣言の構文や、それらの型のオブジェクトを作成したり使用したりするための構文は、ISO-C++ の型システムとの統合を促進するために大幅に改変されています。この後の一連のサブセクションでは、これらの変更について詳しく説明します。デリゲートについては、セクション 2 全体のトピックであるクラス内のイベント メンバと一緒に取り上げるために、セクション 2.3 で説明することにします (トラッキング参照構文の導入およびデザインの全般的な変化の背景となる論理的根拠の詳細については、「Appendix A, Motivating the Revised Language Design」を参照してください)。
2.1 マネージ クラス型の宣言
元の言語の定義では、参照クラス型の前には __gc キーワードが付けられていました。改訂された言語では、__gc キーワードの代わりに 2 つのスペース区切りキーワードのいずれかを使用します。ref class または ref struct のいずれかです。struct と class の違いは、型の本体の最初のラベルのない部分で宣言されているメンバの既定のアクセス レベルが public (struct の場合) になるか private (class の場合) になるかの違いだけです。
同様に、元の言語の定義では、値クラス型の前には __value キーワードが付けられていました。改訂された言語では、__value キーワードの代わりに 2 つのスペース区切りキーワードのいずれかを使用します。value class または value struct のいずれかです。
インターフェイス型は、元の言語の定義ではキーワード __interface で示されていました。改訂された言語では、代わりに interface class を使用します。
たとえば、次のようなクラス宣言があったとします。
// 元の言語の構文
public __gc class Block { ... }; // 参照クラス
public __value class Vector { ... }; // 値クラス
public __interface IMyFile { ... }; // インターフェイス クラス
これらの宣言は、改訂された言語のデザインでは次のようになります。
// 改訂された言語の構文
public ref class Block { ... };
public value class Vector { ... };
public interface class IMyFile { ... };
gc (ガベージ コレクションの対象であることを表します) ではなく ref (参照型を表します) が採用されたのは、その方が型の本質的な性質がわかりやすいと考えられたためです。
2.1.1 abstract としてのクラスの指定
元の言語の定義では、キーワード __abstract は型のキーワードの前 (__gc の前または後) に置かれ、クラスが不完全であり、そのクラスのオブジェクトをプログラムの中で作成できないことを表します。
public __gc __abstract class Shape {};
public __gc __abstract class Shape2D: public Shape {};
改訂された言語のデザインでは、コンテキスト キーワード abstract を、クラス名と、クラス本体、基本クラス派生リスト、またはセミコロンとの間に指定します。
public ref class Shape abstract {};
public ref class Shape2D abstract : public Shape{};
もちろん、意味的な変更はありません。
2.1.2 sealed としてのクラスの指定
元の言語の定義では、キーワード __sealed は class キーワードの前 (__gc の前または後) に置かれ、そのクラスのオブジェクトを継承できないことを表します。
public __gc __sealed class String {};
V2 の言語のデザインでは、コンテキスト キーワード abstract を、クラス名と、クラス本体、基本クラス派生リスト、またはセミコロンとの間に指定します (派生クラスをシールすることもできます。たとえば String クラスは、Object から暗黙的に派生しています)。クラスをシールすると、sealed の参照クラス オブジェクトを通じてすべての仮想関数呼び出しを (コンパイル時に) 静的に解決できるというメリットがあります。これは、sealed 指定子によって、String のトラッキング ハンドルが、それ以降に派生するクラス (呼び出される仮想メソッドをオーバーライドする可能性がある) を参照できなくなるからです。
public ref class String sealed {};
クラスを abstract と sealed の両方として指定することもできます。これは、静的クラスを表す特殊な状況になります。これについて、CLI のドキュメントには次のように書かれています。
abstract と sealed の両方である型は静的なメンバのみを持ち、一部の言語で名前空間と呼ばれる要素として機能します。
以下は、V1 の構文を使用した abstract sealed クラスの宣言の例です。
public __gc __sealed __abstract class State
{
public:
static State();
static bool inParamList();
private:
static bool ms_inParam;
};
この宣言を、改訂された言語のデザインに変換すると、次のようになります。
public ref class State abstract sealed
{
public:
static State();
static bool inParamList();
private:
static bool ms_inParam;
};
2.1.3 CLI の継承: 基本クラスの指定
CLI のオブジェクト モデルでは、単一のパブリック継承のみがサポートされています。しかし、元の言語の定義では、基本クラスにアクセス キーワードが指定されていないと、ISO-C++ 標準に従って、既定でプライベート派生が指定されているものと見なされます。このため、各 CLI 継承宣言では、この既定の解釈をオーバーライドするためだけに public キーワードを指定しなければなりませんでした。このことは、多くのユーザーによって、コンパイラの融通のなさと感じられていました。
// V1: エラー: 既定でプライベート派生になります
__gc class My : File{};
改訂された言語の定義では、CLI 継承定義でアクセス キーワードがなかった場合は既定でパブリック派生になります。したがって、アクセス キーワード public は必須ではなくオプションになります。この変更は完全を期すために紹介したものであり、これによって V1 コードの修正が必要になることはありません。
// V2: ok: 既定でパブリック派生になります
ref class My : File{};
2.2 CLI 参照クラスのオブジェクトの宣言
元の言語の定義では、参照クラス型のオブジェクトは ISO-C++ ポインタ構文を使用して宣言し、オプションで __gc キーワードをアスタリスク (*) の左に指定します。以下は、V1 構文によるさまざまな参照クラス型オブジェクトの宣言の例です。
public __gc class Form1 : public System::Windows::Forms::Form {
private:
System::ComponentModel::Container __gc *components;
Button __gc *button1;
DataGrid __gc *myDataGrid;
DataSet __gc *myDataSet;
void PrintValues( Array* myArr )
{
System::Collections::IEnumerator* myEnumerator =
myArr->GetEnumerator();
Array *localArray = myArr->Copy();
// ...
}
};
改訂された言語のデザインでは、新しい宣言トークン (^) を使用して参照クラス型のオブジェクトを宣言します。これは、正式にはトラッキング ハンドルと呼びますが、単純にハットとも呼ばれます (参照型は CLI ヒープ内にあるため、ガベージ コレクションによってヒープがコンパクト化されるときに知らぬ間に移動される可能性があります。トラッキングという形容詞は、このことを強調しています)。トラッキング ハンドルは実行時に透過的に更新されます。類似の概念として、(a) トラッキング参照 (%) と (b) 内部ポインタ (interior_ptr<>) の 2 つがあります。これらについては、セクション 4.4.3 で説明します。
ISO-C++ のポインタ構文の再利用から宣言型の構文に移行したのには、主な理由が 2 つあります。
- ポインタ構文を使用すると、オーバーロード演算子を参照オブジェクトに直接適用できず、内部名を通じて演算子を呼び出さなければなりません。たとえば、rV1->op_Addition(rV2) としなければならず、rV1+rV2 に比べて直感性に欠けます。
- ガベージ コレクトされるヒープに格納されているオブジェクトに対して許可されないポインタ操作がいくつかあります (キャストやポインタ演算など)。トラッキング ハンドルの概念は CLI 参照型の本質をより的確に捉えていると私たちは考えています。
トラッキング ハンドルでは __gc 修飾子を使用する必要はなく、サポートもされていません。オブジェクトそのものの使用方法は変わりません。これまでどおり、ポインタ メンバ選択演算子 (->) によってメンバにアクセスできます。たとえば、上の V1 のテキストを新しい言語構文に変換すると、次のようになります。
public ref class Form1: public System::Windows::Forms::Form{
private:
System::ComponentModel::Container^ components;
Button^ button1;
DataGrid^ myDataGrid;
DataSet^ myDataSet;
void PrintValues( Array^ myArr )
{
System::Collections::IEnumerator^ myEnumerator =
myArr->GetEnumerator();
Array ^localArray = myArr->Copy();
// ...
}
};
2.2.1 CLI ヒープへのオブジェクトの動的割り当て
元の言語のデザインでは、オブジェクトをネイティブ ヒープとマネージ ヒープに割り当てるために 2 つの new 式が存在することが、ほとんど意識されませんでした。ネイティブ ヒープとマネージ ヒープのどちらにオブジェクトを割り当てるべきかは、ほとんどの場合、コンパイラがコンテキストから正しく判断できます。以下に例を示します。
Button *button1 = new Button; // OK: マネージ ヒープ
int *pi1 = new int; // OK: ネイティブ ヒープ
Int32 *pi2 = new Int32; // OK: マネージ ヒープ
コンテキストに基づくヒープの割り当てを望まない場合は、__gc または __nogc のいずれかのキーワードを使用してコンパイラを制御することもできます。改訂された言語では、gcnew キーワードの導入によって、異なる 2 つの new 式の存在が明確になっています。たとえば、上の 3 つの宣言は、改訂された言語では次のようになります。
Button^ button1 = gcnew Button; // OK: マネージ ヒープ
int * pi1 = new int; // OK: ネイティブ ヒープ
interior_ptr<Int32> pi2 = gcnew Int32; // OK: マネージ ヒープ
(interior_ptr については、セクション 3 で詳しく説明します。一般に、interior_ptr は、マネージ ヒープに存在する可能性がある (実際には存在していなくてもかまいません) オブジェクトを指します。参照先のオブジェクトが実際にマネージ ヒープにある場合は、そのオブジェクトが移動されると透過的に更新されます)。
以下は、前のセクションで宣言した Form1 のメンバを V1 で初期化する例です。
void InitializeComponent()
{
components = new System::ComponentModel::Container();
button1 = new System::Windows::Forms::Button();
myDataGrid = new DataGrid();
button1->Click +=
new System::EventHandler(this, &Form1::button1_Click);
// ...
}
この初期化コードを改訂された言語の構文に書き直すと、次のようになります。gcnew 式のターゲットになる場合は参照型にハットが必要ない点に注意してください。
void InitializeComponent()
{
components = gcnew System::ComponentModel::Container;
button1 = gcnew System::Windows::Forms::Button;
myDataGrid = gcnew DataGrid;
button1->Click +=
gcnew System::EventHandler( this, &Form1::button1_Click );
// ...
}
2.2.2 オブジェクトなしへのトラッキング参照
新しい言語のデザインでは、0 は null アドレスを表すものとしてではなく、単純に整数 (1、10、100 などと同じ) として扱われるようになりました。このため、トラッキング参照の null 値を表す特殊なトークンを導入する必要が生じました。たとえば、オブジェクトなしを参照するように参照型を初期化する場合、元の言語のデザインでは次のようになります。
// OK: オブジェクトなしを参照するように obj を設定します
Object * obj = 0;
// エラー: 暗黙的なボックス化は行われません ...
Object * obj2 = 1;
改訂された言語では、Object の初期化や値の割り当てを値型で行うと、その値型の暗黙的なボックス化が行われます。したがって、上の obj と obj2 は両方とも、参照先のボックス化された Int32 オブジェクト (それぞれ値 0 および 1 を保持している) に初期化されます。以下に例を示します。
// 0 と 1 の両方の暗黙的なボックス化が行われます
Object ^ obj = 0;
Object ^ obj2 = 1;
その結果、トラッキング ハンドルの null への明示的な初期化、割り当て、および比較を行えるようにするために、新しいキーワードとして nullptr が導入されました。したがって、先ほどの V1 の例を改訂された言語で正しく書き直すと次のようになります。
// OK: オブジェクトなしを参照するように obj を設定します
Object ^ obj = nullptr;
// OK: obj2 を Int32^ に初期化します
Object ^ obj2 = 1;
V1 コードを改訂された言語のデザインに移植する際には、このことによって作業が少し複雑になります。たとえば、次のような値クラスの宣言があったとします。
__value struct Holder { // 元の V1 の構文
Holder( Continuation* c, Sexpr* v )
{
cont = c;
value = v;
args = 0;
env = 0;
}
private:
Continuation* cont;
Sexpr * value;
Environment* env;
Sexpr * args __gc [];
};
args と env はいずれも CLI 参照型です。コンストラクタでこれら 2 つのメンバが 0 に初期化されていますが、これらは、このままでは新しい構文に移行できません。nullptr に変更する必要があります。
// 改訂された V2 の構文
value struct Holder
{
Holder( Continuation^ c, Sexpr^ v )
{
cont = c;
value = v;
args = nullptr;
env = nullptr;
}
private:
Continuation^ cont;
Sexpr^ value;
Environment^ env;
array<Sexpr^>^ args;
};
これらのメンバを 0 と比較するテストも、同様に nullptr との比較に変更する必要があります。以下は元の構文です。
// 元の V1 の構文
Sexpr * Loop (Sexpr* input)
{
value = 0;
Holder holder = Interpret(this, input, env);
while (holder.cont != 0)
{
if (holder.env != 0)
{
holder=Interpret(holder.cont,holder.value,holder.env);
}
else if (holder.args != 0)
{
holder =
holder.value->closure()->
apply(holder.cont,holder.args);
}
}
return value;
}
改訂された言語の構文は、上の構文の 0 がそれぞれ nullptr に変更されて、次のようになります (この変換では変換ツールが役に立ちます。すべてとは言わないまでも、NULL マクロの使用を含む多くの変換を自動化できます)。
// 改訂された V2 の構文
Sexpr ^ Loop (Sexpr^ input)
{
value = nullptr;
Holder holder = Interpret(this, input, env);
while ( holder.cont != nullptr )
{
if ( holder.env != nullptr )
{
holder=Interpret(holder.cont,holder.value,holder.env);
}
else if (holder.args != nullptr )
{
holder =
holder.value->closure()->
apply(holder.cont,holder.args);
}
}
return value;
}
nullptr は、任意のポインタ型やトラッキング ハンドル型に変換されますが、整数型への上位変換は行われません。たとえば、以下の一連の初期化で nullptr が初期値として認められるのは最初の 2 つだけです。
// OK: オブジェクトなしを参照するように obj と pstr を設定します
Object^ obj = nullptr;
char* pstr = nullptr; // ここでは 0 も有効です ...
// エラー: nullptr は 0 には変換されません ...
int ival = nullptr;
同様の例をもう 1 つ紹介します。次のようなオーバーロード メソッドのセットがあったとします。
void f( Object^ ); // (1)
void f( char* ); // (2)
void f( int ); // (3)
上のメソッドを、次のように、nullptr リテラルで呼び出します。
// エラー: あいまいです。 (1) と (2) に一致します
f( nullptr );
この呼び出しがあいまいになるのは、nullptr がトラッキング ハンドルとポインタの両方に一致し、両者の優先順位も決まっていないからです (このあいまいさを解決するには、明示的なキャストが必要です)。
0 で呼び出した場合は、(3) に完全に一致します。
// OK: (3) に一致します
f( 0 );
これは、0 が整数型だからです。仮に f(int) がなかったとしても、この呼び出しはあいまいにはならず、標準変換によって f(char*) に一致します。一致の規則では、完全一致が標準変換より優先されます。完全一致がなかった場合は、標準変換が値型の暗黙的なボックス化より優先されます。あいまいにならないのはこの規則があるからです。
2.3 CLI 配列の宣言
元の言語のデザインにおける CLI 配列オブジェクトの宣言は、標準の配列宣言を拡張したものですが、あまり直感的とは言えません。具体的には、次のように、配列オブジェクトの名前と配列の次元 (カンマで指定) との間に __gc キーワードを配置します。
// V1 の構文
void PrintValues( Object* myArr __gc[]);
void PrintValues( int myArr __gc[,,]);
改訂された言語のデザインではこの構文が単純化されており、STL の vector の宣言を彷彿させるテンプレートのような宣言を使用します。 1 つ目のパラメータは要素型を指定します。2 つ目のパラメータは配列の次元を指定します (既定値は 1 なので、2 つ目の引数が必要なのは多次元配列の場合だけです)。配列オブジェクト自体はトラッキング ハンドルなので、ハットを付ける必要があります。要素型も参照型の場合は、要素型にもハットが必要です。たとえば、上の例を改訂された言語で表現すると、次のようになります。
// V2 の構文
void PrintValues( array<Object^>^ myArr );
void PrintValues( array<int,3>^ myArr );
参照型はオブジェクトではなくトラッキング ハンドルなので、CLI 配列を関数の戻り値の型として指定することもできます (ネイティブの配列は関数の戻り値の型には指定できません)。そのための構文は、元の言語のデザインではやや直感性に欠けるものとなっています。以下に例を示します。
// V1 の構文
Int32 f() [];
int GetArray() __gc[];
V2 では、この宣言がずっとわかりやすいものになっています。以下に例を示します。
// V2 の構文
array<Int32>^ f();
array<int>^ GetArray();
どちらのバージョンの言語でも、ローカル マネージ配列の短縮形の初期化がサポートされています。以下に例を示します。
// V1 の構文
int GetArray() __gc[]
{
int a1 __gc[] = { 1, 2, 3, 4, 5 };
Object* myObjArray __gc[] = {
__box(26), __box(27), __box(28), __box(29), __box(30)
};
// ...
}
この構文は、V2 では次のように大幅に単純化されています (改訂された言語のデザインではボックス化は暗黙的に行われるため、__box 演算子が取り除かれています。詳細については、セクション 3 を参照してください)。
// V2 の構文
array<int>^ GetArray()
{
array<int>^ a1 = {1,2,3,4,5};
array<Object^>^ myObjArray = {26,27,28,29,30};
// ...
}
配列は CLI 参照型なので、各配列オブジェクトの宣言はトラッキング ハンドルです。したがって、CLI ヒープに割り当てる必要があります (短縮形の表記では、マネージ ヒープの割り当ては隠れています)。配列オブジェクトの初期化の明示的な形式は、元の言語のデザインでは次のようになります。
// V1 の構文
Object* myArray[] = new Object*[2];
String* myMat[,] = new String*[4,4];
新しい言語のデザインでは、new 式が (既に説明したように) gcnew に置き換えられています。次元のサイズは、次のように、gcnew 式へのパラメータとして渡されます。
// V2 の構文
array<Object^>^ myArray = gcnew array<Object^>(2);
array<String^,2>^ myMat = gcnew array<String^,2>(4,4);
改訂された言語では、gcnew 式の後に明示的な初期化リストを指定できます。これは、V1 言語ではサポートされていません。以下に例を示します。
// V2 の構文
// 明示的な初期化リストを gcnew の後に指定します
// これは V1 ではサポートされていません
array<Object^>^ myArray =
gcnew array<Object^>(4){ 1, 1, 2, 3 }
2.4 デストラクタのセマンティクスの変更
元の言語のデザインでは、クラス デストラクタは参照クラスでは使用できますが値クラスでは使用できません。この点は、改訂された V2 の言語のデザインでも変更されていません。ただし、クラス デストラクタのセマンティクスは大幅に変更されています。"何が" "なぜ" 変更されたのか (および、それが既存の V1 コードの変換にどう影響するのか) がこのセクションのトピックです。これは、このドキュメントで最も込み入ったセクションになると思われるため、なるべくゆっくりと進むことにしましょう。またこれは、この 2 つのバージョンの言語間の変更のうちで、おそらくプログラマレベルでは最も重要な変更になります。したがって、一歩一歩詳しく見ていくだけの価値があります。
2.4.1 非確定的ファイナライゼーション
オブジェクトに関連付けられているメモリがガベージ コレクタによって回収される前には、関連付けられている Finalize() メソッドがあればそれが呼び出されます。このメソッドは、オブジェクトのプログラム内の存続期間に結び付いていないため、一種の特殊なデストラクタと考えることができます。これをファイナライゼーションと呼びます。Finalize() メソッドは、いつ呼び出されるかだけでなく、呼び出されるかどうかさえもはっきりしません。ガベージ コレクションの動作が非確定的ファイナライゼーションと呼ばれる意味はここにあります。
非確定的ファイナライゼーションは、動的メモリ管理と連携して機能します。利用可能なメモリがある程度まで不足すると、ガベージ コレクタが介入して問題が解決されます。ガベージ コレクション環境では、メモリを解放するためのデストラクタは必要ありません。初めてアプリケーションを実装するときにメモリ リークの可能性をいちいち心配しなくてもよいというのは落ち着かない気もしますが、それにもすぐに慣れるでしょう。
しかし、非確定的ファイナライゼーションが機能しない場合もあります。データベース接続やなんらかの種類のロックなど、オブジェクトがクリティカルなリソースを保持している場合がそうです。そのような場合は、そのリソースをできるだけ早く解放する必要があります。これは、ネイティブ環境ではコンストラクタとデストラクタをペアとして組み合わせることによって行われます。オブジェクトが宣言されたローカル ブロックが終了するか、例外がスローされてスタックが戻されることによって、オブジェクトの存続期間が終わると、すぐにデストラクタが呼び出されてリソースが自動的に解放されます。このしくみは実に効果的であり、元の言語のデザインに含まれなかったのは非常に残念でした。
CLI によって提供されているソリューションは、クラスで IDisposable インターフェイスの Dispose() メソッドを実装するというものです。ここで問題になるのが、Dispose() はユーザーが明示的に呼び出す必要があるということです。これではエラーの原因となるため、一歩後退と言わざるを得ません。C# 言語では、特殊な using ステートメントを使用することによって目立たない形でこれを自動化することができます。しかし、元の言語のデザインには、先にも述べたように、特別なサポートは一切用意されていませんでした。
2.4.2 デストラクタから Finalize() へ (V1 のケース)
元の言語では、参照クラスのデストラクタは次の 2 つのステップによって実装されます。
- ユーザーが指定したデストラクタの名前が内部で Finalize() に変更されます。また、クラスに基本クラスがある場合は (CLI のオブジェクト モデルでは、単一の継承のみがサポートされるのを忘れないでください) 、ユーザーが指定したコードの後に基本クラスのファイナライザの呼び出しが挿入されます。例として、V1 の言語仕様に含まれている次の簡単な階層について見てみましょう。
__gc class A {
public:
~A() { Console::WriteLine(S"in ~A"); }
};
__gc class B : public A {
public:
~B() { Console::WriteLine(S"in ~B"); }
};
この場合、両方のデストラクタの名前が Finalize() に変更されます。また、B の Finalize() の WriteLine() の呼び出しの後に、A の Finalize() メソッドの呼び出しが追加されます。これが、ファイナライゼーションの際にガベージ コレクタによって既定で呼び出されます。この内部の変換の様子を以下に示します。
// V1 のデストラクタの内部変換
__gc class A {
public:
void Finalize() { Console::WriteLine(S"in ~A"); }
};
__gc class B : public A {
public:
void Finalize() {
Console::WriteLine(S"in ~B");
A::Finalize();
}
};
- 2 つ目のステップでは、コンパイラによって仮想デストラクタが合成されます。このデストラクタは、V1 のユーザー プログラムによって直接呼び出されるか、delete 式が適用された結果として呼び出されます。ガベージ コレクタによって呼び出されることはありません。
では、合成されたデストラクタの内部はどうなっているのでしょうか。そこには 2 つのステートメントがあります。1 つは、もうそれ以上 Finalize() が呼び出されないようにするための GC::SuppressFinalize() への呼び出しです。もう 1 つは、実際の Finalize() の呼び出しです。これは、先に述べたように、ユーザーが指定したクラスのデストラクタを表します。この内部の様子を以下に示します。
__gc class A {
public:
virtual ~A()
{
System::GC::SuppressFinalize(this);
A::Finalize();
}
};
__gc class B : public A {
public:
virtual ~B()
{
System::GC:SuppressFinalize(this);
B::Finalize();
}
};
この実装によって、ユーザーがクラスの Finalize() メソッドを、いつかわからないタイミングではなく今、明示的に呼び出せるようになりますが、これは、Dispose() メソッドのソリューションには結び付いていません。改訂された言語のデザインでは、この点が変更されています。
2.4.3 デストラクタから Dispose() へ (V2 のケース)
改訂された言語のデザインでは、デストラクタの名前は内部で Dispose() メソッドに変更され、参照クラスが自動的に拡張されて IDispose インターフェイスが実装されます。したがって、先ほどのクラスのペアは、V2 では次のように変換されます。
// V2 のデストラクタの内部変換
__gc class A : IDisposable {
public:
void Dispose() {
System::GC::SuppressFinalize(this);
Console::WriteLine( "in ~A"); }
}
};
__gc class B : public A {
public:
void Dispose() {
System::GC::SuppressFinalize(this);
Console::WriteLine( "in ~B");
A::Dispose();
}
};
V2 でデストラクタが明示的に呼び出されるか、トラッキング ハンドルに delete が適用されると、基になる Dispose() メソッドが自動的に呼び出されます。また、クラスが派生クラスだった場合は、基本クラスの Dispose() メソッドの呼び出しが、合成されたメソッドの最後に挿入されます。
しかし、これだけではまだ確定的ファイナライゼーションには至りません。確定的ファイナライゼーションを実現するには、ローカルの参照オブジェクトのサポートを追加する必要があります (元の言語のデザインにはこれに相当するサポートがないため、これは変換とは別の問題になります)。
2.4.4 参照オブジェクトの宣言
改訂された言語では、参照クラスのオブジェクトをローカル スタックで、またはクラスのメンバとして、直接アクセスできるかのように宣言することができます (ただし、Microsoft Visual Studio 2005 の Beta1 リリースではまだサポートされていません)。これを、セクション 2.4.3 で説明したデストラクタと Dispose() メソッドとの関連付けと組み合わせると、参照型のファイナライゼーション セマンティクスの自動的な呼び出しを実現できます。これにより、CLI コミュニティを悩ませた非確定的ファイナライゼーションの問題が、少なくとも C++/CLI のユーザーにとっては解決されます。では、その意味するところを見てみましょう。
まず、参照クラスを定義して、オブジェクトを作成するとクラス コンストラクタによってリソースが獲得されるようにします。次に、クラス デストラクタの中で、オブジェクトの作成時に獲得したリソースを解放します。
public ref class R {
public:
R() { /* 貴重なリソースを獲得します */ }
~R(){ /* 貴重なリソースを解放します */ }
// ... 他のすべての作業 ...
};
オブジェクトは型の名前を使用してローカルで宣言しますが、ハットは付けません。メンバ関数の呼び出しなどでこのオブジェクトを使用する際には、常に矢印 (->) ではなくドット (.) をメンバ選択演算子として使用します。ブロックの最後で、関連付けられているデストラクタ (Dispose() に変換されている) が自動的に呼び出されます。
void f()
{
R r;
r.methodCall();
// ...
// r はここで自動的に破棄されます
// つまり、r.Dispose() が呼び出されます ...
}
これは、C# の using ステートメントと同じような構文糖であり、参照型はすべて CLI ヒープに割り当てなければならないという、基になる CLI の制約を無視するものではありません。基になるセマンティクスは変更されていません。次のように記述しても同じことになります (おそらくは、このような変換がコンパイラによって内部で行われているものと思われます)。
// 同等の実装 ...
// ただし、この場合は try/finally 句に含める必要があります
void f()
{
R^ r = gcnew R;
r->methodCall();
// ...
delete r;
}
要するに、改訂された言語のデザインでは、ローカル オブジェクトの存続期間に結び付いた自動的な獲得/解放のメカニズムとして、再びデストラクタとコンストラクタの組み合わせが使用されています。これは実にめざましい重要な成果であり、この点については、言語デザイナは大いに賞賛されてしかるべきでしょう。
2.4.5 明示的な Finalize() ( !R ) の宣言
改訂された言語では、これまで見てきたように、デストラクタから Dispose() メソッドが合成されます。これは、デストラクタが明示的に呼び出されない場合に問題になります。なぜなら、ファイナライゼーションの際にガベージ コレクタが、オブジェクトに関連付けられている Finalize() メソッドを以前のように見つけられないことになるからです。改訂された言語のデザインでは、デストラクションとファイナライゼーションの両方をサポートするために、ファイナライザを指定するための特殊な構文が導入されています。以下に例を示します。
public ref class R {
public:
!R() { Console::WriteLine( "I am the R::finalizer()!" ); }
};
! というプレフィックスは、クラス デストラクタを導入するチルド (~) を連想させるためのものです。つまり、オブジェクトの存続期間後に実行されるメソッドの両方に、クラス名の前に付くトークンがあることになります。合成された Finalize() メソッドが派生クラス内で呼び出された場合は、基本クラスの Finalize() メソッドの呼び出しが最後に挿入されます。デストラクタが明示的に呼び出された場合は、ファイナライザは抑制されます。内部では次のような変換が行われます。
// V2 の内部の変換
public ref class R {
public:
void Finalize()
{ Console::WriteLine( "I am the R::finalizer()!" ); }
};
2.4.6 V1 から V2 に移行する際の影響
この変更によって、V1 プログラムを V2 でコンパイルした場合に、参照クラスに重要なデストラクタが含まれていると、実行時の動作が知らぬ間に変更されることになります。必要になると思われる変換アルゴリズムを以下に示します。
- デストラクタが存在する場合は、クラス ファイナライザに書き換えます。
- Dispose() メソッドが存在する場合は、クラス デストラクタに書き換えます。
- デストラクタは存在するが Dispose() メソッドはない場合は、デストラクタを残したまま (1) を実行します。
V1 から V2 にコードを移行する際には、この変換を実行するのを忘れないようにしてください。そうしないと、関連付けられているファイナライゼーション メソッドの実行にアプリケーションがなんらかの形で依存している場合に、アプリケーションの動作が知らぬ間に変更されることになります。
3. クラスまたはインターフェイス内でのメンバ宣言
改訂された言語デザインでは、プロパティと演算子の宣言が大幅に変更されており、以前のデザインで公開されていた基本実装の詳細が隠蔽されています。また、イベント宣言についても変更が加えられています。
V1 からの変更点としては、静的コンストラクタがアウトオブラインで定義できること (V1 ではインラインで定義する必要がありました)、およびデリゲート コンストラクタの概念が導入されたことが挙げられます。
3.1 プロパティ宣言
以前の言語デザインでは、set または get の各プロパティ アクセサは独立したメンバ関数として指定されます。各メソッドの宣言部には、__property キーワードがプレフィックスとして付けられます。メソッド名は set_ または get_ から始まり、その後に (ユーザーが認識可能な) プロパティの実際の名前が続きます。したがって、x 座標を持つ Vector の get プロパティの名前は get_x となり、ユーザーは x と指定してこのプロパティを呼び出します。こうしたメソッドの名称表記と独立した仕様は、実行時におけるプロパティの基本実装を反映しています。たとえば、以下のコードでは、複数の座標プロパティを持つ Vector クラスを定義しています。
public __gc __sealed class Vector
{
public:
// ...
__property double get_x(){ return _x; }
__property double get_y(){ return _y; }
__property double get_z(){ return _z; }
__property void set_x( double newx ){ _x = newx; }
__property void set_y( double newy ){ _y = newy; }
__property void set_z( double newz ){ _z = newz; }
};
このコードでは、プロパティ関連の機能が散開しており、関連する sets や gets をユーザーが語彙的にまとめる必要があるため、かなり分かりにくいものでした。また、このコードは語彙的に冗長で、洗練されたものでもありません。改訂された言語デザインでは、より C# に近い構文が採用されており、property キーワードの後にプロパティの型と純粋なプロパティ名が続きます。set access メソッドと get access メソッドは、プロパティ名の後のブロック内に置かれます。ただし、C# とは異なり、access メソッドのシグネチャを指定する必要があります。たとえば、以下の例は上記のコードを新しい言語デザインに変換したものです。
public ref class Vector sealed
{
public:
property double x
{
double get()
{
return _x;
}
void set( double newx )
{
_x = newx;
}
} // 注意: セミコロンは必要ありません ...
};
public get、private または protected set など、プロパティのアクセス メソッドがアクセス レベルの違いを反映している場合は、アクセス ラベルを明示的に指定できます。既定では、プロパティのアクセス レベルはクラス ブロックのアクセス レベルを反映します。たとえば、上記の Vector の定義では、get メソッドおよび set メソッドの両者ともアクセス レベルは public になります。set メソッドを protected または private に設定するには、上記の定義を以下のように変更します。
public ref class Vector sealed
{
public:
property double x
{
double get()
{
return _x;
}
private:
void set( double newx )
{
_x = newx;
}
} // 注意: private の有効範囲はここまでです ...
// 注意: dot は Vector の public メソッドです ...
double dot( const Vector^ wv );
// etc.
};
プロパティ内のアクセス キーワードの有効範囲は、プロパティ ブロックの閉じ括弧までか、または別のアクセス キーワードが指定されている箇所までです。有効範囲がプロパティの定義ブロックを超えることはありません。定義ブロック以外の箇所については、クラス ブロックのアクセス レベルが適用されます。たとえば、上記の定義において、Vector::dot() は public なメンバ関数となります。
Vector の 3 つの座標に対して set/get プロパティを記述する作業は少々退屈なものとなります。これらのプロパティでは、(a) 適切な型の private な静的メンバを宣言する、(b) ユーザーの要求に応じてプロパティの値を返す、(c) ユーザーの要求に応じてプロパティに値を割り当てる、といったお決まりの実装を行うからです。改訂された言語デザインではプロパティの省略構文を利用できます。上記の処理は自動的に実装されます。
public ref class Vector sealed
{
public:
// プロパティの省略構文 (実装は同じ)
property double x;
property double y;
property double z;
};
プロパティの省略構文には興味深い副作用があります。つまり、バックステージの静的メンバがコンパイラによって自動的に生成される一方で、クラス内からそのメンバにアクセスするには set/get アクセサを使用する以外に手段がないということです。まさにデータの隠蔽が厳格に実施されていると言えます。
3.2 プロパティ インデックスの宣言
インデックス付きプロパティに対する以前の言語サポートには大きな欠点が 2 つあります。1 つは、クラスレベルの添字が使用できないため、すべてのインデックス付きプロパティに対して名前を設定しなければならないということです。その結果、Vector または Matrix オブジェクト クラスに対して直接適用可能な、マネージされた添字演算子などを利用できなくなります。もう 1 つの欠点は、重要性という点では劣りますが、プロパティとインデックス付きプロパティの視覚的な判別が困難になることです。パラメータの数から判別する以外に術がないからです。また、インデックス付きプロパティには通常のプロパティと同様の問題があります。つまり、アクセサが個々のメソッドとして別々に定義されており、一体化されていないということです。以下に例を示します。
public __gc class Vector;
public __gc class Matrix
{
float mat[,];
public:
__property void set_Item( int r, int c, float value);
__property int get_Item( int r, int c );
__property void set_Row( int r, Vector* value );
__property int get_Row( int r );
};
上記を見れば分かるように、インデクサは、2 次元または 1 次元インデックスを示す追加パラメータによってのみ判別されます。改訂された構文では、インデクサ名の後の角括弧 ([、]) によってインデクサの判別が行われます。角括弧内のパラメータは、各インデックスの数と型を表しています。
public ref class Vector;
public ref class Matrix
{
private:
array<float, 2>^ mat;
public:
property int Item [int,int]
{
int get( int r, int c );
void set( int r, int c, float value );
}
property int Row [int]
{
int get( int r );
void set( int r, Vector^ value );
}
};
改訂された構文を使用して、クラスの object に直接適用可能なクラス レベルのインデクサを指定するには、明示的なインデクサ名の代わりに default キーワードを再使用します。以下に例を示します。
public ref class Matrix
{
private:
array<float, 2>^ mat;
public:
// ok: 以下はクラス レベルのインデクサ
//
// Matrix mat ...
// mat[ 0, 0 ] = 1;
//
// default インデクサの set アクセサを呼び出します ...
property int default [int,int]
{
int get( int r, int c );
void set( int r, int c, float value );
}
property int Row [int]
{
int get( int r );
void set( int r, Vector^ value );
}
};
改訂された言語構文では、default インデックス付きプロパティが指定されると、get_Item および set_Item という 2 つの名前が予約されます。これは、default インデックス付きプロパティに対して生成される基本名が存在するためです。
インデックスの場合は、プロパティとは異なり、省略構文は使用できません。
3.3 デリゲートとイベント
デリゲートおよび通常のイベントの宣言に対して行われた唯一の変更点は、以下の例で示すように 2 つのアンダーラインが削除されたことです。この変更については、まったく論争にもなりませんでした。つまり、2 つのアンダーラインを維持すべきという主張がまったくなかったということです。この点については今では誰もが同意できると思いますが、結果として以前の言語デザインに対する印象が悪化しました。
// 以前の言語 (V1)
__delegate void ClickEventHandler(int, double);
__delegate void DblClickEventHandler(String*);
__gc class EventSource {
__event ClickEventHandler* OnClick;
__event DblClickEventHandler* OnDblClick;
// ...
};
// 改訂された言語 (V2)
delegate void ClickEventHandler( int, double );
delegate void DblClickEventHandler( String^ );
ref class EventSource
{
event ClickEventHandler^ OnClick;
event DblClickEventHandler^ OnDblClick;
// ...
};
イベント (およびデリゲート) は reference 型です。V2 では、ハット (^) の存在によって、この点がより明確にされています。イベントでは、通常の形式の宣言とともに、明示的な宣言構文がサポートされます。明示的な宣言構文では、add()、raise()、remove() などイベントに関連したメソッドをユーザーが指定します (add() および remove() メソッドのみが必須で、raise() メソッドはオプションです)。
V1 デザインのもとでこれらのメソッドを作成する場合、ユーザーは明示的なイベント宣言を行うことができません。その代わり、ユーザーは存在しないイベントの名前を指定する必要があります。V1 言語仕様に従って記述した以下の例で示すように、各メソッドの指定は、add_EventName、raise_EventName、および remove_EventName という形式で行われます。
// 以前の V1 言語での宣言
// add、remove、および raise を明示的に実装します ...
public __delegate void f(int);
public __gc struct E {
f* _E;
public:
E() { _E = 0; }
__event void add_E1(f* d) { _E += d; }
static void Go() {
E* pE = new E;
pE->E1 += new f(pE, &E::handler);
pE->E1(17);
pE->E1 -= new f(pE, &E::handler);
pE->E1(17);
}
private:
__event void raise_E1(int i) {
if (_E)
_E(i);
}
protected:
__event void remove_E1(f* d) {
_E -= d;
}
};
このデザインにおける問題の大部分は、機能面ではなくコードの分かりにくさにあります。このデザインではこれらのメソッドの追加がサポートされていますが、上記のコードを見て実際に何が行われているかを即座に理解するのは困難です。V1 のプロパティやインデックス付きプロパティと同様に、クラスの宣言部に各メソッドが散開しています。さらに問題なのが、E1 イベントの宣言が実際には存在しないことです (ここでもまた、基本実装の詳細が機能面に関するユーザー レベルの構文を侵害しており、語彙上の複雑さが露呈しています)。こうした複雑さを生じさせないようにコードを記述するのは非常に骨の折れる作業となります。以下の変換例で示すように、V2 デザインでは宣言が大幅に簡素化されています。イベントとそれに関連する delegate 型を宣言して、その後の中括弧内にイベントのメソッドを 2 ~ 3 つ指定します。以下に例を示します。
// 改訂された V2 言語デザイン
delegate void f( int );
public ref struct E {
private:
f^ _E; // デリゲートも参照型です。
public:
E()
{ // 「0」が nullptr で置き換えられている点に注意してください。
_E = nullptr;
}
// V2 の集中的な構文による明示的なイベント宣言
event f^ E1
{
public:
void add( f^ d )
{
_E += d;
}
protected:
void remove( f^ d )
{
_E -= d;
}
private:
void raise( int i )
{
if (_E)
_E(i);
}
}
static void Go()
{
E^ pE = gcnew E;
pE->E1 += gcnew f( pE, &E::handler );
pE->E1(17);
pE->E1 -= gcnew f( pE, &E::handler );
pE->E1(17);
}
};
構文は言語デザインの観点から取るに足りないものとして軽視されがちですが、ユーザーが言語を使用する上で (ほとんど意識しないとしても) 非常に大きなインパクトを持っています。難解で洗練されていない構文を使用すると、開発プロセスにおける危険性が増大します。これは、フロントガラスの汚れや煙が自動車事故の危険性を高めるのと同じです。改訂されたデザインでは、磨き上げられた新品のフロントガラス並みに構文の透明性が向上しています。
3.4 仮想関数のオーバーライドの禁止
V1 の __sealed キーワードは、reference 型または仮想関数の変更に使用します。reference 型に対して使用されると、セクション 2.1.2 で説明したように、この型からこれ以上派生できなくなります。仮想関数に対して使用されると、それ以降の派生クラスにおいてメソッドのオーバーライドができなくなります。以下に例を示します。
class base { public: virtual void f(); };
class derived : public base {
public:
__sealed void f();
};
この例では、関数プロトタイプの完全一致を基に、derived::f() が base::f() インスタンスをオーバーライドしています。__sealed キーワードは、derived クラスの継承クラスが derived::f() をオーバーライドできないことを示しています。
新しい言語デザインでは、sealed がシグネチャの後に置かれます。V1 では、実際の関数プロトタイプの前であればどこにでも置くことができましたが、新しいデザインにおいてこれは許可されません。また、sealed を使用する場合、virtual キーワードも明示的に使用する必要があります。つまり、上記の derived の正しい変換例は以下のようになります。
class derived : public base
{
public:
virtual void f() sealed;
};
このインスタンスで virtual キーワードを指定しない場合、エラーが発生します。V2 では、=0 の代わりにコンテキスト キーワード abstract を使用することで、純粋な仮想関数を表すことができます。このキーワードは、V1 ではサポートされていませんでした。以下に例を示します。
class base { public: virtual void f()=0; };
以下のように書き換えることができます。
class base { public: virtual void f() abstract; };
3.5 オーバーロード演算子
以前の言語デザインにおいて最も特筆すべき機能は、演算子のオーバーロードのサポートでしょう (またはその機能が実質的に使用できなかったことかもしれません)。reference 型の宣言部において、ネイティブの operator+ 構文の代わりに、演算子の基本内部名 (この場合は op_Addition) を明示的に記述する必要がありました。ただし、より厄介な問題は、内部名を使用して明示的に演算子を呼び出す必要があるため、(a) 直感的な構文および (b) 新しい型と既存の型との混合という演算子のオーバーロードの主なメリットを享受できなくなったことにあります。以下に例を示します。
public __gc __sealed class Vector {
public:
Vector( double x, double y, double z );
static bool op_Equality( const Vector*, const Vector* );
static Vector* op_Division( const Vector*, double );
static Vector* op_Addition( const Vector*, const Vector* );
static Vector* op_Subtraction( const Vector*, const Vector* );
};
int main()
{
Vector *pa = new Vector( 0.231, 2.4745, 0.023 );
Vector *pb = new Vector( 1.475, 4.8916, -1.23 );
Vector *pc1 = Vector::op_Addition( pa, pb );
Vector *pc2 = Vector::op_Subtraction( pa, pc1 );
Vector *pc3 = Vector::op_Division( pc1, pc2->x() );
if ( Vector::op_Equality( pc1, p2 ))
// ...
}
改訂された言語デザインでは、静的演算子の宣言と使用の両方において、C++ のネイティブ プログラマが通常抱く期待が再び実現されました。V2 構文に変換した Vector クラスを以下に示します。
public ref class Vector sealed {
public:
Vector( double x, double y, double z );
static bool operator ==( const Vector^, const Vector^ );
static Vector^ operator /( const Vector^, double );
static Vector^ operator +( const Vector^, const Vector^ );
static Vector^ operator -( const Vector^, const Vector^ );
};
int main()
{
Vector^ pa = gcnew Vector( 0.231, 2.4745, 0.023 ),
Vector^ pb = gcnew Vector( 1.475,4.8916,-1.23 );
Vector^ pc1 = pa + pb;
Vector^ pc2 = pa-pc1;
Vector^ pc3 = pc1 / pc2->x();
if ( pc1 == p2 )
// ...
}
3.6 変換演算子
V1 言語デザインで変換を指定する場合、op_Implicit を記述する必要があります。これは C++ にふさわしい記述方法ではなく、不適切なデザインと言えるものでした。たとえば、V1 言語仕様に従って記述した MyDouble の定義例を以下に示します。
__gc struct MyDouble
{
static MyDouble* op_Implicit( int i );
static int op_Explicit( MyDouble* val );
static String* op_Explicit( MyDouble* val );
};
この例は、指定された整数を MyDouble に変換するためのアルゴリズムが、op_Implicit 演算子によって提供されることを示しています。また、この変換はコンパイラによって暗黙的に行われます。同様に、指定された MyDouble オブジェクトを integer またはマネージされた String エンティティのいずれかに変換するためのアルゴリズムは、2 つの op_Implicit 演算子によってそれぞれ提供されます。 ただし、ユーザーが明示的に要求しない限り、コンパイラがこの変換を実行することはありません。
C# では、このクラスは以下のように記述されます。
class MyDouble
{
public static implicit operator MyDouble( int i );
public static explicit operator int( MyDouble val );
public static explicit operator string( MyDouble val );
};
すべてのメンバに対して public アクセス ラベルを明示的に指定するという奇妙な点を除き、この C# のコードは、C++ マネージ拡張よりも C++ のコードによく似ています。そのため、この点について修正する必要がありました。しかし、どのようにして修正すれば良いのでしょうか。
一方では、変換演算子として解釈される単一引数を取るコンストラクタがないことから、C++ プログラマに若干の混乱を与えています。ただし、もう一方では、デザインの不適切さからマネージの対象となっており、意図せざる結果の制御を目的として、ISO-C++ 委員会が explicit キーワードを導入する結果となりました。「意図せざる結果」の例としては、次元を表す引数として単一の整数を受け取る Array クラスが挙げられます。Array クラスは、ユーザーが望まない場合においてさえ、すべての整数を Array オブジェクトに暗黙的に変換します。こうした「意図せざる結果」について筆者が初めて認識したのは Andy Koenig の説明を聞いたときでした。彼の説明によると、2 番目の引数をダミーとしてコンストラクタに与えることによって、「意図せざる結果」の発生を回避できるとのことです。そのため、C++/CLI に単一コンストラクタによる暗黙変換が用意されていないことについて残念だとは思いません。
一方、C++ でクラスの型を設計しているときに、変換ペアを作成するのは良い考えとは言えません。最良の方法は標準の string クラスを使用することです。単一の引数を取るコンストラクタが C 形式の文字列を受け取ると暗黙の変換が行われます。ただし、string オブジェクトを C 形式の文字列に変換する暗黙の変換演算子は提供されません。ユーザーが明示的に名前付き関数 (この場合は c_str()) を呼び出す必要があります。
変換演算子への暗黙的/明示的処理の追加 (および変換セットの単一宣言形式へのカプセル化) は、C++ の当初の変換演算子サポートに対する機能改善と考えられます。1988 年の Usenix C++ カンファレンスにおいて、Robert Murray が講演 (講演のタイトルは Building Well-Behaved Type Relationships in C++) を行って以来、C++ の変換演算子に対する機能拡張の必要性が広く認識されてきました。こうした動きが最終的に explicit キーワードの導入へとつながっています。改訂された V2 言語による変換演算子のサポートを以下の例で示します。C# のコードと比べて若干簡素化されていますが、これは演算子が暗黙の変換アルゴリズムを既定で使用するためです。
ref struct MyDouble
{
public:
static operator MyDouble^ ( int i );
static explicit operator int ( MyDouble^ val );
static explicit operator String^ ( MyDouble^ val );
};
V1 と V2 の間でのもう 1 つの違いは、V2 では単一引数を取るコンストラクタが明示的に宣言されたかのようにして処理されることです。そのため、コンストラクタを呼び出すためには明示的なキャストを行う必要があります。ただし、明示的な変換演算子が定義されている場合は、単一引数を取るコンストラクタではなく変換演算子が呼び出されるという点に注意してください。
3.7 インターフェイス メンバの明示的なオーバーライド
インターフェイスを実装するインターフェイス メンバのインスタンスをクラス内に 2 つ作成するのが望ましい場合が多々あります。インスタンスの 1 つは、クラス オブジェクトがインターフェイス ハンドル経由で操作される場合に使用されます。もう 1 つは、クラス オブジェクトがクラス インターフェイス経由で利用される場合に使用されます。以下に例を示します。
public __gc class R : public ICloneable
{
// Icloneable 経由で使用されます ...
Object* ICloneable::Clone();
// R 経由で使用されます ...
R* Clone();
};
V1 では、インターフェイス名で限定されたメソッド名を持つインターフェイス メソッドを明示的に宣言することによってこの機能を実現します。クラス固有のインスタンスでは、こうした限定は行われません。上記の例では、こうすることによって、R のインスタンス経由で Clone() が明示的に呼び出されたとき、その戻り値をダウンキャストする必要がなくなります。
V2 では、以前の構文に代わって、汎用的なオーバーライド メカニズムが導入されています。上記の例は以下のように書き換えることができます。
public __gc class R : public ICloneable
{
// Icloneable 経由で使用されます ...
Object^ InterfaceClone() = ICloneable::Clone;
// R 経由で使用されます ...
virtual R^ Clone() new;
};
このように変更する場合、明示的にオーバーライドされるインターフェイス メンバに対して、クラス内で固有の名前を与える必要があります。この例では、InterfaceClone() というやや不適切な名前を指定しています。動作はこれまでと同じで、名前を変更した InterfaceClone() が ICloneable インターフェイス経由で呼び出されます。一方、R 型のオブジェクトを経由する場合は、2 つ目の Clone() インスタンスが呼び出されます。
3.8 プライベート仮想関数
V1 では、仮想関数のアクセス レベルによって、派生クラス内でのオーバーライドを制限することはできません。V2 では、この機能が変更されています。V2 では、仮想関数がアクセスできない基本クラスの仮想関数をオーバーライドすることできません。以下に例を示します。
__gc class My
{
// 派生クラスにアクセスできません ...
virtual void g();
};
?
__gc class File : public My {
public:
// V1, ok: g() が My::g() をオーバーライドします。
// V2, エラー: オーバーライドできません。: My::g() にアクセスできません ...
void g();
};
上記のデザインについては、V2 にマッピングすることはできません。基本クラスのメンバーを non-private に変更して、アクセスできるようにする必要があります。継承メソッドを基本メソッドと同じアクセス レベルにする必要はありません。この例の場合、最も安全な変更方法は My メンバを protected にすることです。こうすることによって、 My 経由でのプログラム全体からメソッドへのアクセスをこれまでどおり制限できます。
ref class My {
protected:
virtual void g();
};
?
ref class File : My {
public:
void g();
};
V2 では、基本クラス内に virtual キーワードを明示的に指定しないと、警告メッセージが表示されますので注意してください。
3.9 静的整数型定数リンケージの非リテラル化
static const の整数メンバは依然としてサポートされていますが、その linkage 属性は変更されています。現在は、literal の整数メンバに以前の linkage 属性が格納されています。たとえば、以下の V1 クラスについて考えてみてください。
public __gc class Constants {
public:
static const int LOG_DEBUG = 4;
// ...
};
上記のコードは、このフィールドに対して以下に示す CLI の基本属性を生成します (literal 属性が太字表記になっていることに注意してください)。
.field public static literal int32
modopt([Microsoft.VisualC]Microsoft.VisualC.IsConstModifier) STANDARD_CLIENT_PRX = int32(0x00000004)
?
このコードは依然として V2 構文に変換できます。
public ref class Constants {
public:
static const int LOG_DEBUG = 4;
// ...
};
ただし、literal 属性が生成されることはもうありません。したがって、CLI ランタイムからは定数として見なされません。
.field public static int32 modopt([Microsoft.VisualC]Microsoft.VisualC.IsConstModifier) STANDARD_CLIENT_PRX = int32(0x00000004)
以前と同じように中間言語の literal 属性を生成するには、新たにサポートされた literal データ メンバを宣言部に記述する必要があります。以下に例を示します。
public ref class Constants {
public:
literal int LOG_DEBUG = 4;
// ...
};
4 値型とその動作
このセクションでは、CLI の 列挙型と値クラス型について紹介します。これに合わせて、ボックス化と、CLI ヒープ上のボックス化されたインスタンスにアクセスすることについても紹介します。加えて、内部ポインタと固定ポインタについて紹介します。この領域では言語に大幅な修正が施されました。
4.1 CLI の列挙型
元の言語の CLI の列挙宣言では、前部に __value キーワードを置きます。これは、System::ValueType から派生した、類似の機能を備えている CLI の列挙体をネイティブの列挙体と区別するためです。以下に例を示します。
__value enum e1 { fail, pass };
public __value enum e2 : unsigned short {
not_ok = 1024,
maybe, ok = 2048
};
修正を施した言語では、ネイティブの列挙体とCLI の列挙体の区別に関する問題は、値型のルートではなく、後者のクラスの種類を強調することで解消しています。__value キーワードはなくなり、列挙クラスのスペース区切りキーワード ペアに置き換えられました。これが、参照クラスの宣言、値クラスの宣言、およびインターフェイスクラスの宣言に対応するキーワードのペアを提供しています。
enum class ec;
value class vc;
ref class rc;
interface class ic;
次に、修正を施した言語デザインにおける列挙体ペアの e1 と e2 の変換を示します。
enum class e1 { fail, pass };
public enum class e2 : unsigned short {
not_ok = 1024,
maybe, ok = 2048
};
この些細な構文の修正とは別に、マネージ列挙型の性質が多くの点で変更されました。
- CLI の列挙体の前方宣言は、V2 では非サポートになりました。マップは行われず、コンパイル時エラーとしてフラグされます。
__value enum status; // V1: ok
enum class status; // V2: error
- 組み込みの算術型とオブジェクトクラス階層間のオーバーロード解決は、V1 と V2 の間に戻されました。副次的な影響として、V2 ではマネージ列挙体は V1 でのように暗黙的に算術型に変換されなくなりました。
- V2 では、V1 の場合と異なり、マネージ列挙体のスコープは維持されます。V1 では、列挙子は列挙体の格納スコープ内に表示されますが、V2 では、列挙子は列挙体のスコープ内にカプセル化されます。
4.1.1 オブジェクトの一種である CLI の列挙体
たとえば、次のコード片を参照してください。
__value enum status { fail, pass };
void f( Object* ){ cout << "f(Object)\n"; }
void f( int ){ cout << "f(int)\n"; }
int main()
{
status rslt;
// ...
f( rslt ); // which f is invoked?
}
ネイティブ C++ のプログラマにとって、呼び出されたオーバーロード f() のインスタンスはどれかという質問に対する答えは f(int) のインスタンスとなるのが普通です。列挙体は、シンボル整数定数で、このケースでは優先される標準整数拡張に参加します。実際、元の言語デザインでは、これは呼び出しが解決するインスタンスでした。このため、ネイティブの C++ フレームで使用する場合ではなく、オブジェクトから間接的に派生する、既存の BCL (基本クラス ライブラリ) フレームワークとの対話に列挙体を必要とした場合に驚くことが多数あります。修正を施した言語デザインでは、呼び出される f() のインスタンスは f(Object^) のインスタンスです。
これを強制するために V2 では、CLI の列挙型と算術型の暗黙的な変換をサポートしていません。このため、マネージ列挙型のオブジェクトを算術型に割り当てるには明確なキャストが必要になります。次の例を参照してください。
V1 では、非オーバーロード型のメソッドとして、次の呼び出しが認められています。
f( rslt ); // ok: V1; error: V2
rslt に格納される値は暗黙的に整数値に変換されます。V2 では、この呼び出しはコンパイル エラーになります。変換を正しく行うには、次のように変換演算子を挿入する必要があります。
f( safe_cast<int>( rslt )); // ok: V2
4.1.2 CLI の列挙型のスコープ
C 言語と C++ 言語の相違の 1 つに、C++ 言語では struct 機能にスコープが追加されたことがあります。C 言語では、struct は単なるデータの集計で、インターフェイスと関連スコープのいずれもサポートしていませんでした。これは当時としては極めて急進的な変更で、C 言語から移行した新規 C++ ユーザーの論争の的となりました。ネイティブの列挙体と CLI の列挙体の関係に類似しています。
元の言語デザインでは、ネイティブの列挙体のスコープの欠落をシミュレートするために、マネージ列挙体の列挙子に弱く挿入された名前を定義することが試みられました。これは失敗に終わりました。問題は、これにより列挙子がグローバル名前空間に流出してしまい、名前の衝突の管理が困難になってしまうことでした。修正を施した言語では、別の CLI 言語に準拠して、マネージ列挙体でスコープをサポートしています。
このため、修正を施した言語では CLI の列挙体の列挙子の不正な使用は認められません。実際の例を見てみましょう。
// original language design supporting weak injection
__gc class XDCMake {
public:
__value enum _recognizerEnum {
UNDEFINED,
OPTION_USAGE,
XDC0001_ERR_PATH_DOES_NOT_EXIST = 1,
XDC0002_ERR_CANNOT_WRITE_TO = 2,
XDC0003_ERR_INCLUDE_TAGS_NOT_SUPPORTED = 3,
XDC0004_WRN_XML_LOAD_FAILURE = 4,
XDC0006_WRN_NONEXISTENT_FILES = 6,
??};
ListDictionary* optionList;
ListDictionary* itagList;
XDCMake()
??{
optionList = new ListDictionary;
// here are the problems ...
optionList->Add(S"?", __box(OPTION_USAGE)); // (1)
optionList->Add(S"help", __box(OPTION_USAGE)); // (2)
itagList = new ListDictionary;
itagList->Add(S"returns",
__box(XDC0004_WRN_XML_LOAD_FAILURE)); // (3)
?? }
};
ソース コードをコンパイルするには、修正を施した言語構文への変換時に不正使用されている 3 つの列挙子名 ((1)、(2)、(3)) を修正する必要があります。以下に、元のソース コードの正しい変換を示します。
ref class XDCMake
{
public:
enum class _recognizerEnum
??{
UNDEFINED, OPTION_USAGE,
XDC0001_ERR_PATH_DOES_NOT_EXIST = 1,
XDC0002_ERR_CANNOT_WRITE_TO = 2,
XDC0003_ERR_INCLUDE_TAGS_NOT_SUPPORTED = 3,
XDC0004_WRN_XML_LOAD_FAILURE = 4,
XDC0006_WRN_NONEXISTENT_FILES = 6
? };
ListDictionary^ optionList;
ListDictionary^ itagList;
XDCMake()
??{
optionList = gcnew ListDictionary;
optionList->Add("?",_recognizerEnum::OPTION_USAGE); // (1)
optionList->Add("help",_recognizerEnum::OPTION_USAGE); //(2)
itagList = gcnew ListDictionary;
itagList->Add( "returns",
recognizerEnum::XDC0004_WRN_XML_LOAD_FAILURE); //(3)
??}
};
これにより、ネイティブの列挙体 と CLI の列挙体の間のデザイン戦略が変更します。V2 ではCLI の列挙体に関連スコープが維持されるので、列挙体の宣言をクラスにカプセル化する必要と意味がなくなりました。このイディオムは、グローバル ネーム ポリューション問題を解決するために Bell Laboratories で cfront 2.0 の頃に発生しました。
Bell Laboratories の Jerry Schwarz が新規 iostream ライブラリのオリジナルのベータ版をリリースした時、Jerry は、このライブラリに定義されている関連列挙体を一切カプセル化せずに、read、write、append といった一般的な列挙子によって既存のコードをコンパイルすることをほとんど不可能にしました。第 1 の解決方法は、io_read、io_write といった名前を破壊することです。第 2 の解決方法は、列挙体にスコープを追加して言語を修正することです。これは、当時としては実際的な解決方法ではありませんでした (中間的な解決方法は、クラスまたはクラス階層に列挙体をカプセル化することでした。列挙体のタグ名と列挙子によって囲まれたクラス スコープはこの場所に追加されます)。つまり、列挙体をクラスに置いたのは、少なくとも本来は、哲学的な動機からではなく、グローバル名前空間ポリューション問題に対する実際的な動機によるものでした。
V2 における CLI の列挙体では、列挙体をクラス内にカプセル化するにたる理由は存在しません。実際、システム名前空間を見ると、列挙体、クラス、およびインターフェイスのすべてが同じ宣言空間に存在していることがわかります。
4.2 暗黙のボックス化
その通りです。我々は方向を逆転したのです。政治の世界では、選挙で敗れることになるかもしれません。言語デザインの世界では、機能性に対して実際的な経験ではなく哲学的立場を優先しましたが、これは間違いでした。推論として、元の複数継承言語デザインでは、Stroustrup は仮想基本クラス サブオブジェクトを派生クラス コンストラクタ内で初期化できないとしたので、この言語では、仮想基本クラスとして機能するクラスに既定コンストラクタを定義する必要があります。これが、任意の後続の仮想派生によって呼び出されるであろう既定コンストラクタです。
仮想基本クラス階層の問題は、共有仮想サブオブジェクトの初期化に関する責任が後続の各派生に移行する点にあります。たとえば、初期化によるバッファの割り当てを必要とする基本クラスを定義する場合、このバッファのユーザー指定のサイズが引数としてコンストラクタに送られます。次に、inputb と outputb という 2 つの後続の仮想派生を提供した場合、各派生によって基本クラス コンストラクタに特定の値が提供されます。ここで、inputb と outputb の両方から in_out クラスを派生した場合、共有仮想基本クラス サブオブジェクトに対するいずれの値も評価できません。
このため、元の言語デザインにおいて、Stroustrup は派生クラス コンストラクタのメンバー初期化リスト内で仮想基本クラスを明示的に初期化することを禁止しました。これで問題は解消されましたが、実際面では仮想基本クラスの初期化を指示できないので実行不可能なことが判明しました。nihcl という名前の SmallTalk コレクション ライブラリのフリーウェア バージョンを実装した、National Institute of Health の Keith Gorlen が、より柔軟な言語デザインを作り出す必要がある点を Bjarne に説得する上で主要な役割を果たしました。
オブジェクト指向の階層デザインの原則では、派生クラスはその直接の基本クラスの非プライベートな実装にのみ関係するものとされています。仮想継承の柔軟な初期化デザインをサポートするために、Bjarne はこの原則に違反する必要がありました。すべての仮想サブオブジェクトの初期化の責任は、それが発生する階層の深さに関わらず、階層の最派生クラスが負います。たとえば、inputb と outputb はいずれもその直接の仮想基本クラスの初期化に明示的に責任を負います。in_out が inputb と outputb の両方から派生した場合、in_out はかつて削除された仮想基本クラスに対して責任を負い、inputb と outputb 内で明示的に行われた初期化は妨げられます。
これにより、言語開発者が必要とする柔軟性が得られますが、セマンティクスが複雑になってしまいます。複雑化による負担は、仮想基本クラスの状態を制限し、インターフェイスの指定だけを認めることで取り除くことができます。これは、C++ の推奨デザイン イディオムです。C++/CLI では、インターフェイス型を備えたポリシーに発展されています。
ここで、極めて単純な作業を実施する実際のコード例を示します。この場合、明示的なボックス化の大部分は表示のない構文負担です。
// original language requires explicit __box operation
int my1DIntArray __gc[] = { 1, 2, 3, 4, 5 };
Object* myObjArray __gc[] = {
__box(26), __box(27), __box(28), __box(29), __box(30)
};
Console::WriteLine( "{0}\t{1}\t{2}", __box(0),
__box(my1DIntArray->GetLowerBound(0)),
__box(my1DIntArray->GetUpperBound(0)) );
ご覧の通り、このコードでは、たくさんのボックス化が実施されます。V2 では、 値型のボックス化は暗黙的に実施されます。
// revised language makes boxing implicit
array<int>^ my1DIntArray = {1,2,3,4,5};
array<Object^>^ myObjArray = {26,27,28,29,30};
Console::WriteLine( "{0}\t{1}\t{2}", 0,
my1DIntArray->GetLowerBound( 0 ),
my1DIntArray->GetUpperBound( 0 ) );
4.3 追跡ハンドルをボックス値へ
ボックス化は、CLI の統一型システムの特性です。値型には、その状態が直接格納され、参照型は暗黙的なデュプレです。名前付きエンティティは、マネージ ヒープに割り当てられた名前のないオブジェクトのハンドルです。たとえば、値型をオブジェクトに割り当てたり初期化したりする場合、最初に関連メモリを割り当て、次に値型の状態をコピーし、最後にこの匿名値/参照ハイブリッドのアドレスを戻して、値型をボックス化のイメージが発生する CLI ヒープに配置する必要があります。C# で表すと次のようになります。
object o = 1024; // C# implicit boxing
ここでは、コードが単純になり分かりやすいということより重要なことが起こっています。C# のデザインでは、内部で実行されている操作の複雑さに加えて、ボックス化自体の抽象化の複雑さも隠されています。一方、V1 では、効率化に対する誤った見方になると考え、明示的な命令をユーザーに求めています。
Object *o = __box( 1024 ); // V1 explicit boxing
この場合の選択肢は多岐にわたります。個人的な意見としては、これらの場合にユーザーに明示的な要求を強制することは、ユーザーが家を出ようとした際にユーザーの母親がこれを繰返し求めることにしか相当しないと思います。一方で、ある時点において、ユーザーは警告を吸収する必要があります。これが成熟です。同時に、ある時点では、子供の成熟を信用する必要があります。言語デザイナーを母親に置き換え、プログラマーをその子供に置き換えると、V2 においてボックス化が暗黙的な理由が分かります。
Object ^o = 1024; // V2 implicit boxing
C# や Microsoft Visual Basic .NET といった言語では意図的に削除されている __box キーワードは元の言語デザインにて第 2 のより重要なサービスを果たします。これは、マネージ ヒープ上でボックス化されたインスタンスを直接操作するためのボキャブラリと追跡ハンドルの両方を提供します。たとえば、次の小規模プログラムを参照してください。
int main()
{
double result = 3.14159;
__box double * br = __box( result );
result = 2.7;
*br = 2.17;
Object * o = br;
Console::WriteLine( S"result :: {0}", result.ToString() ) ;
Console::WriteLine( S"result :: {0}", __box(result) ) ;
Console::WriteLine( S"result :: {0}", br );
}
WriteLine を 3 回呼び出すために生成されたコードは、ボックス化された値型の値にアクセスするために必要になるコストを示しています (これらの相違を指摘していただいたことを Yves Dolce に感謝します)。ここで、太字の線は各呼び出しに関連するオーバーヘッドを示しています。
// Console::WriteLine( S"result :: {0}", result.ToString() ) ;
ldstr "result :: {0}"
ldloca.s result
call instance string [mscorlib]System.Double::ToString()
call void [mscorlib]System.Console::WriteLine(string, object)
??
// Console::WriteLine( S"result :: {0}", __box(result) ) ;
ldstr "result :: {0}"
ldloc.0
box [mscorlib]System.Double
call void [mscorlib]System.Console::WriteLine(string, object)
// Console::WriteLine( S"result :: {0}", br );
ldstr "result :: {0}"
ldloc.0
call void [mscorlib]System.Console::WriteLine(string, object)
ボックス化された値型を直接 Console::WriteLine に渡すと、ボックス化および ToString() の呼び出しを行う必要がなくなります (もちろん、br を初期化する result への事前のボックス化が存在するので、br を実際に使用するまで何も得られません)。
修正を施した言語構文では、ボックス化した値型のサポートは、その強力さはそのままにより洗練した形で型システムに統合されています。たとえば、以下に以前の小規模プログラムの変換を示します。
int main()
{
double result = 3.14159;
double^ br = result;
result = 2.7;
*br = 2.17;
Object^ o = br;
Console::WriteLine( S"result :: {0}", result.ToString() ) ;
Console::WriteLine( S"result :: {0}", result );
Console::WriteLine( S"result :: {0}", br );
}
4.4 値型セマンティクス
以下に、V1 言語仕様で使用されている標準的な値型を示します。
__value struct V { int i; };
__gc struct R { V vr; };
V1 では、4 種類の値型の構文バリアントがありました (フォーム 2 と 3 は同じ意味を持っています)。
V v = { 0 };
V *pv = 0;
V __gc *pvgc = 0; // Form (2) is an implicit form of (3)
__box V* pvbx = 0; // must be local
4.4.1 継承仮想メソッドの呼び出し
Form (1) は、標準的な値オブジェクトで、ToString() のような継承仮想メソッドの呼び出しを試行する場合を除き、よく理解されています。以下に例を示します。
このメソッドを呼び出す場合は、V ではオーバーライドされないので、コンパイラによる基本クラスの関連仮想テーブルへのアクセスが必要になります。値型は、その仮想テーブル (vptr) に関連ポインタを持たない正式なストレージなので、v をボックス化する必要があります。元の言語デザインでは、暗黙的なボックス化はサポートされていないので、プログラマは次のように明示的に指定する必要があります。
__box( v )->ToString(); // V1: note the arrow
このデザインの背後にある主たる目的は教育です。値型にインスタンスを提供しない「コスト」について理解するよう、基にあるメカニズムをプログラマに明らかにすることを望んでいるのです。V で ToString のインスタンスを格納するには、ボックス化は必要ありません。
変更が施された言語デザインでは、基にあるボックス化自体のコストは取り除かれていませんが、オブジェクトを明示的にボックス化する構文上の複雑さは取り除かれています。
V 内に ToString メソッドの明示的なインスタンスを提供しないコストについてはクラス デザイナを惑わせる恐れがあります。暗黙的なボックス化が好まれる理由は、通常、クラス デザイナは 1 人しか存在しない一方で、ユーザー数は無限で、ユーザーには V を修正し、煩わしい明示的なボックスを削除する権限がないからです。
値クラスに ToString のオーバーライド インスタンスを提供するかどうかは、これを使用する頻度と場所に従って決定する必要があります。使用頻度が低い場合、この定義から得られる利点はほとんどありません。同様に、パフォーマンスが不要な用途に呼び出しても、全般的なパフォーマンスは向上しません。また、ボックス値に追跡ハンドルを保持することができ、ハンドルがボックス化を必要としていないことを通じて呼び出します。
4.4.2 値クラスの既定コンストラクタの廃止
元の言語デザインと変更が施された言語デザインの間の値型のもう 1 つの相違点として、既定コンストラクタのサポートが廃止されました。これは、関連の既定コンストラクタを呼び出すことなく CLI で値型のインスタンスを作成できる機会が実行時にあるためです。すなわち、値型内で既定コンストラクタをサポートしようとする V1 での試みは実際面では保証できませんでした。保証がないのであれば、アプリケーションでが非確定的な状態で置いておくよりサポートを廃止するほうがすっきりします。
これは、当初の予想より悪くありません。これは、値型の各オブジェクトが自動的にゼロ設定されるからです (各型は規定値に初期化されます)。すなわち、ローカル インスタンスのメンバが未定義になることはありません。その意味では、些細な既定コンストラクタを定義する能力がなくても失うものは何もありません。実際、CLI で実行したほうがより効率的になります。
問題は、元の V1 言語のユーザーが重要な既定コンストラクタを定義する場合です。これは、修正を施した V2 言語デザインにマップされていません。コンストラクタ内のコードは、ユーザーが明示的に呼び出す必要のある名前付き初期化メソッドに移行する必要があります。
一方、修正を施した V2 言語デザインの値型オブジェクトの宣言は変更されていません。これのマイナス面は、値型がネイティブ型をラップするには次の点で不十分である点にあります。
- 値型内でデストラクタはサポートされていません。すなわち、オブジェクトの有効期間の終了によってトリガされる動作の設定を自動化することができません。
- ネイティブ型は、ポインタとしてマネージ型に格納する以外に方法はありません。ネイティブ型は、引き続き、ネイティブ ヒープ上に割り当てられます。
小規模なネイティブ クラスを参照型ではなく値型にラップし、ヒープが二重割り当てされないようにしています。ネイティブ ヒープはネイティブ型を保持し、CLI ヒープはマネージ ラッパーを保持します。ネイティブ クラスを値型にラップすることで、マネージ ヒープを避けることができますが、ネイティブ ヒート メモリの回収を自動化する手段がなくなってしまいます。参照型以外に、重要なネイティブ クラスをラップする実際的なマネージ型はありません。
4.4.3 内部ポインタ
フォーム (2) と (3) は、ほとんどすべてをアドレス指定できます (つまり、マネージまたはネイティブのすべて)。このため、たとえば、元の言語デザインでは次のすべてが認められています。
// from Section 4.4
__value struct V { int i; };
__gc struct R { V vr; };
V v = { 0 };
V *pv = 0;
V __gc *pvgc = 0; // Form (2) is an implicit form of (3)
__box V* pvbx = 0; // must be local
R* r;
pv = &v; // address a value type on the stack
pv = __nogc new V; // address a value type on native heap
pv = pvgc; // we are not sure what this addresses
pv = pvbx; // address a boxed value type on managed heap
pv = &r->vr; // an interior pointer to value type within a
// reference type on the managed heap
V* では、ローカル ブロック内 (このため、ダングリングが可能)、グローバルな範囲、ネイティブ ヒープ内 (アドレス指定するオブジェクトが削除済みの場合など)、CLI ヒープ内 (このため、ガベージ コレクション時に再配置が必要な場合に追跡されます)、および CLI ヒープの参照オブジェクトの内部 (内部ポインタは、これの呼び出し時に、透過的に追跡されます) で位置をアドレス指定できます。
元の言語デザインでは、V* のネイティブな側面を分離する手段はありませんでした。すなわち、オブジェクトまたはサブオブジェクトをマネージ ヒープにアドレス指定する可能性を何が処理するかについて包括的に処理されます。
修正を施した言語デザインでは、値型ポインタは 2 種類に分類されます。非 CLI ヒープ位置に限定の V* とマネージ ヒープ内のアドレスが見込まれますが、これに限定されない内部ポインタの interior_ptr<V> です。
// may not address within managed heap
V *pv = 0;
// may or may not address within managed heap
interior_ptr<V> pvgc = nullptr;
元の言語のフォーム (2) と (3) では、interior_ptr<V> にマップが行われます。フォーム (4) は tracking handle です。マネージ ヒープ内にボックス化されているオブジェクト全体をアドレス指定します。修正を施した言語では V^ に変換されます。
V^ pvbx = nullptr; // __box V* pvbx = 0;
元の言語デザインの次の宣言は、修正を施した言語デザインでは内部ポインタにマップされます (これらは、System 名前空間の値型です)。
Int32 *pi; => interior_ptr<Int32> pi;
Boolean *pb; => interior_ptr<Boolean> pb;
E *pe; => interior_ptr<E> pe; // Enumeration
???????????
組み込み型はマネージ型とみなされませんが、システム名前空間の型に対してエイリアスとして機能します。このため、次のマップは、元の言語と修正を施した言語のいずれに対しても有効です。
int * pi; => int* pi;
int __gc * pi => interior_ptr<int> pi;
既存の thing1 プログラムに V* を変換する場合に最も保守的な戦略は、これをつねに interior_ptr<V> に変換することです。これは、元の言語での処理方法です。修正を施した言語では、プログラマは、内部ポインタの変わりに V* を指定して値型を非マネージ ヒープ アドレスに制限するオプションを利用できます。プログラムの変換時に、その使用のすべての推移閉包が可能で、割り当てアドレスがマネージ ヒープ内に存在しない場合、V* のまま残しても問題ありません。
4.4.4 固定ポインタ
ガベージ コレクタでは、通常は圧縮フェーズ中に、CLI ヒープ上に存在するオブジェクトをヒープ内の別の場所に移動する場合があります。この移動は、追跡ハンドル、追跡参照、およびこれらのエンティティを透過的に更新する内部ポインタにとって問題になりません。ただし、ユーザーが CLI ヒープ上のオブジェクトのアドレスをランタイム環境の外部に送っている場合は問題になります。この場合、オブジェクトの一時的な移動によりランタイム エラーが発生する場合があります。これらのオブジェクトが移動しないように、外部使用の範囲において場所を固定する必要があります。
元の言語デザインでは、__pin キーワードでポインタ宣言を修飾することで固定ポインタを宣言します。次に、元の言語仕様からわずかに変更された例を示します。
__gc struct H { int j; };
int main()
{
H * h = new H;
int __pin * k = & h -> j;
// ...
};
新しい言語デザインでは、内部ポインタの構文に似た構文を使用して固定ポインタを宣言します。
ref struct H
{
public:
int j;
};
int main()
{
H^ h = gcnew H;
pin_ptr<int> k = &h->j;
// ...
}
修正を施した言語における固定ポインタは、内部ポインタの特殊な例です。固定ポインタに対する V1 の制限は残っています。たとえば、パラメータやメソッドの戻り値の型として使用できなく、さらにローカル オブジェクト上でしか宣言を行えません。修正を施した言語デザインでは、制限がさらに追加されています。
- 固定ポインタの規定値は、0 ではなく nullptr です。pin_ptr<> を初期化したり、0 を割り当てることはできません。既存のコードに 0 を割り当てるには、nullptr に変更する必要があります。
- V1 では、固定ポインタを使用してオブジェクト全体の位置を指定できます。元の言語仕様から次の例を示します。
__gc struct H { int j; };
void f( G * g )
{
H __pin * pH = new H;
g->incr(& pH -> j);
};
修正が施された言語では、新しい式に戻されたオブジェクト全体を固定できません。さらに、内部メンバのアドレスを固定する必要があります。以下に例を示します。
void f( G^ g )
{
H ^ph = gcnew H;
pin_ptr<int> pj = &ph->j;
g->incr( pj );
}
5. 言語の修正の概要
このセクションで紹介する修正は、一種の言語雑録です。このセクションでは、文字列リテラルの処理の変更、省略記号と Param 属性間のオーバーロード解決の変更、typeof の typeid への変更、および新規キャスト記法の safe_cast の導入について説明します。
5.1 文字列リテラル
元の言語デザインでは、マネージ文字列リテラルは文字列リテラルに S を前置きして示していました。以下に例を示します。
String *ps1 = "hello";
String *ps2 = S"goodbye";
次の CIL 表現で ildasm を通じて示している通り、この 2 つの初期化の間のパフォーマンス オーバーヘッドは重要です。
// String *ps1 = "hello";
ldsflda valuetype $ArrayType$0xd61117dd
modopt([Microsoft.VisualC]Microsoft.VisualC.IsConstModifier)
'?A0xbdde7aca.unnamed-global-0'
newobj instance void [mscorlib]System.String::.ctor(int8*)
stloc.0
// String *ps2 = S"goodbye";
ldstr "goodbye"
stloc.0
リテラル文字列に S を前置きすることを覚ておく (または学習する) だけですむのは非常に助かります。修正が施された V2 言語では、文字列リテラルの処理は透過的に行われ、利用状況に応じて決定されます。S を指定する必要はありません。
コンパイラにどちらの解釈なのか明示的に指示する必要がある場合はどうでしょうか。この場合、明示的なキャストを適用します。以下に例を示します。
f( safe_cast<String^>("ABC") );
さらに、文字列ラテラルは、 String を標準変換ではなく些細な変換と突き合わせるようになりました。これは、なんでもないことのように聞こえますが、競合する正式パラメータと同じく String および const char* を含む一連のオーバーロード関数の解決を変更します。const char* インスタンスに解決されていた解決が、あいまいとしてフラグされるようになりました。以下に例を示します。
void f(const char*);
void f(String^);
// v1: f( const char* );
// v2: error: ambiguous ...
f("ABC");
これはどういうことでしょうか。 相違があるのはなぜでしょうか。このプログラム内に f という名前のインスタンスが 1 つ以上存在するので、関数オーバーロード解決アルゴリズムを呼び出しに適用する必要があります。オーバーロード関数の正式な解決は 3 つの手順で構成されています。
- 候補関数のコレクション。候補関数は、呼び出している関数の名前の字句が一致するスコープ内のメソッドです。たとえば、My() は R のインスタンスを通じて呼び出されるので、R (またはその基本クラス階層) のメンバでない My という名前を持つ関数は候補関数ではありません。この例では、2 つの候補関数が使用されています。My という名前を持つ R のメンバ関数が 2 つ存在します。候補関数のセットが空の場合、このフェーズでの呼び出しは失敗します。
- 候補関数の中の実行可能関数のセット。実行可能関数は、複数の引数とその型を持つ呼び出しに指定された引数で呼び出すことができる関数です。例中の 2 つの候補関数は実行可能関数でもあります。実行可能関数のセットが空の場合、このフェーズでの呼び出しは失敗します。
- 呼び出しの一致が最優先の関数を選択します。これは、実行可能関数パラメータの型に引数を変換するために適用する変換をランク付けして行います。これは、パラメータ関数が 1 つしか存在しない場合は比較的容易ですが、複数存在した場合はより複雑になります。最優先の一致が存在しない場合、このフェーズでの呼び出しは失敗します。すなわち、実引数の型を正式なパラメータの型に変換する必要がある変換が等しく適当な場合、この呼び出しはあいまいとしてフラグされます。
元の言語デザインでは、この呼び出しの解決によって const char* インスタンスが最優先の一致として呼び出されます。V2 では、"abc" を const char* と String^ に突き合わせるために必要な変換は同等—すなわち、等しく適当—で、この呼び出しは不適当—すなわち、あいまい—としてフラグされます。
これにより、2 つの疑問が生じます。
- 実引数 "abc" の型は何でしょうか。
- 一方の型変換が他方に優先すると判断するアルゴリズムはどういうものでしょうか。
文字列リテラルの "abc" の型は const char[4] です。文字列リテラルの末尾で文字を終端する暗黙的な null が存在します。
一方の型変換が他方に優先すると判断するアルゴリズムには、型変換の階層への配置が含まれます。階層について理解するところでは、これらの変換はすべて暗黙的に行われます。明示的なキャスト記法を使用すると階層がオーバーライドされます。これは、かっこが式に優先して通常の演算子をオーバーライドするのと同様です。
- 正確な一致が最優先されます。意外にも、引数を正確な一致にするには、パラメータ型が正確に一致している必要はなく、ある程度似ているだけで十分です。これは、この例で何が起こっているかを理解し、言語がどのように変更されたかを理解する上での鍵となります。
- プロモーションは標準変換に優先します。たとえば、short int の int へのプロモートは、int の double への変換に優先します。
- 標準変換はボックス化変換に優先します。たとえば、int の double への変換は、int の Object へのボックス化に優先します。
- ボックス化変換は暗黙的なユーザー定義変換に優先します。たとえば、int の Object へのボックス化は、SmallInt 値クラスの変換演算子の適用に優先します。
- 暗黙的なユーザー定義変換は変換なしに優先します。暗黙的なユーザー定義変換がない場合アルゴリズムはエラーになります (ただし、正式なシグネチャではここにパラメータ配列または省略記号が格納される場合があります)。
ところで、正確な一致が正確に一致している必要がないとはいったいどういうことでしょうか。たとえば、const char[4] は const char* と String^ のいずれとも正確に一致していません。さらに、例中のあいまいさは 2 つの矛盾する正確な一致の間に存在します。
正確な一致には、偶然にも、複数の些細な変換も含まれます。適用可能な ISO-C++ には 4 種類の些細な変換があり、正確な一致とみなされます。これらは lvalue 変換と呼ばれています。また、4 番目の型は限定変換と呼ばれています。この 3 種類の lvalue 変換は、限定変換を必要とする正確な一致に優先する正確な一致として扱われます。
lvalue 変換のフォームの 1 つにネイティブ配列 - ポインタ変換があります。これは、const char[4] の const char* との突き合せに含まれるものです。このため、My("abc") を My(const char*) と突き合わせると、正確な一致になります。C++/CLI 言語の初期の段階では、これが最優先の一致でした。
このため、コンパイラが呼び出しをあいまいとフラグするには、const char[4] の String^ への変換も些細な変換を通じて正確な一致とする必要があります。この変更は、V2 で導入されました。この呼び出しがあいまいとしてフラグされるのはこのためです。
5.2 パラメータ配列と省略記号
元の言語デザインおよび Visual Studio 2005 でリリースされる V2 言語では、C# と Visual Basic .NET でサポートされるパラメータ配列を明示的にサポートしていません。その代わり、通常の配列に属性をフラグします。次を参照してください。
void Trace1( String* format, [ParamArray]Object* args[] );
void Trace2( String* format, Object* args[] );
どちらも変わりないように見えますが、ParamArray 属性を使用すると、C# または別の CLI 言語ではこれを呼び出しごとに可変個数の要素を取る配列としてタグを付けされます。元の言語と変更を施した言語のプログラムでの動作の違いは、オーバーロード関数のセットの解決にあり、Artur Laksberg が紹介する次の例の通り、一方のインスタンスは省略記号を宣言し、次のインスタンスは ParamArray を宣言します。
int My(...); // 1
int My( [ParamArray] Int32[] ); // 2
元の言語デザインでは、省略記号が属性に優先します。属性は言語の正式な側面ではないので妥当だといえます。ただし、V2 では、言語内でパラメータ配列が直接サポートされています。これはより厳密に型指定されているので省略記号に優先します。こうして、元の言語では、次の呼び出しは My(...) に解決されます。
修正が施された言語では、これは ParamArray インスタンスに解決されます。プログラムの行動が ParamArray の呼び出しに優先して省略記号インスタンスの呼び出しに依存している場合、シグネチャと呼び出しのいずれかを修正する必要があります。
5.3 typeof から T::typeid に
元の言語デザインでは、マネージ型の名前を渡すと、__typeof() 演算子では関連の Type* オブジェクトを返します。以下に例を示します。
// Creates and initializes a new Array instance.
Array* myIntArray =
Array::CreateInstance( __typeof(Int32), 5 );
変更された言語デザインでは、__typeof は、マネージ型の指定時に Type^ を返す typeid の追加フォームに置き換えられました。
// Creates and initializes a new Array instance.
Array^ myIntArray =
Array::CreateInstance( Int32::typeid, 5 );
5.4 キャスト記法と safe_cast<> の導入
この項目は冗長で、気短な読者は終わりにある実際の変更の図にジャンプしたくなるかもしれません。
既存の構造を変更することは極めて困難で、ある意味、最初の構造を作り上げることより困難だといえます。ほとんど自由度がなく、ソリューションは理想的な再構築と既存の構造依存の実際性との間の折衷になりがちです。たとえば、書籍の活字を組んだことがある方は、既存のページを修正する作業が、ページの体裁の修正をそのページに限定しなければならないことによっていかに制限されるかお分かりになると思います。活字を次のページに送ったり、テキストを追加したり、切りすぎたり (切りすぎなかったり) することが許されず、修正の目的が修正をページにフィットさせることを優先し妥協することに思えることがままあります。
言語の拡張も同様です。1990 年代初頭、オブジェクト指向のプログラムが重要なパラダイムとなると、C++ においてタイプ セーフでダウンキャストな機能の必要性が切迫しました。ダウンキャストは、基本クラスのポインタまたは参照を派生クラスのポインタまたは参照にユーザーに明示的に変換することです。基本クラスのポインタが派生クラスのオブジェクトの一種でない場合、プログラムの動作が異常になる傾向があるので、ダウンキャストには明示的なキャストが必要になります。問題は、実行時の側面の 1 つが基本クラスのポインタの実際の型なので、コンパイラではチェックできない点にあります。また、ダウンキャスト機能は仮想関数呼び出しと同じように動的な解決のフォームを必要とします。ここで、2 つの疑問が持ち上がります。
- ダウンキャストがオブジェクト指向パラダイムに必要になる理由は何でしょうか。すべての場合で仮想関数メカニズムで十分ではないのでしょうか。つまり、ダウンキャスト (またはなんらかのキャスト) が必要になるのはプログラマ側の設計ミスだと主張できないのはなぜでしょうか。
- C++ でダウンキャストのサポートが問題になる必要があるのはなぜでしょうか。Smalltalk (または Java や C#) といったオブジェクト指向グラムでは問題になりませんでした。C++ でダウンキャスト機能のサポートが困難な理由は何でしょうか。
仮想関数は、型のグループに共通する型依存アルゴリズムを表しています (ISO-C++ ではサポートされていませんが C++/CLI ではサポートされている、興味深いデザイン代案であるインターフェイスは考慮していません)。このファミリのデザインは、通常、共通インターフェイス (仮想関数) を宣言する抽象基本クラスが存在するクラス階層とアプリケーション ドメインで実際のファミリ型を表す具体的な派生クラスのセットで表されます。
たとえば、CGI アプリケーション ドメインのライト階層には、色、輝度、位置、オン、オフといった共通属性があります。一握りの光でワールド空間を引き締め、共通インターフェイスを通じて特定の光がスポットライト、指向性ライト、非指向性ライト (太陽光など)、またはスポット ライトであるか煩わされることなく制御できます。この場合、仮想インターフェイスを発動させるために特定のライトの種類にダウンキャスティングする必要はなく、すべて等しくなります。実稼動環境では、すべてが等しくなることはなく、多くの場合、スピードが問題になります。各メソッドをダウンキャストし、明示的に呼び出すことで、仮想メカニズムを経る代わりに呼び出しのインライン実行を実行できるのであれば、この方法を選択することもありえます。
このため、C++ でダウンキャストを行う理由の 1 つは、ランタイム パフォーマンスの大幅な向上の代償として仮想メカニズムを抑制することです (この手動最適化の自動化が研究の活動的な領域です。ただし、register または inline キーワードの明示的な使用を置き換えるより解決するほうがはるかに困難です)。
ダウンキャストを行う第 2 の理由は、多相性の二重性にあります。多相性を考慮する上で、フォームを受動的と動的のペアに分ける方法があります。
仮想呼び出し (およびダウンキャスト機能) は多相性の動的な使用に相当します。ここでは、プログラム実行に含まれる特定のインスタンスにおける基本クラスのポインタの実際の型に基づく操作が実行されます。
一方、派生クラスのオブジェクトをその基本クラスのポインタに割り当てることは多相性の受動的なフォームです。多相性は置換メカニズムとして使用されます。これは、たとえば、汎用前の CLI におけるオブジェクトの主用途です。受動的に使用すると、置換およびストレージに対して選択された基本クラスのポインタによって抽象的過ぎるインターフェイスが得られる場合が一般的です。たとえば、オブジェクトではそのインターフェイスを通じて 5 つのメソッドが提供されます。行動をより明確化するには明示的なダウンキャストが必要になります。たとえば、スポットライトの角度やスポットライトが消える速度を調節する場合は、明示的なダウンキャストが必要になります。サブ型のファミリ内の仮想インターフェイスをその多数の子の考えうるすべてのメソッドのスーパーセットになることは実行不可能です。ダウンキャスト機能がオブジェクト指向言語で必ず必要になるのはこのためです。
オブジェクト指向言語に安全なダウンキャスト機能が必要なのであれば、C++ にこの機能が追加されるまでこんなにも長い時間を要したのはなぜでしょうか。 問題は、ポインタのランタイム型に関する情報を使用可能にする方法です。仮想関数の場合、現在ではよく知られていますが、ランタイム情報はコンパイラによって 2 つの部分に分けてセットアップされます。(a) クラスのオブジェクトには、(それ自体興味深い歴史を持つクラスのオブジェクトの始まりと終わりのいずれかの時点において) 適切な仮想テーブルをアドレス指定する追加仮想テーブルのポインタ メンバが格納されます。このため、たとえば、スポットライト オブジェクトによってスポットライト仮想テーブル、指向性ライト、指向性ライト仮想テーブルなどがアドレス設定されます。また、(b) 各仮想関数にはテーブルに関連の固定スロットが用意されます。呼び出しを行う実際のインスタンスはこのテーブルに格納されたアドレスで表されます。このため、たとえば、仮想 Light デストラクタがスロット 0 に関連付けられたり、Color がスロット 1 に関連付けられたりします。これは、コンパイル時に設定され、オーバーヘッドが最低限なので、柔軟性のない戦略時に効率的です。
問題は、第 2 のアドレスを追加するか、型エンコーディングの一種を直接追加するかして C++ のサイズを変更せずに型情報をポインタに使用可能にする方法でした。これは、依然として支配的なユーザー コミュニティであるユーザーオブジェクト指向パラダイムを使用しないプログラマ (およびプログラム) には受け入れられないでしょう。多相性のクラス型に特殊なポインタを導入することも考えられましたが、これは非常に紛らわしく、特にポインタ演算の問題で、2 つを混合することが極めて困難になってしまいます。各ポインタをその現在の関連型に関連付けているランタイム テーブルを維持することも、動的に更新することも許されません。
次の問題は、異なってはいるけれど、プログラムに対して合理的な願望を持っている 2 つのユーザー コミュニティです。ソリューションは、その願望と相互運用性を維持する一方で、この 2 つのコミュニティの意見の折衷案である必要があります。これは、いずれかの側が主張するソリューションが実行不可能になる可能性が高く、さらに最終的に実装するソリューションが不完全なものになる可能性が高くなることを意味しています。実際の解決では、多相性のクラスの定義を中心に展開しました。 多相性クラスは仮想関数を含むクラスです。多相性クラスは動的でタイプセーフのダウンキャストをサポートします。すべての多相性クラスにはその関連仮想テーブルにこの追加ポインタ メンバが格納されるので、「ポインタをアドレスに維持する」という問題が解決されます。このため、関連の型情報を拡張された仮想テーブル構造に格納できます。タイプセーフなダウンキャストのコストは、この機能のユーザーに (その大部分が) ローカライズされます。
タイプセーフなダウンキャストに関する次の問題は、その構文です。これはキャストなので、ISO-C++ 委員会への元の提案では、次に示すような、飾りのないキャスト構文が使用されていました。
spot = ( SpotLight* ) plight;
しかし、このままではユーザーがキャストのコストを管理できないとして委員会に却下されました。動的なタイプセーフのダウンキャストが以前は安全でなかった構文と同じ構文を備え、静的なキャスト表記法をキャストした場合、代替になり、不要でコストがかかりすぎる場合でもユーザーはランタイム オーバーヘッドを抑えることができません。
通常、C++ には、コンパイラがサポートする機能を抑制するメカニズムが存在します。たとえば、クラス スコープ演算子 (Box::rotate(angle)) を使用するか (クラスのポインタや参照でなく) クラス オブジェクトを通じて仮想メソッドを呼び出すことで仮想メカニズムを無効化できます。後者による抑制は言語的には不要で、実装の品質の問題です。これは、フォームの宣言における一時ファイルの作成の抑制に似ています。
// compilers are free to optimize away the temporary ...
X x = X::X( 10 );
こうして、この提案について更に検討することになり、多くの代替表記法が検討されました。委員会に戻された表記法に (?type) というフォームがありました。これは、未決定の—すなわち、動的な—性質を示しています。これを使用することで、ユーザーは 2 つのフォーム—静的または動的—を切り替えることができますが、誰からもあまり好まれませんでした。こうして、更なる検討が進められました。3 番目の成功を収めた表記法は現在標準の dynamic_cast<type> で、4 つの新しいスタイルのキャスト記法に一般化されました。
ISO-C++ では、dynamic_cast が不適切なポインタ型に適用されると 0 が返されます。また、参照型に適用されると std::bad_cast 例外がスローされます。元の言語デザインでは、(ポインタ表現のために) マネージ参照型に dynamic_cast を適用すると、必ず 0 が返されます。dynamic_cast のバリアントをスローする例外にアナログとして __try_cast<type> が導入されました。ただし、キャストが失敗すると System::InvalidCastException がスローされます。
public __gc class ItemVerb;
public __gc class ItemVerbCollection
{
public:
ItemVerb *EnsureVerbArray() []
{
return __try_cast<ItemVerb *[]>
(verbList->ToArray(__typeof(ItemVerb *)));
}
};
修正が施された言語では、__try_cast は safe_cast として再キャストされました。次に、修正言語における同じコード片を示します。
using namespace stdcli::language;
public ref class ItemVerb;
public ref class ItemVerbCollection
{
public:
array<ItemVerb^>^ EnsureVerbArray()
{
return safe_cast<array<ItemVerb^>^>
( verbList->ToArray( ItemVerb::typeid ));
}
};
マネージの世界では、コードを検証不能にしたまま型間にキャストするプログラマの能力を慣らして検証可能なコードを許容することが重要です。これは、C++/CLI のダイナミック プログラミング パラダイムの重要な側面です。このため、古いスタイルのキャストのインスタンスは、ランタイム キャストとして次のように内的に再キャストされます。
// internally recast into the
// equivalent safe_cast expression above
( array<ItemVerb^>^ ) verbList->ToArray( ItemVerb::typeid );
一方、多相性により能動的なモードと受動的なモードの両方が実現したので、サブタイプの非仮想 API へのアクセスを得るためだけにダウンキャストを行うことが必要になることがあります。これは、クラスのメンバが階層内に任意の型をアドレス指定する (トランスポート メカニズムの受動的な多相性) ことを望み、特定のプログラム コンテキスト内の実際のインスタンスが分かっている場合に起こることがあります。この場合、システム プログラマはキャストのランタイム チェックは許容できないオーバーヘッドであると強い印象を持ちます。C++/CLI がマネージ システム プログラミング言語として機能するには、コンパイル時間 (すなわち、静的) ダウンキャストを行う方法を提供する必要があります。修正が施された言語で static_cast 表記法を適用してコンパイル時間ダウンキャストを行うことができるようにしているのはこのためです。
// ok: cast performed at compile-time.
// No run-time check for type correctness
static_cast< array<ItemVerb^>^>(
verbList->ToArray( ItemVerb::typeid ));
問題は、static_cast を行うプログラマがこれを正しく、善意から行うことを保証する手段がなく、マネージ コードが検証可能であることを強制する手段がないことです。これは、動的プログラム パラダイム下ではネイティブ下でより切迫した問題となりますが、システム プログラミング言語で静的キャストとランタイム キャストを切り替える機能をユーザーに認めないだけでは不十分です。
ただし、C++/CLI のパフォーマンスの点で注意すべき点があります。ネイティブのプログラミングでは、古いスタイルのキャスト記法と新しいスタイルの static_cast 表記法にパフォーマンスの点で違いはありません。しかし、新しい言語デザインでは、古いスタイルのキャスト記法は新しいスタイルの static_cast 表記法を使用した場合より非常に費用がかかります。これは、コンパイラで古いスタイルの記法が例外をスローするランタイム チェックに内的に変換されるためです。さらに、アプリケーションを停止させる未知の例外が発生することになるので、コードの実行プロファイルも変更されます。しかし、static_cast 表記法を使用した場合、同じエラーがこの例外を生じることはありません。これがユーザーを新しいスタイルの表記法に駆り立てる助けになると考える人もいるかもしれません。しかし、エラーが発生した場合に限られますが、古いスタイルの表記法を使用するプログラムの速度が著しく低速化してしまい、その理由も明らかになりません。これは、次の C プログラマの落とし穴に似通っています。
// pitfall # 1:
// initialization can remove a temporary class object,
// assignment cannot
Matrix m;
m = another_matrix;
// pitfall # 2: declaration of class objects far from their use
Matrix m( 2000, 2000 ), n( 2000, 2000 );
if ( ! mumble ) return;
付録 : 修正を施した言語デザインの勧め
元の言語デザインと修正を施した言語デザインの間で最も顕著で驚くべき変更点は、マネージ参照型の宣言が変更されたことでしょう。
// original language
Object * obj = 0;
// revised language
Object ^ obj = nullptr;
これを見て心に浮かぶ 2 つの主要な疑問点は、ハット (Microsoft ではカレット (^) とも呼ばれています) を使う理由、より根本的に新しい構文が必要な理由は何かということです。修正が加えられた C++/CLI 言語デザインに関する疑問より、元の言語デザインを整理して侵略性を抑えることができなかった理由は何でしょうか。
C++ は、コンピュータ指向のシステム ビューに基づいています。高いレベルの型システムをサポートしていますが、こういう場合の常として回避メカニズムが存在し、こういったメカニズムは常にコンピュータに導入されます。いざとなるとユーザーは困窮して、予想もつかないようなことを行います。プログラム抽象化の下にトンネルを生成し、アドレスとオフセットに型を分解しました。
CLI は、OS とアプリケーションの間で動作するソフトウェア抽象化層です。いざとなると、ユーザーは文字通り何もない所から実行環境、クエリ、コーディング、およびオブジェクトの作成を反映します。トンネルを生成する変わりに、飛び越えてしまうのですが、両足を地に付けることに慣れているユーザーはこれを大変不安に感じます。
たとえば、次の記述は何を意味しているでしょうか。
ISO-C++ では、T の性質に関わらず、次の特性が確定します。(1) t に関連して sizeof(T) と等しい バイトのコンパイル時間メモリ コミットメントが存在します。(2) t と関連してこのメモリは t の範囲の間はプログラム内のその他のすべてのオブジェクトから独立しています。(3) t に関連して状態/値がメモリに直接格納されます。(4) このメモリと状態は t の範囲の間維持されます。
これらの特性の結果として何が生じるでしょうか。
項目 (1) は、t が多相性になりえないことを示しています。すなわち、継承階層を通じて関連の型ファミリを表すことはできません。言い換えると、派生インスタンスによって追加メモリ要求が課せられない些細な場合を除き、多相性型にコンパイル時間メモリ コミットメントを持たせることはできません。これは、T がプリミティブ型であるかないか、または複雑な階層の基本クラスとして機能しているかどうかに関わらず該当します。
C++ の多相性型は、この型がポインタ (T*) と参照 (T&) のいずれかとみなされた場合—すなわち、宣言がオブジェクトの T を間接的に参照した場合に限り可能になります。次の例を参照してください。
b は、ネイティブ ヒープに格納されている Derived オブジェクトをアドレス指定するものではありません。値 b は新しい式で割り当てられた Derived オブジェクトと何のつながりもありません。さらに、Derived オブジェクトの Base 部は分割され、b の独立したスタックベースのインスタンスにメンバごとにコピーされます。CLI オブジェクト モデルでこれを記述するボキャブラリは存在しません。
C++ では、ランタイムまでリソースのコミットメントを遅らすために 2 種類の間接フォームが明示的にサポートされています。
ポインタ: T *pt = 0;
参照: T &rt = *pt;
ポインタは、C++ オブジェクト モデルに準拠しています。次を参照してください。
pt には、固定サイズまたは範囲の型である size_t の値が直接格納されます。ポインタの直接使用とポイントされたオブジェクトの間接使用の切り替えに字句キューが使用されます。時として、どのモードが何に、いつどのように適用されるか不明瞭になる場合があります。*pt++;
参照では、有効性はそのままにポインタの字句的な複雑性が構文から取り除かれています。
`
re class="code">Matrix operator+( const Matrix&, const Matrix& );
Matrix m3 = m1 + m2;
参照では、直接モードと間接モードの切り替えは行われず、この 2 つ間でフェーズ-シフトが行われます。(a) 初期化時に直接操作され、(b) その後の使用では透過的になります。
ある意味、C++ オブジェクト モデルにおける参照は、物理学における量子異常に相当します。(a) 場所はとりますが、一時オブジェクトを除き、実体はありません。(b) 割り当て時に深いコピーを示し、初期化時に浅いコピーを示します。(c) const オブジェクトと異なり、不変です。これらは関数パラメータとして以外 ISO-C++ で役に立つことは少ないですが、言語の修正ではインスピレーションを得ることができました。
C++.NET デザインの挑戦
文字通り、CLI をサポートするための C++ の拡張におけるすべての側面に対して、問題は (a) C++ プログラマに自然に、(b) CLI 自体の最高の機能であるかのように「共通言語インフラストラクチャを C++ に統合する方法」に落ち着きます。元の言語デザインにおいてこのバランスが達成されていなかったのは誰の目にも明らかです。
リーダー言語デザインの挑戦
このプロセスの一部を体験してもらうために、次の挑戦を紹介します。CLI の参照型を宣言し、使用するにはどうする必要があるでしょうか。これは、C++ オブジェクト モデルとはまったく異なります。メモリ モデルが異なり (ガベージ コレクション)、コピー セマンティクスが異なり (浅いコピー)、継承モデルが異なります (モノリシック、インターフェイスの追加サポートと単一の継承をサポートするオブジェクトにルートされます)。
元の C++ デザイン向けマネージ拡張
C++ で CLI の参照型をサポートするための基礎となるデザインの選択では、既存の言語にとどまるか、言語を拡張し、既存の標準を捨て去るか決定する必要があります。
この決定はどのようにするのでしょうか。いずれを選択しても批判は免れません。この基準は、追加言語のサポートがドメインの抽象化 (同時実行とスレッドを想像してください) とパラダイム シフト (オブジェクト指向の型とサブ型の関係とジェネリクスを想像してください) のいずれに相当すると信ずるかに要約されます。
追加言語のサポートが単に別のドメインの抽象化に相当すると信ずる場合は、既存の言語内にとどまることを選択します。追加言語のサポートがプログラミングのパラダイム シフトを表すと理解する場合は、言語を拡張します。
要するに、元の言語デザインでは追加言語サポートを単なるドメインの抽象化とみなし、不適切ですがマネージ拡張と呼んでいました。このため、必然的にデザインの選択は既存の言語にとどまっていました。
既存の言語にとどまることを決定すると、3 種類のアプローチが可能になります。この説明は、CLI の参照型を表すことに制限しているので注意してください。
- 言語サポートを透過的にします。コンパイラは意味を文脈から判断します。あいまいな場合エラーが発生します。ユーザーは特別な構文でコンテキストからあいまいさをなくします (例として、オーバーロード関数解決とその優先階層を考慮してください)。
- ドメインの抽象化のサポートをライブラリとして追加します (実行可能モデルとして標準テンプレート ライブラリを考慮してください)。
- 既存の言語要素を再使用して、付属する仕様に示された用途のコンテキストに基づき許容される用途と行動を限定します (仮想基本クラスの初期化とダウンキャストの意味、または関数内、ファイル スコープ内、およびクラス宣言内での静的キーワードの複数使用を考慮してください)。
誰もが認める最高の選択肢は 1 番です。「この言語の他のものと変わりはなく、ただ異なっているだけです。コンパイラに解決させましょう。」これの強みは、既存のコードの点でユーザーに対してすべて透過的であるという点です。既存のアプリケーションを引き抜き、オブジェクトを 1、2 個追加し、コンパイルするだけですみます。 混乱はありません。型とソース コードの点で完全な相互運用性が得られます。永久運動機関の理想性について誰も主張しないのと同じく、このシナリオが理想的だとは誰も主張しません。物理における障害は熱力学の第 2 法則で、エントロピーの存在です。 マルチパラダイムのプログラミング言語では、法則の点ではかなり異なりますが、システムの崩壊を等しく宣告できます。
マルチパラダイム言語では、状況は各パラダイム内である程度機能しますが、パラダイムが誤って混在すると崩壊する傾向があり、プログラムにエラーが発生するか、さらに悪いことに誤った結果を生成することにつながります。独立オブジェクトベースのクラス プログラミングと多相性オブジェクト指向のクラス プログラミングの狭間ではこの問題に陥ることがよくあります。スライシングは初心者の C++ プログラマーの気を狂わせます。
DerivedClass dc; // an object
BaseClass &bc = dc; // ok: bc is really a dc
BaseClass bc2 = dc; // ok: but dc has been sliced to fit into bc2
言語デザインの第 2 の法則は、動作が異なる場合はユーザーがプログラムする際に失敗しないように気がつく程度に異なる外観を持たせることです。ポインタと参照の違いに関する C プログラマの理解を深めるために 2 時間のプレゼンテーションの 30 分を割いたことがよくありました。依然として、大部分の C++ プログラマは参照宣言を使用すべき場合とポインタを使用すべき場合およびその理由について明確に述べることはできません。
この混乱によりプログラミングがさらに困難になり、単に放り出すか、そのサポートによって得られる現実のパワーの間で大規模なトレードオフが起こるのです。この差は、使用に耐えるかどうかに関するデザインの明瞭さです。通常、このデザインは類推を通じて存在します。クラス メンバのポインタが言語に導入されると、メンバ選択演算子が (たとえば、-> から ->* に) 拡張され、関数構文のポインタが同様に (int (*pf)() から int (X::*pf)() に) 拡張されます。同じことは、静的クラス データ メンバの初期化などにも該当します。
参照は演算子のオーバーロードのサポートに必要でした。次のような直感的な構文を生成できます。
Matrix c = a + b; // Matrix operator+( Matrix lhs, Matrix rhs );
c = a + b + c;
ただし、これは効率的な実装だとはいえません。C 言語のポインタは効率的ですが、非直感的な構文に分断されます。
// Matrix operator+( const Matrix* lhs, const Matrix* rhs );
Matrix c = &a + &b;
c = &( &a + &b ) + &c;
参照を導入することでポインタの効率性が得られる反面、直接アクセス可能な値型の字句の単純さが失われてしまいます。その宣言は、ポインタに類似していて、初期化が簡単です。
// Matrix operator+( const Matrix& lhs, const Matrix& rhs );
Matrix c = a + b;
しかし、ポインタに慣れているユーザーはその意味的動作に混乱します。
このため、C++ のオブジェクトの静的動作に慣れている C++ プログラマがマネージ参照型を理解し、正しく使用することがどの程度容易なのかという点が問題になります。そして、この取り組みにおいてプログラマを支援する上で考えうる最良のデザインは何でしょうか。
2 つの型は大きく異なっているので、特殊な処理が必要なのは当然だと感じられたので、第 1 の選択肢は除かれました。修正が施された言語でもこの選択が支持されました。第 1 の選択肢に賛同する、しばしば我々の大部分も含む、ユーザーは、じっくりとこの問題に取り組んでいません。これは非難しているのではなく、事実を述べているに過ぎません。先のデザインの挑戦に取り組み、透過的デザインに考えついたのであれば、経験上それは実行可能なソリューションではないと断言します。
ライブラリ デザインと既存の言語要素のいずれかの手段を取る第 2、第 3 の選択肢にはそれぞれ強力な擁護者が存在します。ライブラリ ソリューションは、Stroustrup の cfront ソースに簡単にアクセスできるために Bell Laboratories ではいやになるほど繰り返されるようになりました。一時は、ここにくるすべての人の言い分でした。この人物は cfront にハックし同時実行を追加し、別の人物も cfront にハックしドメイン拡張を追加し、それぞれが新しい Adjective-C++ 言語を自慢しあいました。ライブラリでの処理が最も適しているという意見が Stroustrup の意見でした。
それではライブラリ ソリューションを選択しなかったのはなぜでしょうか。ひとつには、感覚の問題です。2 つの型は大きく異なっているので、特殊な処理が必要なのは当然だと感じる一方で、2 つの型は極めて似ているので、当然類似の処理が必要だとも感じました。ライブラリ型は、いろいろな意味で言語に組み込まれている型のようにふるまいますが、この言語の 1 級市民ではありません。参照型をこの言語の 1 級市民とするのが最も良いと感じたので、ライブラリ ソリューションを採用しないと決定しました。これは、依然として意見の分かれるところです。
参照型と既存の型オブジェクト モデルが違いすぎると感じたので透過的ソリューションを放棄し、参照型と既存の型オブジェクト モデルを言語に列する必要があると感じたのでライブラリ ソリューションを放棄しました。最後の問題は、参照型を既存の言語にどのようにして統合するかという点です。
最初から始めているのであれば、統一型システムを提供するためにいかようにもできました。少なくとも型システムを変更するまでは、何をしても真新しい製品の輝きを備えていたでしょう。これが製造と技術の段階で我々が実施する作業です。我々は祝福と呪いに制限されます。既存の C++ オブジェクト モデルを捨て去ることができないので、何にしてもこれに適合する必要があります。元の言語デザインでは、新しいトークンを導入しないようにさらに厳しく制限されているので、既存のものを使用する必要があります。このため、選択の余地はほとんどありません。
要するに、元のデザインの列挙した制限の中で (多大な混乱がないことを望みます)、言語デザイナはマネージ参照型の唯一の実行可能な表現は、既存のポインタ構文を再使用することだと感じました。参照は再割り当てを行うことができなかったり、オブジェクトなしを参照できなかったりと、柔軟性にかけていました。
// the mother of all objects allocated on the managed heap...
Object * pobj = new Object;
// the standard string class allocated on the native heap...
string * pstr = new string;
これらのポインタはかなり特徴があります。たとえば、pobj にアドレス指定されるオブジェクト エンティティがマネージ ヒープを通じた圧縮スイープを介して移動されると、pobj は透過的に更新されます。オブジェクト追跡といった概念は、pstr とこれがアドレス指定するエンティティの間の関係には存在しません。ポインタをコンピュータのアドレスと間接オブジェクト参照の切り替えとする C++ の概念は存在しません。参照型のハンドルでは、ランタイム ガベージ コレクタをより容易にするためにオブジェクトの実仮想アドレスをカプセル化し、プライベート データ メンバでは拡張性とローカリゼーションを容易にするためにクラスの実装をカプセル化します。ただし、ガベージ コレクションを行う環境でこのカプセル化に違反するとより重大な結果につながります。
このため、pobj はポインタに似ていますが、型システムから外れるキャストやポインタ演算といった多数の一般的なポインタ的なことは禁止されています。参照マネージ型を宣言し、割り当てを行う完全修飾構文を使用すればより明示的に区別できます。
// ok, now these looks different ...
Object __gc * pobj = __gc new Object;
string * pstr = new string;
一見して、ポインタ ソリューションが適当に思えます。結局、新しい式の自然なターゲットのようで、いずれも浅いコピーをサポートしています。問題は、ポインタが型抽象ではなく、(メモリの内部組織と範囲の翻訳方法に関するタグ型の推奨とこれに続いて最初のバイトのアドレスを備えた) コンピュータの表現で、これはソフトウェア ランタイムによってメモリに課せられている抽象化と、これから推定可能な自動化とセキュリティには不十分だという点にあります。これは、異なるパラダイムを表現するオブジェクト モデル間の歴史的な問題です。
次の問題は、(メタファー警告—こじつけのメタファーを紹介しようとしています。胃が弱い読者はがまんするか、次の段落に進んでください) 非常に似ていると同時にまったく異なっている構成要素を再使用するよう制限する閉じられた言語デザインの不可欠なエントロピーで、プログラマのエネルギーは砂漠の蜃気楼の熱に失われてしまうことになります (メタファー警告終了)。
ポインタ構文の再使用は、プログラマにとって認知上のノイズの源となります。ネイティブ ポインタとマネージ ポインタの間で区別することが多すぎ、抽象化のより高いレベルでの管理に最適なコーディングのフローの妨げになっています。システム プログラマとして必要なパフォーマンスを押し込むためにレベルを下げなければならない場合もありますが、そのレベルにとどまるつもりはありません。
元の言語デザインが成功しているのは、既存の C++ プログラムを未変更でリコンパイルする機能をサポートし、簡単に既存のインターフェイスを新しいマネージ環境に発行するラッパー パターンをサポートしているからです。これにより、追加機能をマネージ環境に追加できます。また、時間と経験が命じるままに、追加機能や既存のアプリケーションの一部をマネージ環境に直接移植できます。これは、既存のコード ベースや既存の専門知識ベースを持つ C++ プログラマにとって素晴らしい成果です。これについては恥ずかしむべきことは一切ありません。
しかし、元の言語デザインの実際の構文とビジョンには重大な欠点があります。これは、デザイナの力不足ではなく、既存の言語にとどまろうとする基礎デザイン選択の保守的な性質に原因があります。さらに、マネージ サポートがドメインの抽象化を表すのではなく、オブジェクト指向の一般的なプログラミングをサポートするために Stroustrup が導入した言語拡張に類似の言語拡張を必要とする革新的なプログラミング パラダイムであるという誤解に起因しています。これが修正が施された言語デザインが象徴することで、元の言語デザインへの参加者にトラブルをもたらすにも関わらず必要かつ適切である理由です。これが、このガイドと変換ツールの背後に流れる動機です。
修正を施された C++/CLI の言語デザイン
明確なプログラミング パラダイムを表す C++ での共通言語インフラストラクチャのサポートが明らかになると、より規模の大きい C++ コミュニティの感情を尊重し、その参加と支援を得るためにユーザーに 1 級のコーディング方法と ISO-C++ 標準との上質なデザイン統合を提供するために言語を拡張する必要が生じました。さらに、元の言語デザインの C++ マネージ拡張の短縮名を置き換える必要が生じました。
CLI のフラッグシップの機能は参照型で、これを既存の C++ 言語に統合することがコンセプトの証明を表していました。一般基準はなんだったでしょうか。これを際立たせるが、既存の型システムに類似していると感じさせるマネージ参照型を表す方法が必要でした。これにより、フォームの一般カテゴリに親しみを感じさせる一方で、その特殊な機能に注目させることができます。この類似は、C++ の開発時における Stroustrup による参照型の導入です。このため、一般フォームが次のようになりました。
Type TypeModToken Id [ = init ];
ここで、TypeModToken は新しいコンテキストで再使用される言語のトークンとして認識されます (繰り返しますが、参照の導入と似ています)。
当初、これは驚くほど論議を呼び、依然として一部のユーザーの不満の種となっています。心に残っている 2 つの一般的な初期反応は、(a) typedef、wink、wink で処理できるというものと、(b) それほどわるくないというものでした (後者は iostream ライブラリに入出力するために左右のシフト演算子を使用したときの自分の反応を思い出させてくれました)。
必須の行動特性は、演算子の適用時にオブジェクト の意味を示すことで、元の構文ではサポートできないものでした。既存の C++ の参照について考慮して、これを柔軟的な参照と呼んでいました (ここでの参照の二重使用—1 つはマネージ参照型で、他方は「ポインタではない」ネイティブの C++ 型— は偶然で、最も気に入っているデザイン戦略の 1 つの Gang of Four Patterns におけるテンプレートの再使用に似ています)。
- オブジェクトなしを参照できる必要がありました。再解釈キャストの 0 に初期化された参照をよく拝見しますが、ネイティブの参照ではこれを直接行うことはできません (参照にオブジェクトなしを参照させるのに、関数パラメータの規定の引数として使用される null オブジェクトを通例として示す明示的なシングルトンを提供するのが従来式の方法です)。
- 初期値は必要なく、オブジェクトなしを参照するものとして開始できます。
- 別のオブジェクトを参照するよう再割り当てすることができます。
- あるインスタンスを別のインスタンスに割り当てたり初期化したりすると、既定どおり浅いコピーが行われます。
多くの仲間が明確にしてくれたので、このことについて考えました。すなわち、ネイティブの参照と区別される特性について言及しましたが、マネージ参照型のハンドルとして区別される特性について言及しませんでした。
この型のことをポインタや参照ではなくハンドルと呼びたいと考えています。これは、これらの用語はネイティブ側からひきつがれたものだからです。ハンドルはカプセル化のパターンンの 1 つなので良い名前です—John Carolan という名前の人物が Cheshire Cat というかわいらしい名前で最初にこのデザインを私に紹介してくれました。オブジェクトの内容を隠すことができます。
この場合、隠す動作は、ガベージ コレクタのスイープ時における参照型の再配置に起因します。この再配置はランタイムによって追跡され、ハンドルは新しい位置に正しく更新されます。追跡ハンドルと呼ばれる理由がこれです。
この新しい追跡参照構文について述べておきたい最後の項目は、メンバ選択演算子です。私には、オブジェクト構文 (.) を使用するのは非常に簡単なことに思えます。ポインタ構文 (->) も同様に分かりやすいと感じる人もいて、追跡参照の用途の異なる面から立場を議論しました。
// the pointer no-brainer
T^ p = gcnew T;
// the object no-brainer
T^ c = a + b;
物理の世界における光と同じように、追跡参照はプログラム コンテキストによってオブジェクトのようにふるまったり、ポインタのようにふるまったりします。使用するメンバ選択演算子は、元の言語デザインの場合と同じく、矢印のそれです。
キーワードに関する余談
最後に、Stroustrup が C++ 言語デザインにクラスを追加した理由はなんだったのでしょうか。C++ には C 言語の struct が拡張されいて、クラスで実行可能なすべてがサポートされているので真の意味でこれを導入する必要はありませんでした。このことについて Bjarne に尋ねたことはないので特別な裏話はありませんが、この興味深い質問の答えは C++/CLI に追加されたキーワードに幾分関係がありそうです。
可能性のある答えの 1 つ—私はこれをフット ソルジャー シャッフルと呼んでいます—として、クラスの導入が不可欠だったことが考えられます。結局、既定のメンバ アクセスは 2 つのキーワード間で異なるだけでなく、派生関係のアクセス レベルも異なります。そのどちらもないのはなぜでしょうか。
当時、既存の言語と互換性を備えていないだけでなく、言語ツリー (Simula-68) の異なる分岐から新しいキーワードをインポートすることは C 言語のコミュニティに対する攻撃と誤解されるリスクがありました。暗黙的な既定のアクセス規則の違いが本当に動機だったのでしょうか。自分はこれには納得できません。
1 つには、この言語ではクラス キーワードを使用するデザイナが全体の実装を公開した場合でもこれを妨げたり警告したりしません。この言語自体にはパブリック アクセスおよびプライベート アクセスに関するポリシーは一切存在しないので、既定のラベルなしアクセス レベルの許可を重要なプロパティ—すなわち、非互換性を導入するコストを上回る程度には重要—とみなすことが妥当だとは到底考えられません。
同様に、ラベルなし基本クラスをプライベートな継承に既定化することはデザインの実践として賢明とは思えません。型/サブ型の行動を示していないので継承のフォームの複雑さが増し、分かりやすさが損なわれてしまいます。これでは代替性の法則に反してしまいます。インターフェイスではなく実装の再使用を表し、プライベート継承が既定だとするのは誤りだと考えます。
もちろん、マーケット シェアを得るために競合他社につけこもうとするライバルに塩をおくることになるので言語市場では製品のわずかな不完全性についても一切認めるべきではないので、このことを公に述べることはできません。知的なニッチの間では揶揄抽象がよくあります。むしろ、新たに進化した製品が展開するまで不完全性について認めるものはいないでしょう。
クラスに非互換性を導入する理由が他にあるでしょうか。struct の C 言語の概念は抽象データ型の概念です。クラスの C++ の概念 (もちろん、C++ に端を発したものではありません) は、データ抽象化の概念とそれに付随するカプセル化とインターフェイス コントラクトのアイデアです。抽象データ型は、アドレスを示し、キャストし、分解し、迅速に移動するためのアドレスに関連のデータの連続した塊にすぎません。データ抽象化は、存続期間と行動を備えたエンティティです。言語の世界では少なくとも文字によって大きな差が生じられるので、教育学的な重要性があります。これは、修正を施したデザインにおいても心に留めておくべきことです。
C++ で struct をまとめて切り捨てなかったのはなぜでしょうか。片方を維持して他方を導入し、2 つの違いを文字通り最小に抑えるのでは洗練しているとはいえません。しかし、他に選択肢がありえたでしょうか。C++ に C との後方互換性を可能な限り厳密に付与する必要があったので、struct キーワードを維持する必要がありました。既存のプログラマにまったく不人気でしたが、放置することが認められてもいませんでした (これについてはまた別の機会にお話します)。
既定で struct がパブリックなのはなぜでしょうか。パブリックでないと、既存の C プログラムでコンパイルできないからです。Advanced Principles of Language Design でこのことに関して述べているのを聞いたことはありませんが、そうなると実際面で混乱をきたします。struct の使用をパブリック実装に保証し、クラスの使用をプライベート実装とパブリック インターフェイスに保証するといった方針を言語に課すこともできましたが、これには実用的な目的がなく、過剰になりすぎてしまいます。
実際、Bell Laboratories での cfront 1.0 言語コンパイラのリリースのテストでは、言語法律家の小規模なサークル内で、前方宣言と後に続く定義 (または任意のこのような組み合わせ) で一貫して 1 つまたはその他のキーワードを使用し続けるべきか、交換して使用できるようにするべきか小さな議論が交わされました。struct に本当の意味での意義があるのであれば、これを認めるべきではありません。
謝辞
元のC++ のマネージ拡張から修正を施した C++/CLI 言語デザインへの発展的な移行に伴う問題を理解するためにたゆまぬ支援と指導を行ってくださった Visual C++ チームのメンバに感謝します。特に、自分が担当した箇所の重大な混乱に耐えてくれた Arjun Bijanki と Artur Laksberg に多大なる感謝をいたします。また、Brandon Bray、Jonathan Caves、Siva Challa、Tanveer Gani、Mark Hall、Mahesh Hariharan、Jeff Peil、Andy Rich、Alvin Chardon、Herb Sutter に感謝します。多大な支援と迅速さを提供してくました。本書は、皆さんの専門知識に謝意を表します。
参考書籍
STL Tutorial and Reference Guide by David Musser, Gillmer Derge, and Atul Saini, Addison-Wesley, 2001
C++ Standard Library by Nicolai Josuttis, Addison-Wesley, 1999
C++ Primer by Stanley Lippman and Josee Lajoie, Addison-Wesley, 1998
執筆者紹介
Stanley Lippman、Microsoft 社の Visual C++ チームの設計者。Bell Laboratories にて 1984 年に考案者の Bjarne Stroustrup と供に C++ に関与。その間、Disney Feature Animation 社と DreamWorks 社で勤務。Fantasia 2000 でソフトウェア テクニカル ディレクタを勤める。