如果你玩过 Rust 的 match,被 TypeScript 的泛型推断救过命,那你其实早就在消费 Standard ML 留下的技术遗产。我最早接触到 SML,是在一门程序设计语言原理课上,第一反应是"这年头谁还用这么冷门的语言",结果课上到一半,它成了唯一让我主动装回自己电脑继续折腾的课题。原因很简单:SML 语言本身体积很小,但它在"编译器帮你找错"这件事上的设计深度,放到今天依然能打。这篇是 Standard ML 快餐教程的第一篇,主题就是初识。我会带你装好编译器、进入 REPL,用几十个可以直接粘到终端里跑的小例子,把类型推断、表达式、函数定义、模式匹配这四个核心概念一次性过一遍。没接触过函数式编程的读者完全能跟上,有 Haskell 或者 OCaml 经验的人,也可以把它当成一次快速温习语法的机会。SML 的语法极少,和"快餐"二字很搭,不用等出师,读完这篇你就能像看普通代码一样读别人写的 SML。
1. 先搞明白:SML是什么,现在学它还有没有性价比
1.1 一个来自上世纪的小而美
Standard ML,通常缩写为 SML,是一门通用的函数式编程语言。它诞生于上世纪七十年代末到八十年代初的欧洲学术界,核心目标是"让程序设计语言能被可靠地分析和推理"——这句话落到实际,其实就是:SML 的设计者极度看重可靠性,凡是能交给编译器静态检查的东西,绝不留给运行时去猜。跟同一时期或者更晚出现的很多语言相比,SML 显得非常克制:没有复杂的继承体系,没有运行时反射,连循环关键字都没有。这种克制在几十年后反而变成了优点——语言规模小,学起来成本极低。
ML 不是一门语言,而是一个家族。今天市面上常见的 OCaml、F#,以及各种论文里高频出现的 ML 方言,往上追溯都有共同的祖先。SML 是这个家族里被标准化过的一支,语法规范在九十年代重新修订后基本定型,后面几乎没有再大规模变动。对教程读者来说,这反而是个好消息:你完全不用担心"学的版本会过时",十几年前写的 SML 代码今天大概率还能原样编译运行。在技术圈追新追到审美疲劳的当下,这种"稳定得无聊"反而是一种奢侈。
1.2 学SML的三笔实际回报
先说说它到底能换回什么,不然快餐吃不下去。
第一,它是理解现代类型系统的捷径。Rust 的模式匹配、Kotlin 的 when、Scala 的偏函数、TypeScript 的自动推断,这些你现在可能每天在用的特性,绝大多数都能在 SML 里看到更原始、也更清爽的形态。在 SML 里被类型系统拦住一次,比你读十篇"为什么类型安全重要"的博客都管用。很多概念我在别的语言里半懂不懂,是在 SML 里真正想通的。
第二,它能有效矫正"万物皆可变"的编程直觉。SML 的变量一旦绑定就不能改,循环没有,状态全靠递归和函数参数传递。很多从命令式语言过来的朋友会觉得别扭,但这种"别扭"恰恰逼人养成了更纯粹的函数式思考方式,回头写 Python、Go 时,你会发现自己更愿意把副作用控制在局部,代码反而更好测了。
第三,生态虽然不大,但质量稳定。SML 的规范和标准库都很稳定,网上公开的教材、论文代码大多可以原样运行。快餐教程的读者最怕的是例子跑不起来,在 SML 这里,这个概率很低。我把红底白字的错误信息也放在了后面几节,那些才是初识阶段真正值得反复看的内容。
2. 环境准备:十分钟把SML跑起来
2.1 编译器怎么选
目前主流的 SML 实现主要就两款值得记。
- SML/NJ:教学和入门首选。命令是
sml,REPL 体验好,错误信息直接、带位置提示,站在新手角度,这是最不容易劝退的选择,很多高校的课程默认就是它。 - Poly/ML:命令是
poly,以稳定和性能见长,常被一些需要长期运行的工具链选作底层运行时。语法和 SML/NJ 完全一样,只是 REPL 提示符和部分细节不同。
如果只选一个,我建议无脑 SML/NJ。等你看完这篇,真想拿 SML 去写常驻服务或更大的项目,再考虑 Poly/ML 也不迟。两款都是免费开源实现,安装没有门槛,不会让你在第一步就卡住。
2.2 安装与验证
以 SML/NJ 为例,常见三类环境这样装。
macOS,假设你已经装了包管理器:
brew install smlnj smlDebian / Ubuntu 系列的 Linux:
sudo apt install smlnj smlWindows 用户:直接去官网下载安装包,安装完成后把安装目录下的 bin 路径加入系统 PATH,然后在 cmd 或 PowerShell 里运行sml。如果你之前装过 WSL,直接在 WSL 里用 Linux 方式装更省心,体验也更好。
装好之后,终端会进入一个以-开头的 REPL 提示符。敲下面这行再回车:
- 1 + 2;你会看到:
val it = 3 : int这里的it是 SML 给未命名表达式结果自动起的名字,: int是类型标注。能走到这一步,环境就算通了。后面所有示例,你都可以照着这个模式在 REPL 里验证。
2.3 REPL和脚本文件的工作流
初学阶段我推荐"REPL 为主、文件为辅":三五行的验证直接在 REPL 里敲,超过十行的练习就写进.sml文件,再用use命令加载:
- use "hello.sml";文件内容可以非常简单:
val greeting = "Hello, SML"; print (greeting ^ "\n");注意这里我用了^做字符串拼接,这是 SML 的字符串运算符,不是+。这个细节很多人第一次都会踩,第 7 节避坑部分我还会专门讲。
3. 类型推断:SML最值得上的招牌菜
3.1 编译器"边读边猜"是怎么回事
绝大多数语言处理类型的方式可以分成两派:一派要求你显式标注每个变量和函数的类型,像早年 C、Java 的主流风格;另一派是让编译器根据上下文自动推导。SML 走的是后者,而且是走得最彻底的那一档:你几乎可以不写任何类型标注,编译器自己把类型算出来。
用一个生活化的类比:你不需要告诉编译器"我手里拿的是一个苹果",你只要说"把这个苹果交给收银台",编译器看到"交给收银台"这个操作要求参数里面有价格信息,它就会自己推断"苹果应该有价格属性"。推断不出来的时候,它就告诉你哪里对不上了。
这套机制在类型系统里有个响当当的名字,叫 HM 类型推断,是上世纪七十年代末的研究成果。你不需要背这个名词,但理解它的存在,能帮你看懂后面所有 REPL 输出的类型签名。
3.2 从REPL里读出类型信息
刚才的1 + 2输出val it = 3 : int。再看几个:
- val name = "sml"; val name = "sml" : string - [1, 2, 3]; val it = [1, 2, 3] : int list - (1, "one"); val it = (1, "one") : int * string最后一个圆括号里是元组,类型写成int * string。列表类型是int list,元组类型用*分隔,这两种写法是 SML 新手最先需要习惯的语法。
真正体现推断能力的是函数:
- fun inc n = n + 1; val inc = fn : int -> int我没写任何类型标注,编译器看到n + 1,知道+要求两个 int,于是自动认定n是 int,函数返回 int。再看一个更夸张的:
- fun swap (a, b) = (b, a); val swap = fn : 'a * 'b -> 'b * 'a这里出现了'a、'b,它们叫类型变量,意思是"可以是任意类型"。swap接收一个二元组,把两个元素调换后返回。编译器不关心具体类型,它只保证关系一致:换进去的是'a * 'b,换出来就必须是'b * 'a。这就是参数化多态,现代语言里到处都有,SML 是它的早期形态。
3.3 类型错误:编译器的"对不上账"
初学阶段你一定会大量遇到类型错误。别烦,SML 的错误信息其实是同类语言里比较友好的。经典场景:
- 1 + "sml";你会看到类似下面的输出:
stdIn:1.1-1.8 Error: operator and operand don't agree [literal] operator domain: int * int operand: int * string expression: 1 + "sml"翻译一下:+这个运算符要求两个 int,你却给了 int 和 string,账对不上。信息里的operator domain和operand是重点。你可以把这条规则理解成"这边要求什么 vs 你实际给的是什么"。绝大多数 SML 类型错误都能靠这一条准则定位:先看 operator domain,再看 operand,中间对不上,就是问题根源。第 7 节我还会用具体例子再说一遍读法。
4. 第一轮语法冲击:一切皆表达式
4.1 SML里几乎没有"语句"
如果你从 C、Python、Java 切过来,会立刻发现一个反常现象:SML 里没有传统意义上的语句(statement),几乎所有东西都是表达式(expression)。两者的区别在于:表达式有返回值,语句只是"执行动作",本身没有值。
举例来说,C 语言里int x = 1;这条语句本身没有值,它只是把1塞进x。但 SML 里val x = 1这个绑定的右侧1是表达式,整个val声明把名字x和值1绑定在一起。类似的,if在 C 里是语句,在 SML 里是表达式:
- val result = if 3 > 2 then "yes" else "no"; val result = "yes" : stringif...then...else整体是有值的,这个值就是两个分支中被选中的那个。这种设计让代码里少了很多零碎的赋值语句,逻辑更接近数学表达式的写法。刚开始不习惯,但写几天后你会爱上这种"每个结构都返回值"的一致性。
4.2 val绑定与不可变性
val是 SML 里最重要的声明关键字,用来把名字绑定到值。注意我用的是"绑定"而不是"赋值",背后的差别极其关键:SML 的绑定是不可变的,一旦val x = 42,在这个作用域里x就永远是 42,不存在"改 x"这种操作。
很多初学函数式编程的人会问:变量都不能改,怎么写程序?答案是:用新名字。SML 允许你遮蔽(shadow)旧名字:
- val x = 1; val x = 1 : int - val x = x + 1; val x = 2 : int第二个x是新的绑定,它先读取旧x的值加 1,再把自己绑定为新名字。旧的那个x并没有被修改,只是在当前作用域里暂时被遮住了。这种"创造新值,而不是修改旧值"的哲学,就是函数式编程的地基,也是后面理解不可变数据结构、并发安全这些话题的前提。
4.3 let表达式与局部作用域
如果不想在最外层污染命名空间,就用let...in...end创建局部绑定:
val result = let val base = 10 val doubled = base * 2 in doubled + 5 end;这里base和doubled只在let内部可见,in之后的表达式就是整个let表达式的值。let也可以嵌套,这在函数体内部尤其常用,后面写递归函数时你会频繁遇到它。第一次看到三段式关键字别慌,它的逻辑就是"准备变量、用它们算结果、收工"。
5. 函数与模式匹配:扔掉if-else的拐杖
5.1 fun子句:像数学定义一样写函数
SML 定义函数用fun,基本形式:
fun square x = x * x;类型是int -> int。这是"单子句"函数。真正体现 SML 美感的是"多子句"函数——同一个函数名,按参数形态写多个定义分支:
fun greet "morning" = "早上好" | greet "evening" = "晚上好" | greet _ = "你好";当参数是"morning"时走第一个分支,"evening"时走第二个分支,其他任何值走第三个分支。这里的_是通配符,表示"不关心具体是什么"。这种写法把条件和返回值一一对应,读代码时一目了然,比一长串if...else if...else清爽太多。匹配分支会按从上往下的顺序尝试,所以最具体的分支要写前面,兜底分支放最后。
5.2 模式匹配:拆解数据结构的利器
模式匹配是 SML 最核心的设计之一,它不只是分支选择工具,更是一种"按形状拆数据"的语法。以元组为例:
fun area (width, height) = width * height;参数(width, height)本身就是一个元组模式。调用area (3, 4)时,SML 自动把3绑定到width,4绑定到height。你不需要像某些语言那样写.width、.height之类的访问器,模式匹配直接替你完成解构。
如果想在函数体内的任意位置做匹配,用case...of表达式,它可以出现在任何需要值的地方:
fun describe number = case number of 0 => "zero" | 1 => "one" | n => "many: " ^ Int.toString n;这里n是变量模式,会匹配任何前面分支没接住的值。SML 会检查分支是否覆盖了所有可能情况,有漏掉的话编译器会直接给 warning,这个"强迫你考虑完整"的机制相当舒服,能帮你提前发现不少逻辑漏洞。
5.3 递归:SML里没有循环
SML 没有for和while,重复计算全靠递归。这是函数式语言最让新手劝退的地方,但也是训练抽象能力最好的地方。经典阶乘:
fun factorial 0 = 1 | factorial n = n * factorial (n - 1);第一个分支factorial 0 = 1是递归终止条件,第二个分支是递归步骤。写递归的要点就两件事:找对出口,写对递推式。你可能会担心递归太慢、会爆栈,初学阶段的数据规模完全不用担心;等学到后面的部分我再专门讲尾递归优化。这段代码已经足够展示 SML 的核心套路:用模式匹配找出口,用递归表达重复。
也可以把let和递归结合,写一个"局部辅助函数"的常见结构:
fun sumList nums = let fun helper [] acc = acc | helper (x :: rest) acc = helper rest (acc + x) in helper nums 0 end;这里的acc是累加器参数,这种写法的好处是每一步的结果都作为参数传给下一次递归,是尾递归的雏形。第一次看不懂没关系,先记住:列表从左往右"啃",每啃下一个头元素,就把结果累积到acc,剩下的继续递归。这个思路后面讲fold时你会再见一次。
6. 列表、元组与一学就会的高阶函数
6.1 列表的构造与类型
列表是 SML 最常用的数据结构,写法沿用函数式语言的传统:空列表是[],往头部加元素用::(读作 cons):
val empty = []; val one = 1 :: []; val three = 1 :: 2 :: 3 :: [];1 :: 2 :: 3 :: []和[1, 2, 3]完全等价,后者只是前者的语法糖。列表的所有元素必须是同一类型,所以[1, "two"]会直接报类型错误。类型写法是int list、string list这种"元素类型 + list"的形式。
取头部元素时,用模式匹配比用内置函数更安全。内置的hd和tl在空列表上会抛异常,而模式匹配可以把空列表的情况也写进分支,让编译器逼着你处理:
fun firstOrZero [] = 0 | firstOrZero (x :: _) = x;这个函数永远不会崩,因为空列表的情况被显式处理了。能用模式匹配解决的事,就尽量少依赖那些"半总会失败"的辅助函数,这是 SML 写代码的一个隐藏风格准则。
6.2 元组与列表:一种类型一个形态
元组和列表的区别很直白:元组的长度固定、元素类型可以不同;列表的长度任意、元素类型必须相同。类型写法上元组用*分隔元素类型:
val pair = ("sml", 42); (* string * int *)模式匹配在两者上同样好用。尤其是在函数参数上,元组能帮你把多个参数打包传递,顺便让类型签名更清晰:
fun distance (dx, dy) = Math.sqrt (Real.fromInt (dx * dx + dy * dy));这里(dx, dy)是一个元组模式,distance的类型是int * int -> real。你不需要记住Math.sqrt、Real.fromInt这些函数的具体名字,只要知道"整数往实数转换再开方"这个流程即可,用到时查标准库效率更高。
6.3 map/filter/fold:把遍历交给函数
高阶函数是"接收函数作为参数"的函数。SML 里最常用的三个列表高阶函数是List.map、List.filter、List.foldl。它们的思路比手写递归更高一层:你只告诉它"对每个元素做什么",遍历过程由它内部包办。
val squares = List.map (fn x => x * x) [1, 2, 3, 4, 5]; (* [1, 4, 9, 16, 25] *) val evens = List.filter (fn x => x mod 2 = 0) [1, 2, 3, 4, 5, 6]; (* [2, 4, 6] *) val total = List.foldl (fn (x, acc) => acc + x) 0 [1, 2, 3, 4, 5]; (* 15 *)其中fn x => ...是匿名函数,相当于临时写给map的"单元素处理规则"。foldl稍微难懂一点:它把列表一个元素一个元素地"折叠"进初始值0,每次折叠调用fn (x, acc) => acc + x,把当前元素加到累计值上。你可以把它理解成"从左往右扫过列表的循环,循环体是一个函数",只不过循环变量变成了显式的参数。
顺便记住一个语法技巧:运算符前面加上op前缀就能当普通函数使用,所以上面的foldl可以简写得更干净:
val total = List.foldl (op +) 0 [1, 2, 3, 4, 5];三个函数组合起来,可以写出极紧凑的代码。比如统计一个整数列表里有几个偶数:
fun countEvens nums = List.length (List.filter (fn x => x mod 2 = 0) nums);这行代码没有循环变量,读法就是"过滤出偶数,数一下个数"。养成这种组合式思考之后,再回头看到处都是for循环的代码,反而会觉得绕。
7. 初学阶段最常踩的五个坑
7.1 分号:REPL与文件的规则不一样
REPL 里每个声明后面都要跟分号,分号是"告诉 REPL 可以求值了"的信号。注意这个输出:
- val x = 1 =如果你忘了分号,REPL 会一直用=提示继续等待输入。这时补一个分号再回车即可。而在.sml文件里,分号的作用是分隔多个顶层声明,如果文件最后只有一个声明,末尾分号可写可不写。最容易犯的错,是文件里两个声明之间漏了分号,结果第二个声明被当成第一个声明的延续,报出莫名其妙的语法错误。我的个人习惯:文件里每个顶层val/fun后面都老老实实加分号,省心。
7.2 函数调用里括号与空格的距离
SML 的函数应用不需要括号,f x就是调用f作用于x。但函数应用的结合优先级非常高,比算术运算符还高。f x + 1会被解析成(f x) + 1,而f (x + 1)才是"先把x + 1算出来再传给f"。想表达后者却写成f x + 1,是纯纯的语法误解。类似的,map f xs没问题,但如果想对x :: xs做 map,就必须写map f (x :: xs),因为::在函数应用面前优先级也不够。遇到这类错误,先检查是不是少了一对括号。
7.3 读类型错误先看"账本对账"
前面提到过,SML 类型错误信息的灵魂是operator domainvsoperand。很多新手看到一大段Error: operator and operand don't agree就直接懵,其实只要抓住expression:后面那行,它标记了你代码里到底哪一段类型对不上。比如2 :: ["a"],错误信息会引导你意识到:::要求元素类型和列表元素类型一致,你给了 int 元素和 string list,于是::的类型推断当场矛盾。每次报错就按这个流程走:先看expression是哪段代码,再看 operator domain 要求什么,最后看 operand 实际是什么,三行看下来,问题基本就定位了。
7.4 等号比较不能用在函数类型上
SML 的多态里有个"等号类型"机制,你会看到某些类型变量写作''a而不是'a。''a表示"支持相等比较的类型"。数字、字符串、元组都可以比相等,但函数不能比相等。比如:
fun sameNumber (a, b) = (a = b);它的类型会被推断成''a * ''a -> bool,双引号开头的''a就是在约束:这里只接受可比较相等的类型。如果你试图拿两个函数去调用sameNumber,编译器会当场拒绝,理由是函数没有相等性。这个设计看似不起眼,实际上避免了很多语言里"比较两个函数对象"这种说不清道不明的逻辑陷阱。遇到''a类型的报错,想想是不是把函数、或是包含函数的值拿去做=比较了。
7.5 字符串和字符别混用
SML 里"a"是字符串,字符要写成#"a"。字符串拼接用^,不是+。从 Python 转过来的人习惯性敲"a" + "b",SML 会直接给类型错误。把字符串拆成字符列表可以用explode,反向拼回来用implode:
val chars = explode "abc"; (* [#"a", #"b", #"c"] *) val back = implode chars; (* "abc" *)这几个函数在写字符串处理的练习时非常顺手。Int.toString、Real.toString也是同一类高频小工具,字符串拼接前记得先用它们把数字转过来,不然类型错误又会找你聊天。
我自己把这套初识内容完整走下来,最大的体会是:SML 的语法少到你几乎不需要刻意记"API",真正消耗脑力的是调整思维方式。被编译器拦着改了一晚上代码之后,再回到日常使用的语言,会不自觉地在脑内补类型。下一篇快餐教程,我打算讲 SML 里最有创造力的部分——datatype自定义类型,以及用模式匹配把二叉树、栈这些结构干净地写出来,那是 SML 真正进入"设计程序"门槛的地方。学完初识这一篇,建议你把第 7.3 节那个"账本对账"的排查思路抄在桌边,接下来几天它会是你最高频的朋友。