第 1 章 引言(Introduction)
本章内容包括:
- 什么是 Haskell
- 什么是纯函数式编程,以及它为何重要
- 在编程中使用抽象的优势
- 我们所学到的内容
复杂的软件系统无处不在,而作为程序员的我们,需要合适的工具来构建它们。我们最需要的,是一种能帮助我们简化开发过程的编程语言。Haskell 正是这样一种最先进的语言——它融合了多种前沿技术,为我们提供了强大的表达力与优雅的设计。Haskell 拥有令人印象深刻的语言特性组合与少有的优美结构,因此逐渐被神秘与传奇所笼罩……而本书将带你揭开它的面纱。
在本书中,我们将实现多个小型(甚至可以说是微型)的项目。其中有些是纯粹为了趣味,有些是实用工具,还有一些是专门挑选出来,用来展示如何高效地使用 Haskell 来创建快速、安全且可靠的软件。通过这些项目的实践,你将学习如何运用一些最优雅、最精致的编程范式来编写软件。
如果你曾经研究过 Haskell,却不知道从何入手,或对它的实用性心存疑虑——别担心。本书不要求你具备任何 Haskell 或函数式编程的基础知识。我们将从最简单的算法开始,最终实现我们自己的 Web 服务器!
所以——跳进来吧,这段旅程,绝对值得!
1.1 什么是 Haskell?(What is Haskell?)
如果你拿起了这本书,那么你很可能已经对 Haskell 有一定的了解。你知道它是一种编程语言,或许也知道它是一种函数式编程语言,甚至听说过它一些特性非常独特。但问题是:为什么要使用 Haskell? 又是什么让它如此与众不同?
1.1.1 抽象与理论(Abstraction and theory)
首先,Haskell 拥有许多高级抽象机制,例如代数数据类型(Algebraic Data Types)、类型类(Type Classes)以及单子(Monads)——这些内容我们都会在本书中详细介绍。这些抽象让我们能够编写整洁、可组合、易复用的代码,将复杂的逻辑概括为可重用的功能模块。这为什么重要?因为它能节省时间,减少调试痛苦。在 Haskell 中,一个算法通常只需实现一次,然后就能在不同的上下文中反复复用。举个例子:既然我们可以写一个通用的排序算法,为什么还要为不同的数据类型分别实现呢?
Haskell 的一个独特之处在于——它与学术界及编程语言研究有着直接的联系。其他语言往往源于工业需求,这一点在其架构设计中体现得很明显。 例如 Java,最初的许多底层设计决策,是为了实现“一次编写,到处运行”的承诺。后来,企业版(Enterprise Edition)的规范变化又带来了用于 Web 服务和分布式计算的 API 设计。而 Haskell 则完全不同。它并非由工业需求驱动,而是基于类型理论和现代编程语言设计的最新研究成果构建而成。Haskell 的核心理念是从一组简单的概念出发,构建出完整的语言体系。这使得它在设计上更注重程序的正确性,并且在语言特性上极度偏向安全性。
然而,这并不意味着 Haskell 忽视了性能。它的编译器是一个优化编译器(optimizing compiler),会分析源代码并进行重写优化以减少运行时间。 在某些情况下,这些激进的优化甚至能让 Haskell 的性能接近 C 语言等低级语言。此外,Haskell 还提供了功能强大的并行(parallelism)、并发(concurrency)和异步计算(asynchronous computation)库,使得编写多线程程序变得简单。 更重要的是,开发者不必操心内存管理等细节问题,因为 Haskell 的运行时系统内置了垃圾回收机制(garbage collector)。
Haskell 不只是普通的函数式编程语言,而是纯函数式编程语言(pure functional programming language)。 “纯(pure)”意味着我们编写的函数与数学中的函数几乎完全一致:它们有输入,有输出,但没有副作用(side effects)。也就是说,函数只能操作传入的数据,而不能,例如,额外从文件或网络读取数据。这有什么好处?当然有!它让我们更容易理解和推理程序的行为,仅通过阅读代码就能知道它在做什么。 更重要的是,这种方式通常能带来更少的错误和更高的可靠性。在本书的后续章节中,我们将深入探讨这一理念——因为它迫使我们以一种全新的方式思考编程。
1.1.2 一个安全的环境(A safe place)
那么,考虑到前面提到的那些设计理念,Haskell 与其他语言相比如何呢? 衡量一门编程语言的重要标准之一是它**“约束的强度”**——也就是这门语言的编译器或解释器在多大程度上强制遵守特定的语义规则。 这种“约束强度”会影响语言的两个核心特性:自由度(freedom)*与*安全性(safety)。
- 自由度指的是程序员在修改程序状态、操作资源(如内存、文件、套接字、线程等)时所受到的限制程度。
- 安全性则表示程序在多大程度上能够避免未定义行为和潜在错误。
这两者通常是反相关的: 当语言允许更多危险操作时,安全性会降低;而如果语言禁止这些操作,则安全性提升,但程序员的自由度相应下降——某些操作要么无法实现,要么实现成本极高。
这一关系如图 1.1 所示:
图 1.1 自由度与安全性的关系
编程语言在设计时,必须在这两者之间做出权衡。例如,许多动态类型脚本语言(如 Python)几乎允许程序员做任何想做的事。 这种设计带来的结果是:它们的内在安全性极低。写脚本很容易,但同样容易写出在运行时崩溃的脚本。相比之下,静态类型、编译型语言通过让编译器进行静态检查来提高安全性,强迫程序员遵守类型系统的规则,以生成可以被安全执行的程序。编译器的检查越严格,语言出错的可能性就越小,但编程难度也随之上升。例如:
- 在 Python 中,任意类型的值都可以传给任意函数,这让多态函数非常容易编写,但也容易出错——因为你无法确定传入参数的类型是否符合预期。
- 在 C 语言中,编译器至少会检查变量的类型是否匹配,但它赋予了程序员完全的内存控制权。这使得底层数据操作十分灵活,但也容易出现未定义行为。
- Java 使用垃圾回收(GC)来抽象化内存管理问题,但仍允许对象引用在不同线程或进程之间共享,从而可能导致竞态条件(race condition)。
- Rust 则禁止此类共享行为,通过借用检查与所有权系统排除了许多意外行为,但仍然允许可变状态(mutable state)。
- Haskell 位于编程语言的“安全端”。它通过不可变数据和无副作用的函数等限制来提升安全性,只有使用特定的编程模型(如
IO或Monad)时,才允许产生副作用。
在图 1.2中我们可以把程序编译的过程理解为一个管道(pipeline):程序员编写语法正确的源代码,然后由编译器(或解释器)转换为可执行的二进制代码。
图 1.2 程序编译管线(The Programming Pipeline)
编译器越严格,对程序员来说写代码就越“困难”。但这种严格性同时也过滤掉了那些可能在运行时崩溃的程序。 因此,如果一门语言的编译器非常严格,那么一旦程序能够通过编译,它很可能:
- 能正确地反映程序员的意图
- 运行时不会崩溃
- 并且不会产生未定义行为
换句话说,编译器帮助确保程序的可预测性与正确性。而 Haskell 正是属于这种“安全范式”下的语言。接下来,为了帮助你理解 Haskell 这种安全范式的独特性与优势,我们将从一些背景知识开始讲起。
1.2 纯函数式之道(The pure functional way)
自编程诞生以来,我们就一直被一个简单却几乎无法彻底解决的问题所困扰:我们如何确定我们的程序是正确的?这里的“正确”,不仅仅意味着程序不会崩溃,而是它确实按照我们预期的行为执行。在软件开发中,我们花费了大量资源在质量保证与测试上——这似乎说明,我们其实并不能完全信任自己写的程序。为什么会这样?当我们编写一个函数时,难道我们不知道它在做什么吗?在大多数情况下,我们确实知道!然而,有一样东西常常无法被彻底掌控,那就是——状态(state)。当程序运行时,它总会维护某种内部状态:这包括程序正在处理的数据(例如变量的值)以及程序的执行环境(例如运行所在的系统和上下文)。在过去的 50 年中,人们尝试了许多方法来更好地管理状态。其中一个答案是:将状态拆分为对象(objects),并为其提供受控的访问接口——这就是**面向对象编程(OOP)**的本质。但,还有另一种截然不同的思路。
1.2.1 声明式“食谱”(A declarative recipe)
如果我们不试图分割状态,而是尽量减少状态呢? 状态越少,问题就越少,对吧?这正是 Haskell 所采用的思想——**纯函数式编程(pure functional programming)**的核心!
对于尚未接触过函数式编程的读者,我们先来一次小小的“探险”。首先,什么是函数式编程?要理解它,我们先回顾非函数式程序是如何运作的。一般的(命令式)程序就像一份食谱(recipe),是一系列需要按顺序执行的指令。比如下面这份“巧克力蛋糕食谱”:
- 用小火在锅中融化黄油。
- 加入巧克力。
- 在碗中打入 8 个鸡蛋。
- 加入面粉、糖和泡打粉。
- 用搅拌器充分搅拌。
- 加入黄油和巧克力混合物。
- 将蛋糕糊倒入烤盘。
- 以 200°C 烘烤 25 分钟。
最终,按步骤执行下来,你就得到了一个巧克力蛋糕。但现在,如果我们想做一个柠檬蛋糕呢?食谱大概和上面相同,唯一的不同是第 2 步——这次不加巧克力,而是加入柠檬相关的原料。于是我们不得不复制大部分相同的步骤,只为改动一小部分。这带来了问题:哪些步骤是通用的?哪些是特例?比如第 1、2 步和第 6 步都与制作“基础蛋糕糊”无关——它们依赖特定的材料。这种依赖,就是状态问题!每完成一步,你都改变了厨房的“状态”,而接下来的步骤往往依赖这种变化。例如,“充分搅拌”这一步只有在确实有材料可搅拌时才有义。
那么,函数式的食谱会是什么样子呢?
- “黄油巧克力混合物”定义为:在小火下融化黄油与巧克力。
- “蛋糕糊”定义为:8 个打散的鸡蛋、面粉、糖与泡打粉的混合物。
- “巧克力蛋糕糊”定义为:蛋糕糊与黄油巧克力混合物,用搅拌器混合而成。
- “巧克力蛋糕”定义为:巧克力蛋糕糊在 200°C 下烘烤 25 分钟而成。
我们立刻能看出根本性的不同:这种食谱不是告诉你“该怎么做”,而是定义了各个中间结果之间的关系。从这些定义中,我们可以自动推导出制作步骤:要做出巧克力蛋糕,首先需要巧克力蛋糕糊;要做出巧克力蛋糕糊,则需要蛋糕糊与黄油巧克力混合物;再进一步,这些材料各自都有自己的定义。通过递归地展开定义,我们最终得到了最基础的操作(如添加原料),从而推导出整个过程。那么,如果现在我们想做一个柠檬蛋糕呢?非常简单!只需在第三步中将“黄油巧克力混合物”换成“柠檬混合物”即可,其他定义完全不用改动。在这种描述方式下,我们的“食谱”中没有状态变化,只有**定义(definition)**之间的依赖关系。因此,整个过程也不再有固定顺序:无论你是先制作蛋糕糊还是先制作巧克力混合物,都没关系——你(作为烘焙者)可以自由选择最方便的方式。这正是纯函数式编程思想的缩影:通过定义关系而非操作步骤,我们消除了状态依赖,使程序更简洁、更可靠、更易于推理。
1.2.2 从蛋糕到程序(From cake to program)
那么,这与编程有什么关系呢?如果我们把原料、混合物和蛋糕都想象成变量,在命令式(imperative)食谱中,这些变量会随着步骤的执行而不断变化;而在函数式(functional)食谱中,它们从不改变——每个值只被生成一次。函数式食谱中的步骤并不依赖变量的“状态”,而只依赖于它们的“定义”。当某个变量被需要时,我们只需“求值(evaluate)”即可。此外,在函数式食谱中,有些步骤包含“如何进行变换”的信息,例如“在 200°C 下烘烤 25 分钟”。在软件中,这种“变换”就由函数(function)*来表示,并且函数还可以*被参数化。
这就引出了函数式编程的一个核心特性:
函数是一等公民(first-class citizen)。 也就是说,函数本身可以像其他值一样被传递、返回、存储,甚至作为参数传入其他函数。
这种编程方式也被称为声明式编程(declarative programming),因为整个程序可以被看作一个由许多更小(甚至递归)的定义所组成的大定义。这种方式极大地促进了代码的可复用性:同一个定义可以在不同上下文中被重用。由此我们也能明白——为什么在这种编程风格中我们不希望存在可变变量(mutable variables):因为可变变量不再是“定义”,它们会随时间改变,破坏了纯粹的描述性结构。然而,并非所有函数式语言都禁止可变状态。有些语言虽然被称为“函数式”,但并非纯函数式(purely functional)。对于一门语言要称为“纯函数式”,它的函数必须没有副作用(side effects)。所谓副作用,是指函数在执行过程中与外部状态(函数之外的世界)发生交互。换句话说,纯函数不能做以下事情:
- 输入或输出:
- 文件读写
- 网络套接字操作
- 线程或其他进程交互
- 数据库访问
- 使用在多次访问之间会变化的值:
- 随机数
- 当前时间
- 直接访问内存:
- 从内存读取
- 向内存写入
当然,在纯语言中仍然有办法完成这些操作——只不过需要用受控的方式(例如使用特定的编程模型或设计模式)来“包装”这些副作用。在 Haskell 中,这种机制被称为 单子(Monadic)编程,我们将在后续章节深入探讨。这种编程方式与主流命令式编程迥然不同,它迫使我们重新思考许多“理所当然”的编程概念。有趣的是,现代编程语言正越来越多地引入函数式思想和特性:无论是 Java、TypeScript、Python,甚至 C++,都在不断吸收函数式理念。此外,一些大型公司设计的新语言(如 F# [微软]、Reason [Facebook])直接脱胎于函数式语言 OCaml,也进一步印证了这一趋势。因此,理解函数式编程思想,是让你作为开发者立于未来不败之地的重要一步。
1.2.3 一切都是为了简单(It’s all for simplicity)
除此之外,Haskell 还以大量“陌生而奇特”的概念作为语言核心特性,例如:
- 单子(Monadic programming)
- 类型类(Type classes)
- 惰性求值(Lazy evaluation)
- 软件事务内存(Software Transactional Memory, STM)
- 广义代数数据类型(Generalized Algebraic Data Types, GADTs)
那为什么要引入这些复杂的概念呢?这都是为了保持简洁(simplicity)。在软件工程中,我们早已总结出许多保持代码可维护性的原则,比如:
KISS(Keep It Simple, Stupid,保持简单)
DRY(Don’t Repeat Yourself,不要重复自己)
然而,在大多数编程语言中,我们往往不得不与语言本身的限制对抗才能遵守这些原则。有时我们不得不复制代码,而不是重构类层级; 有时我们无法将复杂逻辑拆分成更小的部分,因为语言的结构不允许。而 Haskell 的声明式设计与高抽象能力恰恰能反其道而行之:它不仅让我们更容易遵循“整洁代码(clean code)”原则,还主动帮助我们编写可维护、可组合、可重用的程序。这也是为什么 Haskell 近年来在工业界越来越受欢迎。
Haskell 的学术背景在语言的方方面面都可见一斑。这些特性并不是纯粹的学术实验,而是——只要你理解它们,就能发挥巨大的实用价值。Haskell 的“纯净”特性还让测试变得异常简单:
- 如果功能定义明确,测试可以直接反映功能规格;
- 无需写“适配器类”将代码嵌入测试框架;
- 不必模拟复杂的“伪生产环境”;
- 代码覆盖率分析也更直观。
因为我们测试的仅仅是无副作用的函数。要理解函数的行为,只需观察它的输入与输出——没有隐藏状态。除了工业应用和可测试性之外,Haskell 还拥有一个充满热情的开源社区,持续为语言开发工具与库。这种热情或许源于学术兴趣,或是希望用更简洁的方式开发软件。但更深层的原因可能更简单:
Haskell 很好玩!
一旦掌握了它,你会感觉自己仿佛握着一根魔杖,只需念出正确的“咒语”,就能在数字世界中释放强大的力量。许多以 Haskell 为职业的开发者都会提到:他们热爱程序的正确性、简洁性与理论之美。但归根结底,这些都源于一个更本质的原因——
构建复杂而优雅的软件系统的纯粹乐趣。
这种感觉让 Haskell 编程从不显得繁琐或笨重,反而总能激发出“还能让它更简单”的念头。简而言之,这就是编程的快乐——至少对我们许多人来说,正是如此。
1.3 抽象的使用(Usage of abstraction)
显然,没有任何一种编程语言是“万能”的。Haskell 是一种高度抽象的语言,它重新定义了“高级语言”的含义。这意味着开发者编写的源代码与最终由处理器执行的指令之间存在相当大的距离。这种设计有明显的优点,也有相应的缺点。
1.3.1 优点(The good parts)
当复杂性变得难以承受时,抽象就会展现出它的力量,而现代软件几乎处处充满复杂性。Haskell 能够让这些复杂性变得可管理,因此非常适合用于密码学系统和分布式系统。通过最小化状态(state),开发者也随之减少了产生 bug 和非预期行为的可能性,这使得实现安全相关的协议或 Web 服务器逻辑几乎毫无压力。声明式语言通常非常依赖定义(definition-heavy),这在程序逻辑主要基于定义时尤为有利——例如编译器、转译器(transpiler)*以及*文件格式转换工具。
然而,Haskell 是一种通用编程语言。一般来说,任何程序都可以用它编写。其丰富的库生态使它能够在广泛的工业场景中得到应用。尤其是在构建包含多种数据源的大型软件时,Haskell 的优势尤为明显,因为它的核心理念正是将不同的软件组件组合起来。毕竟,整体大于部分之和。
作为 Haskell 的初学者,我们可能会问:“这个语言能用来做什么项目?” 一个好的起点是输入输出明确的工具。想想 UNIX 工具的哲学:“只做一件事,并把它做好。”这类工具通常会读取输入、处理它、并输出结果。Haskell 的纯函数式特性非常适合这种模型。即使任务本身稍显复杂,比如在一堆数据上计算统计信息,也能很自然地建模。一个典型的例子是 Pandoc —— 一个用于不同文档格式之间转换的开源工具。它可以读取 HTML、OpenDocument 或 LaTeX 格式的文件,并输出等价的 DocBook、EPUB 或 PDF 文件。
与许多其他语言不同,Haskell 诞生于学术界而非工业界。因此,许多 Haskell 项目(无论是业余还是专业)都围绕着编译器、解释器和**领域特定语言(DSL)**的构建展开。 毕竟,还有什么比“用别人写的语言编程”更有趣的呢?——那就是“用自己写的语言编程!”
Haskell 允许你定义自己的操作符,因此你可以在 Haskell 内部构建属于自己的领域特定语言组件。其学术背景也体现在大量的证明助理(proof assistants)*和*自动推理工具上,这些程序可以用于数学证明或软件规范验证。
Haskell 简化复杂软件架构的能力,使其成为后端数据分析或复杂数据管理系统的热门选择。像 Facebook、Target、Barclays、Standard Chartered 和 NASA 等大型公司都在使用 Haskell 处理数据密集型应用。原因很简单:
Haskell 代码通常更可靠、更易重构、测试和验证也更加简单。
如果你在写软件时想要一个“稳妥的选择”,Haskell 是一个极好的选项。
我个人认为,Haskell 在**快速原型开发(rapid prototyping)**中尤其出色。你可以仅用几行代码就写出一个简单的应用,然后自然地扩展它,几乎无需重构。
- 想从单线程模型变成多线程模型?几行代码就够了。
- 想换掉配置文件格式?只需切换到另一个解析器。
- 想把文件输入改成网络输入?多数情况下也不需要修改系统的其他部分。
Haskell 应用程序往往能随着你的需求变化而自然生长,优雅而灵活。
1.3.2 缺点(The bad parts)
抽象固然很好,但 Haskell 与底层代码的距离,就如同航天飞机之于马里亚纳海沟。这种高度抽象带来了一个显著的缺点:
作为程序员,你永远无法完全掌控程序内部发生的事情。
在你的意图与实际硬件之间,隔着一个你几乎无法控制的 运行时系统(runtime)。
- 线程(threads)由运行时管理;
- 内存分配的时机由运行时决定;
- 垃圾回收器(garbage collector)会在它“认为有必要”时启动。
在某种程度上,这确实让开发者的工作更轻松,但同时也使 Haskell 不太适合以下几类应用场景:
- 实时性要求极高的程序(real-time critical applications)
- 状态密集型程序(state-heavy programs),例如视频游戏或多媒体应用
- 设备驱动或操作系统
当然,这并不是说用 Haskell 编写这些程序是不可能的。但这无疑是一项艰巨的挑战,需要对 Haskell 的底层机制和技术细节进行深入研究——而这些内容已超出本书的讨论范围。
1.4 我们所学到的(The things we learn)
有人声称,学习 Haskell 必须具备数学背景。 本书的目标之一,就是彻底打破这种刻板印象—— 通过浅显易懂的方式讲解语言的基本概念,让任何有编程经验的人都能学会 Haskell。
你完全不需要有任何函数式编程(Functional Programming)的基础! 不过,你应该:
- 熟悉过程式或命令式编程语言(如 C、Java、Python 等);
- 具备算法与数据结构的基本概念;
- 对操作系统和文件系统有一定了解。
理想读者应当:
- 拥有 1–2 年以上的编程经验;
- 曾经参与过一些(小型)软件项目,了解真实开发中可能遇到的问题;
- 具备 基础的操作系统知识(尤其是 UNIX 相关内容)。
与其他教材不同,本书不会带你进行一场“速成”式的 Haskell 高级技巧之旅。 相反,它将通过有趣、实用、富有创造力的项目,一步步展示如何在现实项目中解决实际问题。 每一章都会介绍 最佳实践(best practices),并解释为什么这些原则重要。
Haskell 的世界浩瀚如海,充满了各种陌生概念。 这本书不会让你一头扎进深海,而是像潜水教练一样, 带你探索这片海洋安全而明亮的浅层区域。 等到最后一章时,你将足够自信,能够独自驾驶潜艇,深入探索那片深蓝。 ——这将是一段奇妙的旅程。
本书包含了丰富多样的项目:
- 从入门开始,我们会编写一些新手友好的工具,例如:
- 一个用于“单词阶梯”(word ladder)小游戏的简单但巧妙的人工智能;
- 一个能将 CSV 文件美观地格式化并以 ASCII 表格打印出来的工具,同时支持搜索等功能。
- 之后,我们将进入更数据密集型的项目,比如:
- 操作音频与图像文件;
- 在程序中实现音频和图像的处理与合成;
- 构建属于我们自己的音乐合成器和多线程图像处理库。
- 最后,我们会挑战构建微服务(microservice): 实现一个能执行动作、存储数据且类型安全(type-safe)的 Web 服务器! 在这一过程中,你将看到多个 Haskell 高级库 的实际应用。
我们的应用主要针对 类 UNIX 系统(如 Linux、BSD、macOS)。 如果你使用的是 Windows,也可以通过 Windows Subsystem for Linux (WSL) 来运行。 所有项目都将是 命令行(CLI)应用。 这是刻意为之的选择,因为它简化了开发流程, 让我们能专注于核心逻辑,而无需被 GUI 编程分散注意力。
阅读完本书后,你将能够:
- 自如地使用 Haskell 编写实际项目;
- 避开新手常见的陷阱;
- 理解并掌握函数式编程的核心思想;
- 将这些理念迁移到其他语言中去。
总结(Summary)
- Haskell 是一种纯函数式编程语言,强调安全性与可组合性(composability)。
- 安全的代码拒绝危险操作,从而减少 bug 与未定义行为,更好地表达程序员意图。
- Haskell 深植于学术研究,其特性体现了现代编程理论的诸多成果。
- Haskell 是一门垃圾回收(GC)+ 编译型语言,原生支持并行与并发。
- 纯函数(Pure Function) 没有副作用,只接受输入并产生输出。
- 函数式与声明式编程关注中间结果的定义,而非执行步骤的顺序。
- 副作用(Side Effect) 是指函数在执行过程中与外部程序状态发生的交互。
- Haskell 的声明式特性与高抽象性帮助程序员遵循整洁代码原则(Clean Code Principles)。
- Haskell 的抽象能力简化了复杂的软件架构,因此在数据分析与复杂数据管理的后端开发中非常受欢迎。

