面向对象编程的4大特性。
大部分面向对象编程语言都提供了相应的语法机制来支持这些特性,但不同的编程语言实现这四大特性的语法机制可能会有所不同。
每个特性存在的意义和目的,以及它们能解决哪些编程问题。
对于这四大特性,尽管大部分面向对象编程语言都提供了相应的语法机制来支持,但不同的编程语言实现这四大特性的语法机制可能会有所不同。在讲解四大特性的时候,并不与具体某种编程语言的特定语法相挂钩,同时,也希望不要局限在自己熟悉的编程语言的语法思维框架里。
封装(Encapsulation)
封装的定义
封装也叫作信息隐藏或者数据访问保护。类通过暴露访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据。
下面这段代码是金融系统中一个简化版的虚拟钱包的代码实现。在金融系统中,会给每个用户创建一个虚拟钱包,用来记录用户在系统中的虚拟货币量。
Wallet 类主要有四个属性(也可以叫作成员变量),也就是前面定义中提到的信息或者数据。
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();
}
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) < 0) {
throw new InvalidAmountException("...");
}
this.balance.add(increasedAmount);
this.balanceLastModifiedTime = System.currentTimeMillis();
}
public void decreaseBalance(BigDecimal decreasedAmount) {
if (decreasedAmount.compareTo(BigDecimal.ZERO) < 0) {
throw new InvalidAmountException("...");
}
if (decreasedAmount.compareTo(this.balance) > 0) {
throw new InsufficientAmountException("...");
}
this.balance.subtract(decreasedAmount);
this.balanceLastModifiedTime = System.currentTimeMillis();
}
}
这段代码体现了面向对象编程的思想。
- 封装(Encapsulation) :通过使用私有属性和公共方法,该代码将数据和相关的操作封装在钱包类内部。私有属性只能通过公共方法进行访问,从而保护数据的完整性和安全性,并隐藏了内部实现的细节。
- 单一职责原则(Single Responsibility Principle,SRP) :这段代码将钱包的属性和相关操作封装在一个类中,遵循了SRP的原则,每个类应该只有一个引起变化的原因。
- 异常处理(Exception Handling) :在代码中,通过抛出自定义的异常(InvalidAmountException和InsufficientAmountException)来处理不合法的操作或状态。这种异常处理机制可以提高代码的健壮性和可靠性。
参照封装特性,对钱包的这四个属性的访问方式进行了限制。调用者只允许通过下面这六个方法来访问或者修改钱包里的数据。
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 关键字语法)这两种语法机制来实现抽象。(“接口类”特指编程语言提供的接口语法。)
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)
继承的定义
继承是用来表示类之间的is-a 关系,比如猫是一种哺乳动物。从继承关系上来讲,继承可以分为两种模式,单继承和多继承。
单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类,比如猫既是哺乳动物,又是爬行动物。
为了实现继承特性,编程语言需要提供特殊的语法机制来支持,比如 Java 使用extends 关键字来实现继承,C++ 使用冒号(class B : public A),Python 使用paraentheses(),Ruby 使用 <。不过,有些编程语言只支持单继承,不支持多重继承,比如 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();
for (int i = size-1; i>=0; --i) { // 保证数组中的数据有序
if (elements[i] > 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 < dynamicArray.size(); ++i) {
System.out.println(dynamicArray[i]);
}
}
public static void main(String args[]) {
DynamicArray dynamicArray = new SortedDynamicArray();
test(dynamicArray); // 打印结果:1、3、5
}
}
这段Java代码定义了三个类:DynamicArray、SortedDynamicArray和Example。
DynamicArray是一个动态数组类,它使用一个数组来存储元素。它包含以下成员:
size:表示数组中元素的个数。capacity:表示数组的容量。elements:一个整数类型的数组,用于存储元素。
DynamicArray类提供了一些方法,包括:
size():返回数组中元素的个数。get(int index):返回指定索引位置的元素。add(Integer e):向数组中添加一个元素。如果数组已满,会自动扩容。
SortedDynamicArray是DynamicArray的子类,它继承了父类的属性和方法,并重写了add(Integer e)方法。在SortedDynamicArray中,添加元素时会保证数组中的元素是有序的。
Example类包含一个静态方法test(DynamicArray dynamicArray)和一个静态的main方法。test方法用于测试动态数组的功能。在main方法中,首先创建一个SortedDynamicArray对象,并将其传递给test方法进行测试。测试中添加了几个元素,并打印了数组中的元素。
总体来说,这段代码实现了一个动态数组类DynamicArray和一个基于有序数组的动态数组类SortedDynamicArray。SortedDynamicArray类保证了元素的有序性。Example类用于测试这些类的功能,输出测试结果。
在上面的例子中,用到了三个语法机制来实现多态。
- 编程语言要支持父类对象可以引用子类对象,也就是可以将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 {
String hasNext();
String next();
String remove();
}
public class Array implements Iterator {
private String[] data;
public String hasNext() { ... }
public String next() { ... }
public String remove() { ... }
//... 省略其他方法...
}
public class LinkedList implements Iterator {
private LinkedListNode head;
public String 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 来实现多态
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 语句等。
你熟悉的编程语言是否支持多重继承?如果不支持,请说一下为什么不支持。如果支持,请说一下它是如何避免多重继承的副作用的。
你熟悉的编程语言对于四大特性是否都有现成的语法支持?对于支持的特性,是通过什么语法机制实现的?对于不支持的特性,又是基于什么原因做的取舍?
设计模式面试题:mp.weixin.qq.com/s/9SBV9ZycA…