跳至主要內容

面向对象是什么

AruNi_Lu设计模式设计原则与思想约 4411 字大约 15 分钟

本文内容

前言

其实,面向对象是一个很广泛的概念,它包括面向对象编程、面向对象编程语言、面向对象分析和面向对象设计等。我们常说的面向对象一般默认指的是面向对象编程。

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;
  • 为什么需要多态:提高代码可扩展性和复用性。
上次编辑于: