在前面的章节中,我们已经看到了函数式编程如何用 LINQ 函数(如 Select 或 Aggregate)替换For
和ForEach
循环,这绝对很棒 - 前提是您使用的是固定长度数组或Enumerable
,它们会自行确定何时完成迭代。
但是,当您完全不确定要迭代多长时间时,您会怎么做?如果您无限期地迭代直到满足条件怎么办?
以下是一些非函数式代码的示例,展示了我所说的那种情况。我在这里想象一些大富翁游戏的代码。你被关在监狱里,你这个淘气的人!逃脱规则如下:
- 支付 50 元(我选择英镑)
- 掷出双数
- 使用
免费逃脱
卡
在真正的大富翁游戏中,需要考虑其他玩家的回合,但我将其简化为无限循环,直到满足其中一个条件。如果我真的这样做,我可能也会在这里添加一些验证逻辑,但再次强调,我会保持简单。
var inJail = true;
var inventory = getInventory();
var rnd = getRandomNumberGenerator();
while(inJail)
{
var playerAction = getAction();
if(playerAction == Actions.PayFine)
{
inventory.Money -= 50;
inJail = false;
}
if(playerAction == Actions.GetOutOfJailFree)
{
inventory.GetOutOfJailFree -= 1;
inJail = false;
}
if(playerAction == Actions.RollDice)
{
var dieOne = rnd.Random(1, 6);
var dieTwo = rnd.Random(1,6);
inJail = dieOne == dieTwo; // get out if a double
}
}
您不可能使用 Select
语句执行上述操作,这根本不可能。我们无法说出何时会满足条件,并且我们将继续迭代 While
循环,直到其中一个条件满足为止。
我们如何才能实现此功能?While
循环是一个语句,因此不是函数式编程语言的首选。
有几个选项,我将描述每个选项,但这是需要进行某种权衡的领域之一。每个选择都有后果,我会尽力考虑它们各自的优缺点。
系好安全带,我们开始吧……
尾递归
处理无限循环的经典函数式编程方法是使用递归。简而言之,对于那些不熟悉它的人来说 - 递归是使用一个调用自身的函数。还会有某种条件来确定是否应该进行另一次迭代,或者是否实际返回数据。
如果在递归函数的末尾做出决定,则称为尾部递归。
大富翁问题的纯递归解决方案可能如下所示:
// I'm making the Inventory object a Record to make it
// a bit easier to be functional
var inventory = getInventory();
var rnd = getRandomNumberGenerator();
var updatedInventory = GetOutOfJail(inventory);
private Inventory GetoutOfJail(Inventory oldInv)
{
var playerAction = getAction();
return playerAction switch
{
Actions.PayFine => oldInv with
{
Money = oldInv.Money - 50
},
Actions.GetOutOfJailFree => oldInv with
{
GetOutOfJail = oldInv.GetOutOfJail - 1
},
Actions.RollDice =>
{
var dieOne = rnd.Random(1, 6);
var dieTwo = rnd.Random(1,6);
// return unmodified state, or else
// iterate again
return dieOne == dieTwo
? oldInv
: GetOutOfJail(oldInv);
}
};
}
任务完成了,对吧?其实不是,在使用上述函数之前我会仔细考虑。问题是,每个嵌套函数调用都会在 .NET 运行时的 Stack 上添加一个新项,如果存在大量递归调用,那么这可能会对性能产生负面影响,或者导致应用程序因 Stack Overflow Exception 而终止。
如果保证只有少量迭代,那么递归方法从根本上来说没有什么问题。如果代码的使用在增强后发生重大变化,您还必须确保重新审视这一点。结果可能是,这个很少使用的函数在某一天会变成需要数百次迭代的大量使用函数。如果发生这种情况,那么企业可能会想知道为什么他们出色的应用程序几乎在一夜之间突然变得几乎无响应。
所以,就像我说的,要仔细考虑。这样做的好处是相对简单,不需要您编写任何样板来实现它。
F# 和许多其他功能更强大的语言都具有一项称为尾部优化递归调用
的功能,这意味着可以编写递归函数而不会使堆栈爆炸。但是,C# 中没有此功能,并且也没有计划在未来提供此功能。根据情况,F# 优化将使用 while(true)
循环创建中间语言 (IL) 代码,或者使用名为 goto
的 IL 命令将执行环境的指针物理地移回循环的开头。
我确实研究了从 F# 引用通用尾部优化递归调用并通过编译的 DLL 将其公开给 C# 的可能性,但这有其自身的性能问题,因此浪费精力。
我在网上看到有人讨论另一种可能性,即添加一个构建后事件,直接操纵 C# 编译为的 .NET 中间语言,以便它可以追溯使用 F# 尾部优化功能。这非常聪明,但对我来说听起来太辛苦了。这也是一项潜在的额外维护任务。
在下一节中,我将研究一种在 C# 中模拟尾部优化递归调用的技术。
Trampolining
我不太确定Trampolining
这个术语从何而来,但它出现的时间早于 .NET。我能找到的最早的参考资料是 90 年代的学术论文,研究如何用 C 实现 LiSP 的一些功能。不过我猜它甚至比这还要古老一些。
基本思想是,你有一个以 thunk 作为参数的函数 - thunk 是存储在变量中的代码块。在 C# 中,它们被实现为 Func
或 Action
。
获得 thunk 后,你可以使用 while(true)
创建一个无限循环,并使用某种方式评估确定循环是否应该终止的条件。这可以通过一个额外的 Func
来完成,该 Func
返回一个 bool
,或者某种需要由 thunk 在每次迭代时更新的包装器对象。
但归根结底,我们看到的基本上是将 while
循环隐藏在代码库的后面。确实 while
并非纯粹的功能性,但这是我们可能需要妥协的地方之一。从根本上讲,C# 是一种混合语言,支持 OO 和 FP 范式。总会有一些地方无法让它完全按照 F# 的方式运行。这就是其中之一。
有多种方法可以实现 trampolining,但我倾向于采用这种方法:
public static class FunctionalExtensions
{
public static T IterateUntil<T>(this T @this, Func<T, T> updateFunction, Func<T, bool> endCondition)
{
var currentThis = @this;
while(!endCondition(currentThis)