C#中的函数式编程:递归与纯函数(二)

序言中,我们提到函数式编程的两大特征:无副作用、函数是第一公民。现在,我们先来深入第一个特征:无副作用。

无副作用是通过引用透明(Referential transparency)来定义的。如果一个表达式满足将它替换成它的值,而程序的行为不变,则称这个表达式是引用透明的。

现在,我们不妨进行一个尝试:我们来实现一些函数,但是这次有一个限制:只能用无副作用的表达式。

先以素数判定为例子,我们要写一个函数bool IsPrime(int n),它返回这个整数是不是素数。简单起见,我们采用最朴素的方法:依次检查2~n-1的整数,如果存在n的因子,则返回false,否则返回true.

这种问题的原始做法是使用循环,但是使用循环需要修改循环变量的值,从而产生副作用。

那怎么办了?有一个和循环关系紧密的概念——递归。递归不会改变变量的值,我们尝试用递归实现。

直接对IsPrime递归似乎不太可行,我们需要写一个辅助方法IsPrimeLoop。这个方法的参数除了n以外还有一个辅助参数acc,这个辅助参数起到类似循环变量的作用,它表示当前我们正在尝试的因子。

那这个函数要怎么实现呢?我们约定从小到大枚举整数,那么当acc == n时,循环就结束了,返回true。若acc != n,则循环继续。接着我们需要判断acc是不是n的因子,如果是,则n不是素数,返回false,否则继续递归循环。

借助这个辅助函数,我们只要调用IsPrimeLoop(n, 2)就可以判断了。代码如下:

private static bool IsPrimeLoop(int n, int acc) =>
    (acc == n) || (n % acc != 0 && IsPrimeLoop(n, acc + 1));
public static bool IsPrime(int n) =>
    n >= 2 && IsPrimeLoop(n, 2);

注意到,这里的辅助函数IsPrimeLoop是私有的,因为这个函数是专门供IsPrime调用的,它的访问范围应该限制在IsPrime内。在C#6及以前,这是做不到的,只能把它设定为类私有尽可能减小访问范围。在C#7,我们可以利用内部函数进一步完善。

public static bool IsPrime(int n)
{
    bool Loop(int acc) =>
        (acc == n) || (n % acc != 0 && Loop(acc + 1));

    return n >= 2 && Loop(2);
}

这时我们的Loop函数可以省略掉参数n,而且Loop的访问范围被限制在了IsPrime内。这样,我们就能在无副作用的前提下,实现素数的判定函数。

注意到,由于我们的IsPrime函数没有用到任何有副作用的表达式,所以,我们可以保证调用IsPrime也不会产生任何副作用。一般的,如果一个函数满足对它的调用一定是引用透明的,我们称这个函数为纯函数

下面我们来做一个练习,这里我需要你用递归实现阶乘函数int Fact(int n),当n>0时返回1*2*3*...*n的值,当n<=0时返回1,不考虑结果溢出的情况。你的实现不应该包含有副作用的表达式。

如果你完成了,请往下看。

下面我给出两个你可能的实现

public static int Fact(int n) =>
    n <= 0 ? 1 : n * Fact(n - 1);
public static int Fact(int n)
{
    int Loop(int acc, int result) =>
        acc > n ? result : Loop(acc + 1, result * acc);

    return Loop(1, 1);
}

当然,你的具体写法可能有所不同,但基本上可以归为两类。一类是像第一个那样,利用Fact(n)=n * Fact(n-1)进行递归;还有就是就像第二个那样,通过递归来让参数acc从1到n循环,并乘进一个结果变量result.

直观来看,第一个函数会更“递归”一点,而第二个函数则更像用递归实现的循环。为了进一步揭析这两个实现的区别,我们来手动展开一下两个版本的Fact(5)的递归过程:

版本一:

Fact(5) = 5 * Fact(4)

= 5 * 4 * Fact(3)

= 5 * 4 * 3 * Fact(2)

= 5 * 4 * 3 * 2 * Fact(1)

= 5 * 4 * 3 * 2 * 1 * Fact(0)

= 5 * 4 * 3 * 2 * 1 * 1

= 120

版本二:

Fact(5) = Loop(1, 1)

= Loop(2, 1)

= Loop(3, 2)

= Loop(4, 6)

= Loop(5, 24)

= Loop(6, 120)

= 120

发现没有?版本一的式子会逐渐变长,而版本二的式子长度则保持不变。这是因为,后者是尾递归。尾递归的定义为递归调用被立刻返回的递归。尾递归的特点是它理论上不需要额外的空间存储递归信息,就像我们展开式子那样,尾递归占用的空间是恒定的,而非尾递归调用则需额外的空间储存信息。事实上,尾递归和循环是等价的,因为尾递归可以想象成跳转到函数开头,只不过这个“跳转”是无副作用的。因此,我们可以用尾递归去实现循环,从而去除副作用。由于尾递归具有这种好处,我们通常尽可能的使用尾递归,只有在无法转换成尾递归,或者递归层数不大时,才使用非尾递归。

注意到我前面提到尾递归理论上不需要额外空间,但是很多语言在实现尾递归的时候会消耗栈空间的。比如JVM的尾递归会消耗栈空间,一些诸如Scala等编译到JVM的语言会将尾递归转换成循环从而防止栈溢出。但是C#编译器没有这个操作,那.NET在进行尾递归时会消耗栈空间吗?我们不妨来试一下。我的测试环境是.NET Core,使用之前定义的IsPrime函数,然后给它传入int.MaxValue,运行。

嗯,栈溢出了。

根据目前的实验结果,.NET在实现尾递归时会消耗栈空间。但是我用的是Debug模式,那切换到Release模式会怎样呢?

哈!没有溢出!

从上面实验可以看出,.NET Core在Debug模式下尾递归会消耗栈空间,Release模式不会。

因此,我们可以通过打开Release模式来避免尾递归产生栈溢出错误。

现在,递归相关的知识已经介绍完了。现在我们来讲讲递归的价值。

有的人觉得既然循环可以解决问题,那就没必要花时间去学什么递归;而有的人则觉得循环是魔鬼的,都应该改成递归。事实上,这两种极端的想法都是错误的。

递归的价值在于它能保证你写的函数是纯函数,从而降低一些意外的副作用产生的可能性。还记得序言的那个例子吗?那个程序就可以用尾递归实现来避免bug的产生。

当然,如果你要我写一个阶乘算法,或者写一个素数判断算法,我肯定用for循环。因为这个函数足够简单,我有自信做到,即使我的函数产生了副作用,但是这个副作用只是局部的,整个函数还是纯的函数。

但是,当程序复杂时,尤其是产生闭包时,这些副作用会比较隐晦,此时,使用尾递归能降低代码出错的几率。

尾递归还有一种好处:它能减少代码逻辑上的复杂性。我见过有一些好几重循环嵌套的程序,循环变量之间还相互依赖,逻辑非常复杂。但是,如果你把它改成尾递归,你就需要将循环转为一个或多个递归函数,从而使得逻辑结构更加的清晰。

最后,用一句话总结,递归应该减少你的负担,而不是成为你的负担

习题:

一、用尾递归改写序言中提到的副作用产生bug的例子。

二、对于斐波那契数列数列fib(n)定义为:当n<=2时,fib(n)=1;当n>2时,fib(n)=fib(n-1)+fib(n-2)。分别用尾递归和非尾递归实现fib,并比较两个实现的效率差异。你能解释其中的原因吗?

原文地址:https://www.cnblogs.com/dhlee/p/functional-csharp-2.html

时间: 2024-08-07 16:18:11

C#中的函数式编程:递归与纯函数(二)的相关文章

Javascript 中的函数式编程

本文和大家分享的主要是javascript中函数式编程相关内容,一起来看看吧,希望对大家学习javascript有所帮助. 函数式编程(functional programming)或称函数程序设计,又称泛函编程,是一种编程范型,比起命令式编程,函数式编程更加强调程序执行的结果而非执行的过程,倡导利用若干简单的执行单元让计算结果不断渐进,逐层推导复杂的运算,而不是设计一个复杂的执行过程. 函数式编程,近年来一直被炒得火热,国内外的开发者好像都在议论和提倡这种编程范式.在众多的函数式语言中,Jav

python中的函数式编程与装饰器

2.1 python中的函数式编程 函数式编码的特点 把计算视为函数而非指令 纯函数式编程,不需要变量,没有副作用,测试简单 支持高阶函数,代码简洁 python支持的函数式编程 不是纯函数式编码:允许有变量 支持高阶函数:函数也可以作为变量传入 支持闭包:有了闭包就能返回函数 有限度地支持匿名函数 2.2 python中高阶函数 函数名可以作为变量,如 高阶函数:只能接收函数作为参数的函数 变量可以是指向函数 函数的参数可以接收变量 一个函数可以接收另一个函数作为参数 能接收函数作为参数的函数

97. 在LotusScript中模拟函数式编程

本文将介绍96. 通用字段修改器用到的在LotusScript中模拟函数式编程的技巧. 函数式编程 函数式编程是一种优美而强大的编程范式.它源于Alonzo Church提出的λ演算(Lambda演算),而某个问题能表示成Lambda演算,按照Church–Turing论题,等价于该问题在数学上是可以有效计算的.粗略地说,用对应于Lambda演算的一门函数式程序语言可以写出任何理论上可计算问题的计算程序.因为与可计算理论的紧密关系和强大的表现力,函数式编程在学术界历来很受重视.但是在日常应用的软

Scala 中的函数式编程基础(二)

主要来自 Scala 语言发明人 Martin Odersky 教授的 Coursera 课程 <Functional Programming Principles in Scala>. 2. Higher Order Functions 把其他函数作为参数或者作为返回值,就是 higher order functions,python 里面也可以看到这样使用的情形.在酷壳上的博客有一个例子就是将函数作为返回值. 2.1 匿名函数 在 python 里边叫 lambda 函数,常常与 map(

JS函数式编程【译】4.在Javascript中实现函数式编程的技术

?? Functional Programming in Javascript 主目录上一章 建立函数式编程环境 第四章 在Javascript中实现函数式编程的技术 扶好你的帽子,我们现在要真正进入函数式的思想了. 这章我们继续下面的内容: 把所有的核心概念放到一个集中的范式里 探索函数式编程之美 一步步跟踪函数式模式相互交织的逻辑 我们将贯穿整章建立一个简单的应用做一些很酷的事情 你可能已经注意到,在上一章我们介绍Javascript的函数式库的时候引入了一些概念, 而不是在第二章<函数式编

函数式编程和高阶函数

函数式编程 函数式编程就是一种抽象程度很高的编程范式,纯粹的函数式编程语言编写的函数没有变量,因此,任意一个函数,只要输入是确定的,输出就是确定的,这种纯函数我们称之为没有副作用.而允许使用变量的程序设计语言,由于函数内部的变量状态不确定,同样的输入,可能得到不同的输出,因此,这种函数是有副作用的. 函数式编程的一个特点就是,允许把函数本身作为参数传入另一个函数,还允许返回一个函数! Python对函数式编程提供部分支持.由于Python允许使用变量,因此,Python不是纯函数式编程语言. 一

函数(作用域,匿名函数,函数式编程,高阶函数)

一.函数作用域 1.函数名表示的是内存地址 1 def test1(): 2 print('in the test1') 3 def test(): 4 print('in the test') 5 return test1 6 7 print(test()) 打印:in the test<function test1 at 0x000001E90E452EA0> 2.函数的作用域只跟函数声明时定义的作用域有关,跟函数的调用位置无任何关系 1 name = 'xiaopang' 2 def f

[原创]ActionScript3游戏中的图像编程(连载三十二)

2.2.5 投影距离的模拟 Photoshop投影样式面板的下一个属性是距离,它也存在于Flash的投影滤镜选项中.两者初始值一致,经笔者测试,两者在效果实现和数值意义方面基本一致.Flash不需要对默认参数进行更改. 下一项是扩展,乍一看,在Flash中并没有找到对应项.但仔细观察,在Photoshop投影样式的基础选项里,除了alpha以外,就只剩该属性用了百分比. [原创]ActionScript3游戏中的图像编程(连载三十二),布布扣,bubuko.com

ActionScript3游戏中的图像编程(连载四十二)

2.3.4 Photoshop高度==Flash距离? 剩下高度一项了,跟距离相对应吗? 但是,高度以角度为单位,但距离却是像素,似乎拉不上关系.不过我们照样做下试验:先试一下Photoshop的高度.在调整的过程里发现,浮雕的厚度并没随着高度的增加而变大.只感觉到光影往某个方向微妙地移动着. 图 2.69~图 2.75展示了不同高度下的效果. 图 2.69 高度=0° 图 2.70 高度=15° 图 2.71 高度=30° 图 2.72 高度=45° 图 2.73 高度=60° 图 2.74