働くプログラマ:

C# を楽しむ (第 2 部)

Ted Neward

Ted Newardようこそ。前回のコラム「C# を楽しむ」(msdn.microsoft.com/magazine/dn754595) では、手に負えそうもない設計上の問題に関する考えを明確にする際に、他のプログラミング言語の知識が役に立つ場合があることを簡単に説明しました。そこで取り上げた問題とは、何年も前にコンサルティングの仕事をしていたときに経験した、ローカルに保存された取引リストと、そのリストと同一と考えられるリモートに保存された取引リストを照合するというものです。取引額を一致させる (まったく同じ取引の記録でも他の項目が一致する保証はありませんでした) か、結果リストで一致しない項目にフラグを付けることが必要でした。

私が F# に精通しているという理由から、課題として F# を取り上げました。率直に言うと、Scala、Clojure、Haskell などの言語にしても不都合はありませんでした。関数型言語ならば、すべて同じように機能します。ここで重要なのは、言語自体やそれを実行するプラットフォームではなく、関数型言語に含まれる考え方です。これは非常に関数型向きの問題でした。

F# ソリューション

F# ソリューションを C# に変換する方法について説明する前に、図 1 を見て F# ソリューションを振り返りましょう。

F# になじみのない方は特に、前回のコラムを参照して 図 1 で使用している F# 構文の要点を確認してください。C# に変換しながら、F# も確認していくので、このまま読み進めてもかまいません。

図 1 異なる取引を解決するための F# ソリューション

type Transaction =
  {
    amount : float32;
    date : DateTime;
    comment : string
  }
type Register =
  | RegEntry of Transaction * Transaction
  | MissingRemote of Transaction
  | MissingLocal of Transaction
let reconcile (local : Transaction list) 
  (remote : Transaction list) : Register list =
  let rec reconcileInternal outputSoFar local remote =
    match (local, remote) with
    | [], _
    | _, [] -> outputSoFar
    | loc :: locTail, rem :: remTail ->
      match (loc.amount, rem.amount) with
      | (locAmt, remAmt) when locAmt = remAmt ->
        reconcileInternal (RegEntry(loc, rem) :: 
          outputSoFar) locTail remTail
      | (locAmt, remAmt) when locAmt < remAmt ->
        reconcileInternal (MissingRemote(loc) :: 
          outputSoFar) locTail remote
      | (locAmt, remAmt) when locAmt > remAmt ->
        reconcileInternal (MissingLocal(rem) :: 
          outputSoFar) local remTail
      | _ ->
        failwith "How is this possible?"
  reconcileInternal [] local remote

C# ソリューション

出発点として、Transaction 型と Register 型が必要です。Transaction 型は簡単です。この型は、名前の付いた 3 つの要素が設定された単純な構造体型で、C# クラスとして簡単にモデル化できます。

class Transaction
{
  public float Amount { get; set; }
  public DateTime Date { get; set; }
  public String Comment { get; set; }
}

これらの自動プロパティによって、このクラスは F# クラスとほとんど変わらず簡潔になります。実を言うと、F# バージョンの機能について 1 対 1 の変換を行う場合、オーバーライドした Equals メソッドと GetHashCode メソッドを導入する必要があります。しかし、このコラムの目的に関して言えば、導入しなくてもうまくいきます。

判別共用体 Register 型は複雑です。C# の列挙型のように、Register 型のインスタンスは 3 つの値 (RegEntry、MissingLocal、または Missing­Remote) のいずれかしか取ることができません。C# 列挙型とは異なり、その各値にデータを含むことができます (RegEntry に一致した Transactions 2 つ、または MissingLocal か MissingRemote に該当する見つからない Transaction)。3 つの個別のクラスを作成するのは簡単ですが、その 3 つのクラスをなんらかの形で関連させる必要があります。図 2 に示すように、返された出力として 3 つのいずれか (ただしそれら 3 つのみ) を含むことができるリストが必要です。そこで、継承を使用します。

図 2 継承を使用して 3 つの個別のクラスを含める

class Register { }
  class RegEntry : Register
  {
    public Transaction Local { get; set; }
    public Transaction Remote { get; set; }
  }
  class MissingLocal : Register
  {
    public Transaction Transaction { get; set; }
  }
  class MissingRemote : Register
  {
    public Transaction Transaction { get; set; }
  }

これは、驚くほど複雑なわけではなく、冗長なだけです。これが運用向けコードだった場合、いくつかのメソッド (Equals、GetHashCode、および (ほぼ確実に) ToString) を追加する必要があります。より慣用的に C# らしくする方法はいくつかありますが、F# の発想に非常に近い Reconcile メソッドを記述します。慣用的な最適化については後で取り上げます。

F# バージョンには "外部" (パブリックにアクセスでき、再帰的に内部を呼び出す関数) カプセル化関数があります。しかし、C# にはメソッドを入れ子にする考え方がありません。これに最も近い処理を実現できる方法は、2 つのメソッド (public 宣言したメソッドと private 宣言したメソッド) を使用することです。それでも、まったく同じにはなりません。F# バージョンでは、入れ子になった関数をすべてのものからカプセル化できます。同じモジュールの他の関数からカプセル化することもできます。しかし、図 3 に示すように、それ以上は望めません。

図 3 入れ子にした関数がここではカプセル化される

class Program
{
  static List<Register> ReconcileInternal(List<Register> Output,
             List<Transaction> local,
             List<Transaction> remote)
  {
    // . . .
  }
  static List<Register> Reconcile(List<Transaction> local,
             List<Transaction> remote)
  {
    return ReconcileInternal(new List<Register>(), local, remote);
  }
}

ちなみに、Reconcile 内のローカル変数として参照されるラムダ式として内部関数を完全に記述することで、"すべてのものから隠す" 再帰的手法を実現できます。とは言うものの、オリジナルに少し卑屈すぎるほどに準拠しているため、まったく C# らしくないでしょう。

多くの C# 開発者はこのような手法を取りませんが、この手法で F# バージョンとほとんど同じ効果を得られます。ReconcileInternal 内では、使用されているデータ要素を明示的に抽出し、その要素を if/else-if ツリー内に明示的に記述しています。これは、簡潔明瞭な F# のパターン照合とは対照的です。ただし、これはまったく同じコードです。ローカル リストまたはリモート リストが空の場合、再帰は終わりです。次のように出力が返され、終了します。

static List<Register> ReconcileInternal(List<Register> Output,
              List<Transaction> local,
              List<Transaction> remote)
{
  if (local.Count == 0)
    return Output;
  if (remote.Count == 0)
    return Output;

ここで、各リストの "先頭" を抽出する必要があります。また、各リストの残り ("末尾"、Tail) への参照を保持しておく必要もあります。

Transaction loc = local.First();
List<Transaction> locTail = local.GetRange(1, local.Count - 1);
Transaction rem = remote.First();
List<Transaction> remTail = remote.GetRange(1, remote.Count - 1);

これは、注意を怠ると大きなパフォーマンスの低下を招くおそれのある箇所です。F# ではリストは不変なので、リストの末尾を取得することがそのままリストの次の項目の参照を取得することになります。コピーは作成されません。

しかし、C# にはそのような保証はなく、毎回リストの完全なコピーが作成される可能性があります。GetRange メソッドでは "シャロー コピー" が作成されます。つまり、新しいリストが作成されます。ただし、このリストはオリジナルの Transaction 要素を参照します。おそらくこれが、異質になりすぎることなく期待できる最善の方法です。それでもやはり、コードがボトルネックになる場合は、必要に応じて異質にします。

F# バージョンをもう一度見てみましょう。2 つ目のパターン照合で調べているのは、ローカル取引とリモート取引の額です (図 4 参照)。これらの値も同様に抽出して、比較を開始します。

図 4 ローカルの額とリモートの額を調べる F# バージョン

 

float locAmt = loc.Amount;
  float remAmt = rem.Amount;
  if (locAmt == remAmt)
  {
    Output.Add(new RegEntry() { Local = loc, Remote = rem });
    return ReconcileInternal(Output, locTail, remTail);
  }
  else if (locAmt < remAmt)
  {
    Output.Add(new MissingRemote() { Transaction = loc });
    return ReconcileInternal(Output, locTail, remote);
  }
  else if (locAmt > remAmt)
  {
    Output.Add(new MissingLocal() { Transaction = rem });
    return ReconcileInternal(Output, local, remTail);
  }
  else
    throw new Exception("How is this possible?");
}

ツリーの各分岐は、この時点では非常に簡単に理解できます。Output リストに新しい要素を追加して、ローカル リストとリモート リストの未処理の要素を再帰処理します。

まとめ

C# ソリューションが本当に優れているとしたら、なぜ最初に F# に寄り道するのでしょうか。その説明は、同じプロセスを経験した人に対してでない限り、容易ではありません。F# への寄り道が必要だった主な理由は、最初にアルゴリズムを具体化するためです。最初にこれを試みたときは、まったくうまくいきませんでした。まず、2 つの "foreach" ループを使用して 2 つのリストを反復処理しました。途中で状態の追跡を試みましたが、何百万年かかってもデバッグを完了できないような、大規模でとてつもなくごちゃごちゃしたものになりました。

「違う考え方をする」 (Think differently: 有名なコンピューター会社が十数年前に使用したマーケティングのキャッチコピー) 方法を身に付けると、言語の選択肢以外の成果が生まれます。Scala、Haskell、Clojure などを使用しても、同じ筋書きを簡単に説明できたでしょう。ポイントは、言語機能セットではなく、ほとんどの関数型言語の背景にある考え方 (特に再帰処理) です。この考え方が、思考の行き詰まりを打ち破るのに役立つのです。

これは、Pragmatic Programmers の発行者の 1 人であり、Ruby で有名な Dave Thomas が最初に提唱したように、開発者に新しいプログラミング言語を毎年学ぶことをお勧めする理由の一部です。頭脳は新しい考え方や新しい選択肢に触れずにはいられません。Scheme、Lisp、Forth のようなスタック ベース言語、Io のようなプロトタイプ ベース言語などをしばらくの間使用すれば、似たような考えが浮かびます。

Microsoft .NET Framework プラットフォーム以外のさまざまな言語の簡単な解説を知りたい場合は、Bruce Tate の著作『7 つの言語 7 つの世界』(オーム社、2011 年) を強くお勧めします。いくつかの言語は、.NET プラットフォームで直接使用することはないでしょう。しかし、必ず再利用可能なコードを得られるわけではなくても、問題に関する考え方や、ソリューションの立案方法が得られることもあるのです。それでは、コーディングを楽しんでください。


Ted Neward は、コンサルティング サービス会社の iTrellis で CTO を務めています。これまでに 100 本を超える記事を執筆している Ted は、さまざまな書籍を執筆および共同執筆していて、『Professional F# 2.0』(Wrox、2010 年、英語) もその 1 つです。C# MVP であり、世界中で講演を行っています。彼は定期的にコンサルティングを行い、開発者を指導しています。彼の連絡先は ted@tedneward.com (英語のみ) または ted@itrellis.com (英語のみ) です。彼がチームの作業に加わることに興味を持ったり、ブログをご覧になったりする場合は、blogs.tedneward.com (英語) にアクセスしてください。

この記事のレビューに協力してくれたマイクロソフト技術スタッフの Lincoln Atkinson に心より感謝いたします。