软件构造心得(六)
OOP技术的使用
本篇博客继ADT的学习后,主要基于Java介绍对于ADT的具体实现技术OOP(Object-Oriented Programming)。
一、封装与信息隐藏
1、信息隐藏
为了避免表示泄露,一般我们会限制某一成员或方法调用的范围,以下有四个关键词:private、default、protect、public。其调用范围按从左到右的顺序从小到大排列。
每一个可被调用的范围如下图:
其中需要注意的是“同一包内”指的是同一包内的子孙类,因此为了避免表示泄露,我们会在一些类内成员前加private。
注:这里顺便讲一下经常与private一同出现的final,当final加在field前时,表示它在赋值后不可以修改其指向;当final加在方法前时,表明其方法不会被子类重写(override);当final加在class前时,表明这个类不允许有子类。
2、接口(interface)与子类
接口就像是提供了一个抽象的模板,当有一些类似的类出现时,可以按照这个模板实现(填充)其需要的方法,然后就可以使用了。因此,interface中一般只含有方法的定义、传入参数而无需写具体实现(因为不同的类可能具体实现不同)。
然而,在这之中可能会出现很多类实现方法一样,这样就会导致这一方法被重复编写,为了方便,接口中还可以以default为前缀,并在interface中完成实现,这代表的就是同一接口不同类中这个方法编写起来是一样的,减少重复地编写。
此外interface中也允许存在加了static前缀的方法,此时这种方法需要也在interface中实现。由于接口中不能存在构造器,因此往往需要知道某一具体类的名称,那这样可能也会造成表示泄露。为了更好地维护ADT的性质,可以在interface中加入以static为前缀的方法,以充当构造器。
总的来说,有以下关于接口的例子(同时包含了三种形式):
public interface Example { default int method1(int a) {…} static int method2(int b) {…} public int method3(); } public class C implements Example { @Override public int method3() {…} }
default和static方法都直接在interface中实现,而其他方法需要在具体类中实现。
子类看似与接口有一些类似,但是它不同于接口,子类是建立在父类之上的,而不是建立在接口之上。可以把接口看作完全抽象的类,而父类就是是实实在在的具体类,里面也有许多成员和方法。子类可以说是它的子集,更具体的一部分。而父类代表的是各子类所拥有的共同性质与方法。以Animal类为例,Animal作为动物,其有着所有动物的共同方法:eat();而Dog、Horse作为其子类,不仅都继承了父类中的eat()方法,还有着与其他子类互不相同的特性成员与方法。如Dog类中有叫声woof()方法,而Horse类中有叫声puff()方法。如下代码:
class Animal { public void eat() {...} } class Dog extends Animal { public void woof() {...} } class Horse extends Animal { public void purr() {...} }
3、重写(override)与重载(overload)
还是观察以上子类的例子,我们发现,Dog类中的叫声woof()方法和Horse类中purr()方法本质上都是Animal中的同一性质叫声sound(),那我们可以抽象出这一共性来表达吗?显然是可以的,但是由于Dog类中的叫声和Horse类中的叫声实现方法不同,我们很快能想到可以用类似接口的形式定义父类方法吗?其实是可以的,这我们在后面会讲到。但是按现在的看法,父类是一个具体类而不是抽象类,因此没办法用类似接口的定义来定义sound()方法。此时我们就可以用到子类中的重写(override)来实现了,override是可以在子类中实现一个跟父类中函数名、返回值、传入参数一模一样的方法(即与父类完全一样的方法),它可以在调用子类的时候覆盖父类的方法,如:
class Animal { public void eat() {...} public void sound(){System.out.println("default");} } class Dog extends Animal { public void sound(){System.out.println("woof");} } class Horse extends Animal { public void sound(){System.out.println("purr");} } Animal a=new Animal(); Dog b=new Dog(); Horse c=new Horse(); Animal d=new Dog(); a.sound(); b.sound(); c.sound(); d.sound();
以上例子的四个输出分别是”default“、”woof“、”purr“和”woof“。需要注意的是第四个输出,虽然它是Animal类,但是其sound()方法已经被子类重写了,因此为”woof“。
而重载(overload)与重写类似,也是用于同名的方法中,但是其传入的参数可以是不同的,这就可以视作是一种对于函数的拓展,究竟应该使用哪个重载的方法取决于传入参数的类型。如:
public class Adder { public int addThem(int x, int y) { return x + y; } public double addThem(double x, double y) { return x + y; } }
其中若是A.addThem(2,3)则调用第一个,若是A.addThem(3.14,3.15)则调用第二个。
注:可以通过super调用父类方法。
4、abstract class:
回到以上一开始的问题,我们可以将父类中的一些方法视作接口中的方法然后在子类再实现吗?实际上是可以的,这就用到了abstract前缀,在类和方法前加上abstract,表示它是一个抽象类的抽象方法,在子类中实现即可。有如下例子:
abstract class Animal{ void eat() {...} abstract void sound(); } class Dog extends Animal{ void sound(){System.out.println("Woof");} } class Horse extends Animal{ void sound(){System.out.println("purr");} }
实现的仍然是上面的动物叫声的例子。需要注意的是从某种意义上,接口interface是一种特殊的abstract class,相当于它是全部方法都抽象的抽象类。
5、子类与父类的LSP
LSP,Liskov Substitution Principle,确定了子类方法可以完全覆盖父类的原则。在构造子类方法时,都要满足LSP。
首先先介绍Java中的静态检查原则:
(1) 子类型可以增加方法,但不可删;
(2) 子类型需要实现抽象类型 (接口、抽象类)中所有未实现的方法;
(3) 类型中重写的方法必须有相同或子类型的返回值;
(4) 子类型中重写的方法必须使用同样类型的参数;
(5) 子类型中重写的方法不能抛出额外的异常。
而里面这些涉及到了子类返回值和异常的协变和参数的逆变,这也与LSP极为贴合(除了Java中参数不能逆变)。
LSP的原则:
(1) 更强的不变量;
(2) 更弱的前置条件;
(3) 更强的后置条件。
其中,第(2)(3)个条件就是要求要有更强的规约。