文章目录
- 总结
目标主要是为了弄清楚:
● 使用函数式风格来编程的意义是什么?
● 为什么我要将函数作为参数传递?我定义一个接口,让他们来调用不就好了?我为什么要把函数作为一个值
1. 函数的概念
1.1 数学中的函数
函数是我们从小到大就在数学中接触的概念,在数学课本中函数的定义是这样的:
给定一个数集A,假设其中的元素为x,对A中的元素x施加对应法则f,记作f(x),得到另一数集B,假设B中的元素为y,则y与x之间的等量关系可以用y=f(x)表示,函数概念含有三个要素:定义域A、值域B和对应法则f。其中核心是对应法则f,它是函数关系的本质特征。
也就是定义域到值域的映射关系。 例如 successor(x) = x + 1
的函数表达的正整数和输出结果的关系, 函数名称叫 successor
。
在数学中, 对函数起名字并不是一个必要的工作,只是为了方便使用它,这没有错,但是函数名称和函数定义是不存在强关系的, successor
也可以被其他名字替换, 它本身不代表什么。
假定 A 是定义域, B 是值域, 在 Kotlin 的语法中则可以表示为 : (A) -> B
, 其反函数(求导) 则为 (B) -> A
1.1.1 偏函数
下面有两个条件
- A. 必须是定义域对所有元素进行定义
- B. 定义域中的元素不能和值域中的多个元素对应
满足 A & B
的函数称为 “全函数”, 而 !A & B
的称为 “偏函数”
严格意义上来说 , 偏函数不是函数,全函数才是真男人。
为什么我们要知道这个看起来很冷的词汇呢? 这是因为在编程中,许多错误就是开发者把偏函数当成全函数来使用。
例如 f(x) = 1/ x
是一个 N 到 Q 的偏函数, 因为 定义域没有对 0 进行进行定义, 所以当输入 x = 0 时,会输出错误,这是不符合函数的预期的。
而 f(x) = 1 / x , x属于N*
或 f(x) = 1 / x , 值域为 Q 或错误值(N 到 Q或错误值)
则是全函数, 我们输入 x = 0 时,要么是正确结果,要么是符合预期的错误结果。
将 偏函数转化成全函数是安全编程的一个重要部分!!!!!, 而上面转化成全函数的两种做法也正是我们常用的处理偏函数的常用方式,即:
- 对定义域进行指定、定义
- 向值域添加错误的元素
1.1.2 多参数的函数
在编程中, 我们的函数经常看起来不止有一个入参,例如 f(x, y) = x * y
,而函数的定义是 一个源集 到 一个目标集 的关系,那这还是函数吗?
答案是肯定的,我们引入了 元组(tuple) 这个特例概念, 即 (x, y) 甚至 (x, y, z) 都是一个元组,这个元组的定义域就是源集。所以是没有 多参数函数 这种概念的。
1.1.3 柯里化函数
柯里化函数是对上面所讲的 元组函数 的变形。
假定 f(x, y) = x + y
,那么有以下逻辑推演:
(上面的推演是我自己写的,数学好的大佬不要骂我,我就是这样蛊惑自己的哈哈哈哈啊哈哈哈哈哈)
f(x)(y)
就是 f(x, y)
的柯里化形式, 数学中称这种函数为柯里化函数
1.1.4 偏应用函数
这个概念很好理解,它是在柯里化函数上进行深化的。
例如函数: f(x, y) = x + y
那么它等价于 f(x)(y)
那 f(x)
是什么呢? 它代表的是自变量 x 对应的映射函数 , 当 x = 1时,f(1) 的结果 是一个函数,这个函数:输入是 y, 结果是 y 加上这个1。 那么我们称 f(x)
是f(x, y)
对x的偏应用函数 ,偏应用函数会对自变量计算产生很大的影响,这个我们会在后面讲到。
1.2 Kotlin中的函数
在 Kotlin 中:
- 函数是数据
函数是有类型的,可以被传递,也可以被返回,也可以被放到集合中 - 数据也可以是函数
数据可以看成是 源集任意,目标集只有一个 , 也可以称为常函数
例如val x = 5
, 可以看成是一个 f(x)=5 的函数,就是自变量无关的一种特殊函数
1.2.1 纯函数
1.2.1.1 定义
前面讲过数学中的全函数, 在 Kotlin 中,程序员创造了一个与之相似的概念,叫 “纯函数”, 这是因为虽然编程语言定义了 fun
关键字来声明函数,但是很多时候,程序员所写的函数很少能称为真正的函数,所以提出这个概念,愿景是希望Kotlin开发者能够多写真正的函数。
函数要成为纯函数的条件如下:
- 不能改变函数外界的任何事物
- 内部的改变对外部不可见
- 不能改变入参
- 对于相同的参数, 无论何时执行,始终只返回同一个值
- 函数不能抛出异常或错误(不能出现crash)
- 始终返回一个值
1.2.1.2 例子
请看下面代码的 1~9 的方法, 想想哪些函数是纯函数:
第一个:纯函数
第二个:纯函数, 而且是常函数
第三个:不是纯函数, 因为当 b == 0
时, 程序会抛出错误
第四个:是纯函数,因为当 b == 0.0
时,会返回 Double.Infinity
第五个:不是纯函数, 因为 percent1
是公有的, 在两次调用该函数的期间, 这个 percent1
有可能会被外界改变,所以函数可能会返回不一样的值
第六个:是纯函数, 虽然依赖的 percent2
是可变的,但是该类中没有其他地方去改变这个值,而且因为它是私有的所以它不能被外界所改变。但是这种是不安全的,推荐将 percent2 改成 val 来声明
第七个:对于 参数 a 是纯函数,因为 percent3
是不可变的
第八个:非纯函数, 它改变了入参 list
的内容
第九个:纯函数,因为 list + i
返回的是一个新的 List 对象
1.2.2 值函数
Kotlin 是允许将函数写成数据的
例如 下面的函数:
等价于:
这里用了 lambda 表达式,这里就不再赘述,之前学习过:Lambda学习
在 Kotlin 中,函数有两种定义形式,一种是通过 fun
关键字定义,一种是使用 值 来定义,他们的区别是什么呢?为什么不像 Java 那样只用一种方法来定义呢?
- 通过
fun
Kotlin 是会优化 fun
关键字声明的函数,更优效率,并且更加美观 - 通过值函数
可以作为数据传递, 或者作为变量存储到 list、map中
(当然fun
声明的函数也可以做为对象传递, 使用::funName
的形式 )
1.2.3 复合函数
下面我们将两个函数进行复合, 我们不仅要学习拆分函数的,也需要学会聚合函数,例如下面的两个函数:
可能第一眼,会这样: val result = square(triple(3))
但这时不是真正的复合函数,只是复合了函数的应用。
下面的答案是以函数编程来进行复合的:
然后我们就可以通过函数引用的方式,来进行复合:
如果想要把 compose 变得更加强大和通用,可以加入泛型, 如下所示:
这样,我们就把 compose 的功能变得很强大了, 使用泛型,能匹配任何类型的 compose 函数。
2. 高级函数的特征
上面一章学了函数的概念, 但是还没有解答一个最基本的问题,即为什么要将函数作为数据,进行使用或者传递,为什么不只是用 fun 版本? 下面需要来考虑处理多参数的函数。
2.1 多参数函数
没有多个参数的函数, 只有多个参数组成的元组的函数, 就是元组的参数可以是任意多个, 它本身可以是 Pair
或者 Triple
类型等。
现在定义一个函数, 由两个整数相加, 将函数作用于第一个整数,然后返回一个函数, 这个函数的类型是 :
那么这个整数相加的函数就是:
那该如果使用 add 函数来将 3 和 5 相加呢,那就要用到上面学习的柯里化函数了, add 函数被认为是等效的元组函数val add: (Int, Int) -> Int = { a, b -> a + b }
的柯里化形式,使用为:
2.2 高阶函数的定义
在上一章中,我们为了达到函数复合 ,编写了一个 compose
函数,这种函数接收两个函数组成的元组,作为其参数,并返回一个函数。 但其实可以用值函数来代替 fun 函数, 这种特殊类型的函数, 以函数为参数并返回函数,称之为高阶函数HOF。 下面我们将 compose 函数( Int 版本), 写成值函数的形式:
最后使用:
2.2.1 compose 的多态高阶版本
上面的 compose 只能符合 Int 到 Int , 我们可以使其多态化,让其复合多种不同的类型,为此我们加入泛型。下面来编写一个多态版本的 compose 值函数,看起来我们只要将上面的 Int 换成泛型就行了?如下
但是这样是不行的,因为 Kotlin 不允许对属性使用泛型, 如果你要使用泛型,只能在类、接口和fun函数上,所以我们只能把其改成 fun 函数:
higherCompose
不接收任何函数,并且始终返回相同的值,是一个常函数。接下来使用它时,必须要指明泛型类型,告诉编译器当前函数使用的类型,不然编译会报错:
下面来编写一个 higherCompose
, 使得 higherCompose(f)(g)
等价于 higherCompose(g)(f)
,很简单,交换下 f 和 g 的类型即可:
这样的目的是测试参数的顺序, 使用从 Int 到 Int 的函数进行测试将是模棱两可的,因可以按两种顺序符合函数,这样很难检测出错误,我们在测试时,可以使用多种类型
可以看看这样的测试代码:
2.3 使用匿名函数
我们可以使用匿名函数来省略中间函数的定义,例如:
可以使用匿名函数写成:
也可以使用高阶函数写成:
一般来说,匿名函数还命名函数,选择是任意的, 通常下,如果一个函数只使用一次,可以把这个函数弄成匿名函数
2.4 闭包
看下下面代码:
上面的 addTax
对 price
来说不是一个纯函数,因为函数依赖了参数以外的属性 taxRate
, 对于同样的price,它可能会返回不同的结果(尽管 taxRate 是使用 val 来声明的)。 只能说该函数是元组 (price, taxRate)
的纯函数。
所以当函数作为参数传递给其他函数时,它们可能会引发问题。 如果这类函数在同一个类出现很多次,这会使得程序难以阅读或者维护。
为了使得函数易于阅读和维护,一种方法是使他们更加的模块化,这使得程序的每个部分都可以单独的作为一个模块来使用,我们可以通过把元组作为参数来实现:
上面学了多参数处理,所以也可以写成值函数版本或者柯里化版本…
2.5 应用偏函数和自动柯里化
上面写了闭包类型和柯里化类型,虽然对于同样的入参,他们的结果是一样的,但是他们的语义是不一样的。
闭包是一股脑的将参数塞入,而柯里化则是层层递进。
上面的柯里化版本,其实就等价于下面的类:
代码:
等价于柯里化的:
可以看到,其实柯里化函数和偏应用函数是密切相关的,可以做到一个参数接一个参数将一个元组给替换为可偏应用的函数。这就是其和元组参数的区别。
2.5.1 例1
试着写一个函数, 双参的柯里化函数,偏应用其第一个参数,推演如下:
2.5.2 例2
试着写一个函数, 也是双参的柯里化函数,偏应用其第二个参数,推演如下:
2.5.3 例3
写一个函数来将柯里化 (A, B) -> C
类型的函数
2.6 切换偏应用函数的参数
假如一个函数有两个参数,但有时候我们不想直接得到结果,(例如其中一个参数还不清楚值),我们只想通过一个参数来获得一个偏应用函数,例如下面的:
开发者可能想先计算税,然后获得一个参数的新函数,然后可以将该函数应用于任何价格:
但是对于 addTax
函数来说, 如果两个参数变化了位置,即第一个价格,第二个是税率,这个函数又是别人写的,对我们来说我们现在只知道税率,不知道价格, 我们该怎么办才好呢?
答案是我们可以写一个 fun 函数来交换柯里化函数的参数:
2.8 使用正确的类型
前面的示例虽然使用了 Int、String、Double 等基础类型来表示价格、税率等业务实体,这也是我们编程中常用的做法,但是它会导致一些问题,我们应该相信类型而不是名字, 例如 称 Double 值为 “price” 并不意味它是一个价格, 这只是表明了一种意图。称另个一个 Double 值 叫 “taxRate” ,则表示了另一个意图。
为了使程序更安全,我们需要使编译器可以检查更广范围的类型,这可以避免将 “price” 和 “taxRate” 相加,在编译器中,两个 Double 值相加是没问题的,但实际上是错误的。
2.8.1 避免使用标准类型带来的错误
下面来看一个代码例子,来看待这种问题。 假设一个商品有名称、价格、重量,需要创建商品销售的发票,这些发票必须注明商品、数量、总价和总重量。
下面用一个 Product 来表示一个商品:
然后,用一个 OrderLine 来表示一个订单的每一行:
继续使用标准类型, 使用 List<OrderLine>
来表示订单, 下面我在main方法中来处理订单的价格和数量,代码如下:
可以看到,编译是不会报错的, 也有运行结果, 但是这个结果显然是错误的。
建模中有一个概念: 类不应该有多个相同类型的属性, 相反, 他们应该有一个具有特定基数的属性, 这很好的提醒了我们,如果在类中定义了几个相同类型的属性,则需要小心这些属性被弄混
2.8.2 定义值类型
为了避免上面出现的问题, 应该使用 值类型, 例如一个类来表示价格:
但这并不能解决问题,因为也可以写成:
接下来需要做的,就是为 Price 和 Weight 定义加法, 可以通过 operator
关键字来声明:
这样编译器就能帮我们检查到错误了。
现在, 不再使用 sumByDouble 来计算 price 的总和, 因为它只检查 Double 类型。 我们可以使用更好的方法: fold
或者 reduce
,它们可以将集合缩减为单个元素, 两者区别不是很明显,通常取决于:
- fold 提供初始元素, 结果与元素类型是不同的
- reduce 不提供初始元素, 结果与类型是相同的
下面将使用 fold ,会使用一个为 0 的价格 Prioce(0.0) 以及一个为 0 重量 Weight(0.0) 作为初始值,然后作为参数的函数则使用刚刚定义的加法,可以使用 Lambda 表达式,代码如下:
如果编译器没有报错,就说明我们使用了正确的类型,这个时候,我们已经把类型检查做的很好了!
当然如果想要深化,那确实还有可以深化的地方,比如 不可能有 0 值的重量或者价格的商品,我们不希望别人在构造我们的对象时,声明了 Price(0.0) 、 Weight(0.0) 这样的代码,如果遇到了,我们希望抛出异常。 而如果他们想使用这个对象,来放在 fold 或者 reduce 中来做累加, 我们可以提供一个单位函数给他们使用(单位函数就是类似于加法中的0,乘法中的1这样的中立元素)。
为了达到目的, 我们可以使用私有构造函数和工厂函数,对于 Price, 可以修改为如下代码:
构造函数现在是私有的,伴生对象 invoke
函数现在被声明为 operator 并包含验证代码, 相当于被重载了 .()
运算符
因此,可以完全像构造函数一样使用工厂函数,最终处理订单的代码如下所示:
总结
- 纯函数除了返回值之外没有任何可见的作用,我们要多写纯函数
- 值函数可以通过使用 lmbda表达还是或者 引用fun函数来实现
- 如果要让抽象编程推向极致,经常需要做的就是把带元组参数的函数,声明成偏应用的柯里化函数
- 可以通过合成函数来创建新的函数
- 通过允许编译器检测类型问题,可以使用值类型使程序更安全