第二章 面向对象
这章开头说,“作为一名SCJP6,意味着你必须精通Java中面向对象的知识。必须熟悉继承层次结构,灵活自如地应用多态性的强大能力,内聚与松散耦合必须成为你的第二性征,复合则成为你的谋生之道。”(复合类型就是引用类型,包括类引用、接口引用、数组引用)。
在如今框架横飞的年代,我们就像拼装工人一样,不问为什么,不去思考,没有思想。我常常对自己说,我不要再写垃圾代码了。但总是昨夜还沉浸在《Java与模式》的优雅,第二天就又屈服于疲于奔命的进度和莫名其妙的设计文档了。
每个夜晚你总会在写字楼的6层看见一个为编程痴狂的老小子,一边敲着键盘,一边摇头晃脑的吼着汪峰的《我想要怒放的生命》,加油!
2.1 封装
考试目标5.1 编写代码,实现类中的紧封装、松耦合和高内聚,并描述这样做的优点。
为什么要封装?
通常在类中,我们的实例变量(定义在类中,但位于任何方法之外,并且只有在实例化类时才会被初始化的变量),还有一些只有本类会用到的方法,都用private来声明,然后如果需要对实例变量访问,就写一些getter和setter。
如果都public了呢?比如你定义了一条记录的ID是用sequence获取的,一个调用者自己指定了一个旧的ID给对象,做插入数据库的操作,那一定是要抛异常了。
如何实现封装:
- 保护实例变量(使用访问修饰符,通常是private)
- 建立public访问器方法,强制调用代码使用这些方法而不是直接访问实例变量。
- 对于访问器方法,使用JavaBeans命名规则set<propertyName>和get<propertyName>
2.2 继承、IS-A、HAS-A关系
考试目标5.5 编写代码,实现IS-A关系和/或HAS-A关系。
书云:“不使用继承,即便编译最微小的Java程序也几乎是不可能的。”,继承可以说是面向对象的基础。
子类继承超类,子类继承了超类的非私有的成员变量和成员方法,就像这些成员本来就是他们自己的一样。
需要注意的是Java不支持多重继承,一个类只能直接继承一个类。
继承的作用:
- 促进代码的复用。这个很好理解,所有的类都继承自Object,它提供给所有的类equal()等方法,如果所有的类都要自己实现这个方法的话,那太可怕了。
- 使用多态性。书中给了个载入游戏图形的例子:
//游戏图形的超类,所有子类通过继承GameShape来获得显示图形的方法
class GameShape {
//显示图形的方法
public void displayShape(){
System.out.println("displaying shape");
}
}
//GameShape的一个子类,游戏人物的图形对象
class PlayerPiece extends GameShape{
//code
}
//GameShape的一个子类,墙砖的图形对象
class TilePiece extends GameShape{
//code
}
//现在假设有一个GameLauncher类,当我们进入这张地图的时候,它会把这些图形对象(即GameShape的子类)都载入进来。
//换句话说,GameLauncher的工作就是实例化这些XxxxPiece类,然后让他们调用父类GameShape的displayShape()方法。
class GameLauncher{
//这个方法并不关心参数是GameShape的哪个子类。
public static void doShapes(GameShape shape){
shape.displayShape();
}
public static void main(String[] str){
PlayerPiece player = new PlayerPiece();
TilePiece tile = new TilePiece();
doShapes(player); //体现了多态性的好处,加入后面又加入了新的Piece,
doShapes(tile); //比如WeaponPiece,在doShapes中依然不用关心它是什么。
}
}
2.2.1 IS-A关系
在OO中,IS-A的概念基于类继承和接口实现。在Java中,使用extends和implements来表达IS-A关系。
IS-A:书云“这个东西是那个东西的一种”。我觉得这个解释足够了,不再将这个概念妖魔化了。= =
类A继承类B,可以说“类A IS-A 类B”.
2.2.2 HAS-A关系
HAS-A关系基于引用。类A中的代码具有对类B实例的引用,则“类A HAS-A 类B”。
书云:“IS-A、HAS-A关系以及封装只是面向对象设计的冰山一角”。其实我们在设计架构的时候,就是从这些基本的概念出发的。记得和同事在设计类的时候,一个同事说:“是它的就是它的,不可分割的就给它;不是它的就不是它的,不能生加上去。”这是一个原则,但是我也反对为了面向对象而面向对象。
2.3 多态性
考试目标5.2 给定一个场景,编写代码,演示多态性的使用。而且,要判断何时需要强制转化,还要区分与对象引用强制转换相关的编译器错误和运行时错误。
多态性:可以传递多个IS-A测试的任何Java对象都可以被看作是多态的。
访问对象的唯一方式是通过引用变量。关于引用,要记住:
- 引用变量只能属于一种类型。一经声明,类型就永远不能再改变(尽管它引用的对象可以改变类型)。
- 引用是一个变量,因此它可以重新赋予给其他对象(除非该引用被声明为final)。
- 引用变量的类型决定了可以在该变量引用的对象上调用的方法。
- 引用变量可以引用具有与所声明引用的类型相同的任何对象,或者——最重要的一点是——它可以引用所声明类型的任何子类型。
- 引用变量可以声明为类类型或接口类型。如果将变量声明为接口类型,它就可以引用实现该接口的任何类的任何对象。
前面2.2我们说到“在OO中,IS-A的概念基于类继承和接口实现”,2.2中基于类继承的说的比较多,接口同样可以表达IS-A的关系,实现多态。
Java为什么没有多重继承?
如果一个类扩展另外两个类,并且两个超类都具有doStuff()方法,那么问题就出险了:子类将继承doStuff()方法的哪个版本讷?这个问题可能导致一种“致命的死亡菱形”的情形,B、C继承A,D继承B、C,而且B、C都重写类A中的一个方法,那么从理论上讲,类D就继承了同一个方法的两种不同实现。Java的设计者考虑到这种可能的混乱,所以规定一个类只能直接继承一个超类。
那如果我有下面的需求该怎么办呢?
比如上面的GameShape例子,它的子类通过继承它,获得了displayShape()方法,来显示图像。现在我想让GameShape的子类都可以使用Animatable类的animate()方法,来实现游戏贴图的一些动画。
由于animate()方法不仅提供给GameShape的子类,也会被其他的类使用,比如Game2Shape,所以我不能把animate()写在GameShape中。但又不能继承两个类,而在每个子类中都写一个自己的animate()显然又不优雅。
这时,我们就可以用接口来实现这个需求。即
interface Animatable{
void animate();
}
class PlayerPiece extends GameShape implements Animatable{
public void animate(){
//code
}
}
这就完成了用接口实现IS-A关系。
以PlayerPiece为例,我们可以说
- PlayerPiece IS-A Object
- PlayerPiece IS-A GameShape
- PlayerPiece IS-A PlayerPiece
- PlayerPiece IS-A Animatable
以上就体现了PlayerPiece的多态性。
多态方法调用仅使用于实例方法。不涉及静态方法和变量。
而在继承中子类可以使用超类的方法,也可以自己来实现这一方法,这就涉及到了重写(override)。
2.4 重写和重载
考试目标1.5 给定一个代码示例,判断一个方法是否正确地重写或重载了另一个方法,并判断该方法的合法返回值(包括协变式返回值)。
考试目标5.4 给定一个场景,编写代码,声明和/或调用重写方法或重载方法。编写代码,声明和/或调用超类、重写构造函数或重载构造函数。
2.4.1 重写方法(override)
重写的规则:
- 变元列表必须与被重写的方法的变元列表完全匹配。
- 返回类型必须与超类中被重写方法中原先声明的返回类型或其子类型相同。
- 访问级别的限制性一定不能比被重写方法的更严格。
- 访问级别的限制性可以比被重写方法的弱。
- 仅当实例方法被子类继承时,它们才能被重写。与实例的超类同包的子类可以重写未标识为private或final的任何超类方法。不同包的子类只能重写那些标识为public或protected的非final方法。
- 重写方法可以抛出任何未检验(运行时)异常,无论被重写方法是否声明了该异常。
- 重写方法一定不能抛出比被重写方法声明的检验异常更新或更广的检验异常。比如,一个声明FileNotFoundException异常的方法不能被一个声明SQLException、Exception或任何其他非运行时异常的方法重写,除非它是FileNotFoundException的一个子类。
- 重写方法能够抛出更少或更有限的异常。
- 不能重写表示为final的方法。
- 不能重写标识为static的方法。
调用被重写方法的超类版本:super关键字
2.4.2 重载方法(overload)
重载的规则:
- 重载方法必须改变变元列表。
- 重载方法可以改变返回类型。
- 重载方法可以改变访问修饰符。
- 重载方法可以声明新的或更广的检验异常。
- 方法能够在同一个类或者一个子类中被重载。
调用重载方法:调用哪个重载方法,取决于变元的类型。
class Animal {}
class Horse extends Animal{}
class UseAnimals{
public void doStuff(Animal a){
System.out.print("In the Animal version");
}
public void doStuff(Horse h){
System.out.print("In the Horse version");
}
public static void main(String[] str){
UseAnimals ua = new UseAnimals();
Animal obj = new Horse();
ua.doStuff(obj); //在这里引用类型决定了调用哪个重载方法
}
}
//结果显示"In the Animal version"
重载方法和重写方法中的多态性
用一个例子来说明:
public class Animal {
public void eat(){
System.out.println("Generic Animal Eating Generically");
}
}
public class Horse extends Animal{
public void eat(){
System.out.print("Horse eating hay");
}
public void eat(String s){
System.out.print("Horse eating "+s);
}
}
//测试方法
public class Test{
public static void main(String[] str){
//这里是下表中“方法调用的代码”
}
}
不同调用方法的结果:
方法调用的代码 | 结果 | 解释 |
Animal a = new Animal(); a.eat(); | Generic Animal Eating Generically | |
Horse h = new Horse(); h.eat(); | Horse eating hay | |
Animal ah = new Horse(); ah.eat(); | Horse eating hay | 多态性起作用——确定调用的是哪个eat()时,使用的是实际的对象类型(Horse),而不是引用类型(Animal) 注意和前一个例子的情况相区分,前面的情况是选择用UseAnimals对象的哪个方法,由变元类型(或说引用类型)来决定(编译时); 现在的情况是选择用哪个对象的eat()方法,Animal里如果没有这个方法会报编译时错误,但是就算有,运行时还是由实际的对象类型来决定。 |
Horse he = new Horse(); he.eat("Apples"); | Horse eating Apples | 调用重载方法eat(String s); |
Animal a2 = new Animal(); a2.eat("treats"); | 编译时错误 | Animal没有带String变元的eat()方法 |
Animal ah2 = new Horse(); ah2.eat("Carrots"); | 编译时错误 | 原因同上 |
重载方法和重写方法的区别:
重载方法 | 重写方法 | |
变元 | 必须改变 | 一定不能改变 |
返回类型 | 可以改变 | 除协变式返回外,不能改变 |
异常 | 可以改变 | 可以减小或消除。一定不能抛出新的或更广的检验异常 |
访问级别 | 可以改变 | 一定不能执行更严格的限制(可以降低限制) |
调用 | 引用类型决定了选择哪个重载版本(基于声明的变元类型)。在编译时刻做出决定。 调用的实际方法仍然是一个在运行时发生的虚拟方法调用,但是编译器总是知道所调 用方法的签名。因此在运行时,不仅是方法所在的类,而且变元匹配也已经明确了。 | 对象类型(也就是堆上实际的实例的类型)决定了调用哪个方法。 在运行时决定。 |
2.5 引用变量强制转换
考试目标5.2 给定一个场景,编写代码,演示多态性的使用。而且,要判断何时需要强制转化,还要区分与对象引用强制转换相关的编译器错误和运行时错误。
向下转型:把引用变量转换为子类类型。如Horse h = (Horse) new Animal();但如果调用父类里没有的方法,可以通过编译,但运行时会抛出java.lang.ClassCastException异常。
向上转型:把引用变量转换为超类类型。如Animal a = new Horse(); 不需要转化,这是天然的IS-A 关系。
2.6 实现接口
考试目标1.2 编写代码,声明接口。编写代码,实现或扩展一个或多个接口。编写代码,声明抽象类。编写代码,扩展抽象类。
在第一章的“声明接口”里说过,接口就是一种契约,任何实现这个接口的实现类都必须同意为该接口的所有方法提供实现。
合法的非抽象实现类必须执行以下操作:
- 为来自所声明接口的所有方法提供具体(非抽象)的实现。
- 遵守合法重写的所有规则。
- 在实现方法上声明非检验异常,而不是在接口方法上声明,也不是在接口方法上什么异常的子类。
- 保持接口方法的签名,并且保持相同的返回类型(或子类型),但是不必声明在接口方法声明中声明过的异常。
两条规则:
- 一个类可以实现多个接口。书云:“子类将定义你是谁以及是做什么的,而实现则定义你所扮演的角色或者你能戴的帽子,而不会理会你与实现同样接口(但来自不同的继承树)的其它类有多大的差别”。
- 接口自身可继承另一个接口,而且接口可以继承多个接口。
2.7 合法的返回类型
考试目标1.5 给定一个代码示例,判断一个方法是否正确地重写或重载了另一个方法,并判断该方法的合法返回值(包括协变式返回值)。
2.7.1 返回类型的声明
哪些内容声明为返回类型,这主要取决于是在重写方法、重载方法还是在声明新方法。
重载方法上的返回类型
没有什么限制,重载方法关键是变元要变化。
重写、返回类型和协变式返回
从Java5开始,只要新的返回类型是被重写的(超类)方法所声明的返回类型的子类型,就允许更改重写方法中的返回类型(这就是传说中的协变式返回)。以前的Java版本要求重写的方法返回类型一定要与原来的一致。
2.7.2 返回值
六条规则:
1.可以在具有对象引用返回类型的方法中返回null。
2.数组是完全合法的返回类型。
public String[] go(){
return new String[]{"Neil","Neo","Nail"};
}
3.在具有基本返回类型的方法内,可以返回任何值或变量,只要它们能够隐式转换为所声明的返回类型。
public int foo(){
char c ='c';
return c;
}
4.在具有基本返回类型的方法内,可以返回任何值或变量,只要它们能够显式地强制转换为所声明的返回类型。
public int foo(){
float f = 32.5f;
return (int) f;
}
5.一定不能从返回类型为void的方法返回任何值。
6.在具有对象引用返回类型的方法内,可以返回任何对象类型,只要它们能够隐式地强制转换为所声明的返回类型。换句话说,能通过IS-A测试的(也就是使用instanceof运算符测试为true)任何对象都能够从那个方法中返回。
//声明返回超类,实际返回子类
public Animal getAnimal(){
return new Horse(); //Assume Horse extends Animal
}
//声明返回超级父类Object,实际返回数组
public Object getObject(){
int[] nums = {1,2,3};
return nums; //Return an int array,which is still an object
}
//声明返回接口,实际返回接口的一个实现类
public interface Chewable{}
public class Gum implements Chewable{}
public class TestChewable{
//Method with an interface return type
public Chewable getChewable(){
return new Gum(); //Return interface implementer
}
}
2.8 构造函数和实例化
考试目标1.6 给定一组类和超类,为一个或多个类编写构造函数。给定一个类声明,半段是否会创建默认构造函数。如果会,请确定该构造函数的行为。给定一个嵌套类或非嵌套类清单,编写代码,实例化该类。
考试目标5.3 解释继承对构造函数、实例或静态变量,以及实例或静态方法在修饰符方面的影响。
考试目标5.4 给定一个场景,编写代码,声明和/或调用重写方法或重载方法。编写代码,声明和/或调用超类、重写构造函数或重载构造函数。
构造函数基础:
- 构造函数是用来创建新对象的,每当我们“new”的时候,JVM就会按照你所指定的构造函数来创建一个对象实例。
- 每个类都至少有一个构造函数。
- 构造函数都没有返回类型(有就成方法了)。不同的构造函数通过不同的变元来区分(或者为空)。
构造函数链:
当 Horse h = new Horse(); 的时候究竟发生了什么?(Horse extends Animal,Animal extends Object)
- 调用Horse构造函数。通过一个对super()的(隐式)调用,每个构造函数都会调用其超类的构造函数,除非构造函数调用同一个类的重载构造函数。
- 调用Animal构造函数(Animal是Horse的超类)。
- 调用Object构造函数(Object是所有类的最终超类,因此,Animal类扩展Object)。这时,我们处于栈的顶部。
- 为Object实例变量赋予显式值。
- Object构造函数完成。
- 为Animal实例变量赋予显式值。
- Animal构造函数完成。
- 为Horse实例变量赋予显式值。
- Horse构造函数完成。
构造函数规则:
- 构造函数能使用任何访问修饰符。
- 构造函数名称必须与类名匹配。
- 构造函数一定不能有返回类型。
- 让方法具有与类相同的名称是合法的,但是建议不要这样做。
- 如果不在类代码中键入构造函数,编译器将自动生成默认构造函数。
- 默认构造函数总是无变元构造函数。
- 如果在类代码中已经有带变元的构造函数存在,而没有无变元的构造函数,那在编译时不会自动生成无变元构造函数。
- 每个构造函数都必须将对重载构造函数[this()]或超类构造函数[super()]的调用作为第一条语句。如果没有编译器会自动插入super();
- 除非在超类构造函数运行之后,否则不能调用实例方法或访问实例变量。
- 只能将静态变量和方法作为调用super()或this()的一部分进行访问。例如:super(Animal.NAME)
- 抽象类具有构造函数,这些构造函数总是在实例化具体子类时才调用。
- 接口没有构造函数。接口不是对象继承树的一部分。
- 调用构造函数的唯一方法是从另一个构造函数内部进行调用。
2.8.1 判断是否会创建默认构造函数
如何证明会创建默认构造函数?
只有在类代码中没有构造函数的,才会生成默认构造函数。
如何知道它就是默认构造函数?
默认构造函数的特征:
- 具有与类相同的访问修饰符。
- 没有任何变元。
- 包含super();
public class Foo{
public Foo(){
super();
}
}
如果超类构造函数有变元会怎样?
那在new的时候必须带参 new Animal(“monkey”);
2.8.2 重载构造函数
重载构造的时候要注意:
- this()或super()一定要在第一行。
- 不要写如下的死循环代码:
class A {
A(){
this("foo");
}
A(String s){
this();
}
}
2.9 静态成员
考试目标1.3 编写代码,将基本类型、数组、枚举和对象作为静态变量、实例变量和局部变量声明、初始化并使用。此外,使用合法的标识符为变量命名。
2.9.1 静态变量和静态方法
当方法永远与实例完全无关时,我们就将它声明为static。
访问静态方法和变量:
- 用“ 类名.静态变量/方法 ”来访问。
- 静态方法不能访问实例(非静态)变量。
- 静态方法不能访问非静态方法。
- 静态方法能够访问静态方法和静态变量。
static方法的重定义问题:
我们都知道静态方法是不能被重写的,但是可以被重定义。这个问题很迷糊人,从代码上来看,重写和重定义没有区别。那么重定义(redefine)和重写(override)有啥区别呢?
重定义操作的是静态方法,静态方法跟类有关;重写操作的是非静态方法,跟实例对象有关。看下下面的代码:
public class Tenor extends Singer{
public static String sing(){
return "fa";
}
public String sing2(){
return "fa2";
}
public static void main(String[] args){
Tenor t = new Tenor();
Singer s = new Tenor();
System.out.println(t.sing()+" "+s.sing()+" "+t.sing2()+" "+s.sing2());
}
}
class Singer{
public static String sing(){
return "la";
}
public String sing2(){
return "la2";
}
}
//运行结果是:fa la fa2 fa2
2.10 耦合与内聚
考试目标5.1 编写代码,实现类中的紧封装、松耦合和高内聚,并描述这样做的优点。
书云:“Sun考试对内聚和耦合所下的定义略带主观性”、“本章所讨论的内容是从考试角度出发的,绝不是关于这两条OO设计原则的真经”。
很多东西都是“兼容”标准,各自实现。
Java的OO设计目标:紧封装、松耦合、高内聚。以实现易于创建、易于维护、易于增强的目标。
耦合(Coupling):耦合是指一个类了解另一个类的程度。如果类A对类B的了解很少,仅限于类B通通过其接口公开的信息,类A并不知道B的更多具体实现,那就称类A和类B是松耦合的。我们说类B做到了紧封装。
内聚(Cohesion):内聚用于表示一个类具有单一的、明确目标的程度。一个类的目标越明确,其内聚性越高。
书中举了一个报表类的低内聚和高内聚例子,内聚性低的报表类将报表的保存、选择库、打印等方法都写在一个类里面。内聚性高的设计将这些目标明确,一个目标封装在一个类里面,如报表的打印类、选库类、保存类等等。
我个人觉得凡事都讲个度,要根据需求的复杂程度来决定设计,既不能让运行的花销太大,也不能为了XXXX而死板的设计。