当我们谈到接口的时候,可能会联想到三样事物:
我们经常说一个库或者模块对外提供了某某API。通过主动暴露的接口来通信,可以隐藏软件系统内部的工作细节。这也是我们最熟悉的第一种接口含义。
第二种接口是一些语言提供的关键字,比如Java的interface。interface关键字可以产生一个完全抽象的类。这个完全抽象的类用来表示一种契约,专门负责建立类与类之间的联系。
第三种接口即是我们谈论的“面向接口编程”中的接口,接口的含义在这里体现得更为抽象。用《设计模式》中的话说就是:接口是对象能响应的请求的集合。
本文讨论第二种和第三种。
假设java的世界里,有一只鸭子(Duck),还有一只让鸭子发声的(AnimalSound)类,它有一个makeSound方法,接受Duck类型的对象为参数,当让这个鸭子叫的时候,用代码描述就是:
public class Duck {
public void makeSound(){
System.out.println('嘎');
}
}
public class AnimalSound {
// 只接受Duck类型参数
public void makeSound(Duck duck){
duck.makeSound();
}
}
public class Test{
public static void main(String args[]){
AnimalSound animalSound=new AnimalSound();
Duck duck=new Duck();
animalSound.makeSound(duck); // 输出:嘎
}
}
后来这个世界多了一只鸡,现在我们想让鸡也叫唤起来,但发现这是一件现有代码不可能完成的事情,因为在上面这段代码中AnimalSound类的sound方法里,被规定只能接受Duck类型的对象作为参数。
在享受静态语言类型检查带来的安全性的同时,我们也失去了一些编写代码的自由。
像java这样的静态语言,通常可以设置为"向上传型"——当给一个类变量赋值时,这个变量的类型既可以使用这个类本身,也可以使用这个类的超类。
就像看到鸭子叫,我们既可以说“一只鸭子在叫”,也可以说“一只鸟在叫”,甚至可以说成“一只生物在叫”。通过向上转型,对象的具体类型被隐藏在“超类型”身后。当对象类型之间的耦合关系被解除之后,这些对象才能在类型检查系统的监视下相互替换使用,这样才能看到对象的多态性。
所以如果想让鸡也叫唤起来,必须先把duck对象和chicken对象都向上转型为它们的超类型Animal类,进行向上转型的工具就是抽象类或者interface。
public abstract class Animal{
abstract void makeSound(); // 抽象方法
}
// 让Duck类和Chicken类都继承自抽象类Animal:
public class Duck extends Animal {
public void makeSound(){
System.out.println('嘎');
}
}
public class Chicken extends Animal {
public void makeSound(){
System.out.println('咯');
}
}
因此,AnimalSound的makeSound方法需要接受Animal类而非Duck类
public class AnimalSound {
// 只接受Duck类型参数
public void makeSound(Animal animal){
animal.makeSound();
}
}
public class Test{
public static void main(String args[]){
AnimalSound animalSound=new AnimalSound();
Animal duck=new Duck();
animalSound.makeSound(duck); // 输出:嘎
Animal chicken=new Chicken();
animalSound.makeSound(chicken); // 输出:咯
}
}
很明显,Animal是抽象类。它的主要作用在于向上传型,完成多态的功能,更重要的是建立契约——这些契约行为暴露了一个类或者对象能够做什么,但是不关心具体如何去做。
继承自抽象类的具体类都会继承抽象类里的abstract方法,并且要求覆写它们。这些契约在实际编程中非常重要,可以帮助我们编写可靠性更高的代码。比如在上面代码中,各个子类都必须实现makeSound方法,才能保证在不会抛出异常。
总而言之,不关注对象的具体类型,而仅仅针对超类型中的“契约方法”来编写程序,可以产生可靠性高的程序,也可以极大地减少子系统实现之间的相互依赖关系。这就是我们本文要讨论的主题:
面向接口编程,而不是面向实现编程。
从过程上来看,“面向接口编程”其实是“面向超类型编程”。当对象的具体类型被隐藏在超类型身后时,这些对象就可以相互替换使用,我们的关注点才能从对象的类型转移到对象的行为上。
上面的代码中,我们通过引入超类Animal来解决问题。但是有个缺点在于此Animal抽象类是基于单继承的,也就是说我们不可能让Duck和Chicken再继承自另一个家禽类。如果使用interface,可以仅仅针对发出叫声这个行为来编写程序,同时一个类也可以实现多个interface。
public interface class Animal{
abstract void makeSound(); // 抽象方法
}
// 让Duck类和Chicken类都继承自抽象类Animal:
public class Duck implements Animal {
public void makeSound(){
System.out.println('嘎');
}
}
public class Chicken implements Animal {
public void makeSound(){
System.out.println('咯');
}
}
// ...
// 其它代码同上
本系列文章也多次提到,JavaScript不是一个真正有类的语言。面向接口编程在JavaScript中的最大作用就退化到了检查代码的规范性。比如检查某个对象是否实现了某个方法,或者检查是否给函数传入了预期类型的参数。如果忽略了这两点,有可能会在代码中留下一些隐藏的bug。比如我们尝试执行obj对象的show方法,但是obj对象本身却没有实现这个方法,
Uncaught TypeError:: aaa.show() is not a function.
这是很多jser都会遇到过的错误。为此我们不得不写一些防御性的代码:
aaa.show && aaa.show()
这时候你会想,如果能有静态语言的类型检查机制,我就不用在业务代码中写那么多无关的东西了。
在js中,假设你要判断一个对象是否数组,可能会这么做:
var isArray=function(obj){
return obj&&
typeof obj==='object'&&
typeof obj.length==='number'&&
typeof obj.splice==='function'
};
如果它满足数组的方法,它就可以当数组来用了。现在看一个更加"高级"的语言是如何实现的。
TypeScript是微软开发的一种编程语言,是JavaScript的一个超集。TypeScript代码最终会被编译成原生的JavaScript代码执行。通过TypeScript,我们可以使用静态语言的方式来编写JavaScript程序。用TypeScript来实现一些设计模式,显得更加原汁原味。本系列文章结束后,笔者也将对ts进行系统梳理。
假设我们正在编写一个用户界面程序,页面中有成百上千个子菜单。因为项目很复杂,我们决定让整个程序都基于命令模式来编写,即编写菜单集合界面的是某个程序员,而负责实现每个子菜单具体功能的工作交给了另外一些程序员。
那些负责实现子菜单功能的程序员,在完成自己的工作之后,会把子菜单封装成一个命令对象,然后把这个命令对象交给编写菜单集合界面的程序员。他们已经约定好,当调用子菜单对象的execute方法时,从而执行对应的子菜单命令。
// 定义抽象类
interface Command{
execute:Function;
}
class RefreshMenuBarCommand implements Command{
constructor(){}
execute(){
console.log('刷新菜单界面');
}
}
class AddSubMenuCommand implements Command{
constructor(){}
execute(){
console.log('增加二级菜单');
}
}
假设某个程序员一时疏忽,在删除二级菜单方法中漏写了execute:
class DelSubMenuCommand implement sCommand{
constructor(){}
//忘记重写execute方法
}
const delSubMenuCommand=new DelSubMenuCommand();
delSubMenuCommand.execute();
//输出:Uncaught Type Error:undefined is not a function
ts的编译器给出了报错提示。