在先前的内容中,我们介绍了函数式编程中的独特形式的数据结构。
那么我们程序结构可以变的更函数式化吗?
让我们想一想,常用的程序中,我们常用的结构有哪些,他们本身偏向于什么风格?
首先,当然还是要回想起我们最重要的两个法则(表达式与不可变)
我们回想起,顺序,分支,循环.... 其中顺序自不必说, 分支(if)语句可以通过if表达式来一定程度的替代...(if表达式每一个分支块都将有一个返回值) 那么,循环。我们该怎么办,循环怎么做到有返回值?
我们仔细想一想,循环本质是什么?
以相对更复杂的for循环为例,
-
首先我们有循环的起始状态
-
然后有每一轮循环变化的状态
-
最后则是有退出循环的终止条件。
...
记住这些,接下来我们再看一种结构,递归
这里我们以尾递归为例
-
同样我们会有一个递归函数的初始的入参
-
然后我们会将这一轮的状态传至下一轮的递归调用
-
当然,良好的递归最后应该有一个适宜的退出条件。
从结构上看,二者都具备:
1. 初始状态
2. 每轮迭代 / 递归需要传递的状态
3. 终止条件
差别只在于:
• 循环把「下一轮状态」写在 for/while 头部;
• 尾递归把「下一轮状态」作为参数重新调用自身。
虽然就用途/实现上有所不同,但本质上,我们可以用尾递归可以完全模拟出循环的效果。
这听上去可能有些暴力,但也先无需着急。
事实上,这需要我们从想要用循环解决什么问题开始来思考。如果是一些非常计算密集的算法问题,直接使用命令式的循环当然会是更好的解决方案,他既方便,操作也更自由,效率也更好,能更好的实现我们的思路,因为我们就是要精准的控制每一次计算。
例如动态规划
int[] dp = new int[10 + 1];
int capacity = 10;
int[] weights = new int[capacity + 1];
int[] values = new int[capacity + 1];
for (int i = 0; i < weights.Length; i++)
{
for (int w = capacity; w >= weights[i]; w--)
{
dp[w] = Math.Max(dp[w], dp[w - weights[i]] + values[i]);
}
}
我们当然需要精确的计算来解决这个问题,所以这时候用循环完全没有任何问题
不过这显然不是我们循环的全部用途,算法的实现只是循环的其中一种用途而已。
retry
事实上业务等层面上有一个非常经典的例子。我们往往会用循环去反复进行一个操作,直到成功,或是失败次数到达一定数量为止。这就是循环的一个经典的 retry 场景。
int res = 0;
SomeWork obj = new SomeWork();
for (int i = 0; i < 10; i++)
{
var r = obj.CallSth();
if (r != -1)
{
res = r;
break;
}
}
在这个场景下,我们本质是要获取一个可用的结果或者资源,而不是一次高效的算法计算。这种情况下我们用循环反而添加了更多的代码噪音,和相对难理解一些的逻辑。
把它改写成尾递归后,意图就非常直白了:
var res1 = GetResult(obj, 10);
int GetResult(SomeWork someWork, int retryCnt)
{
if (retryCnt <= 0) return -1;
var r = someWork.CallSth();
if (r != -1) return r;
return GetResult(someWork, retryCnt - 1);
}
可以看到的是,这时候我们已经不再需要使用break等控制流程的关键词去打断循环,我们只通过不同的状态,去决定函数的返回值而已。若调用失败,我们就会尝试再次递归的调用一次自己。递归可以让我们整个循环在语义上有了一个明确的返回值。而不需要关心相对复杂的流程控制逻辑,如果配合ADT,我们还可以加上语义明确的返回值,这将使我们在编写中更容易的使用结果。
Option<int> GetResult(SomeWork someWork, int retryCnt)
{
if (retryCnt <= 0) return -1;
var r = someWork.CallSth();
if (r != -1) return Option<int>.None;
return GetResult(someWork, retryCnt - 1);
}
这本质上是一种,直到结果可用的场景。
接下来我们可以再进一步再进一步:抽象为高阶函数
充足的复用价值意味着可以把「重试」抽象出来,令其与业务逻辑彻底解耦:
public static Option<T> Retry<T>(Func<Option<T>> action, int retryTimes)
{
Option<T> Loop(int left)
=> left <= 0
? Option<T>.None
: action() is { HasValue: true } ok
? ok
: Loop(left - 1);
return Loop(retryTimes);
}
这样我们便可以更轻松的使用我们的Retry来实现我们的意图
小结
-
循环与尾递归在理论上等价
-
业务型「直到成功」逻辑写成尾递归/高阶函数 + 不可变状态,可显著降低心智负担
「把流程变成值」是函数式编程的核心做法
-
在C#中使用尾递归,需要谨慎,C#并不对尾递归进行优化
-
与 LINQ 链式调用一样,尾递归写法牺牲了少量运行时性能,换取了更高的可组合性与可读性——这正是函数式风格所追求的。
微信公众号: @scixing的炼丹炉
Bilibili: @无聊的年



被折叠的 条评论
为什么被折叠?



