引言

传统的结构化程序设计通过设计一系列的过程(即算法)来求解问题。这些过程一旦被确定,就要开始考虑存储数据的方式。Pascak 语言的设计者 Niklaus Wirth 将程序定义为算法+数据结构,算法是第一位的,数据结构是第二位的。这明确表述了程序员的工作方式:首先要确定如何操作数据,然后再考虑如何组织数据,以便于数据操作。而面向对象程序设计却将数据放在第一位,然后再考虑操作数据的算法。面向对象程序设计是当今主流的程序设计范型之一,Java 是完全面向对象的语言,本文将主要以Java为例讨论面向对象的特性。面向对象的程序是由对象组成的,每个对象包含对用户公开的特定部分和隐藏的实现部分。在 Java 的面向对象概念中,有三大特性,分别是封装、继承、多态。

封装

面向对象程序设计里的类是构造对象的模板或蓝图,对象是类的实例。封装是与对象有关的一个重要概念,它是将对象的数据和行为组合在一起,对对象的使用者隐藏数据的实现方式。对象中的数据成为实例域(instance field),操纵数据的过程称为方法(method)。每个对象都有一组特定的实例域,它们的值表示了对象当前的状态,无论何时只要向对象发送一个消息都有可能改变对象的状态(比如调用对象的方法)。设计良好的模块会隐藏所有的实现细节,把它的 API 与它的实现清晰地隔离开来。模块之间只通过它们的 API 进行通信,一个模块不需要知道其他模块的内部工作情况。这个封装概念也是软件设计的基本原则之一。 Java 语言里的访问控制机制定义了类、接口和成员的可访问性,比如 private、protected、public 和包访问级别。一般有几个可访问性规则:

  • 尽可能是每个类或成员不被外界访问,应该设置尽可能小的访问级别。关于类的控制,实际编程时可以将接口定义部分和实现部分放到不同的 jar 中去,仅开放接口定义部分给其他模块依赖。
  • 如果子类里的方法覆盖了超类中的一个方法,子类中的访问级别不能低于超类中的访问级别,这样用于确保任何可使用超类的实例的地方也都可以使用自类的实例。(后面会讲类的继承)
  • 实例域绝不能是公有的,也包含类中静态域,常量除外。要确保所有公有静态 final 域所引用的对象是不可变的。

继承

继承是实现代码重用的有力手段,人们可以基于已存在的类构造出一个新类,复用已有类的方法和域。被继承的类称为超类或父类,新类称为子类。子类中可以访问父类里的 protected 和 public 方法、域,如果二者在同一个包中,也可以访问包访问级别的方法、域。下面示例中的 Manager 类继承了 Employee 类,并在其基础上增加了 bonus 域和访问方法。因此,Manager 类的对象可以通过方法查到薪水和奖金信息。

public class Employee {
    private double salary;

    public double getSalary() {
        return this.salary;
    }
}

public class Manager extends Employee {
    private double bonus;

    public double getBonus() {
        return this.bonus;
    }
}

子类除了可以访问父类里特定访问级别的方法和域,还可以覆盖父类里的方法。当子类的对象调用这个方法的时候,实际上是用的子类里的实现。与方法调用不同的是,继承打破了封装性。因为子类依赖于超类中特定功能的实现细节,超类的实现有可能会随着发行版本的不同而有所变化,因此子类必须要要跟着超类的更新而演变,除非超类是专门为了扩展而设计的。对于可访问的类和方法,Java 通过final关键字去限制类不能被继承、方法不能被覆盖。而与 Java 语言风格类似的 C#中的方法默认是不能被覆盖的,除非给它加上virtual关键字修饰。实际编程中,对于接口的某个实现类,如果想要对它进行扩展,比继承更合适的是组合。也就是说对接口新写一个实现类,该类中增加一个新的私有域,它引用原有实现类的实例。新类中的所有方法实现都可以调用原有实现类的方法,返回它的结果,也可以对其结果进行修饰后再返回,或者干脆重写一套实现。通过继承的方式需要了解父类里方法的实现方式,然后决定是否对其覆盖。而通过组合的方式则看不到对象的实现细节,仅仅可以在创建实例时改变引用的对象来改变行为方式。

多态

B 类继承了 A 类,并且覆盖了 A 类的 method()方法。

A object1 = new A();
A object2 = new B();
A[] objects = new A[] {object1, object2};
for (A object : objects) {
    object.method();
}

当 A 的对象调用方法时,虽然声明的都是同一个类型,但是运行的时候,实际上指向的是不同的实现。一个对象可以引用多种实际类型的现象称为多态,在运行时能够自动地选择调用哪个方法的现象称为动态绑定。在设计模式里面有个“里氏替换原则”,它的大致意思是子类对象能够替换父类对象,而程序逻辑不变。如果对一个非抽象类的非抽象抽象方法进行覆盖就会违反该原则。里氏替换原则并不是禁止覆盖父类方法,而是要按照父类所期望的那样去覆盖。

  • 如果继承是为了代码重用,也就是共享方法,那么被共享的父类方法应该保持不变,子类里不能覆盖它重新定义。子类只能通过新添加方法来扩展功能。
  • 如果继承是为了多态,而多态的前提就是子类覆盖并重新定义父类的方法,应该将父类里的对应方法定义为抽象方法,父类最好也是抽象类或者直接就是接口。

参考

  • 《Java 核心技术卷 1:基础知识》第 8 版
  • 《Effective Java》第 2 版