聊一聊函数式编程中的Hindley-Milner

2,342 阅读13分钟

前言

了解过函数式编程的人可能对这个系统有所了解,函数式编程中的类型签名就是以Hindley-Milner系统写的。

本篇文章我将讲解浅入Hindley-Milner系统、直至深入函数式编程中的类型签名。Hindley-Milner的思想是什么?类型签名的作用?该如何去运用?会给我们带来什么样的好处?上述问题都将进行一一解答。相信它一定会对你有所帮助。

作者

请允许我先解释一下Hindley-Milner这两个“单词”,如果你去搜索的话,你会发现并不会搜索到相对应的或者并非有意义的汉语翻译。因为Hindley-Milner系统的命名是通过两个人的名字拼凑而成。J. Roger Hindley && Robin Milner

J. Roger Hindley

J. Roger Hindley是著名的英国逻辑学家,以Hindley-Milner类型推断算法而闻名。自1998年以来,他一直是斯旺西大学的名誉研究员。

Robin Milner

亚瑟·约翰·罗宾·葛瑞尔·米尔纳(英语:Arthur John Robin Gorell Milner,1934年1月13日-2010年3月20日),生于英国普利茅斯,计算机科学家。1991年获得图灵奖。他是英国皇家学会成员,ACM会士。

你是否嗅到了一丝令人兴奋的味道,罗辑学家 + 计算机科学家 = ??。说实话我很喜欢这种组合,思维的碰撞与合作💥才会有多种可能性、才会更加精彩。显然 Hindley-Milner 是他们的优秀碰撞的结果。

Hindley-Milner

首先我们先有个概念,Hindley-Milner 是什么?

从功能上讲,Hindley-Milner是一种根据使用来推断值类型的算法。它从字面上形式化了一种直觉,即可以通过其支持的功能来推断类型。

这样,我来举一个容易理解的生活例子,但是我并不想这个例子影响你的理解。

  • 假设你知道天下雨,小明会带伞
  • 并且假设你知道现在下雨了

下面的推断文字很重要,希望你可以细细阅读

那么你通过上述两条信息推断出“小明会带伞”的这条结论。如果没有第二个假设的话,也就是说你并不知道现在下没下雨,那么你的结论是什么呢?在物理学中,这种情况的推导出来的结论是叠加态的,小明同时带了伞也没带伞,叠加了两种状态。

当有了第二条假设后,通过第一条的形式化,产生了一种直觉 -> 小明会带伞

可能有人会有疑问,这不是常识吗,一想就明白了。可是据我了解计算机没有常识。

先通俗的理解:Hindley-Milner系统可以提供上述的假设支持,Hindley-Milner算法的目的是能够推断出“表达式e具有类型t ”形式的语句

概念

从计算机任何语言中出发,聊一下任何一个表达式的时候。可以想像,我们想谈一谈这些表达式的类型,并且想弄清楚是根据什么样的规则,并且如何推断类型是一件如何困难的事情。

作者根据这些痛点,创建了Hindley-Milner,它具有如下基础特性:

  • 具有抽象性和通用性,因此它使我们能够纯粹根据其形式(形式化,想想上面的例子)来推理类型推断的语句,而不必担心其内容。
  • 对表达式的含义给出精确,明确而直观的定义。
  • 根据少量无争议的原始概念给出这些定义。
  • 类似地,给出类型的定义,表达式具有类型的思想以及我们可以推断给定表达式具有给定类型的思想。
  • 适合于使用简单,简洁的符号表示,例如,而不是说“通过将第一个表达式应用于第二个表达式而形成的表达式具有从字符串到我们在当前上下文中无需指定的某种函数的类型”我们可以简单地说“ e1e2):Stringte_1(e_2):String →  t“。
  • 可以轻松地翻译成计算机可以理解和实现的东西,因此我们最终可以使类型推断自动化
  • 类型推断会具备一些规则,例如:“如果我已经可以证明某个表达式具有该类型,而另一个表达式具有该类型,则该第三个表达式也具有该另一类型”

下面我们来举个javascript中函数的例子,根据这个例子一起推导一些逻辑:

上述的函数类型为StringStringString → String(给定string类型,推导出string类型)。但是呢,也可以是IntIntInt → Int

就像Hindley-Milner中的e,te, t ,它可以基本上代表任何语言中的任何类型,例如Int, String 等, 但是也不一定代表的是类型,也可能代表的是函数或者其他什么的,由你想象。

上述函数类型也可以写成t.tt \forall t.t → tStringStringString → String中每个类型都是单个类型,而t.tt\forall t.t → t代表了多种类型。

注:全称量词x:P(x)表示P(x)对于所有x为真。nN:n2n∀ 全称量词 ∀ x: P(x) 表示 P(x) 对于所有 x 为真。 ∀ n ∈ N: n² ≥ n

所以上述函数具有抽象类型 t.tt\forall t.t → t, 针对每种类型tt,该函数的类型推导为 ttt → t,这样的话我们可以得出一个公式

λx.x:α.ααλx. x: ∀ α. α → α

希望你读到这里可以理解一部分 Hindley-Milner 的设计理念和部分实现方式。它最终的实现元语言例子如下:

接下来回到我们前端方面,那么我们哪里用到了Hindley-Milner呢?

答: type signatures 是以 Hindley-Milner 写的。

稍微消化一下,我们继续。

type signatures

type signatures(类型签名)

很多语言中都有类型签名的设定,学过Typescript的前端一看type应该会觉得很亲切。而我们这里将从函数的角度出发来学习类型签名。

类型签名定义函数的输入和输出,有时包括函数包含的参数数量,参数类型和参数顺序。

类型签名在写纯函数时所起的作用非常大,大到英语都不能望其项背。这些签名轻轻诉说着函数最不可告人的秘密。短短一行,就能暴露函数的行为和目的。类型签名还衍生出了 “自由定理(free theorems)” 的概念。因为类型是可以推断的,所以明确的类型签名并不是必要的;不过你完全可以写精确度很高的类型签名,也可以让它们保持通用、抽象。类型签名不但可以用于编译时检测(compile time checks),还是最好的文档。所以类型签名在函数式编程中扮演着非常重要的角色——重要程度远远超出你的想象。

javascript是弱语言类型,没有类型上的检查限制。但这并不意味我们需要一味的否定类型,写项目中我们还是需要数据类型的区分。只不过,语言层面上没有相关的集成让我们时刻谨记各种数据的类型罢了。这也成为了Typescript流行的一种优势。

辨别类型和它们的含义是一项重要的技能,这项技能可以让你在函数式编程的路上走得更远。不仅源码、文档等更易理解,类型签名本身也基本上能够告诉你它的函数性(functionality)。大多源码中你都会看到类型签名的影子。

下面我们通过一些Case来理解Hindley-Milner语法,随着Case慢慢解读,理解。

Case 1

capitalize表达式上方的备注就是函数的类型签名。

在 Hindley-Milner 系统中,函数都写成类似 a -> b 这个样子,其中 a 和b 是任意类型的变量。因此,capitalize 函数的类型签名可以理解为:

一个接受 String 返回 String 的函数

换句话说,它接受一个 String 类型作为输入,并返回一个 String 类型的输出。

再换个角度想这个问题,管道思想。没错,就是下图这样的管道。

这个函数像不像一个管道?水从一头进入,另一头出去。

String类型的数据从管子入口进去 从出口出来的也是String类型。

类型签名中的“->”符号就像一个管道,并且指出了流向

函数式编程的核心思想之一就是管道思想,每个函数都是一个负责输入输出的管道,管道之间相互头尾相连形成新管道。数据流向一直都是清晰的。

接下来我们继续了解类型签名。

Case 2

可以试着读一下

一个接受 String 返回 Number 的函数

Case 3

这里的curryramda方法,柯里化里面的二元函数,拆分为两个一元函数。

如果你不理解上面的写法,可以看一下下方的写法,是一样的。

我们来试着读一下

  • [String] 为item都是字符串的数组。
  • 首先传入String类型
  • 再传入[String]类型
  • 输出String类型。

这么理解是不是有点绕,有点不够通透?我们来进行一个分组,重新读一下试一试。

我们加上一个括号,然后试着重新读一下

  • 首先传入String类型
  • 输出了一个函数 ( [String] -> String )
  • 再往这个函数里传入[String]
  • 则会返回String

加上括号之后好理解一些。下面将不再展开curry写法。这边附上curry实现方法

Case 4

  • 传入正则类型
  • 返回了一个函数,一个传入String返回[String]的函数。
  • 再往返回的函数中传入String
  • 返回[String]类型

Case 5

我们再上升一个难度

我们依旧可以用上面的加括号的方式进行解读,是没有问题的

依然可以通过函数的类型签名来读懂这个函数的含义。

但是总加括号难免读起来比较复杂,因为curry函数可以一次传入多个参数,不需要一个一个传。所以也可以这么解读:

  • 传入正则、String和另一个String类型,返回的还是一个String类型。

也可以这样:

  • 传入正则和一个String类型,返回一个函数,一个输入String类型并且返回String类型的函数。

我们依然可以一层一层的进行分析,去了解函数的含义。

Case 6

如果函数的参数中有函数呢?对吧,函数当然也可以当参数传入函数。

这里我们依然先用括号方法,加上括号

这里第一个参数传入的是一个函数,一个输入a类型 返回b类型的函数。

  • 传入一个函数,一个输入a类型 返回 b类型的函数
  • 返回一个函数,一个输入[a]类型 返回 [b]类型的函数
  • 再往返回的函数中传入[a]类型
  • 则返回[b]类型。

这里多解读一下,a 和 b 是什么类型呢? 是任意类型

  • a -> b 可以是任意类型的a 到 任意类型的b
  • a -> a 必须是同一种类型的a 到 同一种类型的a
  • a 与 b 的类型可以不同,也可以相同

是一种泛型代表,强类型语言一般都会有泛型概念。有了泛型概念之后,类型签名简直就像一字一句的告诉我们函数做了什么事情。

然而Hindley-Milner给我们的不仅仅只是上方那样的逐句解读。还记得第二条假设吗,我们回顾一下。

  1. 假设你知道天下雨,小明会带伞
  2. 并且假设你知道现在下雨了

最开始我们举的推理例子。 我们从类型签名的表面逐句解读了之后,映射的就是第一句话,至此我们知道了一个完整的假设。

接下来我们可以继续基于第一句话,进行第二句话的假设。

再看一遍上方的类型签名

// map :: (a -> b) -> [a] -> [b]

第一个传入的函数 (a -> b) ,其中的a是哪里来的呢? 我们不妨大胆假设一下:这里的a就是[a]的item。那我们回过头来再解读一下这个函数签名。

  • 第一个传入的函数的传入参数a 就是 第二个参数[a]的item
  • 那么 第一个函数的作用就是处理[a]的item(a)类型 返回b类型。
  • 函数最终返回的是[b]类型,
  • 所以我们推断出map函数的作用就是把[a]类型数组 处理 成 [b]类型数组的方法。
  • 怎么处理的呢?通过传入的第一个(a -> b)函数,逐个处理。

有了第二条假设后,我们就可以一步一步推断函数的作用,更加的清晰了,这就是最开始我们说的Hindley-Milner中的“推理”。

接下来我们再看一下难度更高一点的。

Case 7

reduce 可能是以上签名里让人印象最为深刻的一个,同时也是最复杂的一个了。我们试着解读一下

注意看 reduce 的签名类型,可以看到它的第一个参数是个函数,这个函数接受一个 b 和一个 a 并返回一个 b。那么这些 a 和 b 是从哪来的呢?很简单,签名中的第二个和第三个参数就是 b 和元素为 a 的数组,所以唯一合理的假设就是这里的 b 和每一个 a 都将传给前面说的函数作为参数。我们还可以看到,reduce 函数最后返回的结果是一个 b,也就是说,reduce 的第一个参数函数的输出就是 reduce 函数的输出。知道了 reduce 的含义,我们才敢说上面关于类型签名的推理是正确的。

Case 8

类型签名也可以把类型约束为特性的形状

如上图,在Typescript中interface接口的功能就是约束类型。其中,RaceResults类型约定了两个字段first,second,都是string类型的。

这时,a必须是符合RaceResults接口类型的类型。

不仅在Typescript中有类型约束的概念,在其他强语言中一般都有。而在函数式编程中也非常多的类型约束:

上述safeHead函数的签名的意思是,传入[a]类型 返回一个 Maybe(a)类型,其中的返回值就被约束住了,返回的值必须是Maybe(a)形状。

Maybe的具体实现我也贴在上面,方便大家理解。

有了类型约束之后,再写类型签名的时候就可以更好的区分逻辑模块,就像收纳一样,给读者留下了一条又一条指向真相的线索。一字一句的告诉我们函数那些不可告人的秘密。

END

Hindley-Milner是个伟大的创作,就像世界语,为了让不同国家的人彼此友好沟通,没有屏障,所以世界语被创造出来,虽然现在被英语代替(因为美金石油体系的扩张)。

而Hindley-Milner也一样,打破程序中的语言与语言之间的壁垒,让开发者彼此沟通的只有思想与逻辑。

在函数式编程中,每个函数都应该有一个类型签名。当然你可以用繁重的备注去代替它,没有问题的。因为我们最终的目的都是一致的。

技术无界

往期文章推荐