技术图文:03 结构型设计模式(上)
结构型设计模式(上)
本教程主要介绍一系列用于如何将现有类或对象组合在一起形成更加强大结构的经验总结。
知识结构:
享元模式 -- 实现对象的复用
Sunny 软件公司欲开发一个围棋软件,其界面效果如下图所示:
图2 围棋软件界面效果图
Sunny 软件公司开发人员通过对围棋软件进行分析,发现在围棋棋盘中包含大量的黑子和白子,它们的形状、大小都一模一样,只是出现的位置不同而已。
如果将每一个棋子都作为一个独立的对象存储在内存中,将导致围棋软件在运行时所需内存空间较大,如何降低运行代价、提高系统性能是 Sunny 公司开发人员需要解决的一个问题。
为了节约存储空间,提高系统性能,Sunny 公司开发人员使用共享技术来设计围棋软件中的棋子,其基本结构如下图所示:
图3 围棋棋子结构图
Chessman
充当抽象享元类,WhiteChessman
和BlackChessman
是具体享元类,ChessmanFactory
是享元工厂类。
在实现该类时,使用了 单列模式 和 简单工厂模式,确保了工厂对象的唯一性,并提供工厂方法来向客户端返回共享对象。
将棋子的位置Coordinates
定义为棋子的一个外部状态,在需要时再进行设置,这样即解决了黑子和白子的共享,又解决了显示不同位置的问题。
完整代码如下:
(1)棋子位置类(坐标类):
public class Coordinates
{
public int X { get; set; }
public int Y { get; set; }
public Coordinates(int x, int y)
{
X = x;
Y = y;
}
public override string ToString()
{
return X + "," + Y;
}
}
(2)棋子类:
//棋子抽象类
public abstract class Chessman
{
public abstract string Color { get; }
public void Display(Coordinates coord)
{
Console.WriteLine("棋子颜色:{0},位置:{1}", Color, coord);
}
}
//白色棋子实体类
public class WhiteChessman : Chessman
{
public override string Color
{
get { return "白色"; }
}
}
//黑色棋子实体类
public class BlackChessman : Chessman
{
public override string Color
{
get { return "黑色"; }
}
}
(3)围棋棋子工厂类:
public class ChessmanFactory
{
private static readonly ChessmanFactory Instance = new ChessmanFactory();
private static Hashtable _ht; //使用HashTable来存储共享对象
private ChessmanFactory()
{
_ht = new Hashtable();
Chessman black = new BlackChessman();
Chessman white = new WhiteChessman();
_ht.Add("b", black);
_ht.Add("w", white);
}
//返回共享工厂的唯一实例
public static ChessmanFactory GetInstance()
{
return Instance;
}
//通过key获取存储在HashTable中的共享对象
public Chessman GetChessman(string color)
{
return _ht[color] as Chessman;
}
}
(4)客户端:
static void Main(string[] args)
{
ChessmanFactory factory = ChessmanFactory.GetInstance();
Chessman black1 = factory.GetChessman("b");
Chessman black2 = factory.GetChessman("b");
Chessman black3 = factory.GetChessman("b");
Console.WriteLine("判断两颗黑子是否相同:{0}", object.ReferenceEquals(black1, black2));
Chessman white1 = factory.GetChessman("w");
Chessman white2 = factory.GetChessman("w");
Console.WriteLine("判断两颗白子是否相同:{0}", object.ReferenceEquals(white1, white2));
black1.Display(new Coordinates(1, 2));
black2.Display(new Coordinates(3, 4));
black3.Display(new Coordinates(1, 3));
white1.Display(new Coordinates(2, 5));
white2.Display(new Coordinates(2, 4));
}
输出结果如下图所示:
图4 运行结果
从输出结果可以看出,虽然我们获取了三个黑子对象和两个白子对象,但是它们的内存地址相同,也就是说,它们实际上是同一个对象。
在每次调用display()
方法时,由于设置了不同的外部状态,所以显示在棋盘的不同位置。
若一个软件系统在运行时产生的对象数量太多,将导致运行代价过高,带来系统性能下降等问题。为了避免系统中出现大量相同或相似的对象,同时又不影响客户端程序通过面向对象的方式对这些对象进行操作,享元模式 因此诞生。
享元模式(Flyweight Pattern):通过共享技术实现相同或相似对象的重用,存储这些对象的地方称为享元池(Flyweight Pool)。
享元模式 以共享的方式高效地支持大量细粒度对象的重用,享元对象能做到共享的关键是区分了内部状态(Intrinsic State)和外部状态(Extrinsic State)。
内部状态是存储在享元对象内部并且不会随着环境改变而改变的状态,内部状态可以共享。 外部状态是随环境改变而改变的、不可以共享的状态。通常由客户端保存,并在创建享元对象之后,需要使用时传入到享元对象内部。每个外部状态之间都是相互独立的。
我们可以将具有相同内部状态的对象存储在享元池中,享元池中的对象是可以共享的,需要的时候就将对象从享元池中取出,实现对象的复用。通过向取出对象注入不同的外部状态,可以得到一系列相似的对象,而这些对象在内存中实际上只存储一份。
享元模式 结构较为复杂,一般与 简单工厂模式 一起使用,其结构如下图所示:
图5 享元模式类图
Flyweight
(抽象享元类):通常是一个接口或抽象类,在抽象享元类中要将内部状态和外部状态分开处理,通常将内部状态作为享元类的属性,而外部状态通过注入的方式添加到享元类中。ConcreteFlyweight
(实体享元类):它实现/继承了抽象享元类,其实例称为享元对象。UnsharedConcreteFlyweight
(非共享实体享元类):并不是所有的抽象享元类的子类都需要被共享,不能被共享的子类可设计为非共享实体享元类,当需要一个非共享实体享元类的对象时可以直接通过实例化创建。FlyweightFactory
(享元工厂类):用于创建并管理享元对象,它针对抽象享元类编程,将各种类型的实体享元对象存储在一个享元池中,享元池一般设计为一个存储“键值对”的集合,可以结合 简单工厂模式 进行设计。当用户请求一个实体享元对象时,享元工厂提供一个存储在享元池中已经创建的实例或者创建一个新的实例(如果不存在的话),返回新创建的实例并将其存储在享元池中。在一个系统中,通常只有唯一一个享元工厂,因此可以使用 单例模式 进行享元工厂类的设计。
注意:享元模式 需要维护一个记录了系统已有的所有享元对象的列表,而这本身需要耗费资源。另外,为了使享元对象可以共享,需要将一些状态外部化,使得程序逻辑复杂化。因此,应当在有足够多的实例对象可供共享时才值得使用享元模式。
享元模式与字符串
在 C# 语言中,如果每次执行类似string str1 = “abcd”
的操作时都创建一个新的字符串对象将导致内存开销很大,因此如果第一次创建了内容为“abcd”的字符串对象str1
,下一次再创建相同的字符串对象str2
时会将它的引用指向str1
,不会重新分配内存空间,从而实现了“abcd”在内存中的共享。
见以下程序代码:
class Program
{
static void Main(string[] args)
{
string str1 = "abcd";
string str2 = "abcd";
string str3 = "ab" + "cd";
string str4 = "ab";
str4 += "cd";
Console.WriteLine(object.ReferenceEquals(str1, str2));
Console.WriteLine(object.ReferenceEquals(str1, str3));
Console.WriteLine(object.ReferenceEquals(str1, str4));
Console.WriteLine(str1 == str4);
Console.WriteLine(str1.Equals(str4));
str2 += "e";
Console.WriteLine(object.ReferenceEquals(str1, str2));
}
}
图6 程序运行结果
前两个输出语句均为 True
,说明str1
,str2
,str3
在内存中引用了相同的对象。字符串 str4
的初值为“ab”,再对它进行操作str4 += “cd”
,此时虽然str4
的内容与str1
相同,但是由于str4
的初始值不同,在创建str4
时重新分配了内存,所以第三个输出语句结果为false
。最后一个输出语句也为 false
,说明当对str2
进行修改时将创建一个新的对象,修改工作在新对象上完成,而原来引用的对象并没有发生任何改变,str1
仍然引用原有对象,而str2
引用新对象,str1
与str2
引用了两个完全不同的对象(Copy On Write)。
注:string
类型是一个特殊的引用类型,它的判断不同于其它引用类型去比较对象引用是否指向堆中同一实例,而是和值类型判断一致,比较对象内容是否一一相等。
外观模式 -- 为外部调用提供统一入口
Sunny 软件公司欲开发一个可应用于多个系统的文件加密模块,该模块可以对文件中的数据进行加密并将加密之后的数据存储在一个新文件中,具体的流程包括三个部分,分别是读取源文件、加密、保存加密之后的文件,其中,读取文件和保存文件使用流来实现,加密操作通过求模运算实现。
这三个操作相对独立,为了实现代码的独立重用,让设计更符合 单一职责原则,这三个操作的业务代码封装在三个不同的类中。
通过分析,得到软件结构如下图所示:
图7 文件加密模块结构图
实现代码如下:
(1)FileReader
:充当文件读取的子系统类。
internal class FileReader
{
public string Read(string fileNameSrc)
{
StringBuilder sb = new StringBuilder();
try
{
FileStream fs = new FileStream(fileNameSrc, FileMode.Open);
int data;
while ((data = fs.ReadByte()) != -1)
{
sb.Append((char) data);
}
fs.Close();
}
catch (Exception e)
{
Console.WriteLine("读取文件错误:" + e.Message);
return null;
}
Console.WriteLine("读取文件,获取明文:" + sb);
return sb.ToString();
}
}
(2)FileWriter
:充当文件保存的子系统类。
internal class FileWriter
{
public void Write(string encryptStr, string fileNameDes)
{
try
{
FileStream fs = new FileStream(fileNameDes, FileMode.Create);
byte[] str = Encoding.Default.GetBytes(encryptStr);
fs.Write(str, 0, str.Length);
fs.Flush();
fs.Close();
}
catch (Exception e)
{
string info = "保存文件错误:" + e.Message;
Console.WriteLine(info);
return;
}
Console.WriteLine("保存密文,写入文件。");
}
}
(3)CipherMachine
:充当数据加密的子系统类。
internal class CipherMachine
{
public string Encrypt(string plainText)
{
Console.Write("数据加密,将明文转换为密文:");
StringBuilder sb = new StringBuilder();
char[] chars = plainText.ToCharArray();
foreach (char ch in chars)
{
sb.Append((ch%7));
}
Console.WriteLine(sb);
return sb.ToString();
}
}
(4)EncryptFacade
:充当加密外观类,为外部调用提供统一入口。
public class EncryptFacade
{
private readonly FileReader _reader;
private readonly CipherMachine _cipher;
private readonly FileWriter _writer;
public EncryptFacade()
{
_reader = new FileReader();
_cipher = new CipherMachine();
_writer = new FileWriter();
}
public void FileEncrypt(string fileNameSrc, string fileNameDes)
{
string plainStr = _reader.Read(fileNameSrc);
if (string.IsNullOrEmpty(plainStr))
{
return;
}
string encryptStr = _cipher.Encrypt(plainStr);
_writer.Write(encryptStr, fileNameDes);
}
}
(5)客户端代码:
class Program
{
static void Main(string[] args)
{
EncryptFacade ef = new EncryptFacade();
ef.FileEncrypt("src.txt", "des.txt");
}
}
输出结果:
图8 输出结果
在本案例中,对文件 src.txt 中的数据进行加密,该文件内容为 “Hello world!”,加密之后将密文保存到另一个文件 des.txt 中,程序运行后保存在文件中的密文为“233364062325”。
在软件开发中,有时候为了完成一项较为复杂的功能,一个客户类需要和多个业务类交互,由于涉及到的类比较多,导致使用时代码较为复杂,此时,特别需要一个角色,由它来负责和多个业务类进行交互,而客户类只需与该类交互。
外观模式 通过引入一个外观类(Facade)来充当这个角色,为多个业务类的调用提供统一的入口,简化了类与类之间的交互。在外观模式中,那些需要交互的业务类被称为子系统(Subsystem)。
如果没有外观类,那么每个客户类需要和多个子系统之间进行复杂的交互,系统的耦合度将很大,如图 9(A)所示;而引入外观类之后,客户类只需要直接与外观类交互,客户类与子系统之间原有的复杂关系由外观类来实现,从而降低了系统的耦合度,如图 9(B)所示。
图9 外观模式示意图
外观模式 并不给系统增加任何新功能,它仅仅是简化调用接口。
外观模式(Facade Pattern):又称为门面模式,为子系统中的一组接口提供一个统一的入口。
外观模式 是 迪米特法则 的一种具体实现,通过引入一个新的外观角色可以降低原有系统的复杂度,同时降低客户类与子系统的耦合度。
外观模式 的结构如下图所示:
图10 外观模式结构图
Facade
:在 Client 可以调用它的方法,Facade 知道相关的(一个或者多个) SubSystem 的功能和责任;在正常情况下,它将所有从 Client 发来的请求委派给相应的 SubSystem 处理。SubSystem
:在软件系统中可以有一个或者多个 SubSystem,每个 SubSystem 可以不是一个单独的类,而是一个类的集合,它实现 SubSystem 的功能;每一个 SubSystem 都可以被 Client 直接调用,或者被 Facade 调用;SubSystem 并不知道 Facade 的存在,对于 SubSystem 而言,Facade 仅仅是另外一个 Client 而已。
(1)子系统类
internal class SubSystemA
{
public void MethodA()
{
//业务实现代码
}
}
internal class SubSystemB
{
public void MethodB()
{
//业务实现代码
}
}
internal class SubSystemC
{
public void MethodC()
{
//业务实现代码
}
}
(2)外观类
public class Facade
{
private SubSystemA _obj1 = new SubSystemA();
private SubSystemB _obj2 = new SubSystemB();
private SubSystemC _obj3 = new SubSystemC();
public void Method()
{
_obj1.MethodA();
_obj2.MethodB();
_obj3.MethodC();
}
}
(3)客户端
class Program
{
static void Main(string[] args)
{
Facade facade = new Facade();
facade.Method();
}
}
抽象外观类
如果在案例“文件加密模块”中需要更换一个加密类,不再使用原有的基于求模运算的加密类CipherMachine
,而改为基于移位运算的新加密类NewCipherMachine
,其代码如下:
internal string Encrypt(string plainText)
{
Console.Write("数据加密,将明文转换为密文:");
StringBuilder sb = new StringBuilder();
int key = 10; //设置密钥,移位数为10
char[] chars = plainText.ToCharArray();
foreach (char ch in chars)
{
int temp = Convert.ToInt32(ch);
//小写字母移位
if (ch >= 'a' && ch <= 'z')
{
temp += key%26;
if (temp > 122) temp -= 26;
if (temp < 97) temp += 26;
}
//大写字母移位
if (ch >= 'A' && ch <= 'Z')
{
temp += key%26;
if (temp > 90) temp -= 26;
if (temp < 65) temp += 26;
}
sb.Append((char) temp));
}
Console.WriteLine(sb);
return sb.ToString();
}
如果不增加新的外观类,只能通过修改原有外观类EncryptFacade
的源代码来实现加密类的更换,将原有的对CipherMachine
类型对象的引用改为对NewCipherMachine
类型对象的引用,这违背了 开闭原则,因此需要通过增加新的外观类来实现对子系统对象引用的改变。
如果增加一个新的外观类NewEncryptFacade
来与FileReader
、FileWriter
以及新增加的NewCipherMachine
类进行交互,虽然原有系统类库无须做任何修改,但是因为客户端代码中原来针对EncryptFacade
类进行编程,现在需要改为NewEncryptFacade
类,因此需要修改客户端源代码。
如何在不修改客户端代码的前提下使用新的外观类呢?
图11 引入抽象外观类之后的文件加密模块结构图
(1)抽象外观类
public abstract class AbstractEncryptFacade
{
public abstract void FileEncrypt(string fileNameSrc, string fileNameDes);
}
(2)新增加的实体外观类
public class NewEncryptFacade : AbstractEncryptFacade
{
private readonly FileReader _reader;
private readonly NewCipherMachine _cipher;
private readonly FileWriter _writer;
public NewEncryptFacade()
{
_reader = new FileReader();
_cipher = new NewCipherMachine();
_writer = new FileWriter();
}
public override void FileEncrypt(string fileNameSrc, string fileNameDes)
{
string plainStr = _reader.Read(fileNameSrc);
if (string.IsNullOrEmpty(plainStr))
{
return;
}
string encryptStr = _cipher.Encrypt(plainStr);
_writer.Write(encryptStr, fileNameDes);
}
}
配置文件
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<appSettings>
<add key="facade" value="SunnyFacade.NewEncryptFacade"/>
</appSettings>
</configuration>
客户端
using System.Configuration;
using System.Reflection;
class Program
{
static void Main(string[] args)
{
string facadeString = ConfigurationManager. AppSettings["facade"];
AbstractEncryptFacade ef = Assembly.Load("SunnyFacade").CreateInstance(facadeString)
as AbstractEncryptFacade;
if (ef != null)
ef.FileEncrypt("src.txt", "des.txt");
}
}
输出结果如下:
图12 输出结果
原有外观类EncryptFacade
也需作为抽象外观类AbstractEncryptFacade
类的子类,更换具体外观类时只需修改配置文件,无须修改源代码,符合 开闭原则。
适配器模式 -- 不兼容结构的协调
Sunny 软件公司在很久以前曾开发了一个算法库,里面包含了一些常用的算法,例如排序算法和查找算法,在进行各类软件开发时经常需要重用该算法库中的算法。
在为某学校开发教务管理系统时,开发人员发现需要对学生成绩进行排序和查找,该系统的设计人员已经开发了一个成绩操作接口IScoreOperation
,在该接口中声明了排序方法Sort(int[])
和查找方法Search(int[], int)
,为了提高排序和查找的效率,开发人员决定重用算法库中的快速排序算法类QuickSort
和二分查找算法类BinarySearch
,其中QuickSort
的QuickExchangeSort(int[])
方法实现了快速排序,BinarySearch
的BinSearch (int[], int)
方法实现了二分查找。
由于某些原因,现在 Sunny 公司开发人员已经找不到该算法库的源代码,无法直接通过复制和粘贴操作来重用其中的代码;部分开发人员已经针对IScoreOperation
接口编程,如果再要求对该接口进行修改或要求大家直接使用QuickSort
类和BinarySearch
类将导致大量代码需要修改。
Sunny 软件公司开发人员面对这个没有源码的算法库,遇到一个幸福而又烦恼的问题:如何在既不修改现有接口又不需要任何算法库代码的基础上能够实现算法库的重用?
图13 需协调的两个系统的结构示意图
现在我们需要IScoreOperation
接口能够和已有算法库一起工作,让它们在同一个系统中能够兼容,最好的实现方法是增加一个适配器角色,通过适配器来协调这两个原本不兼容的结构。
图14 算法库重用结构图
IScoreOperation
接口充当抽象目标,QuickSort
和BinarySearch
类充当适配者,OperationAdapter
充当适配器。
(1)抽象成绩操作类:目标接口
public interface IScoreOperation
{
void Sort(int[] array);
int Search(int[] array, int key);
}
(2)快速排序类:适配者
public class QuickSort
{
public void QuickExchangeSort<T>(T[] array) where T : IComparable<T>
{
QuickExchangeSort(array, 0, array.Length - 1);
}
private void QuickExchangeSort<T>(T[] array, int left, int right) where T : IComparable<T>
{
if (left < right)
{
T current = array[left];
int i = left;
int j = right;
while (i < j)
{
while (array[j].CompareTo(current) > 0 && i < j)
j--;
while (array[i].CompareTo(current) <= 0 && i < j)
i++;
if (i < j)
{
T temp = array[i];
array[i] = array[j];
array[j] = temp;
j--;
i++;
}
}
array[left] = array[j];
array[j] = current;
if (left < j - 1)
QuickExchangeSort(array, left, j - 1);
if (right > j + 1)
QuickExchangeSort(array, j + 1, right);
}
}
}
(3)二分查找类:适配者
public class BinarySearch
{
public int BinSearch<T>(T[] array, T key)
where T : IComparable<T>
{
int left = 0;
int right = array.Length - 1;
while (left <= right)
{
int mid = (left + right)/2;
if (array[mid].CompareTo(key) < 0)
left = mid + 1;
else if (array[mid].CompareTo(key) == 0)
return mid;
else
right = mid - 1;
}
return -1;
}
}
(4)操作适配器:适配器
public class OperationAdapter : IScoreOperation
{
private readonly QuickSort _sortObj;
private readonly BinarySearch _searchObj;
public OperationAdapter()
{
_sortObj = new QuickSort();
_searchObj = new BinarySearch();
}
public void Sort(int[] array)
{
_sortObj.QuickExchangeSort<int>(array);
}
public int Search(int[] array, int key)
{
return _searchObj.BinSearch<int>(array, key);
}
}
(5)配置文件
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<appSettings>
<add key="Adapter" value="SunnyAdapter.OperationAdapter"/>
</appSettings>
</configuration>
(6)客户端代码
using System.Configuration;
using System.Reflection;
class Program
{
static void Main(string[] args)
{
Assembly assembly = Assembly.Load("SunnyAdapter");
IScoreOperation scoureOperation = assembly.CreateInstance(ConfigurationManager.AppSettings["Adapter"])
as IScoreOperation;
if (scoureOperation == null)
return;
int[] scores = { 84, 76, 50, 69, 90, 91, 88, 96 };
Console.WriteLine("成绩排序结果:");
scoureOperation.Sort(scores);
for (int i = 0; i < scores.Length; i++)
{
Console.Write(scores[i]+",");
}
Console.WriteLine();
Console.WriteLine("查找成绩90:");
int score = scoureOperation.Search(scores, 90);
if (score == -1)
Console.WriteLine("没有找到成绩90。");
else
Console.WriteLine("找到成绩90。");
Console.WriteLine("查找成绩92:");
score = scoureOperation.Search(scores, 92);
if (score == -1)
Console.WriteLine("没有找到成绩92。");
else
Console.WriteLine("找到成绩92。");
}
}
图15 运行结果
如果需要使用其它排序算法类和查找算法类,可以增加一个新的适配器类,使用新的适配器来适配新的算法,原有代码无须修改。
通过引入配置文件和反射机制,可以在不修改客户端代码的情况下使用新的适配器,无须修改源代码,符合“开闭原则”。
适配器模式(Adpter Pattern)将一个接口转换成客户希望的另一个接口,使接口不兼容的那些类可以一起工作,其别名为包装器(Wrapper)。
在 适配器模式 中引入了一个被称为适配器(Adapter)的包装类,而它所包装的对象称为适配者(Adaptee),即被适配的类。适配器的实现就是把客户类的请求转化为对适配者的相应接口的调用。从而解决了接口不兼容的问题,使得原本没有任何关系的类可以协同工作。
根据适配器类与适配者类的关系不同,适配器模式可分为:
对象适配器(适配器与适配者之间是关联关系) 类适配器(适配器与适配者之间是继承或实现关系)
图16 对象适配器模式结构图
(1)目标接口
public interface ITarget
{
void Request();
}
(2)适配者类
public class Adaptee
{
public void SpecificRequest()
{
;
}
}
(3)适配器类
public class Adapter : ITarget
{
private readonly Adaptee _adaptee;
public Adapter(Adaptee adaptee)
{
_adaptee = adaptee;
}
public void Request()
{
_adaptee.SpecificRequest();
}
}
在对象适配器中,客户端需要调用Request()
方法,而适配者类Adaptee
没有该方法,但是它所提供的SpecificRequest()
方法却是客户端所需要的。为了使客户端能够使用适配者类,需要提供一个包装类Adapter
,即适配器类。这个包装类包装了一个适配者的实例,从而将客户端与适配者衔接起来,在适配器的Request()
方法中调用适配者的SpecificRequest()
方法。
图17 类适配器模式结构图
public class Adapter : Adaptee, ITarget
{
public void Request()
{
base.SpecificRequest();
}
}
适配器模式包含以下 3 个角色:
ITarget
(目标接口):定义客户所需接口。Adaptee
(适配者类):即被适配的角色,它定义了一个已经存在的接口,这个接口需要适配,适配者类一般是一个具体类,包含了客户希望使用的业务方法,在某些情况下可能没有适配者类的源码。Adapter
(适配器类):适配器可以调用另一个接口,作为一个转换器,对Adaptee
和ITarget
进行适配,它通过实现ITarget
并 关联/继承 一个Adaptee 对象/类 使二者产生联系。
由于Java、C#等语言不支持多重类继承,因此类适配器的使用受到很多限制,例如如果目标ITarget不是接口,而是一个类,就无法使用类适配器;此外,如果适配者Adaptee
为sealed
类,也无法使用类适配器。在C#等面向对象编程语言中,大部分情况下我们使用的是对象适配器,类适配器较少使用。
桥接模式 -- 处理多维度变化
Sunny 软件公司欲开发一个跨平台图像浏览系统,要求该系统能够显示 BMP、JPG、GIF、PNG 等多种格式的文件,并且能够在 Windows、Linux、Unix 等多个操作系统上运行。
系统首先将各种格式的文件解析为像素矩阵(Matrix
),然后将像素矩阵显示在屏幕上,在不同的操作系统中可以调用不同的绘制函数来绘制像素矩阵。
系统需具有较好的扩展性以支持新的文件格式和操作系统。
Sunny 软件公司的开发人员针对上述要求,提出了一个初始设计方案,其基本结构如下图所示:
图18 跨平台图像浏览器初始结构图
在上图的初始设计方案中,使用了一种多层继承结构。
Image
是抽象父类,而每一种类型的图像类作为其直接子类,不同的图像文件格式具有不同的解析方法,可以得到不同的像素矩阵;由于每一种图像又需要在不同的操作系统中显示,不同的操作系统在屏幕上显示像素矩阵有所差异,因此需要为不同的图像类再提供一组在不同操作系统显示的子类。
对该设计方案进行分析,发现存在如下两个主要问题:
【1】由于采用了多层继承结构,导致系统中类的个数急剧增加,在各种图像的操作系统实现层提供了12个实体类,加上各级抽象层的类,系统中类的总个数达到了17个,在该设计方案中,实体层的类的个数 = 所支持的图像文件格式数 × 所支持的操作系统数
。
【2】系统扩展麻烦,由于每一个实体类既包含图像文件格式信息,又包含操作系统信息,因此无论是增加新的图像文件格式还是增加新的操作系统,都需要增加大量的实体类。
如何解决这两个问题?
谈谈两种常见文具 “毛笔” 和 “蜡笔” 的区别。
假如我们需要大中小 3 种型号的画笔,能够绘制 12 种不同的颜色。如果使用蜡笔,需要准备 36支,如果使用毛笔,只需要提供 3 种型号的毛笔,外加 12 个颜料盒即可,涉及到的对象个数为 15,远小于36,却能实现与36支蜡笔同样的功能。如果增加一种新型号的画笔,并且也需要具有 12 种颜色,对应的蜡笔需增加 12支,而毛笔只需增加 1支。
为什么会这样呢?
通过分析可知:
蜡笔:颜色和型号两个不同的变化维度(即两个不同的变化原因)融合在一起,无论是对颜色还是对型号进行扩展都势必会影响另一个维度; 毛笔:颜色和型号实现了分离,增加新的颜色或者型号对另一方都没有任何影响。 如果使用软件工程中的术语,我们可以认为在蜡笔中颜色和型号之间存在较强的耦合性,而毛笔很好地将二者解耦,使用起来非常灵活,扩展也更为方便。
我们通过分析得知,该系统也存在两个独立变化的维度:
图像文件格式(对应图像格式的解析) 操作系统(对应像素矩阵的显示)
图19 跨平台图像浏览器中存在的两个独立变化维度示意图
为了减少所需生成的子类数目,将这两个维度分离,使得它们可以独立变化,增加新的图像文件格式或者操作系统时都对另一个维度不造成任何影响。
Sunny 公司开发人员重构了系统的设计,如下图所示:
图20 跨平台图像浏览器重构后的结构图
完整代码如下:
//各种格式的文件最终都被转化为像素矩阵,
//不同的操作系统提供不同的方式显示像素矩阵。
public class Matrix
{
//...
}
“实现类”层次结构:
public abstract class ImageImp
{
public abstract void DoPaint(Matrix m);
}
public class LinuxImp : ImageImp
{
public override void DoPaint(Matrix m)
{
Console.WriteLine("在Linux操作系统中显示图像。");
}
}
public class UnixImp : ImageImp
{
public override void DoPaint(Matrix m)
{
Console.WriteLine("在Unix操作系统中显示图像。");
}
}
public class WindowsImp : ImageImp
{
public override void DoPaint(Matrix m)
{
Console.WriteLine("在Windows操作系统中显示图像。");
}
}
“抽象类”层次结构:
public abstract class Image
{
protected ImageImp Imp;
public void SetImageImp(ImageImp imp)
{
this.Imp = imp;
}
public abstract void ParseFile(string fileName);
}
public class BmpImage : Image
{
public override void ParseFile(string fileName)
{
Matrix m = new Matrix();
Imp.DoPaint(m);
Console.WriteLine(fileName + ",格式为BMP。");
}
}
public class GifImage : Image
{
public override void ParseFile(string fileName)
{
Matrix m = new Matrix();
Imp.DoPaint(m);
Console.WriteLine(fileName + ",格式为GIF。");
}
}
public class JpgImage:Image
{
public override void ParseFile(string fileName)
{
Matrix m = new Matrix();
Imp.DoPaint(m);
Console.WriteLine(fileName+ ",格式为JPG。");
}
}
public class PngImage : Image
{
public override void ParseFile(string fileName)
{
Matrix m = new Matrix();
Imp.DoPaint(m);
Console.WriteLine(fileName + ",格式为PNG。");
}
}
配置文件代码:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<appSettings>
<add key="image" value="SunnyBridge.BmpImage"/>
<add key="os" value="SunnyBridge.LinuxImp"/>
</appSettings>
</configuration>
客户端代码:
using System.Reflection;
using System.Configuration;
class Program
{
static void Main(string[] args)
{
string image = ConfigurationManager.AppSettings["image"];
string os = ConfigurationManager.AppSettings["os"];
Assembly assembly = Assembly.Load("SunnyBridge");
Image img = assembly.CreateInstance(image) as Image;
ImageImp imageImp = assembly.CreateInstance(os) as ImageImp;
if (img != null)
{
img.SetImageImp(imageImp);
img.ParseFile("光头强");
}
}
}
输出结果如下图所示:
图21 运行结果
如果需要更换图像文件格式或者更换操作系统,只需修改配置文件即可,在实际使用时,可以通过分析图像文件格式后缀名来确定具体的文件格式,在程序运行时获取操作系统信息来确定操作系统类型,无须使用配置文件。
当增加新的图像文件格式或者操作系统时,原有系统无需做任何修改,只需增加一个对应的实现类即可,系统具有较好的可扩展性,完全符合“开闭原则”。
桥接模式与多层继承方案不同,它将两个独立变化的维度设计为两个独立的继承等级结构,并且在抽象层建立一个抽象关联,该关联关系类似一条连接两个独立继承结构的桥,故名桥接模式。
桥接模式(Bridge Pattern):将抽象部分与它的实现部分分离,使它们都可以独立地变化。
用 抽象关联 取代了传统的 多继承; 将类之间的 静态继承关系 转换为 动态的对象组合关系;
桥接模式 结构如下图所示:
图22 桥接模式类图
Abstraction
(抽象类):定义了一个Implementor
类型的对象并可以维护该对象,它与Implementor
之间具有关联关系,它既可以包含抽象业务方法,也可以包含具体业务方法。RefinedAbstraction
(扩充抽象类):实现了在Abstraction
中声明的抽象业务方法,在RefinedAbstraction
中可以调用在Implementor
中定义的业务方法。Implementor
(实现类接口):通过关联关系,在Abstraction
中不仅拥有自己的方法,还可以调用到Implementor
中定义的方法,使用关联关系来替代继承关系。ConcreteImplementor
(实体实现类):在程序运行时,ConcreteImplementor
对象将替换其父类对象,提供给抽象类实体的业务操作方法。
通常情况下,我们将具有两个独立变化维度的类的一些普通业务方法和与之关系最密切的维度设计为“抽象类”层次结构(抽象部分),而将另一个维度设计为“实现类”层次结构(实现部分)。
对于毛笔而言:
型号是其固有的维度,因此可以设计一个抽象的毛笔类,在该类中声明并部分实现毛笔的业务方法,而将各种型号的毛笔作为其子类; 颜色是其另一个维度,由于它与毛笔之间存在一种“设置”的关系,因此我们可以提供一个抽象的颜色接口,而将具体的颜色作为实现该接口的子类。
结构示意图如下图所示:
图23 桥接模式类图
桥接模式 中体现了“单一职责原则”、“开闭原则”、“合成复用原则”、“里氏代换原则”、“依赖倒转原则”等设计原则。熟悉该模式有助于我们深入理解这些设计原则,也有助于我们形成正确的设计思想和培养良好的设计风格。
适配器模式与桥接模式的联用
桥接模式:用于系统的初步设计,对于存在两个独立变化维度的类可以将其分为抽象化和实现化两个角色,使它们可以分别进行变化; 适配器模式:初步设计完成之后,当发现系统与已有类无法协同工作时,可以采用适配器模式,解决两个已有接口间不兼容问题;
某系统的报表处理模块中,需要将报表显示和数据采集分开,系统可以有多种报表显示方式也可以有多种数据采集方式,如可以从文本文件中读取数据,也可以从数据库中读取数据,还可以从Excel文件中获取数据。如果需要从Excel文件中获取数据,则需要调用与Excel相关的API,而这个API是现有系统所不具备的,该API由厂商提供。
在设计过程中,由于存在报表显示和数据采集两个独立变化的维度,因此可以使用 桥接模式 进行初步设计;为了使用Excel相关的API来进行数据采集则需要使用适配器模式。系统的完整设计中需要将两个模式联用,如下图所示:
图24 桥接模式与适配器模式联用示意图