详细设计——面向对象

AI WARNING

未进行对照审核。

面向对象中的模块与耦合

在面向对象设计中,模块是代码组织的基本单元,可以指一个方法、一个类,或者一个包。衡量模块设计质量的两个关键指标是耦合内聚

  • 耦合:衡量模块之间关联的强度。低耦合是我们的目标,意味着修改一个模块对其他模块的影响较小。
  • 内聚:衡量模块内部各元素之间相关联的强度。高内聚是我们的目标,意味着模块的功能专一、职责清晰。

与结构化方法主要关注数据和控制流耦合不同,面向对象方法引入了新的耦合形式:

  • 访问耦合:指一个类对另一个类的成员(属性或方法)的访问。
  • 继承耦合:指子类与父类之间的依赖关系。

降低耦合的设计原则回顾

在进行详细设计时,应遵循一些基本原则以降低耦合度:

  1. 全局变量是有害的:尽量避免使用全局变量,它们会引入隐式的、难以追踪的依赖。
  2. 显式化:依赖关系和数据传递应尽可能显式化,例如通过参数传递而非共享全局状态。
  3. 不要重复:重复的代码意味着一处修改可能需要在多处同步,增加了耦合和维护成本。
  4. 面向接口编程:依赖于抽象接口而非具体实现,可以降低模块间的直接耦合。

访问耦合

访问耦合描述了一个类(客户端)对另一个类(服务方)的成员的依赖程度。根据依赖的紧密程度,可以分为不同级别:

类型 耦合性 解释 例子
隐式访问 最高 服务方 B 未在客户端 A 的规格中出现,但在 A 的实现中出现。 级联消息
实现中访问 服务方 B 的引用是客户端 A 方法中的局部变量。 1. 通过引入局部变量,避免级联消息。
2. 方法中创建一个对象,将其引用赋予方法的局部变量,并使用。
成员变量访问 服务方 B 的引用是客户端 A 的成员变量。 类的规格中包含所有需接口和供接口(需要特殊语言机制)。
参数变量访问 服务方 B 的引用是客户端 A 方法的参数变量。 类的规格中包含所有需接口和供接口(需要特殊语言机制)。
无访问 最低 理论最佳,无关联耦合,维护时不需要对方任何信息。 完全独立。

级联消息问题

级联消息是指形如 a.getB().getC().doSomething() 这样的调用链。它暴露了过多的内部结构,导致客户端与链条中的多个对象产生耦合。
例如,在 Employee 类中计算同事数量:

1
2
3
4
5
6
// Employee.java
// private Project involvedInProject;
public int numberColleagues() {
// 暴露了 Project 类的 getProjectMembers() 和 Set 类的 count()
return involvedInProject.getProjectMembers().count() - 1;
}

这种方式使得 Employee 不仅依赖 Project,还间接依赖了 Project 内部获取成员的方式以及返回集合的类型。

解决方案

  1. 引入局部变量:将中间结果存入局部变量,减少链式调用的深度,但本质上仍有耦合。
    1
    2
    3
    4
    public int numberColleagues() {
    Set<Employee> projectMembers = involvedInProject.getProjectMembers();
    return projectMembers.count() - 1;
    }
  2. 委托:在 Project 类中提供一个直接获取同事数量的方法,Employee 只需调用该方法。这是更好的做法,符合迪米特法则。
    例如,在银行转账场景中,FundsTransfer 对象需要检查收款人账户 payee (类型为 Account) 的持有人 holder (类型为 Customer) 是否被监控:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 原始做法
    void execute() {
    if (payee.getHolder().isMonitored()) { ... }
    }

    // 解决方案:在 Account 类中添加委托方法
    // Account.java
    private Customer holder;
    public boolean isHolderMonitored() {
    return holder.isMonitored();
    }

    // FundsTransfer.java
    void execute() {
    if (payee.isHolderMonitored()) { // 调用 Account 的委托方法
    // ... send record to police
    }
    // ...
    }

迪米特法则

迪米特法则(Law of Demeter, LoD),也称为「最少知识原则」,旨在限制一个对象对其协作者的了解程度。简单来说:只与你直接的朋友交谈,不要和朋友的朋友交谈

一个对象 O 的方法 M 只能调用以下对象的方法:

  1. O 自身。
  2. M 的参数。
  3. M 内部创建的对象。
  4. O 的直接组件对象(成员变量)。

迪米特法则案例

考虑一个场景:获取某个联系人家庭住址所在邮政区域的人口数量。
原始调用可能像这样:contact.getHomeAddress().getPostcode().getPostalArea().getPopulationSize()。这严重违反了迪米特法则。

改进方案:通过添加委托方法或改变对象关联来减少导航深度。例如,可以在 Contact 类中直接关联 PostalArea,或者提供一个方法 getHomePostalAreaPopulation() 来封装这个逻辑。

按接口编程

这里的「接口」不仅指语言层面(如 Java interface),更广义地指模块对外提供的契约。

  • 编程到必需接口,而非仅是提供接口:客户端应明确声明其依赖的接口,服务方则提供这些接口。
  • 契约式设计
    • 模块/类的契约:定义了模块/类提供的服务和它所依赖的服务。
    • 方法的契约:通过前置条件后置条件不变量来精确描述方法的行为。
      • 前置条件:调用方法前必须满足的条件。
      • 后置条件:方法成功执行后必须满足的条件。
      • 不变量:在方法执行期间及执行前后都必须保持为真的类的状态属性。

接口分离原则

接口分离原则(ISP)

接口分离原则(Interface Segregation Principle, ISP)指出:客户端不应该被迫依赖于它们不使用的方法。换句话说,使用多个专门的、客户端特定的接口比使用一个庞大的通用接口要好。

  • 问题:多用途的「胖接口」会导致不必要的依赖。如果一个类实现了某个胖接口,即使它只用到了接口中的部分方法,当接口中其他不相关的方法发生改变时,这个类也可能需要重新编译或调整。
  • 解决:将胖接口拆分成更小、更具体的接口。每个客户端只依赖于它实际需要的接口。

ISP 案例:服务器与多种 UI

假设一个 Server 类需要被多种用户界面(GUI, Console, Touchpad)使用。

  • 不佳设计Server 类实现一个包含所有 UI 所需方法的大接口,或者直接暴露所有方法。当新增一种 UI (如 VoiceUI) 时,可能需要修改 Server 的接口,导致所有已有的 UI 客户端都需要重新编译。
    classDiagram
    class Server {
        +forGUI()
        +forConsole()
        +forTouchpad()
    }
    class GUI
    class Console
    class Touchpad
    GUI --|> Server : uses
    Console --|> Server : uses
    Touchpad --|> Server : uses
  • 符合 ISP 设计:为每种 UI 定义专门的接口(如 GUIServer, ConsoleServer),Server 类实现这些小接口。或者 Server「收集」这些接口。当新增 UI 时,只需添加新的接口和实现,不影响现有 UI。
    classDiagram
    class GUIServer {
        <<interface>>
        +forGUI()
    }
    class ConsoleServer {
        <<interface>>
        +forConsole()
    }
    class TouchpadServer {
        <<interface>>
        +forTouchpad()
    }
    class Server {
        +forGUI()
        +forConsole()
        +forTouchpad()
    }
    Server ..|> GUIServer
    Server ..|> ConsoleServer
    Server ..|> TouchpadServer
    
    class GUI
    class Console
    class Touchpad
    GUI --> GUIServer
    Console --> ConsoleServer
    Touchpad --> TouchpadServer
    这样,各个 UI 与 Server 的其他部分隔离开来。

继承耦合

继承是面向对象的重要特性,但也引入了耦合。子类与父类之间的耦合程度取决于子类如何使用和修改继承来的特性。

类型 耦合性 解释
修改 最高 子类任意修改从父类继承来的方法的接口或实现,没有任何规则和限制。这是最差的继承耦合,可能破坏多态。
精化 子类根据预定义的规则(如协变返回类型、更窄的参数类型)修改继承信息,或添加新信息,但至少有一个方法的接口被改动。这是必要的耦合。
扩展 子类只增加新的方法和成员变量,不修改或精化任何继承来的成员。如果客户端使用父类引用,则只需父类信息。这是较好的继承耦合。
最低 两个类之间没有继承关系。

修改继承耦合案例

如果 Stack 类继承自 Array 类,仅仅是为了使用 Array 的内部数据结构,并且 Array 的某些方法(如 putAt)对 Stack 来说语义上无意义而被删除或修改其行为,这就形成了不良的修改继承耦合。更好的方式是让 Stack 类包含一个 Array 类型的成员变量(组合)。

里氏替换原则

里氏替换原则(LSP)

里氏替换原则(Liskov Substitution Principle, LSP)由 Barbara Liskov 提出,其核心思想是:「所有派生类都必须可以替代它们的基类」。更通俗地说:程序中任何使用基类指针或引用的地方,都必须能够透明地使用其派生类的对象,而客户端无需了解它们之间的差异

LSP 是实现继承耦合正确性的关键。违反 LSP 会导致意外行为和难以维护的代码。

LSP 与契约式设计
B. Meyer 对 LSP 的阐述是:「当在派生类中重定义一个方法时,你只能用一个更弱的前置条件来替换它的前置条件,用一个更强的后置条件来替换它的后置条件。」

  • 派生类的方法不应要求比基类方法更多的前置条件(即不能缩小输入的有效范围)。
  • 派生类的方法不应承诺比基类方法更少的后置条件(即不能扩大输出的无效范围或减少承诺的效果)。

违反 LSP 的经典案例

  1. 正方形是矩形吗?:在数学上,正方形是一种特殊的矩形。但在编程中,如果 Square 继承自 Rectangle

    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
    class Rectangle {
    protected double width, height;
    public void setWidth(double w) { this.width = w; }
    public void setHeight(double h) { this.height = h; }
    public double getArea() { return width * height; }
    }

    class Square extends Rectangle {
    // 正方形的特性:宽和高必须相等
    @Override
    public void setWidth(double w) {
    super.setWidth(w);
    super.setHeight(w); // 破坏了独立设置宽高的行为
    }
    @Override
    public void setHeight(double h) {
    super.setHeight(h);
    super.setWidth(h); // 破坏了独立设置宽高的行为
    }
    }

    // 客户端代码
    Rectangle r = new Square();
    r.setWidth(5);
    r.setHeight(4);
    // 期望面积是 5*4=20,但对于 Square 对象,面积可能是 4*4=16 或 5*5=25
    // 这就违反了客户端对 Rectangle 行为的预期。

    这里,SquaresetWidthsetHeight 方法改变了基类方法的行为(后置条件),违反了 LSP。

  2. 企鹅是鸟吗?:鸟类(Bird)通常有 fly() 方法。企鹅(Penguin)是鸟,但不会飞。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    class Bird {
    public virtual void fly() { /* 飞行逻辑 */ }
    }
    class Penguin extends Bird {
    @Override
    public void fly() {
    throw new UnsupportedOperationException("Penguins don't fly!"); // 改变了基类行为
    }
    }
    void makeBirdFly(Bird b) {
    b.fly(); // 如果 b 是 Penguin,会抛出异常
    }

    Penguinfly() 方法抛出异常,这与 Birdfly() 方法(正常飞行)的预期行为不符,违反了 LSP。

LSP 总结:

  • LSP 关注的是语义可替换性
  • 设计前需充分理解类的行为和目的。
  • 子类必须能够完全替代父类,而不影响程序的正确性。

组合优于继承

这是一个重要的面向对象设计原则。

  • 继承主要用于实现多态(is-a 关系,且符合 LSP)。
  • 组合/委托应用于代码复用(has-a 或 uses-a 关系)。

Coad 使用继承的规则:仅当以下所有条件都满足时才使用继承:

  1. 子类表达的是「一种特殊的」,而不是「扮演一个角色」。
  2. 子类的实例永远不需要变成另一个类的对象。
  3. 子类扩展而非覆盖或废除其超类的职责。
  4. 子类不扩展仅作为工具类的功能。

组合替代继承:乘客与代理人

一个人(Person)可以是乘客(Passenger),也可以是代理人(Agent),甚至同时是两者。如果使用继承:

classDiagram
class Person {
    +Name
    +Address
}
class Passenger {
    +FrequentFlyerID
    +Reservation
}
class Agent {
    +Password
    +AuthorizationLevel
}
Person <|-- Passenger
Person <|-- Agent
class AgentPassenger
class AgentPassenger
AgentPassenger --|> Passenger
AgentPassenger --|> Agent
%% AgentPassenger 继承自 Passenger 和 Agent,导致菱形继承问题(如果语言支持多重继承)或代码重复。

这种设计难以处理一个人同时具有多种角色的情况(如 AgentPassenger)。

使用组合的方案Person 类包含对 PassengerRoleAgentRole 等角色对象的引用。

classDiagram
class Person {
    +Name
    +Address
    +PassengerRole passengerInfo
    +AgentRole agentInfo
}
class PassengerRole {
    +FrequentFlyerID
    +Reservation
}
class AgentRole {
    +Password
    +AuthorizationLevel
}
Person o-- PassengerRole
Person o-- AgentRole

或者,Person 拥有一个 PersonRole 的引用,PersonRole 是一个抽象基类或接口,PassengerAgent 是其具体实现(策略模式)。

classDiagram
class Person {
    +Name
    +Address
    +Role role
}
Person o-- Role
<<interface>> Role
class PassengerRole {
    +FrequentFlyerID
    +Reservation
}
class AgentRole {
    +Password
    +AuthorizationLevel
}
Role <|.. PassengerRole
Role <|.. AgentRole

这种方式更灵活,易于扩展和管理角色。

内聚

内聚衡量一个模块内部各个元素(如方法、属性)之间联系的紧密程度。目标是高内聚

提高内聚的方法:

  1. 信息内聚与行为内聚:将操作数据的行为和数据本身封装在一起。例如,Position 类不仅包含经纬度数据,还包含计算距离、方向等操作。
  2. 单一职责原则

单一职责原则

单一职责原则(SRP)

单一职责原则(Single Responsibility Principle, SRP)指出:「一个类应该只有一个引起它变化的原因。」 这意味着一个类应该只承担一项职责。

  • 如果一个类承担了多个职责,那么当其中一个职责发生变化时,可能会影响到其他职责,导致类变得不稳定。
  • 职责的变化是类变化的唯一原因。

SRP 案例:账户与 XML 序列化

一个 Account 类如果既负责账户的业务逻辑(存款、取款、查余额),又负责将账户信息序列化为 XML 字符串。

1
2
3
4
5
6
7
class Account {
private double balance;
public void deposit(double amount) { /* ... */ }
public void withdraw(double amount) { /* ... */ }
public double getBalance() { return balance; }
public String toXml() { /* 将账户信息转为 XML */ }
}

这个 Account 类有两个变化的原因:

  1. 账户业务逻辑的改变(如利率计算方式改变)。
  2. XML 格式的改变。
    这违反了 SRP。

解决方案:将 XML 序列化的职责分离到另一个类,如 AccountXmlSerializer

1
2
3
4
5
6
7
8
9
10
class Account {
private double balance;
public void deposit(double amount) { /* ... */ }
public void withdraw(double amount) { /* ... */ }
public double getBalance() { return balance; }
}

class AccountXmlSerializer {
public String toXml(Account account) { /* 将账户信息转为 XML */ }
}

现在,Account 类只负责业务逻辑,AccountXmlSerializer 只负责 XML 序列化。它们各自只有一个变化的原因。

面向对象的信息隐藏与封装

信息隐藏是模块化设计的核心原则之一,旨在隐藏模块内部的设计决策(称为「秘密」),使得模块的修改不影响其他部分。

  • 主要秘密:通常指模块的职责,这部分信息来源于需求规格说明书(SRS)。如果职责可能变化,就需要隐藏。
  • 次要秘密:指实现主要秘密的具体设计决策和实现细节。

封装是实现信息隐藏的机制。它将数据(属性)和操作这些数据的行为(方法)捆绑在一起,并对外隐藏内部实现细节,只暴露必要的接口。

  • 接口:对象对外的可见部分,描述了对象的基本特征和可进行的操作。包括方法名、参数、返回类型、不变量、异常等。
  • 实现:对象内部的细节,如数据结构、具体算法、对其他对象的引用、类型信息等。这些都应该被隐藏。

封装实现的细节

  1. 封装数据和行为

    • ADT 是封装的源头,它定义了一组对象以及在这些对象上的一组操作,而不涉及具体实现。例如,栈 ADT 定义了 push, pop, peek 等操作,但不规定是用数组还是链表实现。
    • 类型可以看作是保护底层未类型化表示的一层「外衣」,约束了对象的交互方式。
    • 数据成员应设为 private,通过 public 方法(Accessors - 访问器/Getter,Mutators - 修改器/Setter)进行有意义的访问和修改,这些方法可以包含约束检查、数据转换等逻辑。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      public class Position {
      private double latitude; // 私有数据
      private double longitude;

      public double getLatitude() { return latitude; } // 访问器
      public void setLatitude(double latitude) { this.latitude = latitude; } // 修改器

      // 封装的行为
      public double calculateDistance(Position other) { /* ... */ }
      public double calculateDirection(Position other) { /* ... */ }
      }
  2. 封装内部结构

    • 不应暴露类内部使用的数据结构。例如,如果一个 Route 类内部使用数组存储路径点 Position[] positions,不应直接返回该数组的引用或提供直接按下标访问的方法,因为这会使客户端依赖于数组这种具体实现。
    • 改进:提供抽象的操作,如 appendPosition(Position p)getPosition(int index)(内部进行索引转换和对象创建),或者使用迭代器模式来遍历集合而不暴露集合的具体类型。
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      // 暴露内部结构(不推荐)
      public Position[] getPositionsArray() { return positions; }

      // 隐藏内部结构(推荐)
      public class Route {
      private List<Position> positions = new ArrayList<>(); // 使用 List 接口
      public void addPosition(Position p) { positions.add(p); }
      public Position getPositionAt(int index) {
      if (index >= 0 && index < positions.size()) {
      return new Position(positions.get(index)); // 返回副本或不可变对象
      }
      return null;
      }
      public Iterator<Position> getPathIterator() { return positions.iterator(); }
      }
  3. 封装其他对象的引用

    • 如果一个类的方法返回其内部对象的引用,客户端可能会修改这个内部对象,破坏封装。
    • 解决方案:返回对象的副本(如 new Position(internalPosition))或不可变对象,或者通过委托将操作封装在当前类中。
  4. 封装类型信息

    • 利用里氏替换原则(LSP),客户端应通过父类或接口的引用与对象交互,从而隐藏具体子类的类型信息。这增强了灵活性和可扩展性。
  5. 封装潜在变更

    • 识别应用中可能变化的部分,将它们与保持不变的部分分离开。
    • 将易变的部分封装起来,以便将来修改或扩展这些部分时,不影响其他稳定的部分。这是开放/封闭原则的基础。
      例如,Position 类如果最初使用笛卡尔坐标,后来可能改为极坐标。通过封装 getLatitude/Longitude 等接口,内部表示的变化不会影响客户端。

权限最小化原则

Principle 10: Minimize The Accessibility of Classes and Members

类和成员的可访问性应尽可能小。这是封装和信息隐藏的具体体现。

  • 抽象:关注对象的外部视图,将其行为与其实现分离。
  • 封装:类不应暴露其内部实现细节。

Java 中的访问修饰符(public, protected, default, private)提供了不同级别的访问控制。应根据需要选择最严格的访问级别。例如,如果一个类仅在包内部使用,就不应声明为 public

为变更而设计的设计原则

软件的核心挑战之一是应对变化。优秀的设计能够使系统在需求变更时更易于维护和扩展。

开放/封闭原则

开放/封闭原则(OCP)

开放/封闭原则(Open/Closed Principle, OCP)指出:软件实体(类、模块、函数等)应该对于扩展是开放的,但对于修改是关闭的。

  • 对扩展开放:模块的行为可以被扩展,以满足新的需求。
  • 对修改关闭:一旦模块完成并通过测试,其源代码不应被修改。

统计数据表明,修正 bug 虽然频繁,但影响较小;而新增需求虽然数量一般,却可能造成大范围的影响。OCP 的目标是使新增需求通过添加新代码(扩展)来实现,而不是修改现有稳定代码。

如何实现 OCP?

  • 抽象是关键:依赖于抽象(接口或抽象类)而不是具体实现。
  • 多态:利用多态性,使得客户端代码可以与不同具体实现以统一的方式交互。

违反 OCP 的 RTTI (Run-Time Type Information)

使用 instanceof 或类型转换(向下转型)来判断对象类型并执行不同操作,是违反 OCP 的常见模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 违反 OCP 和 LSP
class Shape {}
class Square extends Shape { void drawSquare() { /*...*/ } }
class Circle extends Shape { void drawCircle() { /*...*/ } }

void drawShapes(List<Shape> shapes) {
for (Shape shape : shapes) {
if (shape instanceof Square) {
((Square) shape).drawSquare();
} else if (shape instanceof Circle) {
((Circle) shape).drawCircle();
}
// 如果要增加 Triangle,必须修改这里的 if-else 结构
}
}

当需要支持新的 Shape 类型时,必须修改 drawShapes 方法,这违反了「对修改关闭」的原则。

使用多态实现 OCP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
interface Shape { // 抽象
void draw();
}
class Square implements Shape {
public void draw() { /* draw square */ }
}
class Circle implements Shape {
public void draw() { /* draw circle */ }
}
// 可以新增 Triangle 实现 Shape 接口
class Triangle implements Shape {
public void draw() { /* draw triangle */ }
}

void drawShapes(List<Shape> shapes) {
for (Shape shape : shapes) {
shape.draw(); // 客户端依赖抽象,通过多态调用具体实现
}
// 新增 Triangle 时,此方法无需修改
}

在这个版本中,drawShapes 方法对扩展是开放的(可以添加新的 Shape 实现),对修改是关闭的(自身代码无需变动)。

OCP 总结:

  • 没有程序能做到 100% 关闭。应基于对可能发生变化的预测来规划类的设计。
  • OCP 的实现通常依赖于依赖倒置原则(DIP) 和里氏替换原则(LSP)。

依赖倒置原则

依赖倒置原则(DIP)

依赖倒置原则(Dependency Inversion Principle, DIP)包含两条:

  1. 高层模块不应该依赖于低层模块。两者都应该依赖于抽象。
  2. 抽象不应该依赖于细节。细节应该依赖于抽象。

传统的结构化设计中,高层模块调用低层模块,形成自顶向下的依赖关系。DIP 将这种依赖关系「倒置」了过来。

  • 高层模块:包含业务策略和主要流程的模块。
  • 低层模块:提供具体实现和工具功能的模块。
  • 抽象:通常指接口或抽象类。

DIP 的核心思想:通过引入抽象层,解除高层模块与低层模块之间的直接依赖。高层模块定义其需要的接口(抽象),低层模块实现这些接口。

DIP 案例:复印程序

一个 Copy 程序需要从键盘(KeyboardReader)读取数据并打印到打印机(PrinterWriter)。

  • 不符合 DIPCopy 直接依赖具体的 KeyboardReaderPrinterWriter
  • 符合 DIP
    1. 定义抽象接口 ReaderWriter
    2. Copy 模块依赖 ReaderWriter 接口。
    3. KeyboardReader 实现 ReaderPrinterWriter 实现 Writer
    4. 如果需要支持从文件读取或写入到网络,只需创建新的类实现 ReaderWriter 接口,Copy 模块无需改动。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    interface Reader { int read(); }
    interface Writer { void write(int c); }

    class KeyboardReader implements Reader { /* ... */ }
    class PrinterWriter implements Writer { /* ... */ }
    class DiskWriter implements Writer { /* ... */ } // 新增的写入方式

    class Copy {
    public void execute(Reader r, Writer w) {
    int c;
    while ((c = r.read()) != EOF) {
    w.write(c);
    }
    }
    }

DIP 总结:

  • 抽象类/接口倾向于比具体类更稳定。
  • 抽象是扩展和修改的「铰链点」。
  • 并非所有具体类都需要抽象层(例如,像 String 这样不太可能变化的类可以直接使用)。

设计原则间的关系

  • OCP(开放/封闭原则) 陈述了目标:对扩展开放,对修改关闭。
  • DIP(依赖倒置原则) 提供了实现 OCP 的一种核心机制:依赖于抽象。
  • LSP(里氏替换原则) 是确保 DIP 和多态能够正确工作的「保险」:子类必须能真正替换父类。

通过遵循这些原则,我们可以构建出更灵活、可维护、易于扩展的面向对象系统。

耦合与内聚的度量

为了更客观地评估设计质量,可以采用一些度量指标:

类间耦合度量

  • CBO(Coupling Between Object classes):一个类与其他类的耦合数量。计算一个类访问其他类的成员(方法或变量)的数量,或者包含被其他类访问的方法或变量的数量。不包括继承关系。目标是低 CBO
  • DAC(Data Abstraction Coupling):一个类中具有抽象数据类型(ADT,即其他类定义)的属性数量。目标是低 DAC
  • Ca(Afferent Coupling, 输入耦合):依赖于该类的外部类的数量
  • Ce(Efferent Coupling, 输出耦合):该类依赖的外部类的数量
    目标是低 Ca 和 Ce。一个理想的组件应该是稳定的(很多类依赖它,Ca 高)并且不依赖很多其他东西(Ce 低),或者是不稳定的(很少类依赖它,Ca 低)但可以依赖其他稳定的组件(Ce 高)。
  • DIT(Depth of Inheritance Tree):从当前类到继承树根节点的最大路径长度。DIT 增大,继承程度提高,可能复用更多方法,但也可能使类的行为更难预测。
  • NOC(Number Of Children):一个类的直接子类数量。NOC 增大,复用性可能增加,但抽象也可能被稀释,测试量也会增加。

类内聚度量LCOM(Lack of Cohesion in Methods):方法间缺乏内聚性的度量。有多种计算版本。

  • 一种定义:考虑一个类 C1C_1nn 个方法 M1,M2,,MnM_1, M_2, \dots, M_n。令 {Ij}\{I_j\} 为方法 MjM_j 使用的实例变量集合。
    • P={(Ii,Ij)IiIj=}P = \{ (I_i, I_j) \mid I_i \cap I_j = \empty\}(没有共同实例变量的方法对数量)
    • Q={(Ii,Ij)IiIj}Q = \{ (I_i, I_j) \mid I_i \cap I_j \neq \empty\}(有共同实例变量的方法对数量)
    • LCOM=max{PQ,0}\text{LCOM} = \max \left\lbrace |P| - |Q|, 0 \right\rbrace
  • 另一种定义:将类看作一个图,方法是节点,如果两个方法访问了至少一个相同的实例变量,则它们之间有边。LCOM 定义为图中连通分量的数量。
    目标是低 LCOM(高内聚)。如果 LCOM1\text{LCOM} \ge 1(按第二种定义,即存在多个连通分量),则该类可能应该被拆分。

这些度量可以帮助识别潜在的设计问题,但它们只是指导,不应盲目追求特定数值,需结合具体场景和设计目标进行权衡。

总结设计原则

本次学习的核心是面向对象设计中的一系列重要原则,它们共同指导我们创建高质量、可维护、可扩展的软件系统:

源于模块化的基本原则:

  1. 全局变量是有害的
  2. 显式化
  3. 不要重复(DRY)
  4. 面向接口编程(Programming to Interface)/契约式设计(Design by Contract)

面向对象特有的重要原则(部分 SOLID):

  1. 迪米特法则(LoD):减少不必要的了解。
  2. 接口分离原则(ISP):客户端不应依赖于它不需要的接口。
  3. 里氏替换原则(LSP):子类必须能够替换父类。
  4. 组合优于继承(Favor Composition Over Inheritance):优先使用组合/委托实现代码复用。
  5. 单一职责原则(SRP):一个类只有一个变化的原因。
  6. 权限最小化原则(Minimize Accessibility):尽可能限制类和成员的可见性。
  7. 开放/封闭原则(OCP):对扩展开放,对修改关闭。
  8. 依赖倒置原则(DIP):依赖于抽象,而非具体实现。

这些原则相互关联,共同服务于构建健壮、灵活的软件系统。