过程式与声明式
有一个意图:找出所有年龄大于 18 的用户,按名字排序。
# 过程式:你来翻译
result = []
for user in users:
if user.age > 18:
result.append(user)
result.sort(key=lambda u: u.name)-- 声明式:你描述意图
SELECT * FROM users WHERE age > 18 ORDER BY name;同样的意图,两种截然不同的表达。前者,你告诉机器每一步怎么走:先遍历、再过滤、再排序。后者,你只描述你要什么,至于用 B-tree 还是全表扫描、用归并排序还是快速排序,你完全不知道,也不需要知道。
这个差异不是风格问题。背后是一个根本性的问题:谁来负责把意图翻译成执行?
一、两种程序观
过程式:程序是一份指令清单
过程式程序员看到的世界是时序的:程序是一组按顺序发生的事情。基本单元是语句(statement)——每条语句都在改变世界的状态。
int balance = 1000; // 语句:建立状态
balance -= 200; // 语句:改变状态
printf("%d\n", balance); // 语句:读取状态这种世界观来自机器本身的本质。冯·诺依曼架构的 CPU 就是这样工作的:取指令、解码、执行、写回,一步一步,状态逐渐变迁。过程式是对机器工作方式最直接的映射。
图灵机是过程式思维的数学原型。1936 年,图灵描述了一台假想的机器:一条无限长纸带、一个读写头、一组状态转移规则。计算的本质是:读取当前符号,查找规则,写入新符号,移动读写头,切换状态。这是”怎么做”的最纯粹表达——步骤、状态、时序,三位一体。
声明式:程序是一份意图规格
声明式程序员看到的世界是逻辑的:程序是对结果的约束描述。基本单元是表达式(expression)或约束(constraint)——描述”它应该是什么”,不说”怎么到达”。
SELECT name, age FROM users
WHERE age > 18
ORDER BY name
LIMIT 10;这四行没有任何”步骤”。它是一份规格说明:我要名字和年龄,来自用户表,满足年龄大于 18,按名字有序,取前十个。执行计划是数据库的内部事务,与你无关。
Lambda 演算是声明式思维的数学原型。同样在 1936 年,Church 描述了一套完全不同的计算模型:表达式、函数、化简规则。(λx. x + 1) 3 直接化简为 4——没有”先加载寄存器,再执行 ADD 指令”,只有”这个表达式的值是什么”。时间隐藏在化简过程中,对使用者不可见。
数学上等价,认知上天壤之别
Church-Turing 论题的核心结论:图灵机与 Lambda 演算在计算能力上完全等价。任何图灵机能算的,Lambda 演算也能算,反之亦然。
但认知方式截然不同:
| 维度 | 图灵机(过程式) | Lambda 演算(声明式) |
|---|---|---|
| 基本单元 | 状态 + 转移规则 | 表达式 + 化简规则 |
| 时间 | 显式,步骤有先后 | 隐含,化简无时序 |
| 状态 | 核心概念,显式修改 | 不存在,只有值 |
| 程序员在想 | ”先做这个,再做那个" | "这个等于什么” |
两种模型的等价性是一个深刻的数学事实:它证明了两种思维方式都是完整的,没有哪个更”本质”。你可以用任意一种表达任何计算——选择哪种,是认知偏好和工程权衡,不是能力边界。
二、谁负责翻译
过程式:程序员是人肉翻译机
过程式编程有一个隐藏的认知负担:你需要在两个层面同时思考。一个层面是业务意图(我要找出成年用户),另一个层面是机器执行(循环边界是什么,临时列表怎么初始化,比较函数怎么写)。
这个翻译工作全部由程序员承担。你是业务意图和机器指令之间的翻译员,而且是唯一的翻译员。
好处是透明:你知道每一步发生了什么。坏处是认知负荷高:你要同时维护”我要什么”和”我怎么做到”两套思维。随着系统规模增大,翻译工作的复杂度远超业务本身的复杂度。
声明式:翻译工作被委托出去
声明式的本质是一次信任委托:你把”怎么做”的权力交给运行时、编译器或框架,自己只保留”要什么”的描述权。
-- 你负责描述意图
SELECT * FROM users WHERE age > 18 ORDER BY name;
-- 数据库负责翻译(你不知道,也不需要知道)
-- → 检查是否有 age 字段的索引
-- → 估算全表扫描 vs 索引扫描的代价
-- → 选择执行计划
-- → 流水线执行,必要时并行化这个委托有明确的前提:你相信运行时比你更懂”怎么做”。
声明式的优势来自这个信任成立的场景:
- SQL 引擎的查询优化器掌握统计信息,能选出你想不到的最优执行计划
- React 调度器知道浏览器的刷新时机,能批量合并更新,你手动操作 DOM 做不到
- Terraform 控制器知道资源的依赖关系,能并行创建独立资源,你手写 API 调用序列难以实现
当信任破裂——ORM 生成了 N+1 查询,React 触发了不必要的深层重渲染——声明式的抽象层变成了阻碍,你必须打破它,回到过程式精确控制。
封装决策,而非封装数据
声明式隐藏的不只是”步骤”,而是设计决策。
OOP 的封装隐藏了数据(Parnas 1972:隐藏的是设计决策,不是数据)。声明式的封装更进一步——它隐藏了执行策略这个最大的设计决策。当 PostgreSQL 升级查询优化器,你的 SQL 自动变快;当 React 升级调度器,你的组件渲染自动更高效。你的代码没变,但”怎么做”已经悄悄改进了。
这是声明式最深层的工程价值:你的代码描述了永远成立的意图,运行时持续改进实现它的方式。
三、时间藏在哪里
过程式:程序员是时间的主人
过程式代码的执行顺序就是你写的顺序。时间是显式的,是你的责任。
# 顺序是语义的一部分——调换任意两行可能改变结果
balance = 1000
balance -= 200 # 必须在取款后检查
if balance < 0:
raise InsufficientFundsError()
log_transaction(200) # 必须在操作成功后记录这带来精确控制:副作用的时序由你保证,任何”先后”关系都在你的掌控中。
但这也是一种认知负担:你脑子里需要同时维护一条时间线。当并发出现,多条时间线交织,过程式的时序管理变得指数级复杂——这是数据竞争、死锁、顺序依赖 bug 的根源。
声明式:时间被委托进运行时
声明式把时间藏起来了。
SELECT * FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.created_at > '2026-01-01';这里没有”先查 orders,再查 users”。SQL 引擎可能并行扫描两张表,可能先扫描小表再用 hash join,可能直接用索引跳过大部分数据。你声明了结果的形状,时间被隐藏在执行计划里。
// React 组件:只描述 UI 应该长什么样
function UserList({ users }) {
return (
<ul>
{users.filter(u => u.age > 18).map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
// React 决定何时渲染、如何 diff、批量还是立即更新你没有在控制渲染时机,React 的调度器在控制。
三种对待时间的哲学
这门课已经出现过三种不同的时间哲学:
| 范式 | 对时间的态度 | 手段 |
|---|---|---|
| 过程式 | 时间是我的领地,我掌控每一步 | 显式的步骤序列和状态变迁 |
| 函数式 | 时间是问题,消灭它 | 不可变性——每个”变化”都是新值 |
| 声明式 | 时间是运行时的事,不是我的事 | 委托——描述终态,执行时序外包 |
FP 是激进的:拒绝承认可变状态的存在。声明式是务实的:时间还在,但那是别人的责任。声明式配置(Terraform、Kubernetes)将这个思路推到极致——你声明系统”应该是什么状态”,控制器负责在任何时刻将现实状态调和到目标状态,时间和顺序完全不是你要关心的事。
声明式是一个光谱
“声明式”不是一个开关,而是一个光谱——越往右走,时间被藏得越深:
过程式 → 函数式 → 声明式查询 → 声明式配置
显式步骤序列 不可变值变换 描述结果形状 描述终态
程序员控制时间 时间不存在 运行时控制时间 控制器负责调和
汇编 / C Haskell SQL / GraphQL Terraform / K8s没有哪个位置天然更好。越靠右,你放弃的控制越多,获得的优化空间越大;越靠左,你保留的精确控制越多,认知负担越重。工程决策是在两者之间找到合适的位置。
四、控制如何让渡
过程式的组合:调用栈
过程式代码通过调用栈组合:函数调用函数,你知道每一层在发生什么。这是透明的组合——你可以用 debugger 单步执行,逐层检查。
def process_payment(order):
validate_order(order) # 你知道这先发生
charge_card(order.card) # 你知道这后发生
update_inventory(order.items) # 你知道这再后发生
send_confirmation(order.email) # 你知道这最后发生调用栈是过程式的核心组合机制,也是它的认知模型:程序的执行就是一棵调用树的深度优先遍历。
声明式的组合:约束叠加
声明式代码通过约束叠加组合:把多个约束组合在一起,描述更复杂的意图。
-- 约束叠加:每个子句是一层约束,组合成完整描述
SELECT
u.name,
COUNT(o.id) AS order_count,
SUM(o.amount) AS total_spent
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2025-01-01'
GROUP BY u.id
HAVING total_spent > 1000
ORDER BY total_spent DESC;这里没有”先做 JOIN 再做 WHERE”的时序——这些约束是同时成立的,执行顺序由优化器决定。你只是把多个约束叠在一起,描述了你想要的结果的形状。
React 组件树是同样的逻辑:
<AuthGuard>
<Layout>
<UserList filter={activeFilter} sort={currentSort} />
</Layout>
</AuthGuard>每一层组件是一层约束(认证检查、布局、数据过滤),组合在一起描述了 UI 的完整形状。没有”先渲染 AuthGuard 再渲染 Layout”的显式时序,React 决定如何高效地把这棵树变成 DOM。
声明式 DSL 的边界
声明式抽象能走多深,取决于 DSL 的表达力边界。当意图无法被 DSL 表达时,就会出现”从声明式逃逸回过程式”的操作:
-- SQL 无法表达复杂的递归逻辑,用存储过程逃逸回过程式
CREATE PROCEDURE calculate_compound_interest(...)
BEGIN
DECLARE i INT DEFAULT 0;
WHILE i < years DO
-- 显式循环,过程式
SET balance = balance * (1 + rate);
SET i = i + 1;
END WHILE;
END;// React 无法表达复杂的 DOM 测量逻辑,用 ref 逃逸回命令式
const divRef = useRef(null);
useEffect(() => {
// 过程式:直接操作 DOM
const height = divRef.current.getBoundingClientRect().height;
setCalculatedHeight(height);
}, []);这不是设计缺陷,是合理的工程边界:声明式抽象覆盖 80% 的场景,剩下 20% 的边界情况需要过程式兜底。 好的 DSL 设计会把这个逃逸口设计得清晰且受控。
五、多语对照:三种语言在光谱上的位置
基础风格倾向
| 维度 | Python | TypeScript | Rust |
|---|---|---|---|
| 基底风格 | 过程式为基底,声明式渐进渗透 | 声明式天然土壤(类型系统 + UI) | 过程式精确控制 + 声明式零成本抽象 |
| 声明式入口 | 推导式、生成器表达式 | 条件类型、映射类型 | 迭代器链、derive 宏 |
| 运行时代价 | 无单态化,动态分派 | 类型擦除,编译后消失 | 单态化,零运行时开销 |
声明式的不同表达
Python:声明式通过推导式渗透进过程式代码。
# 过程式
result = []
for user in users:
if user.age > 18:
result.append(user.name.upper())
# 声明式(推导式)
result = [user.name.upper() for user in users if user.age > 18]两者在 Python 里都是自然的写法,Python 不强迫你选边。
TypeScript:类型系统本身就是声明式的——你声明值的形状,编译器负责检查约束是否满足。
// 类型级别的声明式:描述"结果应该是什么形状"
type ActiveAdminUsers = {
[K in keyof User as User[K] extends true ? K : never]: User[K]
};
// 编译器推导所有满足约束的键,你没有写任何"怎么做"TypeScript 的类型系统是图灵完备的,这意味着可以在类型层面做任意复杂的声明式计算。
Rust:迭代器链是过程式性能 + 声明式表达的组合,零成本抽象让声明式不牺牲性能。
// 声明式的表达,过程式的性能(编译器展开为等价的循环)
let result: Vec<String> = users.iter()
.filter(|u| u.age > 18)
.map(|u| u.name.to_uppercase())
.collect();Rust 的核心洞见:声明式不必然意味着运行时开销。迭代器链在编译时被展开为零抽象的机器码,和手写循环性能完全相同。
三语言的哲学立场
- Python:信任程序员选择合适的风格。过程式和声明式共存,混用是正常的,不强加一种世界观。
- TypeScript:类型是声明式的核心资产。类型系统本身比运行时代码更声明式,这是 TypeScript 最独特的声明式贡献。
- Rust:零成本抽象打破了”声明式 = 慢”的误解。在 Rust 里,声明式写法和过程式写法往往生成完全相同的机器码——你可以在不付出性能代价的前提下选择更清晰的声明式表达。
延伸阅读
- Abelson & Sussman《SICP》(1984) — 编程范式基础,第一章即讨论过程抽象
- Church《An Unsolvable Problem of Elementary Number Theory》(1936) — Lambda 演算原始论文
- Codd, E.F.《A Relational Model of Data》(1970) — 关系模型和 SQL 的声明式基础
关联 meta 维度
- 02 函数式编程 — 声明式最自然的载体;FP 消灭时间,声明式委托时间
- 04 泛型编程 — 类型层面的声明式抽象
- 05 响应式与事件驱动 — 声明式数据流;时间作为一等公民
- meta: 编译与执行 — 声明式的执行背后:编译器如何翻译意图