面向对象编程的四个主要原则:抽象、封装、继承和多态性的详细介绍

621 阅读15分钟

对象设计实际上是由三个部分组成有关:

  • 分析:在这里我们学习一个领域,发现功能和非功能需求,并把它们变成用例
  • 设计:我们利用责任驱动设计等设计方法,将需求转化为角色、责任和协作,以及
  • 编程:在这里我们将设计映射成经过测试的、灵活的、可维护的代码。

image.png 对象设计是由三个部分组成的。分析、设计和编程

仅仅学习了编程方面,我发现自己对好的设计是什么样子的,如何将需求转化为对象,以及何时和为什么要使用抽象、封装、继承和多态等概念感到困惑。是的,我多少还记得这些概念是什么,但知道什么时候和为什么要使用它们对我来说并不清楚。

面向对象编程的四个主要原则(抽象、继承、封装和多态性)。核心原则是抽象化。没有它,其他的就不可能存在。 image.png

在这篇文章中,我想重新审视这四个主要观点--这些面向对象编程的原则--讨论它们为什么是有益的,并以简单、可亲和实用的解释来说明它们。

抽象

当你按下遥控器上的开机键时,你真的知道你的电视是如何开启的吗?作为用户,你是否需要知道你的遥控器发出的0和1的具体序列,以向电视的接收器发出信号,让它打开?还是说按下开机键就够了?

真的知道当你在点火器上转动钥匙时你的汽车是如何启动的吗?作为一个司机,你是否需要了解点火开关,电池的电压如何击中点火线圈,发动机的火花如何被引导到火花塞,以及如何点燃燃料使汽车运转?还是转动钥匙和听到 "叮叮 "声就够了? image.png

抽象化使技术更容易使用。我们大多数人都知道,按下遥控器上的 "开 "字键就能打开电视。这对我们来说已经很好了。想象一下,如果你需要知道低层次的电子细节,以便打开你的电视,观看你最喜欢的HBO节目。学习曲线将是巨大的。如果是这样的话,很少有人会看电视。

应该清楚的是,我们使用抽象概念来操纵这个世界--这些概念模型让我们对幕后更为复杂的发明有足够的了解。而在面向对象的程序中,我们旨在简化并使之易于使用的发明是对象

抽象是设计的一个关键原则。由于面向对象的程序可能会变得相当大,指望别人了解其中每一个类或组件的内部工作,这在人类看来是不可能的。因此,我们努力将对象抽象化,使其能够透露出意图,易于使用,并且易于理解

考虑一下洗衣机的抽象化问题。

// Options for the wash cycle
type WashOptions = {
  dryLevel: 'low' | 'medium' | 'high'
  temperature: 'cold',
  duration: 'hour',
  ecoEnabled: false
}

// The abstraction
class WashingMachine {
	
  // Private instance variables
  ... 

  public startCycle (options: WashOptions): void {
	  // Parse the options
    // Get access to the physical layer
    // Convert options into commands
    // Lots of low-level code
    // And so on...
    ...
  }
	
  // More methods
  ...

}

假设现在唯一的public 方法是startCycle 方法,那么客户还需要知道什么?它需要知道startCycle如何工作的吗?它需要知道private 类中的实例变量吗?它需要知道任何其他的private 方法吗? image.png

不,绝对不需要。就像电视遥控器上的电源按钮一样,它是为公众使用而简化的。如果有人要使用这个抽象概念,他们只需要知道startCycle 方法的存在以及如何调用它。就这样了。所有其他的细节都在这个类中被抽象出来了。

// Obtain access to the abstraction
import { WashingMachine } from '../washer'
 
// Create an instance
const washer = new WashingMachine();

// Usage
washer.startCycle({
  dryLevel: 'medium',
  temperature: 'cold',
  duration: 'hour',
  ecoEnabled: false
});

通过把客户不需要知道的东西抽象出来,我们减少了混乱,并使其他开发者很容易知道如何使用我们的代码和扩展它。

抽象是设计的根本原则

抽象有很多用途--对设计者和客户都是如此。关于抽象和抽象工具,如接口、抽象类和类型,还有很多可以说的,比如说它们给我们带来的事实。

  • 使用契约具体事物进行设计的能力。
  • 将 "什么"和 "如何"分开的能力。
  • 声明性的东西与命令性的东西分开的能力。
  • 高层次的想法与低层次的细节分开来关注的能力。

但现在,我要说的是,它是设计的根本原则,也是许多其他重要主题的中心点,如设计模式、原则、责任驱动的设计等等。

也请看

抽象是宏观层面的原则。下面是几个直接相关的微观层面的话题。

  • 每次保持单一的抽象层:通过每次保持单一的抽象层,使你的方法保持凝聚力和可读性。了解你的类在什么和知道什么方面有什么责任,并坚持下去。
  • 最小化接口。将公共API(类、组件或服务)简化为最小的操作集,让人们做他们需要做的事。可能不是对人最友好的,但操作可以通过组合的方式让客户完成他们的目标。
  • 人性化的界面。找出人们想要做的事情,然后以尽可能以人为本的语言来设计界面。
  • 薄界面,厚实现。斯坦福大学计算机科学教授John Ousterhout的想法是设计薄的界面,厚的实现。这个想法进一步强化了抽象化是关于使界面易于理解和使用,同时隐藏复杂的细节。

继承

如果我们能重用代码或为更具体的用例扩展它,那不是很好吗?这就是继承的意义所在。

为了实现代码重用,在TDD中,我们有一个叫做 "三原则"的技术,它规定在看到三次重复时,我们应该把它重构为一个抽象的概念。

我用来证明的经典例子是,我们依赖来自前端应用程序的几个不同的API端点,但我们在每个API适配器上重复获取逻辑(想想/users/forum/billing ,等等)。

相反,我们可以做的是将数据获取逻辑重构到一个共同的地方。抽象类是这个用例的一个好工具。

// Base API
export abstract class API {
  protected baseUrl: string;
  private axiosInstance: AxiosInstance;
   
  constructor (baseUrl: string) {
    this.baseUrl = baseUrl
    this.axiosInstance = axios.create({})
    this.enableInterceptors();
  }
  private enableInterceptors () {    // Here's where you can define common refetching logic  }
  // Common "get" logic
  protected get<T> (url: string, requestConfig?: RequestConfig): Promise<AxiosResponse<T>>{    return this.axiosInstance.get<T>(`${this.baseUrl}/${url}`, {      headers: requestConfig?.headers ? requestConfig.headers : {},      params: requestConfig.params ? requestConfig.params : null,    });  }
  // Common "post" logic
  protected post<T> (url: string, requestConfig?: RequestConfig): Promise<AxiosResponse<T>>{    return this.axiosInstance.post<T>(`${this.baseUrl}/${url}`, {      headers: requestConfig?.headers ? requestConfig.headers : {},      params: requestConfig.params ? requestConfig.params : null,    });  }}

然后,对于我们需要的每一个API适配器,我们可以仅仅对基础API抽象类进行子类化,以获得对通用功能的访问(publicprotected 方法和属性)。

// Users API
export class UsersAPI extends API {
  constructor () {
    super('http://example.com/users');
  }
 
  // High-level functionality (extending it)
  async getUsers (): Promise<Users> {
    let response = await this.get('/'); // Use the common logic from the base class    return response.data.users as Users
  }
  
  ...
}

// Forum API
export class ForumAPI extends API {
  constructor () {
    super('http://example.com/forum');
  }
 
  // High-level functionality (extending it)
  async getPosts (): Promise<Posts> {
    let response = await this.get('/'); // Here, as well    return response.data.posts as Posts;
  }
  
  ...
}

// Billing API
export class BillingAPI extends API {
  constructor () {
    super('http://example.com/billing');
  }
 
  // High-level functionality (extending it)
  async getPayments (): Promise<Payments> {
    let response = await this.get('/'); // And here ;)    return response.data.payments as Payments;
  }
  
  ...
}

继承依赖于抽象原则;有了它,我们就获得了将(重复的)低级细节抽象到基类的能力(API ),这样子类就可以专注于(独特的)高级细节(UsersAPI,ForumAPI,BillingAPI )。

image.png 使用继承进行重构的一个很好的理由是清理重复,使通用功能更容易被用于更具体的对象。

这是关于重用,而不是层次结构

当大多数开发者学习继承时,他们会看到一些琐碎的动物-狗人-职员-教师的例子,这些例子很好地映射到现实世界中,但它们所创建的复杂的层次结构表现出真正的问题。

需要注意的是,存在于现实世界中的概念往往不能转化为有用的软件对象,这一点非常重要。例如,在一个在线换油的预定表格中,对汽车的速度、尺寸、升级、零件和车窗颜色的层次结构进行建模可能并不重要。然而,在一个多人赛车游戏的设计中,它们很可能是有用和必要的。

我们为什么要发明抽象的东西呢?这有什么意义呢?为了帮助我们表达我们需要表达的东西以满足需求,并且以一种可维护的方式来表达--就是这样。正如我在solidbook.io中写到的,软件设计的目标是写出以下代码。

  1. 满足客户的需求,并且
  2. 能被开发人员低成本地修改

适当使用继承有助于第2点。因此,继承是我们在寻找重用机会时使用的工具,而不是用来表达现实世界的层次相似性。

"在面向对象软件开发的核心,存在着对现实世界物理学的违反。我们有重塑世界的许可,因为在我们的机器中模拟真实世界并不是我们的目标。"

- Rebecca Wirfs-Brock通过对象设计[2002]。

组成和委托

如果继承真的只是为了重用(而不是为了构建世界),那么这意味着我们可以完全跳过层次结构,使用组合和委托的技术来实现继承。

这意味着我们也可以像这样重构HTTP逻辑的重复。

class UsersAPI {
	
  // Compose using dependency injection
  constructor (http: API) {
    this.http = http;
    this.baseURL = 'https://example.com/users'
  }
  
  getUsers () {
    return this.http.get(this.baseURL)
  }

  ...

因此,如果我们遇到了重复,我们有很多重构技术。

  • 继承(提取超类)。当你有两个或多个具有共同方法或字段的类时,创建一个超类并继承共享行为。
  • 组合(提取类)。当一个类为两个或更多的类工作时,将相关的方法提取到它自己的类中,并将你依赖的类与新的类组成。

经验法则--只继承契约,不继承凝结物。如果你要用经典的OO方式来继承,我一般建议你只从契约(接口、抽象类、类型)中继承或扩展,而不是从集合体(类)中继承。有一种设计方法解释了为什么这个规则是有意义的(见责任驱动设计),但作为一个快速的经验法则,如果你发现自己需要对一个具体的类进行子类化,你就会知道你是否使用了糟糕的继承。

封装

对象包含状态,根据该状态做出决定,并向其他对象传递信息,要求它们帮助获取或计算东西,就像路由器网络或原子网一样。

由于对象根据其状态做出决定(并制定业务逻辑),因此每个对象管理自己的状态并防止来自外部类的突变是很重要的。如果不这样做,会导致奇怪的行为、错误和贫血的领域模型

封装是一种使状态private 的技术。封装意味着拥有状态的类决定状态可以被访问和改变的程度。这是通过使用public 方法来实现的。

让我们继续用洗衣机的例子来证明。根据洗衣机的当前状态,有些行为是有效的,而有些则是无效的。

image.png

例如,如果机器目前处于ON ,那么调用turnOff() ,将机器转到OFF ,是有效的。如果机器处于WASH 状态,调用pause() 将机器转为PAUSED 状态也是可以的。但如果机器是OFF (或ON ),那么调用PAUSE ,就成立了。

为什么?因为洗衣机不可能从OFFPAUSED 。这没有意义。

封装是为我们管理这些规则(也被称为类不变性)的机制。

type WashState = 'OFF' | 'ON' | 'WASH' | 'PAUSED' | 'DONE';
type Cycle = undefined | 'COOL' | 'WARM';

type MachineState = {
  washState: WashState;
  currentCycle: Cycle;
}

class WashingMachine {
  // Encapsulation of state is achieved here by making
  // state private and inaccessible directly.
  private state: MachineState;
	
  // Access is provided via public methods
  public getWashState () : WashState {
    return this.state.washState;
  }
  
  public getCurrentCycle (): Cycle {
    return this.state.currentCycle;
  }

  // State is changed only through the use of public
  // mutator methods
  public pause (): void {
    // Note this important business logic which is encapsulated
    // to the correct place. The related data and behavior live
    // together.

    // We ONLY do the pause logic if the current state is
    // 'WASH'
    if (this.state.washState === 'WASH') {
      // Stop washing
      ...
      // Cue pause sound
      ...
      // Set new state
      this.state.washState = 'PAUSED'
    }
  }

	...
}

鉴于此,客户端的用法可能如下。

washer.startCycle({
  dryLevel: 'medium',
  temperature: 'cold',
  duration: 'hour',
  ecoEnabled: false
});

console.log(washer.getWashState()); // 'WASH'
washer.pause();
console.log(washer.getWashState()); // 'PAUSED'

封装将数据和相关的行为结合在一起,就像抽象一样,简化了使用,同时也是我们放置相关逻辑的一个地方。通过这种技术,我们发明了单一的真理来源,并获得了适当的地方来一劳永逸地定位逻辑。

多态性

多态性是指 "以多种形式出现"。这是关于设计你的用例和算法的一种方式,我们总是得到相同的高层行为,但我们允许在运行时有动态的低层行为

不管怎么样,让我们用一个例子来证明,让我向你介绍一下责任驱动设计中的角色概念(据我所知,这是正确进行OO的实际方法)。想一想杂货店的店员的角色。一个店员应该做什么?回答问题,扫描产品,将物品装袋,并提示你付款,对吗?所以你可以说这些是店员的职责

image.png

一张店员CRC卡,定义了店员的角色,相关的责任和合作关系。

现在我们假设有4个不同的员工在这家杂货店工作。每个人都可以担任店员的角色。有詹姆斯,他一般都很爽快;有玛丽亚,她可能有点忧郁;有马克斯,他总是很准时(而且是你见过的最友好的人);还有马克,他实际上是经理,但有时会在他们人手不足的时候代班。

很明显,每个员工的工作方式都略有不同,但他们仍然履行着一个店员应该履行的职责。也就是说,他们是具体的类,实现了一个Clerk 的合同。

让我们用一个接口来表示这个契约。

interface Clerk {
  answerQuestion (question: string): Answer;
  scanProduct (product: Product): void;
  bagProduct (product: Product): void;
  promptForPayment (customer: Customer): Promise<PaymentResult>
}

然后,我们可以通过实现Clerk 接口来实现具体化,如果我们愿意的话,用稍微不同的行为来处理事情。

class ClerkJames implements Clerk {

  answerQuestion (question: string): Answer {
    ... // Implement uniquely to James
  }

  scanProduct (product: Product): void {
    ... // Implement uniquely to James
  }

  bagProduct (product: Product): void {
    ... // Implement uniquely to James
  }

  promptForPayment (customer: Customer): Promise<PaymentResult> {
    ... // Implement uniquely to James
  }

}

class ClerkMariah implements Clerk {

  answerQuestion (question: string): Answer {
    ... // Implement uniquely to Mariah
  }

  scanProduct (product: Product): void {
    ... // Implement uniquely to Mariah
  }

  bagProduct (product: Product): void {
    ... // Implement uniquely to Mariah
  }

  promptForPayment (customer: Customer): Promise<PaymentResult> {
    ... // Implement uniquely to Mariah
  }

}

// Implement the others

然后最后,在依赖一个Clerk 的代码中,我们会提供一个Clerk 和一个Customer 来检查出来。

function checkoutCustomer (clerk: Clerk, customer: Customer): Promise<PaymentResult> {
  for (let question of customer.getQuestions()) {
    clerk.answer(question);
  }

  for (let product of customer.getProductsInCart()) {
    clerk.scanProduct(product);
    clerk.bagProduct(product);
  }

  await clerk.promptForPayment(customer);
}

注意,这个函数依赖于一个Clerk ,而不是一个ClerkJames 或一个ClerkMariah (或以此类推)。因此,谁出现在工作中来填补这个角色并不重要。可以是James、Mariah、Max或Mark--并不重要。重要的是,一些具有书记员角色的对象被提供,并且完全实现了合同的要求,Clerk

let customer: Customer = { ... };

let clerkJames: ClerkJames = new ClerkJames();
let clerkMariah: ClerkMariah = new ClerkMariah();

checkoutCustomer(clerkJames, customer); // Valid
checkoutCustomer(clerkMariah, customer); // Also valid since it's a `Clerk`

你明白这是怎么一回事吗?通过依赖契约而不是具体化,我们获得了为不同的可能的实现进行代入的能力。

这就是多态性的意义所在。动态运行时行为。可替代性。 image.png

我想让你从这个例子中得到的是,依赖角色/契约的想法可以非常有效。在清洁/六边形/端口和适配器架构的背景下,你这样做最常见的原因是。

  • 使用依赖关系反转,使核心代码与基础设施代码分开
  • 保持应用层用例的纯净,以便对其进行单元测试。将基础设施的依赖性(StripeAPI,PaypalAPI )换成测试的双重性(MockPaymentAPI ),这样我们就不会在测试中对外部服务或基础设施进行真正的调用。

<!--在这里画一张图,谁依赖支付API(合同)并不重要;然后显示StripeAPI、PaypalAPI和MockPaymentAPI。给出这个额外的例子来推动这个观点,并在店员等之间进行比对。 -->

Also see

React组件是多态性的一个例子:我以前提到过,但在React中,组件类是多态性的一个例子。通过扩展组件类并实现 render() 方法,我们创建了动态的运行时行为(其中的动态行为是如何我们的组件被渲染)。请阅读更多这里

经验法则 - 开关语句(条件语句)可以被重构为多态性。大多数时候,如果我们在一个特定的块中处理三个或更多的条件(并且我们期望增加更多的条件),重构为多态性可能是一个好主意。将通用/普通行为提取到一个契约中(抽象类或接口),并将具体行为放在子类中。

Conclusion

抽象、封装、继承和多态性是面向对象编程的四个主要原则。

  • 抽象让我们有选择地关注高层,并以抽象的方式关注低层的细节。
  • 继承是关于代码重用,而不是层次结构。
  • 封装使状态保持私有,这样我们就可以更好地执行业务规则,保护模型不变性,并为相关数据和逻辑开发一个单一的真理源。
  • 多态性为我们提供了设计动态运行时行为的能力,易于扩展和可替代。