前文提到,理解面向对象编程和面向对象编程语言,关键是要理解四大特性(封装、抽象、继承、多态)。仅仅知道定义是不够的,我们要深刻理解它们的意义和目的,以及能解决什么问题。
本文结合代码来解析四大特性。有一点要注意,不同编程语言对于四大特性的语法机制不尽相同,但我们的分析不与特定编程语言挂钩,不要局限在语法的细节中。
一、封装(Encapsulation)
1. 什么是封装
封装也叫信息隐藏或数据访问保护。类隐藏具体的数据和实现细节,只暴露有限的访问接口。由此,可以限制外部,只能通过类提供的这些访问接口来访问类内部的数据和信息。
接下来,我们用一段 demo 为例来分析。在金融系统中,会给每个用户创建一个虚拟钱包,记录用户在系统中的虚拟货币量。对于虚拟钱包的业务背景,只需要简单了解即可。后续我们会利用 OOP 的设计思想来详细介绍虚拟钱包的设计实现。
1 public class Wallet { 2 3 private String id; // 钱包编号 4 private long createTime; // 钱包创建时间 5 private BigDecimal balance; // 钱包余额 6 private long balanceLastModifiedTime; // 上次余额变更时间 7 8 public Wallet() { 9 this.id = IdGenerator.getInstance().generate(); 10 this.createTime = System.currentTimeMillis(); 11 this.balance = BigDecimal.ZERO; 12 this.balanceLastModifiedTime = System.currentTimeMillis(); 13 } 14 15 public String getId() { 16 return this.id 17 } 18 19 public long getCreateTime() { 20 return this.createTime; 21 } 22 23 public BigDecimal getBalance() { 24 return this.balance; 25 } 26 27 public long getBalanceLastModifiedTime() 28 { 29 return this.balanceLastModifiedTime; 30 } 31 32 public void increaseBalance(BigDecimal increasedAmount) { 33 if (increasedAmount.compareTo(BigDecimal.ZERO) < 0) { 34 throw new InvalidAmountException("..."); 35 } 36 37 this.balance.add(increaseBalance); 38 this.balanceLastModifiedTime = System.currentTimeMillis(); 39 } 40 41 public void decreaseBalance(BigDecimal decreasedAmount) { 42 if (decreasedAmount.compareTo(ZERO) < 0) { 43 throw new InvalidAmountException("..."); 44 } 45 46 if (decreasedAmount.compareTo(this.balance) > 0) { 47 throw new InsufficientAmountException("..."); 48 } 49 50 this.balance.subtract(decreasedAmount); 51 this.balanceLastModifiedTime = System.currentTimeMillis(); 52 } 53 }
从以上代码可以看到,Wallet 类有四个成员变量,我们使用封装特性,对这四个成员变量的访问进行限制。调用者只能通过以下六个方法来访问或修改钱包中的数据:
-
- String getId()
- long getCreateTime()
- BigDecimal getBalance()
- long getBalanceLastModifiedTime()
- void increaseBalance(BigDecimal increasedAmount)
- void decreaseBalance(BigDecimal decreasedAmount)
之所以这样设计,是因为 id、createTime 在对象创建时确定,不应再被改动,所以 Wallet 类不对外暴露它们的修改方法。此外,这两个属性的初始化设置,对于 Wallet 类的调用者来说应该是透明的。因此,在 Wallet 类的构造函数中将其初始化,而非通过构造函数的参数来外部赋值。
对于钱包余额 balance 属性,只能增减,不能重新设置。所以在 Wallet 类中只暴露 increaseBalance() 和 decreaseBalance() 方法,并没有暴露 set 方法。
对于 balanceLastModifiedTime 这个属性,应该和 balance 的修改操作绑定在一起。所以将这个属性的修改操作完全封装在 increaseBalance() 和 decreaseBalance() 方法中,不对外暴露。这样也可以保证 balance 和 balanceLastModifiedTime 两个数据的一致性。
对于封装特性,需要编程语言本身提供访问权限控制来支持。如果没有访问权限控制的功能,那么外部代码可以随意读写属性,无法达到隐藏信息、保护数据的目的,封装自然也就无从谈起了。
2. 封装的意义是什么?能解决什么问题?
如果对类中属性的访问不做限制,那么任意代码都可以访问、修改类中的属性。这样看起来虽然比较灵活,但过度灵活意味着不可控——属性可能被随意修改,而且修改逻辑散落在各处,势必影响代码可读性和可维护性。
此外,如果类仅仅通过有限的方法暴露必要操作,可以提高易用性。如果把所有细节暴露出来,调用者需要对类有详尽了解才能确保正确调用。这实际是一种负担。如果我们把细节封装起来,暴露几个方法给调用者使用,那就会比较容易使用,而且大大降低了出错的概率。
二、抽象(Abstraction)
1. 什么是抽象
抽象讲的是如何隐藏方法的具体实现,让调用者只需关注方法提供了哪些功能即可,不用关注实现细节。
在面向对象编程中,通常借助编程语言提供的接口类、抽象类这两种语法机制来实现抽象特性。PS:接口的概念比较宽泛,譬如还有 API 的含义。我们后续都用“接口类”来特指编程语言提供的接口语法。
我们还是用一个 demo 来理解抽象特性:
1 public interface IPictureStorage { 2 void savePicture(Picture picture); 3 Image getPicture(String pictureId); 4 void deletePicture(String pictureId); 5 void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo); 6 } 7 8 public class PictureStorage implements IPictureStorage { 9 10 // ...省略其他属性... 11 12 @Override 13 public void savePicture(Picture picture) { ... } 14 15 @Override 16 public Image getPicture(String pictureId) { ... } 17 18 @Override 19 public void deletePicture(String pictureId) { ... } 20 21 @Override 22 public void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo) { ... } 23 }
以上代码利用 Java 的 interface 接口语法来实现抽象特性。调用者在使用图片存储功能时,只要知道 IPictureStorage 接口类暴露了哪些方法即可,不用去 PictureStorage 类里查看具体实现逻辑。
抽象特性其实是很容易实现的。即使不依赖接口类和抽象类这种特殊语法机制,单纯的 PictureStorage 类本身就满足抽象特性。因为类的方法包裹了具体的实现逻辑,这本身就是一种抽象。调用者在使用函数时,并不需要去研究函数内部的实现逻辑,只要通过函数的命名、注释和文档,了解其功能即可使用。
前文提到面向对象三大特性和四大特性的问题,为什么有时会把抽象排除在外?这是因为抽象是一个通用的设计思想,并非仅用在面向对象编程中,架构设计等方面也经常使用抽象。此外,抽象特性本身不需要编程语言提供特殊语法机制,支持函数即可实现。综上所述,抽象没有很强的特异性,所以有时不被看作面向对象编程的特性之一。
2. 抽象的意义是什么?它能解决什么问题?
抽象作为一种只关注功能,不关注实现的设计思路,帮我们过滤掉了很多不必要信息。
此外,抽象作为一种非常宽泛的设计思想,在代码设计中起到重要指导作用,很多设计原则都体现了抽象的思想。譬如基于接口而非实现编程、开闭原则(对扩展开放,对修改关闭)、代码解耦等。
我们在给类的方法命名时,也要有抽象思维,不要在方法定义中暴露太多细节,从而保证后续需要修改方法实现逻辑时,不用修改其定义。譬如将方法命名为 getAliyunPictureUrl(),那么后续不再使用阿里云时,这个命名也要随之修改。如果定义成 getPictureUrl() 这种比较抽象的函数,就避免了这个问题。
三、继承(Inheritance)
1. 什么是继承
继承用于表示类之间的 is-a 关系。譬如我们说人是一种哺乳动物。
从继承关系来区分,继承可以分为两种模式——单继承和多继承。单继承就是说,一个子类只继承一个父类。多继承则是说,一个子类可以继承多个父类。
为了实现继承特性,编程语言需要提供特殊的语法机制来支持。譬如,Java 使用 extends,C++ 使用 : 。区别在于,有些编程语言只支持单继承,譬如 Java、PHP。有些语言支持多继承,譬如 C++、Python。
2. 继承的意义是什么?它能解决哪些问题?
继承最大的好处是代码复用。如果两个类有相同的属性和方法,我们就可以将这些相同部分抽取到父类中,让两个子类继承父类。这样,两个子类就可以重用父类中的代码,避免代码重复写多遍。但这并非继承独有的,我们也可以用其他方式来解决代码复用问题,譬如组合。
过度使用继承,继承的层次过深过复杂,就会使得代码可读性、可维护性变差。为了了解一个类的功能,可能需要按照继承关系向上逐层查看直接父类和间接父类。此外,子类和父类高度耦合,修改父类的代码,也会影响到子类。
综上,继承特性比较有争议。有些观点认为,继承是一种反模式,我们应该少用甚至不用。后续会写篇关于“多用组合少用继承”设计思想的博客,再详细分析。
四、多态(Polymorphism)
1. 什么是多态
多态是指,定义时使用父类,实际运行时可以传入子类,从而可以调用子类的方法实现。譬如,定义时写的参数是汽车(父类),实际调用的时候传入的是 F1 赛车(子类),那就可以使用 F1 赛车的特性,譬如速度超快。
多态的特性用文字描述不易理解,还是老规矩,写个 demo:
1 public class DynamicArray { 2 private static final int DEFAULT_CAPACITY = 10; 3 protected int size = 0; 4 protected int capacity = DEFAULT_CAPACITY; 5 protected Integer[] elements = new Integer[DEFAULT_CAPACITY]; 6 7 public int size() { 8 return this.size; 9 } 10 11 public Integer get(int index) { 12 return elements[index]; 13 } 14 15 //...省略n多方法... 16 17 public void add(Integer e) { 18 ensureCapacity(); 19 elements[size++] = e; 20 } 21 22 protected void ensureCapacity() { 23 //...如果数组满了就扩容...代码省略... 24 } 25 } 26 27 public class SortedDynamicArray extends DynamicArray { 28 @Override 29 public void add(Integer e) { 30 ensureCapacity(); 31 32 int i; 33 34 for (i = size-1; i>=0; --i) { //保证数组中的数据有序 35 if (elements[i] > e) { 36 elements[i+1] = elements[i]; 37 } else { 38 break; 39 } 40 } 41 42 elements[i+1] = e; 43 ++size; 44 } 45 } 46 47 public class Example { 48 public static void test(DynamicArray dynamicArray) { 49 dynamicArray.add(5); 50 dynamicArray.add(1); 51 dynamicArray.add(3); 52 53 for (int i = 0; i < dynamicArray.size(); ++i) { 54 System.out.println(dynamicArray.get(i)); 55 } 56 } 57 58 public static void main(String args[]) { 59 DynamicArray dynamicArray = new SortedDynamicArray(); 60 test(dynamicArray); // 打印结果:1、3、5 61 } 62 }
多态这种特性也需要编程语言提供一些语法支持。在上面的 demo 中,用到了三个语法机制来实现多态:
-
- 编程语言要支持引用父类对象的变量可以引用子类对象。
- 编程语言要支持继承。
- 编程语言要支持子类可以重写父类方法。
对于多态特性的实现方式,除了“继承+重写”之外,还有两种实现方式比较常见。一是利用接口类语法,另一个是利用 duck-typing 语法。但是要注意,不是所有编程语言都支持接口类或 duck-typing。譬如 C++ 不支持接口类,而 duck-typing 只有一些动态语言才支持,譬如 Python。
接下来,我们再通过一个 demo 看看,如何利用接口类来实现多态特性。
1 public interface Iterator { 2 boolean hasNext(); 3 String next(); 4 String remove(); 5 } 6 7 public class Array implements Iterator { 8 private String[] data; 9 10 public boolean hasNext() { 11 ... 12 } 13 14 public String next() { 15 ... 16 } 17 18 public String remove() { 19 ... 20 } 21 22 //...省略其他方法... 23 } 24 25 public class LinkedList implements Iterator { 26 private LinkedListNode head; 27 28 public boolean hasNext() { 29 ... 30 } 31 32 public String next() { 33 ... 34 } 35 36 public String remove() { 37 ... 38 } 39 40 //...省略其他方法... 41 } 42 43 public class Demo { 44 private static void print(Iterator iterator) { 45 while (iterator.hasNext()) { 46 System.out.println(iterator.next()); 47 } 48 } 49 50 public static void main(String[] args) { 51 Iterator arrayIterator = new Array(); 52 print(arrayIterator); 53 54 Iterator linkedListIterator = new LinkedList(); 55 print(linkedListIterator); 56 } 57 }
在以上 demo 中,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() 的实现逻辑。
接下来用 Python 写了一个 demo,用于说明如何用 duck-typing 来实现多态特性。
1 class Logger: 2 def record(self): 3 print(“I write a log into file.”) 4 5 class DB: 6 def record(self): 7 print(“I insert data into db. ”) 8 9 def test(recorder): 10 recorder.record() 11 12 def demo(): 13 logger = Logger() 14 db = DB() 15 test(logger) 16 test(db)
duck-typing 实现多态的方式非常灵活。Logger 和 DB 两个类没有任何关系,既不是继承关系,也不是接口和实现的关系。但只要它们都定义了 record() 方法,就可以被传递到 test() 方法中,在实际运行的时候,执行对应的 record() 方法。
所谓 duck-typing,即只要两个类具有相同方法,就可以实现多态,并不要求两个类之间有任何关系。这是一些动态语言所特有的语法机制。而像 Java 这样的静态语言,通过继承实现多态特性,必须要求两个类之间有继承关系,通过接口实现多态特性,类必须实现对应的接口。
2. 多态的意义是什么?它能解决什么问题?
多态特性能提高代码的可扩展性和复用性。咱们以上文的第二个 demo(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 语句等。