mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-19 15:43:44 +08:00
mod
This commit is contained in:
118
极客时间专栏/软件设计之美/设计一个软件—编程范式/12 | 编程范式:明明写的是Java,为什么被人说成了C代码?.md
Normal file
118
极客时间专栏/软件设计之美/设计一个软件—编程范式/12 | 编程范式:明明写的是Java,为什么被人说成了C代码?.md
Normal file
@@ -0,0 +1,118 @@
|
||||
<audio id="audio" title="12 | 编程范式:明明写的是Java,为什么被人说成了C代码?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e8/33/e8692dd5903a84d1c6061b1c4f161f33.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
在上一个小模块,我给你讲了程序设计语言,帮助你重新审视一下自己最熟悉的日常工具。但是,使用程序设计语言是每个程序员都能做到的,可写出的程序却是千差万别的。这一讲,我们就来看看这些差异到底是怎样造成的。
|
||||
|
||||
在开始之前,我先给你讲一个小故事。
|
||||
|
||||
在一次代码评审中,小李兴致勃勃地给大家讲解自己用心编写的一段代码。这段代码不仅实现了业务功能,还考虑了许多异常场景。所以,面对同事们提出的各种问题,小李能够应对自如。
|
||||
|
||||
在讲解的过程中,小李看到同事们纷纷点头赞许,心中不由得生出一丝骄傲:我终于写出一段拿得出手的代码了!讲解完毕,久久未曾发言的技术负责人老赵站了起来:“小李啊!你这段代码从功能上来说,考虑得已经很全面了,这段时间你确实进步很大啊!”
|
||||
|
||||
要知道,老赵的功力之深是全公司人所共知的。能得到老赵的肯定,对小李来说,那简直是莫大的荣耀。还没等小李窃喜的劲过去,老赵接着说了,“但是啊,写代码不能只考虑功能,你看你这代码写的,虽然用的是Java,但写出来的简直就是C代码。”
|
||||
|
||||
正在兴头上的小李仿佛被人当头泼了一盆冷水,我用的是Java啊!一门正经八百的面向对象程序设计语言,咋就被说成写的是C代码了呢?
|
||||
|
||||
“你看啊!所有的代码都是把字段取出来计算,然后,再塞回去。各种不同层面的业务计算混在一起,将来有一点调整,所有的代码都得跟着变。”老赵很不客气地说。还没缓过神来的小李虽然想辩解,但他知道老赵说得是一针见血,指出的问题让人无法反驳。
|
||||
|
||||
在实际的开发过程中,有不少人遇到过类似的问题。老赵的意思并不是小李的代码真就成了C代码,而是说用Java写的代码应该有Java的风格,而小李的代码却处处体现着C的风格。
|
||||
|
||||
那这里所谓代码的风格到底是什么呢?它就是编程范式。
|
||||
|
||||
## 编程范式
|
||||
|
||||
编程范式(Programming paradigm),指的是程序的编写模式。使用了什么编程范式,通常意味着,你主要使用的是什么样的代码结构。从设计的角度说,编程范式决定了你在设计的时候,可以使用的元素有哪些。
|
||||
|
||||
现在主流的编程范式主要有三种:
|
||||
|
||||
- 结构化编程(structured programming);
|
||||
- 面向对象编程(object-oriented programming);
|
||||
- 函数式编程(functional programming)。
|
||||
|
||||
**结构化编程**,是大部分程序员最熟悉的编程范式,它通过一些结构化的控制结构进行程序的构建。你最熟悉的控制结构应该就是if/else这样的选择结构和do/while这样的循环结构了。
|
||||
|
||||
结构化编程是最早普及的编程范式,现在最典型的结构化编程语言是C语言。C语言控制结构的影响极其深远,成为了很多程序设计语言的基础。
|
||||
|
||||
**面向对象编程**,是现在最主流的编程范式,它的核心概念就是对象。用面向对象风格写出的程序,本质上就是一堆对象之间的交互。面向对象编程给我们提供了一种管理程序复杂性的方式,其中最重要的概念就是多态(polymorphism)。
|
||||
|
||||
现在主流的程序设计语言几乎都提供面向对象编程能力,其中最典型的代表当属Java。
|
||||
|
||||
**函数式编程**,是近些年重新崛起的编程范式。顾名思义,它的核心概念是函数。但是,它的函数来自于数学里面的函数,所以,和我们常规理解的函数有一个极大的不同:不变性。也就是说,一个符号一旦创建就不再改变。
|
||||
|
||||
函数式编程的代表性语言应该是LISP。我们在[第8讲](https://time.geekbang.org/column/article/245868)曾经提到过它。之所以要把这位老祖宗搬出来,因为确实还没有哪门函数式编程语言能够完全独霸一方。
|
||||
|
||||
编程范式不仅仅是提供了一个个的概念,更重要的是,它对程序员的能力施加了约束。
|
||||
|
||||
- 结构化编程,限制使用goto语句,它是对程序控制权的**直接**转移施加了约束。
|
||||
- 面向对象编程,限制使用函数指针,它是对程序控制权的**间接**转移施加了约束。
|
||||
- 函数式编程,限制使用赋值语句,它是对程序中的**赋值**施加了约束。
|
||||
|
||||
之后讲到具体的编程范式时,我们再来展开讨论,这些约束到底是什么意思。
|
||||
|
||||
与其说这些编程范式是告诉你如何编写程序,倒不如说它们告诉你**不要**怎样做。理解这一点,你才算是真正理解了这些编程范式。
|
||||
|
||||
如果你去搜索编程范式的概念,你可能会找到更多的编程范式,比如,逻辑式编程,典型的代表是Prolog语言。但这些编程范式的影响力和受众面都相当有限。如果你想扩展自己的知识面,可以去了解一下。
|
||||
|
||||
## 多范式编程
|
||||
|
||||
从道理上讲,编程范式与具体语言的关系不大,这就好比你的思考与用什么语言表达是无关的。但在实际情况中,每一种语言都有自己的主流编程范式。比如,C语言主要是结构化编程,而 Java主要是面向对象编程。
|
||||
|
||||
不过,虽然每种语言都有自己的主流编程范式,但丝毫不妨碍程序员们在学习多种编程范式之后,打破“次元壁”,将不同编程范式中的优秀元素吸纳进来。这里的重点是“优秀”,而非“所有”。
|
||||
|
||||
举个例子,在Linux的设计中,有一个虚拟文件系统(Virtual File System,简称 VFS)的概念,你可以把它理解成一个文件系统的接口。在所有的接口中,其中最主要的是file_operations,它就对应着我们熟悉的各种文件操作。
|
||||
|
||||
下面是这个[结构的定义](https://github.com/torvalds/linux/blob/master/include/linux/fs.h),这个结构很长,我从中截取了一些我们最熟悉的操作:
|
||||
|
||||
```
|
||||
struct file_operations {
|
||||
loff_t (*llseek) (struct file *, loff_t, int);
|
||||
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
|
||||
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
|
||||
int (*open) (struct inode *, struct file *);
|
||||
int (*flush) (struct file *, fl_owner_t id);
|
||||
int (*release) (struct inode *, struct file *);
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果你要开发一个自己的文件系统,只需要把支持的接口对应着实现一遍,也就是给这个结构体的字段赋值。
|
||||
|
||||
我们换个角度看,这个结构体主要的字段都是函数指针,文件系统展现的行为与这些函数的赋值息息相关。只要给这个结构体的字段赋值成不同的参数,也就是把不同的函数关联上,这个文件系统就有了不同的行为。如果熟悉面向对象编程,你会发现,这不就是多态吗?
|
||||
|
||||
C是一门典型的结构化编程语言,而VFS的设计展现出来的却是面向对象编程的特点,编程范式的“次元壁”在这里被打破了。
|
||||
|
||||
事实上,类似的设计还有很多,比如,Java里有一个著名的基础库,Google出的Guava。它里面就提供了函数式编程的基础设施。在Java 8之前,Java在语法上并不支持函数式编程,但这并不妨碍我们通过类模拟出函数。
|
||||
|
||||
配合着Guava提供的基础设施,我很早就开始把函数式编程的方式运用在Java中了。同样,C++有一个functor的概念,也就是函数对象,通过重载 () 这个运算符,让对象模拟函数的行为。
|
||||
|
||||
无论是在以结构化编程为主的语言中引入面向对象编程,还是在面向对象为主的语言中引入函数式编程,在一个程序中应用多种编程范式已经成为了一个越来越明显的趋势。
|
||||
|
||||
不仅仅是在设计中,现在越来越多的程序设计语言开始将不同编程范式的内容融合起来。Java从Java 8开始引入了Lambda语法,现在我们可以更优雅地写出函数式编程的代码了。同样,C++ 11开始,语法上也开始支持Lambda了。
|
||||
|
||||
之所以多范式编程会越来越多,是因为我们的关注点是做出好的设计,写出更容易维护的代码,所以,我们会尝试着把不同编程风格中优秀的元素放在一起。比如,**我们采用面向对象来组织程序,而在每个类具体的接口设计上,采用函数式编程的风格,在具体的实现中使用结构化编程提供的控制结构**。
|
||||
|
||||
让我们回过头,看看开篇故事小李的委屈吧!老赵之所以批评小李,关键点就是小李并没有把各种编程范式中优秀的元素放到一起。Java是提供对面向对象的支持,面向对象的强项在于程序的组织,它归功的设计元素应该是对象,程序应该是靠对象的组合来完成,而小李去把它写成了平铺直叙的结构化代码,这当然是不值得鼓励的。
|
||||
|
||||
对于今天的程序员来说,**学习不同的编程范式,将不同编程范式中的优秀元素应用在我们日常的软件设计之中,已经由原来的可选项变成了现在的必选项**。否则,你即便拥有强大的现代化武器,也只能用作古代的冷兵器。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们今天讨论了编程范式。编程范式指的是程序的编写模式。现在主流的编程范式主要有三种:结构化编程、面向对象编程和函数式编程。编程范式对程序员的能力施加了约束,理解编程范式的一个关键点在于,**哪些事情不要做**。
|
||||
|
||||
从道理上讲,编程范式与具体语言的关系不大,但很多语言都有着自己主流的编程范式。但现在的一个趋势是,打破编程范式的“次元壁”,把不同编程范式中优秀的元素放在一起。
|
||||
|
||||
一方面,我们可以通过设计,模拟出其他编程范式中的元素,另一方面,程序设计语言的发展趋势也是要融合不同编程范式中优秀的元素。学习不同的编程范式,已经成为每个程序员的必修课。
|
||||
|
||||
在接下来的几讲里,我们就来深入地讨论一下各种编程范式。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**学习不同的编程范式,将其中优秀的元素运用在日常工作中**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5b/eb/5b70cc56084dca6bfd966d0259f03ceb.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
今天我们谈到了编程范式,每个程序员都会有自己特别熟悉的编程范式,但今天我想请你分享一下,你在学习其他编程范式时,给你思想上带来最大冲击的内容是什么。欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
113
极客时间专栏/软件设计之美/设计一个软件—编程范式/13 | 结构化编程:为什么做设计时仅有结构化编程是不够的?.md
Normal file
113
极客时间专栏/软件设计之美/设计一个软件—编程范式/13 | 结构化编程:为什么做设计时仅有结构化编程是不够的?.md
Normal file
@@ -0,0 +1,113 @@
|
||||
<audio id="audio" title="13 | 结构化编程:为什么做设计时仅有结构化编程是不够的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/15/c7/1530e9d5c695a27e01a60161df1872c7.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
上一讲,我们讲到了编程范式,现在开发的一个重要趋势就是多种编程范式的融合,显然,这就要求我们对各种编程范式都有一定的理解。从这一讲开始,我们就展开讨论一下几个主要的编程范式。首先,我们来讨论程序员们最熟悉的编程范式:结构化编程。
|
||||
|
||||
很多人学习编程都是从C语言起步的,C语言就是一种典型的结构化编程语言。C的结构化编程也渗透进了后来的程序设计语言之中,比如,C++、Java、C#等等。
|
||||
|
||||
说起结构化编程,你一定会想起那些典型的控制结构,比如:顺序结构、选择结构和循环结构,还会想到函数(如果用术语讲,应该叫subroutine)和代码块(block)。这几乎是程序员们每天都在使用的东西,对于这些内容,你已经熟悉得不能再熟悉了。
|
||||
|
||||
但是,不知道你是否想过这样一个问题?面向对象编程之所以叫面向对象,是因为其中主要的概念是对象,而函数式编程主要的概念是函数。可结构化编程为什么叫结构化呢,难道它的主要概念是结构?这好像也不太对。
|
||||
|
||||
其实,**所谓结构化,是相对于非结构化编程而言的**。所以,要想真正了解结构化编程,就要回到非结构化的古老年代,看看那时候是怎么写程序的。也就是说,只有了解结构化编程的发展历程,你才能更好地认清结构化编程的不足。
|
||||
|
||||
没错,正是因为你太熟悉结构化编程了,我反而要说说它的不足,告诉你在今天做设计,仅仅有结构化编程已经不够了。好,我们就先从结构化编程的演化讲起。
|
||||
|
||||
## 结构从何而来
|
||||
|
||||
你一定知道,结构化编程中的顺序结构就是代码按照编写的顺序执行,选择结构就是if/else,而循环结构就是do/while。这几个关键字一出,是不是就有一股亲切感扑面而来?
|
||||
|
||||
但是,你有没有想过,这些结构是怎么来的呢?
|
||||
|
||||
我们都知道,今天的编程语言都是高级语言,那对应着就应该有低级语言。就今天这个讨论而言,比高级语言低级的就是汇编语言。如果你去了解汇编指令,你会发现,它的编程模式与我们习惯的高级语言的编程模式有着很大的差异。
|
||||
|
||||
使用汇编写代码,你面对的是各种寄存器和内存地址。那些我们在高级语言中经常面对的变量,需要我们自己想办法解决,而类型,则统统没有。至于前面提及的那些控制结构,除了顺序结构之外,在汇编层面也是不存在的。
|
||||
|
||||
连if/else和do/while都没有,让我怎么写程序啊?
|
||||
|
||||
别急,在汇编里有个goto,它可以让代码跳转到另外一个地方继续执行。还有几个比较指令,让你可以比较两个值。
|
||||
|
||||
我们先想一下, if语句做的是什么呢?执行一个表达式,然后,根据这个表达式返回值是真是假,决定执行if后面的代码,还是else后面的代码。
|
||||
|
||||
好,如果我们这么写汇编代码,就是先执行一段代码,把执行结果和0比较。如果不等于0就接着执行,等于0就跳转到另外一个地方执行,这不就和if语句的执行逻辑是一样的吗?
|
||||
|
||||
没错,如果你尝试反汇编一段有if语句的C代码,也会看到类似的汇编代码。如果你是一个Java程序员,也可以通过javap反汇编一段Java类,也可以看到类似的字节码,因为字节码在Java里就相当于汇编。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/91/e1/9100bceaf7456e8df68yyd9b59c240e1.jpg" alt="">
|
||||
|
||||
有了对if语句的理解,再来理解do/while就容易了,就是在判断之后,是决定跳到另外一个地方,还是继续执行下面的代码。如果执行下面的代码,执行到后面就会有一个goto让我们跳回来,再作一次判断。
|
||||
|
||||
了解这些,再加上汇编语言本身的顺序执行,你最熟悉的控制结构就都回来了。所以,即便是用汇编,你依然可以放心地以原来的方式写代码了。
|
||||
|
||||
对于已经有了编程基础的你而言,理解这些内容并不难。但你有没有想过,以前的程序员真的就是用这样的控制结构写程序的吗?并不是。
|
||||
|
||||
原来的程序员面对的的确是这些汇编指令,但是他们是站在直接使用指令的角度去思考。所以,他们更习惯按照自己的逻辑去写,这其中最方便的写法当然就是需要用到哪块逻辑,就goto到哪里执行一段代码,然后,再goto到另外一个地方。
|
||||
|
||||
这种写起来自由自在的方式,在维护起来却会遇到极大的挑战,因为你很难预测代码的执行结果。有人可能只是图个方便,就goto到一个地方继续执行。可只要代码规模稍微一大,就几乎难以维护了,这便是非结构化的编程方式。
|
||||
|
||||
## Goto是有害的
|
||||
|
||||
于是,有人站了出来,提出编程要有结构,不能这么肆无忌惮,结构化编程的概念应运而生。这其中有个重要人物,你一定听说过,叫迪杰斯特拉(Dijkstra),他是1972年的图灵奖的获得者。
|
||||
|
||||
学习算法的时候,你肯定学过以他名字命名的最短路算法;学习操作系统时,你肯定学过PV原语,PV原语这个名字之所以看起来有些奇怪,主要因为Dijkstra是荷兰人。
|
||||
|
||||
1968 年,他在ACM通讯上发表了一篇文章,题目叫做《[Goto 是有害的](https://homepages.cwi.nl/~storm/teaching/reader/Dijkstra68.pdf)》(Go To Statement Considered Harmful),这篇文章引起了轩然大波。
|
||||
|
||||
不是所有人都会接受这种新想法,那些习惯了自由放纵的程序员对Dijkstra进行了无情的冷嘲热讽。他们认为,按照结构化的方法写效率太低。今天的你可能很难想象,C语言初问世之际,遭到最大的质疑是效率低。对,你没听错,C语言被质疑效率低,和Java面世之初遇到的挑战如出一辙。
|
||||
|
||||
提出这种质疑的人只看到了新生事物初生时的不足,却忽略了它们强大的可能性。他们不知道,一旦构建起新的模型,底层实现是可以不断优化的。
|
||||
|
||||
更重要的是,有了新的更高级却也更简单的模型,入门门槛就大幅度降低了,更多的人就可以加入进来,进一步促进这门语言的发展。程序员数量的增多,就可以证明这一点。
|
||||
|
||||
现在的很多程序员其实对底层知识的了解并不多,但丝毫不妨碍他们完成基本的业务功能。只要使用的人足够多,人们就会有更强的驱动力去优化底层实现。时至今日,已经很少有人敢说自己手写的汇编性能一定优于编译器优化后的结果。
|
||||
|
||||
最终这场争论逐渐平息,新的结构逐渐普及,也证明了Dijkstra是对的。goto语句的重要性逐渐降低,一些现代程序设计语言干脆在设计上就把goto语句拿掉了。
|
||||
|
||||
## 功能分解
|
||||
|
||||
你可能没有想过,这种结构化编程的思想最初是为了证明程序正确性而诞生的。
|
||||
|
||||
Dijkstra很早就得出一个结论:编程是一项难度很大的活动。因为一个程序会包含非常多的细节,远超一个人的认知能力范围,任何一个细微的错误都会导致整个程序出现问题。
|
||||
|
||||
所以,他提出goto语句是有害的,还有一个重要的原因是,Dijkstra为了证明程序的正确性,在借助数学推导的方法,将大问题拆分成小问题,逐步递归下去,拆分成更小的、可证明的单元时,他发现goto语句的存在影响了问题的递归拆分,导致问题无法被拆分。
|
||||
|
||||
你也许看出来了,我要说的就是结构化编程另一个重要的方面:功能分解。
|
||||
|
||||
功能分解就是将模块按照功能进行拆分。这样一来,一个大问题就会被拆解成一系列高级函数的组合,而这些高级函数各自再进一步拆分,拆分成一系列的低一级的函数,如此一步步拆分下去,每一个函数都需要按照结构化编程的方式进行开发。这一思想符合人们解决问题的直觉,对软件开发产生了深远的印象。
|
||||
|
||||
以此为基础,后来出现各种结构化分析和结构化设计的方法。将大型系统拆分成模块和组件,这些模块和组件再做进一步的拆分,这些都是来自结构化编程的设计思想。在今天看来,这一切简直再正常不过了,几乎融入了每个程序员的日常话语体系之中。
|
||||
|
||||
好,说完了结构化编程的发展历程,我们自然也就能看出它的不足之处了。
|
||||
|
||||
虽然,结构化编程是比汇编更高层次的抽象,程序员们有了更强大的工具,但人们从来不会就此满足,随之而来的是,程序规模越来越大。这时,结构化编程就显得力不从心了。用一个设计上的说法形容结构编程就是“抽象级别不够高”。
|
||||
|
||||
这就好比你拿着一个显微镜去观察,如果你观察的目标是细菌,它能够很好地完成工作,但如果用它观察一个人,你恐怕就很难去掌握全貌了。结构化编程是为了封装低层的指令而生的,而随着程序规模的膨胀,它组织程序的方式就显得很僵硬,因为它是自上而下进行分解的。
|
||||
|
||||
一旦需求变动,经常是牵一发而动全身,关联的模块由于依赖关系的存在都需要变动,无法有效隔离变化。显然,如何有效地组织这么大规模的程序并不是它的强项,所以,结构化编程注定要成为其它编程范式的基石。
|
||||
|
||||
如果站在今天的角度看,结构化编程还存在一个问题,就是可测试性不够,道理和上面是一样的,它的依赖关系太强,很难拆出来单独测试一个模块。
|
||||
|
||||
所以,仅仅会结构化编程,并不足以让我们做出好的设计,必须把它与其他编程范式结合起来,才能应对已经日益膨胀的软件规模。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们讲了程序员们最熟悉的编程范式:结构化编程。其实,从编程范式的角度,大概每个程序员都能够比较熟练地使用结构化编程提供给我们的编程元素。
|
||||
|
||||
今天这一讲,我主要带着你回顾了一下结构化编程的由来,让你知道即便是我们已经非常熟悉的一些控制结构,也是前人经过不断努力得来的。
|
||||
|
||||
除了知道结构化编程给我们提供了什么,我们还要看到它限制了什么,也就是goto语句。goto语句实际上就是一种对程序控制权的直接转移,它可以让程序跑到任何地方去执行。而对它施加限制之后,程序就不再是随意编写的了。
|
||||
|
||||
结构化编程带来的另一个重要方面是功能分解,也就是将大问题拆分成可以解决的小问题,这一思想影响深远,是我们做设计的根基所在。
|
||||
|
||||
我还给你讲了结构化编程的不足,主要就是在结构化编程中,各模块的依赖关系太强,不能有效地将变化隔离开来。所以,它还需要与其他的编程范式进行配合。下一讲,我们就来讲讲现在最主流的组织程序的方式:面向对象编程。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**结构化编程不能有效地隔离变化,需要与其他编程范式配合使用。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ef/0c/ef37ed4401ccba4237e49e18747dc40c.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
Dijkstra在结构化编程这件事上的思考远远大于我们今天看到的样子。你是否也有这样的经历,你在学习哪门技术时,了解到其背后思想之后,让你觉得受到了很大的震撼。欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
208
极客时间专栏/软件设计之美/设计一个软件—编程范式/14 | 面向对象之封装:怎样的封装才算是高内聚?.md
Normal file
208
极客时间专栏/软件设计之美/设计一个软件—编程范式/14 | 面向对象之封装:怎样的封装才算是高内聚?.md
Normal file
@@ -0,0 +1,208 @@
|
||||
<audio id="audio" title="14 | 面向对象之封装:怎样的封装才算是高内聚?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2d/bc/2d120f16099156a7dfd2c1db2e568abc.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
上一讲,我讲了你最熟悉的编程范式:结构化编程。结构化编程有效地解决了过去的很多问题,它让程序员们解决问题的规模得以扩大。
|
||||
|
||||
随着程序规模的逐渐膨胀,结构化编程在解决问题上的局限也越发凸显出来。因为在它提供的解决方案中,各模块的依赖关系太强,不能有效地将变化隔离开来。这时候,面向对象编程登上了大舞台,它为我们提供了更好的组织程序的方式。
|
||||
|
||||
在一些从结构化编程起步的程序员的视角里,面向对象就是数据加函数。虽然这种理解不算完全错误,但理解的程度远远不够。结构化编程的思考方式类似于用显微镜看世界,这种思考方式会让人只能看到局部。而想要用好面向对象编程,则需要我们有一个更宏观的视角。
|
||||
|
||||
谈到面向对象,你可能会想到面向对象的三个特点:封装、继承和多态。在接下来的三讲,我们就分别谈谈面向对象的这三个特点。
|
||||
|
||||
也许你会觉得,学面向对象程序设计语言的时候,这些内容都学过,没什么好讲的。但从我接触过的很多程序员写程序的风格来看,大多数人还真的不太理解这三个特点。还记得我们在第12讲中提到的那个故事吗?小李之所以被老赵批评,主要就是因为他虽然用了面向对象的语言,代码里却没有体现出面向对象程序的特点,没有封装,更遑论继承和多态。
|
||||
|
||||
嘴上说得明明白白,代码写得稀里糊涂,这就是大多数人学习面向对象之后的真实情况。所以,虽然看上去很简单,但还是有必要聊聊这些特点。
|
||||
|
||||
这一讲,我们先从封装说起。
|
||||
|
||||
## 理解封装
|
||||
|
||||
我们知道,面向对象是解决更大规模应用开发的一种尝试,它提升了程序员管理程序的尺度。
|
||||
|
||||
**封装,则是面向对象的根基**。它把紧密相关的信息放在一起,形成一个单元。如果这个单元是稳定的,我们就可以把这个单元和其他单元继续组合,构成更大的单元。然后,我们再用这个组合出来的新单元继续构建更大的单元。由此,一层一层地逐步向上。
|
||||
|
||||
为了让你更好地理解这个过程,我们先回到面向对象的最初。“面向对象”这个词是由Alan Kay创造的,他是2003年图灵奖的获得者。在他最初的构想中,对象就是一个细胞。当细胞一点一点组织起来,就可以组成身体的各个器官,再一点一点组织起来,就构成了人体。而当你去观察人的时候,就不用再去考虑每个细胞是怎样的。所以,面向对象给了我们一个更宏观的思考方式。
|
||||
|
||||
但是,这一切的前提是,每个对象都要构建好,也就是封装要做好,这就像每个细胞都有细胞壁将它与外界隔离开来,形成了一个完整的个体。
|
||||
|
||||
在Alan Kay关于面向对象的描述中,他强调对象之间只能通过消息来通信。如果按今天程序设计语言的通常做法,发消息就是方法调用,对象之间就是靠方法调用来通信的。但这个方法调用并不是简单地把对象内部的数据通过方法暴露。在Alan Kay的构想中,他甚至想把数据去掉。
|
||||
|
||||
因为,封装的重点在于对象提供了哪些行为,而不是有哪些数据。也就是说,即便我们把对象理解成数据加函数,数据和函数也不是对等的地位。函数是接口,而数据是内部的实现,正如我们一直说的那样,接口是稳定的,实现是易变的。
|
||||
|
||||
理解了这一点,我们来看一个很多人都有的日常编程习惯。他们编写一个类的方法是,把这个类有哪些字段写出来,然后,生成一大堆getter和setter,将这些字段的访问暴露出去。这种做法的错误就在于把数据当成了设计的核心,这一堆的getter和setter,就等于把实现细节暴露了出去。
|
||||
|
||||
一个正确的做法应该是,我们**设计一个类,先要考虑其对象应该提供哪些行为。然后,我们根据这些行为提供对应的方法,最后才是考虑实现这些方法要有哪些字段。**
|
||||
|
||||
请注意,方法的命名,体现的是你的意图,而不是具体怎么做。所以,**getXXX和setXXX绝对不是一个好的命名**。举个例子,设计一个让用户修改密码的功能,有些人直觉的做法可能是这样:
|
||||
|
||||
```
|
||||
class User {
|
||||
private String username;
|
||||
private String password;
|
||||
|
||||
...
|
||||
|
||||
// 修改密码
|
||||
public void setPassword(final String password) {
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但我们鼓励的做法是,把意图表现出来:
|
||||
|
||||
```
|
||||
class User {
|
||||
private String username;
|
||||
private String password;
|
||||
|
||||
...
|
||||
|
||||
// 修改密码
|
||||
public void changePassword(final String password) {
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这两段代码相比,只是修改密码的方法名变了,但二者更重要的差异是,一个在说做什么,一个在说怎么做。**将意图与实现分离开来**,这是一个优秀设计必须要考虑的问题。
|
||||
|
||||
不过,在真实的项目中,有时确实需要暴露一些数据,所以,等到你确实需要暴露的时候,再去写getter也不迟,你一定要问问自己为什么要加getter。至于setter,首先,大概率是你用错了名字,应该用一个表示意图的名字;其次,setter通常意味着修改,这是我们不鼓励的。
|
||||
|
||||
我后面讲函数式编程时,会讲到不变性,可变的对象会带来很多的问题,到时候我们再来更具体地讨论。所以,设计中更好的做法是设计不变类。
|
||||
|
||||
## 减少暴露接口
|
||||
|
||||
之所以我们需要封装,就是要构建一个内聚的单元。所以,我们要**减少这个单元对外的暴露**。这句话的第一层含义是减少内部实现细节的暴露,它还有第二层含义,**减少对外暴露的接口**。
|
||||
|
||||
一般面向对象程序设计语言都支持public、private这样的修饰符。程序员在日常开发中,经常会很草率地给一个方法加上public,从而不经意间将一些本来应该是内部实现的部分暴露出去。举个例子,一个服务要停下来的时候,你可能要把一些任务都停下来,代码可能会这样写:
|
||||
|
||||
```
|
||||
class Service {
|
||||
public void shutdownTimerTask() {
|
||||
// 停止定时器任务
|
||||
}
|
||||
|
||||
public void shutdownPollTask() {
|
||||
// 停止轮询服务
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
别人调用时,可能会这样调用这段代码:
|
||||
|
||||
```
|
||||
class Application {
|
||||
private Service service;
|
||||
|
||||
public void onShutdown() {
|
||||
service.shutdownTimerTask();
|
||||
service.shutdownPollTask();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
突然有一天,你发现,停止轮询任务必须在停止定时器任务之前,你就不得不要求别人改代码。而这一切就是因为我们很草率地给那两个方法加上了public,让别人有机会看到了这两个方法。
|
||||
|
||||
从设计的角度来说,我们必须谨慎地问一下,这个方法真的有必要暴露出去吗?
|
||||
|
||||
就这个例子而言,我们可以仅仅暴露一个方法:
|
||||
|
||||
```
|
||||
class Service {
|
||||
private void shutdownTimerTask() {
|
||||
// 停止定时器任务
|
||||
}
|
||||
|
||||
private void shutdownPollTask() {
|
||||
// 停止轮询服务
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
this.shutdownTimerTask();
|
||||
this.shutdownPollTask();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们调用代码也会简单很多:
|
||||
|
||||
```
|
||||
class Application {
|
||||
private Service service;
|
||||
|
||||
public void onShutdown() {
|
||||
service.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
尽可能减少接口暴露,这个原则不仅仅适用于类的设计,同样适用于系统设计。在我的职业生涯中,看到了很多团队非常随意地在系统里面添加接口,一个看似不那么复杂的系统里,随随便便就有成百上千个接口。
|
||||
|
||||
如果你想改造系统去掉一些接口时,很有可能会造成线上故障,因为你根本不知道哪个团队在什么时候用到了它。所以,在软件设计中,暴露接口需要非常谨慎。
|
||||
|
||||
关于这一点,你可以有一个统一的原则:**最小化接口暴露**。也就是,每增加一个接口,你都要找到一个合适的理由。
|
||||
|
||||
## 不局限于面向对象的封装
|
||||
|
||||
虽说封装是面向对象的一个重要特征,但是,当理解了封装之后,你同样可以把它运用于非面向对象的程序设计语言中,把代码写得更具模块性。
|
||||
|
||||
比如,我们知道C语言有头文件(.h 文件)和定义文件(.c 文件),在通常的理解中,头文件放的是各种声明:函数声明、结构体等等。很多C程序员甚至有一个函数就在头文件里加一个声明。
|
||||
|
||||
有了今天对于封装的讲解,再来看C语言的头文件,我们可以让它扮演接口的角色,而定义文件就成了实现。根据今天的内容,既然,接口只有相当于public接口的函数才可以放到头文件里,那么,在头文件里声明一个函数时,我们首先要问的就是,它需要成为一个公开的函数吗?
|
||||
|
||||
C语言没有public和private这样的修饰符,但我曾在一些C的项目上加入了自己的定义:
|
||||
|
||||
```
|
||||
#define PUBLIC
|
||||
#define PRIVATE static
|
||||
|
||||
```
|
||||
|
||||
然后,我们规定头文件里只能放公有接口,而在实现文件中的每个函数前面,加上了PUBLIC和PRIVATE,以示区分。这里将PRIVATE定义成了static,是利用了C语言static函数只能在一个文件中可见的特性。
|
||||
|
||||
我们还可以把一个头文件和一个定义文件合在一起,把它们看成一个类,不允许随意在头文件中声明不相关的函数。比如,下面是我在一个头文件里定义了一个点(Point):
|
||||
|
||||
```
|
||||
struct Point;
|
||||
struct Point* makePoint(double x, double y);
|
||||
double distance(struct Point* x, struct Point* y);
|
||||
|
||||
```
|
||||
|
||||
你可能注意到了,Point这个结构体我只给了声明,没有给定义。因为我并不希望给它的用户访问其字段的权限,结构体的具体定义是实现,应该被隐藏起来。对应的定义文件很简单,就不在这里罗列代码了。
|
||||
|
||||
说到这里,你也许发现了,C语言的封装做得更加彻底。如果用Java或C++ 定义Point类的话,必然会给出具体的字段。从某种程度上来说,Java 和 C++的做法削弱了封装性。
|
||||
|
||||
讲到这里,你应该已经感受到面向对象和结构化编程在思考问题上的一些差异了。有了封装,对象就成了一个个可以组合的单元,也形成了一个个可以复用的单元。面向对象编程的思考方式就是组合这些单元,完成不同的功能。同结构化编程相比,这种思考问题的方式站在了一个更宏观的视角上。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们学习了面向对象编程,它是一种以对象为编程元素的编程范式。面向对象有三个特点:封装、继承和多态。
|
||||
|
||||
封装,是面向对象的根基。面向对象编程就是要设计出一个一个可以组合,可以复用的单元。然后,组合这些单元完成不同的功能。
|
||||
|
||||
封装的重点在于对象提供了哪些行为,而不是有哪些数据。即便我们把对象理解成数据加函数,数据和函数也不是对等的地位。函数是接口,应该是稳定的;数据是实现,是易变的,应该隐藏起来。
|
||||
|
||||
设计一个类的方法,先要考虑其对象应该提供哪些行为,然后,根据这些行为提供对应的方法,最后才是考虑实现这些方法要有哪些字段。getter和setter是暴露实现细节的,尽可能不提供,尤其是setter。
|
||||
|
||||
封装,除了要减少内部实现细节的暴露,还要减少对外接口的暴露。一个原则是最小化接口暴露。有了对封装的理解,即便我们用的是C语言这样非面向对象的语言,也可以按照这个思路把程序写得更具模块性。
|
||||
|
||||
理解了封装,下一讲,我们再来看面向对象另外一个特征:继承。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**基于行为进行封装,不要暴露实现细节,最小化接口暴露。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c3/5e/c3cbdea561b6751a4c56f928c3d5345e.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你了解一下迪米特法则(Law of Demeter),结合今天的课程,分享一下你对迪米特法则的理解。欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
259
极客时间专栏/软件设计之美/设计一个软件—编程范式/15 | 面向对象之继承:继承是代码复用的合理方式吗?.md
Normal file
259
极客时间专栏/软件设计之美/设计一个软件—编程范式/15 | 面向对象之继承:继承是代码复用的合理方式吗?.md
Normal file
@@ -0,0 +1,259 @@
|
||||
<audio id="audio" title="15 | 面向对象之继承:继承是代码复用的合理方式吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7c/3c/7ca41d724b9415c48accdee97dcece3c.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
上一讲,我们讨论了面向对象的第一个特点:封装。这一讲,我们继续来看面向对象的第二个特点:继承。首先,你对继承的第一印象是什么呢?
|
||||
|
||||
说到继承,很多讲面向对象的教材一般会这么讲,给你画一棵树,父类是根节点,而子类是叶子节点,显然,一个父类可以有许多个子类。
|
||||
|
||||
父类是干什么用的呢?就是把一些公共代码放进去,之后在实现其他子类时,可以少写一些代码。讲程序库的时候,我们说过,设计的职责之一就是消除重复,代码复用。所以,在很多人的印象中,继承就是一种代码复用的方式。
|
||||
|
||||
如果我们把继承理解成一种代码复用方式,更多地是站在子类的角度向上看。在客户端代码使用的时候,面对的是子类,这种继承叫实现继承:
|
||||
|
||||
```
|
||||
Child object = new Child();
|
||||
|
||||
```
|
||||
|
||||
其实,还有一种看待继承的角度,就是从父类的角度往下看,客户端使用的时候,面对的是父类,这种继承叫接口继承:
|
||||
|
||||
```
|
||||
Parent object = new Child();
|
||||
|
||||
```
|
||||
|
||||
不过,接口继承更多是与多态相关,我们暂且放一放,留到下一讲再来讨论。这一讲,我们还是主要来说说实现继承。其实,实现继承并不是一种好的做法。
|
||||
|
||||
也就是说,**把实现继承当作一种代码复用的方式,并不是一种值得鼓励的做法**。一方面,继承是很宝贵的,尤其是Java这种单继承的程序设计语言。每个类只能有一个父类,一旦继承的位置被实现继承占据了,再想做接口继承就很难了。
|
||||
|
||||
另一方面,实现继承通常也是一种受程序设计语言局限的思维方式,有很多程序设计语言,即使不使用继承,也有自己的代码复用方式。
|
||||
|
||||
可能这么说你还不太理解,接下来,我就用一个例子来帮你更好地理解继承。
|
||||
|
||||
## 代码复用
|
||||
|
||||
假设,我要做一个产品报表服务,其中有个服务是要查询产品信息,这个查询过程是通用的,别的服务也可以用,所以,我把它放到父类里面。这就是代码复用的做法,代码用Java写出来是这样的:
|
||||
|
||||
```
|
||||
class BaseService {
|
||||
// 获取相应的产品信息
|
||||
protected List<Product> getProducts(List<String> product) {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
// 生成报表服务
|
||||
class ReportService extends BaseService {
|
||||
public void report() {
|
||||
List<Product> product = getProduct(...);
|
||||
// 生成报表
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果采用Ruby的mixin机制,我们还可以这样实现,先定义一个模块(module):
|
||||
|
||||
```
|
||||
module ProductFetcher
|
||||
# 获取相应的产品信息
|
||||
def getProducts(products)
|
||||
...
|
||||
end
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
然后,在自己的类定义中,将它包含(include)进来:
|
||||
|
||||
```
|
||||
# 生成报表服务
|
||||
class ReportService
|
||||
include ProductFetcher
|
||||
|
||||
def report
|
||||
products = getProducts(...)
|
||||
# 生成报表
|
||||
..
|
||||
end
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
在这个例子中,ReportService并没有继承任何类,获取产品信息的代码也是可以复用的,也就是这里的ProductFetcher这个模块。这样一来,如果我需要有一个获取产品信息的地方,它不必非得是一个什么服务,无需继承任何类。
|
||||
|
||||
这是Ruby的做法,类似的语言特性还有Scala里的trait。
|
||||
|
||||
在C++中,虽然语法并没有严格地区分实现继承,但《Effective C++》这本行业的名著,给出了一个实用的建议:实现继承采用私有继承的方式实现:
|
||||
|
||||
```
|
||||
class ReportService: private ProductFetcher {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
请注意,在这个实现里,我的私有继承类名是ProductFetcher。是的,它并不需要和这个报表服务有什么直接的关系,使用私有继承,就是为了复用它的代码。
|
||||
|
||||
从前面的分析中,我们也不难看出,获取产品信息和生成报表其实是两件事,只是因为在生成报表的过程中,需要获取产品信息,所以,它有了一个基类。
|
||||
|
||||
其实,在Java里面,我们不用继承的方式也能实现,也许你已经想到了,代码可以写成这样:
|
||||
|
||||
```
|
||||
class ProductFetcher {
|
||||
// 获取相应的产品信息
|
||||
public List<Product> getProducts(List<String> product) {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
// 生成报表服务
|
||||
class ReportService {
|
||||
private ProductFetcher fetcher;
|
||||
|
||||
public void report() {
|
||||
List<Product> product = fetcher.getProducts(...);
|
||||
// 生成报表
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这种实现方案叫作组合,也就是说ReportService里组合进一个ProductFetcher。在设计上,有一个通用的原则叫做:**组合优于继承**。也就是说,如果一个方案既能用组合实现,也能用继承实现,那就选择用组合实现。
|
||||
|
||||
好,到这里你已经清楚了,代码复用并不是使用继承的好场景。所以,**要写继承的代码时,先问自己,这是接口继承,还是实现继承?如果是实现继承,那是不是可以写成组合?**
|
||||
|
||||
## 面向组合编程
|
||||
|
||||
之所以可以用组合的方式实现,本质的原因是,获取产品信息和生成报表服务本来就是两件事。还记得我们在[第3讲](https://time.geekbang.org/column/article/241094)里讲过的“分离关注点”吗?如果你能看出它们是两件事,就不会把它们放到一起了。
|
||||
|
||||
我还讲过,分解是设计的第一步,而且分解的粒度越小越好。当你可以分解出来多个关注点,每一个关注点就应该是一个独立的模块。最终的**类是由这些一个一个的小模块组合而成,这种编程的方式就是面向组合编程**。它相当于换了一个视角:类是由多个小模块组合而成。
|
||||
|
||||
还以前面的报表服务为例,如果使用Java,按照面向组合的思路写出来,大概是下面这样的。其中,为了增加复杂度,我增加了一个报表生成器(ReportGenerator),在获取产品信息之后,还要生成报表:
|
||||
|
||||
```
|
||||
class ReportService {
|
||||
private ProductFetcher fetcher;
|
||||
private ReportGenerator generator;
|
||||
|
||||
public void report() {
|
||||
List<Product> product = fetcher.getProducts(...);
|
||||
// 生成报表
|
||||
generator.generate(product);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
请注意,我在前面的表述中,故意用了模块这个词,而不是类。因为ProductFetcher和ReportGenerator只是因为我们用的是Java,才写成了类;如果用Ruby,它们的表现形式就会是一个module;而在Scala里,就会成为一个trait。我们再用Ruby 示意一下:
|
||||
|
||||
```
|
||||
class ReportService
|
||||
include ProductFetcher
|
||||
include ReportGenerator
|
||||
|
||||
def report
|
||||
products = getProducts(...)
|
||||
# 生成报表
|
||||
generateReport(products)
|
||||
end
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
而使用C++的话,表现形式则会是私有继承:
|
||||
|
||||
```
|
||||
class ReportService: private ProductFetcher, private ReportGenerator {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
C++本身支持宏定义,所以,我们可以自定义一些宏,将这些不同的概念区分开来:
|
||||
|
||||
```
|
||||
#define MODULE(module) class module
|
||||
#define INCLUDE(module) private module
|
||||
|
||||
```
|
||||
|
||||
上面的类定义就可以变成更有表达性的写法:
|
||||
|
||||
```
|
||||
MODULE(ProductFetcher) {
|
||||
...
|
||||
}
|
||||
|
||||
MODULE(ReportGenerator) {
|
||||
...
|
||||
}
|
||||
|
||||
class ReportService:
|
||||
INCLUDE(ProductFetcher),
|
||||
INCLUDE(ReportGenerator) {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我有一个C++的高手朋友,把这种做法称之为“[小类大对象](https://www.jianshu.com/p/a830d2261392)”,这里面的小类就是一个一个的模块,而最终的大对象是最终组合出来的类生成的对象。
|
||||
|
||||
关于面向对象,有一点我们还没有说,就是**面向对象面向的是“对象”,不是类**。很多程序员习惯把对象理解成类的附属品,但在Alan Kay的理解中,对象本身就是一个独立的个体。所以,有些程序设计语言可以直接支持在对象上进行操作。
|
||||
|
||||
还是前面的例子,我想给报表服务增加一个接口,对产品信息做一下处理。用Ruby写出来会是这样:
|
||||
|
||||
```
|
||||
module ProductEnhancer
|
||||
def enhance
|
||||
# 处理一下产品信息
|
||||
end
|
||||
end
|
||||
|
||||
service = ReportService.new
|
||||
# 增加了 ProductEnhancer
|
||||
service.extend(ProductEnhancer)
|
||||
|
||||
# 可以调用 enhance 方法
|
||||
service.enhance
|
||||
|
||||
```
|
||||
|
||||
这样的处理只会影响这里的一个对象,而同样是这个ReportService的其他实例,则完全不受影响。这样做的好处是,我们不必写那么多类,而是根据需要在程序运行时组合出不同的对象。
|
||||
|
||||
在这里,相信你再一次意识到了要学习多种程序设计语言的重要性。Java只有类这种组织方式,所以,很多有差异的概念只能用类这一个概念表示出来,思维就会受到限制,而不同的语言则提供了不同的表现形式,让概念更加清晰。
|
||||
|
||||
前面只是讲了面向组合编程在思考方式的转变,下面我们再来看设计上的差异。举个例子,我们有个字体类(Font),现在的需求是,字体能够加粗(Bold)、能够有下划线(Underline)、还要支持斜体(Italic),而且这些能力之间是任意组合的。
|
||||
|
||||
如果采用继承的方式,那就要有8 个类:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a3/0a/a3cf4c150e4fcbb98d4d7b6212e2700a.jpg" alt="">
|
||||
|
||||
而采用组合的方式,我们的字体类(Font)只要有三个独立的维度,也就是是否加粗(Bold)、是否有下划线(Underline)、是否是斜体(Italic)。这还不是终局,如果再来一种其他的要求,由3种要求变成4种,采用继承的方式,类的数量就会膨胀到16个类,而组合的方式只需要再增加一个维度就好。我们把一个M*N的问题,通过设计转变成了M+N的问题,复杂度的差别一望便知。
|
||||
|
||||
虽然我们一直在说,Java在面向组合编程方面能力比较弱,但Java社区也在尝试不同的方式。早期的尝试有[Qi4j](https://www.infoq.cn/article/2007/11/qi4j-intro),后来Java 8加入了default method,在一定程度上也可以支持面向组合的编程。这里我们只是讲了面向对象社区在组合方面的探索,后面讲函数式编程时,还会讲到函数式编程在这方面的探索。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们学习了面向对象的第二个特点:继承。继承分为两种,实现继承和接口继承。实现继承是站在子类的视角看问题,接口继承则是站在父类的视角。
|
||||
|
||||
很多程序员把实现继承当作了一种代码复用的方式,但实际上,实现继承并不是一个好的代码复用的方式,之所以这种方式很常见,很大程度上是受了语言的局限。
|
||||
|
||||
Ruby的mixin机制,Scala提供的trait以及C++提供的私有继承都是代码复用的方式。即便只使用Java,也可以通过组合而非继承的方式进行代码复用。
|
||||
|
||||
今天我们还讲到这些复用方式背后的编程思想:面向组合编程。它给我们提供了一个不同的视角,但支撑面向组合编程的是分离关注点。将不同的关注点分离出来,每一个关注点成为一个模块,在需要的时候组装起来。面向组合编程,在设计本身上有很多优秀的地方,可以降低程序的复杂度,更是思维上的转变。
|
||||
|
||||
现在你已经知道了,在继承树上从下往上看,并不是一个好的思考方式,那从上往下看呢?下一讲,我们就来讲讲继承的另外一个方向,接口继承,也就是面向对象的第三个特点:多态。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**组合优于继承**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/67/a0/67e0cbd436dd50a8933b251e4c97a4a0.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你去了解一下一种叫[DCI (Data,Context和 Interaction)](https://en.wikipedia.org/wiki/Data,_context_and_interaction)<br>
|
||||
的编程思想,结合今天的课程,分享一下你对DCI的理解。欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
@@ -0,0 +1,233 @@
|
||||
<audio id="audio" title="16 | 面向对象之多态:为什么“稀疏平常”的多态,是软件设计的大杀器?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/yy/2f/yybe932749e9cfdfyy25c02a925ffa2f.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
前面两讲,我们讲了面向对象的两个特点:封装和继承,但真正让面向对象华丽蜕变的是它的第三个特点:多态。
|
||||
|
||||
有一次,我在一个C++的开发团队里做了一个小调查。问题很简单:你用过virtual吗?下面坐着几十个C++程序员,只有寥寥数人举起了手。
|
||||
|
||||
在C++里,virtual表示这个函数是在父类中声明的,然后在子类中改写(Override)过。或许你已经发现了,这不就是多态吗?没错,这就是多态。这个调查说明了一件事,很多程序员虽然在用支持面向对象的程序设计语言,但根本没有用过多态。
|
||||
|
||||
只使用封装和继承的编程方式,我们称之为基于对象(Object Based)编程,而只有把多态加进来,才能称之为面向对象(Object Oriented)编程。也就是说,多态是一个分水岭,将基于对象与面向对象区分开来,可以说,没写过多态的代码,就是没写过面向对象的代码。
|
||||
|
||||
对于面向对象而言,多态至关重要,正是因为多态的存在,软件设计才有了更大的弹性,能够更好地适应未来的变化。我们说,软件设计是一门关注长期变化的学问,只有当你开始理解了多态,你才真正踏入应对长期变化的大门。这一讲,我们就谈谈多态。
|
||||
|
||||
## 理解多态
|
||||
|
||||
多态(Polymorphism),顾名思义,一个接口,多种形态。同样是一个绘图(draw)的方法,如果以正方形调用,则绘制出一个正方形;如果以圆形调用,则画出的是圆形:
|
||||
|
||||
```
|
||||
interface Shape {
|
||||
// 绘图接口
|
||||
void draw();
|
||||
}
|
||||
|
||||
class Square implements Shape {
|
||||
void draw() {
|
||||
// 画一个正方形
|
||||
}
|
||||
}
|
||||
|
||||
class Circle implements Shape {
|
||||
void draw() {
|
||||
// 画一个圆形
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上一讲,我们说过,继承有两种,实现继承和接口继承。其中,实现继承尽可能用组合的方式替代继承。而接口继承,主要是给多态用的。
|
||||
|
||||
这里面的重点在于,这个继承体系的使用者,主要考虑的是父类,而非子类。就像下面这段代码里,我们不必考虑具体的形状是什么,只要调用它的绘图方法即可。
|
||||
|
||||
```
|
||||
Shape shape = new Squre();
|
||||
shape.draw();
|
||||
|
||||
```
|
||||
|
||||
这种做法的好处就在于,一旦有了新的变化,比如,需要将正方形替换成圆形,除了变量初始化,其他的代码并不需要修改。不过,这是任何一本面向对象编程的教科书上都会讲的内容。
|
||||
|
||||
那么,问题来了。既然多态这么好,为什么很多程序员不能在自己的代码中很好地运用多态呢?因为多态需要构建出一个抽象。
|
||||
|
||||
构建抽象,需要找出不同事物的共同点,而这是最有挑战的部分。而遮住程序员们双眼的,往往就是他们眼里的不同之处。在他们眼中,鸡就是鸡,鸭就是鸭。
|
||||
|
||||
**寻找共同点这件事,地基还是在分离关注点上**。只有你能看出来,鸡和鸭都有羽毛,都养在家里,你才有机会识别出一个叫做“家禽”的概念。这里,我们又一次强调了分离关注点的重要性。
|
||||
|
||||
我们构建出来的抽象会以接口的方式体现出来,强调一点,这里的接口不一定是一个语法,而是一个类型的约束。所以,在这个关于多态的讨论中,接口、抽象类、父类等几个概念都是等价的,为了叙述方便,我这里统一采用接口的说法。
|
||||
|
||||
在构建抽象上,接口扮演着重要的角色。首先,**接口将变的部分和不变的部分隔离开来**。不变的部分就是接口的约定,而变的部分就是子类各自的实现。
|
||||
|
||||
在软件开发中,**对系统影响最大的就是变化**。有时候需求一来,你的代码就要跟着改,一个可能的原因就是各种代码混在了一起。比如,一个通信协议的调整需要你改业务逻辑,这明显就是不合理的。**对程序员来说,识别出变与不变,是一种很重要的能力。**
|
||||
|
||||
其次,**接口是一个边界**。无论是什么样的系统,清晰界定不同模块的职责是很关键的,而模块之间彼此通信最重要的就是通信协议。这种通信协议对应到代码层面上,就是接口。
|
||||
|
||||
很多程序员在接口中添加方法显得很随意,因为在他们心目中,并不存在实现者和使用者之间的角色差异。这也就造成了边界意识的欠缺,没有一个清晰的边界,其结果就是模块定义的随意,彼此之间互相影响也就在所难免。后面谈到Liskov替换法则的时候,我们还会再谈到这一点。
|
||||
|
||||
所以,**要想理解多态,首先要理解接口的价值,而理解接口,最关键的就是在于谨慎地选择接口中的方法**。
|
||||
|
||||
至此,你已经对多态和接口有了一个基本的认识。你就能很好地理解一个编程原则了:面向接口编程。面向接口编程的价值就根植于多态,也正是因为有了多态,一些设计原则,比如,开闭原则、接口隔离原则才得以成立,相应地,设计模式才有了立足之本。
|
||||
|
||||
这些原则你可能都听说过,但在编码的细节上,你可能会有一些忽略的细节,比如,下面这段代码是很多人经常写的:
|
||||
|
||||
```
|
||||
ArrayList<> list = new ArrayList<String>();
|
||||
|
||||
```
|
||||
|
||||
这么简单的代码也有问题,是的,因为它没有面向接口编程,一个更好的写法应该是这样:
|
||||
|
||||
```
|
||||
List<> list = new ArrayList<String>();
|
||||
|
||||
```
|
||||
|
||||
二者之间的差别就在于变量的类型,是面向一个接口,还是面向一个具体的实现类。
|
||||
|
||||
相对于封装和继承而言,多态对程序员的要求更高,需要你有长远的眼光,看到未来的变化,而理解好多态,也是程序员进阶的必经之路。
|
||||
|
||||
## 实现多态
|
||||
|
||||
还记得我们在编程范式那一讲留下的一个问题吗?面向对象编程,会限制使用函数指针,它是对程序控制权的间接转移施加了约束。理解这一点,就要理解多态是怎么实现的。
|
||||
|
||||
讲多范式编程时,我举了Linux文件系统的例子,它是用C实现了面向对象编程,而它的做法就是用了函数指针。再来回顾一下:
|
||||
|
||||
```
|
||||
struct file_operations {
|
||||
loff_t (*llseek) (struct file *, loff_t, int);
|
||||
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
|
||||
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
|
||||
int (*open) (struct inode *, struct file *);
|
||||
int (*flush) (struct file *, fl_owner_t id);
|
||||
int (*release) (struct inode *, struct file *);
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
假设你写一个HelloFS,那你可以这样给它赋值:
|
||||
|
||||
```
|
||||
const struct file_operations hellofs_file_operations = {
|
||||
.read = hellofs_read,
|
||||
.write = hellofs_write,
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
只要给这个结构体赋上不同的值,就可以实现不同的文件系统。但是,这种做法有一个非常不安全的地方。既然是一个结构体的字段,那我就有可能改写了它,像下面这样:
|
||||
|
||||
```
|
||||
void silly_operation(struct file_operations* operations) {
|
||||
operations.read = sillyfs_read;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如此一来,本来应该在hellofs_read运行的代码,就跑到了sillyfs_read里,程序很容易就崩溃了。对于C这种非常灵活的语言来说,你根本禁止不了这种操作,只能靠人为的规定和代码检查。
|
||||
|
||||
到了面向对象程序设计语言这里,这种做法由一种编程结构变成了一种语法。给函数指针赋值的操作下沉到了运行时去实现。如果你了解运行时的实现,它就是一个查表的过程,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/81/c4/811965ea831b3df14c2165e3804b3ec4.jpg" alt="">
|
||||
|
||||
一个类在编译时,会给其中的函数在虚拟函数表中找到一个位置,把函数指针地址写进去,不同的子类对应不同的虚拟表。当我们用接口去调用对应的函数时,实际上完成的就是在对应的虚拟函数表的一个偏移,不管现在面对的是哪个子类,都可以找到相应的实现函数。
|
||||
|
||||
还记得我在开头提的那个问题吗?问C++程序员是否用过virtual。在C++这种比较注重运行时消耗的语言中,只有virtual的函数会出现在虚拟函数表里,而普通函数就是直接的函数调用,以此减少消耗。对于Java程序员而言,你可以通过给无需改写的方法添加final帮助运行时做优化。
|
||||
|
||||
当多态成了一种语法,函数指针的使用就得到了限制,犯错误的几率就大大降低了,程序行为的可预期性就大大提高了。
|
||||
|
||||
## 没有继承的多态
|
||||
|
||||
回到Alan Kay关于面向对象的思考中,他考虑过封装,考虑过多态。至于继承,却不是一个必然的选项。只要能够遵循相同的接口,就可以表现出来多态,所以,多态并不一定要依赖于继承。
|
||||
|
||||
比如,在动态语言中,有一个常见的说法,叫Duck Typing,就是说,如果走起来像鸭子,叫起来像鸭子,那它就是鸭子。两个类可以不在同一个继承体系之下,但是,只要有同样的方法接口,就是一种多态。
|
||||
|
||||
像下面这段代码,Duck和FakeDuck并不在一棵继承树上,但make_quack调用的时候,它们俩都可以传进去。
|
||||
|
||||
```
|
||||
class Duck
|
||||
def quack
|
||||
# 鸭子叫
|
||||
end
|
||||
end
|
||||
|
||||
class FakeDuck
|
||||
def quack
|
||||
# 模拟鸭子叫
|
||||
end
|
||||
end
|
||||
|
||||
def make_quack(quackable)
|
||||
quackable.quack
|
||||
end
|
||||
|
||||
make_quack(Duck.new)
|
||||
make_quack(FakeDuck.new)
|
||||
|
||||
```
|
||||
|
||||
我们都知道,很多软件都有插件能力,而插件结构本身就是一种多态的表现。比如,著名的开源图形处理软件[GIMP](https://www.gimp.org/),它自身是用C开发的,为它编写插件就需要按照它规定的结构去编写代码:
|
||||
|
||||
```
|
||||
struct GimpPlugInInfo
|
||||
{
|
||||
/* GIMP 应用初始启动时调用 */
|
||||
GimpInitProc init_proc;
|
||||
|
||||
/* GIMP 应用退出时调用 */
|
||||
GimpQuitProc quit_proc;
|
||||
|
||||
/* GIMP 查询插件能力时调用 */
|
||||
GimpQueryProc query_proc;
|
||||
|
||||
/* 插件安装之后,开始运行时调用*/
|
||||
GimpRunProc run_proc;
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
我们所需做的就是按照这个结构声明出PLUG_IN_INFO,这是隐藏的名字,将插件的能力注册给GIMP这个应用:
|
||||
|
||||
```
|
||||
GimpPlugInInfo PLUG_IN_INFO = {
|
||||
init,
|
||||
quit,
|
||||
query,
|
||||
run
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
你看,这里用到的是C语言,一种连面向对象都不支持的语言,但它依然能够很好地表现出多态。
|
||||
|
||||
现在你应该理解了,多态依赖于继承,这只是某些程序设计语言自身的特点。你也看出来了,在面向对象本身的体系之中,封装和多态才是重中之重,而继承则处于一个很尴尬的位置。
|
||||
|
||||
我们花了三讲的篇幅讲了面向对象编程的特点,在这三讲中,我们不仅仅以Java为基础讲了传统的面向对象实现的一些方法,也讲到了不同语言在解决同样问题上的不同做法。正如我们在讲程序设计语言时所说,一定要跳出单一语言的局限,这样,才能对各种编程思想有更本质的认识。
|
||||
|
||||
在这里,你也看到了面向对象编程的三个特点也有不同的地位:
|
||||
|
||||
- 封装是面向对象的根基,软件就是靠各种封装好的对象逐步组合出来的;
|
||||
- 继承给了继承体系内的所有对象一个约束,让它们有了统一的行为;
|
||||
- 多态让整个体系能够更好地应对未来的变化。
|
||||
|
||||
后面我们还会讲到面向对象的设计原则,而这些原则的出发点就是面向对象的这些特点,所以,理解面向对象的这些特点,是我们后面把设计做好的基础。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们讲到了面向对象的第三个特点:多态,它是基于对象和面向对象的分水岭。多态,需要找出不同事物的共同点,建立起抽象,这也是很多程序员更好地运用多态的阻碍。而我们找出共同点,前提是要分离关注点。
|
||||
|
||||
理解多态,还要理解好接口。它是将变的部分和不变的部分隔离开来,在二者之间建立起一个边界。一个重要的编程原则就是**面向接口编程**,这是很多设计原则的基础。
|
||||
|
||||
我们今天还讨论了多态的实现,它通过将一种常见的编程结构升华为语法,降低程序员犯错的几率。最后,我们说了,多态不一定要依赖于继承实现。在面向对象编程中,更重要的是封装和多态。
|
||||
|
||||
结构化编程也好,面向对象编程也罢,这些都是大多数程序员都还是比较熟悉的,而下面我们要讲到的编程范式已经成为一股不可忽视的力量。然而,很多人却对它无知无觉,这就是函数式编程。下一讲,我们就来说说函数式编程。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**建立起恰当的抽象,面向接口编程。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/e4/d85fa1220e55fe7291b480b335d0c5e4.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你去了解一下Go语言或Rust语言是如何支持多态的,欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
270
极客时间专栏/软件设计之美/设计一个软件—编程范式/17 | 函数式编程:不用函数式编程语言,怎么写函数式的程序?.md
Normal file
270
极客时间专栏/软件设计之美/设计一个软件—编程范式/17 | 函数式编程:不用函数式编程语言,怎么写函数式的程序?.md
Normal file
@@ -0,0 +1,270 @@
|
||||
<audio id="audio" title="17 | 函数式编程:不用函数式编程语言,怎么写函数式的程序?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/85/0e/85a4d6dc9abb827e954126e6eca2850e.mp3"></audio>
|
||||
|
||||
你好,我是郑晔!
|
||||
|
||||
前面几讲,我们讲了结构化编程和面向对象编程,对于大多数程序员来说,这些内容还是比较熟悉的。接下来,我们要讨论的函数式编程,对一些人来说就要陌生一些。
|
||||
|
||||
你可能知道,Java和C++已经引入了Lambda,目的就是为了支持函数式编程。因为,函数式编程里有很多优秀的元素,比如,组合式编程、不变性等等,都是我们值得在日常设计中借鉴的。即便我们使用的是面向对象编程语言,也可以将这些函数式编程的做法运用到日常工作中,这已经成为大势所趋。
|
||||
|
||||
但是,很多人学习函数式编程,刚刚知道了概念,就碰上了函数式编程的起源,遇到许多数学概念,然后,就放弃了。为什么学习函数式编程这么困难呢?主要是因为它有一些不同的思维逻辑,同时人们也缺少一个更好的入门方式。
|
||||
|
||||
所以,在这一讲中,我打算站在一个更实用的角度,帮你做一个函数式编程的入门。等你有了基础之后,后面两讲,我们再来讨论函数式编程中优秀的设计理念。
|
||||
|
||||
好,我们开始吧!
|
||||
|
||||
## 不断增加的需求
|
||||
|
||||
我们从一个熟悉的场景出发。假设我们有一组学生,其类定义如下:
|
||||
|
||||
```
|
||||
// 单个学生的定义
|
||||
class Student {
|
||||
// 实体 ID
|
||||
private long id;
|
||||
// 学生姓名
|
||||
private String name;
|
||||
// 学号
|
||||
private long sno;
|
||||
// 年龄
|
||||
private long age;
|
||||
}
|
||||
|
||||
// 一组学生的定义
|
||||
class Students {
|
||||
private List<Student> students
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果我们需要按照姓名找出其中一个,代码可能会这么写:
|
||||
|
||||
```
|
||||
Student findByName(final String name) {
|
||||
for (Student student : students) {
|
||||
if (name.equals(student.getName())) {
|
||||
return student;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这时候,新需求来了,我们准备按照学号来找人,代码也许就会这么写:
|
||||
|
||||
```
|
||||
Student findBySno(final long sno) {
|
||||
for (Student student : students) {
|
||||
if (sno == student.getSno()) {
|
||||
return student;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
又一个新需求来了,我们这次需要按照 ID 去找人,代码可以如法炮制:
|
||||
|
||||
```
|
||||
Student findById(final long id) {
|
||||
for (Student student : students) {
|
||||
if (id == student.getId()) {
|
||||
return student;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
看完这三段代码,你发现问题了吗?这三段代码,除了查询的条件不一样,剩下的结构几乎一模一样,这就是一种重复。
|
||||
|
||||
那么,我们要怎么消除这个重复呢?我们可以引入查询条件这个概念,这里只需要返回一个真假值,我们可以这样定义:
|
||||
|
||||
```
|
||||
interface Predicate<T> {
|
||||
boolean test(T t);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
有了查询条件,我们可以改造一下查询方法,把条件作为参数传进去:
|
||||
|
||||
```
|
||||
Student find(final Predicate<Student> predicate) {
|
||||
for (Student student : students) {
|
||||
if (predicate.test(student)) {
|
||||
return student;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
于是,按名字查找就会变成下面这个样子(其他两个类似,就不写了)。为了帮助你更好地理解,我没有采用Java 8的Lambda写法,而用了你最熟悉的对象:
|
||||
|
||||
```
|
||||
Student findByName(final String name) {
|
||||
return find(new Predicate<Student>() {
|
||||
@Override
|
||||
public boolean test(final Student student) {
|
||||
return name.equals(student.getName());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样是很好,但你会发现,每次有一个新的查询,你就要做一层这个封装。为了省去这层封装,我们可以把查询条件做成一个方法:
|
||||
|
||||
```
|
||||
static Predicate<Student> byName(final String name) {
|
||||
return new Predicate<Student>() {
|
||||
@Override
|
||||
public boolean test(final Student student) {
|
||||
return name.equals(student.getName();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其他几个字段也可以做类似的封装,这样一来,要查询什么就由使用方自己决定了:
|
||||
|
||||
```
|
||||
find(byName(name));
|
||||
find(bySno(sno));
|
||||
find(byId(id));
|
||||
|
||||
```
|
||||
|
||||
现在我们想用名字和学号同时查询,该怎么办呢?你是不是打算写一个byNameAndSno的方法呢?且慢,这样一来,岂不是每种组合你都要写一个?那还受得了吗。我们完全可以用已有的两个方法组合出一个新查询来,像这样:
|
||||
|
||||
```
|
||||
find(and(byName(name), bySno(sno)));
|
||||
|
||||
```
|
||||
|
||||
这里面多出一个and方法,它要怎么实现呢?其实也不难,按照正常的and逻辑写一个就好,像下面这样:
|
||||
|
||||
```
|
||||
static <T> Predicate<T> and(final Predicate<T>... predicates) {
|
||||
return new Predicate<T>() {
|
||||
@Override
|
||||
public boolean test(final T t) {
|
||||
for (Predicate<T> predicate : predicates) {
|
||||
if (!predicate.test(t)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
类似地,你还可以写出or和not的逻辑,这样,使用方能够使用的查询条件一下子就多了起来,他完全可以按照自己的需要任意组合。
|
||||
|
||||
这时候,又来了一个新需求,想找出所有指定年龄的人。写一个byAge现在已经很简单了。那找到所有人该怎么写呢?有了前面的基础也不难。
|
||||
|
||||
```
|
||||
Student findAll(final Predicate<Student> predicate) {
|
||||
List<Student> foundStudents = new ArrayList<Student>();
|
||||
for (Student student : students) {
|
||||
if (predicate.test(student)) {
|
||||
foundStudents.add(student);
|
||||
}
|
||||
}
|
||||
|
||||
return new Students(foundStudents);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如此一来,要做什么动作(查询一个、查询所有等)和用什么条件(名字、学号、ID 和年龄等)就成了两个维度,使用方可以按照自己的需要任意组合。
|
||||
|
||||
直到现在,我们所用的代码都是常规的Java代码,却产生了神奇的效应。这段代码的作者只提供了各种基本元素(动作和条件),而这段代码的用户通过组合这些基本的元素完成真正的需求。这种做法完全不同于常规的面向对象的做法,其背后的思想就源自函数式编程。在上面这个例子里面,让代码产生质变的地方就在于Predicate的引入,而它实际上就是一个函数。
|
||||
|
||||
这是一个简单的例子,但是我们可以发现,按照“消除重复”这样一个简单的编写代码逻辑,我们不断地调整代码,就是可以写出这种函数式风格的代码。在写代码这件事上,我们常常会有一种殊途同归的感觉。
|
||||
|
||||
现在,你已经对函数式编程应该有了一个初步的印象,接下来,我们看看函数式编程到底是什么。
|
||||
|
||||
## 函数式编程初步
|
||||
|
||||
函数式编程是一种编程范式,**它提供给我们的编程元素就是函数**。只不过,这个函数是来源于数学的函数,你可以回想一下,高中数学学到的那个f(x)。同我们习惯的函数相比,它要规避状态和副作用,换言之,同样的输入一定会给出同样的输出。
|
||||
|
||||
之所以说函数式编程的函数来自数学,因为它的起源是数学家Alonzo Church发明的Lambda演算(Lambda calculus,也写作 λ-calculus)。所以,Lambda这个词在函数式编程中经常出现,你可以简单地把它理解成**匿名函数**。
|
||||
|
||||
我们这里不关心Lambda演算的数学逻辑,你只要知道,Lambda演算和图灵机是等价的,都是那个年代对“计算”这件事探索的结果。
|
||||
|
||||
我们现在接触的大多数程序设计语言都是从图灵机的模型出发的,但既然二者是等价的,就有人选择从Lambda演算出发。比如早期的函数式编程语言LISP,它在20 世纪50年代就诞生了,是最早期的几门程序设计语言之一。它的影响却是极其深远的,后来的函数式编程语言可以说都直接或间接受着它的影响。
|
||||
|
||||
虽然说函数式编程语言早早地就出现了,但函数式编程这个概念却是John Backus在其[1977 年图灵奖获奖的演讲](https://www.thocp.net/biographies/papers/backus_turingaward_lecture.pdf)上提出来。有趣的是,John Backus 获奖的理由是他在Fortran语言上的贡献,而这门语言和函数式编程刚好是两个不同“计算”模型的极端。
|
||||
|
||||
了解了函数式编程产生的背景之后,我们就可以正式打开函数式编程的大门了。
|
||||
|
||||
函数式编程第一个需要了解的概念就是函数。在函数式编程中,函数是一等公民(first-class citizen)。一等公民是什么意思呢?
|
||||
|
||||
- 它可以按需创建;
|
||||
- 它可以存储在数据结构中;
|
||||
- 它可以当作实参传给另一个函数;
|
||||
- 它可以当作另一个函数的返回值。
|
||||
|
||||
对象,是面向对象程序设计语言的一等公民,它就满足所有上面的这些条件。在函数式编程语言里,函数就是一等公民。函数式编程语言有很多,经典的有LISP、Haskell、Scheme等,后来也出现了一批与新平台结合紧密的函数式编程语言,比如:Clojure、F#、Scala等。
|
||||
|
||||
很多语言虽然不把自己归入函数式编程语言,但它们也提供了函数式编程的支持,比如支持了Lambda的,这类的语言像Ruby、JavaScript等。
|
||||
|
||||
**如果你的语言没有这种一等公民的函数支持,完全可以用某种方式模拟出来**。在前面的例子里,我们就用对象模拟出了一个函数,也就是Predicate。在旧版本的C++中,也可以用functor(函数对象)当作一等公民的函数。在这两个例子中,既然函数是用对象模拟出来的,自然就符合一等公民的定义,可以方便将其传来传去。
|
||||
|
||||
在开头,我提到过,随着函数式编程这几年蓬勃的发展,越来越多的“老”程序设计语言已经在新的版本中加入了对函数式编程的支持。所以,如果你用的是新版本,可以不必像我写得那么复杂。
|
||||
|
||||
比如,在Java里,Predicate本身就是JDK自带的,and方法也不用自己写,加上有Lambda语法简化代码的编写,代码可以写成下面这样,省去了构建一个匿名内部类的繁琐:
|
||||
|
||||
```
|
||||
static Predicate<Student> byName(String name) {
|
||||
return student -> student.getName().equals(name);
|
||||
}
|
||||
|
||||
find(byName(name).and(bySno(sno)));
|
||||
|
||||
```
|
||||
|
||||
如果按照对象的理解方式,Predicate是一个对象接口,但它可以接受一个Lambda为其赋值。有了前面的基础,你可以把它理解成一个简化版的匿名内部类。其实,这里面主要工作都在编译器上,它帮助我们做了类型推演(Type Inference)。
|
||||
|
||||
在Java里,可以表示一个函数的接口还有几个,比如,Function(一个参数一个返回值)、Supplier(没有参数只有返回值),以及一大堆形式稍有不同的变体。
|
||||
|
||||
这些“函数”的概念为我们提供了一些基础的构造块,从前面的例子,你可以看出,函数式编程一个有趣的地方就在于这些构造块可以组合起来,这一点和面向对象是类似的,都是由基础的构造块逐步组合出来的。
|
||||
|
||||
我们讲模型也好,面向对象也罢,对于这种用小组件逐步叠加构建世界的思路已经很熟悉了,在函数式编程里,我们又一次领略到同样的风采,而这一切的出发点,就是“函数”。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一讲我们讨论了**函数式编程**这种编程范式,它给我们提供的编程元素是函数。只不过,这个函数不同于传统程序设计语言的函数,它的思想根源是数学中的**函数**。
|
||||
|
||||
函数是函数式编程的一等公民(first-class citizen)。一等公民指的是:
|
||||
|
||||
- 它可以按需创建;
|
||||
- 它可以存储在数据结构中;
|
||||
- 它可以当作实参传给另一个函数;
|
||||
- 它可以当作另一个函数的返回值。
|
||||
|
||||
如果你使用的程序设计语言不支持函数是一等公民,可以用其他的方式模拟出来,比如,用对象模拟函数。随着函数式编程的兴起,越来越多的程序设计语言加入了自己的函数,比如:Java和C++增加了Lambda,可以在一定程度上支持函数式编程。
|
||||
|
||||
函数式编程就是把函数当做一个个的构造块,然后将这些函数组合起来,构造出一个新的构造块。这样有趣的事情就来了。下一讲,我们来看看这件有趣的事,看函数式编程中是怎么组合函数的。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**函数式编程的要素是一等公民的函数,如果语言不支持,可以自己模拟。**
|
||||
|
||||
## 思考题
|
||||
|
||||
今天我们开始了函数式编程的讲解,我想请你谈谈函数式编程给你留下的最深刻印象,无论是哪门函数式编程语言也好,还是某个函数式编程的特性也罢。欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
181
极客时间专栏/软件设计之美/设计一个软件—编程范式/18 | 函数式编程之组合性:函数式编程为什么如此吸引人?.md
Normal file
181
极客时间专栏/软件设计之美/设计一个软件—编程范式/18 | 函数式编程之组合性:函数式编程为什么如此吸引人?.md
Normal file
@@ -0,0 +1,181 @@
|
||||
<audio id="audio" title="18 | 函数式编程之组合性:函数式编程为什么如此吸引人?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a5/05/a58147a2c476ab47cfc11bf9eda0ec05.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
从上一讲开始,我们开启了函数式编程之旅,相信你已经对函数式编程有了一个初步的认识。函数式编程是一种以函数为编程元素的编程范式。但是,如果只有函数这一样东西,即使是说出花来,也没有什么特别的地方。
|
||||
|
||||
之前我讲过,GC来自于函数式编程,Lambda也来自于函数式编程。此外,在 Java 8增加的对函数式编程的处理中,流(Stream)的概念也从函数式编程中来,Optional也和函数式编程中的一些概念有着紧密的联系。由此可见,函数式编程给我们提供了许多优秀的内容。
|
||||
|
||||
接下来,我们来**讲讲函数式编程在设计上对我们帮助最大的两个特性:组合性和不变性。**
|
||||
|
||||
首先,我们来讨论一下组合性,看看函数式编程为什么能够如此吸引人。
|
||||
|
||||
## 组合行为的高阶函数
|
||||
|
||||
在函数式编程中,有一类比较特殊的函数,它们可以接收函数作为输入,或者返回一个函数作为输出。这种函数叫做**高阶函数**(High-order function)。
|
||||
|
||||
听上去稍微有点复杂,如果我们回想一下高中数学里有一个复合函数的概念,也就是 f(g(x)) ,把一个函数和另一个函数组合起来,这么一类比,是不是就好接受一点了。
|
||||
|
||||
那么,**高阶函数有什么用呢?它的一个重要作用在于,我们可以用它去做行为的组合**。我们再来回顾一下上一讲写过的一段代码:
|
||||
|
||||
```
|
||||
find(byName(name).and(bySno(sno)));
|
||||
|
||||
```
|
||||
|
||||
在这里面,find的方法就扮演了一个高阶函数的角色。它接收了一个函数作为参数,由此,一些处理逻辑就可以外置出去。这段代码的使用者,就可以按照自己的需要任意组合。
|
||||
|
||||
你可能注意到了,这里的find方法只是一个普通的Java函数。是这样的,如果不需要把这个函数传来传去,普通的Java函数也可以扮演高阶函数的角色。
|
||||
|
||||
可以这么说,高阶函数的出现,让程序的编写方式出现了质变。按照传统的方式,程序库的提供者要提供一个又一个的完整功能,就像findByNameAndBySno这样,但按照函数式编程的理念,提供者提供的就变成了一个又一个的构造块,像find、byName、bySno这样。然后,使用者可以根据自己的需要进行组合,非常灵活,甚至可以创造出我们未曾想过的组合方式。
|
||||
|
||||
这就是典型的函数式编程风格。**模型提供者提供出来的是一个又一个的构造块,以及它们的组合方式。由使用者根据自己需要将这些构造块组合起来,提供出新的模型,供其他开发者使用**。就这样,模型之间一层又一层地逐步叠加,最终构建起我们的整个应用。
|
||||
|
||||
前面我们讲过,一个好模型的设计就是逐层叠加。**函数式编程的组合性,就是一种好的设计方式**。
|
||||
|
||||
但是,能把模型拆解成多个可以组合的构造块,这个过程非常考验人的洞察力,也是“分离关注点”的能力,但是这个过程可以让人得到一种智力上的愉悦。为什么函数式编程一直处于整个IT行业的角落里,还能吸引一大批优秀的开发者前赴后继地投入其中呢?这种智力上的愉悦就是一个重要的原因。
|
||||
|
||||
还记得我们在课程一开始讲的分层模型吗?这一点在函数式编程社区得到了非常好的体现。著名的创业孵化器[Y Combinator](https://www.ycombinator.com/)的创始人Paul Graham曾经写过一篇文章《[The Roots of Lisp](http://www.paulgraham.com/rootsoflisp.html)》([中文版](http://daiyuwen.freeshell.org/gb/rol/roots_of_lisp.html)),其中用了七个原始操作符加上函数定义的方式,构建起一门LISP语言。
|
||||
|
||||
没错,是构建了一门语言。有了语言,你就可以去完成任何你想做的事了。这篇文章非常好地体现了函数式编程社区这种逐步叠加构建模型的思想。有兴趣的话,你可以去读一下。
|
||||
|
||||
当我们把模型拆解成小的构造块,如果构造块足够小,我们自然就会发现一些通用的构造块。
|
||||
|
||||
## 列表转换思维
|
||||
|
||||
我们说过,早期的函数式编程探索是从LISP语言开始的。LISP这个名字源自“List Processing”,这个名字指明了这个语言中的一个核心概念:List,也就是列表。程序员对List并不陌生,这是一种最为常用的数据结构,现在的程序语言几乎都提供了各自List的实现。
|
||||
|
||||
LISP 的一个洞见就是,大部分操作最后都可以归结成列表转换,也就是说,数据经过一系列的列表转换会得到一个结果,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3c/a7/3cbf06a7962dc5ed3db56d3f93859aa7.jpg" alt="">
|
||||
|
||||
**想要理解这一系列的转换,就要先对每个基础的转换有所了解。最基础的列表转换有三种典型模式,分别是map、filter和reduce**。如果我们能够正确理解它们,基本上就可以把for循环抛之脑后了。做过大数据相关工作的同学一定听说过一个概念:MapReduce,这是最早的一个大数据处理框架,这里的map和reduce就是源自函数式编程里列表转换的模式。
|
||||
|
||||
接下来,我们就来一个一个地看看它们分别是什么。
|
||||
|
||||
首先是map。map就是把一组数据通过一个函数映射为另一组数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ea/a2/ea79b6143783def94684df60023811a2.jpg" alt="">
|
||||
|
||||
比如,我有一组数[1、2、3、4],然后做了一个map操作,这里用作映射的函数是乘以2,也就是说,这组数里面的每个元素都乘以2,这样,我就得到了一组新的数[2、4、6、8]。
|
||||
|
||||
再来看filter。filter是把一组数据按照某个条件进行过滤,只有满足条件的数据才会留下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/16/8f1653c0aa10e5c97b266ea898ec2f16.jpg" alt="">
|
||||
|
||||
同样[1、2、3、4]为例,我们做一个filter操作,过滤的函数是大于2,也就是说,只有大于2的数才会留下,得到的结果就是[3、4]。
|
||||
|
||||
最后是reduce。reduce就是把一组数据按照某个规则,归约为一个数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fc/62/fcfcd8af1e638839b831c932eae9e962.jpg" alt="">
|
||||
|
||||
还是[1、2、3、4],如果我们做一个reduce操作,其归约函数是一个加法操作,也就是这组数里面的每个元素相加,最终会得到一个结果,也就是 1+2+3+4=10。
|
||||
|
||||
好,有了基础之后,我们就可以利用这些最基础的转换模式去尝试解决问题了。比如,上一讲我们讲了一个学生的例子,现在,我们想知道这些学生里男生的总数。我们可以给Student类增加一个性别的字段:
|
||||
|
||||
```
|
||||
// 单个学生的定义
|
||||
class Student {
|
||||
...
|
||||
// 性别
|
||||
private Gender gender;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
要想知道男生的总数,传统做法应该是这么做:
|
||||
|
||||
```
|
||||
long countMale() {
|
||||
long count = 0;
|
||||
for (Student student : students) {
|
||||
if (Gender.MALE == student.getGender())) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
按照列表转换的思维来做的话,我们该怎么做呢?首先,要把这个过程做一个分解:
|
||||
|
||||
- 取出性别字段;
|
||||
- 判别性别是否为男性;
|
||||
- 计数加1。
|
||||
|
||||
这三步刚好对应着map、filter和reduce:
|
||||
|
||||
- 取出性别字段,对应着map,其映射函数是取出学生的性别字段;
|
||||
- 判别性别是否为男性,对应filter,其过滤函数是,性别为男性;
|
||||
- 计数加1,对应着reduce,其归约函数是,加1。
|
||||
|
||||
有了这个分解的结果,我们再把它映射到代码上。Java 8对于函数式编程的支持,除了Lambda之外,它也增加了对列表转换的支持。为了兼容原有的API,它提供了一个新的接口:Stream,你可以把它理解成List的另一种表现形式。如果把上面的步骤用Java 8的Stream方式写出来,代码应该是这样的:
|
||||
|
||||
```
|
||||
long countMale() {
|
||||
return students.stream()
|
||||
.map(student -> student.getGender())
|
||||
.filter(gender -> gender == Gender.MALE)
|
||||
.map(gender -> 1L)
|
||||
.reduce(0L, (sum, element) -> sum + element);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这基本和上面操作步骤是一一对应的,只是多了一步将性别转换成1,便于后面的计算。
|
||||
|
||||
map、filter和reduce只是最基础的三个操作,列表转换可以提供的操作远远比这个要多。不过,你可以这么理解,大多数都是在这三个基础上进行了封装,提供一种快捷方式。比如,上面代码的最后两步map和reduce,在Java 8的Stream接口提供了一个count方式,可以写成方法:
|
||||
|
||||
```
|
||||
long countMale() {
|
||||
return students.stream()
|
||||
.map(Student::getGender)
|
||||
.filter(byGender(Gender.MALE))
|
||||
.count();
|
||||
}
|
||||
|
||||
static Predicate<Gender> byGender(final Gender target) {
|
||||
return gender -> gender == target;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
一方面,我用了方法引用(Student::getGender),这是Java提供的简化代码编写的一种方式。另一方面,我还把按照性别比较提取了出来,如此一来,代码的可读性就提升了,你基本上可以把它同前面写的操作步骤完全对应起来了。
|
||||
|
||||
同样是一组数据的处理,我更鼓励使用函数式的列表转换,而不是传统的 for 循环。一方面因为它是一种更有表达性的写法,从前面的代码就可以看到,它几乎和我们想做的事是一一对应的。另一方面,这里面提取出来比较性别的方法,它就是一个可以用作组合的基础接口,可以在多种场合复用。
|
||||
|
||||
很多Java程序员适应不了这种写法,一个重要的原因在于,他们缺少对于列表转换的理解。缺少了一个重要的中间环节,必然会出现不适。
|
||||
|
||||
你回想一下,我们说过结构化编程给我们提供了一些基础的控制结构,那其实也是一层封装,只不过,我们在编程之初就熟悉了if、for之类的写法。如果你同样熟悉函数式编程的基础设施,这些代码理解起来同那些控制结构没有什么本质区别,而且这些基础设施的抽象级别要比那些控制结构更高,提供了更好的表达性。
|
||||
|
||||
我们之前在讲DSL的时候就谈到过代码的表达性,其中一个重要的观点就是,有一个描述了做什么的接口之后,具体怎么做就可以在背后不断地进行优化。比如,如果一个列表的数据特别多,我们可以考虑采用并发的方式进行处理,而这种优化在使用端完全可以做到不可见。MapReduce 甚至将运算分散到不同的机器上执行,其背后的逻辑是一致的。
|
||||
|
||||
## 面向对象与函数式编程的组合
|
||||
|
||||
至此,我们已经学习了函数式编程的组合。你可能会有一个疑问,我们之前在讲面向对象的时候,也谈到了组合,这里讲函数式编程,又谈到了组合。这两种组合之间是什么关系呢?其实,对比一下代码,你就不难发现了,面向对象组合的元素是类和对象,而函数式编程组合的是函数。
|
||||
|
||||
这也就牵扯到在实际工作中,如何将面向对象和函数式编程两种不同的编程范式组合运用的问题。**我们可以用面向对象编程的方式对系统的结构进行搭建,然后,用函数式编程的理念对函数接口进行设计**。你可以把它理解成盖楼,用面向对象编程搭建大楼的骨架,用函数式编程设计门窗。
|
||||
|
||||
通过这两讲的例子,相信你已经感受到,一个好的函数式的接口,需要我们做的同样是“分离关注点”。虽然你不知道组合的方式会有多少种,但你知道,所有的变化其实就是一些基础元素的不断组合。在后面的巩固篇中,讲到Moco时,我们还会领略到这种函数式接口的魅力。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我先给你讲了一类特殊的函数——高阶函数,它可以接受函数或返回函数。有了高阶函数,函数式编程就可以组合了,把不同的函数组合在一起完成功能,这也给逐层构建新抽象埋下了伏笔,函数式编程从此变得精彩起来。从设计的角度看,这种模型的层层叠加,是一种好的设计方式。
|
||||
|
||||
函数式编程中,还有一个重要的体系,就是列表转换的思想,将很多操作分解成若干转换的组合。最基础的三个转换是:map、filter和reduce,更多的转换操作都可以基于这三个转换完成。
|
||||
|
||||
面向对象和函数式编程都提到了组合性,不同的是,面向对象关键在于结构的组合,而函数式编程在于函数接口的组合。
|
||||
|
||||
组合性为我们提供了一个让函数接口组合的方式,下一讲我们再来讲一个让代码减少Bug的设计理念:不变性。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**设计可以组合的函数接口**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3a/61/3a8b5db0d00f84bacf93d9bf80e10d61.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
函数式编程的组合性会给人带来极大的智力愉悦,你在学习软件开发的过程中,还有哪些东西曾经给你带来极大的智力愉悦呢?欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
158
极客时间专栏/软件设计之美/设计一个软件—编程范式/19 | 函数式编程之不变性:怎样保证我的代码不会被别人破坏?.md
Normal file
158
极客时间专栏/软件设计之美/设计一个软件—编程范式/19 | 函数式编程之不变性:怎样保证我的代码不会被别人破坏?.md
Normal file
@@ -0,0 +1,158 @@
|
||||
<audio id="audio" title="19 | 函数式编程之不变性:怎样保证我的代码不会被别人破坏?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/98/93/98b2b3a6ef9b6564e4f023cda3e9a893.mp3"></audio>
|
||||
|
||||
你好!我是郑晔。
|
||||
|
||||
经过前两讲的介绍,你已经认识到了函数式编程的能力,函数以及函数之间的组合很好地体现出了函数式编程的巧妙之处。不过,我们在讲编程范式时说过,学习编程范式不仅要看它提供了什么,还要看它约束了什么。这一讲,我们就来看看函数式编程对我们施加的约束。
|
||||
|
||||
在软件开发中,有一类Bug是很让人头疼的,就是你的代码怎么看都没问题,可是运行起来就是出问题了。我曾经就遇到过这样的麻烦,有一次我用C写了一个程序,怎么运行都不对。我翻来覆去地看自己的代码,看了很多遍都没发现问题,不得已,只能一步一步跟踪代码。最后,我发现我的代码调用到一个程序库时,出现了与预期不符的结果。
|
||||
|
||||
这个程序库是其他人封装的,我只是拿过来用。按理说,我调用的这个函数逻辑也不是特别复杂,不应该出现什么问题。不过,为了更快定位问题,我还是打开了这个程序库的源代码。经过一番挖掘,我发现在这个函数底层实现中,出现了一个全局变量。
|
||||
|
||||
分析之后,我发现正是这个全局变量引起了这场麻烦,因为在我的代码执行过程中,有别的程序会调用另外的函数,修改这个全局变量的值,最终,导致了我的程序执行失败。从表面上看,我调用的这个函数和另外那个函数八竿子都打不到,但是,它们却通过一个底层的全局变量,产生了相互的影响。
|
||||
|
||||
这就是一类非常让人头疼的Bug。有人认为这是全局变量使用不当造成的,在Java设计中,甚至取消了全局变量,但类似的问题并没有因此减少,只是以不同面貌展现出来而已,比如,static 变量。
|
||||
|
||||
那么造成这类问题的真正原因是什么呢?**真正原因就在于变量是可变的**。
|
||||
|
||||
## 变之殇
|
||||
|
||||
你可能会好奇,难道变量不就应该是变的吗?为了更好地理解这一类问题,我们来看一段代码:
|
||||
|
||||
```
|
||||
class Sample1 {
|
||||
private static final DateFormat format =
|
||||
new SimpleDateFormat("yyyy.MM.dd");
|
||||
|
||||
public String getCurrentDateText() {
|
||||
return format.format(new Date());
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果你不熟悉JDK的SimpleDateFormat,你可能会觉得这段代码看上去还不错。然而,这段代码在多线程环境下就会出问题。正确的用法应该是这样:
|
||||
|
||||
```
|
||||
public class Sample2 {
|
||||
public String getCurrentDateText() {
|
||||
DateFormat format = new SimpleDateFormat("yyyy.MM.dd");
|
||||
return format.format(new Date());
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
两段代码最大的区别就在于,SimpleDateFormat在哪里构建。一个是被当作了一个字段,另一个则是在函数内部构建出来。这两种不同做法的根本差别就在于,SimpleDateFormat对象是否共享。
|
||||
|
||||
为什么这个对象共享会有问题呢?翻看format方法的源码,你会发现这样一句:
|
||||
|
||||
```
|
||||
calendar.setTime(date);
|
||||
|
||||
```
|
||||
|
||||
这里的calendar是SimpleDateFormat这个类的一个字段,正是因为在format的过程中修改了calendar字段,所以,它才会出问题。
|
||||
|
||||
我们来看看这种问题是怎么出现的,就像下面这张图看到的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/db/d267c5c0ec206bef0eeae93f056d50db.jpg" alt="">
|
||||
|
||||
- A线程把变量的值修改成自己需要的值;
|
||||
- 这时发生线程切换,B线程开始执行,将变量的值修改成它所需要的值;
|
||||
- 线程切换回来,A线程继续执行,但此时变量已经不是自己设置的值了,所以,执行会出错。
|
||||
|
||||
回到SimpleDateFormat上,问题是一样的,calendar就是那个共享的变量。一个线程刚刚设置的值,可能会被另外一个线程修改掉,因此会造成结果的不正确。而在Sample2的写法中,通过每次创建一个新的SimpleDateFormat对象,我们将二者之间的共享解开,规避了这个问题。
|
||||
|
||||
那如果我还是想按照Sample1的写法写,SimpleDateFormat这个库应该怎么改写呢?可能你会想,SimpleDateFormat的作者没写好,如果换我写,我就会给它加上一个同步(synchronized)或者加上锁(Lock)。你甚至都没有注意,你轻易地将多线程的复杂性引入了进来。还记得我在分离关注点那节讨论的问题吗,多线程是另外一个关注点,能少用,尽量少用。
|
||||
|
||||
一个更好的办法是将calendar变成局部变量,这样一来,不同线程之间共享变量的问题就得到了根本的解决。但是,这类非常头疼的问题在函数式编程中却几乎不存在,这就依赖于函数式编程的不变性。
|
||||
|
||||
## 不变性
|
||||
|
||||
函数式编程的不变性主要体现在值和纯函数上。值,你可以将它理解为一个初始化之后就不再改变的量,换句话说,当你使用一个值的时候,值是不会变的。纯函数,是符合下面两点的函数:
|
||||
|
||||
- 对于相同的输入,给出相同的输出;
|
||||
- 没有副作用。
|
||||
|
||||
把值和纯函数合起来看,**值保证不会显式改变一个量**,**而纯函数保证的是**,**不会隐式改变一个量**。
|
||||
|
||||
我们说过,函数式编程中的函数源自数学中的函数。在这个语境里,函数就是纯函数,一个函数计算之后是不会产生额外的改变的,而函数中用到的一个一个量就是值,它们是不会随着计算改变的。所以,在函数式编程中,计算天然就是不变的。
|
||||
|
||||
正是由于不变性的存在,我们在前面遇到的那些问题也就不再是问题了。一方面,如果你拿到一个量,这次的值是1,下一次它还是1,我们完全不用担心它会改变。另一方面,我们调用一个函数,传进去同样的参数,它保证给出同样的结果,行为是完全可以预期的,不会碰触到其他部分。即便是在多线程的情况下,我们也不必考虑同步的问题,后续一系列的问题也就不存在了。
|
||||
|
||||
这与我们习惯的方式有着非常大的区别,因为传统方式的基础是面向内存单元的,改来改去甚至已经成为了程序员的本能。所以,我们对counter = counter + 1这种代码习以为常,而初学编程的人总会觉得这在数学上是不成立的。
|
||||
|
||||
在之前的讨论中,我们说过,传统的编程方式占优的地方是执行效率,而现如今,这个优点则越来越不明显,反而是因为到处可变而带来了更多的问题。相较之下,我们更应该在现在的设计中,考虑借鉴函数式编程的思路,把不变性更多地应用在我们的代码之中。
|
||||
|
||||
那怎么应用呢?首先是值。我们可以编写不变类,就是对象一旦构造出来就不能改变,Java程序员最熟悉的不变类应该就是String类,怎样编写不变类呢?
|
||||
|
||||
- 所有的字段只在构造函数中初始化;
|
||||
- 所有的方法都是纯函数;
|
||||
- 如果需要有改变,返回一个新的对象,而不是修改已有字段。
|
||||
|
||||
前面两点可能还好理解,最后一点,我们可以看一下Java String类的replace方法签名:
|
||||
|
||||
```
|
||||
String replace(char oldChar, char newChar);
|
||||
|
||||
```
|
||||
|
||||
在这里,我们会用一个新的字符(newChar)替换掉这个字符串中原有的字符(oldChar),但我们并不是直接修改已有的这个字符串,而是创建一个新的字符串对象返回。这样一来,使用原来这个字符串的类并不用担心自己引用的内容会随之变化。
|
||||
|
||||
有了这个基础,等我们后面学习领域驱动设计的时候,你就很容易理解值对象(Value Object)是怎么回事了。
|
||||
|
||||
我们再来看纯函数。**编写纯函数的重点是**,**不修改任何字段**,**也不调用修改字段内容的方法**。因为在实际的工作中,我们使用的大多数都是传统的程序设计语言,而不是严格的函数式编程语言,不是所有用到的量都是值。所以,站在实用性的角度,如果要使用变量,就使用局部变量。
|
||||
|
||||
还有一个实用性的编程建议,就是使用语法中不变的修饰符,比如,Java就尽可能多使用final,C/C++就多写const。无论是修饰变量还是方法,它们的主要作用就是让编译器提醒你,要多从不变的角度思考问题。
|
||||
|
||||
当你有了用不变性思考问题的角度,你会发现之前的很多编程习惯是极其糟糕的,比如,Java程序员最喜欢写的setter,它就是提供了一个接口,修改一个对象内部的值。
|
||||
|
||||
不过,纯粹的函数式编程是很困难的,我们只能把编程原则设定为**尽可能编写不变类和纯函数**。但仅仅是这么来看,你也会发现,自己从前写的很多代码,尤其是大量负责业务逻辑处理的代码,完全可以写成不变的。
|
||||
|
||||
绝大多数涉及到可变或者副作用的代码,应该都是与外部系统打交道的。能够把大多数代码写成不变的,这已经是一个巨大的进步,也会减少许多后期维护的成本。
|
||||
|
||||
而正是不变性的优势,有些新的程序设计语言默认选项不再是变量,而是值。比如,在Rust里,你这么声明的是一个值,因为一旦初始化了,你将无法修改它:
|
||||
|
||||
```
|
||||
let result = 1;
|
||||
|
||||
```
|
||||
|
||||
而如果你想声明一个变量,必须显式地告诉编译器:
|
||||
|
||||
```
|
||||
let mut result = 1;
|
||||
|
||||
```
|
||||
|
||||
Java也在尝试将值类型引入语言,有一个专门的[Valhalla 项目](http://openjdk.java.net/projects/valhalla/)就是做这个的。你也看到了,不变性,是减少程序问题的一个重要努力方向。
|
||||
|
||||
现在回过头来看编程范式那一讲里说的约束:
|
||||
|
||||
>
|
||||
函数式编程,限制使用赋值语句,它是对程序中的赋值施加了约束。
|
||||
|
||||
|
||||
理解了不变性,你应该知道这句话的含义了,一旦初始化好一个量,就不要随便给它赋值了。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我们讲了无论是全局变量、还是多线程,变化给程序设计带来了很多麻烦,然后我们还分析了这类问题的成因。
|
||||
|
||||
然而,这类问题在函数式编程中并不存在。其中,重要的原因就是函数式编程的不变性。函数式编程的不变性主要体现在它的值和纯函数上。深入学习函数式编程时,你会遇到的与之相关的各种说法:无副作用、无状态、引用透明等等,其实都是在讨论不变性。
|
||||
|
||||
即便使用传统的程序设计语言,我们也可以从中借鉴一些编程的方法。比如,编写不变类、编写纯函数、尽量使用不变的修饰符等等。
|
||||
|
||||
经过了这三讲的介绍,相信你已经对函数式编程有了很多认识,不过,我只是把设计中最常用的部分给你做了一个介绍,这远远不是函数式编程的全部。就算Java这种后期增补的函数式编程的语言,其中也包含了惰性求值、Optional等诸多内容,值得你去深入了解。不过我相信有了前面知识的铺垫,你再去学习函数式编程其他相关内容,难度系数就会降低一些。
|
||||
|
||||
关于编程范式的介绍,我们就告一段落,下一讲,我们开始介绍设计原则。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**尽量编写不变类和纯函数。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/24/1e/24e5693b11652ff520e01fce5648b11e.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
最后,我想请你去了解一下[Event Sourcing](http://microservices.io/patterns/data/event-sourcing.html),结合今天的内容,谈谈你对它的理解。欢迎在留言区写下你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
331
极客时间专栏/软件设计之美/设计一个软件—编程范式/加餐 | 函数式编程拾遗.md
Normal file
331
极客时间专栏/软件设计之美/设计一个软件—编程范式/加餐 | 函数式编程拾遗.md
Normal file
@@ -0,0 +1,331 @@
|
||||
<audio id="audio" title="加餐 | 函数式编程拾遗" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8b/e6/8b242017466e3413701257479967dee6.mp3"></audio>
|
||||
|
||||
你好,我是郑晔!
|
||||
|
||||
我们之前用了三讲的篇幅讲了函数式编程,相信函数式编程在你心目中已经没有那么神秘了。我一直很偏执地认为,想要成为一个优秀的程序员,函数式编程是一定要学习的,它简直是一个待人发掘的宝库,因为里面的好东西太多了。
|
||||
|
||||
不过,考虑到整个课程的主线,我主要选择了函数式编程在设计上有较大影响的组合性和不变性来讲。但其实,函数式编程中有一些内容,虽然不一定是在设计上影响那么大,但作为一种编程技巧,也是非常值得我们去了解的。
|
||||
|
||||
所以,我准备了这次加餐,从函数式编程再找出一些内容来,让你来了解一下。相信我,即便你用的不是函数式编程语言,这些内容对你也是很有帮助的。
|
||||
|
||||
好,我们出发!
|
||||
|
||||
## 惰性求值
|
||||
|
||||
还记得我们第17讲的那个学生的例子吗?我们继续使用学生这个类。这次简化一点,我只使用其中的几个字段:
|
||||
|
||||
```
|
||||
class Student {
|
||||
// 学生姓名
|
||||
private String name;
|
||||
// 年龄
|
||||
private long age;
|
||||
// 性别
|
||||
private Gender gender;
|
||||
|
||||
public Student(final String name,
|
||||
final long age,
|
||||
final Gender gender) {
|
||||
this.name = name;
|
||||
this.age = age;
|
||||
this.gender = gender;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然后,我们来看一段代码,你先猜猜这段代码的执行结果会是什么样子:
|
||||
|
||||
```
|
||||
// 数据准备
|
||||
Student jack = new Student("Jack", 18, Gender.MALE);
|
||||
Student rose = new Student("Rose", 18, Gender.FEMALE);
|
||||
List<Person> students = asList(jack, rose);
|
||||
|
||||
// 模拟对象
|
||||
Function<Person, String> function = mock(Function.class);
|
||||
when(function.apply(jack)).thenReturn("Jack");
|
||||
|
||||
// 映射
|
||||
students.stream().map(function);
|
||||
|
||||
// 验证
|
||||
verify(function).apply(jack);
|
||||
|
||||
```
|
||||
|
||||
这段代码里,我们用到了一个mock框架mockito,核心就是验证这里的function变量是否得到了正确的调用,这其中就用到了我们在第18讲中提到的map函数。
|
||||
|
||||
也许你已经猜到了,虽然按照普通的Java代码执行逻辑,verify的结果一定是function得到了正常的调用,但实际上,这里的function并没有调用。也就是说,虽然看上去map函数执行了,但并没有调用到function的apply方法。你可以试着执行这段代码去验证一下。
|
||||
|
||||
为什么会是这样呢?答案就在于这段代码是惰性求值的。
|
||||
|
||||
什么叫惰性求值呢?**惰性求值(Lazy Evaluation)是一种求值策略,它将求值的过程延迟到真正需要这个值的时候**。惰性求值的好处就在于可以规避一些不必要的计算,尤其是规模比较大,或是运行时间比较长的计算。
|
||||
|
||||
其实,如果你学习过设计模式,惰性求值这个概念你应该并不陌生。有一些设计模式就是典型的惰性求值,比如,Proxy模式,它就是采用了惰性求值的策略,把一些消耗很大的计算延迟到不得不算的时候去做。还有Singleton模式有时也会采用惰性求值的策略,在第一次访问的时候,再去生成对象。
|
||||
|
||||
在函数式编程中,惰性求值是一种很常见的求值策略,也是因为惰性求值的存在,我们可以做出很多有趣的事情。
|
||||
|
||||
## 无限流
|
||||
|
||||
在传统的编程方式中,我们熟悉的集合类都是有限长度的,因为集合中的每个元素都是事先计算好的。但现在有了惰性求值,我们就可以创造出一个无限长的集合。
|
||||
|
||||
你可能会有疑问,怎么可能会有无限长的集合呢?内存也存不下啊?如果你这么想的话,说明你的思路还是传统的方式。无限长集合中的元素并不是预置进去的,而是在需要的时候,才计算出来的。
|
||||
|
||||
**无限长集合真正预置进去的是,元素的产生规则**。这样一来,元素就会像流水一样源源不断地产生出来,我们将这种集合称为无限流(Infinite Stream)。
|
||||
|
||||
比如,我们要产生一个自然数的集合,可以这么做:
|
||||
|
||||
```
|
||||
Stream.iterate(1, number -> number + 1)
|
||||
|
||||
```
|
||||
|
||||
在这里,我们定义了这个集合的第一个元素,然后给出了后续元素的推导规则,一个无限流就产生了。
|
||||
|
||||
当然,因为惰性求值的存在,这么定义的一个无限流并不会做真正的计算,只有在我们需要用到其中的一些元素时,计算才会执行。比如,我们可以按需取出一些元素,在下面这段代码中,我们跳过了无限流的前两个元素,然后,取出三个元素,将结果打印了出来:
|
||||
|
||||
```
|
||||
Stream.iterate(0, number -> number + 1)
|
||||
.skip(2)
|
||||
.limit(3)
|
||||
.forEach(System.out::println);
|
||||
|
||||
```
|
||||
|
||||
也许你会关心,什么情况下无限流才会真正的求值呢?其实,我们前面讲组合性时提到过,有一些基础的列表操作,列表操作可以分为两类,中间操作(Intermediate Operation)和终结操作(Terminal Operation),像map和filter这一类的就是中间操作,而像reduce一类的就属于终结操作。只有终结操作才需要我们给出一个结果,所以,只有终结操作才会引起真正的计算。
|
||||
|
||||
你可能会好奇,无限流的概念很有意思,但它有什么用呢?如果你对无限流有了认识,很多系统的设计都可以看作成一个无限流。比如,一些大数据平台,它就是有源源不断的数据流入其中,而我们要做的就是给这个无限流提供各种转换,你去看看现在炙手可热的Flink,它使用的就是这种思路。
|
||||
|
||||
## 记忆
|
||||
|
||||
我们再来看另一个关于惰性求值带来的有趣做法:记忆(Memoization)。
|
||||
|
||||
前面说过,Proxy模式之所以要采用惰性求值的策略,一个重要的原因就是真正的计算部分往往是消耗很大的。所以,一旦计算完成,一个好的策略就是将计算的结果缓存起来,这样,再次调用时就不必重新计算了。其实,这种做法就是记忆。
|
||||
|
||||
记忆,在Wikipedia上是这样定义的:
|
||||
|
||||
>
|
||||
在计算中,记忆是一种优化技术,主要用于加速计算机程序,其做法就是将昂贵函数的结果存储起来,当使用同样的输入再次调用时,返回其缓存的结果。
|
||||
|
||||
|
||||
这里的一个重点是,同样的输入。我们已经知道了,函数式编程中的函数是纯函数,同样的输入必然会给出同样的输出。所以,我们就不难理解,记忆这种技术在函数式编程中的作用了。
|
||||
|
||||
实现记忆这种技术并不难,下面就给出了一个实现,这里用到了Java并发库中的类AtomicReference,从而消除了可能产生的多线程问题:
|
||||
|
||||
```
|
||||
public static <T> Supplier<T> memoize(Supplier<T> delegate) {
|
||||
AtomicReference<T> value = new AtomicReference<>();
|
||||
return () -> {
|
||||
T val = value.get();
|
||||
if (val == null) {
|
||||
synchronized(value) {
|
||||
val = value.get();
|
||||
if (val == null) {
|
||||
val = Objects.requireNonNull(delegate.get());
|
||||
value.set(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
return val;
|
||||
};
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个实现用起来也很简单:
|
||||
|
||||
```
|
||||
long ultimateAnswer = memoize(() -> {
|
||||
// 这里有一个常常的计算
|
||||
// 返回一个终极答案
|
||||
return 42;
|
||||
})
|
||||
|
||||
```
|
||||
|
||||
在这里,memoize是一个通用的实现,它的适用范围很广。我们仔细对比就不难发现,这里我们已经实现了Proxy模式的能力,换言之,有了它,我们可以不再需要Proxy模式。后面我们讲到设计模式也会提到,一些设计模式是受限于程序设计语言自身能力不足而出现的,这里也算为这个观点添加了一个注脚。
|
||||
|
||||
## Optional
|
||||
|
||||
让我们回到学生的例子上,如果想获取一个学生出生的国家,我们该怎么写这段代码呢?直觉上的写法是这样的:
|
||||
|
||||
```
|
||||
public Country getBirthCountry() {
|
||||
return this.getBirthPlace() // 获取出生地
|
||||
.getCity() // 获取城市
|
||||
.getProvince() // 获取省份
|
||||
.getCountry(); // 获取国家
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
然而,在真实项目中,代码并不能这么写,因为这样可能会出现空指针,所以,我们不得不把代码写成这样:
|
||||
|
||||
```
|
||||
public Country getBirthCountry() {
|
||||
Place place = this.birthPlace;
|
||||
if (place != null) {
|
||||
City city = place.getCity();
|
||||
if (city != null) {
|
||||
Province province = city.getProvince();
|
||||
if (province != null) {
|
||||
return province.getCountry();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这是一段令人作呕的代码,但我们不得不这么写,因为空指针总是一个令人头疼的问题。事实上,作为程序员,我们经常会有忘记做空指针检查的时候。这不是一个人的问题,而是整个行业的问题, IT 行业每年都会因此造成巨大的损失。
|
||||
|
||||
>
|
||||
<p>我将其称为自己犯下的十亿美元错误……<br>
|
||||
I call it my billion-dollar mistake…<br>
|
||||
——Sir C. A. R. Hoare,空引用的发明者</p>
|
||||
|
||||
|
||||
难道空指针就是一个无解的问题吗?程序员们并不打算束手就擒,于是,一种新的解决方案产生了,就是可选对象。这个解决方案在Java 8中叫Optional,在Scala中叫Option。接下来,我们就以Java 8中的Optional为例进行讲解。
|
||||
|
||||
Optional是一个对象容器,其中可能包含着一个非空的值,也可能不包含。这是什么意思呢?它和直接使用对象的场景是一一对应的,如果包含值,就对应着就是有值的场景;而不包含,则对应着值为空的场景。
|
||||
|
||||
那该如何去创建一个Optional对象呢?
|
||||
|
||||
- 如果有一个非空对象,可以用 of() 将它包装成一个 Optional 对象;
|
||||
- 如果要表示空,可以返回一个 empty();
|
||||
- 如果有一个从别处传来的对象,你不知道它是不是空,可以用 ofNullable()。
|
||||
|
||||
```
|
||||
Optional.of("Hello"); // 创建一个Optional对象,其中包含了"Hello"字符串
|
||||
Optional.empty(); // 创建了一个表示空对象的Optional对象。
|
||||
Optional.ofNullable(instance); // 创建了一个Optional对象,不知instance是否为空。
|
||||
|
||||
```
|
||||
|
||||
也许你会好奇,直接使用对象都解决不了问题,把对象放到一个容器里就解决了?还真能。因为你要用这个对象的时候,需要把对象取出来,而要取出对象,你就需要判断一下这个对象是否为空。就像下面这面代码这样:
|
||||
|
||||
```
|
||||
if (country.isPresent()) {
|
||||
return country.get();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
只有Optional里包含的是一个非空的对象时, get() 方法才能正常执行,否则,就会抛出异常。显然,当你调用 get()的时候,意图是很明显的,我要处理的是一个非空的值,所以,就必须加上一段判断对象是否存在的代码。
|
||||
|
||||
这比直接访问对象多用了一步,但正是这多出的一步让你的大脑必须想一下,自己是否需要加上判空的处理,而不是像普通对象一样,一下子就滑了过去。
|
||||
|
||||
而且因为 get()本身是有意图的,用工具也可以扫描出缺失的判断,比如,如果你用IntelliJ IDEA写程序的话,不加判断,直接get()的话,它就会给你一个警告。
|
||||
|
||||
使用Optional,我们还可以给空对象增加一些额外的处理,比如给个缺省值:
|
||||
|
||||
```
|
||||
country.orElse(china); // 返回一个缺省的对象
|
||||
|
||||
```
|
||||
|
||||
也可以生成一个新的对象:
|
||||
|
||||
```
|
||||
country.orElseGet(Country::new); // 调用了一个函数生成了一个新对象
|
||||
|
||||
```
|
||||
|
||||
或是抛出异常:
|
||||
|
||||
```
|
||||
country.orElseThrow(IllegalArgumentException::new);
|
||||
|
||||
```
|
||||
|
||||
其实,我们拿到一个值之后,往往要做一些更多的处理。使用了Optional,我们甚至可以不用把其中的值取出来,直接就做一些处理了。比如,它提供map、flatMap、filter等一些方法,就是当Optional包含的对象不为空时,调用对应的方法做处理,为空的时候,直接返回表示空的Optional对象。
|
||||
|
||||
从下面这张图,你就能够理解这些方法的基本逻辑:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/56/59/563d937c8cf9e3772a10cf3b34fd4b59.jpg" alt="">
|
||||
|
||||
好,有了对Optional的基本了解,我们在日常工作中怎么用它呢?很简单,**在方法需要返回一个值时,如果返回的对象可能为空,那就返回一个Optional**。这样就给了这个方法使用者一个提示,这个对象可能为空,小心处理。
|
||||
|
||||
比如,获取学生的出生地,方法可以这么写:
|
||||
|
||||
```
|
||||
Optional<Place> getBirthPlace() {
|
||||
return Optional.ofNullable(this.birthPlace);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
好,回到我们前面的问题上。获取一个学生出生的国家,代码可以怎么写呢?如果相应的方法都改写成Optional,代码写出来会是这个样子:
|
||||
|
||||
```
|
||||
public Optional<Country> getBirthCountry() {
|
||||
return Optional.ofNullable(this.birthPlace)
|
||||
.flatMap(Place::getCity)
|
||||
.flatMap(City::getProvince)
|
||||
.flatMap(Province::getCountry);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
虽然我们不能说这段代码一定有多优雅,但是至少比层层嵌套的if判断要整洁一些了。
|
||||
|
||||
最后,你可能会问,这个Optional和函数式编程有什么关系呢?其实,Optional将对象封装起来的做法来自于函数式编程中一个叫Monad的概念,你可以简单地把它理解成一个对象容器。Optional就对应着其中的一种:Maybe Monad。
|
||||
|
||||
我们前面也看到了,正是因为这个容器的存在,解决了很多问题。Monad 的概念解释起来还有很多东西要说,篇幅所限,就不过多阐述了,有兴趣不妨自己去了解一下。
|
||||
|
||||
这种对象容器的思想也逐渐在开枝散叶,比如,在Rust的标准库里,有一个[Result](http://doc.rust-lang.org/std/result/),用来定义可恢复的故障。它可以是一个正常值,也可以是一个错误值:
|
||||
|
||||
```
|
||||
enum Result<T, E> {
|
||||
Ok(T),
|
||||
Err(E),
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
下面是一段摘自Rust标准库文档的代码,当我们有了前面对于Optional的讲解,理解起这段代码也就容易多了。
|
||||
|
||||
```
|
||||
enum Version { Version1, Version2 }
|
||||
|
||||
// 定义一个解析版本的函数
|
||||
fn parse_version(header: &[u8]) -> Result<Version, &'static str> {
|
||||
match header.get(0) {
|
||||
None => Err("invalid header length"), // 无法解析,返回错误
|
||||
Some(&1) => Ok(Version::Version1), // 解析出版本1
|
||||
Some(&2) => Ok(Version::Version2), // 解析出版本2
|
||||
Some(_) => Err("invalid version"), // 无效版本,返回错误
|
||||
}
|
||||
}
|
||||
|
||||
let version = parse_version(&[1, 2, 3, 4]);
|
||||
// 根据返回值进行处理
|
||||
match version {
|
||||
Ok(v) => println!("working with version: {:?}", v),
|
||||
Err(e) => println!("error parsing header: {:?}", e),
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## 总结时刻
|
||||
|
||||
今天,我给你讲了两个比较有用的函数式编程的概念:惰性求值和Optional。
|
||||
|
||||
惰性求值是一种求值策略,它将求值的过程延迟到真正需要这个值的时候,其作用就是规避一些不必要的计算。因为惰性求值的存在,还衍生出一些有趣的做法,比如,无限流和记忆。无限流启发了现在的一些大数据平台的设计,而记忆可以很好地替代Proxy模式。
|
||||
|
||||
Optional是为了解决空对象而产生的,它其实就是一个对象容器。因为这个容器的存在,访问对象时,需要增加一步思考,减少犯错的几率。
|
||||
|
||||
正如我在前面课程中讲到,函数式编程中有很多优秀的内容,值得我们去学习借鉴。我在这几讲中讲到的内容,也只能说是管中窥豹,帮助你见识函数式编程一些优秀的地方。
|
||||
|
||||
如果你想了解更多函数式编程,不妨读读《[计算机程序的构造与解释](http://book.douban.com/subject/1148282/)》,体会一层一层构建抽象的美妙。如果还想了解更多,那就找一门函数式编程语言去学习一下。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:**花点时间学习函数式编程。**
|
||||
|
||||
## 思考题
|
||||
|
||||
现在,你已经对函数式编程不陌生了,我想请你谈谈学习函数式编程的感受,无论你是刚刚跟着我学习的,还是之前已经学习过的,欢迎在留言区分享你的想法。
|
||||
|
||||
感谢阅读,如果你觉得这一讲的内容对你有帮助的话,也欢迎把它分享给你的朋友。
|
||||
Reference in New Issue
Block a user