C# 课程 - 第4课:面向对象编程基础:C# 示例中的抽象、封装、继承、多态






4.81/5 (82投票s)
这是我为同事举办的培训的第 4 篇文章
全部课程集
- C#Lectures - 第 1 讲:
原始类型 - C# 课程 - 第2课:在 C# 中处理文本:char, string, StringBuilder, SecureString
- C# 课程 - 第3课:C# 类型设计。你必须知道的类基础知识
- C# 课程 - 第4课:面向对象编程基础:C# 示例中的抽象、封装、继承、多态
- C# 课程 - 第5课:C# 示例中的事件、委托、委托链
- C# 课程 - 第6课:C# 中的特性和自定义特性
- C# 讲座 - 第 7 讲:
反射( 通过 C# 示例) - C# 课程 - 第8课:灾难恢复。C# 示例中的异常和错误处理
- C# 讲座 - 第 9 讲:
Lambda 表达式 - C# 课程 - 第10课:LINQ 简介,LINQ to Objects 第一部分
引言
关于面向对象编程 (OOP) 和面向对象设计 (OOD) 的文章可能数不胜数。你可能会问:为什么这个人还要写一篇?我有两个答案。首先,我的文章是系列的一部分,我通过这个系列来培训我的新同事学习 C#,而 OOP 基础是这个系列的要求。第二个原因是,有时候只有特定的作者才能用更适合你的语言来解释某个话题。我希望成为这样一位作者,如果至少有一个人能对自己说:“我以前不知道\不理解\不明白这一点,而这位作者为我找到了合适的词语。”,那对我来说就是最大的回报。在一篇简短的文章中解释 OOP 这样的话题非常困难,写一篇非常具体的技术文章要容易得多。请不要对这篇文章太过苛责,如果你不喜欢某些内容,请停止阅读并搜索其他资源。
面向对象编程
“面向对象编程(OOP)是一种基于‘对象’概念的编程范式,对象是包含数据结构的数据,数据形式为字段,通常称为属性;代码形式为过程,通常称为方法。” - 维基百科
面向对象编程是一种使用对象来模拟软件试图解决的任务的方法。你的模型是现实世界的简化模型。你创建类来描述现实世界中的某些实例,这些类包含一些内部数据并具有某些状态。类的对象相互通信,并且可以在内部以及与外部对象进行某些数据操作。关于 OOP 和 OOD(面向对象设计)的描述太多了,我不想重新发明轮子创造我自己的描述。我建议每位程序员找到最适合自己需求的好资源,并阅读关于面向对象范式的内容。我的建议是阅读 Eric Evans 的《领域驱动设计》和 Grady Booch 的《面向对象设计》。我喜欢他们解释概念的方式以及他们解决问题并开始实现软件解决方案的方法。我个人认为,当解决方案简单时,它是好的。我见过几十种设计,它们如此复杂,以至于无法扩展和维护。架构师的任务是通过清晰易懂的简单解决方案来解决问题。这种解决方案的可伸缩性和可维护性应该是直接而简单的。当开发人员向解决方案添加越来越多的功能时,整个概念应该保持不变,初始架构应该是解决方案的基石。以上所述都是真实的,并且可以为任何解决方案实现。作为架构师或程序员,你应该定期坐下来思考你可以简化和增强模型和解决方案的方面。一旦改进过程建立起来,你将获得持续的改进,使你的软件保持稳定、可伸缩和健壮。在本文中,我想重点介绍被认为是 OOP 主要思想的 4 个原则:抽象、封装、继承和多态,并用 C# 代码示例展示它们的实现。
抽象
每个人开发软件产品都有其复杂性。如今,所有软件产品都变得越来越复杂。几乎你使用的每个程序都包含云服务器和数据库。许多系统都有后台运行的分析和统计引擎,对于最终用户来说不一定可见,但对软件提供商来说却非常重要。所有这些复杂性都由一个工程师或(99%的情况下)一群人设计和实现。这些开发者的任务是将系统的复杂性分解,并将其分成构成软件产品的小单元。这项任务通常由不同的工程团队(可能是扮演不同角色的同一个人)通过迭代来完成。首先,架构师进行高层设计,然后与开发人员一起进行低层设计,最后在解决方案文档化后,程序员开始实现。当然,这只是复杂系统开发流程的一个例子。如今,我们有许多组织软件开发流程的方法。底线是,最终你将得到用于构建软件的构建块,它们是系统组成的单元。在 OOP 中,这些构建块是类。(在说出这样的话后,我通常会收到很多评论,说不仅是类,不仅是 OOP 等等)。为了简单起见,我们就假定只有类。类是构建软件的基本单元。类的对象可以包含一些数据来表示其状态,类本身也可以包含一些数据(静态成员),同时对象和类都可以通过事件、方法、属性、直接访问类成员等方式与其他对象进行交互。有关 C# 中类的更多信息,请参阅我在此处的文章这里。
当你用类构建软件时,你会将现实世界的对象封装成软件抽象。抽象过程就是构建一个软件模型,该模型代表现实世界中某个或某些任务的解决方案。你的抽象映射到真实的物体或过程,并在你的软件解决方案中代表这些物体。Grady Booch 将抽象定义为:“抽象表示了一个对象的本质特征,这些特征将其与所有其他类型的对象区分开来,从而根据观察者的视角,提供了清晰明确的概念界限。” 抽象实际上是面向对象设计(OOD)的一部分——即使用面向对象技术构建模型的過程。
让我们来看一个关于抽象和 OOD 的例子。假设我们有一个农民,他种植蔬菜:番茄和卷心菜。他今年收成很好,赚了一些额外的钱,决定将他的农场数字化。他购买了控制器,这些控制器每小时会向他发送关于土地当前温度、空气湿度和土地湿度的信号。此外,他还有一个控制器可以远程开启灌溉系统。现在,这位农民需要一个简单的软件来分析来自 3 个控制器的输入,并根据蔬菜类型决定是否需要开启灌溉。让我们思考一下这里的解决方案。我们需要为这个解决方案构建一个模型(抽象)。建议如下:
- 我们需要为每种蔬菜创建一个类。这个类将有一个方法,该方法接收控制器的数据,并决定是否需要灌溉,需要多少水(灌溉需要执行多长时间)。由于蔬菜的数量未来可能会增加,我们建议使用一个抽象类,所有蔬菜类都将继承它。这将使我们能够轻松地添加更多蔬菜类。
- 我们需要一个 IrrigationSystem 类,它将从所有控制器请求数据,并与蔬菜类检查是否应开启灌溉。如果需要,此类将开启灌溉。
这是展示类模型代码的一个示例。这是一个非常简单的伪代码示例,仅用于演示物理世界中的事物和过程如何映射到类等软件元素。
关于如何使用文章或代码的简要说明。类名、方法和属性,任何技巧或窍门。
////////////////////////////////////ABSTRACTION//////////////////////////////////////////
public abstract class Vegetable
{
public virtual bool DoINeedIrrigationNow(float LandTemperature, float AirHumidity, float LandHumidity){return false;}
public void IrrigateMe()
{
//here we call irrigation device to give water
}
}
internal class Tomato : Vegetable
{
public override bool DoINeedIrrigationNow(float LandTemperature, float AirHumidity, float LandHumidity)
{
//here we have code that checks input parameters and returns result
return true;
}
}
internal class Cabbage : Vegetable
{
public override bool DoINeedIrrigationNow(float LandTemperature, float AirHumidity, float LandHumidity)
{
//here we have code that checks input parameters and returns result
return true;
}
}
internal sealed class IrrigationSystem
{
List<Vegetable> vegetables = new List<Vegetable>();
public void IrrigationProcess()
{
//let's assume we already read configuration here and know
//all the vegetables that we support and have irrigation
//devices connected to them
float LandTemp = 0;
float AirHumidity = 0;
float LandHumitity = 0;
foreach (Vegetable item in vegetables)
{
//read values of specific controllers and path them to function
if (item.DoINeedIrrigationNow(LandTemp,AirHumidity, LandHumitity))
{
item.IrrigateMe();
}
}
}
}
封装
封装是抽象的一种延续。一旦我们确定了解决方案的模型,并选择了应该如何实现,我们就应该向最终用户隐藏实现细节。封装隐藏了实现细节,并将它们视为秘密。开发人员定义了每个特定类应该公开什么,并将这些数据提供给类的用户,所有其他细节都隐藏起来。封装是通过隐藏对象的内部结构以及其方法的实现来实现的。所有公共的部分都是与特定对象交互的接口,该接口由开发人员(作为其创建者)控制。正如 Liskov 所说:“为了使抽象起作用,实现必须被封装。”。封装有助于为抽象添加规则,并使其对最终用户更加清晰。就类和对象而言,这意味着它们应该通过一个接口来封装,以进行交互,并且实现是隐藏的,被视为一个黑箱。封装可以保护你的类免遭意外访问其内部结构,并防止通过更改某些状态或内部变量的方式来损坏它,而这些方式是你没有预料到的。隐藏成员变量并通过函数来访问类,可以让你实现并测试一次,然后就能确保你的类始终运行正常。
以 C# 这样的编程语言来谈论,我们可以为封装定义以下建议:
- 所有数据成员都应该是私有的。在将某个数据成员声明为非私有之前,你应该三思。
- 如果你在类中有一些只在内部使用且不期望被类用户使用的方法,你应该隐藏它们并将其设为私有。
- 要直接访问数据成员,你应该使用方法或属性。
- 用你模型的语言来定义你的属性,不要将它们与它们所覆盖的成员变量的名称相关联。当然,这是在成员变量的名称与你的模型不匹配的情况下。
- 如果你写了一些简单的 getter/setter 对,试着思考一下为什么会这样。好的设计(除了一些例外)没有提供访问对象内部某些变量的流程。所有东西都应该被封装到函数中。
下面的代码演示了一个类,它代表了封装在类中的手机的简化模型。
////////////////////////////////////ENCAPSULATION//////////////////////////////////////////
internal sealed class CellPhone
{
private int m_CellNetworkConnectionQuality;//let's say it is integer from 1 to 10
private int m_BatteryPercentage; //shows in percents battery status from 1 to 100
public bool CallToPerson(string PersonsCellPhone)
{
//calling and return true
AddCallDataToLog();
return true;
}
public bool StopCurrentCall()
{
//stopping and return true
return true;
}
public bool AnswerInputCall()
{
//answering and return true
AddCallDataToLog();
return true;
}
public void GetPhoneStatus(out int NetworkStatus, out int BatteryStatus)
{
NetworkStatus = m_CellNetworkConnectionQuality;
BatteryStatus = m_BatteryPercentage;
}
private void AddCallDataToLog()
{
//adds call data to log
}
}
继承
在我们日常的语境中,继承意味着项目 A 从项目 B 继承某些特征或行为。通常,项目 A 和项目 B 之间存在某种关系。新车型的设计继承了很多前代车型的元素,孩子继承父母的眼睛和头发颜色等等。有时,像人类孩子-父母的例子一样,继承是我们无法控制的。有时,像汽车型号的例子一样,这是工程师完全控制的过程。在 OOP 中,继承是你完全可以控制的。继承有成千上万的定义。我想提供其中几个:
“继承可以定义为一个类获取另一个类的属性(方法和字段)的过程。通过使用继承,信息可以以分层的方式进行管理。”
“继承使你能够创建新类,这些类可以重用、扩展和修改在其他类中定义的行为。成员被继承的类称为基类/父类,继承这些成员的类称为派生类/子类。”
C# 支持单一继承。这意味着一个类只能有一个基类。像 C++ 这样的其他语言支持多重继承,即一个类可以派生自多个类。C# 设计者决定只允许一个基类,这最初可能让来自 C++ 的人感到不便,但最终他们也习惯了(我就是个例子)。
继承规则:
- 你应该知道继承是可传递的。如果类 A继承类 B,并且类 B继承类 C,这意味着类 A也继承了类 C的所有内容。在 .NET 中,所有 C# 类都遵循这条链,直到达到所有类的父类System.Object(更多信息请参阅我在此处的文章这里)。下面是一个展示此的小示例。
- 正如你在上面的示例中看到的,我将最后一个类标记为sealed。当你使用sealed修饰符用于类时,它会限制该类被继承。你会问,为什么要做这个标记,我可以继承也可以不继承,为什么要有一个特殊的标记。第一个原因是防止不需要且想要限制的派生。一个很好的例子是基础string类(更多关于字符串的信息,你可以了解我在此处的文章这里)。第二个原因是提高效率。如果你调用一个 sealed 类的成员,并且类型在编译时被声明为该 sealed 类,编译器可以在(大多数情况下)使用 call IL 指令而不是 callvirt IL 指令来实现方法调用。这是因为方法目标不能被重写。Call 消除了空检查,并且比 callvirt 进行更快的 vtable 查找,因为它不必检查虚拟表。
- 当一个类继承另一个类时,它会继承其所有以及其父类的public或protected, internal and internal protected标记的方法和它们的签名。Public 部分对子类用户可用,protected 部分仅在子类内部可用。即使派生类继承了基类的私有成员,它也无法访问这些成员。
- 继承使我们能够在所有使用基类的地方使用继承的类。
- 基类可能具有标记为virtual的方法。virtual这个词的意思是,父类有方法实现,可能满足子类的需求。如果情况并非如此,子类可以自由地重写该方法。在虚拟方法调用中,实例的运行时类型决定了要调用的实际方法实现。在非虚拟方法调用中,实例的编译时类型是决定因素。
- 基类可能还有另一种类型的方法。这些是abstract方法。你必须在子类中重写abstract方法,而基类没有默认实现。抽象方法只能在抽象类中定义。抽象类是不能通过调用new运算符实例化的类。抽象类是只能被派生的类。它们可能包含一个或多个抽象方法的签名。派生类也可以是抽象的,但如果它们不是抽象的,则必须实现从其父类继承的所有抽象函数。
- 接口与抽象类类似。它是一种引用类型,只有抽象成员。当一个类实现一个接口时,它必须实现接口中定义的所有成员。一个类可以实现多个接口,而它只能有一个基类。使用 C# 中的接口,你可以实现多重继承,以确保一个类实现了你感兴趣的一组接口。
- 派生类可以通过定义具有相同名称和签名的成员来隐藏父类成员。你可以使用new关键字来隐式地指示这一点。使用 new 关键字不是必需的,但这是良好的风格,此外,如果不使用new运算符,你会收到编译器警告。这称为方法隐藏。
下面的代码演示了前面描述的内容。
////////////////////////////////////INHERITANCE//////////////////////////////////////////
internal class C
{
public void CMethod()
{
Console.WriteLine("I'm a method of class C");
}
}
internal class B : C
{
public void BMethod()
{
Console.WriteLine("I'm a method of class B");
}
}
internal sealed class A : B //class A can't be inherited as we declared it as sealed
{
public void AMethod()
{
Console.WriteLine("I'm a method of class A");
}
}
internal class D : B
{
protected void ProtectedMehodInD()
{
Console.WriteLine("I'm protected method of class D");
}
public virtual void VirtualMethodInD()
{
Console.WriteLine("I'm default implementation of virtual method in D");
}
public new void BMethod()
{
Console.WriteLine("I'm a hidden B method of class B and reimplemented in class D");
}
}
internal class E : D
{
public void EMethod()
{
Console.WriteLine("I'm a method of class E ");
ProtectedMehodInD();
}
}
internal class F : D
{
public override void VirtualMethodInD()
{
Console.WriteLine("I'm overridden implementation of virtual method in D");
}
}
internal abstract class AbstractClass
{
public abstract void AbstractFunction();
}
internal interface Interface1
{
void Interface1Function();
}
internal interface Interface2
{
void Interface2Function();
}
//below we have one parent abstract class and implement two interfaces
internal class AbstractAndInterfaceImplementation : AbstractClass, Interface1, Interface2
{
public override void AbstractFunction()
{
Console.WriteLine("Abstract function implementation");
}
public void Interface1Function()
{
Console.WriteLine("Interface1Function function implementation");
}
public void Interface2Function()
{
Console.WriteLine("Interface2Function function implementation");
}
}
//INHERITANCE USAGE
//transitive inheritance example
A aVar = new A();
aVar.AMethod();//prints "I'm a method of class A"
aVar.BMethod();//prints "I'm a method of class B"
aVar.CMethod();//prints "I'm a method of class C"
//protected example
E eVar = new E();
//the code below will not compile as we can't see protected
//methods here, but we can successfully use it inside class E implementation
//eVar.ProtectedMehodInD(); - failed at compile time
eVar.CMethod();//prints "I'm a method of class C"
eVar.EMethod();//prints "I'm a method of class E" and "I'm protected method of class D"
//use child classes in context of parent class
C cVar = new A();
cVar.CMethod();//prints "I'm a method of class C"
cVar = new B();
cVar.CMethod();//prints "I'm a method of class C"
//hide base class member
D dVar = new D();
dVar.BMethod();//prints "I'm a hidden B method of class B and reimplemented in class D"
//virtual functions
dVar.VirtualMethodInD();//prints "I'm default implementation of virtual method in D"
dVar = new E();
dVar.VirtualMethodInD();//prints "I'm default implementation of virtual method in D"
dVar = new F();
dVar.VirtualMethodInD();//prints "I'm overridden implementation of virtual method in D"
//abstract class and interface
AbstractAndInterfaceImplementation classVar = new AbstractAndInterfaceImplementation();
AbstractClass abstractVar = classVar;
abstractVar.AbstractFunction();//prints "Abstract function implementation"
Interface1 interface1Var = classVar;
interface1Var.Interface1Function();//prints ""Interface1Function function implementation""
Interface2 interface2Var = classVar;
interface2Var.Interface2Function();//prints ""Interface2Function function implementation""
多态
多态是通过继承技术实现的,但它被单独视为 OOP 的基石之一。就像我们在这篇文章中讨论过的其他术语一样,多态有成千上万的定义,它们的意思相同,但描述方式不同。我想向你展示一些多态的定义:
“多态是指对象能够采取多种形式的能力。在 OOP 中,多态最常见的用法是当父类的引用用于引用子类对象时。”
https://tutorialspoint.org.cn/
“多态是类型论中的一个概念,其中一个名称可以指代许多不同类的实例,只要它们通过某个共同的超类相关联。因此,由该名称标识的任何对象都能够以不同的方式响应一组公共操作。通过多态,一个操作可以被层次结构中的不同类以不同的方式实现。”
Grady Booch
多态可以是静态的,当我们知道在编译时使用哪种类型时;也可以是动态的,当用于实际工作的类型在运行时确定时。动态多态更加灵活,实际上,当有人谈论它时,他们通常指的是动态。我个人对多态的定义是:“在运行时动态决定实现的能��。”如果你有一个流程,最终会产生特定的功能,这些功能可以以不同的方式实现,那么多态是最好的方法。在我看来,多态的一个好例子是包含不同插件的架构。核心功能提供了一些接口,第三方可以以不同的方式实现这些接口,然后这些功能作为插件加载到核心中。
在 C# 中,静态多态通过函数或运算符重载来实现。动态多态在 C# 中是通过虚拟函数实现的。基类可以定义并实现虚拟方法,派生类则可以重写它们。当派生类重写函数时,意味着该类提供了自己的定义和实现。在运行时,当客户端代码调用虚拟方法时,CLR 会查找对象的运行时类型,并调用该方法的正确实现。
下面的代码演示了一个简单的多态示例。
////////////////////////////////////POLYMORPHISM//////////////////////////////////////////
internal class CBaseShape
{
public virtual void PaintMyself()
{
Console.WriteLine("I'm default implementation and don't paint anything");
}
}
internal class Rhombus : CBaseShape
{
public override void PaintMyself()
{
Console.WriteLine(" *");
Console.WriteLine(" ***");
Console.WriteLine(" *****");
Console.WriteLine(" ***");
Console.WriteLine(" *");
}
}
internal class Square : CBaseShape
{
public override void PaintMyself()
{
Console.WriteLine(" ****");
Console.WriteLine(" ****");
Console.WriteLine(" ****");
Console.WriteLine(" ****");
}
}
internal class Rectangle : CBaseShape
{
public override void PaintMyself()
{
Console.WriteLine(" ********");
Console.WriteLine(" ********");
Console.WriteLine(" ********");
}
}
//POLYMORPHISM USAGE
bool bExit = true;
Rectangle rect = new Rectangle();
Rhombus romb = new Rhombus();
Square sqr = new Square();
while (bExit)
{
CBaseShape bs = new CBaseShape();
Console.WriteLine(@"Type your choice or type 'exit' to stop");
Console.WriteLine(@"Reminding you can see behavior of following figures: rhombus, square, rectangle");
string line = Console.ReadLine();
if (line == "exit") // Check string
{
break;
}
//here we assume that classes as Rhombus, Rectangle and Square come to us from some
//third party DLLs that we load while runtime
switch (line)
{
case "rhombus":
bs = romb;
break;
case "square":
bs = sqr;
break;
case "rectangle":
bs = rect;
break;
default:
break;//doing nothing here
}
bs.PaintMyself();
}
使用 new 与 override
我之所以在文章中添加这一节,是因为你们读了几千遍之后,我收到了几条改进建议。其中一条建议是稍微用不同的方式展示多态,另一条建议是解释何时应该使用 new,何时使用 override,以及它们之间的区别。
让我们回顾一下展示多态示例的代码,其中使用前面章节的类,但以不同的方式执行其用法。
//When we use new and when override
CBaseShape[] t = new CBaseShape[3];
t[0] = new Rhombus();
t[1] = new Rectangle();
t[2] = new Square();
for (int i = 0; i < t.Length; i++)
{
t[i].PaintMyself();
}
在这个代码示例中,我们在循环中调用 PaintMyself 函数,并为此目的使用基类对象。编译器不知道每次迭代将调用哪个方法,并且会动态链接到正确的对象。现在,让我们稍微修改一下 Rectangle 类,并将 PaintMyself 实现中的 override 替换为 new。当我们再次编译并执行时,结果将是 Rhombus 和 Square 调用了它们的实现,但 Rectangle 调用了默认实现。发生这种情况是因为使用 new 运算符时,Rectangle 类没有实现 CBaseShape 的虚拟函数,编译器别无选择,只能调用默认实现。基于此,你应该理解 new 和 override 之间的区别,并在应用程序中使用多态时非常小心地使用它们。我已经更新了附加到此项目中的源文件,其中包含此示例,你可以自行尝试。
附注:Pawel,感谢你为我的文章提供的宝贵意见。
来源
- “面向对象分析与设计及应用” 作者:Grady Booch
- “领域驱动设计:驾驭复杂软件核心” 作者:Eric Evans
- https://en.wikipedia.org/wiki/Object-oriented_programming
- Jeffrey Richter - CLR via C#
- Andrew Troelsen - Pro C# 5.0 and the .NET 4.5 Framework
- https://msdn.microsoft.com