下载安卓APP箭头
箭头给我发消息

客服QQ:3315713922

IT业界:拥抱函数式编程吧,用得非常爽

作者:课课家教育     来源: 今日头条点击数:735发布时间: 2020-05-03 19:15:49

标签: IT业界编程语言函数式编程

软考,您想通过吗?一次通过才是硬道理

函数式编程是种编程典范,它将电脑运算视为函数的计算。函数编程语言最重要的基础是 λ 演算(lambda calculus)。而且λ演算的函数可以接受函数当作输入(参数)和输出(返回值)。和指令式编程相比,函数式编程强调函数的计算比指令的执行重要。和过程化编程相比,函数式编程里,函数的计算可随时调用。

拥抱函数式编程吧,用得非常爽!

 IT业界:IT业界:拥抱函数式编程吧,用得非常爽_IT业界_编程语言_函数式编程_课课家 

 

几十年来我都在用面向对象的语言编程。我用过的第一个面向对象的语言是 C++,后来是 Smalltalk,最后是 .NET 和 java

 

我曾经对使用继承、封装和多态充满热情。它们是范式的三大支柱。

 

我渴望实现重用之美,并在这个令人兴奋的新天地中享受前辈们积累的智慧。

 

想到将现实世界的一切映射到类中,使得整个世界都可以得到整齐的规划,我无法抑制自己的兴奋。

 

然而我大错特错了。

01 继承,倒塌的第一根支柱

 

乍一看,继承似乎是面向对象范式的最大优势。所有新手教程讲解继承时都会拿出最简单的继承的例子,而这个例子似乎很符合逻辑。

 

然后就是满篇的重用了。甚至以后的一切都是重用了。

 

我囫囵吞下这一切,然后带着新发现兴冲冲地奔向世界了。

 

1. 香蕉猴子丛林问题

 

带着满腔的信仰和解决问题的热情,我开始构建类的层次结构然后写代码。似乎一切皆在掌控中。

 

我永远不会忘记我准备从已有的类继承并实现重用的那一天。那是我期待已久的时刻。

 

后来有了新的项目,我想起了另一个项目里我很喜欢的那个类。

 

没问题,重用拯救一切。我只需要把那个类拿过来用就好了。

 

嗯……其实……不仅是那一个类。还得把父类也拿过来。但……应该就可以了吧。

 

额……不对,似乎还需要父类的父类……还有……嗯,我们需要所有的祖先类。好吧好吧……搞定了。没问题。

 

不错。但编译不过,怎么回事?哦我知道了……这个对象还需要另一个对象。所以那个也得拿过来。没问题……

 

等等……我不仅需要那个对象,还需要那个对象的父类,和父类的父类,和……包含的所有对象的所有祖先……

 

唉……

 

Erlang 的创建者 Joe Armstrong 有句名言:

 

面向对象语言的问题在于,它们依赖于特定的环境。你想要个香蕉,但拿到的却是拿着香蕉的猩猩,乃至最后你拥有了整片丛林。

 

2. 香蕉猴子丛林的解决方法

 

这个问题的解决方法是,不要把类层次建得那么深。但如果继承是重用的关键,那么给继承机制添加的任何限制都会限制重用。对吧?

 

没错。

 

那我们可怜的面向对象程序员该怎么办?指望一杯三聚氰胺奶维系我们的健康吗?

 

答案就是:包含和委托(Contain and Delegate)。一会儿会详细解释。

 

3. 菱形继承问题

 

早晚你会遇到下面这种恶心的问题,有些语言甚至根本解决不了。

  

 

大多数面向对象语言都不支持这种情况,尽管看上去似乎很符合逻辑。为什么面向对象语言支持这种情况如此困难?

 

来看看下面的伪代码:

 

Class PoweredDevice {

 

}

 

Class Scanner inherits from PoweredDevice {

 

function start() {

 

}

 

}

 

Class Printer inherits from PoweredDevice {

 

function start() {

 

}

 

}

 

Class Copier inherits from Scanner, Printer {

 

}

 

注意 Scanner 和 Printer 类都实现了名为 start 方法。

 

那么问题来了,Copier继承哪个start?是Scanner的还是Printer的?肯定不可能同时继承啊。

 

4. 菱形继承的解决

 

解决方案很简单:不要这样做。

 

没错。大多数面向对象都不让你这么干。

 

但是,但是……要是必须这样建模该怎么办?我需要重用!

 

那就必须使用包含和委托。

 

Class PoweredDevice {

 

}

 

Class Scanner inherits from PoweredDevice {

 

function start() {

 

}

 

}

 

Class Printer inherits from PoweredDevice {

 

function start() {

 

}

 

}

 

Class Copier {

 

Scanner scanner

 

Printer printer

 

function start() {

 

printer.start()

 

}

 

}

 

注意现在 Copier 类包含一个 Printer 实例和一个 Scanner 实例。然后将 start 函数委托给 Printer 类的实现。要委托给 Scanner 也很简单。

 

这个问题是继承这根支柱上的另一条裂缝。

 

5. 脆弱的基类问题

 

好吧,那我尽量使用较浅的类层次结构,并保证里面没有环,这样就不会出现菱形继承了。

 

似乎一切都解决了。直到我们发现……

 

我前一天工作得好好的代码今天出错了!关键是,我没有改任何代码!

 

嗯也许是个 bug……但等等……的确有些改动……

 

但改动的不是我的代码。似乎改动来自我继承的那个类。

 

为什么基类的改动会破坏我的代码?

 

原来是这样……

 

看看下面这个基类(用Java写的,但就算你不懂Java,应该也很容易看懂):

 

import java.util.ArrayList;

 

public class Array

 

{

 

private ArrayList a = new ArrayList();

 

public void add(Object element)

 

{

 

a.add(element);

 

}

 

public void addAll(Object elements[])

 

{

 

for (int i = 0; i < elements.length; ++i)

 

a.add(elements[i]); // this line is going to be changed

 

}

 

}

 

重要提示:注意加了注释的那一行。稍后这行的改动将会导致别的东西出错。

 

这个类的接口上有两个函数:add() 和 addAll()。add() 函数负责添加一个元素,addAll() 函数会调用 add 函数添加多个元素。

 

下面是继承的类:

 

public class ArrayCount extends Array

 

{

 

private int count = 0;

 

@Override

 

public void add(Object element)

 

{

 

super.add(element);

 

++count;

 

}

 

@Override

 

public void addAll(Object elements[])

 

{

 

super.addAll(elements);

 

count += elements.length;

 

}

 

}

 

ArrayCount类是通用的Array类的特化。两者行为上的唯一区别就是ArrayCount会维护一个count,记录元素的个数。

 

我们来仔细看看这两个类。

 

Array的add()给局部的ArrayList添加一个元素。

 

Array的addAll()针对每个元素调用局部的ArrayList的add方法。

 

ArrayCount的add()调用父类的add()然后增加count。

 

ArrayCount的addAll()调用父类的addAll()然后给count增加相当于元素个数的数。

 

一切都很正常。

 

现在是出问题的地方。基类中加注释的那行代码现在改成这样:

 

public void addAll(Object elements[])

 

{

 

for (int i = 0; i < elements.length; ++i)

 

add(elements[i]); // this line was changed

 

}

 

从基类的作者的角度来看,这个类实现的功能完全没有变化。而且所有自动化测试也都通过来了。

 

但是基类的作者忘记了继承的类。而继承类的作者被错误吵醒了。

 

现在ArrayCount的addAll()调用父类的addAll(),后者在内部调用add(),而add()被继承类重载了。

 

因此,每次继承类的add()被调用时,count都会增加,然后在继承类的addAll()被调用时再次增加。面试官问线程安全的List,看完再也不怕了,推荐大家阅读。

 

count被增加了两次。

 

既然会发生这种现象,那么继承类的作者必须清楚基类是怎样实现的。而且,基类的每个改动必须要通知所有继承类的作者,因为这些改动可能会以不可预知的方式破坏继承类。

 

唉!这个巨大的裂隙威胁到了整个继承支柱的稳定。关注微信公众号:Java技术栈,在后台回复:java,可以获取我整理的 N 篇最新 Java 技术教程,都是干货。

 

6. 脆弱的基类的解决方法

 

这个问题还得要包含和委托来解决。

 

使用包含和委托,可以从白盒编程转到黑盒编程。白盒编程的意思是说,写继承类时必须要了解基类的实现。

 

而黑盒编程可以完全无视基类的实现,因为不可能通过重载函数的方式向基类注入代码。只需要关注接口即可。

 

这种趋势太讨厌了……

 

继承本应带来最好用的重用。

 

在面向对象语言中实现包含和委托并不容易。它们是为了继承方便而设计的。

 

如果你和我一样,你就会开始反思这个继承了。但更重要的是,这些问题应当引起你对于通过层次结构进行分类的反思。

 

7. 层次结构的问题

 

每到一个新公司时,我都要为在哪儿保存公司文档(即员工手册)而纠结。

 

是应该建一个Documents文件夹,然后在里面建个Company呢?

 

还是应该建个Company文件夹,然后在里面建个Documents呢?

 

两者都可以。但哪个是正确的?哪个更好?

 

层次分类的思想是因为基类(父类)更通用,继承类(子类)更专用。沿着继承链越往下走,概念就越专用(见上面的形状层次)。

 

但如果父节点和子节点能随意交换位置,那么显然这种模型是有问题的。

 

8. 层次结构的解决

 

真正的问题出在……

 

层次分类是错误的。

 

那层次分类应该用在哪里?

 

包含关系。

 

真实世界里有很多包含关系(或者叫做独占关系)的层次结构。

 

但你找不到层次分类。仔细想一下。面向对象范式是根据充满了各种对象的真实世界建立的。但它用错了模型——层次分类在真实世界中没有类比。

 

但真实世界里到处都是层次包含关系。层次包含关系的一个非常好的例子就是你的袜子。袜子放在装袜子的抽屉里,然后抽屉包含在衣柜里,衣柜包含在卧室里,卧室包含在房子里,等等。

 

硬盘上的目录也是层次包含关系的另一个例子——它们包含文件。

 

那我们该怎样分类呢?

 

仔细想一下公司文档,就会发现其实放在哪儿都无所谓。我可以放在Documents目录下或者放在Stuff目录下也可以。

 

我选择的分类法是标签。我给它加上不同的标签。

 

Document

 

Company

 

Handbook

 

标签是没有顺序或层次的(这同时解决了菱形继承问题)。

 

标签可以类比为接口,因为同一份文档可以有多种类型。

 

但既然有了这么多裂缝,估计继承的支柱已经倒塌了。

 

再见,继承。

 

02 封装,倒塌的第二根支柱

乍一看,封装似乎是面向对象编程的第二大好处。

 

对象状态变量被保护起来防止外部访问,即它们被封装在对象内部。

 

我们不需要再操心那些可能被不知道谁访问的全局变量。

 

封装是变量的保险柜。

 

封装太伟大了!

 

封装万岁……

 

直到你遇到了这个问题……

 

1. 引用问题

 

为了提高效率,对象传递给函数时传递的是引用,而不是值。

 

也就是说,函数不会传递对象本身,而是传递指向对象的一个引用或指针。

 

如果一个对象的引用被传递给另一个对象的构造函数,构造函数就能将这个对象引用放到私有变量中,用封装保护起来。

 

但这个传递的对象不是安全的!

 

为什么不是?因为其他代码也可能拥有指向该对象的指针,比如调用构造函数的那段代码。它必须有指向对象的引用,否则没办法传递给构造函数。

 

2. 引用的解决

 

构造函数必须要复制传递过来的对象。而且不能是浅复制,必须是深复制,即传入的对象内包含的所有对象和所有对象中包含的所有对象……都必须要复制。

 

完全没有效率。

 

而且更糟糕的是,并非所有对象都能复制的。一些拥有操作系统资源的对象,最好的情况是复制无效,最糟糕的情况是根本不可能复制。

 

所有主流面向对象语言都有这个问题。

 

再见,封装。

 

03 多态,倒塌的第三根支柱

 

多态是面向对象的三位一体中永远被人抛弃的那一位。

 

就像是三人组中的Larry Fine。

 

不管他们去哪儿都会带着他,但他永远是配角。

 

并不是因为多态不好,而是因为实现多态并不需要面向对象语言。

 

接口也能实现多态,而且不需要面向对象的负担。

 

而且,接口也不会限制你能混入的不同行为的数目。

 

所以,无需多言,我们可以告别面向对象的多态,去迎接基于接口的多态吧。

 

所谓技多不压身,我们所读过的每一本书,所学过的每一门语言,在未来指不定都能给我们意想不到的回馈呢。其实做为一个开发者,有一个学习的氛围跟一个交流圈子特别重要这里我推荐一个Java学习交流群私信小编获得进群资料,不管你是小白还是大牛欢迎入驻,大家一起交流成长。

虽然 λ 演算并非设计来于计算机上执行,但可视为第一个函数式编程语言。1980年代末期,Haskell发布,企图集合很多函数式编程研究里的想法。

速度和空间上的顾虑

函数式编程常被认为严重耗费在CPU和内存资源。主因有二:

早期的函数式编程语言实现时并无考虑过效率问题。

非函数式编程语言为求提升速度,会在某些部分放弃边界检查或垃圾回收等功能。

缓式评估亦为语言如Haskell增加了额外的管理工作。

函数式编程语言

纯函数式的程序没有变量和副作用。

函数式编程经常使用递归。

纯函数式编程语言

因为纯函数式程式设计语言没有变量,函数没有副作用,编写出的程式可以利用memorization、common subexpression elimination和平行计算在运行时和编译时得到大量优化。