本文是对 Aiming for correctness with types 的整理与翻译。
内容结构概览
- Rust 宣传为什么容易让人反感:RIIR、优越感、学习曲线和“正确性”之间的张力。
- Rust 的三个判断:写 Rust 要换思维;写出任意代码更难;写出正确代码更容易。
- 正确性不是绝对目标:多数业务只需要“足够正确”,但每个错误都有成本。
- 隐式契约无处不在:社会规则如此,协议和 API 也是如此。
- SSH tarpit 的例子:协议允许“发一些其他行”,但客户端默认假设对方不会恶意拖延。
- HTTP 访问控制例子:一个服务托管
internal.example.org、ducks.example.org和giraffes.example.org。 - 手写 HTTP 解析的坑:代理和源站对多个
Hostheader 的理解不同,导致内部站点被绕过。 - 使用 Go/Node 标准 HTTP 库:看似修复了手写解析,但引入了大量隐式行为。
- Node.js header 行为:header 名小写化、重复 header 合并、
set-cookie特殊处理、rawHeaders。 - JavaScript 对象字段的“社会契约”:
originReq.headers = ...可以写,但并不代表 API 真有这个字段。 - TypeScript 能救一部分:能发现
AddressInfo和string混用、set-cookie类型错误、statusCode可能为空等。 - TypeScript 的边界:它不能表达“对象 key 一定是小写 header 名”,也不能修复底层 API 设计。
- Go 的 HTTP Header 模型:
map[string][]string、Canonical Header Key、大小写规范化和可构造非法状态。 - Go zero value 问题:字段总有零值,导致“未设置”和“空字符串”常常混在一起。
- Go map 的 out-of-band ok:
value, ok把值和存在性拆成两个通道,能表达无意义组合。 - Rust 的显式可选性:必填字段必须初始化,默认值要显式,optional 用
Option<T>表达。 - “小心点别写 bug”不是工程方法:工程应该设计系统减少错误,而不是靠人的自律。
- Go HTTP API 的更多例子:
Proto/ProtoMajor/ProtoMinor、ContentLength = -1、URL/RawFragment。 - hyper 的建模方式:
Request<T>、opaqueMethod、opaqueVersion、HeaderMap<HeaderValue>。 - Rust 类型如何阻止无意义值:不能随便构造非法 HTTP version、非法 header name/value。
- 结论:Rust 的价值不只是内存安全,而是鼓励用类型把“不该发生的状态”排除在系统之外。
Rust 经常被拿来和其他语言比较。很多时候,讨论会很快滑向一个让人不太舒服的方向:某个项目是不是应该重写成 Rust?也就是社区里经常说的 RIIR,Rewrite It In Rust。
这类讨论之所以容易变味,不只是因为 Rust 用户热情,也不只是因为“重写”本身容易引发争论。更关键的是,Rust 的很多优点并不是一眼就能感受到的。它不像“这个程序快了 3 倍”那样直观,也不像“这个程序少占 80% 内存”那样好量化。Rust 最重要的一部分价值在于:它让一些错误更难写出来,让一些状态根本无法被表示,让一些原本只能靠文档、约定、代码审查和测试兜底的隐式契约,变成类型系统中的显式约束。
这篇文章谈的就是这个主题:用类型追求正确性。
它不是说 Rust 能让程序自动正确,也不是说其他语言写不出正确程序。恰恰相反,文章花了很长篇幅承认 Rust 的学习成本,也承认很多业务系统并不需要绝对正确。一个音乐推荐系统就算推荐得一般,也未必立刻影响收入;一个非核心功能偶尔出错,也许只需要修 bug、补测试、发补丁。但每个错误都有成本。错误会消耗工程时间,带来回归风险,拖慢新功能开发,甚至影响用户信任。
Rust 的价值在于,它鼓励我们把更多“不应该发生”的事情,提前建模进类型里。不是靠“大家小心点”,而是靠 API 设计让错误状态更难构造,甚至无法构造。
一、Rust 宣传为什么容易让人反感
文章开头讨论了 Rust advocacy 的困难。每当 Rust 被拿来和其他语言比较,谈话很容易变成“为什么某个软件应该重写成 Rust”。这类讨论经常让另一边觉得 Rust 用户有优越感,好像他们在说:“你们以前写的都不够好,换 Rust 才是正道。”
这种感受并不是凭空来的。Rust 确实要求人换一种思维方式。很多在 JavaScript、Go、Python、Java 里很自然的写法,搬到 Rust 里会立刻碰壁。Rust 会逼你处理所有权、生命周期、错误、可选值、并发共享、可变状态。刚开始时,这很像一个新来的经理非要把所有流程改一遍,让人觉得它是在制造麻烦。
但坚持一段时间后,很多“麻烦”会慢慢显露出意义。Rust 要求你改变,不是为了显得高深,而是因为它试图建立一个更严格的系统:让内存安全、线程安全、状态合法性和错误处理不再完全依赖人的小心。
文章提出了三个判断:
1. 用 Rust 编程需要不同的思维方式。
2. 在 Rust 里写出“任意能跑的代码”更难。
3. 在 Rust 里写出“正确的代码”更容易。
前两个判断,初学者通常很快就能感受到。第三个判断则比较难证明。因为“正确”不是一个简单目标。不同软件对正确性的要求差别很大:登月软件、自动驾驶、数据库、支付系统,对正确性的要求很高;但很多普通业务系统只需要“足够正确”。
问题是,即便只需要“足够正确”,错误也不是免费的。服务可用性没有达到承诺,可能要给客户 credit;修一堆小 bug 会消耗工程时间;测试本身可能有 bug;修复可能引入回归。另一方面,过度追求完美也会拖慢产品迭代,让公司被市场淘汰。
所以重点不是“永远追求绝对正确”,而是找到平衡。而 Rust 的位置,正是在这个平衡上提供了一种更系统的帮助:它让你在设计阶段就把一些错误排除掉,而不是等它们在线上爆炸。
二、隐式契约无处不在
文章接着引入“隐式契约”这个概念。
社会生活里到处都是隐式契约。什么话能说,什么话不该说,什么时候该沉默,什么时候该礼貌回应,这些东西并不总是写在纸上,也没人强制执行。但如果你违反它,别人会觉得你粗鲁、怪异,甚至危险。
软件世界也一样。很多协议、API、库函数都有隐式契约。它们不一定被类型系统强制,也不一定被运行时检查,但大家默认你会遵守。
比如,如果一个服务监听 TCP 80 端口,大家通常认为它说的是 HTTP。没人能阻止你在 80 端口上说完全不同的协议,但那会破坏别人的预期。
再比如 SSH。客户端连接服务器后,服务器会发送版本字符串。但在发送版本字符串之前,服务器也可能发送一些“其他行”。这给了一个有趣的攻击或防御空间:服务器可以非常慢地、一行一行地发送永远没有尽头的数据,让客户端一直等。这就是 SSH tarpit 的思路。协议允许某些“其他行”,但客户端隐式假设对方不会这么玩。
于是,网络程序必须自己加超时。连接超时、读超时、请求超时,这些往往不是协议文档里最显眼的部分,却是实际工程里必须知道的东西。
这说明:隐式契约如果没有被系统强制,就会被各种现实情况打破。软件非常“不礼貌”。它会发重复 header,会发大小写奇怪的 header,会半开连接,会卡住不发数据,会构造你没想到的状态。
这就引出文章的核心例子:HTTP Header。
三、一个 HTTP 访问控制代理
假设我们有一个只说 HTTP/1.1 的服务。它托管几个域名:
internal.example.org
ducks.example.org
giraffes.example.org
其中 ducks.example.org 和 giraffes.example.org 可以公开访问;internal.example.org 只能从公司 VPN 或本机访问。于是我们想写一个代理,检查请求来源 IP 和 Host header。如果外部用户请求内部域名,就拒绝。
一开始用 Node.js 的 net 模块手写一个很简单的 TCP 服务。它从 socket 里读数据,直到看到 \r\n\r\n,认为这就是完整 HTTP request header。然后解析第一行和后续 header 行,判断 Host。
为了简化示例,允许访问内部站点的 IP 只有两类:
127.0.0.x
2.58.12.x
于是写一个函数:
function isAllowed(addr) {
return addr.startsWith("127.0.0.") || addr.startsWith("2.58.12.");
}
然后根据 socket 地址判断是否允许访问。
用本机请求 internal.example.org,返回 200;从局域网地址请求,返回 403。看起来很完美。
接着,源站服务也用 Go 手写了一个非常简陋的 HTTP parser。它根据 Host 决定返回什么内容:
ducks.example.org -> Have some happy ducks!
giraffes.example.org -> Here's a long neck
internal.example.org -> [CONFIDENTIAL] The secret ingredient is love
代理负责挡住外部访问 internal。源站负责按 Host 返回不同内容。
这套东西一开始看起来可用。但问题很快来了。
四、重复 Host Header:代理和源站理解不一致
curl 很聪明。你用 -H "Host: ducks.example.org" 指定 Host 时,它会替换默认 Host。你写成奇怪大小写,比如 hoST,它也会规范成 Host。如果你试图传两个 Host header,它通常只会保留第一个。
但 curl 不是唯一能发 HTTP 请求的工具。我们可以手写原始请求,用 netcat 直接发 TCP 数据。
构造一个请求:
GET / HTTP/1.1
Host: internal.example.org
Host: ducks.example.org
User-Agent: netcat/0.7.1
注意这里有两个 Host。代理和源站的手写 parser 对它们的处理方式不同。代理看到一个 Host,源站可能看到另一个 Host。结果是:外部请求居然绕过了访问控制,拿到了 internal.example.org 的机密内容。
这就是典型的 parser disagreement。两个组件都以为自己在处理 HTTP,但它们对“重复 Host header 应该怎么办”的理解不同。访问控制发生在代理层,实际业务判断发生在源站层,只要两边解析结果不一致,就可能出现安全漏洞。
这也是文章要强调的点:问题不只是这个 bug 本身,而是这一整类 bug。只修这一个 if 判断没意义。真正有价值的是设计系统,避免代理和源站用不同方式理解同一个请求。
五、别手写 HTTP?标准库也有隐式行为
你可能会说:谁会直接从 TCP 流里手写 HTTP parser?直接用标准库不就好了?
文章也正是这么做的。Go 端改用 net/http。Node 端也改用 http.Server 和 http.ClientRequest。
但标准库不是魔法。它确实帮你处理了大量协议细节,但也带来了很多隐式行为。
先看 Go。http.ResponseWriter.Write 看起来只是写响应 body。但如果你还没调用 WriteHeader,它会自动写 200 OK。如果还没设置 Content-Type,它会读取前 512 字节内容,自动探测 MIME 类型。这个行为在大多数情况下很方便,但它不是 io.Writer 接口承诺的一部分。
这就产生一个很微妙的问题:http.ResponseWriter 实现了 io.Writer。如果你把它传给一个只接受 io.Writer 的函数,从接口签名看,那只是“写字节”。但实际写入时,它可能触发 HTTP header、status code、content-type sniffing 等行为。于是接口注释、实现注释和真实运行时行为之间,靠的是隐式契约。
再看 Node。改用 http.Server 后,req.headers 看起来像一个普通对象。于是代码写成:
function isRestricted(req) {
return req.headers.Host === "internal.example.org";
}
但 Node 会把 header 名全部小写化,所以应该访问 req.headers.host。这不是语法错误,不会崩溃,只是逻辑错误。访问 Host 得到 undefined,访问控制失效。
这还没完。req.socket.address() 返回的不是字符串,而是一个对象,里面有 address、family、port。如果把整个对象传给 isAllowed,就会在运行时报 startsWith is not a function。
JavaScript 很宽容。宽容的代价就是:你可以写出一堆看起来合理、实际完全不对的代码。
六、JavaScript 对象字段是一种“社会契约”
Node 代理里还有一个特别典型的错误:
originReq.headers = req.headers;
看起来像把原请求 header 复制给转发请求。问题是,http.ClientRequest 根本没有一个叫 headers 的公开字段。JavaScript 允许你给对象随便挂字段,所以这行代码能运行,但它没有你以为的效果。
类似地:
res.headers = originRes.headers;
http.ServerResponse 也没有这么一个字段。正确做法是调用 setHeader、writeHead 等 API。
这就是文章里的一个精彩说法:JavaScript 里对象的“字段”某种程度上像社会构造。你可以给对象加任何属性,但这不代表库会使用它,也不代表它是 API 的一部分。
如果一个 API 只靠文档告诉你“应该用 setHeader,不要直接赋值”,那用户就很容易写错。文档是必要的,但文档不是强制机制。只靠文档维持正确性,本质上还是在靠人小心。
七、Node Header 行为远比普通对象复杂
Node 的 message.headers 不是普通对象,它做了很多转换。
它会把 header 名小写化。对于重复 header,它会根据 header 名采用不同策略:
某些 header,比如 host、content-length、authorization 等,重复项会被丢弃。
set-cookie 永远是数组,重复项追加到数组。
cookie 重复时用 "; " 连接。
其他 header 重复时用 ", " 连接。
这套规则很复杂,但它有现实原因。HTTP header 并不是简单的 Map<String, String>。一些 header 可以重复,一些不能重复;一些重复时可以合并,一些不能合并;有些合并分隔符是逗号,有些是分号。
如果你写透明代理,可能希望尽量原样转发 header。Node 提供了 rawHeaders,它是一个字符串数组,按原始顺序保存 header 名和值:
[ name1, value1, name2, value2, ... ]
这看起来能解决问题。但用 rawHeaders 转发时,如果你对每个 header 调 originReq.setHeader(k, v),相同 header 名会互相覆盖。于是两个 Set-Cookie 只剩一个。
你可以先聚合成 { key: [values...] } 再 setHeader。但如果两个 header 名大小写不同,比如 Set-Cookie 和 set-Cookie,又会遇到另一类行为。HTTP header 名本来大小写不敏感,但如果你想做完全透明代理,又会发现 Node 的 API 不一定允许你保留所有原始细节。
这说明:HTTP 不是“字符串到字符串的 map”。如果 API 把它近似成一个普通对象,就会把复杂性推给用户。
八、TypeScript 能救一部分
接着,文章给 JavaScript 代码加上 TypeScript 检查。即使仍然写 .js,也可以用 JSDoc 注解配合 tsc --allowJs 做静态检查。
一开始,普通检查能发现一些问题;开启 --strict 后,发现更多问题。
比如:
req.headers[k] 可能是 string | string[] | undefined
originRes.statusCode 可能是 number | undefined
isAllowed 参数没有类型
isRestricted 参数没有类型
socket.address() 返回 AddressInfo,不是 string
这非常有帮助。TypeScript 能指出很多“你以为它是字符串,实际它可能不是”的地方。它也能发现 set-cookie 这种特殊类型:set-cookie 是 string[] | undefined,所以拿它和普通字符串比较时,TypeScript 会直接报错。
文章对 TypeScript 的态度很正面:它是混乱 JavaScript 世界里非常好的补救方案。它让很多错误提前暴露出来,不用等运行时爆炸。
但 TypeScript 也有边界。它不能很好表达“这个对象的所有 key 都已经被 lower-case”。所以 req.headers.Host 这种大小写错误,不一定能被它捕获。Node 的类型定义也必须追随 Node API 本身的设计。如果底层 API 本来就复杂、隐式、多种行为混在一起,TypeScript 只能把这种复杂性暴露出来,而不能完全消除它。
换句话说,类型检查器能帮你,但它无法从根本上修复一个不够 misuse-resistant 的 API。
九、Go 的 HTTP Header:看起来简单,实际也有坑
然后文章转向 Go。
Go 的 http.Header 类型本质上是:
map[string][]string
也就是每个 header name 对应一个字符串切片。这样看起来比 JavaScript 的普通对象强一些,因为它承认一个 header 可以有多个值。
但问题在 header name 上。HTTP header 名大小写不敏感。Go 的 net/http 不像 Node 那样全部小写化,而是 canonicalize 成 Title-Case,比如 accept-encoding 会变成 Accept-Encoding。
这个设计有性能优化,也有历史惯性,但它仍然依赖契约。因为 http.Header 是一个普通 map,你完全可以手动构造不符合文档预期的值:
http.Header{
"Host": []string{"internal.example.org"},
"host": []string{"ducks.example.org"},
}
你也可以写:
http.Header{
"secure": []string{},
}
这个 header 写到网络上时什么都不输出,但如果代码只检查 key 是否存在,就可能认为“secure”存在。这就是类型没有表达真实约束导致的问题。
文档会告诉你该用 CanonicalHeaderKey,会告诉你 header 名大小写规则。但类型系统没有强制。你可以构造无意义状态,也可以写出看起来合法、实际违反约定的数据。
这就是“隐式契约”的问题再次出现。
十、Go 的零值:简单背后的代价
Go 的一个卖点是每个类型都有 zero value。变量不初始化,也有默认值。int 是 0,string 是空字符串,slice 可以是 nil,interface 可以是 nil。
这让很多代码写起来很方便,但也让“未设置”和“设置为空”变得难以区分。
文章用一个 Profile 数据库举例:
type Profile struct {
Name string
Bio string
}
如果想更新用户资料,只更新 Name,不更新 Bio,该怎么办?如果传一个 Profile{Name: "Elizabeth"},那么 Bio 的零值是空字符串。这个空字符串到底表示“不更新 Bio”,还是表示“把 Bio 清空”?类型本身说不清。
一种办法是把字段改成指针:
type Profile struct {
Name *string
Bio *string
}
nil 表示未设置,&"" 表示设置为空字符串。这样能表达更多状态,但也带来更多指针和 nil 处理。
如果结构体不是你定义的,比如来自 protobuf 生成代码,那你可能没有这种自由。你只能额外加 HasName、HasBio 这种 out-of-band 标记:
type Profile struct {
Name string
HasName bool
Bio string
HasBio bool
}
这又会产生新问题。Name 和 HasName 是两个独立字段,它们能表达四种组合:
空字符串 + false
空字符串 + true
非空字符串 + false
非空字符串 + true
其中某些组合可能没有意义,但类型系统允许你构造它们。
Go map 也是类似。读取 map[string]string 不存在的 key,会得到字符串零值 ""。如果想知道 key 是否存在,就要用:
v, ok := m[k]
这里的 v 和 ok 是两个返回值。它们表达的是一个整体状态,但被拆成了两个通道。标准 map 不会返回 "lol", false 这种组合,但如果你自己设计一个类似接口,就没有什么能阻止你这么做。
文章的判断很直白:这种 out-of-band 的“是否存在”信号,是很多应用级 bug 的来源。它不会导致内存崩溃,所以比 C 好一点;但它可能静默做错事。于是漏洞不再来自内存破坏,而来自逻辑错误。
十一、“小心点别写 bug”不是工程方法
文章在这里提出一个核心观点:
工程不是要求人永远不犯错。
工程是设计系统,让错误更少发生。
“你小心点就好了”不是工程方法。C 社区有时会说,写出内存 bug 是程序员不够熟练。但这是一种自我抬高。真实世界里,人都会犯错。系统设计的目标,不是挑选永远不会犯错的人,而是让普通人也更难犯严重错误。
Rust 的意义就在这里。它不是因为程序员更聪明才更安全,而是因为系统把一些错误提前挡住了。
例如 Rust 里:
struct Person {
name: String,
}
这个 name 必须初始化。你不能构造一个缺少 name 的 Person。如果你希望它默认是空字符串,需要显式实现或派生 Default,再显式使用默认值。
如果字段是可选的,就写:
struct Person {
name: Option<String>,
}
这时状态就很清楚:要么 Some(value),要么 None。不是靠空字符串约定,不是靠另一个 bool 标记,不是靠文档说“空字符串代表未设置”。
HashMap::get 也是类似。它返回 Option<&V>。如果 key 存在,就是 Some(&value);不存在,就是 None。调用方必须处理这两种情况:可以 unwrap,可以 expect,可以 if let,可以 match。但你不能假装一定有值,除非你明确选择在没有值时 panic。
更重要的是,这不一定有性能代价。很多情况下,Option<&T> 和裸指针大小相同,因为 None 可以用 null pointer 表示。
这就是用类型建模真实状态:不是多写一堆运行时检查,而是把状态空间设计得更准确。
十二、Go HTTP Request 里的无意义状态
文章又回到 HTTP,继续看 Go 的 http.Request。
Go 的请求里,协议版本被存成三个字段:
Proto string
ProtoMajor int
ProtoMinor int
于是可以构造:
Request{
Proto: "HTTP/1.1",
ProtoMajor: 2,
ProtoMinor: 0,
}
这显然自相矛盾。更合理的设计应该是有一个 HTTPVersion 类型,内部只存一份结构化信息,并由它负责格式化。但如果字段公开,还能被随意修改;如果字段私有,又需要 constructor 和 getter。Go 可以做这些,但标准库很多 API 没这么建模。
再看 ContentLength。它是 int64,其中 -1 表示未知。这个叫 in-band signaling:把某个普通值保留为特殊含义。问题是,那 -2 到最小 int64 又代表什么?类型系统并没有表达。
再看 URL。Go 的 url.URL 里同时有 Fragment 和 RawFragment,还有 Path 和 RawPath。这可能是出于性能或保留原始编码的考虑,但它把 escaped 和 unescaped 两种形式同时暴露给用户。用户要构造 URL 时,到底应该填 Fragment 还是 RawFragment?要看 URL.String() 文档,再看 EscapedFragment() 文档,才能理解最终行为。
这就是文章不满的地方:API 看起来简单,其实把复杂语义分散在文档和方法行为里。你可以构造很多“看起来像 URL,但内部字段互相矛盾”的值,然后让各个方法努力把它解释成“多数情况下合理”的东西。
十三、hyper 的做法:只能构造有意义的东西
最后,文章看 Rust 生态里的 hyper。
hyper 是 Rust 里非常重要的底层 HTTP 库。它的 Request<T> 大致是:
pub struct Request<T> {
head: Parts,
body: T,
}
它对 body 是泛型的,因为 body 可以是内存中的字符串、字节、文件、流式数据等。请求头部 Parts 里包含 method、uri、version、headers、extensions。
这里的重点是:很多字段不是普通字符串或普通 map。
Method 是一个 opaque type,内部包着一个私有 enum。常见方法如 GET、POST、PUT、DELETE 是枚举变体;扩展方法则有自己的表示方式。用户不能随便构造一个内部无意义状态。
Version 也是 opaque type。它提供 HTTP_09、HTTP_10、HTTP_11、HTTP_2、HTTP_3 等常量,内部是私有 enum。安全代码里没有办法构造一个 HTTP/4.-7 这种无意义版本。
Headers 更重要。headers 字段不是 Vec<(String, String)>,不是 HashMap<String, String>,也不是 HashMap<String, Vec<String>>,而是:
HeaderMap<HeaderValue>
它是一个专门为 HTTP header 设计的 multimap。key 是 HeaderName,value 是 HeaderValue。header name 和 header value 都不是随便的字符串,而是带验证和语义的类型。
比如,在 Go 中可以构造非规范的 header:
headers.Add("Née", "élégante")
并写出不符合 HTTP header name 规则的字段。在 hyper 里,直接插入字符串会编译不通过,因为它需要 HeaderValue。如果你用 HeaderValue::from_static 传入非法值,程序会安全地 panic。对于用户输入,也可以用非 panic 版本,比如 HeaderName::from_bytes、HeaderValue::from_bytes,返回 Result,由调用者明确处理错误。
这就是类型建模的价值:不是“永远不会失败”,而是失败路径被显式暴露。你不能无意间把任意字符串塞进 header name。你必须先把它转换成 HeaderName,转换可能失败。你必须决定失败时怎么办。
而且 hyper 的实现并不只是“更安全但更慢”。它用专门的数据结构处理 HTTP header 的常见场景,常见 header 名甚至有枚举或静态表示,避免不必要分配。也就是说,正确建模不一定牺牲性能。好的类型设计可以同时提升正确性和性能。
十四、Rust 的类型建模不是外星科技,而是一种折中
文章最后强调:Rust 不是外星科技。它不是完全脱离现有系统的纯理论语言。它受很多语言影响,也继承了很多工程妥协。
它的独特之处在于折中。它既不是动态语言那样完全宽松,也不是纯函数语言那样把副作用严格推到边界。它试图在系统编程、性能、内存控制、类型表达能力和日常工程可用性之间找到平衡。
学习 Rust 后,很多人会说“它让我成为更好的程序员”。这句话容易显得优越,但真正含义不是“我更聪明了”。而是 Rust 逼你正视了很多以前可以忽略的失败情况。它要求你更早处理可选值、错误、共享状态、生命周期、并发可变性。久而久之,你会在其他语言里也更容易看见这些坑。
当然,Rust 更难写。你可以先用 String 和 clone,先用 Arc,先写朴素版本。你不需要一开始就设计出最零拷贝、最完美的生命周期结构。Rust 的很多高级能力可以慢慢学。
但如果你持续写 Rust,并认真顺着它鼓励的方向走,你会开始先写类型和函数签名,再写实现。你会开始用 enum 表达状态机,用 Option 表达可选,用 Result 表达失败,用私有字段和 constructor 保护不变量,用类型把“非法状态”挡在外面。
这就是文章标题里的 “Aiming for correctness with types”。不是“类型保证一切正确”,而是“用类型瞄准正确性”,把系统朝更少错误的方向推。
十五、这篇文章真正想表达什么
这篇文章表面上比较了 JavaScript、TypeScript、Go 和 Rust。它指出 JavaScript 太宽松,TypeScript 能救一部分,Go 的 API 看似简单但容易表达无意义状态,Rust/hyper 则更强调通过类型建模真实语义。
但更深层的主题不是语言粉丝大战。文章明确说,不想像给运动队加油一样讨论语言。真正关心的是系统:什么系统能防止错误?什么系统能让错误更早暴露?什么 API 让错误状态无法构造?什么 API 只是把复杂行为藏在文档里?
这也是为什么文章花了大量篇幅讲 HTTP header。HTTP 看起来很简单:请求行,header,空行,body。但一旦你实现代理、转发 header、处理重复 header、大小写、Host、Set-Cookie、raw headers、status code、content length,就会发现它一点都不简单。
如果语言和库的类型系统无法表达这些差异,复杂性就不会消失。它只是从类型系统里逃出来,散落到文档、测试、代码审查、运行时错误和线上 bug 里。
Rust 的优势不是它让复杂性消失,而是它鼓励你把复杂性放在更明确的位置。
十六、总结
这篇文章从 Rust 宣传的困境讲起。Rust 用户常说“Rust 让正确代码更容易写”,但这句话很容易被听成优越感。文章把它拆成三个判断:写 Rust 要换思维;写出任意代码更难;但写出正确代码更容易。前两个判断初学者很容易体会,第三个判断则需要通过具体例子说明。
接着,文章引入隐式契约。无论社会生活还是软件系统,很多规则都没有被强制执行。协议和 API 中也有大量隐式假设。SSH tarpit 说明,即便协议允许某些行为,客户端也必须用 timeout 保护自己。HTTP header 例子则更具体:如果代理和源站对多个 Host header 的解析不同,就可能导致访问控制绕过。
随后,文章从一个手写 Node.js TCP 代理和手写 Go HTTP 源站开始。它们都用简陋方式解析 HTTP。攻击者构造两个 Host header 后,代理和源站对 Host 的理解不一致,外部用户拿到了内部站点内容。改用 Go 和 Node 的标准 HTTP 库后,手写 parser 的问题减少了,但又暴露出标准库 API 的大量隐式行为:Go 的 ResponseWriter.Write 会自动写状态码和探测 Content-Type;Node 的 req.headers 会小写化 header 名、合并重复 header、特殊处理 set-cookie;ClientRequest 和 ServerResponse 没有 headers 字段,但 JavaScript 允许随便赋值,代码能跑却没有预期效果。
TypeScript 能发现一部分问题。开启 strict 后,它能指出 socket.address() 返回的不是字符串,statusCode 可能为空,req.headers[k] 可能是 string | string[] | undefined,set-cookie 不能和普通字符串比较。但 TypeScript 也受限于底层 API 设计。它不能完美表达“所有 header key 都已被小写化”,也不能把一个复杂、隐式、历史包袱很多的 API 变成完全 misuse-resistant。
Go 的部分进一步说明“简单”背后的代价。http.Header 本质是 map[string][]string,它靠文档约定 header 名要 canonicalize,但类型允许你构造大小写冲突、空切片等无意义状态。Go 的 zero value 让未设置和空值容易混淆。map 查询用 value, ok 把值和存在性拆成两个返回值,能表达一些没有意义的组合。http.Request 里协议版本有 Proto、ProtoMajor、ProtoMinor 三个字段,可以互相矛盾;ContentLength = -1 用 in-band signaling 表示未知;url.URL 同时暴露 Fragment 和 RawFragment,让用户必须查多个方法的文档才能知道怎么正确构造。
Rust 的部分则展示另一种建模方式。必填字段必须初始化;默认值要显式使用 Default;可选字段用 Option<T>;HashMap::get 返回 Option<&T>,迫使调用者处理有值和无值两种情况。hyper 的 HTTP 类型也遵循类似原则:Method 和 Version 是 opaque type,无法用安全代码构造无意义版本;HeaderMap<HeaderValue> 不是普通 map,而是专门为 HTTP header 设计的 multimap;HeaderName 和 HeaderValue 都有合法性检查,用户输入走返回 Result 的构造函数,失败路径必须显式处理。
最终结论是:Rust 的价值不只是内存安全。内存安全当然重要,但 Rust 更深的工程价值在于,它鼓励用类型系统表达真实世界的不变量,让非法状态无法构造,让错误更早出现,让 API 更抗误用。工程不是要求程序员永远小心、不犯错;工程是设计系统,让错误更少发生。Rust 正是这样一种系统。

4522

被折叠的 条评论
为什么被折叠?



