16.3 模 式 讲 解
16.3.1 认识模板方法模式
1.模板方法模式的功能
模板方法模式的功能在于固定算法骨架,而让具体算法实现可扩展。
这在实际应用中非常广泛,尤其是在设计框架级功能的时候非常有用。框架定义好了算法的步骤,在合适的点让开发人员进行扩展,实现具体的算法。比如在DAO实现中设计通用的增删改查功能,这个在后面会给大家示例。
模板方法模式还额外提供了一个好处,就是可以控制子类的扩展。因为在父类中定义好了算法的步骤,只是在某几个固定的点才会调用到被子类实现的方法,因此也就只允许在这几个点来扩展功能。这些可以被子类覆盖以扩展功能的方法通常被称为“钩子”方法,在后面也会给大家示例。
2.为何不是接口
有的朋友可能会问一个问题,不是说在Java中应该尽量面向接口编程吗,为何模板方法的模板采用的是抽象方法呢?要回答这个问题,首先搞清楚抽象类和接口的关系:
■ 接口是一种特殊的抽象类,所有接口中的属性自动是常量,也就是public final static的,而所有接口中的方法必须是抽象的。
■ 抽象类,简单点说是用abstract修饰的类。这里要特别注意的是抽象类和抽象方法的关系,记住两句话:抽象类不一定包含抽象方法;有抽象方法的类一定是抽象类。
■ 抽象类和接口相比较,最大的特点就在于抽象类中是可以有具体的实现方法的,而接口中所有的方法都是没有具体的实现的。
延伸
因此,虽然Java编程中倡导大家“面向接口编程”,并不是说就不再使用抽象类了。那么什么时候使用抽象类呢?
通常在“既要约束子类的行为,又要为子类提供公共功能”的时候使用抽象类。
按照这个原则来思考模板方法模式的实现,模板方法模式需要固定定义算法的骨架,这个骨架应该只有一份,算是一个公共的行为,但其中具体的步骤的实现又可能是各不相同的,恰好符合选择抽象类的原则。
把模板实现成为抽象类,为所有的子类提供了公共的功能,就是定义了具体的算法骨架;同时在模板中把需要由子类扩展的具体步骤的算法定义成为抽象方法,要求子类去实现这些方法,这就约束了子类的行为。
因此综合考虑,用抽象类来实现模板是一个很好的选择。
3.变与不变
程序设计的一个很重要的思考点就是“变与不变”,也就是分析程序中哪些功能是可变的,哪些功能是不变的,然后把不变的部分抽象出来,进行公共的实现,把变化的部分分离出去,用接口来封装隔离,或者是用抽象类来约束子类行为。
模板方法模式很好地体现了这一点。模板类实现的就是不变的方法和算法的骨架,而需要变化的地方,都通过抽象方法,把具体实现延迟到子类中了,而且还通过父类的定义来约束了子类的行为,从而使系统能有更好的复用性和扩展性。
4.好莱坞法则
什么是好莱坞法则呢?简单点说,就是“不要找我们,我们会联系你”。
模板方法模式很好地体现了这一点,作为父类的模板会在需要的时候,调用子类相应的方法,也就是由父类来找子类,而不是让子类来找父类。
这其实也是一种反向的控制结构。按照通常的思路,是子类找父类才对,也就是应该是子类来调用父类的方法,因为父类根本就不知道子类,而子类是知道父类的,但是在模板方法模式里面,是父类来找子类,所以是一种反向的控制结构。
那么,在Java里面能实现这样功能的理论依据在哪里呢?
理论依据就在于Java的动态绑定采用的是“后期绑定”技术,对于出现子类覆盖父类方法的情况,在编译时是看数据类型,运行时则看实际的对象类型(new操作符后跟的构造方法是哪个类的)。一句话:new谁就调用谁的方法。
因此在使用模板方法模式的时候,虽然用的数据类型是模板类型,但是在创建类实例的时候是创建的具体的子类的实例,在调用的时候,会被动态绑定到子类的方法上,从而实现反向控制。其实在写父类的时候,它调用的方法是父类自己的抽象方法,只是在运行的时候被动态绑定到了子类的方法上。
5.扩展登录控制
在使用模板方法模式实现以后,如果想要扩展新的功能,有以下几种情况。
一种情况是只需要提供新的子类实现就可以了。比如想要切换不同的加密算法,现在使用的是MD5,如果想要实现使用3DES的加密算法,那就新做一个子类,然后覆盖实现父类加密的方法,在里面使用3DES来实现即可,已有的实现不需要做任何变化。
另外一种情况是想要给两个登录模块都扩展同一个功能,这种情况多属于需要修改模板方法的算法骨架的情况,应该尽量避免,但是万一前面没有考虑周全,后来出现了这种情况,怎么办呢?最好就是重构,也就是考虑修改算法骨架,尽量不要去找其他的替代方式,替代的方式也许能把功能实现了,但是会破坏整个程序的结构。
还有一种情况是既需要加入新的功能,也需要新的数据。比如,现在对于普通人员登录,要实现一个加强版,要求登录人员除了编号和密码外,还需要提供注册时留下的验证问题和验证答案,验证问题和验证答案是记录在数据库中的,不是验证码,一般Web开发中登录使用的验证码会放到session中,这里不去讨论它。
假如现在就要进行如此的扩展,应该怎样实现呢?由于需要一些其他的数据,那么就需要扩展LoginModel,加入自己需要的数据;同时可能需要覆盖由父类提供的一些公共的方法,来实现新的功能。
还是看看代码示例吧,会比较清楚。
首先呢,需要扩展LoginModel,把具体功能需要的数据封装起来。只是增加父类没有的数据就可以了。示例代码如下:
其次,就是提供新的登录模块控制实现。示例代码如下:
看看这个时候的测试。示例代码如下:
运行看看,能实现功能吗?好好测试体会一下,看看是如何扩展功能的。
16.3.2 模板的写法
在实现模板的时候,到底哪些方法实现在模板上呢?模板能不能全部实现了,也就是模板不提供抽象方法呢?当然,就算没有抽象方法,模板一样可以定义成为抽象类。
通常在模板里面包含以下操作类型。
■ 模板方法:就是定义算法骨架的方法。
■ 具体的操作:在模板中直接实现某些步骤的方法。通常这些步骤的实现算法是固定的,而且是不怎么变化的,因此可以将其当作公共功能实现在模板中。如果不需为子类提供访问这些方法的话,还可以是private的。这样一来,子类的实现就相对简单些。如果是子类需要访问,可以把这些方法定义为protected final的,因为通常情况下,这些实现不能够被子类覆盖和改变了。
■ 具体的AbstractClass操作:在模板中实现某些公共功能,可以提供给子类使用,一般不是具体的算法步骤的实现,而是一些辅助的公共功能。
■ 原语操作:就是在模板中定义的抽象操作,通常是模板方法需要调用的操作,是必须的操作,而且在父类中还没有办法确定下来如何实现,需要子类来真正实现的方法。
■ 钩子操作:在模板中定义,并提供默认实现的操作。这些方法通常被视为可扩展的点,但不是必须的,子类可以有选择地覆盖这些方法,以提供新的实现来扩展功能。比如,模板方法中定义了5步操作,但是根据需要,某种具体的实现只需要其中的1、2、3几个步骤,因此它就只需要覆盖实现1、2、3这几个步骤对应的方法。那么4和5步骤对应的方法怎么办呢,由于有默认实现,那就不用管了。也就是说钩子操作是可以被扩展的点,但不是必须的。
■ FactoryMethod:在模板方法中,如果需要得到某些对象实例的话,可以考虑通过工厂方法模式来获取,把具体的构建对象的实现延迟到子类中去。
总结起来,一个较为完整的模板定义示例,其示例代码如下:
对于上面示例的模板写法,其中定义成为protected的方法,可以根据需要进行调整,如果是允许所有的类都可以访问这些方法,那么可以把它们定义成为public的,如果只是子类需要访问这些方法,那就使用protected的,都是正确的写法。
16.3.3 Java回调与模板方法模式
模板方法模式的一个目的,就在于让其他类来扩展或具体实现在模板中固定的算法骨架中的某些算法步骤。在标准的模板方法模式实现中,主要是使用继承的方式,来让父类在运行期间可以调用到子类的方法。
其实在Java开发中,还有另外一个方法可以实现同样的功能或是效果,那就是——Java回调技术,通过回调在接口中定义的方法,调用到具体的实现类中的方法,其本质同样是利用Java的动态绑定技术。在这种实现中,可以不把实现类写成单独的类,而是使用匿名内部类来实现回调方法。
应用Java回调来实现模板方法模式,在实际开发中使用的也非常多,也算是模板方法模式的一种变形实现吧。
还是来示例一下,这样会更清楚。为了大家更好地对比理解,把前面用标准模板方法模式实现的例子,采用Java回调来实现一下。
(1)先定义一个模板方法需要的回调接口。
在这个接口中需要把所有可以被扩展的方法都要定义出来。实现的时候,可以不扩展,直接转调模板中的默认实现,但是不能不定义出来,因为是接口,不定义出来,对于想要扩展这些功能的地方就没有办法了。示例代码如下:
(2)这里使用的LoginModel跟以前相比没有任何变化,就不再赘述。
(3)下面来定义登录控制的模板。它的变化相对较多,大致有以下一些。
■ 不再是抽象的类了,所有的抽象方法都删除了。
■ 对模板方法,就是login的那个方法,添加一个参数,传入回调接口。
■ 在模板方法实现中,除了在模板中固定的实现外,所有可以被扩展的方法,都应该通过回调接口进行调用。
示例代码如下:
(4)由于是直接在调用的地方传入回调的实现,通常可以通过匿名内部类的方式来实现回调接口,当然实现成为具体类也是可以的。如果采用匿名内部类的方式来使用模板,那么就不需要原来的NormalLogin和WorkerLogin了。
(5)写个客户端来测试看看。客户端需要使用匿名内部类来实现回调接口,并实现其中想要扩展的方法。示例代码如下:
运行一下,看看效果是不是跟前面采用继承的方式实现的结果是一样的,然后好好比较一下这两种实现方式。
(6)简单小结一下对于模板方法模式的这两种实现方式:
■ 使用继承的方式,抽象方法和具体实现的关系是在编译期间静态决定的,是类级的关系;使用Java回调,这个关系是在运行期间动态决定的,是对象级的关系。
■ 相对而言,使用回调机制会更灵活,因为Java是单继承的,如果使用继承的方式,对于子类而言,今后就不能继承其他对象了,而使用回调,是基于接口的。
■ 相对而言,使用继承方式会更简单点,因为父类提供了实现的方法,子类如果不想扩展,那就不用管。如果使用回调机制,回调的接口需要把所有可能被扩展的方法都定义进去,这就导致实现的时候,不管你要不要扩展,都要实现这个方法,哪怕你什么都不做,只是转调模板中已有的实现,都要写出来。
延伸
从另一方面说,回调机制是通过委托的方式来组合功能,它的耦合强度要比继承低一些,这会给我们更多的灵活性。比如某些模板实现的方法,在回调实现的时候可以不调用模板中的方法,而是调用其他实现中的某些功能,也就是说功能不再局限在模板和回调实现上了,可以更灵活地组织功能。
事实上,在前面讲命令模式的时候也提到了Java回调,还通过退化命令模式来实现了Java回调的功能。所以也有这样的说法:命令模式可以作为模板方法模式的一种替代实现,那就是因为可以使用Java回调来实现模板方法模式。
16.3.4 典型应用:排序
模板方法模式的一个非常典型的应用,就是实现排序的功能。至于有些朋友认为排序是策略模式的体现,这很值得商榷。先来看看在Java中排序功能的实现,然后再来说明为什么排序的实现主要体现了模板方法模式,而非策略模式。
在java.util包中,有一个Collections类,它里面实现了对列表排序的功能,提供了一个静态的sort方法,接受一个列表和一个Comparator接口的实例,这个方法实现的大致步骤如下。
(1)先把列表转换成为对象数组。
(2)通过Arrays的sort方法来对数组进行排序,传入Comparator接口的实例。
(3)然后再把排好序的数组的数据设置回到原来的列表对象中去。
这其中的算法步骤是固定的,也就是算法骨架是固定的了,只是其中具体比较数据大小的步骤,需要由外部来提供,也即是传入的Comparator接口的实例,就是用来实现数据比较的,在算法内部会通过这个接口来回调具体的实现。
如果Comparator接口的compare()方法返回一个小于0的数,表示被比较的两个对象中,前面的对象小于后面的对象;如果返回一个等于0的数,表示被比较的两个对象相等;如果返回一个大于0的数,表示被比较的两个对象中,前面的对象大于后面的对象。
下面一起看看使用Collections来对列表进行排序的例子。假如现在要实现对一个拥有多个用户数据模型的列表进行排序。
(1)先来定义出封装用户数据的对象模型。示例代码如下:
(2)直接使用Collections来排序。写个客户端来测试一下。示例代码如下:
运行结果如下所示:
(3)小结。
看了上面的示例,你会发现,究竟列表会按照什么标准来排序,完全是依靠Comparator的具体实现,上面实现的是按照年龄的升序排列,你也可以尝试修改这个排序的比较器,那么得到的结果就会不一样了。
也就是说,排序的算法是已经固定了的,只是进行排序比较的这一个步骤,由外部来实现。我们可以通过修改这个步骤的实现,从而实现不同的排序方式。因此从排序比较这个功能来看,是策略模式的体现。
注意
但是请注意一点,你只是修改了排序的比较方式,并不是修改了整个排序的算法,事实上,现在Collections的sort()方法使用的是合并排序的算法,无论你怎样修改比较器的实现,sort()方法实现的算法是不会改变的,不可能变成冒泡排序或是其他的排序算法。
(4)排序,到底是模板方法模式的实例,还是策略模式的实例,到底哪个说法更合适?
认为是策略模式的实例的理由:
■ 上面的排序实现,并没有像标准的模板方法模式那样,使用子类来扩展父类,至少从表面上看不太像模板方法模式;
■ 排序使用的Comparator的实例,可以看成是不同的算法实现,在具体排序时,会选择使用不同的Comparator实现,就相当于是在切换算法的实现。
因此认为排序是策略模式的实例。
认为是模板方法模式的实例的理由:
■ 首模板方法模式的本质是固定算法骨架,虽然使用继承是标准的实现方式,但是通过回调来实现,也不能说这就不是模板方法模式;
■ 从整体程序上看,排序的算法并没有改变,不过是某些步骤的实现发生了变化,也就是说通过Comparator来切换的是不同的比较大小的实现,相对于整个排序算法而言,它不过是其中的一个步骤而已。
因此认为是模板方法模式的实例。
总结语:
排序的实现,实际上组合使用了模板方法模式和策略模式,从整体来看是模板方法模式,但到了局部,比如排序比较算法的实现上,就使用的是策略模式了。
至于排序具体属于谁的实例,这或许是个仁者见仁、智者见智的事情,我们倾向于说:排序是模板方法模式的实例。毕竟设计模式的东西,要从整体上、设计上、本质上去看待问题,而不能从表面上或者是局部来看待问题。
16.3.5 实现通用的增删改查
对于实现通用的增删改查的功能,基本上是每个做企业级应用系统的公司都有的功能,实现的方式也是多种多样的,一种很常见的设计就是泛型加上模板方法模式,再加上使用Java回调技术,尤其是在使用Spring和Hibernate等流行框架的应用系统中更是常见。
注意
为了突出主题,以免分散大家的注意力,我们不去使用Spring和Hibernate这样的流行框架,也不去使用泛型,只用模板方法模式来实现一个简单的、用JDBC实现的通用增删改查的功能。
先在数据库中定义一个演示用的表,演示用的是Oracle数据库。其实你可以用任意的数据库,只是数据类型要做相应的调整。简单的数据字典如下:表名是tbl_user。
(1)定义相应的数据对象来描述数据。示例代码如下:
(2)定义一个用于封装通用查询数据的查询用的数据模型。由于这个查询数据模型和上面定义的数据模型有很大一部分是相同的,因此让这个查询模型继承上面的数据模型,然后添加上多出来的查询条件。示例代码如下:
(3)为了让大家能更好的理解这个通用的实现,先不去使用模板方法模式,直接使用JDBC来实现增删改查的功能。
所有的方法都需要和数据库进行连接,因此先把和数据库连接的公共方法定义出来。没有使用连接池,用最简单的JDBC自己连接,示例代码如下:
使用纯JDBC来实现新增的功能。示例代码如下:
修改和删除的功能和新增功能差不多,只是sql不同,还有设置sql中变量值不同,这里就不去写了。
接下来看看查询方面的功能。查询方面只做一个通用的查询实现,其他查询的实现基本上也差不多。示例代码如下:
(4)基本的JDBC实现写完了,该来看看如何把模板方法模式用上了。模板方法是要定义算法的骨架,而具体步骤的实现还是由子类来完成,因此把固定的算法骨架抽取出来,就成了使用模板方法模式的重点了。
首先来观察新增、修改、删除的功能,发现哪些是固定的,哪些是变化的呢?分析发现变化的只有sql语句,还有为sql中的“?”设置值的语句,真正执行sql的过程是差不多的,是不变化的。
再来观察查询的方法,查询的过程是固定的。变化的除了有sql语句、为sql中的“?”设置值的语句之外,还多了一个如何把查询回来的结果集转换成对象集的实现。
好了,找到变与不变之处,就可以来设计模板了。先定义出增删改查各自的实现步骤来,也就是定义好各自的算法骨架,然后把变化的部分定义成为原语操作或钩子操作,如果一定要子类实现的那就定义成为原语操作;在模板中提供默认实现,且不强制子类实现的功能定义成为钩子操作就可以了。
另外,来回需要传递数据,由于是通用的方法,就不能用具体的类型了,又不考虑泛型,那么就定义成Object类型好了。
根据上面的思路,一个简单的、能实现对数据进行增删改查的模板就可以实现出来了。完整的示例代码如下:
(5)简单又可以通用的JDBC模板做好了,下面看看如何使用这个模板来实现具体的增删改查功能。示例代码如下:
看到这里,可能有些朋友会想,为何不把准备sql的方法为sql中“?”赋值的方法,还有结果集映射成为对象的方法也做成公共的呢?
注意
其实这些方法是可以考虑做成公共的,用反射机制就可以实现,但是这里为了突出模板方法模式的使用,以免加的东西太多,把大家搞迷惑了。事实上,用模板方法加上泛型再加上反射的技术,就可以实现可重用的,使用模板时几乎不用再写代码的数据层实现,这里就不去展开了。
(6)享受的时刻到了,来写个客户端,使用UserJDBC的实现。示例代码如下:
运行一下,看看结果,看看数据库的值,再好好体会一下是如何实现的。
16.3.6 模板方法模式的优缺点
■ 模板方法模式的优点是实现代码复用。
模板方法模式是一种实现代码复用的很好的手段。通过把子类的公共功能提炼和抽取,把公共部分放到模板中去实现。
■ 模板方法模式的缺点是算法骨架不容易升级。
模板方法模式最基本的功能就是通过模板的制定,把算法骨架完全固定下来。事实上模板和子类是非常耦合的,如果要对模板中的算法骨架进行变更,可能就会要求所有相关的子类进行相应的变化。所以抽取算法骨架的时候要特别小心,尽量确保是不会变化的部分才放到模板中。
16.3.7 思考模板方法模式
1.模板方法模式的本质
模板方法模式的本质:固定算法骨架。
模板方法模式主要是通过制定模板,把算法步骤固定下来,至于谁来实现,模板可以自己提供实现,也可以由子类去实现,还可以通过回调机制让其他类来实现。
通过固定算法骨架来约束子类的行为,并在特定的扩展点来让子类进行功能扩展,从而让程序既有很好的复用性,又有较好的扩展性。
2.对设计原则的体现
模板方法很好地体现了开闭原则和里氏替换原则。
首先从设计上分离变与不变,然后把不变的部分抽取出来,定义到父类中,比如算法骨架,一些公共的、固定的实现等。这些不变的部分被封闭起来,尽量不去修改它们。要想扩展新的功能,那就使用子类来扩展,通过子类来实现可变化的步骤,对于这种新增功能的做法是开放的。
其次,能够实现统一的算法骨架,通过切换不同的具体实现来切换不同的功能,一个根本原因就是里氏替换原则,遵循这个原则,保证所有的子类实现的是同一个算法模板,并能在使用模板的地方,根据需要切换不同的具体实现。
3.何时选用模板方法模式
建议在以下情况中选用模板方法模式。
■ 需要固定定义算法骨架,实现一个算法的不变的部分,并把可变的行为留给子类来实现的情况。
■ 各个子类中具有公共行为,应该抽取出来,集中在一个公共类中去实现,从而避免代码重复。
■ 需要控制子类扩展的情况。模板方法模式会在特定的点来调用子类的方法,这样只允许在这些点进行扩展。
16.3.8 相关模式
■ 模板方法模式和工厂方法模式
这两个模式可以配合使用。
模板方法模式可以通过工厂方法来获取需要调用的对象。
■ 模板方法模式和策略模式
这两个模式的功能有些相似,但是是有区别的。
从表面上看,两个模式都能实现算法的封装,但是模板方法封装的是算法的骨架,这个算法骨架是不变的,变化的是算法中某些步骤的具体实现;而策略模式是把某个步骤的具体实现算法封装起来,所有封装的算法对象是等价的,可以相互替换。
因此,可以在模板方法中使用策略模式,就是把那些变化的算法步骤通过使用策略模式来实现,但是具体选取哪个策略还是要由外部来确定,而整体的算法步骤,也就是算法骨架则由模板方法来定义了。