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

无副作用是通过引用透明(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 != && IsPrimeLoop(n, acc + ));
public static bool IsPrime(int n) =>
n >= && IsPrimeLoop(n, );

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

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

这时我们的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 <= ? : n * Fact(n - );
public static int Fact(int n)
{
int Loop(int acc, int result) =>
acc > n ? result : Loop(acc + , result * acc); return Loop(, );
}

当然,你的具体写法可能有所不同,但基本上可以归为两类。一类是像第一个那样,利用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,并比较两个实现的效率差异。你能解释其中的原因吗?

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

  1. C#中的函数式编程:递归与纯函数(二) 学习ASP.NET Core Razor 编程系列四——Asp.Net Core Razor列表模板页面

    C#中的函数式编程:递归与纯函数(二)   在序言中,我们提到函数式编程的两大特征:无副作用.函数是第一公民.现在,我们先来深入第一个特征:无副作用. 无副作用是通过引用透明(Referential ...

  2. 可爱的 Python : Python中的函数式编程,第三部分

    英文原文:Charming Python: Functional programming in Python, Part 3,翻译:开源中国 摘要:  作者David Mertz在其文章<可爱的 ...

  3. Java 中的函数式编程(Functional Programming):Lambda 初识

    Java 8 发布带来的一个主要特性就是对函数式编程的支持. 而 Lambda 表达式就是一个新的并且很重要的一个概念. 它提供了一个简单并且很简洁的编码方式. 首先从几个简单的 Lambda 表达式 ...

  4. C#中的函数式编程:序言(一)

    学了那么久的函数式编程语言,一直想写一些相关的文章.经过一段时间的考虑,我决定开这个坑. 至于为什么选择C#,在我看来,编程语言分三类:一类是难以进行函数式编程的语言,这类语言包括Java6.C语言等 ...

  5. (数据科学学习手札48)Scala中的函数式编程

    一.简介 Scala作为一门函数式编程与面向对象完美结合的语言,函数式编程部分也有其独到之处,本文就将针对Scala中关于函数式编程的一些常用基本内容进行介绍: 二.在Scala中定义函数 2.1 定 ...

  6. Apache Beam中的函数式编程理念

    不多说,直接上干货! Apache Beam中的函数式编程理念 Apache Beam的编程范式借鉴了函数式编程的概念,从工程和实现角度向命令式妥协. 编程的领域里有三大流派:函数式.命令式.逻辑式. ...

  7. C#中面向对象编程中的函数式编程详解

    介绍 使用函数式编程来丰富面向对象编程的想法是陈旧的.将函数编程功能添加到面向对象的语言中会带来面向对象编程设计的好处. 一些旧的和不太老的语言,具有函数式编程和面向对象的编程: 例如,Smallta ...

  8. Java中的函数式编程(二)函数式接口Functional Interface

    写在前面 前面说过,判断一门语言是否支持函数式编程,一个重要的判断标准就是:它是否将函数看做是"第一等公民(first-class citizens)".函数是"第一等公 ...

  9. 小白的Python之路 day3 函数式编程,高阶函数

    函数式编程介绍   函数是Python内建支持的一种封装,我们通过把大段代码拆成函数,通过一层一层的函数调用,就可以把复杂任务分解成简单的任务,这种分解可以称之为面向过程的程序设计.函数就是面向过程的 ...

  10. Learning Python 012 函数式编程 1 高阶函数

    Python 函数式编程 1 高阶函数 高阶函数 Q:什么是高阶函数? A:一个函数接收另一个函数作为参数,这种函数就称之为高阶函数. 简单举个例子: def add(x, y, f): return ...

随机推荐

  1. Mybatis【一对多、多对一、多对多】知识要点

    Mybatis[多表连接] 我们在学习Hibernate的时候,如果表涉及到两张的话,那么我们是在映射文件中使用<set>..<many-to-one>等标签将其的映射属性关联 ...

  2. mysql mariadb 删除表中的数据时数据库变大

    删除表中数据以前 [root@RM uar3]# du -sh * 3.3G apache-tomcat-7.0.54 150M instalRM4UAR 0 mariadb 903M mariadb ...

  3. iOS学习——UIPickerView的实现年月选择器

    最近项目上需要用到一个选择器,选择器中的内容只有年和月,而在iOS系统自带的日期选择器UIDatePicker中却只有四个选项如下,分别是时间(时分秒).日期(年月日).日期+时间(年月日时分)以及倒 ...

  4. error:安装手电筒程序后在打开程序后报错:你的camera flashlight正在被其他程序占据

    Q:没打开权限 A:设置里面,把这个app的camera权限打开就行了

  5. css学习の第五弹—单位和值

    一. >>1.颜色表示方法总结: 1.英文命令颜色 前面几个小节中经常用到的就是这种设置方法: p{color:red;} 2.RGB颜色 这个与 photoshop 中的 RGB 颜色是 ...

  6. java线程学习第一天__低配版的卖面包机

    package Thread;import javax.xml.bind.ValidationEvent;class snacks{    private int  SaledSnacks=0;   ...

  7. WCF服务寄宿到IIS

    一.WCF简介: Windows Communication Foundation(WCF)是由微软开发的一系列支持数据通信的应用程序框架,可以翻译为Windows 通讯开发平台.整合了原有的wind ...

  8. PHP实现发送模板消息到微信公众号

    简述:在这里会具体讲述到如何实现:如何通过后台的代码来实现发送模板消息到已经关注了"心想"公众号的用户. (本人新手,目前实习中,我的所有文档都是在自己开发过程中的记录,有些言语跟 ...

  9. 使用openssl演练数字签名

    以下代码摘自网上,设置一个server和client,client代码如下: package main import (    "fmt"    "io/ioutil&q ...

  10. Harris Corner

    Harris Corner网上已经有很多的资料了,但它也是我读研究生后读的第一篇论文,对我有一种特别的意义. 这篇文章我想从几个方面来讲解Harris Corner,一是Harris Corner的思 ...