Contents
  1. 1. (二)Pulse 语言的设计思路
    1. 1.1. 前言
    2. 1.2. 一、从结论到约束
    3. 1.3. 二、func 与 handle 的分离
    4. 1.4. 三、15 个关键字
    5. 1.5. 四、错误也是事件
    6. 1.6. 五、三层分离
    7. 1.7. 六、I/O 也是事件
    8. 1.8. 七、模块间通信
    9. 1.9. 八、显式可见性
    10. 1.10. 九、unsafe
    11. 1.11. 十、刻意不做的东西
    12. 1.12. 十一、从第一篇到这里
    13. 1.13. 后记
    14. 1.14. 参考

(二)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 可以 sendset

这样分的好处是可测试性好: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: "余额不足" }
    }
}

PaymentFailedPaymentSuccess 一样,有类型定义、有处理逻辑、可以被追踪和订阅。


五、三层分离

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 的做法:**没有默认,必须显式写 publicprivate**。

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 的核心就一句话:事件来了,处理,发新事件。 程序员只写这个,其余交给运行时。


参考

附件:

Contents
  1. 1. (二)Pulse 语言的设计思路
    1. 1.1. 前言
    2. 1.2. 一、从结论到约束
    3. 1.3. 二、func 与 handle 的分离
    4. 1.4. 三、15 个关键字
    5. 1.5. 四、错误也是事件
    6. 1.6. 五、三层分离
    7. 1.7. 六、I/O 也是事件
    8. 1.8. 七、模块间通信
    9. 1.9. 八、显式可见性
    10. 1.10. 九、unsafe
    11. 1.11. 十、刻意不做的东西
    12. 1.12. 十一、从第一篇到这里
    13. 1.13. 后记
    14. 1.14. 参考