函数式编程 第八讲 循环者,递归也

在先前的内容中,我们介绍了函数式编程中的独特形式的数据结构。

那么我们程序结构可以变的更函数式化吗?

让我们想一想,常用的程序中,我们常用的结构有哪些,他们本身偏向于什么风格?

首先,当然还是要回想起我们最重要的两个法则(表达式与不可变)

我们回想起,顺序,分支,循环.... 其中顺序自不必说, 分支(if)语句可以通过if表达式来一定程度的替代...(if表达式每一个分支块都将有一个返回值) 那么,循环。我们该怎么办,循环怎么做到有返回值?

我们仔细想一想,循环本质是什么?

以相对更复杂的for循环为例,

  1. 首先我们有循环的起始状态

  2. 然后有每一轮循环变化的状态

  3. 最后则是有退出循环的终止条件。

...

记住这些,接下来我们再看一种结构,递归

这里我们以尾递归为例

  1. 同样我们会有一个递归函数的初始的入参

  2. 然后我们会将这一轮的状态传至下一轮的递归调用

  3. 当然,良好的递归最后应该有一个适宜的退出条件。

从结构上看,二者都具备:  

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: @无聊的年

【函数式编程】(八)循环与递归_哔哩哔哩_bilibilihttps://www.bilibili.com/video/BV1t2y3BdEsd/?spm_id_from=333.1387.upload.video_card.click

图片

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值