C--基础-核心概念和模式交互式指南-全-
C# 基础、核心概念和模式交互式指南(全)
一、面向对象的编程概念
欢迎学习面向对象编程(OOP)。你可能已经熟悉“需要是发明之母”这句谚语同样的概念也适用于此。如果我们对为什么引入这种类型的编程有一个基本的想法,或者这些概念将如何使真实世界的编程变得容易,我们的学习路径将是令人愉快的,我们将能够在各个方向上扩展我们的学习。因此,我将尝试解决一些常见的问题,然后提供一个面向对象编程的概述。
我只有两条警告信息给你。
- 如果你在第一遍后没有理解所有的东西,不要灰心丧气。有时这看起来很复杂,但渐渐地对你来说会变得容易。
- 有很多对 OOP 的批评。不要忘记,每个人都有批判新事物的倾向。所以,即使你想批判这些概念,我建议你先试着去理解和运用这些概念,然后自己决定是欣赏还是批判。
现在让我们开始旅程吧…
我们开始用二进制代码进行计算机编程,需要机械开关来加载这些程序。你可以猜到在那个年代,程序员的工作是非常具有挑战性的。后来,一些高级编程语言被开发出来,使他们的生活变得更容易。他们开始编写简单的类似英语的说明来服务我们的目的。反过来,编译器用来将这些指令翻译成二进制,因为计算机只能理解二进制语言的指令。所以,我们变得乐于开发那些高级语言。
但是经过一段时间,计算机的容量和功能增加了很多。一个明显的结果是,我们需要扩展我们的视野,我们开始尝试在计算机编程中实现更复杂的概念。不幸的是,当时可用的编程语言都不够成熟,无法实现这些概念。这些是我们主要关心的问题:
- 我们如何重用现有的代码来避免重复工作?
- 如何在共享环境中控制全局变量的使用?
- 当应用中出现过多跳转时(使用类似
goto
的关键字),我们如何调试代码? - 假设一个新的程序员加入了一个团队。他发现很难理解这个程序的整体结构。我们怎样才能让他/她的生活更轻松?
- 如何才能有效地维护一个庞大的代码库?
为了克服这些问题,专业程序员想出了将大问题分解成小块的主意。这背后的想法非常简单:如果我们能解决每个小问题/小块,最终我们会解决大问题。因此,他们开始将大问题分解成小部分,函数(或过程或子例程)的概念出现了。这些功能中的每一个都致力于解决一个小问题领域。在高层次上,管理功能及其交互成为关注的关键领域。在这种背景下,结构化编程的概念应运而生。结构化编程开始流行起来,因为小函数易于管理和调试。除此之外,我们开始限制全局变量的使用,在函数中用局部变量代替(在大多数情况下)。
结构化编程流行了近二十年。在此期间,硬件容量显著增加,一个明显的影响是,人们希望完成更复杂的任务。渐渐地,结构化编程的缺点和局限性引起了我们的注意;例如
- 假设,我们在应用的多个函数中使用了特定的数据类型。现在,如果我们需要更改数据类型,我们必须跨产品的所有功能实现更改。
- 很难用结构化编程的关键组件(即数据+函数)对所有真实场景进行建模。在现实世界中,无论何时我们创造一个产品,我们都需要关注两个方面。
- 目的。我们为什么需要这种产品?
- 行为。该产品如何让我们的生活变得更轻松?
然后对象的概念就产生了。
Points to Remember
结构化编程和面向对象编程之间的根本区别可以概括为:我们关注的是数据本身,而不是对数据的操作。
面向对象编程的核心原则很少。你可以很容易地猜到,我们将在本书的其余部分详细讨论它们。首先,我将逐一介绍他们。
类和对象
这些都是 OOP 的核心。类是其对象的蓝图或模板。对象是类的实例。用简单的语言来说,我们可以说,在结构化编程中,我们将问题隔离或划分为函数,而在 OOP 中,我们将问题划分为对象。在计算机编程中,我们已经熟悉了 int、double、float 等数据类型。这些被称为内置数据类型或原始数据类型,因为它们已经在相应的计算机语言中进行了定义。但是当我们需要创建自己的数据类型(例如,学生)时,我们需要创建一个学生类。正如当我们需要创建一个整数变量时,我们需要首先引用 int,同样,当我们需要创建一个学生对象(例如,John)时,我们需要首先引用我们的学生类。同样,我们可以说罗纳尔多是足球运动员类的对象,哈里是雇员类的对象,你最喜欢的汽车是车辆类的对象,等等。
包装
封装的目的至少是以下之一:
- 设置限制,使对象的组件不能被直接访问
- 将数据与作用于该数据的方法绑定在一起(即形成一个胶囊)
在一些 OOP 语言中,信息的隐藏在默认情况下是不实现的。因此,他们提出了一个额外的术语,叫做信息隐藏。
稍后我们会看到数据封装是一个类的关键特性之一。在理想情况下,这些数据对外界是不可见的。只有通过类内部定义的方法,我们才能访问这些数据。因此,我们可以将这些方法视为对象数据和外部世界(即我们的程序)之间的接口。
在 C# 中,我们可以通过正确使用访问说明符(或修饰符)和属性来实现封装。
抽象
抽象的主要目的是只显示必要的细节,隐藏实现的背景细节。抽象与封装也有很大的关系,但是通过一个简单的日常场景就可以很容易理解这种区别。
当我们按下遥控器上的按钮打开电视时,我们并不关心电视的内部电路,也不关心遥控器如何启动电视。我们简单的知道遥控器上不同的按钮有不同的功能,只要正常工作,我们就很开心。因此,用户与封装在遥控器(或电视)中的复杂实现细节隔离开来。同时,可以通过遥控器执行的常见操作可以被认为是遥控器中的抽象。
遗产
每当我们谈论可重用性时,我们通常指的是继承,继承是一个类对象获得另一个类对象的属性的过程。考虑这个例子。公共汽车是一种交通工具,因为它符合交通工具的基本标准。同样,火车是另一种交通工具。同样,尽管货物列车和旅客列车是不同的,但我们可以说它们都继承了列车类别,因为最终它们都满足了列车的基本标准,而列车又是车辆。因此,我们可以简单地说,继承的概念支持层次分类。
在编程世界中,继承从现有类(在 C# 中称为基类或父类)创建一个新的子类,它位于层次链中的上一级。然后我们可以添加新的功能(方法)或者修改基类功能(覆盖)到子类中。我们必须记住,由于这些修改,核心架构不应受到影响。换句话说,如果您从 Vehicle 类派生 Bus 类,并在 Bus 类中添加/修改功能,这些修改不应该影响为 Vehicle 类描述的原始功能。
因此,关键的优势是我们可以通过这种机制避免大量的重复代码。
多态
多态通常与一个具有多种形式的名称相关联。考虑你的宠物狗的行为。当它看到一个不认识的人时,它很生气,开始不停地叫。但是当它看到你的时候,它会发出不同的声音,表现出不同的行为。在编码界,你可以想到一个非常流行的方法,加法。对于两个整数的加法,我们期望得到两个整数的和。但是对于两个字符串,我们期望得到一个连接的字符串。
多态有两种类型。
- 编译时多态:一旦程序被编译,编译器可以很早就决定在什么情况下调用哪个方法。也称为静态绑定或早期绑定。
- 运行时多态:实际的方法调用在运行时被解析。在编译时,我们无法预测程序运行时将调用哪个方法(例如,程序可能会因不同的输入而表现不同)。举一个非常简单的用例:假设,当我们执行一个程序时,我们想在第一行生成一个随机数。而如果生成的数字是偶数,我们会调用一个方法 Method1(),打印“Hello”;否则,我们将调用一个名称相同但输出“Hi”的方法。现在,你会同意,如果我们执行程序,那么只有我们能看到哪个方法被调用(即,编译器不能在编译时解析调用)。在程序执行之前,我们不知道会看到“Hello”还是“Hi”。这就是为什么有时它也被称为动态绑定或延迟绑定。
摘要
本章讨论了以下主题。
- 面向对象编程导论
- 为什么会进化?
- 和结构化编程有什么不同?
- 面向对象编程的核心特征是什么?
二、积木:类和对象
班级
一个类就是一个蓝图或者一个模板。它可以描述其对象的行为。它是如何构建或实例化对象的基础。
目标
对象是一个类的实例。
面向对象编程(OOP)技术主要依赖于这两个概念——类和对象。通过一个类,我们创建了一个新的数据类型,对象被用来保存数据(字段)和方法。对象行为可以通过这些方法公开。
如果你熟悉足球(或英式足球,在美国众所周知),我们知道参加比赛的球员是根据他们在不同位置的技能挑选出来的。除了这些技能,他们还需要具备最低水平的比赛体能和一般运动能力。所以,如果我们说 c 罗是足球运动员(又名英式足球运动员),我们可以预测 c 罗拥有这些基本能力以及一些足球特有的技能(尽管 c 罗对我们来说是未知的)。所以,我们可以简单地说,罗纳尔多是一个足球运动员阶层的对象。
Note
尽管如此,你可能会觉得这是一个先有鸡还是先有蛋的困境。你可以争辩说,如果我们说,“X 先生踢得像 c 罗”,那么在这种情况下,c 罗的表现就像一个阶级。然而,在面向对象的设计中,我们通过决定谁先来使事情变得简单,我们将那个人标记为应用中的类。
现在考虑另一个足球运动员,贝克汉姆。我们可以再次预测,如果贝克汉姆是足球运动员,那么贝克汉姆一定在足球的很多方面都很优秀。此外,他必须具备参加比赛的最低健康水平。
现在假设罗纳尔多和贝克汉姆都参加了同一场比赛。不难预测,虽然 c 罗和贝克汉姆都是足球运动员,但在那场比赛中,他们的踢球风格和表现会有所不同。同样,在面向对象编程的世界中,对象的性能可以彼此不同,即使它们属于同一个类。
我们可以考虑任何不同的领域。现在你可以预测你的宠物狗或宠物猫可以被认为是动物类的对象。你最喜欢的车可以被认为是一个车辆类的对象。你喜欢的小说可以考虑作为一个书类的对象,等等。
简而言之,在现实世界的场景中,每个对象都必须有两个基本特征:状态和行为。如果我们考虑足球运动员类的对象—罗纳尔多或贝克汉姆,我们会注意到他们有“比赛状态”或“非比赛状态”这样的状态在玩耍状态下,他们可以表现出不同的行为——他们可以跑,他们可以踢,等等。
在非玩耍状态下,它们的行为也会发生变化。在这种状态下,他们可以小睡一会儿,或者吃饭,或者只是通过看书、看电影等活动放松一下。
同样,我们可以说,在任何特定的时刻,我们家里的电视可以处于“开”或“关”的状态。当且仅当它处于开启模式时,它可以显示不同的频道。如果处于关闭模式,它将不会显示任何内容。
因此,从面向对象编程开始,我们总是建议您问自己这样的问题:
- 我的对象可能有哪些状态?
- 在这些状态下,它们可以执行哪些不同的功能(行为)?
一旦你得到了这些问题的答案,你就可以开始下一步了。这是因为任何面向对象程序中的软件对象都遵循相同的模式:它们的状态存储在字段/变量中,它们的能力/行为通过不同的方法/函数来描述。
现在让我们开始编程吧。要创建对象,我们需要首先决定它们将属于哪个类;也就是说,一般来说,如果我们要创建对象,首先需要创建一个类。
Note
有一些例外情况(如系统中的ExpandoObject
类。动态名称空间)。它可以表示一个对象,其成员可以在运行时添加或删除。但是你才刚刚开始类和对象的旅程。让我们把事情变得非常简单。此刻我们可以忽略那些边角案例。
Points to Remember
一般来说,如果我们想使用对象,我们需要先有一个类。
假设我们已经创建了一个类,并将这个类的名称指定为 A。现在我们可以使用下面的语句创建一个类 A 的对象obA
:
A obA=new A();
前一行可以分解为以下两行:
A obA;//Line-1
obA=new A();//Line-2
在第 1 行的末尾,obA
是一个引用。在此之前,没有分配任何内存。但是一旦新的出现,内存就被分配了。
如果您仔细观察,您会发现在第二行中,类名后面跟了一个括号。我们用它来构造物体。这些是用于运行初始化代码的构造函数。构造函数可以有不同的实参(也就是说,它们可以随不同数量的形参或不同类型的形参而变化)。
在下面的例子中,类 A 有四个不同的构造函数。
但是如果我们没有为我们的类提供任何构造函数,C# 将提供一个默认的。
Points to Remember
如果我们没有为我们的类提供任何构造函数,C# 将为你提供一个默认的无参数公共构造函数。但是如果你提供了任何构造函数,那么编译器不会为你生成默认的构造函数。
因此,当我们看到如下内容时,我们确信使用了无参数的构造函数。
A obA=new A();
但是要知道它是用户自定义的构造函数还是 C# 提供的(换句话说,默认的构造函数),我们需要考察类体;例如,如果在一个类定义中,我们编写了如下代码。
我们可以得出结论,这里我们使用了用户定义的无参数构造函数。因此,在这种情况下,C# 编译器不会为我们生成任何默认的构造函数。
课堂演示
如果您已经达到了这一点,这意味着您可以猜到类只是我们程序的积木。我们将变量(称为字段)和方法封装在一个类中,形成一个单元。这些变量被称为实例变量(静态变量将在本书的后面部分讨论),因为该类的每个对象(即该类的每个实例)都包含这些变量的副本。(稍后,您将了解到字段可以是任何隐式数据类型、不同的类对象、枚举、结构、委托等。).另一方面,方法包含一组代码。这些只是一系列执行特定操作的语句。实例变量通常通过方法来访问。这些变量和方法统称为类成员。
Points to Remember
- 根据 C# 语言规范,除了字段和方法之外,一个类还可以包含许多其他东西——常量、事件、运算符、构造函数、析构函数、索引器、属性和嵌套类型。但是为了简单起见,我们从最常见的方法和字段开始。我将在本书后面的章节中讨论其他主题。
- 字段和方法可以与不同种类的修饰符相关联。
- 字段修饰符可以是它们中的任何一种—静态、公共、私有、受保护、内部、新、不安全、只读和易变。
- 方法修饰符可以是以下任意一种:静态、公共、私有、受保护、内部、新、虚拟抽象重写或异步。
其中大部分将在接下来的章节中介绍。
考虑一个简单的例子。现在我们已经创建了一个名为 ClassEx1 的类,并且只封装了一个整型字段 MyInt。我们还在该字段中初始化了值 25。因此,我们可以预测,每当我们创建这个类的对象时,该对象中都会有一个名为 myInt 的整数,对应的值将是 25。
为了便于参考,我们已经创建了两个对象— obA
和obB from o
ur classes 1 class。我们已经测试了对象中变量 MyInt 的值。你可以看到在这两种情况下,我们得到的值都是 25。
演示 1
using System;
namespace ClassEx1
{
class ClassEx1
{
//Field initialization is optional.
public int MyInt = 25;
//public int MyInt;
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** A class demo with 2 objects ***");
ClassEx1 obA = new ClassEx1();
ClassEx1 obB = new ClassEx1();
Console.WriteLine("obA.i ={0}", obA.MyInt);
Console.WriteLine("obB.i ={0}", obB.MyInt);
Console.ReadKey();
}
}
}
输出
附加注释
-
没有必要以这种方式初始化 MyInt。我们才刚刚开始。我们从一个非常简单的例子开始。换句话说,字段初始化是可选的。
-
如果您没有为您的字段提供任何初始化,它将采用一些默认值。我们将很快介绍这些默认值。
-
Suppose that in the preceding example, you did not initialize the field. Then your class will look like this:
Still, you can instantiate your object and then supply your intended value like this:
ClassEx1 obA = new ClassEx1(); obA.MyInt = 25;//setting 25 into MyInt of obA
如果你熟悉 Java,要在控制台打印,你可能会喜欢这种格式。C# 也允许这样做。
Console.WriteLine("obA.i =" + obA.MyInt);
Console.WriteLine("obB.i =" + obB.MyInt);
学生问:
先生,请告诉我们更多关于构造函数的信息。
老师说:我们必须记住这些要点:
- 构造函数用于初始化对象。
- 类名和相应的构造函数名必须相同。
- 它们没有任何返回类型。
- 我们可以说有两种类型的构造函数:无参数构造函数(有时称为无参数构造函数或默认构造函数)和有参数构造函数(称为参数化构造函数)。按照 C# 的说法,我们是在创建自己的无参数构造函数,还是由 C# 编译器创建,这都无关紧要。在这两种情况下,我们通常称之为默认构造函数。或者我们也可以根据构造函数是静态构造函数还是非静态构造函数(或者实例构造函数)来区分构造函数。你将熟悉本章中的实例构造函数。实例构造函数用于初始化类的实例(对象),而静态构造函数用于在类第一次出现时初始化类本身。我在另一章讨论了“静态”。
- 一般来说,常见的任务,如类中所有变量的初始化,都是通过构造函数来实现的。
学生问:
先生,构造函数没有任何返回类型。这个语句的意思是他们的返回类型是 void 吗?
老师说:不。隐式地,构造函数的返回类型和它的类类型是一样的。我们不应该忘记,即使是 void 也被认为是一个返回类型。
学生问:
先生,我们对使用用户定义的无参数构造函数和 C# 提供的默认构造函数有点困惑。两者看起来是一样的。两者有什么关键区别吗?
老师说:我已经提到过,用 C# 的说法,我们是否创建了自己的无参数构造函数或者它是否是由 C# 编译器创建的并不重要。在这两种情况下,我们通常称之为默认构造函数。有时两者可能看起来一样。但是请记住,使用用户定义的构造函数,我们可以有一定的灵活性。我们可以把自己的逻辑和一些额外的控制对象的创建。
考虑下面的例子并分析输出。
演示 2
using System;
namespace DefaultConstructorCaseStudy
{
class DefConsDemo
{
public int myInt;
public float myFloat;
public double myDouble;
public DefConsDemo()
{
Console.WriteLine("I am initializing with my own choice");
myInt = 10;
myFloat = 0.123456F;
myDouble = 9.8765432;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Comparison between user-defined and C# provided default constructors***\n");
DefConsDemo ObDef = new DefConsDemo();
Console.WriteLine("myInt={0}", ObDef.myInt);
Console.WriteLine("myFloat={0}", ObDef.myFloat.ToString("0.0####"));
Console.WriteLine("myDouble={0}", ObDef.myDouble);
Console.Read();
}
}
}
输出
分析
您可以看到,在我们为变量设置值之前,我们已经打印了一行附加的内容,“我正在用我自己的选择进行初始化。”
但是,如果您只是不提供这个无参数的构造函数,而想使用 C# 提供的默认构造函数,您将得到下一节中显示的输出。
附加注释
要查看下面的输出,您需要注释掉或移除前面示例中的构造函数定义。现在您可以看到,这些值中的每一个都用该类型的相应默认值进行了初始化。
你必须记住另一个关键点。我们可以为用户定义的构造函数使用我们自己的访问修饰符。因此,如果您提供自己的无参数构造函数,您可以使它不是公共的。
让我们看看 C# 语言规范告诉了我们什么。
如果一个类不包含实例构造函数声明,则自动提供一个默认的实例构造函数。默认构造函数只是调用直接基类的无参数构造函数。如果类是抽象的,那么默认构造函数的声明的可访问性是受保护的;否则,默认构造函数声明的可访问性是公共的。因此,默认构造函数始终采用以下形式:
protected C(): base() {}
或者
public C(): base() {}
C
是类的名称。如果重载决策无法确定基类构造函数初始值设定项的唯一最佳候选项,则会发生编译时错误。
很快,您将熟悉这些新术语:访问修饰符、重载和基。所以,不要惊慌。你可以学习这些概念,然后再来回答这一部分。
所以,简单来说,下面的声明
class A
{
int myInt;
}
相当于这样:
class A
{
int myInt;
public A():base()
{ }
}
学生问:
先生,我们看到 C# 提供的默认构造函数正在用一些默认值初始化实例变量。其他类型的默认值是什么?
老师说:你可以参考下表供你参考。
| 类型 | 默认值 | | :-- | :-- | | sbyte,byte,short,ushort,int,uint,long,ulong | Zero | | 茶 | \x0000 ' | | 漂浮物 | 0.0f | | 两倍 | 0.0d | | 小数 | 0.0 米 | | 弯曲件 | 错误的 | | 结构体 | 将所有值类型设置为默认值,将所有引用类型设置为 null* | | 枚举 E | 0(转换为类型 E) |*We will discuss value types and reference types in detail later in the book.
学生问:
先生,我们似乎可以调用一些方法来初始化这些变量。那我们为什么选择构造函数呢?
老师说:如果你从那个角度考虑,那么你必须同意,要做那个工作,你需要显式地调用方法;也就是说,用简单语言来说,呼叫不是自动的。但是对于构造函数,我们在每次创建对象时都执行自动初始化。
学生问:
先生,字段初始化和通过构造函数初始化哪个先发生?
老师说:字段初始化首先发生。这个初始化过程遵循声明顺序。
恶作剧
你能预测产量吗?
using System;
namespace ConsEx2
{
class ConsEx2
{
int i;
public ConsEx2(int i)
{
this.i = i;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Experiment with constructor***");
ConsEx2 ob2 = new ConsEx2();
}
}
}
输出
编译错误:“ConsEx2”不包含采用 0 个参数的构造函数。
说明
参见下面的问答。我们还将很快讨论关键字“this”。
学生问:
先生,在这种情况下,我们应该有一个来自 C# 的默认构造函数。那为什么编译器会抱怨一个 0 参数的构造函数呢?
老师说:我已经提到过,在 C# 中,当且仅当我们不提供任何构造函数时,我们可以得到一个默认的 0 参数构造函数。但是,在这个例子中,我们已经有了一个参数化的构造函数。所以,在这种情况下,编译器不会为我们提供默认的 0 参数构造函数。
因此,如果您想删除这个编译错误,您有以下选择:
-
您可以像这样定义一个自定义构造函数:
public ConsEx2() { }
-
您可以从该程序中移除自定义构造函数声明(您已经定义但尚未使用)。
-
您可以在您的
Main()
方法中提供必要的整数参数,如下所示:ConsEx2 ob2 = new ConsEx2(25);
学生问:
先生,我们能说那个类是自定义类型吗?
老师说:是的。
学生问:
先生,你能解释一下参考的概念吗?
老师说:是的。当我们写ClassA obA=new ClassA()
;ClassA 的一个实例将在内存中诞生,它创建一个对该实例的引用并将结果存储在obA
变量中。所以,我们可以说内存中的对象被一个叫做“引用”的标识符引用。
稍后,当你进一步了解内存管理时,你会看到在 C# 中,我们主要使用两种类型的数据——值类型和引用类型。值类型存储在堆栈中,引用类型存储在堆中。因为对象是引用类型,所以它们存储在堆中。但是重要的是引用本身存储在一个堆栈中。所以,当我们写作时
ClassA obA=new Class A();
你可以想象如下:
我们假设对象存储在堆地址 10001 中,obA 在堆栈中保存着这个线索。
学生问:
先生,为什么我们同时使用堆栈和堆?
老师说:这一级最简单的答案是,当引用变量超出范围时,它将从堆栈中删除,但实际数据仍然存在于堆中,直到程序终止或垃圾收集器清除该内存。因此,我们可以控制特定数据的生命周期。
学生问:
先生,那么引用基本上是用来指向一个地址的。这是正确的吗?
老师说:是的。
学生问:
先生,那么引用就类似于 C/C++中的指针。这是正确的吗?
老师说:引用似乎是一种特殊的指针。但是我们必须注意这两者之间的关键区别。通过指针,我们可以指向任何地址(基本上,它是内存中的一个数字槽)。因此,很有可能使用指针,我们可以指向一个无效的地址,然后我们可能会在运行时遇到不想要的结果。但是引用类型将总是指向有效的地址(在托管堆中),或者它们将指向 null。
很快,我们将学习 C# 中的一个关键概念。它被称为垃圾收集机制,用于回收内存。垃圾收集器不知道这些指针。因此,在 C# 中,指针不允许指向引用。稍后,您还将了解到,如果一个结构(在 C# 中称为 struct)包含一个引用,则指针类型不允许指向该结构。
为了简单起见,您可以记住,在 C# 中,指针类型只在“不安全”的上下文中出现。我将在本书的后面讨论这种“不安全”的环境。
学生问:
先生,我们如何检查引用变量是否指向 null?
老师说:下面的简单检查可以满足你的需要。为了便于参考,我可以在前面的程序中添加这几行代码。
......
ConsEx2 ob2 = new ConsEx2(25);
if (ob2 == null)
{
Console.WriteLine("ob2 is null");
}
else
{
Console.WriteLine("ob2 is NOT null");
}
.....
学生问:
先生,多个变量可以引用内存中的同一个对象吗?
老师说:是的。以下类型的声明非常好:
ConsEx2 ob2 = new ConsEx2(25);
ConsEx2 ob1=ob2;
演示 3
在下面的例子中,我们创建了同一个类的两个对象,但是实例变量(i
)用不同的值初始化。为了完成这项工作,我们使用了一个可以接受一个整数参数的参数化构造函数。
using System;
namespace ClassEx2
{
class ClassA
{
public int i;
public ClassA(int i)
{
this.i = i;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** A class demo with 2 objects ***");
ClassA obA = new ClassA(10);
ClassA obB = new ClassA(20);
Console.WriteLine("obA.i =" + obA.i);
Console.WriteLine("obB.i =" + obB.i);
Console.ReadKey();
}
}
}
输出
说明
学生问:
先生,这是什么目的?
老师说:好问题。有时我们需要引用当前对象,为此,我们使用“this”关键字。在前面的例子中,不使用“this”关键字,我们也可以编写类似下面的代码来达到相同的结果。
class ClassA
{
int i;//instance variable
ClassA(int myInteger)//myInteger is a local variable here
{
i=myInteger;
}
}
你熟悉像a=25
这样的代码;这里我们给 a 赋值 25。但是你熟悉像25=a;
这样的代码吗?不。编译器会引发一个问题。
在前面的例子中,myInteger
是我们的局部变量(在方法、块或构造函数中可见),而i
是我们的实例变量(在类中声明,但在方法、块或构造函数之外)。
所以,代替 myInteger,如果我们使用 I,我们需要告诉编译器我们的赋值方向。不应该混淆“哪个值被分配到哪里”这里我们将局部变量的值赋给实例变量,编译器应该清楚地理解我们的意图。有了this.i=i;
,编译器会清楚地明白,应该用局部变量 I 的值来初始化实例变量 I。
我也可以从另一个角度来解释这个场景。假设,在前面的场景中,您错误地编写了类似 i=i 的内容。那么从编译器的角度来看就会出现混乱。因为在那种情况下,它看到你在处理两个相同的局部变量。(虽然你的本意不同,你的意思是左边的 I 是字段,另一个是方法参数)。现在,如果你为 ClassA 创建一个对象,obA
,试着看看obA.i
的值,代码如下:
ClassA obA = new ClassA(20);
Console.WriteLine("obA.i =" + obA.i);
您将得到 0(整数的默认值)。所以,你的实例变量不能得到你想要的值,20。在这种情况下,我们的 Visual Studio Community Edition IDE 也会发出警告:“对同一个变量进行了赋值,您是想给其他变量赋值吗?”
Points to Remember
与字段同名的方法参数会在方法体中隐藏整个字段。在这种场景中,关键字“this”帮助我们识别哪个是参数,哪个是字段。
演示 4
在下面的演示中,我们使用了两个不同的构造函数。用户定义的无参数构造函数总是用值 5 初始化实例变量 I,但是参数化构造函数可以用我们提供的任何整数值初始化实例变量。
using System;
class ClassA
{
public int i;
public ClassA()
{
this.i = 5;
}
public ClassA(int i)
{
this.i = i;
}
}
class ClassEx4
{
static void Main(string[] args)
{
Console.WriteLine("*** A Simple class with 2 different constructor ***");
ClassA obA = new ClassA();
ClassA obB = new ClassA(75);
Console.WriteLine("obA.i =" + obA.i);
Console.WriteLine("obB.i =" + obB.i);
Console.ReadKey();
}
}
输出
附加注释
- 前面,我们使用同一个构造函数来创建不同的对象,这些对象用不同的值进行初始化。在这个例子中,我们使用了不同的构造函数来创建用不同的值初始化的不同对象。
- 在 Java 中,我们可以用 this (5)代替 this.i=5。但是在 C# 中,那种编码是不允许的。对于这种编码,我们会遇到如下编译错误:
演示 5
我提到过一个类可以同时有变量和方法。所以,现在我们要用一个返回整数的方法来创建一个类。该方法用于接受两个整数输入,然后返回这两个整数的和。
using System;
namespace InstanceMethodDemo
{
class Ex5
{
public int Sum(int x, int y)
{
return x + y;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** A Simple class with a method returning an integer ***\n\n");
Ex5 ob = new Ex5();
int result = ob.Sum(57,63);
Console.WriteLine("Sum of 57 and 63 is : " + result);
Console.ReadKey();
}
}
}
输出
对象初始化器
老师继续说:现在我们要学习创建物体的两种不同的技术。我们可以根据需要使用它们。考虑下面的程序,它后面是输出和分析。
演示 6
using System;
namespace ObjectInitializerEx1
{
class Employee
{
public string Name;
public int Id;
public double Salary;
//Parameterless constructor
public Employee() { }
//Constructor with one parameter
public Employee(string name) { this.Name = name; }
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Object initializers Example-1***");
//Part-1:Instantiating without Object Initializers
//Using parameterless constructor
Employee emp1 = new Employee();
emp1.Name = "Amit";
emp1.Id = 1;
emp1.Salary = 10000.23;
//Using the constructor with one parameter
Employee emp2 = new Employee("Sumit");
emp2.Id = 2;
emp2.Salary = 20000.32;
//Part-2:Instantiating with Object Initializers
//Using parameterless constructor
Employee emp3 = new Employee { Name = "Bob", Id = 3, Salary = 15000.53 };
//Using the constructor with one parameter
Employee emp4 = new Employee("Robin") { Id=4,Salary = 25000.35 };
Console.WriteLine("Employee Details:");
Console.WriteLine("Name ={0} Id={1} Salary={2}", emp1.Name,emp1.Id,emp1.Salary);
Console.WriteLine("Name ={0} Id={1} Salary={2}", emp2.Name,emp2.Id,emp2.Salary);
Console.WriteLine("Name ={0} Id={1} Salary={2}", emp3.Name, emp3.Id, emp3.Salary);
Console.WriteLine("Name ={0} Id={1} Salary={2}", emp4.Name, emp4.Id, emp4.Salary);
Console.ReadKey();
}
}
}
输出
分析
请仔细注意以下部分。
在这个例子的第二部分,我们已经介绍了对象初始化器的概念。我们可以看到,与底部(第二部分)相比,在顶部(第一部分),我们需要编写更多的代码来完成对象(emp1 和 emp2)。在第二部分中,一行代码就足以实例化每个对象(emp3 和 emp4)。我们还试验了不同类型的构造函数。但是很明显,在所有情况下,对象初始化器都简化了实例化过程。这个概念是在 C# 3.0 中引入的。
可选参数
老师继续说:现在考虑下面的程序和输出。
演示 7
using System;
namespace OptionalParameterEx1
{
class Employee
{
public string Name;
public int Id;
public double Salary;
public Employee(string name = "Anonymous", int id = 0, double salary = 0.01)
{
this.Name = name;
this.Id = id;
this.Salary = salary;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Optional Parameter Example-1***");
Employee emp1 = new Employee("Amit", 1, 10000.23);
Employee emp2 = new Employee("Sumit", 2);
Employee emp3 = new Employee("Bob");
Employee emp4 = new Employee();
Console.WriteLine("Employee Details:");
Console.WriteLine("Name ={0} Id={1} Salary={2}", emp1.Name, emp1.Id, emp1.Salary);
Console.WriteLine("Name ={0} Id={1} Salary={2}", emp2.Name, emp2.Id, emp2.Salary);
Console.WriteLine("Name ={0} Id={1} Salary={2}", emp3.Name, emp3.Id, emp3.Salary);
Console.WriteLine("Name ={0} Id={1} Salary={2}", emp4.Name, emp4.Id, emp4.Salary);
Console.ReadKey();
}
}
}
输出
分析
这里我们使用了构造函数中可选参数的概念。这个构造函数需要三个参数:一个用于雇员的姓名,一个用于雇员的 ID,一个用于雇员的工资。但是如果我们传递的参数更少,编译器根本不会抱怨。另一方面,我们的应用选择了我们已经在可选参数列表中设置的默认值。从输出的最后一行,您可以看到 employee 对象的默认值是匿名的、0 和 0.01(对应于雇员的姓名、ID 和薪水)。
学生问:
先生,在 OOP 中我们看到代码总是被捆绑在对象中。这种类型的设计在现实场景中有什么好处?
老师说:其实有很多好处。从现实世界的场景思考;例如,考虑您的笔记本电脑或打印机。如果您的笔记本电脑中的任何部件出现故障,或者您的打印墨盒没有墨水了,您可以简单地更换这些部件。您不需要更换整个笔记本电脑或整个打印机。同样的概念也适用于其他真实世界的对象。
而且,您可以在类似型号的笔记本电脑或打印机中重复使用相同的部件。
除此之外,你必须同意我们不关心这些功能是如何在那些部分实际实现的。如果这些部分工作正常,满足我们的需求,我们就很高兴。
在面向对象编程中,对象扮演着同样的角色:它们可以被重用,也可以被插入。同时,他们隐藏了实现细节。例如,在演示 5 中,我们可以看到,当我们调用带有两个整数参数(57 和 63)的 Sum()方法时,我们知道我们将得到这些整数的和。外部用户完全不知道该方法的内部机制。因此,我们可以通过向外界隐藏这些信息来提供一定程度的安全性。
最后,从另一个编码的角度来看,假设下面的场景。假设您需要在程序中存储员工信息。如果你开始这样编码:
string empName= "emp1Name";
string deptName= "Comp.Sc.";
int empSalary= "10000";
然后对于第二个雇员,我们必须这样写:
string empName2= "emp2Name";
string deptName2= "Electrical";
int empSalary2= "20000";
等等。
真的可以这样继续下去吗?答案是否定的。简单地说,像这样创建一个雇员类和过程总是一个更好的主意:
Employee emp1, emp2;
它更干净、可读性更强,显然是一种更好的方法。
学生问:
先生,到目前为止,我们已经讨论了构造函数,但没有讨论析构函数。为什么呢?
老师说:我将在第十四章(内存清理)讨论带有垃圾收集的析构函数。
摘要
本章讨论了以下主题。
- 类、对象和引用的概念
- 对象和引用之间的区别
- 指针和引用的区别
- 局部变量和实例变量的区别
- 不同类型的构造函数及其用法
- 用户定义的无参数构造函数和 C# 提供的默认构造函数之间的区别
- this 关键字
- 对象初始化器的概念
- 可选参数的概念
- 面向对象方法在现实编程中的好处
三、继承的概念
教师开始讨论:继承的主要目的是促进可重用性和消除冗余(代码)。基本思想是,子类可以获得其父类的特征/特性。在编程术语中,我们说子类是从它的父类/基类派生出来的。因此,父类被放在类层次结构中的更高一级。
类型
一般来说,我们处理四种类型的继承。
- 单一继承:子类从一个基类派生而来
- 分层继承:一个基类可以派生出多个子类
- 多级继承:父类有一个孙类
- 多重继承:一个孩子可以来自多个父母
Points to Remember
- C# 不支持多重继承(通过类);也就是说,子类不能从多个父类派生。为了处理这种情况,我们需要理解接口。
- 还有另一种类型的继承被称为混合继承。它是两种或两种以上遗传类型的结合。
让我们从一个关于继承的简单程序开始。
演示 1
using System;
namespace InheritanceEx1
{
class ParentClass
{
public void ShowParent()
{
Console.WriteLine("In Parent");
}
}
class ChildClass :ParentClass
{
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Testing Inheritance***\n\n");
ChildClass child1 = new ChildClass();
//Invoking ShowParent()through ChildClass object
child1.ShowParent();
Console.ReadKey();
}
}
}
输出
附加注释
我们已经通过一个子类对象调用了ShowParent()
方法。
Points to Remember
- 请记住,在 C# 中,Object 是。NET 框架。换句话说,
System.Object
是类型层次结构中的最终基类。 - 除了构造函数(实例和静态)和析构函数,所有成员都是继承的(也就是说,这与访问说明符无关)。但是由于它们的可访问性限制,所有继承的成员在子类/派生类中可能都不可访问。
- 子类可以添加新成员,但不能删除父成员的定义。(就像你可以为自己选择一个新名字,但不能改变父母的姓氏一样)。
- 继承层次结构是可传递的;也就是说,如果类 C 继承了类 B,而类 B 又派生自类 A,那么类 C 就包含了类 B 和类 A 的所有成员。
学生问:
这意味着私有成员也继承了。这种理解正确吗?
老师说:是的。
学生问:
我们如何检查私有成员也被继承的事实?
老师说:你可以参考演示 2 中显示的程序和输出。
演示 2
using System;
namespace InheritanceWithPrivateMemberTest
{
class A
{
private int a;
}
class B : A { }
class Program
{
static void Main(string[] args)
{
B obB = new B();
A obA = new A();
//This is a proof that a is also inherited. See the error message.
Console.WriteLine(obB.a);//A.a is inaccessible due to its
//protection level
Console.WriteLine(obB.b);//'B' does not contain a definition
//for 'b' and no extension ......
Console.WriteLine(obA.b);//'A' does not contain a definition
//for 'b' and no extension ......
}
}
}
输出
分析
我们遇到了两种不同类型的错误:CS0122 和 CS1061。
- CS0122: A.a 由于其保护级别而无法访问。它指示来自类 A 的私有成员 A 在子类 b 中被继承。
- CS1061:我们用另一个字段测试了输出,该字段不在此类层次结构中(即,该字段不存在,既不在 A 中也不在 B 中)。当我们试图用 A 类或 B 类对象访问成员时,我们遇到了一个不同的错误。因此,如果 a 在 B 类中不存在,那么你应该得到一个类似的错误。
学生问:
为什么 C# 不支持通过类的多重继承?
老师说:主要原因是为了避免歧义。在典型的场景中,它会造成混乱;例如,假设我们的父类中有一个名为Show()
的方法。父类有多个子类,比如 Child1 和 Child2,它们为了自己的目的正在重新定义(用编程术语来说,重写)方法。代码可能类似于演示 3 所示。
演示 3
class Parent
{
public void Show()
{
Console.WriteLine("I am in Parent");
}
}
class Child1: Parent
{
public void Show()
{
Console.WriteLine("I am in Child-1");
}
}
class Child2:Parent
{
public void Show()
{
Console.WriteLine("I am in Child-2");
}
}
现在,让我们假设另一个名为孙的类派生自 Child1 和 Child2,但是它没有覆盖Show()
方法。
所以,现在我们有了歧义:孙子将从哪个类继承/调用Show()
——Child 1 还是 Child 2?为了避免这种类型的歧义,C# 不支持通过类的多重继承。这就是所谓的钻石问题。
所以,如果你这样编码:
class GrandChild : Child1, Child2//Error: Diamond Effect
{
public void Show()
{
Console.WriteLine("I am in Child-2");
}
}
C# 编译器会报错:
学生问:
所以,编程语言不支持多重继承。这种理解正确吗?
老师说:不。这个决定是由编程语言的设计者做出的(例如,C++支持多重继承的概念)。
学生问:
为什么 C++设计者支持多重继承?似乎钻石问题也会影响到他们。
老师说:我试图从我的角度来解释。他们可能不想放弃多重继承(也就是说,他们希望包含这个特性来丰富语言)。他们为您提供支持,但将正确使用的控制权留给了您。
另一方面,由于这种支持,C# 设计者希望避免任何不想要的结果。他们只是想让语言更简单,更不容易出错。
老师问:
C# 中有混合继承吗?
老师解释:仔细想想。混合继承是两种或两种以上继承的结合。所以,如果你不想通过类来组合任何类型的多重继承,这个问题的答案是肯定的。但是,如果你试图用任何类型的多重继承(通过类)进行混合继承,C# 编译器将立即提出它的关注。
老师问:
假设我们有一个父类和一个子类。我们能猜到类的构造函数会以什么顺序被调用吗?
老师说:我们必须记住,构造函数的调用遵循从父类到子类的路径。让我们用一个简单的例子来测试一下:我们有一个父类 parent、一个子类 child 和一个孙类 grande。顾名思义,子类派生自父类,孙类派生自子类。我们已经创建了一个孙类的对象。请注意,构造函数是按照它们的派生顺序调用的。
演示 4
using System;
namespace ConstructorCallSequenceTest
{
class Parent
{
public Parent()
{
Console.WriteLine("At present: I am in Parent Constructor");
}
}
class Child : Parent
{
public Child()
{
Console.WriteLine("At present: I am in Child Constructor");
}
}
class GrandChild : Child
{
public GrandChild()
{
Console.WriteLine("At present: I am in GrandChild Constructor");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Testing the call sequence of constructors***\n\n");
GrandChild grandChild = new GrandChild();
Console.ReadKey();
}
}
}
输出
说明
学生问:
先生,有时候我们不确定。在继承层次结构中,谁应该是父类,谁应该是子类?我们如何处理这种情况?
老师说:你可以试着记住一句简单的话:足球运动员就是运动员,但反过来就不一定了。或者,公共汽车是一种交通工具,但反过来就不一定了。这种“是-a”测试可以帮助你决定谁应该是父母;例如,“运动员”是父类,“足球运动员”是子类。
我们还通过这个“is-a”测试来预先确定我们是否可以将一个类放在同一个继承层次中。
一个特殊的关键词:基础
在 C# 中,有一个特殊的关键字叫做 base。它用于以有效的方式访问父类(也称为基类)的成员。每当子类想要引用它的直接父类时,它可以使用 base 关键字。
让我们通过两个简单的例子来研究 base 关键字的不同用法。
演示 5
using System;
namespace UseOfbaseKeywordEx1
{
class Parent
{
private int a;
private int b;
public Parent(int a, int b)
{
Console.WriteLine("I am in Parent constructor");
Console.WriteLine("Setting the value for instance variable a and b");
this.a = a;
this.b = b;
Console.WriteLine("a={0}", this.a);
Console.WriteLine("b={0}", this.b);
}
}
class Child : Parent
{
private int c;
public Child(int a, int b,int c):base(a,b)
{
Console.WriteLine("I am in Child constructor");
Console.WriteLine("Setting the value for instance variable c");
this.c = c;
Console.WriteLine("c={0}", this.c);
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Testing the use of base keyword. Example-1***\n\n");
Child obChild = new Child(1, 2, 3);
//Console.WriteLine("a in ObB2={0}", obChild.a);// a is private,
//so Child.a is inaccessible
Console.ReadKey();
}
}
}
输出
分析
我们需要了解为什么有必要使用关键字 base。如果我们在前面的示例中没有使用它,我们将需要编写类似如下的代码:
public Child(int a, int b, int c)
{
this.a = a;
this.b = b;
this.c = c;
}
这种方法有两个主要问题。您试图编写重复的代码来初始化实例变量 a 和 b。在这种特殊情况下,您将收到编译错误,因为 a 和 b 由于其保护级别而不可访问(请注意它们是私有的)。通过使用“base”关键字,我们有效地处理了这两种情况。虽然我们在子类构造函数声明之后写了“base”这个词,但是父类构造函数在子类构造函数之前被调用。理想情况下,这是我们的真实意图。
Points to Remember
前面你已经看到,当我们初始化一个对象时,构造函数体在父类到子类的方向上执行。但是初始化的相反方向(即子到父)随着字段的初始化(以及父类构造函数调用的参数)而发生。*
在 C# 中,不能使用一个实例字段在方法体外部初始化另一个实例字段。
*我认为以下 MSDN 资源值得一读:
https://blogs.msdn.microsoft.com/ericlippert/2008/02/15/why-do-initializers-run-in-the-opposite-order-as-constructors-part-one/
https://blogs.msdn.microsoft.com/ericlippert/2008/02/18/why-do-initializers-run-in-the-opposite-order-as-constructors-part-two/
恶作剧
产量是多少?
using System;
namespace FieldInitializationOrderEx1
{
class A
{
int x = 10;
int y = x + 2;//Error
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** Analyzing C#'s field initialization order ***");
int x = 10;
int y = x + 2;//ok
Console.WriteLine("x={0}", x);
Console.WriteLine("y={0}", y);
Console.ReadKey();
}
}
}
输出
分析
这个限制是由 C# 的设计者实现的。
学生问:
为什么 C# 设计者设置了这个限制(与错误 CS0236 相关)?
老师说:这个话题有很多讨论。上例中的语句y=x+2;
相当于y=this.x+2;
“this”
表示当前对象,因此,如果我们要像this.x
一样进行调用,需要先完成当前对象。但是在某些情况下(例如,如果x
是一个还没有被创建的属性(而不是一个字段),或者它是另一个实例的一部分,等等),当前对象可能还没有完成。)我们将很快了解更多关于属性的内容。我们还应该记住,创建构造函数是为了处理这种初始化。因此,如果允许这些类型的构造,他们也可以质疑构造函数的用途。
老师继续说:现在我们来看看下面这个例子中 base 关键字的另一种用法。注意,在这个例子中,我们通过基类方法中的 base 关键字调用父类方法(ParentMethod()
)。
演示 6
using System;
namespace UseOfbaseKeywordEx2
{
class Parent
{
public void ParentMethod()
{
Console.WriteLine("I am inside the Parent method");
}
}
class Child : Parent
{
public void childMethod()
{
Console.WriteLine("I am inside the Child method");
Console.WriteLine("I am calling the Parent method now");
base.ParentMethod();
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Testing the use of base keyword. Example-2***\n\n");
Child obChild = new Child();
obChild.childMethod();
Console.ReadKey();
}
}
}
输出
Points to Remember
- 根据语言规范,基类访问只允许在构造函数、实例方法或实例属性访问中进行。
- 关键字“base”不应在静态方法上下文中使用。
- 它类似于 Java 中的“super”关键字和 C++中的“base”关键字。它几乎与 C++的 base 关键字相同,它就是从这个关键字被采用的。然而,在 Java 中,有一个限制规定“super”应该是第一个语句。Oracle Java 文档说超类构造函数的调用必须是子类构造函数的第一行。
学生问:
先生,假设有一些方法在父类和子类中都有一个共同的名字。如果我们创建一个子类对象,尝试调用同一个命名方法,会调用哪个?
老师说:你试图在这里引入方法覆盖的概念。我们将在关于多态的章节中进一步讨论它。但是要回答您的问题,请考虑下面的程序和输出。
演示 7
using System;
namespace UseOfbaseKeywordEx3
{
class Parent
{
public void ShowMe()
{
Console.WriteLine("I am inside the Parent method");
}
}
class Child : Parent
{
public void ShowMe()
{
Console.WriteLine("I am inside the Child method");
//base.ParentMethod();
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Testing the use of base keyword. Example-3***\n\n");
Child obChild = new Child();
obChild.ShowMe();
Console.ReadKey();
}
}
}
输出
分析
在这种情况下,您的程序被编译并运行,但是您应该注意到您会收到一条警告消息,提示您的派生类方法隐藏了继承的父类方法,如下所示:
因此,如果您想调用父类方法,您可以简单地使用子类方法中的代码,就像这样:
base 关键字可用于以下任何一种情况:
- 调用父类中定义的隐藏/重写方法。
- 我们可以在创建派生类的实例时指定特定的基类构造函数版本(参见演示 5)。
学生问:
在我看来,子类可以使用它的超类方法。但是有什么方法可以让超类使用它的子类方法呢?
老师说:不。你必须记住,超类是在它的子类之前完成的,所以它不知道它的子类方法。它只声明一些(想想一些契约/方法)可以被它的子节点使用的东西。只有付出而不期望从孩子那里得到回报。
如果你仔细观察,你会发现“是-a”测试是单向的(例如,足球运动员总是运动员,但反过来就不一定了;所以没有向后继承的概念)。
学生问:
先生,所以每当我们想使用一个父类方法,并把额外的东西放入其中,我们可以使用关键字 base。这种理解正确吗?
老师说:是的。
学生问:
先生,在 OOP 中,继承帮助我们重用行为。还有其他方法可以达到同样的效果吗?
老师说:是的。尽管继承的概念在很多地方被使用,但它并不总是提供最佳的解决方案。为了更好地理解它,您需要理解设计模式的概念。一个非常常见的替代方法是使用组合的概念,这将在后面介绍。
学生问:
先生,如果一个用户已经为他的应用创建了一个方法,我们应该通过继承的概念来重用同一个方法,以避免重复工作。这种理解正确吗?
老师说:一点也不。我们不应该以这种方式概括继承。这取决于特定的应用。假设已经有人做了一个Show()
方法来描述一个汽车类的细节。现在让我们假设你也创建了一个名为Animal
的类,你也需要用一个方法描述一个动物的特征。假设您也认为名称“Show()”最适合您的方法。在这种情况下,因为我们已经有了一个名为Car and if you think that you need to reuse t
的类中的Show()
方法,而你的Animal
类中的【何】方法,你可以写这样的代码:
Class Animal: Car{...} .
现在想一想。“这样的设计好吗?”你必须同意汽车和动物之间没有关系。因此,我们不应该在相同的继承层次结构中关联它们。
学生问:
我们如何继承构造函数或析构函数?
在本章的开始,我提到了构造函数(静态和非静态)和析构函数是不被继承的。
摘要
本章涵盖了以下主题。
- 继承的概念
- 继承的不同类型
- 为什么 C# 不支持通过类的多重继承
- C# 中允许的混合继承类型
- “base”关键字的不同用法
- C# 的 base 关键字和 Java 的 super 关键字的简单比较
- 继承层次结构中的构造函数调用序列
- 如果父类方法的子类也包含同名的方法,如何调用父类方法
- 如何将类放入继承层次结构中
- 继承概念的正确使用
还有更多。
四、熟悉多态
老师开始讨论:让我们回忆一下本书开始时我们讨论的关于多态的内容。多态通常与一个名称的多种形式相关联;例如,如果我们有两个整数操作数进行加法运算,我们期望得到整数的和,但是如果操作数是两个字符串,我们期望得到一个连接的字符串。我还提到多态有两种类型:编译时多态和运行时多态。
这里我们将从编译时多态开始讨论。
在编译时多态中,编译器可以在编译时将适当的方法绑定到相应的对象,因为它具有所有必要的信息(例如,方法参数)。因此,一旦程序被编译,它就能更早地决定调用哪个方法。这就是为什么它也被称为静态绑定或早期绑定。
在 C# 中,编译时多态可以通过方法重载和运算符重载来实现。
Points to Remember
在 C# 中,方法重载和运算符重载可以帮助我们实现编译时多态。
方法重载
老师继续:先说一个程序。考虑下面的程序和相应的输出。你注意到什么特别的模式了吗?
演示 1
using System;
namespace OverloadingEx1
{
class OverloadEx1
{
public int Add(int x, int y)
{
return x + y;
}
public double Add(double x, double y)
{
return x + y;
}
public string Add(String s1, String s2)
{
return string.Concat(s1, s2);
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Concept of method Overloading***\n\n");
OverloadEx1 ob = new OverloadEx1();
Console.WriteLine("2+3={0}", ob.Add(2, 3));
Console.WriteLine("20.5+30.7={0}", ob.Add(20.5, 30.7));
Console.WriteLine("Amit + Bose ={0}", ob.Add("Amit","Bose"));
Console.ReadKey();
}
}
}
输出
分析
学生回应:可以。我们看到所有的方法都有相同的名字“Add ”,但是从它们的方法体来看,似乎每个方法都在做不同的事情。
老师说:正确的观察。当我们做这种编码时,我们称之为方法重载。但是您还应该注意到,在这种情况下,方法名称是相同的,但是方法签名是不同的。
学生问:
什么是方法签名?
老师说:理想情况下,方法名和参数的数量和类型组成了它的签名。C# 编译器可以区分名称相同但参数列表不同的方法;例如,对于一个 C# 编译器来说,double Add(double x, double y)
和int Add(int x, int y)
是不同的。
恶作剧
下面的代码段是方法重载的一个例子。这样对吗?
class OverloadEx1
{
public int Add(int x, int y)
{
return x + y;
}
public double Add(int x, int y, int z)
{
return x + y+ z;
}
}
回答
是的。
恶作剧
下面的代码段是方法重载的一个例子吗?
class OverloadEx1
{
public int Add(int x, int y)
{
return x + y;
}
public double Add(int x, int y)
{
return x + y;
}
}
回答
不会。编译器不会考虑“返回类型”来区分这些方法。我们必须记住,返回类型不被认为是方法签名的一部分。
学生问:
先生,我们可以让构造函数重载吗?
老师说:当然。你可以为构造函数重载写一个类似的程序。
演示 2
using System;
namespace ConstructorOverloadingEx1
{
class ConsOverloadEx
{
public ConsOverloadEx()
{
Console.WriteLine("Constructor with no argument");
}
public ConsOverloadEx(int a)
{
Console.WriteLine("Constructor with one integer argument {0}", a);
}
public ConsOverloadEx(int a, double b)
{
Console.WriteLine("You have passed one integer argument {0} and one double argument {1} in the constructor", a,b);
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Constructor overloading Demo***\n\n");
ConsOverloadEx ob1 = new ConsOverloadEx();
ConsOverloadEx ob2 = new ConsOverloadEx(25);
ConsOverloadEx ob3 = new ConsOverloadEx(10,25.5);
//ConsOverloadEx ob4 = new ConsOverloadEx(37.5);//Error
Console.ReadKey();
}
}
}
输出
分析
学生问:
先生,这似乎也是方法重载。构造函数和方法有什么区别?
老师澄清:我们已经在关于类的讨论中谈到了构造函数。作为参考,构造函数与类同名,并且没有返回类型。因此,您可以将构造函数视为一种特殊的方法,它与类同名,并且没有返回类型。但是还有许多其他的区别:构造函数的主要焦点是初始化对象。我们不能直接打电话给他们。
学生问:
先生,我们能这样写代码吗?
演示 3
class ConsOverloadEx
{
public ConsOverloadEx()
{
Console.WriteLine("A Constructor with no argument");
}
public void ConsOverloadEx()
{
Console.WriteLine("a method");
}
}
老师说:Java 8 允许这样做,但是 C# 编译器会出错。
输出
学生问:
先生,我们能重载 Main()方法吗?
老师说:是的。可以考虑以下方案。
演示 4
using System;
namespace OverloadingMainEx
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Testing Overloaded version of Main()***");
Console.WriteLine("I am inside Main(string[] args) now");
Console.WriteLine("Calling overloaded version\n");
Main(5);
//Console.WriteLine("***Concept of method Overloading***\n\n");
Console.ReadKey();
}
static void Main(int a)
{
Console.WriteLine("I am inside Main(int a) now");
}
}
}
输出
分析
虽然您可以编译并运行前面的程序,但编译器会显示以下警告消息:
学生问:
先生,那么如果我们增加一个方法体,为什么我们会得到一个编译错误,就像下面这样?
老师说:根据规范,你的程序可以有一个带有 Main(string[] args)或 Main()方法的入口点。这里出现了 Main 方法的两个版本。这就是为什么编译器不知道使用哪一个作为入口点。因此,您需要按照编译器的建议来决定入口点。如果您简单地删除或注释掉 Main(string[] args)版本,您的程序可以成功编译,然后如果您运行该程序,您将收到以下输出:
恶作剧
我们的程序中可以有多个 Main()方法吗,如下所示?
演示 5
using System;
namespace MultipleMainTest
{
class Program1
{
static void Main(string[] args)
{
Console.WriteLine("I am inside Program1.Main(string[] args) now");
Console.ReadKey();
}
}
class Program2
{
static void Main()
{
Console.WriteLine("I am inside Program2.Main() now");
Console.ReadKey();
}
}
}
老师说:你会得到以下错误:
为了避免这个错误,您可以从您的项目属性中设置入口点(这里我们从 Program2 中选择了 Main())。
现在,如果您运行该程序,您将获得以下输出:
建议/良好的编程实践
如果可能的话,尽量与重载方法的参数名及其对应的顺序保持一致。
下面是一个good design
的例子:
public void ShowMe(int a) {..}
public void ShowMe(int a, int b){...}
[Note that in 2nd line, the position of int a is same as 1st case]
Bad design:
public void ShowMe(int a) {..}
public void ShowMe(int x, int b){...}
[Note that in 2nd line, we start with int x instead of int a]
老师继续说:到目前为止,我们已经用方法重载测试了编译时多态。让我们也用运算符重载来测试这种风格。
运算符重载
每个操作符都有自己的功能;比如+可以把两个整数相加。通过操作符重载技术,我们可以用它来连接两个字符串;也就是说,我们可以对不同类型的操作数执行类似的机制。换句话说,我们可以简单地说操作符重载帮助我们为操作符提供特殊的/额外的含义。
学生问:
先生,那么有人可能会误用这个概念。它们可以重载与运算符重载相矛盾的操作;例如,++运算符也可用于递减。理解正确吗?
老师说:是的,我们需要小心。我们不应该使用++运算符来递减。如果我们这样做,那将是一个极其糟糕的设计。除此之外,我们必须注意 C# 不允许我们重载所有的操作符。MSDN 提供了以下指导方针。
| 经营者 | 过载能力 | | :-- | :-- | | +, -, !,~,++, -,真,假 | 我们可以支配这些一元运算符。 | | +,-,*,/,%,&,|,^,<> | 我们可以支配这些二元运算符。 | | ==, !=,,<=, > = | 比较运算符可以重载(但请参见该表后面的注释)。 | | &&, || | 条件逻辑运算符不能重载,但它们使用&和|进行计算,这可以重载。 | | [ ] | 我们不能重载数组索引操作符,但是我们可以定义索引器。 | | (T)x | 我们不能重载转换操作符,但是我们可以定义新的转换操作符(例如,在显式和隐式的上下文中) | | +=,-=,*=,/=,%=,&=,|=,^=,<<=, > >= | 我们不能重载赋值运算符,而是+=;例如,使用可以重载的+来计算。 | | =, ., ?:, ??、->、= >、f(x)、as、选中、未选中、默认、委托、is、新建、sizeof、typeof | 我们不能支配这些经营者。 |Note
如果重载,比较运算符必须成对重载;比如= =重载,我们就需要重载!=也。反之亦然,类似于< and >和<= and > =。
老师说:让我们跟着演示。这里我们将一元运算符++应用于一个矩形对象,以增加矩形对象的长度和宽度。
演示 6
using System;
namespace OperatorOverloadingEx
{
class Rectangle
{
public double length, breadth;
public Rectangle(double length, double breadth)
{
this.length = length;
this.breadth = breadth;
}
public double AreaOfRectangle()
{
return length * breadth;
}
public static Rectangle operator ++ (Rectangle rect)
{
rect.length ++;
rect.breadth++;
return rect;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Operator Overloading Demo:Overloading ++ operator***\n");
Rectangle rect = new Rectangle(5, 7);
Console.WriteLine("Length={0} Unit Breadth={1} Unit", rect.length,rect.breadth);
Console.WriteLine("Area of Rectangle={0} Sq. Unit",rect.AreaOfRectangle());
rect++;
Console.WriteLine("Modified Length={0} Unit Breadth={1} Unit", rect.length, rect.breadth);
Console.WriteLine("Area of new Rectangle={0} Sq. Unit", rect.AreaOfRectangle());
Console.ReadKey();
}
}
}
输出
现在让我们重载二元运算符+。
演示 7
using System;
namespace OperatorOverloadingEx2
{
class ComplexNumber
{
public double real,imaganinary;
public ComplexNumber()
{
this.real = 0;
this.imaganinary = 0;
}
public ComplexNumber(double real, double imaginary )
{
this.real = real;
this.imaganinary = imaginary;
}
//Overloading a binary operator +
public static ComplexNumber operator +(ComplexNumber cnumber1, ComplexNumber cnumber2)
{
ComplexNumber temp = new ComplexNumber();
temp.real = cnumber1.real + cnumber2.real;
temp.imaganinary = cnumber1.imaganinary + cnumber2.imaganinary;
return temp;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Operator Overloading Demo 2:Overloading binary operator + operator***\n");
ComplexNumber cNumber1 = new ComplexNumber(2.1, 3.2);
Console.WriteLine("Complex Number1: {0}+{1}i", cNumber1.real,cNumber1.imaganinary);
ComplexNumber cNumber2 = new ComplexNumber(1.1, 2.1);
Console.WriteLine("Complex Number2: {0}+{1}i", cNumber2.real, cNumber2.imaganinary);
//Using the + operator on Complex numbers
ComplexNumber cNumber3 = cNumber1 + cNumber2;
Console.WriteLine("After applying + operator we have got: {0}+{1}i", cNumber3.real, cNumber3.imaganinary);
Console.ReadKey();
}
}
}
输出
分析
学生问:
先生,在运算符重载的例子中,您使用了关键字“static”。这是故意的吗?
老师说:是的。我们必须记住一些关键的限制。
- 运算符函数必须标记为 public 和 static。
否则,您可能会遇到这种错误:
- 关键字 operator 后跟运算符符号。
- 函数参数在这里是操作数,并且返回作为表达式结果的操作符函数的类型。
Points to Remember
- 运算符函数必须标记为 public 和 static。
- 关键字 operator 后跟运算符符号。
方法覆盖
老师继续说:有时我们想重新定义或修改我们的父类的行为。在这种情况下,方法重写就成了问题。考虑下面的程序和输出。然后在分析部分仔细检查每一点。
演示 8
using System;
namespace OverridingEx1
{
class ParentClass
{
public virtual void ShowMe()
{
Console.WriteLine("Inside Parent.ShowMe");
}
public void DoNotChangeMe()
{
Console.WriteLine("Inside Parent.DoNotChangeMe");
}
}
class ChildClass :ParentClass
{
public override void ShowMe()
{
Console.WriteLine("Inside Child.ShowMe");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Method Overriding Demo***\n\n");
ChildClass childOb = new ChildClass();
childOb.ShowMe();//Calling Child version
childOb.DoNotChangeMe();
Console.ReadKey();
}
}
}
输出
分析
在前面的程序中,我们看到:
- 如果您在前面的程序中省略了单词 virtual 和 override,您将收到以下警告消息:
- 如果您使用虚拟关键字但省略 override 关键字,您将再次收到以下警告消息(您可以运行该程序):
- 顾名思义,ChildClass 是一个派生类,其父类是 ParentClass。
- ParentClass 和 ChildClass 中都定义了具有相同签名和返回类型的名为 ShowMe()的方法。
- 在 Main()方法中,我们创建了一个子类对象 childOb。当我们通过这个对象调用方法 DoNotChangeMe()时,它可以调用方法(遵循继承属性)。没有魔法。
- 但是当我们通过这个对象调用方法 ShowMe()时,它调用的是 ChildClass 中定义的 ShowMe()版本;也就是说,父方法版本被覆盖。因此,这种情况称为方法重写。
- 现在请仔细注意:我们是如何在 ChildClass 中重新定义 ShowMe()方法的。我们使用了两个特殊的关键字——虚拟和覆盖。使用关键字 virtual,我们的意思是该方法可以在子类/派生类中重新定义。而 override 关键字是在确认我们是在有意的重定义父类的方法。
- 如果您在前面的程序中省略了单词 virtual,您将收到以下编译错误:
我们将很快在这个上下文中讨论关键字“new”。
虚方法和重写方法的返回类型、签名和访问说明符必须相同。例如,在前面的示例中,如果在子类的 ShowMe()中将可访问性从 public 更改为 protected,如下所示:
protected override void ShowMe()
{
Console.WriteLine("Inside Child.ShowMe");
}
您将收到编译错误:
学生问:
先生,在方法重载中,返回类型并不重要。但这很重要。这是正确的吗?
老师说:是的。我们必须记住,虚方法和重写方法的签名、返回类型和可访问性应该匹配。
学生问:
先生,下面的程序会收到任何编译错误吗?
演示 9
class ParentClass
{
public virtual int ShowMe(int i)
{
Console.WriteLine("I am in Parent class");
return i;
}
}
class ChildClass : ParentClass
{
public override void ShowMe(int i)
{
Console.WriteLine("I am in Child class");
}
}
老师说:是的。您将得到以下错误:
输出
因此,为了克服这一点,正如编译器所建议的,您可以将方法(在子类中)的返回类型更改为 int,并在方法体中进行一些必要的更改,如下所示:
public override int ShowMe(int i)
{
Console.WriteLine("I am in Child class");
Console.WriteLine("Incrementing i by 5");
return i +5;//Must return an int
}
或者,您可以使用具有 void 返回类型的方法,如下所示(但这次它将被视为方法重载):
public void ShowMe()
{
Console.WriteLine("In Child.ShowMe()");
}
如果在程序中使用这两个重新定义的方法,实际上是在实现方法重载和方法重写。浏览下面的例子。
演示 10
using System;
namespace OverridingEx2
{
class ParentClass
{
public virtual int ShowMe(int i)
{
Console.WriteLine("I am in Parent class");
return i;
}
}
class ChildClass : ParentClass
{
public override int ShowMe(int i)
{
Console.WriteLine("I am in Child class");
Console.WriteLine("Incrementing i by 5");
return i + 5;//Must return an int
}
public void ShowMe()
{
Console.WriteLine("In Child.ShowMe()");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** Overloading with Overriding Demo***\n");
ChildClass childOb = new ChildClass();
Console.WriteLine(childOb.ShowMe(5));//10
childOb.ShowMe();
Console.ReadKey();
}
}
}
输出
老师说:据说面向对象的程序员要经过三个重要阶段。在第一阶段,他们熟悉非面向对象的构造/结构(例如,他们使用决策语句、循环构造等。).在第二阶段,他们开始创建类和对象,并使用继承机制。最后在第三阶段,他们使用多态来实现延迟绑定,使他们的程序更加灵活。所以让我们来看看如何在 C# 程序中实现多态。
多态实验
老师继续说:多态通常与一个具有多种形式/结构的方法名称相关联。为了更好的理解它,我们需要先明确核心概念。所以,看看程序及其相应的输出。
演示 11
using System;
namespace BaseRefToChildObjectEx1
{
class Vehicle
{
public void ShowMe()
{
Console.WriteLine("Inside Vehicle.ShowMe");
}
}
class Bus : Vehicle
{
public void ShowMe()
{
Console.WriteLine("Inside Bus.ShowMe");
}
public void BusSpecificMethod()
{
Console.WriteLine("Inside Bus.ShowMe");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Base Class reference to Child Class Object Demo***\n\n");
Vehicle obVehicle = new Bus();
obVehicle.ShowMe();//Inside Vehicle.ShowMe
// obVehicle.BusSpecificMethod();//Error
//Bus obBus = new Vehicle();//Error
Console.ReadKey();
}
}
}
输出
分析
请注意前面程序中的两行重要代码:
Vehicle obVehicle = new Bus();
obVehicle.ShowMe();
这里我们通过一个父类引用(Vehicle 引用)指向一个派生类对象(Bus 对象),然后我们调用 ShowMe()方法。这种调用方式是允许的,我们不会收到任何编译问题;也就是说,基类引用可以指向派生类对象。
但是我们不能使用这两条线:
-
obVehicle.BusSpecificMethod();//Error
(因为这里的表观类型是车辆而不是公共汽车)。要消除这个错误,你需要向下转换,如下:((Bus)obVehicle).BusSpecificMethod();
-
Bus obBus = new Vehicle();//Error
如前所述,要消除此错误,需要进行向下转换,如下所示:
Bus obBus = (Bus)new Vehicle();
Points to Remember
通过父类引用,我们可以引用子类对象,但反之则不然。对象引用可以隐式向上转换为基类引用,并显式向下转换为派生类引用。我们将在一些关键比较的分析章节(第八章)中详细了解向上转换和向下转换操作。
现在我们将使用关键字 virtual 和 override 稍微修改一下程序,如下所示。请注意,我们用 virtual 标记了父类(Vehicle)方法,用 override 标记了子类(Bus)方法。
演示 12
using System;
namespace PloymorphismEx1
{
class Vehicle
{
public virtual void ShowMe()
{
Console.WriteLine("Inside Vehicle.ShowMe");
}
}
class Bus : Vehicle
{
public override void ShowMe()
{
Console.WriteLine("Inside Bus.ShowMe");
}
public void BusSpecificMethod()
{
Console.WriteLine("Inside Bus.ShowMe");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Polymorphism Example-1 ***\n\n");
Vehicle obVehicle = new Bus();
obVehicle.ShowMe();//Inside Bus.ShowMe
// obVehicle.BusSpecificMethod();//Error
//Bus obBus = new Vehicle();//Error
Console.ReadKey();
}
}
}
输出
分析
注意输出。这次调用的是子类方法(不是父类方法!).这是因为我们已经将 Vehicle 类中的 ShowMe()方法标记为 virtual。现在,编译器将不再看到调用该方法的明显类型(即,该调用无法在编译时绑定中解析)。当我们通过基类引用指向子类对象的方法时,编译器使用基类引用所引用的对象类型来调用正确的对象的方法。在这种情况下,编译器可以从 Bus 类中选择 ShowMe(),因为 Bus 对象被基类(Vehicle)引用所引用。
因此,通过将基类中的方法标记为虚拟的,我们打算实现多态。现在我们可以有意地在子类中重新定义(覆盖)该方法。在子类中,通过用关键字 override 标记一个方法,我们有意地重新定义了相应的虚方法。
Points to Remember
- 在 C# 中,默认情况下,所有方法都是非虚拟的。但是,在 Java 中,它们默认是虚拟的。因此,在 C# 中,我们需要标记关键字 override 来避免任何无意识的覆盖。
- C# 还使用 new 关键字将一个方法标记为非重写的,我们将很快讨论这一点。
学生问:
先生,你是说,“父类引用可以指向子对象,但反过来就不正确了。”我们为什么支持这种设计?
老师说:我们必须同意这些事实:我们可以说所有的公共汽车都是交通工具,但反过来不一定正确,因为还有其他交通工具,如火车、轮船,它们不一定是公共汽车。
同样,在编程术语中,所有派生类都是基类,但反之则不然。例如,假设我们有一个名为 Rectangle 的类,它是从另一个名为 Shape 的类派生而来的。那么我们可以说所有的矩形都是形状,但反过来就不正确了。
你必须记住,我们需要对继承层次进行“是-a”测试,“是-a”的方向总是直截了当的。
学生问:
先生,你是说调用将在运行时被解析为以下代码?
Vehicle obVehicle = new Bus();
obVehicle.ShowMe();
但是我们可以清楚地看到,总线对象是由父类引用指向的,编译器可以在早期绑定(或编译时绑定)期间将 ShowMe()绑定到总线对象。为什么它不必要地拖延了进程?
老师说:看着前面的代码,你可能会这样想。但是让我们假设我们还有一个子类 Taxi,它也是从父类 Vehicle 继承而来的。在运行时,基于某些情况,我们需要从 Bus 或 Taxi 调用 ShowMe()方法。考虑如下情况:我们正在生成一个 0 到 10 之间的随机数。然后我们检查这个数字是偶数还是奇数。如果是偶数,我们使用 Bus 对象,否则我们使用 Taxi 对象调用相应的 ShowMe()方法。
考虑下面的代码。
演示 13
using System;
namespace PolymorphismEx3
{
class Vehicle
{
public virtual void ShowMe()
{
Console.WriteLine("Inside Vehicle.ShowMe");
}
}
class Bus : Vehicle
{
public override void ShowMe()
{
Console.WriteLine("Inside Bus.ShowMe");
}
}
class Taxi : Vehicle
{
public override void ShowMe()
{
Console.WriteLine("Inside Taxi.ShowMe");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Polymorphism Example-3 ***\n");
Vehicle obVehicle;
int count = 0;
Random r = new Random();
while( count <5)
{
int tick = r.Next(0, 10);
if(tick%2==0)
{
obVehicle = new Bus();
}
else
{
obVehicle = new Taxi();
}
obVehicle.ShowMe();//Output will be determined during runtime
count++;
}
Console.ReadKey();
}
}
}
输出
请注意,输出可能会有所不同。
这是第一次运行:
这是第二次运行:
等等。
说明
现在,您应该意识到,对于这种编码,为什么编译器需要将决策延迟到运行时,以及我们是如何实现多态的。
学生问:
在某些情况下,我们可能想要设置限制。父类中的方法不应被其子类的方法重写。我们如何实现这一目标?
老师说:在很多面试中,你可能会面临这个问题。我们必须记住,我们可以通过使用“static”、“private”或“sealed”关键字来防止重写。但是这里我们只讨论了“密封”的使用。
考虑下面的代码。这里编译器本身阻止了继承过程。
演示 14
sealed class ParentClass
{
public void ShowClassName()
{
Console.WriteLine("Inside Parent.ShowClassName");
}
}
class ChildClass : ParentClass //Error
{
//Some code
}
我们将收到以下错误:“ChildClass”:不能从密封类型“ParentClass”派生。
输出
老师继续说:密封的关键字可能不仅仅与类相关联。我们也可以用方法来使用它。通过下面的程序可以更好的理解它。
示范 15
class ParentClass
{
public virtual void ShowClassName()
{
Console.WriteLine("Inside Parent.ShowClassName");
}
}
class ChildClass : ParentClass
{
sealed public override void ShowClassName()
{
Console.WriteLine("Inside ChildClass.ShowClassName");
}
}
class GrandChildClass : ChildClass
{
public override void ShowClassName()
{
Console.WriteLine("Inside GrandChildClass.ShowClassName");
}
}
这里我们正在试验多级继承,其中(顾名思义)ChildClass 从 ParentClass 派生,GrandChildClass 从 ChildClass 派生。但是在 ChildClass 中,我们使用了用覆盖方法 ShowClassName()密封的关键字。因此,这表明我们不能在它的任何派生类中进一步重写该方法。
但是孙子一般都很调皮。因此,它试图违反由其父类强加的规则(注意,ChildClass 是 GrandChildClass 的父类)。因此,编译器立即提出了它的关注,说您不能违反您父母的规则,并显示以下错误消息:
输出
老师继续说:现在考虑私有构造函数的情况。如果一个类只有私有构造函数,它就不能成为子类。这个概念可以用来创建一个单例设计模式,通过使用 new 关键字,我们可以防止在系统中创建不必要的对象;例如,下面的程序会给你一个编译错误。
示范 16
class ParentClass
{
private ParentClass() { }
public void ShowClassName()
{
Console.WriteLine("Inside Parent.ShowClassName");
}
}
class ChildClass : ParentClass //Error
{
//Some code
}
输出
恶作剧
你能预测产量吗?有没有编译错误?
示范 17
using System;
namespace QuizOnSealedEx1
{
class QuizOnSealed
{
public virtual void TestMe()
{
Console.WriteLine("I am in Class-1");
}
}
class Class1: QuizOnSealed
{
sealed public override void TestMe()
{
Console.WriteLine("I am in Class-1");
}
}
class Class2: QuizOnSealed
{
public override void TestMe()
{
Console.WriteLine("I am in Classs-2");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Quiz on sealed keyword usage***\n");
Class2 obClass2 = new Class2();
obClass2.TestMe();
Console.ReadKey();
}
}
}
输出
程序将成功编译并运行。
说明
我们在这里没有遇到任何问题,因为 Class2 不是 Class1 的子类。它也是从同一个父类 QuizOnSealed 派生的,并且可以自由地覆盖 TestMe()方法。
Points to Remember
密封类不能是基类。它们阻止了衍生过程。这就是为什么它们不能是抽象的。MSDN 指出,通过一些运行时优化,我们可以更快地调用密封类成员。
学生问:
先生,到目前为止,您已经对方法和类使用了关键字 sealed。可以应用到成员变量上吗?
老师说:不。我们可以在这些上下文中使用 readonly 或 const。我们可以像声明变量一样声明常量,但关键是声明后不能更改。另一方面,我们可以在声明期间或通过构造函数给 readonly 字段赋值。要声明一个常量变量,我们需要在声明前加上关键字 const。常数是隐式静态的。这两者之间的比较包含在 C# 中一些关键比较的分析一章中(第八章)。
恶作剧
代码会编译吗?
class A
{
sealed int a = 5;
}
回答
不可以。在 C# 中,这是不允许的。
在这种情况下,您可以使用 readonly。
恶作剧
代码会编译吗?
class A
{
sealed A()
{ }
}
回答
不可以。在 C# 中,这是不允许的。
老师继续说:关键字 sealed 是用来防止重写的,但是根据语言规范,构造函数根本不能被重写。如果我们想防止构造函数被它的派生类调用,我们可以将它标记为私有。(构造函数不会被子类继承;如果需要,我们需要显式调用基类构造函数)。
学生问:
先生,为了防止遗传,哪一个过程需要优先考虑:情况 1 还是情况 2?
案例 1:
class A1
{
private A1() { }
}
案例 2:
sealed
class A2
{
//some code..
}
老师说:首先,我们需要知道我们的要求。我们不应该预先概括任何决定。在第一种情况下,我们可以添加其他东西,然后我们可以很容易地从中派生出一个新的类。但是在第二种情况下,我们不能派生一个子类。为了更好地理解它,让我们向案例 1 添加一些代码,并遵循这个案例研究。
示范 18
using System;
namespace SealedClassVsA_ClassWithPrivateCons
{
class A1
{
public int x;
private A1() { }
public A1(int x) { this.x = x; }
}
sealed class A2
{
//some code..
}
class B1 : A1
{
public int y;
public B1(int x,int y):base(x)
{
this.y = y;
}
}
//class B2 : A2 { }//Cannot derive from sealed type 'A2'
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Case study: sealed class vs private constructor***\n");
B1 obB1 = new B1(2, 3);
Console.WriteLine("\t x={0}",obB1.x);
Console.WriteLine("\t y={0}",obB1.y); Console.Read();
}
}
}
输出
分析
我们可以看到,我们可以扩展案例 1 中的类,但是请注意注释行:
//class B2 : A2 { }//Cannot derive from sealed type 'A2'
如果取消注释,将会出现以下编译错误:
要记住的关键是,如果你使用私有构造函数只是为了防止继承,那么你使用的方法是错误的。私有构造函数通常用在只包含静态成员的类中。当你学习设计模式时,你会发现我们可以使用私有构造函数来停止额外的实例化。在这些情况下,我们的意图是不同的。
学生问:
先生,给我们一些提示,这样我们就可以很容易地区分方法重载和方法重写。
老师说:以下几点可以帮助你复习知识:
在方法重载中,所有的方法都可以驻留在同一个类中(注意这里的单词‘may ’,因为我们可以有这样的例子,方法重载的概念跨越了两个类——父类和子类)。
在方法重写中,涉及父类和子类的继承层次,这意味着在方法重写的情况下,至少涉及父类及其子类(即,最少两个类)。
考虑下面的程序和输出。
示范 19
using System;
namespace OverloadingWithMultipleClasses
{
class Parent
{
public void ShowMe()
{
Console.WriteLine("Parent.ShowMe1.No parameter");
}
public void ShowMe(int a)
{
Console.WriteLine("Parent.ShowMe1\. One integer parameter");
}
}
class Child:Parent
{
//An overloaded method in child/derived class
public void ShowMe(int a,int b)
{
Console.WriteLine("Child.ShowMe1\. Two integer parameter");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** Overloading across multiple classes***\n");
Child childOb = new Child();
//Calling all the 3 overloaded methods
childOb.ShowMe();
childOb.ShowMe(1);
childOb.ShowMe(1,2);
Console.ReadKey();
}
}
}
输出
Note
甚至当你要使用这些重载方法时,Visual Studio 也会给你提示。可以看到子类对象可以访问 1+2;也就是说,在本例中总共有三个重载方法。
在方法重载中,签名是不同的。在方法重写中,方法签名是相同/兼容的。(虽然你现在不需要考虑兼容这个词。稍后你可能会学到 Java 中的协变返回类型,在那里“兼容”这个词对你来说是有意义的。但在 C# 中,可以忽略“兼容”二字)。
我们可以通过方法重载实现编译时(静态)多态,但我们可以通过方法重写实现运行时(动态)多态。对于静态绑定/早期绑定/重载,编译器在编译时收集它的知识,所以一般来说,它执行得更快。
Points to Remember
所有 C# 方法默认都是非虚的(但在 Java 中,正好相反)。这里我们使用关键字 override 来有意地覆盖或重定义一个方法(被标记为虚拟的)。除了这两个关键字之外,关键字 new 也可以将一个方法标记为非重写的。
老师说:让我们用一个简单的程序来演示“new”关键字在重写上下文中的用法,从而结束这次讨论。考虑下面的程序和输出。
演示 20
using System;
namespace OverridingEx3
{
class ParentClass
{
public virtual void ShowMe()
{
Console.WriteLine("Inside Parent.ShowMe");
}
}
class ChildClass : ParentClass
{
public new void ShowMe()
{
Console.WriteLine("Inside Child.ShowMe");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** Use of 'new' in the context of method Overriding ***\n");
ParentClass parentOb = new ParentClass();
parentOb.ShowMe();//Calling Parent version
ChildClass childOb = new ChildClass();
childOb.ShowMe();//Calling Child version
Console.ReadKey();
}
}
}
输出
分析
如果您没有在子类的 ShowMe()方法中使用关键字 new,您将看到一条警告消息:
如果你熟悉 Java,你可能会发现这个特性很有趣,因为它在那里是不被允许的。想法很简单:C# 引入了标记非重写方法的概念,我们不希望多态地使用它。
老师继续说:为了理解 override 关键字的区别,考虑下面的例子。这里一个子类使用 override,另一个使用 new。现在比较多态行为。
演示 21
using System;
namespace OverridingEx4
{
class ParentClass
{
public virtual void ShowMe()
{
Console.WriteLine("Inside Parent.ShowMe");
}
}
class ChildClass1 : ParentClass
{
public override void ShowMe()
{
Console.WriteLine("Inside Child.ShowMe");
}
}
class ChildClass2 : ParentClass
{
public new void ShowMe()
{
Console.WriteLine("Inside Child.ShowMe");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** Use of 'new' in the context of method Overriding.Example-2 ***\n");
ParentClass parentOb;
parentOb= new ParentClass();
parentOb.ShowMe();
parentOb = new ChildClass1();
parentOb.ShowMe();//Inside Child.ShowMe
parentOb = new ChildClass2();
parentOb.ShowMe();//Inside Parent.ShowMe
Console.ReadKey();
}
}
}
输出
分析
我们可以在输出的最后一行看到使用 new 关键字的影响。我们可以看到“新”成员不是多态的(不打印子。ShowMe)。
抽象类
我们经常期望别人来完成我们未完成的工作。一个真实的例子是购买和改造房产。很常见的是,爷爷奶奶买了一套房产,然后父母在那套房产上盖了一个小房子,后来一个孙子把房子做大了或者重新装修了老房子。基本的想法是一样的:我们可能希望有人继续并首先完成未完成的工作。我们给他们自由,完工后,他们可以根据自己的需要进行改造。抽象类的概念最适合编程世界中的这类场景。
这些是不完整的类,我们不能从这种类型的类中实例化对象。这些类的子类必须首先完成它们,然后它们可以重新定义一些方法(通过重写)。
一般来说,如果一个类包含至少一个不完整/抽象的方法,那么这个类本身就是一个抽象类。术语抽象方法意味着该方法有声明(或签名)但没有实现。换句话说,您可以将抽象成员视为没有默认实现的虚拟成员。
Points to Remember
包含至少一个抽象方法的类必须标记为抽象类。
子类必须完成未完成的任务;也就是说,他们需要提供那些实现,但是如果他们没有提供,他们将再次被标记上 abstract 关键字。
因此,当一个基类/父类想要定义一个将被它的子类共享的通用形式时,这种技术非常有用。它只是将填写细节的责任传递给它的子类。让我们从一个简单的演示开始。
演示 22
using System;
namespace AbstractClassEx1
{
abstract class MyAbstractClass
{
public abstract void ShowMe();
}
class MyConcreteClass : MyAbstractClass
{
public override void ShowMe()
{
Console.WriteLine("I am from a concrete class.");
Console.WriteLine("My ShowMe() method body is complete.");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Abstract class Example-1 ***\n");
//Error:Cannot create an instance of the abstract class
// MyAbstractClass abstractOb=new MyAbstractClass();
MyConcreteClass concreteOb = new MyConcreteClass();
concreteOb.ShowMe();
Console.ReadKey();
}
}
}
输出
老师继续说:抽象类也可以包含具体的方法。子类可能会也可能不会重写这些方法。
示范 23
using System;
namespace AbstractClassEx2
{
abstract class MyAbstractClass
{
protected int myInt = 25;
public abstract void ShowMe();
public virtual void CompleteMethod1()
{
Console.WriteLine("MyAbstractClass.CompleteMethod1()");
}
public void CompleteMethod2()
{
Console.WriteLine("MyAbstractClass.CompleteMethod2()");
}
}
class MyConcreteClass : MyAbstractClass
{
public override void ShowMe()
{
Console.WriteLine("I am from a concrete class.");
Console.WriteLine("My ShowMe() method body is complete.");
Console.WriteLine("value of myInt is {0}",myInt);
}
public override void CompleteMethod1()
{
Console.WriteLine("MyConcreteClass.CompleteMethod1()");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Abstract class Example-2 ***\n");
//Error:Cannot create an instance of the abstract class
// MyAbstractClass abstractOb=new MyAbstractClass();
MyConcreteClass concreteOb = new MyConcreteClass();
concreteOb.ShowMe();
concreteOb.CompleteMethod1();
concreteOb.CompleteMethod2();
Console.WriteLine("\n\n*** Invoking methods through parent
class reference now ***\n");
MyAbstractClass absRef = concreteOb;
absRef.ShowMe();
absRef.CompleteMethod1();
absRef.CompleteMethod2();
Console.ReadKey();
}
}
}
输出
说明
前面的例子演示了我们可以使用抽象类引用来指向子类对象,然后我们可以调用相关的方法。稍后我们将了解到,我们可以从这种方法中获得巨大的好处。
学生问:
我们如何在这里实现运行时多态的概念?
老师说:我们在前面的例子中用过。请注意以下代码部分:
学生问:
抽象类可以包含字段吗?
老师说:是的。在前一个例子中,我们使用了这样一个字段;也就是 myInt。
学生问:
在前面的示例中,访问修饰符是 public。它是强制性的吗?
老师说:不。我们也可以用其他类型的。稍后你会注意到这是接口的关键区别之一。
学生问:
假设一个类中有十多个方法,其中只有一个是抽象方法。我们需要用关键字 abstract 来标记类吗?
老师说:是的。如果一个类包含至少一个抽象方法,那么这个类本身就是抽象的。你可以简单的认识到一个事实,用一个抽象的关键词来表示不完整。因此,如果你的类包含一个不完整的方法,那么这个类就是不完整的,因此它需要用关键字 abstract 来标记。
所以,简单的公式是,只要你的类至少有一个抽象方法,这个类就是一个抽象类。
老师继续说:现在考虑一个相反的情况。假设,你已经将你的类标记为抽象类,但是其中没有抽象方法,就像这样:
abstract class MyAbstractClass
{
protected int myInt = 25;
//public abstract void ShowMe();
public virtual void CompleteMethod1()
{
Console.WriteLine("MyAbstractClass.CompleteMethod1()");
}
public void CompleteMethod2()
{
Console.WriteLine("MyAbstractClass.CompleteMethod2()");
}
}
恶作剧
我们能编译这个程序吗?
回答
是的。它仍然可以编译,但是你必须记住你不能为这个类创建一个对象。所以,如果你这样编码:
MyAbstractClass absRef = new MyAbstractClass();//Error
编译器会提出它的问题。
学生问:
那么,先生,我们如何从抽象类中创建一个对象呢?
老师说:我们不能从抽象类中创建对象。
学生问:
先生,在我看来,一个抽象类如果不被扩展,实际上是没有任何用处的。这是正确的吗?
老师说:是的。
学生问:
如果一个类扩展了一个抽象类,它必须实现所有的抽象方法。这是正确的吗?
老师说:简单的公式是,如果你想创建一个类的对象,这个类需要被完成;也就是说,它不应该包含任何抽象方法。因此,如果子类不能提供所有抽象方法的实现(即主体),它应该用关键字 abstract 再次标记自己,如下例所示。
abstract class MyAbstractClass
{
public abstract void InCompleteMethod1();
public abstract void InCompleteMethod2();
}
abstract class ChildClass : MyAbstractClass
{
public override void InCompleteMethod1()
{
Console.WriteLine("Making complete of InCompleteMethod1()");
}
}
在这种情况下,如果您忘记使用关键字 abstract,编译器将引发一个错误,指出 ChildClass 没有实现 InCompleteMethod2()。
学生问:
我们可以说一个具体的类是一个不抽象的类。这是正确的吗?
是的。
学生问:
有时候我们会对关键词的顺序感到困惑;例如,在前面的案例中,我们使用:
public override void InCompleteMethod1(){...}
老师说:这个方法必须有一个返回类型,它应该在你的方法名之前。所以,如果你能记住这个概念,你就绝对不会写出 C# 中不正确的“public void override
学生问:
我们可以用抽象和密封来标记一个方法吗?
老师说:不。这就像如果你说你想探索 C# 但你不会浏览任何材料。类似地,通过声明 abstract,您希望在派生类之间共享一些公共信息,并且您表明重写对于它们是必要的;也就是说,继承链需要增长,但同时,通过声明 sealed,您希望在派生过程中加上结束标记,这样继承链就不会增长。因此,在这种情况下,您试图同时实现两个相反的约束。
恶作剧
你能预测产量吗?
using System;
namespace ExperimentWithConstructorEx1
{
class MyTestClass
{
//Constructors cannot be abstract or sealed
abstract MyTestClass()//Error
{
Console.WriteLine("abstract constructor");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Quiz : Experiment with a constructor***\n");
MyTestClass ob = new MyTestClass();
Console.ReadKey();
}
}
}
输出
编译错误。
学生问:
先生,为什么构造函数不能是抽象的?
老师说:我们通常在一个类中使用关键字 abstract,表示它是不完整的,子类将负责使他完整。我们必须记住,构造函数不能被重写(也就是说,它们是密封的)。此外,如果你分析构造函数的实际目的(即初始化对象),你必须同意,因为我们不能从抽象类创建对象,这种设计非常适合这里。
恶作剧
你能预测产量吗?
using System;
namespace ExperimentWithAccessModifiersEx1
{
abstract class IncompleteClass
{
public abstract void ShowMe();
}
class CompleteClass : IncompleteClass
{
protected override void ShowMe()
{
Console.WriteLine("I am complete.");
Console.WriteLine("I supplied the method body for showMe()");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Quiz : Experiment with access
specifiers***\n");
IncompleteClass myRef = new CompleteClass();
myRef.ShowMe();
Console.ReadKey();
}
}
}
输出
编译器错误。
注意:我们需要在 CompleteClass 中使用 public 访问修饰符,而不是 protected 访问修饰符。然后,您可以获得以下输出:
学生问:
先生,为什么我们不在两个职业中使用 protected?
老师说:从概念上讲,你可以这样做,但是你会在 Main()方法中遇到编译时错误。这是因为受保护方法的访问仅限于该类(它在其中定义)及其派生类实例。
摘要
本章涵盖了
- 方法重载
- 运算符重载
- 方法覆盖
- 抽象类
- 如何用抽象类实现运行时多态
- 方法签名
- 如何识别方法是否重载
- 如何霸王构造函数
- 如何霸王主 _)法
- 我们如何在程序中使用多个 Main()
- 如何实现编译时多态和运行时多态
- 为什么需要延迟绑定
- 虚拟、覆盖、密封和抽象关键字的使用
- 如何用不同的技术防止继承
- 在应用中使用 sealed 关键字与在应用中使用私有构造函数的比较
- 方法重载和方法重写的简单比较
- 为什么构造函数不能是抽象的
- 23+完整的程序演示和输出,详细涵盖这些概念
五、接口:面向对象的艺术
接口介绍
教师开始讨论:接口是 C# 中的一种特殊类型。一个接口只包含定义一些规范的方法签名。子类型需要遵循这些规范。当你使用一个接口时,你会发现它和一个抽象类有很多相似之处。
通过接口,我们声明了我们试图实现的内容,但是我们没有指定如何实现它。也可能出现接口类似于不包含任何实例变量的类。他们所有的方法都是在没有主体的情况下声明的(也就是说,方法实际上是抽象的)。关键字 interface 用于声明接口类型;它前面是您想要的接口名称。
Points to Remember
-
简单地说,接口帮助我们将“什么部分”和“如何部分”分开
-
要声明它们,请使用 interface 关键字。
-
接口方法没有主体。我们简单地用分号替换主体,就像这样:
void Show();
-
接口方法没有附加访问修饰符。
-
建议您在接口名称前面加上大写字母 I,例如
interface I MyInterface{..}
借助于接口,我们可以在运行时支持动态方法解析。一旦定义,一个类可以实现任意数量的接口。像往常一样,让我们从一个简单的例子开始。
演示 1
using System;
namespace InterfaceEx1
{
interface IMyInterface
{
void Show();
}
class MyClass : IMyInterface
{
public void Show()
{
Console.WriteLine("MyClass.Show() is implemented.");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring Interfaces.Example-1***\n");
MyClass myClassOb = new MyClass();
myClassOb.Show();
Console.ReadKey();
}
}
}
输出
Points to Remember
如果我们试图实现一个接口,我们需要匹配方法签名。
学生问:
先生,如果这些方法不完整,那么使用接口的类需要实现接口中的所有方法。这是正确的吗?
老师说:正是。如果类不能全部实现,它会通过将自己标记为抽象来宣布它的不完整性。下面的例子将帮助你更好地理解这一点。
这里,我们的接口有两个方法。但是一个类只实现了一个。所以,类本身变得抽象了。
interface IMyInterface
{
void Show1();
void Show2();
}
//MyClass becomes abstract. It has not implemented Show2() of //IMyInterface
abstract class MyClass2 : IMyInterface
{
public void Show1()
{
Console.WriteLine("MyClass.Show1() is implemented.");
}
public abstract void Show2();
}
分析
公式是一样的:一个类需要实现接口中定义的所有方法;否则,它就是一个抽象类。
如果你忘记实现Show2()
并且没有用abstract
keyword
标记你的类,如下…
编译器将引发以下错误。
学生问:
先生,在这种情况下,MyClass2 的一个子类只需实现 Show2()就可以完成任务。这是正确的吗?
老师说:是的。“演示 2”是一个完整的例子。
演示 2
using System;
namespace InterfaceEx2
{
interface IMyInterface
{
void Show1();
void Show2();
}
//MyClass becomes abstract. It has not implemented Show2() of IMyInterface
abstract class MyClass2 : IMyInterface
{
public void Show1()
{
Console.WriteLine("MyClass.Show1() is implemented.");
}
public abstract void Show2();
}
class ChildClass : MyClass2
{
public override void Show2()
{
Console.WriteLine("Child is completing -Show2() .");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring Interfaces.Example-2***\n");
//MyClass is abstract now
//MyClass myClassOb = new MyClass();
MyClass2 myOb = new ChildClass();
myOb.Show1();
myOb.Show2();
Console.ReadKey();
}
}
}
输出
学生问:
先生,您之前说过接口可以帮助我们实现多重继承的概念。我们的类可以实现两个或者更多的接口吗?
老师说:是的。下面的例子向您展示了如何做到这一点。
演示 3
using System;
namespace InterfaceEx3
{
interface IMyInterface3A
{
void Show3A();
}
interface IMyInterface3B
{
void Show3B();
}
class MyClass3 :IMyInterface3A, IMyInterface3B
{
public void Show3A()
{
Console.WriteLine("MyClass3 .Show3A() is completed.");
}
public void Show3B()
{
Console.WriteLine("MyClass3 .Show3B() is completed.");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring Interfaces.Example-3***\n");
MyClass3 myClassOb = new MyClass3();
myClassOb.Show3A();
myClassOb.Show3B();
Console.ReadKey();
}
}
输出
学生问:
在前面的程序中,方法名称在接口中是不同的。但是如果两个接口包含相同的方法名,我们如何实现它们呢?
老师说:很好的问题。我们需要使用显式接口实现的概念。在显式接口实现中,方法名前面是接口名,比如
演示 4
using System;
namespace InterfaceEx4
{
//Note: Both of the interfaces have the same method name //"Show()".
interface IMyInterface4A
{
void Show();
}
interface IMyInterface4B
{
void Show();
}
class MyClass4 : IMyInterface4A, IMyInterface4B
{
public void Show()
{
Console.WriteLine("MyClass4 .Show() is completed.");
}
void IMyInterface4A.Show()
{
Console.WriteLine("Explicit interface Implementation.IMyInterface4A .Show().");
}
void IMyInterface4B.Show()
{
Console.WriteLine("Explicit interface Implementation.IMyInterface4B .Show().");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring Interfaces.Example-4***\n");
//All the 3 ways of callings are fine.
MyClass4 myClassOb = new MyClass4();
myClassOb.Show();
IMyInterface4A inter4A = myClassOb;
inter4A.Show();
IMyInterface4B inter4B = myClassOb;
inter4B.Show();
Console.ReadKey();
}
}
输出
Points to Remember
-
我们必须注意一个有趣的事实。当我们显式地实现接口方法时,我们不会将关键字 public 附加到它们上面。但是在隐式实现中,这是必要的。
-
根据 MSDN 的说法:“显式接口成员实现包含访问修饰符是编译时错误,包含修饰符抽象、虚拟、重写或静态是编译时错误。”
-
如果一个类(或结构)实现一个接口,那么它的实例隐式转换为接口类型。这就是为什么我们可以毫无错误地使用下面的行:
IMyInterface4A inter4A = myClassOb;
或
IMyInterface4B inter4B = myClassOb;
在此示例中,myClassOb 是 MyClass4 类的一个实例,它实现了两个接口—IMyInterface4A 和 IMyInterface4B。
学生问:
一个接口可以继承或实现另一个接口吗?
老师说:可以继承但不能实现(按定义)。考虑下面的例子。
演示 5
using System;
namespace InterfaceEx5
{
interface Interface5A
{
void ShowInterface5A();
}
interface Interface5B
{
void ShowInterface5B();
}
//Interface implementing multiple inheritance
interface Interface5C :Interface5A, Interface5B
{
void ShowInterface5C();
}
class MyClass5 : Interface5C
{
public void ShowInterface5A()
{
Console.WriteLine("ShowInterface5A() is completed.");
}
public void ShowInterface5B()
{
Console.WriteLine("ShowInterface5B() is completed.");
}
public void ShowInterface5C()
{
Console.WriteLine("ShowInterface5C() is completed.");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring Interfaces.Example-5***");
Console.WriteLine("***Concept of multiple inheritance through
interface***\n");
MyClass5 myClassOb = new MyClass5();
Interface5A ob5A = myClassOb;
ob5A.ShowInterface5A();
Interface5B ob5B = myClassOb;
ob5B.ShowInterface5B();
Interface5C ob5C = myClassOb;
ob5C.ShowInterface5C();
Console.ReadKey();
}
}
}
输出
恶作剧
预测产量。
using System;
namespace InterfaceEx6
{
interface Interface6
{
void ShowInterface6();
}
class MyClass6 : Interface6
{
void Interface6.ShowInterface6()
{
Console.WriteLine("ShowInterface6() is completed.");
}
}
class Program
{
static void Main(string[] args)
{
MyClass6 myClassOb = new MyClass6();
myClassOb.ShowInterface6();//Error
//Interface6 ob6 = myClassOb;
//ob6.ShowInterface6();
Console.ReadKey();
}
}
}
输出
存在编译错误。
分析
您可以看到,我们已经显式地实现了接口。根据语言规范,要访问显式接口成员,我们需要使用接口类型。要克服这个错误,您可以使用以下代码行(即,取消前面显示的两行的注释):
Interface6 ob6 = myClassOb;
ob6.ShowInterface6();
然后,您将获得以下输出:
或者,您可以使用以下代码行获得相同的输出:
((Interface6)myClassOb).ShowInterface6();
学生问:
我们可以从一个类扩展,同时实现一个接口吗?
老师说:是的。你总是可以从一个类扩展(只要它不是密封的或者没有其他类似的约束)。在这种情况下,建议您使用位置符号。首先定位父类,然后是逗号,最后是接口名称,如下所示:
Class ChildClass: BaseClass,IMyinterface{...}
学生问:
为什么我们需要显式接口方法?
老师说:如果你仔细观察,你会发现显式接口的真正威力是当我们在两个或更多不同的接口中有相同的方法签名时。虽然他们的签名相同,但目的可能不同;例如,如果我们有两个接口——ITriangle 和 I rectangle——并且都包含一个具有相同签名的方法(例如,BuildMe()
),您可以假设 I triangle 中的BuildMe()
可能想要构建一个三角形;而 IRectangle 中的BuildMe()
可能想要构建一个矩形。因此,希望您能够根据情况调用适当的BuildMe()
方法。
标签/标记/标记界面
老师继续说:一个空的接口被称为标签/标记/标记接口。
//Marker interface example
interface IMarkerInterface
{
}
学生问:
先生,为什么我们需要一个标记界面?
老师说:
- 我们可以创造一个共同的父母。(值类型不能从其他值类型继承,但可以实现接口。我们将很快了解值类型)。
- 如果一个类(或一个结构)实现了一个接口,那么它的实例将隐式转换为接口类型。如果一个类实现了一个标记接口,就不需要定义一个新的方法(因为接口本身没有任何这样的方法)。
- 我们可以使用带有标记接口的扩展方法来克服程序中的一些挑战。
Note
MSDN 建议你不要使用标记接口。他们鼓励你使用属性的概念。属性和扩展方法的详细讨论超出了本书的范围。
老师问:
你能告诉我抽象类和接口的区别吗?
学生说:
- 抽象类可以完全实现,也可以部分实现;也就是说,在抽象类中,我们可以有具体的方法,但是接口不能有。接口包含行为的契约。(虽然在 Java 中,这个定义略有修改。从 Java 8 开始,我们可以在接口中使用默认关键字来提供方法的默认实现。
- 一个抽象类只能有一个父类(它可以从另一个抽象类或具体类扩展而来)。一个接口可以有多个父接口。一个接口只能从其他接口扩展。
- 默认情况下,接口的方法是公共的。抽象类可以有其他风格(例如,私有的、受保护的等等。).
- 在 C# 中,接口中不允许有字段。抽象类可以有字段(静态的和非静态的,有不同种类的修饰符)。
所以,如果你写了这样的东西:
interface IMyInterface
{
int i;//Error:Cannot contain fields
}
您将收到一个编译器错误。
但是,下面的代码没有问题:
abstract class MyAbstractClass
{
public static int i=10;
internal int j=45;
}
学生问:
先生,我们如何决定我们应该使用抽象类还是接口呢?
老师说:好问题。我相信如果我们想要集中的或者默认的行为,抽象类是一个更好的选择。在这些情况下,我们可以提供默认实现,它在所有子类中都可用。另一方面,接口实现从零开始。它们指明了某种关于要做什么的规则/契约(例如,您必须实现该方法),但是它们不会强制您执行该方法的哪一部分。此外,当我们试图实现多重继承的概念时,接口是首选。
但与此同时,如果我们需要向一个接口添加一个新方法,那么我们需要跟踪该接口的所有实现,并且我们需要将该方法的具体实现放在所有这些地方。前面是一个抽象类。我们可以在具有默认实现的抽象类中添加一个新方法,我们现有的代码将会顺利运行。
MSDN 提供以下建议:(可以参考这个在线讨论: https://stackoverflow.com/questions/20193091/recommendations-for-abstract-classes-vs-interfaces
)
- 如果您希望创建组件的多个版本,请创建一个抽象类。抽象类为组件版本化提供了一种简单易行的方法。通过更新基类,所有继承类都会随着更改而自动更新。另一方面,接口一旦创建就不能更改。如果需要接口的新版本,您必须创建一个全新的接口。
- 如果您正在创建的功能将对各种不同的对象有用,请使用接口。抽象类应该主要用于密切相关的对象;而接口最适合为不相关的类提供公共功能。
- 如果你正在设计小而简洁的功能,使用接口。如果您正在设计大型功能单元,请使用抽象类。
- 如果希望在组件的所有实现中提供通用的已实现功能,请使用抽象类。抽象类允许您部分实现您的类;而接口不包含任何成员的实现。
学生问:
先生,我们能把接口密封起来吗?
老师说:实现一个接口的责任完全留给了开发者。那么,如果你把接口密封了,那么谁来实现那个接口的不完整的方法呢?基本上,你试图同时实现两个相反的构造。
在下面的声明中,Visual Studio IDE 会引发错误。
演示 6
using System;
namespace Test_Interface
{
sealed interface IMyInterface
{
void Show();
}
class Program
{
static void Main(string[] args)
{
//some code
}
}
}
输出
学生问:
先生,我们可以在接口方法前使用关键字“抽象”吗?
老师说:有必要这样做吗?微软明确声明接口不能包含方法的实现;也就是说,它们是抽象的。在 Visual Studio IDE 中,如果您编写如下代码,您将会看到一个编译时错误:
演示 7
interface IMyInterface
{
abstract void Show();
}
输出
现在从前面的示例中删除关键字 abstract,构建您的程序,然后打开 ILcode。你可以看到它已经被标记为虚拟和抽象。
学生问:
先生,我们知道界面非常强大。但与此同时,我们也看到了许多与之相关的限制。您能总结一下与界面相关的主要限制吗?
老师说:以下是其中的一些,不包括最基本的。
我们不能在接口中定义任何字段、构造函数或析构函数。此外,您不应该使用访问修饰符,因为它们隐含地是公共的。
不允许嵌套类型(例如,类、接口、枚举和结构)。所以,如果你像这样写代码:
interface IMyInterface
{
void Show();
class A { }
}
编译器会报错,如下所示:
不允许接口从类或结构继承,但它可以从另一个接口继承。所以,如果你像这样写代码:
class A { }
interface IB : A { }
编译器会报错,如下所示:
学生问:
先生,您能总结一下使用界面的好处吗?
老师说:在很多情况下,界面是非常有用的,比如在下面:
- 当我们试图实现多态时
- 当我们试图实现多重继承的概念时
- 当我们试图开发松散耦合的系统时
- 当我们试图支持平行发展时
学生问:
先生,为什么我们需要这样的限制,“一个接口不能从一个类继承”?
老师说:一个类或结构可以有一些实现。所以,如果我们允许一个接口从它们继承,接口可能包含实现,这违背了接口的核心目标。
摘要
本章回答了以下问题:
- 什么是接口?
- 你如何设计一个界面?
- 接口的基本特征是什么?
- 如何实现多个接口?
- 如何处理拥有同名方法的接口?
- 有哪些不同类型的接口?
- 你如何处理显式接口技术?
- 为什么我们需要显式接口方法?
- 什么是标记接口?
- 抽象类和接口的区别是什么?
- 我们如何决定我们应该使用抽象类还是接口?
- 与接口相关的主要限制是什么?
六、将属性和索引器用于封装
属性概述
教师开始讨论:我们已经知道封装是面向对象编程的关键特征之一。在 C# 中,属性非常重要,因为它们有助于封装对象状态。属性是提供灵活机制来读取、写入或计算私有字段值的成员。最初,属性可能看起来类似于字段,但实际上它们要么附加了 get,要么附加了 set,或者同时附加了这两个块。这些特殊的块/方法被称为访问器。简单地说,get 块用于读取目的,set 块用于分配目的。
在下面的代码中,我们将研究如何获得对获取或设置私有成员值的完全控制。除了这种类型的控制和灵活性,我们还可以对属性施加一些约束,这些特征使它们在本质上是独一无二的。
演示 1
using System;
namespace PropertiesEx1
{
class MyClass
{
private int myInt; // also called private "backing" field
public int MyInt // The public property
{
get
{
return myInt;
}
set
{
myInt = value;
}
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring Properties.Example-1***");
MyClass ob = new MyClass();
//ob.myInt = 10;//Error: myInt is inaccessible
//Setting a new value
ob.MyInt = 10;//Ok.We'll get 10
//Reading the value
Console.WriteLine("\nValue of myInt is now:{0}", ob.MyInt);
//Setting another value to myInt through MyInt
ob.MyInt = 100;
Console.WriteLine("Now myInt value is:{0}", ob.MyInt);//100
Console.ReadKey();
}
}
}
输出
分析
如果使用ob.myInt=10;
,编译器将会引发一个问题,如下所示:
但是,您可以看到,使用 myInt 属性,我们可以完全控制获取或设置私有字段 MyInt。
- 请注意命名约定:为了更好的可读性和理解,我们只是将私有字段名称的开头字母替换为相应的大写字母(在本例中,myInt 的 M 替换为 M)。
- 注意上下文关键字值。它是与属性相关联的隐式参数。我们通常用它来做作业。
- 有时,存储由公共属性公开的数据的私有字段被称为后备存储或后备字段。因此,myInt 是前面示例中的私有支持字段。
- 对于属性,可以使用以下任何修饰符:public、private、internal、protected、new、virtual、abstract、override、sealed、static、unsafe 和 extern。
学生问:
主席先生,我们如何透过物业来施加约束/限制?
老师说:假设您想要一个约束,如果想要的值在 10 到 25 之间,用户可以设置一个值(在前面的程序中)。否则,系统将保留以前的值。这种类型的约束可以通过属性轻松实现。在这种情况下,要实现此约束,我们可以按如下方式修改 set 块:
set
{
//myInt = value;
/*Imposing a condition:
value should be in between 10 and 25.
Otherwise, you'll retain the old value*/
if ((value >= 10) && (value <= 25))
{
myInt = value;
}
else
{
Console.WriteLine("The new value {0} cannot be set", value);
Console.WriteLine("Please choose a value between 10 and 25");
}
}
现在,如果您再次运行该程序,您会收到以下输出:
老师继续说:当我们处理一个只有一个访问器的属性时,我们称之为只读属性。
以下是只读属性:
......
private int myInt;
public int MyInt
{
get
{
return myInt;
}
//set accessor is absent here
}
只设置了访问器的属性称为只写属性。以下是只写属性的示例:
......
private int myInt;
public int MyInt
{
//get accessor is absent here
set
{
myInt = value;
}
}
通常,我们有两个访问器,这些属性被称为读写属性。在演示 1 中,我们使用了读写属性。
从 C# 3.0 开始,我们可以减少与属性相关的代码长度。考虑以下代码:
//private int myInt;
public int MyInt
{
//get
//{
// return myInt;
//}
//set
//{
// myInt = value;
//}
get;set;
}
我们用一行代码替换了演示 1 中使用的九行代码。这种声明被称为自动属性声明。在这种情况下,编译器将为我们输入预期的代码,以使我们的生活更加轻松。
减少代码大小
假设您有一个只读属性,如下所示:
class MyClass
{
private double radius = 10;
public double Radius
{
get { return radius; }
}
}
您可以通过使用表达式主体属性(在 C# 6.0 中引入)来减少代码大小,如下所示:
class MyClass
{
private double radius = 10;
//Expression bodied properties (C#6.0 onwards)
public double Radius => radius;
}
您可以看到,两个大括号和关键字 get 和 return 被替换为符号'=>
'。
如果您打开 IL 代码,您会看到这些属性访问器在内部被转换为get_MethodName()
和set_MethodName()
。您可以检查它们的返回类型;例如,在这里我们得到的方法有
public int get_MyInt() {...}
和
public void set_MyInt(){...}
考虑下面的代码和相应的 IL 代码。
演示 2
using System;
namespace Test4_Property
{
class MyClass
{
//private int myInt;
public int MyInt
{
//automatic property declaration
get;set;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring Properties.Example-1***");
MyClass ob = new MyClass();
//ob.myInt = 1;//Error:myInt is inaccessible
//Setting a new value
ob.MyInt = 106;//Ok.We'll get 106
//Reading the value
Console.WriteLine("\nValue of myInt is now:" + ob.MyInt);
Console.ReadKey();
}
}
}
密码是什么
Points to Remember
从 C# 6 开始,我们可以使用如下的属性初始化器:
public int MyInt2
{
//automatic property declaration
get; set;
} = 25;//Automatic initialization
这意味着 MyInt2 是用值 25 初始化的。我们也可以通过移除 set 访问器来使它成为只读的。
在 C# 7.0 中,我们可以进一步减少代码,如下所示:
您必须检查编译器版本是否设置为版本 7。如果您的编译器设置为 C#6.0 或更低版本,对于该代码块,您将得到以下错误:
在撰写本文时,我不需要做任何更改,因为对我来说,C# 7.0 是默认设置。
要检查您的语言版本,您可以转到项目属性,然后构建,然后高级构建设置,然后语言版本。也可以参考下面的截图,供大家参考。
学生问:
先生,似乎如果我们有一个 set 访问器,我们可以使它成为私有的,在这种情况下,它的行为就像一个只读属性。这是正确的吗?
老师说:是的。即使您使它受到保护,也意味着您不希望将它公开给其他类型。
学生问:
主席先生,为什么我们要选择公共财产而不是公共领域?
老师说:用这种方法,你可以促进封装,这是面向对象的关键特性之一。
学生问:
先生,我们什么时候应该使用只读属性?
老师说:创建不可变类型。
虚拟财产
老师继续说:我们之前说过,我们可以用不同类型的修饰符创建不同类型的属性。我们在这里挑选了其中的两个。考虑下面的代码。
演示 3
using System;
namespace VirtualPropertyEx1
{
class Shape
{
public virtual double Area
{
get
{
return 0;
}
}
}
class Circle : Shape
{
int radius;
public Circle(int radius)
{
this.radius = radius;
}
public int Radius
{
get
{
return radius;
}
}
public override double Area
{
get
{
return 3.14 * radius * radius;
}
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Case study with a virtual Property***");
Circle myCircle = new Circle(10);
Console.WriteLine("\nRadius of the Cricle is {0} Unit", myCircle.Radius);
Console.WriteLine("Area of the Circle is {0} sq. Unit",myCircle.Area);
Console.ReadKey();
}
}
}
输出
抽象属性
如果用以下代码替换 VirtualPropertyEx1 中的 Shape 类:
abstract class
Shape
{
public abstract double Area
{
get;
}
}
再次运行这个程序,你会得到同样的输出。但是这次你使用了一个抽象属性。
Points to Remember
我们已经使用了继承修饰符,例如 abstract、virtual 和 override,以及属性的例子。我们也可以使用其他继承修饰符,比如 new 和 sealed。
除此之外,属性可以与所有的访问修饰符相关联(公共的、私有的、受保护的和内部的);静态修饰符(static);和非托管代码修饰符(不安全的、外部的)。
恶作剧
你能预测产量吗?
using System;
namespace QuizOnPrivateSetProperty
{
class MyClass
{
private double radius = 10;
public double Radius => radius;
public double Area => 3.14 * radius * radius;
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Quiz on Properties***");
MyClass ob = new MyClass();
Console.WriteLine("Area of the circle is {0} sq. unit", ob.Area);
Console.ReadKey();
}
}
}
输出
分析
你可以看到,这里我们使用了表达式体属性(从 C# 6.0 开始可用)。
恶作剧
你能预测产量吗?
using System;
namespace QuizOnPrivateSet
{
class MyClass
{
private double radius = 10;
public double Radius
{
get
{
return radius;
}
private set
{
radius = value;
}
}
public double Area => 3.14 * radius * radius;
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Quiz on Properties***");
MyClass ob = new MyClass();
ob.Radius = 5;
Console.WriteLine("Radius of the circle {0} unit", ob.Radius);
Console.WriteLine("Area of the circle is {0} sq. unit", ob.Area);
Console.ReadKey();
}
}
}
输出
分析
请注意,set 访问器前面有关键字 private。
索引器
考虑下面的程序和输出。
演示 4
using System;
namespace IndexerEx1
{
class Program
{
class MySentence
{
string[] wordsArray;
public MySentence( string mySentence)
{
wordsArray = mySentence.Split();
}
public string this[int index]
{
get
{
return wordsArray[index];
}
set
{
wordsArray[index] = value;
}
}
}
static void Main(string[] args)
{
Console.WriteLine("***Exploring Indexers.Example-1***\n");
string mySentence = "This is a nice day.";
MySentence sentenceObject = new MySentence(mySentence);
for (int i = 0; i < mySentence.Split().Length; i++)
{
Console.WriteLine("\t sentenceObject[{0}]={1}",i,sentenceObject[i]);
}
Console.ReadKey();
}
}
}
输出
分析
我们看到了这个项目的一些有趣的特点。
- 程序类似于 properties,但关键区别在于 property 的名字是这个。
- 我们像数组一样使用索引参数。这些被称为索引器。我们可以把一个类或结构或接口的实例看作数组。this 关键字用于引用实例。
Points to Remember
-
所有的修饰符——私有的、公共的、受保护的、内部的——都可以用于索引器(就像属性一样)。
-
返回类型可以是任何有效的 C# 数据类型。
-
我们可以创建一个有多个索引器的类型,每个索引器有不同类型的参数。
-
通常,我们可以通过消除 set 访问器来创建只读索引器。尽管它在语法上是正确的,但是建议您在这些场景中使用方法(例如,使用方法来检索与雇员 ID 相对应的雇员信息总是好的)。所以,你应该避免这样:
//NOT a recommended style Class Employee{ //using indexers to get employee details public string this[int empId] { get { //return Employee details } } }
老师继续说:让我们来看另一个演示。在下面的程序中,我们使用了一个字典来保存一些雇员的名字和他们的薪水。然后我们就在想办法看他们工资的上限。如果在我们的字典中找不到员工,我们会说没有找到记录。
要使用字典类,我们需要在程序中包含下面一行,因为该类是在那里定义的:
using System.Collections.Generic;
字典是一个集合和一对。它使用散列表数据结构来存储一个键及其相应的值。该机制非常快速和高效。建议你多了解字典。你应该首先明白
employeeWithSalary = new Dictionary<string, double>();
employeeWithSalary.Add("Amit",20125.87);
在前面两行代码中,我们创建了一个字典,然后使用了它的Add
方法。在这个字典中,每当我们想要添加数据时,第一个参数应该是字符串,第二个应该是双精度。因此,我们可以添加一个 Employee Amit(一个字符串变量)和 salary(一个 double 变量)作为字典元素。对于字典中的其余元素,我们遵循相同的过程。浏览程序,然后分析输出。
演示 5
using System;
using System.Collections.Generic;
namespace IndexerQuiz1
{
class EmployeeRecord
{
Dictionary<string, double> employeeWithSalary;
public EmployeeRecord()
{
employeeWithSalary = new Dictionary<string, double>();
employeeWithSalary.Add("Amit",20125.87);
employeeWithSalary.Add("Sam",56785.21);
employeeWithSalary.Add("Rohit",33785.21);
}
public bool this[string index, int predictedSalary]
{
get
{
double salary = 0.0;
bool foundEmployee = false;
bool prediction = false;
foreach (string s in employeeWithSalary.Keys)
{
if (s.Equals(index))
{
foundEmployee = true;//Employee found
salary = employeeWithSalary[s];//Employees
//actual salary
if( salary>predictedSalary)
{
//Some code
prediction = true;
}
else
{
//Some code
}
break;
}
}
if(foundEmployee == false)
{
Console.WriteLine("Employee {0} Not found in our database.", index);
}
return prediction;
}
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Quiz on Indexers***\n");
EmployeeRecord employeeSalary = new EmployeeRecord();
Console.WriteLine("Is Rohit's salary is more than 25000$ ?- {0}", employeeSalary["Rohit",25000]);//True
Console.WriteLine("Is Amit's salary is more than 25000$ ?- {0}", employeeSalary["Amit",25000]);//False
Console.WriteLine("Is Jason's salary is more than 10000$ ?-{0}", employeeSalary["Jason",10000]);//False
Console.ReadKey();
}
}
}
输出
分析
我介绍这个程序是为了展示我们可以使用不同类型参数的索引器。
学生问:
先生,我们可以在同一个类中有多个索引器吗?
老师说:是的。但是在这种情况下,方法签名必须彼此不同。
学生问:
先生,那么索引器可以重载吗?
老师说:是的。
学生问:
先生,到目前为止,索引器看起来像数组。数组和索引器有什么区别?
老师说:是的。甚至有时开发人员将索引器描述为虚拟数组。但是这里有一些关键的区别:
- 索引器可以接受非数字下标。我们已经测试过了。
- 索引器可以重载,但数组不能。
- 索引器值不属于变量。因此,它们不能用作 ref 或 out 参数,而数组可以。
接口索引器
学生问:
先生,我们如何使用带接口的索引器?
老师说:接下来是一个例子,我们用索引器隐式实现了一个接口。在我们继续之前,我们需要记住接口索引器和类索引器之间的关键区别。
- 接口索引器没有主体。(注意,在下面的演示中,get 和 set 只带有分号。)
- 接口索引器没有修饰符。
演示 6
using System;
namespace IndexerEx2
{
interface IMyInterface
{
int this[int index] { get; set; }
}
class MyClass : IMyInterface
{
//private int[] myIntegerArray;
private int[] myIntegerArray = new int[4];
public int this[int index]
{
get
{
return myIntegerArray[index];
}
set
{
myIntegerArray[index] = value;
}
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring Indexers with interfaces***\n");
MyClass obMyClass = new MyClass();
//Initializing 0th, 1st and 3rd element using indexers
obMyClass[0] = 10;
obMyClass[1] = 20;
obMyClass[3] = 30;
for (int i = 0; i <4; i++)
{
// Console.WriteLine("\t obMyClass[{0}]={1}", i, obMyClass[i]);
System.Console.WriteLine("Element #{0} = {1}", i, obMyClass[i]);
}
Console.ReadKey();
}
}
}
输出
恶作剧
代码会编译吗?
using System;
namespace IndexerQuiz2
{
interface IMyInterface
{
int this[int index] { get; set; }
}
class MyClass : IMyInterface
{
private int[] myIntegerArray = new int[4];
//Explicit interface implementation
int IMyInterface.this[int index]
{
get => myIntegerArray[index];
set => myIntegerArray[index] = value;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Quiz on Indexers with explicit interface technique***\n");
MyClass obMyClass = new MyClass();
IMyInterface interOb = (IMyInterface)obMyClass;
//Initializing 0th, 1st and 3rd element using indexers
interOb[0] = 20;
interOb[1] = 21;
interOb[3] = 23;
for (int i = 0; i < 4; i++)
{
Console.WriteLine("\t obMyClass[{0}]={1}", i,interOb[i]);
}
Console.ReadKey();
}
}
}
回答
是的。以下是输出:
分析
这是一个我们用索引器显式实现接口的例子。在这个例子中,我们使用了最新的 C# 7.0 特性(注意 get、set 主体)。
我们正在接触元素。
索引器的显式实现是非公共的(也是非虚拟的;即不能被覆盖)。因此,如果我们尝试使用 MyClass 对象obMyClass
,而不是接口对象,就像前面的演示一样,我们将得到编译错误。
Points to Remember
-
接口索引器没有主体。
-
接口索引器没有修饰符。
-
从 C# 7.0 开始,我们可以这样写代码:
public int MyInt { get => myInt; set => myInt = value; }
-
索引器的显式实现是非公共和非虚拟的。
摘要
本章涵盖了
- 不同类型的属性
- 自动属性
- 具有最新 c# 7.0 特性的表达式体属性
- 虚拟和抽象属性
- 为什么我们应该更喜欢公共财产而不是公共领域
- 属性与数组有何不同
- 如何通过属性施加约束/限制
- 何时使用只读属性,何时避免使用只读属性
- 索引。
- 索引器与属性有何不同
- 如何使用带有显式和隐式接口的索引器,以及要记住的限制
- 接口索引器与类索引器有何不同
七、理解类变量
老师开始讨论:有时我们不想通过一个类型的实例来操作。相反,我们更喜欢研究类型本身。在这些场景中,我们想到了类变量或类方法的概念。它们通常被称为静态变量或静态方法。在 C# 中,类本身可以是静态的。一般来说,当我们将关键字 static 标记为一个类时,它就是一个静态类;当它被标记上一个方法时,它被称为静态方法;当我们把它和一个变量联系起来时,我们称之为静态变量。
类别变量
让我们从一个简单的例子开始。
演示 1
using System;
namespace StaticClassEx1
{
static class Rectangle
{
public static double Area(double len, double bre)
{
return len * bre;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring class variables.Example-1***\n");
double length = 25;
double breadth = 10;
Console.WriteLine("Area of Rectangle={0} sq. unit", Rectangle.Area(length, breadth));
Console.ReadKey();
}
}
}
输出
分析
可以看到我们通过类名调用了 Rectangle 类的Area (..)
方法。我们在这里没有创建 Rectangle 类的任何实例。
学生问:
我们是否也可以创建 Rectangle 类的一个实例,然后调用 Area(..)方法?
老师说:不行,这里不允许。如果允许,那么还有必要引入静态类的概念吗?因此,如果您在 Visual Studio 中编写代码,并尝试引入如下代码行:
Rectangle rect = new Rectangle();//Error
您将得到以下编译错误。
学生问:
但是如果我们在 Rectangle 类中有一个非静态方法,我们如何访问这个方法呢?这一次,我们需要一个实例来访问该方法。
老师说:这就是为什么静态类有限制:它们只能包含静态成员。所以,如果你试着在我们的 Rectangle 类中放一个非静态方法,比如说ShowMe()
,就像这样:
您将得到以下编译错误。
学生问:
我们不能从静态类创建实例。但是子类可以创建一个实例。在这种情况下,实际的概念可能会被误用。这种理解正确吗?
老师说:C# 的设计者已经注意到这个事实,因为我们不允许从静态类创建子类;也就是说,静态类不能被继承。因此,在我们之前的示例中,如果您尝试以下列方式创建非静态派生类(例如,ChildRectangle ):
您将得到一个编译错误。
学生问:
然后静态类被密封。这是正确的吗?
老师说:是的。如果您打开 IL 代码,您将看到以下内容:
老师继续说:你可能也注意到了,我们几乎在每个地方都使用控制台课程。这个类也是一个静态类。如果您右键单击控制台,然后按“转到定义”(或按 F12),您将看到以下内容:
Points to Remember
- 静态类是密封的(即,它们不能被继承或实例化)。
- 它们只能包含静态成员。
- 静态类不能包含实例构造函数。
- 系统。控制台和系统。数学是静态类的常见例子。
关于静态方法的讨论
老师继续说:到目前为止,我们已经看到了带有一些静态方法的静态类。你知道关键字 static 是用来表示“奇异事物”的。在设计模式中,有一种模式叫做单例模式,它可以使用静态类。
有时,我们也认为静态方法更快(更多信息见 MSDN 的文章 https://msdn.microsoft.com/en-us/library/ms973852.aspx
)。但是,关键是它们不能是任何实例的一部分。这就是为什么我们的Main()
方法是静态的。
如果你注意到Main()
方法,你可以看到它包含在一个非静态类(程序)中。因此,很明显,非静态类可以包含静态方法。为了详细探究这一点,让我们来看下面的程序,其中有一个包含静态和非静态方法的非静态类。
演示 2
using System;
namespace StaticMethodsEx1
{
class NonStaticClass
{
//a static method
public static void StaticMethod()
{
Console.WriteLine("NonStaticClass.StaticMethod");
}
//a non-static method
public void NonStaticMethod()
{
Console.WriteLine("NonStaticClass.NonStaticMethod");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring static methods
.Example-1***\n");
NonStaticClass anObject = new NonStaticClass();
anObject.NonStaticMethod();//Ok
//anObject.StaticMethod();//Error
NonStaticClass.StaticMethod();
Console.ReadKey();
}
}
}
输出
如果取消对以下行的注释:
//anObject.StaticMethod();
您将收到以下错误:
现在考虑修改后的程序。我在这里引入了一个静态变量和一个实例变量,用静态和实例方法来分析它们。
演示 3
using System;
namespace StaticMethodsEx2
{
class NonStaticClass
{
static int myStaticVariable = 25;//static variable
int myInstanceVariable = 50;//instance variable
//a static method
public static void StaticMethod()
{
Console.WriteLine("NonStaticClass.StaticMethod");
Console.WriteLine("myStaticVariable = {0}", myStaticVariable);//25
//Console.WriteLine("StaticMethod->instance variable = {0}", myInstanceVariable);//error
}
//a non-static method
public void NonStaticMethod()
{
Console.WriteLine("NonStaticClass.NonStaticMethod");
Console.WriteLine("NonStaticMethod->static variable = {0}", myStaticVariable);//25 Ok
//Console.WriteLine("myStaticVariable = {0}", this.myStaticVariable);//Error
Console.WriteLine("myInstanceVariable = {0}", myInstanceVariable);//50
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring static methods
.Example-2***\n");
NonStaticClass anObject = new NonStaticClass();
anObject.NonStaticMethod();//Ok
//anObject.StaticMethod();//Error
NonStaticClass.StaticMethod();
Console.ReadKey();
}
}
}
输出
分析
请注意注释行。它们中的每一个都可能导致编译错误。例如,如果取消对该行的注释:
//Console.WriteLine("myStaticVariable = {0}", this.myStaticVariable);//Error
它会导致以下错误:
因为这里也是实例引用。
老师继续:以后你会学到,在 C# 中,我们有扩展方法。(我们可以用新方法扩展现有类型,而不会影响类型的定义。)这些基本上是静态方法,但是使用实例方法语法调用,因此您可以将静态方法视为实例方法。它们最常用于 LINQ 查询运算符的上下文中。然而,对这些主题的详细讨论超出了本书的范围。
关于静态构造函数的讨论
我们可以使用静态构造函数来初始化任何静态数据,或者执行只需要运行一次的操作。我们不能直接调用静态构造函数(也就是说,我们不能直接控制静态构造函数何时被执行)。但是我们知道它会在以下两种情况下被自动调用:
- 在创建类型的实例之前。
- 当我们在程序中引用一个静态成员时。
考虑下面的程序和输出。
演示 4
using System;
namespace StaticConstructorEx1
{
class A
{
static int StaticCount=0,InstanceCount=0;
static A()
{
StaticCount++;
Console.WriteLine("Static constructor.Count={0}",StaticCount);
}
public A()
{
InstanceCount++;
Console.WriteLine("Instance constructor.Count={0}", InstanceCount);
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring static constructors***\n");
A obA = new A();//StaticCount=1,InstanceCount=1
A obB = new A();//StaticCount=1,InstanceCount=2
A obC = new A();//StaticCount=1,InstanceCount=3
Console.ReadKey();
}
}
}
输出
分析
从程序和输出中,我们看到静态构造函数只执行一次(不是每个实例)
如果您引入此代码:
static A(int A){ }
您将得到一个编译时错误。
如果您引入此代码:
public static A(){...}
您将得到以下编译时错误:
Points to Remember
- 静态构造函数对每种类型只执行一次。我们无法直接控制何时执行静态构造函数。但是我们知道,当我们试图实例化一个类型或者当我们试图访问该类型中的一个静态成员时,会自动调用一个静态构造函数。
- 一个类型只能有一个静态构造函数。它必须是无参数的,并且不接受任何访问修饰符。
- 按照声明顺序,静态字段初始值设定项在静态构造函数之前运行。
- 在没有静态构造函数的情况下,字段初始值设定项就在类型被使用之前执行,或者在运行时突发奇想的任何时候执行。
学生问:
什么时候应该使用静态构造函数?
老师说:写日志会很有用。它们还用于为非托管代码创建包装类。
摘要
本章涵盖了
- 静态类概念
- 静态方法和静态变量概念
- 静态构造函数概念
- 如何在 C# 中实现这些概念以及与之相关的限制
- 何时以及如何使用这些概念
八、C# 中一些关键比较的分析
老师说:在这一章中,我们讨论了 C# 中一些常见的比较。我们开始吧。
隐式转换与显式转换
通过强制转换,我们可以将一种数据类型转换成另一种。有时我们称这种过程为类型转换。基本上,有两种类型的造型:隐式和显式。顾名思义,隐式转换是自动的,我们不需要担心它。但是,我们需要转换操作符来进行显式转换。除此之外,还有两种其他类型的转换:使用帮助器类的转换和用户定义的转换。在这一章中,我们将关注隐式和显式转换。让我们现在过一遍。
在隐式转换中,转换路径遵循从小到大的整数类型,或者从派生类型到基类型。
下面的代码片段将会完美地编译和运行:
int a = 120;
//Implicit casting
double b = a;//ok- no error
对于显式强制转换,考虑相反的情况。如果你写了这样的东西:
int c = b;//Error
编译器会抱怨。
所以,你需要写这样的东西:
//Explicit casting
int c = (int)b;//Ok
Points to Remember
如果一种类型可以转换为另一种类型,则可以应用铸造;也就是说,不能将字符串赋给整数。你总是会在这种尝试中遇到错误;例如,您总是会得到一个错误,即使您尝试对它应用 cast。
int d = ( int)"hello";//error
隐式和显式转换有一些基本的区别。隐式转换是类型安全的(没有数据丢失,因为我们是从一个小容器到一个大容器,我们有足够的空间)。显式转换不是类型安全的(因为在这种情况下,数据从一个大容器移动到一个小容器)。
学生问:
先生,当我们处理引用类型时,如何处理强制转换异常?
老师说:在这些场景中,我们将使用“is”或“as”运算符。我们稍后将讨论它们。
拳击对拳击
老师继续:现在我们来讨论另一个重要的话题:装箱和拆箱。这里我们需要处理值类型和引用类型。
对象(系统。对象)是所有类型的最终基类。因为 Object 是一个类,所以它是一个引用类型。当我们应用强制转换将值类型转换为对象类型(即引用类型)时,该过程称为装箱,相反的过程称为取消装箱。
通过装箱,值类型在堆上分配一个对象实例,然后将复制的值装箱(存储)到该对象中。
这里有一个拳击的例子:
int i = 10;
object o = i;//Boxing
现在考虑相反的情况。如果您尝试编写这样的代码:
object o = i;//Boxing
int j = o;//Error
您将面临编译错误。
为了避免这种情况,我们需要使用拆箱,就像这样:
object o = i;
int j = (int)o; //Unboxing
学生问:
哪种转换是隐式的:装箱还是取消装箱?
老师说:拳击。请注意,我们不需要编写这样的代码:
int i = 10;
object o = (object)i;
//object o=i; is fine since Boxing is implicit.
学生问:
装箱、拆箱和类型转换操作似乎是相似的。这是真的吗?
老师说:有时它可能看起来令人困惑,但如果你专注于基本规则,你可以很容易地避免困惑。通过这些操作(向上转换/向下转换,装箱/取消装箱),我们试图将一件事转换成另一件事。这基本上是他们的相似之处。现在重点说说装箱拆箱的特长。装箱和取消装箱是值类型和对象类型(即引用类型)之间的转换。通过装箱,值类型的副本从堆栈移动到堆中,而取消装箱则执行相反的操作。所以,你基本上可以说,通过装箱,我们将值类型转换为引用类型(显然,取消装箱是这种操作的对应)。
但是从更广泛的意义上来说,使用“投射”这个词,我们的意思是说我们并没有在物体上移动或操作。我们只想转换它们的表面类型。
学生问:
先生,unboxing 和 downcasting(显式强制转换)的共同之处是什么?
两者都可能是不安全的,并且它们可能会引发 InvalidCastException。基本上,显式造型总是很危险的。考虑操作不安全的情况。假设你想把一个 long 转换成一个 int,你已经在下面的演示中写了这样的代码。
演示 1
#region invalid casting
long myLong = 4000000000;
int myInt = int.MaxValue;
Console.WriteLine(" Maximum value of int is {0}", myInt);
//Invalid cast:Greater than maximum value of an integer
myInt = (int) myLong;
Console.WriteLine(" Myint now={0}", myInt);
#endregion
输出
分析
您可以看到,您没有收到任何编译错误,但是整数 myInt 的最终值是不需要的。所以,这种转换是不安全的。
学生问:
"装箱和取消装箱会影响程序的性能."这是真的吗?
老师说:是的。它们会严重影响程序的性能。这里我们介绍了两个程序来分析。演示 2 分析选角的表现,演示 3 分析拳击的表现。请注意,强制转换或装箱操作所花费的时间总是会影响程序的性能,如果我们不断增加 for 循环结构中的迭代次数,这些时间会变得非常重要。
演示 2
using System;
using System.Diagnostics;
namespace CastingPerformanceComparison
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Analysis of casting performance***\n");
#region without casting operations
Stopwatch myStopwatch1 = new Stopwatch();
myStopwatch1.Start();
for (int i = 0; i < 100000; i++)
{
int j = 25;
int myInt = j;
}
myStopwatch1.Stop();
Console.WriteLine("Time taken without casting : {0}", myStopwatch1.Elapsed);
#endregion
#region with casting operations
Stopwatch myStopwatch2 = new Stopwatch();
myStopwatch2.Start();
for ( int i=0;i<100000;i++)
{
double myDouble = 25.5;
int myInt = (int)myDouble;
}
myStopwatch2.Stop();
Console.WriteLine("Time taken with casting: {0}", myStopwatch2.Elapsed);
#endregion
Console.ReadKey();
}
}
}
输出
分析
看到时差了吗?在铸造操作中,时间要长得多。当我们改变迭代次数时,这种差异也就不同了。(在您的机器上,您可以看到类似的差异,但在每次单独运行时,这些值可能会略有不同。)
演示 3
Note
我们在这里使用了泛型编程的简单概念。所以,一旦你理解了泛型的概念,你就可以回到这个程序。
using System;
using System.Collections.Generic;
using System.Diagnostics;//For Stopwatch
namespace PerformanceOfBoxing
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Performance analysis in Boxing ***");
List<int> myInts = new List<int>();
Stopwatch myStopwatch1 = new Stopwatch();
myStopwatch1.Start();
for (int i = 0; i < 1000000; i++)
{
//Adding an integer to a list of Integers. So, there is no need of boxing.(Advantage of Generics)
myInts.Add(i);
}
myStopwatch1.Stop();
Console.WriteLine("Time taken without Boxing: {0}", myStopwatch1.Elapsed);
//Now we are testing :Boxing Performance
List<object> myObjects = new List<object>();
Stopwatch myStopwatch2 = new Stopwatch();
myStopwatch2.Start();
for (int i = 0; i < 1000000; i++)
{
//Adding an integer to a list of Objects. So, there is need of boxing.
myObjects.Add(i);
}
myStopwatch2.Stop();
Console.WriteLine("Time taken with Boxing :{0}", myStopwatch2.Elapsed);
Console.ReadKey();
}
}
}
分析
再次注意时差。在拳击比赛中,时间要长得多。当我们改变迭代(循环)的次数时,这种差异也会改变。
输出
向上转换与向下转换
通过类型转换,我们试图改变对象的外观类型。在一个继承链中,我们可以从下往上走,也可以从上往下走。
通过向上转换,我们从一个子类引用中创建一个基类引用;对于向下转换,我们做相反的事情。
我们已经看到所有的足球运动员(一种特殊类型的运动员)都是运动员,但反过来就不一定了,因为有网球运动员、篮球运动员、曲棍球运动员等等。而且我们也看到了一个父类引用可以指向一个子类对象;也就是说,我们可以这样写
Player myPlayer=new Footballer();
(像往常一样,我们假设球员类是基类,球员类是从基类派生出来的)。这是向上抛的方向。在向上转换中,我们可以有以下注释:
- 它简单而含蓄。
- 当我们从一个子类引用创建一个基类引用时,这个基类引用可以对子对象有一个更严格的视图。
为了清楚地理解这几点,我们来看下面的例子。
演示 4
using System;
namespace UpVsDownCastingEx1
{
class Shape
{
public void ShowMe()
{
Console.WriteLine("Shape.ShowMe");
}
}
class Circle:Shape
{
public void Area()
{
Console.WriteLine("Circle.Area");
}
}
class Rectangle:Shape
{
public void Area()
{
Console.WriteLine("Rectangle.Area");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Upcasting Example***\n");
Circle circleOb = new Circle();
//Shape shapeOb = new Circle();//upcasting
Shape shapeOb = circleOb;//Upcasting
shapeOb.ShowMe();
//shapeOb.Area();//Error
circleOb.Area();//ok
Console.ReadKey();
}
}
}
输出
分析
请注意,我们已经使用以下代码行实现了向上转换:
Shape shapeOb = circleOb;//Upcasting
shapeOb.ShowMe();
您可以看到,尽管 shapeOb 和 circleOb 都指向同一个对象,但是 shapeOb 无法访问 circle 的 Area()方法(即,它对该对象具有限制性视图)。但是 circleOb 可以很容易地访问自己的方法。
学生问:
先生,为什么父参考在这个设计中有限制性的观点?
老师说:当父类被创建时,它不知道它的子类和它将要添加的新方法。因此,父类引用不应该访问专门的子类方法是有意义的。
老师继续说:如果你写了下面这样的东西,你是沮丧的。
Circle circleOb2 = (Circle)shapeOb;//Downcast
因为现在您正在从基类引用创建子类引用。
但是向下转换是显式的,不安全的,这种转换我们会遇到 InvalidCastException。
恶作剧
让我们修改 Main()方法,如下所示。(我们保持其余部分不变;也就是说,所有三个类别——Shape、Circle 和 Rectangle——都与前面的程序相同。现在预测输出。
static void Main(string[] args)
{
Console.WriteLine("***Downcasting is unsafe demo***\n");
Circle circleOb = new Circle();
Rectangle rectOb = new Rectangle();
Shape[] shapes = { circleOb, rectOb };
Circle circleOb2 = (Circle)shapes[1];//Incorrect
//Circle circleOb2 = (Circle)shapes[0];//Correct
circleOb2.Area();
Console.ReadKey();
}
输出
将引发运行时异常。
分析
这是一个我们在运行时会遇到 InvalidCastException()的例子。形状[1]是矩形对象,不是圆形对象。所以,如果你使用向下转换,你需要小心。
是与是
在某些情况下,我们经常需要动态检查一个对象的类型,这两个关键字在这里起着重要的作用。
关键字 is 与给定类型进行比较,如果可以进行强制转换,则返回 true,否则将返回 false。另一方面,as 可以将给定的对象转换为指定的类型,如果它是可转换的;否则,它将返回 null。
因此,我们可以说,使用 as 关键字,我们既可以进行强制转换能力检查,也可以进行转换。
演示 5:使用“is”关键字
这里我们稍微修改了一下程序,用三种不同的形状代替了两种不同的形状:三角形、矩形和圆形。我们将不同的形状存储在一个数组中,然后计算每个类别的总数。
using System;
namespace IsOperatorDemo
{
class Shape
{
public void ShowMe()
{
Console.WriteLine("Shape.ShowMe");
}
}
class Circle : Shape
{
public void Area()
{
Console.WriteLine("Circle.Area");
}
}
class Rectangle : Shape
{
public void Area()
{
Console.WriteLine("Rectangle.Area");
}
}
class Triangle : Shape
{
public void Area()
{
Console.WriteLine("Triangle.Area");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***is operator demo***\n");
//Initialization-all counts are 0 at this point
int noOfCircle = 0, noOfRect = 0, noOfTriangle = 0;
//Creating 2 different circle object
Circle circleOb1 = new Circle();
Circle circleOb2 = new Circle();
//Creating 3 different rectangle object
Rectangle rectOb1 = new Rectangle();
Rectangle rectOb2 = new Rectangle();
Rectangle rectOb3 = new Rectangle();
//Creating 1 Triangle object
Triangle triOb1 = new Triangle();
Shape[] shapes = { circleOb1, rectOb1,circleOb2, rectOb2,triOb1,rectOb3 };
for(int i=0;i<shapes.Length;i++)
{
if( shapes[i] is Circle)
{
noOfCircle++;
}
else if (shapes[i] is Rectangle)
{
noOfRect++;
}
else
{
noOfTriangle++;
}
}
Console.WriteLine("No of Circles in shapes array is {0}", noOfCircle);
Console.WriteLine("No of Rectangles in shapes array is {0}", noOfRect);
Console.WriteLine("No of Triangle in shapes array is {0}", noOfTriangle);
Console.ReadKey();
}
}
}
输出
分析
看看这些代码段:
我们不是盲目地处理形状数组中的对象。一旦我们浏览了每一个,我们就可以控制它们是圆形、矩形还是三角形。如果这不是我们想要的类型,“如果条件”将为假,我们可以避免运行时意外。
因此,您总是可以测试这样一个简单的事实,即所有的圆都是形状,但是下面几行代码的情况正好相反:
Console.WriteLine("*****");
Shape s = new Shape();
Circle c = new Circle();
Console.WriteLine("Any Circle is a Shape?{0}", c is Shape);//True
Console.WriteLine("Any Shape is a Circle? {0}", (s is Circle));//False
输出
演示 6:使用“as”关键字
现在通过一个类似的程序。但是这一次,我们使用了 as 关键字,而不是 is 关键字。
using System;
namespace asOperatorDemo
{
class Program
{
class Shape
{
public void ShowMe()
{
Console.WriteLine("Shape.ShowMe");
}
}
class Circle : Shape
{
public void Area()
{
Console.WriteLine("Circle.Area");
}
}
class Rectangle : Shape
{
public void Area()
{
Console.WriteLine("Rectangle.Area");
}
}
static void Main(string[] args)
{
Console.WriteLine("***as operator demo***\n");
Shape shapeOb = new Shape();
Circle circleOb = new Circle();
Rectangle rectOb = new Rectangle();
circleOb = shapeOb as Circle; //no exception
if( circleOb!=null)
{
circleOb.ShowMe();
}
else
{
Console.WriteLine("'shapeOb as Circle' is prodcuing null ");
}
shapeOb = rectOb as Shape;
if (shapeOb != null)
{
Console.WriteLine("'rectOb as Shape' is NOT prodcuing null ");
shapeOb.ShowMe();
}
else
{
Console.WriteLine(" shapeOb as Circle is prodcuing null ");
}
Console.ReadKey();
}
}
}
输出
分析
如果操作是可强制转换的,则运算符 as 会自动执行转换;否则,它返回 null。
Points to Remember
as 运算符将成功执行向下转换操作,否则将计算为 null(如果向下转换失败)。因此,我们在前面的程序中进行空值检查的方法在 C# 编程中非常常见。
通过值传递值类型与通过引用传递值类型(使用 ref 与 out)
老师继续说:我们已经知道值类型变量直接包含其数据,引用类型变量包含对其数据的引用。
因此,通过值向方法传递值类型变量意味着我们实际上是在向方法传递一个副本。因此,如果该方法对复制的参数进行任何更改,它对原始数据没有影响。如果您希望 caller 方法所做的更改反映回原始数据,您需要用 ref 关键字或 out 关键字通过引用传递它。
让我们看一下这个程序。
演示 7:按值传递值类型
using System;
namespace PassingValueTypeByValue
{
class Program
{
static void Change(int x)
{
x = x * 2;
Console.WriteLine("Inside Change(), myVariable is {0}", x);//50
}
static void Main(string[] args)
{
Console.WriteLine("***Passing Value Type by Value-Demo***");
int myVariable = 25;
Change(myVariable);
Console.WriteLine("Inside Main(), myVariable={0}", myVariable);//25
Console.ReadKey();
}
}
}
输出
分析
这里我们在 change()方法中做了一个改变。但是这个改变的值没有反映在 Change()方法之外,因为在 Main()方法内部,我们看到 myVariable 的值是 25。这是因为实际上所做的更改是在 myVariable 的副本上(或者换句话说,影响只是在局部变量 x 上)。
ref 参数与 out 参数
老师继续说:现在考虑同样的程序,只做了一点小小的修改,如下所示(用箭头突出显示)。
演示 8
输出
分析
这里我们在 change()方法中做了一个改变。并且这个改变的值反映在 Change()方法之外。这里 ref 关键字已经完成了这个任务。对于 ref int x,我们不是指整数参数,而是指对 int(在本例中是 myVariable)的引用。
Points to Remember
我们需要在将 myVariable 传递给 ChangeMe()方法之前对其进行初始化;否则,我们会遇到编译错误。
老师继续说:现在考虑一个非常相似的程序。这次我们将展示 out 参数的用法。
演示 9:使用“out”参数
using System;
namespace PassingValueTypeUsingOut
{
class Program
{
static void Change(out int x)
{
x = 25;
x = x * 2;
Console.WriteLine("Inside Change(), myVariable is {0}", x);//50
}
static void Main(string[] args)
{
Console.WriteLine("***Passing Value Type by Reference using out-Demo***");
//Need to be initialized, if you use 'ref'
int myVariable;
Change(out myVariable);
Console.WriteLine("Inside Main(), myVariable={0}", myVariable);//50
Console.ReadKey();
}
}
输出
分析
这里我们取得了类似的结果(和 ref 一样,变化在 Main()和 ChangeMe()中都有体现)。但是如果你仔细观察,你会发现在这个程序中,我们并没有在将 myVariable 传递给 ChangeMe()方法之前对它进行初始化。对于 out 参数,这种初始化不是强制性的(但是对于 ref,这是必须的)。你还会注意到,我们需要在它从函数中出来之前赋值;对于 out 参数,它是必需的。
Points to Remember
对于 out 参数,这种初始化不是强制性的(但是对于 ref,这是必须的)。另一方面,我们需要在它从函数中出来之前给它赋值。
恶作剧
假设我们修改了 Change()方法,如下所示:
static void Change(out int x)
{
//x = 25;
int y = 10;
x = x * 2;
Console.WriteLine("Inside Change(), myVariable is {0}", x);
}
前面的代码可以编译吗?
回答
不。我们会得到一个编译错误。
分析
正如我们前面提到的,在我们离开方法 Change()之前,我们需要给 x 赋值。在这种情况下,你给另一个变量 y 赋值(10 ),这个变量在这里没有用。
学生问:
主席先生,默认情况下,参数是如何传递的:通过值还是通过引用?
老师说:它们是按价值传递的。
学生问:
先生,我们可以将引用类型作为值传递吗(反之亦然)?
老师说:是的。在 PassingValueTypeUsingRef(演示 8)示例中,我们传递了一个带有 Ref 关键字的值类型。现在考虑一个相反的情况。这里我们将引用类型(字符串)作为值类型传递。
演示 10:将引用类型作为值传递
using System;
namespace PassReferenceTypeUsingValue
{
class Program
{
static void CheckMe(string s)
{
s = "World";
Console.WriteLine("Inside CheckMe(), the string value is {0}", s);//World
}
static void Main(string[] args)
{
string s = "Hello";
Console.WriteLine("Inside Main(), Initially the string value is {0}", s);//Hello
CheckMe(s);
Console.WriteLine("Inside Main(), finally
the string value is {0}", s);//Hello
Console.ReadKey();
}
}
}
输出
我们可以观察到 CheckMe()所做的更改没有反映到 Main()中。
学生问:
先生,这样看来,一旦我们将引用类型作为值传递,我们就不能修改该值了。这种理解正确吗?
老师说:一点也不。这取决于你如何使用它;例如,考虑下面的程序。这里我们使用这种机制来改变数组的前两个元素。
演示 11:数组元素的案例研究
using System;
namespace PassReferenceTypeUsingValueEx2
{
class Program
{
static void CheckMe(int[] arr)
{
arr[0] = 15;
arr[1] = 25;
arr = new int[3] { 100, 200,300};
Console.WriteLine("********");
Console.WriteLine("Inside CheckMe(),arr[0]={0}", arr[0]);//100
Console.WriteLine("Inside CheckMe(),arr[1]={0}", arr[1]);//200
Console.WriteLine("Inside CheckMe(),arr[2]={0}", arr[2]);//300
Console.WriteLine("********");
}
static void Main(string[] args)
{
Console.WriteLine("***Passing reference Type by value.Ex-2***");
int[] myArray= { 1, 2, 3 };
Console.WriteLine("At the beginning,myArray[0]={0}", myArray[0]);//1
Console.WriteLine("At the beginning,myArray[1]={0}", myArray[1]);//2
Console.WriteLine("At the beginning,myArray[2]={0}", myArray[2]);//3
CheckMe(myArray);
Console.WriteLine("At the end,myArray[0]={0}", myArray[0]);//15
Console.WriteLine("At the end,myArray[1]={0}", myArray[1]);//25
Console.WriteLine("At the end,myArray[2]={0}", myArray[2]);//3
Console.ReadKey();
}
}
}
输出
分析
在 CheckMe()方法中,一旦我们创建了一个新数组,引用数组就开始指向一个新数组。因此,在这之后,在 Main()中创建的原始数组没有任何变化。实际上,在那次操作之后,我们处理的是两个不同的数组。
恶作剧
代码会编译吗?
class Program
{
static void ChangeMe( int x)
{
x = 5;
Console.WriteLine("Inside Change() the value is {0}", x);
}
static void ChangeMe(out int x)
{
//out parameter must be assigned before it leaves the function
x = 5;
Console.WriteLine("Inside ChangeMe() the value is {0}", x);
}
static void ChangeMe(ref int x)
{
x = 5;
Console.WriteLine("Inside ChangeMe() the value is {0}", x);
}
static void Main(string[] args)
{
Console.WriteLine("***ref and out Comparison-Demo***");
//for ref, the variable need to be initialized
int myVariable3=25;
Console.WriteLine("Inside Main(),before call, the value is {0}", myVariable3);
ChangeMe( myVariable3);
ChangeMe(ref myVariable3);
ChangeMe(out myVariable3);
Console.WriteLine("Inside Main(),after call, the value is {0}", myVariable3);
}
输出
分析
我们可以使用 ChangeMe(out myVariable3)或 ChangeMe(ref myVariable3)和 ChangeMe(myVariable3)。他们不允许在一起。如果您注释掉 ChangeMe(out myVariable3)及其相关调用,您会收到如下输出:
学生问:
C# 中一个方法(函数)可以返回多个值吗?
老师说:是的,它能。在这种情况下,我们很多人更喜欢 KeyValuePair。但是刚才我们已经学会了 out 的用法,它可以帮助我们实现一个类似的概念。考虑下面的程序。
演示 12:返回多个值的方法
class Program
{
static void RetunMultipleValues(int x, out double area, out double perimeter)
{
area = 3.14 * x * x;
perimeter = 2 * 3.14 * x;
}
static void Main(string[] args)
{
Console.WriteLine("***A method returning multiple values***");
int myVariable3 = 3;
double area=0.0,perimeter=0.0;
RetunMultipleValues(myVariable3, out area, out perimeter);
Console.WriteLine("Area of the circle is {0} sq. unit", area);
Console.WriteLine("Peremeter of the Cicle is {0} unit", perimeter);
}
}
输出
C# 类型的简单比较
老师说:C# 类型可以大致分为
- 值类型
- 参考类型
- 指针类型
- 泛型类型
让我们来探讨这一部分的前三个。第四种类型(即泛型)将在本书的第二部分讨论。所以,让我们从值类型和引用类型开始。
值类型和引用类型
值类型的一些示例是常见的内置数据类型(例如,int、double、char、bool 等)。)、枚举类型和用户定义的结构。(一个例外是 String,它是一种内置数据类型,也是一种引用类型。)
引用类型的一些例子是类(对象)、接口、数组和委托。
内置引用类型包括对象、动态和字符串。
这两种类型的根本区别在于它们在内存中的处理方式。
让我们从它们之间的关键区别开始。
| 值类型 | 参考类型 | | :-- | :-- | | 按照 MSDN 的说法,在自己的内存位置保存数据的数据类型是值类型。 | 另一方面,引用类型包含一个指向另一个实际包含数据的内存位置的指针。(你可以简单地认为它由两部分组成:一个对象和对该对象的引用。) | | 值类型的赋值总是导致实例的复制。 | 引用类型的赋值导致它只复制引用,而不是实际对象。 | | 常见的例子包括除字符串以外的内置数据类型(例如,int、double、char、bool 等)。),枚举类型,用户定义的结构。 | 常见的例子包括:类(对象)、接口、数组、委托和一个特殊的内置数据类型字符串(别名系统。字符串)。 | | 通常,值类型没有空值。 | 引用类型可以指向 null(即,它不指向任何对象)。 |学生问:
先生,在 C# 中如何检查 class 是引用类型,structure 是值类型?
老师说:考虑下面的程序和输出。
演示 13:值类型与引用类型
using System;
namespace ImportantComparison
{
struct MyStruct
{
public int i;
}
class MyClass
{
public int i;
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Test-valueTypes vs Reference Types***\n");
MyStruct struct1, struct2;
struct1=new MyStruct();
struct1.i = 1;
struct2 = struct1;
MyClass class1, class2;
class1= new MyClass();
class1.i = 2;
class2 = class1;
Console.WriteLine("struct1.i={0}", struct1.i);//1
Console.WriteLine("struct2.i={0}", struct2.i);//1
Console.WriteLine("class1.i={0}", class1.i);//2
Console.WriteLine("class2.i={0}", class2.i);//2
Console.WriteLine("***Making changes to strcut1.i(10) and class1.i (20)***");
struct1.i = 10;
class1.i = 20;
Console.WriteLine("***After the changes, values are :***");
Console.WriteLine("struct1.i={0}", struct1.i);//10
Console.WriteLine("struct2.i={0}", struct2.i);//1
Console.WriteLine("class1.i={0}", class1.i);//20
Console.WriteLine("class2.i={0}", class2.i);//20
Console.ReadKey();
}
}
}
输出
分析
我们可以看到,当我们在 class1 中进行更改时,class1 和 class2 这两个类对象都更新了它们的实例变量 I。但对于建筑来说,情况并非如此。struct2.i 保持旧值 1,即使 struct1.i 更改为 10。
When we wrote struct2 = struct1;
struct2 结构成为 struct1 的独立副本,具有自己单独的字段。
When we wrote class2 = class1;
我们正在复制指向同一个对象的引用。
学生问:
先生,什么时候我们应该选择值类型而不是引用类型?
老师说:
一般来说,栈可以比堆更有效地被使用。所以,数据结构的选择很重要。
在引用类型中,当我们的方法完成执行时,不会回收内存。为了回收内存,需要调用垃圾收集机制。它并不总是可靠和简单的。
学生问:
先生,什么时候我们应该选择引用类型而不是值类型?
老师说:
对于值类型,生存期是一个很大的问题。当一个方法完成它的执行时,内存被回收。
这些不适合跨不同类共享数据。
指针类型
在 C# 中,支持指针,但是在不安全的上下文中。我们需要用“不安全”关键字标记代码块。您还需要用/unsafe 选项编译代码。所以基本上,通过使用“不安全”标签,你可以用指针进行 C++风格的编码。目的是一样的:一个指针可以保存变量的地址,可以强制转换成其他指针类型(显然这些操作是不安全的)。
注意
- 最常见的指针运算符是*、&、和-->。
- 我们可以将以下任何类型视为指针类型:byte、sbyte、short、ushort、int、uint、long、ulong、float、double、decimal、bool、char、任何枚举类型、任何指针类型或仅具有非托管类型字段的用户定义的结构类型。
以下是一些基本的指针类型声明:
int *p
表示 p 是一个指向整数的指针int **p
意味着 p 是一个指向整数的指针char* p
表示 p 是一个指向字符的指针void* p
表示 p 是一个指向未知类型的指针(虽然它是允许的,但建议使用时要特别小心)
考虑下面的例子。
演示 14:指针类型
using System;
namespace UnsafeCodeEx1
{
class A
{
}
class Program
{
static unsafe void Main(string[] args)
{
int a = 25;
int* p;
p = &a;
Console.WriteLine("***Pointer Type Demo***");
Console.WriteLine("*p is containing:{0}", *p);
A obA = new A();
//Error:Cannot take the address of, get the size of, or declare a pointer to a managed type ('A')
//A* obB = obA;
Console.ReadKey();
}
}
}
输出
分析
在 Visual Studio 2017 中,您需要通过启用如下复选框来允许不安全的代码:
否则,您会遇到以下错误:
学生问:
先生,我们什么时候在 C# 的上下文中使用指针?
老师说:一个基本目的是与 C APIs 的互操作性。除此之外,有时我们可能希望访问托管堆边界之外的内存来处理一些关键问题。正如微软所说:“如果不能访问指针,那么与底层操作系统接口、访问内存映射设备或实现时间关键的算法就不可能或不切实际。”
Points to Remember
-
我们不能在指针类型和对象之间转换。指针不从对象继承。
-
为了在同一个地方声明多个指针,用底层类型写*号,比如
int* a, b, c; //ok
,但是如果我们像这样写,我们会遇到一个编译器错误。
int *a,*b,*c;//Error
-
稍后我们将学习垃圾收集,它基本上是对引用的操作。垃圾收集器可以在清理过程中收集对象引用,即使某些指针指向它们。这就是指针不能指向引用(或任何包含引用的结构)的原因。
常量与只读
老师继续:C# 支持两个特殊的关键字:const 和 readonly。共同之处在于,它们都试图阻止对一个字段的修改。尽管如此,他们还是有一些不同的特点。我们将通过一些程序段来验证这些。
Points to Remember
我们可以像声明变量一样声明常量,但关键是声明后不能更改。另一方面,我们可以在声明过程中或通过构造函数给 readonly 字段赋值。
要声明一个常量变量,我们需要在声明前加上关键字 const。我们必须记住常量是隐式静态的。
演示 15:使用“const”关键字
using System;
namespace ConstantsEx1
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Quiz : Experiment with a constructor***\n");
const int MYCONST = 100;
//Following line will raise error
MYCONST=90;//error
Console.WriteLine("MYCONST={0}", MYCONST);
Console.ReadKey();
}
}
}
输出
类似地,对于 readonly,我们将得到以下行的错误:
public readonly int myReadOnlyValue=105;
//Following line will raise error
myReadOnlyValue=110;//error
恶作剧
代码会编译吗?
Class ReadOnlyEx
{
public static readonly int staticReadOnlyValue;
static ReadOnlyEx()
{
staticReadOnlyValue = 25;
}
//Some other code e.g. Main Method() etc..
}
回答
是的。
恶作剧
代码会编译吗?
Class ReadOnlyEx
{
public readonly int nonStaticReadOnlyValue;
public ReadOnlyEx(int x)
{
nonStaticReadOnlyValue = x;
}
//Some other code e.g.Main method() etc..
}
回答
是的。
恶作剧
代码会编译吗?
Class ReadOnlyEx
{
public readonly int myReadOnlyValue=105;
public int TrytoIncreaseNonStaticReadOnly()
{
myReadOnlyValue++;
}
//Some other code e.g.Main method() etc..
}
回答
不。在这种情况下,您只能通过构造函数来更改值。(trytoiincreasenonstanticreadonly()不是此处的构造函数)
恶作剧
代码会编译吗?
public static const int MYCONST = 100;
回答
不。常量是隐式静态的。我们不允许在这里提到关键字 static。
恶作剧
输出会是什么?
class TestConstants
{
public const int MYCONST = 100;
}
class Program
{
static void Main(string[] args)
{
TestConstants tc = new TestConstants();
Console.WriteLine(" MYCONST is {0}", tc.MYCONST);
Console.ReadKey();
}
}
回答
我们将遇到编译时错误。我们已经提到常量是隐式静态的。因此,我们不能通过实例引用来访问它们。
我们应该在这里使用类名。因此,下面一行代码可以正常工作:
Console.WriteLine(" MYCONST is {0}", TestConstants.MYCONST);
学生问:
在程序中使用常量有什么好处?
老师说:它们容易阅读和修改。我们可以修改单个位置来反映整个程序的变化。否则,我们可能需要找出变量在程序中的每一次出现。这种方法很容易出错。
学生问:
什么时候我们应该选择 readonly 而不是 const?
老师说:当我们想要一个变量值时,它不应该被改变,但是这个值只有在运行时才知道;例如,我们可能需要在设置初始值之前做一些计算。
我们还会注意到,只读值可以是静态的,也可以是非静态的,而常量总是静态的。因此,一个类的不同实例可以有不同的值。“只读”的一个非常常见的用法是设置一种软件许可证。
Points to Remember
- 只读值可以是静态的,也可以是非静态的;而常数总是静态的。
- readonly 的一个非常常见的用途是设置一种软件许可。
摘要
本章涵盖了
- 隐式和显式转换的比较
- 装箱和取消装箱的比较
- 拳击和铸造的比较
- 上抛和下抛的比较
- 将 is 和用作关键字
- 通过值传递值类型与通过引用传递值类型
- ref 与 out 参数之间的比较
- 我们如何将引用类型作为值传递(反之亦然)?
- C# 中一个方法如何返回多个值?
- 值类型与引用类型
- 如何在 C# 中检查 class 是引用类型,structure 是值类型?
- 什么时候我们应该选择值类型而不是引用类型,反之亦然。
- 指针类型概述。
- const 和 readonly 之间的比较
九、C# 中 OOP 原则的快速回顾
老师开始讨论:欢迎来到 C# 面向对象编程的最后一部分。让我们回顾一下本书中已经介绍过的核心原则。
- 类别和对象
- 多态
- 抽象
- 包装
- 遗产
我们可以再加两个。
- 信息传递
- 动态绑定
恶作剧
您还记得 C# 的基本构件是如何涵盖这些主题的吗?
答案
- 类和对象:在整本书中,几乎在每个例子中,我们都使用了不同类型的类和对象。在静态类的例子中,我们没有创建对象。我们可以通过类名访问静态字段。
- 多态:涵盖了两种类型的多态。编译时多态通过方法重载(和操作符重载)来覆盖,运行时多态通过使用虚拟和重写关键字的方法重写技术来覆盖。
- 抽象:这个特性通过抽象类和接口进行了测试。
- 封装:除了访问修饰符,我们还使用了属性和索引器的概念。
- 继承:我们在两章中探讨了不同类型的继承。
- 消息传递:这个特性在多线程环境中很常见。但是我们可以在这一类中考虑运行时多态。
- 动态绑定:通过方法覆盖实例的运行时多态可以属于这一类。#
学生问:
先生,您能总结一下抽象和封装的区别吗?
老师说:将数据和代码包装成一个实体的过程称为封装。使用这种技术,我们可以防止任意和不安全的访问。我们使用了不同种类的访问修饰符以及带有 get 和 set 访问器的属性示例来实现封装的概念。
在抽象中,我们展示了基本的特性,但是对用户隐藏了详细的实现;例如,当我们用遥控器打开电视时,我们并不关心该设备的内部电路。只要按下按钮后图像从电视中出来,我们对这个设备绝对没问题。
您可以重新阅读第一章了解这些定义。
学生问:
一般来说,编译时多态和运行时多态哪个更快?
老师说:我表示,如果电话能够尽早解决,通常会更快。这就是为什么我们可以得出编译时绑定比运行时绑定(或多态)更快的结论——因为您预先知道要调用哪个方法。
学生问:
先生,你早些时候告诉我们,继承并不总是提供最好的解决办法。你能详细说明一下吗?
老师说:在某些情况下,作文可以提供更好的解决方案。但是要理解构成,你需要知道这些概念:
- 联合
- 聚合
关联可以是单向的也可以是双向的。当你看到这种 UML 图时,这意味着 ClassA 知道 ClassB,但反过来却不是这样。
下图显示了一个双向关联,因为这两个类彼此都认识。
考虑一个例子。在大学里,一个学生可以向多个老师学习,一个老师可以教多个学生。在这种关系中没有专门的所有权。所以,当我们在编程中用类和对象来表示它们时,我们可以说这两种对象都可以独立地创建和删除。
聚合是一种更强的关联类型。广泛代表如下。
考虑此类别中的一个示例。假设 X 教授提交辞职信,因为他决定加入一个新的机构。虽然 X 教授和他以前的机构没有对方也能生存,但最终 X 教授需要与机构中的一个部门建立联系。在编程世界中类似的情况下,我们会说系是这种关系的所有者,并且系里有教授。
同样,我们可以说汽车有座位,自行车有轮胎,等等。
注意
一个系有一个教授。这就是为什么关联关系也被称为“具有”关系。(这里一定要记住和继承的关键区别。继承与“是”的关系相关联。
组合是一种更强的聚合形式,这次我们有一个填充的菱形。
学院中的一个系不能离开学院而存在。学院只创建或关闭它的系。(你可以争辩说,如果根本没有系,学院就不可能存在,但是我们没有必要考虑这种类型的极端情况而使事情复杂化。换句话说,一个系的寿命完全取决于它的学院。这也被称为死亡关系,因为如果我们摧毁了学院,它的所有部门都会被自动摧毁。
为了展示构图的威力,让我们重温一下我们在第三章讨论过的钻石问题,然后分析下面的程序。
我们现有的代码
using System;
namespace CompositionEx1
{
class Parent
{
public virtual void Show()
{
Console.WriteLine("I am in Parent");
}
}
class Child1 : Parent
{
public override void Show()
{
Console.WriteLine("I am in Child-1");
}
}
class Child2 : Parent
{
public override void Show()
{
Console.WriteLine("I am in Child-2");
}
}
假设孙辈派生自 Child1 和 Child2,但它没有覆盖Show()
方法。
因此,我们预期的 UML 图可能如下所示:
我们现在有了歧义。孙儿将从哪个类调用Show()
方法——child 1 还是 Child2?为了消除这种类型的歧义,C# 不支持通过类的多重继承。这就是所谓的钻石问题。
所以,如果你这样编码:
class GrandChild : Child1, Child2//Error: Diamond Effect
{
public void Show()
{
Console.WriteLine("I am in Child-2");
}
}
C# 编译器会报错:
现在让我们看看如何用构图来处理这种情况。考虑下面的代码。
演示 1:处理前面问题的组合
using System;
namespace CompositionEx1
{
class Parent
{
public virtual void Show()
{
Console.WriteLine("I am in Parent");
}
}
class Child1 : Parent
{
public override void Show()
{
Console.WriteLine("I am in Child-1");
}
}
class Child2 : Parent
{
public override void Show()
{
Console.WriteLine("I am in Child-2");
}
}
//class GrandChild : Child1, Child2//Error: Diamond Effect
//{
//}
class Grandchild
{
Child1 ch1 = new Child1();
Child2 ch2 = new Child2();
public void ShowFromChild1()
{
ch1.Show();
}
public void ShowFromChild2()
{
ch2.Show();
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Composition
to handle the Diamond Problem***\n");
Grandchild gChild = new Grandchild();
gChild.ShowFromChild1();
gChild.ShowFromChild2();
Console.ReadKey();
}
}
}
输出
分析
您可以看到 Class1 和 Class2 都覆盖了它们的父方法Show()
。而孙儿类没有自己的Show()
方法。不过,我们可以通过孙子的对象调用那些特定于类的方法。
孙子女正在其体内创建来自 Class1 和 Class2 的对象。因此,如果我们的应用中不存在孙对象(例如,如果这些对象被垃圾收集),我们可以说系统中没有 Class1 或 Class2 对象。您还可以对用户设置一些限制,使他们不能直接在应用中创建 Class1 和 Class2 的对象;但是为了简单起见,我们忽略了这一部分。
演示 2:聚合示例
假设在前面的例子中,你想变得自由一点。您希望避免孙类和子类之间的死亡关系。您可以使用聚合来实现一个程序,其中其他类可以有效地使用对 Class1 和 Class2 的引用。
using System;
namespace AggregationEx1
{
class Parent
{
public virtual void Show()
{
Console.WriteLine("I am in Parent");
}
}
class Child1 : Parent
{
public override void Show()
{
Console.WriteLine("I am in Child-1");
}
}
class Child2 : Parent
{
public override void Show()
{
Console.WriteLine("I am in Child-2");
}
}
//class GrandChild : Child1, Child2//Error: Diamond Effect
//{
//}
class Grandchild
{
Child1 ch1;
Child2 ch2;
public Grandchild(Child1 ch1, Child2 ch2)
{
this.ch1 = ch1;
this.ch2 = ch2;
}
public void ShowFromChild1()
{
ch1.Show();
}
public void ShowFromChild2()
{
ch2.Show();
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Aggregation
to handle the Diamond Problem***\n");
Child1 child1 = new Child1();
Child2 child2 = new Child2();
Grandchild gChild = new Grandchild(child1,child2);
gChild.ShowFromChild1();
gChild.ShowFromChild2();
Console.ReadKey();
}
}
}
输出
分析
在这种情况下,Child1 和 Child2 对象可以在没有孙对象的情况下继续存在。这就是为什么我们说组合是一种更强的聚合形式。
注意
你意识到一般化、特殊化和实现。我们在应用中使用了这些概念。当我们的类扩展另一个类(即继承)时,我们使用泛化和特化的概念;例如,足球运动员是一种特殊的运动员。或者我们可以说足球运动员和篮球运动员都是运动员(泛化)。当我们的类实现一个接口时,我们使用了实现的概念。
学生问:
OOP 的挑战和缺点是什么?
老师说:许多专家认为,一般来说,面向对象程序的规模较大。由于更大的尺寸,我们可能需要更多的存储空间(但是现在,这些问题已经不重要了。)
一些开发人员发现面向对象编程风格的困难。他们可能仍然喜欢其他方法,比如结构化编程。因此,如果他们被迫在这样的环境中工作,生活对他们来说就变得艰难了。
此外,我们不能以面向对象的方式为每个现实世界的问题建模。然而,总的来说,我个人喜欢面向对象的编程风格,因为我相信它的优点大于缺点。
摘要
本章包括以下内容:
- 快速回顾本书中的核心 OOP 原则
- 如何区分抽象和封装
- 如何在我们的 C# 应用中实现组合和聚合的概念
- 与 OOP 相关的挑战和缺点
十、委托和事件
委托介绍
老师开始讨论:委托是 C# 编程中最重要的话题之一,它们使 C# 变得非常强大。委托是从 System.Delegate 派生的引用类型。它们类似于对象引用,但主要区别在于它们指向方法。我们可以通过使用委托来实现类型安全。因此,有时我们称它们为类型安全函数指针。
Points to Remember
- 一个对象引用指向一个特定类型的对象(例如,当我们写
A ob=new A();
时,我们的意思是ob
是对一个A
类型对象的引用);而委托指向特定类型的方法。 - 委托是一个知道如何调用与其关联的方法的对象。有了委托类型,您就知道它的实例调用哪种方法。
- 我们可以用委托编写插件方法。
假设我们有一个名为 Sum 的方法,带有两个整型参数,如下所示:
public static int Sum(int a, int b)
{
return a+b;
}
我们可以声明一个委托来指向Sum
方法,如下所示:
Mydel del = new Mydel(Sum);
但在此之前,我们需要定义Mydel
委托,它必须具有相同的签名,如下所示:
public delegate int Mydel(int x, int y);
对于Sum
方法和Mydel
委托,返回类型、参数及其对应的顺序是相同的。(记住方法名不是签名的一部分。)
注意,Mydel
与任何具有integer
返回类型(int)并接受两个整数参数的方法兼容,比如Sum
(int a,int b)方法。
正式的定义
委托是从 System 派生的引用类型。委托,它的实例用于调用具有匹配签名的方法。委托的一般定义是“委托”因此,我们可以说我们的委托必须用匹配的签名来表示方法。
下面的示例阐释了委托的用法。
案例 1 是一个不使用委托的方法调用。
案例 2 是一个调用委托的方法。
演示 1
using System;
namespace DelegateEx1
{
public delegate int Mydel(int x, int y);
class Program
{
public static int Sum(int a, int b) { return a + b; }
static void Main(string[] args)
{
Console.WriteLine("***Delegate Example -1: A simple delegate demo***");
int a = 25, b = 37;
//Case-1
Console.WriteLine("\n Calling Sum(..) method without using a delegate:");
Console.WriteLine("Sum of a and b is : {0}", Sum(a,b));
Mydel del = new Mydel(Sum);
Console.WriteLine("\n Using delegate now:");
//Case-2
Console.WriteLine("Calling Sum(..) method with the use of a delegate:");
//del(a,b) is shorthand for del.Invoke(a,b)
Console.WriteLine("Sum of a and b is: {0}", del(a, b));
//Console.WriteLine("Sum of a and b is: {0}", del.Invoke(a, b));
Console.ReadKey();
}
}
}
输出
缩短你的代码长度
我们可以缩短前面例子中的代码长度。
替换此行:
Mydel del = new Mydel(Sum);
使用这一行:
Mydel del = Sum;
请注意注释行。del(a,b)
是的简写
del.Invoke(a,b)
学生问:
假设在我们的程序中,Sum()方法是重载的。那么如果我们写 Mydel del=Sum,编译器可能会很困惑;。这是正确的吗?
老师说:一点也不。编译器可以绑定正确的重载方法。让我们用一个简单的例子来测试一下。(在前面的例子中,我们用委托测试了静态方法,所以这次我们有意使用非静态方法来涵盖这两种情况。)
演示 2
using System;
namespace Quiz1OnDelegate
{
public delegate int Mydel1(int x, int y);
public delegate int Mydel2(int x, int y,int z);
class A
{
//Overloaded non static Methods
public int Sum(int a, int b) { return a + b; }
public int Sum(int a, int b,int c) { return a + b+ c; }
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Quiz on Delegate***");
int a = 25, b = 37, c=100;
A obA1 = new A();
A obA2 = new A();
Mydel1 del1 = obA1.Sum;
Console.WriteLine("del1 is pointing Sum(int a,int b):");
//Pointing Sum(int a, int b)
Console.WriteLine("Sum of a and b is: {0}", del1(a, b));
Mydel2 del2 = obA1.Sum;//pointing Sum(int a, int b, int c)
Console.WriteLine("del2 is pointing Sum(int a,int b,int c):");
//Pointing Sum(int a, int b, int c)
Console.WriteLine("Sum of a, b and c is: {0}", del2(a, b,c));
//same as
//Console.WriteLine("Sum of a, b and c is: {0}", del2.Invoke(a, b, c));
Console.ReadKey();
}
}
}
输出
分析
编译器正在选择正确的重载方法。如果您错误地编写了这样的代码,您总是会收到一个编译时错误:
del1(a,b,c)
或者,如果你这样编码:
del2(a,b)
学生问:
为什么委托经常被称为类型安全函数指针?
老师说:当我们想把任何方法传递给委托时,委托签名和方法签名需要匹配。因此,它们通常被称为类型安全函数指针。
恶作剧
代码会编译吗?
using System;
namespace Test1_Delegate
{
public delegate int MultiDel(int a, int b);
class A : System.Delegate//Error
{ ..}
}
回答
不。我们不能从委托类派生。
多播代理/链接代理
老师继续说:当一个委托被用来封装一个匹配签名的多个方法时,我们称之为多播委托。这些委托是 System 的子类型。MulticastDelegate,它是 System.Delegate 的子类。
演示 3
using System;
namespace MulticastDelegateEx1
{
public delegate void MultiDel();
class Program
{
public static void show1() { Console.WriteLine("Program.Show1()"); }
public static void show2() { Console.WriteLine("Program.Show2()"); }
public static void show3() { Console.WriteLine("Program.Show3()"); }
static void Main(string[] args)
{
Console.WriteLine("***Example of a Multicast Delegate***");
MultiDel md = new MultiDel(show1);
md += show2;
md += show3;
md();
Console.ReadKey();
}
}
}
输出
学生问:
在前面的例子中,我们的多播委托的返回类型是 void。这背后的意图是什么?
老师说:一般来说,对于多播委托,我们在调用列表中有多个方法。但是,单个方法或委托调用只能返回单个值,因此多播委托类型应该具有 void 返回类型。如果您仍然想尝试一个非 void 返回类型,您将只从最后一个方法接收返回值。将调用前面的方法,但返回值将被丢弃。为了清楚地理解,请完成下面的测验。
恶作剧
假设我们已经编写了下面的程序,其中多播委托和与之相关的方法都有返回类型。程序会编译吗?
using System;
namespace MulticastDelegateEx2
{
public delegate int MultiDel(int a, int b);
class Program
{
public static int Sum(int a, int b)
{
Console.Write("Program.Sum->\t");
Console.WriteLine("Sum={0}", a+b);
return a + b;
}
public static int Difference(int a, int b)
{
Console.Write("Program.Difference->\t");
Console.WriteLine("Difference={0}", a - b);
return a - b;
}
public static int Multiply(int a, int b)
{
Console.Write("Program.Multiply->\t");
Console.WriteLine("Multiplication={0}", a * b);
return a * b;
}
static void Main(string[] args)
{
Console.WriteLine("***Testing a Multicast Delegate***");
MultiDel md = new MultiDel(Sum);
md += Difference;
md += Multiply;
int c = md(10, 5);
Console.WriteLine("Analyzing the value of c");
Console.WriteLine("c={0}", c);
Console.ReadKey();
}
}
}
输出
是的,程序将会编译,输出如下:
分析
注意c
的值。为了编译和运行,多播委托不需要 void 返回类型。但是,如果我们有这些方法的返回类型,并且我们编写了这样的代码,那么我们将从调用/调用链中最后一个被调用的方法中获取值。在这两个值之间的所有其他值都将被丢弃,但不会对此发出警报。因此,建议您试验 void 返回类型的多播委托。
学生问:
因此,即使我们对多播委托使用 nonvoid 返回类型,我们也不会看到任何编译错误。这种理解正确吗?
老师说:是的。在这种情况下,您将只接收最后一个方法的返回值。所以,只要想想这对你是否有意义。
学生问:
我们可以使用委托来定义回调方法吗?
是的。这是使用委托的主要目的之一。
学生问:
多播委托的调用列表是什么?
老师说:多播代理维护一个代理的链表。这个列表称为调用列表,由一个或多个元素组成。当我们调用多播委托时,调用列表中的委托按照它们出现的顺序被同步调用。如果在执行过程中出现任何错误,它将抛出一个异常。
委托中的协变和逆变
当我们实例化一个委托时,我们可以给它分配一个比“最初指定的返回类型”具有“更多派生的返回类型”的方法从 C# 2.0 开始,这种支持就可用了。另一方面,逆变允许方法的参数类型比委托类型派生得少。协方差的概念从 C#1.0 开始就支持数组,所以我们可以这样写:
Console.WriteLine("***Covariance in arrays(C#1.0 onwards)***");
//ok, but not type safe
object[] myObjArray = new string[5];
但是这不是类型安全的,因为这种行
myObjArray[0] = 10;//runtime error
会遇到运行时错误。
委托/方法组方差中的协方差
从 C# 2.0 开始,委托就支持协变和逆变。对泛型类型参数、泛型接口和泛型委托的支持始于 C#4.0。到目前为止,我还没有讨论过泛型类型。所以,这一节处理非泛型委托,从协方差开始。
演示 4
using System;
namespace CovarianceWithDelegatesEx1
{
class Vehicle
{
public Vehicle ShowVehicle()
{
Vehicle myVehicle = new Vehicle();
Console.WriteLine(" A Vehicle created");
return myVehicle;
}
}
class Bus:Vehicle
{
public Bus ShowBus()
{
Bus myBus = new Bus();
Console.WriteLine(" A Bus created");
return myBus;
}
}
class Program
{
public delegate Vehicle ShowVehicleTypeDelegate();
static void Main(string[] args)
{
Vehicle vehicle1 = new Vehicle();
Bus bus1 = new Bus();
Console.WriteLine("***Covariance in delegates(C# 2.0 onwards)***");
ShowVehicleTypeDelegate del1 = vehicle1.ShowVehicle;
del1();
//Note that it is expecting a Vehicle(i.e. a basetype) but received a Bus(subtype)
//Still this is allowed through Covariance
ShowVehicleTypeDelegate del2 = bus1.ShowBus;
del2();
Console.ReadKey();
}
}
}
输出
分析
在前面的程序中,我们可以看到编译器没有抱怨这一行:
ShowVehicleTypeDelegate del2 = bus1.ShowBus;
尽管我们的委托返回类型是 Vehicle,但是它的 del2 对象接收了一个派生类型“Bus”对象。
委托的矛盾
逆变与参数有关。假设委托可以指向接受派生类型参数的方法。在 contravariance 的帮助下,我们可以使用同一个委托指向一个接受基类型参数的方法。
演示 5
using System;
namespace ContravariancewithDelegatesEx1
{
class Vehicle
{
public void ShowVehicle(Vehicle myV)
{
Console.WriteLine(" Vehicle.ShowVehicle");
}
}
class Bus : Vehicle
{
public void ShowBus(Bus myB)
{
Console.WriteLine("Bus.ShowBus");
}
}
class Program
{
public delegate void TakingDerivedTypeParameterDelegate(Bus v);
static void Main(string[] args)
{
Vehicle vehicle1 = new Vehicle();//ok
Bus bus1 = new Bus();//ok
Console.WriteLine("***Exploring Contravariance
with C# delegates***");
//General case
TakingDerivedTypeParameterDelegate del1 = bus1.ShowBus;
del1(bus1);
//Special case:
//Contravariance
:
/*Note that the delegate expected a method that accepts a bus(derived) object parameter but still it can point to the method that accepts vehicle(base) object parameter*/
TakingDerivedTypeParameterDelegate del2 = vehicle1.ShowVehicle;
del2(bus1);
//Additional note:you cannot pass vehicle object here
//del2(vehicle1);//error
Console.ReadKey();
}
}
}
输出
分析
浏览程序和支持的注释行,以便更好地理解代码。从前面的例子中我们可以看到,我们的委托TakingDerivedTypeParameterDelegate
期望一个接受总线(派生的)对象参数的方法,然而它可以指向一个接受车辆作为(基本)对象参数的方法。
事件
老师说:事件是用来通知或表示一个物体的状态发生了变化。该信息对于该对象的客户端非常有用(例如,GUI 应用中的鼠标点击或按键是非常常见的事件示例)。
在现实世界中,考虑一个社交媒体平台,比如脸书。每当我们更新任何关于脸书的信息,我们的朋友都会立即得到通知。(这是一个非常常见的观察者设计模式的例子)。因此,您可以假设当您对脸书页面进行一些更改时,内部会触发一些事件,以便您的朋友可以获得这些更新。只有那些已经在我们的好友列表中的人(即,我们已经接受他们为我们的好友)才会收到这些更新。在编程术语中,我们说这些人注册在我们的好友列表中。如果有人不想获得更新,他/她可以简单地从好友列表中注销。因此,术语“注册和取消注册”与事件相关联。
在我们前进之前,我们必须记住以下几点:
- 事件与委托相关联。要理解事件,首先要学习委托。当一个事件发生时,它的客户给它的委托被调用。
- 英寸 NET 中,事件被实现为多播委托。
- 这里遵循发布者-订阅者模型。发布者(或广播者)发布通知(或信息),订户接收该通知。但是用户可以自由决定何时开始监听,何时停止监听(用编程术语来说,就是何时注册,何时注销)。
- Publisher 是包含委托的类型。订阅者通过在发布者的委托上使用+=来注册自己,并通过在该委托上使用-=来注销自己。因此,当我们将+=或-=应用于一个事件时,它们具有特殊的含义(换句话说,在这些情况下,它们不是赋值的快捷方式)。
- 用户之间不说话。实际上,这些是支持事件架构的关键目标:
- 订户不能相互通信。
- 我们可以构建一个松散耦合的系统。
- 如果我们使用 Visual Studio IDE,当我们处理事件时,它使我们的生活变得极其简单。但是我相信这些概念是 C# 的核心,所以最好从基础开始学习。
- 那个。NET framework 提供了一个支持标准事件设计模式的泛型委托,如下所示:
public delegate void EventHandler<TEventArgs>(object sendersource, TEventArgs e) where TEventArgs : EventArgs;
直到现在,你还没有学习 C# 泛型。为了支持向后兼容性,中的大多数事件。NET framework 遵循我们在这里使用的非泛型自定义委托模式。
在 C# 中实现简单事件的步骤
(这里我们将尝试遵循最广泛接受的命名约定。)
步骤 1:创建发布者类
-
1.1.创建代理人。(首先,为您的活动选择一个名称,比如说 JobDone。然后创建一个名为 JobDoneEventHandler 的委托。
-
1.2.基于委托创建事件(使用 event 关键字)。
-
1.3.引发事件。(标准模式要求该方法应该用 protected virtual 标记。此外,该名称必须与事件名称相匹配,并以 On 为前缀)。
步骤 2:创建订户类。
-
2.1 .编写事件处理程序方法。按照约定,事件处理程序方法的名称以 On 开头。
让我们看一下这个程序。
演示 6
using System;
namespace EventEx1
{
//Step1-Create a publisher
class Publisher
{
//Step1.1-Create a delegate.Delegate name should be //"yourEventName"+EventHandler
public delegate void JobDoneEventHandler(object sender, EventArgs args);
//Step1.2-Create the event based on the delgate
public event JobDoneEventHandler JobDone;
public void ProcessOneJob()
{
Console.WriteLine("Publisher:One Job is processed");
//Step1.3-Raise the event
OnJobDone();
}
/*The standard pattern requires that the method should be tagged with protected
virtual. Also the name must match name of the event and it will be prefixed with "On".*/
protected
virtual void OnJobDone()
{
if (JobDone != null)
JobDone(this, EventArgs.Empty);
}
}
//Step2-Create a subscriber
class Subscriber
{
//Handling the event
public void OnJobDoneEventHandler(object sender, EventArgs args)
{
Console.WriteLine("Subscriber is notified");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***A simple event demo***");
Publisher sender = new Publisher();
Subscriber receiver = new Subscriber();
sender.JobDone += receiver.OnJobDoneEventHandler;
sender.ProcessOneJob();
Console.ReadKey();
}
}
}
输出
学生问:
先生,我们可以为一个事件订阅多个事件处理程序吗?
老师说:是的。在 C# 中,事件被实现为多播委托,因此我们可以将多个事件处理程序关联到一个事件。假设我们有两个订阅者:Subscriber1 和 Subscriber2,他们都希望从发布者那里获得通知。以下代码将正常工作:
Points to Remember
在现实世界的编码中,您必须小心这些订阅(例如,在您的应用中,您只通过事件进行注册,然后在一段时间后,您会观察到内存泄漏这一副作用。因此,您的应用会很慢(可能会崩溃)。如果您没有将取消订阅操作放在适当的位置,垃圾收集器将无法回忆起这些记忆。
传递带有事件参数的数据
如果你再看一下前面的程序,你会发现我们没有用事件参数传递任何特定的东西。
在现实编程中,我们需要传递比 EventArgs 更多的信息。空(或 null)。在这些情况下,我们需要遵循以下步骤:
- 创建 System.EventArgs 的子类。
- 用事件封装预期数据。在下面的例子中,我们使用了一个属性。
- 创建此类的一个实例,并将其与事件一起传递。
为了更好地演示,我稍微修改了前面的程序。
演示 7
using System;
namespace EventEx2
{
//Step-a. Create a subclass of System.EventArgs
public class JobNoEventArgs : EventArgs
{
//Step-b.Encapsulate your intended data with the event. In the below example, we have used a property.
private int jobNo;
public int JobNo
{
get
{
return jobNo;
}
set
{
JobNo = value;
}
}
public JobNoEventArgs(int jobNo)
{
this.jobNo = jobNo;
}
}
//Step1-Create a publisher
class Publisher
{
//Step1.1-Create a delegate.Delegate name should be "yourEventName"+EventHandler
//public delegate void JobDoneEventHandler(object sender, EventArgs args);
public delegate void JobDoneEventHandler(object sender, JobNoEventArgs args);
//Step1.2-Create the event based on the delgate
public event JobDoneEventHandler JobDone;
public void ProcessOneJob()
{
Console.WriteLine("Publisher:One Job is processed");
//Step1.3-Raise the event
OnJobDone();
}
/*The standard pattern requires that the method should be tagged with protected
virtual.
Also the name must match name of the event and it will be prefixed with "On".*/
protected
virtual void OnJobDone()
{
if (JobDone != null)
//Step-c. Lastly create an instance of the event generator class and pass it with the event.
JobDone(this,new JobNoEventArgs(1));
}
}
//Step2-Create a subscriber
class Subscriber
{
//Handling the event
public void OnJobDoneEventHandler(object sender, JobNoEventArgs args)
{
Console.WriteLine("Subscriber is notified.Number of job processed is :{0}",args.JobNo);
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** Event example 2:Passing data with events***");
Publisher sender = new Publisher();
Subscriber receiver = new Subscriber();
sender.JobDone += receiver.OnJobDoneEventHandler;
sender.ProcessOneJob();
Console.ReadKey();
}
}
}
输出
分析
现在我们可以看到,通过遵循前面的机制,我们可以在引发事件时获得额外的信息(处理的作业数)。
事件访问器
回到我们关于事件的第一个程序(EventEx1),在这里我们将事件声明为
public event JobDoneEventHandler JobDone;
编译器使用私有委托字段对此进行转换,并提供两个事件访问器:add 和 remove。
以下代码将产生等效的行为:
//public event JobDoneEventHandler JobDone;
#region custom event accessors
private JobDoneEventHandler _JobDone;
public event JobDoneEventHandler JobDone
{
add
{
_JobDone += value;
}
remove
{
_JobDone -= value;
}
}
#endregion
如果你在这个程序中使用这些代码,你需要修改我们的OnJobDone()
方法,就像这样:
如果你想证实我们的说法,你可以简单地参考 IL 代码。
从 IL 代码中,我们可以看到 add 和 remove 部分被编译成add_<EventName>
和remove_<EventName>
。
如果编译器为我们做了所有的事情,那么我们为什么需要为这些细节而烦恼呢?简单的答案是
- 我们自己定义这些访问器来进行额外的控制(例如,我们可能想要进行一些特殊类型的验证,或者我们可能想要记录更多的信息,等等)。)
- 有时我们需要显式地实现一个接口,而这个接口可能包含一个或多个事件。
现在稍微修改一下我们的 EventEx2 程序。在这种情况下,我们使用自定义访问器并记录一些附加信息。让我们看看下面的程序和输出。
演示 8
using System;
namespace EventAccessorsEx1
{
//Step-a. Create a subclass of System.EventArgs
public class JobNoEventArgs : EventArgs
{
//Step-b.Encapsulate your intended data with the event. In the below example, we have used a property.
private int jobNo;
public int JobNo
{
get
{
return jobNo;
}
set
{
JobNo = value;
}
}
public JobNoEventArgs(int jobNo)
{
this.jobNo = jobNo;
}
}
//Step1-Create a publisher
class Publisher
{
//Step1.1-Create a delegate.Delegate name should be "yourEventName"+EventHandler
//public delegate void JobDoneEventHandler(object sender, EventArgs args);
public delegate void JobDoneEventHandler(object sender, JobNoEventArgs args);
//Step1.2-Create the event based on the delgate
//public event JobDoneEventHandler JobDone;
#region custom event accessors
private JobDoneEventHandler _JobDone;
public event JobDoneEventHandler JobDone
{
add
{
Console.WriteLine("Inside add accessor-Entry");
_JobDone += value;
}
remove
{
_JobDone -= value;
Console.WriteLine("Unregister completed-Exit from remove accessor");
}
}
#endregion
public void ProcessOneJob()
{
Console.WriteLine("Publisher:One Job is processed");
//Step1.3-Raise the event
OnJobDone();
}
/*The standard pattern requires that the method should be tagged with protected
virtual.
* Also the name must match name of the event and it will be prefixed with "On".*/
protected
virtual void OnJobDone()
{
if (_JobDone != null)
//Step-c. Lastly create an instance of the event generator class and pass it with the event.
_JobDone(this, new JobNoEventArgs(1));
}
}
//Step2-Create a subscriber
class Subscriber
{
//Handling the event
public void OnJobDoneEventHandler(object sender, JobNoEventArgs args)
{
Console.WriteLine(" Subscriber is notified.Number of job processed is :{0}", args.JobNo);
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** Testing custom event accessors***");
Publisher sender = new Publisher();
Subscriber receiver = new Subscriber();
//Subscribe/Register
sender.JobDone += receiver.OnJobDoneEventHandler;
sender.ProcessOneJob();
//Unsubscribe/Unregister
sender.JobDone -= receiver.OnJobDoneEventHandler;
Console.ReadKey();
}
}
}
输出
当您应用自定义事件访问器时,建议您也实现锁定机制;也就是说,我们可以这样写:
一般来说,锁定操作开销很大。为了使我们的例子简单,我在这里忽略了这个建议。
学生问:
先生,什么类型的修饰语被允许用于事件?
老师说:既然我们已经使用了虚拟关键字,你可以猜测覆盖事件是允许的。事件也可以是抽象的、密封的或静态的。
摘要
本章涵盖了
- 委托及其重要性
- 如何在我们的程序中使用委托
- 为什么委托是类型安全的
- 多播代理
- 如何使用委托实现协变和逆变
- 事件以及如何使用它们
- 如何传递带有事件参数的数据
- 事件访问器以及如何使用它们
十一、匿名函数的灵活性
匿名方法和 Lamda 表达式
教师开始讨论:让我们回到我们的代表计划(DelegateEx1)。我在该程序中添加了几行代码来生成相同的输出。为了帮助你理解调用之间的差异,我保留了旧的东西。
注意额外的东西。这些额外的代码块可以帮助你更好地理解匿名方法和 lambda 表达式。C# 2.0 引入了匿名方法,C# 3.0 引入了 lambda 表达式。
顾名思义,没有名字的方法就是 C# 中的匿名方法。匿名方法的主要目标是快速完成一个动作,即使我们写的代码更少。它是一个可以用作委托参数的代码块。
类似地,lambda 表达式是一个没有名字的方法。它用于代替委托实例。编译器可以将这些表达式转换为委托实例或表达式树。(表达式树的讨论超出了本书的范围。)
在下面的演示中,添加了两个额外的代码块:一个用于匿名方法,一个用于 lambda 表达式。每一个都产生相同的输出。
演示 1
using System;
namespace LambdaExpressionEx1
{
public delegate int Mydel(int x, int y);
class Program
{
public static int Sum(int a, int b) { return a + b; }
static void Main(string[] args)
{
Console.WriteLine("*** Exploring Lambda Expression***");
//Without using delgates or lambda expression
int a = 25, b = 37;
Console.WriteLine("\n Calling Sum method without using a delegate:");
Console.WriteLine("Sum of a and b is : {0}", Sum(a, b));
//Using Delegate( Initialization with a named method)
Mydel del = new Mydel(Sum);
Console.WriteLine("\n Using delegate now:");
Console.WriteLine("Calling Sum method with the use of a delegate:");
Console.WriteLine("Sum of a and b is: {0}", del(a, b));
//Using Anonymous method(C# 2.0 onwards)
Mydel del2 = delegate (int x, int y) { return x + y; };
Console.WriteLine("\n Using Anonymous method now:");
Console.WriteLine("Calling Sum method with the use of an anonymous method
:");
Console.WriteLine("Sum of a and b is: {0}", del2(a, b));
//Using Lambda expression(C# 3.0 onwards)
Console.WriteLine("\n Using Lambda Expresson now:");
Mydel sumOfTwoIntegers = (x1, y1) => x1 + y1;
Console.WriteLine("Sum of a and b is: {0}", sumOfTwoIntegers(a, b));
Console.ReadKey();
}
}
}
输出
分析
以下是 lambda 表达式的主要特征:
- 它是一个匿名方法(或未命名的方法),而不是委托实例。
- 它可以包含创建委托或表达式树的表达式或语句(LINQ 查询和表达式树超出了本书的范围)。
请注意,我们有以下委托:
public delegate int Mydel(int x, int y);
我们已经指定并调用了一个 lambda 表达式
(x1, y1) => x1 + y1
因此,您可以看到 lambda 表达式的每个参数对应于委托参数(本例中为 x1 到 x,y1 到 y ),表达式的类型(本例中 x+y 为 int)对应于返回委托的类型。
- Lambda 运算符
=>
(读作 goes to)用于 lambda 表达式中。它具有正确的结合性,其优先级与赋值(=
)运算符相同。 - 输入参数在 lambda 运算符的左侧指定,表达式或语句在 lambda 运算符的右侧指定。
- 如果我们的 lambda 表达式只有一个参数,我们可以省略括号;例如,我们可以这样写来计算一个数的平方:
x=>x*x
Points to Remember
- C# 2.0 引入了匿名方法,C# 3.0 引入了 lambda 表达式,它们很相似,但是 lambda 表达式更简洁,专家建议如果你的应用是面向。NET Framework 版或更高版本,一般来说你应该更喜欢 lambda 表达式而不是匿名方法。这两个特性在 C# 中被称为匿名函数。
- 您应该避免在匿名方法体中使用不安全的代码和跳转语句,如 break、goto 和 continue。
学生问:
那么我们应该总是尝试使用匿名方法,因为它更快,代码更小。这是正确的吗?
老师说:不。看看与匿名方法相关的限制。此外,如果您需要多次编写类似的功能,您必须避免匿名方法。
函数、动作和谓词委托
作者注:现在我们将快速介绍三个重要的泛型委托。我把这个主题放在这里是因为它们很容易与 lambda 表达式和匿名方法联系起来。我们将很快讨论泛型。所以,如果你已经对泛型编程有了一个基本的概念,你可以继续;否则,一旦你对他们有了更多的了解,请回来。
Func 代表
Func 委托有多种形式。它们可以接受 0 到 16 个输入参数,但总是有一个返回类型。考虑以下方法:
private static string ShowStudent(string name, int rollNo)
{
return string.Format("Student Name is :{0} and Roll Number is :{1}", name, rollNo);
}
要使用委托调用此方法,我们需要遵循以下步骤:
第一步。像这样定义委托:
public delegate string Mydel(string n, int r);
第二步。像这样用委托附加方法:
Mydel myDelOb = new Mydel (ShowStudent);
或者简而言之,
Mydel myDelOb = ShowStudent;
第三步。现在,您可以像这样调用该方法:
myDelOb.Invoke ("Jon", 5);
或者仅仅是
myDelOb ("Jon", 5);
但是在这种情况下,我们可以使用现成的/内置的委托函数使代码更简单、更短,如下所示:
Func<string, int, string> student = new Func<string, int, string>(ShowStudent);
Console.WriteLine(ShowStudent("Amit", 1));
因此,您可以预测这个 Func 委托很好地考虑了两种输入类型——string 和 int——以及返回类型 string。在 Visual Studio 中,如果您将光标移动到此处,可以看到最后一个参数被视为函数的返回类型,其他参数被视为输入类型。
学生问:
先生,我们有不同种类的方法,可以接受不同数量的输入参数。与前面的方法不同,我们如何在考虑多于或少于两个输入参数的函数中使用 Func?
老师说:Func 代表可以考虑 0 到 16 个输入参数。所以,我们可以使用这些形式中的任何一种:
Func<T, TResult>
Func<T1, T2, TResult>
Func<T1, T2, T3, TResult>
.....
Func<T1, T2, T3..., T15, T16, TResult>
动作代表
动作委托可以接受 1 到 16 个输入参数,但没有返回类型。因此,假设我们有一个SumOfThreeNumbers
方法,它有三个输入参数,其返回类型是 void,如下所示:
private static void SumOfThreeNumbers(int i1, int i2, int i3)
{
int sum = i1 + i2 + i3;
Console.WriteLine("Sum of {0},{1} and {2} is: {3}", i1, i2, i3, sum);
}
我们可以使用动作委托来获得三个整数的和,如下所示:
Action<int, int, int> sum = new Action<int, int, int>(SumOfThreeNumbers);
sum(10, 3, 7);
谓词委托
谓词委托用于评估某些东西。例如,一个方法定义了一些标准,我们需要检查一个对象是否满足这些标准。考虑以下方法:
private static bool GreaterThan100(int myInt)
{
return myInt > 100 ? true : false;
}
我们可以看到这个方法评估一个输入是否大于 100。我们可以使用谓词委托来执行相同的测试,如下所示:
Predicate<int> isGreater = new Predicate<int>(GreaterThan100);
Console.WriteLine("125 is greater than 100? {0}", isGreater(125));
Console.WriteLine("60 is greater than 100? {0}", isGreater(60));
下面的程序用一个简单的程序演示了所有这些概念。
演示 2
using System;
namespace Test1_FuncVsActionVsPredicate
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Testing Func vs Action vs Predicate***");
//Func
Console.WriteLine("<---Using Func--->");
Func<string, int, string> student = new Func<string, int, string>(ShowStudent);
Console.WriteLine(ShowStudent("Amit", 1));
Console.WriteLine(ShowStudent("Sumit", 2));
//Action
Console.WriteLine("<---Using Action--->");
Action<int, int, int> sum = new Action<int, int, int>(SumOfThreeNumbers);
sum(10, 3, 7);
sum(5, 10, 15);
//Predicate
Console.WriteLine("<---Using Predicate--->");
Predicate<int> isGreater = new Predicate<int>(GreaterThan100);
Console.WriteLine("125 is greater than 100? {0}", isGreater(125));
Console.WriteLine("60 is greater than 100? {0}", isGreater(60));
Console.ReadKey();
}
private static string ShowStudent(string name, int rollNo)
{
return string.Format("Student Name is :{0} and Roll Number is :{1}", name, rollNo);
}
private static void SumOfThreeNumbers(int i1, int i2, int i3)
{
int sum = i1 + i2 + i3;
Console.WriteLine("Sum of {0},{1} and {2} is: {3}", i1, i2, i3, sum);
}
private static bool GreaterThan100(int myInt)
{
return myInt > 100 ? true : false;
}
}
}
输出
摘要
本章讨论了以下内容:
- 匿名方法
- λ表达式
- 函数、动作和谓词委托
- 如何在 C# 应用中有效地使用这些概念
十二、泛型
泛型程序和非泛型程序的比较
教师开始讨论:泛型是 C# 的关键概念之一。它们出现在 C# 2.0 中,从那以后,它们扩展了新的特性。
为了理解泛型的强大,我们将从一个非泛型程序开始,然后编写一个泛型程序。稍后,我们将进行比较分析,然后我们将尝试发现泛型编程的优势。考虑下面的程序和输出。
演示 1:非泛型程序
using System;
namespace NonGenericEx
{
class NonGenericEx
{
public int ShowInteger(int i)
{
return i;
}
public string ShowString(string s1)
{
return s1;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***A non-generic program example***");
NonGenericEx nonGenericOb = new NonGenericEx();
Console.WriteLine("ShowInteger returns :{0}", nonGenericOb.ShowInteger(25));
Console.WriteLine("ShowString returns :{0}", nonGenericOb.ShowString("Non Generic method called"));
Console.ReadKey();
}
}
}
输出
现在让我们试着介绍一个泛型程序。在我们开始之前,这些是关键点:
- 尖括号
<>
用于创建通用类。 - 我们可以定义一个类,用占位符来表示它的方法、字段、参数等的类型。以及在泛型程序中;这些占位符将被特定的类型替换。
- 微软声明:“泛型类和方法结合了可重用性、类型安全性和效率,这是非泛型类和方法所不能做到的。泛型最常用于集合和对集合进行操作的方法。的 2.0 版。NET Framework 类库提供了一个新的命名空间 System。包含几个新的基于泛型的集合类。建议所有面向。NET Framework 2.0 和更高版本使用新的泛型集合类,而不是旧的非泛型集合类,如 ArrayList。(见
https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/generics/introduction-to-generics
)。)
让我们从下面的程序开始。
演示 2:泛型程序
using System;
namespace GenericProgrammingEx1
{
class MyGenericClass<T>
{
public T Show(T value)
{
return value;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Introduction to Generics***");
MyGenericClass<int> myGenericClassIntOb = new MyGenericClass<int>();
Console.WriteLine("Show returns :{0}", myGenericClassIntOb.Show(100));
MyGenericClass<string> myGenericClassStringOb = new MyGenericClass<string>();
Console.WriteLine("Show returns :{0}", myGenericClassStringOb.Show("Generic method called"));
MyGenericClass<double> myGenericClassDoubleOb = new MyGenericClass<double>();
Console.WriteLine("Show returns :{0}", myGenericClassDoubleOb.Show(100.5));
Console.ReadKey();
}
}
}
输出
分析
我们现在可以做一个演示 1 和演示 2 的对比分析。我们看到了以下特征:
-
对于非泛型方法,我们需要指定像
ShowInteger()
和ShowString()
这样的方法来处理特定的数据类型。另一方面,对于通用版本,Show()
就足够了。一般来说,通用版本的代码行更少(即代码更小)。 -
在演示 1 的
Main()
中,我们在第二行遇到了一个编译时错误,如下所示:Console.WriteLine("ShowDouble returns :{0}", nonGenericOb.ShowDouble(25.5));//error
原因是:在这个例子中,我们没有定义一个'ShowDouble(double d)'
方法。因此,为了避免这个错误,我们需要在类中包含一个额外的方法 NonGenericEx,如下所示:
进一步分析
我们的NonGenericEx
类的代码大小随着这一增加而增加。我们需要增加代码大小,因为我们现在试图处理不同的数据类型“double”
现在来看演示 2,我们在不修改 MyGenericClass 的情况下获得了 double 数据类型。因此,我们可以得出结论,通用版本更加灵活。
注意
一般来说,泛型编程比非泛型编程更灵活,并且需要更少的代码行。
考虑下面的程序。
演示 3
using System;
using System.Collections;
namespace GenericEx2
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Use Generics to avoid runtime error***");
ArrayList myList = new ArrayList();
myList.Add(10);
myList.Add(20);
myList.Add("Invalid");//No compile time error but will cause
//runtime error
foreach (int myInt in myList)
{
Console.WriteLine((int)myInt); //downcasting
}
Console.ReadKey();
}
}
}
输出
该程序不会引发任何编译时错误。
但是在运行时,我们会遇到这个错误:
这是因为第三个元素(即我们的 ArrayList 中的 myList [2])不是整数(它是一个字符串)。在编译时,我们没有遇到任何问题,因为它是作为对象存储的。请注意取自 visual studio 的快照:
分析
在这种类型的编程中,由于装箱和向下转换,我们可能会面临性能开销。
现在考虑下面的程序。
演示 4
using System;
using System.Collections.Generic;
namespace GenericEx3
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Use Generics to avoid runtime error***");
List<int> myGenericList = new List<int>();
myGenericList.Add(10);
myGenericList.Add(20);
myGenericList.Add("Invalid");// compile time error
foreach (int myInt in myGenericList)
{
Console.WriteLine((int)myInt);//downcasting
}
Console.ReadKey();
}
}
}
输出
在这种情况下,我们不能在 myGenericList 中添加字符串,因为它只用于保存整数。该错误在编译时被捕获;我们不需要等到运行时才得到这个错误。
分析
通过比较演示 3 和演示 4,我们可以说
- 为了避免运行时错误,我们应该更喜欢泛型版本的代码,而不是非泛型版本。
- 如果我们使用泛型编程,我们可以避免装箱/拆箱带来的损失。
- 我们可以使用
List<string> myGenericList2 = new List<string>();
来创建一个包含字符串的列表。List版本比非泛型版本 ArrayList 更加灵活和可用。
演示 5:自引用泛型类型练习
让我们假设在您的雇员类中有雇员 id 和部门名称。写一个简单的程序来判断两个雇员是否相同。但是对您的约束是,您的类应该从定义该比较方法规范的通用接口派生。
下面的演示可以被视为需求的一个示例实现。
using System;
namespace GenericEx4
{
interface ISameEmployee<T>
{
string CheckForIdenticalEmployee(T obj);
}
class Employee : ISameEmployee<Employee>
{
string deptName;
int employeeID;
public Employee(string deptName, int employeeId)
{
this.deptName = deptName;
this.employeeID = employeeId;
}
public string CheckForIdenticalEmployee(Employee obj)
{
if (obj == null)
{
return "Cannot Compare with a Null Object";
}
else
{
if (this.deptName == obj.deptName && this.employeeID == obj.employeeID)
{
return "Same Employee";
}
else
{
return "Different Employee";
}
}
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("**Suppose, we have an Employee class that contains deptName and employeeID***");
Console.WriteLine("***We need to check whether 2 employee objects are same or not.***");
Console.WriteLine();
Employee emp1 = new Employee("Maths", 1);
Employee emp2 = new Employee("Maths", 2);
Employee emp3 = new Employee("Comp. Sc.", 1);
Employee emp4 = new Employee("Maths", 2);
Employee emp5=null;
Console.WriteLine("Comparing Emp1 and Emp3 :{0}", emp1.CheckForIdenticalEmployee(emp3));
Console.WriteLine("Comparing Emp2 and Emp4 :{0}", emp2.CheckForIdenticalEmployee(emp4));
Console.WriteLine("Comparing Emp3 and Emp5 :{0}", emp3.CheckForIdenticalEmployee(emp5));
Console.ReadKey();
}
}
}
输出
分析
这是一个类型将自己命名为具体类型的示例(换句话说,这是一个自引用泛型类型的示例)。
一个特殊的关键字默认值
我们熟悉 switch 语句中 default 关键字的用法,其中 default 用于指代默认情况。在泛型的上下文中,它有特殊的含义。这里我们使用 default 用它们的默认值初始化泛型类型(例如,引用类型的默认值是 null,值类型的默认值是按位零)。
考虑下面的例子。
演示 6
using System;
namespace CaseStudyWithDefault
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Case study- default keyword***");
Console.WriteLine("default(int) is {0}", default(int));//0
bool b1 = (default(int) == null);//False
Console.WriteLine("default(int) is null ?Answer: {0}", b1);
Console.WriteLine("default(string) is {0}", default(string));//null
bool b2 = (default(string) == null);//True
Console.WriteLine("default(string) is null ? Answer:{0}", b2);
Console.ReadKey();
}
}
}
输出
分析
我们必须记住,int
是值类型,string
是引用类型。因此,您现在可以使用前面的程序和输出来检查它们的默认值。
演示 7:分配
让我们假设你有一个仓库,你可以存储多达三个对象。要存储这些对象,可以使用数组。编写一个泛型程序,通过它你可以在库中存储/检索不同的类型。使用 default 关键字的概念用数组各自的类型初始化数组。
下面的演示可以被视为需求的一个示例实现。
using System;
namespace Assignment
{
public class MyStoreHouse<T>
{
T[] myStore = new T[3];
int position = 0;
public MyStoreHouse()
{
for (int i = 0; i < myStore.Length; i++)
{
myStore[i] = default(T);
}
}
public void AddToStore(T value)
{
if (position < myStore.Length)
{
myStore[position] = value;
position++;
}
else
{
Console.WriteLine("Store is full already");
}
}
public void RetrieveFromStore()
{
foreach (T t in myStore)
{
Console.WriteLine(t);
}
//Or Use this block
//for (int i = 0; i < myStore.Length; i++)
//{
// Console.WriteLine(myStore[i]);
//}
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Use case-default keyword
in generic programming:***");
Console.WriteLine("***\nCreating an Integer store:***");
MyStoreHouse<int> intStore = new MyStoreHouse<int>();
intStore.AddToStore(45);
intStore.AddToStore(75);
Console.WriteLine("***Integer store at this moment:***");
intStore.RetrieveFromStore();
Console.WriteLine("***\nCreating an String store:***");
MyStoreHouse<string> strStore = new MyStoreHouse<string>();
strStore.AddToStore("abc");
strStore.AddToStore("def");
strStore.AddToStore("ghi");
strStore.AddToStore("jkl");//Store is full already
Console.WriteLine("***String store at this moment:***");
strStore.RetrieveFromStore();
Console.ReadKey();
}
}
}
输出
通用约束
考虑下面的程序和输出,然后进行分析。
演示 8
using System;
using System.Collections.Generic;
namespace GenericConstraintEx
{
interface IEmployee
{
string Position();
}
class Employee : IEmployee
{
public string Name;
public int yearOfExp;
public Employee(string name, int years)
{
this.Name = name;
this.yearOfExp = years;
}
public string Position()
{
if (yearOfExp < 5)
{
return " A Junior Employee";
}
else
{
return " A Senior Employee";
}
}
}
class EmployeeStoreHouse<Employee> where Employee : IEmployee
//class EmployeeStoreHouse<Employee>//error
{
private List<Employee> MyStore = new List<Employee>();
public void AddToStore(Employee element)
{
MyStore.Add(element);
}
public void DisplaySore()
{
Console.WriteLine("The store contains:");
foreach (Employee e in MyStore)
{
Console.WriteLine(e.Position());
}
}
}
namespace Generic.Constraint_1
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Example of Generic Constraints***");
//Employees
Employee e1 = new Employee("Amit", 2);
Employee e2 = new Employee("Bob", 5);
Employee e3 = new Employee("Jon", 7);
//Employee StoreHouse
EmployeeStoreHouse<Employee> myEmployeeStore = new EmployeeStoreHouse<Employee>();
myEmployeeStore.AddToStore(e1);
myEmployeeStore.AddToStore(e2);
myEmployeeStore.AddToStore(e3);
//Display the Employee Positions in Store
myEmployeeStore.DisplaySore();
Console.ReadKey();
}
}
}
}
输出
注意
在这个例子中,我们检查了如何在应用中设置约束。如果不使用“where Employee:IEmployee”语句,我们会遇到以下问题:
上下文关键字'where
'帮助我们在应用中设置约束。一般来说,我们可以有以下约束:
where T
:struct
表示类型 T 必须是值类型。(请记住,struct 是一种值类型。)where T: class
表示类型 T 必须是引用类型。(记住类是一个引用类型。)where T: IMyInter
表示类型 T 必须实现 IMyInter 接口。where T: new()
意味着类型 T 必须有一个默认的(无参数的)构造函数。(如果与其他约束一起使用,将其放在最后一个位置。)where T: S
意味着类型 T 必须从另一个泛型类型 s 派生。它有时被称为裸类型约束。
恶作剧
学生问:
我们能否编写一个更通用的 EmployeeStoreHouse 形式?
回答
老师说:是的,我们能。考虑下面的代码。
class EmployeeStoreHouse<T> where T : IEmployee
{
private List<T> MyStore = new List<T>();
public void AddToStore(T element)
{
MyStore.Add(element);
}
public void DisplaySore()
{
foreach (T e in MyStore)
{
Console.WriteLine(e.Position());
}
}
}
协方差和逆变
在第十章关于委托的讨论中,你了解到委托中的协变和逆变支持是从 C# 2.0 开始的。从 C# 4.0 开始,这些概念可以应用于泛型类型参数、泛型接口和泛型委托。第十章也探讨了非泛型委托的概念。
在本章中,我们将通过更多的案例继续探讨这些概念。
在继续之前,请记住以下几点:
- 协方差和逆变处理带有参数和返回类型的类型转换。
- 协方差和逆变已用于我们对不同类型的对象/数组等的编码。
- 。NET 4 支持泛型委托和泛型接口。(在早期版本中,我们会遇到泛型委托或泛型接口的编译错误)。
- 逆变通常被定义为调整或修改。当我们试图在编码世界中实现这些概念时,我们也试图接受以下真理(或类似的真理):
- 所有的足球运动员都是运动员,但反过来就不一样了(因为有很多运动员打高尔夫、篮球、曲棍球等。)同样,我们可以说所有的公交车都是交通工具,但反过来就不成立。
- 在编程术语中,所有的派生类都是基类,但反之则不然。例如,假设我们有一个名为 Rectangle 的类,它是从一个名为 Shape 的类派生而来的。那么我们可以说所有的矩形都是形状,但反过来就不成立了。
- 按照 MSDN 的说法,协方差和逆变的概念处理数组、委托和泛型类型的隐式引用转换。协方差保持赋值兼容性,逆变则相反。
学生问:
“任务兼容性”是什么意思?
老师说:这意味着你可以把一个更具体的类型分配给一个兼容的不太具体的类型。例如,整数变量的值可以存储在对象变量中,如下所示:
int i = 25;
object o = i;//ok: Assignment Compatible
让我们试着从数学的角度来理解协变、逆变和不变性的含义。
假设我们只考虑整数的定义域。
情况 1:我们定义我们的函数,f (x) = x + 2 对于所有的 x。
如果 x ≤ y,那么我们也可以说 f (x) ≤ f (y)对于所有的 x,投影(函数 f)是保持大小方向的。
情况 2:我们定义我们的 f(x)=–x(对于所有的 x 都属于整数)。
现在我们可以看到 10 ≤ 20 但是 f (10) ≥ f (20)(因为 f(10)=–10,f(20)=–20 和–10 >–20)。所以,我们的大小方向颠倒了。
情况 3:我们定义我们的函数,f (x) = x*x。
现在,我们可以看到–1≤0 且 f(–1)> f(0),但 1 < 2 且 f (1) < f (2)。所以投影(函数 f)既不总是保持大小的方向,也不反转大小的方向。
在情况 1 中,函数 f 是协变的;在情况 2 中,函数 f 是逆变的;在情况 3 中,函数 f 是不变的。
Points to Remember
你可以随时参考微软在 https://docs.microsoft.com/en-us/dotnet/standard/generics/covariance
-and-contravariance
的简单定义。
- 协方差我们可以使用比最初指定的更派生的类型。
- 我们可以使用一个比最初指定的更通用(更少派生)的类型。
- 不变性我们只允许使用最初指定的类型。
协方差和逆变统称为方差。
从。NET Framework 4 中,在 C# 中有关键字将接口和委托的泛型类型参数标记为协变或逆变。协变接口和委托用 out 关键字标记(表示值出来)。逆变接口和委托与 in 关键字相关联(指示值进入)。
让我们看一下我们的 C# 例子。记住 IEnumerable
作者注:注意,我们可以看到“out”这个词与 IEnumerable 的定义相关联。所以,我们可以将 IEnumerable
现在检查 Visual Studio 中 Action
或者,检查 Visual Studio 中的定义 IComparer
Note
注意,我们可以看到 in 中的单词与 Acion
底线是:由于 IEnumerable
另一方面,由于动作
为了测试这两种风格,我们将讨论泛型接口的协变性和泛型委托的逆变性。我建议您尝试实现剩下的两种情况:用泛型委托实现协方差,用泛型接口实现逆变。
演示 9:通用接口的协变性
using System;
using System.Collections.Generic;
namespace CovarianceWithGenericInterfaceEx
{
class Parent
{
public virtual void ShowMe()
{
Console.WriteLine(" I am from Parent, my hash code is :" + GetHashCode());
}
}
class Child : Parent
{
public override void ShowMe()
{
Console.WriteLine(" I am from Child, my hash code is:" + GetHashCode());
}
}
class Program
{
static void Main(string[] args)
{
//Covariance Example
Console.WriteLine("***Covariance with Generic Interface Example***\n");
Console.WriteLine("***IEnumerable<T> is covariant");
//Some Parent objects
Parent pob1 = new Parent();
Parent pob2 = new Parent();
//Some Child objects
Child cob1 = new Child();
Child cob2 = new Child();
//Creating a child List
List<Child> childList = new List<Child>();
childList.Add(cob1);
childList.Add(cob2);
IEnumerable<Child> childEnumerable = childList;
/* An object which was instantiated with a more derived type argument (Child) is assigned to an object instantiated with a less derived type argument(Parent). Assignment compatibility is preserved here. */
IEnumerable<Parent> parentEnumerable = childEnumerable;
foreach (Parent p in parentEnumerable)
{
p.ShowMe();
}
Console.ReadKey();
}
}
}
输出
分析
仔细阅读程序中包含的注释,以便更好地理解。
演示 10:与泛型委托的对比
using System;
namespace ContravarianceWithGenericDelegatesEx
{
//A generic delegate
delegate void aDelegateMethod<in T>(T t);
class Vehicle
{
public virtual void ShowMe()
{
Console.WriteLine(" Vehicle.ShowMe()");
}
}
class Bus: Vehicle
{
public override void ShowMe()
{
Console.WriteLine(" Bus.ShowMe()");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Contra-variance with Generic Delegates
example ***");
Vehicle obVehicle = new Vehicle();
Bus obBus = new Bus();
aDelegateMethod<Vehicle> delVehicle = ShowVehicleType;
delVehicle(obVehicle);
//Contravariance
with Delegate
//Using less derived type to more derived type
aDelegateMethod<Bus> delChild = ShowVehicleType;
delChild(obBus);
Console.ReadKey();
}
private static void ShowVehicleType(Vehicle p)
{
p.ShowMe();
}
}
}
输出
分析
像前一个例子一样,浏览这个程序中的注释以获得更好的理解。
学生问:
在前面的程序中,您使用了带有泛型委托的静态方法(ShowVehicleType (…))。你能在非静态方法中使用同样的概念吗?
老师说:显然,你可以。
摘要
本章讨论了以下内容:
- C# 中的泛型
- 为什么泛型很重要
- 泛型编程相对于非泛型编程的优势
- 泛型上下文中的关键字 default
- 如何在泛型编程中施加约束
- 通用接口的协变
- 与泛型委托相反
十三、异常处理
关于异常处理的讨论
老师开始讨论:一般来说,当我们为一个应用编写代码时,我们期望它总是能够顺利执行。但是有时候,我们在执行那些程序的时候会遇到突然的惊喜。这些意外可能以各种方式出现,并通过一些粗心的错误(例如,试图实现错误的逻辑,或忽略程序代码路径中的一些漏洞等)出现。)然而,许多失败都超出了程序员的控制范围,这也是事实。我们经常把这些不想要的情况称为例外。当我们编写应用时,处理这些异常是必不可少的。
定义
我们可以将异常定义为一个事件,它打破了正常的执行/指令流。
当出现异常情况时,会创建一个异常对象并将其抛出到创建该异常的方法中。该方法可能会也可能不会处理异常。如果它不能处理异常,它将把责任传递给另一个方法。(类似于我们的日常生活,当情况超出我们的控制范围时,我们会向他人寻求建议)。如果没有负责处理特定异常的方法,则会出现一个错误对话框(指示未处理的异常),并且程序的执行会停止。
Points to Remember
异常处理机制处理。如果处理不当,应用会过早死亡。因此,我们应该尝试编写能够以优雅的方式检测和处理意外情况的应用,并防止应用过早死亡。
让我们从一个简单的例子开始。下面的程序将成功编译,但它将在运行时引发一个异常,因为我们忽略了除数(b)是 0 的事实(即,我们将 100 除以 0)。
演示 1
using System;
namespace ExceptionEx1
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring Exceptions.***");
int a=100, b=0;
int c = a / b;
Console.WriteLine(" So, the result of a/b is :{0}", c);
Console.ReadKey();
}
}
}
输出
系统。DivideByZeroException:“试图除以零。”
老师继续说:在继续之前,我将强调一些关于异常处理机制的要点。你必须反复检查这些要点。
- 中的所有例外。NET 是对象。
- 系统。Exception 是异常的基类。
- 应用中的任何方法都可能在应用运行时引发意外。如果出现这种情况,在编程术语中,我们说该方法抛出了一个异常。
- 我们使用以下关键字来处理 C# 异常:try、catch、throw、finally
- 我们试图用 try/catch 块来保护异常。可能引发异常的代码放在 try 块中,这种异常情况在 catch 块中处理。
- 我们可以将多个 catch 块与一个 try 块相关联。当一个特定的 catch 块处理突发事件时,我们说 catch 块已经捕获了异常。
- finally 块中的代码必须执行。finally 块通常放在 try 块或 try/catch 块之后。
- 当 try 块中引发异常时,控件将跳转到相应的 catch 或 finally 块。try 块的剩余部分将不会被执行。
- 异常遵循继承层次结构。有时,如果我们将可以处理父类异常的 catch 块(例如,catch block1)放在只能处理派生类异常的 catch 块(例如,catch block2)之前,我们可能会遇到编译时错误。从编译器的角度来看,这是一个不可达代码的例子,因为在这种情况下,catch block1 已经能够处理 catch block2 可以处理的异常。因此,控制根本不需要到达 catch block2。我们将通过一个例子来研究这种情况。
- 我们可以使用任何组合:try/catch、try/catch/finally 或 try/finally。
- finally 块中的代码必须执行。
- 如果我们不处理异常,CLR 将代表我们捕获它,我们的程序可能会过早死亡。
与 Java 有一个关键区别:这里所有的异常都是隐式未检查的。因此,C# 中没有 throws 关键字的概念。这是一个争论的热门话题。
老师继续说:现在让我们看看如何处理我们在前面的例子中遇到的异常。
演示 2
using System;
namespace ExceptionEx1Modified
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring Exceptions***");
int a = 100, b = 0;
try
{
int c = a / b;
Console.WriteLine(" So, the result of a/b is :{0}", c);
}
catch (Exception ex)
{
Console.WriteLine("Encountered an exception :{0}", ex.Message);
}
finally
{
Console.WriteLine("I am in finally
.You cannot skip me!");
}
Console.ReadKey();
}
}
}
输出
分析
我们可以从程序的输出中确认以下几点:
- 当 try 块中引发异常时,控件跳转到相应的 catch 块。try 块的剩余部分没有执行。
- 尽管我们遇到了异常,finally 块中的代码还是执行了。
- 我们使用了一个名为 Message 的公共属性。在系统中。例外,还有一些众所周知的属性。在 Visual Studio 中可以很容易地看到它们。
如箭头所示,在大多数情况下,您可能需要这三个属性:Message、StackTrace 和 InnerException。本章在各种示例中使用了 Message 和 StackTrace 属性。从截图中可以很容易地看出,这些都是只读属性(它们只有 get 属性)。为了便于您立即参考,我展开了这三个属性以显示它们的描述。
- InnerException 属性:获取系统。导致当前异常的异常实例。
- Message 属性:描述当前异常。
- StackTrace 属性:使用该属性,我们可以获得导致异常的方法调用的层次结构。它为我们提供了调用堆栈上直接帧的字符串表示。
学生问:
先生,我们可以很容易地在除法运算之前放置一个 if 块,如 if(b==0 ),以避免除数为 0,在这种情况下,我们可以很容易地排除使用 try/catch 块。
老师澄清道:“你只是在考虑这个简单的例子,这就是为什么它会以这种方式出现在你面前。是的,在这种情况下,你的除数是固定的,你可以用那种方式保护你的代码。然而,考虑这样一种情况,b 的值也是在运行时计算的,并且您不能提前预测该值。此外,如果在所有可能的情况下都需要这样的保护,你的代码可能看起来很笨拙,很明显不可读。”
老师继续说:为了便于参考,下面是一些在语言规范中定义的异常类。
| 系统。算术异常 | 算术运算期间发生的异常的基类,如 System。DivideByZeroException 和 System.OverflowException。 | | 系统。ArrayTypeMismatchException | 当由于存储元素的实际类型与数组的实际类型不兼容而导致数组存储失败时,将引发此异常。 | | 系统。DivideByZeroException | 当试图将整数值除以零时会引发此异常。 | | 系统。IndexOutOfRangeException | 当试图通过小于零或超出数组边界的索引对数组进行索引时,将引发此异常。 | | 系统。异常 | 当在运行时从基类型或接口到派生类型的显式转换失败时,将引发此异常。 | | 系统。空引用的异常 | 当使用空引用的方式导致需要被引用的对象时,将引发此异常。 | | System.OutOfMemoryException | 当分配内存(通过 new)的尝试失败时抛出。 | | 系统。堆栈溢出异常 | 当由于有太多挂起的方法调用而耗尽执行堆栈时,将引发此异常;通常表示非常深或无限的递归。 | | 系统。TypeInitializationException | 当静态构造函数抛出异常,并且没有 catch 子句来捕获它时,将引发该异常。 | | 系统。溢出异常 | 当检查的上下文中的算术运算溢出时,将引发此异常。 |有关更详细的异常列表,可以在 Visual Studio 中按 Ctrl+Alt+E,然后展开公共语言运行时异常选项,如下面的屏幕截图所示。
现在考虑下面的例子,看看如何在我们的程序中用多个 catch 块处理多个异常。
演示 3
using System;
namespace HandlingMultipleEx
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Handling multiple Exceptions***");
string b1;
int input;
Console.WriteLine("Enter your choice( 0 or 1)");
b1 = Console.ReadLine();
//Checking whether we can parse the string as an integer
if (int.TryParse(b1, out input))
{
Console.WriteLine("You have entered {0}", input);
switch (input)
{
case 0:
int a = 100, b = 0;
try
{
int c = a / b;
Console.WriteLine(" So, the result of a/b is :{0}", c);
}
catch (DivideByZeroException ex)
{
Console.WriteLine("Encountered an exception with integers:{0}", ex.Message);
Console.WriteLine("Encountered an exception with integers:{0}", ex.StackTrace);
}
catch (Exception ex)
{
Console.WriteLine("In Choice0.Exception block ..{0}",ex.Message);
}
break;
case 1:
int[] myArray = { 1, 2, 3 };
try
{
Console.WriteLine(" myArray[0] :{0}", myArray[0]);
Console.WriteLine(" myArray[1] :{0}", myArray[1]);
Console.WriteLine(" myArray[2] :{0}", myArray[2]);
Console.WriteLine(" myArray[3] :{0}", myArray[3]);
}
catch (IndexOutOfRangeException ex)
{
Console.WriteLine("Encountered an exception with array
elements :{0}", ex.Message);
Console.WriteLine("Encountered an exception with array
elements :{0}", ex.StackTrace);
}
catch (Exception ex)
{
Console.WriteLine("In Choice1.Exception block ..{0}", ex.Message);
}
break;
default:
Console.WriteLine("You must enter either 0 or 1");
break;
}
}
else
{
Console.WriteLine("You have not entered an integer!");
}
Console.ReadKey();
}
}
}
输出
案例 1:用户输入了 0。
情况 2:用户输入了 1。
案例 3:用户输入了一个字符串。
分析
我们可以从程序的输出中确认以下几点:
- 当引发异常时,只执行一个 catch 子句。例如,如果 block-catch(DivideByZeroException ex){..}可以处理异常,block- catch (Exception ex){..}不需要进入画面。
- 在前面的程序中,所有类型的异常(除了 DivideByZeroException 和 IndexOutOfRangeException)都在 block- catch (Exception ex)中捕获,并且该块必须作为最后一个 catch 块放置,因为 System。异常类是所有异常的基类。
恶作剧
你能预测产量吗?
演示 4
using System;
namespace Quiz1Exception
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Exploring Exceptions***");
int a = 100, b = 0;
try
{
int c = a / b;
Console.WriteLine(" So, the result of a/b is :{0}", c);
}
catch (ArithmeticException ex)
{
Console.WriteLine("Encountered an exception :{0}", ex.Message);
}
//Error:Exceptions follows the inheritance
hierarchy.
//So, we need to place catch blocks properly.
catch (DivideByZeroException ex)
{
Console.WriteLine("Encountered an DivideByZeoException :{0}", ex.Message);
}
Console.ReadKey();
}
}
}
输出
编译器错误。
分析
异常遵循继承层次结构。因此,我们需要适当地放置 catch 块。在这种情况下,DivideByZeroException 是 ArithmeticException 的子类(而 arithmetic Exception 又是 Exception 的子类)。您可以在 Visual Studio 中轻松检查这一点。
Points to Remember
因此,当您处理多个 catch 块时,您需要首先放置更具体的异常子句。
catch 条款的其他变体
老师继续说:到目前为止,我们已经看到了不同的 catch 块。我们将注意到,您可以简单地使用 catch(
catch (Exception)
{
Console.WriteLine("Encountered an Exception");
}
这个街区也可以。这是 catch 子句的另一种变体。
catch ()
{
Console.WriteLine("Encountered an Exception");
}
但是,强烈建议您尽量避免这两种 catch 块。
恶作剧
代码会编译吗?
//some code before
catch (Exception)
{
Console.WriteLine("Encountered an Exception");
}
catch { }
//some code after
回答
是的。但是,在 Visual Studio 2017 中,您会看到这条警告消息:
恶作剧
代码会编译吗?
//some code before
catch { }
catch (Exception)
{
Console.WriteLine("Encountered an Exception");
}
//some code after
回答
不会。在 Visual Studio 2017 中,您会看到以下错误信息:
说明
按照语言规范,没有命名异常类的 catch 子句可以处理任何异常。此外,还有一些不是从 System.Exception 派生的异常,称为非 CLS 异常。一些。NET 语言(包括 C++/CLI)支持这些异常。在 Visual C# 中,我们不能抛出非 CLS 异常,但我们可以捕捉它们。默认情况下,Visual C# 程序集将非 CLS 异常捕获为包装异常(请参见上一个测验输出中的警告消息)。因此,我们也可以在 block-catch (Exception ex){..}.
专家建议,当您知道需要执行某些特定任务(例如,写入日志条目)来响应非 CLS 异常,但不需要访问异常信息时,可以使用 catch{}。关于这个话题的更多信息,可以去 https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/exceptions/how-to-catch-a-non-cls-exception
。
老师继续说:我们有 catch 块的另一个变体,它是在 C# 6.0 中引入的。
下面是 catch 子句的第三种变体。
catch (WebException ex) when (ex.Status == WebExceptionStatus.Timeout)
{
//some code
}
在这种情况下,when 子句就像一个过滤器。因此,在这种情况下,如果抛出了 WebException,但是布尔条件(后面跟有 when)不为真,这个 catch 块将不会处理该异常。因此,使用这种过滤器,我们可以再次捕获相同的异常,但在不同的 catch 块中处理它,如下所示:
catch (WebException ex) when (ex.Status == WebExceptionStatus.Pending)
{
//some code
}
或者,
catch (WebException ex) when (ex.Status == WebExceptionStatus.ProtocolError)
{
//some code
}
老师继续说:现在我们来看看一个方法如何抛出异常。方法可以用 throw 关键字抛出异常。在下面的例子中,当除数为 0 时,我们从 Divide 方法中抛出了 DivideByZeroException,然后在 catch 块中处理它。
演示 5
using System;
namespace ThrowingExceptionEx
{
class Program
{
static int a = 100, b = 0, c;
static void Divide(int a, int b)
{
if (b != 0)
{
int c = a / b;
}
else
{
throw new DivideByZeroException("b comes as Zero");
}
}
static void Main(string[] args)
{
Console.WriteLine("***Exploring Exceptions:Throwing an Exception Example***");
try
{
Divide(a, b);
Console.WriteLine("Division operation completed");
}
catch (DivideByZeroException ex)
{
Console.WriteLine("Encountered an exception :{0}", ex.Message);
}
Console.ReadKey();
}
}
}
输出
学生问:
先生,有什么不同的方法来提出一个例外?
老师说:一般来说,有两种不同的方式可以提出异常。
方法 1:我们刚刚看到,任何方法都可以通过使用 throw 关键字来引发异常。该语句会立即引发异常,并且控制权不会转移到紧跟在 throw 语句之后的语句。
方法 2:当我们处理 C# 语句和异常时,我们可能会遇到由于错误的逻辑、漏洞等等导致的异常。
老师继续说:有时我们需要反复抛出(称为再抛出)一个异常。在某些情况下是必要的;例如,当我们想要写一个日志条目或者当我们想要发送一个新的更高级别的异常时。
以下是重新引发异常的格式:
try
{
//some code
}
catch(Exception ex)
{
//some code e.g. log it now
//Now rethrow it
throw;
}
Note
如果您使用throw ex
而不是throw;
,程序将不会有任何编译问题,但是如果您检查 StackTrace 属性,您会发现它与原始属性不同。因此,强烈建议您只有在真正想要重新抛出原始异常时才使用throw;
。请参见演示 6 的输出来确认这一点。
演示 6
using System;
namespace RethrowingExceptionEx
{
class Program
{
static int a = 100, b = 1, c;
static void Divide(int a, int b)
{
try
{
b--;
c = a / b;
//some code
}
catch(Exception ex)
{
//some code e.g. log it now
Console.WriteLine("a={0} b={1}", a,b);
Console.WriteLine("Message: {0}", ex.Message);
Console.WriteLine("StackTrace: {0}", ex.StackTrace);
//Now rethrow it
throw; //will throw the current exception
//throw new ArithmeticException();//throwing the parent class exception
}
}
static void Main(string[] args)
{
Console.WriteLine("***Exploring Rethrowing an Exception Example***");
try
{
Divide(a, b);
Console.WriteLine(" Main.Divide() is completed");
}
catch (DivideByZeroException ex)
{
Console.WriteLine("\na={0} b={1}", a, b);
Console.WriteLine("Message: {0}", ex.Message);
Console.WriteLine("StackTrace: {0}", ex.StackTrace);
}
catch (Exception ex)
{
Console.WriteLine("\nIn catch(Exception ex)");
Console.WriteLine("a={0} b={1}", a, b);
Console.WriteLine("Message: {0}", ex.Message);
Console.WriteLine("StackTrace: {0}", ex.StackTrace);
}
Console.ReadKey();
}
}
}
输出
分析
现在您可以明白为什么第一个案例中的日志记录很重要了。一遇到异常,我们就记录下来,然后我们看到除数(b)在 Divide()方法中变成了 0。如果您没有记录它,那么当您看到最终的日志语句时,您可能会想,当 b 为 1 时,为什么会出现这个异常。
取消前面程序中throw new ArithmeticException();
行的注释,如下所示:
您将收到以下输出:
学生问:
先生,看来我们可以在这种情况下抛出任何例外。这是正确的吗?
老师说:是的,但是很明显,这是不被推荐的。但是,当您学习创建自己的异常时,您可以将这个原始异常与您的自定义异常消息结合起来,然后重新抛出它以获得更好的可读性。
创建自定义异常
老师继续说:有时我们想定义自己的异常来获得更有意义的信息。在我们继续之前,我们必须记住以下几点:
- 在异常层次结构中,我们注意到两种主要类型的异常类:SystemException 和 ApplicationException。SystemException 由运行时(CLR)抛出,ApplicationException 由用户程序抛出(到目前为止,我们一直使用 System Exception)。最初,有人建议用户定义的异常应该从 ApplicationException 类派生。然而,后来 MSDN 建议:“你应该从 Exception 类而不是 ApplicationException 类派生定制异常。您不应在代码中引发 ApplicationException 异常,也不应捕捉 ApplicationException 异常,除非您打算重新引发原始异常。(见
https://msdn.microsoft.com/en-us/library/system.applicationexception.aspx
)。) - 当我们创建自己的异常时,类名应该以单词 exception 结尾。(见
https://docs.microsoft.com/en-us/dotnet/standard/exceptions/how-to-create-user-defined-exceptions
)。) - 提供构造函数的三个重载版本(如演示 7 所述)。
当我们创建自己的例外时,我们将尝试遵循所有这些建议。
演示 7
using System;
namespace CustomExceptionEx1
{
class ZeroDivisorException : Exception
{
public ZeroDivisorException() : base("Divisor is zero"){ }
public ZeroDivisorException(string msg) : base(msg){ }
public ZeroDivisorException(string msg, Exception inner) : base(msg, inner)
{ }
}
class TestCustomeException
{
int c;
public int Divide(int a, int b)
{
if (b == 0)
{
//Ex.Message= "Divisor should not be Zero"
throw new ZeroDivisorException("Divisor should not be Zero");
//Ex.Message= "Divisor is Zero"
//throw new ZeroDivisorException();
}
c = a / b;
Console.WriteLine("Division completed");
return c;
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***A Custom Exception Example***");
int a = 10, b = 1, result;
try
{
b--;
TestCustomeException testOb = new TestCustomeException();
result = testOb.Divide(a, b);
}
catch (ZeroDivisorException ex)
{
Console.WriteLine("Caught the custom exception
: {0}", ex.Message);
}
finally
{
Console.WriteLine("\nExample completed");
Console.ReadKey();
}
}
}
}
输出
分析
我们使用了构造函数的第二个重载版本。如果您想使用默认的构造函数(前面已经注释过了),会有一个不同的消息。
摘要
本章回答了以下问题。
- 什么是例外?
- 我们如何处理程序中的错误?
- 我们在 C# 中处理异常时常用的关键字有哪些?
- 我们应该如何在程序中放置 try、catch 和 block,目的是什么?
- catch 子句有哪些不同的变体?
- 我们如何在程序中使用异常过滤器?
- 我们如何对异常进行分类?
- 我们如何定制例外?
十四、内存清理
教师开始讨论:管理内存是程序员关心的一个重要问题。酪 NET 试图让他们的生活变得更容易,负责清除那些在某个特定点之后没有用处的对象。在编程中,我们称之为脏对象或未引用对象。
垃圾收集器程序作为低优先级线程在后台运行,并跟踪脏对象。。NET 运行库可以定期调用此程序从内存中移除未引用的或脏的对象。
然而,有一个问题。一些对象需要特殊的拆卸代码来释放资源。一个非常常见的例子是当我们打开一个或多个文件,然后执行一些操作(例如,读、写等。)但忘记关闭文件。在其他情况下,也可能需要类似的注意,例如当我们处理程序中的非托管对象、锁定机制或操作系统句柄等时。程序员显然需要释放这些资源。
一般来说,当程序员努力清理(或释放)内存时,我们说他们试图释放对象,但当 CLR 自动处理释放资源时,我们说垃圾收集器正在执行其工作或垃圾收集正在进行。
Points to Remember
程序员可以通过显式释放对象来释放资源,或者 CLR 通过垃圾收集机制自动释放资源。
垃圾收集器如何工作
老师继续说:分代式垃圾收集器用于比长寿命对象更频繁地收集短寿命对象。我们这里有三代:0,1,2。短期对象存储在第 0 代中。生命周期较长的对象被推送到更高的层代—1 或 2。垃圾收集器在低代中比在高代中工作得更频繁。
一旦我们创建了一个对象,它就驻留在第 0 代中。当第 0 代填满时,垃圾收集器被调用。在第一代垃圾收集中幸存下来的对象被提升到下一个更高的代,即第 1 代。在第 1 代垃圾收集中幸存下来的对象进入最高的第 2 代。
Note
您可以记住 3-3 规则:垃圾收集工作在三个不同的阶段,通常,垃圾收集器在三种不同的情况下被调用。
垃圾收集的三个阶段
以下是垃圾收集的三个不同阶段:
- 阶段 1 是标记阶段,在该阶段中,活的物体被标记或识别。
- 阶段 2 是重定位阶段,在此阶段,它更新将在阶段 3 中压缩的对象的引用。
- 阶段 3 是压缩阶段,它从死的(或未被引用的)对象中回收内存,压缩操作在活动的对象上执行。它将活动对象(在此之前一直存在)移动到分段的旧末端。
调用垃圾收集器的三种情况
以下是调用垃圾收集器的三种常见情况:
- 在案例 1 中,我们的内存不足。
- 在情况 2 中,我们分配的对象(在托管堆中)超过了定义的阈值限制。
- 在第三种情况下,系统。调用 GC()方法。
我之前说过 GC。Collect()方法可用于强制垃圾收集机制。这个方法有许多重载版本。在下面的例子中,我们使用 GC。Collect(Int32),强制从第 0 代到指定代立即进行垃圾回收。
为了理解这些概念,让我们检查下面的程序和输出。我们通过调用系统使垃圾收集器开始工作。GC()(案例三)。
演示 1
using System;
namespace GarbageCollectionEx4
{
class MyClass
{
private int myInt;
//private int myInt2;
private double myDouble;
public MyClass()
{
myInt = 25;
//myInt2 = 100;
myDouble = 100.5;
}
public void ShowMe()
{
Console.WriteLine("MyClass.ShowMe()");
}
public void Dispose()
{
GC.SuppressFinalize(this);
Console.WriteLine("Dispose() is called");
Console.WriteLine("Total Memory:" + GC.GetTotalMemory(false));
}
~MyClass()
{
Console.WriteLine("Destructor is Called..");
Console.WriteLine(" After this destruction total Memory:" + GC.GetTotalMemory(false));
//To catch the output at end, we are putting some sleep
System.Threading.Thread.Sleep(60000);
}
}
class Program
{
public static void Main(string[] args)
{
Console.WriteLine("*** Exploring Garbage Collections.***");
try
{
Console.WriteLine("Maximum Generations of GC:" + GC.MaxGeneration);
Console.WriteLine("Total Memory:" + GC.GetTotalMemory(false));
MyClass myOb = new MyClass();
Console.WriteLine("myOb is in Generation : {0}", GC.GetGeneration(myOb));
Console.WriteLine("Now Total Memory is:{0}", GC.GetTotalMemory(false));
Console.WriteLine("Collection occured in 0th Generation:{0}", GC.CollectionCount(0));
Console.WriteLine("Collection occured in 1th Generation:{0}", GC.CollectionCount(1));
Console.WriteLine("Collection occured in 2th Generation:{0}", GC.CollectionCount(2));
//myOb.Dispose();
GC.Collect(0);//will call generation 0
Console.WriteLine("\n After GC.Collect(0)");
Console.WriteLine("Collection occured in 0th Generation:{0}", GC.CollectionCount(0));//1
Console.WriteLine("Collection occured in 1th Generation:{0}", GC.CollectionCount(1));//0
Console.WriteLine("Collection occured in 2th Generation:{0}", GC.CollectionCount(2));//0
Console.WriteLine("myOb is in Generation : {0}", GC.GetGeneration(myOb));
Console.WriteLine("Total Memory:" + GC.GetTotalMemory(false));
GC.Collect(1);//will call generation 1 with 0
Console.WriteLine("\n After GC.Collect(1)");
Console.WriteLine("Collection occured in 0th Generation:{0}", GC.CollectionCount(0));//2
Console.WriteLine("Collection occured in 1th Generation:{0}", GC.CollectionCount(1));//1
Console.WriteLine("Collection ccured in 2th Generation:{0}", GC.CollectionCount(2));//0
Console.WriteLine("myOb is in Generation : {0}", GC.GetGeneration(myOb));
Console.WriteLine("Total Memory:" + GC.GetTotalMemory(false));
GC.Collect(2);//will call generation 2 with 1 and 0
Console.WriteLine("\n After GC.Collect(2)");
Console.WriteLine("Collection occured in 0th Generation:{0}", GC.CollectionCount(0));//3
Console.WriteLine("Collection occured in 1th Generation:{0}", GC.CollectionCount(1));//2
Console.WriteLine("Collection ccured in 2th Generation:{0}", GC.CollectionCount(2));//1
Console.WriteLine("myOb is in Generation : {0}", GC.GetGeneration(myOb));
Console.WriteLine("Total Memory:" + GC.GetTotalMemory(false));
}
catch (Exception ex)
{
Console.WriteLine("Error:" + ex.Message);
}
Console.ReadKey();
}
}
}
输出
分析
再把理论过一遍,理解输出。然后尝试理解垃圾收集是如何发生的。我们可以看到,每当我们调用第 2 代时,其他代也会被调用。
您还可以看到,我们创建的对象最初放置在第 0 代中。
学生问:
先生,我们怎么能调用析构函数呢?
老师说:你不能调用析构函数。垃圾收集器负责这项工作。
学生问:
什么是托管堆?
老师说:当 CLR 初始化垃圾收集器时,它会分配一段内存来存储和管理对象。这种内存称为托管堆。
老师继续:一般来说,Finalize()(或者对象的析构函数)是被调用来清理内存的。因此,我们可以提供析构函数来释放我们的对象所拥有的一些未被引用的资源,在这种情况下,我们需要覆盖 Object 类的 Finalize()方法。
学生问:
垃圾收集器什么时候调用 Finalize()方法?
老师说:我们永远不知道。当发现没有引用的对象时,或者稍后当 CLR 需要回收一些内存时,它可能会立即调用。但是我们可以通过调用 System 来强制垃圾收集器在给定的点运行。GC.Collect(),它有很多重载版本。(我们已经通过调用 GC 看到了一个这样的用法。Collect(Int32))。
学生问:
为什么压缩是必要的?
老师继续说:当 GC 从堆中移除所有预期的对象(即那些没有引用的对象)时,堆中就包含了分散的对象。为了简单起见,你可以假设这是我们的堆。在垃圾收集器的清理操作之后,它可能如下所示(白色块表示空闲/可用块):
你可以看到,如果我们现在需要在我们的堆中分配五个连续的内存块,我们不能分配它们,尽管总的来说我们有足够的空间来容纳它们。为了处理这种情况,垃圾收集器需要应用压缩技术,将所有剩余的对象(活动对象)移动到一端,形成一个连续的内存块。因此,压缩后,它可能看起来像这样:
现在,我们可以轻松地在堆中分配五个连续的内存块。
这样,托管堆不同于旧的非托管堆。(这里我们不需要遍历地址链表来为新数据寻找空间。我们可以简单地使用堆指针;因此,实例化。网速更快)。压缩后,对象通常停留在相同的区域,因此访问它们也变得更容易和更快(因为页面交换更少)。这就是为什么微软也认为,虽然压缩操作的成本很高,但这种效果带来的总体收益更大。
学生问:
我们应该何时调用 GC。Collect()?
老师说:我已经提到过,调用 GC 通常是一个开销很大的操作。但是在一些特殊的场景中,我们绝对相信如果我们能够调用 GC,我们将会获得一些显著的好处。当我们在代码中取消引用大量对象时,可能会出现这样的例子。
另一个常见的例子是,当我们试图通过一些常见的操作来查找内存泄漏时(例如,重复执行测试来查找系统中的泄漏)。在每一次操作之后,我们可能会尝试收集不同的计数器来分析内存增长并获得正确的计数器。我们可能需要打电话给 GC。在每个操作开始时收集()。
我们将很快讨论内存泄漏分析。
学生问:
假设我们需要在应用运行的某个特定时间段回收一定量的内存。我们应该如何着手满足需求?
老师说。NET framework 提供了一个特殊的接口 IDisposable。
我们需要实现这个 IDisposable 接口,作为一个明显的动作,我们需要覆盖它的 Dispose()方法。当开发人员想要释放资源时,这是最佳实践。这种方法的另一个主要优点是,我们知道程序何时会释放未被引用的资源。
Points to Remember
当我们实现 IDisposable 接口时,我们假设程序员会正确地调用 Dispose()方法。一些专家仍然建议,作为预防措施,我们也应该实现一个析构函数。如果没有调用 Dispose(),这种方法可能会很有用。我同意这种双重实现在现实编程中更有意义。
C# 在这种情况下提供了特殊的支持。您可以使用“using
语句”来减少代码大小,使其更具可读性。它被用作 try/finally 块的语法快捷方式。
内存泄漏分析
一般来说,当计算机程序运行了很长一段时间,但未能释放不再需要的内存资源时,我们可以感受到内存泄漏的影响(例如,随着时间的推移,机器变得很慢,或者在最糟糕的情况下,机器可能会崩溃)。有了这些信息,很明显“它多快引起我们的注意”取决于我们应用的泄漏率。
考虑一个非常简单的例子。假设我们有一个在线应用,用户需要填写一些数据,然后单击提交按钮。现在假设应用的开发人员错误地忘记了在用户按下提交按钮时释放一些不再需要的内存,由于这种错误判断,应用每次点击会泄漏 512 字节。在一些初始点击中,我们可能不会注意到任何性能下降。但是,如果成千上万的在线用户同时使用该应用,会发生什么呢?如果 10 万个用户点击提交按钮,我们最终将损失 48.8 MB 的内存,10 亿次点击将损失 4.76 GB 的内存,以此类推。
简而言之,即使我们的应用或程序每次执行都会泄漏非常少量的数据,很明显,在一段时间后,我们会看到某种故障;例如,我们可能会注意到我们的设备正在与一个系统崩溃。OutOfMemoryException,或者设备中的操作变得非常慢,以至于我们需要经常重启我们的应用。
在像 C++这样的非托管语言中,当预期的工作完成时,我们需要释放内存;否则,在一段时间内,内存泄漏的影响将是巨大的。在托管代码中,CLR 的垃圾回收器将我们从这些情况中解救出来。尽管如此,仍有一些情况需要我们小心处理;否则,我们可能会注意到内存泄漏的影响。
如果垃圾收集器工作正常,我们可以说,在给定的时间点,如果一个对象没有引用,垃圾收集器将找到该对象,它将假设不再需要该对象,因此,它可以回收该对象占用的内存。
那么,我们如何检测泄漏呢?windbg.exe 是在大型应用中查找内存泄漏的常用工具。除此之外,我们可以使用其他图形工具,如微软的 CLR Profiler、SciTech 的 Memory Profiler、Red Gate 的 ANTS Memory Profiler 等等,来查找我们系统中的漏洞。许多组织都有自己的内存泄漏工具来检测和分析泄漏。
在 Visual Studio 的最新版本中,有一个诊断工具可以检测和分析内存泄漏。它非常人性化,易于使用,你可以在不同的时间段拍摄不同的内存快照。工具中的标记表示垃圾收集器活动。这个工具的真正强大之处在于,您可以在调试会话处于活动状态时实时分析数据。图形中的尖峰可以立即吸引程序员的注意力。演示 2 包括执行以下程序后的快照示例。
演示 2
using System;
using System.Collections.Generic;
namespace AnalyzingLeaksWithSimpleEventEx1
{
public delegate string MyDelegate(string str);
class SimpleEventClass
{
public int ID { get; set; }
public event MyDelegate SimpleEvent;
public SimpleEventClass()
{
SimpleEvent += new MyDelegate(PrintText);
}
public string PrintText(string text)
{
return text;
}
static void Main(string[] args)
{
IDictionary<int, SimpleEventClass> col = new Dictionary<int, SimpleEventClass>();
for (int objectNo = 0; objectNo < 500000; objectNo++)
{
col[objectNo] = new SimpleEventClass { ID = objectNo };
string result = col[objectNo].SimpleEvent("Raising an event ");
Console.WriteLine(objectNo);
}
Console.ReadKey();
}
}
}
来自诊断工具的快照
这是诊断工具窗口的屏幕截图;它包括三个不同的快照,用于分析给定时间点的内存使用情况。
我们可以看到堆的大小是如何随着时间的推移而增长的。如果你仔细观察,你会发现我们在代码中注册了一个事件
SimpleEvent += new MyDelegate(PrintText);
但从未注销过。
我还用微软的 CLR Profiler 展示了一个案例研究,来分析与程序相关的内存泄漏。这个工具是免费的,非常容易使用(尽管目前它已经失去了其他工具的普及)。您可以下载 CLR 探查器(用于。NET Framework 4)来自 https://www.microsoft.com/en-in/download/confirmation.aspx?id=16273
(注意:在撰写本文时,该链接工作正常,但将来可能会被更改/删除)。
让我们分析与不同程序相关的泄漏,但在这种情况下,我们将使用 CLR profiler。
考虑下面的程序(我在程序完成执行后拍摄了快照):
演示 3
using System;
using System.IO;//For FileStream
//Analysis of memory leak with an example of file handling
/* Special note: To use the CLR profiler:
use the command: csc /t:exe /out:AnalyzingLeaksWithFileHandlingEx1.exe Program.cs to compile
General Rule: csc /out:My.exe File.cs <- compiles Files.cs and creates My.exe
(you may need to set the PATH environment variable in your system)*/
namespace AnalyzingLeaksWithFileHandlingEx1
{
class Program
{
class FileOps
{
public void readWrite()
{
for (int i = 0; i < 1000; i++)
{
String fileName = "Myfile" + i + ".txt";
String path = @"c:\MyFile\" + fileName;
{
FileStream fileStreamName;
try
{
fileStreamName = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite);
//using (fileStreamName = new //FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite))
{
Console.WriteLine("Created file no : {0}", i);
//Forcefully throwing an exception, so that we cannot close //the file
if (i < 1000)
{
throw new Exception("Forceful Exception");
}
}
// FileStream not closed
// fileStreamName.Close();
}
catch (Exception e)
{
Console.WriteLine("Caught exception" + e);
}
}
}
}
}
static void Main(string[] args)
{
FileOps filePtr = new FileOps();
{
filePtr.readWrite();
Console.ReadKey();
}
}
}
}
来自 CLR 探查器的快照
CLR 探查器的示例报告可能如下所示:
分析
从这个截图中,可以看到垃圾收集器需要清理不同代的次数。此外,如果您打开相应的直方图,您可以看到异常的问题与文件处理有关。为了供您参考,我在程序执行后打开了最终堆字节直方图、对象终结直方图和重定位对象直方图。
这是最终的堆字节直方图:
这是最终确定的对象直方图:
这是重新定位的对象直方图:
让我们修改程序
现在我们在前面的程序中启用了using
语句,就像这样:
现在我们有了这份报告:
幸存对象的直方图如下:
重定位对象的直方图如下:
分析
现在我们可以看到不同之处:系统。FileStream 实例不再是一个问题。还要注意,垃圾收集器需要执行的任务比前一种情况少得多。
除此之外,您必须注意另一个重要的特征:如果我们分析 IL 代码,我们将看到一个 try/finally 块。
在这种情况下,编译器已经为我们创建了 try/finally 块,因为我们正在试验using
语句。我已经提到过using
语句充当 try/finally 块的语法快捷方式。
Points to Remember
根据微软的说法,using
语句确保即使在调用对象上的方法时出现异常,Dispose()方法也会被调用。通过将对象放在 try 块中,然后在 finally 块中调用 Dispose()方法,可以获得相同的结果;实际上,编译器就是这样翻译using
语句的。
所以,这一行代码:
using (FileStream fileStreamName = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite))
{
//lines of codes
}
转换成这个:
FileStream fileStreamName = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite);
try
{
//lines of codes
}
finally
{
if (fileStreamName != null) ((IDisposable) fileStreamName).Dispose();
}
老师继续说:让我们进入另一个讨论。在这种情况下,我们必须记住一个关键点:如果我们在 GC 中传递当前对象。SuppressFinalize()方法,则不会调用当前对象的 Finalize()方法(或析构函数)。
考虑以下三个程序及其输出,以理解我们如何在 C# 中回收内存。
演示 4
using System;
namespace GarbageCollectionEx1
{
class MyClass : IDisposable
{
public int Sum(int a, int b)
{
return a + b;
}
public void Dispose()
{
GC.SuppressFinalize(this);
Console.WriteLine("Dispose() is called");
}
~MyClass()
{
Console.WriteLine("Destructor is Called..");
System.Threading.Thread.Sleep(5000);
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** Exploring Garbage Collections.Example-1***");
MyClass myOb = new MyClass();
int sumOfIntegers = myOb.Sum(10,20);
Console.WriteLine("Sum of 10 and 20 is: " + sumOfIntegers);
myOb.Dispose();
Console.ReadKey();
}
}
}
输出
请注意,调用了 Dispose()方法,但没有调用对象的析构函数。
演示 5
现在我们已经注释掉了行//GC.SuppressFinalize(this);
并且我们没有调用 Dispose()方法;也就是说,两行都被注释掉了。
using System;
namespace GarbageCollectionEx2
{
class MyClass : IDisposable
{
public int Sum(int a, int b)
{
return a + b;
}
public void Dispose()
{
//GC.SuppressFinalize(this);
Console.WriteLine("Dispose() is called");
}
~MyClass()
{
Console.WriteLine("Destructor is Called..");
//To catch the output at end, we are putting some sleep
System.Threading.Thread.Sleep(15000);
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** Exploring Garbage Collections.Example-2***");
MyClass myOb = new MyClass();
int sumOfIntegers = myOb.Sum(10, 20);
Console.WriteLine("Sum of 10 and 20 is: " + sumOfIntegers);
//myOb.Dispose();
Console.ReadKey();
}
}
}
输出
在这种情况下调用了析构函数方法。
演示 6
现在我们注释掉前面程序中的行//GC.SuppressFinalize(this);
,但是调用 Dispose()。
using System;
namespace GarbageCollectionEx3
{
class MyClass : IDisposable
{
public int Sum(int a, int b)
{
return a + b;
}
public void Dispose()
{
//GC.SuppressFinalize(this);
Console.WriteLine("Dispose() is called");
}
~MyClass()
{
Console.WriteLine("Destructor is Called..");
//To catch the output at end,we are putting some sleep
System.Threading.Thread.Sleep(30000);
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** Exploring Garbage Collections.Example-3***");
MyClass myOb = new MyClass();
int sumOfIntegers = myOb.Sum(10, 20);
Console.WriteLine("Sum of 10 and 20 is: " + sumOfIntegers);
myOb.Dispose();
Console.ReadKey();
}
}
}
输出
Dispose()方法和析构函数现在都被调用。
恶作剧
如果你理解我们到目前为止讨论的程序,预测这里的输出。
注意,我们的程序结构类似于 GarbageCollectionEx1 唯一的区别是一个类包含另一个类。
using System;
namespace GarbageCollectionEx1._1
{
class MyClassA : IDisposable
{
MyClassB classBObject;
class MyClassB : IDisposable
{
public int Diff(int a, int b)
{
return a - b;
}
public void Dispose()
{
GC.SuppressFinalize(this);
Console.WriteLine("MyClass B:Dispose() is called");
}
~MyClassB()
{
Console.WriteLine("MyClassB:Destructor is Called..");
System.Threading.Thread.Sleep(5000);
}
}
public int Sum(int a, int b)
{
return a + b;
}
public int Diff(int a, int b)
{
classBObject = new MyClassB();
return classBObject.Diff(a, b);
}
public void Dispose()
{
GC.SuppressFinalize(this);
Console.WriteLine("MyClassA:Dispose() is called");
classBObject.Dispose();
}
~MyClassA()
{
Console.WriteLine("MyClassA:Destructor is Called..");
System.Threading.Thread.Sleep(5000);
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("*** Quiz:Exploring Garbage Collections.***");
MyClassA obA = new MyClassA();
int sumOfIntegers = obA.Sum(100, 20);
int diffOfIntegers = obA.Diff(100, 20);
Console.WriteLine("Sum of 10 and 20 is:{0}",sumOfIntegers);
Console.WriteLine("Difference of 10 and 20 is:{0}",diffOfIntegers);
obA.Dispose();
Console.ReadKey();
}
}
}
输出
恶作剧
现在让我们注释掉前面程序中的代码,如下所示:
会输出什么?
回答
分析
注意,这次调用了 MyClassA 的 Dispose()和 MyClassB 的析构函数。
摘要
本章回答了以下问题:
- 什么是垃圾收集(GC)?在 C# 中是如何工作的?
- 有哪些不同的 GC 代?
- 调用垃圾收集器有哪些不同的方法?
- 怎么才能强制 GC?
- 什么是内存泄漏?
- 内存泄漏的可能原因是什么?
- 怎样才能有效的使用 Dispose()方法来收集内存?
- 我们如何将内存泄漏分析与 Visual Studio 的诊断工具和微软的 CLR Profiler 结合使用?
十五、设计模式介绍
介绍
教师开始讨论:在一段时间内,软件工程师在软件开发过程中面临一个共同的问题。没有标准来指导他们如何设计和进行。当一个新成员(有经验或没有经验无关紧要)加入团队,并且他/她被分配从头开始做一些事情或者修改现有架构中的一些东西时,这个问题变得很重要。由于没有标准,理解系统架构需要巨大的努力。设计模式解决了这个问题,并为所有开发人员提供了一个公共平台。请注意,这些模式将在面向对象的设计中应用和重用。
大约在 1995 年,四位作者——Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 提交了他们的书《设计模式:可重用面向对象软件的元素》( Addison-Wesley,1995 年),他们在书中提出了软件开发中设计模式的概念。这些作者被称为“四人帮”。他们引入了 23 种设计模式,这些模式是基于软件开发人员长时间的经验开发出来的。现在,如果任何新成员加入开发团队,并且他知道新系统遵循一些特定的设计模式,他可以立即对该设计架构有所了解。因此,他可以在很短的时间内与其他团队成员一起积极参与开发过程。
现实生活中设计模式的第一个概念来自建筑建筑师克里斯托弗·亚历山大。他反复经历了一些常见的问题。因此,他试图以一种统一的方式用一个相关的解决方案(针对建筑设计)来解决这些问题。人们认为软件行业掌握了这个概念,因为软件工程师可以将他们的产品与构建应用联系起来。每个模式描述了一个在我们的环境中反复出现的问题,然后描述了该问题解决方案的核心,以这样一种方式,你可以使用这个解决方案一百万次,而不必以同样的方式做两次。—克里斯托弗·亚历山大
GoF 向我们保证,尽管模式是针对建筑和城镇描述的,但是相同的概念也可以应用于面向对象设计中的模式。我们可以用物体和界面来代替墙和门的原始概念。两者的共同点是,在核心上,两种类型的模式都试图在某些特定的上下文中找到一些解决方案。
1995 年,用 C++讨论了最初的概念。但是 C# 是 2000 年才出现的。在本书中,我们将尝试用 C# 来研究三种设计模式。如果你熟悉其他流行的编程语言,比如 Java、C++等等,那么你会很容易理解这些概念。我选择了简单且容易记忆的例子来帮助你发展这些概念。
要点
- 设计模式是针对常见问题的通用可重用解决方案。
- 我们的目标是制作一个如何解决问题的模板,可以在许多不同的情况下使用。
- 这些是对通信对象和类的描述,这些对象和类是为解决特定上下文中的一般设计问题而定制的。
- “四人帮”讨论了 23 种设计模式,它们可以分为三大类。
- 创建模式:这些模式抽象了实例化过程。我们试图创造一个独立于物体的构成、创造和表现的系统。以下五种模式属于这一类。
- 单一模式
- 原型模式
- 工厂方法模式
- 构建器模式
- 抽象工厂模式
- 结构模式:这里我们关注如何将类和对象组合成相对较大的结构。他们通常使用继承来组成接口或实现。以下七种模式属于这一类。
- 代理模式
- 轻量级模式
- 复合模式
- 桥接模式
- 立面图案
- 装饰图案
- 适配器模式
- 行为模式:这里我们关注的是算法和对象间的责任分配。我们也关注他们之间的交流过程。我们需要敏锐地观察这些物体相互联系的方式。以下 11 种模式属于这一类。
- 观察者模式
- 战略模式
- 模板方法模式
- 命令模式
- 迭代器模式
- 纪念品图案
- 状态模式
- 中介模式
- 责任链模式
- 访问者模式
- 解释程序模式
- 创建模式:这些模式抽象了实例化过程。我们试图创造一个独立于物体的构成、创造和表现的系统。以下五种模式属于这一类。
这里我们只探索三种设计模式:每一类一种。我选择了最简单的例子,以便你能容易地理解它们。但是你必须反复思考它们中的每一个,练习,尝试将它们与其他问题联系起来,并最终继续编写代码。这个过程会帮助你掌握这门学科。
单一模式
GoF 定义
确保一个类只有一个实例,并提供对它的全局访问点。
概念
一个特定的类应该只有一个实例。我们只会在需要的时候使用那个实例。
现实生活中的例子
假设你是一个运动队的成员。你的团队将在锦标赛中与另一个团队比赛。根据游戏规则,双方队长必须掷硬币来决定哪一方先开始游戏。所以,如果你的团队没有队长,你需要选一个人当队长。而且,你的队伍必须只有一个队长。
一个计算机世界的例子
在软件系统中,有时我们决定只使用一个文件系统。通常,我们用它来集中管理资源。
说明
在这个例子中,我们将构造函数设为私有,这样我们就不能以正常的方式实例化。当我们试图创建一个类的实例时,我们检查我们是否已经有一个可用的副本。如果我们没有这样的副本,我们将创建它;否则,我们将简单地重用现有的副本。
类图
解决方案资源管理器视图
下面显示了程序各部分的高级结构。
讨论
我们实现了一个非常简单的例子来说明单例模式的概念。这种方法被称为静态初始化。
最初,C++规范对于静态变量的初始化顺序有些模糊。但是。NET Framework 解决了这个问题。
这种方法的显著特点如下:
- CLR(公共语言运行时)负责变量的初始化过程。
- 当引用类的任何成员时,我们将创建一个实例。
- 公共静态成员确保一个全局访问点。它确认实例化过程将不会开始,直到我们调用类的实例属性(即,它支持惰性实例化)。sealed 关键字防止类的进一步派生(这样它的子类就不能滥用它),readonly 确保赋值过程将在静态初始化期间发生。
- 我们的构造函数是私有的。我们不能在外部实例化单例类。这有助于我们引用系统中可能存在的唯一实例。
履行
using System;
namespace SingletonPatternEx
{
public sealed class Singleton
{
private static readonly Singleton instance=new Singleton();
private int numberOfInstances = 0;
//Private constructor is used to prevent
//creation of instances with 'new' keyword outside this class
private Singleton()
{
Console.WriteLine("Instantiating inside the private constructor.");
numberOfInstances++;
Console.WriteLine("Number of instances ={0}", numberOfInstances);
}
public static Singleton Instance
{
get
{
Console.WriteLine("We already have an instance now.Use it.");
return instance;
}
}
//public static int MyInt = 25;
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Singleton Pattern Demo***\n");
//Console.WriteLine(Singleton.MyInt);
// Private Constructor.So,we cannot use 'new' keyword.
Console.WriteLine("Trying to create instance s1.");
Singleton s1 = Singleton.Instance;
Console.WriteLine("Trying to create instance s2.");
Singleton s2 = Singleton.Instance;
if (s1 == s2)
{
Console.WriteLine("Only one instance exists.");
}
else
{
Console.WriteLine("Different instances exist.");
}
Console.Read();
}
}
}
输出
挑战
考虑下面的代码。假设我们在 Singleton 类中增加了一行代码,如下所示:
假设我们的 Main()方法如下所示:
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Singleton Pattern Demo***\n");
Console.WriteLine(Singleton.MyInt);
Console.Read();
}
}
现在,如果您执行该程序,您将看到以下输出:
这是这种方法的缺点。在 Main()内部,您只尝试了 MyInt 静态变量,但是您的应用仍然创建了 Singleton 类的一个实例;也就是说,您对实例化过程的控制较少。每当您引用该类的任何成员时,实例化过程就会开始。
在大多数情况下,这种方法在. NET 中更受欢迎。
问答环节
问题 1:为什么我们要把事情复杂化?我们可以简单地编写我们的单例类,如下所示:
public class Singleton
{
private static Singleton instance;
private Singleton() { }
public static Singleton Instance
{
get
{
if (instance == null)
{
instance = new Singleton();
}
return instance;
}
}
}
答:这种方法可以在单线程环境中工作。但是考虑多线程环境。在多线程环境中,假设两个(或更多)线程试图对此进行评估:
if (instance == null)
如果他们发现实例还没有被创建,他们每个人都会尝试创建一个新的实例。因此,我们最终可能会得到该类的多个实例。
问题 2:有没有其他的方法来模拟单例设计模式?
答:方法有很多。他们每个人都有自己的优点和缺点。让我们讨论其中的一种,叫做双重检查锁定。MSDN 将这一方法概述如下:
//Double checked locking
using System;
public sealed class Singleton
{
/*We are using volatile to ensure that
assignment to the instance variable finishes before it's access*/
private static volatile Singleton instance;
private static object lockObject = new Object();
private Singleton() { }
public static Singleton Instance
{
get
{
if (instance == null)
{
lock (lockObject)
{
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
}
这种方法可以帮助我们在真正需要的时候创建实例。但是一般来说,锁定机构是昂贵的。
问题 3:为什么我们将实例标记为 volatile?
答案:我们来看看 C# 规范告诉我们的:“volatile 关键字表示一个字段可能会被同时执行的多个线程修改。声明为 volatile 的字段不受假定由单个线程访问的编译器优化的影响。这可确保字段中始终显示最新的值。”
简单地说,volatile 关键字帮助我们提供了一种序列化访问机制;也就是说,所有线程将按照它们的执行顺序观察任何其他线程的改变。请记住,volatile 关键字适用于类(或结构)字段;我们不能将它们应用于局部变量。
如果你有兴趣了解更多关于单例模式的不同方法,你可以看看 Jon Skeets 的评论。在他的文章 http://csharpindepth.com/Articles/General/Singleton.aspx
中,他讨论了各种选择(及其优缺点)来建立一个单例模式的模型。
适配器模式
GoF 定义
将一个类的接口转换成客户期望的另一个接口。适配器允许类一起工作,否则由于不兼容的接口而无法工作。
概念
下面给出的例子最好地描述了核心概念。
现实生活中的例子
这种类型最常见的例子是电源适配器。交流电源提供不同类型的插座,以适应所需的插座。考虑另一个例子。很多时候,我们需要通过总机用充电器给手机充电。但是,如果我们发现我们的移动充电器不能用于(或插入)特定的配电盘,我们需要使用适配器。在现实生活中,即使是翻译语言的译者也可以被认为遵循了这种模式。
因此,您可以这样想象:您的应用被插入到一个适配器(在本例中是 x 形的)中,该适配器使您能够使用预期的接口。没有适配器,您就不能连接应用和接口。
下图说明了使用适配器之前的情况:
下图说明了使用适配器后的情况:
一个计算机世界的例子
下面的例子很好地描述了这种模式最常见的用法。
说明
在这个例子中,我们可以很容易地计算出一个矩形的面积。请注意 Calculator 类及其 GetArea()方法。我们需要在 GetArea()方法中提供一个矩形来获取矩形的面积。现在假设我们想计算一个三角形的面积,但是我们的约束是我们想通过计算器的 GetArea()得到它的面积。我们如何做到这一点?
为了满足需求,我们为三角形制作了一个适配器(示例中为 CalculatorAdapter ),并在它的 GetArea()方法中传递一个三角形。该方法将三角形视为矩形,然后调用 Calculator 类的 GetArea()来获取面积。
类图
有向图文档
解决方案资源管理器视图
以下是该计划各部分的高级结构:
履行
using System;
namespace AdapterPattern
{
class Rect
{
public double l;
public double w;
}
class Calculator
{
public double GetArea(Rect r)
{
return r.l * r.w;
}
}
//Calculate the area of triangle using Calculator and Rect type as input.Whether we have Triangle.
class Triangle
{
public double b;//base
public double h;//height
public Triangle(int b, int h)
{
this.b = b;
this.h = h;
}
}
class CalculatorAdapter
{
public double GetArea(Triangle t)
{
Calculator c = new Calculator();
Rect r = new Rect();
//Area of Triangle=0.5*base*height
r.l = t.b;
r.w = 0.5*t.h;
return c.getArea(r);
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Adapter Pattern Demo***\n");
CalculatorAdapter cal=new CalculatorAdapter();
Triangle t = new Triangle(20,10);
Console.WriteLine("Area of Triangle is " + cal.GetArea(t)+" Square unit");
Console.ReadKey();
}
}
}
输出
让我们修改插图。
我们已经看到了适配器设计模式的一个非常简单的例子。但是如果你想遵循面向对象的设计原则,你可能需要修改这个例子。一个主要原因是我们需要使用接口,而不是使用具体的类。因此,记住前面的目标,让我们修改我们的插图。
以下是新示例的主要特征:
- Rect 类实现 RectInterface,CalculateAreaOfRectangle()方法帮助我们计算矩形对象的面积。
- Triangle 类实现了 TriInterface,CalculateAreaOfTriangle()方法帮助我们计算三角形对象的面积。
- 您的约束是您需要使用 RectInterface 来计算三角形的面积。为了达到这个目的,我们制作了一个可以与 RectInterface 对话的适配器。
- 现在注意使用这种模式的好处:矩形和三角形代码都不需要改变。我们使用了一个适配器来帮助我们与 RectInterface 对话,在高层次上,似乎通过使用 RectInterface 方法,我们正在计算一个三角形的面积。
- 请注意,GetArea(RectInterface r)方法不知道通过 TriangleAdapter,它正在获取一个三角形对象,而不是一个矩形对象。
- 注意另一个重要的事实和用法。假设您没有很多矩形对象,但是您的需求很大。通过这种模式,您可以使用一些行为类似矩形对象的三角形对象。怎么做?嗯,如果你仔细注意,你会发现通过使用适配器(虽然我们调用的是 CalculateAreaOfRectangle()),它实际上是在调用 CalculateAreaOfTriangle()。因此,我们可以根据需要修改方法体;例如,我们可以将三角形面积乘以 2.0,得到 200 平方英尺的面积。单位(就像一个长 20 个单位,宽 10 个单位的矩形对象)。在您需要处理面积为 200 平方单位的对象的情况下,这可能会有所帮助。
- 为了更好的可读性,在这个例子中,我们没有遵循 C# 标准的接口命名约定(也就是说,我们没有以“I”作为接口的开头)。
解决方案资源管理器视图
以下是该计划各部分的高级结构:
履行
using System;
namespace AdapterPattern_Modified
{
interface RectInterface
{
void AboutRectangle();
double CalculateAreaOfRectangle();
}
class Rect : RectInterface
{
public double Length;
public double Width;
public Rect(double l, double w)
{
this.Length = l;
this.Width = w;
}
public double CalculateAreaOfRectangle()
{
return Length * Width;
}
public void AboutRectangle()
{
Console.WriteLine("Actually, I am a Rectangle");
}
}
interface TriInterface
{
void AboutTriangle();
double CalculateAreaOfTriangle();
}
class Triangle : TriInterface
{
public double BaseLength;//base
public double Height;//height
public Triangle(double b, double h)
{
this.BaseLength = b;
this.Height = h;
}
public double CalculateAreaOfTriangle()
{
return 0.5 * BaseLength * Height;
}
public void AboutTriangle()
{
Console.WriteLine(" Actually, I am a Triangle");
}
}
/*TriangleAdapter is implementing RectInterface
.
So, it needs to implement all the methods defined
in the target interface.*/
class TriangleAdapter:RectInterface
{
Triangle triangle;
public TriangleAdapter(Triangle t)
{
this.triangle = t;
}
public void AboutRectangle()
{
triangle.AboutTriangle();
}
public double CalculateAreaOfRectangle()
{
return triangle.CalculateAreaOfTriangle();
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Adapter Pattern Modified Demo***\n");
//CalculatorAdapter cal = new CalculatorAdapter();
Rect r = new Rect(20, 10);
Console.WriteLine("Area of Rectangle is :{0} Square unit", r.CalculateAreaOfRectangle());
Triangle t = new Triangle(20, 10);
Console.WriteLine("Area of Triangle is :{0} Square unit", t.CalculateAreaOfTriangle());
RectInterface adapter = new TriangleAdapter(t);
//Passing a Triangle instead of a Rectangle
Console.WriteLine("Area of Triangle using the triangle adapter is :{0} Square unit", GetArea(adapter));
Console.ReadKey();
}
/*GetArea(RectInterface r) method does not know that through TriangleAdapter,it is getting a Triangle instead of a Rectangle*/
static double GetArea(RectInterface r)
{
r.AboutRectangle();
return r.CalculateAreaOfRectangle();
}
}
}
输出
注意
GoF 解释了两种适配器:类适配器和对象适配器。
- 对象适配器通过对象组合来适应。我们讨论的适配器是对象适配器的一个例子。在许多地方,您会注意到这个对象适配器的典型类图。
在我们的例子中,TriangleAdapter 是实现 RectInterface(目标接口)的适配器,Triangle 是被适配器。您可以看到适配器保存了 adaptee 实例(即在本例中实现了对象组合)。
- 类适配器通过子类化来适应。他们是多重继承的支持者。但是我们知道在 C# 中,不支持通过类的多重继承。(我们需要接口来实现多重继承的概念)。
下面是支持多重继承的类适配器的典型类图:
问答环节
问:如何在 C# 中实现类适配器设计模式?
答:我们可以子类化一个现有的类,并实现所需的接口。考虑下面的代码块。
class ClassAdapter : Triangle, RectInterface
{
public ClassAdapter(double b, double h) : base(b, h)
{
}
public void AboutRectangle()
{
Console.WriteLine(" Actually, I am an Adapter");
}
public double CalculateAreaOfRectangle()
{
return 2.0 * base.CalculateAreaOfTriangle();
}
}
但是我们必须注意,这种方法可能并不适用于所有场景;例如,当我们需要修改 C# 接口中没有指定的方法时。在这些情况下,对象适配器是有用的。
访问者模式
GoF 定义
表示要在对象结构的元素上执行的操作。Visitor 允许您定义一个新的操作,而不改变它所操作的元素的类。
概念
在这种模式中,我们可以将算法从它所操作的对象结构中分离出来。因此,我们可以向现有的对象结构添加新的操作,而无需修改那些结构。这样,我们就遵循了开放/封闭原则(允许扩展,但不允许修改实体,如类、函数、模块等)。).
现实生活中的例子
我们可以想象一个出租车预订的场景。当出租车到达我们家门口,我们进入出租车时,“来访”的出租车控制了交通。
一个计算机世界的例子
当我们插入公共 API 时,这种模式非常有用。然后,客户端可以使用访问类对某个类执行操作,而无需修改源代码。
说明
这里我们给出了一个简单的例子来描述访问者设计模式。你可以在这里看到两个类层次——最左边的一个代表原始的类层次。最右边的是我们创造的。IOriginalInterface 层次结构中的任何修改/更新操作都可以通过这个新的类层次结构来完成,而不会干扰原始代码。
考虑一个简单的例子。假设,在这个例子中,我们想要修改 MyClass 中的初始整数值,但是我们的约束是我们不能改变现有层次结构中的代码。
为了满足这一要求,在下面的演示中,我们将功能实现(即算法)从原始的类层次结构中分离出来,并将所有逻辑放入 visitor 类层次结构中。
类图
解决方案资源管理器视图
以下是该计划各部分的高级结构:
履行
using System;
namespace VisitorPattern
{
interface IOriginalInterface
{
void accept(IVisitor visitor);
}
class MyClass : IOriginalInterface
{
private int myInt = 5;//Initial or default value
public int MyInt
{
get
{
return myInt;
}
set
{
myInt = value;
}
}
public void accept(IVisitor visitor)
{
Console.WriteLine("Initial value of the integer:{0}", myInt);
visitor.visit(this);
Console.WriteLine("\nValue of the integer now:{0}", myInt);
}
}
interface IVisitor
{
void visit(MyClass myClassElement);
}
class Visitor : IVisitor
{
public void visit(MyClass myClassElement)
{
Console.WriteLine("Visitor is trying to change the integer value");
myClassElement.MyInt = 100;
Console.WriteLine("Exiting from Visitor- visit");
}
}
class Program
{
static void Main(string[] args)
{
Console.WriteLine("***Visitor Pattern Demo***\n");
IVisitor v = new Visitor();
MyClass myClass = new MyClass();
myClass.accept(v);
Console.ReadLine();
}
}
}
输出
问答环节
问题 1:什么时候我们应该考虑实现访问者设计模式?
答:当我们需要在不修改现有架构的情况下添加功能时。这是访问者模式的主要目标。对于这种模式,封装不是主要考虑的问题。
问题 2:这种模式有什么缺点吗?
答:这里封装不是它的主要关注点。因此,在许多情况下,我们可能会使用访问者来打破封装。如果我们经常需要向现有架构添加新的具体类,那么访问者层次结构将变得难以维护。例如,假设我们想在原来的层次结构中添加另一个具体的类。在这种情况下,我们需要相应地修改 visitor 类的层次结构。
摘要
本章介绍了
- 设计模式
- 三个四人组设计模式:单体模式、适配器模式和 C# 实现的访问者模式
十六、获奖感言和未来之路
恭喜你。你已经到达旅程的终点。我们所有人都可以开始一段旅程,但只有少数人能够用心完成它。所以,你是少数拥有非凡能力成功完成这段距离的人之一。我相信你已经享受了你的学习经历。同样的经历可以帮助你学习这一类的任何新话题。前面我说过,如果你反复思考书中讨论的问题和答案,你会对它们更加清晰,你会对它们感到自信,你会在编程世界中重塑自己。
完整地介绍 C# 语言及其所有特性将需要更多的页面,这将使这本书变得太大而难以消化。那么,下一步是什么?你不应该忘记这个基本原则:学习是一个持续的过程。这本书试图鼓励你深入学习核心主题,这样你就可以顺利地学习高级主题和即将推出的特性。
一旦你读完这本书,你就可以深入探索高级特性,比如集合、LINQ、并发、序列化、反射、多线程、并行编程、异步编程、设计模式等等。有了这些,你会发现这本书里描述的概念对你很有帮助。
最后,我对你有一个要求:批评是允许的,但同时,请让我知道你喜欢这本书的什么。总的来说,批评总是容易的,但是需要艺术的观点和开放的心态来发现与任何类型的工作相关的真正努力。谢谢大家,编码快乐!
第一部分:进入面向对象的世界
Enter into the World of OOP
本节的亮点:
- 什么是面向对象编程(OOP)?
- 为什么我们需要这种类型的编程?
- 我们如何用 C# 的核心积木来涵盖 OOP 的基本概念?
- 如何才能让我们的 C# 应用变得有吸引力和高效?
第二部分:熟悉一些高级概念
Get Familiar with Some Advanced Concepts
本节的亮点:
- 高级 C# 编程之路
- C# 的五个常青先进概念
第三部分:成为现实世界中的英雄
Become a Hero in the Real World
本节的亮点:
- 编程中最吸引人的设计模式概念概述
- 三个重要的设计模式,包括真实世界的例子和计算机世界的例子