长文:我为何青睐 Haskell?【探究 Haskell 的哲学】

文摘   2024-09-12 23:43   浙江  

长文:我为何青睐 Haskell?【探究 Haskell 的哲学】

“不实用”、“学术化”、“边缘化”。当人们得知 Haskell 是我钟爱的编程语言时,我经常听到这样的评价。这不仅仅是一种智力上的自娱自乐,而是真的用于构建实际的应用,尤其是那些涉及网络服务器的项目。业余项目使用 Haskell 尚可接受,但更令人惊讶的是:我所在的 Converge 团队在实际工作中也采用 Haskell。

我对这种反应感到困惑:这不仅仅是因为任何适合一种通用编程语言的问题也可以用另一种解决,而且我们观察到许多新特性正在进入像 Python、Rust 和 TypeScript 这样的编程语言,这些特性要么是受到 Haskell 的启发,要么至少在 Haskell 中得到了更完善的实现。在我看来,这种反应的一部分是一种“选择乏味技术”的偏见(尽管 Haskell 比大多数流行的编程语言都要历史悠久),扭曲成了另一种有害的观念:编程不是数学,任何带有数学气息的东西都应该被排除在外。

这种情况出现在各种不太可能的场合,在这些场合中,让我的对话者理解我认为 Haskell 可能是解决他们自己试图解决的计算问题的最佳选择的所有原因,会非常尴尬(例如晚宴、酒吧等),因此我发现自己写下了这篇辩护文。

实际上,这篇论文的其余部分将是我试图解释为什么我认为 Haskell 可能是大多数程序员的最佳选择,特别是如果你关心能够高效地编写健壮的软件,甚至更进一步,如果你想在编程的同时享受乐趣(这是编写软件时经常被低估的一个方面)。

所有主流的通用编程语言(基本上)都是图灵完备的,因此你可以在一个语言中编写的任何程序实际上也可以用另一个编写。它们之间在计算等价性上是等价的。主要的区别在于语言的表现力、它们提供的保障机制,以及它们的性能特征(尽管这可能更多是一个运行时/编译器实现问题)。

我认为 Haskell 的伟大之处(意味着既提高生产力又增加乐趣)可以归纳为以下几点:防止你犯错的机制;提升你生产力的特性;帮助你更好地推理你的程序的工具。

重新学习和再学习

首先,大多数 2020 年代的程序员都是在某种命令式范式下成长的。因此,像 Haskell 这样的纯函数式语言的学习曲线将会很陡峭。这有两个方面:一个是 Haskell 语言本身,如果你将自己限制在一个简单的子集中,实际上很容易学习;第二个是函数式编程,它要求程序员彻底改变构建程序的方式。

这种重新学习和再学习的过程非常有益,并且会让你成为一个更好的程序员,不管你是否从此使用 Haskell。正如 Alan Perlis 所写:

一种语言如果不改变你对编程的思考方式,就不值得了解。~ Perlisism #19

关于语法的一个小注

在后续章节中会有 Haskell 的简单代码片段。由于语法与许多读者熟悉的类 C 语法相去甚远,这里有一个小指南:

  • :: 表示类型签名(所以 myThing :: String 表示我有一个名为 “myThing” 的名称,它的值是类型 String)。

  • 函数调用不使用括号,你只需在函数名后以空格分隔的方式放置参数。这样做有充分的理由,但它们超出了这个解释器的范围(所以在一个语言中你可能会写 doSomething(withThis, withThat),在 Haskell 中你写 doSomething withThis withThat)。

  • 类型签名中的小写字母是类型变量,只是代表任何类型(所以 head :: [a] -> a 只是表示一个函数,它接受任何类型的列表 a 并返回相同类型的单个值 a)。

  • 你会看到两种类型的“前向”箭头:->=>。单个箭头 -> 用于描述函数的类型:add1 :: Int -> Int 描述一个函数,它接受一个整数并返回一个整数。双箭头 => 描述对使用的类型变量的约束,并总是首先出现:add1 :: Num a => a -> a 描述一个函数,它接受任何满足 Num a 的类型 a,并返回相同类型的值。

  • 注释以 -- 开始。

  • return 并不意味着你想的那样,它只是一个普通函数。

  • do 是语法糖,允许你编写看起来是命令式的东西。

  • 有各种方法可以给局部名称(“变量”)赋值,这取决于上下文。所以你可以通过 let x = <something> in <expression>x <- <something> 来识别它们。

否则,语法应该很容易解析,如果不是对每个方面的详细了解,至少足以理解我试图传达的内容。

减少错误

在许多语言中,确保代码“正确”(或至少在大多数情况下做正确的事情)的方式是通过大量的测试用例,其中一些可能是自动化的,一些可能是手动的。

Haskell 的两个方面极大地减少了在其他语言中典型的测试用例编写负担:一个是类型系统,另一个是纯函数式编程。

Haskell 的类型系统非常强大,这意味着它对程序做出了非常具体的保证,并且非常严格地执行这些保证。语言的表现力使得程序员能够更精确、更简单地在程序的领域内表达程序的《意义》,以及编程的一般领域。类型系统的这两个属性共同减少了可能的错误空间,从而在付出更少的努力的情况下得到更正确的程序。

到目前为止,这些都是抽象的。一些减少程序“错误表面”的具体类型系统特性包括:没有可空类型;能够表示“可能失败”的计算;模式匹配和完整性检查;以及免费避免“原始痴迷”。让我们来看看每一个。

在一种语言中,null(或 nilnone)值可以存在于任何(或大多数)类型中,通常被视为一种便利,但实际上它有很大的代价。在可以使用这种 null 值的语言中,程序员永远无法知道他们正在处理的值是否真的是预期的类型,或者它是否是 null,因此需要在每次使用这个值时进行检查。程序员可能会忘记事情,而 null 值可以存在于许多类型中意味着类型系统不会帮助防止这一点,从而导致诸如“undefined is not a function”或“NoneType object has no attribute”之类的错误。然而,这些都是运行时错误,这意味着程序在其主要任务中失败了,而且这些错误更难找到,因为它们发生在野外。Haskell 没有 null 值。你可以在特定的数据类型中定义它们(例如 Maybe 类型,我们很快就会讲到),但你必须明确定义它们并明确处理它们。因此,由于这种语言设计缺陷而产生的误差表面被消除了,程序员不再需要考虑它。

然而,null 值通常用于表示“失败”的计算。例如,如果你想获取一个空列表的头部,你如何表示结果?在有 null 值的语言中,这样的函数通常会在这些情况下返回 null。这是处理可能失败的计算的一个更一般问题的特定情况:如果你正在解析一些用户输入,而该输入格式不正确,这个解析失败是程序的有效状态,因此你需要某种方式来表示它。类似地,网络请求可能会超时,求解器可能找不到解决方案,用户可能会取消操作等。有两种常见的解决方案,null 值(我们已经提到过)和异常处理。这两种解决方案都为程序员带来了一个新问题:你必须记住处理它们,在异常的情况下是在调用站点而不是在你消费值的地方,而且类型系统不会防止你忘记。

Haskell 通过类型系统以非常不同的方式解决了表示可能失败的计算的问题:明确地通过类型系统。Haskell 中有类型来表示可能失败的计算,因为这是在类型系统中完成的,这些是一等实体,你可以随意传递你的计算结果——可能或可能不失败——你可以随意。当涉及到消费该计算的结果时,类型系统迫使你面对可能没有结果的事实。这阻止了一类运行时错误,而不需要心理上的负担来跟踪可能出现的值或哪些函数可能在某个地方抛出异常。

这两种最常见的类型是 MaybeEitherMaybe 表示可能或可能没有结果的计算。例如,如果你想获取列表的第一个元素,但你不知道列表是否为空,那么你可能想要指定你的 head 函数可以返回结果或 Nothing。与 null 值不同,你不能仅仅假装函数必须返回结果,如下所示的代码片段应该演示:

safeHead :: [a] -> Maybe a
-- 实现在这里不重要,但我包括它
-- 因为它很简单,对于好奇的人来说可能有帮助
safeHead [] = Nothing
safeHead (x : _) = Just x

myFavouriteThings = ["roses 上的雨滴""kittens 上的胡须"]
emptyList = []

faveThing = safeHead myFavouriteThings
-- ^ 但这个东西的类型是什么?
-- 它不是字符串,它是 `Maybe String`
-- 事实上,它的值是 `Just "roses 上的雨滴"`

something = safeHead emptyList
-- ^ 那么这个东西的类型是什么?
-- 再次,它是一个 `Maybe String`,但在这种情况下
-- 它的值是 `Nothing`,因为列表没有第一个元素!

-- 那么我们如何使用我们计算出的值呢?
printTheFirstThing :: [String] -> IO ()
printTheFirstThing myList = case safeHead myList of
  Just something -> putStrLn something
  Nothing -> putStrLn "你没有最喜欢的东西吗?多么悲伤。"

在这个例子中,你可以看到,当消费可能失败的计算的结果时,你必须明确处理失败的情况。有很多方法可以做到这一点,上面提到的模式匹配( case x of ...)只是其中之一,我们很快就会讲到。

Maybe 也可以在你想要一个可空的数据结构字段时使用。这是计算可能失败的一个特定情况,但通常被认为是不同的。这里是这在 Haskell 中的样子:

data Person = Person {
  name :: String,
  dob :: Day,
  favouriteThing :: Maybe String
}


和以前一样,Haskell 的类型系统不会让你忘记处理 favouriteThing 可能是空值的情况,所以你不会遇到运行时错误,就像你可能在一种你可以忘记这样做的语言中一样。

Maybe 在失败条件明显的情况下很有用,但它不会给你很多关于计算失败的 原因 的解决方案,它只告诉你 失败了。相比之下,一个 Either a b 可以包含两个值,Left aRight b。按照惯例,Left 包含一个失败值,而 Right 包含一个成功值,所以类型通常被指定为 Either e a,其中 e 是“错误”,而 a 只是结果类型。

这种方式的一个可能的用途是在解析或验证一些用户输入时,你可能想要告诉用户不仅仅是他们给你的东西无效,而是它以什么方式无效。为此,你可以有一个看起来像这样的 validate 函数:

validateAddress :: String -> Either AddressParseError ValidAddress

这让你能够向用户返回更有帮助的错误,这些是你的程序中的一个预期路径,但它阻止了你忘记处理失败情况,或者不小心将失败情况当作成功情况处理。

为了明确,这意味着我们不再通过将它们抛到调用栈上来处理已知的错误状态,而是将它们视为我们表达式的类型的潜在值。反过来,这意味着我们现在可以在函数调用点 向下 栈有一个完整的所有失败模式的描述。考虑这两段代码:

def do_something():
  result = get_a_result()
  if result != "a result":
    raise InvalidResultError(result)
  return 42

doSomething :: Either InvalidResultError Int
doSomething =
  let result = getResult
   in if result /= "a result"
        then Left (InvalidResultError result)
        else Right 42

在第一段代码中,我们不知道 do_something 可能抛出什么异常,部分原因是我们不知道 get_a_result 可能抛出什么异常。相比之下,在第二段代码中,我们知道 所有 可能的失败状态,因为它们被捕获在类型系统中。

我们可以将这种被迫处理失败情况的想法概括为说 Haskell 让我们编写 函数而不是 部分 函数。这意味着我们必须处理整个输入域,而不是输入域的一部分,否则编译器会向我们抱怨,并有时直截了当地拒绝给我们一个程序。最容易看到这种情况是如何工作的是通过查看 Haskell 中如何进行模式匹配,使用一个基本程序来帮助我们根据所选选项组织我们的夜晚。我们不会实现整个程序,而是这里有一个摘录来说明模式匹配的使用。

data Option =
  NightIn
  | Restaurant VenueName
  | Theatre VenueName EventName

data OrganiserResult = Success | NeedsSeatChoice [Seat] | Failure Reason

organiseMyEvening :: Option -> IO OrganiserResult
organiseMyEvening NightIn = do
  cancelAllPlans
  return Success
organiseMyEvening (Restaurant venue) = attemptBooking venue
organiseMyEvening (Theatre venue event) = do
  availableSeats <- checkForSeats venue event
  case availableSeats of
    [] -> return (Failure (Reason "there are no seats available, sorry :("))
    seats -> return (NeedsSeatChoice seats)

在上面的例子中,如果我们要为我们的夜晚活动添加一个额外的选项,比如去电影院,并且 忘记 相应地更新 organiseMyEvening 函数,编译器会向我们抱怨,直到我们修复它。如果没有类型系统中的这种完整性检查,我们可能会遇到运行时错误,但有了这种类型的检查,我们就不必担心是否 记得 更新所有使用给定值的地方。

Haskell 类型系统的最后一个主要方式,可以帮助我们避免编程时的常见错误,是与避免“原始痴迷”有关。我们的晚间组织片段中有一个提示:我们的 RestaurantTheatre 构造函数接受一个 VenueNameEventName。这些自然可以被表示为普通的字符串,而且在许多语言中就是这样,但 Haskell 为我们提供了一种非常简单、零成本的方式来表示它们,比字符串有更多的语义价值,更有意义。可能还不明显为什么这是一个值得解决的问题,然而。让我们想象一下,如果我们将这些表示为普通字符串,那么我们会有类似这样的东西:

data Option =
  NightIn
  | Restaurant String
  | Theatre String String -- 分别是场馆名称和事件名称

checkForSeats :: String -> String -> IO [Seat]

这可能是你第一次写的时候 可以 的,尽管你需要注释,如上所述,以提醒自己每个值是什么。这是我们第一次烦恼(尽管还不是问题)的地方——类型系统不会帮助我们记住是什么,我们必须依赖任意的评论或文档(或者可能是变量名)来记住,这是很多开销。问题在于使用这些值时,比如在 checkForSeats 中。我们很容易混淆场馆名称和事件名称,我们总是会返回零个座位(因为我们可能不知道一个叫做 King Lear 的剧院在伦敦,他们正在上演莎士比亚的杰作 _The National Theatre_)。这是错误的行为,但很容易做到,类型系统不会帮助我们。“原始痴迷”是使用原始类型(字符串、数字、布尔值等)来表示数据,而不是具有更多语义价值的类型。解决方案是在你的类型系统中编码你的领域,这可以防止这种错误。这在许多命令式语言中可能非常繁琐,但在 Haskell 中,我们可以简单地将一个值包装在 newtype 中,类型系统突然阻止我们陷入使用错误值的陷阱。因此我们的代码变成了:

newtype VenueName = VenueName String
newtype EventName = EventName String

data Option =
  NightIn
  | Restaurant VenueName
  | Theatre VenueName EventName

checkForSeats :: VenueName -> EventName -> IO [Seat]

上面写着这是“零成本”的方法,这意味着与通常创建一个数据结构来包装一些值的方式不同,newtypes 在内存中的表示与它们包装的类型完全相同(结果是它们只能包装一个类型),因此它们只存在于类型系统层面,但对你的程序没有其他影响。

到目前为止,我们已经讨论了四种帮助我们作为程序员编写正确代码的类型系统特性:缺乏可空类型、表示“可能失败”的计算、模式匹配和完整性检查,以及避免“原始痴迷”。

其他语言具有这些特性中的一些(特别是 Rust,其类型系统受到 Haskell 的启发),但大多数其他语言缺乏第二支柱:纯函数式编程。纯函数式语言帮助我们避免常见错误的两个方面是:不可变数据和显式副作用(一起,给我们纯度和引用透明性)。

几乎所有 Haskell 中的数据都是不可变的。这意味着一类错误,如数据竞争,或在读写之间对象变化,根本不存在。在单线程代码中这很好,因为你不需要考虑任何地方的可变状态,你只是使用像折叠或遍历这样的东西来实现你的目标,但当这在并发 Haskell 中真正闪耀时,你不必担心互斥锁和锁,因为你的数据根本无法 _被改变_。这意味着如果你想并行化一个计算,你只需将其分叉到不同的线程并等待它们全部返回,而没有多线程计算的棘手错误。即使你确实需要在线程之间共享一些可变状态,Haskell 中构建这种方式的方式(例如在 STM 库中)仍然避免了其他语言中的锁和互斥锁的问题。

不可变性让你在消除命令式语言中发现的错误类型方面走了一半的路,但 纯度 会让我们走完剩下的路。Haskell 函数是纯的,这意味着它们不允许任何副作用,也不依赖于除了传入它们的参数之外的任何东西。有方法编码副作用,因为,最终,任何有用的程序至少需要执行 一些 I/O,并且有方法在函数中包含不是 直接 作为参数传递的东西(隐式参数),但 Haskell 的构造方式意味着这些方式不会违反语言的纯度,因为我们使用 monads 来编码这些东西。

Monads:起初它们让每一个新手 Haskell 程序员感到困惑,然后几乎每个人都觉得有必要写自己的 monad 教程。确切地说 monads 是什么以及它们为什么有用,超出了我们在这里想要讨论的范围,但我们 正在 寻找的特定好处是它如何允许我们编码副作用,以及这将如何帮助你在编程时避免错误。

让我们看一些基本的在线社区函数:

data Response = Success | Failure FailureReason

sendGreetings :: User -> IO Response

updateUser :: UserId -> User -> IO Response

findNewestUser :: [User] -> Maybe User

在许多命令式语言中,找到最新用户并发送某种问候的活动可能会在一个函数中完成,或者一组深度嵌套的函数。然而,没有什么可以阻止你在简单的 findNewestUser 函数中进行数据库调用、发送电子邮件或做任何其他事情。这对于追踪错误和性能问题来说可能是一场噩梦,也阻止了函数之间的紧密耦合。

上面的函数有两种形式:findNewestUser 返回我们现在已经熟悉的 Maybe User——如果有最新用户,它会返回它,否则它会返回 Nothing。其他两个函数 返回我们尚未见过的东西:IO ResponseIO,像 Maybe 一样包裹另一种类型(在这种情况下:Response),而不是像 Maybe 那样表示一个“可能失败”的计算,它表示你被允许执行 I/O 操作的任何上下文(比如与你的数据库交谈或发送电子邮件,就像我们上面的情况一样)。在 IO 单子之外执行 I/O 是不可能的——你的代码将无法编译——而且,I/O “着色”了调用它的所有函数,因为如果你正在调用返回 IO 的东西,那么你也必须返回 IO

这看起来可能像是很多官僚作风,但它实际上做了两件非常有帮助的事情:首先,它立即告诉程序员“嘿,这个函数执行 I/O 中的副作用”,这意味着他们不必阅读代码就能理解它的作用,只需类型签名;其次,这意味着你不能在你认为是纯的函数中意外执行 I/O——这本身就消除了整个类别的错误,其中一个人可能认为他理解了一个函数的所有依赖项,但实际上有一些东西正在影响它,因为它可以执行副作用。

然而,这只是一个部分令人满意,因为将所有执行副作用的东西包裹在 IO 中有点不精确,类似于使用原始类型来表示具有更高级别语义的值也是不精确的,它可能导致类似的错误类别:没有什么可以说“在这个函数中你可以发送电子邮件,但你不能写入数据库。” 类型系统已经帮助了你 一点点 ,但还没有达到我们迄今为止所期望的护栏。

幸运的是,由于两个额外的语言特性:即 ad hoc 多态性和类型类,我们可以 确切地 编码我们希望函数被允许执行的效果,并使其无法执行任何其他操作。让我们修改我们的例子以利用这一点,注意 class X a where 意味着我们正在声明一个 X 类型,它们有一些相关联的函数,我们必须为它们编写具体的实现。这类似于某些语言中的接口,或 Rust 中的特征(基于 Haskell 的类型类)。在这个例子中,m 只是一个代表“二阶”类型的类型变量(例如 IOMaybe)。

data Response = Success | Failure FailureReason

class CanReadUsers m where

  getUsers :: m (Either FailureReason [User])

class CanWriteUsers m where

  updateUser :: UserId -> User -> m Response

class CanSendEmails m where

  sendEmail :: EmailAddress -> Subject -> Body -> m Response

findNewestUser :: [User] -> Maybe User

sendGreetings :: CanSendEmails m => User -> m Response

greetNewestUser :: (
  CanReadUsers m,
  CanWriteUsers m,
  CanSendEmails m
  ) => m Response

我们在这里引入了一个新函数 greetNewestUser 来说明我们如何可以组合这些 约束 我们能够做的事情。我们的实现可能会做这样的事情:找到所有用户,过滤出最新用户,发送电子邮件,并标记用户已被问候。我们已经编码了这些功能在 greetNewestUser 的类型级别,而我们没有为 sendGreetings 这样做,所以 sendGreetings 实际上不可能从数据库中获取用户或不小心更新数据库中的用户信息。它 只能 发送电子邮件。为了完成这个例子,让我们看看这些函数的实现可能是什么样子:

-- 这些将在其他地方定义,但只是为了让你知道类型
joinDate :: User -> Day
emailAddress :: User -> EmailAddress
setAlreadyGreeted :: User -> User
hasBeenGreeted :: User -> Bool
userId :: User -> UserId

findNewestUser users = safeHead (sortOn joinDate users)

sendGreetings user =
  let subject = Subject "欢迎加入俱乐部!"
      body = Body "记住:不要盯着客人看..."
   in sendEmail (emailAddress user) subject body

greetNewestUser = do
  fetchResult <- getUsers
  case fetchResult of
    Left err -> return (Failure err)
    Right users -> case findNewestUser users of
      Nothing -> return (Failure NoUsers)
      Just user -> if hasBeenGreeted user
        then return (Failure AlreadyGreetedUser)
        else do
          sendGreetings user
          let newUserData = setAlreadyGreeted user
           in updateUser (userId user) newUserData

虽然确切的语法可能不熟悉,但本节中的一切都在建立这一点:我们使用数据类型来表示可能失败的计算,它们可以失败以及如何失败;我们使用语义上有意义的类型来描述我们的数据,而不是原始类型;我们明确处理失败情况,而不是被允许忘记它们;我们不能改变状态,所以我们创建了具有必要更新的新数据副本;我们明确编码我们想要执行的副作用,而不是随意地发射它们。

这就结束了关于 Haskell 为你作为程序员提供的护栏的部分,无论是通过其类型系统的强度还是通过语言本身的纯度和引用透明性。远非对程序员的强加,这是非常自由的,因为它允许你将你的精力 描述 你的问题,从而解决它,而不是担心你的程序可能失败的所有方式。

但是 <语言> 也有 <特性>!

Haskell 上面的某些特性存在于其他语言中,或者看起来它们存在于其他语言中。在不试图谈论所有可能的语言的情况下,我们可以看看一些常见的模式以及它们如何不同,或者不,与 Haskell 中的不同。

模式匹配,例如,已经被引入到许多语言中。其中一些具有与 Haskell 相同的特性,像 Rust 的模式匹配,它是穷尽的,并且由编译器强制执行,而其他一些则非常不同,特别是在逐渐类型化的语言中,如 Typescript 和 Python,这里没有保证这种安全渗透到代码库中,通常有逃生舱口,因为你使用的是可选的工具,这些工具是外部的内置工具链。

很少有语言使用像 EitherMaybe 这样的高阶类型来表示可能失败的计算,但 Rust 是一个值得注意的例外,像 Haskell 一样,它强烈鼓励以这种方式表示失败。

子类化通常在某些语言中使用,以使避免原始痴迷“容易”,但这并不像 Haskell 的 newtype 那样严格。例如,Python 有一个 NewType 构造,但它有两个常见于这种实现方式的弱点:第一,子类化意味着我们的 VenueNameEventName 类型可以传递给期望 String 的函数,因为它们不被视为完全不同的类型,第二,与 Haskell 不同,你不能隐藏这些类型的构造函数,这意味着你无法完全实现某些模式,如解析模式(与验证相反)。

最后,虽然其他语言中存在一些库,以隔离和控制副作用,但它们并不像 Haskell 那样作为语言的一部分被强制执行,因为这将需要将纯度构建到语言本身中。

让你更有生产力的事情

提供护栏,对于上一节中列出的所有原因来说,是一个非常有用的语言特性,但如果仅此而已,可能会使构建程序的体验变得非常缓慢。Haskell 有几个特性,实际上使构建这样的程序 生产力,特别是当这些程序在复杂性(或绝对大小)上增长时。

和以前一样,这些特性源于语言的两个关键特性:类型系统的强度和语言的纯函数语义。这两个特性共同赋予了我们高度声明性的代码,因此易于理解和无歧义地操作,以及倾向于重用概念和代码。

为什么这些有用?首先,如果我们的程序是声明性的而不是命令性的,我们可以很容易地理解它,同样也可以简单地从它生成其他代码(或文档),并且重构成为一种“无畏”的活动。对于后者,这意味着我们可以“发现”一组核心概念,并继续在它们的基础上构建,而不是为每个域或库学习不连贯的概念集。

在没有体验过大多数现代编程所坐落的命令式范式之外的情况下,很难解释这些事物如何根本地转变了构建程序的方式,但为了举一个小例子,Haskell 生态系统有一个名为“Hoogle”的工具,它允许你通过类型签名搜索函数。不仅仅是通过具体类型的完整类型签名,甚至可以通过部分类型签名和类型变量而不是实际类型。这意味着,与其搜索一个将函数应用于字符串列表的东西( (String -> String) -> [String] -> [String]),你可以搜索一个将函数应用于一系列事物并返回结果列表的东西:( (a -> b) -> [a] -> [b])。你甚至可以弄错参数的顺序,Hoogle 仍然会找到正确的函数,所以 [a] -> (a -> b) -> [b] 会给你与 (a -> b) -> [a] -> [b] 相同的答案(只是排序不同)!

这之所以有效,是因为 Haskell 的语义、标准库和生态系统都严重依赖于概念重用。几乎所有的库都建立在核心概念集之上。这意味着,如果你想知道如何做某事,并且你面临着一个库或一组数据类型,你很可能会搜索你想要实现的一般模式,你会得到你想要的。几乎没有其他生态系统有类似的东西。

为了进一步阐述概念泛化和重用的思想,让我们考虑两个例子:functor 和 monoids。在我们到达那里之前,我们将从列表开始。

在 Haskell 中,一个列表看起来像这样 myList = [1, 2, 3] :: [Int]。你可以对列表做各种事情,比如将一个函数应用于列表的每个 成员( map),以获得一个新的列表,或者将两个列表拼接在一起 ( [1, 2] <> [3, 4])。在这种意义上,我们已经描述了列表的两个属性,我们可以将其泛化:一个列表是一个你可以在其上应用函数的容器(一个“functor”),并且一个列表是一个具有二元组合操作和恒等值 [] 的对象(一个“monoid”)。

许多其他结构也表现出这些属性,例如一个列表是一个 functor,但 MaybeEither 也是一个 functor,甚至是一个解析器!因此,如果你理解了 functor 的核心概念,你就有一套可以应用于你日常使用的其他数据结构的工具,而无需额外的开销:

fmap (+ 2) [123-- [3, 4, 5]
fmap (+ 2) (Just 2-- Just 4
fmap (+ 2) (Right 5-- Right 7
number = fmap (+ 2) decimal :: Parser Int
-- 解析一个十进制的字符串表示,加上 2,
-- 但好处是我们不必明确处理我们的 `+ 2` 函数的失败情况!
parseMaybe number "4" -- Just 6

类似地,有很多 monoids 潜伏着。明显的示例可能是字符串,但然后,例如,Lucid 库用于编写 HTML 将 HTML 表示为 monoids,这允许你使用与任何其他 monoid 相同的工具来组合它们。再一次,你学习一个核心概念,它变得适用于生态系统中的大部分内容。

[12] <> [34-- [1, 2, 3, 4]
"hello" <> " " <> "world" -- "hello world"
myIntro = p_ (i_ "Don't " <> b_ "panic"-- <p><i>Don't </i><b>panic</b></p>

你甚至可以在自己的代码中使用它,并可以为你自己的数据结构编写简单的实例。这大大减少了你必须编写的专用代码量——相反,你可以简单地重用来自其他地方的代码和概念,无论是标准库还是像 bifunctors 这样的概念扩展。

简而言之:Haskell 的语义和标准库鼓励泛化概念,这反过来又大大促进了概念和代码的重用,这已经推动了生态系统向类似的方向发展。重用意味着程序员只需要发现一次核心概念,而不是每个库,提供了一个加速的学习和更有效的代码使用率。

这里要讨论的最后一个生产力提升是“无畏重构”,这个词经常在 Haskell 社区中被抛出,但它到底意味着什么?这里的要点是,编译器的坚定性使其成为重构代码时的有用盟友。在具有更宽容的编译器或较弱类型系统的语言中,重构代码可能会引入新的错误,这些错误只在运行时被发现。当在 Haskell 中重构时,因为类型系统赋予你正确表达你的程序领域的力量,通常的工作流程是“更改,编译,更改,编译”,直到所有的编译错误都消失,那时你可以非常确信你不会在运行时遇到错误。这减少了程序员的认知负荷,使对代码库的更改(无论大小)变得更快(也更不可怕)。

这一节超越了仅仅提供护栏,护栏显然激发了其他语言维护者将它们引入到他们的语言中,转而讨论对编程生产力非常根本的事情:可组合、可重用的concepts 和“无畏”更改你的程序的能力。这些不仅仅是可以添加到语言中的特性,它们是它的特征,它们与下一节中概述的更抽象的概念有关。

更容易地推理你的程序

一般来说,编程是关于向机器描述某个问题领域:它的本体论,以及控制它的逻辑规则,然后要求它计算一些结果。这些结果应该有一些我们可以解释的意义,这将取决于我们对程序实际 意味着 的理解有多好。此外,为了能够信任我们要求机器进行的计算结果,我们需要确信我们已经很好地描述了问题领域。

一个程序可能有本质复杂性或偶然复杂性。本质复杂性来自于精确描述问题领域,而有些领域比其他领域更复杂。偶然复杂性来自于我们(无法)向机器表达问题领域的能力。我们可以将这些称为 复杂性复杂性 ,以区分它们。

复杂性是不好的,应该消除。它们使得很难推理我们的程序,因此很难信任它们的结果。这也使得编写程序变得困难,因为我们不得不处理所有这些复杂性。这有点像试图用带有厚手套的鲁布·戈德堡机器刺绣挂毯:不太可能给你想要的东西。

我们可以在如何能够向机器表达问题领域的尺度上看待通用编程语言,因此可以信任我们要求机器进行的计算结果。汇编语言在一端:它全是关于在寄存器之间移动字节并在它们上执行算术运算。是的,你可以用汇编语言写任何东西,但是很难推理你将得到的结果。随着我们向“高级”语言的尺度移动,我们获得了一组抽象,它们允许我们忘记低级别的语义(例如在寄存器之间移动字节),因为它们给了我们更接近问题领域语义的新语义。

抽象的目的不是要含糊,而是要在其中可以绝对精确地创造一个新的语义层面。~ Dijkstra, 1972

Haskell 在这方面改进了大多数高级语言,提供了一种表达力,允许更精确地描述问题领域,对程序员和机器都很容易理解。大致有三个主要的贡献因素(也许它们都可以归入 denotation 语义的想法):代数数据类型,参数化和 ad hoc 多态性,以及声明式编程。

我们可以通过说声明式编程描述了计算应该相对于问题领域是什么,而命令式编程描述了如何逐步执行计算来区分它们。

这是有用的区分:在命令式编程中,程序的操作语义(机器必须执行的步骤,以计算结果)被混入问题领域,使得很难推理程序的含义,因此很难理解其正确性。声明式编程,然而,不费心定义这些执行步骤,使程序更容易理解和推理。

在 Haskell 中,一切都是表达式。实际上,你的整个程序是一个由子表达式组成的 单个 表达式。这些子表达式本身有一定的意义,它们的组合也是如此。这与命令式语言不同,在命令式语言中,通常有许多函数调用和循环的行,通常有深度嵌套的函数调用,但这些本质上不是可组合的。Haskell 的纯度强制简洁的程序由无副作用的有意义的子表达式组成。这意味着理解给定表达式的目的,因此推理它是否正确,需要的时间要少得多。

和以前一样,我们回到了我们熟悉的两个支柱:到目前为止,我们已经讨论了纯函数支柱(单个表达式,可组合性,无副作用),但类型系统给了我们工具,以便清晰地向机器(和我们自己)表达自己。

实际上,前面各节都以某种方式触及了这一点:我们已经讨论了数据类型,用于表达一些计算可能以明确定义的方式失败;我们有像 data Response = Success | Failure FailureReason 这样的求和类型,允许我们定义函数可能得到的所有可能值;我们有类型类,可以用作函数的约束,以最通用的方式语义上表达结果(像 CanSendEmails);我们有像 FunctorMonoid 这样的泛化概念,描述了事物的行为方式,而不是实现这些行为的步骤。

代数数据类型和类型类(以及其他处理各种多态性的类似机制)允许我们在 Haskell 中构建我们自己的领域特定语言来编写程序,同时建立在共同的、已建立的概念之上。这些都是声明式的,而不是命令式的,因此它们很容易在语义上理解,因为你不必剔除操作语义(逐步指令),也不必从不属于自己的领域翻译成你自己的领域。

这一节必然是抽象的,因为如果一个人没有走出大多数现代编程所坐落的命令式范式,这个想法就很难传达。为了稍作阐释,这里有一个使用上面讨论的概念的小程序示例。

这个程序是一个基本的会计工具:给定一些初始货币价值,以及一组以各种货币进行的交易(收入或支出),允许我们计算账户的最终价值。

-- 这有点像一个 "枚举" 类型构造器的类型 Currency
data Currency = GBP | EUR | USD

data Money = Money {
  currency :: Currency,
  value :: Double
  }


convert :: Currency -> Money -> Money
convert = -- 在这里不感兴趣实现,因为它基本上是一个查找表

zero :: Money
zero = Money GBP 0

-- Ord 给我们提供了比较事物的方法(一个自然排序)
instance Ord Money where
  compare m1 m2
    | currency m1 == currency m2 = compare (value m1) (value m2)
    | otherwise                  = compare m1 (convert (currency m1) m2)

instance Monoid Money where

  m1 <> m2 = Money (currency m1) (convert (currency m1) m2)
  mempty = zero

instance Functor Money where

  fmap f (Money curr val) = Money curr (f val)

data Transaction = In Money | Out Money

instance Functor Transaction where

  fmap f (In m) = In (f m)
  fmap f (Out m) = Out (f m)

normalise :: Transaction -> Transaction
normalise transaction =
  let m
 = money transaction
   in if m < zero then Out m else In m

instance
 Monoid Transaction where

  t1 <> In m2 = normalise (fmap (<> m2) t1)
  t1 <> Out m2 = normalise (fmap (<> (fmap (* (-1)) m2)) t1)

apply :: Transaction -> Money -> Money
apply (In m) initial = initial <> m
apply (Out m) initial = initial <> fmap (* (-1)) m

getAccountValue :: Money -> [Transaction] -> Money
getAccountValue startValue transactions = apply (fold transactions) startValue

尾声

我喜欢用 Haskell 写作,而且 Haskell 之所以如此,有很多超出这篇辩护文的原因,但我也认为它是通用编程的一个很好的选择,对于任何想要自信且高效地编写健壮软件的人来说,当然,还有愉快地享受。

我认为 Haskell 独特的是它的类型系统和函数纯度的结合——不仅仅拥有函数式编程是不够的,就像我非常喜欢的 LISP 一样,也不仅仅拥有类型系统,尽管 Rust 看起来像是一种很棒的语言。许多语言都有这些特性的一些部分,但只有少数语言拥有所有这些特性,而在这些语言中(包括 Idris、Agda 和 Lean),Haskell 是最成熟的,因此拥有最大的生态系统。

虽然其他语言肯定在添加我上面提到的特性,但这种强大且富有表现力的类型系统和纯函数式编程的结合是语言的基础:没有这些公理特性的其他语言根本无法实现它们(并且在非功能性语言中将其中一些特性构建到较弱类型系统中的尝试通常非常尴尬且不是很有用)。

不是每个人都有在专业环境中选择工具的奢侈,无论是因为团队中的历史还是其他人做出的决定。即使在这种情况下,如果你从未在专业上使用 Haskell,它也会改变你对编程的思考方式,并且为了反转 Alan Perlis 在本文开头的引述:任何改变你对编程思考方式的语言都值得学习。


编程悟道
自制软件研发、软件商店,全栈,ARTS 、架构,模型,原生系统,后端(Node、React)以及跨平台技术(Flutter、RN).vue.js react.js next.js express koa hapi uniapp Astro
 最新文章