このシリーズの最初の例では、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"
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#プログラマーのほとんどが明示的なループを好む理由がわかります。
より現実的な例
実世界でよく出てくるやや関連性の高い例は、要素がクラスや構造体である場合にリストの「最大」要素を取得する方法です。 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 list
maxNameAndSize []
実は、 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