Skip to content

第七章:Hindley-Milner与类型系统

类型揭秘

若您初涉函数式编程领域,很快便会发现类型签名无处不在。类型作为元语言,能让不同背景的开发者高效沟通。这些签名大多采用'Hindley-Milner'系统编写——我们将在本章深入探讨该体系。

在纯函数领域,类型签名的表达能力远超自然语言。它们如耳语般揭示函数本质,用单行代码便传递行为与意图。我们能从中推导'自由定理',通过类型推断省却显式注解,既支持精细约束也兼容泛用抽象。它们不仅是编译时检查工具,更是最佳文档形式。由此可见,类型签名在函数式编程中的核心地位远超表面认知。

JavaScript虽是动态类型语言,但这不意味我们完全忽略类型。仍需处理字符串、数字等类型,只因语言层面缺乏集成而需开发者自行维护。借助注释表达类型信息,可有效弥补这一缺憾。

已有FlowTypeScript等JavaScript类型解决方案。本书旨在传授函数式编程精髓,故采用FP领域的标准类型系统。

神秘符号探秘

从泛黄数理典籍到技术白皮书,从技术博客到源码深处,Hindley-Milner类型签名无处不在。这套系统简洁优雅,但仍需学习方能领会其精妙。

js
// capitalize :: String -> String
const capitalize = s => toUpperCase(head(s)) + toLowerCase(tail(s));

capitalize('smurf'); // 'Smurf'

这里的capitalize接受String返回String。暂且忽略实现细节,重点观察类型签名结构。

HM系统中,函数写作a -> bab为类型变量。故capitalize可解读为'从StringString的函数':输入字符串,输出字符串。

更多函数签名示例:

js
// strLength :: String -> Number
const strLength = s => s.length;

// join :: String -> [String] -> String
const join = curry((what, xs) => xs.join(what));

// match :: Regex -> String -> [String]
const match = curry((reg, s) => s.match(reg));

// replace :: Regex -> String -> String -> String
const replace = curry((reg, sub, s) => s.replace(reg, sub));

strLength与前例同理:接受String返回Number

其他签名或使初学者困惑。临时技巧:始终视最后一个类型为返回值。故match可理解为'接受RegexString返回[String]'。但此时存在着值得深究的柯里化特性。

重构match的签名分组:

js
// match :: Regex -> (String -> [String])
const match = curry((reg, s) => s.match(reg));

通过括号显式分组后可见:该函数接受Regex参数,返回'从String[String]'的函数。柯里化机制使之成立——传入Regex后获得等待String的新函数。虽然不强制要求这种理解方式,但掌握返回类型原理至关重要。

js
// match :: Regex -> (String -> [String])
// onHoliday :: String -> [String]
const onHoliday = match(/holiday/ig);

每个参数依次解构签名前缀。onHoliday即已持有Regex参数的match实例。

js
// replace :: Regex -> (String -> (String -> String))
const replace = curry((reg, sub, s) => s.replace(reg, sub));

观察带完整括号的replace可见,冗余符号影响可读性,故通常省略。若选择同时传参,简洁理解为:replace接受RegexStringString,最终返回String

最后补充要点:

js
// id :: a -> a
const id = x => x;

// map :: (a -> b) -> [a] -> [b]
const map = curry((f, xs) => xs.map(f));

id恒等函数接受任意类型a并返回同类型值。类型变量如代码变量般使用,命名虽惯用a/b但可任意替换。相同变量名必须对应相同类型——重要规则重申:a -> b允许任意类型转换,而a -> a必须同类型(如String -> String成立,String -> Bool非法)

map同样使用类型变量,并引入新类型b。读作:map接受a -> b转换函数和a数组,返回经转换后的b数组。

如此精妙的类型签名直指函数本质:给定a -> b转换与a数组,必对每个元素执行转换得到b数组。任何其他行为都将违背签名承诺。

掌握类型推演能力令您在函数式世界游刃有余。不仅能提升技术文档理解力,类型签名本身便是最佳讲师。反复练习阅读技巧,海量信息将触手可及。

更多案例供自主研习:

js
// head :: [a] -> a
const head = xs => xs[0];

// filter :: (a -> Bool) -> [a] -> [a]
const filter = curry((f, xs) => xs.filter(f));

// reduce :: ((b, a) -> b) -> b -> [a] -> b
const reduce = curry((f, x, xs) => xs.reduce(f, x));

reduce或许是表现力登峰造极之作。初学者可能感到棘手,实则纸面推敲其签名最具启发性(文内尝试解释,仍建议自行探索)。

观察签名特征:首参是接受ba返回b的函数。参数来源于后续的b初始值与a数组。最终输出b暗示:最终迭代结果即为返回值。结合reducer工作原理,可知推论准确无误。

可能性收束

引入类型变量时,将产生参数化多态特性:函数必须统一作用于所有类型。举例分析:

js
// head :: [a] -> a

查看head[a] -> a签名:除数组结构外,对a一无所知。此时函数只能操作数组形态(如获取第一个元素,而非处理元素值本身)。参数化多态强制函数在所有类型上表现一致。

再观此例:

js
// reverse :: [a] -> [a]

仅凭reverse[a] -> [a]签名,可知其无法改变元素类型,无法执行排序(需比较器),但可进行统一规则的重排(如倒序)。参数化多态极大限定了函数的行为可能性。

此类约束使Haskell的Hoogle搜索引擎等工具能精准检索目标函数,足见类型信息的强大效力。

自由定理之道

除推导实现外,类型推演还能产生自由定理。以下案例引自Wadler论文

js
// head :: [a] -> a
compose(f, head) === compose(head, map(f));

// filter :: (a -> Bool) -> [a] -> [a]
compose(map(f), filter(compose(p, f))) === compose(filter(p), map(f));

无需代码即可得定理。首定理表明:对数组head应用f,等价于先map(f)再取head(且前者效率更优)。

看似常识,但计算机需形式化方法实现优化。数学将直觉转化为严谨逻辑,在代码领域尤为重要。

filter定理同理:组合fp进行过滤检测后应用f(注意filter不修改元素),等价于先map(f)再根据p过滤结果。

该推演适用于所有参数多态签名。JavaScript中可通过重写规则或compose函数实现类似优化,展现无限可能。

类型约束机制

最后说明如何通过接口约束类型:

js
// sort :: Ord a => [a] -> [a]

此处a必须实现Ord接口(即具备可排序能力)。这类类型约束既说明函数能力,又限定类型范围。

js
// assertEqual :: (Eq a, Show a) => a -> a -> Assertion

本例的EqShow双约束确保可比较元素并显示差异。

后续章节将通过更多案例深化此概念。

本章小结

Hindley-Milner类型签名在函数式领域无处不在。虽入门容易精进难,但掌握其解读技巧将开启新境界。自本章起,每行代码均附加类型签名注解。

第八章:容器之道