C--简单高效编程教程-全-
C# 简单高效编程教程(全)
一、使用多态的灵活代码
问一个开发人员,“面向对象编程(OOP)的基本特征是什么?”你会立即得到回复说,“类(和对象)、继承、抽象、封装和多态是 OOP 中最重要的特征”。此外,当您分析基于 OOP 的企业代码时,您会发现不同形式的多态。但事实是,一个程序员新手很少使用多态的力量。这一章主要讨论这个话题。它向您展示了一些使用这一原则的简单而强大的代码示例。
概述
多态仅仅意味着一个名字有多种形式。考虑你的宠物狗的行为。当它看到一个不认识的人,它就开始叫。但是当它看到你的时候,它会发出不同的声音,表现出不同的行为。在这两种情况下,这只狗用眼睛看东西,但是根据他的观察,他的行为是不同的。多态代码可以以同样的方式工作。考虑一个方法,你可以用它来添加一些操作数。如果操作数是整数,你应该得到整数的和。但是如果你要处理字符串操作数,你会得到一个连接的字符串。
初始程序
让我们看一个成功编译并运行的程序。在这个程序中,有三种不同类型的动物:tigers, dogs
和monkeys
。他们每个人都能发出不同的声音。所以,有这些名字的类,在每个类中,有一个Sound()
方法。看看你是否能改进这个程序。
演示 1
这是一个不使用多态概念的程序。
using
System;
namespace DemoWithoutPolymorphism
{
class Tiger
{
public void Sound()
{
Console.WriteLine("Tigers roar.");
}
}
class Dog
{
public void Sound()
{
Console.WriteLine("Dogs bark.");
}
}
class Monkey
{
public void Sound()
{
Console.WriteLine("Monkeys whoop.");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Sounds of the different animals.***");
Tiger tiger = new Tiger();
tiger.Sound();
Dog dog = new Dog();
dog.Sound();
Monkey monkey = new Monkey();
monkey.Sound();
Console.ReadKey();
}
}
}
输出
***Sounds of the different animals.***
Tigers roar.
Dogs bark.
Monkeys whoop.
分析
当您使用Tiger tiger = new Tiger();
时,tiger 是对基于Tiger
类的对象的引用。该引用引用对象,但不包含对象数据本身。甚至Tiger tiger;
也是一行有效的代码,告诉你在不创建对象的情况下创建一个对象引用。
明白当你使用Tiger tiger = new Tiger();
时,你是在编程一个实现 。请注意,在这种情况下,引用和对象都是相同的类型。您可以使用多态的概念来改进这个程序。在即将到来的实现中,我向您展示了这样一个例子。我在这个例子中使用了一个接口。我也可以用抽象类来实现同样的事情。在向您展示示例之前,让我提醒您几个要点:
-
当你使用一个抽象类或接口时,首先想到的是继承。如何知道自己是否正确使用了继承?简单的答案是:你做一个测试。例如,矩形是一种形状,但反过来就不一定了。再举一个例子:猴子是一种动物,但不是所有的动物都是猴子。请注意,IS-A 测试是单向的。
-
在编程中,如果你从类 A 继承了类 B,你说 B 是子类,A 是父类或基类。但是最重要的是,你可以说 B 是 A 的一种类型。所以,如果你从一个叫
Animal
(或者一个接口,比如说IAnimal
)的基类派生出一个Tiger
类或者一个Dog
类,你可以说Dog
是-AnAnimal
(或者IAnimal
)或者Tiger
是-AnAnimal
(或者IAnimal
)。 -
如果你有一个继承树,这是——一个测试可以应用在树的任何地方。例如,矩形是一种特殊的形状。正方形是一种特殊的长方形。所以,正方形也是一种形状。
-
假设我们分别使用
Rectangle
和Shape
类来表示矩形和形状。现在,当我们说Rectangle
是一个Shape
时,从程序上来说,我们的意思是一个Rectangle
实例可以调用一个Shape
实例可以调用的方法。如果需要的话,Rectangle
实例也可以调用一些额外的方法。这些额外的方法可以在Rectangle
类中定义。
您知道超类引用可以引用子类对象。这里你可以看到每个tiger, dog,
或monkey
都是一种动物。所以,你可以引入一个超类型,并从它继承所有这些具体的类。让我们把超类型命名为IAnimal
。
这里有一段代码展示了IAnimal
接口。它还让您知道如何在Tiger
类中覆盖它的Sound()
方法。Monkey
和Dog
类可以做同样的事情。
interface IAnimal
{
void Sound();
}
class Tiger : IAnimal
{
public void Sound()
{
Console.WriteLine("Tigers roar.");
}
}
对超类型编程给了你更多的灵活性。它允许你以多种形式使用一个引用变量。下面的代码段演示了这种用法:
IAnimal animal = new Tiger();
animal.Sound();
animal = new Dog();
animal.Sound();
//remaining code skipped
更好的程序
我已经重写了这个程序,它产生相同的输出。让我们看看下面的演示。
演示 2
这是演示 1 的修改版本。
using System;
namespace UsingPolymorphism
{
interface IAnimal
{
void Sound();
}
class Tiger: IAnimal
{
public void Sound()
{
Console.WriteLine("Tigers roar.");
}
}
class Dog: IAnimal
{
public void Sound()
{
Console.WriteLine("Dogs bark.");
}
}
class Monkey: IAnimal
{
public void Sound()
{
Console.WriteLine("Monkeys whoop.");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Sounds of the different animals.***");
IAnimal animal = new Tiger();
animal.Sound();
animal = new Dog();
animal.Sound();
animal = new Monkey();
animal.Sound();
Console.ReadKey();
}
}
}
分析
你注意到区别了吗?在Main()
方法中,使用超类引用animal
来引用不同的派生类对象。
现在你不仅打字更少了,而且你还使用了一个更灵活、更容易维护的程序。如果你愿意,你也可以遍历一个列表。例如,您可以替换Main()
中的以下代码段:
IAnimal animal = new Tiger();
animal.Sound();
animal = new Dog();
animal.Sound();
animal = new Monkey();
animal.Sound();
使用以下代码:
List<IAnimal> animals = new List<IAnimal>
{
new Tiger(),
new Dog(),
new Monkey()
};
foreach (IAnimal animal in animals)
animal.Sound();
如果您使用这些更改再次运行程序,您将看到相同的输出。
Point to Remember
当您使用List<Animal>
时,不要忘记在程序的开头包含以下名称空间:
using System.Collections.Generic;
这场讨论还没有结束。这里,我使用了一种最简单的多态形式。在这种情况下,您可能会想到:我们知道在 C# 中,超类型引用可以引用子类型对象。所以,当我使用下面几行时:
IAnimal animal = new Tiger();
animal.Sound();
您可以肯定地预测到Tiger
类的Sound()
方法将被调用。因此,看起来您预先知道了输出,并且您怀疑多态概念的有用性。如果是这种情况,请考虑下面的讨论。
让我们假设您基于一些运行时条件创建了一个子类型,比如一个随机数或者一个用户输入。在任何一种情况下,您都无法提前预测输出。例如,请参见以下代码行:
IAnimal animal = GetAnimal();
animal.Sound();
有什么区别?看到这段代码段的人都可以假设GetAnimal()
返回的是一种能发出声音的动物。你如何实现这一点?非常简单:让我重写客户端代码。请注意以粗体显示的变化:
class Program
{
static void Main()
{
Console.WriteLine("***Sounds of the different animals.***");
IAnimal animal = GetAnimal();
animal.Sound();
animal = GetAnimal();
animal.Sound();
animal = GetAnimal();
animal.Sound();
Console.ReadKey();
}
private static IAnimal GetAnimal()
{
IAnimal animal;
Random random = new Random();
// Get a number between 0 and 3(exclusive)
int temp = random.Next(0, 3);
if (temp == 0)
{
animal = new Tiger();
}
else if (temp == 1)
{
animal = new Dog();
}
else
{
animal = new Monkey();
}
return animal;
}
}
现在运行这个应用,注意输出。下面是我在各种运行中得到的示例输出:
First Run:
***Sounds of the different animals.***
Monkeys whoop.
Dogs bark.
Monkeys whoop.
Second Run:
***Sounds of the different animals.***
Dogs bark.
Dogs bark.
Tigers roar.
Third Run:
***Sounds of the different animals.***
Tigers roar.
Monkeys whoop.
Dogs bark.
Note
当您从 Apress 网站下载源代码时,请参考第一章中的文件夹 PolymorphismDemo2 以查看完整的程序。
现在很清楚,没有人能提前预测这个程序的输出。您可以在这个例子中看到多态的力量。我将用几个更重要的要点来结束这一章,这将帮助你理解和使用多态代码。
您可以用以下代码替换animal.Sound()
;
:
MakeSound(animal);
其中MakeSound()
定义如下:
private static void MakeSound(IAnimal animal)
{
animal.Sound();
}
我为什么给你看这个?按照这种方法,您可以将超类型引用传递给该方法,以调用适当的子类型方法。这为您提供了灵活性,并帮助您编写可读性更好的代码。下面是我们刚刚讨论过的客户端代码的另一个版本:
class Program
{
static void Main()
{
Console.WriteLine("***Sounds of the different animals.***");
IAnimal animal = GetAnimal();
MakeSound(animal);
animal = GetAnimal();
MakeSound(animal);
animal = GetAnimal();
MakeSound(animal);
Console.ReadKey();
}
private static void MakeSound(IAnimal animal)
{
animal.Sound();
}
private static IAnimal GetAnimal()
{
IAnimal animal;
Random random = new Random();
// Get a number between 0 and 3(exclusive)
int temp = random.Next(0, 3);
if (temp == 0)
{
animal = new Tiger();
}
else if (temp == 1)
{
animal = new Dog();
}
else
{
animal = new Monkey();
}
return animal;
}
}
}
Note
你不应该假设GetAnimal()
和MakeSound(...)
方法只需要是静态的。您也可以将它们用作实例方法。当您从 Apress 网站下载源代码时,请参考第一章中的文件夹 PolymorphismDemo3 来查看这个修改后的程序。
摘要
为了实现多态行为,我从一个接口开始。我也可以用抽象类来实现同样的事情。有些情况下,接口比抽象类更好,反之亦然。你会在第二章看到这方面的讨论。
当您对超类型(它可以是接口、抽象类或简单的父类)进行编码时,代码可以与实现该接口的任何新类一起工作。这有助于您应对未来的变化,并轻松地采用更新的需求。这就是多态的力量。但是如果你在你的程序中只使用具体的类,将来你很可能需要改变你现有的代码,比如当你添加一个新的具体的类的时候。这种方法不遵循开放/封闭原则,即代码应该对扩展开放,但对修改关闭。
我已经向您展示了多态的优势。但是编写多态代码并不总是容易的,使用时需要小心。当我在第四章讨论 SOLID 原理时,你会对此有更好的想法。
本章中的内容可能对您来说并不陌生,但是我相信您现在对多态有了更好的理解。在您进入下一章之前,让我确保我们在这些问题上达成了一致,并且您熟悉以下术语:
当你写道:
Tiger tiger = new Tiger();
tiger.Sound();
你是编程到具体实现。
当你写道:
IAnimal animal = new Tiger();
animal.Sound();
你正在对一个超类型编程。它通常被称为接口编程。
Note
当我们说“对一个接口编程”时,它并不一定意味着你只使用 C# 接口。它可以是抽象类,也可以是父类/基类。
当你写类似这样的东西时,你可以遵循一个更好的方法:
IAnimal animal = GetAnimal();
animal.Sound();
在这种情况下,没有人能够仅仅通过阅读代码来预先预测输出。简单来说,这段代码段暗示你向外界宣布,你通过GetAnimal()
方法得到一个动物,这个动物可以发出声音。
简而言之,本章回答了以下问题:
-
你如何进行一个测试?
-
如何为你的应用编写多态代码,为什么它更好?
-
当你写多态代码时,你如何迭代一个列表?
-
怎样才能写出更好的多态代码?
-
专家如何区分“编程实现”和“编程接口”?
二、抽象类还是接口?
在许多代码段中,您可以使用抽象类来代替 C# 接口,反之亦然。如果代码很小,并且用于执行简单的任务,您可能看不出这两种技术之间的区别。然而,当代码很大且可扩展时,它们之间的选择在性能和维护方面起着至关重要的作用。
在这一章中,我们并不主要关注抽象类和接口之间的基本区别。相反,我们讨论可以使用其中任何一种方法的代码段,编译器不会提出任何问题。然后,我们将分析如何在一些特定的场景中结合这两种技术编写一个高效的程序。
概述
雇主经常要求求职者解释抽象类和接口之间的区别。这是一个常见的问题,我希望你知道答案。在我回答这个问题和分析这两个重要话题之前,让我提醒你一些基本要点,以免将来混淆:
图 2-1
动物等级制度
-
通常,当你在子类间共享一个共同的行为时,一个抽象类最适合,但是你想保证没有人能从这个类中制造一个对象。
-
当您定义其他类扮演的“角色”时,接口是最好的,这些类是否属于同一个继承树并不重要。这是什么意思?参见下面的讨论。
-
在图 2-1 中,你可以看到
Tiger
和Dog
类继承自抽象类Animal
。这些类中有一个Sound()
方法。
图 2-2
毛绒玩具层级
-
在图 2-2 中,你可以看到
TigerToy
类和JumpingDog
类继承自SoftToys
类。这个继承层次中的每个类也包含一个Sound()
方法。 -
现在告诉我,虽然所有的
Tiger, Dog, TigerToy,
和JumpingDog
实例都可以发声,但是你应该混合它们吗?或者,你能说毛绒玩具是动物或者动物是毛绒玩具吗?不是。因为动物和毛绒玩具的等级是不同的。你不应该仅仅因为一只跳跳狗会发出声音就把它当作一只活的动物。 -
但是接口可以适合这种情况。如果你从一个接口开始,比如说,
ISound
,Tiger
类,Dog
类,TigerToy
类和JumpingDog
类可以实现这个接口,并根据需要覆盖Sound()
方法。 -
抽象类有自己的功能和用途。例如,它可以包含接口不能包含的字段和具体方法。从 C# 8.0 开始,您可以包含默认方法。但是通常一个接口就像一个包含所有抽象方法的抽象类。
-
简而言之,当您需要模拟多个类的行为时,接口是正确的选择。这是因为 C# 不支持通过类进行多重继承的概念。
Diamond Problem
设想下面的场景:假设有一个继承层次结构,其中Shape
类位于顶层。这个类有一个叫做AboutMe()
的方法。两个类,Triangle
和Rectangle
,源自Shape
。两个派生类都重新定义了AboutMe()
方法(用编程术语来说,它们为了自己的目的重写了该方法)。代码可能如下所示:
class Shape
{
public virtual void AboutMe()
{
Console.WriteLine("It is an arbitrary Shape.");
}
}
class Triangle : Shape
{
public override void AboutMe()
{
Console.WriteLine("It is a Triangle.");
}
}
class Rectangle : Shape
{
public override void AboutMe()
{
Console.WriteLine("It is a Rectangle");
}
}
现在,假设一个名为GrandShape
的新类派生自Triangle
和Rectangle
。图 2-3 显示了一个示例类图。
图 2-3
多重继承导致的钻石问题
现在我们有了一个歧义:GrandShape
会从哪个类继承或者调用AboutMe()
?是来自Triangle
还是来自Rectangle
?为了消除这种类型的模糊性,C# 不支持通过类进行多重继承的概念。这个问题有一个著名的名字:钻石问题。所以,如果你看到这样的代码:
class GrandShape: Triangle, Rectangle // Error: Diamond Effect
{
// Some code
}
您会注意到 C# 编译器向您显示了以下错误:
CS1721 Class 'GrandShape' cannot have multiple base classes: 'Triangle' and 'Rectangle'
经常有人问 C++为什么支持多继承?那里也可能存在同样的问题。为了回答这个问题,我可以分享一下我的个人观点:C# 设计者希望避免这种特性在应用中产生任何不希望的结果。他们的主要目标是使语言简单,不易出错。当您支持像这样的特殊场景时,您需要实现额外的规则来验证它们。维护这种附加规则会使编程语言变得复杂。最终,这取决于设计语言的团队。
初始程序
让我们考虑一些既能漂浮又能飞行的交通工具。因为船是浮动的,飞机是飞行的,所以我在接下来的例子中使用了一个Boat
类和一个Airplane
类。因为两者都是载体,你可以从一个接口IVehicle
开始,形成下面的继承层次:
interface IVehicle
{
void Fly();
void Float();
}
class Boat: IVehicle
{
public void Float()
{
Console.WriteLine("I like to float.");
}
public void Fly()
{
throw new NotImplementedException();
}
}
class Airplane: IVehicle
{
public void Float()
{
throw new NotImplementedException();
}
public void Fly()
{
Console.WriteLine("I like to fly.");
}
}
但是你可能更喜欢抽象类而不是接口。因此,您可以重新设计代码,如下所示:
abstract class Vehicle
{
public abstract void Float();
public abstract void Fly();
}
class Boat : Vehicle
{
public override void Float()
{
Console.WriteLine("I like to float.");
}
public override void Fly()
{
throw new NotImplementedException();
}
}
class Airplane : Vehicle
{
public override void Float()
{
throw new NotImplementedException();
}
public override void Fly()
{
Console.WriteLine("I like to fly.");
}
}
在这一点上,这两种设计可能看起来是一样的。现在,假设你需要考虑一种新的交通工具:船。你知道,如果你只考虑浮动或飞行的交通工具,你可以把一个共同的行为放在抽象类中。在我们的例子中,在这三种交通工具中,船只漂浮着,但它们不会飞。所以,你可能会想到,你可以创建一个通用的Float()
方法,并把它移到抽象类中。然后,您可以从Boat
和Ship
类中移除Fly()
方法。这也是合理的。如果你这样做了,Boat
和Ship
类可以使用基类的Float()
方法,而不用在它们内部覆盖这个方法。(显然,如果他们愿意,他们可以覆盖该行为。)
现在考虑飞机。您不能从Airplane
类中移除Fly()
方法。因此,您可以看到,如果您需要添加一种具有不同行为的新型车辆,代码的维护将变得很困难。当你有一艘船、一艘船和一架飞机时,你会发现将Float()
方法放在一个抽象类中是有益的。但是如果你有一艘船、一艘船、一架飞机、一架直升机和一枚火箭,你可能会发现在抽象类中使用Fly()
方法对你更有好处。确定哪些行为应该被认为是常见行为并不总是容易的(特别是在不断增加不同工具的应用中)。
这不是唯一需要考虑的问题。稍后,您将看到 SOLID 原则(第四章),并且您将了解到将许多不同的行为放在一个类中并不是一个好主意,即使当您在许多不同的类中有如此多的共同行为时,这种设计可能看起来很有吸引力。
现在,回到最初的代码段,您只考虑了船和飞机。在这种情况下,如果使用一个接口,就需要实现所有的接口方法。因此,由于船不会飞,您需要覆盖Boat
类中的Fly()
方法,如下所示:
public void Fly()
{
throw new NotImplementedException();
}
再说一遍,飞机在正常情况下是不会漂浮的。因此,您需要重写该方法,如下所示:
public void Float()
{
throw new NotImplementedException();
}
当您试图使用多态代码时,这种代码会产生问题。当您使用超类型引用迭代车辆并试图访问 fly 或 float 行为时,这些实现会抛出异常。例如,下列程式码会引发例外状况:
List<Vehicle> vehicles = new List<Vehicle>()
{
new Boat(),
new Airplane()
};
foreach( Vehicle vehicle in vehicles )
{
vehicle.Float();
vehicle.Fly();
}
Note
在第四章,当我讨论利斯科夫替代原理(LSP)时,你会看到对此的详细讨论。
除了刚才提到的问题,考虑一些不寻常的情况,如飞机可以漂浮。或者,考虑到技术的进步,我们可能会期待在不久的将来看到飞行汽车。这些考虑为您提供了一个线索,将行为从载体中分离出来可以帮助您维护应用。因此,让我们跳到下一节,从更好的方法开始,而不是按照最初的设计写一个完整的程序。
更好的程序
让我们假设每辆车都应该有一个国家政府的注册号码。在这种情况下,您可能会在抽象类中使用这个字段。但是如果你需要考虑不同类型的交通工具,比如飞机、轮船或小船,它们可以显示不同的行为,那么接口是更好的选择。你现在能做什么?你的猜测是正确的。您可以在应用中将抽象类与接口结合起来。如前所述,您分离车辆行为并形成不同的继承层次。这种设计有助于您为车辆添加动态行为。看看即将到来的示威游行。
示范
在这个演示中,您可以看到两种不同的继承层次结构。以下是重要的注意事项:
- 每辆车可以有不同的行为。所有这些行为形成一个层次。我假设最初,一辆车不能做任何特殊的事情。为了表示这一点,我在出生状态中添加了一个
DoNothing
行为。在稍后的阶段,浮动或飞行能力可以添加到车辆。为了表示这两种行为,我分别使用了FloatCapability
和FlyCapability
类。所以,我有一个继承层次,你可以看到一个接口ICapability
,它有三个不同的类:FloatCapability, FlyCapability,
和DoNothing
。
下面的类图展示了这个继承链(图 2-4 )。
图 2-4
所有可能的车辆行为形成一个继承层次
下面的代码段代表了这个继承链:
interface ICapability
{
void CurrentCapability();
}
class FloatCapability : ICapability
{
public void CurrentCapability()
{
Console.WriteLine("It can float now.");
}
}
class FlyCapability : ICapability
{
public void CurrentCapability()
{
Console.WriteLine("It can fly now.");
}
}
class DoNothing : ICapability
{
public void CurrentCapability()
{
Console.WriteLine("It does nothing.");
}
}
我将不同的交通工具放在它们各自独立的层次结构中。这次我从一个名为Vehicle
的抽象类开始。Boat
和Airplane
类就是从这个类派生出来的。以下是额外的注意事项:
-
我假设每辆车都有一个注册号码,一辆车在特定时间只能表现出一种行为。但是如果你愿意,你可以改变这种行为。
-
为了设置特定的行为(或能力),我使用了
SetVehicleBehavior()
方法。要显示车辆的当前细节,有一个DisplayDetails()
方法。 -
I place these methods and fields in the abstract class
Vehicle,
which is as follows:abstract class Vehicle { protected string vehicleType = String.Empty; protected ICapability vehicleBehavior; protected string registrationNumber = String.Empty; public abstract void SetVehicleBehavior(ICapability behavior); public abstract void DisplayDetails(); }
注意注意到
SetVehicleBehavior(...)
方法接受一个多态参数,它只不过是一个车辆行为。我用粗体突出显示了它。 -
我之前提到过,在出生状态下,车辆没有任何特殊行为。因此,我在出生状态中添加了
DoNothing
行为。为了说明这一点,下面是来自Boat
类构造函数的示例代码:
public Boat(string registrationId)
{
this.registrationNumber = registrationId;
this.vehicleType = "Boat";
this.vehicleBehavior = new DoNothing();
}
下面的类图总结了细节(图 2-5 ):
图 2-5
车辆、飞机和船只构成了继承层次
Point to Remember
我提醒你,如果你错误地将实例字段vehicleType, vehicleBehavior, and registrationNumber
放在一个接口中,你会看到编译时错误( CS 0525 )说:Interfaces cannot contain instance fields
。
所以,如果你想使用实例字段,你需要一个抽象类。
现在,浏览完整的实现和输出。
using System;
namespace VehicleDemo
{
interface ICapability
{
void CurrentCapability();
}
class FloatCapability : ICapability
{
public void CurrentCapability()
{
Console.WriteLine("It can float now.");
}
}
class FlyCapability : ICapability
{
public void CurrentCapability()
{
Console.WriteLine("It can fly now.");
}
}
class DoNothing : ICapability
{
public void CurrentCapability()
{
Console.WriteLine("It does nothing.");
}
}
abstract class Vehicle
{
protected string vehicleType = String.Empty;
protected ICapability vehicleBehavior;
protected string registrationNumber = String.Empty;
public abstract void SetVehicleBehavior(ICapability behavior);
public abstract void DisplayDetails();
}
class Boat:Vehicle
{
public Boat(string registrationId)
{
this.registrationNumber = registrationId;
this.vehicleType = "Boat";
this.vehicleBehavior = new DoNothing();
}
public override void SetVehicleBehavior(ICapability behavior)
{
this.vehicleBehavior = behavior;
}
public override void DisplayDetails()
{
Console.WriteLine("Current status of the boat:");
Console.WriteLine($"Registration number:{this.
registrationNumber}");
vehicleBehavior.CurrentCapability();
}
}
class Airplane : Vehicle
{
public Airplane(string registrationId)
{
this.registrationNumber = registrationId;
this.vehicleType = "Airplane";
this.vehicleBehavior = new DoNothing();
}
public override void SetVehicleBehavior(ICapability behavior)
{
this.vehicleBehavior = behavior;
}
public override void DisplayDetails()
{
Console.WriteLine("Current status of the airplane:");
Console.WriteLine($"Registration number: {this.
registrationNumber}");
vehicleBehavior.CurrentCapability();
}
}
class Program
{
static void Main(string[] args)
{
try
{
Console.WriteLine("***Vehicles demo.***");
Vehicle vehicle = new Boat("B001");
vehicle.DisplayDetails();
Console.WriteLine("****************");
ICapability currentCapability = new FloatCapability();
vehicle.SetVehicleBehavior(currentCapability);
vehicle.DisplayDetails();
Console.WriteLine("****************");
vehicle = new Airplane("A002");
currentCapability = new FlyCapability();
vehicle.SetVehicleBehavior(currentCapability);
vehicle.DisplayDetails();
Console.WriteLine("****************");
Console.WriteLine("Adding float behavior to the airplane.");
// Adding float capability to an airplane
currentCapability = new FloatCapability();
vehicle.SetVehicleBehavior(currentCapability);
vehicle.DisplayDetails();
Console.WriteLine("****************");
Console.ReadKey();
}
catch( Exception ex)
{
Console.WriteLine($"Error:{ex}");
}
}
}
}
输出
以下是输出:
***Vehicles demo.***
Current status of the boat:
Registration number: B001
It does nothing.
****************
Current status of the boat:
Registration number: B001
It can float now.
****************
Current status of the airplane:
Registration number: A002
It can fly now.
****************
Adding float behavior to the airplane.
Current status of the airplane:
Registration number: A002
It can float now.
****************
分析
继承的竞争对手是构成。当你使用对象组合时,你做了一个测试。例如,一辆汽车有一个身体,或者一个人的身体有一个头。在编程中,假设你用一个名为HUMAN
的类来表示人体,用一个名为HEAD
的类来表示人头。为了表示这一行:“一个人体有一个头”,您将在HUMAN
类中创建一个HEAD
引用。
您已经注意到,在我们的代码示例中,每辆车也有不同的行为。我们是如何表现这些行为的?每辆车都有一个独立的继承链,所有这些行为都实现了ICapability
接口。注意,Vehicle
类包含了一个ICapability
引用。这有助于车辆在特定时刻显示正确的行为。您还看到了每辆车都可以在运行时改变其行为。为了实现这些功能,您确保每个行为都正确地实现了行为接口。
这个例子向您展示了通过结合抽象类、接口和对象组合的真正力量,您可以创建一个高效的应用。
摘要
如果你想有一个集中的行为,使用抽象类。但是当您想要特定于类的实现时,请使用接口。本章回答了以下问题:
-
什么时候抽象类比接口更好?
-
什么时候接口是比抽象类更好的选择?
-
什么是钻石问题?
-
你怎么能做 HAS-A 测试呢?
-
对象组合如何提供更好的解决方案?
-
如何在运行时改变对象的行为?
-
如何将一个抽象类和一个接口结合起来制作一个高效的应用?
三、明智地使用代码注释
注释帮助你理解别人的代码。他们可以描述程序逻辑。然而,专业程序员对注释非常挑剔。出于各种原因,他们不喜欢看到不必要的评论,我们将对此进行讨论。你可能同意也可能不同意所有这些观点。本章包含的不同案例研究可以帮助你决定是否在你的申请中加入评论。
概述
在程序中使用注释是标准的做法。C# 编译器会忽略这些注释,但是它们可以帮助其他人更好地理解您的代码。让我们考虑一个真实的场景。在一个软件组织中,一群人为客户开发软件。有可能若干年后,一个都没有了。这些成员要么进入了不同的团队,要么离开了组织。在这种情况下,有人需要维护软件并继续为客户修复错误。但是如果没有关于程序逻辑的提示或解释,理解代码是非常困难的。在这种情况下,注释很有用。
在 C# 中,您会看到以下类型的注释:
Type-1: 使用双正斜杠(//)的单行注释。下面是一段以单行注释开始的代码。
// Testing whether 2 is greater than 1
Console.WriteLine(2 > 1);
Type-2: 可以使用多行注释一次注释多行。您可以用它来注释掉一组语句。下面是一段以多行注释开始的代码。
/*
Now I use multi-line comments.
It spans multiple lines.
Here I multiply 2 with 3.
*/
Console.WriteLine(2 * 3);
这些是文档注释,是包含 XML 文本的特殊注释。有两种类型:它们要么以三个斜杠(///)开头,通常称为单行文档注释,要么是以一个斜杠和两个星号(/**)开头的分隔注释。下面是一段使用单行文档注释的代码。
/// <summary>
/// <para>This is a custom class.</para>
/// <br>There is no method inside it.</br>
/// </summary>
class MyClass
{
}
下面是一段使用不同形式的代码:
/**
* <summary>
* <para>This is another custom class.</para>
* <br>It is also empty now.</br>
* </summary>
*/
class MyAnotherClass
{
}
最终,目的是一样的:注释帮助其他人理解你为什么写一段代码。
In a Nutshell
-
注释是简单的注释或一些文本。您可以将它们用于人类读者,而不是 C# 编译器。C# 编译器会忽略注释块中的文本。
-
在软件行业,许多技术评审员评审你的代码。注释有助于他们理解程序逻辑。
-
开发人员也可能在几个月后忘记这个逻辑。这些评论可以帮助他记住自己的逻辑。
初始程序
你知道当 C# 编译器看到一个注释时,它会忽略它。演示 1 是一个完整的程序,有许多不同的注释。编译并运行这个程序,以确保您看到预期的输出。
演示 1
在这个程序中,你计算一个矩形的面积。
using System;
namespace Demonstration1
{
/// <summary>
/// This is the Rectangle class
/// </summary>
class Rectangle
{
readonly double l; // length of the rectangle
readonly double b; // breadth of the rectangle
public Rectangle(double le, double br)
{
l = le;
b = br;
}
// Measuring the area
public double Area()
{
return l * b;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Measuring the area of a rectangle.***");
Rectangle r = new Rectangle(2.5, 10);
double area = r.Area();
Console.WriteLine($"The area of the rectangle is {area} square units.");
Console.ReadKey();
}
}
}
输出
以下是输出:
***Measuring the area of a rectangle.***
The area of the rectangle is 25 square units.
分析
这个程序使用不同类型的注释来解释代码。这些对项目没有任何损害。现在的问题是:它们有必要吗?你会发现很多软件行业的人不喜欢评论。他们认为普通人不会阅读你的代码。一般来说,程序员或开发人员会阅读您的代码。因此,在程序中使用过多的注释是不必要的。此外,如果您不维护旧的注释,它们可能会产生误导。我个人的信念是,如果有必要,评论是好的。我不喜欢不必要的注释,如果我的代码足够有表现力,我喜欢删除它们。
更好的程序
你能在没有注释的情况下重写演示 1 中的程序吗?是的,你可以。你可以删除所有的评论。然后,您可以编译并运行程序,以确认您得到了相同的输出。但是问题是:当你这样做的时候,你的代码是可读的吗?一个人能轻易理解吗?让我们来看看演示 2。
演示 2
这是演示 1 的修改版本。有哪些变化?注意,我已经在Rectangle
类中重命名了变量和 area 方法。这些新名字足够有表现力了。任何阅读这段代码的人都应该很清楚我的目标是什么。
using System;
namespace Demo1Modified
{
class Rectangle
{
readonly double length;
readonly double breadth;
public Rectangle(double length, double breadth)
{
this.length = length;
this.breadth = breadth;
}
public double RectangleArea()
{
return length * breadth;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Measuring the area of a rectangle.***");
Rectangle rectangleObject = new Rectangle(2.5, 10);
double area = rectangleObject.RectangleArea();
Console.WriteLine($"The area of the rectangle is {area} square units.");
Console.ReadKey();
}
}
}
分析
这个演示很容易理解。有没有注意到这次我选择了变量名length
和breadth
?在演示 1 中,我分别使用了l
(小写 L,不是 1)和b,
。为了让其他人理解这段代码,我需要编写内联注释,比如// length of the rectangle
或// breadth of the rectangle
。类似地,当我选择方法名RectangleArea(),
时,人们可以推测这个方法将要做什么。如果您要处理不同形状的区域,例如圆形、正方形或三角形,类似类型的方法名称会很有用。
使用 C# 的强大功能
有时你会看到在开始时看起来很有帮助的评论。考虑下面的类,它包含一个 TODO 注释,说明您将来不打算使用SayHello()
方法。它还建议从下一个版本开始使用SayHi()
。
class SimpleTodo
{
// TODO-We'll replace this method shortly.
// Use SayHi() from the next release(Version-2.0).
public void SayHello()
{
Console.WriteLine("Hello, Reader!");
}
public void SayHi()
{
Console.WriteLine("Hi, Reader!");
Console.WriteLine("This is the latest method.");
}
}
这个 TODO 注释似乎很有用。现在来看一个使用这些方法的示例客户端代码:
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***TODO comments example.***");
SimpleTodo simpleTodo = new SimpleTodo();
simpleTodo.SayHello();
simpleTodo.SayHi();
Console.ReadKey();
}
}
这个客户端代码很简单。这段代码没有什么特别之处。现在从公司的角度考虑:公司不与客户共享实际代码。相反,该公司告诉客户如何使用应用的功能。但是客户怎么知道你不打算从 2.0 版本开始使用SayHello()
呢?一种方法是将此信息包含在用户手册中。但是还有一种替代方法。你可以利用属性的力量。从人类行为经常抵制变化的意义上来说,这是更好的。如果他们能用老方法做工作,很可能他们会懒得去测试新方法。以下是一个示例:
class SimpleTodo
{
[ObsoleteAttribute("This method is obsolete.Call SayHi() instead.")]
public void SayHello()
{
Console.WriteLine("Hello, Reader!");
}
public void SayHi()
{
Console.WriteLine("Hi, Reader!");
Console.WriteLine("This is the latest method.");
}
}
现在,相同的客户端代码可以判断出SayHello()
已经过时,客户端应该使用SayHi()
而不是这个旧方法。图 3-1 是来自 Visual Studio IDE 的截图。
图 3-1
SayHello()方法已经过时
摘要
罗伯特·c·马丁(Robert C. Martin)的名著《干净的代码》(Clean Code)(Pearson Education,Inc .)告诉我们,“的评论总是失败的。我们必须拥有它们,因为没有它们我们总是不知道如何表达自己,但使用它们并不是值得庆祝的事情。这本书继续写道:“每次你用代码表达自己的时候,你都应该拍拍自己的背。每次写评论都要做鬼脸,感受自己表达能力的失败。”安德鲁·亨特和戴维·托马斯写的另一本伟大的书《实用程序员》告诉我们:“程序员被教导要注释他们的代码:好的代码有很多注释。不幸的是,从来没有人教过他们为什么代码需要注释:糟糕的代码需要大量的注释。”**
你可能会也可能不会总是同意这些想法,你会找到能指出正反两面的人。甚至这些书也展示了一些好的和不好的评论的好例子。
有很多实际代码很难理解的例子。在这种情况下,一些维护良好的注释可以帮助第一次阅读/开发的人。对我来说,将鼠标悬停在内置函数上有助于更好地理解它。例如,在本书的许多例子中,我已经生成了一些随机数。有一些重载的方法来完成这个活动。我经常使用下面的形式。相关的注释对于我理解这个方法是如何工作的很简单。我挑选了以下与特定版本的Next
方法相关联的内置注释。
//
// Summary:
// Returns a non-negative random integer that is less than the
// specified maximum.
//
// Parameters:
// maxValue:
// The exclusive upper bound of the random number to be generated.
// maxValue must
// be greater than or equal to 0.
//
// Returns:
// A 32-bit signed integer that is greater than or equal to 0, and less
// than maxValue;
// that is, the range of return values ordinarily includes 0 but not
// maxValue. However,
// if maxValue equals 0, maxValue is returned.
//
// Exceptions:
// T:System.ArgumentOutOfRangeException:
// maxValue is less than 0.
public virtual int Next(int maxValue);
这就是我建议您在代码中放置注释之前仔细查看并进行更多分析的原因。当它们真正有益时使用它们。当你的代码通过同行评审时,你会更好地了解什么是有用的。
Note
你应该小心。当您看到不再使用的方法或变量时,一行注释代码会变得更糟。当您使用的注释没有贴近实际代码时,也会带来麻烦。在最坏的情况下,你可能会看到给你错误信息的评论。无论如何,你都不应该让不好的或者不必要的评论出现在你的应用中。
最后一点:注释并不总是用来描述代码。您也可以在应用中看到注释掉的代码。保留注释掉的代码不是推荐的做法。但是为了进一步演示,我在书中使用了一些注释代码。例如,我可能会指出调用方法的另一种方式。有时我会保留可能输出的注释代码,以向您展示为什么这是正确的或不正确的。但是根据专家的建议,我不喜欢在企业应用中看到注释代码。这是因为如果需要,你总是可以通过一个源代码版本管理工具如 Git 或 SVN 找到旧代码。
简而言之,本章讨论了以下问题:
-
什么是代码注释?
-
有哪些不同类型的评论?
-
为什么好的评论是有益的?
-
为什么不必要的评论是不好的,你如何避免它们?
-
如何避免使用 C# 属性的普通注释?
四、了解 SOLID 原则
C# 是一种强大的语言。它支持面向对象编程,并具有无数的特性。如果我们和以前相比,在这些强大功能的支持下,编码似乎变得很容易。但是严酷的事实是:仅仅在应用中使用这些特性并不能保证你已经以正确的方式使用了它们。对于任何给定的需求,识别类、对象以及它们如何相互通信是至关重要的。此外,您的应用必须具有灵活性和可扩展性,以支持未来的增强。现在的问题是:具体的指导方针是什么?你需要跟随专家的足迹。罗伯特·塞西尔·马丁是编程界的名人。他是美国软件工程师和畅销书作家,也被称为“鲍勃叔叔”。他提出了许多原则,其中一部分如下:
-
单一责任原则
-
O 笔/闭原理(OCP)
-
L 伊斯科夫替代原理
-
接口隔离原则(ISP)
-
D 依赖反演原理(DIP)
Robert C. Martin 和 Micah Martin 在他们的书《C# 中的敏捷原则、模式和实践》中讨论了这些原则。通过取每个原则的第一个字母,Michael Feathers 引入了固体首字母缩略词来帮助记忆这些名称。
设计原则是高层次的指导方针,可以用来制作更好的软件。这些并不局限于任何特定的计算机语言。所以,如果你用 C# 理解了这些概念,你可以在类似的语言如 Java 或 C++中使用它们。参见 https://sites.google.com/site/unclebobconsultingllc/getting-a-solid-start
学罗伯特·c·马丁的思想关于这一点:
SOLID 原则不是规则。它们不是法律。它们不是完美的真理。它们是类似于“一天一个苹果,医生远离我”的陈述这是一个很好的原则,很好的建议,但它不是一个纯粹的真理,也不是一条规则。
—鲍勃大叔
在这一章中,我们将详细探讨这些原则。在每一种情况下,我都从一个可以成功编译和运行的程序开始,但是它并不遵循任何特定的设计准则。在分析部分,我们将讨论可能的缺点,并尝试使用这些原则找到更好的解决方案。这个过程可以帮助您理解这些设计准则的重要性。我提醒您,这些案例研究的目的是帮助您更好地思考和创建更好的应用,但这些并不是您在每种情况下都需要遵循的规则。
单一责任原则
一个类就像一个容器,可以容纳很多东西,比如数据、属性或方法。如果放入太多彼此不相关的数据、属性或方法,最终会得到一个庞大的类,这可能会在将来造成问题。让我们考虑一个例子。假设您创建了一个包含多个方法的类,这些方法做不同的事情。在这种情况下,即使只对一个方法做了很小的改动,也需要再次测试整个类,以确保工作流是正确的。一个方法中的更改会影响类中的其他方法。单一责任原则(SRP)反对将多种责任放在一个类中的想法。上面说 一个职业应该只有一个理由去改变 。
所以,在你上课之前,先确定课程的责任或目的。如果多个成员有助于实现一个目的,可以将所有这些成员放在类中。
Point to Remember
当您遵循 SRP 时,您的代码更小、更干净、更不脆弱。现在的问题是:你如何遵循这个原则?一个简单的答案是:你可以根据不同的职责将一个大问题分成更小的块,并将这些小部分放入单独的类中。下一个问题是:我们所说的责任是什么意思?简单来说: 责任是改变的理由 。
在接下来的讨论中,您将看到一个包含三种不同方法的类,这三种方法彼此之间没有紧密的联系。最后,我根据不同的职责分离代码,并将它们放入不同的类中。我们开始吧。
初始程序
在演示 1 中,有一个包含三种不同方法的Employee
类。以下是详细情况:
-
DisplayEmployeeDetail()
显示员工的姓名和工作年限。 -
CheckSeniority()
法可以评价一个员工是否是资深人士。我假设,如果员工有 5+年的经验,他就是高级员工;否则就是初级员工。 -
GenerateEmployeeId()
方法使用字符串连接生成雇员 ID。逻辑很简单:我将名字的第一个单词与一个随机数连接起来,形成一个员工 ID。在下面的演示中,在Main()
中,我创建了两个Employee
实例,并使用这些方法来显示相关的细节。
演示 1
这是一个不遵循 SRP 的程序。
using System;
namespace WithoutSRPDemo
{
class Employee
{
public string empFirstName, empLastName, empId;
public double experienceInYears;
public Employee(string firstName, string lastName, double experience)
{
this.empFirstName = firstName;
this.empLastName = lastName;
this.experienceInYears = experience;
}
public void DisplayEmployeeDetail()
{
Console.WriteLine($"The employee name: {empLastName}, {empFirstName}");
Console.WriteLine($"This employee has {experienceInYears} years of experience.");
}
public string CheckSeniority(double experienceInYears)
{
if (experienceInYears > 5)
return "senior";
else
return "junior";
}
public string GenerateEmployeeId(string empFirstName)
{
int random = new System.Random().Next(1000);
empId = String.Concat(empFirstName[0], random);
return empId;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** A demo without SRP.***");
Employee robin = new Employee("Robin", "Smith", 7.5);
robin.DisplayEmployeeDetail();
string empId = robin.GenerateEmployeeId(robin.empFirstName);
Console.WriteLine($"The employee id: {empId}");
Console.WriteLine($"This employee is a " +
$"{robin.CheckSeniority(robin.experienceInYears)} employee.");
Console.WriteLine("\n*******\n");
Employee kevin = new Employee("Kevin", "Proctor", 3.2);
kevin.DisplayEmployeeDetail();
empId = kevin.GenerateEmployeeId(kevin.empFirstName);
Console.WriteLine($"The employee id: {empId}");
Console.WriteLine($"This employee is a " +
$"{kevin.CheckSeniority(kevin.experienceInYears)} employee.");
Console.ReadKey();
}
}
}
输出
下面是一个示例输出(员工 ID 可能因您的情况而异)。
*** A demo without SRP.***
The employee name: Smith, Robin
This employee has 7.5 years of experience.
The employee id: R586
This employee is a senior employee.
*******
The employee name: Proctor, Kevin
This employee has 3.2 years of experience.
The employee id: K459
This employee is a junior employee.
分析
这个设计有什么问题?这个回答就是我在这里违反了 SRP。显示员工详细信息、生成员工 ID 和检查资历级别都是不同的活动。因为我把所有的东西都放在一个类中,所以当我将来采用变化时可能会面临问题,比如如果高层管理人员设定了一个不同的标准来决定资历级别。也可以使用复杂的算法来生成雇员 ID。在每种情况下,您都需要修改Employee
类等等。您现在可以看到,最好遵循 SRP 并将活动分开。
更好的程序
在下面的演示中,我将介绍另外两个类。SeniorityChecker
类现在包含了CheckSeniority()
方法,而EmployeeIdGenerator
类包含了生成雇员 ID 的GenerateEmployeeId()
方法。因此,将来如果您需要更改程序逻辑来确定资历级别,或者使用新算法来生成员工 ID,您可以在相应的类中进行更改。其他类没有被修改,所以您不需要重新测试它们。
这一次,我也提高了代码的可读性。注意,在演示 1 中,我调用了Main()
中所有需要的方法。但是为了更好的可读性和避免在Main()
中的笨拙,这次我引入了三个静态方法:PrintEmployeeDetail(...), PrintEmployeeId(...),
和PrintSeniorityLevel(...).
,这些方法分别称为DisplayEmployeeDetail()
方法、GenerateEmployeeId()
方法和CheckSeniority()
方法。这三种方法不是必需的,但是它们使客户端代码简单易懂。
演示 2
以下是 SRP 之后的完整演示:
using System;
namespace SRPDemo
{
class Employee
{
public string empFirstName, empLastName;
public double experienceInYears;
public Employee(string firstName, string lastName, double experience)
{
this.empFirstName = firstName;
this.empLastName = lastName;
this.experienceInYears = experience;
}
public void DisplayEmployeeDetail()
{
Console.WriteLine($"The employee name: {empLastName}, {empFirstName}");
Console.WriteLine($"This employee has {experienceInYears} years of experience.");
}
}
class SeniorityChecker
{
public string CheckSeniority(double experienceInYears)
{
if (experienceInYears > 5)
return "senior";
else
return "junior";
}
}
class EmployeeIdGenerator
{
string empId;
public string GenerateEmployeeId(string empFirstName)
{
int random = new System.Random().Next(1000);
empId = String.Concat(empFirstName[0], random);
return empId;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** A demo that follows SRP.***");
Employee robin = new Employee("Robin", "Smith", 7.5);
PrintEmployeeDetail(robin);
PrintEmployeeId(robin);
PrintSeniorityLevel(robin);
Console.WriteLine("\n*******\n");
Employee kevin = new Employee("Kevin", "Proctor", 3.2);
PrintEmployeeDetail(kevin);
PrintEmployeeId(kevin);
PrintSeniorityLevel(kevin);
Console.ReadKey();
}
private static void PrintEmployeeDetail(Employee emp)
{
emp.DisplayEmployeeDetail();
}
private static void PrintEmployeeId(Employee emp)
{
EmployeeIdGenerator idGenerator = new EmployeeIdGenerator();
string empId = idGenerator.GenerateEmployeeId(emp.empFirstName);
Console.WriteLine($"The employee id: {empId}");
}
private static void PrintSeniorityLevel(Employee emp)
{
SeniorityChecker seniorityChecker = new SeniorityChecker();
string seniorityLevel = seniorityChecker.CheckSeniority(emp.experienceInYears);
Console.WriteLine($"This employee is a {seniorityLevel} employee.");
}
}
}
输出
这是输出。注意,它类似于前面的输出,除了第一行声明这个程序现在遵循 SRP。(员工 ID 可能因您的情况而异)。
*** A demo that follows SRP.***
The employee name: Smith, Robin
This employee has 7.5 years of experience.
The employee id: R841
This employee is a senior employee.
*******
The employee name: Proctor, Kevin
This employee has 3.2 years of experience.
The employee id: K676
This employee is a junior employee.
Point to Note
注意,SRP 并不坚持一个类最多应该有一个方法。这里强调的是单一责任。可能有一些密切相关的方法可以帮助你履行职责。例如,如果您有不同的方法来显示名字、姓氏和全名,您可以将这些方法放在同一个类中。这些方法密切相关,将所有这些显示方法放在同一个类中是有意义的。
此外,你不应该得出结论,你必须总是分开的责任,在每一个应用,你作出的。你需要分析变化的本质。拥有太多的类会使应用变得复杂,难以维护。但是如果你知道这个原则,并且在实现一个设计之前仔细思考,你就有可能避免我前面讨论的错误。
开放/封闭原则(OCP)
在这一节中,我们将详细研究开/闭原理(OCP)。它起源于伯特兰·迈耶的作品。
在所有面向对象设计的原则中,这是最重要的。
—罗伯特·马丁
这个原则说,一个软件实体(类、模块、方法等)。)应该对扩展开放,但对修改关闭。这种设计理念背后的思想是,在一个稳定的工作应用中,一旦创建了一个类,并且应用的其他部分开始使用它,该类中的任何进一步变化都会导致工作应用中断。如果您需要新的特性(或功能),您可以扩展现有的类以适应这些新的需求,而不是更改现有的类。有什么好处?因为您没有更改旧代码,所以您的旧功能继续工作,没有任何问题,并且您可以避免再次测试它们。相反,您只需要测试“扩展的”部分(或功能)。
在 1988 年,Bertrand Meyer 建议在这种情况下使用继承,他说了下面的话:“一个类是封闭的,因为它可能被编译、存储在库中、被基线化以及被客户类使用。但是它也是开放的,因为任何新的类都可以使用它作为父类,增加新的特性。当定义了一个子类时,就不需要改变原来的类或打扰它的客户了。
但是继承促进了紧密耦合。在编程中,我们喜欢去掉这些紧密的耦合。罗伯特·c·马丁改进了这个定义,使之成为多态 OCP。新提议使用抽象基类,使用协议而不是超类来允许不同的实现。这些协议对于修改是封闭的,并且提供了另一个抽象层次,这使得松散耦合成为可能。
在这一章,我们遵循马丁的想法,促进多态 OCP。
Note
在本书的最后一章,我描述了一些常见的术语,包括“内聚”和“耦合”如果需要,您现在可以快速浏览一下。
初始程序
假设有一小组学生参加了认证考试。为了证明这一点,我选择了少量的参与者。小尺寸有助于您专注于原则,而不是不必要的细节。Sam, Bob, John,
和Kate
是这个例子中的四个学生。他们都属于Student
阶层。要创建一个Student
类实例,您需要提供一个姓名、注册号和考试成绩。你也提到一个学生是属于理科还是文科。因此,您将在接下来的示例中看到以下代码行:
Student sam = new Student("Sam", "R001", 81.5,"Science");
Student bob = new Student("Bob", "R002", 72,"Science");
Student john = new Student("John", "R003",71,"Arts");
Student kate = new Student("Kate", "R004", 66.5,"Arts");
假设您从两个实例方法开始。DisplayResult()
显示学生所有必要细节的结果,而EvaluateDistinction()
方法评估学生是否有资格获得优秀证书。我假设如果一个科学系的学生在这次考试中得分在 80 分以上,他或她会以优异的成绩获得证书。但是对艺术系学生的标准稍微宽松了一些。如果一个艺术生的分数在 70 分以上,他或她就能得到这一殊荣。
我假设您在这一点上理解 SRP,所以您知道不应该将DisplayResult()
和EvaluateDistinction()
放在同一个类中,如下所示:
class Student
{
readonly string name;
readonly string registrationNumber;
readonly string department;
readonly double score;
public Student(string name, string registrationNumber, double score, string department)
{
this.name = name;
this.registrationNumber = registrationNumber;
this.score = score;
this.department = department;
}
public void DisplayResult()
{
Console.WriteLine($"Name:{this.name} \nReg Number:{this.registrationNumber} " +
$"\nDept:{this.department} \nscore: {this.score}");
Console.WriteLine("*************");
}
public void EvaluateDistinction()
{
if (this.score > 80 && this.department == "Science")
{
Console.WriteLine($"{this.registrationNumber} has passed with distinction.");
}
if (this.score > 70 && this.department == "Arts")
{
Console.WriteLine($"{this.registrationNumber} has passed with distinction.");
}
}
}
这段代码有什么问题?
-
首先,请注意,当我在
Student
类中放置了DisplayResult()
和EvaluateDistinction()
方法时,我违反了 SRP。 -
将来,审查机关可以改变区分标准。在这种情况下,你需要改变
EvaluateDistinction()
方法。这段代码解决问题了吗?在目前的情况下,答案是肯定的。但是大学当局可以再次改变区分标准。你会修改多少次EvaluateDistinction()
方法? -
请记住,每次您修改方法时,您也需要编写/修改现有的测试用例。
可以看到,每次区分标准发生变化,都需要修改Student
类中的EvaluateDistinction()
方法。 所以,该班不遵循 SRP,也不关闭修改 。
一旦你理解了这些问题,你就可以开始一个遵循 SRP 的更好的设计。以下是该设计的主要特点:
-
在下面的程序中,
Student
和DistinctionDecider
是两个不同的类。 -
DistinctionDecider
类包含了the EvaluateDistinction()
方法。 -
为了显示学生的详细信息,您可以覆盖
ToString()
方法,而不是使用单独的方法DisplayResult().
,因此,在Student
类中,您现在可以看到ToString()
方法。 -
在
Main()
中,您会看到下面一行:List<Student> enrolledStudents = MakeStudentList();
MakeStudentList() method
创建一个学生列表。这有助于避免每个学生重复编写代码。你把这个列表传到DisplayStudentResults()
里面,把学生的详细资料一个一个打印出来。您还可以使用同一个列表调用EvaluateDistinction()
来识别获得优异成绩的学生。
演示 3
这是完整的演示。
using System;
using System.Collections.Generic;
namespace WithoutOCPDemo
{
class Student
{
internal string name;
internal string registrationNumber;
internal string department;
internal double score;
public Student(string name,
string registrationNumber,
double score,
string department)
{
this.name = name;
this.registrationNumber = registrationNumber;
this.score = score;
this.department = department;
}
public override string ToString()
{
return ($"Name: {this.name} " +
$"\nReg Number: {this.registrationNumber} " +
$"\nDept: {this.department} " +
$"\nscore: {this.score}" +
$"\n*******");
}
}
class DistinctionDecider
{
public void EvaluateDistinction(Student student)
{
if (student.department == "Science")
{
if (student.score > 80)
{
Console.WriteLine($"{student.registrationNumber} has received a distinction in science.");
}
}
if (student.department == "Arts")
{
if (student.score > 70)
{
Console.WriteLine($"{student.registrationNumber} has received a distinction in arts.");
}
}
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** A demo without OCP.***");
List<Student> enrolledStudents = MakeStudentList();
// Display results.
Console.WriteLine("===Results:===");
foreach(Student student in enrolledStudents)
{
Console.WriteLine(student);
}
// Evaluate distinctions.
DistinctionDecider distinctionDecider = new DistinctionDecider();
Console.WriteLine("===Distinctions:===");
foreach (Student student in enrolledStudents)
{
distinctionDecider.EvaluateDistinction(student);
}
Console.ReadKey();
}
private static List<Student> MakeStudentList()
{
Student sam = new Student("Sam", "R001", 81.5, "Science");
Student bob = new Student("Bob", "R002", 72, "Science");
Student john = new Student("John", "R003", 71, "Arts");
Student kate = new Student("Kate", "R004", 66.5, "Arts");
List<Student> students = new List<Student>();
students.Add(sam);
students.Add(bob);
students.Add(john);
students.Add(kate);
return students;
}
}
}
输出
以下是输出:
*** A demo without OCP.***
===Results:===
Name: Sam
Reg Number: R001
Dept: Science
score: 81.5
*******
Name: Bob
Reg Number: R002
Dept: Science
score: 72
*******
Name: John
Reg Number: R003
Dept: Arts
score: 71
*******
Name: Kate
Reg Number: R004
Dept: Arts
score: 66.5
*******
===Distinctions:===
R001 has received a distinction in science.
R003 has received a distinction in arts.
分析
现在您已经了解了 SRP。如果将来审查机构改变了区分标准,您不必接触Student
类。所以,这部分关闭修改。它解决了问题的一部分。现在考虑另一个未来的可能性:学院当局可以引入一个新的流,如商业,并为这个流设置一个新的区分标准。
你需要再做一些明显的改变。例如,您需要修改EvaluateDistinction()
方法并添加另一个if
语句来考虑商科学生。现在的问题是:以这种方式修改EvaluateDistinction()
方法可以接受吗?请记住,每次修改方法时,您都需要重新编写/修改整个代码工作流。
你现在明白问题了。在这个演示中,每次区分标准改变时,您都需要修改DistinctionDecider
类中的EvaluateDistinction()
方法。 所以,这个班没有关闭进行改装 。
更好的程序
为了解决这个问题,你可以写一个更好的程序。下面的程序展示了这样一个例子,并遵循 OCP 原则,该原则建议我们 编写代码段(如类或方法),这些代码段对扩展是开放的,但对修改是关闭的 。
Note
OCP 可以通过不同的方式实现,但是抽象是这个原则的核心。如果你能按照 OCP 设计你的应用,你的应用将是灵活的和可扩展的。完全实施这一原则并不容易,但是部分遵守 OCP 协议也能为您带来更大的好处。还要注意,我是按照 SRP 开始演示 3 的。如果你不遵循 OCP,你可能会得到一个执行多个任务的类,这意味着 SRP 被破坏了。
这一次,我们需要以更好的方式解决区分的评估方法。因此,我创建了一个包含方法EvaluateDistinction
的接口IDistinctionDecider
。下面是界面:
interface IDistinctionDecider
{
void EvaluateDistinction(Student student);
}
ArtsDistinctionDecider
和ScienceDistinctionDecider
实现了这个接口,并覆盖了IDistinctionDecider
方法来服务于它们的目的。这是它的代码段。每个类别的不同标准以粗体显示:
class ArtsDistinctionDecider : IDistinctionDecider
{
public void EvaluateDistinction(Student student)
{
if (student.score > 70)
{
Console.WriteLine($"{student.registrationNumber} has got distinction in arts.");
}
}
}
class ScienceDistinctionDecider : IDistinctionDecider
{
public void EvaluateDistinction(Student student)
{
if (student.score > 80)
{
Console.WriteLine($"{student.registrationNumber} has distinction in science.");
}
}
}
前面的代码段清楚地显示了不同流中的区分标准。所以,我现在从Student
类中移除了department
字段。剩下的代码很简单,理解下面的演示应该没有任何困难。
演示 4
下面是修改后的程序:
using System;
using System.Collections.Generic;
namespace OCPDemo
{
class Student
{
internal string name;
internal string registrationNumber;
internal double score;
public Student(string name,
string registrationNumber,
double score
)
{
this.name = name;
this.registrationNumber = registrationNumber;
this.score = score;
}
public override string ToString()
{
return(
$"Name: {this.name} " +
$"\nReg Number: {this.registrationNumber} " +
$"\nscore: {this.score}\n*******");
}
}
interface IDistinctionDecider
{
void EvaluateDistinction(Student student);
}
class ArtsDistinctionDecider : IDistinctionDecider
{
public void EvaluateDistinction(Student student)
{
if (student.score > 70)
{ Console.WriteLine($"{student.registrationNumber} has received a distinction in arts.");
}
}
}
class ScienceDistinctionDecider : IDistinctionDecider
{
public void EvaluateDistinction(Student student)
{
if (student.score > 80)
{ Console.WriteLine($"{student.registrationNumber} has received a distinction in science.");
}
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** A demo that follows OCP.***");
List<Student> scienceStudents = MakeScienceStudentList();
List<Student> artsStudents = MakeArtsStudentList();
// Display results.
Console.WriteLine("===Results:===");
foreach (Student student in scienceStudents)
{
Console.WriteLine(student);
}
foreach (Student student in artsStudents)
{
Console.WriteLine(student);
}
// Evaluate distinctions.
Console.WriteLine("===Distinctions:===");
// For science students.
IDistinctionDecider distinctionDecider = new ScienceDistinctionDecider();
foreach (Student student in scienceStudents)
{
distinctionDecider.EvaluateDistinction(student);
}
// For arts students.
distinctionDecider = new ArtsDistinctionDecider();
foreach (Student student in artsStudents)
{
distinctionDecider.EvaluateDistinction(student);
}
Console.ReadKey();
}
private static List<Student> MakeScienceStudentList()
{
Student sam = new Student("Sam", "R001", 81.5);
Student bob = new Student("Bob", "R002", 72);
List<Student> students = new List<Student>();
students.Add(sam);
students.Add(bob);
return students;
}
private static List<Student> MakeArtsStudentList()
{
Student john = new Student("John", "R003", 71);
Student kate = new Student("Kate", "R004", 66.5);
List<Student> students = new List<Student>();
students.Add(john);
students.Add(kate);
return students;
}
}
}
输出
请注意,输出是相同的,除了第一行,它声明这个程序遵循 OCP。
***A demo that follows OCP.***
===Results:===
Name: Sam
Reg Number: R001
score: 81.5
*******
Name: Bob
Reg Number: R002
score: 72
*******
Name: John
Reg Number: R003
score: 71
*******
Name: Kate
Reg Number: R004
score: 66.5
*******
===Distinctions:===
R001 has received a distinction in science.
R003 has received a distinction in arts.
分析
现在的关键优势是什么?
-
Student
类和IDistinctionDecider
对于区分标准的任何未来变化都是不可改变的。它们因修改而关闭。 -
请注意,每个参与者都遵循 SRP。
-
如果您考虑来自不同流的学生,比如商业,您可以添加一个新的派生类,比如说,
CommerceDistinctionDecider
,它实现了IDistinctionDecider
接口,并且您可以为商业学生设置新的区分标准。 -
使用这种方法,您可以避免一个
if-else
链(如演示 3 所示)。如果考虑到商业等新领域,这个链条可能会增长。在这种情况下,避免大的if-else
链被认为是更好的实践。
Note
为了描述这个原理,我使用了类的概念。应该注意的是,罗伯特·c·马丁是用模块来描述这个原理的。如果你纯粹从 C# 的角度来考虑,术语“模块”可能会令人困惑。比如描述System.Reflection
中的Module
类,微软文档中说(参见 https://docs.microsoft.com/en-us/dotnet/api/system.reflection.module?view=net-5.0
):模块是一个可移植的可执行文件,比如 type.dll 或 application.exe,由一个或多个类和接口组成。这个文档还说. NET Framework 模块不同于 Visual Basic 中的模块,Visual Basic 是程序员用来组织应用中的函数和子例程的。类似地,任何 Python 程序员都知道一个模块可以包含很多东西。例如,为了组织他的代码,他可以将变量、函数和类放在一个模块中。为此,他创建了一个扩展名为. py 的单独文件。稍后,他可以从当前文件中的模块导入整个模块或某个特定的函数。
是时候研究下一个原理了。
利斯科夫替代原理
这个原则最初是在 1988 年 Barbara Liskov 的工作中提出的。利斯科夫替换原理 (LSP)说你应该可以用子类型 替换父(或基)类型。换句话说,在一个程序段中,你可以使用一个派生类来代替它的基类,而不会改变程序的正确性。
**回想一下你是如何使用继承的?有一个基类,您可以从它创建一个(或多个)派生类。然后,您可以在派生类中添加新方法。只要用派生类对象直接使用派生类方法,一切都没问题。但是,如果您试图在不遵循 LSP 的情况下获得多态行为,可能会出现问题。怎么做?假设有两个类,B 是基类,D 是(B 的)子类。此外,假设有一个方法接受 B 的引用作为参数。这种方法效果很好。但是如果你传递一个 D 引用而不是 B 给这个方法,这个方法可能会出错。如果你不遵守 LSP,就会发生这种情况。
Note
多态代码展示了你的专业知识,但是记住正确实现多态行为和避免不必要的结果是开发者的责任。
在这一章中,我通过两个案例来帮助你理解这个原则。在第一个案例研究中,我从一个类Rectangle
开始,它有一个构造函数和一个名为ShowArea()
的方法。在构造函数内部,我显示了一个Rectangle
实例的长度和宽度。ShowArea()
方法显示矩形对象的面积。在Main()
方法中,我创建了两个Rectangle
实例并调用了ShowArea()
方法。在这个程序中,您不需要提供矩形的宽度。这是因为默认情况下需要两个单位。因此,您可以看到这段代码中的以下两行都工作正常:
IRectangle shape = new Rectangle(10, 5.5);
shape = new Rectangle(25);
以下是完整的程序:
using System;
namespace UnderstandingLSP
{
class Rectangle
{
protected double length, breadth;
public Rectangle(double length,
double breadth=2)
{
this.length = length;
this.breadth = breadth;
Console.WriteLine($"Length = {length} units.");
Console.WriteLine($"Breadth = {breadth} units.");
}
public virtual void ShowArea()
{
Console.WriteLine($"Area = {length * breadth} sq. units.");
Console.WriteLine("----------");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Understanding LSP.***");
Console.WriteLine("Rectangle-1:");
Rectangle rectangle = new Rectangle(10, 5.5);
rectangle.ShowArea();
Console.WriteLine("Rectangle-2:");
rectangle = new Rectangle(25);
rectangle.ShowArea();
Console.ReadKey();
}
}
}
运行该程序时,您会看到以下输出:
***Understanding LSP.***
Rectangle-1:
Length = 10 units.
Breadth = 5.5 units.
Area = 55 sq. units.
----------
Rectangle-2:
Length = 25 units.
Breadth = 2 units.
Area = 50 sq. units.
----------
程序很简单,输出也很直观。让我们假设你现在考虑正方形。你知道正方形可以被认为是一种特殊类型的矩形。因此,您覆盖了Square
类中的ShowArea()
方法,并编写了这段额外的代码:
class Square : Rectangle
{
public Square(double length) :
base(length)
{
}
public override void ShowArea()
{
Console.WriteLine($"Area = {length * length} sq. units.");
Console.WriteLine("----------");
}
}
现在,您创建一个正方形,并将其添加到旧的矩形列表中:
rectangle = new Square(25);
rectangle.ShowArea();
下面是Main()
方法,供您直接参考(见粗体字中的更改):
static void Main(string[] args)
{
Console.WriteLine("***Understanding LSP.***");
Console.WriteLine("Rectangle-1:");
Rectangle rectangle = new Rectangle(10, 5.5);
rectangle.ShowArea();
Console.WriteLine("Rectangle-2:");
rectangle = new Rectangle(25);
rectangle.ShowArea();
Console.WriteLine("Rectangle-3:");
rectangle = new Square(25);
rectangle.ShowArea();
Console.ReadKey();
}
现在再次运行程序。这一次,您会看到以下输出:
***Understanding LSP.***
Rectangle-1:
Length = 10 units.
Breadth = 5.5 units.
Area = 55 sq. units.
----------
Rectangle-2:
Length = 25 units.
Breadth = 2 units.
Area = 50 sq. units.
----------
Rectangle-3:
Length = 25 units.
Breadth = 2 units.
Area = 625 sq. units.
----------
请注意输出的最后部分。任何看到这个输出的人都会感到困惑。为什么?我们知道像正方形这样的特殊矩形需要不同的公式。不正确的宽度值导致了这种混乱。用户可能认为他或她只与正确的矩形交互。同样,如果一个第三方团队使用Assert
语句编写测试用例来验证ShowArea()
,它很可能会失败。为什么?看到这个的第三方测试人员可能会假设传统的矩形,而不是特定类型的矩形。你明白其中的道理:当你考虑一个正方形的时候,它的长和宽是一样的,它们一起变化,而对于一个长方形来说就不是这样了。简单来说,当你有一个传统的矩形,你需要长度和宽度来计算面积。但是对于一个正方形来说,长和宽是一样的;所以,只有一个就够了。在这个程序中,你不能简单地用矩形代替正方形,反之亦然。当你的矩形是一种特殊类型时(例如,当它是正方形时),你需要使用一些编程逻辑。希望你明白这个设计的问题!
Note
既然正方形是矩形,那么一个Square
类应该继承一个Rectangle
类,这样想不好吗?理想情况下,答案是否定的。问题不在于 IS-A 测试;潜在的问题在于“应该”这个词。当你设计一个Square
类时,你并不总是需要从一个Rectangle
类开始并继承它。此外,一旦你学习了 LSP,你会发现一些“特殊问题”是特定于代码的,你不能预先预测所有的事情。让我们假设你以这样一种方式重写你的Square
类,一旦用户提供长度,宽度取相同的值(反之亦然),你的代码现在对矩形和正方形都工作良好。即使在这种情况下,您也可能会看到“不想要的”结果。例如,假设您有一个方法,它将一个Rectangle
实例作为参数,并更改该实例的长度或宽度值。现在,如果不是传递一个Rectangle
实例,而是传递一个Square
实例,这个方法将破坏Square
对象,因为它只改变长度或宽度,而不是两者。我们可以进一步解决这个问题。但是要记住,在编程中,派生类的变化不应该引起基类的变化。如果错误地发生了,程序也违反了 OCP。因为你事先并不知道所有的事情,所以在某些特定的情况下,反过来做是有意义的。
初始程序
让我们考虑一个更好的场景。假设您有一个支付门户。在这个门户网站中,注册用户可以提出付款请求。为此,您使用了方法ProcessNewPayment()
。在此门户中,您还可以显示用户的上次付款详细信息。为此,您使用方法LoadPreviousPaymentInfo()
。下面是一段示例代码:
interface IUser
{
void LoadPreviousPaymentInfo();
void ProcessNewPayment();
}
class RegisteredUser : IUser
{
string name = String.Empty;
public RegisteredUser(string name)
{
this.name = name;
}
public void LoadPreviousPaymentInfo()
{
Console.WriteLine($"Welcome {name}. Here is your last payment details.");
}
public void ProcessNewPayment()
{
Console.WriteLine($"Processing {name}'s current payment request.");
}
}
此外,让我们假设您创建了一个助手类UserManagementHelper
来显示这些用户所有以前的付款和新的付款请求。你使用ShowPreviousPayments()
和ProcessNewPayments()
进行这些活动。这些方法调用foreach
循环中相应IUser
实例上的LoadPreviousPaymentInfo()
和ProcessNewPayment()
方法。下面是这段代码:
class UserManagementHelper
{
List<IUser> users = new List<IUser>();
public void AddUser(IUser user)
{
users.Add(user);
}
public void ShowPreviousPayments()
{
foreach (IUser user in users)
{
user.LoadPreviousPaymentInfo();
Console.WriteLine("------");
}
}
public void ProcessNewPayments()
{
foreach (IUser user in users)
{
user.ProcessNewPayment();
Console.WriteLine("***********");
}
}
}
在客户端代码中,您创建了两个用户,并显示了他们当前的付款请求以及以前的付款。到目前为止一切正常。
演示 5
现在进行完整的演示。
using System;
using System.Collections.Generic;
namespace WithoutLSPDemo
{
interface IUser
{
void LoadPreviousPaymentInfo();
void ProcessNewPayment();
}
class RegisteredUser : IUser
{
string name = String.Empty;
public RegisteredUser(string name)
{
this.name = name;
}
public void LoadPreviousPaymentInfo()
{
Console.WriteLine($"Welcome, {name}. Here are your last payment details.");
}
public void ProcessNewPayment()
{
Console.WriteLine($"Processing {name}'s current payment request.");
}
}
class UserManagementHelper
{
List<IUser> users = new List<IUser>();
public void AddUser(IUser user)
{
users.Add(user);
}
public void ShowPreviousPayments()
{
foreach (IUser user in users)
{
user.LoadPreviousPaymentInfo();
Console.WriteLine("------");
}
}
public void ProcessNewPayments()
{
foreach (IUser user in users)
{
user.ProcessNewPayment();
Console.WriteLine("***********");
}
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***A demo without LSP.***");
UserManagementHelper helper = new UserManagementHelper();
// Instantiating two registered users
RegisteredUser robin = new RegisteredUser("Robin");
RegisteredUser jack = new RegisteredUser("Jack");
// Adding the users to usermanager
helper.AddUser(robin);
helper.AddUser(jack);
// Processing the payments using
// the helper class.
helper.ShowPreviousPayments();
helper.ProcessNewPayments();
Console.ReadKey();
}
}
}
输出
以下是输出:
***A demo without LSP.***
Welcome, Robin. Here are your last payment details.
------
Welcome, Jack. Here are your last payment details.
------
Processing Robin's current payment request.
***********
Processing Jack's current payment request.
***********
这个节目好像没问题。现在假设您也需要支持来宾用户。您知道您可以处理一个客人用户的付款请求,但是您不会显示他最后的付款细节。因此,您创建了以下实现IUser
的类:
class GuestUser : IUser
{
string name = String.Empty;
public GuestUser()
{
this.name = "guest user";
}
public void LoadPreviousPaymentInfo()
{
throw new NotImplementedException();
}
public void ProcessNewPayment()
{
Console.WriteLine($"Processing {name}'s current payment request.");
}
}
在Main()
中,您现在创建一个 guest 用户实例,并尝试以同样的方式使用您的助手类。这是新的客户端代码(注意粗体显示的变化)。为了让您更容易理解,我添加了一个注释,以引起您对现在导致问题的代码的注意。
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***A demo without LSP.***");
UserManagementHelper helper = new UserManagementHelper();
// Instantiating two registered users
RegisteredUser robin = new RegisteredUser("Robin");
RegisteredUser jack = new RegisteredUser("Jack");
// Adding the users to usermanager
helper.AddUser(robin);
helper.AddUser(jack);
GuestUser guestUser1 = new GuestUser();
helper.AddUser(guestUser1);
// Processing the payments using
// the helper class.
// You can see the problem now.
helper.ShowPreviousPayments();
helper.ProcessNewPayments();
Console.ReadKey();
}
}
这一次,你得到一个惊喜,遇到一个异常。见图 4-1 。
图 4-1
程序遇到 NotImplementedException
虽然GuestUser
实现了IUser
,却导致了UserManagementHelper
的破裂。以下循环:
foreach (IUser user in registeredUsers)
{
user.LoadPreviousPaymentInfo();
Console.WriteLine("------");
}
导致了这个麻烦。在每次迭代中,您在各自的IUser
对象上调用方法LoadPreviousPaymentInfo()
,并且为GuestUser
实例引发异常。由于GuestUser
违反了 LSP,先前的工作方案现在不起作用。解决办法是什么?转到下一部分。
更好的程序
您想到的第一个显而易见的解决方案是使用if-else
链来验证IUser
实例是GuestUser
还是RegisteredUser
。这是一个糟糕的解决方案,因为如果你有另一个特殊类型的用户,你需要在if-else
链中再次验证它。 最重要的是,每次使用这个 if-else 链 修改现有的类时,都会违反 OCP。因此,让我们寻找更好的解决办法。
在接下来的程序中,我从IUser
接口中移除了ProcessNewPayment()
方法。我将这个方法放到另一个接口INewPayment
中。因此,现在我有两个具体的操作界面。因为所有类型的用户都可以提出新的支付请求,所以具体的类RegisteredUser
和GuestUser
都实现了INewPayment
接口。但是您只显示了注册用户的最后一次付款细节。所以,RegisteredUser
类实现了IUser
接口。我一直提倡一个合适的名字。由于IUser
包含了LoadPreviousPaymentInfo()
方法,所以选择一个更好的名字是有意义的,比如说IPreviousPayment
而不是IUser
。我也在助手类中调整了这些新名字。
演示 6
下面是修改后的实现。
using System;
using System.Collections.Generic;
namespace LSPDemo
{
interface IPreviousPayment
{
void LoadPreviousPaymentInfo();
}
interface INewPayment
{
void ProcessNewPayment();
}
class RegisteredUser : IPreviousPayment, INewPayment
{
string name = String.Empty;
public RegisteredUser(string name)
{
this.name = name;
}
public void LoadPreviousPaymentInfo()
{
Console.WriteLine($"Welcome, {name}. Here are your last payment details.");
}
public void ProcessNewPayment()
{
Console.WriteLine($"Processing {name}'s current payment request.");
}
}
class GuestUser : INewPayment
{
string name = String.Empty;
public GuestUser()
{
this.name = "guest user";
}
public void ProcessNewPayment()
{
Console.WriteLine($"Processing a {name}'s current payment request.");
}
}
class UserManagementHelper
{
List<IPreviousPayment> previousPayments = new List<IPreviousPayment>();
List<INewPayment> newPaymentRequests = new List<INewPayment>();
public void AddPreviousPayment(IPreviousPayment previousPayment)
{
previousPayments.Add(previousPayment);
}
public void AddNewPayment(INewPayment newPaymentRequest)
{
newPaymentRequests.Add(newPaymentRequest);
}
public void ShowPreviousPayments()
{
foreach (IPreviousPayment user in previousPayments)
{
user.LoadPreviousPaymentInfo();
Console.WriteLine("------");
}
}
public void ProcessNewPayments()
{
foreach (INewPayment payment in newPaymentRequests)
{
payment.ProcessNewPayment();
Console.WriteLine("***********");
}
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***A demo that follows LSP.***");
UserManagementHelper helper = new UserManagementHelper();
// Instantiating two registered users.
RegisteredUser robin = new RegisteredUser("Robin");
RegisteredUser jack = new RegisteredUser("Jack");
// Adding the info to helper.
helper.AddPreviousPayment(robin);
helper.AddPreviousPayment(jack);
helper.AddNewPayment(robin);
helper.AddNewPayment(jack);
// Instantiating a guest user.
GuestUser guestUser1 = new GuestUser();
helper.AddNewPayment(guestUser1);
// Retrieve all the previous payments
// of registered users.
helper.ShowPreviousPayments();
// Process all new payment requests
// from all users.
helper.ProcessNewPayments();
Console.ReadKey();
}
}
}
输出
以下是输出:
***A demo
that follows LSP.***
Welcome, Robin. Here are your last payment details.
------
Welcome, Jack. Here are your last payment details.
------
Processing Robin's current payment request.
***********
Processing Jack's current payment request.
***********
Processing a guest user's current payment request.
***********
分析
有哪些关键的变化?注意,在演示 5 中,ShowPreviousPayments()
和ProcessNewPayments()
都接受了IUser
实例作为参数。现在ShowPreviousPayments()
接受IPreviousPayment
实例,ProcessNewPayments()
接受INewPayment
实例作为参数。这个新结构解决了我们在演示 5 中面临的问题。
接口隔离原则(ISP)
人们经常看到包含许多方法的胖接口。实现这种接口的类可能不需要所有这些方法。那么,为什么接口包含所有这些方法呢?简单的答案是:支持这个接口的一些实现类。这是接口隔离原则(ISP)关注的领域。它建议不要用不必要的方法污染接口,只支持接口的一个(或一些)实现类。这个想法是: 一个客户端不应该依赖一个它不使用 的方法。一旦你理解了这个原则,你就会意识到当我向你展示一个遵循 LSP 的更好的设计时,我已经使用了 ISP。现在,让我们考虑一个例子,把我们的全部注意力放在 ISP 上。
Points to Remember
继续操作之前,请注意以下几点:
-
客户端是指使用另一个类(或接口)的任何类。
-
接口隔离原则(ISP)中的“接口”一词并不局限于 C# 接口。同样的概念适用于任何基类接口,如抽象类或简单基类。
-
不同来源的许多例子解释了违反 ISP 的情况,重点是抛出一个
NotImplementedException()
。在演示 7 中,我也给你演示了这样一个例子。这有助于我向您展示不遵循 ISP(或 LSP)的方法的缺点。您之前看到了 LSP 可以处理这类问题。 -
ISP 建议你的类不要依赖它不使用的接口方法。当你看下面的例子时,你就会明白这句话的意思了。
初始程序
假设您有一个包含两种方法的接口IPrinter
——PrintDocument()
和SendFax()
。这个类有几个用户。为简单起见,我们称它们为BasicPrinter
和AdvancedPrinter
。图 4-2 显示了一个简单的类图。
图 4-2
sprinter 类层次结构
一台基本的打印机可以打印一些文件。它不支持任何其他功能。所以,BasicPrinter
只需要PrintDocument()
方法。先进的打印机既能打印文件,又能发送传真。所以,AdvancedPrinter
需要这两种方法。
在这种情况下,如果接口IPrinter
中的SendFax()
有变化,就会强制BasicPrinter
代码重新编译。这种情况是不必要的,会给你将来带来潜在的问题。您已经在演示 5 中看到了这样一个有问题的情况。后来,我在演示 6 中向您展示了一个解决方案,我将接口IUser
分成了IPreviousPayment
和INewPayment
。这一次,我跟着 ISP。这个原则建议你用特定客户可能需要的适当方法来设计你的界面。
现在你问我: 为什么一个用户邀请问题摆在首位?或者说,为什么用户需要改变一个基类(或者一个接口)? 要回答这个问题,假设您想要显示您正在使用哪种类型的传真来发送。我们知道传真方式的不同变化,如LanFax
、InternetFax
(或EFax
)和AnalogFax
。所以,早些时候,SendFax()
方法没有使用任何参数,但是现在它需要接受一个参数来显示它使用的传真类型。
为了进一步说明这一点,让我们假设您有一个传真层次结构,如下所示:
interface IFax
{
void FaxType();
}
class LanFax : IFax
{
public void FaxType()
{
Console.WriteLine("Using lanfax to send the fax.");
}
}
class EFax : IFax
{
public void FaxType()
{
Console.WriteLine("Using internet fax(efax) to send the fax.");
}
}
为了使用这个继承链,让我们假设您将AdvancedPrinter
中原来的SendFax()
更新为SendFax(IFax faxType)
,这需要您更改接口IPrinter
。当你这样做的时候,你需要更新BasicPrinter
类来适应这个变化。 现在你看到问题了吧!
Note
您可以看到,AdvancedPrinter
的变化导致界面IPrinter
的变化,这又导致BasicPrinter
更新其传真方法。虽然BasicPrinter
根本不需要这个 fax 方法,但是IPrinter
的另一个客户端中这个方法的改变会迫使BasicPrinter
改变并重新编译。ISP 建议您处理这种情况。
因此,当您看到一个胖接口时,问问自己是否每个客户机都需要所有这些接口方法。如果没有,就把它分成与特定客户相关的更小的接口。
如果你理解了前面的讨论,你就会明白为什么我不建议你写下面的代码:
interface IPrinter
{
void PrintDocument();
void SendFax();
}
你可以看到IPrinter
包含了PrintDocument()
和SendFax()
方法。如果您开始编码时考虑既能打印又能发送传真的高级打印机,那就很好。但是在以后的阶段,如果你的程序也需要支持基本的打印机,你将被迫写类似这样的东西:
class BasicPrinter : IPrinter
{
public void PrintDocument()
{
Console.WriteLine("A basic printer can print documents.");
}
public void SendFax()
{
throw new NotImplementedException();
}
}
这些代码可能会给您带来潜在的问题!你知道基本的打印机不能发送传真。但是由于BasicPrinter
实现了IPrinter
,它需要提供一个SendFax()
实现。因此,当IPrinter
界面中的SendFax()
发生变化时,BasicPrinter
需要适应这种变化。ISP 建议您避免这种情况。
Note
在这种情况下,你还记得演示 5 中的问题吗?当您抛出异常并试图使用多态代码时,您会看到违反 LSP 的影响。一旦你修改了IPrinter
,你也违反了 OCP。
在这种情况下,在Main(),
中,你不能像下面这样编写多态代码(因为这段代码的最后一行会抛出一个运行时错误):
IPrinter printer = new AdvancedPrinter();
printer.PrintDocument();
printer.SendFax();
printer = new BasicPrinter();
printer.PrintDocument();
printer.SendFax();// Will throw error
而且,你不能写这样的东西:
List<IPrinter> printers = new List<IPrinter>
{
new AdvancedPrinter(),
new BasicPrinter()
};
foreach( IPrinter device in printers)
{
device.PrintDocument();
//device.SendFax(); // Will throw error
}
在这两种情况下,您都会看到运行时异常。
演示 7
以下是不遵循 ISP 的完整演示:
using System;
using System.Collections.Generic;
namespace WithoutISPDemo
{
interface IPrinter
{
void PrintDocument();
void SendFax();
}
class BasicPrinter : IPrinter
{
public void PrintDocument()
{
Console.WriteLine("A basic printer can print documents.");
}
public void SendFax()
{
throw new NotImplementedException();
}
}
class AdvancedPrinter : IPrinter
{
public void PrintDocument()
{
Console.WriteLine("An advanced printer can print documents.");
}
public void SendFax()
{
Console.WriteLine("An advanced printer can send a fax.");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***A demo without ISP.***");
IPrinter printer = new AdvancedPrinter();
printer.PrintDocument();
printer.SendFax();
printer = new BasicPrinter();
printer.PrintDocument();
//printer.SendFax();//Will throw error
Console.ReadKey();
}
}
}
输出
以下是输出:
***A demo without ISP.***
An advanced printer can print documents.
An advanced printer can send a fax.
A basic printer can print documents.
分析
您可以看到,为了防止运行时异常,我需要注释掉一行代码。我为这次讨论保留了这个死代码。您已经知道应该避免这种带注释的代码。最重要的是,如前所述,在这个设计中,如果您在AdvancedPrinter
中更改了SendFax()
方法的签名,您需要在IPrinter
中进行更改,这将导致BasicPrinter
更改并重新编译。也从另一个角度考虑问题。假设您需要支持另一台可以打印、传真和影印的打印机。在这种情况下,如果您在IPrinter
界面中添加一种复印方法,现有的客户端BasicPrinter
和AdvancedPrinter
都需要适应这种变化。
更好的程序
让我们找到一个更好的解决方案。你知道有两种不同的活动:一种是打印一些文件,另一种是发送传真。因此,在接下来的例子中,我创建了两个接口:IPrinter
和IFaxDevice
。IPrinter
包含PrintDocument()
方法,IFaxDevice
包含SendFax()
方法。这个想法很简单:
-
需要打印功能的类实现了
IPrinter
接口,需要传真功能的类实现了IFaxDevice
接口。 -
如果一个类需要这两个功能,它就实现这两个接口。
Note
你不应该假设 ISP 说一个接口应该只有一种方法。在我的例子中,IPrinter
接口中有两个方法,BasicPrinter
类只需要其中一个。这就是你只看到一个方法的隔离接口的原因。
演示 8
下面是完整的实现:
using System;
namespace ISPDemo
{
interface IPrinter
{
void PrintDocument();
}
interface IFaxDevice
{
void SendFax();
}
class BasicPrinter : IPrinter
{
public void PrintDocument()
{
Console.WriteLine("A basic printer can print documents.");
}
}
class AdvancedPrinter : IPrinter, IFaxDevice
{
public void PrintDocument()
{
Console.WriteLine("An advanced printer can print documents.");
}
public void SendFax()
{
Console.WriteLine("An advanced printer can send a fax.");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***A demo that follows ISP.***");
IPrinter printer = new BasicPrinter();
printer.PrintDocument();
printer = new AdvancedPrinter();
printer.PrintDocument();
IFaxDevice faxDevice = new AdvancedPrinter();
faxDevice.SendFax();
Console.ReadKey();
}
}
}
输出
以下是输出:
***A demo that follows ISP.***
A basic printer can print documents.
An advanced printer can print documents.
An advanced printer can send a fax.
分析
你可能认为你可以在接口中提供一个默认的实现(从 C# 8.0 开始,这个特性被支持)或者一个抽象类,这样一个子类(或者接口实现类)就不需要担心功能了。如果你这样想,我提醒你,每次在基类(或接口)中添加方法,方法都需要在派生类中实现(或可供使用)。这种做法可能违反 OCP 和 LSP,从而导致维护困难和可重用性问题。例如,如果你在一个接口(或者一个抽象类)中提供了一个默认的传真方法,BasicPrinter
必须通过说类似下面的话来覆盖它:
public void SendFax()
{
throw new NotImplementedException();
}
你看到了潜在的问题!
Note
实现 ISP 还有一种替代技术:“委托”技术。然而,委托增加了应用的运行时间(它可能很小,但肯定是非零的),这会影响应用的性能。此外,基于特定的设计,委托调用可以创建一些附加的对象。太多这样的对象会导致内存问题,特别是当您在应用中处理非常少的内存时。
从属倒置原则
依赖性反转原则(DIP)包括两件重要的事情:
-
高级具体类不应该依赖于低级具体类。相反,两者都应该依赖于抽象。
-
抽象不应该依赖于细节。相反,细节应该依赖于抽象。
我们将研究这两点。
第一点的原因很简单。如果低级类发生变化,高级类可能需要适应变化;否则,应用会中断。这是什么意思?它说你应该避免在高级类中创建一个具体的低级类。相反,您应该使用抽象类或接口。因此,您消除了类之间的紧密耦合。
当你分析我在 ISP 中讨论的案例研究时,第二点也很容易理解。您已经看到,如果一个接口需要更改来支持它的一个客户端,那么其他客户端也会因为更改而受到影响。没有客户喜欢看到这样的应用。
因此,在您的应用中,如果您的高级模块独立于低级模块,您可以轻松地重用它们。这个想法也有助于你设计好的框架。
Note
Robert C. Martin 在他的《C# 中的敏捷原则、模式和实践》一书中解释说,当时的传统软件开发模型(如结构化分析和设计)倾向于创建高级模块依赖于低级模块的软件。但是在 OOP 中,一个设计良好的程序反对这种想法。它颠倒了通常由传统过程方法产生的依赖结构。这就是他在这个原理中使用“反转”一词的原因。
初始程序
假设您有一个两层的应用。使用这个应用,用户可以在数据库中保存员工 ID。为了演示这一点,我使用了一个控制台应用,而不是 Windows 窗体应用。在这里,你看到两个类:UserInterface
和OracleDatabase
。顾名思义,UserInterface
代表一个用户界面(比如一个用户可以输入员工 ID 并点击 Save 按钮将 ID 保存到数据库中的表单)。类似地,OracleDatabase
用于模拟 Oracle 数据库。同样,为了简单起见,这个应用中没有实际的数据库,也没有验证雇员 ID 的代码。这里我们只关注 DIP,所以这些讨论并不重要。
假设使用UserInterface
的SaveEmployeeId()
方法,您可以将员工 ID 保存到数据库中。请注意UserInterface
类中的以下代码段:
public UserInterface()
{
this.oracleDatabase = new OracleDatabase();
}
public void SaveEmployeeId(string empId)
{
// Assume that it is a valid data.
// So, I store it to the database.
oracleDatabase.SaveEmpIdInDatabase(empId);
}
你可以看到我在UserInterface
构造函数中实例化了一个OracleDatabase
对象。稍后,我使用这个对象来调用SaveEmpIdInDatabase()
方法,该方法在 Oracle 数据库中进行实际的保存。下面的类图(图 4-3 )显示了高级类(UserInterface
)对低级类(OracleDatabase
)的依赖关系。
图 4-3
高级类 UserInterface 依赖于低级类 OracleDatabase
这种编码方式非常普遍。但是也有一些问题。在我向您展示更好的方法之前,我们将在分析部分讨论它们。
演示 9
现在,看完整的程序,它不跟随下降。
using System
;
namespace WithoutDIPDemo
{
class UserInterface
{
readonly OracleDatabase oracleDatabase;
public UserInterface()
{
this.oracleDatabase = new OracleDatabase();
}
public void SaveEmployeeId(string empId)
{
// Assuming that this is a valid data.
// So, storing it to the database.
oracleDatabase.SaveEmpIdInDatabase(empId);
}
}
class OracleDatabase
{
public void SaveEmpIdInDatabase(string empId)
{
Console.WriteLine($"The id: {empId} is saved in the oracle database.");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***A demo without DIP.***");
UserInterface userInterface = new UserInterface();
userInterface.SaveEmployeeId("E001");
Console.ReadKey();
}
}
}
输出
以下是输出:
***A demo without DIP.***
The id: E001 is saved in the oracle database.
分析
该程序很简单,但存在以下问题:
-
顶层类(
UserInterface
)对底层类(OracleDatabase
)的依赖性太大。这两个类是紧密耦合的。所以,在未来,如果OracleDatabase
等级发生变化,你可能需要调整UserInterface
的变化。 -
在编写顶级类之前,低级类应该是可用的。因此,在编写或测试高级类之前,您必须先完成低级类。
-
如果使用不同的数据库,您会怎么做?例如,您可以从 Oracle 数据库切换到 MySQL 数据库;或者,您可能需要两者都支持。
更好的程序
在该程序中,您会看到以下层次结构:
interface IDatabase
{
void SaveEmpIdInDatabase(string empId);
}
class OracleDatabase : IDatabase
{
public void SaveEmpIdInDatabase(string empId)
{
Console.WriteLine($"The id: {empId} is saved in the Oracle database.");
}
}
DIP 的第一部分建议我们关注抽象。这使得程序高效。所以,这一次UserInterface
类的目标是抽象IDatabase
,而不是具体的实现,比如OracleDatabase
。这也给了您考虑新数据库的灵活性,比如MYSQLDatabase
。图 4-4 中的类图描述了这个场景。
图 4-4
高级类用户界面依赖于抽象 IDatabase
DIP 的第二部分建议让IDatabase
接口考虑UserInterface
类的需求。这一点很重要,因为如果一个接口需要更改以支持它的一个客户端,其他客户端可能会受到影响。这就是为什么你不应该根据 OracleDatabase 或者 MySQLDatabase(细节)的需求来设计 IDatabase(抽象)。
演示 10
这是给你的完整程序:
using System;
namespace DIPDemo
{
class UserInterface
{
readonly IDatabase database;
public UserInterface(IDatabase database)
{
this.database = database;
}
public void SaveEmployeeId(string empId)
{
database.SaveEmpIdInDatabase(empId);
}
}
interface IDatabase
{
void SaveEmpIdInDatabase(string empId);
}
class OracleDatabase : IDatabase
{
public void SaveEmpIdInDatabase(string empId)
{
Console.WriteLine($"The id: {empId} is saved in the Oracle database.");
}
}
class MySQLDatabase : IDatabase
{
public void SaveEmpIdInDatabase(string empId)
{
Console.WriteLine($"The id: {empId} is saved in the MySQL database.");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***A demo that follows DIP.***");
//Using Oracle now
IDatabase database = new OracleDatabase();
UserInterface userInterface = new UserInterface(database);
userInterface.SaveEmployeeId("E001");
//Using MySQL now
database = new MySQLDatabase();
userInterface = new UserInterface(database);
userInterface.SaveEmployeeId("E002");
Console.ReadKey();
}
}
}
输出
以下是输出:
***A demo that follows DIP.***
The id: E001 is saved in the Oracle database.
The id: E002 is saved in the MySQL database.
分析
您可以看到,通过遵循这个程序,您可以解决演示 9 中的前一个程序的所有潜在问题。简而言之,在 OOP 中,我建议遵循 Robert C. Martin 的名言:
高级模块不应该以任何方式依赖低级模块。
所以,当你有一个基类和一个派生类时,你的基类不应该知道它的任何派生类。但是这个建议几乎没有例外。例如,考虑这样一种情况,您的基类需要在某一点限制派生类实例的数量。
最后一点:您可以看到在演示 10 中,UserInterface
类构造函数接受了一个database
参数。当您在这个类别中同时使用建构函式和属性时,可以为使用者提供额外的便利。这里有一个示例代码。为了遵循命名约定,我需要选择名称“ D 数据库”而不是“数据库”我还保留了注释过的代码,以便您可以将其与现有代码进行比较。
class UserInterface
{
//readonly IDatabase database;
public IDatabase Database { get; set; }
public UserInterface(IDatabase database)
{
//this.database = database;
this.Database = database;
}
public void SaveEmployeeId(string empId)
{
//database.SaveEmpIdInDatabase(empId);
Database.SaveEmpIdInDatabase(empId);
}
}
这样做的好处是什么?现在,您可以在实例化UserInterface
类的同时实例化一个数据库,并在以后使用Database
属性对其进行更改。这里有一个示例代码,您可以将它添加到Main()
的末尾进行测试:
// Additional code for demonstration purpose
userInterface.Database = new OracleDatabase();
userInterface.SaveEmployeeId("E003");
对于本书中使用的类似例子,你可以遵循同样的技巧。
摘要
SOLID 原则是面向对象设计的基本准则。这些高层次的概念可以帮助你做出更好的软件。它们既不是规则,也不是法律,但它们可以帮助你提前想到可能的情景/结果。
在这一章中,我向您展示了遵循(和不遵循)这些原则的应用,并讨论了利弊。让我们快速回顾一下。
SRP 说 一个类应该只有一个理由改变 。使用 SRP,您可以编写更干净、更不脆弱的代码。你确定责任,并根据每个责任进行分类。什么是责任?这是一个改变的理由。但是你不应该假设一个类应该只有一个方法。如果多个方法帮助你实现一个单一的职责,你的类可以包含所有这些方法。根据可能变化的性质,你可以变通这条规则。这样做的原因是,如果一个应用中有太多的类,就很难维护。但是当你知道这个原则并且在你实现一个设计之前仔细思考,你就可以避免我之前讨论的那些典型的错误。
Robert C. Martin 提到 OCP 是最重要的面向对象设计原则。OCP 表明 软件实体(类、模块、方法等。)应该是开放扩展,关闭修改 。如果你不碰正在运行的代码,你就没有破坏它。对于新功能,您可以添加新代码,但不要打乱现有代码。这有助于节省时间,而不是再次测试整个工作流程。相反,您应该关注新添加的代码并测试这一部分。这一原则通常很难实现,但从长远来看,即使部分遵守 OCP 也能为您带来好处。在许多情况下,当您违反 OCP 时,您也违反了 SRP。
LSP 的思想是 你应该能够用子类型 替换父(或基)类型。使用 LSP 编写真正的多态代码是你的责任。这一原则非常重要,并通过两个不同的案例进行了讨论。使用这个原则,您可以避免 if-else 链的长尾效应,并使您的代码也符合 OCP。
ISP 背后的想法是, 一个客户端不应该依赖一个它不使用 的方法。这就是为什么您可能需要将一个 fat 接口拆分成多个接口。我已经向您展示了一个简单的技术来实现这个想法。当你不修改一个现有的接口(或者一个抽象类或者一个简单的基类)时,你就遵循了 OCP。当你不投掷NotImplementedException()
时,你不会破坏 LSP。这就是为什么一个 ISP 兼容的应用可以帮助你制作 OCP 和 LSP 兼容的应用。您可以使用委托技术开发一个 ISP 兼容的应用,这一点我在本书中没有讨论。但重要的一点是,当您使用委托时,您增加了运行时间(您可能会说它可以忽略不计,但它肯定是非零的),这会影响对时间敏感的应用。使用委托,当客户端使用应用时,您可以创建新的对象。但是,在某些情况下,这可能会导致内存问题。
DIP 给我们提出了两个重要的观点。 首先,一个高级的具体类不应该依赖于一个低级的具体类。相反,两者都应该依赖于抽象。其次,抽象不应该依赖于细节。相反,细节应该取决于抽象的 。当你遵循第一部分时,你的应用将是高效和灵活的;您可以在应用中考虑新的具体实现。当你分析这个原则的第二部分时,你会明白你不应该改变一个现有的基类或者接口来支持它的一个客户。这可能会导致另一个客户端崩溃,在这种情况下,您将违反 OCP。我分析了这两点的重要性。**
五、使用 DRY 原则
不要重复你自己(DRY)原则是另一个重要的原则,当一个专业的程序员为一个应用写一段代码时,他必须遵循这个原则。你在第四章学到的单一责任原则(SRP)和开/闭原则(OCP)原则都与 DRY 原则有关。我们从安迪·亨特和迪夫·托马斯的名著《务实的程序员中了解到这个原则。在这本书里,干原理是这样陈述的:
每一条知识都必须在一个系统内有一个单一的、明确的、权威的表述。
第一次看这个的时候可能会觉得很复杂。本章的目的是通过一个案例研究来帮助你理解这个原则。
DRY 的原因
代码重复会导致应用失败。程序员经常称这种情况为软件中的罪恶。现在的问题是——为什么我们会看到重复的代码?有多种原因。让我们用例子来看看其中的一些:
-
程序员无法抗拒简单的复制/粘贴,这对他来说似乎是成功的捷径。
-
项目的最后期限快到了。开发人员假设此时一定数量的副本是可以的。他计划在下一个版本中删除这些重复的内容,但是后来他忘记了。
-
代码注释中也会出现重复。考虑一个例子:一个开发人员非常了解代码。他不需要文档来理解代码的逻辑。一个新的需求迫使他更新部分代码。因此,他复制并粘贴了一段带有现有注释的现有代码,并开始处理该代码段。一旦更新完成,由于各种原因,他忘记更新相关评论。
-
测试人员可能需要传递相同的输入来验证测试套件中的各种方法。
-
有时重复是难以避免的。项目标准可能要求开发人员在代码中放置重复的信息。再举一个例子:假设您的软件面向使用不同编程语言和/或开发环境的多个平台。在这种情况下,您可能需要复制共享信息(例如方法)。
-
除此之外,编程语言可以有一个自身复制一些信息的结构。
等等。
在计算机科学中,遵循许多原则来避免代码重复。例如,数据库规范化技术试图消除重复数据。在第二章中,你看到了你可以把一个公共方法放在一个抽象基类中,以避免在派生类中复制该方法。
我必须说找到重复的代码并不总是容易的。例如,考虑下面的代码段(代码段 1),它有两个方法。
Code Segment 1
public void DisplayCost()
{
Console.WriteLine("Game name: SuperGame");
Console.WriteLine("Version:1.0");
Console.WriteLine("Actual cost is:$1000");
}
public void DisplayCostAfterDiscount()
{
Console.WriteLine("Game name: SuperGame");
Console.WriteLine("Version:1.0");
Console.WriteLine("The discounted price for festive season is:$800");
}
您可以很容易地看到,开始的两行是两种方法共有的。但是,如果重复项与其他代码或注释混杂在一起,那么检测重复项就不那么简单了。例如,考虑代码段 2。
Code Segment 2
public void DisplayCost()
{
Console.WriteLine("\AbcCompany SuperGame's price details:");
Console.WriteLine("Version:1.0 cost is:$1000");
}
public void DisplayCostAfterDiscount()
{
Console.WriteLine("\AbcCompany offers festive season discount.");
Console.WriteLine("Discounted price detail:");
Console.WriteLine("Game: SuperGame. Version: 1.0\. Discounted price:$800");
}
仔细观察,您会发现在两个代码段中,公司名称、游戏名称和软件的版本细节都是重复的。虽然在第一个代码段中很容易找到重复的代码,但在第二个代码段中,您需要仔细阅读代码。
这些代码段只包含两个方法。在真实的应用中,有许多方法,并且不是所有的方法都出现在同一个文件中。因此,如果您在文件中散布重复的信息,一个简单的更新就可能导致软件显示不一致的行为。
在一次更新操作中,如果你有 n 个副本,你需要 n 倍的修改,你不能错过任何一个。这就是为什么你需要小心他们。违反干原理导致你得到湿解。它通常代表“每次都写”、“每件事都写两遍”、“我们喜欢打字”或“浪费每个人的时间”。
像前几章一样,我将从一个开始看起来不错的程序开始。我们将分析这个程序,并通过消除冗余代码来使它变得更好。当你遇到类似的情况时,你可以遵循同样的方法。
初始程序
这里有一个简单的例子。该计划的主要假设如下:
-
有个游戏软件叫
SuperGame
。你创建一个类来代表这个游戏。 -
AboutGame()
方法告诉我们一些关于这个软件的有用信息。例如,它规定使用该软件的最低年龄是 10 岁。它还显示了游戏的当前版本。 -
DisplayCost()
显示该软件最新版本的价格。 -
买家可以得到高达 20%的折扣。
DisplayCostAfterDiscount()
显示最新软件的折扣价。
演示 1
假设有人编写了下面的程序。它成功编译并运行。让我们看看输出,然后按照分析部分。
using System;
namespace WithoutDRYDemo
{
class SuperGame
{
public void AboutGame()
{
Console.WriteLine("Game name: SuperGame");
Console.WriteLine("Minimum age: 10 years and above.");
Console.WriteLine("Current version: 1.0.");
Console.WriteLine("It is a AbcCompany product.");
}
public void DisplayCost()
{
Console.WriteLine("\AbcCompany SuperGame's price details:");
Console.WriteLine("Version:1.0 cost is:$1000");
}
public void DisplayCostAfterDiscount()
{
Console.WriteLine("\n AbcCompany offers a festive season discount.");
Console.WriteLine("Discounted price detail:");
Console.WriteLine("Game: SuperGame. Version: 1.0 Discounted price:$800");
}
}
class Program
{
static void Main()
{
Console.WriteLine("***A demo without DRY principle.***");
SuperGame superGame = new SuperGame();
superGame.AboutGame();
superGame.DisplayCost();
superGame.DisplayCostAfterDiscount();
Console.ReadKey();
}
}
}
输出
以下是输出:
***A demo without DRY principle.***
Game name: SuperGame
Minimum age: 10 years and above.
Current version: 1.0.
It is a AbcCompany product.
AbcCompany SuperGame's price details:
Version:1.0 cost is:$1000
AbcCompany offers a festive season discount.
Discounted price detail:
Game: SuperGame. Version: 1.0 Discounted price:$800
分析
你能看出这个程序的问题吗?公司名称AbcCompany
和版本详情你看到多少次?我知道这是一个简单的程序,但是考虑一下我之前提到的例子。这些方法可以出现在不同的模块中,在这种情况下,如果您需要更新公司信息或版本详细信息,您需要在提供更新之前找出所有使用它的地方。这就是干原则的用武之地。
这个程序受到硬编码字符串使用的困扰。这个问题的解决方案很简单。您可以在一个位置包含那些出现在多个地方的字符串。然后你与程序的其他部分共享这个代码段。因此,当您更新共享位置中的字符串时,更改会正确地反映在它出现的每个地方。
所以,基本思想是,如果你在多个位置看到一个公共代码,你把公共部分从剩余部分中分离出来,把它们放在一个位置,从程序的其他部分调用这个公共代码。这样,您就避免了复制/粘贴技术,这种技术在开始时可能看起来很容易,也很吸引人。
一次且只有一次的原则类似于干。你可以把这个原理应用到一个功能行为上,你可以把它看成是干的子集。简单来说,干和一次只干一次的核心思想是一致的。
你能把演示 1 做得更好吗?让我们看看下面的程序。
更好的程序
这个程序使用一个构造函数来初始化这些值。您可以在类的实例方法中使用这些值。
演示 2
这是演示 1 的改进版本:
using System;
namespace ImprovedVersion
{
class SuperGame
{
readonly string companyName;
readonly string gameName;
readonly double minimumAge;
readonly string version;
readonly double actualCost;
readonly double discountedCost;
public SuperGame()
{
companyName = "AbcCompany";
gameName = "SuperGame";
version = "1.0";
minimumAge = 10;
actualCost = 1000;
discountedCost = 800;
}
public void AboutGame()
{
Console.WriteLine($"Game name: {gameName}");
Console.WriteLine($"Minimum age: {minimumAge} years and above.");
Console.WriteLine($"Current version: {version}.");
Console.WriteLine($"It is a {companyName} product.");
}
public void DisplayCost()
{
Console.WriteLine($"\n{companyName} SuperGame's price details:");
Console.WriteLine($"Version:{version} " +
$"cost is: {actualCost}");
}
public void DisplayCostAfterDiscount()
{
Console.WriteLine($"\n{companyName} offers festive season discount.");
Console.WriteLine("Discounted price detail:");
Console.WriteLine($"Game: {gameName}. " +
$"Version: {version}. " +
$"Discounted price:{discountedCost}");
}
}
class Program
{
static void Main()
{
Console.WriteLine("***An improved version using DRY principle.***");
SuperGame superGame = new SuperGame();
superGame.AboutGame();
superGame.DisplayCost();
superGame.DisplayCostAfterDiscount();
Console.ReadKey();
}
}
}
输出
下面是这个程序的输出:
***An improved version using DRY principle.***
Game name: SuperGame
Minimum age: 10 years and above.
Current version: 1.0.
It is a AbcCompany product.
AbcCompany SuperGame's price details:
Version:1.0 cost is:1000
AbcCompany offers a festive season discount.
Discounted price detail:
Game: SuperGame. Version: 1.0\. Discounted price:800
您可以看到这个程序产生了相同的输出,除了第一行,它显示这是一个改进的版本。
分析
尽管如此,这个例子中还是有一些重复。请注意,公司名称显示在AboutGame(),DisplayCost()
和DisplayCostAfterDiscount()
中。这在这里是可以的,因为我希望客户在任何方法中显示公司名称。
但是你可以改进这个程序。对于不同的游戏(由同一家公司制作),软件的初始版本和公司名称可能不会改变,但是游戏的名称和价格细节可能会改变。所以,我想在这些方面改进程序逻辑。此外,如果你熟悉前一章(第四章)中的坚实原则,你知道这个程序不遵循 SRP。
简而言之,出于各种原因,您将来可能需要更新此程序,例如:
-
软件的成本是可以改变的。
-
折扣价可以改。
-
可以更改版本详细信息。
-
游戏的名字可以改。
-
还有,公司名称本身也可以改。
因此,我将company name, game name, version,
和age requirement
移到一个新的类GameInfo
中。实际价格和折扣价格被移到不同的类别中,GamePrice
。此外,这次我使用了属性,所以您可以在稍后使用这些属性将更改应用到初始值。
在这个即将到来的程序中,当你实例化一个GameInfo
实例时,你提供游戏的名称,但是在此之前,你初始化一个GameInfo
实例和一个GamePrice
实例。该活动帮助您用存储在GameInfo
和GamePrice
中的默认信息实例化一个游戏实例。如前所述,您可以使用这些类的各种属性来更改这些值。
现在,检查一个提议的改进。您可以遵循类似的结构,以最小的努力来合并更改。
演示 3
这是演示 2 的改进版本:
using System;
namespace DRYDemo
{
class GameInfo
{
public string CompanyName { get; set; }
public string GameName { get; set; }
public string Version { get; set; }
public double MinimumAge { get; set; }
public GameInfo(string gameName)
{
CompanyName = "AbcCompany";
GameName = gameName;
Version = "1.0";
MinimumAge = 10.5;
}
}
class GamePrice
{
public double Cost { get; set; }
public double DiscountedCost { get; set; }
public GamePrice()
{
Cost = 1000;
DiscountedCost = 800;
}
}
class Game
{
readonly string companyName;
readonly string gameName;
readonly double minimumAge;
readonly string version;
readonly double actualCost;
readonly double discountedCost;
public Game(
GameInfo gameInfo,
GamePrice gamePrice
)
{
companyName = gameInfo.CompanyName;
gameName = gameInfo.GameName;
version = gameInfo.Version;
minimumAge = gameInfo.MinimumAge;
actualCost = gamePrice.Cost;
discountedCost = gamePrice.DiscountedCost;
}
public void AboutGame()
{
Console.WriteLine($"Game name: {gameName}");
Console.WriteLine($"Minimum age: {minimumAge} years and above.");
Console.WriteLine($"Current version: {version}.");
Console.WriteLine($"It is a {companyName} product.");
}
public void DisplayCost()
{
Console.WriteLine($"\n{companyName} {gameName}'s price details:");
Console.WriteLine($"Version:{version},cost:${actualCost}");
}
public void DisplayCostAfterDiscount()
{
Console.WriteLine($"\n{companyName} offers a festive season discount.");
Console.WriteLine("Discounted price detail:");
Console.WriteLine($"Game: {gameName},Version: {version},Discounted price:${discountedCost}");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** Another improved version following the DRY principle. ***");
// Initial setup
GameInfo gameInfo = new GameInfo("SuperGame");
GamePrice gamePrice = new GamePrice();
// Create the game instance with default setup
Game game = new Game(gameInfo, gamePrice);
// Display the default game detail.
game.AboutGame();
game.DisplayCost();
game.DisplayCostAfterDiscount();
Console.WriteLine("------------");
Console.WriteLine("Changing the game version and price now.");
// Changing some of the game info
gameInfo.Version = "2.0";
gameInfo.MinimumAge = 9.5;
// Changing the game cost
gamePrice.Cost = 1500;
gamePrice.DiscountedCost = 1200;
// Updating the game instance
game = new Game(gameInfo, gamePrice);
// Display the latest detail
game.AboutGame();
game.DisplayCost();
game.DisplayCostAfterDiscount();
Console.ReadKey();
}
}
}
输出
下面是新的输出,它反映了各个领域的变化:
*** Another improved version following the DRY principle. ***
Game name: SuperGame
Minimum age: 10.5 years and above.
Current version: 1.0.
It is a AbcCompany product.
AbcCompany SuperGame's price details:
Version:1.0,cost:$1000
AbcCompany offers a festive season discount.
Discounted price detail:
Game: SuperGame,Version: 1.0,Discounted price:$800
------------
Changing the game version and price now.
Game name: SuperGame
Minimum age: 9.5 years and above.
Current version: 2.0.
It is a AbcCompany product.
AbcCompany SuperGame's price details:
Version:2.0,cost:$1500
AbcCompany offers a festive season discount.
Discounted price detail:
Game: SuperGame,Version: 2.0,Discounted price:$1200
这是改进的终点吗?你知道答案。它们没有尽头;你总是可以改进你的代码。让我们从一个普遍的角度来思考。你知道,一家公司不会在只做了一款游戏后就结束了。它可以创建多个游戏,并且可以使用一个通用的格式来显示关于它们的信息。所以,如果明天公司想让你做一个新游戏,比如说,NewPokemonKid
,你该如何着手?应该复制/粘贴现有代码并开始编辑吗?你知道这个过程根本不推荐。
如果您将Game
、GameInfo,
和GamePrice
类移动到一个共享库中并相应地使用它们,您可以使这个程序变得更好。当你这样做的时候,你遵循了 DRY 原则,因为你没有复制/粘贴现有的代码来制作一个新的游戏/新的需求。相反,您可以重用一个运行良好的现有解决方案,通过使用它,您可以间接地节省测试时间。
因此,我创建了一个名为BasicGameInfo
的类库项目,然后将这些类移动到一个公共文件CommonLibrary.cs
(我从class1.cs
中将其重命名)。我创建这些类public
,这样我就可以从不同的文件中访问它们。
为了您的直接参考,请参见图 5-1 中的解决方案浏览器视图,这里我在DryDemoUsingDll
项目中使用了一个BasicGameInfo
项目参考。
图 5-1
DryDemoUsingDll 正在使用 BasicGameInfo 项目引用
在我创建了项目DryDemoUsingDll
之后,我添加了引用BasicGameInfo
。图 5-2 显示了当我右键单击项目依赖项,添加引用,并准备按下 OK 按钮时的示例快照。
图 5-2
向 C# 项目文件添加 BasicGameInfo 引用
现在我可以在新文件的开头添加using BasicGameInfo;
,这样可以减少输入。比如我可以直接用Game
代替BasicGameInfo.Game
。同样的评论也适用于GameInfo
和GamePrice
。
演示 4
在这个示例演示中,我更改了一些参数,如游戏名称、版本、价格细节等。我把所有的片段都放在这里供你参考。请注意,更新后的客户端代码类似于您在之前的演示中看到的客户端代码。
// The content of CommonLibrary.cs
using System;
namespace BasicGameInfo
{
public class Game
{
readonly string companyName;
readonly string gameName;
readonly double minimumAge;
readonly string version;
readonly double actualCost;
readonly double discountedCost;
public Game(
GameInfo gameInfo,
GamePrice gamePrice
)
{
companyName = gameInfo.CompanyName;
gameName = gameInfo.GameName;
version = gameInfo.Version;
minimumAge = gameInfo.MinimumAge;
actualCost = gamePrice.Cost;
discountedCost = gamePrice.DiscountedCost;
}
public void AboutGame()
{
Console.WriteLine($"Game name: {gameName}");
Console.WriteLine($"Minimum age: {minimumAge} years and above.");
Console.WriteLine($"Current version: {version}.");
Console.WriteLine($"It is a {companyName} product.");
}
public void DisplayCost()
{
Console.WriteLine($"\n{companyName} {gameName}'s price details:");
Console.WriteLine($"Version:{version},cost:${actualCost}");
}
public void DisplayCostAfterDiscount()
{
Console.WriteLine($"\n{companyName} offers a festive season discount.");
Console.WriteLine("Discounted price detail:");
Console.WriteLine($"Game: {gameName},Version: {version},Discounted price:${discountedCost}");
}
}
public class GameInfo
{
public string CompanyName { get; set; }
public string GameName { get; set; }
public string Version { get; set; }
public double MinimumAge { get; set; }
public GameInfo(string gameName)
{
CompanyName = "AbcCompany";
GameName = gameName;
Version = "1.0";
MinimumAge = 10.5;
}
}
public class GamePrice
{
public double Cost { get; set; }
public double DiscountedCost { get; set; }
public GamePrice()
{
Cost = 1000;
DiscountedCost = 800;
}
}
}
// The content of the new client code
using BasicGameInfo;
using System;
namespace DryDemoUsingDll
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** Apply the DRY principle using DLLs. ***");
// Initial setup
GameInfo gameInfo = new GameInfo("NewPokemonKid");
GamePrice gamePrice = new GamePrice();
// Create the game instance with a
// default setup
Game game = new Game(gameInfo,gamePrice);
// Display the default game detail.
game.AboutGame();
game.DisplayCost();
game.DisplayCostAfterDiscount();
Console.WriteLine("------------");
Console.WriteLine("Changing the game version and price now.");
// Changing some of the game info
gameInfo.Version = "2.1";
gameInfo.MinimumAge = 12.5;
// Changing the game cost
gamePrice.Cost = 3500;
gamePrice.DiscountedCost = 2000;
// Updating the game instance
game = new Game(gameInfo, gamePrice);
// Display the latest detail
game.AboutGame();
game.DisplayCost();
game.DisplayCostAfterDiscount();
Console.ReadKey();
}
}
}
输出
当您运行这个程序时,您会得到以下输出:
*** Apply the DRY principle using DLLs. ***
Game name: NewPokemonKid
Minimum age: 10.5 years and above.
Current version: 1.0.
It is a AbcCompany product.
AbcCompany NewPokemonKid's price details:
Version:1.0,cost:$1000
AbcCompany offers a festive season discount.
Discounted price detail:
Game: NewPokemonKid,Version: 1.0,Discounted price:$800
------------
Changing the game version and price now.
Game name: NewPokemonKid
Minimum age: 12.5 years and above.
Current version: 2.1.
It is a AbcCompany product.
AbcCompany NewPokemonKid's price details:
Version:2.1,cost:$3500
AbcCompany offers a festive season discount.
Discounted price detail:
Game: NewPokemonKid,Version: 2.1,Discounted price:$2000
您可以看到我们得到了想要的输出,但是这次我使用了最少的代码。我通过遵循 DRY 原则和重用现有代码实现了这个结果。
我知道你在想什么。您可以看到Game
和GameInfo/GamePrice
之间的紧密耦合。你如何能去除这种耦合?既然你在前一章学了 DIP,那对你来说应该不是问题。我把这个练习留给你。
摘要
代码重复会导致软件中的严重问题。程序员经常称这种重复为软件中的罪恶。为什么我们会看到重复的代码?原因多种多样:有些是积极的,有些是难以避免的。通过删除多余的代码,您可以制作出更好的、更易于维护的软件。
这一章向你展示了 DRY 原则的应用。你看到了一个程序的初始版本可以被多次改进以使它变得更好。最后,您将公共代码移动到了共享库中。
这个原则不仅适用于代码,也适用于代码注释或测试用例。例如,您可以创建一个公共输入文件来测试各种方法,而不是在每个方法中重复传递相同的输入。当你考虑使用代码注释时,试着遵循《实用程序员》一书的建议,这本书告诉我们在代码中保留低级知识,使用注释进行高级解释。这完全符合干原则的哲学;否则,对于每次更新,您都需要更改代码和注释。
简而言之,这个原则帮助你写出更干净更好的代码,从而产生更好的软件。
六、使用工厂分离可变代码
开发人员的最终目标是开发出满足客户需求的应用。代码必须易于扩展、维护,并且足够稳定以满足未来的需求。第一次尝试就写出程序的最佳版本是很困难的。您可能需要分析代码并多次重构它。在本章中,您将看到这样一个场景,并学习如何使用工厂。为了使讨论简单,我从一个小程序开始。我们将继续分析该程序,并相应地进行修改。
为了制作一个稳定的应用,专业程序员的目标是使它松散耦合。他试图找出将来可能会发生变化的代码。一旦完成,他就将这部分代码从剩余的代码库中分离出来。工厂在这种情况下是最好的。
POINTS TO REMEMBER
显而易见的问题是:什么是工厂?简而言之,这是一段处理对象创建过程细节的代码。任何使用工厂的方法都被称为工厂的客户端。你应该注意到一个工厂可以有很多客户。这些客户端可以使用工厂来获取一个对象,然后,根据他们的需要,他们可以调用该对象的方法。因此,如何使用他从工厂收到的对象取决于客户。这就是为什么分离对象创建过程是有益的。否则,您将会在多个客户端中出现重复的代码。
问题陈述
假设你写了一个程序来演示两种不同动物的行为,比如老虎和猫。但是你有一个约束,说你不应该在你的 Main()
方法中实例化动物对象。你为什么会看到这种约束?以下是一些原因:
-
您希望对客户端隐藏实例化逻辑。你知道“变化”是编程世界中唯一不变的东西。让我们看看如果对象的实例化逻辑驻留在客户端会发生什么。当您增强应用以支持新类型的对象时,您也需要更新客户端代码。它还要求重新测试客户端代码。如何将实例化逻辑从客户端代码中分离出来?您将在接下来的演示中看到这一点。
-
可能有单独的类,它们的方法也可以创建猫或老虎。所以,最好将实例化一只猫或一只老虎的代码分离出来。在这种情况下,有多少客户端使用该代码并不重要。每个客户端都可以引用公共位置来实例化一个对象。
初始程序
我希望你明白这个要求。让我们假设您编写了下面的程序,如演示 1 所示。在您完成整个程序之前,请注意以下几点:
-
在这个程序中,
AnimalFactory
类负责实例化一个对象。它包含一个名为CreateAnimal()
的方法来创建一个 tiger 或 cat 实例。因此,AnimalFactory
像一个工厂类,而CreateAnimal()
像一个工厂方法。 -
CreateAnimal()
方法是非静态的,尽管您可以使它成为静态的。我将在本书的最后一章(第十一章)讨论使用静态方法的利弊。 -
在客户端代码中,实例化这个工厂类来获得一个动物。这就是为什么在
Main()
方法中,您会看到下面的代码:AnimalFactory animalFactory = new AnimalFactory(); IAnimal animal = animalFactory.CreateAnimal("cat"); animal.DisplayBehavior();
演示 1
以下是完整的程序:
using System;
namespace UsingSimpleFactory
{
interface IAnimal
{
void DisplayBehavior();
}
class Tiger : IAnimal
{
public Tiger()
{
Console.WriteLine("\nA tiger is created.");
}
public void DisplayBehavior()
{
Console.WriteLine("It roars.");
Console.WriteLine("It loves to roam in a jungle.");
}
}
class Cat : IAnimal
{
public Cat()
{
Console.WriteLine("\nA cat is created.");
}
public void DisplayBehavior()
{
Console.WriteLine("It meows.");
Console.WriteLine("It loves to stay at a home.");
}
}
class AnimalFactory
{
public IAnimal CreateAnimal(string animalType)
{
IAnimal animal;
if (animalType.Equals("cat"))
{
animal = new Cat();
}
else if (animalType.Equals("tiger"))
{
animal = new Tiger();
}
else
{
Console.WriteLine("You can create either a cat or a tiger. ");
throw new ApplicationException("Unknown animal cannot be instantiated.");
}
return animal;
}
}
class Program
{
static void Main()
{
Console.WriteLine("***Creating animals and learning about them.***");
AnimalFactory animalFactory = new AnimalFactory();
IAnimal animal = animalFactory.CreateAnimal("cat");
animal.DisplayBehavior();
animal = animalFactory.CreateAnimal("tiger");
animal.DisplayBehavior();
Console.ReadKey();
}
}
}
输出
以下是输出:
***Creating animals and learning about them.***
A cat is created.
It meows.
It loves to stay at a home.
A tiger is created.
It roars.
It loves to roam in a jungle.
分析
演示 1 中使用的方法在编程中很常见。它有一个名字: 简单工厂模式 。现在让我们来分析这个程序。
如果将来需要创建不同类型的动物,比如说猴子,您可能需要增强这个应用。你会怎么做?您需要修改AnimalFactory
类并扩展if-else
链来考虑猴子。但是如果你这样做,你将违反 OCP,结果,你将需要重新测试AnimalFactory
类。
POINTS TO REMEMBER
当您在类似的例子中看到switch
语句或if-else
链来创建不同类型的对象时,您会得到提示,您可能需要重新打开代码以适应未来的更改。在最坏的情况下,这些代码会在应用的几个部分重复出现。因此,您不断违反 OCP,这可能会在将来导致严重的维护问题。
在这个程序中,Visual Studio 向您显示一条消息,内容如下: CA1822:成员“CreateAnimal”不访问实例数据,可以标记为 static 。还口口声声说:不访问实例数据或调用实例方法的活动成员可以标记为静态。将方法标记为静态后,编译器将向这些成员发出非虚拟调用点。这可以为对性能敏感的代码带来可观的性能提升。我不建议现在采取这种方法。静态方法允许您在不实例化对象的情况下调用方法。但是静态方法也有缺点。例如,您不能在运行时更改静态方法的行为。如前所述,你会在第十一章中看到关于这个的详细讨论。
更好的程序
你明白通过遵循 OCP 原则,你可以使程序变得更好。因此,在接下来的演示中,您将看到一个新的层次结构。
#region Factory hierarchy
abstract class AnimalFactory
{
public abstract IAnimal CreateAnimal();
}
class CatFactory : AnimalFactory
{
public override IAnimal CreateAnimal()
{
return new Cat();
}
}
class TigerFactory : AnimalFactory
{
public override IAnimal CreateAnimal()
{
return new Tiger();
}
}
#endregion
为什么这很有帮助?我以这样一种方式使用这个结构,即整个代码段都是封闭的,以便进行修改。将来,如果您需要支持一种新的动物类型,比如说猴子,您将需要执行以下操作:
-
创建一个将实现
IAnimal
接口的Monkey
类。 -
创建一个
MonkeyFactory
,它将实现AnimalFactory
并为CreateAnimal()
方法提供实现。
因此,只测试新的类就足够了。 您现有的代码未被改动,关闭修改 。注意,在这个程序中有两个独立的继承层次:一个是动物层次,另一个是工厂层次。我在这段代码中标记了它们,供您参考。为了更清楚起见,我还包括了下面的类图(见图 6-1 )。
图 6-1
在演示 2 中,类图显示了两个不同的层次结构
演示 2
下面是完整的演示:
using System;
namespace SimpleFactoryModified
{
#region Animal hierarchy
interface IAnimal
{
void DisplayBehavior();
}
class Tiger : IAnimal
{
public Tiger()
{
Console.WriteLine("\nA tiger is created.");
}
public void DisplayBehavior()
{
Console.WriteLine("It roars.");
Console.WriteLine("It loves to roam in a jungle.");
}
}
class Cat : IAnimal
{
public Cat()
{
Console.WriteLine("\nA cat is created.");
}
public void DisplayBehavior()
{
Console.WriteLine("It meows.");
Console.WriteLine("It loves to stay at a home.");
}
}
#endregion
#region Factory hierarchy
abstract class AnimalFactory
{
public abstract IAnimal CreateAnimal();
}
class CatFactory : AnimalFactory
{
public override IAnimal CreateAnimal()
{
return new Cat();
}
}
class TigerFactory : AnimalFactory
{
public override IAnimal CreateAnimal()
{
return new Tiger();
}
}
#endregion
// Client
class Program
{
static void Main()
{
Console.WriteLine("***Modifying the simple factory in the demonstration 1.***");
// The CatFactory creates cats
AnimalFactory animalFactory = new CatFactory();
IAnimal animal = animalFactory.CreateAnimal();
animal.DisplayBehavior();
// The TigerFactory creates tigers
animalFactory = new TigerFactory();
animal = animalFactory.CreateAnimal();
animal.DisplayBehavior();
Console.ReadKey();
}
}
}
输出
除了第一行,该输出与前面的输出相同。
***Modifying the simple factory in the demonstration 1.***
A cat is created.
It meows.
It loves to stay at a home.
A tiger is created.
It roars.
It loves to roam in a jungle.
分析
您可以用以下几点来总结这个修改后的实现:
-
在
Main()
里面,你决定使用哪个动物工厂——是CatFactory
还是TigerFactory
? -
AnimalFactory
的子类创建一个Cat
实例或一个Tiger
实例。 -
这样,你就支持了 OCP 的概念。因此,您可以得到一个更好、更具可扩展性的解决方案。
新的要求
在第四章中,我说过:完全实现这个原则并不总是容易的,但是即使部分遵守 OCP 协议也是有益的。对于每一项要求来说,实现 OCP 并不容易。一个新的需求可能会要求应用进行许多更改。在这种情况下,根据情况,你必须选择一种技术。
例如,让我们假设您有一个额外的需求:您希望允许客户为动物选择一种颜色。你应该如何进行?一种选择是在构造函数中传递color
属性,并相应地更新程序。
演示 3
下面是一个满足要求的示例演示。我用粗体字做了重要的修改。
using System;
namespace UsingFactoryMethod
{
#region Animal hierarchy
interface IAnimal
{
void DisplayBehavior();
}
class Tiger : IAnimal
{
public Tiger(string color)
{
Console.WriteLine($"\nA {color} tiger is created.");
}
public void DisplayBehavior()
{
Console.WriteLine("It roars.");
Console.WriteLine("It loves to roam in a jungle.");
}
}
class Cat : IAnimal
{
public Cat(string color)
{
Console.WriteLine($"\nA {color} cat is created.");
}
public void DisplayBehavior()
{
Console.WriteLine("It meows.");
Console.WriteLine("It loves to stay at a home.");
}
}
#endregion
#region Factory hierarchy
abstract class AnimalFactory
{
public abstract IAnimal CreateAnimal(string color);
}
class CatFactory : AnimalFactory
{
public override IAnimal CreateAnimal(string color)
{
return new Cat(color);
}
}
class TigerFactory : AnimalFactory
{
public override IAnimal CreateAnimal(string color)
{
return new Tiger(color);
}
}
#endregion
// Client
class Program
{
static void Main()
{
Console.WriteLine("***Modifying demonstration 2 now.***");
// The CatFactory creates cats
AnimalFactory animalFactory = new CatFactory();
IAnimal animal = animalFactory.CreateAnimal("black");
animal.DisplayBehavior();
// The TigerFactory creates tigers
animalFactory = new TigerFactory();
animal = animalFactory.CreateAnimal("white");
animal.DisplayBehavior();
Console.ReadKey();
}
}
}
输出
该程序产生以下输出:
***Modifying demonstration 2 now.***
A black cat is created.
It meows.
It loves to stay at a home.
A white tiger is created.
It roars.
It loves to roam in a jungle.
分析
你可以看到许多变化是必需的。有没有替代的方法?我也这么认为演示 4 就是为此目的而做的。
由于AnimalFactory
是一个抽象类,您可以修改这个类来适应这种变化。在这个替代演示中,我引入了一个新方法MakeAnimal()
,它在调用CreateAnimal()
方法创建动物实例之前接受color
属性。下面是代码:
abstract class AnimalFactory
{
public IAnimal MakeAnimal(string color)
{
Console.WriteLine($"\nThe following animal color is {color}.");
IAnimal animal= CreateAnimal();
return animal;
}
public abstract IAnimal CreateAnimal();
}
在客户端代码中,调用MakeAnimal()
方法而不是CreateAnimal()
来查看动物的颜色。
演示 4
下面是修改后的例子。
using System;
namespace FactoryMethodDemo2
{
#region Animal hierarchy
interface IAnimal
{
void DisplayBehavior();
}
class Tiger : IAnimal
{
public Tiger()
{
Console.WriteLine("\nA tiger is created.");
}
public void DisplayBehavior()
{
Console.WriteLine("It roars.");
Console.WriteLine("It loves to roam in a jungle.");
}
}
class Cat : IAnimal
{
public Cat()
{
Console.WriteLine("\nA cat is created.");
}
public void DisplayBehavior()
{
Console.WriteLine("It meows.");
Console.WriteLine("It loves to stay at a home.");
}
}
#endregion
#region Factory hierarchy
abstract class AnimalFactory
{
public IAnimal MakeAnimal(string color)
{
Console.WriteLine($"\nThe following animal color is {color}.");
IAnimal animal= CreateAnimal();
return animal;
}
public abstract IAnimal CreateAnimal();
}
class CatFactory : AnimalFactory
{
public override IAnimal CreateAnimal()
{
return new Cat();
}
}
class TigerFactory : AnimalFactory
{
public override IAnimal CreateAnimal()
{
return new Tiger();
}
}
#endregion
// Client
class Program
{
static void Main()
{
Console.WriteLine("***Modifying demonstration 2 now.***");
// The CatFactory creates cats
AnimalFactory animalFactory = new CatFactory();
IAnimal animal = animalFactory.MakeAnimal("black");
animal.DisplayBehavior();
// The TigerFactory creates tigers
animalFactory = new TigerFactory();
animal = animalFactory.MakeAnimal("white");
animal.DisplayBehavior();
Console.ReadKey();
}
}
}
输出
以下是输出:
***Modifying demonstration 2 now.***
The following animal color is black.
A cat is created.
It meows.
It loves to stay at a home.
The following animal color is white.
A tiger is created.
It roars.
It loves to roam in a jungle.
分析
在这一章的开始,我们看到了使用工厂的优势。从演示 2 开始,我们为工厂使用了一个新的层次结构,这样所有的具体工厂都继承自AnimalFactory
,我们将对象创建的细节传递给具体工厂(CatFactory
或TigerFactory
)。既然我们遵循 OCP 原则,我们可以添加一个新的混凝土工厂,比如说MonkeyFactory
,来创造猴子。如果我们实现了这个场景,我们就不需要重新打开现有的代码。相反,新的MonkeyFactory
类可以从AnimalFactory,
继承,并且遵循规则,它将创造猴子。在这种情况下,我们需要以创建Tiger
类或Cat
类的相同方式创建一个Monkey
类。请注意,我们永远不需要重新打开现有的代码。
我创建了演示 3 和 4 来支持一个新的需求。在当前结构中维护 OCP 以适应新的需求是非常困难的,因为在开始时没有考虑颜色属性。演示 4 向您展示了您仍然可以进行最少的更改。
摘要
工厂为对象创建提供了另一种方法。本章从一个简单的工厂类开始。它帮助您将可能与代码的其他部分不同的代码分离出来。您将实例化逻辑放在工厂类中,以便为对象创建提供统一的方式。
遵循 OCP 原则,您进一步修改了应用。您为工厂创建了另一个层次结构,并将实际的对象创建逻辑传递给了具体的工厂。
稍后,在演示 4 中,您看到了在抽象工厂类中,您可以设置一个所有派生的具体工厂都必须遵守的通用规则。这个过程可以帮助您以最小的变化适应特定的需求。
七、使用包装器添加功能
继承的另一种选择是合成。这在编程中很常见,通常会给你带来更好的回报。本章使用一些包装器向您展示了关于这个主题的一个有用的案例研究。
你想到的第一个问题可能是:什么是包装器?包装就像包裹着一个物体的顶层。在编程中,你经常使用一个包装器来动态添加一些功能 。这是一种强大的技术,因为您可以根据自己的需要添加或丢弃包装器,并且不会妨碍原始对象的功能。
考虑一个例子:你需要处理一段代码并添加一些新特性。先前有人对此进行了编码,您不能更改现有的代码。当您需要增强一个特性来吸引新客户,但是您不能改变软件的现有工作流时,这种情况在软件行业中很常见,因为您仍然必须支持现有的客户。在这种情况下,由于您不是编写软件第一个版本的团队的一员,您从一开始就没有独占控制权。包装器在这种情况下很有用。如前所述,在这种情况下,您可以在现有功能的基础上添加新功能来支持新客户。事实上,使用不同类型的包装器,您也可以添加不同类型的客户。接下来的例子会让你更清楚这个概念。
问题陈述
考虑一群人,他们每个人都想购买房产并拥有自己的家。每个人的预算和心态都不一样。所以,这群人去拜访一家房屋建筑商,以获得成本估算。为简单起见,我们假设他们有以下选择:
-
他们可以用最少的设施建造一个基本的家,也可以用更多的设施建造一个高级的家。为了便于说明,让我们将这些住宅分别称为
BasicHome
和AdvancedHome
。 -
房屋建筑商给他们提供了选择:顾客可以选择一个操场,或者一个游泳池,或者两者都选。让我们称这些为奢侈品。这些奢侈品都增加了购买者的额外成本。
基于预算限制,客户可以选择各种选项,最终价格会有所不同。最重要的是,今天选择BasicHome
的顾客可以通过增加一个操场或一个游泳池(或两者都有)来升级他的房子。你能为这个场景写一个程序吗?
使用子类化
如果您尝试使用继承来提供解决方案,您将会发现与之相关的问题。假设您从以下结构开始:
class Home
{
// Some code
}
class PlayGround : Home
{
// Some code
}
class SwimmingPool : PlayGround
{
// Some code
}
这不是一个推荐的方法,因为要得到一个游泳池,你首先必须得到一个操场,这可能是客户不想要的。由于类似的逻辑,下面的结构也不是一个好的选择:
class Home
{
// Some code
}
Class SwimmingPool : Home
{
// Some code
}
class PlayGround : SwimmingPool
{
// Some code
}
这是因为在这种情况下,要得到一个操场,你首先必须得到一个游泳池,而顾客可能不想要。 所以,实现多级继承,在这种情况下,并不是一个好主意!
现在,让我们假设你从一个层次继承开始,其中SwimmingPool
和PlayGround
都继承自Home
类,如图 7-1 所示。
图 7-1
等级继承
现在你会有一个有游泳池和操场的家。因此,你最终会得到如图 7-2 所示的设计。
图 7-2
一个类需要从多个基类继承。它导致了 C# 中的菱形问题
但是你知道在 C# 中不能有多个基类。 所以,任何像下面这样的构造也会引发编译错误:
class Home: SwimmingPool, PlayGround // Error
{
}
您可以看到,在这种情况下,使用简单的子类化并不是一个好主意。有哪些选择?让我们继续调查。
你可以继续进行奢侈品的界面。例如,您可以选择以下界面:
interface ILuxury
{
void AddPlayGround();
void AddSwimmingPool();
}
现在你需要一个可以实现这个接口的类。例如,下面是一个Home
类,它扩展了BasicHome
类并实现了ILuxury
接口:
class Home : BasicHome, ILuxury
{
public void AddPlayGround()
{
// Some code
}
public void AddSwimmingPool()
{
// Some code
}
}
但是,顾客可能会选择拥有其中一种奢侈品的房子,而不是两者都拥有。在这种情况下,如果不需要某个方法,您应该编写:
throw new NotImplementedException();
与此相关的问题将在第四章的 LSP 中讨论。为了避免这种情况,您可以遵循 ISP 并隔离ILuxury
接口。是的,这次能成功!既然你在第二章看到了一个类似的解决方案(当我将不同的能力放入一个单独的层次中时),我就不在这里重复了。
接下来,我们寻找另一种方法。本章就是为此而作的。
使用对象合成
让我们看看包装器如何帮助您。 使用包装器,你用另一个对象 包围一个对象。封闭对象通常被称为 装饰器 ,并且符合它所装饰的组件的接口。它将请求转发给原始组件,并可以在这些请求之前或之后执行附加操作。 你可以用这个概念 添加无限数量的责任。以下数字有助于您理解这一点。
图 7-3 显示家(初级或高级)被一个操场包围。
图 7-3
这个家被一个操场包围着
图 7-4 显示住宅被一个游泳池包围。
图 7-4
这个家被一个游泳池包围着
图 7-5 显示该住宅被一个操场和一个游泳池包围。在这里,你首先用一个操场把房子围起来,然后用一个游泳池把房子围起来。
图 7-5
这所房子周围有一个操场和一个游泳池
图 7-6 显示该住宅再次被一个游泳池和一个操场包围。但这次你改变了顺序;你首先在房子周围建一个游泳池,然后在周围建一个操场。
图 7-6
这个家被一个游泳池包围着。随后,你用一个操场包围这个建筑
Note
遵循同样的技术,你可以增加两个操场或游泳池。
让我们试着按照我们的要求来实现这个概念。
在接下来的演示中,涉及到六个玩家:Home, BasicHome, AdvancedHome, Luxury, PlayGround, SwimmingPool
。Home
定义如下:
abstract class Home
{
public double basePrice = 100000;
public double AdditionalCost { get; set; }
public abstract void BuildHome();
public virtual double GetPrice()
{
return basePrice + AdditionalCost;
}
}
以下是一些要点:
-
你可以看到,
Home
的具体实现者必须实现BuildHome()
和GetPrice()
方法。在这个例子中,BasicHome
和AdvancedHome
继承自Home.
-
我假设房屋的基本价格是 10 万美元。使用
AdditionalPrice
属性,可以设置一些额外的价格。我使用这个属性来设置高级住宅的附加成本。目前,对于一个基本家庭来说,这一成本是 0,而对于一个高级家庭来说,这一成本是 25,000 美元。 -
我认为一旦房子建好了,就不需要立即修改。人们可以在以后添加奢侈品。
-
一旦房子建好,你可以为现有的房子选择一个操场或一个游泳池,或者两者都要。因此,
PlayGround
和SwimmingPool
类出现在这个例子中。 -
尽管并不严格要求,但为了共享公共代码,
PlayGround
类和SwimmingPool
类都继承了抽象类Luxury
,其结构如下:abstract class Luxury : Home { protected Home home; public double LuxuryCost { get; set; } public Luxury(Home home) { this.home = home; } public override void BuildHome() { home.BuildHome(); } }
-
像
AdditionalPrice
属性一样,可以使用LuxuryCost
属性设置/更新奢侈品成本。 -
注意
Luxury
持有一个Home
的引用。因此,具体的装饰器(本例中的PlayGround
或SwimmingPool
)正在装饰Home
的一个实例。 -
现在让我们来看看一个混凝土装饰工的结构,比如说,
PlayGround
,如下所示:class PlayGround : Luxury { public PlayGround(Home home) : base(home) { this.LuxuryCost = 20000; } public override void BuildHome() { base.BuildHome(); AddPlayGround(); } private void AddPlayGround() { Console.WriteLine($"For a playground,you pay extra ${this.LuxuryCost}."); Console.WriteLine($"Now the total cost is: ${GetPrice()}."); } public override double GetPrice() { return home.GetPrice() + LuxuryCost; } }
-
你可以看到,通过使用
AddPlayGround()
方法,你可以添加一个操场。当你增加这项设施时,你必须额外支付 20,000 美元。我在构造函数中初始化这个值。最重要的是,注意在添加操场之前,它从基类Luxury
中调用BuildHome()
。该方法又从Home
的具体实现中调用BuildHome()
。 -
SwimmingPool
类的工作方式类似,但是你必须为此付出更多。(是的,我假设在这种情况下,一个游泳池比一个游乐场更贵)。
类图
图 7-7 显示了类图中最重要的部分。
图 7-7
类图显示了除客户端类之外的参与者
示范
这是给你的完整演示。在客户端代码中,您可以看到许多不同的场景来展示此应用的有效性。
using System;
namespace UsingWrappers
{
abstract class Home
{
public double basePrice = 100000;
public double AdditionalCost { get; set; }
public abstract void BuildHome();
public virtual double GetPrice()
{
return basePrice + AdditionalCost;
}
}
class BasicHome : Home
{
public BasicHome()
{
AdditionalCost = 0;
}
public override void BuildHome()
{
Console.WriteLine("A home with basic facilities is made.");
Console.WriteLine($"It costs ${GetPrice()}.");
}
}
class AdvancedHome : Home
{
public AdvancedHome()
{
AdditionalCost = 25000;
}
public override void BuildHome()
{
Console.WriteLine("A home with advanced facilities is made.");
Console.WriteLine($"It costs ${GetPrice()}.");
}
}
abstract class Luxury : Home
{
protected Home home;
public double LuxuryCost { get; set; }
public Luxury(Home home)
{
this.home = home;
}
public override void BuildHome()
{
home.BuildHome();
}
}
class PlayGround : Luxury
{
public PlayGround(Home home) : base(home)
{
this.LuxuryCost = 20000;
}
public override void BuildHome()
{
base.BuildHome();
AddPlayGround();
}
private void AddPlayGround()
{
Console.WriteLine($"For a playground, you pay an extra ${this.LuxuryCost}.");
Console.WriteLine($"Now the total cost is ${GetPrice()}.");
}
public override double GetPrice()
{
return home.GetPrice() + LuxuryCost;
}
}
class SwimmingPool : Luxury
{
public SwimmingPool(Home home) : base(home)
{
this.LuxuryCost = 55000;
}
public override void BuildHome()
{
base.BuildHome();
AddSwimmingPool();
}
private void AddSwimmingPool()
{
Console.WriteLine($"For a swimming pool, you pay an extra ${this.LuxuryCost}.");
Console.WriteLine($"Now the total cost is ${GetPrice()}.");
}
public override double GetPrice()
{
return home.GetPrice() + LuxuryCost;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Using wrappers.***");
Console.WriteLine("Scenario-1: A basic home with basic facilities.");
Home home = new BasicHome();
home.BuildHome();
Console.WriteLine("\nScenario-2: A basic home with an additional playground.");
Luxury homeWithOnePlayGround = new PlayGround(home);
homeWithOnePlayGround.BuildHome();
Console.WriteLine("\nScenario-3: A basic home with two additional playgrounds.");
Luxury homeWithDoublePlayGrounds = new PlayGround(homeWithOnePlayGround);
homeWithDoublePlayGrounds.BuildHome();
Console.WriteLine("\nScenario-4: A basic home with one additional playground and swimming pool.");
Luxury homeWithOnePlayGroundAndOneSwimmingPool = new SwimmingPool(homeWithOnePlayGround);
homeWithOnePlayGroundAndOneSwimmingPool.BuildHome();
Console.WriteLine("\nScenario-5: Adding a swimming pool and then a playground to a basic home.");
Luxury homeWithOneSimmingPool = new SwimmingPool(home);
Luxury homeWithSwimmingPoolAndPlayground = new PlayGround(homeWithOneSimmingPool);
homeWithSwimmingPoolAndPlayground.BuildHome();
Console.WriteLine("\nScenario-6: An advanced home with some more facilities.");
home = new AdvancedHome();
home.BuildHome();
Console.WriteLine("\nScenario-7: An advanced home with an additional playground.");
homeWithOnePlayGround = new PlayGround(home);
homeWithOnePlayGround.BuildHome();
Console.ReadKey();
}
}
}
输出
以下是输出:
***Using wrappers.***
Scenario-1: A basic home with basic facilities.
A home with basic facilities is made.
It costs $100000.
Scenario-2: A basic home with an additional playground.
A home with basic facilities is made.
It costs $100000.
For a playground, you pay an extra $20000.
Now the total cost is $120000.
Scenario-3: A basic home with two additional playgrounds.
A home with basic facilities is made.
It costs $100000.
For a playground, you pay an extra $20000.
Now the total cost is $120000.
For a playground, you pay an extra $20000.
Now the total cost is $140000.
Scenario-4: A basic home with one additional playground and swimming pool.
A home with basic facilities is made.
It costs $100000.
For a playground, you pay an extra $20000.
Now the total cost is $120000.
For a swimming pool, you pay an extra $55000.
Now the total cost is $175000.
Scenario-5: Adding a swimming pool and then a playground to the basic home.
A home with basic facilities is made.
It costs $100000.
For a swimming pool, you pay an extra $55000.
Now the total cost is $155000.
For a playground, you pay an extra $20000.
Now the total cost is $175000.
Scenario-6: An advanced home with some more facilities.
A home with advanced facilities is made.
It costs $125000.
Scenario-7: An advanced home with an additional playground.
A home with advanced facilities is made.
It costs $125000.
For a playground, you pay an extra $20000.
Now the total cost is $145000.
分析
注意,这个实现遵循 OCP 原理。因此,当您创建一个不同类型的 home 时,您不需要打开现有的代码,而是可以创建一个继承自抽象类Home
的新类。
我想让你注意到我在这个例子中稍微违反了 SRP。这是因为我想给你看的是统一加了奢侈品后的最终价格。实际上,当我添加一个包装器时,我不需要计算总成本;相反,它足以显示增加的价格。但是我假设客户会希望看到总的估计价格。这就是为什么,每增加一件奢侈品,我都会显示总费用。另外,价格取决于你选择的房屋类型。因此,将GetPrice()
方法和BuildHome()
方法放在Home
类中是有意义的。
当您使用只做一项工作的包装器时,这个示例模式会更有效。因此,当您遵循 SRP 时,您可以使用这种包装器轻松地添加或删除行为。
我即将完成这一章。但是首先,我想告诉您,当您在。NET 框架和 Java,你会发现很多类似的实现。例如,BufferedStream
类继承自抽象基类Stream.
我从 Visual Studio IDE 中截取了一个快照来展示这个类的构造函数(见图 7-8 )。
图 7-8
Visual Studio IDE 中 BufferedStream 类的部分快照
您可以看到,BufferedStream
类构造函数可以接受一个Stream
类对象作为参数。注意,Luxury
构造函数也接受它的基类对象(Home
)作为参数。因此,您得到了一个线索,即BufferedStream
类遵循包装器模式。
但是现在请注意来自 Visual Studio IDE 的FileStream
类的部分快照(见图 7-9 )。
图 7-9
Visual Studio IDE 中 FileStream 类的部分快照
可以看到没有一个FileStream
类的构造函数可以接受一个Stream
类对象作为参数。因此,这个类没有遵循包装器/装饰器模式。
摘要
本章展示的模式被称为包装器或装饰器模式。本章向您展示了子类化技术的一种替代方法。你已经看到了多级继承和多重继承都不能解决我们在本章开始时提到的问题的原因。稍后,您看到了使用对象组合的实现。您使用了不同类型的包装器在这个应用中动态添加行为。回想一下,简单继承只促进编译时绑定,而不是动态绑定。
简而言之,以下是您在本章中学到的要点:
-
您可以在不修改现有继承层次结构的情况下添加状态和/或行为。
-
在演示 1 中,您定义了一个新的层次结构,它本身扩展了原始/现有层次结构的根。
-
要使用装饰器,首先要实例化一个 home,然后将它包装在装饰器中。
-
这个示例模式有一个名称。我们称之为装饰模式。它展示了一个例子,说明对象组合何时比普通继承执行得更好。
您还看到了. NET 中的一些内置示例。在那里,您了解到BufferedStream
类遵循类似的模式,但是FileStream
类不遵循。
八、使用挂钩的高效模板
本章将向你展示两种重要的技术。首先,您将学习使用模板方法。为什么这很重要?模板方法是代码重用的基本技术之一。假设你按照一个多步算法去完成一个任务。使用模板方法,您可以重新定义这些步骤中的一部分(但不是全部),而不改变它们的调用顺序。
本章从使用模板方法的演示开始。稍后,除了模板方法之外,您还将使用挂钩方法来增强这个应用。
问题陈述
经销商(或卖家)出售各种产品,如电视、洗衣机和音乐播放器。假设你认识这样一个经销商。你可以去他的陈列室买一台电视机。你可以参观同一个展厅,为你的家庭购买一台洗衣机。在每种情况下,您都可以按以下顺序总结整体活动:
1: You visit the dealer showroom.
2: You purchase a product.
3: The dealer generates a bill(or,invoice) for you.
4: The dealer delivers the product to you.
你能制作一个模拟这个场景的应用吗?
POINT TO NOTE
我们许多人把账单和发票区分开来。我建议你不要把重点放在区别上。请在本章中对它们一视同仁。
初始程序
在接下来的节目中,我假设你从同一个经销商那里购买了一台洗衣机和一台电视机。当您购买电视机时,您会看到以下输出:
1.The customer visits a dealer showroom.
2.The customer purchases a television.
3.The bill is printed.
4.The product is delivered.
当您购买洗衣机时,您会看到以下输出:
1.The customer visits a dealer showroom.
2.The customer purchases a washing machine.
3.The bill is printed.
4.The product is delivered.
请注意,步骤 1、3 和 4 对于这两种情况都是通用的。当你购买不同的产品时,你看不出这些步骤有什么不同。还要注意,这些步骤是固定的。例如,一旦你购买了一个产品,账单就生成了,然后产品就交付了。如果你没有首先参观陈列室或选择产品,系统不太可能生成账单并交付产品。(这种情况下我不考虑网购)。
在这种情况下,模板方法是理想的,在这种情况下,您不改变算法的基本步骤,但允许在某些步骤中进行一些小的修改。在我们的示例中,第 2 步略有变化,以显示您选择的产品,但当您购买任何产品时,其余步骤都是相同的。
当你点披萨时,你会注意到类似的场景。例如,你可以选择不同的配料,如培根、洋葱、额外的奶酪或蘑菇。厨师是怎么做比萨饼的?他首先按照他的传统方式准备比萨饼。就在送之前,他添加配料让你开心。你也可以在其他领域找到类似的情况。
现在的问题是:如何创建一个有多个步骤的应用,但其中只有几个步骤不同?答案是:您可以在父类中使用模板方法(由许多步骤组成)。然后,您可以将一些步骤委托给子类(代表特定的产品),并允许它们根据需要覆盖这些步骤。
Note
使用简单的多态,您可以通过在子类中重写父类的所有或大部分方法来带来彻底的改变。但是,当您使用模板方法时,您不会重写子类中的所有父类(或基类)方法。相反,您只覆盖有限数量的方法(或步骤)。这是这种方法和简单多态之间的关键区别。
在接下来的例子中,您可以看到下面的父类包含一个名为PurchaseProduct
的模板方法。为了便于理解,我使用了注释。
public abstract class Device
{
// The following method(step) will NOT vary
private void VisitShowroom()
{
Console.WriteLine("1.The customer visits a dealer showroom.");
}
// The following method(step) will NOT vary
private void GenerateBill()
{
Console.WriteLine("3.The bill is printed.");
}
private void DeliverProduct()
{
Console.WriteLine("4.The product is delivered.\n");
}
/*
The following method will vary. It will be
overridden by derived classes.
*/
protected abstract void SelectProduct();
// The template method
public void PurchaseProduct()
{
// Step-1
VisitShowroom();
// Step-2: Specialized action
SelectProduct();
// Step-3
GenerateBill();
// Step-4
DeliverProduct();
}
}
以下是重点:
-
这个父类是抽象的,因为它包含抽象方法
SelectProduct()
.
一个派生类重写这个方法来显示您购买的产品。 -
注意,在模板方法中有四个方法:
VisitShowroom(), SelectProduct(), GenerateBill(), DeliverProduct()
。这四种方法代表了算法的四个步骤。 -
SelectProduct()
是一个受保护的方法。它允许派生类重新定义/重写该方法。但是模板方法中的其他方法都标有私有关键字/访问修饰符。因此,客户端无法直接访问它们。 -
当您调用
PurchaseProduct()
方法时,派生类不能改变这些方法的执行顺序。此外,您不能在客户端代码中直接访问这些方法(例如,在Main()
方法中)。要完成购买,您需要调用模板方法。这就是我公开这个模板方法的原因。从客户的角度来看,他不知道模板方法是如何完成任务的,因为您没有向客户公开内部逻辑。这是一种更好的做法。
类图
下图(图 8-1 )显示了类图的重要部分。
图 8-1
PurchaseProduct()是这个例子中的模板方法
演示 1
下面是完整的演示:
using System;
namespace TemplateMethodDemo
{
/// <summary>
/// Basic skeleton of action steps
/// </summary>
public abstract class Device
{
// The following method(step) will NOT vary
private void VisitShowroom()
{
Console.WriteLine("1.The customer visits a dealer showroom.");
}
// The following method(step) will NOT vary
private void GenerateBill()
{
Console.WriteLine("3.The bill is printed.");
}
private void DeliverProduct()
{
Console.WriteLine("4.The product is delivered.\n");
}
/*
The following method will vary. It will be
overridden by derived classes.
*/
protected abstract void SelectProduct();
// The template method
public void PurchaseProduct()
{
// Step-1
VisitShowroom();
// Step-2: Specialized action
SelectProduct();
// Step-3
GenerateBill();
// Step-4
DeliverProduct();
}
}
// The concrete derived class-Television
public class Television : Device
{
protected override void SelectProduct()
{
Console.WriteLine("2.The customer purchases a television.");
}
}
// The concrete derived class-WashingMachine
public class WashingMachine : Device
{
protected override void SelectProduct()
{
Console.WriteLine("2.The customer purchases a washing machine.");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***A demonstration of a template Method.***\n");
Console.WriteLine("---The customer wants a television.---");
Device device = new Television();
device.PurchaseProduct();
Console.WriteLine("---The customer wants a washing machine.---");
device = new WashingMachine();
device.PurchaseProduct();
Console.ReadLine();
}
}
}
输出
以下是输出:
***A demonstration of a template Method.***
---The customer wants a television.---
1.The customer visits a dealer showroom.
2.The customer purchases a television.
3.The bill is printed.
4.The product is delivered.
---The customer wants a washing machine.---
1.The customer visits a dealer showroom.
2.The customer purchases a washing machine.
3.The bill is printed.
4.The product is delivered.
分析
你可以看到,在未来,如果你需要考虑一个不同的产品,比如智能手机,你可以很容易地增强应用。在这种情况下,您可以创建一个继承自Device
的SmartPhone
类,并以同样的方式覆盖SelectProduct()
。这里有一个例子:
// The concrete derived class-SmartPhone
public class SmartPhone : Device
{
protected override void SelectProduct()
{
Console.WriteLine("2.The customer purchases a smartphone.");
}
}
Note
这种实现遵循 OCP 原理。但是这种实现违反 SRP 吗?答案在某种程度上似乎是肯定的。但是从一个销售者的角度来想:一个潜在的顾客参观陈列室并选择产品。然后,销售人员生成发票,并将产品交付给客户。从卖方的角度来看,所有这些活动都与“一次成功的销售”联系在一起。从程序员的角度来看,所有这些步骤都是为了完成一个任务:购买产品。这就是为什么这个例子中的客户只能访问模板方法,而其他方法对他是隐藏的。此外,您可能还记得我在前言中说过的话:有时根据问题的复杂性或本质来变通规则是可以的。
增强的需求
让我们为另一个真实场景增强应用。经销商可以决定向任何向他购买电视机的顾客提供特别优惠券。此优惠不适用于其他产品。如何修改这个应用来满足这个新的需求呢?
一种方法很简单。您可以使用一个方法(可以是public
或protected
)来反映这个提议,并将该方法放在父类Device
中。我们把这个方法命名为GenerateGiftCoupon()
。下面是一个示例代码:
/// <summary>
/// Basic skeleton of action steps
/// </summary>
public abstract class Device
{
// The following method(step) will NOT vary
private void VisitShowroom()
{
Console.WriteLine("1.The customer visits a dealer showroom.");
}
// The following method(step) will NOT vary
private void GenerateBill()
{
Console.WriteLine("3.The bill is printed.");
}
private void DeliverProduct()
{
Console.WriteLine("4.The product is delivered.\n");
}
/*
The following method will vary. It will be
overridden by derived classes.
*/
protected abstract void SelectProduct();
// The template method
public void PurchaseProduct()
{
// Step-1
VisitShowroom();
// Step-2: Specialized action
SelectProduct();
// Step-2.1: Elgible for a gift?
GenerateGiftCoupon();
// Step-3
GenerateBill();
// Step-4
DeliverProduct();
}
protected virtual void GenerateGiftCoupon()
{
Console.WriteLine("A gift coupon is generated.");
}
}
现在Device
的任何子类都可以拥有GenerateGiftCoupon()
方法。他们可以根据自己的需要重新定义它。所以现在,根据我们的新要求,如果你购买一台电视机,你可以从经销商那里得到一张特别的优惠券,但购买一台洗衣机则不行。因此,在WashingMachine
类中,你覆盖了这个方法,并以如下方式编写它:
protected override void GenerateGiftCoupon()
{
throw new NotImplementedException();
}
但是在某些情况下,在方法体中抛出异常是有风险的。当我在第四章讨论里斯科夫替代原理(LSP)时,你已经了解了这一点。
为了避免这个问题,您可以让这个方法为空,如下所示:
protected override void GenerateGiftCoupon()
{
// Empty body
}
现在想想:使用空方法是个好主意吗?我不这么认为。让我们看看另一种选择。您可能希望将GenerateGiftCoupon()
抽象化,并根据需要在其子类中覆盖它。是的,这个可以。但问题是,当你在父类中使用抽象方法时,派生类需要为该方法提供具体的实现(否则,它又是抽象的,你不能从中实例化)。所以,如果你有太多的专业课,而其中大部分都不能让你有资格获得礼券,你仍然不得不放弃它们。(还能记得 ISP 吗?)
有没有更好的解决办法?是的,我想是的。你可以用挂钩的方法。我将在演示 2 中展示这一点。 但是 编程中什么是挂钩?用非常简单的话来说,一个挂钩帮助你在现有代码之前或之后执行一些代码。它可以帮助你在运行时扩展程序的行为。挂钩方法可以提供一些默认的行为,如果需要的话,子类可以覆盖这些行为。通常,默认情况下他们什么都不做 。
让我向你展示一个简单的挂钩在这个程序中的用法。请注意以下代码段中的粗体行:
// The template method
public void PurchaseProduct()
{
// Step-1
VisitShowroom();
//Step-2: Specialized action
SelectProduct();
// Step-2.1: Elgible for a gift?
if(IsEligibleForGiftCoupon())
{
GenerateGiftCoupon();
}
// Step-3
GenerateBill();
// Step-4
DeliverProduct();
}
其中挂钩方法定义为:
// If a customer purchases a television
// he can get a gift. By default,
// there is no gift coupon.
protected virtual bool IsEligibleForGiftCoupon()
{
return false;
}
这两段代码告诉我们,当你调用模板方法时,默认情况下GenerateGiftCoupon()
不会被执行。这是因为IsEligibleForGiftCoupon()
返回false
,进而使得模板方法false
内部的if
条件。但是Television
类如下覆盖了这个方法:
protected override bool IsEligibleForGiftCoupon()
{
return true;
}
因此,当实例化一个Television
对象并调用模板方法时,GenerateGiftCoupon()
就在第 3 步之前被调用。
演示 2
下面是使用挂钩方法的完整演示。我保留这些评论是为了让你更容易理解。
using System;
namespace UsingHook
{
/// <summary>
/// Basic skeleton of action steps
/// </summary>
public abstract class Device
{
// The following method (step) will NOT vary
private void VisitShowroom()
{
Console.WriteLine("1.The customer visits a dealer showroom.");
}
// The following method (step) will NOT vary
private void GenerateBill()
{
Console.WriteLine("3.The bill is printed.");
}
private void DeliverProduct()
{
Console.WriteLine("4.The product is delivered.\n");
}
/*
The following method will vary. It will be
overridden by derived classes.
*/
protected abstract void SelectProduct();
// The template method
public void PurchaseProduct()
{
// Step-1
VisitShowroom();
// Step-2: Specialized action
SelectProduct();
// Step-2.1: Elgible for gift?
if(IsEligibleForGiftCoupon())
{
GenerateGiftCoupon();
}
// Step-3
GenerateBill();
// Step-4
DeliverProduct();
}
protected void GenerateGiftCoupon()
{
Console.WriteLine("A gift coupon is generated.");
}
// Hook
// If a customer purchases a television
// he can get a gift. By default,
// there is no gift coupon.
protected virtual bool IsEligibleForGiftCoupon()
{
return false;
}
}
// The concrete derived class-Television
public class Television : Device
{
protected override bool IsEligibleForGiftCoupon()
{
return true;
}
protected override void SelectProduct()
{
Console.WriteLine("2.The customer purchases a television.");
}
}
// The concrete derived class-WashingMachine
public class WashingMachine : Device
{
protected override void SelectProduct()
{
Console.WriteLine("2.The customer purchases a washing machine.");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***A demonstration of a template Method.***\n");
Console.WriteLine("---The customer wants a television.---");
Device device = new Television();
device.PurchaseProduct();
Console.WriteLine("---The customer wants a washing machine.---");
device = new WashingMachine();
device.PurchaseProduct();
Console.ReadLine();
}
}
}
输出
以下是输出:
***A demonstration of a template Method.***
---The customer wants a television.---
1.The customer visits a dealer showroom.
2.The customer purchases a television.
A gift coupon is generated.
3.The bill is printed.
4.The product is delivered.
---The customer wants a washing machine.---
1.The customer visits a dealer showroom.
2.The customer purchases a washing machine.
3.The bill is printed.
4.The product is delivered.
摘要
本章向你展示了如何使用模板方法来创建一个高效的应用。稍后,它演示了如何使用挂钩来适应新的需求,而不改变算法的核心结构。
维基百科上说微软的 Windows 也允许你插入挂钩。您可以使用它们来插入、移除、处理或修改键盘和鼠标事件。不过也有不好的一面。如果不够小心,挂钩的使用会影响应用的整体性能。
但是在我们的例子中,使用挂钩方法是有益的。在类似的情况下,使用 hook 方法,您可以扩展应用以适应新的需求。
九、使用外观简化复杂系统
在第八章中,你看到了如何重新定义多步算法中的一些步骤来完成一项任务。在这一章中,你会看到一个应用,它也执行一系列的任务。但是,您将创建一个简化的界面来执行这些任务,而不是重新定义其中的一些任务。在这种情况下,外观是有用的。
Note
您可能有兴趣了解外观和模板方法之间的区别。一般来说,模板方法属于一个基类,允许子类重新定义一些步骤。您创建一个类的对象并调用这个模板方法来完成您的工作。但是外观经常涉及许多不同类别的多个对象。这一次,您执行一系列步骤来完成涉及所有这些对象的任务。您不需要重新定义这些类中的方法;相反,你可以轻松地给他们打电话。因此,子系统类通常不知道外观的存在。
Facades 为您提供了一个以结构化方式访问不同类的各种方法的入口点。如果您强制执行一个不允许您直接访问单个方法的规则,取而代之的是,您只能通过您的 facade 来访问它们,那么这个 facade 就被称为opaquefacade;否则,它就是一个透明的门面。你也可以让你的门面变得静态。
问题陈述
一个人可以向银行申请贷款。银行在向客户发放贷款之前,必须进行一些背景核实。这种背景验证是一个复杂的过程,由各种子过程组成。银行官员在考虑贷款申请之前也可以参观客户的房产。如果申请人符合所有这些标准,他就可以获得贷款。但这里有一个关键:申请人不知道背景核实的细节,他只对最终结果感兴趣——他是否能获得贷款。银行官员如何做出决定与客户无关。在接下来的示例中,您将看到这样一个过程。为简单起见,我做了以下假设:
-
贷款申请人或客户必须有一些资产。如果资产价值低于他寻求的贷款金额,他就无法获得贷款。
-
如果客户已有贷款,他就不能获得任何新的贷款。
我们的工作是基于这些假设开发一个应用。为了更清楚起见,下面是一个示例输出:
Case-1:
Bob’s current asset value: USD 5000
He claims loan amount: USD 20000
He has an existing loan.
预期结果: Bob 无法获得贷款,原因如下:
-
余额不足。
-
旧贷款存在。
Case-2:
Jack's current asset value: USD 100000
He claims loan amount: USD 30000
He has no existing loan.
预期结果:杰克可以拿到贷款。
Case-3:
Tony's current asset value: USD 125000
He claims loan amount: USD 50000
He has an existing loan.
预期结果:托尼无法获得贷款,原因如下:
- 旧贷款存在。
让我们来构建应用。
初始程序
在这个例子中,您可以看到三个类:Person
、Asset
和LoanStatus
。Person
类的一个实例可以申请贷款。这个类有一个带三个参数的构造函数:name
、assetValue
和previousLoanExist
。为了避免在实例创建期间输入和传递所有三个参数,我将最后两个参数设为可选。下面是Person
类:
class Person
{
public string name;
public double assetValue;
public bool previousLoanExist;
public Person(string name,
double assetValue=100000,
bool previousLoanExist = false)
{
this.name = name;
this.assetValue = assetValue;
this.previousLoanExist = previousLoanExist;
}
}
注意这个类的构造函数。因为有两个可选参数,所以可以使用以下任何一行创建实例:
Person jack = new Person("Jack");
Person kate = new Person("Kate", 70000);
Person tony = new Person("Tony", 125000, true);
现在看Asset
类。这个类有一个方法HasSufficientAssetValue
来验证当前资产值是否大于或等于索赔金额。这里是Asset
班:
class Asset
{
public bool HasSufficientAssetValue(Person person, double claimAmount)
{
Console.WriteLine($"Verifying whether {person.name} has sufficient asset value.");
return person.assetValue >= claimAmount ? true : false;
}
}
现在看LoanStatus
类。这个类有一个方法HasPreviousLoans
来验证一个人是否有贷款。
class LoanStatus
{
public bool HasPreviousLoans(Person person)
{
Console.WriteLine($"Verifying whether {person.name} has any previous loans.");
return person.previousLoanExist;
}
}
在方法体中,我使用了条件操作符。我也可以用链条。你可以选择其中任何一个来使方法HasSufficientAssetValue
和HasPreviousLoans
工作。
POINT TO NOTE
还要注意我在这里使用的简化语句:
return person.previousLoanExist;
而不是使用下面的行:
return person.previousLoanExist ? true : false;
我可以对Asset
类中的HasSufficientAssetValue()
方法做同样的事情。我保留了这两种变化,以便你可以熟悉它们。
演示 1
已经显示了Person
、Asset
和LoanStatus
类。为了简单起见,我将所有三个类和下面的客户机代码放在一个文件中。我不会在下面的代码段中重复这些类。
Note
当您从 Apress 网站下载源代码时,请参考“第九章”中的文件夹“ImplementationWithoutFacade”来查看完整的程序。
现在,假设一个程序员新手编写了下面的客户端代码。他创建了一个名为bob
的person
实例,并显示他是否有资格获得贷款。这是可行的,但这是一个好的解决方案吗?我们接下来将对此进行分析。
class Program
{
static void Main()
{
Console.WriteLine("***Directly interacting with the subsystems.***");
Asset asset = new Asset();
LoanStatus loanStatus = new LoanStatus();
string status = "approved";
string reason = String.Empty;
bool assetValue, previousLoanExist;
// Person-1
Person bob = new Person("Bob", 5000, true);
// Starts background verification
assetValue = asset.HasSufficientAssetValue(bob, 20000);
previousLoanExist = loanStatus.HasPreviousLoans(bob);
if (!assetValue)
{
status = "Not approved.";
reason += "\nInsufficient balance.";
}
if (previousLoanExist)
{
status = "Not approved.";
reason += "\nOld loan exists.";
}
Console.WriteLine($"{bob.name}'s application status: {status}");
Console.WriteLine($"Remarks if any: {reason}");
Console.ReadKey();
}
}
输出
以下是输出:
***Directly interacting with the subsystems.***
Verifying whether Bob has sufficient asset value.
Verifying whether Bob has any previous loans.
Bob's application status: Not approved.
Remarks if any:
Insufficient balance.
Old loan exists.
分析
让我问你几个问题:
-
现在只有一个顾客。如果你有两个或两个以上的贷款申请人,你会怎么办?会在
Main()
内部多次重复后台验证逻辑吗? -
你是否注意到在客户端代码中你暴露了你的后台验证逻辑?这是个好主意吗?
-
如果您不需要创建子系统实例(例如,
Asset
或LoanStatus
实例)来了解结果,您会有什么感觉?相反,您可以假设有一个贷款审批者实例,它是让您了解申请状态的唯一联系点。这将使您能够编写如下内容: -
将来,如果获得贷款有新的标准,让贷款审批者负责处理这种情况。
Person bob = new Person("Bob", 5000,true);
string approvalStatus = loanApprover.CheckLoanEligibility(bob, 20000);
Console.WriteLine($"{bob.name}'s application status:{approvalStatus}");
更好的程序
当你考虑这样的问题时,你意识到你应该寻找一个更好的解决方案。您可以建立一个单一的联系点(比如贷款审批者),以使您的代码更清晰、更易于维护。
类图
图 9-1 显示了演示 2 最重要部分的类图。
图 9-1
客户直接与贷款审批人交谈,以了解他是否能获得贷款
演示 2
这是演示 1 的改进版本:
using System
;
namespace UsingFacade
{
class Person
{
public string name;
public double assetValue;
public bool previousLoanExist;
public Person(string name,
double assetValue=100000,
bool previousLoanExist = false)
{
this.name = name;
this.assetValue = assetValue;
this.previousLoanExist = previousLoanExist;
}
}
class Asset
{
public bool HasSufficientAssetValue(Person person, double claimAmount)
{
Console.WriteLine($"Verifying whether {person.name} has the sufficient asset value.");
return person.assetValue >= claimAmount ? true : false;
}
}
class LoanStatus
{
public bool HasPreviousLoans(Person person)
{
Console.WriteLine($"Verifying whether {person.name} has any previous loans.");
//return person.previousLoanExist ? true : false;
// simplified statement
return person.previousLoanExist;
}
}
class LoanApprover
{
readonly Asset asset;
readonly LoanStatus loanStatus;
public LoanApprover()
{
asset = new Asset();
loanStatus = new LoanStatus();
}
public string CheckLoanEligibility(Person person, double claimAmount)
{
string status = "approved";
string reason = String.Empty;
Console.WriteLine($"\nChecking the loan approval status of {person.name}.");
Console.WriteLine($"[Current asset value:{person.assetValue}," +
$"claim amount:{claimAmount}," +
$"existing loan?:{person.previousLoanExist}.]\n");
if (!asset.HasSufficientAssetValue(person,claimAmount))
{
status = "Not approved.";
reason += "\nInsufficient balance.";
}
if(loanStatus.HasPreviousLoans(person))
{
status = "Not approved.";
reason +="\nOld loan exists.";
}
return string.Concat(status,"\nRemarks if any:",reason);
}
}
class Program
{
static void Main()
{
Console.WriteLine("***Simplifying the usage of a complex system using a facade.***");
// Using a facade
LoanApprover loanApprover = new LoanApprover();
// Person-1
Person bob = new Person("Bob", 5000,true);
string approvalStatus = loanApprover.CheckLoanEligibility(bob, 20000);
Console.WriteLine($"{bob.name}'s application status: {approvalStatus}");
// Person-2
Person jack = new Person("Jack");
approvalStatus = loanApprover.CheckLoanEligibility(jack, 30000);
Console.WriteLine($"{jack.name}'s application status: {approvalStatus}");
// Person-3
Person tony = new Person("Tony", 125000,true);
approvalStatus = loanApprover.CheckLoanEligibility(tony, 50000);
Console.WriteLine($"{tony.name}'s application status: {approvalStatus}");
Console.ReadKey();
}
}
}
输出
以下是输出:
***Simplifying the usage of a complex system using a facade.***
Checking the loan approval status of Bob.
[Current asset value:5000,claim amount:20000,existing loan?:True.]
Verifying whether Bob has sufficient asset value.
Verifying whether Bob has any previous loans.
Bob's application status: Not approved.
Remarks if any:
Insufficient balance.
Old loan exists.
Checking the loan approval status of Jack.
[Current asset value:100000,claim amount:30000,existing loan?:False.]
Verifying whether Jack has sufficient asset value.
Verifying whether Jack has any previous loans.
Jack's application status: approved
Remarks if any:
Checking the loan approval status of Tony.
[Current asset value:125000,claim amount:50000,existing loan?:True.]
Verifying whether Tony has sufficient asset value.
Verifying whether Tony has any previous loans.
Tony's application status: Not approved.
Remarks if any:
Old loan exists.
分析
使用外观,您可以获得以下好处:
-
你为你的客户做了一个简化的界面。
-
您减少了客户端需要处理的对象的数量。
-
如果有许多子系统,用一个外观管理这些子系统可以使通信更容易。
摘要
本章向您展示了如何在应用中使用外观。外观可以帮助您为处理许多子系统的客户开发一个简化的界面。我还讨论了外观和模板方法之间的区别。本章还回顾了不同类型的外观以及在应用中使用它们的优缺点。在考虑在应用中使用外观之前,有必要记住以下几点:
-
您不应该假设在一个应用中只能有一个外观。如果你觉得有用,你可以用两个或更多。
-
一个外观可以显示与另一个外观不同的行为。例如,您可以允许或禁止对子系统的直接访问。当您强制客户端通过 facade 创建实例时,您称之为不透明 facade。当您还允许直接访问子系统时,您正在使用一个透明的外观。
-
如果子系统发生变化,您需要将相应的行为合并到外观层中。
-
使用外观,您可以维护一个额外的编码层。在将产品交付给客户之前,您需要测试这一层。如果外观过于复杂,会产生一些额外的维护成本。
十、内存管理
内存管理是开发人员非常关心的问题,这是一个非常大的话题。本章旨在以简单的方式触及要点,并帮助您理解它们在编程中的重要性。
在创建应用时,仅仅遵循一些设计准则是不够的;这只是等式的一部分。当没有内存泄漏时,应用才是真正高效的。如果一个计算机程序运行了很长时间,但未能释放不再需要的内存资源,您可以猜测任何内存泄漏的影响。以下是一些常见症状:
-
随着时间的推移,机器变慢了。
-
应用中的特定操作需要更长时间来执行。
-
最坏的情况是,应用/系统会崩溃。
初学 C# 的程序员通常认为垃圾收集器(GC)可以在任何可能的情况下负责内存管理。但事实并非如此,不幸的是,这是一个常见的错误。这一章就是为这一讨论而编写的,它建议您防止内存泄漏,以创建更好、更高效的应用。
概观
考虑一个简单的例子。假设您有一个在线应用,用户需要填写一些数据,然后单击提交按钮。现在假设应用的开发人员错误地忘记了在用户按下提交按钮后释放一些不再需要的内存。假设由于这种判断失误,应用每次点击泄漏了 512 字节。在最初的点击过程中,您可能不会注意到任何性能下降。但是,如果成千上万的在线用户同时使用该应用,会发生什么呢?如果 100,000 个用户按下提交按钮,我们最终将损失 48.8 MB 的内存,1000 万(10,000,000)次点击导致 4.76 GB 的损失,等等。
简而言之,即使一个程序为一个普通操作泄漏了非常少量的数据,很明显,随着时间的推移,您将会看到某种类型的故障,例如您的设备因System.OutOfMemoryException
而崩溃,或者设备中的操作变得非常慢,以至于您需要经常重启应用。 多快引起你的注意取决于应用 的泄露率。
在像 C++这样的非托管语言中,一旦预期的任务完成,就释放内存以避免内存泄漏。酪 NET 总是试图让你的编程生活变得更容易。它负责清除特定点之后无用的对象。在编程中,我们称之为脏对象或未引用对象。
它是如何清除脏东西的?英寸 NET 中,堆内存是托管的。这意味着公共语言运行库(CLR)会负责这项工作。在托管代码中,CLR 的垃圾回收器会为您完成这项工作,您不必释放托管内存。它移除堆中不用的东西,并重新收集内存以备将来使用。垃圾收集器程序作为低优先级线程在后台运行。它会为您跟踪脏对象。那个。NET 运行时可以定期调用此程序,从内存中移除未引用或脏的对象。在给定的时间点,如果一个对象没有引用,垃圾收集器会标记这个对象,并回收该对象占用的内存,假设不再需要它。
Note
理论上,一旦一个局部变量引用了一个对象,这个对象就可以在最早不再需要它的时候进行垃圾收集。但是如果在调试模式下禁用优化,对象的生存期会延长到块的末尾。但是 GC 可能不会立即回收内存。有各种因素,如可用内存和自上次收集以来的时间。这意味着孤立对象可以立即释放,或者可能会有一些延迟。
然而,有一个问题。一些对象需要特殊的代码来释放它们的资源。下面是一些常见的例子:你打开了一个文件,执行了一些读或写操作,但是忘记关闭文件。当您处理非托管对象、锁定机制、程序中的操作系统(OS)句柄等时,也需要类似的关注。程序员需要显式释放这些资源,以防止内存泄漏。 一般来说,程序员自己清理(或者释放)内存的时候,你说他们把对象处理掉了,但是 CLR 自动释放资源的时候,你说垃圾收集器执行了它的工作。垃圾收集器使用类实例的终结器(或析构器)来执行最后的清理。你很快就会看到关于他们的讨论 。
POINTS TO REMEMBER
程序员可以通过显式释放对象来释放资源,或者 CLR 可以通过垃圾收集机制自动释放资源。我们经常将它们分别称为处置和终结技术。
堆栈内存与堆内存
为了理解接下来的讨论,理解堆栈内存和堆内存之间的区别是很重要的。如果你知道区别,你可以跳过这一节。否则,继续阅读。
为了执行一个程序,操作系统给你一堆内存。该计划分为几个部分,为各种用途。有两大部分:一个是栈,一个是堆。
堆栈用于局部变量并跟踪程序的当前状态。堆栈遵循后进先出(LIFO)机制。它就像一堆框架,一个框架放在另一个框架的上面。你也可以把它想象成一组盒子,一个盒子放在另一个盒子上面。特定方法的所有局部变量都可以放在一个框架中。在特定的时刻,你可以访问栈顶的帧,但是你不能访问栈底的帧。一旦控件从某个方法返回,顶部的框架就会从堆栈中移除并被丢弃。当最下面的框架成为顶部框架时,可以访问它。这个过程可以继续,直到堆栈为空。为了演示这一点,让我们考虑下面的代码:
using System;
class SampleStackDemo
{
static void GetAnotherInt()
{
int c=3;
}
static void Main()
{
int a=1;
double b=2.5;
GetAnotherInt();
}
}
见下图(图 10-1 )。我在一张快照中向您展示了四个不同的阶段。该图显示了各种堆栈状态,如下所示:
图 10-1
程序运行时堆栈存储器的不同状态
-
Main()
方法中的前两行已经执行完毕,Main()
方法中的第三行(GetAnotherInt();
)开始执行。假设控件进入了实际的方法体,并通过了this method,
内的行int c=3;
,但它没有到达方法体的末尾。您可以看到堆栈在这个阶段不断增长。 -
下图显示控制来自于
GetAnotherInt().
,因此c=3
不再在堆栈上。 -
下图显示正在清理堆栈。当控制离开
Main()
时,a
和b
变量都被删除。但是遵循 LIFO(后进先出)结构,我将向您逐一展示中间删除。
简而言之,对于堆栈分配,您知道一旦从一个方法返回,顶部的框架就会被丢弃,您可以立即使用该空间。
另一方面,堆内存用于对象/引用类型。在这里,程序状态的跟踪并不重要。相反,它专注于存储数据。程序可以很容易地在堆中分配一些空间,并开始使用这些空间来存储信息。
Note
一旦你学会了多线程编程,你会发现每个线程都有自己的堆栈,但是它们共享同一个堆空间。
对于堆,可以以任何顺序添加或移除分配的空间。下面是一个示例图,便于您理解(图 10-2 )。
图 10-2
代表具有不同分配的堆内存的示例图
在这种情况下,您需要记住分配,并且在您重用空间之前,需要有人清除旧的分配。但是,如果忘记删除之前分配的内存,或者使用已经创建的引用指向堆中的另一个对象,或者将它设置为空,会发生什么情况呢?这些分配的内存空间将不断增加(变成垃圾),您将看到内存泄漏的影响。这就是 C# 中的垃圾收集器(GC)帮助你的地方。GC 会定期检查状态,并试图通过释放未使用的空间来帮助您。
每次创建对象时,CLR 都会在托管堆中分配内存。它可以一直分配内存,直到托管堆中的地址空间可用。GC 有一个优化引擎来决定何时回收未使用的内存。
问答环节
我想到了一个解决办法。我可以 在堆上分配内存 ,一旦我的工作完成,我会立即删除它。这样我可以防止垃圾生长。这种理解正确吗?
回答:
是的,建议的解决方案可以工作,并帮助您防止泄漏。但这并不容易。有些情况下,对象需要保持活动一段时间。考虑一个例子:使用一台高级打印机,你同时发送多封电子邮件和传真给不同的收件人。同时,你开始打印一些大文件。这是非常不可能的,所有的收件人同时收到数据,或一个文件有大量的页面被立即打印。因此,在这些情况下,立即删除不是明智的解决方案。
让我们假设有一个叫做 Test 的类。我理解为下面这一行,Test Test obj = new Test();,对象的空间将在堆内存中分配。但是参考变量呢?
回答:
参考变量将留在堆栈存储器中。图 10-3 描述了该场景。
图 10-3
堆栈上的对象引用指向堆中的实际内存
有时我会对这些推荐信感到疑惑?它们与 C/C++中的指针相似吗?
回答:
概念相似,但不相同。在我回答你的问题之前,让我进一步解释一些事情以便更好地理解。我已经提到过 GC 为您管理堆内存。它是如何管理这些东西的?简单来说:
-
它为您释放垃圾/未使用的空间,以便您可以重用这些空间。
-
其次,它可以应用压缩技术,这意味着它可以将所有分配的空间移至内存的一端,并将所有空闲空间移至内存的另一端。这将产生连续的空闲空间,帮助您分配大块内存。
第一点很重要,也是本章的主题。第二点也很重要,因为堆中可能包含分散的对象(见图 10-2 )。在许多情况下,您可能需要一大块连续的内存,虽然从技术上来说堆中有足够的空间,但在特定时间可能不可用。在这些场景中,压缩有助于获得足够的连续空间。这些引用是由垃圾收集器维护的,当这种洗牌完成时,你并没有意识到。
Note
实际上,你有两种不同类型的堆:一种是大对象堆(LOH),另一种是小对象堆(SOH)。大小为 85,000 字节及以上的对象放在大对象堆中。通常,这些是数组对象。为了便于讨论,我只是简单地提到“堆”这个词,而不是对它进行分类。soh 用于三个不同的代,您将在下一节中读到。
为了用简单的数字详细说明这些,让我们假设这是我们的堆(图 10-4 )。在垃圾收集器的清理操作之后,它可能如下所示(白色块表示为空闲/可用块)。
图 10-4
压缩前内存中的分散分配
您可以看到,如果您需要在我们的堆中分配五个连续的内存块,您现在不能分配它们,尽管总的来说有足够的空间。为了处理这种情况,垃圾收集器可以应用压缩技术,将所有剩余的对象(活动对象)移到一端,形成一个连续的内存块。因此,在压缩后,它可能看起来像图 10-5 。
图 10-5
压缩后修改内存中的分配
现在,您可以轻松地在堆中分配五个连续的内存块。这样,托管堆不同于非托管堆。在这里,我们不需要遍历一个地址链表来为新数据寻找空间,您可以简单地使用堆指针。NET 更快。在线链接 https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals
也声明如下:
从托管堆分配内存比非托管内存分配快。因为运行库通过向指针添加值来为对象分配内存,所以它几乎与从堆栈分配内存一样快。此外,因为连续分配的新对象连续存储在托管堆中,所以应用可以快速访问这些对象。
POINTS TO REMEMBER
我说的非托管堆是什么意思?考虑这样一种情况,您亲自管理堆,并负责分配和释放空间。当在托管堆中分配一个对象时,不是获得实际的指针,而是获得一个“句柄”来表示指向内存地址的方向。这很有帮助,因为实际的内存位置可以在 GC 压缩后更改。但是对于一个本机代码(比如当你在 C/C++代码中使用malloc()
函数来分配一个空间时),你得到的是指针,而不是句柄。
压缩后,对象通常停留在同一区域,因此访问它们也变得更容易和更快(因为页面交换更少)。压缩技术成本很高,但总体收益是值得的。微软文档这样写道:
只有当一个集合发现大量不可达对象时,内存才会被压缩。如果托管堆中的所有对象在一次收集后仍然存在,那么就不需要进行内存压缩。
为了提高性能,运行时在单独的堆中为大对象分配内存。垃圾收集器自动为大对象释放内存。但是,为了避免移动内存中的大对象,通常不会压缩内存。
Note
如果您对进一步的细节感兴趣,我鼓励您阅读以下内容。网志文章: https://devblogs.microsoft.com/dotnet/large-object-heap-uncovered-from-an-old-msdn-article/
现在我回到原来的问题。如何解释“指针”这个词很重要在 C/C++中,使用指针指向一个地址,这个地址只是内存中的一个数字槽。但问题是,如果你指向一个无效的地址,你会遇到惊喜!因此,“不安全”上下文中的指针很棘手。
另一方面,C# 中的引用指向托管堆中的有效地址,或者为空。你从 C# 得到的保证。此外,指针非常有用,因为当数据在内存中移动时,您仍然可以使用这些引用来访问这些数据。
运行中的垃圾收集器
分代式垃圾收集器(GC)用于比长期对象更频繁地收集短期对象。我们这里有三代:0,1,2。短期对象存储在第 0 代中。生命周期较长的对象被推送到更高的层代—1 或 2。垃圾收集器在低代中比在高代中工作得更频繁。
一旦创建了对象,它就驻留在第 0 代中。当第 0 代填满时,垃圾收集器被调用。在第 0 代垃圾收集中幸存下来的对象被转移到下一个更高的第 1 代。在第 1 代垃圾收集中幸存下来的对象进入最高的第 2 代。在第 2 代垃圾收集中幸存下来的对象仍属于同一代。当垃圾收集器检测到某一代的存活率太高时,它会提高该代的分配阈值。最后,如果它无法分配更多的内存,您将看到内存泄漏的影响,这一点您将在本章中很快了解到。
Note
有时你会创建一个非常大的对象。这种对象直接进入大对象堆(LOH)。它通常被称为第三代。第 3 代是一个物理代,逻辑上作为第 2 代的一部分收集。在这种情况下,我鼓励你在 https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/fundamentals
阅读在线微软文档。
我建议您记住 3–3 规则,记住垃圾收集的不同阶段和调用 GC 的不同方式。
垃圾收集的不同阶段
以下是垃圾收集的三个不同阶段:
-
阶段 1:是标记阶段,对活体进行标记或识别。
-
阶段 2:这是 r 扩展阶段,在这个阶段中,它更新将在阶段 3 中被压缩的对象的引用。
-
阶段 3:是 c 压缩阶段,从死的(或未引用的)对象中回收内存;压缩操作在活动对象上执行。它将活动对象(在此之前一直存在)移动到分段的旧末端。
调用垃圾收集器的不同情况
下面是调用垃圾收集器的三种不同情况:
-
案例 1:你记忆力差。
-
情况 2:分配的对象(在托管堆中)超过了定义的阈值限制。
-
案例 3:您调用了
System.GC()
方法。GC.Collect()
有很多重载版本。GC
是一个静态类,在系统名称空间中定义。
下面的程序演示了一个简单的案例研究。我在这个例子中使用了GetTotalMemory()
方法。我从 Visual Studio 中挑选了摘要,供您立即参考。解释很清楚:
// Summary:
// Retrieves the number of bytes currently thought to be allocated.
// A parameter indicates whether this method can wait for
// a short interval before returning, to allow the system
// to collect garbage and finalize objects.
//
// Parameters:
// forceFullCollection:
// true to indicate that this method can wait for garbage collection to
// occur before returning; otherwise, false.
//
// Returns:
// A number that is the best available
// approximation of the number of bytes currently
// allocated in managed memory.
同样,您可以从 Visual Studio 中看到任何方法的描述。以下是一些附加方法的简要描述。我在接下来的例子中使用它们:
-
GC.Collect(Int32)
强制从第0
代到指定代立即进行垃圾收集。这意味着当你调用GC.Collect(0),
时,垃圾收集将发生在第0;
代,如果你调用GC.Collect(1),
,垃圾收集将同时发生在第0
代和第 1 代,以此类推。 -
CollectionCount
方法返回指定代对象的垃圾收集次数。 -
在我调用 GC 之后,我调用了
WaitForPendingFinalizers()
方法。Visual Studio 中的方法定义说,这个方法"挂起当前线程,直到正在处理终结器队列的线程清空了该队列。 -
C# 9.0 允许你访问一个对象是否不为空。因此,下面的代码块不会产生编译时错误:
if (sample is not null){// some code}
-
在撰写本文时,
Collect()
有五个重载方法:
public static void Collect();
public static void Collect(int generation);
public static void Collect(int generation, GCCollectionMode mode);
public static void Collect(int generation, GCCollectionMode mode, bool blocking);
public static void Collect(int generation, GCCollectionMode mode, bool blocking, bool compacting);
在 Visual Studio 中可以很容易地看到它们的定义。为了便于您立即参考,我在此提供了参数说明:
代:是被垃圾回收的最老的代的编号。
模式:指定垃圾收集是强制(System.GCCollectionMode.Default
或System.GCCollectionMode.Forced
)还是优化的枚举值
(System.GCCollectionMode.Optimized
)。
blocking: 你把它设置为 true 来执行阻塞式垃圾收集;如果为 false,则尽可能执行后台垃圾收集。
压缩:你设置为 true 来压缩小对象堆;false 表示仅扫描。
本次演示的目的是:
-
向你展示不同代的垃圾收集
-
演示如果垃圾没有被收集,对象可以从一代进入下一代。
演示 1
运行以下程序,并检查输出和分析:
using System
;
namespace GCDemo
{
class Sample
{
public Sample()
{
// Some code
}
}
class Program
{
public static void Main(string[] args)
{
Console.WriteLine("*** Exploring Garbage Collections.***");
try
{
Console.WriteLine($"Maximum GC Generation is {GC.MaxGeneration}");
Sample sample = new Sample();
CheckObjectStatus(sample);
for (int i = 0; i < 3; i++)
{
Console.WriteLine($"\n After GC.Collect({i})");
GC.Collect(i, GCCollectionMode.Forced, false, true);
System.Threading.Thread.Sleep(5000);
GC.WaitForPendingFinalizers();
ShowAllocationStatus();
CheckObjectStatus(sample);
}
}
catch (Exception ex)
{
Console.WriteLine("Error:" + ex.Message);
}
Console.ReadKey();
}
private static void CheckObjectStatus(Sample sample)
{
if (sample is not null) //C# 9.0 onwards
{
Console.WriteLine($" The {sample} object is in Generation:{GC.GetGeneration(sample)}");
}
}
private static void ShowAllocationStatus()
{
Console.WriteLine("---------");
Console.WriteLine($"Gen-0 collection count:{GC.CollectionCount(0)}");
Console.WriteLine($"Gen-1 collection count:{GC.CollectionCount(1)}");
Console.WriteLine($"Gen-2 collection count:{GC.CollectionCount(2)}");
Console.WriteLine($"Total Memory allocation:{GC.GetTotalMemory(false)}");
Console.WriteLine("---------");
}
}
}
输出
这是一个可能的输出。我用粗体突出了一些重要的行。在您的计算机上,您可能会看到不同的输出。查看分析部分,了解更多关于这种差异的信息。
Maximum GC Generation is 2
The GCDemo.Sample object is in Generation: 0
After GC.Collect(0)
---------
Gen-0 collection count:1
Gen-1 collection count:0
Gen-2 collection count:0
Total Memory allocation:347360
---------
The GCDemo.Sample object is in Generation: 1
After GC.Collect(1)
---------
Gen-0 collection count:2
Gen-1 collection count:1
Gen-2 collection count:0
Total Memory allocation:178984
---------
The GCDemo.Sample object is in Generation: 2
After GC.Collect(2)
---------
Gen-0 collection count:3
Gen-1 collection count:2
Gen-2 collection count:1
Total Memory allocation:178824
---------
The GCDemo.Sample object is in Generation: 2
POINT TO NOTE
如果在这些调用之间发生了额外的垃圾收集,就有可能看到不同的计数器。在这个可能的输出中,您可以看到示例实例没有在任何 GC 调用中收集。于是,它幸存了下来,并逐渐转移到第 2 代。
这个输出中的总内存分配似乎是合理的,因为在每次 GC 调用之后,您会看到总分配在减少。它可能不会发生在每一个可能的输出。这是因为在显示内存状态之前,您可能不允许 GC 完成它的工作。因此,为了获得更一致的结果,我还在调用 GC 之后引入了一个睡眠时间,并且我还调用了WaitForPendingFinalizers()
。这给了 GC 更多的时间来完成它的工作。是的,它会导致一些性能损失,但是在我的系统中,它会产生更一致的结果。根据您的系统配置,您可能需要相应地改变睡眠时间。
注意,我使用了下面的重载版本:GC.Collect(i, GCCollectionMode.Forced, false, true)
。如果可能的话,我将第三个参数设为 false 来执行后台垃圾收集。
请注意,在垃圾收集开始之前,除了调用 GC 的线程之外,所有托管线程都被挂起。因此,一旦 GC 完成了它的任务,其他线程就可以再次开始分配空间。如果你知道多线程的概念,理解前面一行对你来说很容易。
最后一点:这些代是 GC 堆的逻辑视图。在物理上,这些对象驻留在托管堆上,托管堆是一块内存。GC 通过调用VirtualAlloc
向操作系统保留这个。然而,我们在这里并不深入讨论这个问题。
分析
这只是一个样本输出;它可以在每次运行 时发生变化。如果需要,您可以再次回顾前面章节中的理论,然后尝试理解垃圾收集是如何发生的。以下是一些重要的观察结果:
-
你可以看到不同代的 GC。
-
您可以看到,一旦您调用了
GC.Collect(2)
,其他代也被调用——注意,计数器增加了。同样,当你调用GC.Collect(1)
时,1 代和 0 代都被调用。 -
您还可以看到,我创建的对象最初放在第 0 代中。
处理一个对象
通常,程序员需要显式地释放一些资源。一些常见的例子包括当您处理事件、锁定机制、文件处理操作或非托管对象时。也有这样的情况,当你知道你已经使用了一个非常大的内存块,而这个内存块在某个执行点之后是不必要的。下面是一些您希望释放内存或资源来提高系统性能的例子。
Note
非托管对象不受. NET 控制。一个常见的例子是当您包装 OS 资源(如数据库连接或网络连接)时。
英寸 NET 中,你有一个带有Dispose()
方法的IDisposable
接口。当程序员想要释放资源时,他可以覆盖这个Dispose()
方法。这是一个推荐的做法,因为您非常清楚何时要释放内存。图 10-6 显示了一个来自 Visual Studio 的快照,显示你可以使用这个方法释放非托管资源。
图 10-6
中的 IDisposable 接口。网
最终确定与处置
每个类只能有一个终结器(通常称为析构函数),不能重载或继承。它没有修饰符,也没有任何参数。您不能直接调用终结器。它是自动调用的。
下面是一个显示终结器或析构器的示例:
class Sample
{
~Sample() // finalizer
{
// Cleanup statements...
}
}
如果您编译这段代码,然后打开 IL 代码,您会注意到如下内容:
.method family hidebysig virtual instance void
Finalize() cil managed
{
.override [mscorlib]System.Object::Finalize
// Code size 13 (0xd)
.maxstack 1
IL_0000: nop
.try
{
IL_0001: nop
IL_0002: leave.s IL_000c
} // end .try
finally
{
IL_0004: ldarg.0
IL_0005: call instance void [mscorlib]System.Object::Finalize()
IL_000a: nop
IL_000b: endfinally
} // end handler
IL_000c: ret
} // end of method Sample::Finalize
Note
您可以使用 IL 反汇编程序来查看 IL 代码。我经常使用 ildasm.exe,它在 Visual Studio 中是自动可用的。要使用此工具,您可以按照以下步骤操作:打开 Visual Studio ➤类型 ildasm 的开发人员命令提示符(您将看到一个新窗口弹出)➤拖动一个. dll 或一个。exe 到这个窗口➤现在展开/点击代码元素。你可以通过这个在线链接了解这个工具的更多信息: https://docs.microsoft.com/en-us/dotnet/framework/tools/ildasm-exe-il-disassembler
这是因为终结器调用隐式转换为:
protected override void Finalize()
{
try
{
// Cleanup statements...
}
finally
{
base.Finalize();
}
}
对继承链中的所有实例递归调用该方法,调用的方向是从最特殊到最不特殊。
Note
Microsoft 建议不要使用空的终结器,因为在终结队列中会为每个终结器创建一个条目。当调用终结器时,GC 开始处理这个队列。因此,如果终结器为空,就会引入不必要的性能损失。
让我们来看一个程序,在这个程序中,你可以看到一个终结器和一个Dispose()
方法同时存在。在你运行这个程序之前,让我告诉你一些事情:
-
静态类
GC
在System
名称空间中定义。 -
这个类有一个方法,叫做
SuppressFinalize()
。如果在GC.SuppressFinalize()
方法中传递当前对象,则当前对象的finalize
方法不会被调用。 -
我想给你看一个析构函数调用。NET 5 或。NET 6。在。NET 框架,非常容易。一旦你退出程序,它会被自动调用。但是在。NET 核心平台(或。NET 5 或。NET 6)。这就是为什么我引入了另一个名为
A
的类,并在构造函数中初始化了一个Sample
对象。在我调用GC
之前,我也没有在Main()
中使用任何Sample
引用。这有助于 GC 分析是否不再需要Sample
对象,然后收集垃圾。可以实现类似的逻辑来模拟。NET 5/。NET 6/。NET 核心平台。
POINT TO REMEMBER
理想情况下,除非非常需要,否则不要在终结器中编写代码。相反,您可能更喜欢使用Dispose()
方法来释放非托管资源并避免内存泄漏。
演示 2
现在运行下面的程序,并遵循输出。然后通过分析。您需要了解。NET 平台。
using System
;
namespace DisposeExample
{
class Sample : IDisposable
{
public void SomeMethod()
{
Console.WriteLine("Sample's SomeMethod is invoked.");
}
public void Dispose()
{
// GC.SuppressFinalize(this);
Console.WriteLine("Sample's Dispose() is called");
// Release unmanaged resource(s) if any
}
~Sample()
{
Console.WriteLine("Sample's Destructor is called.");
}
}
class A : IDisposable
{
public A()
{
Console.WriteLine("Inside A's constructor.");
// C#8 onwards it works.
// using Sample sample = new Sample();
// sample.SomeMethod();
using (Sample sample = new Sample())
{
sample.SomeMethod();
}
}
public void Dispose()
{
// GC.SuppressFinalize(this);
Console.WriteLine("A's Dispose() is called.");
// Release any other resource(s)
}
~A()
{
Console.WriteLine("A's Destructor is Called.");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** Exploring the Dispose() method.***");
A obA = new A();
obA = null;
Console.WriteLine("GC is about to start.");
GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine("GC is completed.");
Console.ReadKey();
}
}
}
输出
下面是我使用. NET5 时的输出。NET 6,或者。网络核心 3.1:
*** Exploring the Dispose() method.***
Inside A's constructor.
Sample's SomeMethod is invoked.
Sample's Dispose() is called.
GC is about to start.
Sample's Destructor is called.
GC is completed.
分析
从这个输出中,您可以注意到以下几点:
-
Sample
类对象的Dispose()
和finalizer
方法都被调用。 -
声明
GC.SuppressFinalize(this)
;是在Sample
类的dispose()
方法中注释的。这就是为什么也调用了Sample
实例的析构函数。如果启用/取消注释该语句,将不会调用Sample
实例的终结器。 -
尚未调用 A 对象的终结器方法。
当我在。NET Framework 4.7.2 中,我可以看到靠近末尾的一行额外的内容,说明在这种情况下还调用了一个类对象的析构函数。以下是输出:
*** Exploring the Dispose() method.***
Inside A's constructor.
Sample's SomeMethod is invoked.
Sample's Dispose() is called.
GC is about to start.
Sample's Destructor is called.
A's Destructor is Called.
GC is completed.
Note
我向微软提出了一个关于. NET Framework 和。NET 核心。如果你有兴趣了解这个讨论,可以参考链接: https://github.com/dotnet/docs/issues/24440
微软认为这是一个预期的行为 in.NET 核心/。NET 5/。NET 6 应用。也有不同的意见。
在这方面,我参考了微软的文档( https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/destructors
)说:
程序员无法控制何时调用终结器;垃圾收集器决定何时调用它。垃圾收集器检查应用不再使用的对象。如果它认为某个对象适合终结,它将调用终结器(如果有)并回收用于存储该对象的内存。
英寸 NET 框架应用(但不是在。NET 核心应用),当程序退出时也会调用终结器。我得到的解释是:终结器可能会产生死锁,阻止程序退出。因此,在退出时运行终结器的代码被进一步放宽。可以参考前面链接我们的讨论。本网上链接 https://github.com/dotnet/docs/issues/17463
深入描述了这个问题。
在详细讨论内存泄漏之前,让我们回顾一下下面的问答环节。
问答环节
我们如何调用析构函数?
回答:
您不能调用析构函数。垃圾收集器负责这项工作。
10.5 什么是托管堆?如何释放资源呢?
回答:
当 CLR 初始化垃圾回收器时,它会分配一段内存来存储和管理对象。这种内存称为托管堆。
一般情况下,调用Finalize()
(或者对象的析构函数)来清理内存。因此,您可以提供析构函数来释放我们的对象所拥有的未引用的资源。在这种情况下,您需要覆盖对象类的Finalize()
方法。通常,程序员会尝试使用Dispose()
方法来释放非托管资源。为了优化性能,如果需要,他可以取消对对象的终结器调用。在这种情况下,您可能会看到类似如下的 dispose 模式:
class Sample : IDisposable
{
protected virtual void Dispose(bool disposing)
{
if( disposing)
{
// Some code to release managed resources.
}
public void Dispose()
{
Dispose( true);
GC.SuppressFinalize(this);
}
~Sample().
{
Dispose(false);
}
// Some code
}
}
Note
注意,从终结器调用时,disposing
参数是false
。但当你从 IDisposable.Dispose
法中调用它时,它就是true
。换句话说,当它被确定性调用时是true
,当它被非确定性调用时是false
。这遵循了微软的编程准则。
10.6 垃圾收集器什么时候调用 Finalize()方法 ?
回答:
我们永远不知道。当发现没有引用的对象时,或者稍后当 CLR 需要回收一些内存时,它可能会立即调用它。但是您可以通过调用有许多重载版本的GC.Collect(),
来强制垃圾收集器在给定的点运行。当我在之前的演示中使用GC.Collect(Int32)
和GC.Collect()
时,你已经看到了两种不同的用法。
10.7 当程序在。NET 框架。但在中情况并非如此。网芯还是。5 号网或 6 号网。这背后的原因是什么?
回答:
对于我在 https://github.com/dotnet/docs/issues/24440
的票,答案总结为:终结器会产生死锁,阻止程序退出。因此,在退出时运行终结器的代码被进一步放宽。微软认为这是意料之中的行为。网芯,。NET 5,以及。NET 6 应用。
10.8 我们应该何时调用 GC。Collect()?
回答:
我已经提到过,调用 GC 通常是一个开销很大的操作。但是在一些特殊的场景中,您可能会确信如果您调用 GC,您将获得一些显著的好处。在代码中取消对大量对象的引用后,可能会出现这样的例子。
另一个常见的例子是当您试图通过一些常见的操作来查找内存泄漏时,例如重复执行一个测试来查找系统中的泄漏。在每个操作之后,您可以尝试收集不同的计数器来分析内存增长并获得正确的计数器。我将很快讨论内存泄漏分析。
POINTS TO REMEMBER
当我们看到IDisposable
接口的使用时,我们假设程序员会正确调用Dispose()
方法。一些专家建议你有一个析构器作为预防措施。当错过了一个打给Dispose()
的电话时,它会有所帮助。记住微软的理念(参见 https://docs.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-dispose
):为了帮助确保资源总是被适当地清理, Dispose
方法应该是幂等的,这样它可以被多次调用而不会抛出异常。此外,后续的 Dispose
调用应该什么也不做。
10.9 在之前的演示(演示 2)中,您为什么要使用“using”语句?
回答:
C# 在这种情况下提供了特殊的支持。您可以使用using
语句来减少代码大小,使其更具可读性。 它是 try/finally block 的语法捷径。为了验证这一点,您可以看到我在演示 2 中使用的 A 的构造函数的 IL 代码。我在这里提出这一点,并做一些大胆的供大家参考:
.method public hidebysig specialname rtspecialname
instance void .ctor() cil managed
{
// Code size 48 (0x30)
.maxstack 1
.locals init (class DisposeExample.Sample V_0)
IL_0000: ldarg.0
IL_0001: call instance void [System.Runtime]System.Object::.ctor()
IL_0006: nop
IL_0007: nop
IL_0008: ldstr "Inside A's constructor."
IL_000d: call void [System.Console]System.Console::WriteLine(string)
IL_0012: nop
IL_0013: newobj instance void DisposeExample.Sample::.ctor()
IL_0018: stloc.0
.try
{
IL_0019: nop
IL_001a: ldloc.0
IL_001b: callvirt instance void DisposeExample.Sample::SomeMethod()
IL_0020: nop
IL_0021: nop
IL_0022: leave.s IL_002f
} // end .try
finally
{
IL_0024: ldloc.0
IL_0025: brfalse.s IL_002e
IL_0027: ldloc.0
IL_0028: callvirt instance void [System.Runtime]System.IDisposable::Dispose()
IL_002d: nop
IL_002e: endfinally
} // end handler
IL_002f: ret
} // end of method A::.ctor
10.9 我可以直接在 1 代或者 2 代分配空间吗?
回答:
不可以。用户代码只能在第 0 代或 LOH 中分配空间。将对象从第 0 代提升到第 1 代(或第 2 代)是 GC 的责任。
内存泄漏分析
你如何检测泄漏?有许多工具可以实现这一目的。例如,windbg.exe 是大型应用中查找内存泄漏的常用工具。除此之外,您可以使用其他图形工具,如微软的 CLR Profiler、SciTech 的 Memory Profiler、Red Gate 的 ANTS Memory Profiler 等来查找系统中的漏洞。许多组织都有特定于公司的内存泄漏工具来检测和分析泄漏。在我之前的组织中,我们的专家开发了这样一个工具。这是一个很好的工具。我很幸运,因为我可以使用它,并了解到许多关于内存泄漏的有趣事情。
在 Visual Studio 的最新版本中,有一个诊断工具可以检测和分析内存泄漏。这是非常用户友好的,你可以采取各种内存快照。工具中的标记表示垃圾收集器活动。这个工具非常有用和有效:您可以在调试会话处于活动状态时实时分析数据。图表中的尖峰会立即吸引你的注意力。以下程序向您展示了一个示例演示。
Note
我假设您知道如何在应用中使用事件。对事件和代表的详细讨论超出了本书的范围。我在我的其他书籍中详细讨论了代表、事件和其他主题,这些书籍是由 Apress 出版的 Interactive C# 和Getting Started with Advanced c#。第一本书展示了如何使用诊断工具以及微软的 CLR Profiler 来分析内存泄漏。第二篇深入讨论了代表和事件。所以,如果你有兴趣,可以看看这些书。在这里,我添加了一些支持性的注释,以帮助您更好地理解代码。我承认在这个程序中很容易发现问题。但是我的核心意图是向您展示如何使用诊断工具来分析泄漏。
在运行该应用之前,确保您启用了启动诊断工具的选项,如图 10-7 所示。在 Visual Studio IDE 中,您可以在工具➤选项➤调试➤常规中看到此选项。
图 10-7
在 Visual Studio 中启用“调试时诊断工具”选项
演示 3
这是完整的演示。在Main()
中,您可以看到两个方法:一个注册事件,一个取消注册事件。您可以看到,由于错误,我注册了太多的事件,而我只注册了其中的一个。这些剩余的未注册事件导致了该应用的泄漏。
using System;
namespace MemoryLeakDemo1
{
delegate void IdChangedHandler(object sender, IdChangedEventArgs eventArgs);
class IdChangedEventArgs : EventArgs
{
public int IdNumber { get; set; }
}
class Sender
{
public event IdChangedHandler IdChanged;
private int Id;
public int ID
{
get
{
return Id;
}
set
{
Id = value;
// Raise the event
OnMyIntChanged(Id);
}
}
protected void OnMyIntChanged(int id)
{
if (IdChanged != null)
{
// As suggested by compiler:
// It is the simplified form of the following lines:
// IdChangedEventArgs idChangedEventArgs = new
// IdChangedEventArgs();
// idChangedEventArgs.IdNumber = id;
IdChangedEventArgs idChangedEventArgs =
new IdChangedEventArgs
{
IdNumber = id
};
IdChanged(this, idChangedEventArgs);
}
}
}
class Receiver
{
public void GetNotification(object sender, IdChangedEventArgs e)
{
Console.WriteLine($"Sender changed the id to:{e.IdNumber}");
}
}
class Program
{
static void Main()
{
Console.WriteLine("***Creating custom events and analyzing memory leaks.***");
Sender sender = new Sender();
Receiver receiver = new Receiver();
RegisterNotifications(sender, receiver);
UnRegisterNotification(sender, receiver);
Console.ReadKey();
}
private static void RegisterNotifications(Sender sender, Receiver receiver)
{
for (int count = 0; count < 10000; count++)
{
// Registering too many events.
sender.IdChanged += receiver.GetNotification;
sender.ID = count;
}
}
private static void UnRegisterNotification(Sender sender, Receiver receiver)
{
// Unregistering only one event.
sender.IdChanged -= receiver.GetNotification;
}
}
}
我运行这个程序并拍摄不同的快照。在这里,我给大家呈现一个诊断工具窗口的截图(图 10-8);它包括五个不同的快照,用于分析给定时间点的内存使用情况。这是一个很大的快照,所以移到下一页。
来自诊断工具的快照
图 10-8
使用 Visual Studio 中的诊断工具拍摄不同的快照
我们来分析一下区别(Objects (Diff))。例如,第四行显示与前一个快照相比,对象数增加了 171。如果您将鼠标悬停在此处,它会告诉您可以打开按对象计数排序的所选快照的堆比较视图。让我们点击这个链接。我可以看到图 10-9 中显示的内容。
图 10-9
特定快照中的对象计数差异
我们可以看到堆的大小是如何随着时间的推移而增长的。请注意,由于错误,我在这段代码的for
循环中重复注册了一个事件:
sender.IdChanged += receiver.GetNotification;
同样,我可以使用微软的 CLR Profiler 向您展示泄漏。但是展示不同工具的用法并不是本章的目的。相反,您可以使用任何您喜欢的工具来防止内存泄漏。由于 Visual Studio 的最新版本中已经提供了诊断工具,所以我不想错过向您展示其用法的机会。
捕捉内存泄漏需要专业知识,因为这并不容易。在前面的演示中,我们的程序有几个方法,这就是为什么很容易捕捉到泄漏。但是想想一些典型的场景:
-
你使用第三方代码,漏洞就在那里。但是您无法立即找到它,因为您无法访问该代码。
-
当遵循某些特定的代码路径时,泄漏就会暴露出来。如果测试团队错过了路径,就很难找到漏洞。
-
一个专门的内存泄漏套件维护可能需要一个单独的测试团队。此外,您不能在内存泄漏套件中包含所有的回归测试。多次运行测试并收集这些计数器是既耗时又消耗资源的活动。因此,建议您经常调整测试用例,并运行您的内存泄漏测试套件。
-
当一个新的 bug 修复发生时,测试团队使用测试用例来验证这个修复。现在您需要询问他们这些测试是否已经包含在内存泄漏测试套件中。如果没有,您需要将它们包含在内。但是,如果在一天之内出现了多个修复(比如 10 个或更多),由于各种原因(例如,您可能有资源限制),可能无法立即将所有测试包含在您的内存泄漏套件中。此外,由于您很晚才能看到结果,而且是在新的修复程序进入主代码库之间,因此很难发现早期的漏洞。
摘要
内存管理是一个重要的话题。这一章给你一个快速的概述,但仍然是一个很大的章节!在讨论了内存泄漏的重要性之后,我们来看看在 C# 中如何管理内存。
我从 C# 中的两种不同类型的内存开始讨论:堆栈内存和堆内存。然后我讨论了 C# 中的垃圾收集器(GC)。您看到了垃圾收集的不同阶段,并了解了 GC 可以启动其操作的不同情况。
稍后,您学习了如何以编程方式处置对象。您看到了关于dispose
方法与最终确定方法的讨论。在这种情况下,你看到了。NET Framework 显示了一种不同的行为。网芯,。NET 5,或者。NET 6。我提了一张票和微软的专家讨论这个区别,大家可以在 https://github.com/dotnet/docs/issues/24440
看到讨论。
在最后一部分,我向您展示了 Visual Studio 中诊断工具的用法,并使用 C# 中的事件分析了内存泄漏。
简而言之,本章回答了以下问题:
-
堆内存和栈内存有什么不同?
-
什么是垃圾收集(GC)?在 C# 中是如何工作的?
-
有哪些不同的 GC 代?
-
调用垃圾收集器有哪些不同的方法?
-
怎样才能强制 GC 调用?
-
在 C# 中,处置和终结有什么不同?
-
什么是内存泄漏?
-
内存泄漏的可能原因是什么?
-
怎样才能有效的使用
Dispose()
的方法来收集内存? -
我们如何在 Visual Studio 的诊断工具中使用内存泄漏分析?
十一、遗留的讨论
这是这本书的最后一章。在这里你会看到一些有趣的讨论,学习一些常用术语。
有时,如果你的应用运行良好,可以变通一些广为接受的规则,但是当你继续编码和开发应用时,你会发现专家的建议有很大的价值。如果你听从他们的建议,你会明白一个简单的选择从长远来看会有很大的影响。本章简要讨论了其中的一些主题。
静态方法还是实例方法?
静态方法很容易使用。一个程序员新手可能会认为在他的程序中使用静态方法还是实例方法没有多大关系。他知道他可以在不实例化对象的情况下调用方法。他喜欢这个。当他看到一些非常有用的静态实用程序方法时,印象更加深刻。但是一个有经验的程序员经常发现很难理解他是否应该使用静态方法。在每一个可能的设计中,他可能会问:哪个更好?简而言之,没有放之四海而皆准的规则。我相信这纯粹取决于你使用的应用。让我们核实一下事实。
概述
还记得第六章的简单工厂(演示 1)吗?你之前看过代码了。为了便于您立即参考,我提供了一个来自 Visual Studio 的屏幕截图。注意图 11-1 中的箭头尖端。
图 11-1
第六章演示 1 中的 AnimalFactory 类
如果您对此进行调查,您会看到以下消息:
这是部分快照;我为你展开完整的信息:CA1822 Member 'CreateAnimal' does not access instance data and can be marked as static.
它还不停地说:
Active Members that do not access instance data or call instance methods can be marked as static. After you mark the methods as static, the compiler will emit non-virtual call sites to these members. This can give you a measurable performance gain for performance-sensitive code.
Note
当我将目标框架设置为。NET 5 或。NET 6。但是当我使用目标框架时,它是不可见的。NET 3.1。
我在第六章没有采纳这个建议。原因很明显:
-
不能用关键字
virtual
或abstract
标记静态方法。 -
因此,您不能重写静态方法。所以,你也不能使用
override
关键字。 -
当您不能使用
override
关键字重定义一个方法时,您不会得到多态行为。
在第六章中,我增强了最初的实现,并将一些责任委托给子类,因为我想实现多态行为。当您认为您的应用可能需要做同样的事情时,最好将该方法设为非静态的。让我给你总结一下要点:
-
If you use a method that can get all information from its parameters and does not operate on any instance of a class, you can make the method static. For example, look into the following
MyUtility
class with static methodShowGreaterNumber()
:class MyUtility { public static double ShowGreaterNumber( double firstNumber, double secondNumber) { return firstNumber >= secondNumber ? firstNumber : secondNumber; } }
对我来说,把它用作
MyUtility.ShowGreaterNumber(24.7, 75.2)
是有意义的,这样可以打印 24.7 和 75.2 之间较大的数字。在打印 24.7 和 75.2 之间的最大值之前,没有必要创建一个MyUtility
的实例。你可以参考内置的Math
类来了解好的静态方法。比如用Math.Max(2,3)
可以得到 3,或者用Math.Abs(-2.52)
可以得到 2.52。 -
如果您不希望看到多态行为,或者您只关心应用的性能,您可以考虑将您的方法设为静态。
-
有时候你看到一部分代码,然后觉得静态方法对你更有意义。但是您已经看到,将来很可能需要增强您的程序,并且您将需要多态来使您的代码更加灵活。每当你有疑问的时候,选择一个非静态的方法而不是它的对应方法(例如,静态方法)。
学习设计模式
让我们乘坐时光机游览一下。使用这台机器让我带你回到软件开发的早期,了解那个时代的一个普遍问题:
没有标准来指导开发者如何设计应用。我们是独一无二的生物。因此,每个公司团队都遵循自己的编码风格。一个新成员加入这样一个团队。对于这个成员来说,理解当前的架构是一项艰巨的任务。因此,他不断向团队的高级成员寻求帮助,并请求他们解释现有的架构。他不停地问他们:为什么在这段代码中遵循这种特殊的设计?有经验的开发人员回答他的问题。他还解释了为什么在之前的团队会议中没有考虑到常见的替代方案。他还建议新成员重用现有的构造,以减少未来的开发工作。
你问:这有什么问题?其实没有问题,这是标准做法,即使在当今世界。但是从不同的角度考虑:假设有经验的人告诉新成员:我们遵循这个代码段的外观模式,或者我们遵循那个代码段的单例模式。如果新加入者已经知道这些编码模式,他的学习会有多容易?不仅如此——因为他知道这些编码风格,遵循一个已知的模式,他很容易更快地为团队做出贡献。我希望您对了解一些标准模式的重要性有所了解!
软件设计模式解决了这类问题,并为所有开发人员提供了一个公共平台。你可以把它们看作是该领域专家的经验记录。这些模式最初是为了重用而应用于面向对象的设计中。
设计模式简史
设计模式的最初想法来自建筑建筑师 Christopher Alexander,他是伯克利的教授。他面临许多性质相似的问题。所以,他用类似的方法解决了这些问题。
“每个模式都描述了一个在我们的环境中反复出现的问题,然后描述了该问题解决方案的核心,以这样一种方式,你可以使用这个解决方案一百万次,而不必以同样的方式做两次。”
—克里斯托夫·亚历山大
他最初的想法是在一个规划良好的城镇中建造建筑物。后来,这些概念进入了软件工程社区。这个社区开始相信,尽管这些模式是针对建筑物和城镇描述的,但同样的概念也可以应用于面向对象的设计。所以,他们用物体和界面取代了墙和门的原始概念。想法是一样的:你可以用一个已知的解决方案来解决一个常见的问题。
这些概念通过像沃德·坎宁安和肯特·贝克这样的前沿软件开发人员开始流行起来。1994 年,通过一个名为程序设计模式语言(PLoP)关于设计模式的行业会议,设计模式的思想进入了面向对象软件开发的主流。它是由 Hillside Group 主办的,Jim Coplien 的论文“一种开发过程生成模式语言”是这一背景下的著名论文。
1994 年,Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 出版了《设计模式:可重用面向对象软件的元素》一书(Addison-Wesley,1994)。随着这本经典教材的推出,设计模式的思想变得非常流行。在这本书中,他们介绍了软件开发的 23 种设计模式。这些作者被称为“四人帮” 。我们通常称它们为 GoF 。他们谈论的模式是由软件开发人员的共同经验开发出来的。
值得注意的是,GoF 讨论了 C++环境中的设计模式。但是 C# 1.0 是 2002 年发布的,之后经历了各种变化。它发展迅速,在很短的时间内就跻身于世界顶级编程语言的行列,并且在今天的市场上,它总是需求量很大。在撰写本文时,C# 9.0 已在 Visual Studio 2019 中可用。因为设计模式的概念是通用的,所以它们总是有价值的。因此,练习基本的设计模式总是能让你成为更好的程序员,并帮助你“升级”自己。以下是一些需要记住的要点:
-
设计模式描述了软件设计问题的通用可重用解决方案。基本思想是,在开发软件时,你可以用相似的解决方案解决相似的问题。提议的解决方案经过了长时间的测试。
-
模式实际上是模板。他们向你建议如何解决问题。对模式的良好理解可以帮助你更快地实现最好的设计。
-
从 OOP 的角度来看,这些模式描述了如何创建对象和类,并定制它们来解决特定环境中的一般设计问题。
23 种 GoF 设计模式中的每一种都专注于特定的面向对象设计。他们每个人都可以描述使用的后果和权衡。GoF 根据它们的目的对这 23 种模式进行了分类,如下所示。
A .创作模式
这些模式抽象了实例化过程。您使系统独立于对象的组成、创建和表示方式。这里你会问:“我应该在应用中的什么地方放置‘new’关键字?”这个决定可以帮助您确定类的耦合程度。以下五种模式属于这一类别:
-
单一模式
-
原型模式
-
工厂方法模式
-
构建器模式
-
抽象工厂模式
B .结构模式
使用这些模式,您可以组合类和对象来形成一个相对较大的结构。通常使用继承或组合来对不同的接口或实现进行分组。在第七章中,你看到了优先选择对象组合而不是继承(反之亦然)会影响软件的灵活性。以下七种模式属于这一类别:
-
代理模式
-
轻量级模式
-
复合模式
-
桥接模式
-
外观图案
-
装饰图案
-
适配器模式
C .行为模式
这些模式关注算法和对象间的责任分配。在这里,您将注意力集中在对象的通信和它们的相互联系上。以下 11 种模式属于这一类别:
-
观察者模式
-
战略模式
-
模板方法模式
-
命令模式
-
迭代器模式
-
纪念品图案
-
状态模式
-
中介模式
-
责任链模式
-
访问者模式
-
解释程序模式
GoF 做了另一个基于范围的分类,即模式主要关注类还是对象。类模式处理类和子类。它们使用继承机制,所以在编译时是静态的。对象模式处理可以在运行时改变的对象。所以,对象模式是动态的。
要快速参考,您可以参考 GoF 推出的下表:
好消息来了!
您已经实现了一些模式!不仅如此,你实际上至少从每个类别中学到了一种模式。本书的第三部分帮助你理解它们:
-
在第六章中,你学习了工厂方法模式。其实你也学了简单工厂模式,这是这个模式的基础。
-
在第七章中,你学习了装饰模式。
-
在第八章中,你学习了模板方法模式。
-
在第九章中,你学习了 facade 模式。
-
等等!还有一个问题:第一部分的第二章为战略模式奠定了基础。
这些模式在 C# 应用中非常常见。恭喜你!你走在正确的道路上。
问答环节
11.1 类模式和对象模式 有什么区别?
回答:
一般来说,类模式侧重于静态关系,而对象模式侧重于动态关系。顾名思义,类模式关注于类及其子类,而对象模式关注于对象的关系。
下表是 GoF 名著中讨论的总结内容:
| |班级模式
|
对象模式
|
| --- | --- | --- |
| 创作 | 可以将对象创建推迟到它的子类 | 可以将对象创建推迟到另一个对象 |
| 结构性 | 侧重于类的组成(主要使用继承的概念) | 聚焦于物体的不同构成方式 |
| 行为 | 描述算法和执行流程。他们也使用继承机制。 | 描述不同的对象如何协同工作并完成一项任务。 |
11.2 我可以在一个应用中组合两个或多个模式吗?
回答:
是的,在现实世界中,这种类型的活动很常见。
这些模式依赖于特定的编程语言吗?
回答:
编程语言可以发挥重要作用。但基本思想是一样的;模式就像模板一样,给你一些如何解决问题的预先想法。假设您选择了 c 之类的其他语言,而不是任何面向对象的编程语言,在这种情况下,您可能需要实现核心的面向对象原则,如继承、多态、封装、抽象等等。因此,选择一种特定的语言是很重要的,因为它可能有一些专门的功能,可以使你的生活更容易。
11.4 我是否应该将数组和链表等常见的数据结构视为不同的设计模式?
回答:
GoF 明确排除了这些,称“它们并不复杂 ,是针对整个应用或子系统的特定领域设计它们可以编码在类中,并按原样重用。所以,在这种情况下,他们不是你要关心的。
11.5 如果没有特定的模式 100%适合我的问题,我应该如何继续?
回答:
毫无疑问,无限数量的问题无法用有限数量的模式来解决。但是如果你知道这些常见的模式和它们的权衡,你可以选择一个相近的匹配。最后,没有人阻止你使用你的模式来解决你自己的问题。但你必须应对风险,并需要考虑你的投资回报。
请记住,世界总是在变化,新的模式也在不断发展。为了理解新模式的必要性,您可能还需要理解为什么旧的/现有的模式不足以满足需求。这些模式试图为你打下 SOLID 基础。这些概念可以帮助你在职业生涯中顺利前进。
避免反模式
设计模式可以帮助你开发更好的应用。但是人们经常误用它们,导致反模式。
一个吸引人的解决方案经常会导致严重的问题。这里有一个常见的例子:一个开发人员在没有分析潜在缺陷的情况下实现了一个快速修复,以满足交付计划。现在考虑一下,如果客户因为快速修复而发现一个大错误,公司的声誉会受到什么影响。
反模式在类似的情况下提醒您,并帮助您采取预防措施。它们让你想起一句谚语:预防胜于治疗。
POINTS TO REMEMBER
反模式不仅警告常见错误,还建议更好的解决方案。有些解决方案在开始时可能并不吸引人,但从长远来看,它们会节省你的时间、精力和声誉。
反模式简史
毫无疑问,设计模式已经帮助了(并且仍在帮助)数百万程序员。然而,渐渐地,人们开始注意到过度使用这些模式带来的负面影响。例如:许多开发人员想要展示他们的专业知识,而没有真正评估在他们的特定领域中使用这些模式的后果。作为一个明显的副作用,模式被植入了错误的环境,低质量的软件被生产出来,最终对他们或他们的组织有很大的惩罚。
软件业需要关注类似错误的负面后果,最终反模式的思想得到了发展。许多专家开始在这一领域做出贡献,但是第一个结构良好的模型来自 Michael Akroyd 题为“反模式:针对对象误用的疫苗”的演讲。“这是 GoF 设计模式的对外观。维基百科称该术语是由计算机程序员安德鲁·克尼格在 1995 年创造的。
随着威廉·j·布朗、拉斐尔·c·马尔沃、海斯·w·麦考密克三世、托马斯·j·莫布雷的名著反模式:重构软件、架构和危机中的项目(罗伯特·益普生/威利,1998),术语“反模式”开始流行起来。后来,斯科特·托马斯加入了他们的小组。他们说:
“因为反模式有如此多的贡献者,将反模式的最初想法分配给单一来源是不公平的。相反,反模式是补充设计模式运动的工作和扩展设计模式模型的自然步骤。”
反模式的例子
以下是一些反模式及其背后的概念/思维模式的例子:
-
过度使用模式: 开发人员可能会不惜任何代价尝试使用模式,不管它是否合适。
-
神类: 用许多不相关的方法控制几乎一切的大物体。
-
不是这里发明的: 我是一家大公司,我想从零开始打造一切。虽然有一个由小公司开发的库,但我不会使用它。我将自己制作一切,一旦开发出来,我将利用我的品牌价值宣布:“嘿,伙计们,我们在这里为你们提供终极图书馆,满足你们的每一个需求。”
-
零表示空: 程序员可能会使用一些特殊的数字,比如-1 或者 999(或者任何类似的东西),来表示一个不合适的整数值。当他在应用中将类似“09/09/9999”的日期视为空日期时,可以看到类似的例子。在这些情况下,如果用户需要这些值,他将不会得到这些值。
-
金锤:X 先生相信技术 T 永远是最好的。所以,如果他需要开发一个新的系统(这需要新的学习),他仍然会选择 T,即使它不合适。他想,“我已经够大了,也很忙。如果我能以某种方式用 t 来管理它,我就不需要再学习任何技术了。”
-
射信使: 你相信测试员“约翰”总是给你找硬缺陷,因为他不喜欢你。你说你已经有压力了,项目的截止日期也快到了。所以,你不希望他把自己卷入这个关键的阶段,以避免更多的缺陷。
-
瑞士军刀: 能满足顾客各种需求的产品的公司目标。或者,想象一下,一家公司试图制造一种可以治愈所有疾病的药物。或者,有人想设计一种软件,可以服务于具有不同需求的各种客户。对他来说,界面有多复杂并不重要。
-
复制粘贴编程:我需要解决一个问题,但是我已经有一段代码可以处理类似的情况。因此,我可以复制一份当前正在运行的旧代码,然后在需要时开始修改它。但是当你从现有的拷贝开始时,你基本上继承了所有与之相关的潜在缺陷。此外,如果将来需要修改原始代码,您需要在多个地方实现修改。这种做法违反了不重复自己的原则。
-
架构师不编码: 我是一名架构师。我的时间很宝贵。我将只展示路径或给出一个关于编码的伟大演讲。有足够多的实施者应该实施我的想法。架构师打高尔夫也是这个反模式的姐妹。
-
伪装的链接和广告: 这来自于一种心态,当用户点击一个链接或广告时,这种心态会愚弄用户并获得收入。顾客经常得不到他/她真正想要的东西。它通常被称为黑暗图案。
-
数字管理: 有人认为更高数量的提交,更高数量的代码行,或者更高数量的缺陷修复等。是伟大开发者的标志。
“用代码的行数来衡量编程进度,就像用重量来衡量飞机的建造进度。”
—比尔·盖茨
反模式的类型
反模式可以属于不同的类别。甚至一个典型的反模式也可以属于多个类别。以下是一些常见的分类:
-
架构反模式:瑞士军刀就是这一类的例子。
-
开发反模式:神类和过度使用模式就是这一类的例子。
-
管理反模式:射杀信使可以属于这一类。
-
组织反模式:架构师不编码,架构师打高尔夫可以属于这一类。
-
用户界面反模式:例子包括伪装的链接和广告。
POINTS TO NOTE
-
您可以从不同的网站/来源了解各种反模式。例如,下面的维基百科链接谈到了各种反模式:
https://en.wikipedia.org/wiki/Anti-pattern
-
您也可以在
http://wiki.c2.com/?AntiPatternsCatalog
获得反模式目录的详细列表以了解更多信息。 -
反模式的概念不限于面向对象编程。
问答环节
11.6 反模式和设计模式有什么关系?
回答:
当你使用设计模式时,你重用了在你之前的人的经验。当你开始仅仅为了使用而盲目地使用这些概念时,你就陷入了重复使用循环解决方案的陷阱。这可能会导致您在未来陷入糟糕的境地,然后您发现您的投资回报率(ROI)不断下降,但您的维护成本不断增加。简而言之,表面上简单而有吸引力的解决方案(或模式)可能会在将来给你带来更多的问题。
11.7 设计模式可能会变成反模式。这种理解正确吗?
回答:
是的,如果你在错误的环境中应用一个设计模式,它会带来比它所解决的问题更多的麻烦,最终它会变成一个反模式。所以,在你开始之前,确保你了解问题的性质和背景。例如,不恰当地使用 mediator 模式可能会导致 God 类反模式。
11.8 反模式仅与软件开发人员相关。这种理解正确吗?
回答:
不。您已经看到了各种类型的反模式。所以,反模式的用处不仅限于开发人员;它可能也适用于其他人。例如,它对经理和技术架构师也很有用。
11.9 即使你现在没有从反模式中获得太多好处,它们也能帮助你在将来以更少的维护成本轻松适应新的特性。这种理解正确吗?
回答:
是的。
11.10 反模式的可能原因是什么?
回答:
它们可能来自不同的来源或心态。下面列出了一些常见的某人可能会说(或想)的例子:
-
“我们需要尽快交付产品。”
-
“我们与客户的关系非常好。因此,目前我们不需要对未来的影响进行太多分析。”
-
“我是重用专家。我非常了解设计模式。”
-
“我们将使用最新的技术和功能来打动我们的客户。我们不需要关心传统系统。”
-
"更复杂的代码将反映我在这方面的专业知识."
11.11 你能提到一些反模式的症状吗?
回答:
在面向对象编程(OOP)中,最常见的症状是您的系统无法轻松采用新功能。此外,维护成本持续增加。您可能还会注意到,您已经失去了关键的面向对象功能,如继承、多态等。
除此之外,您可能会注意到以下部分或全部症状:
-
全局变量的使用
-
代码复制
-
有限/没有代码重用
-
一大类(神类)
-
存在大量无参数方法等。
11.12 如果检测到反模式,有什么补救措施?
回答:
你可能需要重构你的代码,找到一个更好的解决方案。例如,以下是一些避免以下反模式的解决方案:
-
金锤:你可以尝试通过一些适当的训练来教育 X 先生。
-
零表示空:你可以使用一个额外的布尔变量,这个变量对你来说更合理,可以正确地表示空值。
-
数字管理:如果你能明智地使用数字,数字就是好的。你不能仅仅根据一个程序员每周修复的缺陷数量来判断他/她的能力。质量也很重要。例如,修复简单的 UI 布局比修复系统中的严重内存泄漏要容易得多。考虑另一个例子。“更多的测试正在通过”并不意味着您的系统更加稳定,除非这些测试使用不同的代码路径/分支。
-
拍摄信使:欢迎测试员“John”并立即让他参与进来。不要把他当作你的对手。你可以正确地分析他的所有发现,并尽早修复真正的缺陷,以避免最后一刻的意外。
-
复制粘贴编程:不用去寻找快速的解决方案,你可以重构你的代码。你也可以把经常使用的方法进行日常维护,这样可以避免重复,更容易维护。
-
架构师不编码:让架构师参与实现阶段的某些部分。这对组织和他们自己都有帮助。这项活动可以让他们更清楚地了解产品的真正功能。说真的,他们应该重视你的努力。
11.13 什么叫 重构 ?
回答:
在编码领域,术语“重构”意味着改进现有代码的设计,而不改变系统/应用的外部行为。这个过程有助于您获得可读性更好的代码。同时,这些代码应该更能适应新的需求(或者变更请求),并且更易于维护。
一些常用术语
仅仅从一个好的设计开始并不是开发者的责任。保持一个好的设计同样重要。如果我们专注于快速修复,而不维护最初的设计目标或架构,我们可能会遇到新的问题。
不恰当的设计会使应用变得僵化。即使您从一个好的设计开始,对该应用的持续快速修复也会使其效率低下。那么一个简单的改变可能需要很多努力。在最坏的情况下,你会看到的脆弱性问题。这是什么意思?简而言之:一个位置的一个小的变更会导致多个位置的变更,在最坏的情况下,您会发现其中一些区域与最初的变更请求毫无关系。
如果重用您或其他人以前开发的一些内置部分,您可以非常快速地开发应用。当入门级程序员听说重用时,他认为继承是他唯一可用的选择,但这不是真的。您已经看到,在许多情况下,对象组合提供了比继承更好的解决方案。但是,如果您引入的代码段依赖于许多其他东西,或者已经有潜在的错误,那么继承或组合的使用就变得次要了;你确实在损害质量。无法重用软件通常被称为不动。
粘性是 OOP 中另一个需要考虑的重要事情。维基百科将其描述为开发者可以轻松地向系统添加保留设计的代码。在维护设计的时候,如果你能很容易的给你的程序添加新的代码,你的程序粘度就低。相反的是显而易见的:在一个高粘度的设计中,添加 hacks 是容易的,而不是努力保持原来的设计。你可以肯定地看到,通过使用这些黑客,你使你的系统更加僵化。这是粘度的一种形式,也称为设计粘度。
还有一种不同的形式叫做环境粘度。考虑这样一种情况,开发人员在主代码库进行变更之前使用了一个支柱构建。我说的支柱建筑是什么意思?让我给你举个例子:假设你的公司开发了一个有很多组件或模块的大型应用。我称它们为支柱。例如,如果应用可以发送电子邮件和传真,我们就有一个电子邮件支柱和一个传真支柱。由于这些组件很大,为了维护单独的组件,公司需要单独的团队。每个团队可以使用批处理文件编译一个特定的支柱,以确保支柱中的新变化不会破坏同一支柱/组件的其他部分。这是一个柱子建筑。所以,你可以把它看作是一个单模块的构建,或者一个单组件的构建。当完整的构建(即所有支柱的完整编译)是一项耗时的活动时,这是很有吸引力的,但是您需要尽快验证一个关键的修复。让我们举一个例子:你在发布你的软件之前发现了电子邮件的一个漏洞。让我们假设如果你只编译邮件专栏,大约需要 1 个小时。但是如果你触发一个完整的构建(将电子邮件、传真和其他支柱编译在一起),需要将近 5 个小时。因此,为了维持您的交付时间表,您倾向于只运行电子邮件支柱构建。我相信我不需要告诉你,如果你在互连的模块上工作,依赖这种支柱构建是有风险的。这是因为除非你不触发完全构建,否则你不知道最后一分钟的修复是否会导致其他支柱断裂。
内聚和耦合是另外两个重要概念,是由拉里·康斯坦丁在 20 世纪 60 年代末发明的。我们所说的凝聚力是什么意思?衔接的字典含义是相互联系或统一。在 OOP 中,当你设计一个类的时候,它测量类的方法和数据之间的关系的强度。如果你能记住第四章的单一责任原则(SRP ),对你来说会很容易。这些概念是联系在一起的,尽管内聚是一个更一般的概念。
相反的是耦合。维基百科说耦合是软件模块和之间相互依赖的程度。所以,在 OOP 中,你可以说它是两个类之间相互依赖的度量。假设有两个独立的类,A 和 B。现在考虑 A 在它的一个方法中使用 B 对象的情况,或者你在 A 类构造函数中创建一个 B 类对象并对其进行处理。在这些情况下,A 和 B 是紧密耦合的。即使 B 是 A 的子类,使用 A 的方法,你也可以说它们是紧耦合的。记住,我们想要高内聚和低耦合。我用 Robert C. Martin 在“干净的代码博客”(你可以在 https://blog.cleancoder.com/uncle-bob/2014/05/08/SingleReponsibilityPrinciple.html
)中的话来结束这一章:
“如果你思考这个(SRP),你会意识到这只是定义内聚和耦合的另一种方式。我们希望增加因相同原因而变化的事物之间的凝聚力,我们希望减少因不同原因而变化的事物之间的耦合。”
问答环节
11.14 你在这本书中没有谈到 C# 的最新特性。有什么具体原因吗?
回答:
我们都知道,变化是软件行业唯一的“不变”。今天新的东西,明天就可能过时。是的,一些新功能很有趣。例如,在最新版本的 C# 中,可以使用顶级语句执行没有Main()
方法的程序。这些语句按照它们在文件中出现的顺序执行。例如,考虑以下代码:
using System;
namespace WithoutUsingTopLevelStatements
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello World!");
}
}
}
在早期版本的 C# 和。NET 中,namespace
和args
参数是可选的。现在进来了。NET 5,你可以有一个进一步简化的版本:
using System;
Console.WriteLine("Hello World!");
记住,要编译它,你必须使用 C# 9.0(目标框架。NET 5.0);否则,您会看到以下内容:Error CS8400 Feature 'top-level statements' is not available in C# 8.0.
的确,这是一个很大的简化。这个特性对于脚本场景很有用。现在,您输入的内容更少了,代码也更小了,但是您可以毫无问题地获得想要的输出。在这种情况下。NET 平台为你提供了所有幕后必需的东西。今天,如果初学者从这条捷径开始,他可能很难想象背景材料和遗留代码。(但有可能几年后,所有初学者都会更喜欢从这里开始。)
事实是,当你加入一个公司团队时,不太可能每次都使用编程语言的最新特性。相反,大多数时候,您使用遗留版本来支持现有客户。除非公司决定你应该进行更新,否则你也可能会继续修复旧版本中的错误。所以,我总是努力保持平衡。在我的其他书中,我也使用了编程语言的基本特征,这样你就可以很容易地理解它们。我更喜欢编写各种版本都支持的代码。
我想知道你是否在这本书里使用了其他最新的 特征 。
回答:
这是你在这本书里看到的另外两个例子:
-
C# 9.0 允许你检查一个对象是否不为空。因此,下面的代码块不会产生任何编译时错误:
if (sample is not null){ // some code }
-
从 C# 8.0 开始,您可以使用以下语句:
using Sample sample = new Sample();
你能给我一些一般性的建议吗?
回答:
我喜欢跟随我的前辈和老师的脚步,他们是这方面的专家。以下是他们的一些一般性建议:
-
编程到超类型(抽象类/接口),而不是实现。
-
除了少数情况之外,尽可能选择组合而不是继承。
-
尽量做一个松散耦合的系统。
-
隔离可能与代码其余部分不同的代码。
-
封装变化的内容。
摘要
跟随专家的足迹并从记录的经验中学习是一个非常好的策略。所以,理解设计模式非常重要。同时,建议你明智地使用它们;否则,您可能会注意到反模式的影响。一个明显的影响是,您需要投入时间来重构代码或从头开始实现新的设计。无论如何,你应该更喜欢一个没有吸引力的更好的解决方案,而不是一个有吸引力的快速解决方案。
我还在本章末尾描述了一些常用术语。这些通常有助于你理解一个演讲者在技术会议上说了什么,或者一个技术作者在他的书中写了什么。
第一部分:基础知识
Fundamentals
第一部分由三章组成,其中我们将讨论以下问题:
-
我们如何使用多态的力量,为什么它是有益的?
-
我们如何将抽象类和接口结合在一起,以创建一个高效的应用?
-
如何在程序中使用有意义的代码注释,避免不必要的注释?
几乎每个 C# 应用都使用注释、多态的概念以及抽象类和接口。当我们以更好的方式实现这些技术时,程序就会更好。我认为它们是高效应用的基本技术。
第二部分:重要原则
Important Principles
第二部分由两章组成,其中我们将检查以下内容的使用:
-
SOLID 原则。这是五个设计准则的组合。
-
不重复自己(干)的原则
在面向对象编程的世界里,并不缺少原则,但是我们将在接下来的两章中讨论的是更好的应用的基本设计准则。人们无法预先预测所有未来的需求,因此在企业应用中经常需要更改。这就是为什么能够轻松适应未来需求的灵活应用被认为是更好的应用。这一部分将回顾使用(和不使用)这些原则的案例研究,并帮助你思考它们的重要性。对这些原则的详细研究可以帮助您创建高效灵活的应用。
第三部分:高效应用
Make Efficient Applications
第三部分由四章组成,其中我们将按照几个重要的设计模式开发一些有用的应用。这一部分将包括以下内容:
-
我们如何使用工厂将一个更有可能变化的代码段与一个不太可能变化的代码段分开?
-
我们如何使用包装器向应用添加新特性?
-
怎样才能把一个模板方法和一个挂钩方法结合起来做一个高效的应用呢?
-
我们如何使用外观来简化一个复杂的系统?
软件行业充满了模式和设计指南。随着您继续编写和创建不同的应用,您将发现它们的重要性,并理解何时选择一种技术而不是另一种。在书的序言中,我告诉过你 帕累托 原理,或者说 80-20 法则 ,它陈述了 80%的结果来自所有原因的 20%。这就是为什么在这一部分中,我向您展示了构建现实世界应用常用的技术。一旦你掌握了这些技巧,你就会得到以下问题答案的提示:一个职业程序员在写一段代码之前是怎么想的?