为什么我更喜欢函数式编程
在学习 Haskell 之前,作者一直使用主流语言,如 Java、C 和 C++——现在他仍然喜欢它们。那么,一个命令式开发人员如何转变成了一个 Haskell 开发者?他将在本文中将对此做出解释——尤其是对那些在函数式编程方面经验较少的开发人员。
本文最初发布于 Mario Morgenthum 的个人博客,由 InfoQ 中文站翻译并分享。
首先,我将通过对一些主题的讨论比较函数式编程和面向对象编程,因为它是最流行的范式。在第一个代码示例中,我将简要介绍 Haskell 的语法,因为我将在本文中使用它。
控制流
控制流描述你如何告诉程序做什么——形成算法。基本控制元素有以下三种:
- 顺序——顺序执行代码
- 重复——重复执行代码
- 选择——根据条件将代码划分成分支
面向对象编程
- 顺序是语句逐行执行
- 重复是循环,如 for 或 while 语句,或递归
- 选择是 if … else 或 switch 语句
下面这个简单的例子使用 Java 实现文本居中显示。该文本是作为一个字符串数组传入的。每一行是这个数组的一个元素:
复制代码
void alignCenter(String[] text) { int maxLength = 0; for (String line : text) { if (line.length() > maxLength) { maxLength = line.length(); } } for (int i = 0; i < text.length; ++i) { int spaceCount = (maxLength - text[i].length()) / 2; StringBuilder builder = new StringBuilder(); for (int j = 0; j < spaceCount; ++j) { builder.append(' '); } builder.append(text[i]); text[i] = builder.toString(); } }
函数式编程
- 顺序是链式调用
- 重复是递归
- 选择是模式匹配,或 case … of 或 if … else 表达式
下面是同一个例子的 Haskell 实现,展示模式匹配和递归的用法:
复制代码
alignCenter :: [String] -> [String] alignCenter xs = alignCenter' maxLength xs where maxLength = maximum (map length xs) alignCenter' :: Int -> [String] -> [String] alignCenter' _ [] = [] alignCenter' n (x:xs) = (replicate spaceCount ' ' ++ x) : alignCenter' n xs where spaceCount = div (n - length x) 2 {1}
下面是一个没有使用递归的简化版本,使用了 map 和 lambda 函数:
复制代码
alignCenter :: [String] -> [String] alignCenter xs = map (\\x -> replicate (div (n - length x) 2) ' ' ++ x) xs where n = maximum (map length xs)
Haskell 简介
函数的第一行是签名。签名 alignCenter :: [String] -> [String] 告诉我们这是一个名为 alignCenter 的函数,其输入是一个字符串列表,输出是一个新字符串列表(从左往右读)。
第一个函数确定字符串列表中最长的行,并调用第二个函数。我们通过一个简单的表达式 maximum (map length xs) 终止第一个循环。那么它是如何工作的?让我们看下涉及到的所有函数的签名。
复制代码
length :: [a] -> Int map :: (a -> b) -> [a] -> [b] maximum :: [a] -> a
length 函数的输入是一个任意类型的列表,输出是一个 Int 值。类型签名中的所有小写类型都是类型变量,类似于 Java 中 List里的 T。我认为函数的功能非常明了。
map 函数接收两个参数,第一个是 a -> b 类型的函数,第二个是 [a],返回值是 [b]。 那么,“它接收一个函数作为参数”是什么意思呢?是的,这是真的。你可以将函数作为参数传递,不过不能是函数指针(如 C 语言中),也不能是方法引用(如 Java 语言中),要是作为第一类值的真正函数。以函数为参数或返回新函数作为结果的函数称为高阶函数。那么,这个函数是干什么用的呢?它将 [a] 的每个元素传递给 a -> b 函数,后者将 a 转换为 b,并把它们汇集到一个新列表 [b] 中。
现在让我们解析下类型变量 map length xs,其中,xs 是 [String] 类型。
复制代码
map :: (String -> Int) -> [String] -> [Int]
你需要知道 String 是 [Char] 类型的同义词,表示字符列表。这就是为什么它兼容 length 函数。表达式 map length [“Hello”, “World!”] 会被解析成 [5, 6]。我们感兴趣的是列表中最长字符串的长度,因此,我们将结果列表传给 maximum,它会返回列表中长度最大的元素,即 6。
我们看下第二个函数:
复制代码
alignCenter' :: Int -> [String] -> [String]
你可能已经注意到函数名末尾的’。没有什么特别的,它只是 Haskell 中一个有效的标识符字符,因为它在数学中是一个常用符号,表示与先前标识符相关的名称。该函数是递归的,我们遍历文本的每一行,进行转换,并将转换后的行放在所有剩余元素的递归调用之前。
alignCenter’ _[] =[] 这行代码是递归基本型。它的意思是:如果第二个参数是空列表,那么返回一个空列表,因为没有什么可做。在这种情况下,我们对第一个参数的值不感兴趣,所以我们不需要为它命名而只需要以 _ 表示。
以下几行代码就完成了整个工作:
复制代码
alignCenter' n (x:xs) = (replicate spaceCount ' ' ++ x) : alignCenter' n xs where spaceCount = div (n - length x) 2
我们将第一个参数绑定到 n,将第二个参数(一个列表)与模式 (x:xs) 进行匹配,这意味着:将列表的第一个元素绑定到 x,其余所有元素绑定到 xs。我们会根据需要复制空格,将它们与当前元素 x 串在一起,并在所有剩余的元素 xs 递归调用的结果列表前加上:。就这些。
在递归操作(reduction step)之前声明递归的结束条件(base case)非常重要,因为编译器自顶向下运行,并采用它找到的第一个匹配模式。
小结
与相同代码的 OOP 版本相比,我们使用模式匹配和抽象函数节省了大量代码。好了,现在你可能会抱怨:“嗯,你只是把整个代码隐藏在库函数里了,比如 replicate、map 和 maximum”——我告诉你:“是的,当然!因为我不需要成千上万次地重复编写同样的 for 循环!”老实说,Java 代码可以使用 leftPad 之类的东西来复制空格,但它是一个非常具体的函数,专门用于填充字符串,没有其他用途。
在函数式编程中,你能够以一种简单的方式抽象常见的循环用例来执行映射、过滤、折叠和展开等任务。在 OOP 中,如果没有大量的样板代码(如后台接口和内置语法糖),你将无法实现这样优雅的解决方案。
概 念
这些概念描述了构建应用程序的基本思想。代码、数据及其交互在各自的范式中是如何表示的?
面向对象编程
面向对象编程引入了接口、类、继承和对象的概念。对象包含数据字段和方法代码,这些方法通过操作字段来更改对象状态。
函数式编程
函数式编程的核心是函数。与 OOP 中的方法相比,你能用它做的事情更多:
- 把函数传递给其他函数
- 将新函数作为函数的求值结果返回
- 将两个函数组合成一个新函数
- 使用函数的一部分构建一个新函数
函数求值的输出只取决于它的输入。这意味着不存在可以影响函数结果的隐藏变量。这大大提高了可测试性。
数据由代数数据类型表示。在函数式编程中,你不需要像类那样将数据和代码放在容器中。你将构建一组数据类型和一组单独的函数,这些函数对这些类型进行操作。数据类型不知道它们被哪些函数使用,因为它们对函数一无所知,而且每个函数都不知道还有其他函数也对相同的数据类型进行操作。
下面是 Haskell 中数据类型的一些例子,只是让你感受下它们是如何定义的:
复制代码
data Bool = True | False data Customer = Customer Int String data Customer' = Customer' { customerId :: Int, customerName :: String }
总是有一个数据类型名称和一个以|分隔的构造函数名称列表,其中包含可选参数。第一个示例很简单。第二个示例有一个与类型同名的构造函数和两个参数。最后一个示例与前面的示例相同,但是使用了命名参数,这称为记录语法。
Haskell 中的数据是不可变的,这意味着你不能更改 Customer 的姓名,而是需要用新姓名创建一个客户。
小结
假设,你有一个现实世界的问题需要解决。第一步做什么?试着把问题分解成更小的问题,然后再进一步细分下去。然后,描述你的问题,这意味着将你的问题放入你选择的编程语言的俚语中。
在 OOP 的情况下,你需要发现类及其字段和方法,找到相似性,将它们放入抽象类中,并最终通过派生这些抽象类来构建可以供使用的具体类。
FP 则是从函数开始。一个函数处理一个非常小的问题,它操作非常小的类型。在理想情况下,类型完全包含函数所需的信息,不多不少。这可以保证类型和函数几乎不需要更改,即使你完全重构了应用程序的其余部分,除非你的问题发生了变化。事实证明,你还会将你的逻辑类型或业务实体分解为小的技术类型,从而实现无痛且安全的重构。
耦 合
耦合描述组件之间依赖关系以及一个组件的变化对其他组件的影响。
面向对象编程
彼此通信的对象是紧耦合的。限制耦合的一种方法是应用诸如依赖倒置之类的原则,即你应该通过抽象(如接口)而不是实现(如类)进行通信。
为我们希望其交换信息的实现定义接口。为了避免出现很大的通用接口,一个接口应该只包含几个高内聚方法——这称为接口隔离。从长远来看,如果做得不对,你很可能会遇到虚拟接口实现,比如抛出 UnsupportedOperationException 异常或在空方法体中返回虚拟值。
当涉及到接口实现时,你经常添加抽象类来实现接口的某些部分,未受影响的接口方法仍由具体实现来实现——这就是继承的原理,这是 OOP 中最紧密的耦合。
面向对象和继承的思想是为了使编程更接近现实世界。我们都知道这样的例子:“对于 Car 和 Truck 这两个派生类,有两个基类 Vehicle 和 Ship。可是,Amphibian 怎么处理?”它有两个基类的特征——所以你需要多重继承,但因为钻石问题,这是一个坏主意。为了解决这些问题,开发人员引入了组合优于继承的原则,这意味着你应该用可替换的组件组合对象。显然,组合优于继承有点违背 OOP 的原始关键概念之一——继承。
如你所见,一切都关乎正确的类和接口结构——为了设计出一个好的软件设计,还有很多原则、反原则和模式需要你关注。
最后但同样重要的是,下面这个简单的例子展示了如何使用依赖倒置原则实现排序算法与比较逻辑的松耦合,该例子使用接口作为抽象:
复制代码
interface Comparator<T> { int compare(T o1, T o2); } class ArcaneComparator<T> implements Comparator<T> { public int compare(T o1, T o2) { // 在这里插入晦涩难懂的比较实现 } } class Arrays { static <T> void sort(T[] a, Comparator<? super T> c) { // 使用比较器 c, // 不需要了解具体实现 } }
函数式编程
FP 是组合组件而不是耦合组件。FP 中的松耦合函数是指通过识别相似性来抽象函数,提取细节,构建高阶函数,并用细节参数化它们。
让我们来看看下面的情况:
复制代码
sortById :: [Customer] -> [Customer] sortByName :: [Customer] -> [Customer]
有两个函数做同样的事情——它们按照某些标准进行排序。那么,为什么我们不把相似点放到一个新的函数中来防止重复呢?
复制代码
data Ordering = LT | EQ | GT ... sort :: (Customer -> Customer -> Ordering) -> [Customer] -> [Customer] compareId :: Customer -> Customer -> Ordering compareName :: Customer -> Customer -> Ordering
或使用一个类型同义词:
复制代码
type Compare = Customer -> Customer -> Ordering sort :: Compare -> [Customer] -> [Customer] compareId :: Compare compareName :: Compare
sort 的第一个参数是 Customer -> Customer -> ordering 类型的函数,这意味着它接收两个客户,对于小于、等于或大于的情况,分别返回 LT、EQ 或 GT。这有什么不同呢?我们分解出了上述用于对列表进行排序的标准。我们现在可以写成 sort compareId 而不是 sortById。如果你还想叫它 sortById,也很容易做到:
复制代码
sortById :: [Customer] -> [Customer] sortById customers = sort compareId customers
或者:
复制代码
sortById :: [Customer] -> [Customer] sortById = sort compareId
如果你是最近才接触函数式编程,那么第二个版本在你看来可能有点不够清晰,所以我建议你好好看看第一个版本。如果你对第二种方法感兴趣,你可以进一步阅读,这称为 Eta 变换。
sort 函数仍然依赖于 Customer 类型,这已经不重要了,因为这些细节被分解了。只有 compare 函数对类型的细节感兴趣。所以我们可以用一个类型变量替换它:
复制代码
sort :: (a -> a -> Ordering) -> [a] -> [a]
小结
我们可以用任何一种方式表达相同的功能。在 OOP 中,我们使用了一些语言特性,比如接口以及实现该接口的类。在 FP 中,我们有函数。类型 a -> a ->Ordering 表示接口,与该类型匹配的每个函数都可能是该接口的实现。
未讨论的话题
- Monads——因为我不想陷入 monad 谬误
- 类型类——没有理由
一些事实
- Haskell 是有类型 lambda 演算的实现。Haskell 中的所有东西都有数学支持,比如范畴和类型理论。
- Haskell 是强静态类型,但你很少需要自己编写类型,因为编译器在大多数情况下可以从全局推断它的类型。
- 因为抽象性和不可变性,Haskell 的速度比你想的要快。
结束语
在我个人看来,我觉得函数式编程比面向对象编程干净得多。
在编写相同的功能时,你可以:
- 更抽象
- 编写更少代码
- 使用更少的样板特性
而且:
- 更可维护
- 更稳定
- 更有趣
非常感性您耐心地读完这篇文章!