如果你玩过 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教学和入门首选。命令是smlREPL 体验好错误信息直接、带位置提示站在新手角度这是最不容易劝退的选择很多高校的课程默认就是它。Poly/ML命令是poly以稳定和性能见长常被一些需要长期运行的工具链选作底层运行时。语法和 SML/NJ 完全一样只是 REPL 提示符和部分细节不同。如果只选一个我建议无脑 SML/NJ。等你看完这篇真想拿 SML 去写常驻服务或更大的项目再考虑 Poly/ML 也不迟。两款都是免费开源实现安装没有门槛不会让你在第一步就卡住。2.2 安装与验证以 SML/NJ 为例常见三类环境这样装。macOS假设你已经装了包管理器brew install smlnj smlDebian / Ubuntu 系列的 Linuxsudo 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 dont 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绑定到width4绑定到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 最常用的数据结构写法沿用函数式语言的传统空列表是[]往头部加元素用::读作 consval 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 dont 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 bSML 会直接给类型错误。把字符串拆成字符列表可以用explode反向拼回来用implodeval chars explode abc; (* [#a, #b, #c] *) val back implode chars; (* abc *)这几个函数在写字符串处理的练习时非常顺手。Int.toString、Real.toString也是同一类高频小工具字符串拼接前记得先用它们把数字转过来不然类型错误又会找你聊天。我自己把这套初识内容完整走下来最大的体会是SML 的语法少到你几乎不需要刻意记API真正消耗脑力的是调整思维方式。被编译器拦着改了一晚上代码之后再回到日常使用的语言会不自觉地在脑内补类型。下一篇快餐教程我打算讲 SML 里最有创造力的部分——datatype自定义类型以及用模式匹配把二叉树、栈这些结构干净地写出来那是 SML 真正进入设计程序门槛的地方。学完初识这一篇建议你把第 7.3 节那个账本对账的排查思路抄在桌边接下来几天它会是你最高频的朋友。