第六章 面向对象的特征
Java面向对象的三大特征:封装,继承和多态。Java提供private、public、protected这三个修饰符或者缺省来实现封装。提供了extends关键字实现类的继承,子类可以继承父类的成员变量和方法,子类不能继承父类的private修饰的变量和方法,但是可以通过调用父类的public等有权限的方法来访问父类的private变量或方法。
6.1 封装
之前我们在定义实例变量的时候一般都不填写修饰符,也就是我们说的缺省,这样虽然在语法上没有问题,但是它会存在一些潜在的问题,例如有个非常重要的变量,像余额,这一类实例变量设置缺省,或者是public是不切实际的,这类实例变量我们一般是设置为private私有进行封装。
6.1.1 理解封装
封装是指将对象的状态信息隐藏在对象的内部,不允许外部程序直接访问对象的内部信息,而是通过该类提供的方法来实现对内部信息的操作和访问。
封装的目的:
- 隐藏类的实现细节。例如工具类Arrays的sort()方法,我们不需要知道它的具体实现细节,我们只需知道它是排序的便可调用,或者隐藏对象的某个属性,使它不能被随意访问和修改。
- 让使用者只能通过预定的方法来访问数据,从而可以在该方法中添加控制逻辑,限制对成员变量的不合理访问。
- 便于修改,提高代码的可维护性。
为实现良好的封装,需要从两个方面考虑:
- 将对象的成员变量和实现细节隐藏在对象内部,不允许外部直接访问。
- 把方法暴露出来,让方法来控制对这些成员变量进行安全的访问和操作。
6.1.2 使用访问修饰符
Java提供3个访问控制符,private(当前类访问权限)、protected(子类访问权限)和public(公共访问权限),还有一个缺省default(包访问权限)。这4个访问修饰符的访问控制级别从小到大为:
public:公共的,任意位置都可以访问
protected:同包和子类(同包子类,同包非子类,非同包子类)
缺省:同包(同包子类,同包非子类)
private:私有
修饰符 | 范围 |
---|---|
public | 公共的 |
protected | 同包or子类 |
default | 同包 |
private | 类内 |
修饰符/范围 | 类内 | 同包子类 | 同包非子类 | 非同包子类 | 非同包非子类 |
---|---|---|---|---|---|
public | √ | √ | √ | √ | √ |
protected | √ | √ | √ | √ | × |
default | √ | √ | √ | × | × |
private | √ | × | × | × | × |
类的访问修饰符只有public和缺省。对于外部类而言,它也可以使用访问控制符修饰,但外部类只能有两种访问控制级别;public 和默认,外部类不能使用 private 和 protected 修饰,因为外部类没有处于任何类的内部,也就没有其所在类的内部、所在类的子类两个范围,因此 private 和 protected 访问控制符对外部类没有意义。
6.2 类的继承
继承是实现代码复用的重要手段之一。Java只能是单继承,也就是每个类有且只能有一个父类,但是子类可以继承父类的父类。所有类都是Object类的子类,class a extends b:因为单继承的特点,类a只继承类b,但是类b默认我 继承object类,所有类a中也能调用object类的变量和方法,类b称为类a的直接父类,object类称为类a的间接父类。
6.2.1 继承的特点
继承就是在已有类的基础上创建具有扩展性质的一个新类,使用extends关键字实现继承。其中已有类也叫做父类、基类或者超类。新扩展的类叫做子类或者派生类。
6.2.2 方法重写
当子类继承了父类,子类需要使用父类定义好的某个方法,但是这个方法并不能满足子类的需求时,子类可以重写父类的方法,也叫做方法覆盖。
方法重写的特征
- 方法重写只存在于继承关系中
- 方法名相同参数列表相同
- 返回类型和父类方法返回值类型一致或者是父类方法的返回值类型的子类
- 访问修饰符权限大于等于父类方法访问修饰符权限
- 子类方法抛出异常小于等于父类方法抛出异常
一大两小两相等
注意:父类构造方法不能被继承,因此也不能被重写。可以重写的一定是从父类继承的可访问的方法。
6.2.3 super关键字
super可以指代父类对象,用于访问从父类继承得到的实例变量或者方法,同时也可以访问父类的构造方法。super和this一样,都不出现在类方法中,如果出现在构造方法中,必须放在第一行,因此super()和this()调用构造方法时,不能同时出现。
6.2.4 Object类
6.2.4.1 Object类的方法
Object是所有类的父类,当一个类没有使用extends关键字显式的指定父类,则该类继承Object类,因为所有类都是Object的子类,任何Java对象都可以调用Object类的方法。Object提供了以下几个方法:
方法 | 描述 |
---|---|
getClass() | 获得当前对象的类对象 |
hashCode() | 返回当前对象的hashCode() |
equals() | 判断两个对象是否相等 |
clone() | 克隆并返回当前对象副本 |
toString() | 打印该对象 |
notify() | 线程唤醒 |
notifyAll() | 线程唤醒 |
wait() | 线程等待 |
finalize() | 通知垃圾回收器回收,该方法不确实实际执行时间,不推荐使用 |
6.2.4.2 重写equals()方法
所有Java类都继承了Object类的可访问的方法,这其中就包括equals()方法,equals方法是用于判断两个对象是否相等。首先我们来看一下Object中equals方法的源码
public boolean equals(Object obj) {
return (this == obj);
}
从源码中我们可以看到Object中equals(),方法代码很简单,其中this表示当前对象,也就是说谁调用equals方法,this就是谁,obj则是指要和当前对象比对的对象,源码中只是简单的判断this是否等于obj,也就是说判断当前对象和传入对象是否是同一引用,那么这里又产生了新的概念,“同一引用”,下面我们通过示例来学习同一引用
定义Student类:
package cn.bytecollege;
/**
* 定义Student类,并给定两个实例变量
* @author MR.W
*
*/
public class Student {
private String name;
private int age;
public Student(String name, int age) {
super();
this.name = name;
this.age = age;
}
public static void main(String[] args) {
Student s1 = new Student("张三",18);
Student s2 = s1;
System.out.println(s1.equals(s2));
}
}
此时运行程序会发现打印true,在代码16行,我们创建了Student对象并将对象的引用赋值给了变量s1,在代码17行,我们又将s1中的引用赋值给了s2,此时变量s1和s2中都保存的是同一引用或者地址,调用equals方法时返回了true,那么我们现在再从内存的角度理解同一引用:
当代码执行到16行是,此时内存中示意图如下:
当代码执行到底17行是,定义了变量s2,并将s1的值赋值给了s2,也就是说将栈内存中的s1中保存的值复制了一份放到了s2中,此时内存示意图如下:
此时s1和s2就是同一引用,因为他们指向了堆内存中的同一块内存区域,那么调用equals方法后,判断栈内存中值是否相等,换句话说就是判断自己是否和自己相等的,结果是肯定的。
但是在实际情况中只有在少数情况下才会出现两个对象指向同一引用的情况,那么该怎么判断两个对象相等呢,例如s1和s2所有的属性都相等,我们就认为这两个对象相等,此时Object类提供的equals()方法是不能满足我们的需要的,这就需要重写equals()方法。
通常重写equals()方法需要满足以下几个条件:
- 自反性∶ 对任意 x,x.equals(x)一定返回 true。
- 对称性∶ 对任意x和 y,如果 y.equals(x)返回 true,则x.equals(y)也返回 true。
- 传递性∶ 对任意x,y,z,如果x.equals(y)返回 ture,y.equals(z)返回 true,则x.equals(z)一定返回 true。
- 一致性∶ 对任意x和 y,如果对象中用于等价比较的信息没有改变,那么无论调用 x.equals(y) 多少次,返回的结果应该保持一致,要么一直是 true,要么一直是 false。
- 对任何不是 null 的x,x.equals(null)一定返回 false。
下面,我们根据上述规则重写Student的equals()方法。
package cn.bytecollege;
/**
* 定义Student类,并给定两个实例变量
* @author MR.W
*
*/
public class Student {
private String name;
private int age;
public Student(String name, int age) {
super();
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object obj) {
//1.判断是否是同一引用,如果是则直接返回true
if(this == obj) {
return true;
}
//2.判断obj是否为null,如果是则返回false
if(obj == null) {
return false;
}
//3.判断是否是同一类型,instanceof无法判断子类与父类,它会认为子类与父类为同一个类型
//所有我们使用getClass()来避免这种错误
if(!(obj.getClass()==Student.class) ) {
return false;
}
//if(!(obj instanceof Student) ) {
// return false;
//}
//4.转换为同一类型对象
Student s = (Student) obj;
//5.判断所有实例变量是否相等
if(this.name.equals(s.name)&&this.age==s.age) {
return true;
}
return false;
}
}
根据重写的步骤,我们可以将重写 equals()方法归纳为以下5:
- 判断是否是同一引用,如果是则直接返回true
- 判断obj是否为null,如果是则返回false
- 判断是否是同一类型
- 转换为同一类型对象
- 判断所有实例变量是否相等
下面,我们编写测试类:
package cn.bytecollege;
public class EuqalsTest {
public static void main(String[] args) {
Student s1 = new Student("张三", 18);
Student s2 = new Student("张三", 18);
Student s3 = new Student("李四",19);
System.out.println(s1.equals(s2));
System.out.println(s1.equals(s3));
System.out.println(s2.equals(s3));
}
}
6.2.4.3 ==和equals的区别
我们在判断两个基本类型是否相等时,通常使用双等号,但是判断两个对象相等,使用==就比较有局限性了,因为使用双等号只能判断两个变量指向同一引用的情况,而我们在日常开发中通常两个对象的所有实例变量相等,即可认为两个对象相等。从内存的角度来说,==用于判断变量栈内存中保存的内容是否相等,而equals则是判断对象在堆内存中的内容是否相等。简而言之,基本类型相等的判断使用==,而判断两个对象是否相等,则需要重写equals方法来判断。
6.3 多态
6.3.1 什么是多态
同一对象调用相同方法,展现的不同行为,主要通过方法重写和方法重载实现。
Java引用变量有两个类型:编译时类型和运行时类型。编译时类型有声明该变量时使用的类型决定,运行时类型由实际赋值给该变量的对象决定。如果两种类型不一致,就会出现多态性(也就是父类引用指向了子类对象)。
下面我们通过示例来演示出现多态的情况:
定义父类,父类中定义了实例变量和两个实例方法
package cn.bytecollege.poly;
public class Base {
public int a = 100;
public void base() {
System.out.println("父类的普通方法");
}
public void test() {
System.out.println("父类被子类覆盖的方法");
}
}
定义子类,子类中定义了实例变量,两个实例方法,其中一个方法重写了父类方法:
package cn.bytecollege.poly;
public class Sub extends Base{
public int a = 20;
public void sub() {
System.out.println("子类中的普通方法");
}
@Override
public void test() {
System.out.println("子类覆盖了父类中的方法");
}
}
定义测试类,在测试类创建3个对象:
package cn.bytecollege.poly;
public class Test {
public static void main(String[] args) {
//创建父类对象,编译时类型和运行时类型一致
Base base = new Base();
System.out.println(base.a);
base.base();
base.test();
//创建子类对象,编译时类型和运行时类型一致
Sub sub = new Sub();
System.out.println(sub.a);
sub.sub();
sub.test();
//编译时类型和运行时类型不一致,多态发生
Base ploy = new Sub();
System.out.println(ploy.a);
ploy.base();
ploy.test();
}
}
上面的程序中,显示创建了3个对象,前面两个对象base和sub,编译时类型和运行时类型完全相同,因此不会出现多态,他们调用成员变量和成员方法是结果正常,第三个poly比较特殊,他的编译时类型是Base,运行时类型则是Sub,当调用改对象的test()方法时,实际上执行的是子类中覆盖后的test()方法,这就可能出现多态了。
当把一个子类对象直接赋值给父类引用变量时,就同上面的代码一样,当运行时调用该变量的方法时,其方法行为总是表现出子类方法的行为特征,而不是父类方法的行为特征,这就是相同类型的变量、调用同一个方法是呈现出多种不同的行为特征,这就是多态。
与方法不同的是,对象的实例变量不具备多态性。比如上面的ploy引用变量,程序中输出实例变量时,输出的了父类的实例变量。
6.3.2 instanceof 运算符
instanceof 运算符的作用是判断对象是否是某个类型,第一个操作数通常是一个引用类型的变量,后面的操作数通常是一个类,或者是其子类等等,如果是则返回true,如果不是则返回false。
在使用 instanceof 运算符时需要注意; instanceof 运算符前面操作数的编译时类型要么与后面的类相同,要么与后面的类具有父子继承关系,否则会引起编译错误。下面程序示范了instanceof运算符的用法。
下面我们通过示例来学习:
package cn.bytecollege.instance;
/**
* 定义Person类型
* @author MR.W
*
*/
public class Person {
}
package cn.bytecollege.instance;
/**
* 定义Student类
* @author MR.W
*
*/
public class Student extends Person{
}
package cn.bytecollege.instance;
public class Test {
public static void main(String[] args) {
//定义Student类型对象
Student s = new Student();
//s 是Student类型的对象,因此返回true
System.out.println(s instanceof Student);
//父类引用指向子类对象
Person p = new Student();
//因为Student和Person存在继承关系,所以p可以看做是Person类型
//返回true
System.out.println(p instanceof Person);
//因为运行时类型就是Student类型,所以返回true
System.out.println(p instanceof Student);
//p不是String类型,编译出错
System.out.println(p instanceof String);
}
}
6.3.3 向上转型和向下转型
在前面重写equals方法时,我们在第4步做了一个转型操作,从代码中我们可以看出,我们将Object类型的对象转成了Student类型的对象,我们知道Object是所有Java类的父类,也就是说Student是Object的子类,这种将父类对象转型成子类的操作就叫做向下转型,类似我们在基本类型的表示大范围的数据类型转成表示小范围的数据类型,需要强制转换。反之,我们也可以将子类对象转换成父类型,这种操作在Java中我们称之为向下转型。需要注意的是:引用类型之间的转换只能在具有继承关系的两个类型之间进行,如果是两个没有任何继承关系的类型,则无法进行类型转换,否则编译时就会出现错误。如果试图把一个父类实例转换成子类类型,则这个对象必须实际上是子类实例才行(即编译时类型为父类类却,而运行时类型是子类类型),否则将在运行时引发 ClassCastException 异常。
下面,我们通过示例来学习向上转型和向下转型:
package cn.bytecollege.instance;
public class CastDemo {
public static void main(String[] args) {
Student s = new Student();
//子类对象转成父类,向上转型,自动转换
Person p = s;
Person p2 = new Person();
//父类对象转子类,向下转型,强制转换
Student s2 = (Student) p;
//String和Person没有任何关系,转换时会编译出错
String str = p2;
}
}
6.4 初始化块
我们知道构造方法可以对对象进行状态的初始化,和构造方法具有相同功能的是初始化块。
6.4.1 初始化块
一个类中可以存在多个初始化块,相同类型的初始化块按照书写的先后顺序执行,其语法格式如下:
[修饰符]{
//代码块
}
下面我们通过示例来学习初始化块:
package cn.bytecollege.init;
public class Student {
private String name;
//定义初始化块
{
System.out.println("执行了第1个初始化块");
//初始化中也可以对实例变量初始化
name = "张三";
}
public Student() {
System.out.println("执行了构造方法");
}
//定义初始化块
{
System.out.println("执行了第2个初始化块");
}
public static void main(String[] args) {
Student student = new Student();
System.out.println("main方法中访问name属性:"+student.name);
}
}
运行结果如下:
从上面的结果可以看出,当创建Java对象时,系统总是先调用类中定义的初始化块,如果一个类中定义了2个普通的初始化块,前面定义的初始化块先执行,后面定义的初始化块后执行。
虽然 Java 允许一个类里定义 2个普通初始化块,但这没有任何意义。因为初始化块是在创建 Java对象时隐式执行的,而且它们总是全部执行,因此完全可以把多个普通初始化块合并成一个初始化块,从而可以让程序更加简洁,可读性更强。
从上面的示例中我们可以看出,实例变量也可以在初始化块中进行初始化,也就是说从某种程度上来说,初始化块是构造器的补充,但是初始化块不能替代构造方法。因为初始化块是一段固定执行的代码,他不能接受任何参数。如果有一段初始化处理代码对所有对象完全相同,且无需接受任何参数,就可以把这段初始化代码提取到初始化块中。
6.4.3 静态初始化块
如果定义初始化块使用了static修饰,则这个初始化块就变成了静态初始化块,也被称为类初始化块(普通初始化块负责对对象进行初始化,类初始化块则负责对类进行初始化)。静态初始化块是类相关的,系统将在类初始化阶段执行静态初始化块,而不是在创建对象时才执行,因此静态初始化块比普通初始化块先执行。并且类初始化块通常用于对类变量进行初始化处理,静态初始化块不能对实例变量进行初始化。
与普通初始化块类似的是,系统在类初始化阶段执行静态初始化块时,不仅会执行本类的静态初始化块,而且还会一直上溯到 java.lang.Object 类(如果它包含静态初始化块),先执行java.lang.Object 类的静态初始化块(如果有),然后执行其父类的静态初始化块,最后才执行该类的静态初始化块,经过这个过程,才完成了该类的初始化过程。只有当类初始化完成后,才可以在系统中使用这个类,包括访问这个类的类方法、类变量或者用这个类来创建实例。
注意:静态初始化块也被称为类初始化块,同样静态成员不能访问非静态成员,因此静态初始化块不能访问实例变量和实例方法。
下面,我们通过示例来学习静态初始化块:
package cn.bytecollege.init;
public class Base {
//静态初始化块
static{
System.out.println("Base静态块");
}
{
System.out.println("Base初始化块");
}
public Base() {
System.out.println("Base构造方法");
}
}
package cn.bytecollege.init;
public class Sub extends Base{
static {
System.out.println("Sub静态块");
}
{
System.out.println("Sub初始化块");
}
public Sub() {
System.out.println("Sub构造方法");
}
}
package cn.bytecollege.init;
public class Test {
public static void main(String[] args) {
Sub s = new Sub();
}
}
运行结果如下图:
从结果我们可以看出,我们创建Sub对象时,先追溯到父类,执行了父类的静态块代码,然后执行了子类的静态块代码,然后执行了父类的初始化块中的代码和构造方法,最后才到子类中执行了子类的初始化代码块和构造方法。
总结一下,我们可以归纳出静态代码块和初始化块的顺序
6.4.4 初始化块中的陷阱
需要注意的是在类运行过程中,一定是类成员先初始化,查看下面的示例:
package cn.bytecollege.init;
public class Base {
static Sub sub = new Sub();
//静态初始化块
static{
System.out.println("Base静态块");
}
{
System.out.println("Base初始化块");
}
public Base() {
System.out.println("Base构造方法");
}
}
package cn.bytecollege.init;
public class Sub extends Base{
static {
System.out.println("Sub静态块");
}
{
System.out.println("Sub初始化块");
}
public Sub() {
System.out.println("Sub构造方法");
}
}
package cn.bytecollege.init;
public class Test {
public static void main(String[] args) {
Sub s = new Sub();
}
}
运行结果如下:
这个结果你可能会有疑惑,但是你始终记得static修饰的成员先初始化就能得到答案,当程序运行时,我们创建了sub对象,此时,因为static修饰的会先运行,代码第4行我们创建了sub对象,创建子类对象时会先创建父类对象,因此会先创建父类对象,创建完父类对象后再创建子类对象,然后继续根据我们上一小节总结的顺序进行执行。
6.5 final关键字
final关键字可以用于修饰类,变量和方法,用于表示不可变的意思。
final修饰变量时,表示该变量一旦获得初始值以后,就不能被不能被重新赋值,final既可以修饰成员变量,也可以修饰局部变量、形参。
6.5.1 final修饰成员变量
成员变量是随类初始化或对象初始化而初始化的。当类初始化时,系统会为该类的类变量分配内存,并分配默认值∶当创建对象时,系统会为该对象的实例变量分配内存,并分配默认值。也就是说,当执行静态初始化块时可以对类变量赋初始值; 当执行普通初始化块、构造器时可对实例变量赋初始值。因此,成员变量的初始值可以在定义该变量时指定默认值,也可以在初始化块、构造器中指定初始值。 对于 final 修饰的成员变量而言,一旦有了初始值,就不能被重新赋值,如果既没有在定义成员变量时指定初始值,也没有在初始化块、构造器中为成员变量指定初始值,那么这些成员变量的值将一直是系统默认分配的 0、”\u0000’、false 或 null,这些成员变量也就完全失去了存在的意义。因此 Java 语法规定∶ final 修饰的成员变量必须由程序员显式地指定初始值。 归纳起来,final 修饰的类变量、实例变量能指定初始值的地方如下。
- 类变量:必须在静态初始化块中指定初始值或声明该类变量时指定初始值,而且只能在两个地 方的其中之一指定。
- 实例变量∶ 必须在非静态初始化块、声明该实例变量或构造器中指定初始值,而且只能在三个 地方的其中之一指定。
package cn.bytecolleg.fin;
public class Final1 {
static int age;
String gender;
final String name;
//静态块中初始化类变量
static {
age = 18;
//静态块中不能初始化实例变量
// gender = "男";
}
{
//初始化块中可以初始化final修饰的变量
gender = "女";
name = "张三";
}
}
与普通成员变量不同的是,final 成员变量(包括实例变量和类变量)必须由程序员显式初始化。
6.5.2 final修饰局部变量
系统不会对局部变量进行初始化,局部变量必须由程序员显式初始化。因此使用 final 修饰局部变量时,既可以在定义时指定默认值,也可以不指定默认值。 如果 final 修饰的局部变量在定义时没有指定默认值,则可以在后面代码中对该 final 变量赋初始值,但只能一次,不能重复赋值;如果 final 修饰的局部变量在定义时已经指定默认值,则后面代码中不能再对该变量赋值。下面程序示范了 final 修饰局部变量、形参的情形。
package cn.bytecolleg.fin;
public class Final2 {
public static void test(final int a) {
//方法中不能对final修饰的形参赋值
// a = 100;
System.out.println(a);
}
public static void main(String[] args) {
test(300);
}
}
6.5.3 final修饰方法
final 修饰的方法不可被重写,如果出于某些原因,不希望子类重写父类的某个方法,则可以使用 final 修饰该方法。 Java 提供的 Object 类里就有一个 final方法∶ getClassO),因为 Java不希望任何类重写这个方法,所以使用 final把这个方法密封起来。但对于该类提供的 toString()和 equals()方法,都允许子类重写,因此没有使用 final 修饰它们。
package cn.bytecollege.init;
public class Father {
public final void test() {
}
}
package cn.bytecollege.init;
public class Son extends Father{
//编译出错,final修饰的方法不能被重写
@Override
public final void test() {
}
}
6.5.4 final修饰类
final修饰的类不能有子类,也就是说final修饰的类不能被继承,当子类继承父类时,父类的有些方法可能被重写,属性可以被访问,如果不希望出现上述情况,可以使用final修饰类,这样讲阻止子类继承。
package cn.bytecollege.init;
public final class Father {
}
package cn.bytecollege.init;
//编译出错,final修饰的类不能被继承
public class Son extends Father{
}
6.5.5 不可变类
不可变(immutable)类的意思是创建该类的实例后,该实例的实例变量是不可改变的。Java 提供的 8 个包装类和 java.lang.String 类都是不可变类,当创建它们的实例后,其实例的实例变量不可改变。
如果需要创建自定义的不可变类,可遵守如下规则。
- 使用 private 和 final 修饰符来修饰该类的成员变量。
- 提供带参数构造器,用于根据传入参数来初始化类里的成员变量。
- 仅为该类的成员变量提供getter 方法,不要为该类的成员变量提供 setter 方法,因为普通方法无 法修改 final 修饰的成员变量。
6.6 软件设计原则
在软件开发中,软件的可维护性和代码的可复用性是一个开发者必须所思考的内容,为了增加软件的可扩展性和灵活性,开发者应该尽可能的根据以下这6条原则开发程序。
6.6.1 单一职责原则(Single Responsibility Principle)
单一职责简要来说就是对于一个类而言,应该只专注做一件事情。单一职责元素是一种对对象的理想期望,对象不应该承担太多的职责。这样就可以保证对象的高内聚,以及细粒度,方便对对象的重用。如果一个对象承担了太多的职责,当客户端需要该对象的某个职责时,就不得不把所有的职责都包含进来,从而造成代码冗余。
6.6.2 里式替换原则(Liskov Substitution Principle)
在面向对象的语言中,继承是一种非常优秀的机制,继承主要有2下几个优点:
- 代码复用,减少子类的工作量,每个子类都拥有父类的方法和属性
- 提高代码的可重用性及可扩展性
同样,继承也存在若干缺点,主要体现在以下几个方面:
- 继承是入侵式的,只要继承就必须拥有父类的方法和属性
- 增强了耦合性,当父类的常量、变量、方法修改时,必须考虑子类的修改,这有可能造成大量的代码需要重构
里式替换原则可以简单的概况为所有引用基类的地方必须能透明的使用其子类,换句话说,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生任何错误或者异常。但是反过来则不行,子类能出现的地方,父类一不定能出现,这一点要尤为注意。
6.6.3 依赖倒置原则(Dependence Inversion Principle)
依赖倒置原则是指:高层模块不应该依赖底层模块,两者都依赖其抽象,并且抽象不应该依赖细节,而应该是细节依赖于抽象。
在Java语言中,抽象就是指接口或抽象类,两者都是不能直接被实例化的;细节就是具体的实现类,实现类实现了接口或继承了抽象类,其特点是可以直接被实例化。依赖倒置原则在Java语言中的表现是:
- 模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生;
- 接口或抽象类不依赖于实现类;
- 实现类依赖于接口或抽象类。
依赖倒置原则更加精确的定义就是“面向接口编程”——OOD(Object-OrientedDesign)的精髓之一。依赖倒置原则可以减少类间的耦合性,提高系统的稳定性,降低并行开发引起的风险,提高代码的可读性和可维护性。依赖倒置原则是JavaBean、EJB等组件设计模型背后的基本原则。
6.6.4 接口隔离原则(Interface Segregation Principle)
接口隔离原则的具体含义如下。
- 一个类对另外一个类的依赖性应当是建立在最小的接口上的。
- 一个接口代表一个角色,不应当将不同的角色都交给一个接口。没有关系的接口合并在一起,形成一个臃肿的大接口,这是对角色和接口的污染。因此使用多个专门的接口比使用单一的总接口要好。
- 不应该强迫客户依赖于它们不用的方法。接口属于客户,不属于它所在的类层次结构,即不要强迫客户使用它们不用的方法,否则这些客户就会面临由于这些不使用的方法的改变所带来的改变。
6.6.5 迪米特法则(Law of Demeter)
迪米特法则又叫最少知识原则(Least Knowledge Principle,LKP),意思是一个对象应当对其他对象尽可能少的了解。迪米特法则最初是用来作为面向对象的系统设计风格的一种法则,在1987年由Ian Holland在美国东北大学为一个叫迪米特的项目设计提出的,因此叫做迪米特法则。
按照迪米特法则,如果两个类不必彼此直接通信,那么这两个类就不应当发生直接的相互作用;如果一个类需要调用另一个类的某一个方法,可以通过第三者转发这个调用。
6.6.6 开闭原则(Open-Closed Principle)
开闭原则是指一个软件实体应当对扩展开放,对修改关闭。
这个原则说的是,在设计一个模块的时候,应当使这个模块可以在不被修改的前提下被扩展,即应当可以在不必修改源代码的情况下改变这个模块的行为。在面向对象的编程中,开闭原则是最基础的原则,起到总的指导作用,其他原则(单一职责、里氏替换、依赖倒置、接口隔离、迪米特法则)都是开闭原则的具体形态,即其他原则都是开闭原则的手段和工具。开闭原则的重要性可以通过以下几个方面来体现。
- 开闭原则提高复用性。在面向对象的设计中,所有的逻辑都是从原子逻辑组合而来的,而不是在一个类中独立实现一个业务逻辑,代码粒度越小,被复用的可能性就越大,避免相同的逻辑重复增加。开闭原则的设计保证系统是一个在高层次上实现了复用的系统。
- 开闭原则提高可维护性。一个软件投产后,维护人员的工作不仅仅是对数据进行维护,还可能对程序进行扩展,就是扩展一个类,而不是修改一个类。开闭原则对已有软件模块,特别是最重要的抽象层模块要求不能再修改,这就使变化中的软件系统有一定的稳定性和延续性,便于系统的维护。
- 开闭原则提高灵活性。所有的软件系统都有一个共同的性质,即对系统的需求都会随时间的推移而发生变化。在软件系统面临新的需求时,系统的设计必须是稳定的。开闭原则可以通过扩展已有的软件系统,提供新的行为,能快速应对变化,以满足对软件新的需求,使变化中的软件系统有一定的适应性和灵活性。
- 开闭原则易于测试。测试是软件开发过程中必不可少的一个环节。测试代码不仅要保证逻辑的正确性,还要保证苛刻条件(高压力、异常、错误)下不产生“有毒代码”(Poisonous Code),因此当有变化提出时,原有健壮的代码要尽量不修改,而是通过扩展来实现。否则,就需要把原有的测试过程回笼一遍,需要进行单元测试、功能测试、集成测试,甚至是验收测试。开闭原则的使用,保证软件是通过扩展来实现业务逻辑的变化,而不是修改。因此,对于新增加的类,只需新增相应的测试类,编写对应的测试方法,只要保证新增的类是正确的就可以了。