继承是一把双刃剑,在实现代码复用的同时破坏了封装。
组合则在实现代码复用的同时,保留了原本类的封装性。
从面向对象的思想来看,有两个维度需要权衡:
- 客观世界的抽象;
- 软件工程的效率,即代码复用。
这两者一定程度上是有取舍的。
子类的访问权限
为保证父类有良好的封装性,不会被子类随意访问或修改,设计父类时建议遵循如下规则:
- 尽量隐藏父类的内部数据,即成员变量。尽可能把父类的成员变量设置为
private
,不要让子类直接访问父类的成员变量; - 不要让子类随意访问、修改父类的方法。
- 父类中那些辅助其他的工具方法,尽量使用
private
修饰,不让子类访问; - 父类中那些需要被外部类调用的方法,必须使用
public
修饰,但又不想让子类重写,可以再使用final
修饰; - 如果希望父类某个方法被子类重写,但不希望被其他类自由访问,则使用
protected
修饰。
- 父类中那些辅助其他的工具方法,尽量使用
- 尽量不在父类的构造器里调用将要被子类重写的方法。
class Base
{public Base(){test();}public void test(){System.out.println("将被子类重写的方法");}
}public class Sub extends Base
{private String name;public void test(){System.out.println("子类重写父类的方法" + name.length());}public static void main(String[] args){var s = new Sub(); // 空指针异常}
}
- 第22行,实例化
Sub
对象时,首先会调用Base
的构造器。此时,Base
的构造器里调用了被子类重写后的方法,也就是第16行的test()
。这时,对象的实例变量name
是空指针,导致name.length()
出现空指针异常。
通过两种方式可以把类设置为不能被其他类继承:
- 使用
final
修饰这个类,这种类叫最终类,不能被当成父类; - 把这个类的所有构造器都修饰为
private
。对这种类,可提供一个静态方法,用于实例化该类的对象。
要避免滥用继承。什么时候适合从父类派生出子类呢?
- 子类需要额外的成员变量。例如
Person
类没有提供”年级“这个field(以前叫属性,现在翻译成域),而Student
类可以在继承Person
的基础上派生出grade
这个属性。- 子类需要增加自己独特的行为方式。
Person
类不一定都studying()
,但是子类Student
会studying()
。Good good study, day day up!
组合
如果只是出于代码复用的角度,使用组合更合适。
把一个类当做另一个类的组合(部件),从而允许新类直接复用该类的public方法。
组合的核心机制是把部件类的对象(实例)当做自己的成员变量,从而能驱使这个对象去调用部件类的public方法。从外部看,看到的是新类在调用,而不是部件类的方法,从而保证了封装性。此外,把这个”成员对象“修饰为private
,可以更好保护其不被外部类直接修改。
从类复用的角度,部件类其实扮演了父类的角色,即将自己的方法提供给新类。
从继承的角度实现代码复用
对Animal, Wolf, Bird
这三个类,它们从继承关系来看如下图:
class Animal
{private void beat(){System.out.println("心跳");}public void breathe(){best(); // 类内部调用System.out.println("呼吸");}
}class Wolf extends Animal
{public void run(){System.out.println("奔跑");}
}class Bird extends Animal
{public void fly(){System.out.println("飞翔");}
}public class InheritTest
{public static void main(String[] args){var b = new Bird();b.breathe(); // 继承b.fly();var w = new Wolf();w.breathe();w.run();}
}
- 通过继承,实现了对
breathe()
方法代码的复用。
从组合的角度实现代码复用
从组合的角度,这三类的关系如下图:
对应代码如下:
class Animal
{private void beat(){System.out.println("心跳");}public void breathe(){best(); // 类内部调用System.out.println("呼吸");}
}class Wolf extends Animal
{private Animal ani; // 把Animal类的对象当做成员public Wolf(Animal a){this.ani = a;}public breathe(){ani.breathe(); // 复用Animal类的方法}public void run(){System.out.println("奔跑");}
}class Bird extends Animal
{private Animal ani;public Bird(Animal a){this.ani = a;}public breathe(){ani.breathe();}public void fly(){System.out.println("飞翔");}
}public class CompositeTest
{public static void main(String[] args){// 首先要创建一个animal的对象var a1 = new Animal();var b = new Bird(a1); // 利用创建的Animal对象a1去初始化b里的成员变量b.anib.breathe();b.fly();//------var a2 = new Animal();var w = new Wolf(a2);w.breathe();b.run();}
}
第53行和第58行创建了两个Animal的实例a1,a2。如果用同一个实例来初始化b和w,会有问题吗?
在使用组合时,创建了两个Animal对象,是不是意味着组合的内存开销大?
不是。继承的开销也很大:在创建子类对象时,除为子类的实例变量分配空间,还要为父类的实例变量分配空间。
设父类有2个实例变量,子类有3个实例变量,则继承方式下,创建子类实例需要分配2+3=5块内存空间;
使用组合时,首先给部件类的对象分配2个内存空间,然后给整体类的对象分配3个内存空间。只不过这时有一个额外的引用变量来引用部件类的对象。
从这个角度看,二者的内存开销没有本质差别。
从抽象的角度看
从抽象的角度,上述类的关系更适合用继承来描述。继承表达”是“(is-a)的关系;组合则表达”有“(has-a)的关系。