働くプログラマ

マルチパラダイムと .NET (第 7 部): パラメーター メタプログラミング

Ted Neward

image: Ted Neward大学生のころ、計算ばかり行っていたミクロ経済学の講義中に、教授から今でも心に響く賢明な教えを受けました。

「選択したテーマの退屈で細かい計算に苦労して取り組んでいるうちに、なぜこのような計算を苦労してやっているのかわからなくなったときは、私の話に割り込んで『アンダーソン教授、ポイントはどこにあるのでしょう』と尋ねるようにしなさい。そうしたら、少し時間を取って前に戻り、そこまでの流れを説明しましょう。」

このシリーズをすべて読み続けてきた読者の皆さんが、同じ壁に突き当たったとしても当然です。そこで、少し時間を取って前に戻り、ここまでの流れを確認しましょう。

要約

James Coplien が著書『マルチパラダイム デザイン』(ピアソンエデュケーション、2001 年) で説明しているように (この書籍は、このシリーズを書く際に非常に刺激を受けました)、すべてのプログラミングは、共通性 ("常時" ケースを表すコードの作成) を捉え、言語内の可変性の構造を使用して特定の状況下では異なる動作や構造を許可する手法である、という考えが中核にあります。

たとえば、オブジェクト指向プログラミングでは、共通性をクラスに取り込み、継承を利用して共通性に変更を加えるサブクラスを作成することで可変性を実現します。通常、このような可変性の実現は、プログラミングする言語に応じてメソッドやメッセージを使用して、クラスの特定部分の動作を変更することによって行います。ただし、プロジェクトの共通性と可変性のニーズは、オブジェクト指向パラダイムなど、1 つの特定のパラダイムに完全に適合しているとは限りません。このオブジェクト指向プログラミングでさえ、手続き型プログラミングでは簡単に取り込むことができない可変性を実現するために、手続き型プログラミングから進化したものです。

このコラムの読者にとって幸運だったのは、マイクロソフトが Visual Studio 2010 で提供している言語がマルチパラダイム言語 (1 つの言語内に複数の異なるプログラミング パラダイムが一緒に組み込まれている言語) であることです。Coplien は、C++ をこのようなマルチパラダイム言語として最初に特定しました。C++ には、手続き型、オブジェクト、およびメタプログラミング (正確にメタオブジェクトと呼ぶこともあります) という、3 つの主要パラダイムが一緒に持ち込まれています。C++ は複雑な言語で、平均的開発者が習得するには難しすぎるという批判を幅広い層から受けています。この主な理由は、特定の問題を解決する際に言語のさまざまな機能をいつ使用すればよいかがわかりづらいことにあります。

近代言語は、高度なマルチパラダイム言語として開発されることがほとんどです。Visual Studio 2010 の F#、C#、および Visual Basic は、手続き型、オブジェクト指向、メタオブジェクト、動的、および関数型の 5 つのパラダイムを直接サポートしています。そのため、これらの 3 つの言語 (C++/CLI を含める場合は 4 つ) はすべて、C++ と同じ運命を辿る危険性があります。

このような言語に混在している各パラダイムを明確に理解しておかないと、開発者はお気に入りの機能というわなに簡単にはまることになります。つまり、お気に入りの 1 つの機能やパラダイムに頼りすぎると、他の機能やパラダイムを排除することになり、結局は翻弄され、書き直しを余儀なくされる非常に複雑なコードを作成することになります。このようなことが何度も繰り返されると、その言語は開発者の不満の対象となり、いずれは新しい言語を求めたり、その言語を使用しなくなったりすることがほとんどです。

手続き型パラダイムとオブジェクト指向パラダイム

ここまでは、手続き型プログラミングや構造化プログラミングに適用される共通性と可変性の分析について見てきました。このような分析では、共通性をデータ構造に取り込みます。この共通データ構造は別のプロシージャ呼び出しに渡して操作します。さらに、同じデータ構造を操作する新しいプロシージャ呼び出しを作成することで可変性を生み出します。また、オブジェクトについての共通性と可変性も調べました。オブジェクトの場合は、共通性をクラスに取り込み、このクラスをサブクラス化し、サブクラスのメソッドやプロパティをオーバーライドすることで、クラスに変更を加え可変性を生み出します。

このような継承の大部分は正の可変性のみを可能にするため、別の問題が発生することも覚えておいてください。つまり、クラスのメンバーであるメソッドやフィールドなどを、基本クラスから削除することはできません。CLR では、派生クラスからアクセス可能なメンバーの実装をシャドウ処理する (たとえば、C# では override キーワードではなく、virtual キーワードを使用する) ことで非表示にできます。しかし、この場合はその動作を完全に削除するのではなく、他の動作に置き換えることになります。いずれにせよフィールドは残ったままです。

このことから、憂慮すべき新事実が明らかになります。つまり、オブジェクト (少なくとも純粋なオブジェクト) は、必要なことをすべて実行できるわけではありません。たとえば、オブジェクトの継承を使用して、構造上の境界線に沿って可変性を捉えることはできません。コレクション クラスは、多種多様なデータ型 (整数、倍精度浮動小数点、文字列など) を扱えることを除けばスタックに似た動きをしますが、その構造上の違いを捉えることはできません。確かに、CLR 内では、統一型システムを使用できます。System.Object クラスの参照インスタンスを格納し、必要に応じてダウンキャストすることはできますが、1 つの型のみ格納する型を作成できるわけではありません。

このことから、従来のオブジェクトの軸の外側で物事を捉える方法が必要になり、テーマはメタオブジェクト プログラミングに移ります。

このようなメタ手法は、当初、生成によるものでした。つまり、なんらかの種類のテンプレートを基にソース コードが生成されます。この手法では、さまざまな軸で、ある程度の可変性を実現できますが、(多くの場合) ソース作成時に実行するモデルに限定されます。また、ソース テンプレートは、コード生成時に生成されるコードになんらかの形で (通常、テンプレート言語内に隠されている判断ステートメントを使って) 変更を加える必要があり、これでテンプレートの複雑さが増すことになるため、この手法では、可変性の数が増えるにつれて問題が生じます。

このようなメタ手法の 2 つ目が、反映プログラミングまたは属性プログラミングです。コードは、実行時に、完全な再現性があるプラットフォームのメタデータ機能 (リフレクション) を使用してコードを検査し、検査結果に応じて異なる動作を実行します。

これにより、実行時の判断によって実装や動作の柔軟性は実現されますが、独自の制限事項がもたらされます。つまり、反映型 (属性型) の設計内に型関係が存在しないため、たとえば、XML に永続化可能な型ではなく、データベースに永続化可能な型のみをメソッドに渡すことを、プログラムで保証することができません。継承などの関係がないと、タイプ セーフ性 (エラーを防ぐ重要な機能) がある程度失われることになります。

そこで、テーマは .NET 環境の 3 つ目のメタオブジェクト機能であるパラメーター ポリモーフィズムに移ります。これは、型をパラメーターとして含む型を定義する機能です。簡単に言うと、Microsoft .NET Framework でジェネリックと呼ばれる機能です。

ジェネリック

ジェネリックの最も簡単な形式では、クライアント コードから型構造の一部を渡して、コンパイル時に型を作成できます。つまり、スタック動作コレクションの開発者は、ライブラリのコンパイル時に、クライアントが異なるインスタンスにどのような型を格納するかを把握する必要はありません。この情報は、コレクションのインスタンスの作成時にクライアントが指定します。

たとえば、以前の記事で、デカルト座標で座標点の型を定義する場合、軸の値 (X と Y) の表現に関して、値を整数にするか、負の値を許可するかなど、(Point クラスの開発者が) 事前に決定を行う必要があることを説明しました。

数学で使用するデカルト座標の座標点は、浮動小数点数や負の値を考慮する必要があります。デカルト座標点を使用して、コンピューター グラフィックス画面にピクセルを表示するには、正の整数で特定の数値範囲に収まるようにします。これは、40 億ピクセル四方のコンピューターの画面がまだ一般的ではないためです。

したがって、コンピューターの画面に対して適切に設計されたデカルト座標点のライブラリには、異なる Point 型が複数必要になります。たとえば、X フィールドと Y フィールドを符号なしバイト型にするものや倍精度浮動小数点型にするものなどです。ほとんどの場合、これらすべての型の間で動作は同じですが、共通性の取り込みに明らかに違反しています。つまり、DRY (Don't Repeat Yourself、同じことを繰り返さない) という原則に違反しています。

パラメーター ポリモーフィズムを使用すると、このような共通性を適切に取り込むことができます。

class Point2D<T> {
  public Point2D(T x, T y) { this.X = x; this.Y = y; }

  public T X { get; private set; }
  public T Y { get; private set; }
  // Other methods left to the reader's imagination
}

開発者は、使用するデカルト座標点の範囲と型のプロパティを正確に指定できるようになります。数学的領域で作業を行う場合は Point2D<double> 値のインスタンスを作成し、画面に表示する作業を行う場合は Point2D<sbyte> のインスタンスまたは Point2D<ushort> のインスタンスを作成します。それぞれ明確に異なる別の型なので、Point2D<sbyte> と Point2D<double> の比較や代入を試みると、あたかも厳密に型指定される言語を使用しているかのように、コンパイル時に無残に失敗することになります。

ただし、以前のコラムで説明したように、この Point2D 型にもいくつか欠点があります。デカルト座標点の共通性は確実に取り込みましたが、X と Y の値には事実上どのような種類の値でも使用できます。これは、 (「このグラフは、ある映画に対する各人の評価をグラフにしたものです」というような) 特定のシナリオには役に立つかもしれませんが、一般に、Point2D<DateTime> を作成すると混乱を招く可能性があり、Point2D<System.Windows.Forms.Form> を作成するとほぼ確実に混乱を招くでしょう。ここでは、なんらかの負の可変性を導入し (または、好みによっては、正の可変性のレベルを絞り込み)、Point2D の軸の値に指定できる型の種類を制限する必要があります。

多くの .NET 言語では、型パラメーターに指定する必要がある条件を明示的に記述することで、パラメーター化の制約 (型の制約と呼ぶこともあります) を利用して、このような負の可変性を取り込みます。

class Point2D<T> where T : struct {
  public Point2D(T x, T y) { this.X = x; this.Y = y; }

  public T X { get; private set; }
  public T Y { get; private set; }
  // Other methods left to the reader's imagination
}

つまり、コンパイラは、T に関して値型ではないものは受け入れません。

正直に言えば、これは正確には負の可変性ではありませんが、特定の機能の削除を試みる問題と対比して使用され、実際の負の可変性の機能とほとんど違いがありません。

可変動作

通常、パラメーター ポリモーフィズムを使用する場合、構造上の軸に沿って可変性を提供しますが、C++ Boost ライブラリの開発者が例を挙げて示しているように、パラメーター ポリモーフィズムで操作できるのは軸に沿ってだけではありません。型の制約をうまく利用すれば、ジェネリックを使用して、構築しようとしているオブジェクトの動作のメカニズムをクライアントから指定できる、ポリシー メカニズムを提供することもできます。

これを説明するため、診断ログに関する従来からの問題について考えてみましょう。サーバー (またはクライアント コンピューター) で実行しているコードの問題を診断する場合、コードベースを通じてコードの実行を追跡することを考えます。そのため、通常は、ファイルにメッセージを書き込むことになります。しかし、少なくとも特定のシナリオでは、コンソールにメッセージを表示することもあれば、メッセージを破棄することもあります。長年の間、診断ログのメッセージの処理は厄介な問題で、さまざまな解決策が提案されています。Boost のレッスンでも、新しい手法が提案されています。

まず、インターフェイスを定義します。

interface ILoggerPolicy {
  void Log(string msg);
}

インターフェイスは簡単です。ここに変更対象の動作を定義するメソッドを 1 つまたは複数指定します。これは、次のようにインターフェイスの一連のサブタイプを利用して行います。

class ConsoleLogger : ILoggerPolicy {
  public void Log(string msg) { Console.WriteLine(msg); }
}

class NullLogger : ILoggerPolicy {
  public void Log(string msg) { }
}

ここには、2 つの実装候補があります。1 つはコンソールにログ メッセージを書き込み、もう 1 つはログ メッセージを破棄します。

これを使用するには、クライアントがどちらを使用するかを選択する必要があります。そのためには、ロガーを型指定するパラメーターとして宣言し、クライアントが実際にログ記録を行うインスタンスを作成します。

class Person<A> where A : ILoggerPolicy, new() {
  public Person(string fn, string ln, int a) {
    this.FirstName = fn; this.LastName = ln; this.Age = a;
    logger.Log("Constructing Person instance");
  }

  public string FirstName { get; private set; }
  public string LastName { get; private set; }
  public int Age { get; private set; }

  private A logger = new A();
}

その後、使用するロガーの種類を指定するには、次のように、単純に構築時のパラメーターを渡します。

Person<ConsoleLogger> ted = 
  new Person<ConsoleLogger>("Ted", "Neward", 40);
var anotherTed  = 
  new Person<NullLogger>("Ted", "Neward", 40);

このメカニズムにより、開発者は独自のカスタム ログ実装を作成して、Person<> インスタンスから使用されるようにそのカスタム実装をプラグインできます。Person<> の開発者は使用するログ実装の詳細について把握する必要はありません。ただし、これを行う方法は、ほかにもたくさんあります。たとえば、外部からロガーのインスタンスに渡すフィールドやプロパティをロガーに用意したり、依存関係の挿入 (DI) 手法を使用して取得したりできます。ただし、ジェネリックベースの手法には、フィールドベースの手法にはないメリットが 1 つあります。それは、コンパイル時の明確な区別です。Person<ConsoleLogger> は、Person<NullLogger> とは明確に異なる型になります。

通貨と換算

開発者を悩ます問題の 1 つは、数量を定量化する単位がなければ、数量があっても役に立たないことです。1,000 ペニーは、1,000 頭の馬、1,000 人の従業員、1,000 枚のピザとは明らかに同じものではありません。ただし、1,000 ペニーと 10 ドルが同じ価値を持つことも明らかです。

これは、単位を揃える (度/ラジアン、フィート/メートル、華氏/摂氏など) 必要性が厳しく求められる数学計算、特に、大型ロケットの誘導管制ソフトウェアを作成する場合にはさらに重要になります。アリアン 5 (Ariane 5) を思い出してください。アリアン 5 の最初の飛行は、変換エラーが原因で自爆してしまいました。また、火星に向けた NASA の宇宙探査機も 1 機、変換エラーが原因で全速力で火星に激突しました。

最近の F# のような新しい言語では言語の直接機能として単位に対応していますが、ジェネリックのおかげで、C# や Visual Basic でも同じような対応が可能です。

あまり表に出たがらない Martin Fowler の話を参考に、まず、簡単な Money クラスについて考えます。このクラスは、特定の金額 (数量) と通貨 (種類) を認識します。

class Money {
  public float Quantity { get; set; }
  public string Currency { get; set; }
}

これは一見機能するように思えますが、Money インスタンスどうしを加算するなど、値の演算が必要であることがすぐにわかります (このような演算は、金銭について考える場合は非常に一般的な処理です)。

class Money {
  public float Quantity { get; set; }
  public string Currency { get; set; }

  public static Money operator +(Money lhs, Money rhs) {
    return new Money() { 
      Quantity = lhs.Quantity + rhs.Quantity, Currency = lhs.Currency };
  }
}

ランチに出かけたときなど (誰でも知っているように、ヨーロッパ人は最高のビールを、アメリカ人は最高のピザを作るので)、米ドル (USD) とユーロ (EUR) を加算して支払おうとすると、当然問題が発生します。

var pizza = new Money() { 
  Quantity = 4.99f, Currency = "USD" };
var beer = new Money() { 
  Quantity = 3.5f, Currency = "EUR" };
var lunch = pizza + beer;

金融指標をちょっと見れば、だまされていることがわかるでしょう。というのも、ユーロが、1 対 1 の割合でドルに換算されています。不慮の不正を防ぐため、現在の換算レートを採用する承認済みの換算処理を実行するのでなければ、USD から EUR に変換できないことをコンパイラに認識させるようにします (図 1 参照)。

図 1 望ましい換算

class USD { }
class EUR { }
class Money<C> {
  public float Quantity { get; set; }
  public C Currency { get; set; }

  public static Money<C> operator +(
    Money<C> lhs, Money<C> rhs) {
    return new Money<C>() { 
      Quantity = lhs.Quantity + rhs.Quantity, 
      Currency = lhs.Currency };
  }
}
... 
var pizza = new Money<USD>() { 
  Quantity = 4.99f, Currency = new USD() };
var beer = new Money<EUR>() { 
  Quantity = 3.5f, Currency = new EUR() };
var lunch = pizza + beer;    // ERROR

USD と EUR は基本的には、コンパイラに比較対象を指定するように設計されたプレースホルダーにすぎません。この 2 つの C の型パラメーターが同じでなければ問題になります。

もちろん、2 つの異なる型パラメーターを組み合わせる機能はありませんが、いつかはこの処理を正確に実行したいと考えることもあるでしょう。これを行うには、もう少し複雑なパラメーター構文が必要です (図 2 参照)。

図 2 型を意図的に組み合わせる

class USD { }
class EUR { }
class Money<C> {
  public float Quantity { get; set; }
  public C Currency { get; set; }

  public static Money<C> operator +(
    Money<C> lhs, Money<C> rhs) {
    return new Money<C>() { 
      Quantity = lhs.Quantity + rhs.Quantity, 
      Currency = lhs.Currency };
  }

  public Money<C2> Convert<C2>() where C2 : new() {
    return new Money<C2>() { Quantity = 
      this.Quantity, Currency = new C2() };
  }
}

これは、ジェネリック クラス内で特殊化されるジェネリック メソッドです。メソッド名の後の <> 構文は、メソッドのスコープに型パラメーターを追加します (この場合、2 つ目の換算先通貨の型を追加しています)。したがって、ピザとビールを購入するコマンドは次のようになります。

var pizza = new Money<USD>() { 
  Quantity = 4.99f, Currency = new USD() };
var beer = new Money<EUR>() { 
  Quantity = 3.5f, Currency = new EUR() };
var lunch = pizza + beer.Convert<USD>();

見た目の好みによって、必要に応じて、C# の変換演算子を使用して換算を自動的に行うこともできますが、コードが読みやすくなるどころか混乱を招く可能性があります。

まとめ

Money<> 構文の例に欠けているものは明らかです。ドルからユーロに、ユーロからドルに換算する方法が必要です。ただし、このような設計の目標の 1 つは、クローズド システムになるのを避けることです。つまり、新しい通貨 (ルーブル、ルピー、ポンド、リラなどの変動通貨) が必要になるたびに Money<> 型の設計者が呼び出されて、新しい通貨を追加することにならないのが目標です。オープン システムでは、他の開発者が必要に応じて新しい通貨を追加でき、すべて "うまくいく" のが理想的です。

まだ終わりではありません。コードをそのまま出荷しないでください。Money<> 型を強力、安全、かつ拡張可能にするために、まだいくつか調整を行います。その過程で、動的プログラミングと関数型プログラミングを見ていきます。

ただし今は、コーディングを楽しんでください。

Ted Neward は、Microsoft .NET Framework および Java のエンタープライズ プラットフォーム システムを専門とする独立企業 Neward & Associates の社長を務めています。これまでに 100 個を超える記事を執筆している Ted は、C# MVP であり、INETA の講演者でもあります。さまざまな書籍を執筆および共同執筆していて、『Professional F# 2.0』(Wrox 2010 年、英語) もその 1 つです。彼は定期的にコンサルティングを行い、開発者を指導しています。彼の連絡先は ted@tedneward.com (英語のみ) です。彼がチームの作業に加わることに興味を持ったり、ブログをご覧になったりする場合は、blogs.tedneward.com (英語) にアクセスしてください。

この記事のレビューに協力してくれた技術スタッフの Krzysztof Cwalina に心より感謝いたします。