面向对象
面向对象程序设计(Object-Oriented Programming,OOP)是一种具有对象概念的程序编程典范,同时也是一种程序开发的抽象方针。对象则指的是类的实例。它将对象作为程序的基本单元,将程序和数据封装其中,以提高软件的重用性、灵活性和扩展性,对象里的程序可以访问及经常修改对象相关连的资料。在面向对象程序编程里,计算机程序会被设计成彼此相关的对象。
一、特性#
1.1 封装#
利用抽象数据类型将数据和基于数据的操作封装在一起,使其构成一个不可分割的独立实体。数据被保护在抽象数据类型的内部,尽可能地隐藏内部的细节,只保留一些对外的接口使其与外部发生联系。用户无需关心对象内部的细节,但可以通过对象对外提供的接口来访问该对象。
优点:
- 减少耦合:可以独立地开发、测试、优化、使用、理解和修改;
- 减轻维护的负担:可以更容易被理解,并且在调试的时候可以不影响其他模块;
- 有效地调节性能:可以通过剖析来确定哪些模块影响了系统的性能;
- 提高软件的可重用性;
- 降低了构建大型系统的风险:即使整个系统不可用,但是这些独立的模块却有可能是可用的。
1.2 继承#
继承是指在某种情况下,一个类会有子类。子类比原本的类(称为父类)要更加具体化。java 只允许单继承,一个子类只能继承自一个父类,使用 extends 关键字。也就是说对于一个类,只能认为它属于另一个大类,如果它需要实现其他功能时,则还要实现功能接口(interface),使用 implements 关键字。
1.3 多态#
C++ 中的虚函数就是为了多态。Java 中其实没有虚函数的概念,它的普通函数就相当于 C++ 的虚函数,动态绑定是 Java 的默认行为。如果 Java 中不希望某个函数具有虚函数特性,可以加上 final 关键字变成非虚函数。
方法的重写(Override)和重载(Overload)是 java 多态性的不同表现。重写解决的是子类与父类针对同一方法需要采取不同实现方式的问题,通过 JVM 在类加载时(解析阶段)的动态绑定机制实现。重载是面向使用者的,针对不同的调用方式返回不同的结果。
二、设计原则#
错误的设计类会带来很多问题,比如下面的悖论(为了方便理解,下文中长方形指代长宽不等的矩形)。
2.1 正方形不是矩形?#
这个悖论的简单实现如下:写出矩形类,定义长和宽,并写出长宽的 setter 方法和一个获取面积的方法:
public class Rectangle {
int length;
int width;
public void setLength(int length) {
this.length = length;
}
public void setWidth(int width) {
this.width = width;
}
public int getArea() {
return length * width;
}
}
写出子类正方形继承父类矩形,由于正方形的长宽是相同的,重写两个 setter 方法保证长宽一致:
class Square extends Rectangle {
public void setLength(int length) {
this.length = length;
this.width = length;
}
public void setWidth(int width) {
this.width = width;
this.length = width;
}
}
最后我们生产一个正方形,并且指派一个矩形质检员来负责检查这个正方形是否合格。质检员把长设为 4,宽设为 3,检查最后得到的面积是否为 12:
public class Main {
public static void main(String[] args) {
Square square = new Square();
System.out.println(test(square)); // false
}
public static boolean test(Rectangle rectangle) {
rectangle.setLength(4);
rectangle.setWidth(3);
return rectangle.getArea() == 4 * 3; // Oops... 9 != 12
}
}
问题出现了,矩形质检员并不知道这是一个正方形,他使用传统的质检矩形的方法来检查竟然发现这个正方形连矩形也不是,难道正方形不是矩形?
2.2 S.O.L.I.D#
要解决上面的问题,首先要对面向对象的设计原则有所了解。SOLID 常应用在测试驱动开发上,并且是敏捷开发以及自适应软件开发基本原则的重要组成部分,它包括:
原则 | 概念 |
---|---|
Single-responsibility principle (单一功能原则) |
对象应该仅具有一种单一功能 |
Open–closed principle (开闭原则) |
软件应该对于扩展开放,对于修改封闭 |
Liskov substitution principle (里氏替换原则) |
程序中的对象应该可以在不改变程序正确性的 前提下被它的子类所替换 |
Interface segregation principle (接口隔离原则) |
多个特定客户端接口要好于一个宽泛用途的接口 |
Dependency inversion principle (依赖反转原则) |
方法应该遵从依赖于抽象而不是一个实例 |
相互对照可以发现上述对于矩形和正方形的实现是不合理的。首先,由于我们需要对正方形和长方形相互区分,实现多态,矩形这个类就同时包含了正方形和长方形这两个语义,相互耦合,模糊不清,违反了单一功能原则。
其次,对于矩形而言,长宽是特征属性,也正是长宽的关系决定了矩形是正方形还是长方形。对特征属性不应该设置 setter 方法,这违反了开闭原则。
最后,子类正方形不能够替代父类矩形使用,违反了里氏替换原则。如果继承的目的是为了多态,而多态的前提就是子类覆盖并重新定义父类的方法,为了符合 LSP,我们应该将父类定义为抽象类,并定义抽象方法,让子类重新定义这些方法。当父类是抽象类时,父类就不能实例化,避免了子类替换父类实例(根本不存在父类实例)时的逻辑不一致[2] 。
2.3 正方形是抽象矩形#
怎样才能打破正方形不是矩形的悖论呢?
Abstraction is the Key.
重写抽象矩形类作为父类,并实现 ClosedFigure 接口,这个接口的功能是求面积。求面积这个功能并不是矩阵本身具有的,而是它作为一个封闭图形能够实现的功能,所以需要用接口来实现。
抽象矩形下定义两个抽象方法,分别用来获取长和宽:
interface ClosedFigure {
int getArea();
}
abstract class AbstractRectangle implements ClosedFigure {
abstract int getLength();
abstract int getWidth();
}
长方形类和正方形类作为子类继承抽象矩形类。对于长方形,定义两个 final 修饰的字段 length & width 表示长宽,这是为了保证特征属性的不可变。对于正方形,定义一个 final 修饰的字段 sideLength 表示边长,并且构造函数只传入一个参数以保证其作为正方形的正确性。
分别重写父类的两个抽象方法和接口的一个抽象方法:
class Rectangle extends AbstractRectangle implements ClosedFigure {
private final int length;
private final int width;
Rectangle(int length, int width) {
this.length = length;
this.width = width;
}
@Override
int getLength() {
return length;
}
@Override
int getWidth() {
return width;
}
@Override
public int getArea() {
return length * width;
}
}
class Square extends AbstractRectangle implements ClosedFigure {
private final int sideLength;
Square(int sideLength) {
this.sideLength = sideLength;
}
@Override
int getLength() {
return sideLength;
}
@Override
int getWidth() {
return sideLength;
}
@Override
public int getArea() {
return sideLength * sideLength;
}
// 追加功能,获取边长
int getSideLength() {
return sideLength;
}
}
问题解决了,以前总是不明白抽象类和接口同时存在的意义,现在终于意识到抽象类是那么重要!