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

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

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

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

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

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

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

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

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

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

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

...

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

这里我们以尾递归为例

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

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

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

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

  1. 初始状态

  2. 每轮迭代 / 递归需要传递的状态

  3. 终止条件

差别只在于:

• 循环把「下一轮状态」写在 for/while 头部;

• 尾递归把「下一轮状态」作为参数重新调用自身。

虽然就用途/实现上有所不同,但本质上,我们可以用尾递归可以完全模拟出循环的效果。

这听上去可能有些暴力,但也先无需着急。

事实上,这需要我们从想要用循环解决什么问题开始来思考。如果是一些非常计算密集的算法问题,直接使用命令式的循环当然会是更好的解决方案,他既方便,操作也更自由,效率也更好,能更好的实现我们的思路,因为我们就是要精准的控制每一次计算。

例如动态规划

cs 复制代码
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 场景。

cs 复制代码
int res = 0;
SomeWork obj = new SomeWork();
for (int i = 0; i < 10; i++)
{
    var r = obj.CallSth();
    if (r != -1)
    {
        res = r;
        break;
    }
}

在这个场景下,我们本质是要获取一个可用的结果或者资源,而不是一次高效的算法计算。这种情况下我们用循环反而添加了更多的代码噪音,和相对难理解一些的逻辑。

把它改写成尾递归后,意图就非常直白了:

cs 复制代码
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,我们还可以加上语义明确的返回值,这将使我们在编写中更容易的使用结果。

cs 复制代码
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);
}

这本质上是一种,直到结果可用的场景。

接下来我们可以再进一步再进一步:抽象为高阶函数

充足的复用价值意味着可以把「重试」抽象出来,令其与业务逻辑彻底解耦:

cs 复制代码
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

相关推荐
mqiqe17 分钟前
AgentScope Java 2.0 协议集成全景解析:A2A、AG-UI、Agent Protocol 三大开放协议实战指南
java·开发语言·ui
lzhdim20 分钟前
12、JavaScript常见的内存泄露问题 - JavaScript学习系列文章
开发语言·前端·javascript·学习·ecmascript
脉动数据行情23 分钟前
Java 实现台股 TWSE/TPEx 行情采集(个股 + K 线)
java·开发语言·twse·tpex·台股
爱学习的小邓同学33 分钟前
Golang --- (1)第一个Golang程序
开发语言·后端·golang
zx_741484811 小时前
【Python 入门】面向对象基础:类、对象、成员变量与构造方法
开发语言·python
惊鸿醉2 小时前
Unity 实战:用讯飞 WebAPI 做一个“说普通话、播方言“的语音程序
unity·c#·游戏引擎·语音识别
liangshanbo12153 小时前
虚拟列表深度面试题整理
java·开发语言·前端
ZISHU_9873 小时前
用 TLabel 给 SynTouch BioTac 数据做语义标注:从原始信号到结构化标注
开发语言·人工智能·python·数据·机器人触觉
略略略咯咯3 小时前
C#Unity
开发语言·unity·c#
gugucoding3 小时前
59. 【Java】Spring Boot 入门:第一个 Web 应用
java·开发语言·spring boot