浅浅认识一下函数式语言

函数式编程(Functional Programming, FP)与面向对象编程(Object Oriented Programming, OOP)、面向过程(Procedure Oriented Programming, POP)一样,是一种编程思想。编程思想究竟是什么呢?我想,这应该指的是一种编程思维模式,也就是我们应当以什么样的角度来看待我们的代码。同样的代码在不同的编程思想下,由于视角不同,我们可能会得出目标一致的不同的代码。

本文的内容仅仅是我自己的浅薄看法,存在不足之处。
本文的代码是Kotlin代码。编程的思想应当是与语言没有强关联的,本文以问题能被描述清晰为目标,语言不是重要因素。

对比面向过程、面向对象和函数式编程

打个比方来说,一个从1到100的整数求和代码,在面向过程、面向对象和函数式编程三个角度来看的话,应当如何分析呢?

面向过程的做法

首先,我们想必不难得出面向过程的代码:

var sum: Int = 0
for (i in 1..100) {
  sum += i
}
println(sum)

面向过程正如它的名字一样,“面向”二字的含义是“以...为中心”的意思。在这种思维模式下,我们以“过程”为中心,对1到100的整数求和被自然的拆解为了若干个步骤,也就是1+2=3,然后3+3=6,接着6+4=10以此类推,在循环计算的过程中设置了一个变量sum存储求和结果并最终将其输出。
再这样的编程思想下,我们的思维被限定于一个一个数字的加和过程之中了,我们要一步一步将这些数字加和起来。

面向对象的做法

虽然实际上对于一些典型的面向对象语言(比如Java)的开发者而言仍然会按照面向过程的思维去写一个这样的代码。但是我们不妨思考一下,一个标标准准的面向对象做法应当怎么去实现呢?

实际上,如果引入了面向对象的思维的话,为了使得代码更为优雅,我们通常要引入设计模式等知识来使得代码更具有组织性与可靠性。
不过没关系,这里我们索性就写的“啰嗦”一点,“大胆”一点,丢失一些严谨性,重在展现面向对象这件事好了。

首先我们先定义这样三个类:

class RangeNumberFactory(start: Int, private val end: Int) {
    private var now = start
    fun next(): RangeNumberFactoryResult {
        if (now > end) return RangeNumberFactoryResult(-1, false)
        return RangeNumberFactoryResult(now++, true)
    }
}

class RangeNumberFactoryResult(val number: Int, val success: Boolean)

class NumberAppender() {
    private var now = 0
    fun append(num: Int) {
        now += num
    }

    fun get(): Int {
        return now
    }
}

注:这里的RangeNumberFactoryResult没用kotlin的data class是故意的,这里考虑的是对于Java而言record这样的特性在Java 17才被引入,早期的Java代码的Result大抵就是这样定义了一个类。

这三个类最重要的分别为RangeNumberFactoryNumberAppender两个类,这两个类对应的对象分别能产生某个区间的各个数字,以及能够实现给定输入的数字的加和计算器。

有了这两个类的话,我们不难写出这样的代码:

val f = RangeNumberFactory(1, 100)
val a = NumberAppender()

var result = f.next()
while (result.success) {
  a.append(result.number)
  result = f.next()
}

println(a.get())

这样也能够实现我们最开始的目的。

面向对象的思维总是希望把数据“包装”起来(即面向对象要素中的“封装”),在这种思维逻辑下,我们总是希望将对最本真的数据的操作封装成较为成熟的方法,并将这些方法对外暴露。

这里不妨再举一个例子好了,对于Java开发者而言,想必我们经常会写出这样的代码:

public class SomethingDTO {
  private String name;
  private int age;

  public String getName() { return name; }
  public int getAge() { return age; }
  public void setName(String name) { this.name = name; }
  public void setAge(int age) { this.age = age; }
}

在我刚开始学习Java的时候就对这件事非常疑惑,为什么不能写成这样子呢:

public class SomethingDTO {
  public String name;
  public int age;
}

可以发现,我们对name的操作与age的操作都是通过这两对方法进行的,实际上我们完全可以直接操作原始的nameage哇。
这样的两对方法在面向对象里叫做getter和setter。实际上,完善的getter/setter能帮助我们的代码变得更为完善,比如这里我希望让age排除不合理的数值输入的话,我们可以轻易地修改setAge实现这一点。

public void setAge(int age) {
  if(age <= 0 || age > 200) throw new RuntimeException("Invalid age!");
  this.age = age;
}

如果我们不使用getter/setter这样的方式的话,调用者能随便修改最本真的数据,我们对非法输入将无能为力。

不过经常有人吐槽,Java程序员的所谓getter和setter其实只是凸显格调罢了,大多数情况下他们还是会用Lombok或者IDEA快捷键去自动生成getter/setter的,谁会在乎这里的逻辑合理性呢?
emmmmm,如果我们是某个库的作者的话,也许我们现在意识不到这里对某处变量限制的重要性,但是使用了这样的模式后,后续我们如果突然想加上某些限制的话,我们可以轻易地修改getter/setter,而不需要调用者大改代码进行配合了。

正如有些人说Java这样的语言过于“啰嗦”,某种角度上来看的话,这门语言同样也具有它自己的严谨性。这里我们只是“小题大做”的写了个很啰嗦的1到100的整数求和,如果对于一个庞大的系统性工程的话,也许这样是一件好事呢。

函数式编程的做法

面向过程的思路会让我们上来就把问题拆解成若干个步骤,面向对象的思路会让我们上来做对象设计并构造一些“趁手兵器”。函数式编程的思维逻辑也许并不像前面的那样上来便细化问题,它更倾向于由整体看待问题,而不是从个体下手,循序渐进的解决问题

先看看函数式编程下的1到100的整数求和怎么实现:

print((1..100).reduce { acc, i -> acc + i })

这里(1..100)是kotlin提供的特殊语法,表示1至100这一左闭右闭的整数区间。我们直接对这个区间进行了reduce操作, 函数会遍历这个范围,并将每个元素与累积值acc相加,最终得到总和。

这个例子相比对于习惯于面向过程或者面向对象的开发者而言,都会多多少少有些意见。面向过程的开发者也许会觉得这仍然是一步一步进行的,我只是用了一种很花哨的方式表示了acc + i这个逐步操作罢了。而面向对象的开发者觉得(1..100)不就是我之前写的NumberRangeFactory,而后面的reduce实际上是个写的花哨一些的NumberAppender
对于这个问题而言,我觉得我们重点仍然是能切换一下自己的固有视角。我们不如从零开始看看这样的代码是怎样诞生的:
首先我们带入先整体再局部的思路看待这段代码,我们一上来便创建好了整个的大问题(1..100),也就是我限定了一个大的区域。

val scope = (1..100)

我们接下来的问题就是对这样的整体进行合适的处理,转换为我们最终想要的这个区域的整数和。这里kotlin为我们提供了reduce方法做处理,能够很轻松的实现加和:

val scope = (1..100)
val process = { acc: Int, i: Int ->
    acc + i
}
val sum = scope.reduce(process)

这里的话process构造了一个Lambda表达式,这是函数式编程的一个特性,在这里我们可以理解为它是一个被灵活创造出来的函数,输入参数是Int类型的acci,执行它后,函数的返回值是acc + i

然后我们调用scope.reduce,并将这样的process表达式传参进入,reduce的作用便是从前向后依次对scope中的元素两两依据传入的process进行加和并输出最终的值。

这里因为我用的语言是kotlin,根据kotlin的语法,首先我们可以省略掉繁琐的中间变量的定义:

val sum = (1..100).reduce({ acc: Int, i: Int -> acc + i })

然后根据kotlin语法,如果一个方法的传参仅仅是一个表达式的话,可以省略括号,因此这里的reduce外不需要括号,因此变成了这样:

val sum = (1..100).reduce { acc: Int, i: Int -> acc + i}

也就变成了最开始的样子了。

不过这个例子对于kotlin这门语言而言确实是啰嗦了,kotlin里实际上直接就有sum可以用,没必要调用reduce

val sum = (1..100).sum()

这样就好了(逃

再多体会一些函数式编程的例子

流式处理的例子

依据函数式编程从整体往结果想问题的思维优势,如果我们利用这样的写法做流式处理,会具有相当大的优势,比如我们可以想想看,我们怎么筛选出1到100之间所有不能整除以3和5的素数呢?

首先我们写一个(1..100),表示“整体”:

(1..100).forEach { num ->
    print(num)
}

这里的forEach的作用就像它长的样子那样,表示对各个元素进行输出。现在我们来想想办法去除掉所有不能被3和5整除的整数:

(1..100)
    .filterNot { num -> num % 3 == 0 }
    .filterNot { num -> num % 5 == 0 }
    .forEach { num ->
        print(num)
    }

这里两个filterNot操作,我们可以形象的想象成(1..100)中的各个数字从头到尾依次像涓涓流淌的水流一样自然的从上而下依次流经这两个过滤器,如果发现不能满足num % 3 == 0num % 5 == 0的话,每一“滴”元素才能顺利流经这个过滤器。我想,这也正是“流式处理”的名字的含义所在吧。

那么如何实现判断素数呢?看看下面这个操作:

(1..100)
    .filterNot { num -> num % 3 == 0 }
    .filterNot { num -> num % 5 == 0 }
    .filter { num -> (2..<num).count { x -> num % x == 0 } == 0 && num != 1 }
    .forEach { num ->
        println(num)
    }

这里filter显然与filterNot是相反的含义,表示满足条件才能放行的意思。后面的条件分为两部分,最后是num != 1,这是显然的,1并不是素数。那第一个条件是什么意思呢?
实际上这里第一部分的条件的意思是希望从[2,num)这个左闭右开区间里找一个能够整除num的数字,如果找到的数量是0(也就是没找着),那么就说明这个数字显然是个素数。想必有了上面的思路后,我们也能很轻松的理解这段代码的意思了。

实际上上面的代码也有一些故意啰嗦的嫌疑,比如这三个filter理论都可以合并一下,而num != 1这个条件完全可以通过把(1..100)改成(2..100)来去掉。

不过这无所谓了,我们只是看看流式处理中函数式编程思想的优势。

对函数式编程的一些偏见

有人会把函数式编程理解为“面向函数”的意思,他们会把函数式语言理解成函数作为参数传来传去的语言。我个人觉得这是一种不妥当的理解。

对于这个问题,在函数式编程中确实会显得函数“更为自由”了,函数居然能看起来作为一种“量”被传来传去。比如在一开始的整数加和例子中的这段代码:

val scope = (1..100)
val process = { acc: Int, i: Int ->
    acc + i
}
val sum = scope.reduce(process)

process这样一个常量里居然存储着Lambda表达式这样一个“函数”,并且它居然还能作为参数传入到reduce中,这看起来是有一些些天马行空了。
但是我觉得如果我们体会到了流式处理中函数式编程的优势后,我们便会意识到这样的特性并不是函数式编程的“目标”,而是函数式编程为了实现流失编程这种目标的“手段”。试想刚才流式处理中的例子写的更为啰嗦一些会变成什么样子:

val process1 = { num -> num % 3 == 0 }
val process2 = { num -> num % 5 == 0 }
val process3 = { x -> num % x == 0 }
val process4 = { num -> (2..<num).count(process3)  == 0 && num != 1 }
(1..100)
    .filterNot(process1)
    .filterNot(process2)
    .filter(process4)
    .forEach { num ->
        println(num)
    }

注:这里只是简单演示一下意思,这段代码实际是跑不通的,因为我偷懒没写各个process参数的参数类型

emmmmmmmm,这样做就不优雅了......

实际上,任何程序的最终目的显然都是为了对数据操作,而不是对函数做操作。虽然函数式编程语言往往把更为自由的传递函数作为一种特性,但是这样做的目的还应当落实在对数据的处理上才对。

在函数式编程中,我们大概追求的东西就像是把“整体”给渐渐“转换”成目标的艺术。

函数式编程的实现的争议

有这样的一种声音认为函数式编程是没有意义的。他们认为所谓的(1..100)做整数加和的例子:

val sum = (1..100).sum()

如果我们用IDEA看看sum的内部实现,代码是这样的:

@kotlin.jvm.JvmName("sumOfByte")
public fun Iterable<Byte>.sum(): Int {
    var sum: Int = 0
    for (element in this) {
        sum += element
    }
    return sum
}

其实sum的内部实现就是一个朴实无华的Iterator迭代器加循环实现的啊,根本也不高级,说到底还是面向对象的那一套。至于reduce的内部实现就更朴实无华了:

public inline fun <S, T : S> Iterable<T>.reduce(operation: (acc: S, T) -> S): S {
    val iterator = this.iterator()
    if (!iterator.hasNext()) throw UnsupportedOperationException("Empty collection can't be reduced.")
    var accumulator: S = iterator.next()
    while (iterator.hasNext()) {
        accumulator = operation(accumulator, iterator.next())
    }
    return accumulator
}

不还是迭代器循环处理嘛,有什么大不了的。

我在文章的开头便一直在表达我的观点,那便是函数式编程只是一种编程思想,而并不是什么很高级的东西。

如果我们一切都要追根溯源的话,其实所有高级语言终将归于机器语言进行执行,机器语言运行在CPU上,里面是计算机组成原理需要涉及的中央处理器的知识,而这些知识是电路与物理学的领域,终究这些内容都将归属于微观世界中的粒子的相互作用......
一味地刨根问底并不是我们对待问题应有的态度,我们对于所谓“原理”的探索应当有个适度。原理当然是我们要了解的内容,但是当我们看到了基础之后,反过来否定上层是没有道理的。
如果这样也可以,那我突然还能发现Python整个语言是没有意义的,因为Python的底层是CPython,本质就是C语言嘛,而Java更是毫无意义了,因为它的底层同样也是C语言构成的,我们为什么不去直接调用C语言开发程序呢?我觉得这种凡事都要刨根问底反过来否定上层的人都差不多得了。

所以我们应当怎么理解编程思想呢?编程思想就是一种思维方式罢了,没有什么大不了的,只是一种想问题的方式。

现如今如Java这样的语言都开始引入了函数式编程,例如Stream API等等,我们经常能在各个语言里找到面向过程、面向对象和函数式编程的影子。实际上思维与思维之间没有无法逾越的鸿沟,人是会思考的动物,我相信所有的思考都是有用的,思维的火花总能有它能够照耀到的地方。

上一篇