Using functions to extract boilerplate code

https://swlaschin.gitbooks.io/fsharpforfunandprofit/content/posts/conciseness-extracting-boilerplate.html

在本系列的第一个示例中,我们看到了一个计算平方和的简单函数,它是用 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 原则(“不要重复自己”),但在这里我们每次都重复了几乎完全相同的循环逻辑。让我们看看是否可以仅提取这三种方法之间的差异:

Function
Initial value
Inner loop logic

Product

product=1

对i进行连乘

SumOfOdds

sum=0

对基数值进行连加

AlternatingSum

int sum = 0 bool isNeg = true

使用 isNeg 标志来决定是加还是减,并为下一次传递翻转标志。还有把数值加上去

有没有一种方法可以剥离重复的代码,只关注设置和内循环逻辑?是的,有的。下面是F#中同样的三个函数:

let product n = 
    let initialValue = 1
    let action productSoFar x = productSoFar * x
    [1..n] |> List.fold action initialValue

//test
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

//test
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

//test
alternatingSum 100

所有这三个函数都具有相同的模式:

  1. 设置初始值

  2. 设置一个动作函数,在循环内的每个元素上执行。

  3. 调用库函数List.fold。这是一个功能强大的通用函数,它从初始值开始,然后依次为列表中的每个元素运行操作函数。

动作函数总是有两个参数:运行总计(或状态)和要作用的列表元素(在上面的示例中称为“x”)。

在最后一个函数alternatingSum中,你会注意到它使用了一个元组(一对值)作为初始值和动作的结果。这是因为运行中的总数和isNeg标志都必须传递给循环的下一次迭代--没有可以使用的“全局”值。折叠的最终结果也是一个元组,所以我们必须使用"snd"(第二)函数来提取我们想要的最终总数。

通过使用 List.fold 并完全避免任何循环逻辑,F# 代码获得了许多好处:

  • 关键的程序逻辑得到了强调和明确。各项功能之间的重要区别变得非常清楚,而共同点则被放到了次要位置

  • 消灭了模板循环代码,因此,代码比C#版本更加紧凑(F#代码4-5行,C#代码至少9行)。

  • 循环逻辑中不可能有错误(如off-by-one),因为这个逻辑没有暴露给我们。

顺便说一下,平方和的例子也可以用 fold 来写:

let sumOfSquaresWithFold n = 
    let initialValue = 0
    let action sumSoFar x = sumSoFar + (x*x)
    [1..n] |> List.fold action initialValue 

//test
sumOfSquaresWithFold 100

"Fold" in C#

你能在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;
}

Doing this in LINQ seems hard to do efficiently (that is, in one pass), and has come up as a Stack Overflow question. Jon Skeet event wrote an article about it.

在LINQ中做到这一点似乎很难有效地做到(也就是一次完成),并且已经作为一个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 

    // handle empty lists
    match list with
    | [] -> 
        None
    | first::rest -> 
        let max = innerMaxNameAndSize first rest
        Some max

F#代码有两部分:

  • innerMaxNameAndSize 函数与我们之前看到的类似。

  • the second bit, match list with, branches on whether the list is empty or not. With an empty list, it returns a None, and in the non-empty case, it returns a Some. Doing this guarantees that the caller of the function has to handle both cases.

  • 第二点,match list with,判断列表是否为空。对于空列表,它返回一个 None,在非空的情况下,它返回一个 Some。这样做可以保证函数的调用者必须处理这两种情况。

然后测试:

//test
let list = [
    {Name="Alice"; Size=10}
    {Name="Bob"; Size=1}
    {Name="Carol"; Size=12}
    {Name="David"; Size=5}
    ]    
maxNameAndSize list
maxNameAndSize []

实际上,我根本不需要写这个,因为F#已经有一个maxBy函数了!

// use the built in function
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

Last updated