面向对象是什么
本文内容
前言
其实,面向对象是一个很广泛的概念,它包括面向对象编程、面向对象编程语言、面向对象分析和面向对象设计等。我们常说的面向对象一般默认指的是面向对象编程。
1. 面向对象编程和面向对象编程语言
面向对象编程(OOP,Object Oriented Programming)是一种 编程范式或编程风格,其中有两个非常重要、基础的概念,叫 类(class)和 对象(object)。
而 面向对象编程语言(OOPL,Object Oriented Programming Language)是指 支持类或对象的语法机制,并有现成的语法能方便地实现面向对象编程的四大特性(封装、抽象、继承、多态)的编程语言。
常见的 OOPL 有 Java、C++、Golang 等;非 OOPL 有 C。
需要注意的是,OOP 和 OOPL 本身并没有强制性的关联。也就是说,不同面向对象编程语言,也可以进行面向对象编程;反过来,即使使用了面向对象编程语言,写出来的代码也不一定是面向对象编程风格的。(后续会详细举例讲解)
其中,理解 OOP 和 OOPL,其中最重要的是理解面向对象编程的四大特性:封装、抽象、继承、多态。
可能你会疑惑为什么有抽象,其实抽象并不是面向对象编程特有,在一些架构设计中也会有。不过我们没必要纠结,关键是看它存在的意义、能解决什么问题。
2. 面向对象分析和面向对象设计
跟面向对象相关的还有两个概念,就是面向对象分析(OOA,Object Oriented Analysis)和面向对象设计(OOD,Object Oriented Design)。
对于这两个概念,我们只需要从字面上去理解就好了,分析和设计最终的产出就是类的设计,比如拆解出了哪些类,每个类有哪些属性、方法,类与类之间的关系,类之间如何交互等。
具体来说,面向对象分析就是搞清楚要做什么,面向对象设计就是搞清楚要怎么做,而面向对象编程就是将分析和设计的结果翻译成代码的过程。
3. OOP 的四大特性
我们在进行面向对象编程的时候,一个很重要的内容就是合理使用 OOP 的四大特性,分别是 封装、抽象、继承、多态。
3.1 封装
封装的定义是什么?
封装,也称为 信息隐藏或数据访问保护,具体在代码中的体现就是:
- 类通过 暴露固定有限的访问接口,而 外部仅能通过类提供的方式(函数)来访问内部信息或数据。
下面举一个实际的例子,来看看什么是封装。
我们在设计一个虚拟钱包 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 = 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 = this.balance.subtract(decreasedAmount);
this.balanceLastModifiedTime = System.currentTimeMillis();
}
}
- 定义钱包的四个属性:id、createTime、balance、balanceLastModifiedTime,这四个属性一般都使用 private 修饰;
- 定义钱包的操作方法,可提供的方法有(见名知意):
- 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 等关键字,就定义了某个属性或方法的访问权限。private 修饰的属性只能在本类中访问,可以保护其不被本类之外的代码直接访问。
相反,如果没有了权限控制,那么任意外部代码都可以通过实例对象 .
属性进行修改,例如 wallet.id = 10
,这样 直接访问、修改属性,就 没法达到隐藏信息和保护数据的目的了,也就无法支持封装特性了。
封装的意义是什么?解决了什么问题?
讲完了封装的定义,我们需要知道封装用来解决了什么问题。
可以通过反证法来说明封装的意义,即如果没有封装,对类中的属性不做限制,那么类属性就可能会在各种不同地方、被各种不同的方式修改,这样会 影响代码的可读性、可维护性。
其次,类仅仅暴露出有限的方法,也能提高类的 易用性。如果把所有类属性都暴露给调用者,调用者操作这些属性时就要对该类的细节有足够的了解,这对调用者来说也是一种负担。而如果把属性封装起来,暴露一些简单易用的方法给调用者,调用者一看就知道该方法是用来干什么的,也就大大降低了使用难度和减少了用错概率。
就比如我们使用的电脑,就把具体的细节隐藏(封装)起来了,只用给用户暴露简单的屏幕、键盘、鼠标,就能方便的操作电脑,大大增加了易用性。
3.2 抽象
抽象的定义是什么?
封装讲的是如何隐藏信息、保护数据,而 抽象 讲的是 如何隐藏方法的具体实现,让调用者只需关心方法提供了什么功能,而不需要知道这些功能是如何实现的。
在 OOP 中,实现抽象主要通过 接口类 或 抽象类 这两种语法机制。
下面举个图片存储的例子来说明。
// 定义接口
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) { ... }
}
- 调用者在使用图片存储功能时,只需要了解 IPictureStorage 接口提供了哪些方法,而不需要去实现类 PictureStorage 中看方法的具体实现细节。
其实,抽象很容易实现,并不是非得依靠接口类或抽象类。因为其实 函数本身就是一种抽象,调用者通过函数名(加上注释或文档)就能了解该方法的功能,而不需要去查看函数包裹的具体的实现逻辑。
所以,为什么有些时候会把抽象排除在面向对象的特性之外,就是因为抽象是一个很通用的设计思想,并不单单用在 OOP 中,只要编程语言的语法中提供 函数,就可以实现抽象。
抽象的意义是什么?解决了什么问题?
实际上,抽象和封装都是人类处理复杂事物的一种有效手段。抽象这种只关注功能点而不关注具体实现 的设计思路,正好帮我们过滤掉许多非必要的信息。
所以,我们在定义类的方法时,需要有抽象思维,不要在方法定义中暴露太多的细节,以保证在需要改变方法的具体实现逻辑时,不用去修改其方法定义。例如方法 getAliyunPictureUrl()
就不具有抽象思维,如果某天需要换一个图片存储地址,比如存到华为云上,那这时这个方法名也要改成 getHuaWeiyunPictureUrl()
。而如果定义一个比较抽象的方法 getPictureUrl()
,即便内部的存储地址修改了,也不需要修改方法名。
3.3 继承
继承的定义是什么?
继承 用来表示 类之间的 is-a 关系,比如猫是一种哺乳动物。
继承又可分为 单继承和多继承,单继承表示子类只能继承一个父类,多继承表示一个子类可以继承多个父类,比如猫既是哺乳动物,又是爬行动物。
继承需要编程语言提供特定的语法机制,比如 Java 使用 extends 关键字来实现继承,C++ 用冒号(class B : public A),Python 用 parentheses()。
不过有些语言 只支持单继承,例如 Java,而 C++ 也支持多继承。
为什么 Java 只支持单继承?
因为多继承有一个副作用,就是当出现 钻石问题(菱形继承)时,会产生 二义性。
例如,类 B 和 类 C 继承自类 A,它们都重写了类 A 中的同一个方法,此时又来个类 D,它继承了类 B 和类 C,那么此时就形成了一个菱形继承。对于类 B 和类 C 中重写的这个方法,类 D 要继承哪一个?这就产生了歧义,所以 Java 并不支持多继承。
顺便说一下,Java 中支持多接口实现,因为接口中的方法,是抽象的(没写也默认),所以类在实现接口时,需要实现接口中的所有方法,这样该类只会调用自己实现的方法,而没有二义性。
JDK 1.8 之后,接口中的方法也可以有默认实现,但是如果一个类实现了多个接口,这些接口中又有相同的默认实现方法,那么 会强制让你实现该方法,所以也不会有二义性。
继承的意义是什么?解决了什么问题?
继承最大的一个好处就是 代码复用。例如 有两个类有一些相同的属性和方法,我们就可以将这些相同的部分抽取到父类中,让两个子类继承父类。这样,两个子类就可以重用父类中的代码,避免代码重复编写。
ps:利用组合关系也能实现代码复用。
但是,过度使用继承,继承层次过深,可能会导致 代码可读性差、可维护性变差。这时候为了知道一个类的功能,还要按照继承关系一层一层地网上查看父类、父类的父类......中的代码。还有,子类和父类高度耦合,修改父类的代码,会直接影响到子类。
所以,继承也是一个非常有争议的特性,很多人觉得继承是一种反模式,我们应该尽量少用,甚至不用。而是使用组合来解决代码复用问题。(后续讲解组合模式的时候会详细分析)
3.4 多态
多态指 在运行时子类可以替换父类,调用子类的方法实现。
多态同样需要编程语言的语法机制来实现,实现多态需要三个条件:
- 继承关系;
- 父类对象引用(指向)子类对象;
- 子类重写(override)父类中的方法。
通过这三个条件,就可以实现 在方法调用时,子类替换父类,从而执行子类中重写的方法。
例如下面这个例子:
class Animal {
public void call() {
System.out.println("动物叫:~~");
}
}
class Cat extends Animal {
@Override
public void call() {
System.out.println("猫:喵喵喵~~~");
}
}
class Dog extends Animal {
@Override
public void call() {
System.out.println("狗:汪汪汪~~~");
}
}
public class Polymorphism {
public static void main(String[] args) {
Animal animal = new Cat();
animal.call();
Animal animal2 = new Dog();
animal2.call();
}
}
输出:
猫:喵喵喵~~~
狗:汪汪汪~~~
Process finished with exit code 0
可以发现,当调用父类的 call()
方法时,由于多态的特性,实际上调用的是子类重写的方法。
其实,多态除了利用 “继承+方法重写” 实现方式外,还可以使用接口类,或者 duck-typing 语法,只不过不是所有编程语言都支持。
下面再来看看接口类如何实现多态。
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 PolyDemo {
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()
实现。
再来看看 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。
多态的意义是什么?解决了什么问题?
多态一个很明显的特性,就是提高了代码的 可扩展性和复用性。
例如上面的 Iterator 例子,利用多态仅用一个 print()
函数就可以实现遍历打印不同类型(Array、LinkedList)集合的数据。当要添加一种集合类型的遍历打印时,只需要让这个集合也实现 Iterator 接口,重新实现自己的 hasNext()
、next()
等方法就行了,不需要改动 print()
方法。这说明提高了代码的 可扩展性。
如果 不使用多态,那么就无法将不同类型的集合(Array、LinkedList)传递给同一个 print()
函数,那么我们就需要 针对每种集合都编写一个自己的 print()
函数。而利用多态便不用这么麻烦,这说明提高了代码的 复用性。
除此之外,多态也是很多设计模式、设计原则、编程技巧的代码实现基础,比如策略模式、基于接口而非实现编程、依赖倒置原则、里式替换原则、利用多态去掉冗长的 if-else 语句等等。(后续会讲解)
4. 总结
下面重点总结一下面向对象的四大特性:
封装:
- 封装是什么:隐藏信息、保护数据;
- 封装怎么做:暴露有限的接口和属性,需要编程语言提供权限控制;
- 为什么需要封装:提高可维护性、提高类的易用性。
抽象:
- 抽象是什么:隐藏具体实现,调用者只需要关心功能,而无需关系具体实现;
- 抽象怎么做:通过接口或抽象类,或者方法本身;
- 为什么需要抽象:提高代码扩展性、可维护性、降低复杂度。
继承:
- 继承是什么:表示类之间的 is-a 关系;
- 继承怎么做:利用语言提供的语法机制,例如 Java 的 extends;
- 为什么需要继承:提高代码复用。
多态:
- 多态是什么:运行时子类替换父类,从而调用子类实现的方法;
- 多态怎么做:利用语言提供的语法机制,例如继承、接口类、duck-typing;
- 为什么需要多态:提高代码可扩展性和复用性。