1.1.一切从FP开始
1. 一切从FP开始
FP全名为Functional Programming(函数式程序设计),它是一种设计、编制和调试函数式程序的技术。
不得不承认,了解FP编程是学习Vert.x过程中的一条必经之路,先看下边的代码,理解一下为什么:
对于了解Java历史的人而言,这段代码比较令人费解的是中间那段request ->
,然后就没有然后了,可能就云里雾里了,实际上这是Java 8带来的一次革新,引入了lambda表达式,而Vert.x中这种代码几乎铺天盖地,那么您可以知道的一个事实就是:Vert.x对JDK的版本要求最低是8.0,最少从Vert.x 3.x(最新4.3.3
)开始就如此了。
Java 8中的lambda表达式的引入,使得Java语言也开始拥有了FP的味道,究竟什么是FP,它的全称是:Functional Programming
,中文翻译为函数式编程,追溯它的家谱,实际上它源起于“编程范式”——Programming Paradigm
,传说:每个编程人员都在创造世界、虚拟世界,所以才有了那句著名的“这个世界是可以形式化的”,编程范式是计算机编程的基本风格和典型模式,它是工程师在编程过程中不知不觉采取的一种编程的方法论,反映了它描述这个世界的思维方式。常用的编程范式很多,在Java语言中,最熟悉的莫过于“一切皆对象!”的面向对象编程(OO,Object Oriented)。
通常开发过程中常见的编程范式包含:面向过程、面向对象、函数式、泛型编程,除此之外,如果要想追求更加深入的范式,而不是烂大街的前四种,可以参考阅读《Six programming paradigms that will change how you think about coding[^1]》,就像作者本人说的——他敢打赌大部分人都没有听说过这些编程范式(我也没听过)。
「注」 往往一门编程语言是专程为某种特定的编程范式设计的,如Java是纯粹的面向对象编程语言,Haskell是纯粹的函数式编程语言,而某些语言支持多范式(Java从8的版本开始)。但是读者千万不要将编程范式和语言绑定:为某种编程范式设计的语言也可以通过一些语言特性写出其他范式的味道!用过JavaScript的开发者都知道,JavaScript通过原型定义可以将单纯的函数式动态脚本写出OOP的味道,这种关系类似于厨师和厨具(一个厨师可以使用多种厨具做出美味的菜肴),于是有了Java 8中的lambda,于是Java从8的版本开始也尝试在FP中争得一席之地。
2. 什么是FP?
FP又称为函数式编程,它是一种以数学函数为编程语言建模的核心编程范式,它将计算机视为数学函数计算;它是一种设计、编制和调试程序的技术,函数式程序是由一些原始函数、定义函数和函数类型的函数表达式组成,最初的代码片段中使用Java8中的lambda表达式语法实现了函数式编程的雏形。函数式编程最初起源于古老的LISP
语言,而现代函数式编程语言的代表则有Haskell、Clean、Erlang以及Clojure等。
2.1. 命令式 / 函数式
谈到函数式(Funcational)编程[^2],不得不提曾经江湖的霸主:命令式(Imperative)编程,理解了二者的区别,那么您对函数式编程才会有更加深入的理解,掌握了概念之后,驾驭它也只是分分钟的事。接下来先看下边代码:
Java程序员对上边的代码应该一点都不陌生,代码本身中规中矩,如果通过Java 8的特性改写成函数式的风格,就会有下边的代码段:
没有对比就没有伤害,从上述代码中可以看到函数式编程在代码面的一些基本现象:
减少了可变量(Immutable Varialbe)的声明。
能够更好使用并行(Parallelism)的思维写代码。
代码更加简洁,至于可读性因人而异(至少作者觉得很流畅)。
第一段代码是Java中典型的命令式编程——这种编程范式中,它专注于“如何去做”,而不去管“做什么”,换句话说,它关心程序解决问题的步骤,一旦编写好程序后,它会按照编写的命令原封不动地去执行,程序的执行效率取决于执行命令的数量。
第二段代码使用了Java 8语法中的lambda,它是典型的函数式编程——这种编程范式中,它更专注于数据的映射(或者说类型之间的关系),侧重点在函数内部的运算,而不是命令本身。
「注」 函数式编程中的函数,并不是指Java语言中的某个方法(不论static或non-static),曾几何时,我们应该记得数学中学习过“映射”的概念,而它所关心的是映射:f(x) = y;函数式编程中的函数实际上是数学函数的概念,它关注的是输入x和输出y之间的对应关系,一个从定义域
x
到值域y
的映射关系。
Java语言目前有一条无法逾越的鸿沟:函数不是一等公民,这是由于它是面向对象设计的语言;简单说,如果Java语言中想要直接使用函数,需要先定义一个class
或interface
,之后才能使用实例方法或静态方法做函数调用,而不能像JavaScript
那样直接定义function
。
2.2. 函数式特性
2.2.1. 闭包
闭包^3(Closure)是一个不太容易理解的概念,简单讲,它是一个能够读取其他函数内部变量的函数。先看下边的JavaScript代码:
这里的init()
函数创建了一个局部变量name
和一个局部函数displayName()
,函数displayName()
仅在init()
内部进行调用,而displayName()
没有自己内部的局部变量,但它仍然可以访问到外部函数init()
中的name
变量;如果有同名的name
在函数displayName()
内部定义,则它会使用displayName()
内部的name
变量而不是init()
函数中定义的同名变量。该语法属于JavaScript的语法,且变量的作用域会受到声明位置的影响,若将上边的代码改变一下呢?
这个函数做了个变化,makeFunc()
的执行结果会返回函数内部的displayName
函数(myFunc在这里是一个函数引用),在某些编程语言中,函数的局部变量仅在函数执行过程可用,比如Java,我们会默认觉得makeFunc()
一旦执行后,name变量将不存在,因为它是局部变量,这一点在JavaScript中会有所差异。好的,闭包来了——在JavaScript中,闭包是由函数以及创建函数的环境组合而成,这个环境包含了该闭包创建时能访问的所有局部变量。运行上述代码,displayName
可访问作用域中的变量——即name
变量,当myFunc()
被调用时,name
将被传到alert
中,在这里内部的displayName
函数就定义了一个闭包,所以闭包可以称为:函数内部定义的可读取内部变量的函数。上述代码中唯一需要区别的是:
function displayName()
:这行代码为闭包的“函数定义”。var myFunc
:这行代码中的变量myFunc
才是一个闭包,可理解为这行代码创建了一个“函数引用”在使用“闭包”。
严格来讲,通常我们所说的闭包表示的是“函数引用”,所以从上边代码可以知道,这里的myFunc
才是闭包。
2.2.2. 高阶函数
函数式语法中很重要的一个特性则是高阶函数(Higher-Order Function),接触高阶函数之前,读者需要先区分几个基本概念:
函数定义
函数引用
函数调用
上边概念搞清楚后,高阶函数的概念就不言而喻了,回到Java 8:
运行上述代码可以得到36
的输出值,请读者思考以下几点:
函数定义:顾名思义就是定义一个函数,常见的方式是
function xxx
,上述代码中,前三个行等号右边部分是函数定义,如:x -> y -> z -> x.apply(y.apply(z))
,x -> x * 3
,x -> x * x
,这些都是函数定义。函数引用:函数引用表示一个变量(Java语言中的左值),该变量本身不是一个确切的值,而是一个函数,如上边的:
compose
,triple
,square
以及f
,在Java语言中,它不能直接通过compose()
这种带()
的方式进行调用,必须使用apply
方法实现函数调用。函数调用:上述代码中,只要出现了
apply
的调用,则是函数调用的代码,这个概念Java比JavaScript易懂,如果是JavaScript您将会看到类似:compose(square)(triple)
的写法。
回到正题,上述代码中的compose
就是高阶函数,一个高阶函数至少满足以下一个条件:
接受一个或多个函数作为输入
返回值是一个函数
而从compose
的定义可以看到,它的参数是一个函数,返回值是一个高阶函数,该高阶函数的形参和返回值依旧是一个函数,这也是上述代码中compose
可以直接使用三次apply
的原因,完整写法应该是:
简单来讲,高阶函数在函数定义时可使用函数作为形参,在函数调用后的返回值也是一个函数。
2.2.3. 元组
元组(Tuple)是多个元素的组合,使用两个元素的元组则是一个特例,但它却很常用;前边章节定义的函数中,使用的函数都只有“单参数”,编程过程中难免会遇到类似下边这样的例子:
上述函数可以简写为:f(x,y) = z
,从函数基本定义,它侧重于“映射”,反应的是源集和目标集之间的关系,并不是多个源集到目标集之间的关系,严格说来:一个函数不允许有多个参数。那么对于上述代码,实际上反应的是x + y
和z
之间的关系,如果把x + y
表示成一个整体,那么它可以有多种组合,此时的x + y
就可以视为一个元组,用(x,y)
表示,即:
此时的函数依旧是一个参数,该参数是一个(x, y)
的元组,而不是两个参数的函数,通俗点理解,您可以将元组函数称为多参函数,所有参数的某一个组合(面向对象中的“状态”)就是一个元组。
2.2.4. 柯里化
柯里化(Currying)在计算机科学术语中是把接受多个参数的函数变换成接受一个单一参数的函数,并且返回接受余下参数且返回结果的新函数的一种技术。
实际上,柯里化是元组函数的一种新的思维模式,如:
在上述变化中,使用g的时候,x就不再是一个参数,上述例子中f(x)(y)
就是f(x,y)
的柯里化形式(单参函数不存在柯里化),对于一个元组函数(多参)应用的这种转换就称为柯里化转换。
**「思」**也许有人会问,学个
Vert.x
怎么这么“啰嗦”,对的,的确如此,因为Vert.x
中融入了很多函数式编程思维,虽然本人不想去纠结函数式编程中的概念,但对于初学者,如果理解了函数式编程的基础,再回过头来看Vert.x
,会更加理解它的编程思维——Vert.x
编程不是语法层面的一种革新,它是思维上的一种升华。
2.3. 浅谈函数分类
前边章节谈到了函数式编程的一些基础特性,本章节介绍函数式编程中的函数分类。
2.3.1. 副作用
当调用一个函数时,除开函数返回值以外,还对主调用函数产生了附加的影响,如:修改全局变量(函数外变量)、修改实参,这样的现象称之为函数副作用(Side Effects)。因为函数式语言中的“函数”表示的是代数中的f(x) = y
的映射关系,在科学计算中,函数副作用表示调用函数过程中,除了返回值以外,还对主调用函数产生了影响。
简单用Java代码演示一下无副作用的例子:
上边是一个典型的函数,它接收参数:a、 b
,返回一个新的值,虽然从函数逻辑中它改变了变量,但由于Java中的参数是按值传递的,所以参数的变化并不影响外界,这种变化在函数之外不可见,因此这个函数是一个没有副作用的函数。细心的读者会发现这里的a、b
有可能会产生越界溢出的问题,但这和函数副作用不矛盾。Java语言中的不可变对象做参数具有天生的无副作用的特性,这也是我们推荐在局部变量中使用final
修饰符的原因。
Java语言中的副作用,同样包含:异常,比如:
当程序在b = 0
的时候,该函数会抛出一个异常:
在这里,抛出异常就是副作用,它导致程序出现了不可预期的结果!如果将上边的函数改写成:
那么即使当b为0,这里也不会抛出异常,那么它就是没有副作用的,但是还有一点需要说明的是:
**「注」**在Java语言中,一个函数不论是有意或者故意抛出了异常,那么这个异常终归是一个副作用,但是有时候在命令式编程过程中,我们又需要这种副作用,比如按引用传递,打印特殊情况等,那么要编写严格意义上的无副作用的程序也会变得有些困难,但是一旦您编写了,那么您需要坚持一个基本原则:保证可观测的副作用不会导致不可观测的BUG。
「Java语言中的值传递」
上一小节提到:Java语言中的不可变对象做参数具有天生的无副作用的特性,为什么呢?Java语言中的类型主要分为基本类型(8种)和引用类型。
基本类型:
byte, short, int, long, float, double, char, boolean
引用数据类型:数组、类、接口、null(不指向任何对象)
Java语言中的方法参数又包括:实际参数和形式参数。
形式参数:称为形参,定义方法时的参数,又称为方法定义中的参数。
实际参数:称为实参,具体写该调用方法时的参数。
根据传入的传递方式,Java中包含两种:按值传递、按引用传递。
值传递
先看一段代码来理解Java语言中的值传递:
运行上述代码会得到:
仔细分析一下上边的结果:实际上num1和num2在作为函数shift
的实参时,JVM会拷贝两个参数的副本,通过复制的方式将副本的值传给a, b
,在这样的情况下,a, b
和num1, num2
相互独立,所以当函数内部交换了a, b
的值时,原始的num1, num2
两个值并没有被影响,不论函数内部如何修改,都不会影响到外部数据,这里的shift
方法是无副作用的。
引用传递
接下来看看按引用传递的代码:
运行上述代码输出为:
从上述代码结果可以知道,modify
函数是一个有副作用的函数,为什么?因为在调用了该函数过后,外部变量arr
的内容被修改了,这种改变使得内存地址中的值产生了变化,假设有这样一个场景:如果写这个函数的Api是你的伙伴,而你调用了它写的Api,最终得到返回值过后,你的入参被改动了,而你需要在后续的代码中使用了arr
,而这个时候,后续代码对这个函数的内部逻辑强关联(因为该函数会改动arr),这样的情况下,这些副作用有可能会影响主逻辑。
Java中的传递
最终用一张图来描述上边两段代码,该图需要读者具备JVM内存存储模型相关知识(读者可以先去理解栈和堆在JVM中的基础知识)。
按值传递
按引用传递
上边的图可以看到按值传递和引用传递的一个很大的区别,在Java语言中,调用函数时候,JVM会拷贝一个“副本”,不同的是基本类型是直接使用的栈拷贝(拷贝一份数据),而引用类型拷贝的是引用本身,而不是复制的对象(这是大部分读者最不能理解的拷贝),按引用传递实际上传递到函数内部的是“引用副本”,但是这个引用和原始引用指向的是同一个对象,所以使得函数内部对象若发生改变,那么这个传入对象就会被改变了。不过剧本也会发生一种例外,就是当你遇到了“不可变对象”(如Java中的String)。
让人误解的String
很多时候讨论按引用传递,很多人往往会使用下边的例子来反驳:既然String在Java中是对象(引用类型),那么为什么当方法内部改变了String的时候,并没改动外围的值:
运行上述代码可以得到下边输出:
初学者面对这个可能有些困惑,按照Java中引用传递的规则,在literal = "Hello " + literal
执行过后,很多人会觉得literal
被改变了,实际上下边的图就可以说明一切了,也诠释了String是不可变对象:
实际上在函数调用时候,Java语言中依旧拷贝了引用本身,最初的literal是指向对象Lang
的,但是由于Java语言中的String是“不可变”对象,也就是说literal = "Hello " + literal
这句话执行后,JVM创建了一个新对象叫:Hello Lang,然后literal
引用直接指向了新对象,原始对象并没有发生改变,而且lang
依旧是指向了原始对象,所以最后打印出来的结果依然是:Lang
,所以这种现象和Java语言中的按引用传递的概念并不冲突。Java语言中的不可变对象有一个明显的特征就是:任何在代码面你所看得见的改动,它都创建了新对象,并且使用你的引用指向该新对象,这也是不可变的含义。
2.3.2. 引用透明
引用透明性(Referential Transparency)的定义为:一个函数的返回值只依赖这个函数的输入值,而不依赖任何外部代码(从控制台、文件、远程URI、数据库、操作系统读取数据),这种不被外界影响的代码就是引用透明的。严格的函数式代码是引用透明的,所以仅仅没有副作用并不足以证明该程序是函数式的。引用透明的代码有几大好处:
独立性:它不依赖任何外部设备,程序可在任何上下文中使用它,只需要提供有效参数即可。
确定性:相同参数永远会返回相同结果,没有意外,哪怕是错误的一个结果,也是由于传入了对应的参数导致。
无异常:它可能会有错误发生如内存耗尽、堆栈溢出——这种错误证明您的程序有Bug,并不是调用API的用户关心的。
正确性:任何时候它都不会导致其他代码意外失败,它不改变外部参数、外界数据。
无依赖:它不会因为外部设备(数据库、文件系统、网络)的不可用、太慢、坏死而崩溃。
2.3.3. 纯/逆/偏/全函数
根据函数本身的特性,通常可以把函数分成四个类型:纯函数、逆函数、偏函数、全函数。
纯函数
纯函数^5(Pure Function)是最容易理解的函数,也是函数式编程的基础:
给出同样的参数值,该函数总是求出同样的结果,该函数结果值不依赖任何隐藏信息、程序可能改变的状态、或者两个不同的执行环境,也不依赖I/O装置(数据库、网络、文件系统都可以理解成一种I/O装置)的外部输入。
结果的求值不会促使任何语义上可观测的副作用或输出。
如:
sin(x)
:返回实数x的sin值。length(s)
:返回字符串s的长度。encrypt(k,d)
:运行一个使用k关于日期d的确定加密算法。
下边的就不是纯函数:
返回当前天是星期几:不同的日期结果不同——这里有一点需要说明,任何时候使用了全局状态或静态变量的做法都是非纯函数;
random()
是非纯函数:每次调用潜在生成不同的值,因为伪随机数器使用了一个全局的“种子”状态,如果改写成random(seed)
,那么该函数就转变成了一个纯函数;printf()
是非纯函数,因为它调用了一个I/O装置,产生了副作用。
逆函数
逆函数(Inverse Function)——不是所有的函数都有逆函数。如果f(x)=y
是一个定义域x
到值域y
的函数,它的逆函数f(y) = x
表示定义域y
到值域x
的函数,函数的逆函数在满足函数要求的情况下也是一个函数:每一个定义域有且仅有一个目标值,有逆函数的函数如:
偏函数
偏函数(Partial Function)是不太好理解的一种函数:没有在一个定义域中定义所有元素但满足其他需求(定义域里不存在任何在值域中有多个元素与它对应的元素)的关系称为偏函数。
**「注」**偏函数在编程中是很重要的一种函数,大部分BUG都是由于工程师将偏函数当做全函数使用而导致的。
先看数学定义,如:f(x) = 1/x
是从N到Q(有理数)的偏函数,因为对x = 0
没有定义;但是它又是一个从N*
到Q的全函数(x > 0)
,也是一个从N到Q和error的全函数。很多时候,在一个偏函数的值域中增加一个元素(某个错误条件),就可以顺利将偏函数转换成一个全函数——若这样做,就是我们通常说的:“需要增加一种情况”(容错设计)。
用计算机术语来定义如:偏函数是一个单参数函数,并且程序并没有对该类型的所有值都实现对应的逻辑处理,简单讲针对f(x)
中的x传入任意值时,除了正常的执行逻辑以外,对于一些特殊的case分支,程序并没有提供相应的处理。偏函数之所以“偏”,是因为它只处理了某些case的情况,而没有处理x所有可能的输入值。
如果偏函数被调用,那么系统应该抛出一个MatchError
类似的异常信息,可使用一些特殊方法:isDefinedAt
测试是否和偏函数匹配的未考虑的情况相匹配。
全函数
全函数和偏函数之间存在一种转换关系,简单来讲可以理解为:如果f(x) = y
中,任意一个定义域x,都可以找到一个和它所对应的值域y,那么这样的函数就称为全函数;反之对于偏函数,任意定义的定义域x,不一定可以找到一个和它对应的值域y。为什么偏函数称为BUG的源头,因为偏函数丢失掉的值域是逻辑中被漏掉的不可观测的结果——比如对于divide(a,b)
是表示a/b
的除法运算,如果没有考虑b = 0
的情况,那么对于任意a和b的定义域下,b = 0
的情况不做处理,它就产生了BUG——在考虑偏函数和全函数的处理过程中,甚至可以忽略掉这个函数本身是否具有副作用。
**「注」**可观测的结果表示程序员针对这种结果进行了处理,比如示例中针对
b = 0
的情况进行了“编码”(不论是打印日志、抛出自定义异常、还是写文件),虽然可能产生了副作用,但属于被程序员处理过的,这种属于可观测。相反不可观测的结果就属于程序员没有考虑到的“逻辑漏洞”,会导致不可预知的情况,产生BUG。
2.3.4. Monad
Monad与其说是函数式编程的一种特性,不如说它是一种设计模式,将一个运算过程,通过函数式编程拆解成相互连接的多个步骤,您只需要提供下一步运算所需要的函数,整个运算就会自动进行下去,当然包括处理整个函数式编程过程中的副作用。
Monad称为单子[^6],它是一种将函数组合成应用的方法,在计算机科学中,单子用来代表计算,它能用来把业务无关的通用程序抽象出来,比如用来处理并行(Future)、异常(Option/Try)、甚至副作用的单子。那么究竟什么是Monad?
参考上述博客的说法,我们一步一步来解析Monad。本书不是函数式编程的专用书,主要目的是为Vert.x的入门者打个基础,在您准备推塔之前,先补满自身的魔法、调整好装备、战技,然后再一战,所以想要看图解的读者,可以直接查看引用中的内容,下边的内容仅仅是一种解读,并非原创,而书写过程中包含了作者的思考。
软件最基本的是数据,比如各种值:
value = 2
。第一个概念是函数:处理值的一系列操作,可以封装成函数,输入一个值,得到另外一个值。比如:
2 + 3 = 5
可理解成+3
的函数作用于2后得到了5的结果,然后以5输出,用程序思维可理解成:(+3)(2) = 5
,更形象一点可以用:addThree(2) = 5
,将x和y代入可以得到:x = 2, y = 5时,addThree(2) = 5
。函数有时候可以理解成一个“黑盒通道”(图中使用了漏斗),上边进入一个值(输入),下边出来一个值;
如果将这些“黑盒通道”串联起来,那么函数本身是可以连接起来使用的,一个函数连接着另外一个函数,前一个函数的输出作为下一个函数的输入,比如一个新函数
-2
,和+3
连接就形成了substractTwo(addThree(2))
的格式;函数还可以一次处理数据集合的每个成员,如一个数组:
[2,3,4]
,当这个数组或集合的每个元素都调用了addThree
后,会生成一个新的集合:[5,6,7]
,这个过程是可以并行的,虽然这里使用了“依次”,但当一个集合通过了这个“黑盒通道”后,作用于每个元素的效果都会产生一个新的元素,很像:f(x) = y
的映射。第二个概念是数据类型(type):数据类型是对值
value
的一种封装,不仅包含值本身,还包含相关的属性、方法,当封装后,有可能2
就不是一个单纯的数字了,而是一种数据类型的实例,用<2>
表示,该实例在它的上下文环境中会被使用。由于
2
的数据类型发生了转变,那么原始的两个函数:+3
和-2
就不能使用了,因为这两个函数都是针对值进行处理的,不是针对封装后的数据类型进行处理,而这里的<2>
已经不是一个单纯的数值。因此,我们需要重新定义一种运算,它接受“值函数”和数据类型的实例为参数,使用“值函数”处理后,再输出数据类型的另外一个实例,如:
map(+3)(<2>) = <5>
,在这里,map就表示我们所定义的运算。在map内部,实际上做了这样的处理,将封装类型
<2>
打开,取出里面的值2
,然后使用值函数+3
对这个值进行处理,得到5
的结果,然后将5
这个结果封装回<5>
。有趣的问题来了,如果把函数本身也封装成数据类型,会如何?比如上边提到的
+3
本身是一种函数,如果封装成<+3>
,将函数也封装成一种数据类型,会发生什么?那么在这样的情况下就需要一种新的运算,它不是值与值的运算,也不是值与数据类型的运算,而是数据类型和数据类型之间的运算。注意这里的魔法,比如两个数据类型:
<+3>
和<2>
,先取出各自数据类型的内部值,+3
是一个函数,2
是一个数值,然后使用该函数处理该数值,最后将这个函数的返回结果再重新封装——实际上还是5
,封装成<5>
再返回。既然函数可以返回一个值,那么这个函数就可以返回某个数据类型,那么我们需要的是这样一种函数:它的输入和输出都是数据类型。
这样的好处?因为数据类型本身可以带有运算(比如封装的
<+3>
),如果每一个步骤返回的都是数据类型的实例,那么我们就可以把所有的函数全部连接起来,形成函数流。比如一个实例,系统的I/O提供了用户的输入;
getLine
可以将用户的输入处理成一个字符串类型(STR)的实例;readFile
函数接受(STR)实例作文件名,返回一个文件类型的实例;putStrLn
函数将文件内容取出来输出。
在上述例子中:
这样的运算连接到一起,就叫做Monad,Monad由于是单子,那么它遵循基本的单子原则^7:
综合性法则(Associative law):对于某一种运算如map,它满足——
x map y map z == x map (y1 -> f(y1) map z)
,其中这里的y = f(y1)
,这种情况下,实际上对于y
进行了封装,封装成了一个函数,可标记为<y>
。Kleisli组合法则(Kleisli composition):Monad的组合性操作,如一种运算map,它满足——
map(x, map(y, z)) == map(map(x,y),z)
,如果不对Monad的值进行组合操作,而是将map
演变成f(x) -> y
的一种证明,那么理解就简单很多,因为map
本身是一种运算,可写成函数。也就是说如果实现一个compose
方法,那么可有:compose(x, compose(y, z)) == compose( compose(x,y), z)
。恒等法则(Identity Law):在Monad中,Identity对于某种操作
op
,如果unit是一种compose函数的元函数,通过unit可证明:compose(f, unit) == f
,同样:compose(unit, f) = f
。由于unit是元函数,那么它满足:unit(x) map f == f(x)
,并且:x map unit == x
。
最终综合两篇博客的阐述,可简单说单子Monad的解释:如果f
是应用一个函数包裹的值,前文中的<f>
,那么Monad则是应用一个返回包裹值的函数到一个包裹的值(抽象了一层)。再举个形象的例子:
定义三个方法,它们都返回Future<T>
,在业务函数中将三个方法使用Monad的方式组合起来
3. 总结
3.1. 函数式的优势
理解了函数式编程后,先看函数式编程的优势:
易于推断:因为它们具有确定性(deterministic),对于一个特定的输入总是产生相同的结果,在很多情况下,您可以用数学理论证明程序是正确的,而不是在大量测试后仍然不确定程序是否在意外的情况下出错。
易于测试,因为它本身没有副作用,所以您不需要那些经常用于在测试里隔离程序以及外界的Mock来辅助测试。
更加模块化,因为它们是由只有输入和输出的函数构成,我们必须要处理副作用,不捕获异常,不必处理上下文变化,不必共享变化的状态,也不存在并发的修改。
复合简单:它让复合和重新复合变得简单,您需要编写各种基础函数,并把它们复合为更高级别的函数,重复这个过程直到您拥有一个与您打算构建的程序一致的函数——这个时候所有的函数都具有引用透明性,它无需更改就可实现重用。
3.2. Java中的方法
Java虽然是面向对象的语言,但在对象和类的级别依旧需要通过编写方法来实现,而该方法本身可以是“函数式”的,函数式编程是一种编程的思维,而且它之中的函数概念是数学概念,任何语言写出来的结果都可以是函数式的:
它不修改函数外的任何东西(包括按引用传递的对象),外部观测不到内部的任何变化。
它不能修改自己的参数。
它不能抛出任何异常。
它必须返回一个值。
只要调用它的参数相同,结果也必须相同。
3.3. 如何处理副作用?
理论上讲,要编写完全无副作用的函数不太可能,结合“防御式编程”,真正具有挑战的空间是如何将书写的方法在“偏函数”和“全函数”之间进行转换,比如下边的一个方法:
在实现过程中需要考虑的问题是什么呢?首先是考虑PreCondition
,您需要给一个定义,对于一个Java语言中的字符串通常有四种不同的情况,有时候我们称之为边界值:
null
:容易引起NullPointerException的情况;EMPTY
:该字符串是一个""
;BLANK
:该字符串是一个" "
;有值:该字符串往往是我们需要的字符串(这里先不提特殊字符,特殊字符的处理主要和具体场景相关)。
为什么我们需要单独考虑null、EMPTY、BLANK
,因为在某些特殊的场景中EMPTY和BLANK是具有业务意义的,它们不能作为异常来处理,很多工具都提供了isNull、isBlank、isEmpty
的单独检查,若不处理null、EMPTY、BLANK
,那么这个函数就变成了一个偏函数,倘若要改成一个完整的全函数则需要考虑入参的不同边界值,或者说在编写过程要考虑入参的一些可观测的明显引起BUG的值。
**「注」**Java语言中的null虽然容易引起NullPointerException,但是在某些情况下,null值会作为上下连接的特殊值来对待,这是历史遗留的编程习惯导致的,要完全写不返回null的情况,可以参考Java8中的Option的写法,不过这种写法需要您足够驾驭函数式编程,并且对它有一个正确的打开方式。
如果文件不存在怎么办?实际上上述函数最好的办法就是使用Monad的方式,如Vert.x中的Future的写法(这里不考虑它的异步作用):
上述代码并不是什么好的代码,只是为了说明在处理PreCondition
过程中的三个特殊边界值(这里的例子只是为了演示处理副作用的过程,并不表示真实程序中应该如此写,因为毕竟这种写法过于繁琐)。而在执行过程,content
也许是另外一个引起副作用的地方,这种一般称为:“局部副作用”,毕竟content似乎又会产生null、BLANK、EMPTY
的结果,那么这个就在于您是否需要这些值,或者说业务中是否需要屏蔽这些值来对待了,不过这是函数内部的事,所以又称为局部副作用。上边的三个异常也许是您自己定义的三个异常,或者说三个Java的Runtime异常,不论哪种,在这里都不需要使用throw的方式抛出来,而是直接将异常封装成另外的一种数据类型,通过Monad的方式返回到上层去:让真正该处理异常的地方去做容错。
根据PMD(一个静态代码分析工具)的基本法则,对于异常,throw本身是不影响性能消耗的,很多人有一种错觉觉得异常影响性能,那是因为他们在编写try/catch时太喜欢用printStackTrace()
方法的原因,就好像没有这条语句自己不懂得调试程序一样,正确打开catch
的方式是在程序中自己提取有用的信息,通过日志去记录下来相关错误,而不是在日志中打印Java语言中的异常信息。
当您针对filename
处理了边界值后,您就将这个函数从一个“偏函数”转换成了一个“全函数”,这样的转换自然就减少了系统里潜在的BUG,至少不会因为null导致程序的崩溃,当使用了Future.failedFuture
的方法后,该Java方法就转变成了一个函数式的,从外部看来,它没有任何副作用,并且是一个全函数。
所以针对副作用的处理(其实逐陆于Vert.x大陆时我们可以使用它带给我们的巨大福利:Future
,后边会详细讲它的用法),总结起来主要有以下几个心得:
一旦出现了分支,那么针对每一种分支,需要提供明确的处理,这也是防御式编程的基本法则,而且在处理过程中需要避免太多的
if
分支来检查处理输入参数的情况。任何IO装置(数据库、文件系统、网络)的封装,对于小的系统,您可以考虑直接使用RuntimeException,但对于业务系统,最好使用自定义异常进行简单的封装,根据不同业务场景,定义不同的异常类型(Java中异常的正确打开方式)。
不在万不得已的时候尽可能不使用CheckedException,对于Java中的CheckedException可使用函数式的Monad进行转换,将它转换成RuntimeException异常封装进行传递,在Java语言中,尽可能把Exception作为一种特殊的数据结构来处理,其实Option是一种不错的方式(这个可能是个人习惯,CheckedException在Java语言中书写起来过于严谨和规范,导致的结果是在编写时往往会设计困难,当然这有可能是我对CheckedException这种东西还没有完全驾驭的原因)。
对于私有方法,用
assert
断言代替if-else
的判断,因为私有方法必须有一种承诺,它对实例内部的调用者在调用它时传入的参数会存在一定的约束,assert和Exception最大的区别是:assert用于处理绝对不应该发生的情况,Exception则是用容错代码来处理异常情况。一旦出现两个函数之间的依赖,那么二者之间必须订立契约,如一个方法A调用了B方法,那么A对B需要有一种最简单的契约,它传给B的参数不能是广义的定义域,广义的定义域其实是最自由的法则,它可以让B自己去处理意外的情况——对不起,从工程角度上讲,您不可能写出那么完美的程序,每个函数都会存在不可避免的缺陷。那么A在调用B时必须遵循B中
PreCondition
的基本契约,况且过多花费时间去处理PreCondition
也会让程序本身变得很“重”。将一些通用的逻辑抽象出来,可直接通过函数的格式减小缩进的阅读代价,这一点我会在本书的后边部分逐一说明,包括如何处理Vert.x中的
handler.succeeded
的判断,如果您在每一个AsyncHandler的回调中都判断,那么铺天盖地的模板代码自然不会太美观。同一逻辑的代码,尽可能在整个程序中仅维持一份,任何具有重复性质的代码,都可以考虑使用抽象或者工具类来完成。
以上是作者开发Zero以及阅读了函数式编程后的一些基本心得,至于这些心得如何应用,这个潘多拉魔盒我们将慢慢来打开,毕竟我们需要结合Vert.x
来理解这些内容。最后重申一遍:Vert.x的编程为什么值得学?因为它是一种编程思维的转变,并不是简单的知识性的旧瓶装新酒的过程,当您感受了Vert.x的魅力后,它会为您的编程哲学打开另外一扇大门,这种编程思维同样可以用在Spring编程中,简单说,初学者不要将Vert.x当做纯粹的工具来学习,相反,需要真正从理论层面去理解这种编程模式带来的思考上的收获。
[^1]: 《Six programming paradigms that will change how you think about coding》https://www.ybrikman.com/writing/2014/04/09/six-programming-paradigms-that-will/
[^2]: 《从Java8说起函数式编程》https://www.cnblogs.com/tina-smile/p/5756074.html, 作者:smile_tina。
[^6]: 《Functors, Applicatives, And Monads In Pictures》http://adit.io/posts/2013-04-17-functors,_applicatives,_and_monads_in_pictures.html
最后更新于