Skip to article frontmatterSkip to article content

虽然关于“为什么OOP很糟糕”的讨论层出不穷, 但OOP依然是事实上的最流行编程范式. OOP是一种表现力极强的模型, 而强大的模型往往伴随着各种误用和滥用, 因此如何正确运用OOP成为一门值得钻研的学问. 具体的OOP语法, 任何一本OOP语言的书籍都会详尽介绍, 这里不再赘述. 本文重点讨论OOP中的重要特性及其最佳实践.

为什么OOP流行

OOP之所以流行, 主要原因包括:

由于OOP提供了这种极具通用性的模仿能力, 它可以用来改造语言, 构造领域专用语言, 使其更贴近业务逻辑. 这正是领域驱动设计的核心要义.

oop_capability.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# as type
class Point:
    def __init__(self, x, y): 
     self.x, self.y = x, y
     
    def __add__(self, other): 
     return Point(self.x + other.x, self.y + other.y)
     
    def __str__(self): 
     return f"({self.x}, {self.y})"

p1 = Point(1, 2)
p2 = Point(3, 4)
result = p1 + p2  # Point(4, 6)

# as module
class MathUtils:
    PI = 3.14159
    _private_constant = 42

    @staticmethod
    def square(x): return x * x

    @classmethod
    def circle_area(cls, radius): return cls.PI * radius * radius

# as callable
class Multiplier:
    def __init__(self, factor):
        self.__factor = factor  # private data

    def __call__(self, value):
        return value * self.__factor

double = Multiplier(2)
result = double(5)  # 10

OOP的不同风格

OOP有多种不同的风格, 以下介绍三种.

class风格
struct+impl风格
struct+function风格
classStyle.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 接口定义
interface InterfaceExample {
    void interfaceMethod();
}

// f父类
class ParentClass {
    public ParentClass() {
        System.out.println("ParentClass constructor");
    }

    public void parentMethod() {
        System.out.println("Parent method");
    }

    protected void finalize() throws Throwable {
        System.out.println("ParentClass destructor");
        super.finalize();
    }
}

// 子类继承父类, 并实现接口
public class ChildClass extends ParentClass implements InterfaceExample {
    public ChildClass() {
        super(); 
        System.out.println("ChildClass constructor");
    }

    @Override
    public void interfaceMethod() {
        System.out.println("Implemented interface method");
    }

    @Override
    protected void finalize() throws Throwable {
        System.out.println("ChildClass destructor");
        super.finalize();
    }

    public static void main(String[] args) {
        ChildClass obj = new ChildClass();
        obj.parentMethod();
        obj.interfaceMethod();
    }
}

这也是最常见的风格, 我们通过class关键词创造一个class, 可以指定父类来继承, 可以指定interface对象来实现, 一般都会存在构造函数, 以及析构函数. 数据和方法都应在class内.

OOP的不同性质

OOP在不同的语言中有不同的性质. 我们就以下4种性质进行介绍.

open class / close class

open class
close class
app.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class App
    def hello()
        "hello"
    end
end

# 补充定义App
class App
    def world()
        "world"
    end
end

app = App.new
puts app.hello() # hello
puts app.world() # world

open class意味着一个类可以补充定义, rust, swift, golang, rust, ruby, scala支持

method bounded / method unbounded

method bounded
method unbounded
int_stack.cpp
1
2
3
4
5
6
7
8
9
class IntStack {
    // ...

    public: 
    void push(int data);
}

IntStack s;
s.push(1);  // 只能通过IntStack的实例调用push方法

method bounded意味着方法跟随类被定义, 它们定义在类的"内部"

是否支持继承

支持继承
不支持继承
inheritance.cpp
1
2
3
class Worker : public Human {
    ...
}

类支持继承

接口是否强迫实现方法

强迫实现方法
不强迫实现方法
interface_coerced.rs
1
2
3
4
5
6
7
8
struct Worker {...}

// fmt::Display是一个trait, 强迫Worker实现fmt方法
impl fmt::Display for Worker {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        ...
    }
}

fmt::Display强迫Worker实现fmt方法, 换句话说Worker被fmt::Display约束

任何一门OOP语言, 一般都在这些风格和性质中组合出自己的OOP风格.

继承

在介绍OOP的实践之前, 我们需要先展开说明继承这个特性. 这里提到的继承特指具体类之间的继承(inheritance), _不是_继承接口或虚基类(subtyping).

继承是目前OOP被诟病较多的特性, 很多语言公开抛弃继承特性. 大家谈到继承的时候一般会诟病的点是

这些诟病的点, 都是确有其事.

一些语言摒弃了多继承抛弃, 另一些引入了额外的特性去解决问题(虚继承). 但耦合仍旧是继承机制避不开的问题.

继承为什么被诟病

但仍有大量语言坚持支持继承这一特性, 原因何在? 继承为何被诟病为“糟糕”, 但又被广泛保留? 问题的根源究竟在哪里?

首先, 继承诞生的前提非常合理.
引入类和对象后, 人们发现大量代码重复, 不同类需要实现相同的方法. 要么复制粘贴, 导致代码冗余;要么将方法抽离到类外, 回归struct+function风格, 破坏封装. 为减少代码重复且保护封装, 继承被发明出来.

继承的原始动机也十分合理. 有了继承, 可以先抽象再具体编码, 例如Object > Creature > Animal > Mammal > Human, 层层递进, 逻辑清晰. 其次, 当系统需要扩展时, 无需重新实现新类, 只需继承已有类并实现子类, 符合开闭原则的实践.

然而, 这一假设站不住脚.

大量文章, 书籍和论文指出, 再优秀的业务分析也无法完全洞悉客户需求, 客户需求会随着系统交付不断变化. 再优秀的架构师也难以设计出永远合适的抽象模型, 因为需求持续演变, 迟早原有抽象会失效

《Laws of Software Evolution Revisited》一文指出, 系统必须不断修改以适应用户不断变化的需求, 停止适应则系统劣化明显, 甚至被弃用. 即便当下需求被完美适配, 系统终将在未来某个时刻变得不再满足需求.

《Laws of Software Evolution Revisited》[1]一文指出, 系统必须不断修改以适应用户不断变化的需求, 停止适应则系统劣化明显, 甚至被弃用. 即便当下需求被完美适配, 系统终将在未来某个时刻变得不再满足需求.

当需要修改时才发现过去与现在通过继承紧密耦合, 基类无法随意更改, 继续继承又难以推进, 系统便走到了生命周期的尽头, 最终可能只能重写.

什么时候使用继承

所以, 我们是否应当完全抛弃继承呢? 答案是未必.

如果两个类之间存在明确的Is-A关系, 使用继承是合适的. 比如, cat is a critter, 那么cat类继承自critter类就是合理的.

具体来说, 先抽象再具体的编码方式在某些场景下适合使用继承. 例如, 业务无关且稳定的概念非常适合继承, 几何领域中的shape > rectangle > square就是一个典型案例.

人们希望通过继承扩展系统本身没有问题, 关键在于继承的对象. 如果继承的是具体类, 容易导致耦合和维护难题;但如果继承的是稳定的interface或trait, 那么所有实现仅与稳定的API耦合, 同时满足开闭原则和依赖反转原则. 这种情况下, 通常不称为继承(inheritance), 而称为创建子类型(subtyping).

综上, 如果使用继承, 则我们应该谨慎的, 小范围的使用继承, 只有继承能够极大简化实现的时候使用. 跨模块的继承是绝对要避免的, 不必要时完全可以用组合, 代理, 甚至函数式编程代替.

代替继承

委托

委托(代理)是代替继承的另一种机制. 类似于object based model中对象可以把数据或函数的请求委托给自己的原形. 在OOP中也存在类似的机制.

delegation.go
1
2
3
4
5
6
7
8
9
10
type Animal struct {  
        Kingdom string  
        Legs    uint8
}

type Cat struct {  
        Animal // 对cat.Kingdom和cat.Legs代理给Animal
        Sound string  
        Fav   []string  
}

这里Cat把Legs和Kingdom委托给Animal. 我们可以直接在Cat的实例中访问这两个属性.

组合

组合是另一种替代继承的方法. 相比于继承的A-is-a-B的关系, 组合则是A-uses-B或者A-has-a-B的关系.

stack.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <vector>

template <typename T>
class StackByInherit : public std::vector<T> {
public:
    void append(T val);
    T pop();
};

template <typename T>
class StackByComposite {
private:
    std::vector<T> _data; // stack-has-a-vector
public:
    void append(T val);
    T pop();
};

这里stack和vector虽然在内存表达上是相同的, 但概念上stack和vector还是有所不同的. 所以Stack is A Vector是站不住脚的, Stack Uses Vector或者Stack has A Vector比较合适.

使用OOP的最佳实践

那么, 什么时候使用OOP, 怎样使用OOP才是最佳实践?

经典参考材料

早在上世纪90年代, 许多经验丰富的开发者就提出了基于OOP的设计原则和设计模式. 相关资料繁多, 这里列举一些经典材料.


CategoryNameDescription
设计模式单例模式 (Singleton)确保一个类只有一个实例, 并提供全局访问点.
工厂模式 (Factory)定义一个创建对象的接口, 让子类决定实例化哪一个类.
抽象工厂模式 (Abstract Factory)提供一个创建一系列相关或相互依赖对象的接口.
观察者模式 (Observer)定义对象间的一种一对多依赖关系, 当一个对象改变时, 所有依赖者都会收到通知.
代理模式 (Proxy)为其他对象提供一种代理以控制对这个对象的访问.
装饰器模式 (Decorator)动态地给对象添加额外的职责.
策略模式 (Strategy)定义一系列算法, 封装起来, 使它们可以互换.
适配器模式 (Adapter)将一个类的接口转换成客户希望的另一个接口.
责任链模式 (Chain of Responsibility)使多个对象都有机会处理请求, 避免请求的发送者和接收者耦合.
命令模式 (Command)将请求封装成对象, 从而使你可用不同的请求对客户进行参数化.
状态模式 (State)允许对象在内部状态改变时改变行为.
备忘录模式 (Memento)在不破坏封装性的前提下, 捕获一个对象的内部状态.
迭代器模式 (Iterator)提供一种方法顺序访问一个集合对象中的各个元素.
组合模式 (Composite)将对象组合成树形结构以表示“部分-整体”的层次结构.
设计原则单一职责原则 (SRP)一个类只负责一项职责.
开闭原则 (OCP)软件实体应对扩展开放, 对修改关闭.
里氏替换原则 (LSP)子类对象能够替换父类对象且程序行为不变.
依赖倒置原则 (DIP)高层模块不应该依赖底层模块, 二者都应该依赖抽象.
接口隔离原则 (ISP)不应强迫客户依赖它们不使用的方法.
迪米特法则 (LoD)一个对象应对其他对象有尽可能少的了解.
合成复用原则 (CRP)优先使用对象组合, 而不是继承来达到复用目的.

不过, 有几点需要说明:

首先, 设计模式诞生于OOP被滥用的年代, 随着技术进步, 部分设计模式已不再依赖OOP特性. 例如template method模式, 由于现代语言中函数普遍为一等公民, 高阶函数能够轻松实现template method的功能.

其次, 许多设计模式内核相似, 表明设计模式存在一定冗余. 例如Adapter, Composite, Facade模式, 均是在原有类基础上封装并提供一组特定接口, 以达到某种目的.

设计模式和设计原则的意义在于启发我们编写更优质的代码, 而非必须遵守的教条, 更不可盲目使用. 若无必要理由, 使用任何设计模式都可能徒增复杂度.

设计模式和原则为我们提供了丰富的经验和启发, 但它们均建立在确定采用OOP的前提下. 由于OOP表现力强大, 且有时过于复杂, 很多情况下我们并不需要如此强的表现力, 简单模型往往足够.

使用OOP的必要条件

什么时候需要使用类和对象呢?

这两点是使用OOP的最低标准. 如果需求不满足这两点, 应优先考虑更简单的模型. 例如, 当仅需暴露一组无状态函数时, 不妨使用module而非class.

真实项目中OOP的三种使用风格

实战中, OOP会引出三种明确的使用风格.

第一种: Data Driven Design

这种风格将类视为数据模型的类型, 例如Account类, 包含id, 昵称, 权限, 余额等数据字段. 此类对象主要作为数据容器在系统中传递. 由于重点在承载数据, 类上通常没有方法, 或者仅有少量与序列化/反序列化, 数据校验和存取相关的方法. 使用这些数据类时, 常通过组合实现, 比如User类组合Account类.

data_driven_design.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int

@dataclass
class Status:
    healthy: bool
    wealthy: bool
    
@dataclass
class Account:
    id: int
    owner: User
    priority: int
    balance: float
    status: Status

    def marshal(self):
        # 序列化方法
        ...

    def validate(self):
        # 数据校验方法
        ...

数据类通常作为底层依赖被整个系统使用, 设计时需格外谨慎. 数据类的设计应对应业务逻辑, 随着业务发展, 数据类型定义会缓慢演变, 此时需做出合理预测, 并用通用方式进行扩展或修改.

第二种: Responsibility Driven Design

此风格将类视为行为的实体表现. 设计时优先考虑行为, 必要时定义简洁通用的interface或trait. 这种类通常承载少量数据, 且数据多为配置参数. 适用于实现各种行为和工具类, 命名通常暗示功能, 如formatter, loader, event_handler, coordinator, ApiFetcher等.

接口较为稳定, 为满足不同需求, 可能存在多套实现, 如Loader接口实现JsonLoader, TomlLoader等. 此风格偶尔使用继承.

这种类非常适合作为模块入口类, 如此一来模块暴露的就不是一组函数, 而是暴露一个类.

responsibilityDrivenDesign.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package examples.oop;

// Stub classes for missing types
class Shape {}
class Color {}
class Style {}

interface Render {
    void render(Shape s);
    void renderAll(Shape[] s);
}

class GUIRenderer implements Render {
    Color color_;
    Style style_;

    public void render(Shape s) { /* Implementation here */ }

    public void renderAll(Shape[] s) { /* Implementation here */ }
}

class PNGRenderer implements Render {
    Color color_;
    Style style_;

    public void render(Shape s) { /* Implementation here */ }

    public void renderAll(Shape[] s) { /* Implementation here */ }
}

上述例子中的类刻画各种Render行为

第三种: Domain Driven Design (DDD)

DDD风格的类主要刻画业务逻辑中的核心概念. 目标是描述一组相互协作的概念, 将语言扩展为领域专有语言(DSL).
根据论文《notable design patterns for domain-specific languages》, 这属于DSL领域中名为“language extension”的设计模式.

DDD风格的类兼具Data Driven和Responsibility Driven的特点, 既承载数据, 又抽象行为, 并与系统中其他类型互动.

例如足球游戏中的player, Team, Goal类:

soccer.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class Player:
    def __init__(self, player_id, name, position, team):
        self.player_id = player_id
        self.name = name
        self.position = position
        self.team = team
        self.has_ball = False

    def pass_ball(self, receiver):
        if not self.has_ball:
            raise Exception(f"{self.name} does not have the ball to pass.")
        if receiver not in self.team.players:
            raise Exception(f"{receiver.name} is not a teammate.")
        self.has_ball = False
        receiver.receive_ball()
        print(f"{self.name} passed the ball to {receiver.name}.")

    def shoot(self, goal):
        if not self.has_ball:
            raise Exception(f"{self.name} cannot shoot without the ball.")
        # Domain rule: maybe check shooting position or stamina here
        self.has_ball = False
        goal.attempt_shot(self)
        print(f"{self.name} shoots at goal!")

    def intercept(self, opponent):
        # Domain rule: only if opponent has ball
        if not opponent.has_ball:
            raise Exception(f"{opponent.name} does not have the ball to intercept.")
        # Successful intercept logic could be probabilistic or based on stats
        success = True  # Simplified here
        if success:
            opponent.has_ball = False
            self.has_ball = True
            print(f"{self.name} intercepted the ball from {opponent.name}.")
        else:
            print(f"{self.name} failed to intercept the ball.")

    def receive_ball(self):
        self.has_ball = True

    def replace(self, substitute):
        if substitute.team != self.team:
            raise Exception("Substitute must be from the same team.")
        self.team.replace_player(self, substitute)
        print(f"{self.name} replaced by {substitute.name}.")

class Team:
    def __init__(self, name, players):
        self.name = name
        self.players = players  # List of Player objects

    def team_mates(self, player):
        return [p for p in self.players if p != player]

    def replace_player(self, current_player, substitute):
        if current_player not in self.players:
            raise Exception(f"{current_player.name} is not in the team.")
        self.players.remove(current_player)
        self.players.append(substitute)

class Goal:
    def __init__(self, position):
        self.position = position
        self.goals_scored = 0

    def attempt_shot(self, player):
        # Simplified scoring logic
        scored = True  # Could be probabilistic based on distance, skill, etc.
        if scored:
            self.goals_scored += 1
            print(f"Goal scored by {player.name}!")
        else:
            print(f"{player.name}'s shot missed.")

team_a = Team("Red Warriors", [])
player1 = Player(1, "Alice", "Forward", team_a)
player2 = Player(2, "Bob", "Midfielder", team_a)
team_a.players.extend([player1, player2])

goal = Goal("North End")

player1.receive_ball()
player1.pass_ball(player2)
player2.shoot(goal)

Player, Team, Goal为游戏核心对象, 通过它们的互相协作编织游戏主题逻辑

player提供方法与其他player, ball, team对象互动, 同时拥有如队友列表, 球员ID, 名字等数据. 它兼具Data Driven和Responsibility Driven的特点.

在DDD中, 若行为为概念独有, 无需单独接口/特质(如传球之于球员);若行为为多种概念共有, 可抽象为接口/特质(如开火之于大炮和枪).
此风格很少用继承, 因为业务发展导致频繁修改, 基于继承的抽象到具体实现不适合频繁变动. 同一概念的不同细分对象(如不同国籍球员)也不通过继承实现.

DDD风格难度最高, 因其既需管理数据(字段较多), 又需与多对象交互(方法众多). 若无适当约束, 类职责易变形, 方法数量易膨胀. 若不重新拆分概念(拆成更多类), 系统演进中易产生超级类.

同时业务逻辑多样, 核心概念各异, 这种类的定义无固定套路.

尽管实现难度大, 优质DDD设计绝对是系统核心价值之一.

三种风格的配合使用

上述三种类风格常配合使用.

Data Driven Design
Responsibility Driven Design
Domain Driven Design

以足球游戏为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 球员能力数据
class PlayerCapacity {
    player_id: string

    speed: float
    passing_skill: float
    organizing_skill: float
    leadership: float
    strength: float
    stamina: float
}

// 比赛表现记录数据
class PerformanceRecord {
    competition_id: string
    player_id: string
    date: datetime
    
    goal: int
    assistance: int
    foul: int   
    playing_second: float
}

// 从json中读入球员能力数据
load(JSON) -> PlayerCapacity

// 从数据库中搜索比赛表现记录
query(player_id, date) -> PerformanceRecord

承载重要数据被各种地方引用

这三种类风格既非教条, 也无明确界限, 更像是业界多年实践总结的规律. 基于第一性原理, 开发时应根据自身需求灵活调配使用.


Footnotes
  1. 原文pdf在本文github repo中的papers/路径下