Files
2026-07-15 21:58:14 +08:00

248 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 编程通用范式:编排-计算分离与渐进结构化
> 本文档从具体语言实现中提炼出语言无关的编程范式。
> 这套范式适用于任何支持函数/模块/接口抽象的语言。
> 分卷:[扩展模型与参数设计](programming-paradigm-extensions.md) · [编写与重构规则](programming-paradigm-practices.md)
---
## 第一部分:核心模型
### 0. 基础约束:代码是写给人看的
所有规则的根基是一条认知事实:**人在单一时刻能处理的信息量是有限的**。代码的一切组织方式——函数拆分、文件划分、层次结构——最终都是为了让阅读者在任意时刻只需面对"刚好能理解"的信息量。
#### 单函数:一屏可读
一个函数应该在**一屏之内读完**,阅读者不需要滚动就能看到全貌。这不是审美偏好,而是认知约束——滚动意味着阅读者必须用记忆力去拼接上下文,记忆力是最不可靠的资源。
经验数字:
- **编排函数**:10-30 行最佳,每一行是一个动作名。超过 50 行几乎必然层级混乱。
- **计算函数**:不超过 50 行。超了说明它做的事情不止一件,应该继续拆。
- **硬上限**:单函数超过 100 行,无论什么理由,都是需要拆分的信号。
#### 单文件:一个文件一个角色
一个文件应该用**一句话说清它的职责**。说不清 → 文件该拆。
经验数字:
- **编排文件**(Workflow / Handler):50-150 行。它只做编排,超了说明业务逻辑混进来了。
- **计算文件**(领域服务 / 工具函数):100-300 行。一个内聚的计算主题。
- **超过 400 行**:几乎必然职责混合。不是"太长了",是"两件事混在一个文件里了"。
拆文件的判断方法:**数一下文件里有几组"不相关的函数"**。如果函数 A-D 互相调用但从不调用 E-G,而 E-G 互相调用也从不调用 A-D——它们就是两个文件。
#### 单次可见逻辑量
在任意一个抽象层级上,阅读者一次看到的**逻辑分支不应超过 7±2 个**(米勒定律)。
- 一个编排函数里,步骤超过 7 个 → 考虑分成"阶段",每个阶段是一个子编排
- 一个 if/switch 里,分支超过 5 个 → 考虑用注册发现或策略模式替代
- 一个模块的公开接口超过 7 个方法 → 考虑拆成更小的模块
- 一个函数同时操作超过 7 个概念(order、customer、payment...)→ 拆成两步
#### 信息密度的平衡
过疏和过密都有害:
- **过密**:一个函数里塞了加载、计算、持久化、异常处理、日志——阅读者被淹没
- **过疏**:每个操作都拆成单独的类和文件,3 行逻辑散落在 5 个文件里——阅读者被迫四处跳转拼接
**判断标准:阅读者理解一段逻辑需要同时打开几个文件?** 如果超过 3 个,说明拆得太碎了。所有规则(分层、拆文件、抽函数)都要在"单点信息过载"和"跨文件跳转"之间找平衡。
#### 文件内的信息布局
一个文件被打开后,阅读者在**前 10 秒**应该能回答:
1. **这个文件是干什么的**(文件名 + 顶部注释/类名)
2. **核心流程是什么**(编排函数在最前面,不要埋在 500 行之后)
3. **依赖了谁**(import / 构造参数一目了然)
具体做法:
- **编排函数/主函数放在文件最前面**,不要被辅助函数埋没
- **公开接口在前,私有实现在后**——阅读者先看"做什么",有兴趣再往下看"怎么做"
- **命名要承载意图**——好的命名让阅读者跳过实现。`compute_risk(order, customer)` 不需要看函数体就知道它干什么;`process(data)` 什么都没说
#### 核心判断标准
所有数字(50 行、400 行、7 个概念)都是启发式信号。真正的判断标准只有一个:
> **一个新加入团队的人,打开这个文件/函数,能否在 30 秒内理解它的意图?**
>
> 能 → 信息密度合适。
> 不能 → 要么太长(拆)、要么层级混乱(抽取)、要么命名不好(重命名)。
### 1. 三段式执行模型
任何有副作用的业务操作,都可以分解为三个阶段:
```
加载(Load)→ 计算(Compute)→ 持久化(Persist)
```
- **加载**:从外部世界(数据库、文件、网络)读取状态,装入内存结构
- **计算**:纯逻辑处理,不接触外部世界,输入决定输出
- **持久化**:把计算结果写回外部世界
这三个阶段的代码**不应混在同一个函数里**。混合是复杂性的第一来源——你无法单独测试计算逻辑,因为它和 IO 纠缠在一起。
**不同语言的实现方式不同,但分离原则不变**:
- 函数式语言天然做到了(纯函数 + IO Monad)
- 面向对象语言需要显式分层(Loader / Service / Persister)
- 脚本语言需要纪律约束(把 IO 调用集中到函数首尾)
### 2. 状态是显式的容器,不是隐式的环境
程序中流转的状态应该是一个**显式的数据结构**,在函数间作为参数传递,而不是藏在全局变量、线程本地存储、闭包捕获、或模块级 mutable state 中。
```
// 原则
f(state) → state'
// 而不是
f() // 偷偷读写某个外部的 state
```
这个显式的数据结构(我们称之为 **Context**)有三层内容:
| 层次 | 性质 | 示例 |
|------|------|------|
| 不可变输入 | 请求级标识,全程不变 | request_id, tenant_id, trace_id |
| 领域状态 | 核心业务数据,可变 | order, customer, account |
| 过程产物 | 节点间的中间结果 | risk_score, payment_result |
**Context 的边界判断**:一个值该不该进 Context,问一个问题——"这个值在不同请求之间会不一样吗?" 会 → 进 Context;不会 → 它是依赖,通过构造/初始化注入。
#### 框架提供的状态容器是合法的 Context
框架提供的响应式状态容器(Vue ref/reactive、React useState、Redux store)本质上就是 Context——它们是显式的、可追踪的、框架帮你管理的状态。**使用它们不违反本原则。**
不同技术栈的 Context 形态:
| 技术栈 | 状态容器(Context) | 编排层 | 计算层 |
|--------|---------------------|--------|--------|
| Vue | ref / reactive | methods / watch | computed / 纯函数 |
| React | useState / store | event handler / useEffect | 纯函数 / useMemo |
| 后端 Java | Context 对象 | Workflow 类 | 领域服务 |
| Rust | &mut Context | orchestrator 函数 | 纯函数 |
**违反本原则的不是"改 ref",而是绕过显式容器传递可变状态**:
- 模块级变量被多个组件/函数隐式共享
- 全局对象/单例被当作垃圾桶随处读写
- 隐式注入(provide/inject、ThreadLocal)传递可变状态而非只读值或修改函数
- 闭包捕获了外层的可变变量,且修改不可追踪
判断标准:**"这个状态的修改,是否可以从编排层的代码里一眼看出来?"** 可以 → 合法;不可以 → 隐式状态,需要招安。
### 3. 编排与计算分离
这是整套范式的核心原理。
**编排(Orchestration)**= 决定"做什么、以什么顺序、在什么条件下"。
**计算(Computation)**= 具体"怎么做"。
两者必须分处不同的代码单元(函数、模块、文件)。
```
// 编排层:只有流程骨架,每一行是一个动作名
function process_order(ctx):
validate(ctx)
check_risk(ctx)
charge(ctx)
ship(ctx)
// 计算层:具体逻辑,不知道自己在哪个流程里
function check_risk(order, customer) -> risk_score:
// 纯计算,无副作用,不依赖 context 容器
```
**关键约束:计算层不应接收 Context 容器。** 它只接收自己真正需要的参数。编排层负责从 Context 中取出参数、调用计算、把结果写回 Context。
精确表述:**计算层接收值、返回值,不接触状态容器。编排层负责从容器取值、调计算、把结果写回容器。**
```
// ✅ 计算函数:接收值,返回值,不碰容器
function compute_risk(order, customer) -> score:
score = 0
if customer.bindDuration < 30: score += 40
if order.amount > 10000: score += 30
return score
// ✅ 编排层:从容器取值 → 调计算 → 写回容器
function handle_submit():
score = compute_risk(order.value, customer.value) // 取值 + 调计算
risk_score.value = score // 写回容器
// ❌ 计算和编排混在一起
function compute_risk():
score = 0
if customer.value.bindDuration < 30: score += 40 // 直接读容器
risk_score.value = score // 直接写容器
api.charge(order.value.id) // 连持久化都混进来了
```
**只读派生不算"接触容器"。** 响应式框架中的派生计算(Vue computed、React useMemo)读取容器但不修改状态,数学上是 `f(state) → derived_value`,属于计算层而非编排层。
这条约束的意义:
- 计算函数的签名暴露了它的真实依赖,可读性极高
- 计算函数可以脱离 Context 独立测试
- Context 的变更不会波及计算层
#### 编排层是一个职责,不是一个固定的架构层
编排的本质是**"组装能力而不实现能力"**。这个职责可以由不同重量级的代码单元承担:
```
最轻 最重
│ │
调用点直接组合 提取组合模块 独立编排模块/类
(模板里写slot组合) (CopyableTip) (OrderWorkflow)
│ │
适合:1-2次使用 适合:3+次重复模式 适合:核心业务流程
不额外建文件 多一个小文件 独立文件,专职编排
```
三者做的事情完全一样——组装能力。区别只是**固化程度**。
后端倾向重量级编排(独立 Workflow 类),因为流程涉及事务边界、持久化时机、多入口调用,这些约束迫使编排逻辑必须集中管理。
前端组件组合通常没有这些约束——一个页面模板组合几个组件,不涉及事务、调用点通常就一个——所以调用点直接组合就够了。
**前端需要重量级编排的信号**:
- 多步表单/向导:步骤之间有依赖和条件跳转
- 复杂状态联动:改 A 要联动更新 B、C、D,不该散落在各个 watch 里
- 同一组合模式重复 3 次以上
选择哪个重量级,问三个问题:
1. **有没有事务/持久化/多入口等约束?** 有 → 独立编排模块
2. **这个组合模式重复出现 3 次以上了吗?** 是 → 提取组合模块
3. **都不是?** → 调用点直接组合,最简方案先行
### 4. 同层同级原则
基础约束(第 0 节)在代码层面的直接推论:**一个函数体内的每一行代码,应该处于同一个抽象层级**。
```
// ✅ 同一层级:都是"做什么"
validate(ctx)
check_risk(ctx)
charge(ctx)
ship(ctx)
// ❌ 层级混乱
validate(ctx)
score = 0
for rule in rules: // 突然掉到"怎么做"
score += rule.eval(order)
charge(ctx)
ship(ctx)
```
判断标准:**每一行能不能用一句自然语言概括为"做了什么"**——如果需要说"它遍历了一个列表然后累加了分数",那这段代码属于下一层。
---