65.9K
CodeProject 正在变化。 阅读更多。
Home

术语和原则概述

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.76/5 (24投票s)

2012年10月25日

CPOL

23分钟阅读

viewsIcon

71475

了解OOP、AOP、松耦合、类继承、接口实现、开闭原则等概念。

背景

我看到很多文章都在谈论松耦合,很多时候也谈论接口实现而不是类继承,或者干脆说正确的做法是遵循开闭原则。

通常这些术语是相关的,但在很多地方它们似乎是同一回事,或者它们的定义根本没有解释任何东西,所以我决定写一篇文章来解释每个术语的含义,并给出个人解释以帮助区分和关联它们。

OOP - 面向对象编程

这可能是最常见的术语,也是对初学者来说最令人困惑的术语。在.NET世界中,我们别无选择,我们不可避免地会创建类(或至少是结构),如果我们实例化它们(这意味着我们执行类似new MyClass()的操作),我们就会创建对象。

这就是面向对象编程,对吧?

嗯……我经常听到这种说法,它是错误的。C语言有结构体,可以容纳特定类型的数据并给它们命名。然而,C语言不被认为是面向对象的。

如果我们查阅维基百科,那里有一个定义说:“在OOP中,每个对象都能够接收消息、处理数据并向其他对象发送消息。”

在我看来,这样的定义是不精确的。也许它是“正确”的定义,但它并不能真正帮助初学者理解问题。

首先,什么是消息?其次,什么是对象?

有些人认为任何方法调用,包括参数,都是一条消息。那对象呢?嗯,你持续使用的任何内存区域都是一个对象。因此,根据这样的定义,C语言是一种面向对象的语言,因为我们通常分配结构体,并且有函数来操作它们。

但如果我们在维基百科上进一步查找,它会说:“……面向对象的方法鼓励程序员将数据放置在程序的其余部分无法直接访问的地方。”

这被称为封装,它是将OOP与非OOP区分开来的重要一点。在C语言中,所有结构体字段都是公共的。你可以创建自己的个人规则来避免直接访问它们,但语言本身会继续允许这样做。在.NET中,字段不应该是公共的,而应该是私有的,并且只能通过方法(属性是一种特殊的方法)进行访问(无论是读取还是写入)。这样做的目的是允许内部实现被重写,而无需调用者更改。也就是说,如果你直接更改字段,你就不会执行可能需要的验证,你可能不会填充依赖于它的辅助字段。但通过使用“set”方法,所有验证或辅助步骤都将完成,即使你不知道它们的存在。

所以,通过使用可见性修饰符,你迈出了OOP的第一步。或者,好吧,它可能已经是OOP了,但对我来说,还有更多!

我正在正确使用可见性修饰符,而且我没有创建结构体,我正在创建类!所以它是面向对象的!

实际上,创建structclass不是问题,因为在.NET中,结构体只改变了它的分配方式,而在某些C++实现中,结构体和类的区别只是默认可见性。对我来说,真正的OOP只有在我们正确使用_继承和方法重写_时才存在(或者,正如一些人所说,当我们使用正确的抽象时)。

为了说明这个问题,我曾在一些使用ORM对象的公司工作过,每个数据库表都有一个类来表示。它们都有相同的结构

  • 数据库中存在的所有字段都有一个Get和Set方法;
  • 有一些方法,如Save、Delete等。

但它们有一个小问题,没有共同的基类。因此,我无法创建一个像DeleteWithConfirmation这样的方法,它能够显示一条消息,如果确认,则删除给定的记录。

问题是,要创建这样的方法,我应该为Person或Product(或其他东西)创建它,而我应该为DatabaseRecords(数据库记录)普遍创建该方法。

因此,OOP的第二步是:创建祖先。一个带有Delete方法的DatabaseRecord类会起作用。然后,Person和Product都可以继承自DatabaseRecord,并且可以创建DeleteWithConfirmation方法,显示消息,如果确认,则调用record.Delete()方法。

但是,Delete的实现应该是什么样子呢?它是在DatabaseRecord中创建的,它不知道自己是Person还是Product。

解决方案,将其创建为抽象的,并强制继承者实现(重写)它。

从维基百科再引用一句话,有一个主题名为“多态性千变万化”,它开头写道:“面向对象编程允许程序员为对象创建过程,而这些对象的确切类型在运行时之前是未知的。”这意味着,对于我们的情况,DeleteWithConfirmation适用于DatabaseRecords,无论它们的真实类型是Persons、Products还是其他任何东西。

所以,我们在这里完成了基本的OOP。我们需要使用封装(即,将字段设为私有,并且只允许通过方法或属性访问它们,即使一开始看起来代码量很大),并且有一个至少有一个虚方法(我们有充分理由要重写)的类,才能说我们正在使用OOP。仅仅创建一个类或仅仅使我们的方法虚化而从未期望重写它们,意味着我们没有使用OOP,即使我们有对象。

但是,如果你不习惯看到virtualoverrideabstract这些术语,那么它们在这里:

  • virtual: 在一个类中声明的方法,其实际代码可以被子类替换;
  • abstract: 这是一个没有默认实现的虚方法,因此它不允许替换其实现,而是强制子类实现它;
  • override: 实现抽象方法或重新实现虚方法的行为。在C#中,你需要使用override关键字明确表明你想实现或重新实现抽象或虚方法。

类继承和接口实现

C++ 中有多重继承,所以它不需要接口。尽管我不同意从 .Net 中移除多重继承的原因,但接口和抽象类的主要区别是

  • 抽象类可以有一个默认实现,但如果你继承该类,就不能继承任何其他类;
  • 接口没有默认实现,但你的对象可以自由地实现任意数量的接口。

我通常会看到“接口是一种契约”的说法。

那是什么意思?我签了什么字吗?我需要律师吗?

答案可能是:接口包含告诉你应该实现哪些方法(就像一份要求你做一件事或多件事的合同),而实现它就是接受这份合同。

好的……有道理,但我还是不喜欢“契约”这个词。但让我们回到主题。

你可以创建一个没有默认实现的抽象类。但你应该这样做吗?

在我看来。不。永远不要这样做。事实上,在普通的C++中,你就是这样创建接口的(在这种情况下是有效的,因为普通的C++没有接口),但在.Net中这样做就像创建了一个不能被具有不同祖先的类实现的接口。

所以,如果你不需要默认实现,就使用接口。

我可以更进一步说:总是创建接口并将参数作为接口接收,然后,如果你愿意,你可以创建一个带有默认实现的抽象类,当人们想要实现接口时可以使用它。

我可以重复所有这些话,但我还是不明白。为什么要创建接口和抽象类?为什么不直接创建抽象类呢?

好的……抽象很棒,但它使事情变得复杂。

我将忽略MarshalByRefObject的一些特性(我认为这是一个真正的错误)和扩展方法(它们是伪解决方案,不是真正的解决方案),我将以Stream为例。

在Stream类中,我们有Write(byte[] buffer, int offset, int length)方法。

我们没有一个简单地写入整个缓冲区的Write(byte[] buffer)。它将很容易实现。

public void Write(byte[] buffer)
{
  if (buffer == null)
    throw new ArgumentNullException("buffer");

  Write(buffer, 0, buffer.Length);
}

该实现所做的只是调用需要偏移量(将为零,以获取数组的开头)和长度(将是数组的完整长度)的Write,因此它将写入完整的缓冲区数组。

使用抽象类,我可以做到这一点。使用接口,我只能声明存在两个不同的Write方法,但我无法为其中一个提供默认实现。

所以,抽象类可以有一个已实现的Write(byte[] buffer),以及一个需要由子类(如File和MemoryStream)实现的Write(byte[] buffer, int offset, int length)

但是现在,为了举例说明为什么它应该从接口开始,想象一下我想创建一个实现,它记录每个被调用的动作,然后重定向到另一个执行实际动作的Stream

第一个问题可能是我需要一个不同的基类。在这种情况下,接口允许我这样做,而抽象类不允许。

此外,如果抽象类没有使带有一个参数的Write成为虚方法,我将无法重新实现它来执行日志记录,因此我将只在带3个参数的写入上实现日志记录。

结果是:用户调用Write(someBuffer),日志会说他调用了write(someBuffer, 0, ...someBuffer length...),而期望的结果是记录Write(someBuffer)

另一方面,如果我们有一个接口,包含两个Write方法,我们将可以选择用不同的日志实现每一个,然后重定向到真实的类。

但是,在大多数实现中,我们只会使用带一个参数的Write调用带三个参数的Write……所以,创建一个抽象类来为那些需要默认实现的人提供默认实现会很好,但我们应该在所有_消费_或_使用_对象的地方使用接口,这样我们就可以自由地接收所有可以做任何额外工作的实现。

AOP - 面向切面编程

我刚刚展示了一个日志示例,所以我会继续使用它。对我来说,AOP的描述很难理解。我不是一个以英语为母语的人,我认为我能很好地阅读和写作英语,但像“跨领域关注点”(cross-cutting concerns)这样的术语对我来说毫无意义。是不是只有我这样?

嗯,我所看到的AOP是它希望为不相关的类添加功能。

例如,当我创建DatabaseRecord.Save时,我只想实现Save方法。我不想费心记录正在做的事情。这将更好地符合单一职责原则(我们很快就会看到),因此我们不会为其他“关注点”编写代码。

什么是“关注点”?我仍然不太明白,但我会说日志记录是一种关于追踪正在做的事情的“关注点”。验证用户是否可以进行此类修改是一个安全“关注点”,但你可能只是想在编写类时避免这些“关注点”。你只想让你的类做它应该做的事情。所有其他“关注点”(日志记录、安全检查等)都应该稍后处理。

由于我们不能在执行期间向已经存在的类中添加代码,接口为我们提供了插入另一个实现的可能性,其中包含额外的关注点,然后重定向到原始类。

因此,日志类可以简单地记录调用的开始,调用原始方法,然后记录调用的结果。

借助一些JIT,我们甚至可以在运行时实现任何接口来执行日志记录、安全检查或其他任何操作。

JIT - 即时(编译)

JIT代表即时编译,实际上它主要被视为针对编译成中间字节码的语言的一种优化技术。其思想是,在执行期间(可能在加载时,也可能在解释方法被频繁使用后),这种中间字节码被编译成目标处理器。Java和.Net都有中间语言并进行JIT编译。

但这并不是JIT唯一的使用场景。如果出于某种原因,我们在应用程序执行期间生成代码,编译它,然后使用它,我们就是在进行JIT——即使我们编译成中间语言。

在这种情况下,它通常有助于避免重复代码。为了看到这种场景,让我们再次看看带接口的日志示例。

我们可能有原始类在没有任何日志的情况下执行操作,然后我们需要额外的类来执行日志记录。

但是,对于接口中存在的每个方法,我们都需要提供一个看起来相同的实现

  • 日志记录开始;
  • 调用原始方法;
  • 记录结果。

它将是相同的,只是方法名和参数改变了。这是唯一改变的地方。

那么,为什么不让这样的重复代码为你实现呢?

嗯,如果你创建一个代码生成器来完成这项工作,那么你将避免手动编写重复的代码,但你仍然需要将其编译为应用程序的一部分(提前编译)。但是,如果你在运行时生成并编译此类代码,那么这将是JIT编译。

单一职责原则 - SRP

我将真实含义和缩写以与其他缩写相反的顺序写出,因为我看到这个缩写完整写出的频率比缩写形式高,这与其他缩写相反。

但正如AOP主题所示,SRP与AOP高度相关……或者我应该说AOP有助于SRP?

我写了一篇关于SRP本身的开云,收到了一些抱怨,现在我将在此处阐述我的观点

你的类不需要处理任何与其目的无关的事情。作为程序员,你不需要在你的类中添加任何与类需要做的事情无关的东西(无论是编程指令、属性还是资源)。

这意味着,如果你在编写一个存储数据的类时需要添加一个[Serializable]属性,你就违反了SRP。

也许你使用了一个强制你这样做的框架(.Net 序列化就是这样,你可能别无选择)。但如果你有选择,请避免这样做。它将简化你的代码,如果你正在编写供其他人使用的库,你将简化他们的生活。

有人说添加[Serializable]属性不会增加类的职责。

但是,有人只是编写了他们的类,却不知道它将被序列化,然后他收到了一个抱怨,说他的类不应该被序列化。因此,为程序员添加那个该死的“标志”成了程序员的责任。同时,我们也可以说,声明“嘿,我是可序列化的!”成了类的责任。

在那篇SRP文章中,我甚至收到了这样的回复:实现ISerializable会违反原则,而属性则不会。但是,在我看来,如果类的创建目的是为了可序列化,那么实现接口是其职责的一部分,因此属性违反了原则,而接口则没有。(我已经看到有人在讨论这个问题了……我希望你在抱怨之前理解我的想法)。

开闭原则

对我来说,开闭原则就是面向对象编程,对于编写可能被许多不同用户使用的库的人来说,它是真正需要的。

前提非常简单(我将用我自己的话来说):你不需要编写所有的类。但你需要创建扩展点,以便用户可以在你的环境中正确创建自己的类。

以Windows Forms为例。它有按钮、文本框、复选框……但你仍然可以创建自己的控件并将其放入窗口中。你无需修改Windows Forms库本身即可做到这一点。

但这作为正常的继承,我应该说它只是OOP的一种不同解释。

而且,为什么是开闭呢?

开放是因为它允许扩展(通过继承),封闭是因为你不需要修改它(而且作为用户我们根本无法修改它,它是一个封闭的库)。

松耦合

这是最难解释和应用的概念之一,但我认为它也是最重要的。

当我们使用接口而不是基类时,我们的代码就已经实现了松耦合。如果我们的方法调用者有一个符合接口的不同类,即使它执行的工作与我期望的不同(它记录日志,验证访问,然后调用远程服务器而不是直接执行工作),我们也实现了松耦合。

AOP和开闭原则已经从中受益。但仍然有一个小问题。我将从这个例子开始

想象一下你正在一个庞大的项目中与许多人合作,其中有太多不同的类、概念等等。

所以,每个人都知道参数应该作为接口传递。没有方法接收File(特定类)或Stream(抽象类),它们都接收(IStream)接口,该接口可以是可记录的或具有其他关注点(遵循AOP,并且再次忽略MarshalByRefObject,它已经以一种我不同意的方式做到了这一点)。

所以,你进行第一次呼叫

var color = CreateObject<IColor>(255, 0, 0, 0);

我想如果你已经使用工厂模式,你就会明白我正在创建一个颜色,而且它是黑色的。

但我接下来这样做

serializer.Serializer(color);

我故意省略了序列化器的创建方式。

这里的问题是:Color是不可序列化的。

  • 它没有 [Serializable] 属性。
  • 它没有实现ISerializable接口。

但我可以轻松读取它的ARGB(Alpha=不透明度,255 = 100%不透明,R=红,G=绿,B=蓝)值,并将其写入流中,然后轻松重新创建相同的颜色。

这并非一个交叉关注点(所以它不是AOP问题),因为我不想为Color类型的某个方法添加额外的动作,我想要为一种不知道如何序列化自身的类添加一个新的动作(因此也是新的职责)。

当序列化器被创建时(比如说在程序集X中),它不知道Color类型(在程序集Y中)的存在,因此它不能有特定的代码来序列化颜色,这也不是它的职责。

当Color类型被创建时,它也不知道序列化器,即使它知道它的存在,它也不负责可序列化。

使用这两个程序集的人创建了这种需求,因此他应该在自己的程序集(比如说程序集Z)中创建Color的序列化。

创建ColorSerializer很简单。问题是,对象被传递给一个通用序列化器进行序列化。我们并不是为了能够序列化颜色而创建一个特定的ColorSerializer。

那么,我们该怎么办呢?

创建一个BaseSerializer抽象类意味着我们可以为任何其他类型创建序列化器,但是如果我们调用一个方法,它会执行一些代码然后序列化一个对象,我们也应该给它正确的序列化器。

使用ISerializable接口使对象能够自行序列化意味着我们可以创建一个能够自行序列化的Color子类。如果我们创建了这样一个SerializableColor类,但我们仍然收到一个在其他地方创建的Color(而不是SerializableColor),它仍然不可序列化,该怎么办?

现在,如果序列化器(在程序集X中)允许为不同类型注册特定的序列化代码(使用字典很容易实现),我们就可以初始化应用程序,注册Color由ColorSerializer序列化,并且在任何随机时刻,我们可以要求序列化器序列化一个对象。

使用该字典,它会发现Color的序列化器是ColorSerializer,并使用它。

如果该值是字符串,它也可以在该字典中搜索字符串的序列化器。

所以,这就是松耦合。

但要做到这一点,Serializer类应该在设计时就考虑到扩展性。它肯定会使用一些接口或抽象类,但不应该期望被序列化的项本身实现接口。它们在运行时绑定。

所以:

  • 如果你可以继承你的Color类使其可序列化,你正在遵循OOP和开闭原则,甚至单一职责原则,但你并没有创建松散耦合的代码,并且当对象是基类(Color)而不是子类(SerializableColor)时,你将陷入困境;
  • 如果你可以继承你的序列化器,使其知道如何序列化颜色,你也在遵循所有相同的原则,并且有相同的问题。如果你无法控制创建哪个序列化器,你将无法序列化颜色;
  • 但是,如果你可以创建一个ColorSerializer并将其注册为颜色的序列化器,并且Serializer类能够通过使用字典(例如)找到正确的对象序列化器,那么你将解决问题并实现松耦合。

IoC - 控制反转

控制反转是一个有问题原则,因为这个术语太通用了,通常文本说一件事却给出另一个例子。

正如名字所暗示的,控制被反转了。但是,我们谈论的是哪种控制呢?答案是它可以是两种控制。

  • 什么:在这种情况下,确切地做什么的控制不属于执行代码的一部分。例如,当代码接收到委托或接口并将其作为执行的一部分调用时,就会发生这种情况。当然有一些规则需要遵循,但它甚至不知道属性设置是否会执行验证、将该属性值写入控制台或文本文件,或者它还能做什么。
  • 何时:在这种情况下,我们谈论的是委托或接口本身。委托被赋予一个方法。但是委托无法控制它何时被调用,甚至是否会被调用。

它们太相似了,对吗?事实上,只要创建一个接口或委托,我们就已经有了控制反转,对吗?

嗯,这就是事情变得复杂的地方。大多数文档都将控制反转仅仅视为第二种情况,即“何时”的情况,但他们使用的例子却像这样

public static void ThisIsNotIoC()
{
  IData data = new Data();
  data.FirstName = "Paulo";
  data.LastName = "Zemek";
}
public static void ThisIsIoC(IData data)
{
  data.FirstName = "Paulo";
  data.LastName = "Zemek";
}

在这样的示例中,代码总是控制 FirstName 和 LastName 何时被设置,而 Data 类从不控制其属性何时被设置。然而,第一个示例不被视为 IoC,而第二个示例被视为 IoC。这就是我在大多数文档中看到的问题,事实是:存在控制反转,它关乎当属性被设置时将发生_什么_(或者数据究竟是什么)。在第一个例子中,我们知道它是一个 Data 实例,所以如果我们知道它做什么,我们已经知道会发生什么。在第二个例子中,数据对象可能是 Data 类型、LoggableData 类型或任何其他类型,我们无法控制通过设置这些属性我们实际在做_什么_。

工厂

我用了一个创建对象的方法,如下所示:

var color = CreateObject<IColor>(255, 0, 0, 0);

我们可以说CreateObject是一个工厂方法(它创建对象),并且我们也使用了控制反转。

这里的控制反转意味着我们无法控制被创建的对象(上一个主题中的“什么”)。

如果我们使用类似

var color = new Color(255, 0, 0, 0)

我们将拥有控制权。工厂的真正目的是减少类之间直接的依赖关系,这本身就是一种松散耦合,而且根据实现方式,它可以完全松散耦合并允许依赖注入。

依赖注入

这是另一个我无法同意的术语。我不知道“注入”是否与我脑海中的含义不同,但当我看到依赖注入时,我以为我们会增加依赖。所以,代码以前不依赖任何东西,现在我们放了一些它必须依赖的东西。

但实际情况是,代码已经依赖于某些可能不存在的东西,而我们正在填充依赖(所以,我将其称为依赖填充)。

举个例子?嗯,在CreateObject<IColor>的情况下,假设CreateObject方法在字典中搜索要创建的正确类型。这个字典一开始是空的,因此,依赖IColor的代码在注册(注入)IColor的实现之前将无法工作。

Register(typeof(IColor), typeof(Color))这样简单的东西可以解决问题,并且可以在调用CreateObject的不同时间完成,例如在应用程序启动时。所以,这样的注册将注入依赖……或者,正如我所说,填充它,因为依赖已经存在。

库与框架

初次发布后,我收到一条评论,要求我解释库和框架之间的区别。

就我个人而言,我称任何.DLL文件为库。因此,大多数框架都始于库。但一个常见的定义是“你调用库,框架调用你”。

根据这个定义,库只是一个你在需要时使用的类或方法。你不需要遵循特定的规则来使用它。另一方面,框架通常是一个独立运行的结构,你需要通过实现一些基类或填充事件来扩展它,在这种情况下,框架会在适当的时候调用你的代码。

我认为这种说法在大多数情况下都适用,但我不能真正说我同意它。我将以我的ConversionManagement框架为例。我称它为一个框架。如果你为它创建特定的转换,它会调用你的代码。但如果你不这样做,并且你只是以其原始形式使用它,框架将不会调用你的代码,但我不能说它会变成一个库。好吧,也许它是一个像库一样使用的框架。但是,那么,什么是一个框架呢?一个_可以_调用你的代码的库?这真的足够了吗?

我不能说我有一个直接的答案,但对我来说,框架通常比一个简单的库更完整,而且你通常(总是?)需要遵循一些规则才能正确使用它。

结论

我真的希望这篇文章有助于理解这些原则。有很多名称,但最终,如果你能创建真正松散耦合的应用程序,你最终会使用所有其他原则,即使你不知道它们的名字。

版本历史

  • 2012年11月10日。重写了IoC和工厂,并添加了依赖注入;
  • 2012年11月08日。更正了一个小小的令人困惑的说法,并添加了库与框架的区别;
  • 2012年11月07日。添加了JIT的更多细节;
  • 2012年10月25日。初始版本。
© . All rights reserved.