前一阵子参与一个项目,对某产品第一个版本的代码进行了重构,原因是项目工期紧,要求第一个版本以最短的时间拿出来,导致产品在部分需求未确定的情况下草草开始,匆匆结束。项目要求有大概五六个子系统,每个子系统间必须使用socket通讯,考虑到数据安全性和具体协议的不确定性,商议新版代码通讯暂时使用tcp协议进行交互,伪代码如下:
public class Connector {
private String ip;
private int port;
public boolean connect(String strIp,int iPort)
{
System.out.printf("try to connect tcp ip:%s ,port:%d\n", strIp,iPort);
this.ip = strIp;
this.port = iPort;
return true;
}
public boolean sendData(String strData)
{
System.out.printf("try to send tcp data:%s \n", strData);
return true;
}
public String recvData()
{
System.out.printf("try to recv tcp data\n");
return "hello word";
}
}
现在我想使用这个类去接收/解析/发送数据,所以我写了一个parse类,之前在学习适配器模式的时候讲过,一般适配器适配的方法有两种,一种是在被适配的类的基础上派生,一种是在适配器类中引用被适配类的实例。这也是parse类使用Connector的两种方法,抛去引用实例的方法不说,咱们就谈谈使用第一种方法的弊端。
继承方式的parse代码如下:
public class Parser extends Connector{
public Parser()
{
connect("192.168.23.1", 8080);
}
public boolean dealCmd(String strData)
{
sendData(strData);
recvData();
//……………………
return true;
}
}
这样确实实现了目前的代码,但是多变的需求提出要支持UDP协议,我们怎么办?将Parser 的基类从Connector换成UdpConnector?那要再用tcp怎么办?并且无论修改Parser 还是修改Connector都不符合开闭原则,那么问题出在哪里了呢?
我们先看看什么叫复用,所谓的复用就是将某个功能进行封装, 使得所有要使用此功能的代码上下文都可以使用此一套代码。这样此功能的实现细节再发生改变的时候只需要修改此一处代码。代码复用的方法有“白箱复用”和“黑箱复用”两种:
白箱复用:B复用A的功能,并且B可以了解A的内部细节,使用技术是公开继承
黑箱复用:B复用A的功能,但B无法看到A的内部细节,使用技术是关联/聚合/组合
从我们的代码来看,Parser 派生自 Connector ,也就是说Parser 对Connector 通讯功能的复用是白箱复用,因为继承使得Parser 对 Connector 的细节必须了解,这影响了封装。如果基类发生了改变,那么封装类也必须跟着改变,并且继承来的功能是静态的,不能在运行时灵活的变化,这都是弊端。
这就衍生出了咱们将要说的聚组复用原则。
聚组复用原则:对已有功能的复用要尽量使用组合/聚合,尽量不要使用类继承。
也就是说,继承不是用的越多对产品扩展越有利,在功能复用的时候,要考虑好两个类之间的关系,确认B是A的一种,才能使用继承。
在上面的例子中,我们需要理清楚parse类和Connector类之间的关系,parse类虽然要复用Connector类的功能,但parse并不是Connector的一种,就如同屠夫庖丁解牛,屠夫用刀切肉,你能说屠夫就是一种刀吗?不能,刀是水果刀、菜刀、切肉刀、金丝大环刀,而屠夫只是再使用刀,拥有这个刀。parse和Connector的关系这在oop中属于从属关系,即has-a,我们来看下面的名词:
Is-a:
是a:A Is B:A是B(继承关系,继承)。
has-a:
有a:A has B:A有B(从属关系,聚合)。
like-a:
像a:A like B:A像B(组合关系,接口)。
关于Is-a、has-a、like-a的使用场景:
如果A,B是Is-a关系,那么应该使用继承,例:玻璃杯、塑料杯都是杯子。
如果A,B是has-a关系,那么应该是用聚合,例:汽车由发动机,底盘,车身,电气设备等组成,那么应该把发动机,底盘这些类聚合成汽车。
如果A,B是like-a关系,那么应该使用组合,例:空调继承于制冷机,但它同时有加热功能,那么你应该把让空调继承制冷机类,并实现加热接口。
综上,我们来看看我们的程序需要如何修改,我们先确认parse是Commector是聚合关系,又有依赖倒置原则,我们可以对Connector进行抽象,让parse关联此抽象,UML如下:
代码如下:
public abstract class IConnector {
protected String ip;
protected int port;
abstract boolean connect(String strIp,int iPort);
abstract boolean sendData(String strData);
abstract String recvData();
}
public class TcpConnector extends IConnector{
@Override
boolean connect(String strIp, int iPort) {
// TODO Auto-generated method stub
System.out.printf("try to connect tcp ip:%s ,port:%d\n", strIp,iPort);
super.ip = strIp;
super.port = iPort;
return true;
}
@Override
boolean sendData(String strData) {
// TODO Auto-generated method stub
System.out.printf("try to send tcp data:%s \n", strData);
return true;
}
@Override
String recvData() {
// TODO Auto-generated method stub
System.out.printf("try to recv tcp data\n");
return "hello word";
}
}
public class Parser{
private IConnector conn;
public Parser(){}
public boolean setConnecotr(IConnector con)
{
conn = con;
conn.connect("192.168.1.111",234);
return true;
}
public boolean dealCmd(String strData)
{
this.conn.sendData(strData);
this.conn.recvData();
//……………………
return true;
}
}
public class Client {
public static void main(String[] args) {
Parser pp = new Parser();
IConnector conn = new TcpConnector();
pp.setConnecotr(conn);
pp.dealCmd("hello service!");
}
}
如果需要使用udp进行数据传输,那么直接实现一个基于Iconnector的UdpConnector类,同时修改Client一行代码即可。这就不会影响parse,符合开闭原则。
下面补充说一下聚组复用/继承复用的优缺点:
合成/聚合复用:
(1).优点:
新对象存取成分对象的唯一方法是通过成分对象的接口; 这种复用是黑箱复用,因为成分对象的内部细节是新对象所看不见的;这种复用支持包装;这种复用所需的依赖较少; 每一个新的类可以将焦点集中在一个任务上; 这种复用可以在运行时动态进行,新对象可以使用合成/聚合关系将新的责任委派到合适的对象。
(2).缺点:
通过这种方式复用建造的系统会有较多的对象需要管理。
继承复用:
(1).优点:
新的实现较为容易,因为基类的大部分功能可以通过继承关系自动进入派生类;修改或扩展继承而来的实现较为容易。
(2).缺点:
继承复用破坏包装,因为继承将基类的实现细节暴露给派生类,这种复用也称为白箱复用; 如果基类的实现发生改变,那么派生类的实现也不得不发生改变;从基类继承而来的实现是静态的,不可能在运行时发生改变,不够灵活。