编程新手常见问题2:class如何设计的tips

柏舟   11-04 阅读次数 1
作者:柏舟
发布日期:11-04
阅读次数 1

最近带师妹写代码,反馈说之前写的技术文章(比如[[05-05 编程新手常见问题:数据结构使用、减少冗余、设计状态]])看不懂。只能说明我对新手有一些误解,所以只好重新写一下入门教程,class到底如何设计。

为什么有class

首先介绍一下class起源。面向对象的说法最早起源于smalltalk,但smalltalk的面向对象和现代含义不太一样,它的抽象其实是消息传递机制,类似于现在所说的python的鸭子类型,对象内部是什么不重要,关键是它的函数接口和能处理的行为。观测它实际的代码,smalltalk反而跟lisp更为神似。

现在主流使用的class起源于C++,真正成熟的设计是Java,它借鉴了smalltalk很多反射机制。它和过程型的语言(比如C)没有本质区别,因为纯使用static也可以模拟C的写法,它制度化了class内部状态和消息传递机制(更现代的说法是接口)。

这样有什么好处呢?它提供了一个通用的抽象:

首先是状态转移方程,作为class内部的成员更新状态:

\[ x_{k+1}=f(x_k, u_k) \]

其中,x是状态,u是输入。比如打方向盘,汽车偏移。

然后是观测接口,对应各种常见的getter:

\[ y_k=g(x_k) \]

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除了状态、动作、传递函数、观测函数、观测值五个部分,还有一个组成部分是参数,比如汽车的长度、汽油号数等等静态的、描述性的字段。

对于新手,在编写代码前需要在纸上绘制代码框架的流程图和数据结构图:

完成这两步后,涉及的具体数据结构(主要是动作、观测值)、状态转移过程和语义(接口函数)就明确了。(更系统的流程需要学习UML)

剩下的就是完善初始化过程,每个class需要什么状态,具体如何通过参数构造。

技巧1:变和不变的分离

对于数据字段,有不变的和变的:

因此,在使用record的时候(作为原子的、数据对象的class),主要包含四种对象:

技巧2:接口的字段可以不用

在改进代码的时候,常常遇见以下场景:

  1. 需要实现不同的算法,但是输入不一致,需要if判断接不同入口。并且可能不断地增加算法。
  2. 有了新的思路,需要新的输入,之前的接口都没法用了。

对于第一个场景:不一定需要根据新的想法大改代码,更工程的思路是将不同的逻辑抽象出来,通过上层注入。比如:

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();
}

但不是语言鼓励的默认做法。原因是:

  1. 破坏开闭原则:每加一个子类型,就要回来改 process() 里的判断分支,容易漏改。
  2. 编译器帮不上忙:分支里访问子类特有字段时,静态语言要求先显式向下转型,转型失败在运行时才暴露。
  3. 类型信息被浪费:静态语言最大的优势是编译期就能校验,用运行时判断等于绕过了这个优势。

最好的做法是在基类/接口声明 process(),各子类各自实现;调用方只依赖基类接口,不判断类型,从而实现依赖倒置。需要强调的是,开闭原则,如果是用于参数兼容性设计,数据结构的多态,那这是很方便的设计,也比较容易维护。如果是算法的多态,但将内部逻辑泄露到外部,就是极其糟糕的实践。

评论

暂无评论,快来发表第一条评论吧!

回复
ⓘ