为什么Haskell是我们构建生产软件系统的首选

2021-01-11 20:37:14

Haskell是我们在构建生产软件系统时使用的第一种编程语言。对于只对该语言有过熟经验的人来说,这似乎很不寻常。 Haskell以其先进的语言和陡峭的学习曲线而闻名。它也经常被认为是一种实用性有限的研究语言。

虽然Haskell确实具有非常大的表面积,但许多其他语言的程序员可能不熟悉许多概念和语法,但它在开发人员生产率,代码可维护性,软件可靠性和所提供的性能方面却无与伦比。在本文中,我将介绍Haskell的一些定义特征,这些特征使它成为一种出色的,具有工业强度的语言,非常适合构建商业软件,以及为什么它通常是我们考虑用于新项目的第一个工具。

Haskell具有非常强大的静态类型系统,可作为程序员的辅助工具,在代码运行之前捕获并防止许多错误。许多程序员遇到诸如Java或C ++之类的静态类型语言,并发现编译器让人烦恼。相比之下,Haskell的静态类型系统与编译类型的时间检查结合在一起,可以作为宝贵的结对编程伙伴,在开发过程中提供即时反馈。

与使用Python,JavaScript或PHP等语言编写代码相比,编写Haskell时需要保持的认知负载要小得多。许多顾虑可以完全转移给编译器,而无需程序员记住。例如,在撰写Haskell时,无需先发问:

这并不是说这些是在Haskell中永远不需要回答的问题。就是说当您需要解决其中一个问题时,编译器会抛出错误。例如,Haskell程序可能需要处理有时不存在的值,但是Haskell程序员必须使用Maybe类型(表示该值可能不存在)来代替将任何值设置为NULL的情况,强制程序员显式处理Nothing值;该值不存在的情况。

Haskell的静态类型系统还带来了其他好处。 Haskell代码使用在其功能之前的类型签名,并描述每个参数的类型和返回值。例如,类似Int-&gt的签名。整数-> Bool表示函数使用两个整数并返回布尔值。由于这些类型签名是由编译器检查和强制执行的,因此,当了解特定代码的作用时,允许阅读Haskell代码的程序员仅查看类型签名。例如,在寻找一种操作字符串,解码JSON或查询数据库的函数时,不会使用上面的类型签名。

类型签名甚至可以用于在Haskell代码的整个语料库中搜索相关功能。使用Haskell的API搜索Hoogle,我们可以根据我们所需的功能来搜索类型签名。例如,如果我们需要将Int转换为Float,则可以在Hoogle中搜索Int-> gt。 Float(搜索结果),它将使我们指向恰当命名的int2Float函数。

Haskell还允许我们通过使用以小写的类型名称表示的类型变量来创建多态类型签名。例如,一个->的签名。 b-> a告诉我们该函数采用两个任意类型的两个参数,并返回其类型与第一个参数相同的值。假设我们要检查元素是否在列表中。我们正在寻找一个函数,该函数需要一个要搜索的项目,一个项目列表并返回一个布尔值。我们不关心项目的类型,只要搜索项目和列表中的项目属于同一类型即可。这样我们就可以在Hoogle中搜索-> [a]->布尔(搜索结果),这将使我们指向elem函数。参数类型是Haskell中一个非常强大的功能,并且可以编写可重用的代码。

Haskell除了是静态类型外,还是一种纯函数式编程语言。这是Haskell的定义功能之一,也是该语言广为人知的功能,即使在只听说过Haskell但从未使用过它的程序员中也是如此。以纯函数式风格编写具有很多好处,并且有利于组织良好的代码库。

“纯函数式编程”中的“纯”一词很重要。从这个意义上讲,纯度意味着我们编写的代码是纯净的,或者没有副作用。另一个描述此术语的术语是引用透明性,即可以在不更改代码功能的情况下用其返回值替换任何表达式(例如具有给定参数列表的函数调用)的属性。仅当此类纯函数没有副作用(例如在主机系统上创建文件,运行数据库查询或发出HTTP请求)时才有可能。 Haskell的字体系统具有这种纯度。

那么,纯粹意味着Haskell程序不会产生副作用吗?当然不是,但这确实意味着影响被推到了我们系统的边缘。任何执行I / O操作的功能(例如查询数据库或接收HTTP请求)都必须具有捕获此类型的返回类型。这意味着像我们在上一节中看到的那样的类型签名(例如,Int-> Float或a-> [a]-> Bool)是相应的函数不会产生副作用的指示符,因为Float和Bool只是原始的返回类型。对于包括副作用的对比示例,FilePath的功能签名-> IO字符串表示该函数采用文件路径并执行I / O操作,该操作返回一个字符串(这正是readFile函数的作用)。

纯函数编程范例的另一个功能是高阶函数,这些函数将函数作为参数。 fmap是最常用的高阶函数之一,它将函数应用于容器(例如列表)中的每个值。例如,我们可以将一个名为square的函数应用于一个整数列表,该函数将一个整数并返回乘以该整数的整数,以将其转换为字符串列表:

正方形:: Int-> Int square x = x * x fmap square [1,2,3,4,5]-返回[1,4,9,16,25]

用这种风格编写的代码往往是可组合的和可测试的。上面的例子是微不足道的,但是有许多高阶函数的应用。例如,我们可以编写一个诸如renderPost之类的函数,该函数获取发布数据的记录并返回以HTML呈现的发布版本。如果我们有帖子列表,则可以运行fmap renderPost postList来生成渲染列表。我们的renderPost函数可以在单案例和多案例案例中使用,而无需任何更改,因为将其与fmap一起使用会改变我们的应用方式。我们还可以为renderPost函数编写测试,并在验证帖子列表的行为时在测试中将其与fmap组合在一起。

通过将上述静态类型和Haskell具有的纯函数样式相结合,在Haskell中开发软件的速度往往非常快。我们采用的常见开发工作流程之一是依赖于名为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端点始终具有100毫秒或更短的响应时间,略胜于PHP端点。

最终,我们有了两个Web服务,一个Web服务使用Haskell编写,另一个Web服务使用PHP编写,它们具有相似的性能,但是前者的成本为200美元/年,而后者的成本为3,000美元/年。值得注意的是,此应用程序的用户群相对较小,每月活跃用户(MAU)不到25,000。成本的这种差异将随着用户群的规模,MAU的数量和基础架构的增加而扩大。

当然可以批评这种比较,但我不认为这是科学的。但是我很清楚,根据我们过去在生产工作负载方面的经验,Haskell的性能至少比PHP高出一个数量级(与许多其他类似的语言相比,PHP 7.0+的性能非常出色)。通过Haskell在其他Web语言上进行操作所带来的成本降低绝不是微不足道的。

Haskell的类型系统除了简单的编译时类型检查之外,另一个好处是,它可以通过在应用程序中创建自定义数据类型来对问题域进行建模。这使程序员可以创建由类型系统强制执行的业务逻辑规则的描述。 Haskell具有所谓的代数数据类型(ADT),由记录(产品类型)和带标记的并集(和类型)组成。记录类似于字典或JSON对象,并且通常以多种语言提供。但是,标记联合并不能以多种语言提供,但是它们可以在域建模中提供极大的灵活性。

通过示例可以很好地说明ADT的功能。假设我们正在创建一个必须跟踪发票的发票系统。发票包含发票所针对的行项目的列表,并且发票状态指示订单已付款还是已取消。我们将用来建模的类型可能如下所示:

类型Dollars = Int数据CustomerInvoice = CustomerInvoice {invoiceNumber :: Int,amountDue :: Dollars,tax :: Dollars,billableItems :: [String],状态:: InvoiceStatus,createdAt :: UTCTime,dueDate :: Day}数据InvoiceStatus =已发出|付费|取消

像在前面关于静态类型的部分中所述,在这样的类型系统中对域规则进行建模(例如,发票的状态为已发出,已付款或已取消)会导致这些规则在编译时被强制执行。与在类方法中编码类似规则相比,这是一组更强大的保证,就像在不具有求和类型的面向对象语言中所做的那样。例如,使用上述类型,就无法定义没有应付金额的CustomerInvoice。除了上述三个值之一之外,定义InvoiceStatus也是不可能的。

上述类型的一种应用可以是基于发票的状态创建通知消息的功能。该函数将CustomerInvoice作为参数,并返回表示电子邮件正文的HTML。

createCustomerNotification ::客户发票->字符串createCustomerNotification发票{..} =已发出-> "新发票#" ++显示发票编号++"到期日" ++ renderDate dueDate已付款-> "已成功支付发票#" ++显示发票编号已取消-> "发票#" ++显示发票编号++"已被取消"

上面的函数使用模式匹配(该语言的另一个功能)来处理每个可能的InvoiceStatus值。 case语句使我们能够处理status参数的不同可能值。它还使我们能够将相关变量纳入范围。

类型系统可以防止我们在更改域规则时犯错误。假设此应用程序启用了一段时间后,我们从用户那里获得了反馈,我们需要能够退还发票。为方便起见,我们将更新InvoiceStatus类型,使其包含退款的值构造函数:

如果这是我们唯一更改的代码,则在编译时,会出现以下错误:

CustomerInvoice.hs:(15,5)-(20,35):错误:[-Wincomplete-patterns,-Werror = incomplete-patterns]模式匹配不是穷举性;在其他情况下:模式不匹配:已退款| 15 |案件状态| ^^^^^^^^^^^^^^^ ...

哎呀!好像我们忘记更新createCustomerNotification函数来处理此新状态值。编译器抛出错误,并告诉我们case语句在其模式匹配中不处理Refunded值。

通过根据类型对域进行建模,编译器可帮助我们确保所有域逻辑都可以处理域中所有可能的值*。当使用动态类型的语言编写代码时,这可以保护我们免受未处理的值的常见错误的困扰。在这种情况下,自动化测试不能代替类型,因为引入新的可能值通常需要更新测试以断言是否可以处理新值,这无助于我们避免问题—忘记就很容易更新业务逻辑的测试,因为它忘记更新业务逻辑。

Haskell社区已经发布了许多高质量的生产级软件包,其中许多软件包已经维护了十年或更长时间。 Haskell社区对于每种功能类别中的哪些软件包是不错的选择(例如解码/编码JSON,解析XML,解码CSV,使用SQL数据库,HTML模板,websocket,使用Redis等)达成了普遍共识。在某些类别中,只有一个最佳选择是事实标准。在其他类别中,有几种可比较的选项可供选择,具体取决于开发人员愿意做出的设计决策或折衷方案。

Haskell在其软件包存储库Hackage中提供了超过21,000个软件包,还有更多发布在GitHub等构建工具可以依赖的地方。但是,这个数目与许多其他语言的存储库中可用的软件包数目相比是相形见war。截至本文发布之日,Ruby已发布了164,000颗宝石。 PyPI上有282,000个Python软件包。截至2020年4月,npm上有超过130万个JavaScript软件包。

这种差异导致我听说过有人对在生产中使用Haskell表示保留:一种与其他语言相比可用的Haskell软件包不多。我对此的回应是,在构建生产系统时,给定语言可用的软件包总数基本上无关紧要。

在构建生产系统时,从不根据可用软件包的总数来决定使用哪个软件包,而是要决定哪个软件包具有良好的声誉,广泛的使用以及其他因素,例如良好的文档记录以及给定的软件包是否为仍在维护。简而言之,质量与数量无关紧要,为此,Haskell社区在整理我前面所述的实际用例所需的软件包方面做得非常出色。

作为纯功能语言的一个特征是,默认情况下,Haskell中的值是不可变的。这并不是说值永远不会改变,而是状态不会就地改变。例如,当函数将元素追加到列表时,将返回新列表,并且旧列表使用的内存将由垃圾回收器释放。这种不变性的好处是它简化了并发编程。在具有可变值的语言中,多个线程访问相同的值可能导致争用条件和死锁等问题。

由于Haskell中的值是不可变的,因此即使程序在多个线程上运行并访问共享内存,也不会出现此类问题。这也导致围绕并发编程的更简单的思维模型。并发代码可以

......