软件设计原则和模式图解






4.86/5 (51投票s)
在本文中,我将尝试使用现实世界的类比和图片来解释一些设计模式和原则。
引言
本文目的
本文的目的是通过图片来解释编程概念和模式。
在思考软件或数学抽象时,将其映射到其他可以想象或绘制的东西总是有帮助的。
俗话说“百闻不如一见”。图片的使用并不意味着文章轻松——事实上,我正在努力以一种简单易懂的方式来教授一个相当复杂的概念。
阅读本文需要一些面向对象语言的知识。特别是,我推荐 C#、Java、C++ 或 TypeScript。
本文的所有示例均使用我最喜欢的 C# 编写。
什么是工程学?
我喜欢将工程学看作是将各种构件组合在一起,以产生客户所需结果的艺术。这适用于任何工程——软件、硬件、机械、土木等。
什么是软件工程?
软件工程就是通用工程的所有内容,再加上许多强大的额外功能。
- 在软件中创建构件比在硬件中容易得多。在面向对象编程(OOP)中——这仅仅意味着创建一个类。
- 您可以自己创建各种构件,然后在应用程序中重用它们。请注意,这在硬件工程中并不常见——通常构建构件的公司和使用该构件进行组装的公司是两家不同的公司,或者至少发生在两个不同的工厂。
- 由于上述原因,人们可以创建不同级别的软件构件——一些更通用,一些更具体,并将它们相应地添加到不同的库中。
- 修复软件问题比修复硬件问题要容易得多,添加或更改软件功能也是如此。
- 在上面提到的面向对象语言中,当创建一个构件时,您实际上是在创建一个构件工厂,这意味着一旦创建了一个类,您就可以根据需要创建该类的任意数量的对象。所以,与硬件构件相比,类更像是视频游戏中的图标,您可以将任意数量对应于相同图标的对象拖到屏幕上,或者像 MS Word 中的形状一样,可以将其拖放到页面上。
- 软件类可以通过适应接口(通过接口实现)或通过使用实现继承来扩展类的功能来相互配合。面向对象编程特性仅仅是语言的“技巧”,它们允许从已有的对象类型(类)构建一些新的对象类型(类)。
示例代码位置
本文顶部的链接可以下载示例代码。它也是 GitHub 存储库的一部分:Patterns In Pictures。
图片和代码示例
具有不同实现的接口示例
接口决定了一个构件如何被其他构件或用户使用。接口不关心构件的实现。说到图片,我想象接口是表面或一组连接,它们限制了构件的使用。
安抚奶嘴和奶瓶有相似的“奶嘴”表面,婴儿的嘴可以互换使用。从某种意义上说,它们有共同的接口,但实现不同。
对于芯片来说,接口是它的连接。
电器设备的电源接口是它的电源插头,冰箱的电源接口也是同一个电源插头。即使这两个设备完全不同,它们的电源接口也是相同的。
这让我开始讨论实现多个接口的构件。例如,一个电器(例如冰箱或灯)的用途远不止于插入电源插座。它们有可以改变的开关,它们为最终用户产生东西(例如冷藏食物或提供照明),然而从作为电器设备的角度来看,它们实现了相同的接口——电源插头。
在软件中,一个用法功能的方面(通常由单个接口实现)被称为一个关注点。我们可以说,冰箱和灯都实现了具有电源插头的设备这一相同的关注点。
用法继承代码示例
演示用法继承的代码示例位于 MultipleInterfaces.sln 解决方案下。这是继承图。
请注意,为了演示用法继承,我在此引入了比这个小示例所需的更多的接口。您不必为每个类都创建接口——只有当您有两种不同实现的相似关注点时,才需要引入接口,正如在 Software Project Development and Decision Making 文章中所指出的。
这是所有类和接口的代码。
public interface IPowerPlug
{
bool IsPlugged { get; set; }
}
它只有一个布尔属性,用于指定设备是否已插入。
public interface ILamp : IPowerPlug
{
bool IsOn { get; }
bool IsSwitchOn { get; set; }
}
ILamp
继承自 IPowerPlug
,并为其添加了两个属性:只读的 IsOn
指定灯是否真的亮着(发光),IsSwitchOn
指定灯的开关是否打开。显然,为了让灯亮着,它必须插入电源插座并且开关也必须打开——这就是 Lamp
类中的实现。
public class Lamp : ILamp
{
bool _isSwitchOn = false;
public bool IsSwitchOn
{
get => _isSwitchOn;
set
{
if (_isSwitchOn == value)
return;
_isSwitchOn = value;
// set IsOn to be true iff
// IsSwitchOn and IsPlugged are true
IsOn = IsSwitchOn && IsPlugged;
}
}
bool _isOn = false;
public bool IsOn
{
get => _isOn;
set
{
if (_isOn == value)
return;
_isOn = value;
// print to console when IsOn changes
Console.WriteLine($"The lamp is {(_isOn ? "On": "Off")}");
}
}
bool _isPlugged = false;
public bool IsPlugged
{
get => _isPlugged;
set
{
if (_isPlugged == value)
return;
_isPlugged = value;
// set IsOn to be true iff
// IsSwitchOn and IsPlugged are true
IsOn = IsSwitchOn && IsPlugged;
}
}
}
与 ILamp
不同,IFridge
接口是通过“多重用法继承”(仅仅是为了演示该功能)实现的。它继承自 IPowerPlug
和 ITemperatureSetter
接口。
public interface IFridge : ITemperatureSetter, IPowerPlug
{
}
这是 ITemperatureSetter
的代码。
public interface ITemperatureSetter
{
// the temperature set by hand
double SetTemperature { get; set; }
// the real fridge temperature
double RealTemperature { get; }
}
为简单起见,我们假设,一旦 Fridge
插入电源,RealTemplerature
就变成 SetTemperature
。这是 Fridge
的实现。
public class Fridge : IFridge
{
bool _isPlugged = false;
public bool IsPlugged
{
get => _isPlugged;
set
{
if (_isPlugged == value)
return;
_isPlugged = value;
RealTemperature = IsPlugged ? SetTemperature : 0;
}
}
double _setTemperature = double.NaN;
public double SetTemperature
{
get => _setTemperature;
set
{
if (_setTemperature == value)
return;
_setTemperature = value;
RealTemperature = IsPlugged ? SetTemperature : 0;
}
}
double _realTemperature = 0;
public double RealTemperature
{
get => _realTemperature;
private set
{
if (_realTemperature == value)
return;
_realTemperature = value;
Console.WriteLine($"Real Temperature is {RealTemperature} degrees");
}
}
}
这是示例的 main
方法。
static void Main(string[] args)
{
//Create the lamp
ILamp lamp = new Lamp();
// plugin the lamp
lamp.IsPlugged = true;
// turn it on
// at this point it should print
// to console that the lamp is on
lamp.IsSwitchOn = true;
// create a fridge
IFridge fridge = new Fridge();
// set the temperature to 58 degrees
fridge.SetTemperature = 58d;
// at this point, it should print
// temperature to console.
fridge.IsPlugged = true;
}
请注意,Java 和 C# 的用法(接口)继承有一个重要特性,无法映射到硬件特性(也无法在图片上显示)——即所谓的成员合并。如果一个具有相同名称的属性或事件,或者一个具有相同名称和签名的多个方法属于两个超接口,则这两个成员会在子接口中合并成一个。
适配器模式
适配器模式的概念可以用简单的硬件欧洲转美国电源适配器很好地展示。这是一种在不改变其实现的情况下更改对象公共接口的模式。
适配器示例的代码位于 AdapterSample.sln 解决方案下。
这是代码图。
我们有一个 AmericanLamp
类,它实现了 IAmericanPowerPlug
接口。
public interface IAmericanPowerPlug
{
bool IsPluggedIntoAmericanPowerOutlet { get; set; }
}
我们想将其适配到 IEuropeanPowerPlug
接口。
public interface IEuropeanPowerPlug
{
bool IsPluggedIntoEuropeanPowerOutlet { get; set; }
}
我们使用著名的“四人帮”书中提供的适配器模式来适配 AmericalLamp
到欧洲插座,创建一个 AdaptedLamp
类。
public class AdaptedLamp : AmericanLamp, IEuropeanPowerPlug
{
// wrapping (adapting) the property.
public bool IsPluggedIntoEuropeanPowerOutlet
{
get => IsPluggedIntoAmericanPowerOutlet;
set
{
IsPluggedIntoAmericanPowerOutlet = true;
}
}
}
可以看到,AdaptedLamp
类继承自 AmericanLamp
,并通过包装(重命名)IPluggedIntoAmericanPowerOutlet
方法来实现 IEuropeanPowerPlug
接口。
实现没有改变,但现在适配后的对象可以传递给期望 IEuropeanPowerPlug
的方法。
class Program
{
static void PlugIntoEuropeanOutlet(IEuropeanPowerPlug europeanPowerPlug)
{
europeanPowerPlug.IsPluggedIntoEuropeanPowerOutlet = true;
}
static void Main(string[] args)
{
IEuropeanPowerPlug adaptedLamp = new AdaptedLamp() { IsSwitchOn = true };
PlugIntoEuropeanOutlet(adaptedLamp);
}
}
请注意,如果我们愿意,我们也可以对适配属性进行一些小的修改,例如在适配的基础上,我们还需要将电压转换为欧洲标准。
实现适配器的另一种方法是包装 AmericanLamp
类而不是继承它。这可能是一种更好但稍微繁琐的方法,可以使用 Roxy IoC 容器和代理生成器来自动化。但我计划写另一篇文章详细介绍 Roxy 在各种模式实现中的用法。
插件(策略)和代理模式
插件(或策略)模式允许为类的相同成员使用不同的实现。各种插件实现有时可以在代码中被替换为已存在的对象,或者有时它们由对象的构造函数参数决定,并且在对象构造后无法更改。
我认为,在软件世界之外,展示插件本质的一个很好的方法是想象一个婴儿吸吮奶瓶或安抚奶嘴。它们都有相似的接口,但实现不同。
在“四人帮”书中,这种模式被称为策略——因为他们主要使用这种模式为类提供不同的行为。事实上,我认为 Plugin
这个名字更好,更通用。
此示例的代码位于 PluginSample.sln 解决方案下。代码用法在 Program.Main
方法中演示。
static void Main(string[] args)
{
Baby baby = new Baby();
Console.WriteLine("Setting succable plugin to pacifier");
baby.SetSuccablePlugin(new Pacifier());
baby.Suck();
Console.WriteLine("Setting succable plugin to a BottleWithMilk");
baby.SetSuccablePlugin(new BottleWithMilk());
baby.Suck();
}
Program.Main
方法的简要描述:创建一个 Baby
对象,将其 SuccablePlugin
设置为 new Pacifier()
。调用 Suck()
方法。然后,将其 SuccablePlugin
重置为 new BottleWithMilk()
并再次调用 Suck()
方法。
控制台将打印以下内容。
Setting succable plugin to pacifier
Pacifier is sucked
Setting succable plugin to a BottleWithMilk
Milk is drunk
这是 Baby
类。
public class Baby
{
ISuccable _succablePlugin = null;
// set the succable plugin
public void SetSuccablePlugin(ISuccable succablePlugin)
{
_succablePlugin = succablePlugin;
}
// method suck - call the corresponding
// plugin method
public void Suck()
{
_succablePlugin?.Suck();
}
}
这是 Pacifier
和 BottleWithMilk
插件的实现。
public class Pacifier : ISuccable
{
public void Suck()
{
Console.WriteLine("Pacifier is sucked");
}
}
public class BottleWithMilk : ISuccable
{
public void Suck()
{
Console.WriteLine("Milk is drunk");
}
}
请注意,我们不仅实现了插件/策略模式,还实现了 Proxy
模式——它非常相似,只是假定公共方法名称与插件方法名称相同(在我们的例子中是true——我们调用的公共方法是 Baby.Suck()
,相应的插件方法是 ISuccable.Suck()
。代理模式的另一个特定要求是代理为插件指针为 null
的情况提供特殊处理。此示例中也发生了这种情况——这是 Baby.Suck()
方法。
public void Suck()
{
_succablePlugin?.Suck();
}
“问号点”运算符可防止在 _succablePlugin
成员为 null
时抛出 null
异常。
事实上,我认为插件和代理模式足够相似,可以将代理视为插件的一个变体。
多个插件和桥模式
假设您有一个带有两个或多个插件的设备,例如,一台带鼠标和键盘的计算机。
假设插件可以是不同类型的——例如,鼠标可以是普通鼠标和高级鼠标,键盘可以是普通键盘和高级键盘。
我们有两个独立的(或几乎独立的)关注点或插件(鼠标和键盘),每个插件都可以有两种实现——(鼠标 vs. 高级鼠标,键盘 vs. 高级键盘)。
潜在地,我们可以获得可能性上的完整笛卡尔积。
- 带普通鼠标和普通键盘的计算机
- 带高级鼠标和普通键盘的计算机
- 带普通鼠标和高级键盘的计算机
- 带高级鼠标和高级键盘的计算机
创建软件表示的最佳方法是使用上面讨论的插件模式来处理鼠标和键盘。
演示多个插件模式的示例位于 MultiPluginSample.sln 解决方案下。
它的主类是 Computer
。
public class Computer
{
// reference to IMouse
public IMouse Mouse { get; set; }
// reference to IKeyboard
public IKeyboard Keyboard { get; set; }
public Computer()
{
// defaults are set to
// PlainMouse and
// PlainKeyboard
Mouse = new PlainMouse();
Keyboard = new PlainKeyboard();
}
// wrapper around IMouse.X and IMouse.Y
// setters
public void MoveMouse(double x, double y)
{
if (Mouse == null)
return;
Mouse.X = x;
Mouse.Y = y;
}
// wrapper around IMouse.LeftButtonClick()
public void MouseClick()
{
Mouse?.LeftButtonClick();
}
// Wrapper around IKeyboard.KeyClick(char c)
public void ClickKeyboardKey(char c)
{
Keyboard?.KeyClick(c);
}
}
该类具有与鼠标和键盘功能相对应的多个公共方法。这些方法本质上是对 IMouse
和 IKeyboard
插件相应方法的包装,例如,方法 Computer.MouseClick()
是对 IMouse.LeftButtonClick()
的包装。
// wrapper around IMouse.LeftButtonClick()
public void MouseClick()
{
Mouse?.LeftButtonClick();
}
该类包含两个公共属性,用于鼠标和键盘插件。
// reference to IMouse
public IMouse Mouse { get; set; }
// reference to IKeyboard
public IKeyboard Keyboard { get; set; }
包装器方法的实现取决于这些属性的设置。
默认情况下,它们在构造函数中设置为 PlainMouse
和 PlainKeyboard
。
public Computer()
{
// defaults are set to
// PlainMouse and
// PlainKeyboard
Mouse = new PlainMouse();
Keyboard = new PlainKeyboard();
}
但是,由于它们的设置器是公共的,它们可以在程序中的任何时候被更改。
这是 Main.Program
方法。
static void Main(string[] args)
{
// create computer with
// default (plain) mouse and keyboard
Computer computer = new Computer();
// mouse and keyboard operations
// should result in console messages mentioning
// the plain mouse and plain keyboard
computer.MoveMouse(20, 50);
computer.MouseClick();
computer.ClickKeyboardKey('h');
// after the keyboard is changed to
// FancyKeyboard, the
// keyboard messages should mention
// the 'Fancy' keyboard.
computer.Keyboard = new FancyKeyboard();
computer.ClickKeyboardKey('h');
}
运行示例会产生以下输出。
Plain Mouse: X = 20
Plain Mouse: Y = 50
Plain Mouse: Left Button Clicked
Plain Keyboard clicked 'h'
Fancy Keyboard clicked 'h'
这是 IMouse
和 IKeyboard
接口。
// IMouse interface
public interface IMouse
{
double X { get; set; }
double Y { get; set; }
void LeftButtonClick();
}
//IKeyboard interface
public interface IKeyboard
{
void KeyClick(char c);
}
相应的插件实现也非常简单,每种实现都会在控制台打印它是“普通”还是“高级”实现。
现在,如果我们花点时间回顾一下桥模式是什么,我们可以看到我们实现的内容几乎与桥模式完全匹配,除了我们这里考虑的实现更强大——因为插件可以被更改——而在标准的桥模式中,实现一旦对象被创建就固定了。
事实上,如果您查看“四人帮”书中关于桥模式的定义,您会发现它允许在不为每个关注点创建单独类型的情况下,创建两个独立关注点的笛卡尔积。在标准的桥模式中,沿其中一个关注点的各种实现通过继承来实现,而沿另一个关注点的变化则作为插件实现。本示例表明,将两个(或更多)关注点都实现为插件,比使用一个关注点进行继承更简单、更强大。
如果有人想将 MultiPlugin 模式的功能限制在“四人帮”书中桥模式的范围内,他所需要做的就是将插件属性的设置器设为私有,并在接受相应对象作为参数的构造函数中设置它们。这将导致 Computer
类发生以下更改:
插件属性获得私有设置器。
// reference to IMouse
public IMouse Mouse { get; private set; }
// reference to IKeyboard
public IKeyboard Keyboard { get; private set; }
此外,构造函数将更改为接受插件对象。
public Computer(IMouse mouse, IKeyboard keyboard)
{
Mouse = mouse;
Keyboard = keyboard;
}
现在满足了桥模式的所有条件——一旦对象被创建,它的插件就不能被更改。
结论
在本文中,我尝试用视觉类比来解释软件工程思想和模式,包括:
- 用法继承
- 适配器模式
- 插件(或策略)模式
- 多插件(或桥)模式
如果时间允许并且取决于本文的受欢迎程度,我计划撰写更多文章,提供非软件世界中软件思想的类比。