理解面向对象编程及面向对象编程语言的关键就是理解其四大特性:封装、抽象、继承、多态。不过,对于这四大特性,光知道它们的定义是不够的,我们还要知道每个特性存在的意义和目的,以及它们能解决哪些编程问题。
封装(Encapsulation)
封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据。
对于封装这个特性,我们需要编程语言本身提供一定的语法机制来支持。这个语法机制就是访问权限控制。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 {
// ...省略其他属性...
public void savePicture(Picture picture) { ... }
public Image getPicture(String pictureId) { ... }
public void deletePicture(String pictureId) { ... }
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
等。
为什么 Java
不支持多重继承呢?
Java
不支持多重继承的原因:
多重继承有副作用:钻石问题(菱形继承)。
假设类B
和类C
继承自类A
,且都重写了类A
中的同一个方法,而类D
同时继承了类B
和类C
,那么此时类D
会继承B
、C
的方法,那对于B
、C
重写的A
中的方法,类D
会继承哪一个呢?这里就会产生歧义。
考虑到这种二义性问题,Java
不支持多重继承。
继承存在的意义是什么?它能解决什么编程问题?
继承最大的一个好处就是代码复用。假如两个类有一些相同的属性和方法,我们就可以将这些相同的部分,抽取到父类中,让两个子类继承父类。这样,两个子类就可以重用父类中的代码,避免代码重复写多遍。不过,这一点也并不是继承所独有的,我们也可以通过其他方式来解决这个代码复用的问题,比如利用组合关系而不是继承关系。
如果我们再上升一个思维层面,去思考继承这一特性,可以这么理解:我们代码中有一个猫类,有一个哺乳动物类。猫属于哺乳动物,从人类认知的角度上来说,是一种 is-a
关系。我们通过继承来关联两个类,反应真实世界中的这种关系,非常符合人类的认知,而且,从设计的角度来说,也有一种结构美感。
继承的概念很好理解,也很容易使用。不过,过度使用继承,继承层次过深过复杂,就会导致代码可读性、可维护性变差。为了了解一个类的功能,我们不仅需要查看这个类的代码,还需要按照继承关系一层一层地往上查看“父类、父类的父类……”的代码。还有,子类和父类高度耦合,修改父类的代码,会直接影响到子类。
所以,继承这个特性也是一个非常有争议的特性。很多人觉得继承是一种反模式,我们应该尽量少用,甚至不用。
多态(Polymorphism)
多态是指,子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。对于多态这种特性,纯文字解释不好理解,我们还是看一个具体的例子。
public class DynamicArray {
private static final int DEFAULT_CAPACITY = 10;
private int size = 0;
private int capacity = DEFAULT_CAPACITY;
private 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
}
}
多态这种特性也需要编程语言提供特殊的语法机制来实现。在上面的例子中,我们用到了三个语法机制来实现多态。
-
第一个语法机制是编程语言要支持父类对象可以引用子类对象,也就是可以将
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
来实现多态特性。我们还是先来看一段代码。这是一段 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
。多态可以提高代码的扩展性和复用性,是很多设计模式、设计原则、编程技巧的代码实现基础。