一、引言
桥接(Bridge)模式也叫桥梁模式,简称桥模式,是一种结构型模式。该模式所解决的问题非常简单,即根据单一职责原则,在一个类中,不要做太多事,如果事情很多,尽量拆分到多个类中去,然后在一个类中包含指向另外一个类对象的指针,当需要执行另外一个类中的动作时,用指针直接去调用另外一个类的成员函数。
二、桥接模式
桥接模式是一种结构型设计模式, 可将一个大类或一系列紧密相关的类拆分为抽象和实现两个独立的层次结构, 从而能在开发时分别使用。
我们举个例子来说明: 继续前面的闯关打斗类游戏。在游戏中,不可避免地要显示各种图像,例如,人物头像、血条、人物背包、各种物品道具等,这些图像源自各种图像文件,从图像文件中把数据读出来并按照一个事先约定好的格式规范保存到一个缓冲区中以方便后续统一的显示处理。
现在的问题是图像文件有多种格式,常用的包括png、jpg、bmp等,为了把数据从这些不同格式的图像文件中读出(注意,不管文件是什么格式,读出到事先约定好的缓冲区中后都变成遵循相同规范的数据,此时这些数据不再有来自不同文件的区别)并显示,程序创建了一个叫作Image
的父类以及分别叫作Image_png
、Image_jpg
、Image_bmp
的子类,代码如下:
class Image {
public:
void draw(const string& pfilename) {
int iLen = 0;
string pData = parsefile(pfilename, iLen);
if (iLen > 0) {
cout << "显示pData所指向的缓冲区中的图像数据." << endl;
}
}
virtual ~Image() {}
private:
//根据文件名分析文件内容,每个子类因为图像文件格式不同,会有不同的读取和处理代码
virtual string parsefile(const string& pfilename, int& iLen) = 0;
};
// 处理png格式的图像文件
class Image_png : public Image {
private:
virtual string parsefile(const string& pfilename, int& iLen) override {
cout << "开始分析png文件中的数据并将分析结果放到pData中,";
iLen = 100; // 模拟长度
string data(iLen, 'x'); // 使用字符串初始化模拟数据
return data;
}
};
// 处理jpg格式的图像文件
class Image_jpg : public Image {
private:
virtual string parsefile(const string& pfilename, int& iLen) override {
cout << "开始分析jpg文件中的数据并将分析结果放到pData中,";
iLen = 150; // 模拟长度
string data(iLen, 'y'); // 使用字符串初始化模拟数据
return data;
}
};
// 处理bmp格式的图像文件
class Image_bmp : public Image {
private:
virtual string parsefile(const string& pfilename, int& iLen) override {
cout << "开始分析bmp文件中的数据并将分析结果放到pData中,";
iLen = 200; // 模拟长度
string data(iLen, 'z'); // 使用字符串初始化模拟数据
return data;
}
};
为了扩大游戏的受众并增加营收,这款游戏需要支持多个操作系统,包括 Windows、Linux 和 macOS。然而,这带来了一个问题:每个操作系统在显示图像数据时的实现代码都不同。虽然 parsefile
成员函数的实现可以与操作系统无关,但 draw
成员函数中的显示代码则需要针对不同操作系统进行调整。
因此,程序员不得不为现有的 Image
类的子类(如 Image_png
、Image_jpg
和 Image_bmp
)创建额外的子类,以适配每个操作系统。这就意味着,如果我们为每种图像格式都增加三种操作系统的适配,原本的结构会变得非常复杂。
例如,原本有 3 个子类,现在需要为每个图片类型子类再增加 3 个操作系统的子类,总共会增加到 9 个新类,加上原有的 4 个类,总共是 13 个类。如果再支持一种新的图像格式,比如 GIF,就需要增加到 17 个类。而如果再增加一个新的操作系统,比如 Android,那么类的数量会增加到 21 个。
很明显,采用继承结构来设计类在这种情况下不是一个好方法。每当我们需要支持新的图像格式或新的操作系统时,类的数量就会迅速增加,导致代码变得复杂且难以维护。
桥接模式通过将继承改为组合的方式来解决这个问题。 具体来说, 就是抽取其中一个维度并使之成为独立的类层次, 这样就可以在初始类中引用这个新层次的对象, 从而使得一个类不必拥有所有的状态和行为。
因此,我们不难发现,其实没有必要把图像文件格式和操作系统类型掺和到一起通过继承设计出一系列类(例如Image_jpg_Linux
这种类),这样设计是违反单一职责原则的,可以像下面这样做:
- 把图像文件格式单独设计成一个继承关系的类,在其中实现parsefile成员函数(因为该成员函数只与图像文件格式有关)。
- 操作系统类型也单独设计成一个继承关系的类,在其中实现draw成员函数(因为该成员函数只与操作系统类型有关)。
这样无论是扩充图像文件格式还是操作系统类型这两组类中的哪一组,都不会影响另外一组类,也就不会造成子类数量的急速增长。当然,在图像文件格式表示的类中有一个指向操作系统类型表示的类对象的指针,从而构成这两个类之间的委托关系。下面给出改造后的代码:
// 操作系统相关的接口
class ImageOS {
public:
virtual void draw(const string& data, int iLen) = 0;
virtual ~ImageOS() {}
};
// Windows 显示实现
class ImageOS_Windows : public ImageOS {
public:
void draw(const string& data, int iLen) override {
cout << "在 Windows 上显示图像数据: " << data << endl;
}
};
// Linux 显示实现
class ImageOS_Linux : public ImageOS {
public:
void draw(const string& data, int iLen) override {
cout << "在 Linux 上显示图像数据: " << data << endl;
}
};
// macOS 显示实现
class ImageOS_Mac : public ImageOS {
public:
void draw(const string& data, int iLen) override {
cout << "在 macOS 上显示图像数据: " << data << endl;
}
};
紧接着,再给一个ImageFormat
类,以及图像文件格式相关类。
// 图像格式基类
class ImageFormat {
public:
ImageFormat(unique_ptr<ImageOS> pimgos) : m_pImgOS(move(pimgos)) {}
virtual void parsefile(const string& pfilename) = 0;
virtual ~ImageFormat() {}
protected:
unique_ptr<ImageOS> m_pImgOS; // 委托
};
// 处理png格式的图像文件
class Image_png : public ImageFormat {
public:
Image_png(unique_ptr<ImageOS> pimgos) : ImageFormat(move(pimgos)) {}
void parsefile(const string& pfilename) override {
cout << "开始分析 PNG 文件: " << pfilename << endl;
//...
}
};
// 处理png格式的图像文件
class Image_jpg : public ImageFormat {
public:
Image_jpg(unique_ptr<ImageOS> pimgos) : ImageFormat(move(pimgos)) {}
void parsefile(const string& pfilename) override {
cout << "开始分析 JPG 文件: " << pfilename << endl;
//...
}
};
// 处理bmp格式的图像文件
class Image_bmp : public ImageFormat {
public:
Image_bmp(unique_ptr<ImageOS> pimgos) : ImageFormat(move(pimgos)) {}
void parsefile(const string& pfilename) override {
cout << "开始分析 BMP 文件: " << pfilename << endl;
//...
}
};
我们使用时:
unique_ptr<ImageOS> windowsOS = make_unique<ImageOS_Windows>();
unique_ptr<ImageFormat> pngImage = make_unique<Image_png>(move(windowsOS));
pngImage->parsefile("image.png"); // 解析并显示图像数据
unique_ptr<ImageOS> linuxOS = make_unique<ImageOS_Mac>();
unique_ptr<ImageFormat> pngImageLinux = make_unique<Image_png>(move(linuxOS));
pngImageLinux->parsefile("image.png"); // 解析并显示图像数据
上述内容并不难理解。
此时,如果增加一个对.gif文件格式的支持,需要增加一个Image_gif
子类(以ImageFormat
作父类),而不需要改动ImageOS
和其于类,这主要得益于ImageFormat
子类中的parsefile
成员函数得到的是一个事先约定好格规范的缓冲区数据,这些缓冲区数据已经脱离了原始的图像文件格式(png、jpg、bmp等)采用了一种统一的格式来表达,所以在执行ImageFormat
子类的parsefile
成员函数时,所遇到的m_pImgOS->draw(presult,iLen)
代码行会直接调用ImageOS子类的draw
方员函数,在这个draw
成员函数中,并不需要区分原始的图像数据来自何种格式的图像格式。
桥接模式结构
引入桥接模式的定义:将抽象部分与实现部分分离,使它们都可以独立弟变化和扩展。
- 抽象部分一般指业务功能,例如
ImageFormat
类,用于解析各种不同的图像文件格式,这就归为业务功能。 - 实现部分一般指具体的平台实现,例如
ImageOS
类,用于根据不同的操作系统来绘制图像,这就归为平台实现。
也就是说,抽象部分是图像文件格式,而实现部分是OS的类型。桥接模式定义的意思就是把这两个维度分开,每个维度可以独立的变化。
三、总结
ImageFormat
类和ImageOS
之间的关系就叫桥接。用“桥接”设计模式定义中用到的术语来说,桥接就在抽象部分与实现部分之间担当着桥梁作用,桥梁两侧的每一部分又都可以独立变化。
在桥接模式的UML图中,存在四种角色:
- 抽象部分接口(Abstraction):这个角色定义了一个抽象类的接口,并包含一个指向
Implementor
类型对象的指针。ImageFormat
类扮演了这个角色。 - 扩展抽象部分接口(RefinedAbstraction):这个角色实现了在
Abstraction
中定义的接口,并且可以调用Implementor
中定义的方法。Image_png、
Image_jpg、
Image_bmp`这些类扮演了这个角色。 - 实现部分接口(Implementor):这个角色定义了实现类的接口,这些接口可能与
Abstraction
中的接口相似,也可能完全不同。通常Implementor
提供的接口只包含基本操作,而Abstraction
中的接口则实现更复杂的功能。ImageOS
类扮演了这个角色。 - 具体实现类(ConcreteImplementor):这个角色实现了
Implementor
中定义的接口。ImageOS_Windows
、ImageOS_Linux
、ImageOS_Mac
这些类扮演了这个角色。
桥接模式用组合关系解决了传统继承关系存在的类数量爆炸式增长的问题,使用对象组合方式解决问题,使代码更灵活、更易于扩展。桥接模式的实现代码不仅体现了单一职责原则,还体现了开闭原则、组合复用原则、依赖倒置原则等。
- 桥接模式通常会于开发前期进行设计, 能够将程序的各个部分独立开来以便开发。 另一方面, 适配器模式通常在已有程序中使用, 让相互不兼容的类能很好地合作。
桥接、 状态模式和策略模式 (在某种程度上包括适配器) 模式的接口非常相似。 实际上, 它们都基于组合模式——即将工作委派给其他对象, 不过也各自解决了不同的问题。 模式并不只是以特定方式组织代码的配方, 你还可以使用它们来和其他开发者讨论模式所解决的问题。
可以将抽象工厂模式和桥接搭配使用。 如果由桥接定义的抽象只能与特定实现合作, 这一模式搭配就非常有用。 在这种情况下, 抽象工厂可以对这些关系进行封装, 并且对客户端代码隐藏其复杂性。也可以结合使用生成器模式和桥接模式: 主管类负责抽象工作, 各种不同的生成器负责实现工作。