関数を使用してボイラープレートコードを抽出する
このシリーズの最初の例では、F#とC#の両方で実装された、平方和を計算する簡単な関数を見ました。 ここで、以下のような類似した新しい関数が必要になったとしましょう:
- Nまでのすべての数の積を計算する
- Nまでの奇数の和を数える
- Nまでの数の交互和を計算する
明らかに、これらの要件はすべて似ていますが、共通の機能をどのように抽出できるでしょうか?
まずは、C#での直接的な実装から始めましょう:
public static int Product(int n){ int product = 1; for (int i = 1; i <= n; i++) { product *= i; } return product;}
public static int SumOfOdds(int n){ int sum = 0; for (int i = 1; i <= n; i++) { if (i % 2 != 0) { sum += i; } } return sum;}
public static int AlternatingSum(int n){ int sum = 0; bool isNeg = true; for (int i = 1; i <= n; i++) { if (isNeg) { sum -= i; isNeg = false; } else { sum += i; isNeg = true; } } return sum;}
これらの実装に共通しているのは何でしょうか? それはループのロジックです! プログラマーとして、私たちはDRY原則(「繰り返すな」)を覚えておくように言われていますが、ここではほぼ同じループロジックを毎回繰り返しています。これら3つのメソッドの違いだけを抽出できないか見てみましょう:
関数 | 初期値 | 内部ループのロジック |
---|---|---|
Product | product=1 | i番目の値を実行中の合計に掛ける |
SumOfOdds | sum=0 | 偶数でない場合、i番目の値を実行中の合計に加える |
AlternatingSum | int sum = 0 bool isNeg = true |
isNegフラグを使用して加算するか減算するかを決定し、次の処理のためにフラグを反転させる |
重複するコードを取り除き、セットアップと内部ループのロジックだけに焦点を当てる方法はありますか? はい、あります。以下がF#での同じ3つの関数です:
let product n = let initialValue = 1 let action productSoFar x = productSoFar * x [1..n] |> List.fold action initialValue
//テストproduct 10
let sumOfOdds n = let initialValue = 0 let action sumSoFar x = if x%2=0 then sumSoFar else sumSoFar+x [1..n] |> List.fold action initialValue
//テストsumOfOdds 10
let alternatingSum n = let initialValue = (true,0) let action (isNeg,sumSoFar) x = if isNeg then (false,sumSoFar-x) else (true ,sumSoFar+x) [1..n] |> List.fold action initialValue |> snd
//テストalternatingSum 100
これら3つの関数はすべて同じパターンを持っています:
- 初期値を設定する
- ループ内の各要素に対して実行されるアクション関数を設定する
- ライブラリ関数
List.fold
を呼び出す。これは強力な汎用関数で、初期値から始めて、リスト内の各要素に対してアクション関数を順番に実行します。
アクション関数には常に2つのパラメータがあります:実行中の合計(または状態)とアクションを実行するリスト要素(上記の例では “x” と呼ばれています)です。
最後の関数 alternatingSum
では、初期値とアクションの結果にタプル(値のペア)を使用していることに気づくでしょう。これは、実行中の合計と isNeg
フラグの両方をループの次の反復に渡す必要があるためです - 使用できる「グローバル」な値はありません。foldの最終結果もタプルなので、欲しい最終合計を抽出するために “snd”(2番目)関数を使用する必要があります。
List.fold
を使用し、ループのロジックを完全に避けることで、F#のコードはいくつかの利点を得ています:
- 主要なプログラムロジックが強調され、明示的になります。関数間の重要な違いが非常に明確になり、共通点は背景に押し出されます。
- 定型的なループコードが排除されます。結果としてコードはC#バージョンよりも簡潔になります(F#コードは4-5行に対し、C#コードは少なくとも9行)。
- ループロジックにエラーが発生することはありません(たとえば、1つずれるなど)。なぜなら、そのロジックが私たちに露出していないからです。
ちなみに、平方和の例も fold
を使って書くことができます:
let sumOfSquaresWithFold n = let initialValue = 0 let action sumSoFar x = sumSoFar + (x*x) [1..n] |> List.fold action initialValue
//テストsumOfSquaresWithFold 100
C#での “Fold”
Section titled “C#での “Fold””C#で “fold” アプローチを使用できますか?はい、できます。LINQには fold
に相当する Aggregate
があります。以下がそれを使用して書き直したC#コードです:
public static int ProductWithAggregate(int n){ var initialValue = 1; Func<int, int, int> action = (productSoFar, x) => productSoFar * x; return Enumerable.Range(1, n) .Aggregate(initialValue, action);}
public static int SumOfOddsWithAggregate(int n){ var initialValue = 0; Func<int, int, int> action = (sumSoFar, x) => (x % 2 == 0) ? sumSoFar : sumSoFar + x; return Enumerable.Range(1, n) .Aggregate(initialValue, action);}
public static int AlternatingSumsWithAggregate(int n){ var initialValue = Tuple.Create(true, 0); Func<Tuple<bool, int>, int, Tuple<bool, int>> action = (t, x) => t.Item1 ? Tuple.Create(false, t.Item2 - x) : Tuple.Create(true, t.Item2 + x); return Enumerable.Range(1, n) .Aggregate(initialValue, action) .Item2;}
ある意味では、これらの実装は元のC#バージョンよりも単純で安全ですが、ジェネリック型からの余計なノイズのせいで、このアプローチはF#での同等のコードほどエレガントではありません。C#プログラマーのほとんどが明示的なループを好む理由がわかります。
より現実的な例
Section titled “より現実的な例”実世界でよく出てくるやや関連性の高い例は、要素がクラスや構造体である場合にリストの「最大」要素を取得する方法です。 LINQの “max” メソッドは最大値のみを返し、最大値を含む要素全体は返しません。
以下は明示的なループを使用した解決策です:
public class NameAndSize{ public string Name; public int Size;}
public static NameAndSize MaxNameAndSize(IList<NameAndSize> list){ if (list.Count() == 0) { return default(NameAndSize); }
var maxSoFar = list[0]; foreach (var item in list) { if (item.Size > maxSoFar.Size) { maxSoFar = item; } } return maxSoFar;}
LINQでこれを効率的に(つまり、1回のパスで)行うのは難しいように見え、Stack Overflowの質問として取り上げられています。Jon Skeetさんもこの問題について記事を書いています。
ここでも、foldが救世主です!
以下が Aggregate
を使用したC#コードです:
public class NameAndSize{ public string Name; public int Size;}
public static NameAndSize MaxNameAndSize(IList<NameAndSize> list){ if (!list.Any()) { return default(NameAndSize); }
var initialValue = list[0]; Func<NameAndSize, NameAndSize, NameAndSize> action = (maxSoFar, x) => x.Size > maxSoFar.Size ? x : maxSoFar; return list.Aggregate(initialValue, action);}
このC#バージョンは空のリストに対してnullを返すことに注意してください。これは危険そうです - では代わりに何をすべきでしょうか?例外をスローする?それも正しくないように思えます。
以下がfoldを使用したF#コードです:
type NameAndSize= {Name:string;Size:int}
let maxNameAndSize list =
let innerMaxNameAndSize initialValue rest = let action maxSoFar x = if maxSoFar.Size < x.Size then x else maxSoFar rest |> List.fold action initialValue
// 空のリストを処理する match list with | [] -> None | first::rest -> let max = innerMaxNameAndSize first rest Some max
F#コードには2つの部分があります:
innerMaxNameAndSize
関数は、これまで見てきたものと似ています。- 2つ目の部分、
match list with
は、リストが空かどうかで分岐します。 空のリストの場合はNone
を返し、空でない場合はSome
を返します。 これにより、関数の呼び出し元が両方のケースを処理することが保証されます。
そしてテスト:
//テストlet list = [ {Name="Alice"; Size=10} {Name="Bob"; Size=1} {Name="Carol"; Size=12} {Name="David"; Size=5} ]maxNameAndSize listmaxNameAndSize []
実は、 maxNameAndSize
関数を書く必要はありませんでした。F#にはすでに maxBy
関数があるからです!
// 組み込み関数を使用するlist |> List.maxBy (fun item -> item.Size)[] |> List.maxBy (fun item -> item.Size)
しかし、ご覧のとおり、空のリストをうまく処理できません。以下は maxBy
を安全にラップしたバージョンです。
let maxNameAndSize list = match list with | [] -> None | _ -> let max = list |> List.maxBy (fun item -> item.Size) Some max