Files
CategoryResourceRepost/极客时间专栏/设计模式之美/设计原则与思想:面向对象/05 | 理论二:封装、抽象、继承、多态分别可以解决哪些编程问题?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

330 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="05 | 理论二:封装、抽象、继承、多态分别可以解决哪些编程问题?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ad/c8/ad62ace3ad990dcd5940518411edc0c8.mp3"></audio>
上一节课,我简单介绍了面向对象的一些基本概念和知识点,比如,什么是面向对象编程,什么是面向对象编程语言等等。其中,我们还提到,理解面向对象编程及面向对象编程语言的关键就是理解其四大特性:封装、抽象、继承、多态。不过,对于这四大特性,光知道它们的定义是不够的,我们还要知道每个特性存在的意义和目的,以及它们能解决哪些编程问题。所以,今天我就花一节课的时间,针对每种特性,结合实际的代码,带你将这些问题搞清楚。
这里我要强调一下,对于这四大特性,尽管大部分面向对象编程语言都提供了相应的语法机制来支持,但不同的编程语言实现这四大特性的语法机制可能会有所不同。所以,今天,我们在讲解四大特性的时候,并不与具体某种编程语言的特定语法相挂钩,同时,也希望你不要局限在你自己熟悉的编程语言的语法思维框架里。
## 封装Encapsulation
首先,我们来看封装特性。封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据。这句话怎么理解呢?我们通过一个简单的例子来解释一下。
下面这段代码是金融系统中一个简化版的虚拟钱包的代码实现。在金融系统中我们会给每个用户创建一个虚拟钱包用来记录用户在我们的系统中的虚拟货币量。对于虚拟钱包的业务背景这里你只需要简单了解一下即可。在面向对象的实战篇中我们会有单独两节课利用OOP的设计思想来详细介绍虚拟钱包的设计实现。
```
public class Wallet {
private String id;
private long createTime;
private BigDecimal balance;
private long balanceLastModifiedTime;
// ...省略其他属性...
public Wallet() {
this.id = IdGenerator.getInstance().generate();
this.createTime = System.currentTimeMillis();
this.balance = BigDecimal.ZERO;
this.balanceLastModifiedTime = System.currentTimeMillis();
}
// 注意下面对get方法做了代码折叠是为了减少代码所占文章的篇幅
public String getId() { return this.id; }
public long getCreateTime() { return this.createTime; }
public BigDecimal getBalance() { return this.balance; }
public long getBalanceLastModifiedTime() { return this.balanceLastModifiedTime; }
public void increaseBalance(BigDecimal increasedAmount) {
if (increasedAmount.compareTo(BigDecimal.ZERO) &lt; 0) {
throw new InvalidAmountException(&quot;...&quot;);
}
this.balance.add(increasedAmount);
this.balanceLastModifiedTime = System.currentTimeMillis();
}
public void decreaseBalance(BigDecimal decreasedAmount) {
if (decreasedAmount.compareTo(BigDecimal.ZERO) &lt; 0) {
throw new InvalidAmountException(&quot;...&quot;);
}
if (decreasedAmount.compareTo(this.balance) &gt; 0) {
throw new InsufficientAmountException(&quot;...&quot;);
}
this.balance.subtract(decreasedAmount);
this.balanceLastModifiedTime = System.currentTimeMillis();
}
}
```
从代码中我们可以发现Wallet类主要有四个属性也可以叫作成员变量也就是我们前面定义中提到的信息或者数据。其中id表示钱包的唯一编号createTime表示钱包创建的时间balance表示钱包中的余额balanceLastModifiedTime表示上次钱包余额变更的时间。
我们参照封装特性,对钱包的这四个属性的访问方式进行了限制。调用者只允许通过下面这六个方法来访问或者修改钱包里的数据。
- String getId()
- long getCreateTime()
- BigDecimal getBalance()
- long getBalanceLastModifiedTime()
- void increaseBalance(BigDecimal increasedAmount)
- void decreaseBalance(BigDecimal decreasedAmount)
之所以这样设计是因为从业务的角度来说id、createTime在创建钱包的时候就确定好了之后不应该再被改动所以我们并没有在Wallet类中暴露id、createTime这两个属性的任何修改方法比如set方法。而且这两个属性的初始化设置对于Wallet类的调用者来说也应该是透明的所以我们在Wallet类的构造函数内部将其初始化设置好而不是通过构造函数的参数来外部赋值。
对于钱包余额balance这个属性从业务的角度来说只能增或者减不会被重新设置。所以我们在Wallet类中只暴露了increaseBalance()和decreaseBalance()方法并没有暴露set方法。对于balanceLastModifiedTime这个属性它完全是跟balance这个属性的修改操作绑定在一起的。只有在balance修改的时候这个属性才会被修改。所以我们把balanceLastModifiedTime这个属性的修改操作完全封装在了increaseBalance()和decreaseBalance()两个方法中不对外暴露任何修改这个属性的方法和业务细节。这样也可以保证balance和balanceLastModifiedTime两个数据的一致性。
对于封装这个特性,我们需要编程语言本身提供一定的语法机制来支持。这个语法机制就是**访问权限控制。**例子中的private、public等关键字就是Java语言中的访问权限控制语法。private关键字修饰的属性只能类本身访问可以保护其不被类之外的代码直接访问。如果Java语言没有提供访问权限控制语法所有的属性默认都是public的那任意外部代码都可以通过类似wallet.id=123;这样的方式直接访问、修改属性,也就没办法达到隐藏信息和保护数据的目的了,也就无法支持封装特性了。
**封装特性的定义讲完了,我们再来看一下,封装的意义是什么?它能解决什么编程问题?**
如果我们对类中属性的访问不做限制那任何代码都可以访问、修改类中的属性虽然这样看起来更加灵活但从另一方面来说过度灵活也意味着不可控属性可以随意被以各种奇葩的方式修改而且修改逻辑可能散落在代码中的各个角落势必影响代码的可读性、可维护性。比如某个同事在不了解业务逻辑的情况下在某段代码中“偷偷地”重设了wallet中的balanceLastModifiedTime属性这就会导致balance和balanceLastModifiedTime两个数据不一致。
除此之外,类仅仅通过有限的方法暴露必要的操作,也能提高类的易用性。如果我们把类属性都暴露给类的调用者,调用者想要正确地操作这些属性,就势必要对业务细节有足够的了解。而这对于调用者来说也是一种负担。相反,如果我们将属性封装起来,暴露少许的几个必要的方法给调用者使用,调用者就不需要了解太多背后的业务细节,用错的概率就减少很多。这就好比,如果一个冰箱有很多按钮,你就要研究很长时间,还不一定能操作正确。相反,如果只有几个必要的按钮,比如开、停、调节温度,你一眼就能知道该如何来操作,而且操作出错的概率也会降低很多。
## 抽象Abstraction
讲完了封装特性,我们再来看抽象特性。 封装主要讲的是如何隐藏信息、保护数据,而抽象讲的是如何隐藏方法的具体实现,让调用者只需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的。
在面向对象编程中我们常借助编程语言提供的接口类比如Java中的interface关键字语法或者抽象类比如Java中的abstract关键字语法这两种语法机制来实现抽象这一特性。
这里我稍微说明一下在专栏中我们把编程语言提供的接口语法叫作“接口类”而不是“接口”。之所以这么做是因为“接口”这个词太泛化可以指好多概念比如API接口等所以我们用“接口类”特指编程语言提供的接口语法。
对于抽象这个特性,我举一个例子来进一步解释一下。
```
public interface IPictureStorage {
void savePicture(Picture picture);
Image getPicture(String pictureId);
void deletePicture(String pictureId);
void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo);
}
public class PictureStorage implements IPictureStorage {
// ...省略其他属性...
@Override
public void savePicture(Picture picture) { ... }
@Override
public Image getPicture(String pictureId) { ... }
@Override
public void deletePicture(String pictureId) { ... }
@Override
public void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo) { ... }
}
```
在上面的这段代码中我们利用Java中的interface接口语法来实现抽象特性。调用者在使用图片存储功能的时候只需要了解IPictureStorage这个接口类暴露了哪些方法就可以了不需要去查看PictureStorage类里的具体实现逻辑。
实际上抽象这个特性是非常容易实现的并不需要非得依靠接口类或者抽象类这些特殊语法机制来支持。换句话说并不是说一定要为实现类PictureStorage抽象出接口类IPictureStorage才叫作抽象。即便不编写IPictureStorage接口类单纯的PictureStorage类本身就满足抽象特性。
之所以这么说那是因为类的方法是通过编程语言中的“函数”这一语法机制来实现的。通过函数包裹具体的实现逻辑这本身就是一种抽象。调用者在使用函数的时候并不需要去研究函数内部的实现逻辑只需要通过函数的命名、注释或者文档了解其提供了什么功能就可以直接使用了。比如我们在使用C语言的malloc()函数的时候,并不需要了解它的底层代码是怎么实现的。
除此之外,在上一节课中,我们还提到,抽象有时候会被排除在面向对象的四大特性之外,当时我卖了一个关子,现在我就来解释一下为什么。
抽象这个概念是一个非常通用的设计思想,并不单单用在面向对象编程中,也可以用来指导架构设计等。而且这个特性也并不需要编程语言提供特殊的语法机制来支持,只需要提供“函数”这一非常基础的语法机制,就可以实现抽象特性、所以,它没有很强的“特异性”,有时候并不被看作面向对象编程的特性之一。
**抽象特性的定义讲完了,我们再来看一下,抽象的意义是什么?它能解决什么编程问题?**
实际上,如果上升一个思考层面的话,抽象及其前面讲到的封装都是人类处理复杂性的有效手段。在面对复杂系统的时候,人脑能承受的信息复杂程度是有限的,所以我们必须忽略掉一些非关键性的实现细节。而抽象作为一种只关注功能点不关注实现的设计思路,正好帮我们的大脑过滤掉许多非必要的信息。
除此之外,抽象作为一个非常宽泛的设计思想,在代码设计中,起到非常重要的指导作用。很多设计原则都体现了抽象这种设计思想,比如基于接口而非实现编程、开闭原则(对扩展开放、对修改关闭)、代码解耦(降低代码的耦合性)等。我们在讲到后面的内容的时候,会具体来解释。
换一个角度来考虑我们在定义或者叫命名类的方法的时候也要有抽象思维不要在方法定义中暴露太多的实现细节以保证在某个时间点需要改变方法的实现逻辑的时候不用去修改其定义。举个简单例子比如getAliyunPictureUrl()就不是一个具有抽象思维的命名因为某一天如果我们不再把图片存储在阿里云上而是存储在私有云上那这个命名也要随之被修改。相反如果我们定义一个比较抽象的函数比如叫作getPictureUrl(),那即便内部存储方式修改了,我们也不需要修改命名。
## 继承Inheritance
学习完了封装和抽象两个特性我们再来看继承特性。如果你熟悉的是类似Java、C++这样的面向对象的编程语言那你对继承这一特性应该不陌生了。继承是用来表示类之间的is-a关系比如猫是一种哺乳动物。从继承关系上来讲继承可以分为两种模式单继承和多继承。单继承表示一个子类只继承一个父类多继承表示一个子类可以继承多个父类比如猫既是哺乳动物又是爬行动物。
为了实现继承这个特性编程语言需要提供特殊的语法机制来支持比如Java使用extends关键字来实现继承C++使用冒号class B : public APython使用parentheses ()Ruby使用&lt;。不过有些编程语言只支持单继承不支持多重继承比如Java、PHP、C#、Ruby等而有些编程语言既支持单重继承也支持多重继承比如C++、Python、Perl等。
为什么有些语言支持多重继承,有些语言不支持呢?这个问题留给你自己去研究,你可以针对你熟悉的编程语言,在留言区写一写具体的原因。
**继承特性的定义讲完了,我们再来看,继承存在的意义是什么?它能解决什么编程问题?**
继承最大的一个好处就是代码复用。假如两个类有一些相同的属性和方法,我们就可以将这些相同的部分,抽取到父类中,让两个子类继承父类。这样,两个子类就可以重用父类中的代码,避免代码重复写多遍。不过,这一点也并不是继承所独有的,我们也可以通过其他方式来解决这个代码复用的问题,比如利用组合关系而不是继承关系。
如果我们再上升一个思维层面去思考继承这一特性可以这么理解我们代码中有一个猫类有一个哺乳动物类。猫属于哺乳动物从人类认知的角度上来说是一种is-a关系。我们通过继承来关联两个类反应真实世界中的这种关系非常符合人类的认知而且从设计的角度来说也有一种结构美感。
继承的概念很好理解,也很容易使用。不过,过度使用继承,继承层次过深过复杂,就会导致代码可读性、可维护性变差。为了了解一个类的功能,我们不仅需要查看这个类的代码,还需要按照继承关系一层一层地往上查看“父类、父类的父类……”的代码。还有,子类和父类高度耦合,修改父类的代码,会直接影响到子类。
所以,继承这个特性也是一个非常有争议的特性。很多人觉得继承是一种反模式。我们应该尽量少用,甚至不用。关于这个问题,在后面讲到“多用组合少用继承”这种设计思想的时候,我会非常详细地再讲解,这里暂时就不展开讲解了。
## 多态Polymorphism
学习完了封装、抽象、继承之后,我们再来看面向对象编程的最后一个特性,多态。多态是指,子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。对于多态这种特性,纯文字解释不好理解,我们还是看一个具体的例子。
```
public class DynamicArray {
private static final int DEFAULT_CAPACITY = 10;
protected int size = 0;
protected int capacity = DEFAULT_CAPACITY;
protected Integer[] elements = new Integer[DEFAULT_CAPACITY];
public int size() { return this.size; }
public Integer get(int index) { return elements[index];}
//...省略n多方法...
public void add(Integer e) {
ensureCapacity();
elements[size++] = e;
}
protected void ensureCapacity() {
//...如果数组满了就扩容...代码省略...
}
}
public class SortedDynamicArray extends DynamicArray {
@Override
public void add(Integer e) {
ensureCapacity();
int i;
for (i = size-1; i&gt;=0; --i) { //保证数组中的数据有序
if (elements[i] &gt; e) {
elements[i+1] = elements[i];
} else {
break;
}
}
elements[i+1] = e;
++size;
}
}
public class Example {
public static void test(DynamicArray dynamicArray) {
dynamicArray.add(5);
dynamicArray.add(1);
dynamicArray.add(3);
for (int i = 0; i &lt; dynamicArray.size(); ++i) {
System.out.println(dynamicArray.get(i));
}
}
public static void main(String args[]) {
DynamicArray dynamicArray = new SortedDynamicArray();
test(dynamicArray); // 打印结果1、3、5
}
}
```
多态这种特性也需要编程语言提供特殊的语法机制来实现。在上面的例子中,我们用到了三个语法机制来实现多态。
- 第一个语法机制是编程语言要支持父类对象可以引用子类对象也就是可以将SortedDynamicArray传递给DynamicArray。
- 第二个语法机制是编程语言要支持继承也就是SortedDynamicArray继承了DynamicArray才能将SortedDyamicArray传递给DynamicArray。
- 第三个语法机制是编程语言要支持子类可以重写override父类中的方法也就是SortedDyamicArray重写了DynamicArray中的add()方法。
通过这三种语法机制配合在一起我们就实现了在test()方法中子类SortedDyamicArray替换父类DynamicArray执行子类SortedDyamicArray的add()方法,也就是实现了多态特性。
对于多态特性的实现方式除了利用“继承加方法重写”这种实现方式之外我们还有其他两种比较常见的的实现方式一个是利用接口类语法另一个是利用duck-typing语法。不过并不是每种编程语言都支持接口类或者duck-typing这两种语法机制比如C++就不支持接口类语法而duck-typing只有一些动态语言才支持比如Python、JavaScript等。
**接下来,我们先来看如何利用接口类来实现多态特性。**我们还是先来看一段代码。
```
public interface Iterator {
boolean hasNext();
String next();
String remove();
}
public class Array implements Iterator {
private String[] data;
public boolean hasNext() { ... }
public String next() { ... }
public String remove() { ... }
//...省略其他方法...
}
public class LinkedList implements Iterator {
private LinkedListNode head;
public boolean hasNext() { ... }
public String next() { ... }
public String remove() { ... }
//...省略其他方法...
}
public class Demo {
private static void print(Iterator iterator) {
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
}
public static void main(String[] args) {
Iterator arrayIterator = new Array();
print(arrayIterator);
Iterator linkedListIterator = new LinkedList();
print(linkedListIterator);
}
}
```
在这段代码中Iterator是一个接口类定义了一个可以遍历集合数据的迭代器。Array和LinkedList都实现了接口类Iterator。我们通过传递不同类型的实现类Array、LinkedList到print(Iterator iterator)函数中支持动态的调用不同的next()、hasNext()实现。
具体点讲就是当我们往print(Iterator iterator)函数传递Array类型的对象的时候print(Iterator iterator)函数就会调用Array的next()、hasNext()的实现逻辑当我们往print(Iterator iterator)函数传递LinkedList类型的对象的时候print(Iterator iterator)函数就会调用LinkedList的next()、hasNext()的实现逻辑。
**刚刚讲的是用接口类来实现多态特性。现在我们再来看下如何用duck-typing来实现多态特性。**我们还是先来看一段代码。这是一段Python代码。
```
class Logger:
def record(self):
print(“I write a log into file.”)
class DB:
def record(self):
print(“I insert data into db. ”)
def test(recorder):
recorder.record()
def demo():
logger = Logger()
db = DB()
test(logger)
test(db)
```
从这段代码中我们发现duck-typing实现多态的方式非常灵活。Logger和DB两个类没有任何关系既不是继承关系也不是接口和实现的关系但是只要它们都有定义了record()方法就可以被传递到test()方法中在实际运行的时候执行对应的record()方法。
也就是说只要两个类具有相同的方法就可以实现多态并不要求两个类之间有任何关系这就是所谓的duck-typing是一些动态语言所特有的语法机制。而像Java这样的静态语言通过继承实现多态特性必须要求两个类之间有继承关系通过接口实现多态特性类必须实现对应的接口。
**多态特性讲完了,我们再来看,多态特性存在的意义是什么?它能解决什么编程问题?**
多态特性能提高代码的可扩展性和复用性。为什么这么说呢我们回过头去看讲解多态特性的时候举的第二个代码实例Iterator的例子
在那个例子中我们利用多态的特性仅用一个print()函数就可以实现遍历打印不同类型Array、LinkedList集合的数据。当再增加一种要遍历打印的类型的时候比如HashMap我们只需让HashMap实现Iterator接口重新实现自己的hasNext()、next()等方法就可以了完全不需要改动print()函数的代码。所以说,多态提高了代码的可扩展性。
如果我们不使用多态特性我们就无法将不同的集合类型Array、LinkedList传递给相同的函数print(Iterator iterator)函数。我们需要针对每种要遍历打印的集合分别实现不同的print()函数比如针对Array我们要实现print(Array array)函数针对LinkedList我们要实现print(LinkedList linkedList)函数。而利用多态特性我们只需要实现一个print()函数的打印逻辑,就能应对各种集合数据的打印操作,这显然提高了代码的复用性。
除此之外多态也是很多设计模式、设计原则、编程技巧的代码实现基础比如策略模式、基于接口而非实现编程、依赖倒置原则、里式替换原则、利用多态去掉冗长的if-else语句等等。关于这点在学习后面的章节中你慢慢会有更深的体会。
## 重点回顾
今天的内容就讲完了,我们来一起总结回顾一下,你需要重点掌握的几个知识点。
**1.关于封装特性**
封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口授权外部仅能通过类提供的方式来访问内部信息或者数据。它需要编程语言提供权限访问控制语法来支持例如Java中的private、protected、public关键字。封装特性存在的意义一方面是保护数据不被随意修改提高代码的可维护性另一方面是仅暴露有限的必要接口提高类的易用性。
**2.关于抽象特性**
封装主要讲如何隐藏信息、保护数据,那抽象就是讲如何隐藏方法的具体实现,让使用者只需要关心方法提供了哪些功能,不需要知道这些功能是如何实现的。抽象可以通过接口类或者抽象类来实现,但也并不需要特殊的语法机制来支持。抽象存在的意义,一方面是提高代码的可扩展性、维护性,修改实现不需要改变定义,减少代码的改动范围;另一方面,它也是处理复杂系统的有效手段,能有效地过滤掉不必要关注的信息。
**3.关于继承特性**
继承是用来表示类之间的is-a关系分为两种模式单继承和多继承。单继承表示一个子类只继承一个父类多继承表示一个子类可以继承多个父类。为了实现继承这个特性编程语言需要提供特殊的语法机制来支持。继承主要是用来解决代码复用的问题。
**4.关于多态特性**
多态是指子类可以替换父类在实际的代码运行过程中调用子类的方法实现。多态这种特性也需要编程语言提供特殊的语法机制来实现比如继承、接口类、duck-typing。多态可以提高代码的扩展性和复用性是很多设计模式、设计原则、编程技巧的代码实现基础。
## 课堂讨论
今天我们要讨论的话题有如下两个。
1. 你熟悉的编程语言是否支持多重继承?如果不支持,请说一下为什么不支持。如果支持,请说一下它是如何避免多重继承的副作用的。
1. 你熟悉的编程语言对于四大特性是否都有现成的语法支持?对于支持的特性,是通过什么语法机制实现的?对于不支持的特性,又是基于什么原因做的取舍?
欢迎在留言区写下你的答案,和同学一起交流和分享。如果有收获,也欢迎你把这篇文章分享给你的朋友。