一、使用多态的灵活代码
问一个开发人员,“面向对象编程(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。我分析了这两点的重要性。**