为什么 Haskell 是我们构建生产软件系统的首选
本文最初发布于 Foxhound Systems 网站,经授权由 InfoQ 中文站翻译并分享。
Haskell 是我们在构建生产软件系统时使用的首选编程语言。对于只是大致了解这种语言的人们来说,这个选择看起来很不一般。人们都知道 Haskell 是一种学习曲线陡峭的高级语言。它也经常被认为是一种实用性有限的研究型语言。
虽然 Haskell 确实内容非常丰富,包含许多使用其他语言的程序员可能不熟悉的概念和语法,但它在开发人员生产力、代码可维护性、软件可靠性和所提供的性能方面却无与伦比。在这篇文章中,我将介绍 Haskell 的一些突出特性,这些特性让它成为一种出色的、具有行业水准的语言,从而非常适合构建商业软件;我还会解释为什么我们的新项目考虑使用的第一个工具往往就是它。
Haskell 具有非常强大的静态类型系统,可作为程序员的辅助工具,在代码甚至没有运行之前就捕获并预防许多错误。许多程序员遇到 Java 或 C++ 这样的静态类型语言后会发现编译器用起来很烦人。相比之下,Haskell 的静态类型系统与编译时类型检查结合在一起,可以作为优秀的结对编程组合,在开发过程中提供即时反馈。
与使用 Python、JavaScript 或 PHP 等语言编写代码相比,编写 Haskell 时需要保持的认知负担要小得多。许多问题可以完全转移给编译器,而无需程序员操心。例如,在撰写 Haskell 时,无需担心以下问题:
我是否需要检查这个字段是否为空?
如果请求负载中缺少字段怎么办?
这个字符串已经被解码为整数了吗?
如果无法将这个字符串解码为整数怎么办?
这个运算符会隐式地将这个整数转换为字符串吗?
这两个值可比吗?
这并不是说上面这些都是在 Haskell 中永远不需要回答的问题;这里说的是当你需要解决其中一个问题时,编译器会抛出一个错误。例如,Haskell 程序可能需要处理有时不存在的值,但是 Haskell 程序员必须使用一个 Maybe 类型(表示这个值可能不存在),而不是将任何值设置为 NULL,而在这个值不存在的情况下,编译器会强制程序员显式处理 Nothing 值。
Haskell 的静态类型系统还带来了其他好处。Haskell 代码使用类型签名,位于其函数之前,并描述每个参数的类型和返回值。例如,诸如 Int -> Int -> Bool 之类的签名表示函数接收两个整数并返回一个布尔值。由于这些类型签名是由编译器检查和强制执行的,因此当程序员了解特定代码的作用时,阅读 Haskell 代码时只需查看类型签名即可。例如,当某人寻找一种用于操纵字符串、解码 JSON 或查询数据库的函数时,就不会使用上述类型签名。
类型签名甚至可以用来在 Haskell 代码的整个语料库中搜索相关函数。使用 Haskell 的 API 搜索工具 Hoogle(https://hoogle.haskell.org/),我们可以根据我们所需的功能来搜索类型签名。例如,如果我们需要将一个 Int 转换为 Float,则可以在 Hoogle 中搜索 Int -> Float(搜索结果:https://hoogle.haskell.org/?hoogle=Int+-%3E+Float),这会为我们指向有着合理命名的 int2Float 函数。
Haskell 还允许我们使用以小写的类型名称表示的多个类型变量来创建多态类型签名。例如,a -> b -> a 的签名告诉我们这个函数接收两个任意类型的参数,并返回一个类型与第一个参数相同的值。假设我们要检查一个元素是否在某个列表中。我们要找一个函数,这个函数需要一个要搜索的项目、一个项目列表并返回一个布尔值。我们不关心项目的类型,只要搜索项目和列表中的项目属于同一类型即可。因此,我们可以在 Hoogle 中搜索 a -> [a] -> Bool(搜索结果:https://hoogle.haskell.org/?hoogle=a%20-%3E%20%5Ba%5D%20-%3E%20Bool),这将为我们指向 elem 函数。参数类型是 Haskell 中一个非常强大的特性,它让我们得以编写可重用的代码。
除了被静态类型化之外,Haskell 是一种纯函数式编程语言。这是 Haskell 的突出特性之一,也是这个语言最为人知的特点,有些只是听说过 Haskell 但从未使用过它的程序员也知道这一点。以纯函数式风格编写代码具有很多好处,并且有利于打造井井有条的代码库。
“纯函数式编程”中的“纯”这个概念很重要。从这个意义上讲,纯度意味着我们编写的代码是纯净的,或者说没有副作用。描述它的另一个术语是引用透明性(https://en.wikipedia.org/wiki/Referential_transparency),也就是可以在不更改代码功能的情况下用其返回值替换任何表达式(例如,具有给定参数列表的函数调用)的属性。仅当这类纯函数没有副作用(例如在主机系统上创建文件、运行数据库查询或发出 HTTP 请求)时这才能做得到。Haskell 的类型系统就具有这种纯度。
那么,纯度是说 Haskell 程序不会产生副作用吗?当然不是,但这确实意味着副作用被推到了我们系统的边缘。执行 I/O 操作的任何函数(例如查询数据库或接收 HTTP 请求)都必须具有捕获它的返回类型。这意味着像我们在上一节中看到的那些类型签名(例如 Int -> Float 或 a -> [a] -> Bool)就是指示,表明相应函数不会产生副作用,因为 Float 和 Bool 只是原始的返回类型。对于包含副作用的对比示例,FilePath -> IOString 的函数签名指示这个函数接收一个文件路径并执行一个 I/O 操作,这个操作返回一个字符串(这正是 readFile 函数的作用)。
纯函数编程范式的另一个特性是高阶函数,这些函数将函数作为参数。fmap 是最常用的高阶函数之一,它将一个函数应用于一个容器(例如列表)中的每个值。例如,我们可以将一个名为 square 的函数应用到一个整数列表中,这个函数接收一个整数并将这个整数乘以其自身后返回,以将列表转换为一个平方整数列表:
square :: Int -> Intsquare x = x * x
fmap square [1,2,3,4,5] -- returns [1,4,9,16,25]
用这种风格编写的代码往往是可组合的和可测试的。上面的示例很普通,但是高阶函数有许多应用场景。例如,我们可以编写一个 renderPost 这样的函数,这个函数获取帖子数据的记录并返回以 HTML 渲染的帖子版本。如果我们有一个帖子列表,则可以运行 fmap renderPost postList 来生成一个渲染列表。我们的 renderPost 函数可以直接用在单帖和多帖场景中,而无需进行任何更改,因为将其与 fmap 组合一起使用改变了我们的应用方式。我们还可以为 renderPost 函数编写测试,并在验证帖子列表的行为时在测试中将其与 fmap 组合在一起使用。
将 Haskell 上述的静态类型和纯函数样式结合后,在 Haskell 中开发软件的速度往往会非常快。我们采用的一个常见开发流程依赖于一个名为 ghcid(https://github.com/ndmitchell/ghcid)的工具。它是一个简单的命令行工具,依赖 Haskell REPL 来自动监视代码更改并进行增量重编译。将更改保存到文件后,我们可以立即查看代码中的任何编译器错误。在 Haskell 中开发应用程序时,我们通常只在一个窗格中打开一个带有文本编辑器的终端,然后在另一个窗格中打开 ghcid。
虽然我们最后还是要手动验证代码结果,例如在浏览器中刷新页面或使用工具来验证 JSON 端点,但许多这样的操作可以推迟到编程会话结束时进行。ghcid 会立即捕获程序员在使用 Python 或 PHP 之类的语言编写 Web 服务时遇到的许多运行时错误,并将它们显示为编译器错误。这要比每次更改某些代码后就得切换到浏览器窗口并刷新页面的操作简单多了,而后者是开发人员都非常熟悉的 Web 应用程序开发流程。
在开发过程中,除了紧密的反馈循环外,Haskell 代码还易于重构和修改。就像用其他任何语言编写的现实世界代码一样,用 Haskell 编写的代码也不会写一次就完事。到头来,它往往需要由并非代码原作者的开发人员来维护、更新和扩展。借助编译时检查,Haskell 中的许多代码重构起来很容易。常见的重构流程是在一个位置进行所需的更改,然后一次修复一个编译器错误,直到程序再次编译。这比动态类型语言的等效更改要容易得多,后者没有为程序员提供此类帮助。
支持动态类型语言的人们通常会争辩说,自动化测试取代了对编译时类型检查的需求,并且也可以帮助预防错误。但是,测试不如类型约束强大。为了让测试行之有效,它们必须:
被实际编写出来,而许多现实世界的代码库测试都很少。
做出正确的断言。
全面(测试各种输入)并提供良好的覆盖范围(测试大部分代码库)。
易于运行并快速完成,否则它们将不会成为开发流程的一部分。
与它们测试的代码同步更新和维护。
Haskell 的类型系统没有上述问题。类型系统是这个语言的自带特性,编译器始终会验证类型是否正确。类型系统是天然全面的,可以完全覆盖每一块 Haskell 代码,并且基础代码的更改并不需要对类型系统做更改。所有这些并不是说类型系统可以代替每种类型的测试。但是它所做的是提供比测试更全面的保证,并且即使在没有测试的情况下,它也存在于每个代码库中。
GHC 是最常用的 Haskell 编译器,可生成非常快速的可执行文件,尤其是与其他通常用于应用程序开发的语言(例如 PHP 或 Python)相比时优势相当明显。这种性能优势既可以提高应用程序的响应速度,又可以降低硬件成本。
当一种语言被人称作是性能缓慢的语言时,它的支持者们往往不以为意,因为他们觉得与雇用程序员的成本相比,硬件的成本相对较低。这个观点可能是正确的,但我们发现 Haskell 与其他用于 Web 开发的语言之间的速度差异实在太大了。
在我们做过的一个项目中,我们开始在 Haskell Web 服务中,而不是现有的 PHP 中来实现新的 API 端点。经过大约一年的功能构建和在 Haskell 中添加端点的工作之后,PHP 和 Haskell Web 服务在请求数量和类型方面的平均工作量都达到了相当的水平,它们也都执行由相同 SQL 数据库支持的相似 CRUD 操作。这个基础架构托管在 AWS 上,每个 Web 服务所使用的基础架构细节如下。
在这个应用程序中,每个 Haskell 和 PHP Web 服务都查询同一数据库,全天处理数量接近的请求、相似的工作量,并具有差不多的流量高峰。PHP 和 Haskell Web 服务都使用 Nginx 作为反向代理。最后,运行 Haskell 基础架构的成本大约是 PHP 基础架构的 1/16(即 6%)。检查我们的 AWS 使用率指标可以发现,Haskell 机器上的 CPU 甚至从未达到 5%使用率。Haskell 端点始终有着 100ms 或更短的响应时间,略胜于 PHP 端点。
最终,我们有了两个 Web 服务,一个 Web 服务用 Haskell 编写,另一个用 PHP 编写;它们具有相似的性能,但前者的成本为 200 美元 / 年,后者的成本为 3,000 美元 / 年。值得注意的是,这个应用程序的用户群相对较小,每月活跃用户(MAU)不到 25,000。这种成本差异将随着用户群的规模、MAU 的数量和基础架构的增加而扩大。
当然,这种对比是可能存在不足的,我也没说这就是科学的对比。但是我很清楚,根据我们过去在生产负载方面的经验,Haskell 的性能至少比 PHP 高出一个数量级(而且与其他许多类似的语言相比,PHP 7.0+ 的性能非常出色)。使用 Haskell 代替其他 Web 语言所带来的运营成本下降绝不是微不足道的。
Haskell 的类型系统除了简单的编译时类型检查之外还有一个好处,那就是它可以在应用程序中使用自定义数据类型来对问题域进行建模。这使程序员可以创建由类型系统强制执行的业务逻辑规则的描述。Haskell 具有所谓的代数数据类型(ADT),由 record(product 类型)和 tagged union(sum 类型)组成。Record 类似于字典或 JSON 对象,很多语言中都很常见。但是,tagged union 在很多语言中都不存在,却可以在域建模中提供很大的灵活性。
通过一个示例可以很好地说明 ADT 的能力。假设我们正在创建一个必须跟踪客户发票的发票系统。每张发票必须包含这个发票所针对的行项目的一个列表,并具有一个表明订单已付款还是已取消的发票状态。我们将用来对此建模的类型可能如下所示:
type Dollars = Intdata CustomerInvoice = CustomerInvoice { invoiceNumber :: Int , amountDue :: Dollars , tax :: Dollars , billableItems :: [String] , status :: InvoiceStatus , createdAt :: UTCTime , dueDate :: Day }data InvoiceStatus = Issued | Paid | Canceled
像这样在类型系统中对域规则进行建模(例如,发票的状态为 Issued、Paid 或 Canceld)会导致在编译时强制执行这些规则,如前面有关静态类型的部分所述。与在类方法中编码类似规则的做法(常见于不具有 sum 类型的面向对象语言)相比,这是一组更强大的保证。例如,使用上述类型,就无法定义没有应付金额的 CustomerInvoice。除了上述三个值之一之外,也无法定义 InvoiceStatus。
上述类型的一种应用场景可以是基于发票的状态创建一个通知消息的函数。这个函数将 CustomerInvoice 作为参数,并返回一个表示通知内容的字符串。
createCustomerNotification :: CustomerInvoice -> StringcreateCustomerNotification invoice{..} = case status of Issued -> "New invoice #" ++ show invoiceNumber ++ " due on " ++ renderDate dueDate Paid -> "Successfully paid invoice #" ++ show invoiceNumber Canceled -> "Invoice #" ++ show invoiceNumber ++ " has been cancelled"
上面的函数使用模式匹配(这个语言的另一个特性)来处理每个可能的 InvoiceStatus 值。case 语句使我们能够处理 status 参数的不同可能值。
类型系统可以防止我们在更改域规则时犯错误。假设这个应用程序运行了一段时间之后,我们从用户那里获得了反馈,于是我们需要能够退还发票。为方便起见,我们将更新 InvoiceStatus 类型,使其包含一个 Refunded 值构造器:data InvoiceStatus = Issued | Paid | Canceled | Refunded复制代码如果这是我们唯一更改的代码,则在编译时会出现以下错误:
CustomerInvoice.hs:(15,5)-(20,35): error: [-Wincomplete-patterns, -Werror=incomplete-patterns] Pattern match(es) are non-exhaustive In a case alternative: Patterns not matched: Refunded |15 | case status of | ^^^^^^^^^^^^^^...
哎呀!好像我们忘记更新 createCustomerNotification 函数来处理这个新状态值。编译器抛出一个错误,并告诉我们 case 语句在其模式匹配中不处理 Refunded 值。
编译器会根据类型对域建模,从而帮助我们确保所有域逻辑都可以处理域中所有可能的值 *。当使用动态类型的语言编写代码时经常会出现未处理值的错误,而 Haskell 就可以为我们避免这类错误。在这种情况下,自动化测试不能代替类型,因为引入新的可能值通常需要更新测试以断言是否可以处理新值,这并不能帮助我们避免问题——我们很容易忘记更新业务逻辑是,所以也很容易忘记更新业务逻辑的测试。
默认情况下,GHC(Haskell 编译器)在未处理值的情况下不会抛出错误,但是 Haskell 生产项目的标准做法是使用 -Wall 和 -Werror 标志,这将打开几乎所有可用警告并将所有警告变成错误。
Haskell 社区已经发布了大批高质量的生产级软件包,其中许多包已经维护了十年或更长时间。Haskell 社区对于每种函数类别(例如解码 / 编码 JSON、解析 XML、解码 CSV、搭配 SQL 数据库、HTML 模板、websocket、使用 Redis 等)中有哪些包是不错的选择这一问题达成了普遍共识。在某些类别中,只有一个最佳选项是事实标准。在其他类别中,有几种不错的选项可供选择,具体哪种更好取决于开发人员愿意做出的设计决策或折衷方案。
Haskell 在其软件包存储库 Hackage(https://hackage.haskell.org/)中提供了超过 21,000 个软件包,还有更多发布在 GitHub 等构建工具可以依赖的地方。但是,这个数目与其他许多语言的存储库中可用的软件包数目相比就逊色多了。截至本文发布之日,Ruby 已发布了 164,000 个 gem(https://rubygems.org/stats)。PyPI 上有 282,000 个 Python 软件包(https://pypi.org/)。截至 2020 年 4 月,npm 上有超过 130 万个 JavaScript 软件包(https://blog.npmjs.org/post/615388323067854848/so-long-and-thanks-for-all-the-packages)。
因为存在这种差距,所以我听说过有人对在生产环境中使用 Haskell 表示保留意见:与其他语言相比,可用的 Haskell 软件包并不多。我对这个质疑的回答是,在构建生产系统时,一种语言可用的软件包总数基本上无关紧要。
在构建生产系统时,我们从不根据可用包的总数来决定使用哪些包,而是要判断哪个包具有良好的声誉、广泛的使用量以及其他一些因素,例如良好的文档以及这个包是否仍在维护等等。简而言之,数量无关紧要,重要的是质量。在这一点上,Haskell 社区在整理我前面所述的实际用例所需的软件包方面做得非常出色。
作为纯函数式语言,Haskell 的一个特征是默认情况下代码中的值是不可变的。这并不是说值永远不会改变,而是说状态不会就地改变。例如,当一个函数将一个元素添加到一个列表时将返回一个新列表,并且旧列表使用的内存将由垃圾回收器释放。这种不变性的好处是它简化了并发编程。在具有可变值的语言中,多个线程访问相同的值可能导致诸如条件争用和死锁之类的问题。
由于 Haskell 中的值是不可变的,因此即使程序在多个线程上运行并访问共享内存,也不会出现这类问题。这也简化了围绕并发编程的思维模型。并发代码通常可以用与单线程代码相同的样式编写,而在新线程上运行底层负载的函数只需包装单线程实现即可。
并发是 Haskell 程序员工具箱中的一项有用工具。在我们从事过的许多项目上我们做了很多工作,包括实现了作为服务于一个 HTTP API 的同一可执行文件的一部分运行的 websocket 服务器,还创建了一个多线程 worker 系统,其所需的开销远低于管理单个 Linux 进程的开销——对于使用并发支持不足的语言编写的 worker 而言,后者是必需的。
Haskell 的类型系统和语言特性使其成为编写编译器的常见选择。其中一个分支是 Haskell 库有时会使用领域特定语言(DSL)来提高其可用性。与通用语言相反,DSL 是一种小型语言,旨在专门用于表达特定应用程序或问题域的规则。
SQL 是最著名和使用最广泛的 DSL 之一,它是用于查询关系数据库系统中所存储数据的语言。与大多数语言不同,SQL 是声明性的而不是命令性的。这意味着 SQL 程序倾向于描述其执行结果应该是什么,而不是这个结果如何实现。熟悉 SQL 的开发人员都能想得到,以命令式方式编写代码来检索表中存储为一系列行的数据会非常麻烦。
Haskell 中支持 DSL 的函数之一称为 Template Haskell。很多库作者经常使用这个方法,以允许库的使用者使用表达性语法来避免大量样板。Persistent 库(https://hackage.haskell.org/package/persistent)就是其中一个例子,它是最流行的 SQL 库之一。它公开了一种 DSL,其使用所谓的持久性实体语法,允许库的用户定义其数据库模式。下面是这种语法的示例。
Person name Text age Int MaybeBlogPost title Text authorId PersonId publicationDate UTCTimeBlogPostTag label Text blogPostId BlogPostId
上面的代码不是 Haskell,如果你从未使用过 Haskell 的 Persistent 库,很可能你从未见过这种语法。然而它的作用显而易见,它定义了三个表(Person、BlogPost 和 BlogPostTag)以及其中的列。这段代码被 Haskell 程序消费,这样就不需要编写约 150 行 Haskell 代码来定义所有数据类型和用于处理这三个表中数据的访问器函数了。
上面只是外部 DSL 的一个示例,外部 DSL 是使用自有语法的 DSL。公开 DSL 的库还包括一些用于 Web 服务器路由定义和 HTML 模板的库。一些库作者选择创建嵌入式领域特定语言(eDSL),这些语言以 Haskell 语法编写。这产生了一系列针对特定领域的类型和函数。一个例子是 Esqueleto(https://hackage.haskell.org/package/esqueleto),一个广泛使用的库。这个库公开了用于编写类型安全的 SQL 查询的 eDSL。
使用编程语言时需要考虑的最重要因素之一就是社区。Haskell 的社区很庞大,其中包括来自许多不同技术背景的各种各样的人们,包括编程语言研究人员(其中一些人自 1990 年 Haskell 诞生以来一直从事其研究工作)、其他一些编程语言的创建者(其编译器是用 Haskell 编写的)、自学成才的 Haskell 爱好者、在商业环境中使用 Haskell 的专业程序员、渴望学习 Haskell 的学生,还有很多。
Haskell 社区非常欢迎初学者。尽管这个语言的深度和广度使它的学习曲线比其他许多语言都更陡峭,但学习者很容易在社区中提出问题,并得到许多真诚希望帮助他人学习这门语言的人们的帮助。
我们常用的与 Haskell 社区互动的一些交流形式包括:
Haskell subreddit(https://www.reddit.com/r/haskell)拥有超过 60,000 读者,并且是 reddit 上最大的编程语言社区之一。
Functional Programming Slack(https://fpchat-invite.herokuapp.com/),有许多专门面向Haskell 的频道(包括 #haskell,#haskell-beginners,#haskell-jobs 和 #haskell-adoption)。
Haskell 邮件列表,例如 haskell-cafe(https://mail.haskell.org/mailman/listinfo/haskell-cafe),其内容包括库公告、语言问答以及志愿者机会等。
Freenode IRC 网络上的 #haskell 频道,通常有 1000 多人连接,它是 Slack 频道的不错替代。Haskell 每周新闻(https://haskellweekly.news/),重点介绍前一周的博客文章和其他公告。
尽管不是传统社区,但 StackOverflow 上的 haskell 标签(https://stackoverflow.com/questions/tagged/haskell)具有与之相关的 46,000 多个问题。
人们可以很容易找到与这个语言相关的特定主题或问题和对应的优秀答案。
上面这个列表并不算完整,加入上面每一个社区也没什么必要。但是,当有人在寻求帮助或想要大致了解这个语言时,随便选择哪个社区都是不错的主意。
为什么 Haskell 是我们构建生产软件系统的首选编程语言呢?原因有很多。我们再来回顾一下这篇文章中列举的各个因素:
Haskell 具有强大的静态类型系统,可以预防错误并减少认知负担。
Haskell 支持编写可组合、可测试且具有可预见副作用的代码。
Haskell 有助于快速开发,无忧重构并具有出色的可维护性。
Haskell 程序具有出色的性能,从而带来更快的应用程序和更低的硬件成本。
Haskell 非常适合域建模和防止域逻辑错误。
Haskell 有着大量成熟的高质量库。
使用 Haskell 很容易编写并发程序。
Haskell 支持领域特定语言,这些语言可增强表达性并减少样板。
Haskell 有一个庞大的社区,到处都是聪明而友善的人们。
这些因素加在一起,使 Haskell 成为了一个令人信服的选择。Haskell 支持快速开发、无忧重构,它易于维护、提供出色的性能并具有成熟的生态系统。这些优势使它成为构建生产级应用程序的绝佳选择。作者介绍Christian Charukiewicz 是 Foxhound Systems 的合伙人兼首席软件工程师。在 Foxhound Systems,我们使用 Haskell 创建快速可靠的定制软件。是否正在寻找可以帮助您开发新产品或将 Haskell 引入您自己开发团队的帮手?请通过 [email protected] 与我们联系。
点个在看少个 bug 👇