(二)Pulse 语言的设计思路
Updated:
(二)Pulse 语言的设计思路
前言
上一篇文章从编程范式的角度,得出了一个结论:事件化模型能从根本上消除并发问题。
思考到这一步,自然会问:如果从零设计一门语言,让事件驱动成为唯一范式,会长什么样?
这就是 Pulse。
这篇文章记录每个设计决策背后的原因。
一、从结论到约束
上篇的核心结论:
共享 + 可变 = 冲突
无共享 + 不可变 = 无冲突
把这个结论翻译成语言约束:
| 结论 | 语言约束 | Pulse 实现 |
|---|---|---|
| 事件不可变 | 事件一旦发出,不能修改 | event 是纯数据定义 |
| 不共享状态 | 状态只在模块内部可见 | state 不可导入 |
| 顺序执行 | 同一模块内事件按序处理 | handle 在模块内串行 |
| 跨模块用消息 | 模块间只能通过事件通信 | send 是唯一的跨模块手段 |
把这些约束做进编译器,靠语法规则强制执行,而不是靠程序员自觉。
二、func 与 handle 的分离
Pulse 把代码分成两种:
func |
handle |
|
|---|---|---|
| 能做什么 | 纯计算 | 处理事件、发新事件 |
| 不能做什么 | 不能 send、不能 set |
— |
| 测试 | 给输入,看输出 | 给事件,看发了什么事件 |
| 类比 | 数学公式 | 流水线工位 |
为什么要这样分?
传统代码:
function processOrder(order) {
validate(order) // 纯计算
saveToDb(order) // 副作用
sendEmail(order.user) // 副作用
log(order) // 副作用
}
纯计算和副作用混在一起
→ 不知道哪行会炸
→ 不知道哪行有外部依赖
→ 测试要 mock 一堆东西
// Pulse:强制分开
private func validate(items: List<Item>) -> bool {
items.length > 0 && items.length <= MAX_ITEMS
}
private handle OrderCreated ({ items }) {
if validate(items) {
send OrderConfirmed { ... }
} else {
send OrderFailed { reason: "无效订单" }
}
}
func 里写不了副作用,不是靠自觉,是编译器不让。
handle 里的 I/O 操作(数据库、HTTP、文件)也是通过发送事件完成的——发一个请求事件,执行层去做真正的 I/O,再把结果作为响应事件送回来。核心区别是:func 不能有任何副作用,handle 可以 send 和 set。
这样分的好处是可测试性好:func 直接断言输出,handle 断言发了什么事件。
三、15 个关键字
先看有哪些:
module interface import → 组织代码
event handle send → 事件流转
pause resume → 控制开关
func const state set → 数据与计算
public private → 可见性
unsafe → 逃生舱
每个关键字都对应一个不可替代的概念。试着去掉任何一个:
| 去掉 | 会怎样 |
|---|---|
pause/resume |
无法优雅地做降级 |
func |
纯计算和副作用混在一起 |
unsafe |
无法接入现实世界 |
set |
无法在 handle 内修改状态 |
去不掉。再试着加一个:
| 加上 | 为什么不要 |
|---|---|
async/await |
事件天然异步,不需要 |
lock/mutex |
不共享状态,不需要锁 |
try/catch |
错误就是事件,用 send |
class |
没有对象,只有模块 |
new |
没有对象,不需要构造 |
加不上。
够用就好,不多加。
四、错误也是事件
传统语言用异常处理错误:
try {
processPayment()
} catch (InsufficientBalance e) {
// 处理余额不足
} catch (NetworkError e) {
// 处理网络错误
} catch (Exception e) {
// 兜底
}
问题:
| 问题 | 说明 |
|---|---|
| 隐式控制流 | 不读实现,不知道会抛什么 |
| 跨层传播 | 异常可能穿透多层调用栈 |
| 吞异常 | 空 catch 块,bug 静默消失 |
Pulse 里没有异常机制,错误和正常结果一样,都是事件:
private handle PaymentRequest ({ order_id, amount }) {
if processPayment(amount) {
send PaymentSuccess { order_id }
} else {
send PaymentFailed { order_id, reason: "余额不足" }
}
}
PaymentFailed 和 PaymentSuccess 一样,有类型定义、有处理逻辑、可以被追踪和订阅。
五、三层分离
Pulse 把职责分成三层。语言层是桥梁,连接业务逻辑和执行层——它不执行 I/O,只描述「要什么」和「拿到后怎么办」:
| 层 | 角色 | 职责 |
|---|---|---|
| 语言层 | 桥梁/契约 | 事件定义、业务逻辑 |
| 执行层 | 施工队 | 调度、路由、降级、I/O 执行 |
| 持久化层 | 数据库、存储系统 | 事件存储、状态快照、日志 |
程序员不写队列代码,不写调度逻辑,不写 I/O 连接管理。这些都是执行层的事。
六、I/O 也是事件
传统代码里,I/O 操作(HTTP、数据库、文件)是直接调用:
let result = db.query("SELECT * FROM orders WHERE id = ?", [id])
let response = http.post("/api/payment", data)
Pulse 里,I/O 也走事件。语言层发请求事件,执行层做真正的 I/O,再把结果作为响应事件送回来:
// 发请求
private handle LoadOrder ({ order_id }) {
send DbQuery {
id: order_id,
sql: "SELECT * FROM orders WHERE id = ?",
params: [order_id]
}
}
// 收响应
private handle DbResult ({ id, rows }) {
set orders[id] = rows[0]
}
执行层提供一组内置的 I/O 事件:
| 类型 | 请求事件 | 响应事件 |
|---|---|---|
| HTTP | HttpRequest |
HttpResponse / HttpError |
| 数据库 | DbQuery / DbExecute |
DbResult / DbError |
| 文件 | FileRead / FileWrite |
FileContent / FileError |
好处是 I/O 操作和业务事件走同一套模型,可追溯、可回放、测试时 mock 响应事件就行。
七、模块间通信
面向对象里,对象之间互相调用方法:
orderService.createOrder(data) // 直接调用
paymentService.processPayment(orderId) // 直接调用
inventoryService.reserve(orderId) // 直接调用
问题:每个服务都知道其他服务的存在,改一个全要动。
Pulse 里,模块之间不互相调用,只收发事件:
// Order 模块不知道谁会处理这个事件
send PaymentRequest { order_id, amount: total }
// Payment 模块不知道事件从哪来
private handle PaymentRequest ({ order_id, amount }) {
// 处理
}
| 对比 | 方法调用 | 事件通信 |
|---|---|---|
| 耦合 | A 知道 B 的接口 | A 只知道事件的格式 |
| 方向 | 双向(调用+返回) | 单向(发出去就完了) |
| 加新消费者 | 改调用方代码 | 加一个 handle,不改发送方 |
模块之间不需要知道彼此的内部结构,只需要约定好事件格式。
八、显式可见性
很多语言有默认可见性:
| 语言 | 默认 |
|---|---|
| Java | 包级私有 |
| Go | 小写私有 |
| Python | 全公开 |
| JavaScript | 全公开 |
Pulse 的做法:**没有默认,必须显式写 public 或 private**。
public event OrderCreated { ... } // 明确:外部可见
private state orders: Map<...> // 明确:内部使用
private handle OrderCreated (...) { // 明确:内部处理
为什么?
因为 public 的东西就是模块的对外契约,改它会影响所有消费者。显式声明让你写的时候就想清楚这一点。
九、unsafe
实际开发中总得调用外部 API、读写文件、访问硬件。这些操作会打破「无副作用」的约束,所以用 unsafe 块包起来,标记清楚:
private handle SensorData ({ device_id, reading }) {
let calibrated = calibrate(reading)
unsafe {
// 这里可以调用底层代码
// FFI、系统调用、外部 API
writeToHardware(device_id, calibrated)
}
send DataProcessed { device_id, value: calibrated }
}
和 Rust 的 unsafe 思路类似:不是禁止,而是圈起来,让代码审查时一眼能看到哪些地方有外部依赖。
十、刻意不做的东西
| 没有 | 为什么 |
|---|---|
| 继承 | 组合优于继承,上篇已论证 |
| 异常 | 错误是事件 |
| 锁 | 不共享,不需要 |
| async/await | 天然异步 |
| null(几乎) | 用 Option<T> |
| 全局变量 | 状态属于模块 |
| 回调地狱 | 事件链是扁平的 |
少一个概念,就少一类问题。
十一、从第一篇到这里
回头看这两篇文章的脉络:
第一篇:编程范式的思考
→ 并发问题的本质是共享可变状态
→ 事件驱动可以从根本上消除
→ 好设计 = 混合使用,按需选择
第二篇(本篇):Pulse 的设计之路
→ 如果把事件驱动作为唯一范式
→ 需要什么约束?15 个关键字
→ 需要什么分离?三层架构
→ 需要什么放弃?继承、异常、锁...
Pulse 就是把上篇的思路落地成具体的语法,看看「一切皆事件」写出来到底是什么样。
后记
Pulse 的核心就一句话:事件来了,处理,发新事件。 程序员只写这个,其余交给运行时。
参考
- 从面向对象到事件化:一种编程范式的思考
- Erlang/OTP: Actor 模型与消息传递
- Rust: unsafe 边界与所有权模型
- Rich Hickey: Simple Made Easy
附件:
