编程新手常见问题2:class如何设计的tips
最近带师妹写代码,反馈说之前写的技术文章(比如[[05-05 编程新手常见问题:数据结构使用、减少冗余、设计状态]])看不懂。只能说明我对新手有一些误解,所以只好重新写一下入门教程,class到底如何设计。
为什么有class
首先介绍一下class起源。面向对象的说法最早起源于smalltalk,但smalltalk的面向对象和现代含义不太一样,它的抽象其实是消息传递机制,类似于现在所说的python的鸭子类型,对象内部是什么不重要,关键是它的函数接口和能处理的行为。观测它实际的代码,smalltalk反而跟lisp更为神似。
现在主流使用的class起源于C++,真正成熟的设计是Java,它借鉴了smalltalk很多反射机制。它和过程型的语言(比如C)没有本质区别,因为纯使用static也可以模拟C的写法,它制度化了class内部状态和消息传递机制(更现代的说法是接口)。
这样有什么好处呢?它提供了一个通用的抽象:
首先是状态转移方程,作为class内部的成员更新状态:
其中,x是状态,u是输入。比如打方向盘,汽车偏移。
然后是观测接口,对应各种常见的getter:
class StateMachine<X, U, Y>{
X State;
public void Step(U input); // f
public Y Observation(); // g, getXXX()
}
比如:汽车模型包括轮子的相位,但是你一般只能读取转速。这样的抽象适用于任意的物理对象和赛博对象。
与过程型编程语言的区别
表面上,它多出来了一系列抽象:关键词class,public/private成员,函数,static函数等等。相比于,过程性的写法:
X f(X state, U input);
Y g(X state);
它不需要传第一个参数state X,可以少写一点代码。
但实际上,它提供了一种消息传递的思路,即接口:
interface IProcess{
public void Step(U action);
public Y Observation();
}
它和底层具体的转移过程f和状态x完全解耦了,无关了。只要任意接口实现了接口,就能够在满足接口的语言的任意位置使用。因此,我们在编程的时候,实际解决的问题是应该如何抽象,语义应该如何设计。(技巧3详细说明)
核心的设计思路
class除了状态、动作、传递函数、观测函数、观测值五个部分,还有一个组成部分是参数,比如汽车的长度、汽油号数等等静态的、描述性的字段。
对于新手,在编写代码前需要在纸上绘制代码框架的流程图和数据结构图:
- 数据结构图:代码包括哪些对象(class),它包括哪些字段。哪些class直接存在关系,class之间的关系是一对一还是一对多。
- 流程图:第一步干什么,第二步干什么,每一步对应的是class/interface的函数签名,思考具体调用了哪些函数。
完成这两步后,涉及的具体数据结构(主要是动作、观测值)、状态转移过程和语义(接口函数)就明确了。(更系统的流程需要学习UML)
剩下的就是完善初始化过程,每个class需要什么状态,具体如何通过参数构造。
技巧1:变和不变的分离
对于数据字段,有不变的和变的:
- 不变作为参数,或打包为setting/config class,在class的构造函数输入。
- 变的一般是状态、动作。状态作为class内部成员,动作和观测值作为函数签名的输入和输出部分。
因此,在使用record的时候(作为原子的、数据对象的class),主要包含四种对象:
- 参数信息:config/setting。
- 观测值:Observation。可以打包观测,也可以写getter。
- 输入动作。
- 状态的快照,有的时候需要持久化,需要把状态dump出来,和观测值类似,不一定和实际内存状态完全相等。
技巧2:接口的字段可以不用
在改进代码的时候,常常遇见以下场景:
- 需要实现不同的算法,但是输入不一致,需要if判断接不同入口。并且可能不断地增加算法。
- 有了新的思路,需要新的输入,之前的接口都没法用了。
对于第一个场景:不一定需要根据新的想法大改代码,更工程的思路是将不同的逻辑抽象出来,通过上层注入。比如:
interface IAlgorithm{
void Step(U1 u1, U2 u2);
}
class Algorithm1 : IAlgorithm{
void StepInternal(U1 u1){...}
void Step(U1 u1, U2 u2){
StepInternal(u1);
}
}
class Algorithm2 : IAlgorithm{
void Step(U1 u1, U2 u2);
}
注意,这个例子中Algorithm1只使用了参数U1,没有使用参数U2,但是它们可以共享接口IAlgorithm。也就是说接口定义的方法,class实现接口可以不使用。
对于第二种场景:如果接口参数特别长,或者因为兼容性的问题特别难修改,替代性的方法就是根据type内部实现多态:
// 作为兼容性输入的占位符
interface IInput{
int Parameter1(); // v1
int? OptionalParameter()=>null; // v2新增也不会破坏兼容
}
class Input1(int x1):IInput;
class Input2(int x1, int x2):IInput;
class Algorithm{
void Step(Normal input, IInput extraParameters){
if (extraParameters is Input1 input1){...}
else if (extraParameters is Input2 input2){...}
else{
throw new NotImplementException();
}
}
}
此处的IInput可以是接口也可以是abstract class,通过同样的方法实现多态。Normal input是都有的参数。IInput extraParameters是包含兼容性设计的参数,可以通过内部的实例化实现任意多的参数,并且IInput可以增加可选的新成员,实现无感的升级和向前兼容。
技巧3:通过接口的行为可以屏蔽内部实现
面向对象的核心思想和动态语言没有本质差异,所谓的鸭子类型:如果一个类型,吃的是鸭子吃的,拉的是鸭子拉的,它的行为是鸭子——那不管鸭子里面是机器人还是其它什么扮演的,它都是鸭子。
而interface就是鸭子类型的制度化。只要一个class满足interface的所有行为,那么它内部的状态x、状态转移f、观测函数g都可以通过interface的方法屏蔽掉内部行为。
通过这个技巧可以实现很多东西:
- 方法内部使用大模型完成。
- 屏蔽远程过程调用,比如一个函数可以本地计算完成,也可以在其它电脑完成,通过网络获得结果。
- 数学上,积分可以用确定性算法,比如辛普森积分,也可以蒙特卡洛。
所以问题的关键是,对于你要解决的问题,哪些行为是不变的,哪些行为是有多种实现路径的。
从 Python 迁移到静态类型语言(如 Java、C++、Rust)时,一个常见的不习惯是:Python 里可以随手用 hasattr / isinstance 在运行时检查对象,再根据实际类型决定行为。而在静态类型语言里,这类"运行时类型判断"通常不受欢迎,需要用多态、接口或模式匹配来替代。
典型场景:一个子类继承自基类、带有额外字段,在调用 process() 时需要判断它到底是哪个类型,再做额外的更新逻辑。
from dataclasses import dataclass, field
@dataclass
class Document:
name: str
@dataclass
class Invoice(Document):
tax_id: str = ""
def process(doc: Document) -> None:
# 通用逻辑:所有 Document 都会执行
print(f"[process] handling {doc.name}")
# 类型分发:只有 Invoice 才做额外更新
if isinstance(doc, Invoice):
print(f"[process] extra step: validating tax_id={doc.tax_id}")
# 另一种写法:用 hasattr 检查字段是否存在(更"鸭子类型")
if hasattr(doc, "tax_id"):
print(f"[process] found tax_id via hasattr: {doc.tax_id}")
if __name__ == "__main__":
process(Document(name="report.txt"))
print("---")
process(Invoice(name="invoice_001.pdf", tax_id="CN-123456"))
静态类型语言(Java、C#、C++、Rust、TypeScript、Go 等)在编译期就确定类型,运行时类型判断虽然通常仍可用(如 Java 的 instanceof、C# 的 is、Rust 的 match + 枚举)。
interface Document {
void process();
}
class Report implements Document {
private final String name;
public void process() {
System.out.println("[process] handling " + name);
}
}
class Invoice implements Document {
private final String name;
private final String taxId;
public void process() {
System.out.println("[process] handling " + name);
System.out.println("[process] extra: " + taxId); // 额外逻辑在类型内部
}
}
// 调用方:无需 instanceof,无需转型
void runAll(List<Document> docs) {
for (Document d : docs) d.process();
}
但不是语言鼓励的默认做法。原因是:
- 破坏开闭原则:每加一个子类型,就要回来改
process()里的判断分支,容易漏改。 - 编译器帮不上忙:分支里访问子类特有字段时,静态语言要求先显式向下转型,转型失败在运行时才暴露。
- 类型信息被浪费:静态语言最大的优势是编译期就能校验,用运行时判断等于绕过了这个优势。
最好的做法是在基类/接口声明 process(),各子类各自实现;调用方只依赖基类接口,不判断类型,从而实现依赖倒置。需要强调的是,开闭原则,如果是用于参数兼容性设计,数据结构的多态,那这是很方便的设计,也比较容易维护。如果是算法的多态,但将内部逻辑泄露到外部,就是极其糟糕的实践。
评论
暂无评论,快来发表第一条评论吧!