14 KiB
面向架构编程范式:超越 OOP 和 MVC
一、编程范式的本质
1.1 什么是编程范式
编程范式(Programming Paradigm)是指程序的表达方式,它不决定程序能实现什么功能,只决定能否方便、易懂地表达功能。
过程式表达:
f1(); f2(); f3();
面向对象表达:
obj.f1(); obj.f2(); obj.f3();
两段代码功能完全相同,都是先执行 F1,再执行 F2、F3。表达方式不同,但实现的功能是一样的。
关键结论:
- 面向对象能写的程序,过程式也能写
- 反过来也一样
- 无论用哪种范式,你的程序都能实现买东西这个功能
1.2 编程范式的真正目的
编程范式的根本目的是为了大规模代码和大规模团队分工合作。
当代码量膨胀到:
- 成千上万行
- 几十万行
- 上百万行
当团队有上百号程序员时,需要将整个项目拆分成多个模块,每个组负责一个模块。程序员需要调用其他人开发的模块,这就引出了模块封装隔离的概念。
1.3 模块封装隔离的概念
把模块的运行原理封装在模块内,只暴露接口。模块的调用者只需要会操作接口,不需要理解模块内部的运行原理,就能使用这个模块。
如果做不好封装隔离:
- 调用其他模块时,需要充分理解那个模块的内部原理
- 整个项目可能有成百上千个模块
- 需要懂上百个模块的内部原理,才能写自己的程序
- 这在实际项目中是不现实的
二、面向对象与模块封装
2.1 面向对象的封装优势
面向对象提供 class 语法,程序员可以很方便地把程序拆分成多个部分:
class A:
def interface1(self): ...
def interface2(self): ...
class B:
def interface1(self): ...
def interface3(self): ...
class C:
def interface2(self): ...
def interface3(self): ...
每个类是一个模块,每个模块下有若干接口(可外部调用的函数)。实际项目中,每个类可能有几百上千行代码,好几十个函数接口,分别由不同程序员开发。
2.2 不只是面向对象在做模块封装
# 过程式模块封装
# module_a.py
def f1(): ...
def f2(): ...
# module_b.py
def f3(): ...
# main.py
import module_a
import module_b
module_a.f1()
module_b.f3()
效果和 class 一样。但这种方案太笨重了,比用类要复杂。
2.3 工程成本原则:"太麻烦,没人用"
工程问题和数学问题不同:
- 数学算法不需要考虑成本
- 工程问题最看重的就是成本
如果一个东西的使用成本大于收益,那根本就不会有人去用它。即使这是个好东西。
这会导致:
- 只有那些最重要的功能才能采用该方案
- 一些边边角角的小功能则完全没法用
- 用它反而会让程序更复杂
结论:面向对象比过程式高级,因为面向对象提供了一种方便的用于封装接口的语法。
2.4 class 的本质澄清
class 的本质是借口(Interface)。
一个 class 定义得好不好,取决于:
- 有没有做好功能的封装
- 有没有暴露出正确的接口
至于汽车有几个轮子、像不像鸭子,根本无关紧要。
正确的类比:手机开机按钮就是一个接口。按下开机键后,手机进行一系列复杂的初始化过程,而用户不需要懂这个过程,只要会按开机键就可以了。
三、面向对象的设计缺陷
3.1 继承的问题:代码无法复用
使用继承进行代码复用的问题:
A
/ \
B C
假设 F1、F2、F3 的代码写在 A 类中。用户只需要 F1 和 F3,无法复用这段代码——因为继承会把 F2 也带进来。
3.2 组合优于继承
组合思想:
Main
/ | \
A B C
子模块拆分成 A 类、B 类、C 类。写 Main 类的程序员需要 F1、F2、F3 中的哪个就组哪个,不需要的就不组。既简单又灵活,完胜继承链条。
组合是一种思想,并不局限于某种特定语法。
3.3 多重继承的致命问题:重名冲突
这是一个记账器例子,子模块 A 和子模块 C 有一个重名的函数 F1,这会造成冲突。
有人会说:把其中一个函数重命名为其他名字不就得了?
现实项目里没这么简单:
- A 模块和 C 模块可能分别是两家公司开发的开源库
- 每个库可能有几万行代码
- F1 函数的调用链可能非常复杂
- 如果要改名,就要改动整条调用链
- 项目还可能存在其他库依赖这两个库的命名
- 要改的话就要连同其他库一起改
真正要命的是:原库可能已经在业界运行过多年,安全性和稳定性有保证。改了源代码就要承担出 bug 的风险。
3.4 这种改动要求是不合理的
对比其他领域的例子:
- 显卡上有一个电容
- 主板上的电容可能和显卡上的电容重名或型号相同
- 不会产生命名冲突
工业产品的拆分逻辑都是组合。子模块中存在重名零件,根本不影响任何东西。这才是正确的封装逻辑。
全天底下各行各业,就面向对象搞特殊。
四、MVC 方案的局限
4.1 MVC 就是组合
MVC 把功能拆分成最细的 Model(数据)和 View(函数),再通过 Controller 组合起来:
class MainController:
def __init__(self):
self.modelA = ModelA()
self.modelB = ModelB()
self.viewC = ViewC()
用哪个 model 或 view 函数就组哪个,不用的就不组。
虽然 A 和 B 存在重名函数,但是并不会产生冲突。
4.2 MVC 的问题:抽象泄露
在 Main 模块中初始化 A 和 B,并交换两者的指针时,存在一个问题:
A 和 B 的初始化逻辑泄露到 Main 这一层了。
在实际项目中:
- A 和 B 可能是别的程序员写的底层库
- Main 模块由业务层程序员写
- 底层库的逻辑本不应该泄露到业务层
正确的封装:每一层程序员将本层的逻辑封装在本层。不应该要求使用者理解本层逻辑,只要使用者会用即可。
4.3 初始化逻辑的复杂性
实际项目中初始化可能存在非常复杂的交互关系:
1. A 先初始化几步
2. 把中间结果传给 B 初始化
3. 再把中间结果传回给 A,继续初始化
另外,实际项目中子模块可能不止两个,很可能存在多个子模块初始化过程相互交织的情况。这要求 Main 程序员必须非常理解 A 和 B 等子模块的内部实现,才能写出 Main 层。
4.4 抽象泄露导致的问题
问题 1:模块无法替换
库存程序员无法写出一个模块 C 去替换模块 B,因为 Main 层耦合了模块 B 的初始化逻辑。模块 C 只要初始化逻辑稍有不同,就无法替换 B。
问题 2:中间层方案要么灵活性差,要么代码极为复杂
一种方案是加一个中间层(Middle Layer),中间层由 A 或 B 这一层的程序员编写。Main 层程序员无需理解中间层的内部实现。
但存在两种情况:
- 中间层代码最为简单,但灵活性差:Main 程序员无法选择组合哪几个子模块或者组合其他同接口的子模块。比如 Main 实现不了组合 A、C、D 三个模块,因为中间层只实现了 A、B 组合
- 让中间层适配,由 Main 层程序员自由选择组合哪些子模块:中间层的代码变得极为复杂
记住"太麻烦,没人用"原则。
4.5 依赖注入和微服务
依赖注入或微服务确实解决了抽象泄露问题。这两个方案都能进行模块替换。
衡量封装得好不好,可以看此模块能不能替换成同类模块。
唯一的问题是:这俩方案太重型了。使用成本太高。
五、时间悖论问题
5.1 时间悖论的根源
回到第一种方案,分析根本原因:
A 模块 ←→ B 模块
↓
Main 模块
这里存在一个时间悖论:
- A 模块和 B 模块是先写出来的
- Main 模块是在之后的某个时间点写出来的
如果想避免抽象泄露到 Main 层,程序员需要在 Main 类出现前表达交换 A 和 B 的指针这件事。然而,这做不到,因为 Main 只有在 A 和 B 存在后才出现。
5.2 语法限制导致的困境
但这是因为语法限制。假设编译器支持使用抽象名称作为指针名,其实是不存在时间悖论的。
六、新语法设计方案
6.1 核心语法设计
语法基本沿袭继承,但在调用函数或成员变量时,使用该成员的整个名称链:
| 符号 | 含义 |
|---|---|
..(两个点) |
向上级寻找 |
...(三个点) |
向平级寻找 |
.(一个点) |
向下级寻找 |
也可以使用上斜杠、横杠和下斜杠表示。符号不重要,重要的是原理。
6.2 use 关键字
当名称列太长时,可以使用 use 关键字进行缩写:
use module_a.b.c as abc
实际上,这个 use 关键字本质上和传统语言里包管理里的 include 或 import 是相同的东西。
6.3 super 关键字
代码中有个 super 关键字。ABCD 模块可能是先写出来的,Main 模块是未来的某个时间点写出来的。
所以写 ABCD 的时候,程序员是不知道未来那个上级模块叫什么名字。这时可以用 super 关键字表示任意名称的上级模块:
class A:
def method(self):
super..call_something() # 向上级寻找
在这种写法下:
- 底层模块的逻辑封装在底层
- 每一层程序员无需理解底层模块,只要会组合就行了
- 可以任意替换同类模块
6.4 from 和 to 关键字
from ModuleC import xxx as xxx_renamed
to ModuleX use some_function
from 和 to 关键字比看上去更重要,类似于主板设计师在 PCB 板上连同导线。这个动作是本编程范式下的基石。
6.5 模块重命名
有时子模块可能需要同类。那么可以像下面这样重命名:
class MainB:
from ModuleA as mod_a
from ModuleB as mod_b
# 两个模块都有相同的接口,但名称不同时进行重命名
6.6 新语法的优势
在抽象泄露的情况下,是无法做到自由替换子模块的,因为上级模块耦合了下层实现。每个下层实现的逻辑不同,替换时要连带替换,泄露到内层的逻辑代码。
使用新语法后:
- 每层程序员可以任意组合 B 或 C
- 两者名称不同时,使用
from关键字重命名即可 - 模块可以自由替换
七、核心原理:工业流水线思想
7.1 职能隔离的比喻
本语法的核心思想是仿照工业流水生产线生产工业品:
- 不同工段的工人是职能隔离的
- 不同工段的工人无需理解前一个工段
- 只需要会组装上一个工段传输过来的零件即可
- 同时,本工段的工人也应该将封装好的零件提供给后一个工段的工人,让其无需理解本工段逻辑
7.2 空间位置关系
拿主板和显卡的例子来做说明:
- 主板上的一个电容和显卡上的一个电容重名或型号相同时,不会产生名称冲突
- 原因在于,主板和显卡是通过三维空间位置寻找子零件
本语法中的全路径巡境表达的是对象成员的空间位置关系,相当于主板设计师在 PCB 板上按空间位置连同导线。
7.3 编译期依赖注入
本语法相当于在面向对象的继承语法的基础上,在编译期实现依赖注入。
本语法是对面向对象的语法的发展或改进,而非否定。
八、API 关键字与调用链耦合
8.1 基本语法存在的问题
但刚刚的基本语法存在一个问题:调用链耦合了其他模块的名称链。
看回例子:
- 模块 A 再调用模块 B 时,调用链耦合了 B 到 D 这个名称链
- 在实际项目中,模块 B 可能是其他公司开发的开源库
- 它写的名称链可能是 B → E → D
- 模块 A 的程序员是不可能要求 B 程序员按 A 的要求改名的
所以这种基本语法仅适用于模块内部,程序员可以完全控制代码的情况下。
8.2 API 关键字解决方案
当模块间进行交互时,需要另外一种封装机制:API 关键字。
class ModuleA:
@API # 标记为公开接口
def public_interface(self): ...
def _private_method(self): ... # 内部方法
本语法中不存在其他面向对象语言中的 public 和 private 关键字,而是使用 API 关键字控制可见性。
8.3 API 关键字的作用
当模块 M 的未标记为 API 时:
- 该 M 的成员变量和成员函数全是 public 公有
- 可被外部任意访问
使用 API 关键字可以:
- 精确控制哪些接口对外暴露
- 将调用链耦合封装在模块内部
- 允许外部替换同接口的模块而不影响内部实现
九、总结
9.1 编程范式演进路径
| 阶段 | 特点 | 问题 |
|---|---|---|
| 过程式 | 简单直接 | 代码膨胀后难以维护 |
| 面向对象 | class 语法便于封装 | 继承导致代码无法复用、重名冲突 |
| MVC | 组合思想 | 抽象泄露、初始化逻辑耦合 |
| 依赖注入/微服务 | 解决抽象泄露 | 太重型、成本高 |
9.2 新范式的核心要点
- 全路径巡境:通过名称链完整表达模块间的空间位置关系
- 编译期依赖注入:在编译阶段完成依赖关系的绑定
- 职能隔离:仿照工业流水线,每层程序员无需理解底层
- 模块可替换:衡量封装好不好的标准是能否自由替换同类模块
- API 关键字:控制模块间交互的接口暴露
9.3 对面向对象的态度
本语法是对面向对象的语法的发展或改进,而非否定。它解决了:
- 继承的代码复用问题
- 多重继承的重名冲突问题
- MVC 的抽象泄露问题
- 时间悖论导致的初始化耦合问题
相关讨论补充(来自弹幕):
- 边界条件处理和输入检查在任何范式下都需要考虑
- 用函数做 API 和用类做 API 区别挺大——类可以维护状态,函数式更纯粹
- 有些成员变量要维护的话,面向对象还是最合适的