本节关键词: 抽象类与抽象方法, 接口, 桥接模式, List, Map

经过前面的学习, 对于基于Java的面向对象编程可算是入门了. 希望你也自己写了一些小程序, 或者看了一些别人写的程序. 在实践的过程中或许遇到了一些之前没有接触过的语法, 例如:

  • 枚举 (enum) 的含意及使用方法;
  • try … catch … finally … 这样的 “异常捕获” 语法;

但本系列教程主要关注基于Java语言的面向对象编程方法及思想, 因此在本教程中将不涉及这些基础语法, 希望读者自行补脑……

当然在后续的例子中我们会很自然地用到这些东西, 到时会有简单的注释, 动一动你聪明的小脑袋, 应该可以很容易看懂, 所以也不用急着专门去学习……

这一节里, 我们将把之前的例子进一步扩展, 让它更 “现实” 一些, 同时, 在这个过程中我们将学习新的知识.

抽象类与抽象方法

此前的例子中, 我们定义了两个类: PigSmallPig, 其中Pig类是SmallPig类的基类(父类), 而SmallPig类是Pig类的派生类(子类).

可以这样说, 作为父类的 Pig 更为通用一些, 而SmallPig 相对具体化一些. 事实上, 派生的过程确实是一步步扩展和具体化的过程……

这里提醒一下, Pig 并非”大猪类”, 它是 SmallPig 类的父类, 是对 “猪” 相对 “普适” 的描述.

好了, 既然还没有”大猪类” 那我们现在就定义一个好了……

因为之前已经定义了 PigSmallPig 类, 那现在就只需要模仿 SmallPig 类来定义”大猪类”就好了.

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
package com.bailey.study.animal;

public class BigPig extends Pig {

public BigPig (String name) {
this(name, "未知");
}

public BigPig(String name, String sex) {
super(name, sex);
}
}

如果对于 “大猪” 没有更多需要进一步描述的, 那上述代码保持现状就好了. 否则, 你可以添加更多的属性和方法进行描述, 或通过 “覆盖” 对 Pig 类中已有的方法进行重新定义.

继续……

目前为止, 我们的猪类有了一个 eat 方法, 可以正常进食了, 但总不能白吃吧, 吃完要长肉, 长到一定程度就应该宰了~ 呵呵, 好悲催的猪! 好邪恶的人类!

因此, 为我们的猪添加一个存储当前体重的属性: weight. 因为无论 BigPig 还是 SmallPig都应该有体重, 因此, 我们把weight属性添加到了它们共同的父类 Pig 类中, 这样通过继承, BigPigSmallPig 就自然地拥有了weight 属性了. 呵呵, 看到一点点传说中的代码复用了吧~

修改后的代码如下 ( Pig.java ):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.bailey.study.animal;

public class Pig {
......
private double weight;
......

private void setWeight(double weight) {
this.weight = weight;
}

public double getWeight() {
return weight;
}
......
}

上述代码中, 为了节省篇幅, 突出重点, 原先已有的代码使用省略号代替了…

注意:

  • weight 属性前使用了 private 修饰符, 防止”外界”随意地修改猪的体重 (包括Pig类的派生类).

  • 另一方面, 我们又不能把weight封得太”死”, 总得留一个读写的途径. 因此, 我们还定义了weight属性的 settergetter 方法.

  • weight的 “读” 方法 ( getWeight() ) 使用了 public 修饰符, 以便 “外界” 可以相对自由地得到猪的体重

  • setWeight ( … ) 方法使用了private 修饰符, 基于2个方面的考虑:

    (a) 把改写体重的控制权掌握在Pig类手中, 我们不想让 “外界” 随意改写体重值, 当然, Pig类的派生类也不行 (因为目前只有”吃”了才会长肉, 而”吃”的方法已经定义在了Pig类中, 无需留给Pig类的子孙后代改写体重的机会, 因此也没有使用protected修饰符 );

    (b) setWeight() 因为是一个”函数”, 因此提供了更多的逻辑处理能力, 试想…… 如果今后我们要 “监视” 体重值, 以便通知饲养员”这头猪可以宰了”, 那么代码就可以添加在 setWeight 方法中了.

OK, 剩下的事情就是在猪吃东西的时候, 调用 setWeight() 方法让猪长点肉了……

也许, 你已经想到了, 改写 Pig 类中的 eat 方法, 把代码攺成类似如下的样子 ( Pig.java ):

1
2
3
4
public void eat(String food) {
System.out.println(name + "吃了" + food);
setWeight(weight * 1.001); // 每吃一次食物, 体重增长 0.1%
}

但是, 有这样一个现实情况: 大猪的生长速度显然不会有小猪快, 呵呵, 也就是说, 吃了相同数量的食物, 小猪的体重增长应该比大猪更多……

因此… 上述代码中 setWeight(weight * 1.001) 这种“硬编码”形式显得太不灵活了……

于是… 我们把体重的增长系数 (1.001) 做一下抽象: 由一个方法来返回增长系数, 这样在 BigPigSmallPig 类中我们只要覆盖这个方法就可以更为灵活地设定猪的体重增长系数. 代码改成如下 ( Pig.java ):

1
2
3
4
5
6
7
8
public void eat(String food) {
System.out.println(name + "吃了" + food);
setWeight(weight * getGrowRatio());
}

protected double getGrowRatio() {
return 1.001;
}

怎么样? 是不是感觉面向对象编程语言越来越好用了?

呵呵, 但是… 相对于BigPigSmallPig而言, Pig类是更为抽象的, 换句话说, Pig类并末具体化为大猪或是小猪, 我们这样 “武断” 地认为一头 “普适” 的猪 (Pig) 就该按 0.1% 的速度生长, 显然有点不妥……

于是… 干脆把 getGrowRatio() 的函数体去掉得了, 因为我们在定义Pig类时根本不知道这头猪该有怎么的生长速度…

于是… 代码变成这样子 ( Pig.java ):

1
2
3
4
5
6
public void eat(String food) {
System.out.println(name + "吃了" + food);
setWeight(weight * getGrowRatio());
}

protected double getGrowRatio();

OMG~ 这也行?! 呵呵, YES, Of Course YES!

只是, 现在getGrowRatio()方法没有内容了, 也就是说, 它更”抽象”了, 必须在定义时添加 abstract 修饰符, 相应地, Pig 类因为拥有一个抽象的方法, 也被变成抽象的猪类了, 在Pig类定义时也应添加abstract修饰符. 正确的代码应如下 ( Pig.java ):

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.bailey.study.animal;

public abstract class Pig {
......

public void eat(String food) {
System.out.println(name + "吃了" + food);
setWeight(weight * getGrowRatio());
}

protected abstract double getGrowRatio();
......
}

好了, 这就是传说中的 “抽象类“ 和 “抽象方法“.

简单来说, “抽象方法” 就是未实现的方法 (没有函数体), “抽象类”就是含有抽象方法的类.

正因为是抽象的, 所以 抽象类是不能实例化的 , 即: 现在不能 new Pig () 了. 其中的道理应该一想就明白… (想不明白就多想一会~)

关于抽象类和抽象方法, 它们完成了如下几项重大的”进化”:

  • 提供了一种机制, 让功能的实现 “推迟” 到了派生类
  • “继承” 机制在实现代码复用的同时, 还提供了父类给子类指明”发展方向”的途径, 这事实上可以上升到所谓的”架构”层次. 而抽象类和抽象方法的引入, 让这种”为子孙后代指明发展方向”的功能发挥得更为自然. 通俗来说, 父类要求子孙们必须具备某项功能, 但在定义父类时又暂时无法确定这项功能应该具体如何实现, 于是把”具体化”的工作交给了后代.

接口

目前为止, 如果我们需要一头小猪, 那只要 new SmallPig(...); 小猪就出现了… 但是… 既然你把它 “生” 出来了, 就得喂它吧, 呵呵~ ( 好吧, 不应该是你把它生出来的! )

也就是说, 如果猪儿们饿了, 应该可以通过调用类似 String getFood() { ... } 这样的方法来获得食物. ( 这猪好高级, 饿了还会主动要吃的, 呵呵~ )

那么, 这个方法定义在那个类里面呢?

也许, 你已经想到了, 我们再来定义一个”饲养员”的类, 把 getFood() 方法定义在”饲养员”类里…… 但是, 可以提供食物的不止是饲养员吧, 比如… 猪妈妈应该可以给点奶吃… 再比如… 我们有一台高大上的”自动喂猪机”, 那它也应该可以提供食物… 换句话说, 提供食物的功能可以由不同的类来实现, 而这些类之间不一定有关系.

再换句话说, 其实, 我们关心的只是有某个东西能提供一个 getFood() 方法, 当调用这个方法的时候, 可以得到食物, 谁管它是什么东西呢……

说这么半天, 其实是想引入”接口“的概念.

术语化点说, 接口 ( Interface ) 是对功能的抽象, 而功能在这里其实就是方法.

这里抽象的好处在于可以把功能的 “描述” 和 “实现” 分离.

“描述”可认为是方法(函数)的原型声明(函数头), 它告诉调用方, 方法名是什么? 需要什么样的参数? 会返回什么样的结果?

而”实现”就是方法(函数)体, 它是这个功能的具体实现.

对于上面的例子来说, “描述” 就是 String getFood();{...} 就是”实现”部分, 关键的是, “实现”部分可以在不同的类 (饲养员/猪妈妈/自动喂猪机) 完成.

如果我们把一个或多个功能描述(函数原型声明部分)集中起来写到一起, 那就可以定义成接口了…… 代码如下 ( IFoodProvider.java ):

1
2
3
4
5
package cn.edu.ynnu.study;

public interface IFoodProvider {
public String getFood();
}

那功能实现部分写在那里呢? 也就是说, 如何实现这个接口呢?

这就得有实现这个接口的类了… 比如: 自动喂猪机, 代码如下 ( PigFeedMechine.java ):

1
2
3
4
5
6
7
8
9
10
11
package com.bailey.study.mechine;

public class PigFeedMechine implements IFoodProvider {
......

@Override // 这是一个"注解", 告诉编译器, 下面这个方法做了一个覆盖 (语法上并非必须)
public String getFood() {
return "玉米"; // 返回食物
}
......
}

当然, 你也可以模仿着定义出 “饲养员”, “猪妈妈”…

怎么使用呢?

我们可以在 Pig 类中添加一个 setFoodProvider(IFoodProvider foodProvider) 方法, 把某个实现了IFoodProvider接口的类的实例”注册”给猪, 当猪需要食物的时候, 只要调用接口的 getFood() 方法即可…… 看看下面的代码 ( Pig.java ):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.bailey.study.animal;

public abstract class Pig {
......
private IFoodProvider foodProvider;

public void setFoodProvider(IFoodProvider foodProvider) {
this.foodProvider = foodProvider; // 记录食物提供方
}

protected void feelHungry() { // 饿了…
if (foodProvider != null) {
eat(foodProvider.getFood()); // 把食物提供方返回的食物吃了
} else {
... // 继续饿着吧~
}
}
......
}

此时, 若要调用 setFoddProvider() 方法, 为猪”注册”一个食物提供方, 只须传入实现了 IFoodProvider 的类的实例即可, 诸如:

SmallPig sp = new SmallPig(...);
sp.setFoodProvider(new PigFeedMechine());

上述代码中, 特别关注几个地方:

  • 第 7 行, setFoodProvider 方法所需要的参数只是一个实现了 IFoodProvider 接口的对象, 并非某个具体的对象(饲养员/自动喂猪机)

  • 第 12 ~ 13 行, 当需要食物时, 使用的也只是接口 IFoodProvider 类型的变量 foodProvider, 并非某个具体的对象

  • 总之, 对于 Pig 类而言, 我们无须关心是谁提供食物, 只关心有某个东西能够提供食物 ( 只要实现了 IFoodProvider即表明此类的实例可以提供食物 ).

    而对于那些实现 IFoodProvider 接口的类而言, 它们同样无须关心谁在使用它.

  • 进一步说, 通过接口机制, 实现了功能提供方 (饲养员/自动喂猪机…)功能调用方 (猪) 之间的解耦合, 这正是我们一直在追求的 “低耦合“.

  • 在这里, 接口就象一座桥, 它把功能提供方和调用方连接起来了, 而这种连接又是相对松散的, 低耦合的, 灵活的.

相对而言:

抽象类实现了一个家族体系 (继承体系) 内的功能抽象, 而接口把功能抽象进一步地延伸到了不同的家族中.

List 和 Map

既然讲到接口, 那么我们就顺带提一下在Java中非常常用的两个接口: ListMap , 这两个接口均定义在 java.util 包中.

  • 实现 List 接口的类很多, 其中最常用的应是 java.util.ArrayList .

    List 用来存储一组顺序数据, 而并不限定数据具体类型. 参看下面的代码, 也许可以有点感觉

    代码很好猜, 如果看不懂就查英汉词典, 就不写注释了):

1
2
3
4
5
6
7
8
9
List list = new ArrayList();
list.add("a");
list.add("b");
list.add("c");
for (int i = 0, size = list.size(); i < size; i++) {
System.out.println(list.get(i));
}
list.remove(0);
list.remove("c");

  • 同样, 实现 Map 接口的类也很多, 其中 java.util.HashMap 较常用.

    Map 用来存储 “键-值对“, 它也同样不限定存储的元素类型. 参看下面的代码:

1
2
3
4
5
6
Map map = new HashMap();
map.put("sp1", new SmallPig("小花", Sex.公));
map.put("sp2", new SmallPig("小黄", Sex.母));
map.put("sp3", new SmallPig("小黑", Sex.公));
System.out.println( ((SmallPig) map.get("sp1")).getName() );
map.remove("sp1");

也许你会注意到上面的代码如果放到 Eclipse 里, 有出现 “警告” ( warning, 划黄色波浪线 ).

为什么呢?

通俗来说, 因为 ListMap 并不限制放入其中的元素类型, 但是这样可能对程序员而言似乎有些 “纵容”, 万一程序员一个失手, 本该放头猪进去, 却一不小心放了一头牛, 那 ListMap 其实是没有意见的, 在语法上没有任何问题, 但是在逻辑上就错了. 为了避免这种情况的发生, 更好的做法是, 在声明和实例化的时候就明确一下要放入的是什么类型的东西, 这样编译器就可以帮你检验放入的数据是否合法了.

上述代改写如下:

1
2
List<String> list = new ArrayList<String>();
Map<String, SmallPig> map = new HashMap<String, SmallPig>();

当然, 既然已经明确了放入map的 “键-值对” 中的”键”是 String 类型的, 而”值”是 SmallPig 类的, 那么前面的代码中就无须强制转换了, 即可以删除 (SmallPig) 部分:

System.out.println( ((SmallPig) map.get(“sp1”) ).getName() );

上面的阐述只是为了便于理解, 严格的定义和概念, 请查阅关于 泛型 的资料.


OK, 就到这里, 本节我们学习了抽象类接口, 另外也捎带了解了一下泛型.

从现在开始, 我们应该逐渐从只关心怎么把功能实现, 转变到如果把程序写得更好 ( 健壮, 结构清晰, 有条理, 易读, 易维护……), 也就是往所谓 “架构” 层面去思考和设计……