Standard ML快餐教程(1) - 初识第一次见到 Standard ML简称 SML这个老家伙多数人的反应是“啥还有这种语言”然后瞄一眼代码看到一大堆val、fun、case和长得像数学作业的声明扭头就想跑。但如果你耐着性子坐下来用一杯咖啡的时间把它最基本的几个概念摸清楚你可能会发现这门 1980 年代出生的语言在类型系统和模块化的思路上比现在不少热门语言都要 Modern。这篇教程我不打算写成一板一眼的语言参考手册。我假设你是那种写过几年代码、熟悉一两种主流语言、看到“函数式”三个字就想尝尝鲜的开发者。咱们直接上手用最快的方式把 SML 的骨架搭起来看看它到底是怎么思考问题的以及——更重要的是——它跟咱们天天用的 C、Java、Python 到底差在哪。1. 先搞清楚 Standard ML 是个什么物种1.1 一门“被定义出来”的语言SML 是一个相当特殊的语言家族成员。它出身于学术界目标是做一门“适合用来做形式化验证、编译器设计、以及编程语言研究”的语言。别被“学术”两个字吓跑它强就强在设计严谨有官方规范叫《The Definition of Standard ML》编译器实现必须严格按这本规范走。这是什么概念类比一下你定义一个新的 JSON 格式随手写个文档大家看着差不多就行。但 SML 直接把自己做成了“标准件”类型系统有数学定义语义有形式化描述程序的行为在规范层面就是确定下来的。所以哪怕你在不同机器上、用不同编译器跑同一段 SML 代码得到的结果应该完全一致。这一点在现代语言里非常罕见也让它成了研究编程语言的人绕不开的参考系。1.2 ML 家族和它的近亲们ML 是 MetaLanguage 的缩写最早是作为某个定理证明工具的命令语言出现的。后来家族不断壮大往一个方向走变成了 OCaml另一个方向走变成了 Haskell还有一个分支就是 Standard ML。现在你去看 Rust 的 enum 加模式匹配看 Swift 的 optional 绑定看 TypeScript 的 discriminated union这些设计里都能嗅到 ML 的味道。所以学 SML 不是学一门“用不上的古董语言”它更像是去读一本“编程语言设计原理”的图解原典。你把 SML 的思维模式吃透了再回头看你平时写的语言很多“为什么这么设计”的迷惑突然就解开了。2. 环境搭建十五分钟跑起来2.1 选编译器SML/NJSML 有好几个实现但最适合入门的是 SML/NJSML of New Jersey。它开箱即用自带 REPL错误提示相对友好并且一直在维护用来学习毫无压力。另一个出镜率较高的实现是 MLton它偏重整体编译优化能编出性能很强的可执行文件但对新手来说启动慢、调试信息不够直接所以入门阶段建议优先 SML/NJ。在某个我参与过的短期研讨会上我们统一用 SML/NJ 跑教学示例。中间有同学换到 MLton 跑结果报错风格完全不同折腾了一阵子又换回来了。这个经验供你参考。2.2 安装步骤速记Windows 用户直接从官网下载安装程序就可以装完之后要把 SML/NJ 的 bin 目录加进 PATH这样在命令行里敲sml才能启动。macOS 用户如果装了 Homebrew一行命令就能解决brew install smlnjLinux 用户用各自发行版的包管理器例如 Debian/Ubuntu 系sudo apt install smlnj装好之后在终端输入sml看到类似Standard ML of New Jersey v110.x [built: ...]的欢迎信息再出现一个-提示符那恭喜你环境已经通了。2.3 REPL 怎么用脚本怎么跑SML/NJ 的 REPL 挺有意思提示符是-每次输入一个表达式或声明回车后它会回显结果再附加一个val it ...给你看。这个it是上一轮结果的默认名字这个机制在跳着做实验时很好使但你要是写正式的声明代码还是建议开一个.sml文件用use指令载入use hello.sml;注意末尾这个分号它在 REPL 里表示“我的输入到此结束”如果你漏了REPL 会一直等你继续输入看起来像卡死了一样。我印象里第一次跑 REPL 的人十个有九个会在这里懵一下。脚本文件本身每一条声明写完之后用不用分号都可以但载入时use file.sml;后面的分号建议保留。3. 从变量到函数SML 的基本语法初体验3.1 绑定不是赋值SML 里定义一个值用val比如val x 42; val y x 1;看起来像变量赋值其实完全不是一套逻辑。这里val引入的叫作“绑定”binding它把名字x和值42绑定到一起之后x永远是42。你不能在后面的代码里给x重新赋一个别的值。想改只能另起一个名字或者引入一个新的作用域。第一次写的时候真的会被这个设定憋到内伤。习惯了 Java、Python 的“变量就是容器想换就换”到这儿突然变成了“名字是贴纸贴上就撕不下来”。但正是这种约束逼着你换一种思路不要想着“状态怎么变化”要想“值怎么流过去”。3.2 函数一切都是声明式SML 里定义函数用fun关键字格式非常接近数学里写函数的方式fun double x x * 2;这里double接收一个参数x返回值就是x * 2。调用它不用括号double 21; (* 得到 42 *)如果函数有多个参数不需要逗号空格分隔即可fun add a b a b;这带来的一个直接感受是SML 的函数定义非常“干净”。没有{}、没有return、没有语句块的分隔符。函数体就是一条表达式。这跟 C 系语言那种“函数是一个过程”的心智模型差别巨大。3.3 递归代替循环既然绑定不可变SML 里自然也没有for、while这种循环语句。那它怎么实现“重复做一件事”答案是用递归。比如算 1 加到 nfun sum n if n 0 then 0 else n sum (n - 1);这段代码读起来是sum 0的结果是 0否则sum n等于n加上sum (n-1)。有没有看到一层“数学归纳法”的味道这就是函数式语言的日常。递归在这里不是炫技工具它就是最普通的表达式。如果 n 比较大普通递归会有栈溢出的风险所以 SML 编译器会做尾调用优化。只要你把递归调用写成函数体的最后一步编译器就能把它优化成迭代。例如上面的sum就不是尾递归因为最后一步是加法。想写成尾递归版本可以引入一个累加参数fun sum_tail n acc if n 0 then acc else sum_tail (n - 1) (acc n);这种写法的好处是调用自身时不需要保留当前栈帧不管 n 多大都不会爆栈。习惯之后你会发现递归写起来甚至比循环更直白因为它把“当前状态”显式地通过参数传递了。4. 模式匹配一门语言的灵魂时刻4.1 用形状说话SML 里最带感的内容之一就是模式匹配。它的作用是用数据的“形状”来控制流程。假设要写一个函数判断列表是不是空fun isEmpty [] true | isEmpty (_ :: _) false;注意这里没有if。第一个分支说“如果列表是空表[]结果是 true”第二个分支说“如果列表长成x :: xs这样——也就是有头部元素和尾部——结果为 false”。用下划线_表示“我不关心这个位置是什么”。整个函数就是两条规则像查表一样清晰。你可以把模式匹配理解为“给数据做形状检测的分支语句”程序一看进来的是什么形状自动跳到对应的分支执行不需要你手写一堆if (list.length 0)之类的判断。4.2 匹配一个完整的对象模式匹配不只能用在列表上它能对任意数据类型做解构。看这个例子用op或者自定义类型来表示二维平面上的点datatype point Point of int * int; fun xCoord (Point (x, _)) x;这里Point是一个构造器它把两个整数打包成一个point类型的值。xCoord函数通过模式Point (x, _)把第一个坐标解出来。整个流程非常接近于“拆盒子”你是通过模式告诉 SML 这个盒子的长相连结是什么。这比 C 里手动访问结构体字段、或者在 Java 里写一堆 getter 要直接得多。你直接从“形状”上提取你需要的那部分。类型安全的解构再配合编译器帮你检查是否所有情况都覆盖到了组合起来相当爽。4.3 一个更“实际”的例子模式匹配不只是在理论课上好看。想象你在写一个 HTTP 状态码的分类函数fun describe 200 OK | describe 404 Not Found | describe 500 Internal Server Error | describe _ Unknown;你看这本质上就是一种“按值分发”的高可读性写法。后面再配一个错误处理类型就能把正常路径和异常情况在类型层面分开比日志里塞字符串、跑挂了才回头看堆栈舒服多了。5. 类型推导与不可变数据和主流语言的几个关键差异5.1 类型是你的安全网SML 是静态类型语言但它几乎不需要你写类型标注——编译器会自动推导出每个表达式、每个函数的类型。比如fun square x x * x;SML 会告诉你说square的类型是int - int就是接收一个整数返回一个整数。如果哪段代码类型对不上编译阶段就会报错根本不会跑到运行时才崩。这在实践里非常重要。相当于有位严格的考官在代码运行前把所有“形状对不上”的问题全部找出来。等你习惯了这种体验再回去写 Python 这种动态类型语言经常会心里发慌——尤其在重构的时候不知道改了这一处会不会把别的地方搞炸。类型推导用生活类比来解释会容易很多它像是拼图时每一块积木的形状都已经定死你不能把圆形塞进方孔。SML 会在你拼到一半的时候就告诉你“这里塞不进去”而不是等你把所有积木压在一起最后整个结构歪掉才发现问题。5.2 不可变数据以及它带来的连锁反应SML 里的绑定和数据结构默认都是不可变的。你可以创建一个新的列表并让它指向旧列表的后半截比如val xs [1, 2, 3]; val ys 0 :: xs;这里的ys是[0, 1, 2, 3]而且它对xs里的内容是共享的不是复制了一份。因为大家都不能改数据所以共享完全安全也无需做深拷贝。这对内存和多线程收益很大也是函数式语言普遍推崇的设计。我第一次用的时候不太适应的一点是不能“修改”意味着如果你想给某个数据结构里面的一个字段增值你得“新建”一个带上新值的副本。写多了之后发现这其实让数据流的走向更清晰了——一段数据从那头进去到这头出来每一步都产生新的值调试的时候就一条直线可以追。5.3 SML 和主流语言对照表维度Standard MLC/Java 系Python绑定/变量默认不可变默认可变默认可变循环用递归实现for/while 直接支持for/while 直接支持分支流程模式匹配为主if/switchif/elif/字典分发类型检查编译期静态检查自动推导编译期C 靠显式标注运行时动态检查函数一等公民可作为参数和返回值C 有函数指针Java 用接口包一层一等公民副作用可有但需要显式表达默认操作的都是副作用到处都有副作用这张表不是要分出谁高谁低而是要说明一点SML 是“少即是多”的思路。它把可变状态、隐藏依赖这些容易引发心智负担的能力尽量收起来换来的是简单、可靠、可推理的代码行为。你在一门语言里能写的花样少了但你能写出的正确代码比例往往上去了。6. 一个完整的小例子列表求和与统计来看一个能直接跑的、稍微综合一点的例子。假设我们要统计一个整数列表里各元素的平方和同时统计列表中正数的个数。fun squareSum [] 0 | squareSum (x :: xs) x * x squareSum xs; fun countPositive [] 0 | countPositive (x :: xs) if x 0 then 1 countPositive xs else countPositive xs;这两段代码都用到了模式匹配空列表返回基本值非空列表拆出头元素x和尾部xs递归处理尾部。注意squareSum和countPositive是两条独立的函数如果列表很长它们会各自遍历一遍列表。如果希望只遍历一次可以写成同时返回两个结果的函数fun stats [] (0, 0) | stats (x :: xs) let val (sum, count) stats xs in (sum x * x, count (if x 0 then 1 else 0)) end;这里let ... in ... end是 SML 里引入局部绑定的方式。它允许你在函数内部给中间结果起名字。这个例子在 REPL 里跑一下stats [1, ~2, 3, 4];注意负数在 SML 里要写成~2不是-2。这是很多新手第一个踩的小坑。-是二元减法运算符负号是~包括在 REPL 里算~5 3你要是写成-5 3编译器会直接报语法错误。跑完之后你会得到(30, 3)前一项是1*1 3*3 4*4 1 9 16 26等一下这里我写的是sum x * x所以得按顺序算1^2 (-2)^2 3^2 4^2 1 4 9 16 30正数个数是 3。结果就是(30, 3)。这个例子的价值在于它展示了let表达式如何让我们在不可变的世界里依然能做“多步计算”。你想串行处理数据不需要引入一个一直在变的变量而是通过层层嵌套的绑定把前一步的输出接到后一步的输入。7. 实操心得新手上路的几次“顿悟”与“翻车”7.1 烦恼之源运算符优先级和括号SML 的函数调用是并置说白了就是f x不需要括号。但这带来一个反直觉的问题f x y不是(f x) y而是先计算f x然后把结果当作新函数继续接收y。这就是所谓的柯里化Currying。大多数情况下这是很方便的但它也意味着有些在别的语言里随手写的式子到了 SML 里会因为“函数结合优先级”出现意想不到的结果。我印象最深的一次是写一个类似“过滤出大于 2 的数”的表达式随手写成了filter (fn x x 2) [1, 2, 3, 4];这个没问题。但如果我少写一个小括号写成filter fn x x 2 [1, 2, 3, 4];编译器直接不认。SML 里匿名函数fn x ...的右侧必须是一个单独的表达式如果后面还要接别的参数括号不能省。这类语法细节建议一开始就多写几遍理解“并置是左结合”这条规律能避开很多“我看不出哪错了”的深夜困惑。7.2 报错读法先看类型再看具体位置SML/NJ 的报错不算特别友好尤其是涉及模式匹配缺分支或类型不匹配的时候。但它的规律很固定常见的错误有两类一类是“found 某种类型”和“expected 另一种类型”不一致另一类是某个模式没有覆盖完所有情况。我自己的排查方式是先把出错的函数体拆成最小可复现单元比如把参数改成明确可推导的类型然后一步步加回去。这样既缩小了排查范围也让我被迫重新思考每一个接线点是否真的对得上。编译器虽然严格但它的要求背后都有逻辑顺着它的提示去补全类型或者分支通常很快就能定位问题。7.3 实战中的“降级”策略先用简单结构再做抽象刚开始用 SML 写真实题目的时候很容易陷入一个思维误区想一口气写出最抽象、最通用的函数。但 SML 的形式化特征决定了你一旦把抽象层次拉太高编译器对你的类型要求也会水涨船高新手很容易被自己的抽象绕进死胡同。我现在的习惯是第一版先用最粗糙的方式写出来哪怕全部是if和显式递归也无所谓目标是让程序先跑通。然后再看哪些重复模式可以被模式匹配抽出来哪些参数可以封装成一个记录类型最后才考虑用函数参数去表达“策略”。这个过程有点像写英语草稿——先确保表达清楚再慢慢打磨修辞。SML 的类型系统在这个过程中相当于一面镜子它会告诉你哪些抽象干净、哪些抽象牵强。8. 常见问题速查表我在带过几个想入门 SML 的朋友之后发现大家踩的坑高度相似。整理成表格方便你直接定位。现象原因解决办法REPL 输入回车没有反应语句末尾没有分号REPL 在等你继续输入补上;再回车写-1报语法错误SML 里负数要用~1改用~1作为负数字面量函数说“在模式匹配里缺少分支”case或者多分支函数没有覆盖所有可能输入补一个兜底模式或者显式处理每一种形状类型不匹配但不清楚哪一步错了某处表达式结果类型跟预期不一致把大表达式拆开逐个绑定到val上再一个个查递归调用爆栈不是尾递归引入累加参数让递归调用成为最后一步字符串拼接符号写成了SML 里字符串拼接是^写a ^ b而不是a b用判等报错SML 的相等比较是单个等号用if x 0不是if x 0调用自己写的库函数时找不到名字源文件没加载到当前环境在 REPL 里use file.sml;重新载入这张表基本覆盖了我见过的新手 80% 的问题。剩下 20% 多半是手滑写错了变量名或者半角全角符号混用这些只能靠编译器提示慢慢对。9. 结语先别扭一阵子再回头看收益Standard ML 对多数人来说不是一门“学了立刻能用来上班”的语言它的价值更多在认知维度逼你换一种编排程序的方式逼你把“状态”当作显式传递的参数而不是随手藏在某个全局变量里。这个过程前期会有明显的不适感——就像用了十几年右手写字突然让你换左手写得又慢又丑。但好在 SML 的语法很短类型系统又稳你只要把 val、fun、模式匹配、递归这几块拼图装进脑子很快就能从“这语言怎么反人类”变成“原来程序还可以这样想”。我个人的体会是学完 SML 再回去看 Rust 的枚举和模式匹配、看 Go 的接口设计、看 Python 的装饰器和高阶函数会有一种“见山又是山”的熟悉感。很多之前只是“知道能用”的特性终于变成了“知道为什么这么设计”的理解。而且 SML 没有什么工业级的历史包袱它就像一个纯净的函数式实验室你做实验时不需要关心框架版本兼容、环境变量、构建工具链这些噪音只要专注在算法和类型本身。如果你看完这篇手痒想写两段代码试试我建议别急着找项目先把 REPL 打开随便定义几个函数用模式匹配解构几个列表感受一下“编译器替你挡住低级错误”的快感。十分钟之后你会发现这门老语言其实相当贴心。它只是不给你那些“偷偷改状态”的甜蜜陷阱罢了。