C--编程考试-70-483-MCSD-指南-全-

C# 编程考试 70-483(MCSD)指南(全)

原文:zh.annas-archive.org/md5/05b4450109c9546908138b0bda090a53

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

MCSD 70-483 考试是针对 C#开发者的入门级微软认证考试,广泛用于衡量他们在 C#编程领域的专业知识。本书是认证指南,旨在帮助你为认证考试中评估的技能做好准备,并促进使用 C#培养解决问题的能力。本书的每一章都设计为微软 MCSD 70-483 考试的备考材料。

对于那些在 C#方面经验不多的人,我们在本书的开头添加了一些章节,以提供有关 C#编程的基本知识。这些知识不仅可以帮助你通过认证,还可以帮助你成为一名更好的 C#开发者。

本书面向对象

本书旨在为经验丰富的开发者和那些打算在不久的将来参加 70-483 编程 C#认证考试的新手提供帮助。本书提供了考试中所有主题的广泛知识。为了更好地理解,本书的每一章都配有代码示例和评估问题。

为了让初学者学习 C#的路径更加容易,我们在本书的前三章中也尝试介绍了 C#和.NET Framework 的基础知识。为了从本书中获得最大价值,你应具备对任何编程语言的合理理解;例如,C、C++或 C#。

本书涵盖内容

第一章,学习 C#基础知识,专注于 C#语言的基础。在本章中,你将了解.NET Framework 的底层架构以及所有组件,如垃圾回收器、公共语言运行时、基本库等是如何相互交互的。我们将分析 C#与其他编程语言(如 C++和 C)之间的相似之处。我们还将探讨使 C#不同于 C++和 C 的特性。最后,通过一个非常基础的 Hello World 程序,你将了解 C#程序的不同组件,如类、命名空间、程序集等。

第二章,理解类、结构和接口,在第一章的基础上进一步介绍了 C#应用程序的一些基础知识。在本章中,你将了解 C#程序中可用的不同访问修饰符,以及它们如何被用来实现代码结构和降低复杂性。我们还将探讨 C#中可用的不同原始数据类型。在查看类和结构变量时,我们将看到引用类型变量和数据类型变量之间的区别。然后,我们将探讨继承,这是 C#编程的一个重要方面。我们将介绍 C#中继承的实现方式以及它与接口实现的区别。

第三章,理解面向对象编程,专注于面向对象编程的四个支柱(面向对象编程OOP)。通过示例,你将学习到每个支柱——封装、多态、抽象和继承——是如何实现的。在探讨继承时,我们将扩展第二章,理解类、结构和接口,并探讨一些其他关键方面,例如方法重写、虚方法和密封和抽象类。在探讨多态时,我们将学习如何在 C#程序中实现编译/静态多态和运行时多态。

第四章,实现程序流程,专注于开发者如何在 C#中管理程序流程。换句话说,本章帮助你理解如何使用 C#中可用的语句来控制程序和做出决策。我们将涵盖各种布尔表达式,如if/elseswitch,它们根据条件控制代码的流程。本章还概述了各种运算符,如条件运算符和相等运算符(<>==),它们控制代码的流程。除了运算符和决策语句之外,本章还帮助你理解遍历集合(for循环、while循环等)和显式跳转语句。

第五章,创建和实现事件和回调,专注于 C#中的事件和回调,它们非常重要,并提供了对程序的更多控制。你将了解使用事件和回调的发布/订阅模型,并专注于委托。然后,我们将探讨启动委托和 lambda 表达式的不同方法。我们还将花时间介绍一个名为 lambda 运算符的新运算符,它在 Lambda 表达式中使用。

第六章,管理和实现多线程,专注于处理长时间运行程序中的响应性以及我们如何让用户了解他们的进度。我们还将探讨如何有效地使用每台计算机都带有的多核处理能力。我们将花时间研究线程、线程属性以及如何使用任务和执行多线程操作。

第七章,实现异常处理,专注于理解如何以有助于程序在各种情况下运行的方式构建你的程序;如何处理未处理的异常;如何在执行完成后使用trycatchfinally关键字并清理资源。阅读本章后,你将理解异常以及如何在你的程序中使用它们。你还将能够创建自定义异常。

第八章,在 C#中创建和使用类型,主要关注 C#中可用的不同类型的变量。在第二章,理解类、结构和接口中,我们向用户介绍了 C#中可用的引用和数据类型变量。在本章中,我们将在此基础上扩展知识,并学习这两种变量类型如何在内存中维护。我们将探讨用于保存引用类型变量的托管堆内存结构。我们还将探讨在 C#中使用变量类型指针类型的使用。使用指针,我们可以实现其他情况下在 C#中视为不安全的内存相关操作。我们还将探讨 C#中一些重要特性,如属性、命名参数和可选参数,这些特性在 C#编程中可用。我们将探讨如何使用装箱将值类型变量转换为对象,并相应地使用拆箱将对象转换回值类型变量。然后,我们将探讨在 C#中对字符串表示形式可能执行的不同操作。我们还将探讨如何使用stringbuilder来优化 C#程序的性能。

第九章,管理对象生命周期,主要关注垃圾回收器如何在.NET Framework 中管理内存的分配和释放。在本章中,你将学习 C#中托管代码和非托管代码之间的区别。我们将探讨垃圾回收器用于内存分配和释放的标记-压缩算法。我们还将探讨我们可以管理分配给非托管代码的内存的可行方法。我们还将探讨如何在 C#应用程序中实现终结化以及这样做对性能的影响。我们将介绍 IDisposable 接口并理解它与finalize块的差异。我们还将查看代码示例,其中我们将结合使用 IDisposable 接口和finalize块,以实现 C#应用程序的最佳内存管理。最后,我们将探讨在 C#应用程序中使用using块的使用。

第十章,使用反射在运行时查找、执行和创建类型,主要关注理解.NET Framework 如何允许我们读取/创建元数据,以及我们如何使用反射在运行时读取元数据并对其进行处理。我们将重点关注使用属性、创建自定义属性以及我们如何在运行时检索属性信息。我们还将涵盖如何使用反射来创建类型、访问属性和调用方法。

第十一章,验证应用程序输入,主要关注验证将访问您的应用程序的不同类型用户的输入,以及我们如何避免应用程序根据用户输入崩溃。本章的目的是理解在您的应用程序中验证输入数据的重要性,.NET Framework 中可用的不同验证技术,以及验证 JSON 数据和 XML 数据的方法。

第十二章,执行对称和不对称加密,主要关注如何保持信息的安全,我们在通过互联网传输信息时可以采取的措施,以及理解密码学来加密和解密明文。阅读本章后,您将了解如何加密和解密文本,并熟悉.NET Framework 中可用于此类练习的不同算法。

第十三章,管理程序集和调试应用程序,主要关注如何管理.NET 程序集、调试应用程序以及如何使用跟踪。本章涵盖了我们已经学过的验证技术,以及针对这些场景的异常处理,以及监控代码块。我们还将探讨用于调试应用程序的 Visual Studio 功能或工具。之后,我们将探讨程序集的版本管理以及如何使相同的程序集并排存在,以及如何分发这些程序集而不影响他人。

第十四章,执行 I/O 操作,主要关注如何在 C#应用程序中执行 I/O 操作。在本章中,我们将探讨 C#中访问 I/O 文件的不同操作以及来自外部 Web 服务的操作。通过代码示例,您将看到我们如何使用System.IO辅助类从文件中读取/写入数据。我们还将探讨 C#提供的FileFileInfo辅助类,用于执行 I/O 操作。然后,我们将探讨WebRequestWebResponse辅助类,这些类帮助我们与来自外部服务/应用程序的数据进行交互。最后,我们将探讨如何在应用程序中异步执行这些操作。

第十五章,使用 LINQ 查询,专注于 LINQ 查询在 C#中的实现。在本章中,您将了解 LINQ 查询的基础知识,了解不同的组件以及它们如何在.NET Framework 中构建。然后,我们将探讨 C#中帮助实现 LINQ 查询的功能。其中一些功能是必需的,而另一些则有助于我们从 LINQ 查询中获得最佳结果。通过代码示例,您将了解隐式类型变量、对象初始化语法、Lambda 表达式、扩展方法和匿名类型等的实现。然后,我们将探讨 LINQ 查询中可用的不同操作。通过代码示例,您将了解可以使用这些运算符的不同场景。最后,我们将探讨如何使用 LINQ 查询对 XML 文件执行操作。

第十六章,序列化、反序列化和集合,专注于.NET Framework 中可用的不同序列化和反序列化方法,如 XML 序列化、JSON 序列化和二进制序列化。我们还将探讨如何在 Web 服务中定义数据合同,以便在不同应用程序之间交换数据。然后,我们将探讨 C#中可用的不同集合对象,如数组、列表、字典、队列和栈,并了解它们如何用于存储和消耗数据。

为了充分利用本书

为了从本书中获得最佳效果,建议您具备以下条件:

  • 对软件开发的基本理解

  • 对任何常见编程语言的基本理解,例如 C、C++或 C#

在本书的整个内容中,我们将探讨不同的 C#代码示例,并使用 Visual Studio 2017 社区版进行代码示例。以下硬件要求对于 Visual Studio 是必需的:

  • 操作系统:

    • Windows 10 或更高版本

    • Windows Server 2016:标准版和数据中心版

    • Windows 8.1

    • Windows Server 2012 R2:基础版、标准版和数据中心版

    • Windows 7 SP1

  • 硬件要求:

    • 至少 2 GB 的 RAM

    • 1.8 GHz 或更快的处理器

  • 其他要求:

    • 系统管理员权限

    • .NET Framework 4.5 或更高版本

  • Visual Studio:本书中的所有代码示例都是在 Visual Studio Community Edition 2017 上编译的(您也可以使用更高版本的 Visual Studio)。它可在www.visualstudio.com/downloads/进行安装。

为了更好地理解,建议读者阅读每章末尾的所有评估以及本书末尾的模拟测试。

建议读者阅读每章提供的代码示例,并在每章之后进行自我练习。

下载示例代码文件

您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packt.com登录或注册。

  2. 选择支持选项卡。

  3. 点击代码下载。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

文件下载完成后,请确保您使用最新版本的以下软件解压或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Programming-in-C-Sharp-Exam-70-483-MCSD-Guide。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他代码包,这些代码包来自我们丰富的书籍和视频目录,可在github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781789536577_ColorImages.pdf

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“默认情况下,名为Main的方法也将添加到类中。”

代码块设置如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“要创建新项目,请单击文件 | 新项目,并选择控制台应用程序(.NET Framework)作为项目类型。”

警告或重要提示看起来是这样的。

小贴士和技巧看起来是这样的。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书籍标题,并给我们发送电子邮件至customercare@packtpub.com

错误清单:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata,选择您的书籍,点击错误提交表单链接,并输入详细信息。

盗版:如果您在互联网上发现任何形式的我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过发送链接至 copyright@packt.com 与我们联系。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

如需更多关于 Packt 的信息,请访问 packt.com

第一章:学习 C#的基础知识

简而言之,编程是编写一组指令的艺术,这些指令指示计算机执行特定任务。在早期,由于内存和速度的限制,编程能力有限。因此,程序员编写了粗糙且简单的任务,执行基本工作。随着时间的推移和更多的增强,人们开始用过程式语言(如 COBOL)编写程序。

尽管语言完成了工作,但程序仍有一些局限性。在应用程序的不同地方编写可重用组件或设计模式的空间不大。因此,应用程序难以维护,可扩展性也是一个挑战。

因此,人们努力开发高级编程语言,以克服过程式语言面临的所有这些挑战。随着时间的推移,设计了许多不同的编程语言。C 是在 1972 年至 1973 年之间开发的。当时,它是一种低级的过程式语言,依赖于底层平台,如 Linux 或 Windows。C 也没有完全利用面向对象编程的概念(我们将在第三章中介绍,理解面向对象编程)。

C++于 1998 年推出,为程序员提供了在保留 C 提供的机器级编程功能的同时,有效使用面向对象编程概念的能力。在本书中,我们将探讨 C#编程的各个方面。C#保留了 C++的 OOP 功能,使我们能够编写与底层硬件实现无关的程序。

在本章中,我们将介绍 C#的基础知识。我们将回顾其底层基础,并深入探讨.NET Framework 架构。我们将学习公共语言运行时如何将应用程序代码转换为机器级代码。我们将了解 C#与其他语言,如 C 和 C++的不同之处和相似之处。然后,我们将学习 C#程序中的不同组件,如类、命名空间和程序集。作为任何新语言的常见传统,我们将查看Hello World程序的实现。

本章包括以下主题:

  • 比较 C#与 C 和 C++

  • .NET Framework

  • .NET Framework 的发布版本

  • C#的 Visual Studio

  • C#的基本结构

  • 在 C#中创建一个基本程序

技术要求

为了更好地理解本章,你需要以下知识:

  • 对软件开发有基本了解

  • 对常见编程语言(C、C++和 C#)有基本了解

在本书的整个过程中,我们将通过 C#的不同代码示例,并使用 Visual Studio 2017 社区版进行代码示例。以下硬件要求对 Visual Studio 是必需的:

  • 操作系统

    • Windows 10 或更高版本

    • Windows Server 2016:Standard 和 Datacenter

    • Windows 8.1

    • Windows Server 2012 R2:Essential、Standard 和 Datacenter

    • Windows 7 SP1

  • 硬件要求

    • 最小 2 GB 的 RAM

    • 1.8 GHz 或更快的处理器

  • 附加要求:

    • 系统管理员权限

    • .NET Framework 4.5

  • Visual Studio:本书中的所有代码示例都是在 Visual Studio Community Edition 2017 上编译的。它可以在以下网址安装:www.visualstudio.com/downloads/

本章的示例代码可以在 GitHub 上找到:github.com/PacktPublishing/Programming-in-C-sharp-Exam-70-483-MCSD-Guide/tree/master/Chapter01

比较 C#与 C 和 C++

在本节中,我们将探讨 C#与其他编程语言(如 C 和 C++)的比较。我们将探讨使 C#与这些语言相似和不同的方面。

C# 与 C 的比较

如果你之前在 C#和 C 上有所开发,你会意识到它们遵循类似的代码语法,例如使用分号,以及类似的方法声明;这两种语言彼此之间非常不同。就像在 C 中一样,我们可以使用相同的数据类型声明数据变量,例如CharInteger。以下特性使 C#与 C 不同:

特性 C# C
面向对象编程 面向对象编程是任何高级编程语言的主要精髓,C#允许我们利用面向对象编程的四个主要支柱:封装、多态、继承和抽象。在第三章,理解面向对象编程中,我们将详细探讨这一点。 作为编程语言,C 不支持多态、封装和继承。它不提供诸如函数重载、虚函数和继承等功能。
异常处理 异常处理是在应用程序执行过程中发生运行时错误的处理过程。C#为我们提供了异常处理功能,帮助我们更好地处理这些场景。在第七章,实现异常处理中,我们将详细探讨这一点。 C 也没有提供任何异常处理功能。
类型安全 程序中声明的每个变量都有一个类型。在典型的类型安全语言中,在程序编译阶段,编译器将验证分配给变量的值,如果分配了错误的类型,则会引发编译时错误。C# 是一种类型安全的语言。然而,在第八章,C#中的类型创建和使用,我们将了解到它还允许你使用关键字 UnSafe 来使用指针。 C 语言实现了类型安全,尽管有一些例外。有一些内置函数,如 printf,并不强制只传递字符字符串给它们。

让我们现在看看 C# 与另一种语言 C++ 的比较。在探索了 C# 和 C++ 的比较之后,我们还将探讨 .NET 框架如何使 C# 相比 C 和 C++ 成为一种平台无关的语言。

C# 与 C++

在大多数编程场景中,C++ 可以被归类为 C 的扩展,并且可以执行所有用 C 编写的代码。它提供了面向对象编程的所有功能,同时保留了 C 提供的功能。C# 和 C++ 之间有一些共同的特征。就像在 C# 中一样,我们可以在 C++ 中实现面向对象编程、异常处理和类型安全。然而,也有一些事情使 C# 与 C++ 不同,并且更类似于 Java。

在我们探讨 C# 和 C++ 之间的差异和相似性之前,我们必须了解一些与面向对象编程相关的关键概念。

实现面向对象编程的语言可以分为两类:

  • 完全面向对象的编程语言

  • 纯面向对象的编程语言

一门语言如果实现了至少四个核心支柱:抽象封装多态继承,则被归类为完全面向对象的编程语言。

另一方面,当一门语言除了是面向对象编程之外,只包含类和对象时,它可以被定义为纯面向对象的编程语言。这意味着所有声明的所有方法、属性和属性都必须在类内部,并且也不应该有任何预定义的数据类型,例如 charint

在 C# 的情况下,我们可以有预定义的数据类型。在第二章,理解类、结构和接口,我们将详细探讨这些预定义的数据类型。这使得 C# 成为一个 完全面向对象的编程语言,而不是一个 纯面向对象的编程语言

另一方面,在 C++ 的情况下,我们可以定义不属于任何类的函数。这也使它成为一个 完全面向对象的编程语言

现在,让我们看看 C# 和 C++ 之间的一些相似之处和不同之处:

特性 C# C++
面向对象编程 如前所述,C# 是一种完全面向对象的语言。 与 C# 类似,C++ 也是一种完全面向对象的语言。
内存管理 C# 有一个内置的垃圾回收器来管理内存的分配和释放。在第九章,管理对象生命周期中,我们将详细了解 C# 中的内存管理。 C++ 没有内置的垃圾回收器。因此,开发者负责处理内存的分配和释放。
继承 C# 不支持多重继承。在第二章,理解类、结构和接口中,我们将学习这意味着什么;然而,简单来说,这意味着一个类一次只能从一个类继承。 与 C# 相比,C++ 允许我们实现多级继承。
指针的使用 虽然 C# 允许我们在代码中使用指针,但我们需要用一段 UnSafe 的代码来声明它。我们将在第八章,在 C# 中创建和使用类型中详细探讨这一点。 C++ 允许我们在代码的任何地方使用指针,而不需要任何隐式声明。

在前两节中,我们看到了 C# 与 C 和 C++ 的比较。然而,有一个重要的区别我们还没有探讨。这个特性是平台无关性,也是微软推出 C# 的主要原因之一。当我们使用 C 和 C++ 时,我们需要根据底层平台特性(如操作系统)来编译代码。

假设我们用 C 或 C++ 编写一个应用程序并编译它。在编译阶段,编译器将代码转换成与底层平台兼容的本地语言代码。这基本上意味着在 Windows 机器上开发和编译的 C++ 应用程序将仅与 Windows 机器兼容。如果编译后的位被用于不同的系统,例如 Linux,它将无法在那里运行。

这种差异是由于编译器及其与底层操作系统(如 Linux 和 Windows)的兼容性不同而造成的。以下是一些在 Linux 和 Windows 上可用的、用于 C 和 C++ 的常见编译器:

  • Linux: GCC, Failsafe C, 和 SubC

  • Windows: Microsoft Windows SDK, Turbo C++, 和 SubC

在 C# 开发之前,与 Java 等其他编程语言相比,这个平台依赖性问题是一个主要的缺点。在 Java 中,当应用程序编译时,它不会直接转换成机器码。相反,它会被转换成一种称为 ByteCode 的中间语言。ByteCode 是平台无关的,可以在不同的平台上部署。

当微软引入 C# 时,他们在语言中融入了同样的原则。当用 C# 编写的应用程序被编译时,它不会转换为与机器兼容的本机代码,而是首先被翻译成一种中间语言,通常称为 IL 代码

在生成 IL 代码后,公共语言运行时CLR)开始发挥作用。CLR 是一个运行时环境,位于底层机器的内存中,并将 IL 代码转换为特定于机器的本机代码。这个过程是即时编译JIT)。在下一节中,我们将探讨 .NET Framework 的底层平台,它为 C# 应用程序处理所有这些。

.NET Framework

.NET Framework 是一个软件开发框架,我们可以用 C#、ASP.NET、C++、Python、Visual Basic 和 F# 等多种语言编写程序。

微软于 2002 年发布了 .NET 1.0 的第一个版本。当前 .NET Framework 的版本是 4.8。本书中编写的代码将基于此版本的 .NET Framework 4.7.2。

.NET Framework 提供了跨不同编程语言的语言互操作性。在 .NET Framework 中编写的应用程序在称为 CLR 的环境或虚拟机组件中执行。

以下图表展示了 .NET Framework 中的不同组件:

在前面的图表中,请注意以下几点:

  • 在层次结构的顶部,我们有应用程序或我们用 .NET 编写的程序代码。它可以像我们在本章中将要创建的简单的 Hello World 控制台应用程序程序一样简单,也可以像编写多线程应用程序一样复杂。

  • 应用程序基于一组类或设计模板,这构成了一个类库。

  • 这些应用程序中编写的代码随后由 CLR 处理,CLR 使用 即时JIT)编译器将应用程序代码转换为机器代码。

  • 机器代码特定于底层平台的属性。因此,对于不同的系统,如 Linux 或 Windows,它将是不同的。

关于 .NET Framework 的更多信息,请参阅微软的官方文档:docs.microsoft.com/en-us/dotnet/framework/get-started/overview

在下一节中,我们将详细学习 .NET Framework 如何相互交互。

语言/应用程序

语言表示在 .NET Framework 中可以构建的不同类型的应用程序。如果您是 .NET Framework 的新手,您可能不熟悉这里列出的某些应用程序:

  • ADO.NET:在 ADO.NET 应用程序中,我们编写程序以从 SQL Server、OLE DB 和 XML 源等数据源访问数据。

  • ASP.NET:在 ASP.NET 应用程序中,我们编写程序以使用 C#、HTML、CSS 等构建网站和服务等网络应用程序。

  • CORE:在.NET Core 应用程序中,我们编写支持跨平台功能的程序。这些程序可以是 Web 应用程序、控制台应用程序或库。

  • Windows Forms:在 Windows Forms 应用程序中,我们编写提供桌面、平板电脑和移动设备客户端应用程序的程序。

  • WPF:在 WPF 或 Windows Presentation Foundation 中,我们编写提供基于 Windows 应用程序用户界面的程序。它仅在 Windows 支持的平台上运行,例如 Windows 10、Windows Server 2019 和 Windows Vista。

  • WCF:在 WCF 或 Windows Communication Foundation 中,我们编写提供一组 API,或者用更简单的说法,提供服务的程序,用于在两个不同的系统之间交换数据。

  • LINQ:在 LINQ 中,我们编写提供.NET 应用程序数据查询能力的程序。

  • Parallel FX:在 Parallel FX 中,我们编写支持并行编程的程序。这涉及到编写利用 CPU 能力的程序,通过并行执行多个线程来完成任务。

类库

.NET Framework 中的类库由一组接口、类和值类型组成,应用程序就是基于这些类型构建的。

这些集合组织在不同的容器中,称为命名空间。它们是一组标准类库,可以在应用程序的不同目的中使用。以下是一些命名空间:

  • Microsoft.Sharp:这个库包含支持 C#源代码编译和代码生成的类型,以及支持动态语言运行时与 C#之间转换的类型。

  • Microsoft.Jscript:这个库包含支持使用 JavaScript 进行编译和代码生成的类。

  • Microsoft.VisualBasic:这个库包含支持 Visual Basic 编译和代码生成的类。

  • Microsoft.VisualC:这个库包含支持 Visual C++编译和代码生成的类。

常见语言运行时(CLR)

CLR 是一个运行时环境,它位于底层机器的内存中,将 IL 代码转换为本地代码。本地代码针对代码运行的底层平台是特定的。这为在.NET Framework 上构建的典型应用程序提供了平台独立性功能。CLR 提供的其他一些功能如下:

  • 内存管理:CLR 为应用程序提供自动分配和释放内存的功能。因此,开发者不需要显式编写代码来管理内存。这消除了可能导致应用程序性能下降的内存泄漏等问题。CLR 通过垃圾回收器管理内存的分配和回收,其内存分配方式如下:

    • 分配内存:当应用程序在 CLR 中执行时,它为其执行保留了一块连续的内存空间。保留的空间被称为托管堆。堆维护一个指向内存地址的指针,其中将分配下一个在过程中定义的对象。

    • 释放内存:在程序的运行时执行过程中,垃圾收集器在预定的时间运行并检查在堆中分配的内存是否仍然在程序执行的范围内。

    • 它确定程序是否仍然基于根或内存对象集合仍在程序的范围内使用内存。如果根据根中的集合,任何内存分配都不可达,垃圾收集器将确定在该内存空间中分配的内存可以被释放。

    • 我们将在第九章,管理对象生命周期中详细探讨内存管理。

  • 异常处理:当应用程序正在执行时,它可能会导致某些执行路径,这些路径可能会在应用程序中产生一些错误。以下是一些常见的例子:

    • 当应用程序尝试访问一个文件,但该文件不在指定的目录路径中时。

    • 当应用程序尝试在数据库上执行查询,但应用程序与底层数据库之间的连接已断开/未打开时。

    • 当我们进入第七章,实现异常处理时,我们将详细探讨异常处理。

在下一节中,我们将查看.NET Framework 的发布历史以及它与不同版本的 CLR 和 C#的兼容性。

.NET Framework 发布版本

.NET Framework 1.0 的第一个版本于 2002 年发布。就像.NET Framework 一样,CLR 和 C#也有不同的版本。不同版本的.NET Framework 与某些特定的 CLR 和 C#版本兼容。以下表格提供了不同.NET Framework 版本与其兼容的 CLR 版本之间的兼容性映射:

.NET Framework CLR 版本
1.0 1.0
1.1 1.1
2.0/3.0/3.5 2.0
4.0/4.5/4.5.1/4.5.2/4.6/4.6.1/4.6.2/4.7/4.7.1/4.7.2/4.8 4

以下表格将.NET Framework 的不同版本与其兼容的 C#版本相匹配,并列出在该版本的 C#中发布的一些重要编程特性:

版本 .NET Framework C#中的重要特性
C# 1.0/1.1/1.2 .NET Framework 1.0/1.1 C#的第一个版本
C# 2.0 .NET Framework 2.0 泛型匿名方法、可空类型和迭代器
C# 3.0 .NET Framework 2.0/3.0/3.5/4.0 查询表达式、Lambda 表达式和扩展方法
C# 4.0 .NET Framework 2.0/3.0/3.5/4.0 动态绑定、命名/可选参数和嵌入式互操作类型
C# 5.0 .NET Framework 4.5 异步成员
C# 6.0 .NET Framework 4.6/4.6.2/4.7/4.7.1/4.7.2 异常过滤器、字符串插值、nameof运算符和字典初始化器
C# 7.0/7.1/7.2/7.3 .NET Framework 4.6/4.6.2/4.7/4.7.1/4.7.2 输出变量、模式匹配、引用局部变量和返回值、以及局部函数
C# 8 .NET Framework 4.8 只读成员和默认接口成员

在下一节中,我们将探讨微软提供的用于使用.NET Framework 构建应用程序的 IDE 工具——Visual Studio,以及它在开发阶段的一些内置功能,这些功能可以帮助我们。

Visual Studio for C#

微软 Visual Studio 是一个全球开发者使用的集成开发环境IDE)工具,用于开发、编译和执行.NET Framework 应用程序。该工具提供了几个功能,可以帮助开发者不仅提高应用程序的质量,而且大大减少开发时间。

这里提到了 Visual Studio 的一些关键特性:

  • 它使用微软的软件开发平台,如 Windows API、表单、WPF 和 Silverlight。

  • 在编写代码时,它提供了 IntelliSense 代码补全功能,这有助于开发者高效地编写代码。

  • 它还提供了一个表单设计器用于构建 GUI 应用程序,一个类设计器,以及数据库模式设计器。

  • 它支持不同的源代码控制系统,如 GitHub 和 TFS。

Visual Studio 的当前版本是 2017。为了开发目的,微软提供了免费的社区版 Visual Studio,可用于非商业活动。

在使用社区版之前,我们必须仔细阅读使用条款和条件:visualstudio.microsoft.com/license-terms/mlt553321/

在下一节中,我们将对编写基本 C#应用程序所涉及的基本语法进行概述。

C#的基本结构

在本节中,我们将介绍 C#应用程序的基本编程语法,即:类、命名空间和程序集。

由于 C#是一种面向对象的语言,在基本层面上,它包含被称为的构建块。这些类相互交互,并在运行时提供功能。一个类由两个组件组成:

  • 数据属性:数据属性指的是在类对象中定义的不同属性。

  • 方法:方法表示在类对象中要执行的不同操作。

例如,我们将探讨在 C#中将汽车表示为对象的方式。在非常基础的层面,一辆汽车将具有以下属性:

  • 制造商:例如 Toyota、Ford 或 Honda。

  • 模型:例如 Mustang、Focus 或 Beetle。

  • 颜色:汽车的颜色,例如红色或黑色。

  • 油耗:每升燃油消耗的距离。

请注意,汽车可以有更多的属性,但因为这个例子只是为了解释,所以我们只包括了这些基本属性。在编写 C#应用程序时,所有这些都将被捕获为Car类的属性。

同样,为了确保Car类实现所有期望的功能,它需要实现以下操作:

  • StartEngine:这个函数表示汽车如何开始移动。

  • GainSpeed:这个函数表示汽车如何加速。

  • ApplyBrake:这个函数表示汽车如何应用刹车以减速。

  • StopEngine:这个函数表示汽车如何停止。

在编写任何 C#应用程序时,起点始终是捕获所有相互交互的演员/对象。一旦我们确定了演员,我们就可以确定每个演员必须具有的数据属性和方法,以便它们可以相互交换所需的信息。

对于正在讨论的Car示例,以下将是Car类的定义。为了解释方便,我们假设属性将是String类型;然而,当我们进入第二章,理解类、结构和接口时,我们将介绍一些可以在类中声明的更多数据类型。对于汽车示例,以下语法将是一个 C#应用程序中的代表性程序:

class Car
{
    string Make;
   string Model;
    string Color;
    float Mileage;
    void StartEngine()
    {
        // Implement Start Engine.
    }

    void GainSpeed()
    {
        // Implement Gain Speed.
    }

    void ApplyBrake()
    {
        // Implement Gain Speed.
    }

    void StopEngine()
    {
        // Implement Gain Speed.
    }
 }

在任何应用程序中,都可能有一些相互关联的类。它们可以根据相似的功能来分类,或者它们可能相互依赖。在 C#中,我们通过命名空间来处理这种功能分离。例如,我们可以有一个命名空间来处理与文件目录中读取/写入日志相关的所有操作。同样,我们可以有命名空间来处理从输入中捕获用户指定信息的所有操作。

当我们的应用程序继续发展和我们有几个命名空间时,我们可能需要将相关的命名空间组合在一起。这确保了如果任何特定命名空间下的任何类发生变化,它不会影响应用程序中定义的所有类。这种命名空间的构建是通过 C#中的程序集来完成的。程序集也被称为 DLL,或动态链接库。根据我们如何组织代码,当应用程序编译时,会产生多个 DLL。

创建一个基本的 C#程序

现在我们将看看如何创建一个基本的 C#程序。为了解释方便,我们将使用控制台应用程序项目:

  1. 要创建一个新项目,请点击文件 | 新建项目,并选择控制台应用程序 (.NET Framework) 作为项目类型:

图片

在为解决方案指定合适的名称和路径后,点击 OK。检查解决方案是否已创建。在这个时候,你应该能看到解决方案资源管理器。默认情况下,应该将一个 .cs 文件 Program.cs 添加到解决方案中。默认情况下,还会添加一个名为 Main 的方法。这个方法是在应用程序执行时的第一个入口点。

请注意,对于控制台程序,无法更改默认方法,这将是应用程序的第一个入口点。

  1. 现在,让我们打开 Program.cs 文件。默认情况下,项目将为以下命名空间提供以下 using 表达式:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

using 语句基本上表示程序可以使用那些命名空间中定义的类和方法进行任何执行。在接下来的章节中,我们将详细介绍命名空间并学习如何使用它们。

  1. 现在,看看程序结构。默认情况下,每个类都需要与一个命名空间相关联。Program.cs 类中存在的命名空间表达式表示该类所属的命名空间:

请注意,C# 是一种区分大小写的语言。这基本上意味着,如果我们将方法名称从 Main 更改为 main,CLR 将无法执行此方法。

C# 中的每个方法都由两部分组成:

  • 输入参数:这是一组变量,当函数执行时,这些变量将被传递给函数。

  • 返回类型:这是函数在完成其处理过程后返回给调用者的值。

在之前声明的 Program 函数的情况下,输入变量是一组参数。输出变量是 void;换句话说,它不返回任何内容。在接下来的章节中,我们将更详细地介绍函数。

现在,让我们编写一个程序语法来执行著名的 Hello World 输出。在控制台应用程序中,我们可以使用 Console.WriteLine 来实现:

  1. 该程序的代码实现如下:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ConsoleApp1
{
     class Program
     {
         static void Main(string[] args)
         {
              Console.WriteLine("Hello World");
         }
     }
}
  1. 在这个阶段,我们已经完成了程序并准备好执行它。点击 Build | Build Solution。检查是否有编译时错误:

  1. 在这个阶段,内部,Visual Studio 应该为项目创建了一个 .exe 应用程序:

  1. 打开命令提示符并直接导航到 .exe 文件创建的位置。执行 .exe 文件并检查 Hello World 的预期输出是否出现在命令提示符中。

摘要

在我们进入下一章之前,让我们总结一下在本章中学到的内容。我们对 C#的构建块进行了简要回顾。我们了解了.NET Framework 架构,并参观了其中的不同组件。我们还分析了 C#与 C 和 C++等编程语言的不同之处。我们讨论了 CLR 的功能以及它在 C#中如何实现垃圾回收。然后我们编写了第一个程序,H**ello World。到现在为止,你应该对 C#是什么以及它包含的功能有了很好的了解。

在下一章中,我们将讨论 C#编程的一些更多基本原理。我们将分析 C#中的不同可能的访问修饰符。访问修饰符确保类中存在的属性和方法只对应用程序中的相关模块公开。我们将学习 C#编程中值类型和引用类型数据变量的行为和实现。我们将讨论继承和接口,以及它们在 C#应用程序中的实现方式。我们将讨论继承和接口之间的区别,以及我们应该在哪些不同场景中使用其中一个或另一个。

问题

  1. 以下哪个陈述关于 C、C++和 C#是正确的?

    • C 是一种面向对象的语言。

    • C++应用程序与底层系统独立。

    • C#应用程序与底层系统独立。

    • C 实现了 C++和 C#的所有功能和特性。

  2. 一个程序集由相关的命名空间和类组成,它们相互作用以提供一定的功能。

    • 正确

    • 错误

  3. 对于控制台项目,我们可以将任何函数设置为应用程序执行的起点。

    • 正确

    • 错误

答案

  1. C 不是一种面向对象的语言。C 和 C++与底层平台不独立,而 C#通过公共语言运行时(Common language runtime)实现了这一功能。C 是 C#和 C++提供的功能和特性的子集。

  2. 正确。一个程序集由多个相关的命名空间和类组成,它们被分组在一起。

  3. 错误。对于控制台应用程序,入口点始终是main程序。

第二章:理解类、结构体和接口

在第一章“学习 C#基础知识”中,我们查看了一个 C#应用程序非常基本组件的概述。C#应用程序中的所有类都由属性和方法组成。使用命名空间和程序集,我们可以将相关的类捆绑在一起。

为了保持结构并减少复杂性,确保只暴露必要的类/功能在类的作用域之外是至关重要的。在 C#程序中,这是通过访问修饰符实现的。在定义类中的属性时,我们还需要清楚 C#中可用的不同变量数据类型。

通过对结构体和类的代码实现进行使用,我们将探讨在程序执行期间数据类型和引用类型变量在实现和行为上的差异。我们还将探讨一些良好的实践,以便为我们的变量选择正确的数据类型。

我们接下来将探讨接口和继承,以及它们在 C#应用程序中的实现方式。通过示例,我们将探讨在不同场景下应该使用它们的各种情况。

本章将涵盖以下主题:

  • C#中的不同访问修饰符类型

  • C#中的不同数据类型

  • 理解类与结构体的区别

  • 理解继承

  • 理解接口及其与继承的不同之处

技术要求

与本书的前几章一样,本章中解释的程序将在 Visual Studio 2017 中开发。

本章的示例代码可以在 GitHub 上找到,地址为github.com/PacktPublishing/Programming-in-C-Exam-70-483-MCSD-Guide/tree/master/Book70483Samples

访问修饰符

所有类,包括它们各自的属性和函数,都与一个访问修饰符相关联。访问修饰符基本上指示了相应元素在应用程序中的访问方式,包括其自身的程序集以及其他程序集。在应用程序中,属性和函数统称为类成员。

在 C#中,类及其类成员可以采用以下访问修饰符:

  • 公共:声明为公共的类或类成员可以被同一程序集内的所有类以及应用程序中不同程序集的类访问。

  • 私有:声明为私有的类成员只能在同一类中访问,不能在类外访问。

  • 受保护:声明为受保护的类或类成员可以在类内部或由继承自该类的类访问。

  • 内部:声明为内部的类或类成员只能被同一程序集内的类访问,但不能被外部程序集访问。

  • 受保护的内部(Protected internal): 声明为 protected internal 的类或类成员只能被同一程序集中的类或在外部程序集中继承自相应类的类访问。

  • 私有受保护的(Private protected): 声明为 private protected 的类或类成员只能在同一类或同一程序集中继承自相应类的类中访问。

让我们看一下以下图片来总结关于访问修饰符的知识。在以下示例中,我们在应用程序中有 Assembly AAssembly BAssembly A 中有 Class A,它有不同的函数,每个函数都有一个单独的访问修饰符。请参考每个函数旁边的注释,了解哪些类在哪些程序集下可以访问相应的函数:

根据我们希望嵌入的不同类成员的可访问级别和安全级别,我们可以选择之前提到的任何访问修饰符。为了保持一些结构并避免引入不必要的复杂性,建议只将类成员暴露给需要与相应类共享一些信息的类。

在下一节中,我们将探讨类成员可以获取的不同数据类型。

C#中的数据类型

在 C# 中,一个变量可以获取以下类型之一:

  • 值类型

  • 引用类型

C# 在程序执行期间根据这些值在 全局程序集缓存GAC)中的保存和维护方式来区分这两种类型。值类型变量保存在堆栈中,而引用类型变量保存在一个托管堆数据结构中。

有其他 指针类型 允许我们访问变量的内存位置中的值。在 第八章,C# 中类型创建和使用,我们将详细探讨这些数据类型。现在,让我们看看这两种数据类型,并详细探讨它们。

值类型变量

在值类型中,变量包含数据或变量的内容。这意味着如果对程序的不同作用域中的值类型变量进行了更改,则更改将不会在控制权转移到调用函数时反映回来。

以下是在 C# 中的不同值类型。

简单类型

以下是一系列简单类型:

  • Int: 例如 1, 2, 4 和 -100。它们可以是带符号的也可以是无符号的。带符号的 int 类型可以是正数也可以是负数。无符号的 int 类型不能是负数;它的最小值是 0

  • Float: 例如,3.14。

  • Long: 与 32 位的 Int 不同,Long 是 64 位整数值。它也可以是带符号的或无符号的。

  • Decimal: 与 Float 类似,十进制数据类型也代表十进制数字,主要区别在于精度。对于 Float 数据成员,精度为 7;然而,在十进制数据类型的情况下,精度为 28 位数字。

  • Char: 表示单个字符序列。它可以获取诸如 Cc 或空白字符,任何特殊字符(如 % 和 #)以及数字(如 1)等值。

  • bool: 可以用来表示获取数字值(如真或假)的变量。

枚举类型

枚举类型用于表示可以获取一组常量值的属性,例如,enum Day {Sat, Sun, Mon, Tues, Wed, Thurs, Fri}

默认情况下,声明中第一个枚举值的值从 0 开始。然后,它将后续枚举值的值增加 1。对于前面的例子,枚举值的值如下:

  • Sat – 0

  • Sun – 1

  • Mon – 2

  • Tues – 3

  • Wed – 4

  • Thurs – 5

  • Fri - 6

我们还可以通过在声明本身中显式定义值来覆盖枚举的默认值。例如,在前面的例子中,如果我们不希望枚举值从 0 开始,我们可以使用以下声明:

enum Day {Sat = 1, Sun, Mon, Tues, Wed, Thurs, Fri}

对于前面的声明,枚举值将获取以下值:

  • Sat – 1

  • Sun – 2

  • Mon – 3

  • Tues – 4

  • Wed – 5

  • Thurs – 6

  • Fri – 7

每个枚举属性都有一个底层数据类型,默认情况下是 Int 类型。如果需要,我们还可以将枚举值的类型更改为长或短。然而,它不能将 char 作为底层数据类型。参考以下 enum 声明,其中我们将枚举值类型设置为 short

enum Day : short {Sat = 1, Sun, Mon, Tues, Wed, Thurs, Fri}

结构体类型

就像类一样,C# 中的结构体可以用来将相关数据组合在一起。像类一样,它们可以有构造函数、字段和方法。然而,结构体和类的实现之间有一些区别。以下是一些关键区别:

特性 结构体
类型 结构体作为值类型变量进行管理。这意味着分配给它们的对象值不会在不同的程序作用域中持久化。 类作为引用类型变量进行管理。这意味着分配给它们的对象值将在不同的程序作用域中持久化。
构造函数 与类不同,C# 不管理默认构造函数。当我们进入 第八章,在 C# 中创建和使用类型,我们将详细探讨默认构造函数。 当声明一个类时,如果没有为类指定构造函数,C# 会自动为该类创建一个默认构造函数。
继承 结构体不能从另一个结构体继承。这意味着如果我们使用结构体,代码重用可能会成为一个挑战。 类可以从其他类继承。

作为值类型变量,当创建一个结构体对象时,整个对象——包括属性、方法等——都会保存在栈中。因此,从性能角度来看,结构体应该仅用于创建只有少数成员的轻量级对象。

在下一节中,我们将通过一个代码示例来展示结构体实现与类中类似实现的区别。

引用类型变量

在引用类型变量中,数据成员包含变量在内存中的确切地址。由于变量仅包含对内存地址的引用,两个独立的引用类型变量可以指向同一个内存地址。因此,如果对引用类型变量进行修改,修改将直接在变量的内存位置上进行。由于修改是直接在变量的内存位置上进行的,两个变量都将反映更新后的值。

以下是在 C#中可用的引用类型:

  • : 如在第一章中讨论的,学习 C#的基础知识,类代表一组相关的属性和方法。

  • 接口: 在 C#中,接口代表一组相关的属性、事件和方法,它只包含声明而没有定义。在本章的后续部分,我们将深入探讨接口,并了解它们在 C#中的实现方式。

  • 动态类型: 动态类型变量避免了编译时的类型检查。例如,如果我们声明一个动态变量类型并将其分配给一个变量,那么它的类型将在运行时分配值时定义。

例如,在下面的代码片段中,我们创建了一个动态类型变量,将其分配给不同的变量,并在运行时评估其类型:

 dynamic typeVariable = 100;
 Console.WriteLine(typeVariable + " " + typeVariable.GetType().ToString());// Output 100 System.Int32
 typeVariable = "Hello";
 Console.WriteLine(typeVariable + " " + typeVariable.GetType().ToString());// Output Hello System.String
 typeVariable = true;
 Console.WriteLine(typeVariable + " " + typeVariable.GetType().ToString());// Output True System.Boolean
 Console.ReadLine();
  • 对象: 当使用new关键字创建类的新的实例时,在内存中为该类创建一个对象。

  • 字符串: String对象是一系列Char对象的序列,其值是不可变的或只读的。这基本上意味着,当我们修改类型为String的变量时,它会在内存中创建一个新的对象。

在下一节中,我们将通过一个代码示例来展示如何在 C#中实现引用类型变量(如Class)和值类型变量(如结构体),以及它们的行为差异。

结构体与类

在第一章中,我们创建了一个基本的Hello World程序。在本主题中,我们将扩展该程序并使用它来实现结构体和类。在这样做的时候,我们将分析引用类型变量和值类型变量的实现和使用之间的差异。如您所知,结构体是一个值类型变量,而类是一个引用类型变量:

  1. 打开在 第一章,学习 C# 基本结构 中创建的 Console 项目,并声明一个具有 xy 坐标两个成员属性的 CoordinatePoint 类。同时创建两个构造函数——一个不带参数,一个带两个参数。请参考以下代码实现:
class CoordinatePoint
{
    public float xCoordinate;
    public float yCoordinate;
    public CoordinatePoint()
    {
    }
    public CoordinatePoint(float x, float y)
    {
         this.xCoordinate = x; 
         this.yCoordinate = y;
    }
}

请注意,在前述代码中,使用 this 变量是可选的。它用于引用类的当前实例,并且可以在类成员和方法参数具有相同名称时用于区分它们。

  1. 声明一个类似的结构体。注意,编译器会为默认构造函数报错:
struct CoordinatePointStruct
{
     public float xCoordinate;
     public float yCoordinate;
     public CoordinatePointStruct()
     {         
        // This default constructor will give an error. 
     }
     public CoordinatePointStruct(float x, float y)
     {
         this.xCoordinate = x;
         this.yCoordinate = y;
     }
 }

如前述代码所示,我们将在 struct 构造函数上看到一个红色标签。这是因为,与类不同,结构体不能有默认构造函数的实现。为了消除错误,我们需要删除默认构造函数。这样做之后,编译器错误就会消失。以下将是结构体的正确实现:

struct CoordinatePointStruct
{
     public float xCoordinate;
     public float yCoordinate;
     public CoordinatePointStruct(float x, float y)
     {
         this.xCoordinate = x;
         this.yCoordinate = y;
     }
 }
  1. Main 类中,我们现在将声明两个函数,分别对应于每个类和结构体。在这两个函数中,我们将通过名为 obj 的参数传递一个对象,该对象分别属于 classstruct 类型。在同一个函数中,我们将结构体和类中的 xy 坐标变量值更改为默认值 0.5F。以下是该代码实现:
static void ChangeValuesClass(CoordinatePoint obj)
{
     obj.xCoordinate = .5F;
     obj.yCoordinate = .5F;
}
static void ChangeValuesStruct(CoordinatePointStruct obj)
{
     obj.xCoordinate = .5F;
     obj.yCoordinate = .5F;
}
  1. 现在,在主函数中,声明类和结构体的对象。注意,在声明各自的对象时,我们在 xCoordinateyCoordinate 成员属性中指定了相同的值。

为了解释说明,我们将编写将相应成员属性的值输出到控制台的语法。以下是该代码实现:

Console.WriteLine("Hello World");
CoordinatePoint classCoordinate = new CoordinatePoint(.82F, .34F);
CoordinatePointStruct structCoordinate = new CoordinatePointStruct(.82F, .34F);
Console.WriteLine("Initial Coordinates for Class are :" + classCoordinate.xCoordinate.ToString() + " " + classCoordinate.yCoordinate.ToString());
Console.WriteLine("Initial Coordinates for Struct are :" + structCoordinate.xCoordinate.ToString() + " " + structCoordinate.yCoordinate.ToString()); 
  1. 现在编写语法来调用每个结构体和类的相应 ChangeValues 函数。在函数调用之后,再有一个语句来打印 structclass 对象属性中的当前值。

有关此内容的代码实现,请参考以下代码:

ChangeValuesClass(classCoordinate);
ChangeValuesStruct(structCoordinate);
Console.WriteLine("Initial Coordinates for Class are :" + classCoordinate.xCoordinate.ToString() + " " + classCoordinate.yCoordinate.ToString());
Console.WriteLine("Initial Coordinates for Struct are :" + structCoordinate.xCoordinate.ToString() + " " + structCoordinate.yCoordinate.ToString()); 
  1. 点击 Build | 构建解决方案,并确保没有编译时错误。

  2. 点击 Debug | 开始调试。或者,用户也可以点击 F5 键或 Start 旁边的三角形图标来启动调试器。请参考以下截图:

注意,控制台显示了以下输出:

注意,在调用更改函数之后,class 对象的值会发生变化。然而,结构体中的值没有变化。

这是因为struct是一个值类型变量。因此,在函数作用域之外对对象的任何更改都没有影响,因为更改发生在内存中完全不同的对象上。

另一方面,class作为一个引用类型变量,也会受到函数作用域之外发生的更改的影响。因此,更改会传播回主对象。

总结来说,以下表格展示了structclass类型变量之间的主要区别:

特性 结构体
默认构造函数 如果一个类没有构造函数,那么每当为该类创建对象时,默认构造函数就会触发,并为类中存在的成员变量设置默认值。这些默认值是根据成员变量的类型默认值设置的。 与类不同,struct不能有任何默认构造函数。这意味着应用程序不会为struct的成员变量分配默认值。
内存实现 如前一个代码示例所示,类被实现为引用类型。这意味着类对象的值在程序执行的各个作用域中持续存在。 如前一个代码示例所示,struct被实现为值类型。这意味着它的值不会在程序执行的各个作用域中持续存在。
继承 我们将在本章以及下一章中详细探讨继承。然而,C#中的类可以继承自其他类。 与类不同,struct不能继承自其他struct或类。这意味着与类相比,在struct中代码重用稍微困难一些。

基于前面的差异,根据需求,开发者可以在structclass之间选择合适的数据类型。

在下一节中,我们将探讨如何在 C#应用程序中实现接口。

接口和继承

接口是一组属性、方法和事件的集合,仅包含声明而没有定义。我们在编程中使用它们将必须实现的一组功能组合在一起,这些功能在理论上属于相同的基本类型。

让我们看看一个汽车的例子。在现实世界中,任何Car类的实现都必须实现某些常见的基本功能,如驾驶、停车和加速。除此之外,任何被归类为汽车的物体也将具有特定于汽车制造商的特征,如本田或尼桑。

在前面的例子中,一个接口可以帮助促进代码重用,并在所有类型的Car中维护结构。在这种情况下,我们可以将Car声明为一个接口,所有汽车派生类型,如尼桑或本田,都必须实现。

与接口类似,我们也可以在 C#应用程序中实现继承。在继承中,我们可以定义具有某些方法和属性的类,然后这些方法和属性可以在子类中继承。在接下来的小节中,我们将探讨如何在 C#应用程序中实现接口和继承。

继承是面向对象编程的主要支柱之一。在第三章《理解面向对象编程》中,我们将探讨与继承相关的高级特性,并了解它是如何工作的。

继承

继承是任何面向对象编程的主要原则之一。通过继承,我们可以在子类中定义可重用的属性和函数。简而言之,它帮助我们跨多个模块重用应用程序中编写的代码。让我们通过一个示例来了解继承是如何帮助我们的。

让我们考虑两辆车,CarACarB。从非常高级的角度来看,我们可以认为这两个类都将具有类似的功能,例如:

  • 一个刹车函数

  • 一个加速函数

  • 一个车型;即柴油/汽油等

  • 颜色

  • 齿轮类型

如果我们需要在 C#应用程序中实现这一点,一种方法是将它们定义为两个独立的类:CarACarB。然而,这种方法的主要问题是这两个类都需要实现所列的共享功能。请参考以下代码,了解CarA在 C#中可能的实现方式:

public class CarA
{
     public DateTime manufacturingDate;
     public string bodyType;
     public float fuelCapacity;
     public void ImplementBrake()
     {
         Console.WriteLine("Inside Base Class Implement Brake");
     }
     public void ImplementAccelerator()
     {
         Console.WriteLine("Inside Base Class Implement Accelerator");
     }
     public void FoldableSeat()
     {
         Console.WriteLine("Inside Base Class Implement Accelerator");
     }
 }

类似地,请参考以下代码,了解CarB在 C#中可能的实现方式:

public class CarB
{
    public DateTime manufacturingDate;
    public string bodyType;
    public float fuelCapacity;
    public void ImplementBrake()
    {
        Console.WriteLine("Inside Base Class Implement Brake");
    }
    public void ImplementAccelerator()
    {
        Console.WriteLine("Inside Base Class Implement Accelerator");
    }
    public void RoofTopExtendable()
    {
         Console.WriteLine("Inside Car B Foldable Seat");
    }
 }

这种实现可能产生以下影响:

  • 没有代码复用:正如您将从前面的示例中理解的那样,CarACarB都有一些共同的特征。然而,我们并没有分别维护这些共同特征,而是重复了代码,这可能会导致维护问题。

  • 可扩展性:从商业/实施的角度来看,可能会有数百万种不同的车型。因此,对于每个新的Car或添加到Car实现中的新共同功能,我们可能会在应用程序中面临一些可扩展性的挑战。

如此清晰地展示,此类应用程序中的变更管理将是一场噩梦,并且执行起来非常困难。

现在,我们将使用继承的概念,看看如何以前更好的方式实现前面的场景。从实现的角度来看,我们将创建一个基类Car,它将包含所有跨不同Car实现中的共同成员变量。然后,我们将定义不同的Car类型,这些类型将继承自基类Car。让我们通过以下代码示例来更好地理解这一点:

  1. 创建一个基类Car。该类将包含CarACarB中共同的成员属性:
public class Car
{
    public DateTime manufacturingDate;
    public string bodyType;
    public float fuelCapacity;
    public void ImplementBrake()
    {
        Console.WriteLine("Inside Base Class Implement Brake");
    }
    public void ImplementAccelerator()
    {
        Console.WriteLine("Inside Base Class Implement Accelerator");
    }
}
  1. 创建一个类CarA,它将继承基类。在 C#中,我们使用:语法来定义继承:
public class CarA : Car
{
    public CarA()
    {
        this.bodyType = string.Empty;
        this.manufacturingDate = DateTime.MinValue;
        this.fuelCapacity = 0.0F;
    }
    public CarA(DateTime manufacturingDate, string bodyType, float fuelCapacity)
    {
        this.bodyType = bodyType;
        this.manufacturingDate = manufacturingDate;
        this.fuelCapacity = fuelCapacity;
        Console.WriteLine("Inside Car A Constructor"); 
    }
    public void FoldableSeat()
    {
        Console.WriteLine("Inside Car A Foldable Seat");
    }
}

如前所述,在父类内部声明的属性在派生类中自动可用。

请注意,基类中可用的属性取决于在基类中对应属性上使用的访问修饰符。

在我们的示例中,我们在基类中使用了public访问修饰符。如果它是privateprotected internal,其在子类中的可访问性将不同。

让我们考虑一个场景,由于某种原因,我们还需要在CarA中声明一个同名的属性bodyType。在 C#中,我们可以通过使用base关键字来区分基类和派生类中存在的属性。请参考以下代码:

public class CarA : Car
{
     string bodyType;
     public CarA()
     {
         this.bodyType = string.Empty;
         base.bodyType = string.Empty;
         this.manufacturingDate = DateTime.MinValue;
         this.fuelCapacity = 0.0F;
     }

如果使用base,它指的是父类中的属性,如果使用this,它指的是子类中的属性。

  1. 同样,为CarB声明一个类:
class CarB : Car
{
    public CarB()
    {
        this.bodyType = string.Empty;
        this.manufacturingDate = DateTime.MinValue;
        this.fuelCapacity = 0.0F;
    }
    public CarB(DateTime manufacturingDate, string bodyType, float fuelCapacity)
    {
        this.bodyType = bodyType;
        this.manufacturingDate = manufacturingDate;
        this.fuelCapacity = fuelCapacity;
        Console.WriteLine("Inside Car B Constructor");
    }
    public void RoofTopExtendable()
    {
        Console.WriteLine("Inside Car B Foldable Seat");
    }
}

请注意,在派生类中,我们也可以创建与基类无关的成员变量。如前述截图所示,CarA类有一个FoldableSeat的实现,这在基类中不存在。

同样,CarB类有一个RoofTopExtendable的实现,这在基类中不存在。

  1. 在主方法中,声明CarACarB对象并调用相应的方法:
CarA carA = new CarA();
carA.ImplementAccelerator();
carA.ImplementBrake();
carA.FoldableSeat();

CarB carB = new CarB();
carB.ImplementAccelerator();
carB.ImplementBrake();
carB.RoofTopExtendable();
Console.ReadLine();
  1. 点击“构建 | 构建解决方案”。注意,没有编译时错误。现在点击“调试 | 开始调试”。注意,以下输出出现在控制窗口中:

以下是对每个输出行项的简要分析:

  • 我们首先调用的方法是ImplementAccelerator,它存在于基类中。正如预期的那样,它执行了基类中的方法。

  • 同样,下一个我们调用的方法是ImplementBrake,它也存在于基类中。在这种情况下,基类中的方法也会被执行。

  • 在下一次调用中,我们执行了仅存在于CarA中的方法。在这种情况下,控制执行该函数中的代码。

  • 同样适用于 B。

因此,使用继承,我们可以提高代码的重用程度,同时使维护活动变得相当可扩展。

一旦我们进入第三章,理解面向对象编程,我们将介绍更多关于继承的功能,例如重写密封的、抽象的类等。然而,现在我们将介绍接口如何帮助我们进行 C#代码开发。

C#中的接口

在前面的例子中,我们展示了如何声明一个具有一些成员变量的基类,并在派生类中继承它们。然而,在某些情况下,我们可能需要从一个类继承两个不同的类。此外,如果我们使用的是结构体,我们将无法从另一个结构体或类继承。

不幸的是,由于以下原因,使用继承,我们无法在 C#应用程序中实现这一点:

  • C#中不允许多重继承。

  • C#中的结构体数据类型不能从其他结构体或类类型继承。

在这种情况下,接口非常有用。接口定义了一组相关的方法和属性,每个实现接口的类都必须实现这些方法和属性。请注意,接口必须只有声明。

关于接口,声明指的是方法的规范及其签名——即输入和输出参数——而定义则指的是方法体中逻辑的实际实现。在讨论以下代码示例时,我们将进一步探讨这一点。

让我们看看我们用于继承的例子,看看我们如何在其中使用接口:

  • 在前面的例子中,我们创建了CarACarB,我们可以推断出它肯定还有其他一些属性,例如颜色、重量、高度、品牌、标志、制造商等。

  • 从数据模型的角度来看,我们可以将它们归类为任何实用工具或产品的通用属性,而不仅仅是汽车。

  • 因此,当我们选择产品时,我们可以这样说,有一些操作,如ImplementBrandImplementColor等,将在所有产品实现中是通用的,而不仅仅是针对CarACarB

  • 因此,这意味着这两个类必须从CarProduct两个类继承才能正确运行。

让我们尝试创建另一个基类Product,并尝试为CarA实现多重继承。以下是Product类的代码实现:

public class Product
{
    public void ImplementBrand()
    {
        Console.WriteLine("Inside Base Class Implement Brake");
    }
    public void ImplementColor()
    {
        Console.WriteLine("Inside Base Class Implement Accelerator");
    }
}

然而,当我们尝试为CarA类实现多重继承时,编译器会给出错误。以下截图显示了编译器给出的错误:

图片

一种解决方案是将CarProduct的实现合并在一起;然而,从数据模型的角度来看,这两个实体之间并没有直接关系。

为了克服上述困境,我们将使用接口。在声明接口时,我们需要遵守以下约定:

  • 要声明接口,我们需要使用interface关键字。

  • 接口不能为任何函数声明指定访问修饰符。

  • 接口也必须只包含函数声明,而没有定义。

以下是ICar接口的代码语法,其中我们声明了接口中应该有的方法:

public interface ICar
{
     void ImplementBrake(); 
     void ImplementAccelerator();
     void ImplementBrand();
     void ImplementColor();
 }

请注意,在前面的例子中,我们只指定了接口中存在的方法应该获得的签名。这被称为声明。实现此接口的类——在我们的例子中是Car类,将负责为接口中存在的方法提供完整的实现。

要实现接口,我们可以使用类似于继承的语法。以下是这个语法的截图:

检查编译时错误。错误表明Car类必须实现接口中声明的所有函数。为了克服前面的错误,我们必须定义接口中的所有函数。类似于ICar,我们也可以为IProduct创建一个接口,然后CarACarB类可以分别实现它。

虽然继承和接口可以在类似场景中使用,但它们之间的一些差异如下:

特性 继承 接口
多重继承 一个类只能从一个类继承。 一个类可以实现多个接口。
数据类型 一个类可以继承自另一个类。然而,一个结构不能从另一个类或结构继承。 类和结构都可以实现接口。
方法定义 在继承中,基类可以定义方法。 接口不能有针对方法的定义。
访问修饰符 基类及其成员属性可以采用不同的访问修饰符,例如publicprivateprotectedprotected internalprivate protected 接口的访问修饰符始终是public

基于这些差异,程序员可以决定他们应用程序的正确方法,并在创建接口或通过继承管理之间进行选择。

摘要

本章涵盖的是 C#语言编程的基础知识。通过使用访问修饰符,我们可以控制应用程序不同模块中不同属性和方法的可访问性。在编写代码时,人们常犯的一个非常常见的错误是将所有属性和方法声明为 public。这不是 C#编程中推荐的做法。我们必须对类中存在的每个属性和方法是否需要不同的访问修饰符进行逻辑评估。

同样,我们应该分析我们需要与类中使用的每个属性关联的数据类型。我们还必须分析我们是否需要一个引用数据类型变量,或者我们是否可以接受值类型变量,因为它们在编译器内存和功能上有所不同。我们还应该利用继承,因为它有助于我们重用代码并以非常精确的方式组织程序。

在下一章中,我们将介绍面向对象的概念,这些是任何高级编程语言(如 C#)的主要构建块。我们将讨论多态、抽象、封装和继承,并详细理解这些概念,同时也会通过一些代码示例来查看它们的实现。

问题

  1. Car类中声明的以下哪个属性不是值类型变量?

    1. public Decimal fuelCapacity;

    2. public Enum carColor;

    3. public String registrationNumber;

    4. public Int numberOfSeats

  2. 以下哪个不是引用类型变量?

    1. 字符串

    2. 结构体

    3. 接口

  3. 在 C#中,一个子类可以继承自多个父类。这个陈述正确吗?

  4. 以下关于接口和类的陈述哪个是不正确的?

    1. 一个类可以实现多个接口。

    2. 一个接口可以同时有函数声明和定义。

    3. 结构体数据变量不能从另一个结构体继承。

    4. 在继承中,如果基类和派生类都有一个同名函数,我们可以使用*base*关键字来隐式调用基类的函数。

  5. 以下关于访问修饰符的陈述哪个是不正确的?

    1. 如果一个成员变量被声明为public,它可以在整个应用程序中被访问。

    2. 如果一个成员变量被声明为private,它只能在同一类中访问。

    3. 如果一个成员变量被声明为protected,它可以在整个命名空间中被访问。

    4. 如果一个成员变量被声明为protected internal,它可以在名称空间中的类以及从它派生的类中访问。

答案

  1. public String registrationNumber;。字符串是一个引用类型变量。所有其他都是值类型变量。

  2. 结构体是一个值类型变量,与所有其他都是引用类型变量不同。

  3. ,在 C#中我们不能有多个继承。一个类只能从一个基类继承。

  4. 在 C#中,一个接口必须只有函数声明而没有定义。所有其他陈述都是正确的。

  5. 如果一个成员变量被声明为protected,它只能在继承自其基父类的类中访问。

第三章:理解面向对象编程

当我们编写任何程序时,除了确保它满足所需的目的外,我们还必须确保考虑以下方面:

  • 代码重用:我们必须尝试以这种方式实现程序流程,以便可以在多个模块中使用常见的功能。

  • 代码维护:我们必须接受任何编写的程序代码都难免会有一些错误。然而,我们必须确保编写的代码清晰且结构化,以便易于理解和维护。

  • 设计模式:设计模式允许我们以这样的方式编写程序,即存在一个通用的模板/结构/功能,可以在多个模块中使用。这确保了应用程序的性能不会受到影响,这是任何程序应用的关键方面。

在过程语言中实现所有这些方面都是困难的。然而,使用面向对象编程,这是任何高级编程语言的主要本质,我们可以实现上述目标。

在本章中,我们将涵盖以下主题:

  • 理解面向对象编程

  • 理解封装

  • 理解抽象

  • 理解继承

  • 理解多态

我们还将通过代码示例来了解这些特性如何在 C#应用程序中实现。

技术要求

如本书前面的章节,我们将要涵盖的程序将在 Visual Studio 2017 中开发。

本章的示例代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Programming-in-C-Exam-70-483-MCSD-Guide/tree/master/Book70483Samples

理解面向对象编程

面向对象编程是一种基于对象的编程概念。对象是一组相关数据,如字段和过程,即方法。例如,一个对象可以是任何东西,从非常简单的对象,如铅笔,到非常复杂类型,如汽车。每个对象都将有其自己的属性集,即属性和在该对象中实现的方法或函数。例如,对于汽车对象,可能的属性可以是颜色、注册号、型号等。可能的功能可以是启动、停止和加速。

在面向对象编程出现之前,我们根据过程编程的原则进行编程。在过程语言中,应用程序被划分为一系列函数。程序中使用的数据存储在一组局部变量中,这些变量被函数使用。这构成了像 COBOL 和 BASIC 这样的传统编程语言的基础。

这种编程概念的主要缺点如下:

  • 没有代码重用:由于整个应用程序被划分为一系列顺序函数,在这个编程概念中没有代码重用。

  • 维护和可扩展性:以下图表只是展示了用过程式语言编写的典型程序运行方式的示意流程。在程序中,块表示不同的代码函数,它们相互连接并相互作用以完成任务:

图片

在这里,就像在用过程式语言编写的任何典型程序中一样,会有许多函数,它们传递参数并以概念方式执行。因此,对其中任何一个函数所做的任何更改或升级都有很大可能导致另一个函数的执行出现问题。因此,从维护和可扩展性的角度来看,用过程式语言编写的应用程序将面临挑战。

在面向对象编程中,每个应用程序都可以划分为具有自己属性和过程的多个对象。例如,让我们考虑我们在上一章中探讨的相同汽车场景。一辆车可以有以下属性和方法:

图片

现在,在面向对象编程语言中,我们可以直接声明一个car对象,设置与属性相关的值,并调用相应的属性,例如启动。

由于对象与相应的属性分组,我们不需要为相应的属性传递任何数据。相反,在执行startstopaccelerate等相应函数时,我们可以将这些属性作为函数参数列表传递数据。

因此,在这种情况下,如果我们未来更改car类的start()函数,我们就不需要麻烦所有其他调用它的地方。

与在过程式语言中按标准方式做事相比,这在应用的可维护性和可扩展性方面是一个重大升级。

现在,让我们深入探讨面向对象语言的四个支柱,并了解我们如何在 C#应用程序中使用它们。

理解封装

封装基本上涉及将所有相关的属性和方法以及访问它们的方式组合在一个对象中。在应用程序设计时,我们需要决定在其中定义多少个对象,以及它所拥有的相关属性和方法。

例如,在汽车示例中,我们有以下相关属性和方法:

  • car是一个对象。

  • makemodelcolor是对象中存在的不同属性。

  • startstopaccelerate是对象中存在的不同方法。

封装使我们能够在任何应用程序中实现以下功能:

  • 安全性:使用封装,我们可以以这种方式定义我们的属性,即不是所有对象的属性都暴露给整个应用程序。在第二章《理解类、结构和接口》中,我们使用了访问修饰符来控制类/命名空间/程序集以及整个应用程序中任何属性/方法的访问安全性。

  • 代码维护:从函数的维护角度来看,总是希望函数具有尽可能少的属性。

使用封装,我们可以将函数所需的参数组织为类的属性,因此我们不需要在每次调用中明确传递它们。

在以下代码示例中,我们将通过 C#代码示例来了解如何实现这一点。

代码示例

让我们考虑一个银行应用程序的例子。在这个银行应用程序中,我们需要实现一个与开户相关的场景。

从类实现的角度来看,以下是在Account类中应该存在的可能属性。请注意,还将有一个额外的类Customer,以表示开设账户的人:

  • openingDate

  • customer

  • float currentBalance

以下是Account类中可能存在的一些方法:

  • bool OpenAccount();

  • bool depositMoney(float deposit);

  • bool withdrawMoney(float withdrawalAmt);

关于Customer类,我们现在将保持简单,并定义以下属性:

  • string name

  • string customerId

请参考以下代码,了解 C#程序中Customer类的声明。在这里,我们创建了一个Customer类,并在其中定义了两个属性,即客户的姓名和一个CustomerID字段,这将为客户提供一个唯一的字段。

在以下代码中,我们将声明两个变量,并使用它们来展示我们之前提到的运算符的示例:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace ConsoleApp1
{
    public class Customer
    {
        public string name;
        public string customerId;
    } 
}

请参考以下代码,了解 C#程序中Account类的声明:

public class Account
{
  public DateTime openingDate;
  public Customer customer;
  private float currentBalance;
  public bool OpenAccount(Customer customer)
  {
     this.openingDate = DateTime.Now.Date;
     this.currentBalance = 0.0f;
     this.customer = customer;
     return true;      
  }  
  public bool DepositMoney(float deposit)
  {
     if(deposit > 0.0f)
     {
         this.currentBalance = this.currentBalance + deposit;
         return true;
     }
     else
     {
         return false;
     }
  }
  public bool WithdrawMoney(float withdraw)
  {
     if(this.currentBalance >= withdraw)
     {
         this.currentBalance = this.currentBalance - withdraw;
         return true;
     }
     else
     {
         return false;
     } 
  }
}
}

以下是实现中的关键项目:

  • Account类中,请注意currentBalance被标记为私有,因为客户可能不希望他们的余额暴露给整个应用程序。

  • Account类中,在OpenAccountDepositMoneyWithdrawMoney这些方法中,我们没有传递与客户、当前余额或开户日期相关的所有属性。这是因为所需的属性已经在Account类中分组在一起。

现在,让我们看看我们将如何调用这些类:

Customer customer = new Customer();
customer.name = "Sample Customer";
customer.customerId = "12345";

Account newAccount = new Account();
newAccount.OpenAccount(customer);
newAccount.DepositMoney(1000);
newAccount.WithdrawMoney(400);

如果你查看函数调用部分,你会理解,因为属性与Account类相关联,所以我们没有明确地将它们传递给函数。因此,如果函数的实现发生变化,从维护的角度来看,影响将会最小。

因此,始终使用封装原则并将应用程序划分为包含相关信息的类块是有益的。

理解抽象

抽象也是面向对象编程中的一个概念,意味着当我们编写代码时,我们应该隐藏实现的外部复杂性和细节。

换句话说,当我们编写一个程序,在接收输入后执行一系列复杂操作并返回输出时,我们应该隐藏程序内部操作的内层复杂性,以便外部应用程序只需关注它们发送给应用程序的输入以及从应用程序获得的输出。

例如,让我们考虑我们在上一个示例中处理过的相同的Account示例。如果我们考虑OpenAccount函数的示例,你会理解为客户开设账户不会那么简单。在我们最终为客户开设账户之前,需要执行几个子任务。例如,以下是一些可能的步骤:

  • 核实客户的身份证明文件

  • 链接和开设不同的银行账户,这些可能是SalaryCurrentSavings

  • 获取,即计算客户的初始存款金额

基本上,在现实生活中,我们上面所写的函数将看起来更接近以下代码片段。在OpenAccount函数中,我们传递了一个Customer对象。在为客户创建银行账户之前,我们执行了三个不同的任务:

  1. VerifyCustomerIdentity(): 在这个函数中,我们的想法是验证客户的身份,这是在开设账户之前的一个常见做法。

  2. OpenAndLinkRelatedAccounts(): 在这个函数中,我们的想法是为同一客户开设不同的账户,即SavingsCurrentSalaried

  3. RetrieveAndCountDeposit(): 在这个函数中,我们的想法是检索客户打算存入的金额,对其进行计数,并将其最终存入客户的账户:

public bool OpenAccount(Customer customer)
{
    this.openingDate = DateTime.Now.Date;
    this.currentBalance = 0.0f;
    this.customer = customer;
    if(VerifiyCustomerIdentity() && OpenAndLinkRelatedAccounts() && RetrieveAndCountDeposit())
      {
        return true;
      }
    else
    {
        return false;
    } 
}
private bool VerifiyCustomerIdentity()
{
    //This function will verify the customer documents.
    return true;
}
private bool OpenAndLinkRelatedAccounts()
{    
    //This function will open the related accounts of savings , current and salary and link them together.
    return true;
}
private bool RetrieveAndCountDeposit()
{
    //This function will fetch the deposit, count and verify the amount.
    return true;
}
public bool DepositMoney(float deposit)
{
     this.currentBalance = this.currentBalance + deposit;
     return true;
} 

请注意以下事项:

  • 这三个函数,即VerifyCustomerIdentity()OpenAndLinkRelatedAccounts()RetrieveAndCountDeposit(),都具有Private作为访问修饰符。这将确保这三个函数中的复杂性不会被暴露在外。

  • 这三个函数在OpenAccount函数内部被调用,因此调用应用程序不需要担心显式调用这些函数。

  • 假设我们在内部私有函数中发现了某些问题。在这种情况下,我们可以轻松地对这些内部函数进行更改,而无需担心外部实现。

理解继承

如果你已经阅读了第二章,理解类、结构和接口,你将已经知道继承如何帮助我们实现代码重用和减少维护,以及它如何让我们对整个应用程序拥有更多的控制。

我们还查看了一些代码示例,了解了继承的工作原理以及它在 C#中的实现方式。现在,我们将探讨继承的一些高级特性,它们的使用方法以及如何在 C#中实现。

方法重写

方法重写是 C#中的一种技术,我们可以用它来调用从基类派生出来的类中定义的方法。在方法重写中,派生类实现了一个在基类中声明且具有相同签名的函数:

  • 与基类中声明的函数相同的名称

  • 函数中相同数量和类型的参数

  • 与基类中声明的函数相同的返回类型

在 C#中,方法重写是通过以下两种方法实现的:

  • 虚方法:虚方法是在基类中定义的,也可以在派生类中定义或重写的方法。请注意,当一个方法被声明为虚方法时,在基类中定义该方法的实现是可选的。如果已经定义了,那么派生类进一步重写它的可能性就更加可选了。一个方法通过使用*virtual*关键字被声明为虚方法。

  • 重写:一旦在基类中将方法声明为virtualabstract,那么通过使用*override*关键字,派生类可以重新定义方法以供自身使用。在本节中,我们将探讨*virtual methods*。在下一节*抽象和密封类*中,我们将深入探讨*abstract*方法。

让我们通过一个代码示例来了解方法重写是如何在 C#中实现的。假设我们有一个基类Car和两个从Car类继承的类FerrariSuzuki。为了解释方便,我们将保持简单,只为这三个类指定一个默认构造函数和一个共同的Accelerate方法。以下将是相同的代码实现:

public class Car
{
     public Car()
     {
         Console.WriteLine("Inside Car");
     }
     public void Accelerate()
     {
         Console.WriteLine("Inside Acceleration of Car");
     }     
}
public class Ferrari : Car
{
     public Ferrari()
     {
         Console.WriteLine("Inside Ferrari");
     }
     public void Accelerate()
     {
         Console.WriteLine("Inside Acceleration of Ferrari");
     }     
}
public class Suzuki : Car
{
     public Suzuki()
     {
         Console.WriteLine("Inside Suzuki");
     }
     public void Accelerate()
     {
         Console.WriteLine("Inside Acceleration of Suzuki");
     }     
 }

现在,让我们使用以下代码为这些类创建一些对象:

Car ferrari = new Ferrari();
ferrari.Accelerate();
Console.WriteLine("End of Ferrari Implementation");
Car suzuki = new Suzuki();
suzuki.Accelerate();
Console.WriteLine("End of Suzuki Implementation"); 

注意,在上述代码中,我们创建了一个Ferrari类的新对象,并将其分配给一个类型为Car的变量ferrari。同样,我们也创建了一个Suzuki类的新对象,并将其分配给一个类型为Car的变量suzuki

当我们执行代码时,我们得到以下输出:

图片

注意,尽管我们在父Car类和派生的FerrariSuzuki类中都有Accelerate方法,但当我们从ferrari对象调用Accelerate方法时,它调用的是父Car类中存在的Accelerate方法。这是由于变量的类型是Car,尽管它被实例化为FerrariSuzuki子类的对象,但基类中的方法尚未被重写。

现在让我们对实现进行轻微的修改,并在基类中将方法声明为virtual,将派生自该类的类中的方法声明为override

public class Car
{
     public Car()
     {
         Console.WriteLine("Inside Car");
     }
     public virtual void Accelerate()
     {
         Console.WriteLine("Inside Acceleration of Car");
     }     
}
public class Ferrari : Car
{
     public Ferrari()
     {
         Console.WriteLine("Inside Ferrari");
     }
     public override void Accelerate()
     {
         Console.WriteLine("Inside Acceleration of Ferrari");
     }     
}
public class Suzuki : Car
{
     public Suzuki()
     {
         Console.WriteLine("Inside Suzuki");
     }
     public override void Accelerate()
     {
         Console.WriteLine("Inside Acceleration of Suzuki");
     }     
 }

现在,再次执行相同的代码,并回顾我们收到了以下输出:

图片

注意,现在,Accelerate方法执行的是FerrariSuzuki派生类中提到的代码,而不是Car父类中指定的代码。

在本章的后面部分,我们还将深入研究多态。有两种类型的多态:运行时多态和编译时多态。运行时多态是通过方法重写实现的。

在下一节中,我们将探讨abstract类,并探索在abstract类中使用virtual方法。

抽象类

C#中的abstract类是一个不能实例化的类,也就是说,程序执行不能创建此类对象。相反,这些类只能作为其他类继承的基类。

我们在需要所有派生类都实现基类中声明的特定函数的具体实现的情况下使用abstract类。以下是一些abstract类的属性:

  • 就像所有其他类一样,一个abstract类可以同时拥有函数和属性。

  • abstract类可以同时拥有抽象和非抽象函数。

让我们看看一个程序来分析abstract类是如何工作的。我们将使用abstract关键字定义一个Animal类。现在,假设每种动物类型,如狗,说话的方式都不同,因此它们必须以自己的方式实现该函数。为了实现这一点,我们将我们的基Animal类声明为abstract,并在其中有一个abstract方法Speak。回顾一下,如果我们尝试实现Speak方法,编译器会抛出一个错误:

图片

要移除这个错误,我们可以简单地移除abstract方法的声明:

public abstract class Animal
{
     public abstract void Speak();
     public void Walk()
     {
         Console.WriteLine("Base Animal Walk Functionality");
     }
}

现在,让我们创建一个继承自这个Animal基类的Dog类。注意,如果未实现Speak方法,编译器将抛出一个错误:

图片

我们可以通过创建一个Speak函数的实现来克服这个错误:

public class Dog : Animal
{
     public override void Speak()
     {
         Console.WriteLine("A dog will bark");
     }
}

请注意,我们使用override关键字让编译器知道我们在派生类中重写了名为Speakabstract函数的实现。

在下一节中,我们将探讨相同的例子,并了解abstract方法与virtual方法的区别。

抽象方法与虚拟方法

在前面的例子中,我们将Speak方法声明为abstract。这迫使我们的Dog类提供该方法的一个实现,否则我们会得到一个编译时错误。现在,如果我们不希望在代码中设置这种特定的限制,会怎样呢?

我们可以通过将abstract方法替换为virtual方法来实现这一点。以下是前面代码的更改实现:

public abstract class Animal
{
     public virtual void Speak()
     { 
     }
}

注意,当你编译代码时,没有错误。此外,仅为了实验,请在Dog类中注释掉Speak方法。

现在,编译程序。请注意,与前面的情况不同,当我们使用abstract方法时,不会出现编译时错误:

图片

在下一节中,我们将探讨sealed类以及它们如何在 C#应用程序中实现。

密封类

C#中的sealed类是一个我们不希望任何派生类继承的类。一旦我们插入关键字sealed,如果派生类尝试从sealed类继承,编译器将给出一个编译时错误。以下是这个截图。为了解释的目的,我们将使用前面例子中使用的相同两个类:

图片

注意,abstractsealed并不总是相辅相成的。abstract意味着该类永远不能被实例化,而sealed类表示该类永远不能被继承。因此,从后视镜来看,如果我们将sealed类声明为abstract,这就没有任何意义了。因此,如果我们尝试将abstract类声明为sealed,我们将得到一个编译时错误,如下所示:

图片

在下一节中,我们将探讨面向对象编程的另一个支柱,即多态性

理解多态性

多态性是一个希腊词,其字面翻译为多形态。在编程术语中,它指的是一个接口具有多个功能。让我们通过以下图表来尝试理解多态性:

图片

在前面的图表中,我们有一些在输入 1上运行的程序代码,并给出输出 1。现在,假设我们犯了一个错误,发送了错误的输入输入 2。在这种情况下,不幸的是,程序代码可能会出错并发送错误消息。在这种情况下,我们可以使用多态性。使用多态性,相同的例子将表示如下:

图片

如我们所见,通过使用多态,我们将在内存中维护三份代码副本,并且根据接收到的输入类型,将加载和执行适当的程序代码副本。

在 C# 中可能存在两种多态类型:

  • 静态/编译时多态,即方法重载或函数重载

  • 执行时间多态,即方法重写或虚函数

让我们逐一介绍这些类型,并使用代码示例来了解它们是如何工作的。

静态/编译时多态

静态多态,也称为函数重载,涉及创建具有相同名称但参数数量或类型不同的函数。

编译器根据传入的输入加载适当的函数。让我们通过以下代码示例来了解它是如何工作的。在这里,我们将创建两个名为 ADD 的函数副本,这些副本在函数接受的参数数量方面有所不同:

static int AddNumber (int a, int b)
{
     Console.WriteLine("Accepting two inputs");
     return a + b;
}
static int AddNumber(int a, int b, int c)
{
     Console.WriteLine("Accepting three inputs");
     return a + b + c;
} 

现在,当调用函数时,根据传递的参数数量,将加载相应的函数:

int result = AddNumber(1, 2);
Console.WriteLine(result);
int result2 = AddNumber(1, 2, 3);
Console.WriteLine(result2);
Console.ReadLine();

程序执行后,我们将得到以下输出:

现在,让我们考虑另一个例子。在前面的例子中,我们根据参数数量实现了多态。在这个例子中,我们将根据参数类型实现多态。

  1. 创建两个类,一个用于 Dog,另一个用于 Cat
public class Dog 
{
}
public class Cat 
{
}
  1. 创建两个具有相同名称的函数,一个接受 Dog 对象的输入,另一个接受 Cat 对象的输入:
static void AnimalImplementation(Dog dog)
{
    Console.WriteLine("The implementation is for a dog."); 
}
static void AnimalImplementation(Cat cat)
{
    Console.WriteLine("The implementation is for a cat.");
}

现在,当调用函数时,根据参数类型,将加载适当的函数:

Cat cat = new Cat();
Dog dog = new Dog();
AnimalImplementation(cat);
AnimalImplementation(dog);
Console.ReadLine();

当程序执行时,它将显示以下输出:

运行时多态

C# 中的运行时多态通过虚方法执行。在这种多态中,编译器通过在运行时识别其形式来执行代码。

方法重写 部分中,我们学习了虚方法,并看到了它们如何允许派生类覆盖基类中函数的实现。在运行时多态中,基类对象持有基类和派生类对象的引用。现在,根据基对象指向的对象,将加载适当的函数。

为了回顾我们对这一点的理解,让我们通过另一个代码示例。在这个例子中,我们将创建一个名为 Animal 的基类,它将被两个类 ManDog 继承。

以下是在 Animal 类中的实现:

public class Animal
{
     public int numOfHands;
     public int numOfLegs;
     public virtual void Speak()
     {
         Console.WriteLine("This is a base implementation in the base animal class");
     }
}

Animal 类中,我们声明了两个属性来表示 AnimalnumOfHandsnumOfLegs。我们还声明了一个名为 Speak 的函数,并将其标记为 Virtual,以便任何继承此类的类都可以提供自己的 Speak 功能实现。

我们将 Speak 函数声明为虚拟的,这意味着这个函数可以在派生类中被重写。

以下是在 Dog 类中的实现:

public class Dog : Animal
{
    public string breed;
    public Dog(string breed, int hands, int legs)
    {
        this.breed = breed;
        base.numOfHands = hands;
        base.numOfLegs = legs;
    }

    public override void Speak()
    {
        Console.WriteLine("A dog will bark , its breed is " + this.breed + " and number of legs and hands         are " + this.numOfLegs + " " + this.numOfHands);
    }
}

在这个实现中,我们创建了一个从 Animal 类继承的 Dog 类。Dog 类有一个名为 Breed 的属性和一个构造函数,该构造函数分别接受 breedhandslegs 三个参数。我们还有一个 Speak 函数,以提供狗对象如何实现 Speak 功能的印象。

以下代码是另一个类 Human 的实现,它也将从 Animal 的基类继承:

public class Human : Animal
{
    public string countryOfCitizenship;
    public Human(string citizenship, int hands, int legs)
    {
         this.countryOfCitizenship = citizenship;
         base.numOfHands = hands;
         base.numOfLegs = legs;
    }
    public override void Speak()
    {
         Console.WriteLine("A man can speak multiple languages, its citizenship is " +                              this.countryOfCitizenship + " and number of legs and hands are " + this.numOfLegs + " " +                  this.numOfHands);
    }
}

在前面的代码中,我们做了以下操作:

  • 我们从 Animal 的基类继承了 Dog 类。

  • 我们在派生类中重写了 Speak 函数。

  • 我们还在使用基类中声明的属性。

现在,让我们看看运行时多态是如何工作的。在下面的代码中,我们声明了一个基类 Animal 的对象,并将其指向派生类的对象:

Animal animal = new Animal();
animal.numOfHands = 2;
animal.numOfLegs = 4;
animal.Speak();

animal = new Dog("Labrador", 0, 4);
animal.Speak();

animal = new Human("India", 2, 2);
animal.Speak();
Console.ReadLine();

一旦我们执行这段代码,我们会注意到,根据基对象 animal 指向的类对象引用,将加载适当的 Speak 方法实现。这种加载是在运行时决定的,这就是为什么这被称为 运行时多态

图片

摘要

在这一章中,我们学习了面向对象编程,这是任何高级编程语言(包括 C#)的主要精髓。我们学习了 OOP 的四个支柱,即封装、抽象、多态和继承,并理解了它们如何帮助我们编写易于维护、可扩展且具有大量重用性的应用程序。

我们学习了封装如何通过将所有相关的属性和方法组合在一个类中来帮助我们保持代码的结构化。然后,我们学习了抽象如何帮助我们简化整个应用程序暴露的模块的复杂性。通过使用抽象,我们可以确保类的所有复杂性都不会暴露给外部类,这也帮助我们更好地维护应用程序。我们还学习了如何使用运行时和静态多态来实现可跨不同输入重用的相似功能,从而帮助我们在整个应用程序中重用代码。最后,我们学习了继承如何帮助我们更好地控制应用程序的实现。使用继承,我们可以确保相似的类实现一套它们共有的属性和方法。

在编写任何 C#程序时,我们高度重要的是要记住这些原则。一些 C#程序员现在犯的最大错误是他们没有利用面向对象编程的核心原则,相反,编写的程序更像是过程性语言程序。从维护的角度来看,这对我们非常有帮助,因为在某种程度上,它确保了一个模块中的错误修复不会影响整个应用程序。

在下一章中,我们将探讨在 C#编程中使用的不同运算符。我们将探讨如何使用运算符和不同的条件选择语句来管理程序流程。我们还将探讨不同的迭代语句,如 for 循环和 while 循环,这些语句有助于我们控制程序的流程。

问题

  1. 以下哪个选项最好地描述了一个具有相同名称但参数数量和类型不同的多个函数的程序?

    1. 方法重载

    2. 方法覆盖

    3. 封装

    4. 抽象

  2. 当派生类定义在基类中存在的函数的实现时,必须使用哪个关键字?

    1. 摘要

    2. 虚拟

    3. 覆盖

  3. 我们可以使用哪个关键字来防止特定类的继承?

    1. 摘要

    2. 私有的

    3. 密封

    4. 受保护的

答案

  1. 方法重载或函数重载是这样一个概念,即具有相同名称的不同函数实现被创建。根据参数的数量或参数的类型,加载适当的函数实现。

  2. 覆盖关键字允许派生类实现基类中声明的抽象方法。

  3. 密封。如果一个类被声明为密封的,它将防止在整个应用程序中继承基类。

第四章:实现程序流程

本章重点介绍如何在 C# 中管理程序流程。换句话说,本章将帮助您了解程序如何通过 C# 中可用的语句控制、验证输入和输出参数并做出决策。我们将涵盖各种布尔表达式,例如 If/Else 和 Switch,它们根据特定条件控制代码的流程。我们还将评估各种运算符,例如条件运算符和相等运算符(<, >, ==),它们都控制代码的流程。我们将关注如何通过集合(使用 for 循环、while 循环等)和显式跳转语句进行迭代。

本章将涵盖以下主题。

  • 理解运算符

  • 理解条件/选择语句

  • 迭代语句

技术要求

本章的练习可以使用 Visual Studio 2012 或更高版本以及 .NET Framework 2.0 或更高版本进行练习。然而,任何从版本 7.0 及以上版本的新 C# 功能都需要您拥有 Visual Studio 2017。

如果您没有这些产品的许可证,您可以下载 Visual Studio 2017 的社区版本:visualstudio.microsoft.com/downloads/

本章的示例代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Programming-in-C-sharp-Exam-70-483-MCSD-Guide/tree/master/Chapter04

理解运算符

在我们深入这个主题之前,让我们了解运算符和操作数是什么。这两个是我们将在本书的这一部分使用的重要术语:

  • 运算符是应用于表达式或语句中的一个或多个操作数的编程元素。

  • 操作数是可以被操作的对象。

C# 提供不同类型的运算符,例如一元运算符([增量运算符] ++, new),它接受一个操作数,二元算术类型的运算符(+, -, *, /),关系类型(> ,<, <=, >=),相等类型(=, !=),以及位移类型(>>, <<),所有这些都在两个操作数之间使用。C# 还提供了一种三元运算符,它接受三个操作数(?:)。

一元运算符

只需要一个操作数的运算符称为一元运算符。它们可以执行增量、减量、取反等操作。它们也可以应用于操作数之前(前缀)或之后(后缀)。

下表列出了几个一元运算符。x 在左侧列中是应用运算符的操作数:

表达式 描述
+x 恒等性:此运算符可以用作一元或二元运算符。如果用于数值,则返回该值。如果应用于两个数值操作数,则返回操作数的总和。在字符串上,它连接两个操作数。
-x 取反: 这个运算符可以用作一元或二元运算符。对数值类型应用此运算符会导致操作数的数值取反。
!x 逻辑取反: 这个运算符取反操作数。它应用于布尔操作数,并在操作数为假时返回。
~x 位取反: 通过反转每个位来产生操作数的补码。
++x 前置递增: 这是一个递增运算符,可以出现在操作数之前或之后。当作为前缀时,结果放在递增之后。如果作为后缀,结果放在递增之前。
--x 前置递减: 这是一个递减运算符,可以出现在操作数之前或之后。当作为前缀时,结果放在递减之后。如果作为后缀,结果放在递减之前。

在以下代码中,我们将声明一些变量并使用它们来展示前面运算符的示例:

int firstvalue = 5; 
int secondvalue = 6;
string firststring = "Hello ";
string secondstring = "World";

+- 可以与单个操作数或多个操作数一起使用。当与多个整数类型的操作数一起使用时,它们要么对操作数求和,要么得到差值。+ 运算符也可以与字符串类型的操作数一起使用。在这种情况下,它将连接两个字符串。字符串和运算符始终是二进制运算符:

//'+' operator
Console.WriteLine(+firstvalue); // output: 5
Console.WriteLine(firstvalue + secondvalue); // output: 11
Console.WriteLine(firststring + secondstring); // output: Hello World
//'-' operator
Console.WriteLine(-firstvalue); // output: -5
Console.WriteLine(firstvalue - secondvalue); // output = -1

! 运算符与布尔操作数配合良好,它产生逻辑取反;也就是说,真变为假,而 ~ 运算符与位运算符配合使用。在以下示例中,显示了一个二进制位表示及其位取反。我们取一个整数值,将其转换为二进制值,然后使用 ~ 运算符取反,并以二进制格式显示:

//'!' operator
Console.WriteLine(!true); 

//output : false

//'~' operator
Console.WriteLine("'~' operator");
int digit = 60;
Console.WriteLine("Number is : {0} and binary form is {1}:", digit, IntToBinaryString(digit));
int digit1 = ~digit;
Console.WriteLine("Number is : {0} and binary form is {1}:", digit1, Convert.ToString(digit1, 2));

//Output: 
Number is : 60 and binary form is 111100:
Number is : -61 and binary form is 11111111111111111111111111000011

++-- 运算符,当应用于整数操作数时,分别对操作数执行递增或递减操作。这些可以应用于操作数的前面或后面。以下示例显示了后置和前置递增和递减运算符。前置在显示之前产生结果,后置在显示之后产生结果:

// '++' Operator
Console.WriteLine(++firstvalue); // output: 6
// '--' Operator
Console.WriteLine(--firstvalue); // output: 5
// '++' Operator
Console.WriteLine(firstvalue++); // output: 5
Console.WriteLine(firstvalue--); // output: 6

关系运算符

如其名所示,关系运算符测试或定义两个操作数之间的关系,例如,如果第一个操作数小于第二个操作数,或者大于等于它。这些运算符应用于数值操作数。

下表列出了几个二进制运算符:

表达式 描述
< 定义为小于运算符。用作 X < Y。如果第一个操作数小于第二个操作数,则返回 true。
> 定义为大于运算符。用作 X > Y。如果第一个操作数大于第二个操作数,则返回 true。
<= 小于或等于运算符。用作 X <= Y
>= 大于或等于运算符。用作 X >= Y

我们将使用前面示例中定义的相同变量来理解这些关系运算符。在这里,我们正在尝试找出firstvalue是否小于secondvaluefirstvalue是否大于secondvalue

// '<' Operator
Console.WriteLine(firstvalue < secondvalue); 
// output = true

// '>' Operator
Console.WriteLine(firstvalue > secondvalue); 
// output = false

// '>=' Operator
Console.WriteLine(secondvalue >= firstvalue); 
// output = true

// '<=' Operator
Console.WriteLine(firstvalue <= secondvalue); 
// output = true

相等运算符

相等运算符是一种二元运算符,需要两个操作数。因为它们检查操作数的相等性,所以这些也可以称为关系运算符。

以下表格列出了可用的相等运算符:

表达式 描述
== 这适用于预定义值类型。定义为相等运算符。用作X == Y。如果第一个操作数等于第二个操作数,则返回true
!= 定义为不等运算符。用作X! = Y。如果操作数不相等,则返回true

我们将使用前面示例中创建的相同变量来尝试理解相等运算符。在这里,我们正在尝试检查firstvalue是否等于secondvalue或不相等:

//using variables created earlier.
// '==' Operator
Console.WriteLine(secondvalue == firstvalue); // output = false
// '!=' Operator
Console.WriteLine(firstvalue != secondvalue); // output = true

位移运算符

位移运算符是另一种类型的二元运算符。它们接受两个整数操作数,并根据指定的位数将位左移或右移。

以下表格列出了可用的位移运算符:

表达式 描述
<< 这是一个二元运算符的示例,允许你将第一个操作数左移由第二个操作数指定的位数。第二个操作数必须是整数类型。
>> 这是一个二元运算符的示例,允许你将第一个操作数右移由第二个操作数指定的位数。第二个操作数必须是整数类型。

在以下示例中,程序接受一个整数操作数,并将其左移或右移 1 位。位移操作适用于二元运算符,因此,为了我们的理解,我编写了一个方法,将整数转换为二进制格式并显示。当我们向程序传递整数9,即i,并使用>>运算符时,其二进制字符串右移 1 位,并显示结果。当1001右移时,它变为100

public static string IntToBinaryString(int number)
{
    const int mask = 1;
    var binary = string.Empty;
    while (number > 0)
    {
        // Logical AND the number and prepend it to the result string
        binary = (number & mask) + binary;
        number = number >> 1;
    }
    return binary;
}

// '>>' Operator
Console.WriteLine("'>>' operator");
int number = 9;
Console.WriteLine("Number is : {0} and binary form is {1}:", number, IntToBinaryString(number));
number = number >> 1;
Console.WriteLine("Number is : {0} and binary form is {1}:", number, IntToBinaryString(number));

//Output: 
//Number is : 9 and binary form is 1001
//Number is : 4 and binary form is 100

// '<<' Operator
Console.WriteLine("'<<' operator");
Console.WriteLine("Number is : {0} and binary form is {1}:", number, IntToBinaryString(number));
number = number << 1;
Console.WriteLine("Number is : {0} and binary form is {1}:", number, IntToBinaryString(number));

//Output: 
//Number is : 4 and binary form is 100
//Number is : 8 and binary form is 1000

逻辑、条件和 null 运算符

C# 允许你使用OR||)、AND&&)或XOR^)将上述运算符组合起来。这些应用于表达式中的两个操作数。

以下表格列出了逻辑、条件和null运算符:

表达式 描述和示例
逻辑或 (&#124;) 此运算符计算两个操作数,如果两个操作数都为false,则返回false
逻辑与 (&) 此运算符有两种形式:一元地址运算符或二元逻辑运算符。当用作一元地址运算符时,它返回操作数的地址。如果用作二元运算符,它评估两个操作数,如果两个操作数都为true,则返回true;否则,它将返回false
条件 AND (&&) 当需要评估两个布尔操作数时使用此条件运算符。当应用时,计算两个操作数,如果两个操作数都为 true,则返回 true。如果第一个操作数返回 false,则条件运算符不会评估另一个操作数。这也被称为短路逻辑 AND 运算符。
条件 OR (&#124;&#124;) 这也称为短路逻辑 OR 运算符。条件 OR 运算符评估两个布尔操作数,如果任一操作数为 true,则返回 true。如果第一个操作数返回 true,则不会评估第二个操作数。
逻辑异或 (^) 此运算符对于整型类型是按位异或,对于布尔类型是逻辑异或和。当应用时,它计算两个操作数,如果任一操作数为 true,则返回 true;否则返回 false
空合并 (??) 空合并运算符计算两个操作数并返回非 null 的操作数。其用法如下:int y = x ?? 1;。在这种情况下,如果 xnull,则 y 被分配值为 1;否则,y 被分配值为 x
三元运算符 (?:) 条件运算符也称为三元运算符,用于评估布尔表达式。条件是 ? true value : false value。如果条件为 true,则运算符返回 true value,但如果条件为 false,则运算符返回 false value。三元运算符支持嵌套表达式或运算符,这也称为右结合性

以下代码将使我们能够详细了解每个语句。最初,我们将定义所需的变量和方法,然后对每个语句进行处理。以下代码可在 GitHub 上找到。链接在技术要求部分提供:

int firstvalue = 5; 
int secondvalue = 6;
int? nullvalue = null;
private bool SecondOperand(bool result)
{
    Console.WriteLine("SecondOperand computed");
    return result;
}
private bool FirstOperand(bool result)
{
    Console.WriteLine("FirstOperand computed");
    return result;
}

在以下代码块中,逻辑 OR (|) 展示了 | 运算符的用法。以下代码块中有两个布尔表达式在运行时评估并返回 truefalse。此运算符始终返回 true,除非两个操作数都返回 false

//LOGICAL OR (|)
Console.WriteLine((firstvalue > secondvalue) | (firstvalue < secondvalue)); 
// output : true
Console.WriteLine((firstvalue < secondvalue) | (firstvalue < secondvalue)); 
// output : true
Console.WriteLine((firstvalue < secondvalue) | (firstvalue > secondvalue)); 
// output : true
Console.WriteLine((firstvalue > secondvalue) | (firstvalue > secondvalue)); 
// output : false

在以下代码块中,逻辑 AND 展示了 & 运算符的用法。逻辑 AND 评估两个操作数,如果两个操作数都评估为 true,则返回 true;否则返回 false

//LOGICAL AND (&)
Console.WriteLine(FirstOperand(true) & SecondOperand(true)); 
// output : FirstOperand computed, SecondOperand computed, true
Console.WriteLine(FirstOperand(false) & SecondOperand(true)); 
// output : FirstOperand computed, SecondOperand computed, false
Console.WriteLine(FirstOperand(true) & SecondOperand(false)); 
// output : FirstOperand computed, SecondOperand computed, false
Console.WriteLine(FirstOperand(false) & SecondOperand(false)); 
// output : FirstOperand computed, SecondOperand computed, false

在以下代码块中,条件 AND (&&) 说明了 && 运算符。此运算符评估第一个操作数,如果它是 true,则评估第二个操作数。否则,它返回 false

//CONDITIONAL AND (&&)
Console.WriteLine(FirstOperand(true) && SecondOperand(true)); 
// output = FirstOperand computed, SecondOperand computed, true
Console.WriteLine(FirstOperand(false) && SecondOperand(true)); 
// output = FirstOperand computed, false
Console.WriteLine(FirstOperand(true) && SecondOperand(false)); 
// output = FirstOperand computed, false
Console.WriteLine(FirstOperand(false) && SecondOperand(false)); 
// output = FirstOperand computed, false

在以下代码块中,条件 OR (||) 说明了 || 运算符。此运算符如果任一操作数为 true,则返回 true;否则返回 false

//CONDITIONAL OR (||)
Console.WriteLine(FirstOperand(true) || SecondOperand(true)); 
// output = FirstOperand computed, true
Console.WriteLine(FirstOperand(false) || SecondOperand(true)); 
// output = FirstOperand computed, SecondOperand computed, true
Console.WriteLine(FirstOperand(true) || SecondOperand(false)); 
// output = FirstOperand computed, true
Console.WriteLine(FirstOperand(false) || SecondOperand(false)); 
// output = FirstOperand computed, SecondOperand computed, false 

在以下代码中,逻辑 XOR (^) 解释了 ^ 运算符在 bool 操作数上的用法。如果其中一个操作数是 true,则返回 true。这与逻辑 OR 运算符类似:

//LOGICAL XOR (^)
Console.WriteLine(FirstOperand(true) ^ SecondOperand(true)); 
// output = FirstOperand computed, SecondOperand computed, false
Console.WriteLine(FirstOperand(false) ^ SecondOperand(true)); 
// output = FirstOperand computed, SecondOperand computed,true
Console.WriteLine(FirstOperand(true) ^ SecondOperand(false)); 
// output = FirstOperand computed, SecondOperand computed,true
Console.WriteLine(FirstOperand(false) ^ SecondOperand(false)); 
// output = FirstOperand computed, SecondOperand computed,false

在这里,我们将探讨空合并和三元运算符。空合并运算符 ?? 用于在返回其值之前检查操作数是否为 null。如果第一个操作数不是 null,则返回其值;否则,返回第二个操作数。这也可以以嵌套形式使用。

三元运算符 (?:) 用于评估表达式。如果它是 true,则返回 true-value;否则,返回 false-value

//Null Coalescing (??)
Console.WriteLine(nullvalue ?? firstvalue);// output : 5

//Ternary Operator (? :)
Console.WriteLine((firstvalue > secondvalue) ? firstvalue : secondvalue);// output : 6
Console.WriteLine((firstvalue < secondvalue) ? firstvalue : secondvalue);// output : 5

理解条件/选择语句

C# 提供了多个条件/选择语句,帮助我们在整个编程过程中做出决策。我们可以使用我们在上一节中学到的所有运算符与这些语句一起使用。这些语句帮助程序根据表达式评估为 truefalse 来采取特定的流程。这些语句是 C# 中最广泛使用的语句。

以下表格列出了可用的条件/选择语句:

Expression 描述
If..else 如果语句评估提供的表达式。如果它是 true,则执行这些语句。如果它是 false,则执行 else 语句。
Switch..case..default Switch 语句评估特定表达式,如果模式与匹配表达式匹配,则执行 switch 部分。
break break 允许我们终止控制流并继续执行下一个语句。
goto goto 用于在表达式评估为 true 时将控制转移到特定标签。

在以下小节中,我们将详细介绍这些语句。

if...else

在用户希望满足条件时执行特定代码块的场景中,使用 if 语句简单且容易。C# 提供了广泛使用的 if 语句,使我们能够实现所需的功能。

If (true) then-语句 Else (false) else-语句。以下为 If / Else 语句的一般语法:

If(Boolean expression)
{
    Then statements;  //these are executed when Boolean expression is true
}
Else
{
    Else statements; //these are executed when Boolean expression is false
}

当布尔表达式评估为 true 时,执行 then-statements;当布尔表达式评估为 false 时,执行 else-statements。当布尔表达式评估为 truefalse 时,程序允许你执行单个或多个语句。然而,多个语句需要用花括号 {} 括起来。这将确保所有语句在一个上下文中按顺序执行。这也被称为 代码块。对于单个语句,这些括号是可选的,但从代码可读性的角度来看,它们是推荐的。此外,我们需要理解变量的作用域仅限于它们被定义的代码块内。

else 语句是可选的。如果没有提供,程序将评估布尔表达式并执行then-statement。在任何给定的时间,if-else语句的then-statementselse-statements中只有一个会被执行。

让我们看看几个例子。在下面的代码块中,我们已经将条件变量设置为true,所以当 if 语句中的布尔表达式被评估时,它返回true,并执行代码块(then-statement)。Else-statement被忽略:

bool condition = true;
if (condition) 
{
    Console.WriteLine("Then-Statement executed");
}
else
{
    Console.WriteLine("Else-Statement executed");
}
//output: Then-Statement executed

在以下场景中,如果语句不包括 else 部分,当布尔表达式评估为true时,默认情况下将执行then-statements

if (condition) 
{
    Console.WriteLine("Then-Statement without an Else executed");
}
//output: Then-Statement without an Else executed

C#还允许嵌套的if和嵌套的else语句。在下面的代码中,我们将看到如何在程序中使用嵌套的 if 语句。

当条件1评估为true时,默认情况下,将执行条件1then-statements。同样,当条件2评估为true时,将执行条件2then-statements

int variable1 = 15;
int variable2 = 10;

if (variable1 > 10)//Condition 1
{
    Console.WriteLine("Then-Statement of condition 1 executed");
     if (variable2 < 15) //Condition 2
     {
          Console.WriteLine("Then-Statement of condition 2 executed");
     }
     else
     {
          Console.WriteLine("Else-Statement of condition 2 executed");
     }
}
else
{
    Console.WriteLine("Then-Statement condition 1 executed");
}
//Output: 
Then-Statement of condition 1 executed
Then-Statement of condition 2 executed

我们也可以在Else语句中定义嵌套的 if。例如,用户想要找出输入的字符是否是元音,如果是,则打印它。下面的代码块说明了如何使用多个 if 语句。程序检查输入的字符是否是元音,并打印结果:

Console.Write("Enter a character: ");
char ch = (char)Console.Read();
if (ch.Equals('a'))
{
    Console.WriteLine("The character entered is a vowel and it is 'a'.");
}
else if (ch.Equals('e'))
{
    Console.WriteLine("The character entered is a vowel and it is 'e'.");
}
else if (ch.Equals('i'))
{
    Console.WriteLine("The character entered is a vowel and it is 'i'.");
}
else if (ch.Equals('o'))
{
    Console.WriteLine("The character entered is a vowel and it is 'o'.");
}
else if (ch.Equals('u'))
{
    Console.WriteLine("The character entered is a vowel and it is 'u'.");
}
else
{
    Console.WriteLine("The character entered is not vowel. It is:" + ch );
}

switch...case...default

switch 语句根据条件或多个条件评估一个表达式,并执行一个带标签的代码块。这些带标签的代码块被称为 switch 标签。每个 switch 标签后面跟着一个 break 语句,这有助于程序跳出循环并继续执行下一个语句。在先前的例子中,我们使用if...else语句检查元音,我们为每个元音使用if...else,并为任何其他字符提供一个默认值。这可以通过使用switch...case...default语句进一步简化。

我们所希望的是,一个条件表达式检查字符。如果它与任何匹配的表达式匹配,即元音,则打印元音;否则,打印它不是元音:

Console.Write("Enter a character: ");
char ch1 = (char)Console.Read();
switch (ch1)
{
    case 'a' :
    case 'e':
    case 'i' :
    case 'o' :
    case 'u':
     Console.WriteLine("The character entered is a vowel and it is: " + ch1);
        break;
    default:
        Console.WriteLine("The character entered is not vowel and it is: " + ch1);
        break;
}

break

在 C#中,break;语句允许我们在包含它的循环或语句块中跳出。例如,在递归函数中,你可能需要在 n 次迭代后跳出。或者,在一个例子中,你想要在 10 次迭代的循环中打印前 5 个数字,你将想要使用 break 语句:

for (int i = 1; i <= 10; i++)
{
    if (i == 5)
    {
        break;
    }
    Console.WriteLine(i);
}

//output:
1
2
3
4

goto

Goto语句允许程序将控制权转移到特定的部分或代码块。这也被称为带标签的语句。经典的例子是我们在上一节讨论的Switch..case语句。当一个表达式匹配一个 case 时,该代码块中的带标签的准则语句将被执行:

for (int i = 1; i <= 10; i++)
{
    if (i == 5)
    {
        goto  number5;
    }
    Console.WriteLine(i);
}
number5:
    Console.WriteLine("You are here because of Goto Label");
//Output
1
2
3
4
You are here because of Goto Label

continue

continue;语句允许程序跳过该代码块中语句的执行,直到代码块结束,然后继续下一个迭代。例如,在一个1..10for循环中,如果continue语句放在一个表达式内,即i <= 5,它会查看所有 10 个数字,但只有 6、7、8、9 和 10 会执行操作:

for (int i = 1; i <= 10; i++)
{
    if (i <= 5)
    {
        continue;
    }
    Console.WriteLine(i);
}
//output
6
7
8
9
10

迭代语句

迭代语句帮助执行循环特定次数或直到满足条件表达式。当循环开始时,代码块中的所有语句都按顺序执行。如果程序遇到跳转语句继续语句,执行流程将改变。在go-to的情况下,控制移动到标记的代码块,而在继续语句的情况下,循环忽略继续之后的全部语句。

以下是在 C#中需要迭代或循环时使用的关键字:

  • do

  • for

  • foreach...in

  • while

do...while

总是与while语句一起使用do语句。do语句执行代码块并评估while表达式。如果while语句评估为true,则代码块再次执行。只要while评估为true,这个过程就会继续。因为条件表达式是在代码块执行之后评估的,所以do...while总是至少执行代码块一次。

break;continue;returnthrow可以在执行过程中任何时候退出这个循环:

int intvariable = 0;
 do
 {
     Console.WriteLine("Number is :" + intvariable);
     intvariable++;

 } while (intvariable < 5);

//Output
Number is :0
Number is :1
Number is :2
Number is :3
Number is :4

for

do..while不同,for首先评估条件表达式,如果为true,则执行代码块。一旦条件为真,代码块才会执行。与do..while类似,我们可以使用returnthrowgotocontinue语句退出循环。

看一下以下for语句的结构:

for (initializer; condition; iterator) 
{
    body
}

初始化器、条件、迭代器都是可选的。主体可以是一行语句或整个代码块:

for (int i = 0; i <= 5; i++)
{
    Console.WriteLine("Number is :" + i);
}

//output
Number is :0
Number is :1
Number is :2
Number is :3
Number is :4
Number is :5

初始化部分

这个部分只执行一次。当程序控制遇到for循环时,初始化部分被触发。#允许在for循环的初始化部分使用一个或多个以下语句,语句之间用逗号分隔:

  • 局部循环变量的声明。这个变量在循环外部不可用。

  • 一个赋值语句。

  • 方法调用。

  • 前置/后置递增或递减。

  • 新对象创建。

  • 等待表达式。我们将在接下来的章节中更详细地探讨这个问题。

条件部分

如我们之前提到的,这是一个可选部分。如果没有提供,默认情况下,它被评估为true。如果提供了,则在每次迭代执行之前评估条件表达式。如果条件评估为false,则循环终止。

迭代部分

迭代部分定义了循环体的行为。正如在 初始化器部分 中详细说明的那样,它可以包含上述一个或多个语句。

语句的罕见用法示例

这里有一个参考示例:

int k;
int j = 10;
for (k = 0, Console.WriteLine("Start: j={j}"); k < j; k++, j--, Console.WriteLine("Step: k={k}, j={j}"))
{
    // Body of the loop.
}
for (; ; )
{
    // Body of the loop.
}

foreach...in

Foreach 可用于 IEnumerable 类型或泛型集合的实例。这与 for 循环类似工作。Foreach 不仅限于这些类型;它还可以应用于任何实现无参数 GetEnumerator 方法并返回类、结构或接口类型的实例。Foreach 还可以应用于由 GetEnumeratorCurrent 属性和参数较少的 MoveNext 方法返回的类型,这些方法返回一个布尔值。

从 C# 7.3 开始,Current 属性返回对返回值的引用(ref T),其中 T 是集合元素类型。

在以下示例中,我们声明了一个字符串列表,并希望遍历列表,在屏幕上显示每个项目:

List<string> stringlist = new List<string>() { "One", "Two", "Three" };
foreach (string str in stringlist)
{
    Console.WriteLine("Element #"+ str);
}

//Output:
Element #One
Element #Two
Element #Three

IEnumerator 有一个名为 Current 的属性和一个名为 MoveNext 的方法。由于 foreach 循环旨在迭代实现这两个方法的集合,它跟踪集合中当前正在评估和处理的项目。这确保了控制不会传递到集合的末尾。此外,foreach 循环不允许用户更改初始化的循环变量,但允许它们修改变量引用的对象中的值。

while

for 循环类似,在执行代码块之前会评估条件。这意味着代码块要么执行多次,要么根本不执行。就像任何其他循环一样,您可以使用 breakcontinuereturnthrow 语句退出循环:

int n = 0;
while (n < 5)
{
    Console.WriteLine(n);
    n++;
}
//output
0
1
2
3
4

摘要

在本章中,我们探讨了算术运算符、关系运算符、移位运算符以及相等、条件和逻辑运算符,这些运算符可以与一个或两个操作数一起使用,并使用逻辑和条件运算符作为布尔表达式进行评估。

我们探讨了条件语句和选择性语句,这些语句帮助我们做出决策。这些语句的例子包括 if 条件、then 语句和 else 语句。switch...case...default 帮助匹配多个表达式并执行多个 switch 标签。

我们还探讨了迭代语句,它允许用户遍历一个集合。当与 gotocontinue 等跳转语句一起使用时,它们可以退出循环。

在下一章中,我们将详细探讨委托和事件。委托和事件在 C# 编程中扮演着重要角色。能够为事件调用回委托使我们能够解耦我们的程序。我们还将了解 Lambda 表达式,它可以用来创建委托。这些也被称为 匿名 方法。

问题

  1. 你有一个评估很多条件的情况。在某个特定场景中,你想要评估两个操作数,如果为真,则执行代码块。以下哪个语句你会使用?

    1. &&

    2. ||

    3. &

    4. ^

  2. 你在代码中使用for循环,并且想要在满足条件时执行特定的代码块。以下哪个语句你会使用?

    1. break;

    2. continue;

    3. throw;

    4. goto;

  3. 在你的程序中,有一个你想要至少执行一次并且直到条件评估为真才停止执行的代码块。以下哪个语句你会使用?

    1. While;

    2. Do...while;

    3. For;

    4. foreach;

答案

  1. c

  2. d

  3. b

进一步阅读

更多关于语句、表达式和操作符的信息可以在docs.microsoft.com/en-us/dotnet/csharp/programming-guide/statements-expressions-operators/找到。

Packt Publishing 网站上有一个有帮助的视频。它被称为Programming in C# .NET (search.packtpub.com/?query=70-483&refinementList%5Breleased%5D%5B0%5D=Available)。

第五章:创建和实现事件与回调

本章重点介绍 C#中的事件和回调。了解它们很重要,因为它们使我们能够更好地控制程序。事件是对象属性更改或按钮被点击时发出的消息或通知。回调,也称为代理,持有函数的引用。C#自带 Lambda 表达式,可以用来创建代理。这些也被称为匿名方法。

我们还将花一些时间了解一个新的运算符,称为 Lambda 运算符。这些用于 Lambda 表达式。它们是在 C# 3.0 版本中引入的,以便开发人员可以实例化代理。Lambda 表达式取代了 C# 2.0 中引入的匿名方法,并且现在被广泛使用。

在本章中,我们将涵盖以下主题:

  • 理解代理

  • 处理和引发事件

到本章结束时,您将了解代理是什么以及如何在事件和回调中使用它们。

技术要求

本章中的练习可以使用 Visual Studio 2012 或更高版本以及.NET Framework 2.0 或更高版本进行练习。然而,从 7.0 版本开始的所有新的 C#功能都需要您安装 Visual Studio 2017。

如果您没有上述任何产品的许可证,您可以下载 Visual Studio 2017 的社区版,网址为:visualstudio.microsoft.com/downloads/

本章的示例代码可以在 GitHub 上找到:github.com/PacktPublishing/Programming-in-C-Sharp-Exam-70-483-MCSD-Guide

理解代理

代理实际上只是一个方法引用,包括一些参数和一个返回类型。当定义代理时,它可以与任何提供兼容签名和方法返回类型的实例相关联。换句话说,代理在 C 和 C++中可以定义为函数指针,但代理是类型安全、安全且面向对象的。

代理模型遵循观察者模式,允许订阅者注册并从提供者接收通知。为了更好地理解观察者模式,请参阅本章末尾的“进一步阅读”部分提供的参考资料。

代理的一个经典例子是 Windows 应用程序中的事件处理器,这些是代理调用的方法。在事件处理的上下文中,代理是事件源和处理事件代码之间的中介。

由于代理能够将方法作为参数传递,因此它们非常适合回调。代理是从System.Delegate类派生出来的。

delegate的一般语法如下:

delegate <return type> <delegate name> <parameter list>

代理声明的示例如下:

public delegate string delegateexample (string strVariable);

在前面的例子中,定义的委托可以被任何具有单个字符串参数并返回字符串变量的方法引用。

实例化一个委托

当我们使用 C# 2.0 之前的版本时,可以使用命名方法。2.0 版本引入了一种新的实例化委托的方法。我们将在接下来的章节中尝试理解这些方法。C# 3.0 版本用 Lambda 表达式替换了匿名方法,Lambda 表达式现在被广泛使用。

使用命名方法初始化委托

让我们看看NamedMethod的一个例子,以便我们了解如何初始化一个delegate。这是在 C# 2.0 之前使用的方法:

delegate void MathDelegate(int i, double j);
public class Chapter5Samples
{
  // Declare a delegate
  public void NamedMethod()
  {
    Chapter5Samples m = new Chapter5Samples();
    // Delegate instantiation using "Multiply"
    MathDelegate d = m.Multiply;
    // Invoke the delegate object.
    Console.WriteLine("Invoking the delegate using 'Multiply':");
    for (int i = 1; i <= 5; i++)
    {
      d(i, 5);
    }
    Console.WriteLine("");

  }
  // Declare the associated method.
  void Multiply(int m, double n)
  {
    System.Console.Write(m * n + " ");
  }
}
//Output:
Invoking the delegate using 'Multiply':
5 10 15 20 25

在前面的代码中,首先我们定义了一个名为MathDelegate的委托,它接受2个参数,1个整数和另一个双精度类型。然后,我们定义了一个类,我们想在其中使用一个名为Multiply的命名方法来调用MathDelegate

MathDelegate d = m.Multiply;这一行是将一个命名方法赋值给委托。

命名方法委托可以封装任何可访问的类或结构,这些类或结构与委托类型匹配,从而允许开发者扩展这些方法。

在以下示例中,我们将看到如何将委托映射到静态和实例方法。将以下方法添加到我们之前创建的Chapter5Samples类中:

public void InvokeDelegate()
{
  HelperClass helper = new HelperClass();

  // Instance method mapped to delegate:
  SampleDelegate d = helper.InstanceMethod;
  d();

  // Map to the static method:
  d = HelperClass.StaticMethod;
  d();
}

//Create a new Helper class to hold two methods
// Delegate declaration
delegate void SampleDelegate();

internal class HelperClass
{
  public void InstanceMethod()
  {
    System.Console.WriteLine("Instance Method Invoked.");
  }

  static public void StaticMethod()
  {
    System.Console.WriteLine("Invoked function Static Method.");
  }
}

//Output: 
Invoked function Instance Method.
Invoked function Static Method.

在前面的代码中,我们定义了两个方法:第一个是一个普通方法,而第二个是一个静态方法。在调用使用命名方法的委托的情况下,我们可以使用第一个普通方法或第二个静态方法:

  • SampleDelegate d = helper.InstanceMethod;: 这是一个普通方法。

  • d = HelperClass.StaticMethod;: 这是一个静态方法。

使用匿名函数初始化委托

在创建新方法可能被视为开销的情况下,C# 允许我们初始化一个委托并指定一个代码块。当委托被调用时,它将处理这个代码块。这是 C# 2.0 中用于调用委托的方法。它们也被称为匿名方法。

一个内联定义的表达式或语句,而不是委托类型,被称为匿名函数。

有两种匿名函数:

  • Lambda 表达式

  • 匿名方法

我们将在接下来的小节中查看这两种类型的函数。然而,在继续之前,我们还应该了解一个新运算符,称为Lambda 运算符。这个运算符用于表示 Lambda 表达式。

Lambda 表达式

从 C# 3.0 开始,引入了 Lambda 表达式,并且在调用委托时被广泛使用。Lambda 表达式是通过 Lambda 运算符创建的。在运算符的左侧,我们指定输入参数,而在右侧,我们指定表达式或代码块。当 Lambda 运算符在表达式体中使用时,它将成员的名称与其实现分开。

Lambda 操作符表示为 => 符号。这个操作符是右结合的,并且与赋值操作符具有相同的优先级。赋值操作符将右侧操作数的值赋给左侧操作数。

在下面的代码中,我们使用 Lambda 操作符来比较字符串数组中的一个特定单词并返回它。在这里,我们将 Lambda 表达式应用于 words 数组的每个元素:

words.Where(w => w.Equals("apple")).FirstOrDefault();

此示例还展示了我们如何使用 LINQ 查询来获得相同的结果。

我们尝试使用 LINQ 查询从一个单词数组中找到 "apple"。任何可枚举集合都允许我们使用 LINQ 进行查询,并返回所需的结果:

public void LambdaOperatorExample()
{
    string[] words = { "bottle", "jar", "drum" };
    // apply Lambda expression to each element in the array
    string searchedWord = words.Where(w => 
                            w.Equals("drum")).FirstOrDefault();
    Console.WriteLine(searchedWord);
    // Get the length of each word in the array.
    var query = from w in words
                where w.Equals("drum")
                select w;

    string search2 = query.FirstOrDefault();
    Console.WriteLine(search2);
}

//Output:
drum
drum

Lambda 表达式是 Lambda 操作符的右侧操作符,并且在表达式树中得到了广泛的应用。

更多关于表达式树的信息可以在 Microsoft 文档网站上找到。

这个 Lambda 表达式必须是一个有效的表达式。如果成员类型是 void,它会被归类为语句表达式。

从 C# 6 开始,这些表达式支持方法和属性获取语句,而从 C# 7 开始,这些表达式支持构造函数、析构函数、属性设置语句和索引器。

在下面的代码中,我们使用表达式来编写变量的第一个名字和最后一个名字,并且我们还使用了 Trim() 函数:

public override string ToString() => $"{fname} {lname}".Trim();

在对 Lambda 表达式和 Lambda 操作符有了基本理解之后,我们可以继续探讨如何使用 Lambda 表达式来调用委托。

回想一下,Lambda 表达式可以表示如下:

Input-Parameters => Expression

在下面的示例中,我们向现有方法中添加了两行代码来使用 Lambda 表达式调用委托。X 是输入参数,其中 X 的类型由编译器确定:

delegate void StringDelegate(string strVariable);
public void InvokeDelegatebyAnonymousFunction()
{
  //Named Method
  StringDelegate StringDel = HelperClass.StringMethod;
  StringDel("Chapter 5 - Named Method");

  //Anonymous method
  StringDelegate StringDelB = delegate (string s) { Console.WriteLine(s); };
  StringDelB("Chapter 5- Anonymous method invocation");

  //LambdaExpression
  StringDelegate StringDelC = (X)=> { Console.WriteLine(X); };
  StringDelB("Chapter 5- Lambda Expression invocation");

}

//Output:
Chapter 5 - Named Method
Chapter 5- Anonymous method invocation
Chapter 5- Lambda Expression invocation

匿名方法

C# 2.0 引入了匿名方法,而 C# 3.0 引入了 Lambda 表达式,后来 Lambda 表达式被匿名方法所取代。

在使用 Lambda 表达式时,匿名方法提供了一些使用 Lambda 表达式时无法实现的功能,例如,它们允许我们避免参数。这些允许匿名方法转换为具有不同签名的委托。

让我们看看如何使用匿名方法来初始化委托的示例:

public void InvokeDelegatebyAnonymousFunction()
{
  //Named Method
  StringDelegate StringDel = HelperClass.StringMethod;
  StringDel("Chapter 5");

  //Anonymous method
  StringDelegate StringDelB = delegate (string s) { Console.WriteLine(s); };
  StringDelB("Chapter 5- Anonymous method invocation");

}
internal class HelperClass
{
  public void InstanceMethod()
  {
    System.Console.WriteLine("Instance method Invoked.");
  }

  public static void StaticMethod()
  {
    System.Console.WriteLine("Invoked function Static Method.");
  }

  public static void StringMethod(string s)
  {
    Console.WriteLine(s);
  }
}

//Output:
Chapter 5
Chapter 5- Anonymous method invocation

在前面的代码中,我们定义了一个字符串委托并编写了一些内联代码来调用它。以下是我们定义内联委托(也称为匿名方法)的代码:

StringDelegate StringDelB = delegate (string s) { Console.WriteLine(s); };

以下代码展示了如何创建匿名方法:

// Creating a handler for a click event.
sampleButton.Click += delegate(System.Object o, System.EventArgs e)
                   { System.Windows.Forms.MessageBox.Show(
                     "Sample Button Clicked!"); };

在这里,我们创建了一个代码块并将其作为 delegate 参数传递。

如果在代码块内部遇到任何跳转语句(如 gotobreakcontinue),并且目标在代码块外部,匿名方法将抛出错误。此外,在跳转语句在代码块外部且目标在内部的情况下,使用 int 匿名方法将抛出异常。

任何在委托作用域之外创建并包含在匿名方法声明中的局部变量被称为匿名方法的外部变量。例如,在下面的代码段中,n是一个外部变量:

int n = 0;
Del d = delegate() { System.Console.WriteLine("Copy #:{0}", ++n); };

匿名方法不允许在 is 操作符的左侧。在匿名方法中不能访问或使用不安全代码,包括外部作用域的inrefout参数。

委托中的方差

C#支持具有匹配方法签名的委托类型中的协变。这个特性是在.NET Framework 3.5 中引入的。这意味着委托现在可以分配具有匹配签名的委托,同时方法也可以返回派生类型。

如果一个方法的返回类型是从在委托中定义的类型派生出来的,那么它在委托中定义为协变。同样,如果一个方法具有比在委托中定义的派生参数类型更少的类型,那么它定义为逆变。

让我们通过一个例子来了解协变。为了这个例子,我们将创建几个类。

在这里,我们将创建ParentReturnClassChild1ReturnClassChild2Return类。这些类中的每一个都有一个字符串类型属性。这两个子类都是从ParentReturnClass继承的:

internal class ParentReturnClass
{
  public string Message { get; set; }
}

internal class Child1ReturnClass : ParentReturnClass
{
  public string ChildMessage1 { get; set; }
}
internal class Child2ReturnClass : ParentReturnClass
{
  public string ChildMessage2 { get; set; }
}

现在,让我们向之前定义的辅助类添加两个新方法,每个方法返回我们之前定义的相应子类:

public Child1ReturnClass ChildMehod1() 
{ 
    return new Child1ReturnClass 
    { 
        ChildMessage1 = "ChildMessage1" 
    }; 
}
public Child2ReturnClass ChildMehod2() 
{ 
    return new Child2ReturnClass 
    { 
        ChildMessage2 = "ChildMessage2" 
    }; 
}

现在,我们将定义一个返回ParentReturnClass的委托。我们还将定义一个新的方法,将为每个子方法初始化这个委托。在下面的代码中,一个重要的观察点是,我们使用了显式类型转换将ParentReturnClass转换为ChildReturnClass1ChildReturnClass2

delegate ParentReturnClass covrianceDelegate();
public void CoVarianceSample()
{
  covrianceDelegate cdel;
  cdel = new HelperClass().ChildMehod1;
  Child1ReturnClass CR1 = (Child1ReturnClass)cdel();
  Console.WriteLine(CR1.ChildMessage1);
  cdel = new HelperClass().ChildMehod2;
 Child2ReturnClass CR2 = (Child2ReturnClass)cdel();
Console.WriteLine(CR2.ChildMessage2);
}

//Output:
ChildMessage1
ChildMessage2

在前面的例子中,委托返回ParentReturnClass。然而,ChildMethod1ChildMethod2都返回从ParentReturnClass继承的子类。这意味着允许返回比在委托中定义的类型更派生的类型的方法。这被称为协变。

现在,让我们再看另一个例子来了解逆变。通过添加一个接受ParentReturnClass作为参数并返回 void 的新方法来扩展之前创建的辅助类:

public void Method1(ParentReturnClass parentVariable1) 
{ 
    Console.WriteLine(((Child1ReturnClass)parentVariable1).ChildMessage1); 
}

定义一个接受Child1ReturnClass作为参数的委托:

delegate void contravrianceDelegate(Child1ReturnClass variable1);

现在,创建一个初始化委托的方法:

public void ContraVarianceSample()
{
  Child1ReturnClass CR1 = new Child1ReturnClass() { ChildMessage1 = "ChildMessage1" };
  contravrianceDelegate cdel = new HelperClass().Method1;
  cdel(CR1);

}

//Output:
ChildMessage1

因为第一个方法与父类一起工作,所以它肯定可以与从父类继承的类一起工作。C#允许的派生类型参数比在委托中定义的少。

内置委托

到目前为止,我们已经看到了如何创建自定义委托并在我们的程序中使用它们。C#自带一些内置委托,开发者可以使用它们而不是必须创建自定义委托。它们如下所示:

  • Func

  • Action

Func接受零个或多个参数,并以一个out参数返回一个值,而Action接受零个或多个参数但不返回任何内容。

在使用 FuncAction 时,不需要显式声明委托:

public delegate TResult Func<out TResult>();

Action 可以定义为以下内容:

public delegate void Action();

正如我们之前提到的,它们都接受零个或多个参数。C# 支持 16 种不同的委托形式,所有这些都可以在我们的程序中使用。

Func 具有两个或更多参数的一般形式如下。它接受逗号分隔的输入和输出参数,其中最后一个参数始终是一个名为 TResult 的输出参数:

public delegate TResult Func<in T1,in T2,in T3,in T4,out TResult>(T1 arg1, T2 arg2, T3 arg3, T4 arg4);

Func 类似,Action 具有两个或更多参数的一般形式如下:

public delegate void Action<in T1,in T2,in T3,in T4>(T1 arg1, T2 arg2, T3 arg3, T4 arg4);

多播委托

通过委托调用多个方法称为多播。您可以使用 +-+=-+ 来向调用方法列表中添加或删除方法。这个列表称为调用列表。它在事件处理中使用。

以下示例展示了我们如何通过调用委托来调用多个方法。我们有两个方法,它们都接受一个字符串参数并在屏幕上显示它。在多播委托方法中,我们将两个方法与 stringdelegate 关联:

delegate void StringDelegate( string strVariable);
public void MulticastDelegate()
{
  StringDelegate StringDel = HelperClass.StringMethod;
  StringDel += HelperClass.StringMethod2;
  StringDel("Chapter 5 - Multicast delegate Method1");
}

//Helper Class Methods
public static void StringMethod(string s)
{
  Console.WriteLine(s);
}

public static void StringMethod2(string s)
{
  Console.WriteLine("Method2 :" + s);
}

/Output:
Chapter 5 - Multicast delegate Method1
Method2 :Chapter 5 - Multicast delegate Method1

处理和引发事件

正如我们在本章引言中提到的,事件是由用户执行的动作,例如按键、鼠标移动或 I/O 操作。有时,事件可以通过系统生成的操作引发,例如在表中创建/更新记录。

.NET 框架事件基于委托模型,该模型遵循观察者模式。观察者模式允许订阅者注册通知,并允许发布者注册推送通知。这就像延迟绑定,是对象广播发生某些事情的一种方式。

允许您订阅/取消订阅来自发布者的事件流的模式称为观察者模式。

例如,在上一章中,我们处理了一个代码片段,该程序用于查找用户输入的字符是否为元音。在这里,用户按下键盘上的键是发布者,它通知程序有关按下的键。现在,我们的程序,作为提供者的订阅者,通过检查输入的字符是否为元音并显示在屏幕上对此做出响应。

对象发送的消息以通知它已发生某些操作称为事件。引发此事件的对象称为事件发送者或发布者。接收并响应事件的对象称为订阅者。

发布者事件可以有多个订阅者,而订阅者可以处理发布事件。请记住,我们之前章节中讨论的多播委托在事件(发布-订阅模式)中得到了广泛的应用。

默认情况下,如果发布者有多个订阅者,它们都会同步调用。C# 支持异步调用这些事件方法。我们将在接下来的章节中详细了解这一点。

在我们深入示例之前,让我们尝试理解我们将要使用的一些术语:

event 这是一个关键字,用于在 C#中的publisher类中定义事件。
EventHandler 此方法用于处理事件。这可能包含或不包含事件数据。
EventArgs 它代表包含事件数据的类的基类。

事件处理器支持两种变体:一种没有事件数据,另一种有事件数据。以下代码表示一个处理没有事件数据的事件的函数:

public delegate void EventHandler(object sender, EventArgs e);

以下代码表示一个处理带有事件数据的事件的函数:

public delegate void EventHandler<TEventArgs>(object sender, TEventArgs e);

让我们来看一个示例,并尝试理解我们如何引发事件并处理它们。

在这个场景中,我们将有一个银行应用程序,客户可以进行创建新账户、查看他们的信用和借记金额以及请求他们的总余额等交易。每当进行此类交易时,我们将引发事件并通知客户。

我们将从Account类(publisher类)开始,以及所有支持的方法,如credit()debit()showbalance()initialdeposit()。这些都是客户可以操作其账户的交易类型。因为客户需要在每次此类交易发生时得到通知,我们将定义一个事件和一个带有事件数据的事件处理器来处理该事件:

public delegate void BankTransHandler(object sender, 
BankTransEventArgs e); // Delegate Definition 
    class Account
    {
        // Event Definition
        public event BankTransHandler ProcessTransaction; 
        public int BALAmount;
        public void SetInitialDeposit(int amount)
        {
            this.BALAmount = amount;
            BankTransEventArgs e = new BankTransEventArgs(amount, 
                                   "InitialBalance");
            // InitialBalance transaction made
            OnProcessTransaction(e);
        }
        public void Debit(int debitAmount)
        {
            if (debitAmount < BALAmount)
            {
                BALAmount = BALAmount - debitAmount;
                BankTransEventArgs e = new BankTransEventArgs(
                                           debitAmount, "Debited");
                OnProcessTransaction(e); // Debit transaction made 
            }
        }
        public void Credit(int creditAmount)
        {
            BALAmount = BALAmount + creditAmount;
            BankTransEventArgs e = new BankTransEventArgs(
                                       creditAmount, "Credited");
            OnProcessTransaction(e); // Credit transaction made
        }
        public void ShowBalance()
        {
            BankTransEventArgs e = new BankTransEventArgs(
                                       BALAmount, "Total Balance");
            OnProcessTransaction(e); // Credit transaction made
        }
        protected virtual void OnProcessTransaction(
                                       BankTransEventArgs e)
        {
            ProcessTransaction?.Invoke(this, e);
        }
    }

你可能已经注意到了我们在上一个示例中使用的新类,即TrasactionEventArgs。这个类携带事件数据。我们现在将定义这个类,它继承自EventArgs基类。我们将定义两个变量,amttype,以携带变量到事件处理器:

public class BankTransEventArgs : EventArgs
    {
        private int _transactionAmount;
        private string _transactionType;
        public BankTransEventArgs(int amt, string type)
        {
            this._transactionAmount = amt;
            this._transactionType = type;
        }
        public int TransactionAmount
        {
            get
            {
                return _transactionAmount;
            }
        }
        public string TranactionType
        {
            get
            {
                return _transactionType;
            }
        }
    }

现在,让我们定义一个订阅者类来测试我们的事件和事件处理器是如何工作的。在这里,我们将定义一个AlertCustomer方法,其签名与在publisher类中声明的代理相匹配。将此方法的引用传递给代理,以便它对事件做出反应:

public class EventSamples
{
 private void AlertCustomer(object sender, BankTransEventArgs e)
 {
  Console.WriteLine("Your Account is {0} for Rs.{1} ", 
                     e.TranactionType, e.TransactionAmount);
 }
 public void Run()
 {
  Account bankAccount = new Account();
  bankAccount.ProcessTransaction += new 
      BankTransHandler(AlertCustomer);
  bankAccount.SetInitialDeposit(5000);
  bankAccount.ShowBalance();
  bankAccount.Credit(500);
  bankAccount.ShowBalance();
  bankAccount.Debit(500);
  bankAccount.ShowBalance();
 }
}

当你执行前面的程序时,对于每次进行的交易,都会引发一个交易处理器事件,该事件调用通知客户方法并在屏幕上显示发生了什么类型的交易,如下所示:

//Output:
Your Account is InitialBalance for Rs.5000
Your Account is Total Balance for Rs.5000
Your Account is Credited for Rs.500
Your Account is Total Balance for Rs.5500
Your Account is Debited for Rs.500
Your Account is Total Balance for Rs.5000

摘要

在本章中,我们学习了代理以及我们如何在程序中定义、启动和使用它们。我们了解了代理的变体、内置代理和多播代理。最后,我们在理解事件、事件处理器和EventArgs之前,研究了代理如何成为事件的基础。

现在,我们可以说事件封装了代理,而代理封装了方法。

在下一章中,我们将学习 C#中的多线程和异步处理。我们将理解并使用程序中的线程,了解任务、并行类、async、await 以及更多内容。

问题

  1. 代理非常适合 ___,因为它们能够将方法作为参数传递。

    1. 多播代理

    2. 内置委托

    3. 回调

    4. 事件

  2. 有哪些不同的方式来初始化委托?选择所有适用的。

    1. 匿名方法

    2. Lambda 表达式

    3. 命名方法

    4. 所有上述选项

  3. 哪个方法可以有比在委托中定义的返回类型更派生的类型?

    1. 匿名方法

    2. 协变

    3. 匿名函数

    4. Lambda 表达式

  4. 哪个内置委托接受零个或多个参数并返回 void?

    1. Action

    2. Func

    3. event

    4. delegate

  5. 在 C# 事件声明中,以下哪个被使用?

    1. event

    2. delegate

    3. EventHandler

    4. class

  6. 订阅者可以通知发布者关于对象发生的更改。

    1. True

    2. False

答案

  1. 回调

  2. 所有上述选项

  3. 协变

  4. Action

  5. event

  6. False

进一步阅读

为了更好地理解观察者模式,请查看docs.microsoft.com/en-us/dotnet/standard/events/observer-design-pattern

以下是一篇关于声明、初始化和使用委托的好文章。那里也可以找到示例:docs.microsoft.com/en-us/dotnet/csharp/programming-guide/delegates/how-to-declare-instantiate-and-use-a-delegate

第六章:管理和实现多线程

当一个长时间运行程序在客户端计算机上开始执行时会发生什么?操作系统如何处理这样的长时间运行进程?操作系统会通知用户其进度吗?操作系统如何让用户知道这些进程已经完成?线程是操作系统处理程序响应性的方式,同时管理其他系统资源。这是通过使用多个执行线程实现的,这是保持应用程序响应性并使用处理器处理其他事件的最强大方式之一。

操作系统将每个运行中的应用程序组织为一个进程。每个进程可能包含一个或多个线程。线程允许操作系统根据需要分配处理器时间。每个线程都有调度优先级和一组系统用于暂停或执行线程的结构。这被称为线程上下文。换句话说,线程上下文包含系统无缝恢复执行所需的所有信息。正如我们之前提到的,一个进程可以包含多个线程,所有这些线程都共享进程的同一虚拟地址空间。

在本章中,我们将专注于创建和管理线程,同步线程间的数据,以及多线程。我们还将探讨操作系统如何使用这一概念来保持应用程序的响应性。

在本章中,我们将涵盖以下主题:

  • 理解线程和线程过程

  • 多线程中的数据同步

  • 多线程

技术要求

本章的练习可以使用 Visual Studio 2012 或更高版本以及.NET Framework 2.0 或更高版本进行练习。然而,任何从 C# 7.0 及更高版本的新 C#功能都需要您安装 Visual Studio 2017。

如果您没有上述任何产品的许可证,您可以从visualstudio.microsoft.com/downloads/下载 Visual Studio 2017 的社区版。

本章的示例代码可以在 GitHub 上找到,地址为github.com/PacktPublishing/Programming-in-C-sharp-Exam-70-483-MCSD-Guide/tree/master/Chapter06

理解线程和线程过程

每当启动.NET 程序时,都会启动一个主线程。这个主线程会创建额外的线程来并发或并行地执行应用程序登录。这些线程被称为工作线程。这些线程可以执行程序代码的任何部分,这可能包括由其他线程执行的部分。由于这些线程可以自由跨越应用程序边界,.NET Framework 提供了一种方法,通过应用程序域(在.NET Core 中不可用)在进程内隔离这些线程。

如果我们的程序可以并行执行多个操作,这将极大地减少总执行时间。这可以通过利用多处理器或多核环境中的多个线程来实现。当与.NET Framework 一起使用时,Windows 操作系统确保这些线程完成它们各自的任务。然而,管理这些任务确实有开销。操作系统为每个线程分配一定量的 CPU 时间,以便它们可以执行。在这段时间之后,发生线程切换,这被称为上下文切换。这个上下文在每次切换时都会被保存和恢复。为了做到这一点,Windows 使用 CPU 寄存器和状态数据。

在有多个处理器和多核系统可用的环境中,我们可以利用这些资源来提高应用程序的吞吐量。考虑一个 Windows 应用程序,其中一个线程(主线程)通过响应用户操作来处理用户界面,而其他线程(工作线程)执行需要更多时间和处理的操作。如果主线程完成了所有这些操作,用户界面将不会响应用户操作。

由于这种开销,我们需要仔细确定何时使用多线程。

在接下来的章节中,我们将关注如何创建和管理线程,了解不同的线程属性,如何创建和传递参数给线程,前台线程和后台线程之间的区别,如何销毁线程,以及更多内容。

线程管理

可以通过创建System.Threading线程类的新实例,并将你希望在新的线程上执行的方法名称传递给构造函数来创建线程。使用这个类给我们提供了更多的程序控制和配置;例如,你可以设置线程的优先级,以及它是否是一个长时间运行的线程,终止它,让它休眠,并实现高级配置选项。Thread.Start方法用于创建线程调用,而Thread.Abort方法用于终止线程的执行。当调用中止方法时,会引发ThreadAbortExceptionThread.Sleep可以用来暂停线程的执行一段时间。最后,Thread.Interrupt方法用于中断一个阻塞的线程。

让我们通过几个示例来理解这些概念。

在下面的代码中,ThreadSample是主线程,它启动了工作线程。工作线程循环 10 次并向控制台写入,让进程知道它已经完成。在启动工作线程后,主线程循环 4 次。请注意,输出取决于你运行此程序的环境。尝试更改thread.sleep语句中的秒数并观察输出:

internal class ThreadingSamples
    {
        public static void ThreadSample()
        {
            Console.WriteLine("Primary thread: Starting a new worker thread.");
            Thread t = new Thread(new ThreadStart(ThreadOne));
            t.Start();
            //Thread.Sleep(1);
            for (int i = 0; i < 4; i++)
            {
                Console.WriteLine("Primary thread: Do something().");
                Thread.Sleep(1);

            }
            Console.WriteLine("Primary thread: Call Join(), to wait until ThreadOne ends.");
            t.Join();
            Console.WriteLine("Primary thread: ThreadOne.Join has returned.");
        }

        public static void ThreadOne()
        {
            for (int i = 0; i < 10; i++)
            {
                Console.WriteLine("ThreadOne running: {0}", i);
                Thread.Sleep(0);
            }
        }
    }

让我们检查我们程序的输出。ThreadOne 首先开始执行并启动 10 个不同的工作线程,然后执行主线程。如果你通过使用 sleep 延迟 ThreadOne 的执行,你会看到主线程会等待直到 ThreadOne 返回:

当程序执行时,会自动创建一个前台线程来执行代码。然后,这个主线程根据需要创建工作线程来执行来自同一进程的代码部分。正如你所看到的,线程在其构造函数中接受一个委托。

在前面的程序中,我们使用了 thread.join,这允许主线程等待直到所有工作线程完成它们的执行。此外,Thread.Sleep(0) 告诉 Windows 当前线程已经完成了它的执行,以便发生上下文切换,而不是 Windows 必须等待分配的时间。

线程属性

每个线程携带某些属性。以下表格详细说明了每一个:

IsAlive 如果线程处于已启动状态,则返回 true
IsBackground 获取或设置此属性以让系统知道如何执行线程。
Name 线程的名称。
Priority 获取或设置线程优先级。默认为 Normal
ThreadState 获取线程的当前状态。

在以下代码示例中,我们将调用一个方法来显示有关某些线程属性的信息。我们还将了解如何暂停线程并终止它:

public static void ThreadProperties()
{
     var th = new Thread(ThreadTwo);
     th.Start();
     Thread.Sleep(1000);
     Console.WriteLine("Primary thread ({0}) exiting...",Thread.CurrentThread.ManagedThreadId);
}

private static void ThreadTwo()
{
    var sw = Stopwatch.StartNew();
    Console.WriteLine("ThreadTwo Id: {0} Threadtwo state: {1}, Threadtwo Priority: {2}",
                              Thread.CurrentThread.ManagedThreadId,
                              Thread.CurrentThread.ThreadState,
                              Thread.CurrentThread.Priority);
    do
    {
        Console.WriteLine("Threadtwo Id: {0}, Threadtwo elapsed time {1:N2} seconds",
                                  Thread.CurrentThread.ManagedThreadId,
                                  sw.ElapsedMilliseconds / 1000.0);
        Thread.Sleep(500);
    } while (sw.ElapsedMilliseconds <= 3000);
        sw.Stop();
}

当你执行程序时,你会看到每个线程的属性。你也会观察到尽管主线程已经完成,但工作线程仍在执行:

你可能已经注意到,一次只有一个线程写入控制台。这被称为 同步。在这种情况下,它由控制台类为我们处理。同步允许没有两个线程同时执行相同的代码块。

参数化线程

在这里,我们将探讨如何向 ThreadStart 方法传递参数。为了实现这一点,我们将在构造函数中使用 ParameterizedThreadStart 委托。此委托的签名如下:

public delegate void ParameterizedThreadStart(object obj)

当你将参数作为对象传递给 ThreadStart 方法时,它将参数转换为适当的数据类型。以下示例程序使用了我们之前使用的相同逻辑,除了我们通过 ThreadStart 方法传递间隔作为参数:

 public static void ParameterizedThread()
 {
     var th = new Thread(ThreadThree);
     th.Start(3000);
     Thread.Sleep(1000);
     Console.WriteLine("Primary thread ({0}) exiting...", Thread.CurrentThread.ManagedThreadId);
}

private static void ThreadThree(object obj)
{
    int interval = Convert.ToInt32(obj);
    var sw = Stopwatch.StartNew();
    Console.WriteLine("ThreadTwo Id: {0} ThreadThree state: {1}, ThreadThree Priority: {2}",
            Thread.CurrentThread.ManagedThreadId,
            Thread.CurrentThread.ThreadState,
            Thread.CurrentThread.Priority);
    do
    {
        Console.WriteLine("ThreadThree Id: {0}, ThreadThree elapsed time {1:N2} seconds",
            Thread.CurrentThread.ManagedThreadId,
            sw.ElapsedMilliseconds / 1000.0);
        Thread.Sleep(500);
    } while (sw.ElapsedMilliseconds <= interval);
    sw.Stop();
}

以下截图显示了前面代码的输出:

现在,让我们看看前台和后台线程。

前台和后台线程

默认情况下,当创建一个线程时,它会被创建为一个前台线程。你可以使用IsBackground属性将一个线程设置为后台线程。前台线程和后台线程的主要区别在于,如果所有前台线程都终止了,后台线程将不会运行。当前台线程停止时,运行时会终止所有后台线程。如果使用线程池创建线程,则这些线程将以后台线程的方式执行。请注意,当非托管线程进入托管执行环境时,它将以后台线程的方式执行。

让我们通过一个例子来了解前台线程和后台线程之间的区别:

public static void BackgroundThread()
{
    Console.WriteLine("Thread Id: {0}" + Environment.NewLine + "Thread State: {1}" + Environment.NewLine + "Priority {2}" + Environment.NewLine + "IsBackground: {3}",
                              Thread.CurrentThread.ManagedThreadId,
                              Thread.CurrentThread.ThreadState,
                              Thread.CurrentThread.Priority,
                              Thread.CurrentThread.IsBackground);
    var th = new Thread(ExecuteBackgroundThread);
    th.IsBackground = true;
    th.Start();
    Thread.Sleep(500);
    Console.WriteLine("Main thread ({0}) exiting...",Thread.CurrentThread.ManagedThreadId);
}
private static void ExecuteBackgroundThread()
{
    var sw = Stopwatch.StartNew();
    Console.WriteLine("Thread Id: {0}" + Environment.NewLine + "Thread State: {1}" +         Environment.NewLine + "Priority {2}" + Environment.NewLine + "IsBackground {3}",
                              Thread.CurrentThread.ManagedThreadId,
                              Thread.CurrentThread.ThreadState,
                              Thread.CurrentThread.Priority,
                              Thread.CurrentThread.IsBackground);
    do
    {
        Console.WriteLine("Thread {0}: Elapsed {1:N2} seconds",
                                  Thread.CurrentThread.ManagedThreadId,
                                  sw.ElapsedMilliseconds / 1000.0);
        Thread.Sleep(2000);
    } while (sw.ElapsedMilliseconds <= 5000);
    sw.Stop();
}

以下截图显示了前面代码的输出:

图片

如你所见,主线程被创建为一个前台线程,而工作线程被创建为一个后台线程。当我们停止主线程时,它也停止了后台线程。这就是为什么在运行 5 秒(while(sw.ElapsedMilliseconds <=5000))的循环中,没有显示经过的时间语句。

线程状态

当创建一个线程时,它将处于未开始状态,直到调用Start方法。线程始终处于至少一个状态,有时它可能同时处于多个状态。在以下图中,每个椭圆形代表一个状态。每行上的文本代表执行的动作:

图片

线程可以同时处于两种不同的状态。例如,如果一个线程处于等待状态,而另一个线程被终止,它可以同时处于等待/加入睡眠终止请求状态。当线程返回到等待调用时,它将接收到一个ThreadAbortException

销毁线程

Thread.Abort方法用于停止一个线程。一旦终止,它就不能重新启动。然而,当你调用Thread.Abort时,它不会立即终止线程,因为Thread.Abort语句抛出一个ThreadAbortException,这需要被捕获。然后,应该执行清理代码。如果你调用Thread.Join方法,这将确保线程等待直到其他线程的执行完成。join方法依赖于超时间隔,所以如果没有指定,等待是不确定的。

当你自己的代码终止一个线程,并且你不想重新抛出它时,请使用ResetAbort方法。你将在第七章,实现异常处理中了解更多关于如何重新抛出异常的信息。

线程池

线程池提供了一组线程,这些线程可以用作工作线程并由系统管理。这使我们能够专注于应用程序逻辑而不是管理线程。这是我们使用多线程的简单方法。从 .NET 框架 4 开始,使用线程池变得容易,因为它们允许我们创建任务并执行异步任务。任务并行库TPL)和异步方法调用主要依赖于线程池。

从线程池创建的线程是后台线程。每个线程使用默认属性。当线程完成任务时,它将返回到等待线程的队列中,以便可以重用。反过来,这减少了为每个任务创建新线程的成本。每个进程可以有一个线程池。

.NET 框架允许我们为线程池设置和获取 MaxThread,尽管可以排队的线程数量受可用内存限制。一旦线程池中的线程忙碌,其他任务将排队,直到线程可用。

重要的是要理解,线程池中任何未处理的异常都将终止此进程。有关线程池的更多信息,请参阅docs.microsoft.com/en-us/dotnet/standard/threading/the-managed-thread-pool

以下示例展示了我们如何使用线程池创建多个线程:

 public static void PoolOfThreads()
 {
     Console.WriteLine("Primary Thread Id: {0}" + Environment.NewLine + "Thread State: {1}" + Environment.NewLine + "Priority {2}" ,
                              Thread.CurrentThread.ManagedThreadId,
                              Thread.CurrentThread.ThreadState,
                              Thread.CurrentThread.Priority);
    PoolProcessmethod();
    //Thread.CurrentThread.Join();
 }
private static void PoolProcessmethod()
{
    for (int i = 0; i < 5; i++)
    {
        ThreadPool.QueueUserWorkItem(new WaitCallback(PoolMethod)); 
    }
}
private static void PoolMethod(object callback)
{
    Thread.Sleep(1000);
    Console.WriteLine("ThreadPool Thread Id: {0}" + Environment.NewLine + "Thread State: {1}" + Environment.NewLine + "Priority {2}" + Environment.NewLine + "IsBackground: {3}" +Environment.NewLine + "IsThreadPoolThread: {4}",
                              Thread.CurrentThread.ManagedThreadId,
                              Thread.CurrentThread.ThreadState,
                              Thread.CurrentThread.Priority,
                              Thread.CurrentThread.IsBackground,
                              Thread.CurrentThread.IsThreadPoolThread);

}

以下截图显示了运行前面代码的输出:

图片

在这里,我们使用线程池创建了五个工作线程。如果你在前面代码中取消注释 Thread.CurrentThread.Join,主线程将不会退出,直到所有线程都已被处理。

线程存储

线程相关的静态字段和数据槽是我们存储线程和应用域唯一数据的两种方式。线程相关的静态字段在编译时定义,并提供最佳性能。另一个好处是它们在编译时进行类型检查。当事先明确需要存储哪种类型的数据时,使用这些字段。

可以使用 ThreadStaticAttribute 创建线程相关的静态字段。

在某些场景中,这些存储需求可能在运行时出现。在这种情况下,我们可以选择数据槽。这些比静态字段慢一些。由于它们是在运行时创建的,因此它们以对象类型存储信息。在使用它们之前,将对象转换为它们相应的类型对我们来说很重要。

.NET 框架允许我们创建两种类型的数据槽:命名数据槽和未命名数据槽。命名数据槽使用 GetNamedDataSlot 方法,这样我们就可以在需要时检索它。然而,NamedDataslot 的一个缺点是,当来自同一应用程序域的两个线程在两个不同的代码组件中使用相同的数据槽并在同一时间执行时,它们可能会互相破坏数据。

ThreadLocal<T> 可以用来创建局部数据存储。

这两种存储数据的方式可以被称为 线程局部存储TLS)。管理 TLS 的几个好处如下:

  • 在一个应用程序域内,一个线程不能修改另一个线程的数据,即使两个线程使用相同的字段或槽位。

  • 当一个线程从多个应用程序域访问相同的字段或槽位时,每个应用程序域都维护一个单独的值。

现在,我们将进入一个示例,看看如何使用 ThreadStatic 属性。在下面的示例中,定义了一个静态变量,并用 ThreadStatic 属性进行了装饰。这确保了每个线程都有自己的变量副本。当你执行以下程序时,你会观察到 _intvariable 对每个线程都增加到 6:

[ThreadStatic]
public static int _intvariable;
public static void ThreadStaticSample()
{
    //Start three threads
    new Thread(() =>
    {
        for (int i = 0; i <= 5; i++)
        {
            _intvariable++;
            Console.WriteLine($"Thread Id:{Thread.CurrentThread.ManagedThreadId}, Int field Value:{_intvariable}");
        }
    }).Start();

    new Thread(() =>
    {
        for (int i = 0; i <= 5; i++)
        {
            _intvariable++;
            Console.WriteLine($"Thread Id:{Thread.CurrentThread.ManagedThreadId}, Int field Value:{_intvariable}");
        }
    }).Start();

    new Thread(() =>
    {
        for (int i = 0; i <= 5; i++)
        {
            _intvariable++;
            Console.WriteLine($"Thread Id:{Thread.CurrentThread.ManagedThreadId}, Int field Value:{_intvariable}");
        }
    }).Start();

}

以下截图显示了运行前面程序的结果。注释掉 ThreadStatic 属性并再次运行程序——你会发现 _intvariable 的值增加到 18,因为每个线程都会更新其值:

图片

让我们看看如何使用 ThreadLocal<T> 来创建局部存储:

 public static ThreadLocal<string> _threadstring = new ThreadLocal<string>(() => {
    return "Thread " + Thread.CurrentThread.ManagedThreadId; });
public static void ThreadLocalSample()
{

    //Start three threads
    new Thread(() =>
    {
        for (int i = 0; i <= 5; i++)
        {
            Console.WriteLine($"First Thread string :{_threadstring}");
        }
    }).Start();

    new Thread(() =>
    {
        for (int i = 0; i <= 5; i++)
        {
            Console.WriteLine($"Second Thread string :{_threadstring}");
        }
    }).Start();

    new Thread(() =>
    {
        for (int i = 0; i <= 5; i++)
        {
            Console.WriteLine($"Third Thread string :{_threadstring}");
        }
    }).Start();

}

上述代码的输出如下:

图片

现在我们已经了解了如何管理线程,让我们看看如何在多线程中同步数据。

多线程中的数据同步

多个线程可以调用一个对象的方法或属性,这可能会使对象的状态无效。对同一对象进行两个或更多线程的冲突更改是可能的。这使得同步这些调用变得很重要,这将使我们能够避免此类问题。当一个类的成员受到冲突更改的保护时,它们被认为是 线程安全的

CLR 提供了多种方式,我们可以通过这些方式同步对对象实例和静态成员的访问:

  • 同步代码区域

  • 手动同步

  • 同步上下文

  • 线程安全集合

默认情况下,对象没有同步,这意味着任何线程都可以在任何时候访问方法和属性。

同步代码区域允许我们同步代码块、方法和静态方法。然而,不支持同步静态字段。如果我们使用 Monitor 类或关键字,则可以进行同步。C# 支持使用 lock 关键字来标记需要同步的代码块。

当应用时,线程在执行代码时会尝试获取锁。如果另一个线程已经获取了此代码块的锁,则该线程会阻塞,直到锁可用。当线程执行代码块或以其他方式退出时,锁会被释放。

MethodImplAttributeMethodImplOptions.Synchronized 与使用 Monitor 或关键字锁定代码块得到相同的结果。

让我们通过一个示例来了解使用任务进行锁定语句。在接下来的章节中,我们将了解更多关于任务的内容。

为了本例的目的,我们创建了一个 Account 类,通过将其锁定到实例来同步其私有字段余额。这确保了没有两个线程同时更新此字段:

 internal class BankAcc
    {
        private readonly object AcountBalLock = new object();
        private decimal balanceamount;
        public BankAcc(decimal iBal)
        {
            balanceamount = iBal;
        }
        public decimal Debit(decimal amt)
        {
            lock (AcountBalLock)
            {
                if (balanceamount >= amt)
                {
                    Console.WriteLine($"Balance before debit :{balanceamount,5}");
                    Console.WriteLine($"Amount to debit     :{amt,5}");
                    balanceamount = balanceamount - amt;
                    Console.WriteLine($"Balance after debit  :{balanceamount,5}");
                    return amt;
                }
                else
                {
                    return 0;
                }
            }
        }
        public void Credit(decimal amt)
        {
            lock (AcountBalLock)
            {
                Console.WriteLine($"Balance before credit:{balanceamount,5}");
                Console.WriteLine($"Amount to credit        :{amt,5}");
                balanceamount = balanceamount + amt;
                Console.WriteLine($"Balance after credit :{balanceamount,5}");
            }
        }
    }

TestLockStatements( ) 方法如下所示:

//Create methods to test this Account class
public static void TestLockStatements()
{
    var account = new BankAcc(1000);
    var tasks = new Task[2];
    for (int i = 0; i < tasks.Length; i++)
    {
        tasks[i] = Task.Run(() => UpdateAccount(account));
    }
    Task.WaitAll(tasks);
}
private static void UpdateAccount(BankAcc account)
{
    var rnd = new Random();
    for (int i = 0; i < 10; i++)
    {
        var amount = rnd.Next(1, 1000);
        bool doCredit = rnd.NextDouble() < 0.5;
        if (doCredit)
        {
            account.Credit(amount);
        }
        else
        {
            account.Debit(amount);
        }
    }
}

我们创建了两个任务,每个任务都调用 UpdateMethod。此方法循环 10 次,并使用信用或借记方法更新账户余额。因为我们使用的是实例级别的 lock(obj) 字段,所以余额字段不会同时更新。

以下代码显示了所需的输出:

Balance before debit : 1000
Amount to debit : 972
Balance after debit : 28
Balance before credit: 28
Amount to credit : 922
Balance after credit : 950
Balance before credit: 950
Amount to credit : 99
Balance after credit : 1049
Balance before debit : 1049
Amount to debit : 719
Balance after debit : 330
Balance before credit: 330
Amount to credit : 865
Balance after credit : 1195
Balance before debit : 1195
Amount to debit : 962
Balance after debit : 233
Balance before credit: 233
Amount to credit : 882
Balance after credit : 1115
Balance before credit: 1115
Amount to credit : 649
Balance after credit : 1764
Balance before credit: 1764
Amount to credit : 594
Balance after credit : 2358
Balance before debit : 2358
Amount to debit : 696
Balance after debit : 1662
Balance before credit: 1662
Amount to credit : 922
Balance after credit : 2584
Balance before credit: 2584
Amount to credit : 99
Balance after credit : 2683
Balance before debit : 2683
Amount to debit : 719
Balance after debit : 1964
Balance before credit: 1964
Amount to credit : 865
Balance after credit : 2829
Balance before debit : 2829
Amount to debit : 962
Balance after debit : 1867
Balance before credit: 1867
Amount to credit : 882
Balance after credit : 2749
Balance before credit: 2749
Amount to credit : 649
Balance after credit : 3398
Balance before credit: 3398
Amount to credit : 594
Balance after credit : 3992
Balance before debit : 3992
Amount to debit : 696
Balance after debit : 3296
Press any key to exit.

在多个线程之间访问共享变量可能会导致数据完整性问题。这些问题可以通过使用同步原语来解决。这些原语由 System.Threading.WaitHandle 类派生。在执行手动同步时,原语可以保护对共享资源的访问。不同的同步原语实例用于保护对资源或某些代码访问部分的访问,这允许多个线程并发访问资源。

你可以在 docs.microsoft.com/en-us/dotnet/standard/threading/overview-of-synchronization-primitives 上了解更多关于同步原语的信息。

.NET Framework 引入了 System.Collections.Concurrent 命名空间,可以在用户代码中无需额外同步的情况下使用。此命名空间包括几个线程安全和可扩展的集合类。这允许多个线程向这些集合添加或从中删除项目。

更多关于这些线程安全集合的信息可以在 docs.microsoft.com/en-us/dotnet/standard/collections/thread-safe/index 上找到。

多线程

开发者可以在进程内创建多个线程,并在整个程序执行过程中管理它们。这使我们能够专注于应用程序逻辑,而不是管理线程。然而,从 .NET Framework 4 开始,我们可以使用以下方法创建多线程程序:

  • TPL

  • 并行语言集成查询PLINQ

为了理解这两个功能,我们需要讨论并行编程。

并行编程

并行编程帮助开发者利用工作站上的硬件,这些工作站拥有多个 CPU 核心。它们允许多个线程并行执行。

在之前的版本中,并行化需要低级线程和锁的操作。从.NET Framework 4 开始,提供了对并行编程的增强支持,形式为运行时、类库类型和诊断工具。

下面的图示显示了并行编程的高级架构:

图片

在接下来的章节中,我们将讨论前面架构图中列出的一些组件。

TPL

TPL 通过创建并行和并发应用程序,使开发者更加高效。这些类型作为System.ThreadingSystem.Threading.Tasks命名空间中的公共类型提供。TPL 允许我们在关注程序工作的同时,最大化代码性能。TPL 基于任务,代表一个线程或线程池。当一个或多个任务并发运行时,这被称为任务并行。任务有几个好处:可扩展性和效率,以及比线程更多的程序控制能力。

因为 TPL 处理工作的分割、调度、取消、状态和其他底层细节,它可以动态地调整并发程度,并使用可用的系统资源或处理器。

了解何时应用并行编程很重要,否则并行化的开销会降低代码执行速度。对线程概念如锁和死锁的基本理解很重要,这样我们才能有效地使用 TPL。

数据并行

当操作可以在源集合元素上并发执行时,这被称为数据并行。在这个过程中,源集合被分割成多个线程并行执行。.NET Framework 通过System.Threading.Tasks.Parallel类支持数据并行。Parallel.ForParallel.ForEach等方法定义在这个类中。当你使用这些方法时,框架会为我们管理所有底层工作。

任务代表一个可能返回也可能不返回值的异步操作,并在System.Threading.Tasks类中定义。

使用任务

任务代表一个可能返回也可能不返回值的操作,并异步执行。由于它们是异步执行的,因此它们作为线程池中的工作线程而不是主线程来执行。这允许我们使用isCanceledIsCompleted属性来了解任务的状态。您还可以使任务同步运行,这将执行在主线程或主要线程上。

任务可以实现IAsyncResultIDisposable接口,如下所示:

public class Task : IAsyncResult, IDisposable

让我们通过一个示例来了解我们如何以不同的方式创建和启动任务。在这个例子中,我们将使用一个接受object类型参数的操作委托:

public static void Run()
{
    Action<object> action = (object obj) =>
    {
        Console.WriteLine("Task={0}, Milliseconds to sleep={1}, Thread={2}",Task.CurrentId, obj,
        Thread.CurrentThread.ManagedThreadId);
        int value = Convert.ToInt32(obj);
        Thread.Sleep(value);
    };

    Task t1 = new Task(action, 1000);
    Task t2 = Task.Factory.StartNew(action, 5000);
    t2.Wait();
    t1.Start();
    Console.WriteLine("t1 has been started. (Main Thread={0})",
                      Thread.CurrentThread.ManagedThreadId);
    t1.Wait();

    int taskData = 4000;
    Task t3 = Task.Run(() => {
        Console.WriteLine("Task={0}, Milliseconds to sleep={1}, Thread={2}",
                          Task.CurrentId, taskData,
                           Thread.CurrentThread.ManagedThreadId);
    });
    t3.Wait();

    Task t4 = new Task(action, 3000);
    t4.RunSynchronously();
    t4.Wait();
}

在这里,我们创建了四个不同的任务。对于第一个任务,我们使用了启动方法,而对于第二个任务,我们使用了task.factory.startnew方法。第三个任务使用run(Action)方法启动,而第四个任务使用同步运行方法在主线程上同步执行。在这里,任务 1、2 和 3 是使用线程池的工人线程,而任务 4 在主线程上执行。

以下截图显示了运行前面代码的输出:

Wait方法类似于Thread.Join,它等待任务完成。这在同步调用线程和异步任务执行时很有用,因为我们可以等待一个或多个线程完成。Wait方法还接受某些参数,允许我们有条件地等待任务完成。

以下表格显示了线程在等待时可以使用的不同选项:

Wait 等待任务执行完成。
Wait(int32) 使任务在执行前等待指定数量的毫秒。
Wait(Timespan) 在指定的时间间隔内等待任务执行完成。
Wait(CancellationToken) 等待任务执行完成。如果cancellationToken在任务执行完成前发出,则等待终止。
Wait(Int32, CancellationToken) 等待任务执行完成。等待在超时或任务完成前发出取消令牌时终止。
WaitAll 等待所有提供的任务完成其执行。类似于Wait方法,WaitAll接受多个参数并相应地执行它们。
WaitAny 等待提供的任务完成其执行。类似于Wait方法,WaitAny接受多个参数并相应地执行它们。

任务支持另外两种方法:WhenAllWhenAny。现在,WhenAll用于创建一个任务,当所有提供的任务都完成时,该任务将完成其执行。同样,WhenAny创建任务并在提供的任务完成其执行时完成。

任务也可以返回一个值。然而,读取任务的输出意味着等待其执行完成。在没有完成执行的情况下,无法使用结果对象。以下是一个示例:

public static void TaskReturnSample()
{
    Task<int> t = Task.Run(() => { return 30 + 40; });
    Console.WriteLine($"Result of 30+40: {t.Result}");
}

通过执行前面的代码,您将看到主线程会等待任务返回一个值。然后,它显示一个按任意键退出的消息:

Result of 30+40: 70
Press any key to exit.

还可以添加一个后续任务。.NET Framework 提供了一个名为ContinueWith的关键字,它允许你在前一个任务执行完毕后创建并执行一个新任务。在以下代码中,我们指示任务使用父任务的结果继续执行:

public static void TaskContinueWithSample()
{
    Task<int> t = Task.Run(() => 
        {
            return 30 + 40;
        }
    ).ContinueWith((t1) => 
    {
        return t1.Result * 10;
    });
    Console.WriteLine($"Result of two tasks: {t.Result}");
}

当任务t完成其执行时,结果被用于第二个任务t1,并显示最终结果:

Result of two tasks: 700
Press any key to exit.

ContinueWith有几个重载方法,允许我们配置后续任务何时执行,例如在任务取消或成功完成后。为了使此配置生效,我们将使用TaskContinuationOptions。更多可用的选项可以在docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskcontinuationoptions?view=netframework-4.7.2找到。

以下代码块展示了如何使用continuationOptions

Task<int> t = Task.Run(() => 
{
    return 30 + 40;
}
).ContinueWith((t1) => 
{
    return t1.Result * 10;
},TaskContinuationOptions.OnlyOnRanToCompletion);

TaskFactory支持创建和调度任务。它还允许我们执行以下操作:

  • 使用StartNew方法创建一个任务并立即启动它

  • 通过调用ContinueWhenAny方法创建一个任务,该任务将在数组中的任何一个任务完成时启动

  • 通过调用ContinueWhenAll方法创建一个任务,该任务将在数组中的所有任务完成时启动

更多关于TaskFactory的阅读资料可以在docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskfactory?view=netframework-4.7.2找到。

使用 Parallel 类

System.Threading类中还有一个名为Parallel的类。这个类为ForForEach循环提供了并行实现。它们的实现与顺序循环类似。当你使用ParallelForParallelForEach时,系统会自动将过程分割成多个任务,并在需要时获取锁。所有这些底层工作都由 TPL 处理。

顺序循环可能看起来如下:

foreach (var item in sourceCollection) 
{     
    Process(item); 
} 

同样的循环可以使用Parallel表示如下:

Parallel.ForEach(sourceCollection, item => Process(item)); 

TPL 管理数据源并创建分区,以便循环可以并行操作多个部分。每个任务将由任务调度器根据系统资源和负载进行分区。然后,如果负载变得不平衡,任务调度器将通过多个线程和进程重新分配工作。

当你有大量并行工作要做时,并行编程可以提高性能。如果不是这种情况,它可能会变得成本高昂。

在给定的场景中理解并行工作方式非常重要。在以下示例中,我们将探讨如何使用Parallel.For,并在顺序循环和并行循环之间进行时间比较。

在这里,我们定义了一个整数数组,并计算数组中每个元素的求和和乘积。在主程序中,我们使用顺序和并行循环调用此方法,并计算每个循环完成过程所需的时间:

static int[] _values = Enumerable.Range(0, 1000).ToArray();

private static void SumAndProduct(int x)
{
    int sum = 0;
    int product = 1;
    foreach (var element in _values)
    {
        sum += element;
        product *= element;
    }
}

public static void CallSumAndProduct()
{
    const int max = 10;
    const int inner = 100000;
    var s1 = Stopwatch.StartNew();
    for (int i = 0; i < max; i++)
    {
        Parallel.For(0, inner, SumAndProduct);
    }
    s1.Stop();

    Console.WriteLine("Elapsed time in seconds for ParallelLoop: " + s1.Elapsed.Seconds);

    var s2 = Stopwatch.StartNew();
    for (int i = 0; i < max; i++)
    {
        for (int z = 0; z < inner; z++)
        {
            SumAndProduct(z);
        }
    }
    s2.Stop();

    Console.WriteLine("Elapsed time in seconds for Sequential Loop: " + s2.Elapsed.Seconds );
}

在前面的代码中,我们执行了两个循环:一个使用并行循环,另一个使用顺序循环。结果显示了每个操作所花费的时间:

System.Threading.Tasks.Parallel 包含多个辅助类,例如 ParallelLoopResultParallelLoopStateParallelOptions

ParallelLoopResult 提供并行循环的完成状态,如下所示:

ParallelLoopResult result = Parallel.For(int i, ParallelLoopState loopstate) =>{});

ParallelLoopState 允许并行循环的迭代与其他迭代交互。最后,LoopState 允许您识别迭代中的任何异常,从迭代中退出,停止迭代,识别是否有任何迭代调用了退出或停止,以及退出长时间运行的迭代。

PLINQ

语言集成查询 (LINQ) 在 .NET Framework 3.5 中引入。它允许我们对内存中的集合,如 List<T> 进行查询。您将在第十五章 使用 LINQ 查询中了解更多关于 LINQ 的信息。然而,如果您想早点了解更多,更多信息可以在 docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/linq/index 找到。

PLINQ 是 LINQ 模式的并行实现。它们类似于 LINQ 查询,并操作任何内存中的集合,但在执行方面有所不同。PLINQ 使用系统中的所有可用处理器。然而,处理器限制在 64 位。这是通过将数据源分区成更小的任务,并在多个处理器的单独工作线程上执行每个任务来实现的。

大多数标准查询运算符都实现在 System.Linq.ParallelEnumerable 类中。以下表格列出了各种并行执行特定的方法:

AsParallel 当您希望系统在可枚举集合上执行并行执行时,可以向系统提供 AsParallel 指令。
AsSequential 使用 AsSequential 指示系统顺序运行。
AsOrdered 要在结果集中保持顺序,请使用 AsOrdered
AsUnordered 要在结果集中不保持顺序,请使用 AsUnordered
WithCancellation 取消标记携带用户的取消执行请求。这必须被监控,以便可以在任何时候取消执行。
WithDegreeofParallelism 控制并行查询中使用的处理器数量。
WithMergeOptions 提供选项,以便我们可以将结果合并到父任务/线程/结果集中。
WithExecutionMode 强制运行时使用并行或顺序模式。
ForAll 允许通过不合并到父线程来并行处理结果。
Aggregate 一个独特的 PLINQ 重载,用于在线程局部分区上启用中间聚合。同时允许我们将最终聚合合并以组合所有分区的结果。

让我们尝试使用其中一些方法,以便我们可以更详细地理解它们。AsParallel扩展方法将whereselect等查询运算符绑定到parallelEnumerable实现。通过简单地指定AsParallel,我们告诉编译器并行执行查询:

public static void PrintEvenNumbers()
{
    var numbers = Enumerable.Range(1, 20);
    var pResult = numbers.AsParallel().Where(i => i % 2 == 0).ToArray();

    foreach (int e in pResult)
    {
        Console.WriteLine(e);
    }

}

当执行时,前面的代码块识别所有偶数并在屏幕上打印它们:

如您所见,偶数并没有按顺序打印。关于并行处理,有一点需要记住的是,它不保证任何特定的顺序。尝试多次执行代码块并观察输出。由于它基于执行时的处理器数量,所以每次都会有所不同。

通过使用AsOrdered运算符,代码块接受 1 到 20 之间的数字范围。然而,使用AsOrdered将排序数字:

public static void PrintEvenNumbersOrdered()
{
    var numbers = Enumerable.Range(1, 20);
    var pResult = numbers.AsParallel().AsOrdered()
        .Where(i => i % 2 == 0).ToArray();

    foreach (int e in pResult)
    {
        Console.WriteLine(e);
    }

}

此示例展示了我们如何在使用Parallel时保持结果集的顺序:

2
4
6
8
10
12
14
16
18
20
Press any key to exit.

当您使用 PLINQ 执行代码块时,运行时会分析查询是否可以并行化。如果是,它会将查询分区成任务然后并发运行。如果不安全并行化查询,它会以顺序模式执行查询。在性能方面,使用顺序算法比使用并行算法更好,因此默认情况下,PLINQ 选择顺序算法。使用ExecutionMode将允许我们指示 PLINQ 选择并行算法。

以下代码块展示了我们如何使用ExecutionMode

public static void PrintEvenNumbersExecutionMode()
{
    var numbers = Enumerable.Range(1, 20);
    var pResult = numbers.AsParallel().WithExecutionMode(ParallelExecutionMode.ForceParallelism)
        .Where(i => i % 2 == 0).ToArray();

    foreach (int e in pResult)
    {
        Console.WriteLine(e);
    }
}

如我们之前提到的,PLINQ 默认使用所有处理器。然而,通过使用WithDegreeofParallelism方法,我们可以控制要使用的处理器数量:

public static void PrintEvenNumbersDegreeOfParallel()
{
    var numbers = Enumerable.Range(1, 20);
    var pResult = numbers.AsParallel().WithDegreeOfParallelism(3)
        .Where(i => i % 2 == 0).ToArray();

    foreach (int e in pResult)
    {
        Console.WriteLine(e);
    }

}

通过更改处理器数量来执行前面的代码块并观察输出。在第一种情况下,我们让系统使用可用的核心/处理器,但在第二种情况下,我们指示系统使用三个核心。您将看到性能差异取决于您的系统配置。

PLINQ 还提供了一个名为AsSequential的方法。这是用来指示 PLINQ 在调用AsParallel之前以顺序方式执行查询。

forEach可以用来遍历 PLINQ 查询的所有结果并将每个任务的输出合并到父线程。在先前的示例中,我们使用forEach来显示偶数。

可以使用 forEach 来保留 PLINQ 查询结果的顺序。因此,当不需要保留顺序并且我们想要实现更快的查询执行时,我们可以使用 ForAll 方法。ForAll 不执行最终的合并步骤;相反,它并行化处理结果。以下代码块正在使用 ForAll 将输出打印到屏幕上:

public static void PrintEvenNumbersForAll()
{
    var numbers = Enumerable.Range(1, 20);
    var pResult = numbers.AsParallel().Where(i => i % 2 == 0);

    pResult.ForAll(e => Console.WriteLine(e));
}

在这种情况下,I/O 正被多个任务使用,因此数字将以随机顺序出现:

当 PLINQ 在多个线程中执行时,随着代码的运行,应用程序逻辑可能在一个或多个线程中失败。PLINQ 使用 Aggregate 异常来封装查询抛出的所有异常,并将它们发送回调用线程。在这样做的时候,你需要在调用线程上有一个 try..catch 块。当你从查询中获取结果时,开发者可以遍历 AggregatedException 中封装的所有异常:

public static void PrintEvenNumbersExceptions()
{
    var numbers = Enumerable.Range(1, 20);
    try
    {
        var pResult = numbers.AsParallel().Where(i => IsDivisibleBy2(i));

        pResult.ForAll(e => Console.WriteLine(e));
    }
    catch (AggregateException ex)
    {
        Console.WriteLine("There were {0} exceptions", ex.InnerExceptions.Count);
        foreach (Exception e in ex.InnerExceptions)
        {
            Console.WriteLine("Exception Type: {0} and Exception Message: {1}", e.GetType().Name,e.Message);
        }
    }
}

private static bool IsDivisibleBy2(int num)
{
    if (num % 3 == 0) throw new ArgumentException(string.Format("The number {0} is divisible by 3", num));
   return num % 2 == 0;
}

上述代码块正在从 PLINQ 中抛出的异常中写入所有详细信息。在这里,我们正在遍历并展示所有六个异常:

你可以通过遍历 InnerExceptions 属性并采取必要的行动。我们将在 第七章 中更详细地探讨内部异常,实现异常处理。然而,在这种情况下,当 PLINQ 执行时,它不会在异常上终止执行,而是会运行所有迭代并提供最终结果。

使用 async 和 await 进行异步编程

异步编程可以帮助你提高应用程序的响应性和性能。在传统方法中,编写和维护异步代码比较困难。然而,C# 5 引入了两个新的关键字,简化了异步编程:asyncawait。当遇到这些关键字时,C# 编译器会为你完成所有困难的工作。它类似于同步代码。TaskTask<T> 是异步编程的核心。

任何 I/O 密集型或 CPU 密集型代码都可以利用异步编程。在 I/O 密集型代码的情况下,当你想从 async 方法返回一个任务时,我们使用 await 操作,而在 CPU 密集型代码中,我们使用 Task.Run 等待启动后台线程的操作。

当使用 await 关键字时,它将控制权返回给调用方法,从而允许 UI 保持响应。

在内部,当编译器遇到 async 关键字时,它会将方法分割成任务,并且每个任务都会被标记上 await 关键字。await 关键字生成代码,用于检查异步操作是否已经完成;也就是说,C# 编译器将代码转换成一个状态机,它跟踪与每个任务/线程相关的元数据,以便在后台任务执行完毕后恢复执行:

private readonly HttpClient _httpClient = new HttpClient();

public async Task<int> GetDotNetCountAsync()
{
    var html = await _httpClient.GetStringAsync("https://dotnetfoundation.org");

    return Regex.Matches(html, @"\.NET").Count;
}

public void TestAsyncMethods()
{
    Console.WriteLine("Invoking GetDotNetCountAsync method");
    int count = GetDotNetCountAsync().Result;
    Console.WriteLine($"Number of times .NET keyword displayed is {count}");
}

在前面的代码块中,我们正在尝试找出一个特定单词在网站上被使用了多少次。前一个代码的输出如下:

Invoking GetDotNetCountAsync method
Number of times .NET keyword displayed is 22
Press any key to exit.

在这里,我们在 GetDotnetCountAsync 方法上使用了 async 关键字。尽管方法是以同步方式执行的,但 await 关键字允许我们返回到调用方法并等待直到 async 方法完成执行,此时它返回结果。

重要的是要理解,异步方法体应该始终包含一个 await,否则此方法永远不会释放。编译器也不会引发错误。

当编写异步方法时,你应该始终使用 async 作为后缀。请注意,async 必须用于事件处理器。这是唯一允许 async 事件处理器像事件一样工作的方法,因为事件没有返回类型。

你可以从 MSDN 在 docs.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/task-based-asynchronous-pattern-tap 上了解更多关于基于任务的异步模式TAP)的信息。

摘要

在本章中,我们探讨了线程、它们的属性、如何使用参数化线程,以及通过详细示例说明了前台线程和后台线程之间的区别。我们还学习了线程状态以及线程如何在多个线程之间存储和共享数据。这就是我们讨论不同同步方法的地方。我们重点介绍了并行编程、任务以及使用任务的异步编程,如何使用并行类,以及 PLINQ。

在下一章中,我们将探讨 C# 中的异常处理。异常处理帮助我们处理程序执行过程中出现的任何意外或异常情况。异常处理使用 trycatchfinally 块。这些块分别帮助开发者尝试可能成功或失败的操作,处理如果发生失败的情况,以及清理不需要的资源。

问题

  1. 默认情况下,你的代码块的主方法以以下哪种方式运行?

    1. 工作线程

    2. 主线程

    3. 后台线程

    4. 以上皆非

  2. 当线程被暂停时,需要执行什么操作才能将其移动到运行状态?

    1. 中断

    2. 恢复

    3. 中断

    4. 暂停

  3. 在编写同步代码区域时,应该使用哪个正确的关键字?

    1. 释放

    2. 获取锁

    3. 解锁

  4. 一个任务可能返回或不返回值。

  5. 当使用 PLINQ 时,结果将按顺序返回。

答案

  1. 主线程

  2. 恢复

进一步阅读

在本章中,我们讨论了 .NET Framework 提供的许多功能,这些功能我们可以用于我们的应用程序。然而,我们没有详细涵盖这个主题。因此,你可能需要阅读几篇 MSDN 文章,以便更好地理解这些概念。查看以下链接:

第七章:实现异常处理

异常处理帮助开发者以有助于处理预期和意外情况的方式构建他们的程序。通常,应用程序逻辑可能会抛出某种未处理的异常,例如,尝试向一个系统中的文件写入代码块,最终导致文件使用异常。如果设置了适当的异常处理,这些场景是可以处理的。

异常处理使用trycatchfinally关键字,使我们能够编写可能不会成功且在需要时可以处理的代码,以及帮助我们清理try块执行后的资源。这些异常可以由 CLR、.NET Framework 或您代码中使用的任何外部库抛出。

在本章中,我们将通过查看以下主题来尝试理解我们如何使用、创建和抛出异常:

  • 代码中的异常及其处理

  • 编译器生成的异常

  • 自定义异常

阅读本章后,您将能够构建应用程序程序并处理应用程序逻辑可能抛出的所有类型的异常。

技术要求

本章的练习可以使用 Visual Studio 2012 或更高版本以及.NET Framework 2.0 或更高版本进行练习。然而,任何从 C# 7.0 及更高版本的新特性都需要您拥有 Visual Studio 2017。

如果您没有上述任何产品的许可证,您可以下载 Visual Studio 2017 的社区版本,链接为visualstudio.microsoft.com/downloads/.

本章的相同代码可以在 GitHub 上找到,链接为github.com/PacktPublishing/Programming-in-C-sharp-Exam-70-483-MCSD-Guide/tree/master/Chapter07

代码中的异常及其处理

异常是派生自System.Exception类的类型。我们使用try块包围可能抛出异常的语句。当发生异常时,控制权跳转到catch语句,在那里 CLR 收集所有必要的堆栈跟踪信息,然后终止程序并向用户显示消息。如果没有进行异常处理,程序将带错误终止。在处理异常时,重要的是要理解,如果我们无法处理异常,我们就不应该捕获它。这确保了应用程序将处于已知状态。当您定义一个catch块时,您定义一个异常变量,可以用来获取更多信息,例如异常的来源、代码中的哪一行抛出了这个异常、异常的类型等等。

程序员可以使用 throw 关键字从应用程序逻辑中创建和抛出异常。每个 try 块可以定义也可以不定义 finally 块,无论是否抛出异常,该块都将执行。这个块帮助我们释放代码块中使用的资源。或者,如果您希望一段代码在所有情况下都执行,则可以将其放在 finally 块中。

在接下来的章节中,我们将探讨如何使用异常、try-catch-finally 块的语法、使用 finally 块、何时可以销毁未使用的对象、不同类型的系统异常以及创建我们自己的异常。

使用异常

如我们之前提到的,C# 程序中的错误是通过异常在运行时传播的。当应用程序代码遇到错误时,它会抛出一个异常,然后由另一个代码块捕获,该代码块收集有关异常的所有信息并将其推送到调用方法,其中提供了 catch 块。如果您使用的是通用异常处理程序,系统将显示一个对话框来显示任何未捕获的异常。

在以下示例中,我们尝试将一个空字符串解析为 int 变量:

public static void ExceptionTest1()
{
    string str = string.Empty;
    int parseInt = int.Parse(str);
}

当执行时,运行时会抛出一个格式异常,其消息指出输入字符串格式不正确。由于这个异常没有被捕获,我们可以看到通用处理程序在对话框中显示这个错误消息:

下面是异常的详细信息:

System.FormatException occurred
  HResult=0x80131537
  Message=Input string was not in a correct format.
  Source=<Cannot evaluate the exception source>
  StackTrace:
   at System.Number.StringToNumber(String str, NumberStyles options, NumberBuffer& number, NumberFormatInfo info, Boolean parseDecimal)
   at System.Number.ParseInt32(String s, NumberStyles style, NumberFormatInfo info)
   at System.Int32.Parse(String s)
   at Chapter7.ExceptionSamples.ExceptionTest1() in C:\Users\srini\source\repos\Programming-in-C-Exam-70-483-MCSD-Guide2\Book70483Samples\Chapter7\ExceptionSamples.cs:line 14
   at Chapter7.Program.Main(String[] args) in C:\Users\srini\source\repos\Programming-in-C-Exam-70-483-MCSD-Guide2\Book70483Samples\Chapter7\Program.cs:line 13

每个 catch 块定义了一个异常变量,它为我们提供了更多关于正在抛出的异常的信息。exception 类定义了多个属性,所有这些属性都包含以下额外信息:

属性 描述
Data 获取关于异常的自定义详细信息,以键/值对集合的形式。
HelpLink 获取或设置与异常相关的帮助链接。
HResult 获取或设置与异常关联的 HRESULT,这是一个数值。
InnerException 获取触发异常的异常实例。
Message 从异常中获取详细信息。
Source 获取或设置导致错误的程序/实例名称或对象/变量。
StackTrace 以字符串格式获取调用堆栈。
TargetSite 获取触发异常的方法。

现在,我们将尝试处理格式异常,并查看每个属性将提供给我们什么。在以下示例中,我们有一个 try 块,其中字符串被解析为整数,以及一个用于捕获格式异常的 catch 块。在 catch 块中,我们显示了我们捕获的异常的所有属性:

public static void ExceptionTest2()
{
    string str = string.Empty;
    try
    {
        int parseInt = int.Parse(str);
    }
    catch (FormatException e)
    {
        Console.WriteLine($"Exception Data: {e.Data}");
        Console.WriteLine($"Exception HelpLink: {e.HelpLink}");
        Console.WriteLine($"Exception HResult: {e.HResult}");
        Console.WriteLine($"Exception InnerException: 
                          {e.InnerException}");
        Console.WriteLine($"Exception Message: {e.Message}");
        Console.WriteLine($"Exception Source: {e.Source}");
        Console.WriteLine($"Exception TargetSite: {e.TargetSite}");
        Console.WriteLine($"Exception StackTrace: {e.StackTrace}");
    }
}

我们试图将一个字符串解析为整数变量。然而,这是不允许的,因此系统抛出异常。当我们捕获异常时,我们正在显示异常的每个属性以观察它存储的内容:

图片

每个异常都是继承自System.Exception基类,它定义了异常的类型并详细说明了所有提供更多异常信息的属性。当你需要抛出异常时,你需要创建异常类的实例,设置所有或部分这些属性,并使用throw关键字抛出它们。

对于一个try块,你可以有多个catch块。在执行过程中,当抛出异常时,首先执行处理该异常的特定catch语句,而任何其他通用的catch语句都将被忽略。因此,按照从最具体到最不具体的顺序组织catch块是很重要的:

public static void ExceptionTest3()
{
    string str = string.Empty;
    try
    {
        int parseInt = int.Parse(str);
    }
    catch (ArgumentException ex)
    {
        Console.WriteLine("Argument Exception caught");
    }
    catch (FormatException e)
    {
        Console.WriteLine("Format Exception caught");

    }
    catch (Exception ex1)
    {
        Console.WriteLine("Generic Exception caught");
    }
}

当程序执行时,尽管存在多个catch块,系统会识别一个合适的catch块并消耗异常。因此,你会在输出中看到“捕获到格式异常”的消息:

Format Exception caught
Press any key to exit.

在调用catch块之前会检查finally块。当在try-catch块中使用资源时,这些资源可能会移动到一个模糊的状态,并且只有在框架的垃圾回收器被调用时才会被收集。程序员可以通过使用finally块来清理这些资源:

public static void ExceptionTest4()
{
    string str = string.Empty;
    try
    {
        int parseInt = int.Parse(str);
    }
    catch (ArgumentException ex)
    {
        Console.WriteLine("Argument Exception caught");
    }
    catch (FormatException e)
    {
        Console.WriteLine("Format Exception caught");

    }
    catch (Exception ex1)
    {
        Console.WriteLine("Generic Exception caught");
    }
    finally
    {
        Console.WriteLine("Finally block executed");
    }
}

如您所见,finally块被执行,但在抛出并捕获异常之前:

Format Exception caught
Finally block executed
Press any key to exit.

尽管我们有三不同的catch块,格式异常被执行,并且之后执行了finally块。

异常处理

程序员将可能抛出异常的应用逻辑分区到try块中,随后是处理这些异常的catch块。如果存在,可选的finally块将执行,无论try块是否抛出异常。你不能只有一个try块——它必须由一个catch块或一个finally块伴随。

在本节中,我们将查看不同的代码块,以便了解try-catch语句、try-finally语句和try-catch-finally语句的用法。

你可以这样使用没有finally块的try-catch语句:

try
{
    //code block which might trigger exceptions
}
catch (SpecificException ex)
{
   //exception handling code block

}

系统还允许你使用带有finally块的try块——不需要捕获异常。这在下述代码中显示:

try
{
    // code block which might trigger exceptions
}
finally
{
    // Dispose resources here.
    //Block you want to execute all times irrespective of try block is executed or not.
}

最后但同样重要的是,有try-catch-finally块:

try
{
    // Code that you expect to throw exceptions goes here.
}
catch (SpecificException ex)
{
    // exception handling code block
}
finally
{
    // code block that you want to run in all scenarios
}

如果运行时在try块中识别到不正确的语法,则会抛出一个编译时错误;例如,在代码编译期间没有catchfinally块的try块。当你没有提供catchfinally块时,编译器会在try块的闭合括号旁边放置一个红色标记,并抛出一个错误,如下面的截图中的错误列表窗口所示:

异常过滤器是一种用于在catch块中捕获的异常类型。System.Exception是任何异常类型类的基类。作为基类,它可以持有代码中的任何异常。我们使用它在我们有处理每个异常的代码或在我们调用method()时抛出异常时。

我们已经讨论过,一个try块可以有多个带有不同异常过滤器的catch块。当运行时评估catch块时,它采取自上而下的方法,并执行最适合已捕获异常的最具体的catch块。如果catch块中的exception过滤器与已抛出的异常匹配,或者与已抛出异常的基类匹配,则执行它。作为一个考试提示,始终记住将最具体的catch语句放在顶部,将通用的放在底部。

理解异常处理的重要性有助于你编写能够处理所有可能场景并执行而不会出现意外行为的正确代码。例如,假设你的程序正在尝试打开并写入一个文件,而你收到了一个如文件未找到文件正在使用中的异常。异常处理使我们能够处理这些场景。在第一种情况下,提示会要求用户提供正确的文件名,而在第二种情况下,提示会检查是否可以创建一个新文件。

在下面的示例中,一个for循环抛出了一个索引超出范围的异常:

public static void ExceptionTest5()
{
     string[] strNumbers = new string[] {"One","Two","Three","Four" };
     try
     {
         for (int i = 0; i <= strNumbers.Length; i++)
         {
             Console.WriteLine(strNumbers[i]);
         }
     }
     catch (System.IndexOutOfRangeException e)
     {
         Console.WriteLine("Index is out of range.");
         throw new System.ArgumentOutOfRangeException(
                     "Index is out of range.", e);
     }
 }

代码处理它,并在抛出之前在屏幕上显示一条消息,以便调用方法可以处理它,如下所示:

然而,我们的主程序不处理异常系统。相反,它使用默认设置并显示一个对话框:

finally块释放了在try块中创建的任何变量或对象。此块最后执行,如果存在则始终运行:

public static void ExceptionTest6()
{
    FileStream inputfile= null;
    FileInfo finfo = new FileInfo("Dummyfile.txt");
    try
    {
        inputfile = finfo .OpenWrite();
        inputfile.WriteByte(0xH);
    }
    finally
    {
        // Check for null because OpenWrite() method might return null.
        if (inputfile!= null)
        {
            inputfile.Close();
        }
    }
}

在前面的示例中,我们在try块中创建了一个文件对象,并尝试向其中写入一些字节。当运行时完成try块的执行后,它执行一个finally块并释放了在try块中创建的file对象。

编译器生成的异常

让我们回顾一下.NET Framework 支持的几个运行时生成的异常。框架在执行有效语句时使用这些异常。然后,根据它们的类型,从以下表格中抛出相应的异常。例如,如果编译器尝试执行除法操作,并且如果除数为零,则会抛出DividebyZeroException异常:

Exception 描述
ArithmeticException 在执行算术操作时触发的异常可以被捕获。
ArrayTypeMismatchException 当数组的值和类型不匹配时,将抛出此异常。
DivideByZeroException 当尝试将整数值除以零时,将抛出此异常。
IndexOutOfRangeException 当使用超出其边界的索引访问数组时,将抛出此异常。
InvalidCastException 在运行时将基类型转换为接口或派生类型将导致此异常。
NullReferenceException 当你尝试访问一个null对象时,将抛出此异常。
OutOfMemoryException 当 CLR 可用内存被利用时,新操作符会抛出此类异常。
OverflowException 在执行除法操作时,例如,如果输出是长整型并且你尝试将其推送到int,则会抛出此异常。
StackOverflowException 递归调用通常会导致此类异常,并指示非常深的或无限递归。
TypeInitializationException 如果你尝试实例化一个抽象类,例如,将抛出此异常。

现在我们已经了解了编译器生成的异常,让我们看看自定义异常。

自定义异常

所有异常都源自.NET Framework 中的System.Exception类。因此,在这些预定义异常不符合我们的需求的情况下,框架允许我们通过从Exception类派生我们的异常类来创建自己的异常。

在以下示例中,我们正在创建一个自定义异常,并从Exception类继承。我们可以为它使用不同的构造函数:

public class MyCustomException : Exception
{
    public MyCustomException():base("This is my custom exception")
    {

    }

    public MyCustomException(string message) 
           : base($"This is from the method : {message}")
    {

    }

    public MyCustomException(string message, Exception innerException) 
       : base($"Message: {message}, InnerException: {innerException}")
    {
    } 
}

当你创建自己的异常类时,从System.Exception类派生,并实现基类,你将获得四个构造函数;实现这三个是最佳实践。在第一种情况下,基类消息属性默认初始化并显示一条消息。然而,在第二种和第三种情况下,抛出此自定义异常的方法需要传递这些值。

摘要

在本章中,我们探讨了如何在程序中使用异常类,如何创建自定义异常以满足我们的需求,以及不同类型的异常。我们还了解了有关如何在应用程序中规划和实现异常的行业标准。

在下一章中,我们将了解类型以及如何创建和消费类型。

问题

  1. C# 支持使用 try 块而不带 catchfinally 块。

    1. 真的

    2. 假的

  2. catch 块需要按照从最通用到最通用的模式使用。

    1. 真的

    2. 假的

  3. 如果存在,finally 块总是会执行。

    1. 真的

    2. 假的

答案

  1. 假的

  2. 假的

  3. 真的

进一步阅读

在实现应用程序代码中的异常处理时,理解行业标准非常重要。请查看以下链接以了解这些最佳实践:docs.microsoft.com/en-us/dotnet/standard/exceptions/best-practices-for-exceptions

第八章:在 C# 中创建和使用类型

类型是 C# 程序的构建块。即使在编写基本的 C# 程序时,我们也必须在创建程序时使用正确的类型。在第二章“理解类、结构和接口”中,我们学习了 C# 程序中类型的基础知识。我们学习了 C# 程序中存在的值类型和引用类型变量。

除了了解不同的类型外,我们还应该理解,在尽可能好的情况下或情境中使用每种类型对我们来说非常重要。我们还应该了解有关创建和使用这些类型的最佳实践。我们将在本章中介绍这一点。

我们将在本章中探讨以下主题:

  • 创建类型

  • 消费类型

  • 如何使用属性来强制封装

  • 使用可选和命名参数

  • 创建索引属性

  • C# 中与字符串操作相关的不同操作

我们将对反射有一个概述,并尝试了解它如何帮助我们查找、执行和创建运行时类型。在第十章“使用反射查找、执行和创建运行时类型”中,我们将深入探讨反射。

技术要求

与本书中前面章节所涵盖的章节一样,本书中解释的程序将在 Visual Studio 2017 中开发。

本章的示例代码可以在 GitHub 上找到:github.com/PacktPublishing/Programming-in-C-Exam-70-483-MCSD-Guide/tree/master/Book70483Samples

创建类型

当我们在 C# 中创建一个变量时,它为我们提供了许多选项来选择变量的适当类型。例如,我们可以选择以下:

  • 如果我们希望变量获取一组定义的变量,我们可以选择 enum 类型。例如,如果我们定义 Dayenum 类型,它可以获取 MondayTuesdayWednesdayThursdayFridaySaturdaySunday 等值。

  • 同样,如果我们选择 int 类型,我们告诉公共语言运行时CLR)它不能有十进制数字。

因此,在为任何变量定义类型时,我们必须逻辑地分析变量的使用情况,然后在 C# 中声明其类型。在下一节中,我们只需简要回顾一下我们在第二章“理解类、结构和接口”中的“C# 数据类型”部分所涵盖的不同类型。

C# 中的类型

在第二章“理解类、结构和接口”中,我们了解到变量可以获取以下类型的值:

  • 值类型:在值类型中,变量包含变量的实际值。这基本上意味着,如果在程序的不同作用域中对值类型变量进行更改,更改在控制权转移到调用函数后不会反映回来。

  • 引用类型:数据成员包含变量在内存中的确切地址。由于变量仅包含对内存地址的引用,两个单独的引用类型变量可以指向相同的内存地址。因此,如果对引用类型变量进行更改,更改将直接在变量的内存位置进行,因此会传播到程序执行中的不同作用域。

  • 指针类型:指针是 C# 中可能存在的另一种变量类型。指针类型用于保存变量的内存地址,使我们能够执行涉及变量内存位置的任何操作。

在下一节中,我们将深入研究指针,并了解它们在我们应用程序中使用时的影响和好处。

不安全代码和指针类型的使用

在 C 或 C++ 等语言中,开发者具有创建 指针* 的功能,这是一个存储另一个变量内存地址的对象。该对象允许应用程序对内存进行非常低级别的访问。然而,由于存在 悬垂指针 的可能性,应用程序的性能会大大降低。悬垂指针是 C 中可能存在的一种潜在情况,即指针对象仍然指向应用程序中不再分配的内存位置。请参考以下图表:

在图中,我们有一个运行在 C 或 C++ 中的应用程序,它声明了指针 B 并将其指向变量 A 的内存地址。指针保存了变量的内存地址。换句话说,指针 B 不会包含变量 A 的内存地址。现在,在程序运行期间某个时刻,应用程序释放了内存位置 A。尽管内存已被释放,但可能存在我们未明确清除包含相应内存地址的指针内容的情形。由于这个错误或疏忽,指针 B 没有更新以指向新的内存块或将其指向 null。因此,该指针仍然引用着应用程序中不再存在的内存位置。这种情况被称为 悬垂指针

C# 通过明确不允许使用指针来消除悬垂指针的可能性。相反,它鼓励人们使用 引用类型。引用类型的内存管理由垃圾回收器管理。

在 第九章,管理对象生命周期 中,我们将进一步探讨垃圾回收器在 .NET 中的工作原理。

然而,在某些情况下,开发者仍然觉得需要在他们的 C# 应用程序中使用指针。这在需要与底层操作系统(如 Windows 或 Linux)进行某些操作的场景中很有用,在这些操作中应用程序正在运行。在这种情况下,我们将需要指针。为了适应这些场景,C# 有一个名为 unsafe代码 概念,它允许开发者在代码中使用指针。使用指针的代码必须明确地用 unsafe 标识符进行分类。这个关键字向 公共语言运行时CLR)传达信息,即代码块是不受管理的或是不安全的——换句话说,已经使用了指针。让我们通过一个代码示例来看看我们如何在 C# 中使用指针类型。

在代码示例中,我们创建了一个函数块,在其中我们使用指针变量。我们将保存 int 类型地址的 int 指针类型变量。请参考以下截图。请注意,当用户尝试编译程序时,他们会得到一个错误:

原因是,默认情况下,C# 编译器不会允许任何包含指针或 unsafe 代码块的代码执行。我们可以通过在函数块中使用 unsafe 关键字来覆盖 C# 的这种行为:

class Program
 {
     static void Main(string[] args)
     {
         UnSafeExample();
     }
     unsafe static private void UnSafeExample()
     {
         int i = 23;
         int* pi = &i;
         Console.WriteLine(i);
         Console.WriteLine(*pi);
         Console.ReadLine();
     }
 }

要允许编译 unsafe 代码,我们需要更改 Visual Studio 的构建设置。要更新设置,我们需要右键单击项目并单击属性。现在,导航到构建部分。请参考以下截图,它突出显示了我们需要指定的 Visual Studio 设置,以允许编译 unsafe 代码:

现在我们已经回顾了 C# 中可能的不同类型。下一节将解释帮助我们选择特定变量类型而不是其他类型的指导原则。

选择变量类型

在 第二章 的 理解类、结构和接口 部分,在 C# 中的数据类型 部分中,我们看到了值类型和引用类型可能的不同数据类型。我们还进行了代码实现,以查看 Struct(值类型)和 Class(引用类型)的行为差异。在本节中,我们将深入探讨这种行为差异,以及它如何帮助我们为变量选择正确的类型。

让我们分析以下代码语句中的值类型和引用类型,看看它们在实现上的差异:

// Value Type
int x = 10;
int y = x 

// Reference Type
Car c = new Car();
Car c2 = c;

在前面的代码中,我们声明了值类型变量 xy。在声明时,x 变量已被赋予一个值。在下一步中,我们将 x 赋予 y。同样,我们有一个名为 Class 的类,我们创建了 c 的对象。在下一个语句中,我们声明了同一类的另一个对象,并将 c 赋予 c2

请参考以下图表,它显示了这些类型如何在内存中实现和管理:

在前面的图表中,我们已将变量x声明为int数据类型,将c声明为Car类的对象。现在,我们知道int是值类型,而Class是引用类型。所以让我们尝试分析为什么两者的行为不同:

  • 对于x,在第一个语句中,即int x = 10,应用程序为其保留了一块内存。声明下方的矩形块表示这一点。

  • 现在,当我们执行int y = x语句时,我们正在声明另一个变量y,并将其分配给x当前值。它内部所做的就是为y在内存中分配另一块内存。因此,由于xy不指向相同的内存位置,它们将持有不同的值。

  • 另一方面,如果我们看看Car类,我们刚刚在其中声明了两个属性:注册号和颜色。现在,当我们使用new语句时,它所做的就是为该类创建一个对象并为其分配内存。然而,与值类型实现相反,它不会在对象中保存值。相反,在对象中,它只保存对分配的内存块的引用。在前面的矩形图中,您将看到,一旦为Car类创建了c对象,就会在创建的对象中保存一个指针。

  • 现在,当我们执行Car c2 = c;语句时,内部会创建一个新的对象c2,但不会为该对象分配新的内存块。相反,它只是保存了对与对象c共享的内存位置的引用。

如前所述的实现所示,每当声明一个新的值类型变量时,应用程序都会为其保留一块新的内存,这与引用类型变量不同。

因此,用更简单的术语来说,以下因素可以帮助我们选择值类型和引用类型:

  • 值类型变量的逻辑不可变性:用非常简单的话来说,这意味着在每次声明值类型时,应用程序都会为其保留一块新的内存。由于它们是不同的内存分配,这意味着如果我们对某个内存位置执行任何操作,该变化不会传递到另一个内存位置。

  • 对象的数量:如果应用程序中创建了大量的对象,那么最好不将它们作为值类型创建,因为这会指数级增加应用程序的内存需求。

  • 对象的大小:如果对象很小,那么将它们作为值类型变量可能是有意义的。然而,如果我们认为对象可能有很多属性,那么引用类型变量将更有意义。

  • 内存管理:值类型变量在栈上管理,而引用类型变量在堆上管理。当我们进入第九章 Chapter 9,管理对象生命周期时,我们将进一步探讨内存管理以及垃圾回收器的工作原理。

现在我们对如何在 C#应用程序中创建和消费不同数据类型有了相当的了解,我们将探讨一些 C#的特性,这些特性帮助我们为应用程序中使用的不同类型设置正确的行为。在下一节中,我们将探讨静态变量以及它们在 C#中的实现方式。

静态变量

当我们讨论值类型与引用类型时,我们了解到在 C#中创建的所有对象在程序执行中都有确定的范围。然而,在某些情况下,我们可能希望变量获取一个在所有对象实例中一致的常量值。我们可以使用Static关键字来实现这一点。在 C#中,Static关键字作为修饰符确保只创建一个变量的实例,并且其作用域是整个程序的运行。我们可以使用Static变量针对类、其成员变量、其成员方法和构造函数。

现在我们来看一些涉及Static关键字的代码示例。

静态成员变量

在本节中,我们将探讨如何使用Static关键字针对类及其成员变量。在下面的代码示例中,我们创建了一个名为ConfigurationStatic类。仅为了解释,我们不会在其中的成员变量上使用Static关键字:

internal static class Configuration
{
     public string ConnectionString;
}

让我们尝试编译程序。我们得到一个错误,指出ConnectionString成员变量也必须声明为static

一旦我们将static关键字应用于ConnectionString成员变量,错误就会消失。这是类的正确表示:

internal static class Configuration
{
    public static string ConnectionString;
}

如果我们需要在成员变量中使用Set/Get值,我们可以通过使用类的名称直接访问它。以下是这个代码片段:

Configuration.ConnectionString = "Sample Connection String";

在前面的代码示例中,我们有一个名为ConfigurationStatic类,其中所有成员变量和属性都必须有static修饰符。然而,在某些情况下,我们可能不希望整个类都是静态的,而只是其中的某个成员变量。

我们可以通过在 C#中使用static修饰符而不是类来达到这一点,而是针对特定的成员变量。如果我们需要在前面代码中使用它,以下将是更新的代码:

internal class Configuration
{
    public static string ConnectionString;
}

然而,访问这个属性的方式将不会改变。我们仍然可以通过使用类的名称来完成。

静态方法

在 C#中,一个类可以有两种类型的方法:静态方法和非静态方法。静态方法在类的不同实例对象之间共享,而非静态方法对每个实例都是唯一的。就像静态成员变量一样,我们可以使用static关键字声明一个方法为静态,并通过直接使用类名来访问它们。

以下代码示例表明我们如何在类中创建一个static方法:

internal class Configuration
{
    public static string ConnectionString;
    public static void CreateConnectionString()
    {      
    }
}

要执行静态方法,我们可以使用以下代码片段:

Configuration.CreateConnectionPath(); 

在下一节中,我们将探讨构造函数及其在 C#中的实现。

构造函数

每当为classstruct类型创建对象时都会调用构造函数。它们可以帮助我们在这些类型中设置一些默认值。

在第二章“理解类、结构和接口”中,当我们理解classstruct类型之间的区别时,提到与类不同,结构体现在没有默认构造函数。在编程术语中,这个构造函数被称为无参构造函数。如果一个程序员没有为类指定任何构造函数,那么每当创建类的对象时,默认构造函数就会触发,并为类中存在的成员变量设置默认值。这些默认值是根据这些成员变量的类型默认值设置的。

在语法方面,构造函数只是名称与其相应类型相同的函数。在方法签名中,它有一个参数列表,可以映射到类型中存在的成员变量。它没有返回类型。

请注意,一个类或结构体可以有多种构造函数,每种构造函数的参数列表都不同。

让我们看看一个代码示例,我们将在这个示例中实现构造函数:

public class Animal
{
     public string Name;
     public string Type;

     public Animal(string Name, string Type)
     {
         this.Name = Name;
         this.Type = Type;
     }
 }

在前面的代码示例中,我们声明了一个具有两个成员变量NameTypeAnimal类。我们还声明了一个接受NameType作为字符串参数的两个参数构造函数。然后,我们使用this运算符将传递给成员变量的值赋给类中存在的成员变量。

我们可以使用以下代码实现来调用这个构造函数:

Animal animal = new Animal("Bingo", "Dog"); 

在下一节中,我们将探讨如何在 C#中实现命名参数。

命名参数

命名参数是在 C# 4.0 中引入的,它允许我们使用参数名而不是参数传递的顺序将参数传递给方法/构造函数/委托/索引器。

使用命名参数,开发者不再需要担心传递参数的顺序。只要他们将与传递的值关联正确的参数名称,顺序就无关紧要。参数名称将与方法定义中参数的名称进行比较。让我们通过以下代码示例来了解它是如何工作的:

internal Double CalculateCompoundInterest(Double principle, Double interestRate, int noOfYears)
{
     Double simpleInterest = (principle) * Math.Pow((1 + 
      (interestRate)/100), noOfYears);
     return simpleInterest;
}

在前面的代码示例中,我们通过传递本金、利率和金额存入银行的时间长度来计算复利。

如果我们不使用命名参数来调用方法,我们将使用以下代码片段:

Double interest = CalculateCompoundInterest(500.5F, 10.5F, 1);            

如果我们仔细观察前面的示例,在调用函数时,开发者需要完全清楚本金和利率参数的顺序。这是因为如果开发者在调用函数时出错,结果输出将是不正确的。

使用命名参数,我们可以使用以下语法调用方法:

Double namedInterest = CalculateCompoundInterest(interestRate: 10.5F, noOfYears: 1, principle: 500.5F); 

注意,在前面的代码中,我们不是按照方法中定义的顺序传递参数值,而是使用参数名称将传递的值与方法中声明的参数进行映射。在下一节中,我们将探讨 C# 4.0 中引入的另一个特性,即 可选参数,它与命名参数一起引入。

可选参数

C# 中的可选参数允许我们以这种方式定义方法,即某些参数是可选的。换句话说,在定义可选参数的函数时,我们指定了一个默认值。

如果在调用方法时未为可选参数传递值,它将假定一个默认值。让我们通过一个代码示例来了解 C# 中可选参数的工作方式:

static float MultiplyNumbers(int num1, int num2 = 2, float num3 = 0.4f)
{
     return num1 * num2 * num3;
}

在前面的代码示例中,我们定义了一个具有三个参数的 MultiplyNumbers 方法,分别是 num1num2num3num1 参数是必需的,而其他两个参数 num2num3 是可选的。

请注意,在定义函数时,如果存在可选参数,则必须将它们放在所有必需参数之后指定。

如果我们需要执行前面的方法,我们可以使用以下任何代码片段:

float result = MultiplyNumbers(2); // output = 1.6f
float result1 = MultiplyNumbers(2, 5); // output = 4f
float result2 = MultiplyNumbers(2, 4, 5); // output = 40f

注意,如果没有传递任何可选参数,编译器将不会出现错误,并且如果未传递任何可选参数,则将使用函数声明中定义的默认值。在下一节中,我们将探讨泛型类型在 C# 中的实现方式。

泛型类型

泛型允许我们设计不涉及数据类型概念的类和方法。换句话说,当我们谈论方法时,泛型允许我们定义方法而不指定输入变量的类型。

让我们通过以下代码实现来了解它如何帮助我们。在以下示例中,我们创建了一个函数,用于比较两个 int 变量 AB 之间的值。如果值相同,它返回 true;如果值不同,它返回 false

static private bool IsEqual(int A, int B)
{
     if(A== B)
     {
         return true;
     }
     else
     {
         return false;
     }
 }

现在,假设我们尝试传递一个不是 int 类型的变量。在以下屏幕截图中,我们尝试传递 string 而不是 int,编译器给出错误:

如以下屏幕截图所示,它将给出以下错误:

如前一个屏幕截图所示,IsEqual 函数接受 int 类型的输入。然而,在调用函数时,我们传递的是 string 类型的变量。由于类型不匹配,编译器显示错误。

为了纠正这个错误,我们需要将 IsEqual 函数泛型化。我们可以通过修改函数,使其不再接受 int 类型的输入变量,而是接受 object 类型的输入变量。

请注意,C# 中的所有变量都继承自 object

在此代码示例中,我们两次调用 IsEqual 函数并传递不同的输入参数。在第一次调用中,我们传递 string;然而,在第二次调用中,我们传递 int。请注意,当我们编译项目时,没有检索到编译时错误,并且函数比较传递的变量,而不考虑类型:

static void Main(string[] args)
{
     UnSafeExample();
     IsEqual("string", "string");
     IsEqual(10, 10);
}

static private bool IsEqual(object A, object B)
{
     if (A == B)
     {
         return true;
     }
     else
     {
         return false;
     }
 }

尽管前面的代码实现对所有数据类型都是泛型的,但它会导致以下问题:

  • 性能下降:在 IsEqual 函数定义中,变量的数据类型是 object。由于这个原因,对于所有调用此函数的情况,变量都需要从其原始类型(即 intstring)转换为 object。这种转换将给应用程序增加额外的负担,从而导致性能下降。在编程术语中,这种转换被称为 装箱和拆箱,我们将在本章稍后讨论。

  • 类型不安全:这种方法不会导致类型不安全。例如,我将通过传递以下变量来调用该函数:

IsEqual(10, "string");

如果我这样做,编译器不会给出任何错误,尽管我们知道这个调用没有意义。为了避免这些问题,同时仍然提供给我们进行泛型调用的能力,C# 提供了使用 泛型类型 的工具。

使用泛型类型,我们可以避免指定函数输入变量的任何数据类型。因此,IsEqual 的实现将如下所示:

static private bool IsEqual<T>(T A, T B)
{
     if (A.Equals(B))
     {
         return true;
     }
     else
     {
         return false;
     }
 }

在前面的代码示例中,请注意,我们使用 T 来表示数据类型,因此使其对所有数据类型都是泛型的。

由于我们没有使用 object,因此不会有变量的装箱和拆箱。如果我们仍然尝试向此函数传递错误的数据类型,如以下截图所示,编译器将给出错误:

截图

在下一个主题中,我们不会介绍 C# 使用来处理数据变量类型的不同概念。我们将介绍如何在 C# 中使用装箱和拆箱将一种数据类型转换为另一种类型,以及我们在消费不同类型的变量时应注意的不同事项。

C# 中的数据类型消费

C# 是一种强类型语言。这基本上意味着,当我们声明一个特定数据类型的变量时,如以下示例所示,我们不能再次声明 x 变量:

int x = 5;

此外,我们无法将任何非整数值赋给此 x 变量。因此,以下语句将给出错误:

x = "Hello";

为了克服这种强类型特性,C# 在我们消费类型时提供了一些功能。这包括值类型变量的装箱和拆箱、使用动态关键字以及将一个数据类型的变量隐式或显式转换为另一个数据类型的变量。让我们逐一了解这些概念,并理解它们在 C# 中的工作原理。

装箱和拆箱

在 C# 中,装箱意味着将值类型变量转换为引用类型变量。拆箱是装箱的相反操作。它指的是将引用类型变量转换为值类型变量。装箱和拆箱对应用程序的性能有害,因为它们是编译器的开销。作为开发者,我们应该尽可能地避免它们;然而,这并不总是可能的,我们在编程过程中会遇到一些情况,迫使我们使用这个概念。

让我们通过以下示例来了解装箱和拆箱是如何工作的:

static private void BoxAndUnBox()
{
     int i = 3;
     // Boxing conversion from value to reference type
     object obj = i;
     // Unboxing conversion from reference type to value type
     i = (int)obj;
 }

在代码实现中,我们可以看到以下内容:

  • 我们已声明一个 i 变量,其类型为 int,并已赋予它 3 的值。现在我们知道,作为 int,这是一个值类型的引用。

  • 接下来,我们声明一个 obj 变量,其类型为 object,并将其赋值为 i 中的值。我们知道 object 是一个引用类型变量。因此,在内部,CLR 将执行装箱,并将 i 变量中的值转换为引用类型变量。

  • 接下来,在第三条语句中,我们正在进行相反的操作。我们试图将一个引用类型变量中的值,即 obj,赋给一个值类型变量,i。在这个阶段,CLR 将执行拆箱操作。

请注意,在进行装箱操作时,我们不需要显式地将值类型转换为引用类型。然而,当我们进行拆箱操作时,我们需要显式指定要转换到的变量类型。这种显式指定要转换到的变量类型的方法被称为类型转换。要进行类型转换,我们可以使用以下语法:

i = (int)obj;

它基本上意味着这种转换可能导致InvalidCastException类型的异常。例如,在上面的例子中,我们知道obj中的值是10。然而,如果它获取一个无法转换为int值的值,例如string,编译器将给出运行时错误。

现在,在下一节中,我们将探讨 C#为我们提供用于在数据类型之间进行转换的不同技术。

C#中的类型转换

C#中的类型转换基本上意味着将变量从一个数据类型转换为另一个数据类型。现在我们将探讨 C#中可用的不同类型转换。

隐式转换

隐式转换是由编译器自动完成的。编译器在没有任何开发者干预或命令的情况下执行隐式类型转换。编译器执行隐式类型转换必须满足以下两个条件:

  • 无数据丢失:编译器必须确定如果它隐式执行转换,将不会发生数据丢失。在第二章,“理解类、结构和接口”,在“数据类型”部分,我们看到了每种数据类型都会在内存中占用空间。因此,如果我们尝试将一个类型为float的变量(占用 32 字节内存)赋值给一个类型为double的变量(占用 64 字节内存),我们可以确信在转换过程中不会发生数据丢失。

  • 无转换异常的可能性:编译器必须确定在将值从一种数据类型转换为另一种数据类型的过程中不会发生异常。例如,如果我们尝试将一个string值设置到一个float变量中,编译器将不会执行隐式转换,因为这将会是一个无效的转换。

现在,让我们看一下以下代码实现,以了解 C#中隐式转换是如何工作的:

 int i = 100;
 float f = i;

在前面的代码示例中,我们声明了一个int类型的变量i,并给它赋值为100。在下一个语句中,我们声明了一个float类型的变量f,并将其值赋给i

现在,编译器会确定隐式转换所需的两个条件都已满足,即float占用的内存比int多,并且不存在无效转换异常的可能性——int值也是float变量中的一个有效值。因此,编译器不会报错,并执行隐式转换。

然而,如果我们进行反向操作,即尝试将 float 值赋给 int,编译器将确定条件未满足,并将给出编译时错误。请参考以下截图:

然而,在某些情况下,即使有可能数据丢失,我们仍然希望进行这些转换。C# 提供了 显式转换,允许我们明确指示编译器让转换发生。让我们看看 显式转换 是如何进行的。

显式转换

当编译器无法隐式更改变量的类型,但我们仍然希望进行转换时,我们需要明确指示编译器进行转换。这被称为显式转换

在 C# 中,有两种进行显式转换的方法:

  • 使用类型转换操作:在这种情况下,我们使用基本数据类型来指示编译器进行显式转换。例如,对于前面示例中尝试的代码实现,以下将是语法:
float k = 100.0F;
int j = (int)k;

在前面的代码中,我们通过在浮点变量之前使用 int 类转换来明确告诉编译器进行类型转换。

  • 使用 Convert:C# 提供了 Convert 类,我们可以使用它来进行多种数据类型之间的类型转换。如果我们使用 Convert 类而不是 int 关键字,以下将是语法:
float k = 100.0F;
int j = Convert.ToInt32(k);

Convert 类可用于不同数据类型之间的类型转换。请参考以下截图以了解 Convert 类中可用的不同选项。根据使用情况,我们可以使用 Convert 类中的适当方法:

因此,程序的总体实现将如下所示:

float k = 100.67F;
int j = (int)k;
int a = Convert.ToInt32(k);
Console.WriteLine(j);
Console.WriteLine(a);
Console.ReadLine();

现在,让我们尝试运行这个程序来看看它给出的输出:

这意味着当我们使用类型转换关键字,即 (int)k,编译器尝试从 float 变量 k 中提取整数部分,结果为 100

另一方面,当我们使用 Convert 类,即 Convert.ToInt32(k),它会尝试提取与浮点变量 k 最接近的整数,结果为 101。这是开发者在决定使用类型转换和 Convert 类之间需要了解的关键区别之一。

当我们查看显式类型转换时,我们需要注意两个辅助方法,这些方法帮助我们进行转换:

  • Parse

  • TryParse

ParseTryParse 方法都用于将 string 转换为不同的数据类型。然而,在处理无效情况异常的方式上存在细微差别。让我们通过以下示例来看看它们是如何工作的以及它们之间的区别:

string number = "100";
int num = int.Parse(number); 

在前面的例子中,我们声明了一个字符串对象,并给它赋值为100。现在,我们正在尝试使用Parse方法将这个值转换为整数。当我们运行程序时,我们看到以下输出:

图片

这意味着解析方法将字符串转换为它的整数等价物,并将值赋给另一个变量,num

现在,假设数字中的值是100wer。现在很明显,number字符串中的值不能转换为int,因为它包含一些无法归类到整数对象中的字符。当我们运行这个程序时,我们得到以下异常:

图片

为了避免这种情况,我们使用TryParse。在TryParse中,CLR 尝试将字符串对象转换为指定的数据类型。然而,如果转换返回错误,TryParse返回false,换句话说,转换失败。在其他情况下,它返回true。因此,如果我们用TryParse编写相同的实现,我们会这样做:

 string number = "100wer"; 
 int num;
 bool parse = int.TryParse(number, out num);
 if(parse)
 {
     Console.WriteLine(num);
 }
 else
 {
     Console.WriteLine("Some error in doing conversion");
 }
 Console.ReadLine(); 

在前面的程序中,我们声明了一个string类型的变量,并使用TryParse将其值转换为int类型的变量。我们正在检查转换是否成功。如果成功,我们打印出数字;在其他情况下,我们打印一条语句来显示在类型转换过程中出现了错误。当我们运行程序时,我们得到以下输出:

图片

从输出中我们可以看到,编译器告诉我们TryParse操作出现了错误;然而,与在相同场景下抛出无效案例异常的Parse方法不同,它并没有在应用程序中抛出异常。

在下一节中,我们将快速回顾封装的概念,这是我们已经在第三章《理解面向对象编程》中讨论过的,我们将看到如何为类的成员变量对象实现属性,这样我们就可以在不用担心隐藏复杂性的情况下消费它们。

强制封装

在之前,我们在第二章《理解类、结构和接口》和第三章《理解面向对象编程》中讨论了以下概念:

  • 访问修饰符及其如何帮助我们控制同一类、同一程序集和派生类中方法和字段的访问。

  • 封装及其如何帮助我们将在同一对象中相关联的字段和方法组合在一起。

然而,封装中还有一个叫做属性的概念,它确保没有人可以直接访问类外的数据字段。这有助于我们确保我们对数据字段的修改有控制权。

属性与类的字段非常相似。就像类的字段一样,它有一个类型、名称和访问修饰符。然而,使其不同的地方在于存在访问器。访问器是允许我们从字段设置和检索值的getset关键字。

属性的语法如下:

class SampleProperty
{ 
     private string name;
     public string Name
     {
         set { if(value != null)
                 {
                     this.name = value;
                 }
               else
                 {
                     throw new ArgumentException();
                 }    
             }
         get { return this.name; }
     }
 }

在前面的代码中,请注意以下几点:

  • 对于SampleProperty类,我们已声明了一个name字段和一个Name属性。

  • name字段已被标记为private,因此它不会在SampleProperty类外部被访问。

  • Name属性已被标记为public,并具有getset访问器。

  • set方法中,我们正在检查传递的值是否为 null。如果是 null,我们将引发一个参数异常。因此,我们在name字段上可以设置的值周围设置了规则。

以这种方式,属性帮助我们消费类的字段。

字符串操作

字符串是 C#中一个非常重要的数据类型。字符串数据类型用于保存文本为string。在编程术语中,它是一系列字符。字符串是一个引用类型变量,与其他基本数据类型变量(如intfloatdouble,它们是值类型变量)不同。此外,字符串在本质上是不变的,也就是说,它们中存在的值不能改变。在本节中,我们将探讨与该数据类型相关的不同操作。

因此,请看以下代码示例:

string s = "Hello";
s = "world";

当我们将Test值分配给已声明的string对象时,CLR 内部会为修改后的string对象分配一个新的内存块。因此,对于我们在字符串上进行的每个操作,而不是修改相同的string对象,CLR 会声明一个新的string对象。由于这个原因,我们在对字符串进行操作时需要非常小心,例如,如果我们在一个字符串对象上执行以下循环操作:

string s = String.Empty;
for(int z = 0; z < 100; z++)
{
    s = + "a";
}

在前面的代码中,我们在循环中将字符串对象s与一个字符a连接起来。这个循环将运行100次。因此,CLR 将不断为string对象分配更多的内存。因此,由于内存使用,从性能角度来看,前面的操作并不好。

为了帮助改进string中的这个特性,C#为我们提供了两个内置类,StringbuilderStringWriter,我们将在下一节中讨论它们。我们还将查看 C#中可用于执行字符串搜索的一些功能。

StringBuilder

Stringbuilder 是 C# 提供的一个内部类,它帮助我们改进 string 操作函数。为了解释这个想法,我们将执行一个从 0100for 循环,并在每个循环中将结果输出与字母 a 连接起来。内部,字符串构建器使用缓冲区来修改字符串值,而不是在每次字符串操作时分配内存。以下代码示例展示了我们如何使用字符串构建器进行字符串操作:

StringBuilder sb = new StringBuilder(string.Empty);
for (int z = 0; z < 100; z++)
{
     sb.Append("a"); 
}

在前面的代码中,我们声明了一个 StringBuilder 对象 sb,并在循环中将其值与 a 连接。内部,StringBuilder 将使用内部缓冲区来管理这些操作。

字符串读取器和字符串写入器

StringReaderStringWriter 类分别从 TextReaderTextWriter 类派生。TextReaderTextWriter 用于处理诸如从 XML 文件读取、生成 XML 文件或从文件读取等 API。

我们将在第十四章 执行 I/O 操作中更详细地研究 TextReaderTextWriter 类。

使用 StringReaderStringWriter 类,我们可以通过操作字符串和字符串构建器的对象来与这些 I/O 操作进行交互。

让我们通过以下示例来更好地理解这些方法。在以下示例中,我们使用 StringWriter 首先创建一个 XML 文件的摘录,然后我们将结果 XML 表示传递给 StringReader,它将尝试读取其中的元素。

在以下代码示例中,我们使用 XMLWriter 创建一个以 Student 为起始元素并具有 Name 属性的 XML 文件。我们使用 StringWriter 保存 XML 文件的字符串表示:

static private string CreateXMLFile()
{
     string xmlOutput = string.Empty;
     var stringWriter = new StringWriter();
     using (XmlWriter writer = XmlWriter.Create(stringWriter))
     {
         writer.WriteStartElement("Student");
         writer.WriteElementString("Name", "Rob");
         writer.WriteEndElement();
         writer.Flush();
     }
     xmlOutput = stringWriter.ToString();
     return xmlOutput;
}

假设我们打印程序的输出;我们将得到以下结果:

现在,在以下代码片段中,我们将使用 StringReader 来读取这个 XML 文件:

static private void ReadXMLFile(string xml)
{
     var stringReader = new StringReader(xml);
     using (XmlReader reader = XmlReader.Create(stringReader))
     {
         reader.ReadToFollowing("Name");
         string studentName = reader.ReadInnerXml();
         Console.WriteLine(studentName);
     }
 }

请注意,我们向函数传递了一个字符串参数,该参数首先被转换为 StringReader 对象。从那个 StringBuilder 对象,我们创建了一个 XmlReader 对象。

ReadToFollowing 函数读取 XML 文件,直到找到具有相应名称的元素,该名称作为参数传递给函数。在前面的代码示例中,我们将 Name 参数传递给 XmlReader 对象。根据我们传递给它的 XML 文件,它将带我们到 Rob 元素。为了读取元素的文本表示,我们可以使用 reader 对象上的 ReadInnerXml 函数。因此,在前面的示例中,studentName 变量将被分配 Rob 的值。如果我们执行代码片段,我们将得到以下输出:

在下一节中,我们将介绍一些我们可以用来在字符串对象中搜索特定字符的函数。

字符串搜索

如其名所示,字符串搜索涉及在另一个字符串中搜索特定字母或字符串的存在。C# 提供了多种方法来完成这项工作。

请注意,C# 是一种区分大小写的语言。因此,搜索字符,比如 C,与在字符串中搜索字符 c 是不同的。

请参考以下使用 string 对象可以进行的不同类型的搜索:

  • Contains: 当我们想要检查一个特定字符是否存在于字符串中时,我们使用 Contains 函数。以下示例检查字符 z 是否存在于字符串对象中。如果存在,它返回 true;否则,返回 false

让我们看看以下示例:

string s = "hello australia";
var contains = s.Contains("z");
if(contains)
{
   Console.WriteLine(" z is present in it.");
}
else
{
   Console.WriteLine(" z is not present");
}  

在前面的代码中,使用 Contains 函数,我们正在检查 z 是否出现在我们调用函数的字符串中。由于我们为具有值 hello australia 的变量调用它,因此它将返回 false 值,因为 z 不在字符串中出现。因此,当代码执行时,我们得到以下输出:

  • IndexOf: 如果我们想要找出字符串中特定字符出现的位置,我们会使用这个函数。

例如,在下面的代码示例中,我们正在寻找字符串 hello australia 中字符 a 的首次和末次出现的位置:

 string s = "hello australia";
 var firstIndexOfA = s.IndexOf("a");
 Console.WriteLine(firstIndexOfA);
 var lastIndexOfA = s.LastIndexOf("a");
 Console.WriteLine(lastIndexOfA);

当我们执行程序时,我们将得到首次出现的位置为 6,末次出现的位置为 14。IndexOf 函数检索字符或字符串在字符串中首次出现的位置,请注意,它不会忽略空格。因此,空白也被计为一个字符。同样,LastIndexOf 函数检索相应字符或字符串出现的最后一个索引:

请注意,在 C# 中,对于任何数组或字符串,第一个字符的索引为零。

  • StartsWith/EndsWith: 如果我们想要检查一个字符串是否以特定的字符开始或结束,我们会使用这个函数。

以下代码示例显示了一个场景,其中我们正在检查之前使用的相同字符串对象是否以 h 开始和结束。在以下代码中,在第一个语句中,我们正在检查 s 字符串变量是否以 h 开始。根据评估结果,我们在控制台窗口中打印输出。同样,在下一个语句中,我们正在检查相同的字符串变量是否以 h 结束。根据评估结果,我们再次在控制台窗口中打印输出:

if(s.StartsWith("h"))
{
     Console.WriteLine("It Starts with h.");
}
else
{
     Console.WriteLine("It does not starts with h.");
}

if (s.EndsWith("h"))
{
     Console.WriteLine("It ends with h.");
}
else
{
     Console.WriteLine("It does not ends with h.");
}

请参考以下输出以了解前面的代码示例:

  • Substring:如果我们想从一个特定的字符串对象中提取子字符串,我们将使用此函数。在 C# 中,可能的子字符串有两种变体。在一个中,我们只指定起始索引并从该特定索引提取子字符串。在另一个变体中,我们指定起始和结束索引,并提取该子字符串中的字符。

下面是这个代码示例:

 string subString = s.Substring(3, 6);
 string subString2 = s.Substring(3);
 Console.WriteLine(subString);
 Console.WriteLine(subString2);

在前面的代码示例中,我们正在寻找字符串对象 hello australia 的两个子字符串。

在第一个子字符串中,我们传递了起始索引为 3,结束索引为 6。因此,子字符串将返回值,lo aus

在第二个子字符串中,我们只传递了起始索引,3。因此,它将从该索引返回整个字符串。以下是此执行输出的截图:

图片

这些是 C# 中可用的不同字符串操作函数。在下一节中,我们将概述反射,并了解它是如何帮助我们从程序集获取结构——换句话说,类及其方法和属性。

反射概述

在 C# 中,反射意味着在运行时检查程序集的内容。它返回程序集中每个类的元数据——因此,它返回以下内容:

  • 类的名称

  • 类中存在的所有属性

  • 所有方法及其返回类型和函数参数

  • 类中存在的所有属性

在 第十章,使用反射在运行时查找、执行和创建类型,我们将深入探讨反射;然而,在本章中,我们只会通过一个代码示例来展示如何在 C# 中实现反射,以解码程序集中存在的所有元数据。

要使用反射,我们需要包含 System.Reflection 命名空间,它帮助我们使用所需的类,例如 Assembly。请参考以下函数,该函数根据其路径读取特定的程序集,并读取程序集中存在的所有类、方法和参数:

static private void ReadAssembly()
{
     string path = @"C:\UCN Code Base\Programming-in-C-Exam-70-483-
      MCSD-Guide\Book70483Samples\Chapter8\bin\Debug\ABC.dll";
     Assembly assembly = Assembly.LoadFile(path);
     Type[] types = assembly.GetTypes();
     foreach(var type in types)
     {
         Console.WriteLine("Class : " + type.Name);
         MethodInfo[] methods = type.GetMethods();
         foreach(var method in methods)
         {
             Console.WriteLine("--Method: " + method.Name);
             ParameterInfo[] parameters = method.GetParameters();
             foreach (var param in parameters)
             {
                 Console.WriteLine("--- Parameter: " + param.Name + " : 
                  " + param.ParameterType); 
             }
         }
     }
    Console.ReadLine();
}

在前面的代码库中,我们已声明了一个 C# 中程序集的完全限定路径。接下来,我们已声明了一个 Assembly 类的对象,并检索了程序集中所有 Types 的数组。然后,我们正在遍历每个类型,并找出这些类型中的方法。一旦我们为每个类型获取了方法列表,我们就检索该方法中存在的参数列表及其参数类型。

摘要

在本章中,我们学习了如何在 C#中管理类型。我们对 C#中可用的不同数据类型进行了回顾。我们深入探讨了值类型和引用类型。我们还回顾了指针数据类型,并学习了它是如何工作的。我们还查看了一些用户可以选择变量类型的实践。我们还查看了一般类型,并学习了它们如何帮助我们提高系统的性能。

然后,我们探讨了我们在 C#中声明类型时使用的不同技术。我们学习了 C#中的装箱和拆箱是如何工作的。然后,我们查看如何消费这些数据类型。我们还探讨了类型转换,包括隐式和显式转换,并学习了它们如何帮助我们转换一种数据类型到另一种数据类型。

然后,我们查看了一下Properties以及它是如何帮助我们更好地控制从类的字段属性设置和检索值的。然后,我们研究了字符串以及它们是如何工作的。我们探讨了字符串的不可变特性。我们探讨了使用StringBuilderStringWriterStringReader,这些工具帮助我们提高使用字符串的性能。然后,我们查看 C#中帮助我们在字符串上执行不同操作函数的不同函数。最后,我们对反射进行了高级回顾,并通过代码示例学习了我们如何检索程序集中的元数据。

在下一章中,我们将探讨 C#中垃圾回收是如何执行的。我们将探讨 CLR 如何在 C#中管理不同数据类型的内存。我们将探讨 C#如何允许我们管理“非托管资源”或本章中我们看到的“指针类型”。我们还将探讨我们如何实现IDisposable接口来管理非托管资源。

问题

  1. 在使用指针声明时,我们在程序函数中使用的关键字是什么?

    1. 密封

    2. 安全

    3. 内部受保护

    4. 不安全

  2. 以下代码片段的输出会是什么?

 float f = 100.23f;
 int i = f;
 Console.WriteLine(i);
    1. 100

    2. 编译时错误

    3. 101

    4. 运行时错误

  1. 以下代码片段的输出会是什么?
string s = "hello australia";
var contains = s.Contains("A");
if(contains)
{
     Console.WriteLine("it's present");
}
else
{
     Console.WriteLine("it's not present");
}
  1. 它存在

  2. 它不存在

答案

  1. 不安全

  2. 编译时错误

  3. 它不存在

第九章:管理对象生命周期

C#是一种托管语言。与需要显式管理内存清理的其他语言,如 C++不同,在 C#中我们不需要担心这一点。.NET Framework 中的垃圾回收器为我们管理内存的分配和释放。

垃圾回收器确保只要我们使用托管类型,即值类型和引用类型变量,我们就不需要显式销毁对象来释放其内存。然而,正如我们在第八章,在 C#中创建和使用类型中发现的,C#也赋予我们利用指针对象类型的能力。在 C#中,我们必须声明使用不安全语法的代码。除此之外,对于在不安全代码中声明的变量,我们还需要管理内存的释放。

在本章中,除了探讨非托管代码的内存管理外,我们还将深入研究以下主题:

  • C#中托管代码和非托管代码的区别

  • C#中垃圾回收的工作原理

  • 如何在应用程序执行期间,垃圾回收器使用托管堆为对象分配内存

  • 理解垃圾回收器使用的标记-压缩算法

  • 如何在 C#中管理非托管资源

  • 理解终结和终结方法的使用对性能的影响

  • 理解IDisposable接口以及它如何帮助克服终结方法的不足

  • 理解如何将Dispose方法与终结方法结合使用,以确保我们应用程序的最佳性能

  • 理解使用using块为所有实现IDisposable接口的类

技术要求

与本书中前面的章节一样,这里解释的程序将在 VS 2017 中开发。

本章的示例代码可以在 GitHub 上找到,地址为github.com/PacktPublishing/Programming-in-C-Sharp-Exam-70-483-MCSD-Guide/tree/master/Book70483Samples

托管代码与非托管代码的比较

在本节中,我们将了解托管代码和非托管代码之间的区别。回想一下,我们也在第一章,学习 C#基础知识中学习了这一点。因此,为了快速回顾,我们只需复习那里覆盖的概念。

这些概念不仅适用于 C#语言,也适用于所有在.NET Framework 中编写的语言。以下是一些托管代码和非托管代码之间的区别:

  • 托管代码由公共语言运行时CLR)执行。因此,代码与底层操作系统独立。另一方面,非托管代码是直接由操作系统执行的代码。

  • 在管理代码的情况下,代码与底层框架或操作系统是独立的。CLR 将代码编译成中间语言(IL)代码,然后将其编译成机器代码。IL 代码由程序正在执行的底层系统或操作系统组成。另一方面,在非管理代码的情况下,代码直接编译成底层机器代码。

  • 由于管理代码是由 CLR 执行的,.NET 框架提供了几个内置功能,如垃圾回收和类型检查异常。然而,对于非管理代码,正如我们将在本章中学习的,程序员需要显式管理内存清理活动,否则这些活动将由垃圾回收器执行。

现在,在我们学习程序员如何管理非管理代码的内存之前,让我们首先了解 C# 中的垃圾回收是如何工作的以及它有多有用。

垃圾回收

垃圾回收是 .NET 中由 CLR 提供的一种功能,它帮助我们清理由管理对象占用的内存。这是一个在 .NET 框架中执行的线程,并且定期检查应用程序中是否有任何未使用的内存。如果它确实找到了内存,那么它将回收该内存并销毁底层对象。

假设我们已经使用 C# 实现了一个 .NET 网络应用程序。现在,让我们假设在任何时间间隔内,都有多个人试图访问这个 .NET 应用程序。以下是一个特定的场景,它将给我们一个关于为什么垃圾回收是 C# 或任何 .NET 应用程序非常重要的部分的启示:

  • 当用户浏览应用程序时,他们可以执行许多功能,例如访问他们的个人资料或执行操作(例如创建、更新和删除信息)。

  • 此信息可以存储在不同的来源中,例如 SQL、Oracle 或更多。

  • 为了访问此信息和执行这些操作,应用程序将在应用程序运行时需要创建不同的对象。

  • 假设一个场景,其中内存正在分配给不同的对象,但没有被清理,随着时间的推移,我们将最终得到一个拥有过多未使用内存的系统。当内存中声明的对象不再需要时,内存清理是逻辑的。例如,假设用户在应用程序中执行了预期的操作后注销。在这种情况下,为该特定用户的操作分配的内存不再需要。因此,可以回收该内存。

  • 如果分配给应用程序的内存有限,这将在一段时间后导致性能下降。

.NET 框架中的垃圾回收确保了管理代码不会出现此类情况。此线程在应用程序的背景中运行,并在设定的时间间隔内回收内存。

请注意,垃圾回收只能回收托管代码的未使用内存。对于稍后我们将学习的非托管代码,我们需要显式编写代码以确保应用程序中不会发生内存泄漏。

.NET 中的垃圾回收器在应用程序中执行以下任务:

  • 内存分配:在.NET 上运行的每个应用程序都在托管堆中维护一个执行所需的内存块。垃圾回收管理从该堆结构到程序中使用的对象的内存分配。在接下来的章节中,我们将了解更多关于托管堆的内容。

  • 内存释放:垃圾回收器在应用程序运行时以设定的时间间隔运行,寻找应用程序不再需要的对象。然后销毁这些对象,并为未来的使用回收内存。

    当程序执行期间出现以下三种条件之一时,垃圾回收器会回收内存:

    • 应用程序内存不足:在.NET 上运行的每个应用程序都需要内存以成功执行。如果 CLR 确定应用程序从操作系统获得空闲的内存,它会告诉垃圾回收器释放任何未使用的内存。

    • 内存重定位:C#中的垃圾回收基于代。代只是应用程序使用的托管堆中的划分。在 C#中,我们可以有三个代:0 代、1 代和 2 代。在接下来的章节中,我们将学习如何对代进行分类。垃圾回收器试图通过将应用程序中使用的对象分类到托管堆的三个代中,来优化系统的性能。在 0 代中,它将新创建的对象保留在应用程序运行中。相比之下,在后续运行中,它识别出在应用程序执行中被使用时间较长的对象。它将它们分类为 1 代和 2 代,然后比 0 代更少地遍历这些代。因此,这导致了更好的性能。

    • 当调用 Collect 方法时:作为程序员,我们几乎不需要显式调用垃圾回收方法,因为.NET 足够智能,能够确保垃圾回收定期发生。然而,在某些情况下,我们可能需要显式调用此方法。在这种情况下,我们可以通过调用GC.Collect方法来实现。在本章中,我们将查看一个程序实现示例,其中我们就是这样做的。

现在,让我们看一下垃圾回收在 C#中与之协同工作的基本结构。我们将从托管堆开始,我们将在下一节中对其进行探索。

托管堆

当应用程序在.NET 框架中执行时,垃圾回收器为存储和管理应用程序执行期间声明的对象分配一段内存。

这部分内存被称为托管堆。它被称为“托管”,因为它用于保存托管变量。以下图表展示了典型的堆结构:

图片

上述图表是典型的堆结构示例。在结构的最顶部,我们有一个根节点。每个节点可以有多个子节点。子节点的地址被保存在父节点本身中。

垃圾收集器在这个托管堆上分配和释放内存。这个堆被称为托管堆。当应用程序中分配一个对象时,该对象被存储在堆中。然后对象保存对堆中下一个对象的引用。

在分配内存时,CLR 会检查堆中是否有可用的空闲内存。如果有内存可用,它就会从堆中分配。然而,垃圾收集器会不时地对托管堆中所有对象进行检查,以确定对象是否在应用程序中被使用。垃圾收集器遍历堆,找出那些与应用程序根不相关以及在任何地方都没有被引用的对象。这些对象被归类为死亡对象。然后,垃圾收集器将这些死亡对象从堆中移除。

在我们开始理解垃圾收集器工作的各个阶段之前,让我们先了解垃圾收集器是如何将托管堆划分为不同的部分,这些部分被称为

垃圾收集器将托管堆划分为三个部分或代:

  • 代 0

  • 代 1

  • 代 2

这个想法是通过在内存中分别处理长寿命和短寿命对象来优化应用程序。例如,如果我们确定对象a是在应用程序执行期间使用的长期对象,那么理想情况下,垃圾收集器就不希望在每次检查时都查看这个对象是否仍然有效。

相反,垃圾收集器将短期对象归类到代 0,将长期对象归类到代 1 或 2。只有在每次垃圾收集运行时,才会检查存在于代 0 中的对象。

另一方面,高代中的对象不会被频繁检查。因此,这避免了不必要的检查,提高了整体应用程序的性能。

代 0 是最年轻的代,所有新的对象都分配到代 0。代 1 的对象包含寿命较长的对象。同样,代 2 由应用程序执行中最长寿的对象组成。让我们通过以下示例来看看代是如何帮助优化应用程序性能的。

假设我们有一个应用程序,A,它在执行过程中声明了不同的对象。方括号表示垃圾收集器维护的不同分区或代。以下每个步骤都表示应用程序执行过程中的一个特定阶段。

请注意,以下示例仅用于说明目的。垃圾收集调用将取决于不同的因素,并不一定基于函数执行的范围。

让我们看一下以下代码示例,看看它是如何工作的。在代码示例中,我们声明了一个私有的ReturnResult函数,该函数没有输入参数,并返回一个object类型的输出参数。在这个函数中,仅为了说明,我们声明了一些变量,并将一个变量a返回给调用函数。现在,让我们按照以下方式执行代码:

static void Main(string[] args)
{
    object a = ReturnResult();
}

static private object ReturnResult()
{
    object a = new object();
    object b = new object();
    object c = new object();
    object d = new object();
    object e = new object();
    return a;    
}

当应用程序执行开始时,应用程序调用ReturnResult函数。然后,在函数中,当执行遇到new关键字时,垃圾收集器被触发。由于所有变量都是新创建的变量,因此这些变量将被添加到第 0 代:

图片

现在,假设在下一个语句中,我们将执行权返回到主函数,并传递对象a。通过这样做,程序执行转移到主操作。然而,因为我们只是返回a,所以其他所有bcde对象在应用程序中就不再需要了。

此外,我们还在主程序中声明了新的对象fgh

如果在这个时候调用垃圾收集器,它将确定对象a在程序执行中仍然需要,但所有其他对象都可以释放。因此,垃圾收集器将回收变量bcde中的内存。新的对象fgh将被添加到第 0 代。对于对象a,垃圾收集器将假设它是一个长期存在的对象,这将移动到第 1 代分区。

现在这些代看起来是这样的:

图片

现在,让我们假设,再次,主程序调用另一个ReturnResultFinal函数,传递对象a。新添加的程序不返回任何内容。以下是这个功能的代码实现:

static void Main(string[] args)
{
     object a = ReturnResult();
     ReturnResultFinal(a);
}
static private object ReturnResult()
{
     object a = new object();
     object b = new object();
     object c = new object();
     object d = new object();
     object e = new object();
     return a;
}
static private void ReturnResultFinal(object a)
{ 
}

在这个阶段,垃圾收集器可以确定除了a之外的所有其他变量都可以从内存中移除。在此期间,它还可以确定此对象可以被提升到第 2 代。现在,这些代看起来是这样的:

图片

在我们继续下一个主题之前,让我们简要地回顾一下垃圾收集器使用的标记-压缩算法。

标记-压缩算法

标记紧凑算法被垃圾收集器用于维护内存。本质上,它可以分为三个阶段:

  • 标记阶段:在标记阶段,垃圾收集器遍历堆中的不同对象,并识别出被根项引用的对象。根项可以是程序执行的起点或特定的函数。如果元素被引用,它就会标记该对象。然后,所有未被引用的其他对象被分类为死亡对象。

  • 重定位阶段:在重定位阶段,垃圾收集器将所有被引用的对象移动,将它们分组,然后更新内存堆中每个后续对象的内存地址。

    此外,垃圾收集器还将应用程序中正在使用的对象分类到不同的代中。

  • 压缩阶段:在压缩阶段,垃圾收集器销毁上一阶段分类的死亡对象,并回收它们的内存。

垃圾收集器所执行的全部过程可能会对应用程序的性能产生影响。这是因为,在程序执行期间,垃圾收集器需要确保在运行过程中堆中的引用没有被更改。这意味着在运行过程中,应用程序的所有其他线程都会暂停。

幸运的是,这种情况并不常见,因为垃圾收集器仅在应用程序执行可用的内存较低时开始清理。因此,当内存较高时,收集算法不会启动。此外,正如我们在讨论代时解释的那样,当垃圾收集开始时,它首先检查 0 代堆对象。如果它们在清理过程中幸存,它们将被提升到下一代。对于更高代的对象,垃圾收集器假设这些对象将在应用程序中使用更长时间。

在下一节中,我们将探讨如何在 C#中显式调用垃圾收集方法。

调用垃圾收集

虽然不推荐这样做,我们也几乎找不到任何理由或情况,在程序执行期间很少需要显式调用垃圾收集器,但我们可以使用以下语法来执行垃圾收集中的Collect方法。以下是对此的代码实现:

GC.Collect();
GC.WaitForPendingFinalizers();

GC存在于系统命名空间中。Collect方法执行我们在上一节中讨论的标记紧凑算法。WaitForPendingFinalizers方法暂停或挂起当前线程,直到垃圾收集器完成其执行。

现在我们已经对 C#中的垃圾收集工作原理有了相当的了解,我们将探讨如何对非托管对象或非托管代码进行内存管理。

管理非托管资源

当我们处理托管对象时,.NET Framework 提供的垃圾回收器已经足够好了。然而,有几个情况下我们需要在我们的代码中使用非托管资源。以下是一些这样的实例:

  • 当我们需要使用指针访问操作系统内存时

  • 当我们进行与文件对象相关的 I/O 操作时

在这些情况下,垃圾回收器不会明确释放内存。我们需要明确管理这些资源的释放。如果我们不释放这些资源,我们可能会遇到与内存泄漏相关的应用程序问题、操作系统文件上的锁定、连接数据库等资源的连接线程泄漏等问题。

为了避免这些情况,C# 提供了终结器。终结器允许我们在垃圾回收器被调用之前在类中清理非托管代码。

请注意,当使用终结器时,我们无法控制指定的终结代码何时被调用。这取决于垃圾回收器来决定对象何时不再需要。然而,我们可以确定的是,终结代码将在对象被垃圾回收器清理之前被调用。

在类中声明终结器,我们使用 ~ 语法。以下是我们用于在 C# 中为特定类声明终结器的代码实现:

public class SampleFinalizerClass
{
     ~SampleFinalizerClass()
     {

     }
}

在前面的代码示例中,我们声明了 SampleFinalizerClass 语法。为了在类中清理非托管资源,我们声明了一个终结器。终结器的名称与类名相同,但后面附加了一个 ~

在终结器中,我们可以执行诸如销毁指针对象、释放文件连接、释放连接数据库的线程等操作。

现在,尽管使用 Finalizer 关键字可以在对象被垃圾回收器销毁之前清理非托管代码,但它确实为垃圾回收器引入了一些额外的开销。让我们通过以下示例来了解这种开销背后的原因。

终结机制

在本节中,我们将了解垃圾回收器如何在 .NET Framework 中执行终结操作。为了执行终结操作,它在系统中维护两个队列:

  • 终结器队列:终结器队列是由垃圾回收器维护的数据结构,它包含了对所有在托管堆中实现了终结方法的对象的引用。使用这个队列,垃圾回收器本质上识别出所有需要调用终结方法的对象,以便在对象本身被销毁之前清理非托管代码。

  • f 可达队列: fReachable队列是垃圾回收器维护的数据结构。它包含托管堆中所有对象的引用,尽管它们与应用程序根没有任何引用,但可以被删除。然而,在删除之前,它必须调用终结方法来清理未管理的代码。

让我们通过以下示例来尝试理解这一点。假设我们有一个应用程序,其中我们声明了一个具有终结方法的对象类A,而其他所有对象都没有终结方法。

请参考以下垃圾回收器中可能存在的不同结构的表示图:

图片

这些结构可以描述如下:

  • 程序作用域: 这代表可能存在于应用程序根作用域中的不同对象,换句话说,是在程序的特定块中被使用的。

  • 托管堆: 这代表由垃圾回收器维护的堆内存结构,用于为程序作用域中存在的对象分配内存。托管堆中有两个部分。一个是0 代,用于存放新创建的短生命周期对象,另一个是1 代,用于存放长生命周期对象。

  • 终结队列: 如前所述,这将包含托管堆中所有实现了终结方法的对象的引用。

  • f 可达队列: 如前所述,这将包含托管堆中所有对象的引用,尽管它们在程序作用域中未被使用,但在回收其内存之前,垃圾回收器需要调用终结方法。

看看以下步骤:

  1. 声明以下两个类:SampleFinalizeClassSampleNoFinalizeClass。请注意,SampleFinalizeClass类有一个终结方法:
public class SampleFinalizerClass
{
     ~SampleFinalizerClass()
     {
     }
}
public class SampleNoFinalizeClass
{
}
  1. 创建三个对象;一个用于SampleFinalizerClass,两个用于SampleNoFinalizerClass
SampleFinalizerClass b = new SampleFinalizerClass();
SampleNoFinalizeClass c = new SampleNoFinalizeClass();
SampleNoFinalizeClass d = new SampleNoFinalizeClass();

由于对象bcd是新创建的对象,它们将被添加到托管堆的 0 代。在此过程中,垃圾回收器还将认识到对象b需要在清除之前进行额外的终结方法调用。它将通过向终结队列添加对象b的引用来创建这个条目。以下图表显示了这会是什么样子:

图片

  1. 通过将对象c传递给另一个函数来传递执行权。以下是这个操作的代码片段:
GarbageCollectorFinalize(c);
// Please note that in the example cs file, these two lines will be in the different blocks of the program
static private void GarbageCollectorFinalize(SampleNoFinalizeClass a)
{
}

现在,假设在程序执行过程中,当控制位于 GarbageCollectorFinalize 函数时,垃圾收集器被调用。垃圾收集器将识别出对象 d 已不再需要,因此其内存可以被回收。然而,对象 c 仍然被引用。因此,它将假设这可能是一个长期存在的对象,并将该对象提升到第 1 代。

对于对象 b,它将识别出它现在没有被引用;然而,它确实有一个终结方法,因此不能被清理。因此,它目前将对象 b 保留在内存中。但是,它从 终结队列 中移除了条目,并在 f 可达队列 中添加了一个条目,以便稍后可以清除变量。

对象 b,由于不能像对象 c 那样从内存中移除,也将被提升到 第 1 代。以下显示了这一点:

图片

这说明了以下内容:

  • 即使对象 b 可能不再需要,它也将在内存中持续更长时间。

  • 如前例所示,垃圾收集器需要执行另一个迭代,以便从内存中清除这些对象。

  • 实现了终结器的未使用对象可能被移动到更高的代。

由于这些原因,我们强烈建议,每次我们需要声明具有终结方法的对象时,我们必须实现 IDisposable 接口。

在我们继续查看 IDisposable 接口之前,让我们看看以下代码实现,它说明了在 C# 中 Finalizer 函数是如何工作的:

  1. 考虑以下代码实现,其中我们声明一个 Finalizer 类,并向其中添加一个 Finalizer 函数:
public class Finalizer
{
     public Finalizer()
     {
         Console.WriteLine("Creating object of Finalizer");
     }
     ~Finalizer()
     {
         Console.WriteLine("Inside the finalizer of class Finalizer");
     }
 }

注意,我们在 Finalizer 类构造函数和 Finalizer 方法中都添加了文本。

  1. 使用以下代码片段创建此类的对象。此外,请注意,我们已经将对象的值设置为 null。设置 null 值表示对象在应用程序中不再需要:
Finalizer f = new Finalizer();
f = null;
Console.ReadLine(); 

注意,通过使用 Console.ReadLine() 语法,我们正在防止应用程序终止。我们这样做是为了分析程序输出的内容。当我们执行 .exe 时,我们得到以下输出:

图片

在前面的输出中,我们只从 Finalizer 类的构造函数中获取消息。尽管对象已被设置为 null,但对象 f 的终结器尚未执行。

这是因为我们无法指定垃圾收集器何时启动。现在,在 .exe 执行中按 Enter。注意,程序停止执行;然而,在终止之前,终结器被调用以回收对象 f 的内存:

图片

这证明了我们关于终结器的观点是正确的,我们之前在本节中讨论过。即使对象 f 在应用程序中不再需要,它仍然保留在托管堆内存中,直到垃圾回收器执行 Finalizer 方法。

  1. 现在,添加以下代码以隐式调用垃圾回收器,并注意终结方法立即被调用:
Finalizer f = new Finalizer();
f = null;
GC.Collect();
Console.ReadLine();

如果我们现在执行程序,我们将看到来自 Finalizer 类终结器的输出,这表明垃圾回收器立即回收了内存:

当我们调用 GC.Collect() 方法时,内部会调用所有不再需要的对象的终结器。因此我们得到消息,类 Finalizer 的终结器内部。

在前面的代码示例中,我们发现如果我们使用 Finalizer,我们可能在程序中遇到一些性能影响。虽然我们可以使用 GC.Collect() 命令隐式调用垃圾回收器,但这也可能导致程序中出现一些延迟。为了克服这些问题,C# 能够在这种情况下使用 IDisposable 接口。在下一节中,我们将了解如何实现此接口以及它如何帮助我们实现更好的性能。

IDisposable 接口

我们在前一节中检查的终结方法对系统有一些性能影响。使用 Finalizer 方法,即使对象不再需要,我们也不确定垃圾回收器何时会回收内存。这意味着有可能会发生未使用的内存比期望的时间更长地保留在托管堆中。

通过 IDisposable 接口,我们可以假设控制应用程序中非托管资源何时被回收的内存。C# 中的 IDisposable 接口只有一个方法,即 Dispose()

在这个方法中,我们可以执行与 Finalizer 方法中相同的清理非托管资源。以下是实现 IDisposable 接口的代码实现:

public class DisposeImplementation : IDisposable
{
     public DisposeImplementation()
     {
         Console.WriteLine("Creating object of DisposeImplementation");
     }
     ~DisposeImplementation()
     {
         Console.WriteLine("Inside the finalizer of class 
                            DisposeImplementation");
     }
     public void Dispose()
     {
     }
 }

注意,在前面的示例中,我们声明了一个 DisposeImplementation 类,并在该类中实现了 IDisposable 接口。

在我们实现 IDisposable 接口时,我们在同一个类中定义了一个 Dispose 函数。

通过 Dispose 方法,我们需要清理这个类中使用的所有非托管资源。虽然这种方法在资源将被回收的时间方面是可靠的,但还有一些要点我们需要理解:

  • 确保调用 Dispose 方法以回收内存是程序员的职责。

  • 如果程序员遗漏了调用 Dispose 方法,那么非托管资源可能不会被清理。

因此,作为一个好的编程实践,我们应该在任何与未托管资源相关的实现中使用FinalizeDispose方法。这将确保如果程序员遗漏了调用Dispose方法,那么Finalize方法将始终存在以回收未托管资源的内存。

此外,为了确保我们不会在FinalizeDispose中重复工作,我们可以使用以下示例中说明的方法。

对于前面实现中使用的相同类,我们将声明一个isDisposed字段。该字段的值设置为false。在Dispose方法中,我们将将其值重置为true,以指示已对非托管资源进行了清理。

现在,为了确保我们不会对资源进行第二次清理,我们将在Finalize方法中检查这个属性的值。如果Dispose属性设置为true,表示清理已经发生,那么将不会发生任何操作。如果Dispose属性设置为false,表示清理尚未发生,那么finalize将像以前一样对资源进行清理。以下是这个功能的代码实现:

public class DisposeImplementation : IDisposable
{
     private bool isDisposed = false;
     public DisposeImplementation()
     {
         Console.WriteLine("Creating object of DisposeImplementation");
     }
     ~DisposeImplementation()
     {
         if(!isDisposed)
         {
             Console.WriteLine("Inside the finalizer of class 
                                DisposeImplementation");
             this.Dispose();
         }
     }
     public void Dispose()
     {
         isDisposed = true;
         Console.WriteLine("Inside the dispose of class 
                            DisposeImplementation");
         /// Reclaim memory of unmanaged resources
     }
 }

现在,我们将以两种方式演示这些类。首先,我们将在调用GC.Collect()方法之前调用Dispose方法。

按如下方式调用Dispose方法:

DisposeImplementation d = new DisposeImplementation();
d.Dispose();
d = null;
GC.Collect();
Console.ReadLine(); 

在前面的代码中,我们在Dispose方法中将标志的值设置为true。除了设置标志外,我们还将从非托管资源中回收内存。因此,当我们调用finalize方法时,由于标志的值已经设置为truefinalize方法内部的代码块将不会执行。

以下输出结果:

现在,让我们考虑另一种场景,即程序员忘记显式调用Dispose方法。以下是这个场景的代码片段:

DisposeImplementation d = new DisposeImplementation();
//d.Dispose();
d = null;
GC.Collect();
Console.ReadLine();

在前面的代码中,我们没有调用Dispose方法,因此标志的值被设置为false。因此,当垃圾回收器在对象d上执行finalize方法时,它也会执行代码块来显式调用同一对象的Dispose方法。

以下是这个的输出结果:

我们还可以使用一个属性来在Dispose方法中抑制调用finalize方法。当我们确定不需要在finalize方法中验证资源时,我们可以使用这个属性。以下是我们可以使用来抑制调用finalize方法的语法:

public void Dispose()
{
     isDisposed = true;
     GC.SuppressFinalize(this);
     Console.WriteLine("Inside the dispose of class 
                        DisposeImplementation");
     /// Reclaim memory of unmanaged resources
}

在前面的代码块中,我们为当前对象使用了GC.SupressFinalize()。这将移除最终化队列中的引用,确保finalize方法永远不会为当前对象触发。因此,如果我们执行相同的输入,我们将得到以下输出:

使用这种模式,我们可以确保在不影响应用程序性能的情况下,从内存中释放非托管资源。

在下一节中,我们将探讨如何将using代码块作为处理实现IDisposable接口的任何类时的良好实践。

使用代码块

任何程序都难免会有错误。可能会有一些不可预见的情况,我们的编写逻辑会抛出异常。

如果我们使用非托管资源,未处理的异常可能会非常有害。它们可能导致悬空内存、文件对象未关闭的连接等问题。

例如,考虑先前的例子,我们编写了一个Dispose方法来释放内存。假设我们在调用Dispose方法之前,应用程序抛出了一个异常。在这种情况下,应用程序将永远不会有机会回收由非托管资源占用的内存。

为了避免此类情况,C# 允许我们在代码中使用using代码块。当我们使用using代码块时,无论using代码块内部发生什么,Dispose方法总是会调用。让我们通过以下代码实现来理解这一点:

using (DisposeImplementation d = new DisposeImplementation())
{

}
Console.ReadLine();
GC.Collect();
Console.ReadLine();

注意,在先前的代码块中,我们使用了相同的DisposeImplementation类,但它是放在using代码块内部的。我们没有显式地将d对象置为 null,以向垃圾回收器指示它不再需要。此外,我们也没有显式调用Dispose方法来释放非托管资源。然而,当我们运行程序时,我们得到以下输出:

using代码块会自动处理它。using代码块确保一旦控制权离开using代码块,它就会为该对象调用Dispose方法。

现在,让我们考虑一个在using代码块中发生错误的场景。为了解释,我们将手动引入一个错误,通过抛出异常。

以下是这个代码片段:

using (DisposeImplementation d = new DisposeImplementation())
{
     throw new Exception("in here");
}

如果我们执行代码,我们会得到以下结果:

现在,在代码中,我们抛出了一个未处理的异常。然而,即使如此,在应用程序由于异常而出错之前,DisposeImplementation对象的Dispose方法仍然会被调用。如果我们不使用using代码块,这种情况就不会发生。为了说明这一点,请从应用程序中移除using代码块,并抛出相同的异常。以下是这个实现的代码:

DisposeImplementation d = new DisposeImplementation();
throw new Exception("in here");

在前面的代码块中,我们移除了using语句,并在对象创建后在对象后面抛出一个未处理的异常。如果我们执行代码,我们会得到以下输出:

如前一个屏幕截图所示,在程序执行过程中,Dispose方法从未为DisposeImplementation对象调用。这说明了作为一个最佳实践,我们必须始终为实现IDisposable接口的类使用using块。

摘要

在本章中,我们学习了 C#中非托管资源的内存管理。我们复习了 C#中托管代码和非托管代码之间的区别。然后我们探讨了垃圾回收器及其工作原理。我们学习了托管堆的内存存储结构,它是程序执行期间创建的不同对象分配内存时使用的。我们学习了垃圾回收器内部使用的代数划分,这有助于提高系统的性能。我们还学习了垃圾回收器使用的标记-压缩算法。然后我们探讨了如何隐式调用垃圾回收。

在此之后,我们继续了解关于非托管对象内存管理的概念。我们学习了Finalize方法及其如何促进非托管对象的内存管理。我们学习了使用Finalize方法时的性能影响,然后我们继续了解IDisposable接口如何帮助克服其缺点。我们学习了如何在类中实现IDisposable接口,以及如何结合DisposeFinalize方法来提高系统的性能。最后,我们学习了为实现IDisposable接口的类使用using块。

在下一章中,我们将探讨 C#中反射的工作原理。

问题

  1. 垃圾回收器可以回收 C#代码中使用的非托管资源的内存。

    1. True

    2. False

  2. 以下哪个可以用来确保不调用Finalize方法?

    1. GC.Collect();

    2. GC.SupressFinalize(this);

    3. GC.WaitForPendingFinalizers();

  3. 以下哪个陈述是不正确的?

    1. Finalize可能导致性能影响,因为对象在内存中保留的时间比所需的时间更长。

    2. 0 代用于保存短生命周期的对象。

    3. 即使使用IDisposable接口,我们也不能抑制垃圾回收器执行Finalize方法时进行的调用。

    4. 使用using块确保垃圾回收器自动调用Dispose方法。

答案

  1. b

  2. b

  3. c, 使用 SupressFinalize 方法,我们能够从终结队列中移除引用,因此终结方法将不会执行。

第十章:使用反射在运行时查找、执行和创建类型

.NET 框架不仅包含代码,还包含元数据。元数据是关于程序中使用的程序集、类型、方法、属性等的描述性数据。这些程序集、属性、类型和方法是在 C# 编程语言内部定义的类。这些类、类型和方法在运行时被检索以解析开发者的应用程序逻辑以执行。属性允许我们在这些程序以及运行时可以使用的程序逻辑中添加额外的信息。

.NET 框架还允许开发者在开发期间定义此元数据信息。它可以在运行时通过反射读取。反射使我们能够创建检索到的类型的实例,并调用其方法和属性。

在本章中,我们将了解 .NET 框架如何允许我们读取和创建元数据,我们还将学习如何使用反射在运行时读取元数据并处理它。在 属性 部分,我们将专注于使用属性、创建自定义属性以及学习如何在运行时检索属性信息。反射 部分提供了如何使用反射创建类型、访问属性和调用方法的概述。反射还允许我们检索属性信息;例如,这可能是我们在执行应用程序逻辑时提供给 .NET 运行时的额外信息。

在本章中,我们将探讨以下主题:

  • 属性

  • 反射

技术要求

本章的练习可以使用 Visual Studio 2012 及更高版本以及 .NET Framework 2.0 及更高版本执行。然而,任何从 C# 7.0 及更高版本的新特性都需要您拥有 Visual Studio 2017。

如果你没有这些产品的任何许可证,你可以从 visualstudio.microsoft.com/downloads/ 下载 Visual Studio 2017 的社区版。

本章的示例代码可以在 GitHub 上找到,地址为 github.com/PacktPublishing/Programming-in-C-sharp-Exam-70-483-MCSD-Guide/tree/master/Chapter10

属性

可以使用属性将类型、方法和属性上的元数据或描述性信息关联起来。元数据指的是程序中定义的类型。例如,一个类是一个类型:每个类定义了某些属性和方法,每个属性属于一个类型,每个方法接受某些数据类型并返回某些数据类型。所有这些信息统称为元数据,可以在程序执行期间访问和检索。

就像任何其他方法一样,当你定义一个属性时,你也可以定义参数。你可以在汇编、类、方法或属性上定义一个或多个属性。根据程序要求,你可以定义应用程序需要的属性类型,并在程序中定义它们。一旦定义,你可以在程序执行时读取这些信息,然后进行处理。

在下一节中,我们将演示如何使用属性并按照我们的要求创建自定义属性。

使用属性

通过属性将信息关联到代码的一种声明性方法是。然而,只有少数属性可以用于每个类型。相反,它们用于特定类型。可以通过在要应用的类型上方使用方括号[]来指定任何类型的属性。

让我们看一下以下代码。通常,当我们想要将对象序列化为二进制或 XML 格式时,我们会看到Serializable属性。在实际应用中,当我们需要通过网络传输大型对象时,我们会将对象序列化为上述格式之一,然后发送。类上的序列化属性使运行时能够允许将对象转换为二进制或 XML 或程序所需的任何格式:

[Serializable]
public class AttributeTest
{
//object of this class can now be serialized
}

属性的另一种常见用途是在单元测试项目中。观察以下代码:

namespace Chapter10.Test
{
    [TestClass]
    public class UnitTest1
    {
        [TestMethod]
        public void TestMethod1()
        {
        }
    }
}

在前面的代码片段中,我们创建了一个新的测试项目,其中每个类和方法都添加了两个属性。通过这种方式添加它们,我们让框架知道这个类代表一个测试类,而这个方法是测试方法。

如前所述,属性的使用可以限制为特定类型。为了实现这一点,我们将使用属性目标。默认情况下,属性应用于前面的类型。但是,使用目标,我们可以设置属性是否应用于类、方法或汇编。

当目标设置为汇编时,意味着该属性应用于整个汇编。同样,目标可以设置为模块、字段、事件、方法、属性或类型。

例如,可以在字段上设置属性,让运行时知道可以接受哪种类型的输入。此外,它还可以设置在方法上,以指定它是一个普通方法还是一个 Web 方法。

框架定义的一些常见属性包括以下内容:

  • 全局:在汇编或模块级别应用的属性通常是全局属性,例如AssemblyVersionAttribute。你可能已经在使用 Visual Studio 创建的每个.NET 项目中看到过它。

让我们看一下以下示例。在这里,你可以看到使用 Visual Studio 创建任何.NET 项目时创建的assembly.cs文件。每个汇编都包含以下代码,它告诉运行时正在执行当前汇编:

using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

[assembly: AssemblyTitle("Chapter10")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("Chapter10")]
[assembly: AssemblyCopyright("Copyright © 2019")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]

[assembly: ComVisible(false)]

// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("f8a2951a-4520-4d0f-ab30-7dd609db84d5")]

[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]
  • 过时: 此属性允许我们标记不应使用的实体或类。因此,当应用时,它会产生一个在应用属性时提供的警告消息。此类定义了三个构造函数:第一个没有任何参数,第二个有一个参数,第三个有两个参数。从代码可读性的角度来看,建议我们使用带参数的构造函数,因为它们根据使用情况生成警告或错误消息。此外,在应用属性时将第二个参数设置为true将引发错误,而false将生成警告。在下面的代码中,我们将看到如何使用过时属性。

在下面的代码片段中,我们定义了一个名为Firstclass的类;后来,创建了一个名为SecondClass的新类。当我们希望新用户访问我们的库时使用第二个类而不是第一个类,那么我们可以使用带有消息的Obsolete属性,这样新用户就会看到并相应地采取行动:

[System.Obsolete(Firstclass is not used any more instead use SecondClass)]
class FirstClass
{
    //Do Firstthing
}

class SecondClass
{
    //Do Secondthing
}
  • 条件性: 当应用条件属性时,前面代码的执行依赖于属性的评估。在实际项目场景中,当在一个实时环境中运行程序时,你不想记录信息和消息并填满你的存储空间。相反,你可以在你的日志方法上设置一个条件属性,这样你就可以在配置文件中的标志设置为true时进行写入。这样,你实际上可以实施选择日志。

在下面的代码中,我们有一个LogMessage方法;然而,类上方的属性会让运行时应用程序知道,当LogErrorOn属性设置为yestrue时,它应该执行此方法:

using System; 
using System.Diagnostics;
Public class Logging
{
    [Conditional(LogErrorON)]
    public static void LogMessage(string message)
    {
        Console.WriteLine(message)
    }
}
public class TestLogging
{     
    static void Main()     
    {         
        Trace.Msg("Main method executing...");         
        Console.WriteLine("This is the last statement.");     
    } 
} 

  • 调用者信息: 调用者信息属性允许你检索调用方法的人。它们是CallerfilePathAttributeCallerLineNumberAttributeCallerMemberNameAttribute。每个都有自己的用途,正如它们的名称所暗示的那样。它们允许我们获取行号、方法名和文件路径。

创建自定义属性

C# 允许你定义自己的属性。这类似于正常的 C# 编程,其中你定义类和属性。要定义一个属性,你需要做的第一件事是从System.Attribute类继承它。你定义的类和属性用于在运行时存储和检索数据。

为了完成自定义属性的定义,你需要完成以下四个步骤:

  • 属性使用

  • 声明属性类

  • 构造函数

  • 属性

可以使用System.AttributeUsageAttribute定义属性使用。我们已提到,某些属性的使用有约束,这定义了它们可以在哪里使用——例如,在类、方法或属性中。AttributeUsageAttribte允许我们定义这样的约束。AllowMultiple指定此属性是否可以在特定类型上使用多次。定义子类的继承控件形成当前属性类。以下是使用AttributeUsage类的通用语法:

[AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = true)]

如您可能已观察到,您可以使用其构造函数在您想要定义的自定义属性上声明AttributeUsage属性,并使用三个参数。使用AtributeTargetsAll,您可以在任何类型为类、属性、方法等的元素上使用CustomAttribute。允许值的完整列表定义在docs.microsoft.com/en-us/dotnet/api/system.attributetargets?view=netframework-4.7.2#System_AttributeTargets_All

InheritedAllowMultiple都是布尔属性,它们接受 true 或 false。

一旦我们定义了AttributeUsage,我们现在可以继续声明我们的自定义类。这应该是一个公共类,并且必须继承自System.Attribute类。

现在我们已经声明了我们的类,我们可以继续定义我们的构造函数和属性。框架允许我们定义一个或多个构造函数,覆盖所有可能的属性组合场景。让我们定义一个自定义属性。这些属性的构造函数接受三个参数——AttributeTargetsAllowMultipleInherited

using System;

namespace Chapter10
{
    [System.AttributeUsage(System.AttributeTargets.Field | System.AttributeTargets.Property, Inherited =false,AllowMultiple = false)]
    public class CustomerAttribute : Attribute
    {
        public CustomerType Type { get; set; }

        public CustomerAttribute()
        {
            Type = CustomerType.Customer;
        }
    }

    public enum CustomerType
    {
        Customer,
        Supplier,
        Vendor
    }
}

上述代码定义了一个名为CustomerAttribute的自定义属性。我们还定义了一个CustomerType枚举,我们希望将其用作Attribute属性。通过在构造函数中不定义任何参数并将Customer类型分配给Type属性,我们告诉运行时,默认情况下,当其值是客户时。此外,此属性被设置为可以在字段或属性上使用,因此不能在类级别上使用。

现在,让我们看看我们如何在我们的类中使用这个属性:

namespace Chapter10
{

    internal class Account
    {
        public string CustomerName { get; set; }

        [Customer]
        public RatingType Rating { get; set; }
    }

    public enum RatingType
    {
        Gold =1,
        Silver =2,
        Bronze=3
    }
}

在这里,我们定义了一个Account类,在其中我们使用了我们的自定义属性。我们应用了一个不带任何参数的属性。这意味着,默认情况下,我们创建了一个客户类型的账户。在下一节中,我们将演示我们如何检索这些属性并在我们的应用程序逻辑中使用它们。

获取元数据

如您所知,获取属性信息与创建我们想要检索的属性的实例并调用System.Attribute类的GetCustomAttribute方法一样简单。

在以下示例中,我们定义了一个名为ChapterInfo的新属性,并定义了一个构造函数来标记其两个属性为必需参数:

[System.AttributeUsage(System.AttributeTargets.Class, Inherited =false,AllowMultiple = false)]
    public class ChapterInfoAttribute : Attribute
    {
        public string ChapterName{ get; set; }
        public string ChapterAuthor { get; set; }

        public ChapterInfoAttribute(string Name, string Author)
        {
            ChapterName = Name;
            ChapterAuthor = Author;
        }
    }

ChapterNameChapterAuthor是开发者在使用此属性时必须定义的两个必需参数。

正如您所看到的,在下面的代码中,属性是在Program类上定义的,有两个值:NameAuthor。在主方法中,调用了GetCustomAttribute来读取其属性,就像您对任何其他类类型变量所做的那样:

namespace Chapter10
{
    [ChapterInfo("SAMPLECHAPTER", "AUTHOR1")]
    class Program
    {
        static void Main(string[] args)
        {
            ChapterInfoAttribute _attribute = (ChapterInfoAttribute)Attribute.GetCustomAttribute(typeof(Program), typeof(ChapterInfoAttribute));
            Console.WriteLine($"Chapter Name is: {_attribute.ChapterName} and Chapter Author is: {_attribute.ChapterAuthor}");
            // Keep the console window open in debug mode.
            System.Console.WriteLine("Press any key to exit.");
            System.Console.ReadKey();
        }
    }
}

观察以下输出:

//Output
Chapter Name is: SAMPLECHAPTER and Chapter Author is: AUTHOR1
Press any key to exit.

正如您所看到的,在程序类属性定义中传递的([ChapterInfo("SAMPLECHAPTER", "AUTHOR1")])值被检索并显示出来。

反射

反射是从应用程序程序在运行时查询元数据的一种方式。反射提供了从加载到内存的程序集中获取的类型信息,您可以使用这些信息创建类的实例,并访问类的属性和方法。

例如,您的应用程序代码执行一个查询并返回一个数据集对象,但您的前端接受一个自定义类或模型,并且该模型在运行时定义。根据接收到的请求,可以使用反射在运行时创建所需的模型/类,通过遍历结果数据集来访问其属性或字段,并设置它们的值。

此外,在先前的章节中,我们学习了如何创建自定义属性。因此,在创建一个用于限制特定属性中数字的属性的属性的情况下,您可以使用反射来读取属性,获取前面的属性,实现应用逻辑以限制数字,或向用户显示消息。

我们还可以使用反射在运行时创建一个类型,并访问其方法和属性。反射与System.Types一起工作,以查询当前加载到内存并正在执行的程序集的信息。

公共语言运行时CLR)管理具有相同作用域的对象的应用程序域。此过程包括将这些程序集加载到这些域中,并根据需要控制它们。

在.NET 世界中,程序集包含模块,模块包含类型,类型包含成员。程序集类用于加载程序集。模块用于标识程序集中类的信息以及全局和非全局方法。

Reflection类中提供了许多方法,例如MethodInfoPropertyInfoTypeCustomAttribute等。这些方法帮助开发者获取运行时信息。在先前的示例中,我们使用了GetCustomAttribute方法来检索属性信息并显示它。

调用方法和使用属性

在本节中,我们将探讨如何使用反射在运行时访问自定义类的属性和方法。

此示例旨在向您展示我们如何在运行时使用反射访问方法和属性。然而,根据您的需求,您可以动态地访问属性、它们的类型和方法以及它们的参数。

我们创建了一个新的自定义类,在其中我们定义了两个整数类型的属性:Number1Number2。然后,我们定义了一个接受参数并返回要加或减的数字的公共方法:

internal class CustomClass1
    {
        public int Number1 { get; set; }
        public int Number2 { get; set; }

        public int Getresult(string action)
        {
            int result = 0;
            switch (action)
            {
                case "Add":
                    result = Number1 + Number2;
                    Console.WriteLine($"Sum of numbers {Number1} and {Number2} is : {result}");
                    break;

                case "Subtract":
                    result = Number1 - Number2;
                    Console.WriteLine($"Difference of numbers {Number1} and {Number2} is : {result}");
                    break;
            }
            return result;
        }
    }

然后,我们创建了一个简单的方法,通过这个方法我们可以访问我们之前创建的自定义类的属性和方法。在第一行,我们检索了自定义类的类型信息。使用这个类型,我们通过Activator.CreateInstance方法创建了一个类的实例。现在,使用我们检索到的类型的GetProperties方法,我们访问了所有的属性,并根据属性名称为每个属性设置了一个值。

在下一行,使用对象的Type信息,我们通过GetMethod方法检索MethodInfo。然后,我们通过两个不同的操作AddSubtract调用了自定义类的public方法两次:

public static void GetResults()
        {
            Type objType = typeof(CustomClass1);
            object obj = Activator.CreateInstance(objType);
            foreach (PropertyInfo prop in objType.GetProperties())
            {
                if(prop.Name =="Number1")
                    prop.SetValue(obj, 100);
                if (prop.Name == "Number2")
                    prop.SetValue(obj, 50);
            }

            MethodInfo mInfo = objType.GetMethod("Getresult");
            mInfo.Invoke(obj, new string[] { "Add" });
            mInfo.Invoke(obj, new string[] { "Subtract" });
        }

如果你运行程序并逐行调试,你会看到每个属性都被检索了,并且已经设置了值。以下是程序的输出:

Sum of numbers 100 and 50 is : 150
Difference of numbers 100 and 50 is : 50
Press any key to exit.

这个示例很简单,因为我们创建了两个整数类型的属性。然而,在实际应用中,这样的简单场景可能并不存在。因此,在运行时,你需要使用GetType方法来理解获取到的属性类型。

此外,在示例中,我们能够获取到我们硬编码的Custom类的类型。使用泛型,我们甚至可以在运行时传递类并获取类型信息。

摘要

在这一章中,我们学习了如何使用系统属性,创建自定义属性,检索属性,然后在我们的应用程序逻辑中使用它们。通过反射检索属性信息,我们还探讨了如何创建类型,访问属性,以及调用方法。

在下一章中,我们将了解为什么验证应用程序输入很重要,流入我们应用程序的信息类型,以及我们如何处理它们。

问题

  1. 在创建自定义属性时,是否可以设置一个目标来限制属性的用法?

    1. True

    2. False

  2. _______ 是用于检索属性信息的方法。

    1. GetAttributeValue

    2. GetCustomAttribute

    3. GetMetadata

    4. GetAttributeMetadata

  3. 系统允许你从对象中检索属性信息吗?

    1. True

    2. False

答案

    1. True

    2. GetAttributeValue

    3. True

第十一章:验证应用程序输入

当在实际项目中工作时,可能会出现不同类型的用户访问您的应用程序并向其中输入信息的情况。如果场景中的任何方面处理不当,或者任何输入数据没有被正确解析,这可能会导致您的应用程序崩溃或导致您的应用程序数据损坏。尽管您在生产环境中验证了应用程序中使用的和访问的所有输入数据,但输入数据可能会与其他外部应用程序交互,这可能会使您的应用程序处于危险之中。

本章的目的是理解在您的应用程序中验证输入数据的重要性。.NET Framework 中提供了不同的验证技术来验证 JSON 数据和 XML 数据。

在接下来的章节中,我们将关注验证输入数据的重要性,如何管理数据完整性,如何使用框架提供的解析语句和正则表达式,以及如何验证 JSON 和 XML 数据。阅读本章后,您将能够创建用于验证传入数据的应用程序逻辑,并处理可能发生的异常场景。

在本章中,我们将涵盖以下主题:

  • 验证输入数据的重要性

  • 数据完整性

  • 解析和转换

  • 正则表达式

  • JSON 和 XML

技术要求

本章的练习可以使用 Visual Studio 2012 或更高版本以及 .NET Framework 2.0 或更高版本来实现。然而,任何从 C# 7.0 及以上版本的新特性都需要您拥有 Visual Studio 2017。

如果您没有任何产品的许可证,您可以从 visualstudio.microsoft.com/downloads/ 下载 Visual Studio 2017 的社区版。

本章的示例代码可以在 GitHub 上找到,链接为 github.com/PacktPublishing/Programming-in-C-Exam-70-483-MCSD-Guide/tree/master/Book70483Samples/Chapter%2011

验证输入数据的重要性

在独立模式下创建和运行应用程序可以使您的应用程序无任何问题地运行。然而,当在实际项目中工作时,您的应用程序将在一个可能存在许多外部接口交互的环境中执行。在这种情况下,您的应用程序能否处理这种通信?它能否处理来自这些外部应用程序的所有类型的数据?将会有许多用户尝试使用您的系统;有些人可能会正确使用它,而其他人可能会试图破坏您的系统。您的应用程序能否容忍这种交互?

这两种类型的用户都可能存在问题。那些正确使用您的系统的人可能会因为输入错误数据或忘记提供必要的数据而犯错误。如果您的应用程序的逻辑基于用户的出生日期,而用户输入了一些文本数据,那么您的应用程序可能会抛出异常并崩溃。

在用户尝试通过提供与应用程序期望的类型不匹配的数据来破坏您的应用程序的情况下,这可能会导致您的应用程序崩溃,并且可能会花费大量时间来恢复它。

上述任何操作都可能导致您的应用程序暂时受损或构成重大问题。当它破坏您的数据库时,恢复您的应用程序可能需要更多的时间和精力。

使用.NET Framework 创建应用程序涉及提供一些内置功能,这些功能可以用来验证一些输入数据,无论是来自内部用户还是外部用户或外部应用程序。框架允许您为每个属性添加属性,以便为您验证数据。这些在您使用 ASP.NET 或 Entity Framework 等时可用。正如您在前几章中学到的,您可以定义自定义属性并对用户输入的数据进行验证。

在下一节中,我们将看到各种数据完整性场景,这些场景在您在应用程序中进行数据验证时非常重要。

数据完整性

在开发任何应用程序时,非常重要的一点是设计它以便它可以处理所有场景,或者至少向用户提供友好的错误信息。我们已经在前一章中学习了异常处理,即第七章,实现异常处理,这在这些场景中非常有用。

在处理数据库或分布式应用程序时,数据完整性起着至关重要的作用。

数据完整性在不同场景中的应用方式不同:

  • 例如,如果您正在创建一个应用程序并将用户信息存储在表中,您可能采用的一个原则是不要在表中维护重复的用户,以便它们可以唯一识别。这被称为实体完整性

  • 在收集人口统计信息的情况下,您可能允许在特定字段中输入某些值或值范围。这被称为域完整性。换句话说,您正在确保每个记录/实体中输入的数据是有效的。

  • 可能会有这样的情况,您必须将数据输入到具有父子关系的多个表中。在这种情况下,您的应用程序应在将信息保存到数据库时维护这些关系。这被称为引用完整性

  • 最后但同样重要的是,在业务场景中,为了根据业务流程实现预期的结果,您的应用程序可能需要强制实施某些约束。这被称为用户定义的完整性或业务定义的完整性。

有许多现实世界的例子。这包括任何电子商务应用程序或任何银行应用程序。验证和控制输入以及程序流的重要性有多大?在银行应用程序中,如果发生停电,会发生什么?在电子商务应用程序中,当用户关闭浏览器或在清理作业启动时,购物车将如何维护?

许多这些数据完整性选项在最新的数据库和框架中可用,这使得我们能够利用这些选项来验证和控制我们程序的流程。

验证数据的一种方法是通过使用数据注释程序集,该程序集在.NET Framework 3.5 及以上版本中可用。数据注释涉及在类中添加有关属性或属性的信息。你可以通过引用System.ComponentModel.DataAnnotations命名空间来使用数据注释。这些数据注释分为三类:

  • 验证属性

  • 显示属性

  • 建模属性

每个这些属性都用于特定的目的:验证属性强制执行数据的验证;显示属性用于用户界面上的显示标签,而建模属性表示相关属性的推荐使用方式。

在以下类中,我们将引用System.ComponentModel.DataAnnotations并使用验证属性、显示属性和建模属性在三个可用的属性上:

using System;
using System.ComponentModel.DataAnnotations;

namespace Chapter11
{
    public class Student
    {
        [Required(ErrorMessage = "Fullname of the student is 
         mandatory")]
        [StringLength(100,MinimumLength =5,ErrorMessage ="Name should 
         have minimum of 5 characters")]
        [DataType(DataType.Text)]
        public string FullName { get; set; }

        [DataType(DataType.EmailAddress)]
        [EmailAddress]
        public string EmailAddress { get; set; }

        [DataType(DataType.Date)]
       [Display(Name ="Date of Birth")]
        public DateTime DOB { get; set; }
    }
}

在名称属性上,我们有一个必填字段属性和字符串长度限制作为验证属性。设置为文本的数据类型是一个数据建模属性,它告诉系统名称属性只接受文本值。在DOB属性上,我们有一个显示属性。然而,显示属性可以在 ASP.NET 应用程序或 WPF 应用程序中使用。

现在,我们创建一个Student类的实例并尝试验证其数据。数据注释帮助我们定义ValidationContext;当一个对象被验证时,将返回ValidationResult,它包含所有属性及其相应的错误消息。在定义Student类中的属性时,我们添加了带有消息的属性。当ValidationContext返回结果时,它返回每个这些属性及其相应的属性和消息:

Student st = new Student();
st.FullName = "st1";
st.EmailAddress = "st@st";
st.DOB = DateTime.Now;

ValidationContext context = new ValidationContext(st, null, null);
List<ValidationResult> results = new List<ValidationResult>();
bool valid = Validator.TryValidateObject(st, context, results, true);
if (!valid)
{
    foreach (ValidationResult vr in results)
    {
        Console.Write("Student class Property Name :{0}", 
         vr.MemberNames.First());
        Console.Write(" :: {0}{1}", vr.ErrorMessage, 
         Environment.NewLine);
    }
}

当你创建一个ValidationContext实例时,我们使用接受三个参数的构造函数。这些参数如下:

  • 我们想要验证的对象的实例

  • 实现了IServiceProvider接口的对象,这意味着你需要使用GetService方法创建一个实例

  • 一个键/值对的字典以供消费

此外,在尝试验证一个对象时,我们将true作为最后一个参数传递,这代表验证对象的所有属性。

当您执行程序时,您应该看到以下输出。学生的姓名应至少有五个字符,电子邮件地址应格式正确:

Student class Property Name :FullName :: Name should have minimum of 5 characters
Student class Property Name :EmailAddress :: The EmailAddress field is not a valid e-mail address.
Press any key to exit.

在下一节中,我们将探讨 C#中可用于验证我们数据的各种功能。

解析和转换

实体完整性和域完整性涉及允许有效值进入我们的应用程序以进行进一步处理。有效值包括操纵或管理用户提供的输入,将其呈现为应用程序可接受的数据。这个过程可能包括将特定类型的数据解析为应用程序接受的数据类型,转换数据类型等。

ParseTryParse是.NET 框架中多个数据类型中可用的两个语句,例如如果您正在编写控制台应用程序,并且希望将参数作为命令行参数接受。在控制台应用程序中,命令行参数始终是字符串类型。那么,您如何将这些参数从字符串类型解析为所需的另一种类型?

在下面的例子中,我们知道我们的第一个参数是一个布尔值,但它被传递为一个字符串。当我们确定传递的值时,我们可以使用解析方法将字符串转换为布尔值。Parse将字符串与静态字符串值进行比较,并返回truefalse。当传递无效数据时,会抛出异常——输入字符串格式无效

让我们从例子开始。定义两个方法,每个方法都接受一个字符串类型的参数。我们希望将其解析为布尔值和整数值。解析布尔值就像使用布尔类型的解析方法一样简单。然而,对于整型,有两种方法。一种方法是使用解析,就像我们在解析布尔值时做的那样,另一种方法是TryParse。当我们不确定提供的字符串参数是否为整数时,我们可以使用TryParse方法,这样它就会给我们一个bool结果,我们可以根据这个结果设置我们的逻辑。在下面的例子中,我们展示了两种方法。这将使我们能够处理异常,并向用户提供有意义的消息:

internal class ParseSamples
    {
        internal void ProcessBool(string boolValue)
        {
            if (bool.Parse(boolValue))
            {
                Console.WriteLine($"Parsed bool value is : 
                 {bool.Parse(boolValue)}");
            }
        }

        internal void ProcessInteger(string intValue)
        {

            int processedValue =int.MinValue;
            if (int.TryParse(intValue, out processedValue))
            {
                Console.WriteLine($"Parsed int value is : 
                 {processedValue}");
            }
            else
            {
                Console.WriteLine("Parsed value is not an integer");
            }
            Console.WriteLine($"Parsed int value is : 
             {int.Parse(intValue)}");

现在我们已经准备好了示例类,让我们使用我们的main方法来调用它。在这里,我们有一个switch语句来检查传递给main方法的参数长度。如果是1,则调用processbool方法;如果是2,则调用两个方法,否则将显示一条消息:

 static void Main(string[] args)
        {
            ParseSamples ps = new ParseSamples();
            switch (args.Length)
            {
                case 1:
                    ps.ProcessBools(args[0]);
                    break;
                case 2:
                    ps.ProcessBools(args[0]);
                    ps.ProcessIntegers(args[1]);
                    break;
                default:
                    Console.WriteLine("Please provide one or two 
                     command line arguments");
                    break;
            }

            // Keep the console window open in debug mode.
            System.Console.WriteLine("Press any key to exit.");
            System.Console.ReadKey();
        }

要调用此方法,因为我们正在尝试在我们的程序中读取命令行参数,这些参数需要在运行时或从属性窗口传递,然后将在运行时读取。参数的传递方式如下。右键单击项目,选择属性,然后导航到调试选项卡,在那里您可以设置这些参数:

图片

当您运行程序时,如果您传递12个参数,相应的 case 语句将被执行,输出将显示在屏幕上:

//Command line argument true
Parsed bool value is : True
Press any key to exit.

//Command line argument true 11
Parsed bool value is : True
Parsed int value is : 11
Parsed int value is : 11
Press any key to exit.

//Command line arguments true Madhav
Parsed bool value is : True
Parsed value is not an integer 

在这里,在最后一个输出中,处理了TryParse语句,但Parse会抛出以下错误。因为Parse期望传递一个合适的字符串,当传递非字符串值或当您的语句与传递的值不对应时,它会抛出错误。然而,如果我们使用try..catch处理这个语句,我们不会看到任何问题。否则,您的程序将崩溃,并出现以下异常对话框:

图片

验证输入的另一种方法是使用转换方法。Convert是在.NET Framework 中定义的一个方法,它将基本类型转换为另一个基本类型。与Parse不同,Convert接受一个对象类型并将其转换为另一个类型。Parse只接受字符串输入。此外,当传递null值时,Convert返回目标类型的最小值。Convert类有几个静态方法,支持.NET Framework 中不同类型之间的转换。Convert方法支持的类型有BooleanCharSByteByteInt16Int32Int64UInt16UInt32UInt64SingleDoubleDecimalDateTimeString

当您应用Convert方法时,可以预期以下任何一种输出。系统要么成功地将源类型转换为目标类型,要么抛出以下异常之一:FormatExceptionInvalidCastExceptionArgumentNull。让我们看一个例子:

internal void ConvertSample()
{
    try
    {
        string svalue =string.Empty; 
        Console.WriteLine(Convert.ToInt32(svalue));
    }
    catch (FormatException fx)
    {
        Console.WriteLine("Format Exception : "+fx.Message);
    }
    try
    {
        double dvalue = 1212121212121212.12;
        Console.WriteLine(Convert.ToInt32(dvalue));
    }
    catch (OverflowException ox)
    {
        Console.WriteLine("OverFlow Exception : " + ox.Message);
    }
    try
    {
        DateTime date= DateTime.Now;
        Console.WriteLine(Convert.ToDouble(date));
    }
    catch (InvalidCastException ix)
    {
        Console.WriteLine("Invalid cast Exception : " + ix.Message);
    }
    double dvalue1 = 12.22;
    Console.WriteLine("Converted Value : " + Convert.ToInt32(dvalue1));
}

在前面的示例中,我们尝试了不同类型的转换。需要注意的是,在转换过程中可能会得到任何输出,因此您必须在应用程序代码中相应地处理它。此外,在将十进制或浮点值转换为整数时,会丢失精确信息。然而,不会抛出异常。

在相同类型的转换中,不会抛出任何异常或进行转换。当尝试将字符串转换为任何其他类型时,会抛出FormatExceptionStringBooleanStringCharStringDateTime可能会抛出此异常。

当特定类型之间的转换无效时,会发生InvalidCastException,如下面的示例所示:

  • Char转换为BooleanSingleDoubleDecimalDateTime

  • BooleanSingleDoubleDecimalDateTime转换为Char

  • DateTime转换为除String以外的任何类型

  • 从除String以外的任何类型转换为DateTime

在数据丢失的情况下会抛出OverflowException,例如,当将一个巨大的十进制数转换为整数时,如我们的示例所示。在我们的示例中,我们将一个双精度值转换为int值。C#中的int类型变量有一个最小值和最大值。如果传递的数字超出了这个范围,则会引发溢出异常:

Format Exception : Input string was not in a correct format.
OverFlow Exception : Value was either too large or too small for an Int32.
Invalid cast Exception : Invalid cast from 'DateTime' to 'Double'.
Converted Value : 12
Press any key to exit.

正则表达式

当谈论验证输入数据时,了解正则表达式非常重要,这是一种强大的文本处理方式。它采用模式匹配技术来识别输入文本中的文本模式,并将其验证到所需格式。例如,如果我们的应用程序想要验证电子邮件,可以使用正则表达式来识别提供的电子邮件地址是否为有效格式。它检查 .com@ 和其他模式,并在匹配所需模式时返回。

System.Text.RegularExpressions.Regex 在 .NET Framework 中充当正则表达式引擎。要使用此引擎,我们需要传递两个参数,第一个是要匹配的模式,第二个是模式匹配发生的文本。

Regex 类提供了四种不同的方法——IsMatchMatchMatchesReplaceIsMatch 方法用于在输入文本中识别模式。MatchMatches 方法用于获取与模式匹配的所有文本的实例。Replace 方法用于替换与正则表达式模式匹配的文本。

现在,让我们通过一些示例来了解正则表达式:

public void ReplacePatternText()
{
    string pattern = "(FIRSTNAME\\.? |LASTNAME\\.?)";

    string[] names = { "FIRSTNAME. MOHAN", "LASTNAME. KARTHIK" };
    foreach(string str in names)
    {
        Console.WriteLine(Regex.Replace(str, pattern, String.Empty));
    }

}

public void MatchPatternText()
{
    string pattern = "(Madhav\\.?)";

    string names = "Srinivas Madhav. and Madhav. Gorthi are same";
    MatchCollection matColl = Regex.Matches(names, pattern);
    foreach (Match m in matColl)
    {
        Console.WriteLine(m);
    }
}

public void IsMatchPattern()
{
    string pattern = @"^c\w+";

    string str = "this sample is done as part of chapter 11";
    string[] items = str.Split(' ');
    foreach (string s in items)
    {
        if (Regex.IsMatch(s, pattern))
        {
            Console.WriteLine("chapter exists in string str");
        }
    }
}

ReplacePatternTest 方法从一个字符串数组中识别 FirstNameLastName 并将它们替换为空字符串。在 MatchPatternText 方法中,我们识别字符串中 Madhav 出现的次数;在第三个方法中,我们使用模式来识别章节词。^c\w+ 模式表示单词的开始,c 表示以 c 开头的单词,\w 表示任何字符,+ 表示与前面的标记匹配。

以下输出显示了 ReplacePatternTest 方法的输出前两行,其中我们将 Madhav 替换为空字符串。第二个输出集识别一个模式并显示它。第三个集是识别字符串中的章节词:

//ReplacePatternText method
MOHAN
 KARTHIK

//MatchPatternText method
Madhav.
Madhav.

//IsMatchPattern method
chapter exists in string str
Press any key to exit.

JSON 和 XML

随着互联网和云应用的广泛使用,JSON 和 XML 在应用程序之间的数据传输方面变得越来越重要。使用 JSON 和 XML 也增加了与数据相关的问题数量,除非数据得到验证。

模式验证可以用来验证 XML 文件,这将帮助我们确定 XML 是否与定义的数据类型一致。然而,为了验证实际数据,你可能仍然会使用本章中讨论的方法。Visual Studio 帮助你创建模式文件。Xsd.exe <XML file> 命令将创建一个模式文件。以下是一个示例 XML 文件。

此 XML 文件有一个 Students 根元素,其中包含与多个学生相关的信息。每个 student 元素都有子元素,包含 FirstNameLastNameSchoolDOB 等值:

<?xml version="1.0" encoding="utf-8" ?>
<Students>
  <student>
    <FirstName>Student1</FirstName>
    <LastName>Slast</LastName>
    <School>School1</School>
    <DOB>23/10/1988</DOB>
  </student>
</Students>

Visual Studio 允许我们为这个 XML 创建一个模式。在 Visual Studio 中打开 XML 文件并选择 XML 菜单项。将出现创建模式选项。选择此选项将创建一个 .xsd 模式:

Sample.xsd文件的内容如下:

<?xml version="1.0" encoding="utf-8"?>
<xs:schema attributeFormDefault="unqualified" elementFormDefault="qualified" >
  <xs:element name="Students">
    <xs:complexType>
      <xs:sequence>
        <xs:element name="student">
          <xs:complexType>
            <xs:sequence>
              <xs:element name="FirstName" type="xs:string" />
              <xs:element name="LastName" type="xs:string" />
              <xs:element name="School" type="xs:string" />
              <xs:element name="DOB" type="xs:string" />
            </xs:sequence>
          </xs:complexType>
        </xs:element>
      </xs:sequence>
    </xs:complexType>
  </xs:element>
</xs:schema>

如您所见,名称被定义为字符串,日期也是如此。因此,当您访问这个日期元素时,我们可能需要将其转换以便在应用程序中使用。

现在,我们将跳转到一些示例代码,观察如何使用模式验证 XML 文件:

static void LoadXML()
{
    var path = new Uri(Path.GetDirectoryName(System.
     Reflection.Assembly.
     GetExecutingAssembly().CodeBase)).LocalPath;
    XmlSchemaSet schema = new XmlSchemaSet();
    schema.Add("", path + "\\sample.xsd");
    XmlReader rd = XmlReader.Create(path + "\\sample.xml");
    XDocument doc = XDocument.Load(rd);
    Console.WriteLine("Validating XML");
    doc.Validate(schema, ValidationEventHandler);
    Console.WriteLine("Validating XML Completed");
}
static void ValidationEventHandler(object sender, 
 ValidationEventArgs e)
{
    XmlSeverityType type;
    if (Enum.TryParse<XmlSeverityType>(e.Severity.ToString(), out 
     type))
    {
        if (type == XmlSeverityType.Error) throw new 
         Exception(e.Message);
    }
}

由于我们传递了一个有效的 XML 文件,所以在验证它时我们没有遇到任何问题。然而,当您尝试从其中删除任何元素时,例如从 XML 文件中删除学校,那么您会遇到错误消息。当您练习这个实验时,请自己尝试,以便更详细地了解验证:

Validating XML
Validating XML Completed
Press any key to exit.

当执行时,此方法要么在控制台上写入一条消息,表明验证已完成,要么在 XML 出现错误时抛出异常。

我们讨论的另一种格式是 JSON。.NET Framework 为我们提供了 JSON 序列化器,可以用来验证 JSON。这就像创建一个 C#类,使用 JSON 序列化器将 C#对象转换为 JSON,然后再将其反序列化回 C#对象。它与.NET Framework 序列化概念类似。然而,并非所有的 JSON 都有用于序列化或反序列化的模式。在这种情况下,我们将验证 JSON 格式。在下面的示例中,我们创建了一个类序列化器来转换 JSON 对象,然后将其反序列化回对象。

在这里,我们创建了一个名为Authors的类,具有三个属性:AuthorNameSkillsDOB。我们将使用此对象来序列化和反序列化此对象:

public class Authors
{
   public string AuthorName { get; set; }
    public string Skills { get; set; }
    public DateTime DOB { get; set; }
}

在下一节中,我们创建了一种新方法,其中我们使用了Newtonsoft.Json命名空间将Authors对象转换为 JSON。您可以使用 NuGet 包来获取NewtonSoft.Json

static string GetJson()
{
    string result = string.Empty;
    Authors aclass = new Authors() { AuthorName = "Author1", Skills = 
     "C#,Java,SQL", DOB = DateTime.Now.Date };
    result = JsonConvert.SerializeObject(aclass);
    Console.WriteLine($"JSON object : {result}");
    return result;
}

接下来,我们将使用JSON.Deserialize方法将 JSON 转换为Authors对象:

static Authors GetObject(string result)
{
    Authors aresult = JsonConvert.DeserializeObject<Authors>(result);
    Console.WriteLine($"Name: {aresult.AuthorName}, Skills = 
     {aresult.Skills}, 
    DOB = {aresult.DOB}");
    return aresult;
}

下面是调用这两个方法的程序。最初,我们调用GetJSON方法来获取Json字符串,然后使用这个字符串通过GetObject方法将其转换为Authors对象。在第二行,我们修改了第一行中得到的字符串结果,并尝试对其进行反序列化。这个操作将抛出异常。

在以下内容中,我们尝试通过连接名为Test的文本来修改.json结果。这是您修改.json对象并尝试将其反序列化为Authors对象时发生的情况:

string result = GetJson();
Authors a = GetObject(result);
string result1 = string.Concat(result, "Test");
Console.ReadLine();
Authors a1 = GetObject(result1);

下面的输出显示了我们将Authors对象转换为的JSON对象,随后是我们在JSON对象中反序列化的Author对象:

JSON object : {"AuthorName":"Author1","Skills":"C#,Java,SQL","DOB":"2019-03-31T00:00:00+11:00"}
Name: Author1, Skills = C#,Java,SQL, DOB = 3/31/2019 12:00:00 AM
Press any key to exit.

这是当我们在修改JSON并尝试将其反序列化为Authors对象时程序抛出的异常:

这是一个尝试验证JSON对象的示例。如果在传输过程中被修改,这个修改可以在反序列化过程中被识别出来。

摘要

在本章中,我们了解了验证输入数据的重要性;在我们的应用程序中验证输入数据的不同方法,包括 ParseConvert 方法;以及我们如何使用正则表达式和数据注释命名空间。我们还简要地探讨了如何验证 XML 和 JSON 输入。

在下一章中,我们将探讨使用 .NET Framework 中可用的加密技术来保护我们的数据,例如电子邮件、密码和 API 密钥的方法。

问题

  1. Parse 方法始终接受 __ 类型作为输入

    1. 任何有效的 .NET 类型

    2. 对象

    3. 字符串

  2. 当将 DateTime 转换为 Double 时,会抛出哪种异常?

    1. 不会抛出异常;相反,它会被成功转换。

    2. 抛出格式异常。

    3. 抛出溢出异常。

    4. 抛出无效转换异常。

  3. 可以使用 ___________ 命名空间提供关于对象成员的信息。

    1. DataContract

    2. 数据注释

    3. System.Reflection

    4. System.XML

答案

  1. 字符串

  2. 抛出 无效转换异常

  3. 数据注释

第十二章:执行对称和不对称加密

在开发分布式应用程序时,保持信息的安全非常重要,特别是在电子商务应用程序的情况下,用户数据,如您的个人和信用卡相关信息,被收集并通过互联网传输。密码学使我们能够加密和解密明文。简单来说,假设我们的应用程序中有明文,可以通过向文本中的每个字符添加一个静态值来转换它,从而使它变得不可读。这个过程称为加密。相反,解密是将这种不可读文本转换回可读文本的过程。

当您加密文本时,它看起来像随机的字节,被称为密文

阅读本章后,您将能够理解如何加密和解密文本,执行这些加密和解密操作的不同算法,以及.NET Framework 在将它们应用于实际项目方面提供的选项。

本章将涵盖以下主题:

  • 密码学

  • 对称加密

  • 非对称加密

  • 数字签名

  • 哈希值

技术要求

本章的练习可以使用支持.NET Framework 2.0 或更高版本的 Visual Studio 2012 或更高版本进行练习。然而,任何从 C# 7.0 及更高版本开始的新 C#功能都需要 Visual Studio 2017。

如果您没有任何产品的许可证,您可以从visualstudio.microsoft.com/downloads/下载 Visual Studio 2017 的社区版本。

本章的示例代码可以在 GitHub 上找到,地址为github.com/PacktPublishing/Programming-in-C-sharp-Exam-70-483-MCSD-Guide/tree/master/Book70483Samples/Chapter12

密码学

当您与涉及创建和管理可通过互联网访问的 Web 应用程序的公共网络一起工作时,您的应用程序面临被未经授权的第三方拦截和修改的高风险。密码学使我们能够保护数据免受此类未经授权的第三方查看或修改。密码学还提供了保护我们的数据并帮助在网络上安全传输数据的方法。要执行此类操作,我们可以使用加密算法在传输之前创建密文。当被未经授权的第三方拦截时,他们很难解密以读取或修改这些数据。

要执行此类操作,.NET 框架提供了System.Secure.Cryptography命名空间,其中包含许多算法,包括以下内容:

  • 秘密密钥加密

  • 公钥加密

  • 数字签名

  • 哈希值

让我们来看一个关于加密可以用于哪些方面的例子。假设,作为一个客户,我正在尝试通过互联网订购一台笔记本电脑。为此,我正在与公司的代表聊天。一旦我对报价、提供的折扣和下订单的条款和条件感到满意,我然后需要通过这个渠道提供个人信息和信用卡信息。

那么,我们如何确保以下方面的内容呢?

  • 对任何窃听我们对话的人来说,这些信息是不清晰的

  • 信息传输没有未经授权的访问

  • 收到的信息来自公司的代表

所有这些都可以通过实现加密算法来实现。这些算法促进了保密性、数据完整性、身份验证和非否认性。

保密性保护用户的身份,数据完整性保护数据免受更改,身份验证确保数据来自已验证的实体,非否认性防止任何一方否认发送了消息。

.NET 框架提供了不同的算法,如前所述。尽管这些很多,但我们将限制本章的讨论到四个主要算法。

密钥加密,也称为对称加密,使用单个共享密钥来加密和解密数据。然而,在这种情况下,保护秘密信息免受未经授权的访问非常重要,因为任何拥有这个密钥的人都可以访问数据并滥用它。因为它使用相同的密钥进行加密和解密,所以这更快,适合大量数据。有不同类型的算法可用,例如DES(代表数据加密标准),三重 DES 和AES(代表高级加密标准)。这些算法同时加密数据块,因此它们也被称为分组密码。DES 和三重 DES 使用 8 字节作为块,而 AES 使用 16 字节作为块,但也支持 24 和 32 字节。

公钥加密,也称为非对称加密,使用公钥/私钥来加密和解密数据。在这两个密钥中,私钥必须保密,防止未经授权的访问,因为任何拥有私钥的人都可以访问你的数据。在这种加密技术中,公钥和私钥在数学上是相关的,并使用固定缓冲区大小。与密钥加密相比,这些更慢,适用于加密少量数据。使用公钥加密的任何数据只能使用私钥解密。此外,如果你使用私钥签名数据,它只能使用公钥验证。

数字签名使用该方独有的数字签名。如公开密钥加密中提到的,一方可以使用私钥签名数据,当另一方收到信息并且发送方的公钥被信任时,你可以识别谁发送了消息,从而维护数据的完整性。

由于发送方的公钥是公开的,任何拥有公钥的人都可以处理消息,这意味着您的消息不是秘密的。为了保持其秘密性,您还需要加密消息。

哈希值将任意长度的数据映射到固定长度的字节序列。当你有一段文本并在重新哈希之前更改它时,它将产生一个新的哈希。这样,我们可以在传输过程中保持数据完整性。

然而,正如在讨论其他加密方法时提到的,这种方法并不验证消息的发送者。

对称加密

对称加密使用单个密钥,并在文本块上工作。这种方法比其他方法更快。在使用此方法时,保持密钥的机密性非常重要,发送者和接收者应使用相同的密钥,这是此方法的缺点。

让我们看看一个例子,了解我们如何加密消息或文本块:

  1. 这里,我们使用加密方法,从文件中读取一段文本,使用对称算法对其进行加密,并将加密内容写入不同的文件。

  2. 加密方法接受一个SymmetricAlgorithm实例,该实例通过传递密钥和初始向量创建一个ICryptoTransform实例。系统允许您生成自己的密钥或使用它生成的密钥。

  3. 然后,我们创建一个内存流来存储运行时的缓冲区:

public static void EncryptSymmetric(SymmetricAlgorithm sem)
{
    //Read content from file
    string filecontent = File.ReadAllText("..\\..\\inputfile.txt");
    //Create encryptor using key and vector
    ICryptoTransform encryptor = sem.CreateEncryptor(sem.Key, sem.IV);
    //create memory stream used at runtime to store data
    using (MemoryStream outputstream = new MemoryStream())
    {
        //Create crypto stream in write mode
        using (CryptoStream encStream = new CryptoStream(outputstream, encryptor, CryptoStreamMode.Write))
        {
            //use streamwrite 
            using (StreamWriter writer = new StreamWriter(encStream))
            {
                // Write the text in the stream writer 
                writer.Write(filecontent);
            }
        }
        // Get the result as a byte array from the memory stream 
        byte[] encryptedDataFromMemory = outputstream.ToArray();
        // Write the data to a file 
        File.WriteAllBytes("..\\..\\Outputfile.txt", encryptedDataFromMemory);
    }
}

使用内存流、ICryptoTransform以及写入模式创建了一个 cryptostream。cryptostream 用于写入内存,然后可以将其转换为数组并写入输出文件。

  1. 执行加密方法后,现在您可以打开解决方案中的输出文件并检查结果。

  2. 现在,我们将从输出文件中读取数据并将其解密为纯文本:

public static string DecryptSymmetric(SymmetricAlgorithm sem)
{
    string result = string.Empty;
    //Create decryptor
    ICryptoTransform decryptor = sem.CreateDecryptor(sem.Key, sem.IV);
    //read file content
    byte[] filecontent = File.ReadAllBytes("..\\..\\Outputfile.txt");
    //read file content to memory stream
    using (MemoryStream outputstream = new MemoryStream(filecontent))
    {
        //create decrypt stream
        using (CryptoStream decryptStream = new CryptoStream(outputstream, decryptor, CryptoStreamMode.Read))
        {
            using (StreamReader reader = new StreamReader(decryptStream))
            {
                //read content of stream till end
                result = reader.ReadToEnd();
            }
        }
    }
    return result;

}

解密方法与加密方法具有相同的签名。然而,我们不是创建一个encryptor类,而是创建一个decryptor类,该类实现了ICryptoTransform接口。

以下是我们创建的 main 程序,其中我们创建AESManaged实例类型的SymmetricAlgorithm,然后将其传递给加密和解密方法:

 static void Main(string[] args)
 {
     Console.WriteLine("Using AES symmetric Algorithm");
     SymmetricAlgorithm sem = new AesManaged();
     Console.WriteLine("Encrypting data from inputfile");
     EncryptDecryptHelper.EncryptSymmetric(sem);
     Console.WriteLine("Data Encrypted. You can check in outputfile. Press any key to decrypt message");
     System.Console.ReadKey();
     Console.WriteLine("Decrypting content form outputfile");
     string message = EncryptDecryptHelper.DecryptSymmetric(sem);
     Console.WriteLine($"Decrypted data : {message}");
     // Keep the console window open in debug mode.
     System.Console.WriteLine("Press any key to exit.");
     System.Console.ReadKey();
 }

在执行程序之前,请检查解决方案中的输入文件,您可以在其中更改内容并执行它。加密后,可以验证解决方案中的输出文件中的加密内容:

图片

当你练习这个示例时,确保所有辅助方法在一个类中,主方法在另一个类中,如 GitHub 上的示例代码所指定。现在你有了所有方法,当你执行它们时,你会看到前面的输出。

你的加密方法使用 AES 算法。它从输入文件读取数据,使用 AES 算法加密数据,然后在屏幕上写一条消息。一旦你按下任意键,你的解密方法就会被启动,解密消息并将其写入输出文件。相同的信息会在屏幕上显示。

在实时场景中,当你想通过文件传输执行安全交易时,这是其中一种方法。因为你将使用对称算法,所以加密或解密内容将很容易。

发送者加密文件的内容并发送给接收者。接收者解密文件内容并对其进行处理。在此方法中,发送者和接收者都应该知道所使用的密钥。

非对称加密

非对称加密使用两个密钥——一个公钥和一个私钥。正因为如此,它的运行速度较慢。此外,必须始终确保私钥的安全。除非你有私钥,否则你不能解密消息。

现在,让我们进入一个示例,尝试了解这是如何完成的。在这个场景中,我们将使用RSACryptoServiceProvider。此算法为我们提供了可用于加密和解密消息的公钥和私钥。加密方法接受一个公钥和要加密的文本,然后我们将文本转换为字节数组,因为加密方法接受字节数组。然后,我们为算法设置公钥并调用加密方法:

public static byte[] EncryptAsymmetric(string publicKey, string texttoEncrypt)
{
    byte[] result;
    UnicodeEncoding uc = new UnicodeEncoding();
    byte[] databytes = uc.GetBytes(texttoEncrypt);
    using (RSACryptoServiceProvider rsa = new RSACryptoServiceProvider())
    {
        rsa.FromXmlString(publicKey);
        result = rsa.Encrypt(databytes, true);
    }
    return result;
}

在解密方法中,我们传递需要解密的字节数组以及一个私钥。一旦使用公钥加密了一条消息,它只能使用对应的私钥进行解密:

public static string DecryptAsymmetric(string privateKey, byte[] bytestoDecrypt)
{
    byte[] result;
    using (RSACryptoServiceProvider rsa = new RSACryptoServiceProvider())
    {
        rsa.FromXmlString(privateKey);
        result = rsa.Decrypt(bytestoDecrypt, true);
    }
    UnicodeEncoding uc = new UnicodeEncoding();
    string resultText = uc.GetString(result);
    return resultText;
}

以下为主要方法,其中我们创建RSACryptoproviderservice类以获取公钥和私钥。rsa.ToXmlString(false)提供公钥,将其设置为true将给我们私钥。我们将使用这些密钥来加密和解密消息:

static void Main(string[] args)
{
    #region asymmetric Encryption
    Console.WriteLine("Using asymmetric Algorithm");
    RSACryptoServiceProvider rsa = new RSACryptoServiceProvider();
 string publicKey = rsa.ToXmlString(false);
 string privateKey = rsa.ToXmlString(true);
    Console.WriteLine("Encrypting data ");
    byte[] resultbytes = EncryptDecryptHelper.EncryptAsymmetric(publicKey,"This is a dummy text to encrypt");
    Console.WriteLine("Data Encrypted. Press any key to decrypt message");
    System.Console.ReadKey();
    Console.WriteLine("Decrypting content");
    string resultText = EncryptDecryptHelper.DecryptAsymmetric(pricateKey, resultbytes);
    Console.WriteLine($"Decrypted data : {resultText}");
    #endregion

    // Keep the console window open in debug mode.
    System.Console.WriteLine("Press any key to exit.");
    System.Console.ReadKey();
}

通过更改输入文本来执行程序,或者你可以尝试更改算法。然而,当你更改算法时,你可能需要应用程序运行和工作所需的任何语法更改:

图片

当你执行程序时,你会看到前面的输出。在示例中,你正在使用非对称算法加密内容。随着控制流通过代码,它显示消息。一旦消息被加密,它将要求你按任意键。按下任意键后,程序将解密消息并在屏幕上显示。

在我们讨论的对称算法场景中,如果您想在两个当事人之间进行安全交易,您可以使用公私钥组合。

接收者使用私钥进行加密,并将文件或文本块发送给接收者,在那里使用公钥来解密内容。如果公钥和私钥不匹配,您将无法读取或验证数据。这是验证输入数据的一种方式。

数字签名

数字签名可以用来签名消息,从而验证发送者。然而,签名消息并不能防止第三方读取消息。为了实现这一点,我们需要加密消息并对其进行签名。

在以下示例中,我们使用公钥和私钥(非对称算法)。我们使用发送者的私钥对消息进行签名,并使用接收者的公钥加密消息。如果您观察代码,我们在这个示例中也使用了哈希计算。在加密消息后,我们再次对消息进行哈希处理。

我们将使用RSACryptoServiceProvider,以及RSAPKCS1SignatureFormatter,它将被用来创建签名。

在以下程序中,我们使用UnicodeEncoding类将文本转换为字节数组,使用接收者的公钥和我们在前几节中学到的对称或非对称算法加密消息,计算内容的哈希值,然后对消息进行数字签名。一旦所有这些过程都得到实现,我们就将数据传输过去,在那里我们重新计算哈希值,验证签名,然后使用密钥解密消息。

在以下示例中,我们使用公私钥进行加密。如前所述,仅仅签名消息并不能保证消息内容的安全。相反,它将允许您验证发送者:

public static void DigitalSignatureSample(string senderPrivatekey, string receiverspublickey, string texttoEncrypt)
{
 UnicodeEncoding uc = new UnicodeEncoding();
 Console.WriteLine("Converting to bytes from text");
 //get bytearray from the message
 byte[] databytes = uc.GetBytes(texttoEncrypt);
 Console.WriteLine("Creating cryptoclass instance");
 //Creating instance for RSACryptoservice provider as we are using for sender and receiver
 RSACryptoServiceProvider rsasender = new RSACryptoServiceProvider();
 RSACryptoServiceProvider rsareceiver = new RSACryptoServiceProvider();
 //getting private and public key
 rsasender.FromXmlString(senderPrivatekey);
 rsareceiver.FromXmlString(receiverspublickey);
 Console.WriteLine("Creating signature formatter instance");
 //GEt signature from RSA
 RSAPKCS1SignatureFormatter signatureFormatter = new RSAPKCS1SignatureFormatter(rsasender);
 //set hashalgorithm
 signatureFormatter.SetHashAlgorithm("SHA1");
 //encrypt message
 Console.WriteLine("encrypting message");
 byte[] encryptedBytes = rsareceiver.Encrypt(databytes, false);
 //compute hash
 byte[] computedhash = new SHA1Managed().ComputeHash(encryptedBytes);
 Console.WriteLine("Creating signature");
 //create signature for the message
 byte[] signature = signatureFormatter.CreateSignature(computedhash);
 Console.WriteLine("Signature: " + Convert.ToBase64String(signature));
 Console.WriteLine("Press any key to continue...");
 Console.ReadKey();
 //receive message then recompute hash
 Console.WriteLine("recomputing hash");
 byte[] recomputedHash = new SHA1Managed().ComputeHash(encryptedBytes);
 //signature deformatter
 Console.WriteLine("Creating signature dformatter instance");
 RSAPKCS1SignatureDeformatter signatureDFormatter = new RSAPKCS1SignatureDeformatter(rsareceiver);
 signatureDFormatter.SetHashAlgorithm("SHA1");
 //verify signature
 Console.WriteLine("verifying signature");
 if (!signatureDFormatter.VerifySignature(recomputedHash, signature))
 {
  Console.WriteLine("Signature did not match from sender");
 }
 Console.WriteLine("decrypting message");
 //decrypt message
 byte[] decryptedText = rsasender.Decrypt(encryptedBytes, false);
 Console.WriteLine(Encoding.UTF8.GetString(decryptedText));
}

以下为主要程序,其中我们创建RSACryptoServiceProvider类的实例并收集公钥和私钥。然而,由于我们在同一方法中加密和解密消息,因此使用了同一套公钥和私钥。

作为本示例的一部分,我们执行了加密和解密。我们可以创建多个 RSA 提供者,并使用它们的公私钥作为发送者和接收者。您可以创建不同的控制台应用程序,一个作为发送者,另一个作为接收者,并模拟现实世界场景。为了简化起见,我使用了一对公私钥来执行操作:

static void Main(string[] args)
{
    #region Digital Signatures
    RSACryptoServiceProvider rsa = new RSACryptoServiceProvider();
    string publicKey = rsa.ToXmlString(false);
    string pricateKey = rsa.ToXmlString(true);
    EncryptDecryptHelper.DigitalSignatureSample(pricateKey, publicKey,"This is a sample text for Digital signatures");
    #endregion

    // Keep the console window open in debug mode.
    System.Console.WriteLine("Press any key to exit.");
    System.Console.ReadKey();
}

通过更改输入消息和算法来检查输出。然而,如前所述,在执行之前,您可能需要注意任何语法上的变化:

图片

在现实世界的场景中,假设两个实体通过实现数字签名的 Web 服务进行通信。发送者将有一组公钥和私钥,接收者也将有一组公钥和私钥。双方应交换各自的公钥以促进应用程序通信。

哈希值

计算哈希从字节数组创建一个固定长度的数值。哈希将可变长度的二进制字符串映射到固定长度的二进制字符串。哈希不能用于双向转换。当你应用哈希算法时,每个字符都会被哈希到一个不同的二进制字符串。

在以下示例中,我们使用SHA1Managed算法来计算哈希。我们计算哈希两次以检查结果是否相同。如前所述,此方法用于维护数据完整性。

在以下代码中,我们使用UnicodeEncoding类将文本转换为字节数组,并使用SHA1Managed算法计算字节数组的哈希。一旦转换,我们将在屏幕上显示每个哈希字节。为了验证哈希,我们重新计算字符串的哈希并比较哈希值。这是验证输入数据的一种方法:

public static void HashvalueSample(string texttoEncrypt)
{
    UnicodeEncoding uc = new UnicodeEncoding();
    Console.WriteLine("Converting to bytes from text");
    byte[] databytes = uc.GetBytes(texttoEncrypt);
    byte[] computedhash = new SHA1Managed().ComputeHash(databytes);
    foreach (byte b in computedhash)
    {
        Console.Write("{0} ", b);
    }
    Console.WriteLine("Press any key to continue...");
    byte[] reComputedhash = new SHA1Managed().ComputeHash(databytes);
    bool result = true;
    for (int x = 0; x < computedhash.Length; x++)
    {
        if (computedhash[x] != reComputedhash[x])
        {
            result = false;
        }
        else
        {
            result = true;
        }
    }

    if (result)
    {
        Console.WriteLine("Hash value is same");
    }
    else
    {
        Console.WriteLine("Hash value is not same");
    }
}

调用哈希值示例的主要方法如下。在这里,我们只是调用一个辅助方法,它对提供的文本执行哈希计算:

static void Main(string[] args)
{ 
    #region Hashvalue
    EncryptDecryptHelper.HashvalueSample("This a sample text for hashvalue sample");
    #endregion

    // Keep the console window open in debug mode.
    System.Console.WriteLine("Press any key to exit.");
    System.Console.ReadKey();
}

当我们计算哈希时,我们显示结果,然后我们进行对比以查看两次调用的结果是否相同:

前面的截图显示了计算哈希并显示哈希数组的程序。此外,当程序重新计算哈希并进行比较时,你会看到相同的哈希值消息。

摘要

在本章中,我们专注于理解密码学以及我们如何使用对称和非对称算法。我们还关注了如何使用这些算法来验证发送者、接收者和消息内容。我们可以利用本章学到的技术来验证输入数据,并在处理安全交易时执行类似操作。我们还探讨了如何使用数字签名来签名消息,以及如何使用哈希值来维护数据完整性。

在下一章中,我们将关注.NET 程序集,我们将如何管理它们,以及我们将如何调试 C#应用程序。

问题

  1. 在本章讨论的四种方法中,哪两种可以用来验证发送者?

    1. 对称算法

    2. 非对称算法

    3. 哈希值

    4. 数字签名

  2. 当两个实体需要使用非对称算法进行通信时,他们需要共享哪个密钥?

    1. 私钥

    2. 公钥

    3. 两者

  3. 哪种类型的算法用于加密大量数据?

    1. 对称

    2. 非对称

    3. 两者

答案

  1. 数字签名

  2. 公钥

  3. 对称

第十三章:管理组件和调试应用程序

在上一章中,我们学习了加密学以及如何使用 C#中可用的不同技术进行加密和解密。在本章中,我们将关注如何管理.NET 组件、调试应用程序以及如何进行跟踪。编写.NET 应用程序看起来相对简单;然而,确保您的程序能够实现其目的、维持质量标准、在异常情况下不会崩溃,并且在所有情况下都能正常工作是非常重要的。为了实现这样的质量输出,重要的是测试您的应用程序并检查在运行时生成的输入源和值,这些值用于应用程序逻辑的进一步处理等。

组件是.NET 应用程序部署的基本单元。它们维护版本、类型、所需资源、范围和安全细节。我们将在接下来的章节中更详细地讨论这一点。

调试是通过逐行检查似乎有问题或您认为会引发错误的代码的过程。在这个过程中,我们可以观察变量和参数的值以及程序是否按预期运行。

理解我们是在创建一个库还是一个独立的应用程序以分发给客户也很重要。基于这一点,我们可以决定需要创建哪种类型(.exe.dll)的应用程序。

跟踪允许您在代码执行时跟踪每一行代码。

在阅读本章后,您将能够理解.NET 中的组件以及如何管理它们,以及版本控制和签名。我们还将探讨调试应用程序的多种方法以及当发生异常时如何编写跟踪消息。在本章中,我们将涵盖以下主题:

    • 组装

    • 调试 C#应用程序

    • 跟踪

技术要求

您可以使用 Visual Studio 2012 或更高版本以及.NET Framework 2.0 或更高版本来练习本章的练习。然而,任何从 C# 7.0 及以后版本的新特性都需要您拥有 Visual Studio 2017。

如果您没有这些产品的任何一项许可证,您可以从visualstudio.microsoft.com/downloads/下载 Visual Studio 2017 的社区版。

本章的示例代码可以在 GitHub 上找到,地址为github.com/PacktPublishing/Programming-in-C-sharp-Exam-70-483-MCSD-Guide/tree/master/Chapter13.

组装

.NET Framework 中的组件可以是两种类型之一,.exe.dll,被称为.NET 应用程序的构建块。

这些组件构成了应用程序的基本单元,并允许程序员维护版本、安全性、使用范围和重用性。由于组件包含执行应用程序所需的所有信息,因此它为运行时提供了有关使用哪些 .NET 类型以及执行应用程序所需的运行时功能的信息。

当你在 .NET 中使用 Visual Studio 创建应用程序时,它会创建源代码文件(.cs 文件)、组件属性(AssemblyInfo.cs):

图片

这些项目允许程序员关联其他组件,从而允许他们创建和维护供多个用户共同工作的更大项目。当完成对单个项目的开发工作后,这些项目可以作为一个组件单元创建,以便向客户发布。

当创建组件时,每个组件都会创建一个清单文件,详细说明以下信息:

  • 在创建此组件时使用过的每个文件

  • 如果有任何已使用的引用

  • 具有唯一名称的组件版本

组件内容和清单

.NET 框架中的组件包含以下四个元素:

  • 组件清单

  • 元数据

  • MSIL 代码

  • 资源

这些元素可以组合成一个组件,如下面的截图所示。在这里,运行时需要清单信息以获取类型信息、依赖组件信息、版本和组件的唯一名称,以便执行:

图片

.NET 框架还允许我们将组件的四个元素组合成多个模块,并在执行程序块时创建一个组件来引用它们。当在组件中引用这些模块时,是清单文件维护了所有引用这些资源所需的链接:

图片

组件清单包含以下信息:组件的名称、版本、用于构建组件的文化、强名称信息(即公钥)、类型信息、文件列表以及它们如何与组件相关联,以及最后,引用的组件及其版本列表。我们可以通过更新 AssemblyInfo.cs 文件来添加更多信息。

清单文件可以是包含 MSIL 的可移植可执行文件(PE)文件的一部分,也可以是独立的 PE 文件。每个组件文件包含构成组件所需的所有文件;它控制这些文件、资源和组件之间的映射关系,并包含引用的组件。

目标 .NET 框架

当你创建一个 C# 应用程序时,你可以指定你的应用程序要针对哪个 .NET Framework。在实际场景中,当框架发布新版本时,并非每个客户都会更新他们的服务器以使用最新版本。此外,当发布新版本时,一些旧功能可能会被弃用,而现有功能的新版本会被添加。在这些情况下,你的应用程序不应失败。因此,.NET Framework 允许你将应用程序针对 .NET Framework 的特定版本。

在使用 Visual Studio 创建新项目时,你可以指定要针对的 .NET Framework 版本,或者使用项目的属性页更改目标框架:

或者,要使用属性页更改目标框架版本,请选择一个现有项目,右键单击它,然后导航到属性:

在“目标框架”下拉菜单中,选择你需要的版本:

签署程序集

为程序集创建一个唯一的标识符被称为对程序集进行签名或强命名。为程序集提供唯一标识符可以避免程序集冲突。每个程序集在其清单中维护模块、资源和文件信息的哈希值。当你对程序集进行签名时,以下信息将被捕获:

  • 程序集的名称

  • 程序集的版本号

  • 如果可用,程序集的文化(也称为代码开发的区域设置

  • 用于对程序集进行签名的公钥被添加到程序集清单中

签署程序集提供了以下好处:

  1. 这允许我们给朋友访问其他已签名的程序集。

  2. 这允许我们在同一时间运行同一程序集的不同版本。

  3. 这允许我们将我们的程序集部署到全局程序集缓存(GAC)。这允许其他应用程序使用我们的程序集。

你可以通过两种方式对程序集进行签名:第一种是使用 Visual Studio,第二种是使用命令行工具。Visual Studio 使签名程序集变得简单。

在这里,我们将演示如何使用 Visual Studio 签署一个程序集:

  1. 导航到项目属性。

  2. 导航到左侧的“签名*”选项卡:

  1. 选择“签名程序集”复选框。

  2. 在下拉菜单中选择 <新建...>,然后选择一个强密钥文件名:

  1. 在弹出的窗口中输入一个密钥文件名。

  2. Visual Studio 允许你选择一个算法并为密钥文件提供密码。

  3. 密码是可选的;你可以根据需要从可用列表中更改算法。

或者,我们可以使用命令提示符和包含以下安装步骤的 Visual Studio 工具对程序集进行签名:

  1. 点击系统上的 Windows 按钮。

  2. 在已安装程序中导航。

  3. 找到 Visual Studio 20xx 安装文件夹。

  4. 选择 Visual Studio 20xx 的开发者命令提示符。

  5. 使用 sn.exe 将强名称对生成到 .snk 文件中:

sn -k keyPair.snk
  1. 一旦创建了密钥文件,你现在可以使用 Visual Studio 对程序集进行签名或使用 al.exe 工具。

  2. 使用 al.exe 将前面步骤中生成的程序集和密钥对链接起来:

al /out:chapter12.dll MyModule.netmodule /keyfile:keyPair.snk

关于这些命令的更多信息可以在 MSDN 上找到(docs.microsoft.com/en-us/dotnet/framework/tools/sn-exe-strong-name-tool)。

程序集版本控制

当你准备好一个程序集并对其进行了签名后,你可以对其进行版本控制。当你对程序集进行版本控制时,当前程序集以及所有依赖的程序集版本都保存在程序集清单中。当版本化的程序集部署到环境中时,它将成为你应用程序的默认版本,如果当前程序集或依赖的程序集与默认版本不匹配,系统将抛出程序集清单不匹配错误。有一种方法可以通过配置文件来覆盖这一点,该配置文件告诉运行时使用特定版本而不是默认版本。

当程序集在运行时执行时,它执行多个步骤以解析程序集绑定:

  1. 它检查当前程序集的版本信息和唯一名称。

  2. 它检查配置文件以查看是否定义了任何版本覆盖策略。

  3. 在任何策略更改的情况下,运行时会根据策略识别和加载重定向的程序集。

  4. 它检查全局程序集缓存(GAC)或配置文件中指定的路径,然后是应用程序目录、子目录,并服务程序集绑定请求。

版本号

每个程序集以两种形式维护版本信息,即标识符和信息。程序集的版本号、名称和区域设置形成程序集的标识符,信息版本以程序集信息文件中指定的字符串格式提供,仅用于信息目的。

程序集的版本号表示为一个四部分字符串:

<Major version>.<Minor Version>.<Build number>.<revision>

例如,如果程序集版本设置为 2.1.1234.2,这表示程序集的主版本是 2,次要版本是 1,构建号是 1234,修订号是 2。当创建或更新此版本时,它将连同所有资源和依赖程序集文件及其版本快照一起保存在清单文件中。此外,版本控制检查仅适用于已签名的程序集。

从现实世界的场景中理解的一个重要事情是,当你构建一个产品并发布你的程序集给客户,后来升级你的程序集,那么你必须维护之前的版本。因此,当发布程序集的新版本时,只要它得到支持,客户端仍然可以使用旧版本。

调试 C# 应用程序

当你构建一个 C# 应用程序时,你将有两个选项,调试模式和发布模式。调试模式帮助你逐行检查代码以查找错误并在需要时进行修复。发布模式不允许我们进入代码。Visual Studio 通过提供更多工具使开发者更容易使用,这些工具允许我们在运行时遇到调试点时执行 Step-inStep-OverStep-Out。以下截图中的蓝色框中突出显示了这些工具:

图片

除了这些工具,Visual Studio 还允许我们查看堆栈跟踪、检查变量以及更多功能。让我们进一步探索,以便更好地了解调试过程。

让我们从基础开始。要设置断点,只需单击要调试的代码行左侧的空白处,或将光标放在该行上并按键盘上的 F9 键。另一种设置断点的方法是选择调试菜单选项并选择新的断点。

当你设置断点时,整行代码会被高亮显示为棕色。当程序带有断点开始执行时,控制会在断点处停止并高亮显示该行,这表示高亮显示的行将被执行:

图片

观察前面的截图;我们在第 13 行设置了断点。当你启动程序时,控制会在设置断点的第 13 行停止。当我们单步执行时,屏幕上会打印输出,但控制会停留在第 14 行,如下面的截图所示:

图片

当遇到断点时,尽管应用程序执行被停止,但所有变量、函数和对象仍然保留在内存中,这允许我们验证它们的值。当你想要调试一个应用程序时,你需要在调试模式下构建它,这会生成一个 .pdb 文件;这个文件是调试的关键。.pdb 文件包含符号(或源代码),这些符号被加载到内存中以允许我们进行调试。如果没有加载这些符号,你可能会看到一个错误消息,表明符号未找到。维护这些 pdb 文件的版本也很重要,因为你的程序集和 .pdb 文件之间的任何版本不匹配都将导致程序集版本不匹配错误。

让我们跳入一个示例代码,并检查在调试过程中我们可以执行的不同操作,以及 Visual Studio 提供给我们的功能。

以下是一个示例程序,它接受两个数字并计算这些数字的和与差,然后调用另一个方法将 10 添加到结果中。让我们通过在代码块中设置几个断点来调试这个应用程序:

internal void Method2()
{
    Console.WriteLine("Enter a numeric value");
    int number1 = Convert.ToInt32(Console.ReadLine());

    Console.WriteLine("Enter another numeric value");
    int number2 = Convert.ToInt32(Console.ReadLine());

    int number3 = number1 + number2;
    int number4 = number1 - number2;
    int number5 = Method3(number4);

}

internal int Method3(int number4)
{
    return number4+10;
}

在这里,我们在第 18 行和第 21 行设置了两个断点。当程序开始执行时,它会在第 18 行停止以等待用户输入,当您选择继续程序执行时,控制会停止在第 21 行。在下面的屏幕截图中,需要注意的一个重要事项是,当控制停止在第 21 行时,您只需将光标悬停在number1变量上即可查看其值。您可以看到number1变量的值为 23:

Visual Studio 调试工具允许我们在调试模式下执行程序时监视变量。您可以在变量上右键单击并选择“添加监视”将变量添加到监视窗口。正如您在屏幕底部所看到的那样,有一个监视窗口,其中已添加了变量number3,我们可以看到值为 43,这是number1number2之和的输出。监视窗口允许您查看代码行执行后的值。当您的应用程序逻辑执行复杂计算时,这非常有用:

另一个在调试过程中非常有用的窗口是即时窗口(Ctrl + Alt + I),可以通过键盘快捷键或调试菜单打开。与监视窗口不同,此窗口可以帮助你在执行代码行之前执行操作。正如您可以在下面的屏幕截图中所见,控制位于第 25 行,即断点被触发的位置;然而,如果您向下看,即时窗口是打开的,我们在其中执行了number1 - number2操作来检查执行该行之前的值:

当您放置一个断点并将光标悬停在左侧边缘的断点上时,系统会显示一个齿轮,这允许您为断点添加条件;例如,您的程序有一个for循环,但您希望断点在循环变量为 5 时触发:

当您点击齿轮时,您将看到一个条件向导,您可以在其中配置断点何时触发的条件。默认情况下,条件设置为 true,这可以通过开发人员更改。在下面的屏幕截图中,我们选择了number4=3,这意味着当数字 4 的值等于 3 时,此断点将被触发:

在调试过程中,另一个需要理解的重要功能是,Visual Studio 中的某些类型的项目允许您在调试应用程序时更改变量值。当以下程序执行时,我们将60作为数值输入到变量number2中。您可以通过以下方式查看变量 2 的值:

现在,当你选择显示的值时,系统允许你更改它(我们将其更改为 40),然后使用修改后的值继续执行代码块。请记住,并非所有项目类型都允许你在运行时更改值:

图片

如前所述,在 Visual Studio 中调试 C#应用程序时,有许多可用的工具,其中一些在下面的屏幕截图中被突出显示。继续和停止调试按钮允许开发者在一处断点被触发后继续执行,或者停止执行。

有进入、退出和跳过按钮。这些按钮允许你在断点触发后进入每一行代码,或者跳过方法的执行并继续在同一上下文中执行,或者进入外部方法。

一旦你调试了程序块并修复了所有发现的问题,你可以通过使用调试菜单中的删除所有断点或禁用所有断点选项一次性禁用或删除所有断点:

图片

在下面的屏幕截图中,突出显示了几个更多选项,例如附加到进程、快速监视...、另存为内存转储...、并行堆栈、立即和监视窗口、调用堆栈等:

图片

我们以这种方式构建项目,即创建多个程序集,无论是辅助程序集还是依赖程序集。在调试时,通过使用附加到进程...命令将运行这些程序集的进程附加到当前调试进程,加载符号非常重要。否则,系统会提示源代码不可用,因此无法进入代码块。

在实际项目场景中,有时你的应用程序会突然崩溃;在这种情况下,你可以保存内存转储并分析内存寄存器以了解发生了什么。这可能需要你具备阅读和理解此类转储的特殊技能。

我们在前面章节中了解了并行任务和多线程;当你的代码块运行多线程应用程序的并行任务时,调试菜单可以帮助你理解并行堆栈和任务。

跟踪

跟踪使我们能够在应用程序执行时对其进行监控。当程序执行时,运行时允许我们写入消息以监控程序的流程控制。这将使我们能够识别应用程序逻辑的任何错误行为。在异常的情况下,我们可以确定代码失败的确切位置以及哪些变量值导致了平稳执行的故障。这些消息可以通过System.Diagnostics.Debug类创建。当创建此类消息时,默认情况下,这些消息将在 Visual Studio 的输出窗口中显示。

除了创建这些消息外,您还可以使用 System.Diagnostics.Trace 类将这些消息重定向到文件或数据库。您可以使用跟踪类注册监听器,这允许您重定向您的消息。在这里,您的调试类或跟踪类充当发布者,而监听器类充当订阅者。我们希望您还记得第五章,创建和实现事件和回调,在那里我们学习了发布者和订阅者模型。

让我们通过一个例子来了解我们如何使用调试消息。在下面的程序中,我们试图接受两个输入参数,并对这些数字执行加法和减法等操作。然而,我们添加了一些额外的行来监控记录发生情况的日志消息:

internal void Method4()
{
    Console.WriteLine("Enter a numeric value");
    int number1 = Convert.ToInt32(Console.ReadLine());
    Debug.WriteLine($"Entered number 1 is: {number1}");

    Console.WriteLine("Enter another numeric value");
    int number2 = Convert.ToInt32(Console.ReadLine());
    Debug.WriteLine($"Entered number 2 is: {number2}");

    int number3 = number1 + number2;
    Debug.WriteLineIf(number3>10, $"Sum of number1 & number 2 is : {number3}");
    int number4 = number1 - number2;
    Debug.WriteLineIf(number4 < 10, $"Difference of number1 & number 2 is : {number4}");
}

因为我们使用了 Debug.WriteLine 来记录消息,所以这些值会写入输出窗口。观察以下输出窗口,其中所有 Debug.WriteLine 消息都被写入:

图片

在前面的代码块中,您可以看到程序块中的最后两个 Debug.WriteLine 语句,其中使用了 Debug.WriteLineIf。系统会检查我们提供的条件,如果返回 true,则系统会将消息写入输出窗口。

现在,让我们更进一步,看看我们如何可以使用跟踪监听器将消息重定向到不同的通道。

我们将使用相同的程序,并添加五条额外的行,其中添加 Console.Outlogfile.txt 文件作为两个不同的跟踪监听器,然后将这两个监听器附加到调试对象上。最后一行是 Debug.Flush,它将对象中的所有消息推送到日志文件:

internal void Method5()
{
    TextWriterTraceListener listener1 = new TextWriterTraceListener(Console.Out);
    Debug.Listeners.Add(listener1);

    TextWriterTraceListener listener2 = new TextWriterTraceListener(File.CreateText("logfile.txt"));
    Debug.Listeners.Add(listener2);

    Console.WriteLine("Enter a numeric value");
    int number1 = Convert.ToInt32(Console.ReadLine());
    Debug.WriteLine($"Entered number 1 is: {number1}");

    Console.WriteLine("Enter another numeric value");
    int number2 = Convert.ToInt32(Console.ReadLine());
    Debug.WriteLine($"Entered number 2 is: {number2}");

    int number3 = number1 + number2;
    Debug.WriteLineIf(number3 > 10, $"Sum of number1 & number 2 is : {number3}");
    int number4 = number1 - number2;
    Debug.WriteLineIf(number4 < 10, $"Difference of number1 & number 2 is : {number4}");
    Debug.Flush();
}

因为我们将 Console.Out 添加为其中一个监听器,所以当我们执行程序时,Debug.WriteLine 消息现在会显示在屏幕上:

图片

此外,因为我们添加了 logfile.txt 作为其中一个监听器,所以程序执行文件夹中会创建一个新的文本文件,其中包含 Debug.WriteLine 消息:

图片

这些监听器不仅限于文本文件和控制台。根据项目需求,您可以添加 XML、数据库监听器,这可能需要一些额外的编码。

摘要

在本章中,我们学习了如何管理 C# 程序集,如何调试程序集或程序块,Visual Studio 在执行这些操作时提供了哪些功能,以及如何使用跟踪。

在下一章中,我们将探讨 C# 提供的不同功能,用于访问和利用文件对象和外部 Web 服务中存在的数据,重点关注在文件对象上执行 I/O 操作,以及 System.Net 命名空间中可用的不同辅助类,这些类帮助我们进行 I/O 操作。

问题

  1. 程序集的版本号代表什么?

    1. 主版本号、次版本号、构建号和修订号。

    2. 主要版本,次要版本,程序集版本和日期。

    3. 主要版本,次要版本,程序集版本和修订号。

    4. 以上所有选项。

  2. 强类型命名程序集的重要好处是什么?

    1. 它允许您共享您的程序集。

    2. 它允许您同时运行超过两个程序集。

    3. 它允许您将其安装到全局程序集缓存(GAC)中。

    4. 以上所有选项。

  3. 在文本文件中记录调试消息的最简单方法是什么?

    1. 创建一个文件并使用文本流对象写入消息。

    2. 创建一个跟踪监听器并将其附加到调试对象。

    3. 使用第三方日志程序集。

    4. 以上所有选项。

答案

  1. 主要版本,次要版本,构建号和修订号

  2. 以上所有选项

  3. 创建一个跟踪监听器并将其附加到调试对象

第十四章:执行 I/O 操作

在任何编程语言中,所有应用程序都依赖于某种类型的数据。这些应用程序相互交互,传递存在于不同来源中的数据,如文件对象和外部 Web 服务。

在本章中,我们将探讨 C#提供的不同功能,以访问和利用文件对象和外部 Web 服务中的数据。

在本章中,我们将涵盖以下主题:

  • 在文件对象上执行 I/O 操作

  • System.Net命名空间中可用的不同辅助类,帮助我们进行 I/O 操作

技术要求

与本书前面章节中介绍的内容类似,本章中解释的程序将在 Visual Studio 2017 中开发。

本章的示例代码可以在 GitHub 上找到,链接为github.com/PacktPublishing/Programming-in-C-sharp-Exam-70-483-MCSD-Guide/tree/master/Chapter14

文件 I/O 操作

文件是一个非常粗略的术语,表示存储在特定目录路径上的数据集合。在编写 C#应用程序时,我们会在几个场合需要使用文件对象:

  • 将数据存储在应用程序中或将其传递给另一个应用程序

  • 访问应用程序执行所需的配置设置

  • 访问目录路径中存在的文件

这些操作被称为 I/O 操作。C#提供了一个名为System.IO的命名空间,其中包含一些辅助类。这些辅助类帮助我们执行文件对象上的 I/O 操作。在本节中,我们将探讨 C#中的这些辅助类。

使用 System.IO 辅助类

System.IO命名空间包含一系列类,允许我们在 C#中进行文件操作。它包括允许我们执行以下操作的类:

  • 从文件中读取数据

  • 将数据写入文件

  • 创建/删除新文件

在本章的不同部分,通过代码示例,我们将查看我们可以在文件上执行的所有这些 I/O 操作。然而,在我们开始查看这些示例之前,我们需要理解一个非常重要的概念,即基于 I/O 操作的基础概念

流表示在 I/O 操作期间应用程序之间交换的字节序列。在 C#中,它由一个名为System.IO.Stream的抽象类表示。它提供了一个包装类来传输字节,所有需要从任何来源读取/写入字节的类都必须从这个特定的类继承。

在我们学习更多关于流以及如何在 C#中处理它们之前,让我们首先看看我们如何处理驱动器、目录以及文件的一些其他基本操作。

驱动器和目录

驱动器代表文件系统的存储介质。它可以是硬盘驱动器、CD 或任何其他存储类型。在.NET Framework 中,我们在System.IO命名空间中有一个DriveInfo类,它帮助我们访问驱动器上的文件系统信息。它提供了可以帮助我们访问诸如名称、大小和驱动器上的可用空间等信息的方法。请参考以下代码实现,其中我们正在遍历驱动器上可用的所有文件:

DriveInfo[] allDrives = DriveInfo.GetDrives();
foreach (DriveInfo d in allDrives)
{
     Console.WriteLine("Drive Name" + d.Name);
     Console.WriteLine(" Drive type " + d.DriveType);
     if (d.IsReady == true)
     {
         Console.WriteLine("Available space ", d.AvailableFreeSpace);
         Console.WriteLine("Total size in bytes ", d.TotalSize);
     }
}
Console.ReadLine();

在前面的代码片段中,我们正在遍历文件系统上所有可用的驱动器(即 C、D、E 等),并发布与以下相关的信息:

  • 驱动器的名称

  • 驱动器的类型,即固定、RAM、CD ROM、可移动的等等

  • 驱动器上的总可用内存大小

  • 驱动器上可用的总空闲内存

如果我们执行此代码,执行将循环遍历文件系统中的所有驱动器。一旦检索到驱动器,执行将检索有关驱动器的某些属性,例如可用空间、总大小和驱动器类型。因此,当我们执行程序时,我们将得到以下输出。请注意,我们可能不会得到所有信息,因为这还取决于目录的安全权限:

在我们执行此程序的系统中,我们只有一个 C 驱动器。因此,当程序执行时,我们正在显示 C 驱动器的属性。

driveinfo对象上还有其他属性。如果我们点击DriveInfo类的“转到定义”,我们可以看到类的属性。请访问以下链接获取更多信息:docs.microsoft.com/en-us/dotnet/api/system.io.driveinfo?view=netframework-4.7.2

文件系统中的每个驱动器都包含目录和文件。一个目录本身可以包含多个子目录和文件。如果我们需要对特定目录进行操作,我们使用 C#中的DirectoryInfo类来完成。在下面的代码片段中,我们正在创建一个DirectoryInfo类的对象,传递特定目录路径的位置:

DirectoryInfo directoryInfo = new DirectoryInfo("C:\\UCN Code Base\\Programming-in-C-Exam-70-483-MCSD-Guide\\Book70483Samples");
foreach (DirectoryInfo f in directoryInfo.GetDirectories())
{
     Console.WriteLine("Directory Name is" + f.Name);
     Console.WriteLine("Directory created time is " + f.CreationTime);
     Console.WriteLine("Directory last modified time is " + f.LastAccessTime);
}

对于directoryInfo对象,我们接下来将遍历所有子目录,并显示以下相关信息:

  • 目录的名称

  • 目录创建的时间

  • 目录最后修改的时间

当我们执行前面的程序时,我们是在 C:\\UCN Code Base\\Programming-in-C-Exam-70-483-MCSD-Guide\\Book70483Samples 文件路径上执行的。请注意,这是我们放置本书中所有章节代码库的地方。因此,当我们执行此程序时,它将遍历所有这些章节的子文件夹,并将获取诸如“目录名称”、“目录创建时间”和“目录最后修改时间”等信息。以下是我们将获得的程序输出:

使用 DirectoryInfo 对象还有其他可用操作。

检查目录是否存在

使用特定的目录路径,我们可以确定在文件系统中是否存在具有该路径的目录。在代码实现中,我们创建一个 DirectoryInfo 对象,然后使用 Exists 属性来检查目录是否存在于文件系统中:

DirectoryInfo directoryInfoExists = new DirectoryInfo("C:\\UCN Code Base\\Programming-in-C-Exam-70-483-MCSD-Guide\\Book70483Samples\\Chapter 20");
if (directoryInfoExists.Exists)
{
     Console.WriteLine("It exists");
}
else
{
     Directory.CreateDirectory("Does not exist");
}

如果我们使用在创建 DirectoryInfo 对象时提到的路径执行代码,程序执行将确定在指定路径的文件系统中是否存在目录。因此,当程序执行时,由于我们目前在 Book70483Samples 基础文件夹中没有名为 Chapter 20 的子文件夹,我们将在控制台输出窗口中看到“不存在”。以下是在控制台窗口中的全局相关输出:

在下一节中,我们将探讨如何使用 C# 在文件系统中创建目录。

创建目录

使用 DirectoryInfo 类,我们还可以在文件系统中创建新目录。以下代码演示了如何创建系统中的新目录:

Directory.CreateDirectory("C:\\UCN Code Base\\Programming-in-C-Exam-70-483-MCSD-Guide\\Book70483Samples\\Chapter 20"); 

如果执行前面的代码,它将在根文件夹中创建一个名为 Chapter 20 的子目录。

在前面的代码中,我们传递了一个绝对路径来创建目录。然而,如果我们需要在特定目录中创建子目录,我们只需执行以下代码:

DirectoryInfo subDirectory = parentDirectory.CreateSubdirectory("NameChildDirectory");

在前面的代码中,parentDirectory 是我们想要在其中创建子目录的父目录。NameChildDirectory 是我们想要给子目录取的名字。

遍历文件

使用 DirectoryInfo 类,我们还可以遍历目录中的文件。以下代码展示了我们如何遍历文件并访问它们的属性:

DirectoryInfo chapter20 = new DirectoryInfo("C:\\UCN Code Base\\Programming-in-C-Exam-70-483-MCSD-Guide\\Book70483Samples\\Chapter 20");
foreach (FileInfo f in chapter20.GetFiles())
{
     Console.WriteLine("File Name is " + f.FullName);
     Console.WriteLine("Directory created time is " + f.CreationTime);
     Console.WriteLine("Directory last modified time is " + f.LastAccessTime);
}

在前面的代码片段中,我们正在遍历 Chapter 20 目录中的文件,并显示其中的信息。在 Chapter 20 文件夹中,我们只有一个文件:dynamics365eula.txt。因此,当程序执行时,它将选择该文件并读取其中的文件信息。为了说明这一点,我们显示了文件名、文件的创建时间和最后访问时间。因此,当代码执行时,我们将获得以下输出:

现在我们对驱动器和 DirectoryInfo 有了一些了解,我们将探索一些辅助类,这些类允许我们对文件进行操作。

文件操作

在本节中,我们将介绍允许我们对目录中存在的文件进行操作的辅助类。C# 提供了 FileFileInfo 辅助类来对文件进行操作。在查看以下代码片段时,我们将查看一些我们可以使用文件对象执行的典型操作。

检查文件是否存在

这基本上涉及检查给定路径是否存在文件。这可以帮助我们编写容错代码,以便仅在确认文件存在于给定路径后读取文件:

string file = "C:\\UCN Code Base\\Programming-in-C-Exam-70-483-MCSD-Guide\\Book70483Samples\\Chapter 20\\IO Operations.txt";
if(File.Exists(file))
{
     Console.WriteLine("File Exists in the mentioned path");
}
else
{
     Console.WriteLine("File does not exists in the mentioned path");
}

File 是在 System.IO 命名空间中可用的静态类。此类提供了我们可以用来执行与文件访问相关的功能的操作。在前面的代码中,我们已声明了一个文件路径,并使用静态的 File 类检查文件是否确实存在于给定的路径中。

将文件从一个位置移动到另一个位置

在这个操作中,我们基本上是将文件从一个位置剪切到另一个位置。以下代码片段显示了如何执行此操作:

string sourceFileLocation = "C:\\UCN Code Base\\Programming-in-C-Exam-70-483-MCSD-Guide\\Book70483Samples\\Chapter 20\\IO Operations.txt";
 string targetFileLocation = "C:\\UCN Code Base\\Programming-in-C-Exam-70-483-MCSD-Guide\\Book70483Samples\\Chapter 21\\New File.txt";
if (File.Exists(sourceFileLocation))
{
     File.Move(sourceFileLocation, targetFileLocation); 
}
else
{
     Console.WriteLine("File does not exists in the mentioned path");
}

在前面的代码片段中,我们首先检查文件是否存在于特定位置。如果文件存在于该位置,我们将将其复制到另一个位置。

当代码执行时,我们会注意到文件是从源位置剪切到目标位置的。

从一个位置复制文件到另一个位置

在这个操作中,我们基本上是将文件从一个位置复制到另一个位置。请注意,Move 操作将删除源文件夹中存在的文件。然而,Copy 操作将把源文件夹中存在的文件复制到目标文件夹。以下代码片段显示了如何执行此操作:

if (File.Exists(targetFileLocation))
{
     File.Copy(targetFileLocation, sourceFileLocation);
}
else
{
     Console.WriteLine("File does not exists in the mentioned path");
}

当代码执行时,我们会看到文件是从源文件位置复制到目标文件位置路径的。

删除文件

在这个操作中,我们将删除指定位置存在的文件:

File.Delete(sourceFileLocation);

当代码执行时,我们会看到文件已从源文件位置删除。一旦代码执行,我们会看到在 sourceFileLocation 路径中指定的文件已被删除。

请注意,与 File 类一起工作的操作与 FileInfo 类以相同的方式工作。我们可以通过 FileInfo 类执行与 File 类相同的实现。

在所有前面的示例中,我们一直在硬编码文件的路径属性。这不是一种推荐的做法,因为它容易出错。例如,如果你查看任何文件的实际路径并将其与我们需要在程序中提供的路径进行比较,你会注意到一个差异:

  • 实际路径:C:\File Location\Chapter 20\Sample.txt

  • 在程序中需要指定的路径:C:\\File Location\\Chapter 20\\Sample.txt

我们需要在路径中提供一些额外的斜杠。此外,当我们组合文件夹路径和文件路径时,我们需要使用额外的\将它们连接起来。出于这些原因,硬编码路径不是一种推荐的做法。更好的方法是使用Path辅助类。以下代码展示了如何使用它:

string sourceFileLocation = @"C:\UCN Code Base\Programming-in-C-Exam-70-483-MCSD-Guide\Book70483Samples\Chapter 20";
string fileName = @"IO Operations.txt";
string properFilePath = Path.Combine(sourceFileLocation, fileName);
Console.WriteLine(Path.GetDirectoryName(properFilePath));
Console.WriteLine(Path.GetExtension(properFilePath));
Console.WriteLine(Path.GetFileName(properFilePath));
Console.WriteLine(Path.GetPathRoot(properFilePath));

在前面的代码实现中,我们声明了一个文件夹路径。请注意在文件名前使用&。这是一个转义字符,允许我们不在文件夹路径结构中指定额外的\。我们还声明了一个文件名,现在我们正在使用辅助静态类Path将它们组合在一起。一旦我们组合了它们,我们就检索结果文件路径中的属性。如果代码执行,我们将得到以下输出:

图片

让我们看看输出:

  • Path.GetDirectoryName:这个方法返回组合路径文件的目录名。请注意,它包含完整的绝对目录路径。

  • Path.GetExtension:这个方法返回组合路径文件的文件扩展名。在这种情况下,它是一个.txt文件。

  • Path.GetFileName:这个方法返回文件名。

  • Path.GetPathRoot:这个方法返回文件路径的根。在这种情况下,它是C:,因此在输出中提到。

既然我们已经了解了文件的基本操作,我们将看看如何访问和修改文件的内容。为此,我们将查看FileStream中可用的操作。

流对象

文件的主要操作与读取、写入、检索和更新文件中的文本有关。在.NET 中,这些操作是通过 I/O 操作中的字节交换来执行的。这个字节序列是流,在.NET 中,它使用抽象的Stream类来表示。这个类是.NET 中所有 I/O 操作(如FileStreamMemoryStream)的基础。

在.NET 中,我们可以使用流执行以下操作:

  • 在流对象中读取数据

  • 将数据写入流对象

  • 在流对象中搜索或查找相关信息

让我们看看使用流对象实现的不同操作。在下一节中,我们将通过FileStream对象,它有助于对文件对象进行操作。

FileStream

使用FileStream对象,我们可以将信息读取并写回到目录中的文件。这是通过使用我们在本章前一部分讨论的FileFileInfo对象来完成的。

让我们通过以下代码示例来了解如何将信息写入文件:

string sourceFileLocation = @"C:\UCN Code Base\Programming-in-C-Exam-70-483-MCSD-Guide\Book70483Samples\Chapter 20\Sample.txt";
using (FileStream fileStream = File.Create(sourceFileLocation))
{
    string myValue = "MyValue";
    byte[] data = Encoding.UTF8.GetBytes(myValue);
    fileStream.Write(data, 0, data.Length);
}

在前面的代码实现中,我们正在执行以下操作:

  • 打开位于指定位置的Sample.txt文件

  • 从它创建一个File对象,然后将文件中的数据转换为FileStream对象

  • 使用 FileStream 对象中可用的 Write 操作将数据写入文件

请注意,我们正在使用 using 块来处理 FileStream 对象。因此,Dispose 方法将自动为 FileStream 对象调用。因此,非托管资源中的内存将自动回收。

请注意,在前面的实现中,我们在将数据写入 FileStream 对象之前对字符串值进行了编码。

处理相同功能的另一种方法是使用 StreamWriter 辅助类。以下代码实现展示了如何使用 StreamWriter 辅助类来处理:

string sourceFileLocation = @"C:\UCN Code Base\Programming-in-C-Exam-70-483-MCSD-Guide\Book70483Samples\Chapter 20\Sample.txt";
using (StreamWriter streamWriter = File.CreateText(sourceFileLocation))
{
    string myValue = "MyValue";
    streamWriter.Write(myValue);
} 

在选择两个辅助类之间,我们需要考虑我们正在处理的数据。FileStream 对象处理字节数组。然而,StreamWriter 类实现了 TextWriter 接口。它只处理字符串数据,并自动将其编码为字节,这样我们就不必显式地执行此操作。然而,在当我们使用 FileStream 类时,我们必须对字节进行编码和解码,以将数据转换为字符串表示形式。

在下一节中,我们将探讨一些与文件 I/O 操作相关的最佳实践。

异常处理

在任何实际场景中,可能有多个人同时处理同一个文件。在 C# 中使用线程,我们可以在对资源执行特定操作时锁定对象。然而,在文件系统中,文件上的锁定不可用。

因此,完全有可能程序正在访问的文件已经被另一个应用程序或用户移动或完全删除。C# 提供了一些异常处理机制,我们可以用它们更好地处理这类情况。请参考以下代码实现,其中我们处理了一个异常:

private static string ReadFileText()
{
    string path =@"C:\UCN Code Base\Programming-in-C-Exam-70-483-MCSD-          Guide\Book70483Samples\Chapter 20\Sample.txt";
     if (File.Exists(path))
     {
         try
         {
             return File.ReadAllText(path);
         }
         catch (DirectoryNotFoundException) 
         {
             return string.Empty; 
         }
         catch (FileNotFoundException) 
         {
             return string.Empty; 
         }
     }
     return string.Empty;
}

从功能角度来看,在前面的代码中,我们正在读取给定位置中的文件。我们检索文件中的所有文本,并将其传递回调用函数。请注意,在代码实现中我们还使用了以下最佳实践:

  • 在代码中,我们首先使用 Exists 方法检查文件是否存在于目录位置。如果文件存在,我们继续从文件中提取数据。

  • 尽管我们在继续之前已经检查了文件是否存在,但在代码移动到下一个块之后,仍然有一些情况下文件被移除、删除或变得不可访问。为了处理这些情况,我们捕获了 DirectoryNotFoundExceptionFileNotFoundException 异常。当路径中指定的目录不再存在时,会抛出 DirectoryNotFoundException。当路径中指定的文件不再存在时,会抛出 FileNotFoundException

现在我们已经对如何在文件上执行 I/O 操作有了相当的了解,我们将查看调用外部 Web 服务并从它们获取响应的示例。

从网络读取数据

在开发 .NET Framework 应用程序时,我们会遇到需要调用外部 API 获取所需数据的多种场景。.NET Framework 提供了一个 System.Net 命名空间,它包含大量辅助类,使我们能够执行这些操作。

在本节中,我们将通过一个示例来了解如何使用 WebRequestWebResponse 类调用外部 API 并处理它们的响应。我们将调用一个外部页面,并处理我们从调用中获得的响应。我们还将查看代码示例,了解如何对外部 Web 服务器进行异步调用。

WebRequest 和 WebResponse

WebRequest 是由 .NET Framework 提供的一个抽象基类,用于从互联网访问数据。使用这个类,我们可以向特定的 URL 发送请求,例如 www.google.com

另一方面,WebResponse 是一个抽象类,它提供了 WebRequest 类调用的 URL 的响应。

WebRequest 对象是通过调用静态的 Create 方法创建的。在方法中,我们传递要请求的地址 URL。请求检查我们传递给它的地址,并选择一个协议实现,例如 HTTP 或 FTP。根据传递的 Web 地址,当创建 WebRequest 对象时,返回适当的派生类实例,例如 HttpWebRequest 用于 HTTP 或 FtpWebRequest 用于 FTP。WebRequest 类还允许我们指定一些其他属性,例如身份验证和内容类型。让我们通过以下代码实现来了解这个类:

WebRequest request = null;
HttpWebResponse response = null;
Stream dataStream = null;
StreamReader reader = null;
try
{
    request = WebRequest.Create("http://www.google.com/search?q=c#");
    request.Method = "GET";
    response = (HttpWebResponse)request.GetResponse();
    dataStream = response.GetResponseStream();
    reader = new StreamReader(dataStream);
    Console.WriteLine(reader.ReadToEnd());
}
catch(Exception ex)
{
    Console.WriteLine(ex.ToString());
}
finally
{
    reader.Close();
    dataStream.Close();
    response.Close();
}

在前面的代码实现中,我们为 http://google.com 创建了一个 WebRequest 对象。我们在 HTTP 请求中使用 GET 方法,并通过 URL 本身嵌入的参数传递。由于协议是 HTTP,我们将 WebResponse 对象转换为 httpWebResponse

一旦我们捕获了响应,我们将从 google.com 获取的字节流检索到 Stream 对象中,然后使用 StreamReader 对象从 google.com 获取响应。

这里需要注意的一个非常重要的事情是,在 finally 块中,我们正在关闭在 try...catch 块中创建的所有响应、流和读取器对象。这是至关重要的:因为我们正在处理非托管资源,为了更好的性能,回收内存是很重要的。

为了进一步阅读,请参考以下来自微软的博客,其中讨论了我们在调用 WebRequest 对象时可以设置的不同参数:docs.microsoft.com/en-us/dotnet/api/system.net.webrequest?view=netframework-4.7.2

在前述代码中,我们正在对外部 Web 服务进行同步调用并等待响应。然而,在实际场景中,这可能不是理想的实现。

我们调用的外部服务器可能需要一些时间来发送响应。因此,如果我们的应用程序在等待响应期间继续等待,那么应用程序的响应性将受到挑战。为了避免此类场景,我们可以使 I/O 调用异步。在下一节中,我们将学习为什么我们需要考虑进行异步 I/O 调用以及它们如何在代码中实现。

异步 I/O 操作

当我们正在学习 WebRequestWebResponse 部分时,我们编写了一个程序,在其中我们调用了 google.com。用非常粗略的话来说,当请求被发送时,它会被 Google 服务器接收,然后分配一个线程来处理这个请求。该线程然后将响应发送给调用机器。

理论上,然而,总是存在服务器上可用的空闲线程较少的可能性。此外,还有可能服务器可能需要很长时间来完成请求并发送响应给调用者。

这里的挑战是我们必须设计调用者和服务器之间的通信方式,以确保应用程序的性能和响应性不受影响。我们通过使调用异步来实现这一点。

如果我们使这些操作异步,我们可以确信,当服务器正在处理请求并发送响应时,我们的应用程序保持响应,用户可以继续使用应用程序。我们通过使用 async/await 关键字来实现这一点。

任何用 C# 编写的异步方法都必须具有以下特征:

  • 方法定义必须包含 async 关键字,以指示该方法以异步方式执行。

  • 方法必须具有以下返回类型之一:

    • Task:如果函数没有返回语句

    • Task<TResult>:如果函数有一个返回语句,返回的对象是类型 TResult

    • Void:如果函数是一个事件处理器

  • 在函数中,我们执行对外部 Web 服务器的异步调用。

  • 函数可能包含一个 await 语句。await 语句基本上告诉编译器,应用程序必须等待该语句,直到外部 Web 服务器执行异步过程完成。

让我们通过以下代码实现来逐一分析这些点:

async Task<int> ExecuteOperationAsync()
{
    using (HttpClient client = new HttpClient())
    {
           Task<string> getStringTask = client.GetStringAsync("http://google.com");
           ExecuteParallelWork();
           string urlContents = await getStringTask;
           return urlContents.Length;
    } 
}

请参考以下前述代码:

  • 我们定义了一个名为ExecuteOperationAsync的函数。为了表明函数的异步行为,我们在函数定义中使用了async关键字。

  • 我们将函数的返回类型声明为Task<int>,这表示函数将返回一个类型为int的对象。

  • 我们声明了一个HttpClient辅助类的对象,并对其进行了http://google.com的调用。我们正在异步地发出请求调用。

  • 为了确保应用程序继续执行其他工作,我们调用了ExecuteParallelWork函数,这样,直到响应到达,应用程序不会停止处理。

  • 我们已经使用了一个await语句,这样编译器会在那个点停止并等待异步请求调用的响应。一旦收到响应,它会检查响应字符串的长度并将结果返回给调用函数。

现在我们对 I/O 操作中异步调用的工作原理有了相当的了解,我们将探讨如何在 I/O 操作中使用它们。

在下一节中,我们将探讨如何使用这个关键字使不同的 I/O 操作异步化。

文件上的异步操作

在本节中,我们将学习如何异步地对文件执行 I/O 操作。当我们要写入文件的数据量很大时,这可能会很有帮助。

以下代码实现展示了如何异步地将数据写入文件。请注意,我们必须使用FileStream对象来执行异步文件 I/O 操作:

public async Task CreateFile()
{
   string path =@"C:\UCN Code Base\Programming-in-C-Exam-70-483-MCSD-              Guide\Book70483Samples\Chapter 20\New.txt";
   using (FileStream stream = new FileStream(path,FileMode.Create,
   FileAccess.Write, FileShare.None, 4096, true))
   {
       byte[] data = new byte[100000];
       new Random().NextBytes(data);
       await stream.WriteAsync(data, 0, data.Length);
   }
}

在前面的代码实现中,我们正在创建一个给定目录位置的新文件。调用函数不需要返回值,因此我们已将返回类型设置为Task。为了创建文件并向其写入数据,我们使用了FileStream对象。有关类构造函数中传入的属性详细分析的更多信息,请参阅以下链接:docs.microsoft.com/en-us/dotnet/api/system.io.filestream?view=netframework-4.7.2

创建对象后,我们生成一个随机字节序列,然后将其异步写入FileStream对象。

对于与异步调用 Web 请求相关的代码实现,我们可以参考上一个示例中的实现,其中我们创建了一个名为HttpClient的对象并进行了异步调用。

在下一节中,我们将学习如何异步且并行地执行多个 I/O 操作。

在应用程序必须等待并行执行的不同函数完成的情况下,这非常有用。

使用await语句进行并行异步调用

在编写程序时,我们经常遇到必须等待来自不同异步调用结果的情况。当处理依赖于来自外部介质(如 Web 服务)的多个响应时,这是必需的。让我们看看以下代码示例:

public async Task ExecuteMultipleRequestsInParallel()
{
    HttpClient client = new HttpClient();
    Task google = client.GetStringAsync("http://www.google.com");
    Task bing = client.GetStringAsync("http://www.bing.com");
    Task yahoo = client.GetStringAsync("http://yahoo.com/");
    await Task.WhenAll(google, bing, yahoo);
}

在前面的代码中,我们正在执行对不同服务器的异步调用。假设我们必须等待所有这些的输出,我们才能继续;我们可以使用 WhenAll 语句。WhenAll 语句将确保在处理可以继续之前,执行将等待来自所有三个异步调用的响应。

摘要

在本章中,我们学习了如何在 C# 中执行与文件和网络相关的 I/O 操作。我们回顾了提供执行 I/O 操作辅助类的命名空间。我们从可以在驱动器和目录上执行的基本操作开始。我们查看了一些代码示例,这些代码示例可以用来遍历目录中的文件。

然后我们查看了一些辅助类,这些类帮助我们进行文件 I/O 操作。我们查看了一些 FileFileInfo 类,这些类帮助我们创建、复制、移动和删除文件。我们查看了一些处理目录和文件路径的最佳实践。然后我们查看了一些 stream,即字节序列,它允许我们编辑文件中存在的信息。然后我们查看了一些文件异常处理的最佳实践。

之后,我们查看了一些处理网络 I/O 操作的辅助类。我们查看了一个代码示例,其中我们在互联网上进行了 HTTP 调用。然后我们查看了一个代码实现示例,其中我们进行了异步 I/O 调用。在可能的情况下,始终使用异步操作是有益的,因为它对应用程序的整体性能更有利。我们回顾了在 I/O 和互联网上执行异步操作的代码示例。

在下一章中,我们将探讨如何在 C# 中使用 LINQ 查询来高效地查询不同的数据源,例如 XML 和 SQL。通过代码示例,我们将探索 LINQ 的不同组件,以及在使用 LINQ 查询时可以使用的不同运算符。

问题

  1. 我们应该使用哪种语法来向文件追加文本?

    1. File.CreateText

    2. FileInfo.Create

    3. File.Create

    4. File.AppendText

  2. 如果应用程序需要等待来自多个来源的异步调用,我们应该使用哪种语法?

    1. async

    2. await

    3. Task

    4. Task.WhenAll

  3. 以下哪个语句是不正确的?

    1. StreamWriter 只与文本一起工作;然而,FileStream 与字节一起工作。

    2. 我们可以在 .NET 中锁定文件。

    3. 如果我们有一个异步函数,它可以有三种返回类型:TaskTask<TResult>Void

    4. 当文件路径中的目录不再可用时,会抛出 DirectoryNotFoundException

答案

  1. File.AppendText.

  2. Task.WhenAll.

  3. 除了“B”之外,即我们可以锁定文件,所有其他语句都是正确的。

第十五章:使用 LINQ 查询

在 .NET 中,我们经常需要从不同的数据源查询数据,例如 XML、SQL 和 Web 服务。在 .NET 的早期版本中,我们使用简单的字符串执行这些操作。这种方法的主要问题是它缺乏任何智能感知,并且在实现上相当繁琐。这些查询也因查询数据的数据源不同而彼此不同,从而增加了代码的复杂性。

为了克服这些问题,LINQ 首次在 .NET 3.5 版本中引入。与传统的数据访问方法相比,LINQ 引入了一种简单且一致的方法,用于查询和修改不同类型的数据源中的数据,例如 XML,甚至内存中的数据结构,如数组。在 LINQ 中,我们使用查询表达式来查询数据。查询表达式使我们能够使用最少的代码对数据进行过滤、排序和分组操作。

本章我们将探讨以下主题:

  • 介绍 LINQ

  • 理解使 LINQ 成为可能的语言特性

  • 理解 LINQ 查询运算符

  • 理解 LINQ 的幕后原理

  • 使用 LINQ 查询 XML

到本章结束时,我们将学习如何在操作 XML 文件时使用 LINQ 查询。我们将探讨 LINQ 查询如何帮助我们编写、查询和修改 XML 文件。

技术要求

与本书中前面章节介绍的内容一样,本书中的程序将在 Visual Studio 2017 中开发。

本章的示例代码可以在 GitHub 的 Chapter 15 目录中找到(github.com/PacktPublishing/Programming-in-C-Sharp-Exam-70-483-MCSD-Guide/tree/master/Book70483Samples)。

介绍 LINQ

在本节中,我们将学习 LINQ 的基础知识。我们可以使用 LINQ 查询针对任何对象集合,唯一条件是对象必须支持 IEnumerable 或泛型 IEnumerable<T> 接口。

此外,我们计划在其中使用 LINQ 的项目的目标框架版本必须是 3.5 或更高版本。

在下一节中,我们将探讨查询,它是 LINQ 操作的基础。我们将研究查询的不同组成部分,并了解它们在 .NET 中的构建方式。

查询

查询是一个字符串表达式,用于从数据源检索数据。该表达式通常与特定的数据源相关,例如 SQL 或 XML,并且通常用该数据源的语言表达。然而,使用 LINQ,我们可以开发一个适用于不同数据源的通用编码模式。该模式分为三个部分:

  • 获取数据源

  • 创建查询

  • 执行查询

以下代码展示了三种操作在最简单形式下的示例:

// 1\. Obtaining the data source.
 int[] numbers = new int[3] { 0, 1, 2};
// 2\. Query creation.
var numQuery =
from num in numbers
where (num % 2) == 0
select num;
// 3\. Query execution.
foreach (int num in numQuery)
{
     Console.Write("{0,1} ", num);
}

在前面的代码示例中,我们创建了一个大小为 3 的整数数组。由于它实现了 IEnumerable<int> 接口,我们将能够在数组上实现 LINQ。在下一步中,我们创建了一个查询,用于过滤数组中存在的偶数。最后,在第三步中,我们正在遍历查询执行的结果并打印它。

在前面的示例中,我们使用数组作为数据源。数组已经支持 IEnumerableIEnumerable <T> 接口。然而,在某些情况下,情况可能并非总是如此。例如,当我们从 XML 文件等源读取数据源时,我们需要使用 LINQ 将数据加载到内存中作为可查询的类型。在这种情况下,我们可以使用 XElement 类型。以下是这个语法的示例:

// Create a data source from an XML document. // 
using System.Xml.Linq; 
XElement students = XElement.Load(@"c:\students.xml"); 

在前面的代码示例中,我们已经将数据从 XML 文件加载到实现了 IQuerable 接口的 XElement 对象中。现在,基于此,我们可以轻松编写 LINQ 查询以执行任何操作。

在我们继续前进并了解更多关于 LINQ 的内容之前,我们必须了解 C# 的内置特性,这些特性有助于我们实现 LINQ 查询。在下一节中,我们将讨论这些特性之一。

理解使 LINQ 成为可能的语言特性

C# 中有几个特性对于实现 LINQ 是必要的,或者有助于我们有效地使用 LINQ 查询。这些是我们将在本章中讨论的一些主题:

  • 隐式类型变量

  • 对象初始化语法

  • Lambda 表达式

  • 扩展方法

  • 匿名类型

隐式类型变量

在 C# 中,我们通常使用静态类型变量。这意味着编译器在编译时知道变量的类型。因此,如果它发现任何可能产生错误的操作,它将在编译时将其突出显示。例如,参考以下代码:

 int i = 1;
 FileStream f = new FileStream("test.txt", FileMode.Open);
 string s = i + f; // This line gives a compile error

我们将观察到编译器会给我们一个编译时错误。以下是这个截图:

图片

如错误描述所示,编译器确定这两个变量的类型不支持该操作,因此抛出此错误。这被称为显式类型。

隐式类型在 C# 3.0 版本中添加。在隐式类型中,编译器在编译时自动识别变量的类型。编译器根据在声明时分配给变量的值来完成此操作。然后,编译器将变量严格类型化为该特定类型。

在 C# 中,我们使用 var 关键字进行隐式类型。以下显示了之前编写的相同代码,但使用了隐式类型:

var i = 1;
FileStream f = new FileStream("test.txt", FileMode.Open);
string s = i + f; // This line gives a compile error

请注意,尽管我们没有显式指定变量的类型为 int,但基于分配给它的值 1,编译器会推断出该变量的类型必须是 int。在这种情况下,它将给出相同的编译时错误。以下是该截图:

隐式 Type 在返回类型在编译时确定的 LINQ 查询情况下有所帮助。除了是强制声明之外,隐式类型还可以提高代码可读性。为了说明这个例子,请参考代码中的以下声明。

注意,在声明中,我们没有声明实际类型,而是使用了 Type 变量,从而提高了代码的可读性:

Dictionary<string, IEnumerable<Tuple<Type, int>>> implicitData = new Dictionary<string, IEnumerable<Tuple<Type, int>>>();
var implicitData = new Dictionary<string, IEnumerable<Tuple<Type, int>>>();

在下一节中,我们将探讨初始化器以及它们如何提高代码可读性。

对象初始化语法

C# 中的初始化器帮助我们结合对象的创建和设置其属性。让我们参考以下代码示例。假设我们有一个 Student 类,其声明如下:

public class Student
{
     public int rollNum { get; set; }
     public string Name { get; set; }
}

现在,假设我们需要为这个类声明一个对象。以传统方式,不使用对象初始化器,我们可以这样做:

Student p = new Student();
p.rollNum = 1;
p.Name = "James";
Student p2 = new Student();
p2.rollNum = 2;
p2.Name = "Donohoe";

注意,在前面的代码中,我们必须分别指定 pp2 对象的创建和设置它们各自的属性。

使用对象初始化语法,我们可以在一个语句中结合对象的创建和属性的设置。例如,如果我们使用对象初始化来执行之前所做的相同功能,我们可以使用以下语法:

// Creating and initializing a new object in a single step
Person p = new Person
{
    FirstName ="James",
    LastName = "Doe"
};

请注意,尽管对象初始化的使用不是必需的,并且不会为我们提供任何额外的功能或特性,但它可以提高我们代码的可读性。如果需要创建相同对象的集合,代码也可以得到增强。以下是该语法的示例:

var students = new List<Student>
{
    new Student
    {
        rollNum = 1,
        Name = "James"
    },
    new Student
    {
        rollNum = 2,
        Name = "Donohoe"
    }
};

注意,对象初始化语法使代码的可读性大大提高,并且在处理匿名类型的情况下,实际上它是必需的。在下一节中,我们将探讨 lambda 表达式。

Lambda 表达式

Lambda 表达式是在 C# 3.0 版本中引入的。Lambda 表达式基于匿名函数,并且 lambda 表达式是表示匿名方法的更简短方式。

在第五章的“使用匿名函数初始化委托”部分,我们探讨了如何在 C# 中使用 delegate 关键字创建匿名函数。简而言之,为了回顾,使用匿名方法,我们可以在某些代码中创建内联方法,将其分配给变量,并传递它。

在 第五章 的 创建和实现事件和回调 部分,在 Lambda 表达式 节中,我们探讨了如何将匿名函数转换为等效的 lambda 表达式。但是,为了回顾,让我们看一下以下代码示例,其中我们将首先创建一个匿名函数,然后为相同的函数创建一个 lambda 表达式:

Func<int, int> anonymousFunc = delegate (int y)
{
    return y * 5;
};
Console.WriteLine(anonymousFunc(1));'. 

在前面的代码中,我们声明了一个 Func<T,T> 格式的委托函数。这意味着这个函数接受 int 类型的输入并返回一个整数输出。因此,前面操作的结果将是 1 * 5,即 5

现在,如果我们需要使用 lambda 表达式编写相同的代码,我们可以使用以下代码语法:

Func<int, int> anonymousFuncLambda = y => y * 5; 
Console.WriteLine(anonymousFuncLambda(1));

请注意在 lambda 表达式中 => 符号的用法。这个符号翻译成“变为”或“对于”。

如果我们执行这两个代码块,我们会注意到操作的结果是相同的。然而,使用 lambda 表达式,我们最终得到更干净的代码,并避免了大量的代码输入。在下一节中,我们将探讨扩展方法。

扩展方法

C# 中的扩展方法允许我们在不修改它们或使用继承的情况下向现有类型添加方法。扩展方法定义在 System.Linq.Enumerables 命名空间中。

扩展方法始终定义在静态类中,并且作为静态方法。除此之外,它还使用 this 关键字来表明它是一个扩展方法。以下是一个代码示例,其中我们在 int 类型上声明了多个扩展方法。为了将调用对象识别为传递给函数的第一个参数,我们使用了 this 关键字:

public static class IntExtensions
{
    public static int MultiplyExtension(this int x, int y)
    {
        return x * y;
    }
}
int z = 6;
Console.WriteLine(z.MultiplyExtension(5));
Console.ReadLine();     

一旦执行前面的代码,我们得到 30 的输出,这是当调用对象 6 乘以在扩展方法中声明的 5 时的输出。在下一节中,我们将探讨匿名类型。

匿名类型

匿名类型是对象初始化器和隐式类型化的组合。匿名类型是一种没有名称的类型。使用匿名类型,通过使用 varnew 关键字,我们可以创建一个对象,而不必定义其类型或类。匿名类型变量的类型是根据其初始化时的值推断出来的。此外,匿名变量的属性是只读的,这意味着在变量初始化后,我们无法更改它们的值。

以下是一些示例代码语法,其中我们声明了一个匿名类型的对象。在这个对象中,我们指定了三个属性,PropertyNum1PropertyNum2PropertyNum3

var anonymousType = new
{
    PropertyNum1 = "One",
    PropertyNum2 = 2,
    PropertyNum3 = true
};
Console.WriteLine(anonymousType.GetType().ToString());             

一旦代码执行,我们得到以下输出:

图片

注意,由于我们正在显示匿名类型的数据类型,对于其各自的属性,执行将根据分配给属性的值显示类型。因此,我们看到的输出是StringInt32Boolean

在下一节中,我们将探讨一些我们在编写 LINQ 查询时经常使用的标准 LINQ 运算符。

理解 LINQ 查询运算符

查询部分所述,每个 LINQ 操作分为三个部分。在第一部分,我们从数据源获取数据。在第二部分,我们对数据进行操作,最后在最后一部分,我们提取数据。

在进行第二部分,即对数据进行操作时,有一些标准运算符我们可以使用。这些运算符帮助我们实现一致的经验和易于适应不同数据源的代码库。

一些标准查询运算符包括SelectSelectManyJoinOrderByAverageGroupByMaxMinWhere。在以下章节中,我们将查看一些代码并学习这些运算符中的一些是如何工作的。

选择和 SelectMany

当我们在 LINQ 中需要从集合中选择一些值时,我们使用Select。例如,在以下代码语法中,我们声明了一个整数数组,并选择了数组中所有的数字:

int[] numbers = new int[3] { 0, 1, 2 };
var numQuery =
from num in numbers
select num;

foreach(var n in numQuery)
{
     Console.Write(n);
}

因此,如果执行前面的代码,它将打印出数组中所有的数字。以下是在前面的代码片段中的输出:

当我们需要从一个集合中选择一个值时,我们使用Select。然而,在需要从嵌套集合中选择值的情况下,即集合的集合,我们使用SelectMany运算符。参考以下代码示例,其中我们使用SelectMany运算符从字符串数组中检索字符串对象中的单个字符:

string[] array =
{
     "Introduction",
     "In",
     "C#"
};
var result = array.SelectMany(element => element.ToCharArray());
foreach (char letter in result)
{
     Console.Write(letter);
}

程序的输出如下:

在前面的程序中,数据源是一个字符串数组。现在,字符串又是一个字符数组。使用SelectMany,我们直接遍历了IntroductionInC#字符串中的字符。因此,使用SelectMany,我们可以用比其他情况下更少的语句执行操作。

在下一节中,我们将查看Join运算符,它帮助我们连接两个集合。

连接运算符

LINQ 中的join运算符帮助我们连接两个可能通过一个共同属性相互关联的集合。参考以下代码示例,它将更好地解释这一点。假设我们有两个类对象,一个代表ClassDetail,另一个代表在班级中学习的Students

public class Student
{
     public int rollNum { get; set; }
     public string Name { get; set; }
     public string classID { get; set; }
}
public class ClassDetail
{
     public string classID { get; set; }
     public string className { get; set; }
}

请注意,在ClassDetail类中,我们有关于班级本身的详细信息,例如ClassIDClassName。在Student类中,我们有关于学生的详细信息,例如rollNumNameClassID。在Student类中,ClassID属性指的是学生目前正在学习的班级。我们将使用这个属性来链接ClassDetailStudent的集合。

以下代码表明我们如何将StudentClass的两个集合项进行连接:

 List<ClassDetail> classNames = new List<ClassDetail>();
 classNames.Add(new ClassDetail { classID = "1", className = "First Standard" });
 classNames.Add(new ClassDetail { classID = "2", className = "Second Standard" });
 classNames.Add(new ClassDetail { classID = "3", className = "Third Standard" });
 List<Student> students = new List<Student>();
 students.Add(new Student { rollNum = 1, classID = "1", Name = "Sia Bhalla" });
 students.Add(new Student { rollNum = 2, classID = "2", Name = "James Donohoe" });
 students.Add(new Student { rollNum = 3, classID = "1", Name = "Myra Thareja" });
 var list = (from s in students
 join d in classNames on s.classID equals d.classID
 select new
 {
     StudentName = s.Name,
     ClassName = d.className
 });
 foreach (var e in list)
 {
     Console.WriteLine("Student Name = {0} , Class Name = {1}", e.StudentName, e.ClassName);
 }

在前面的代码中,我们创建了两个集合列表,一个是Student,另一个是ClassDetail。然后,使用join操作符,我们根据一个公共属性ClassID将两个列表结合起来。在结果项中,我们保存了学生的名字和班级的名字。如果执行此代码,我们将得到以下输出:

在下一节中,我们将查看orderby操作符。

orderby操作符

orderby操作符用于按升序或降序排序你的数据。以下代码展示了如何按降序排序数据:

int[] dataElements = { 8, 11, 6, 3, 9 };
var resultOrder = from dataElement in dataElements
                  where dataElement > 5
                  orderby dataElement descending
                  select dataElement;
Console.WriteLine(string.Join(", ", resultOrder));

在前面的代码中,我们声明了一个整数数组。现在,从这个数组中,我们选择所有大于5的数字。选择后,我们使用orderby子句按降序排序它们。最后,我们打印它们。以下是在程序执行时的输出:

注意,在前面的输出中,数字是降序排列的,并且所有数字都大于5。在下一节中,我们将查看 LINQ 中的Average操作符。

平均值

在 LINQ 中,我们有时需要计算集合中任何数值项的Average值。为了执行此操作,我们可以使用Average操作符。让我们通过以下代码示例来了解它是如何工作的。假设我们有一个以下类:

 public class Student
 {
     public int rollNum { get; set; }
     public string Name { get; set; }
     public string classID { get; set; }
     public int age { get; set; }
 }

现在,我们已经为student类创建了以下对象:

List<Student> students = new List<Student>();
students.Add(new Student { rollNum = 1, classID = "1", Name = "Sia Bhalla", age = 1 });
students.Add(new Student { rollNum = 2, classID = "2", Name = "James Donohoe", age = 35 });
students.Add(new Student { rollNum = 3, classID = "1", Name = "Myra Thareja", age = 8 }); 

为了计算学生的平均年龄,我们可以使用以下代码语句:

var avg = students.Average(s => s.age); 

如果我们执行此代码,我们将得到以下输出:

在下一节中,我们将查看GroupBy操作符。

GroupBy

当我们需要根据某个键值对元素进行分组时,我们在 LINQ 中使用GroupBy子句。每个组由一个相应的键和一个分组元素的集合表示。

为了解释这个操作符,我们将考虑本章中一直在讨论的相同的ClassStudent示例。让我们考虑一个场景,其中我们需要根据学生目前注册的班级对学生进行分组。

为了回顾,以下是Student类的结构:

public class Student
{
     public int rollNum { get; set; }
     public string Name { get; set; }
     public string classID { get; set; }
     public int age { get; set; }
}

假设我们在Student类中有以下对象:

List<Student> students = new List<Student>();
students.Add(new Student { rollNum = 1, classID = "1", Name = "Sia Bhalla", age = 1 });
students.Add(new Student { rollNum = 2, classID = "2", Name = "James Donohoe", age = 35 });
students.Add(new Student { rollNum = 3, classID = "1", Name = "Myra Thareja", age = 8 });
students.Add(new Student { rollNum = 4, classID = "3", Name = "Simaranjit Bhalla", age = 33 });
students.Add(new Student { rollNum = 5, classID = "3", Name = "Jimmy Bhalla", age = 33 });
students.Add(new Student { rollNum = 6, classID = "2", Name = "Misha Thareja", age = 35 });

为了按班级 ID 对学生进行分组,我们使用以下代码:

 List<Student> students = new List<Student>();
 students.Add(new Student { rollNum = 1, classID = "1", Name = "Sia Bhalla", age = 1 });
 students.Add(new Student { rollNum = 2, classID = "2", Name = "James Donohoe", age = 35 });
 students.Add(new Student { rollNum = 3, classID = "1", Name = "Myra Thareja", age = 8 });
 students.Add(new Student { rollNum = 4, classID = "3", Name = "Simaranjit Bhalla", age = 33 });
 students.Add(new Student { rollNum = 5, classID = "3", Name = "Jimmy Bhalla", age = 33 });
 students.Add(new Student { rollNum = 6, classID = "2", Name = "Misha Thareja", age = 35 });
 var groupedResult = from s in students
 group s by s.classID;
 //iterate each group 
 foreach (var classGroup in groupedResult)
 {
     Console.WriteLine("Class Group: {0}", classGroup.Key); 
     foreach (Student s in classGroup) 
     Console.WriteLine("Student Name: {0}", s.Name);
 }

在前面的代码中,我们创建了六个学生类的对象,然后尝试按ClassID对他们进行分组。分组完成后,我们正在遍历创建的组。我们正在打印Key,在这种情况下,是班级 ID 和学生的名字。

如果我们执行代码,我们会得到以下输出:

图片

在前面的代码中,学生被按不同的班级分组。它显示了每个班级中存在的不同学生。

通过这种方式,我们已经看到了 LINQ 中操作符的工作方式。在下一节中,我们将探讨使 LINQ 查询成为可能的幕后接口。

理解 LINQ 背后的原理

现在我们对 LINQ 查询有了相当的了解,让我们考虑一个需要改变 LINQ 工作方式的场景。为了解释,让我们考虑一个需要更改查询中Where子句内置实现的场景。

为了做到这一点,我们首先需要了解Where子句在 LINQ 查询中是如何工作的。我们可以通过查看 Visual Studio 中Where子句的定义来实现这一点。以下是如何显示Where子句定义的示例:

public static IEnumerable<TSource> Where(
    this IEnumerable<TSource> source,
    Func<TSource, bool> predicate)

现在,为了创建我们自己的Where子句实现,我们需要创建一个具有相同签名的扩展方法。

一旦完成,我们就可以从相应的类中移除using语句System.Linq,而是使用我们自己的方法。以下是我们没有自己的自定义实现就改变了Where子句内置实现的完整代码:

public static class LinqExtensions
{
    public static IEnumerable<TSource> Where<TSource>(
        this IEnumerable<TSource> source,
        Func<TSource, bool> predicate)
    {
        foreach (TSource item in source)
        {
            if (predicate(item))
            {
                yield return item;
            }
        }
    }
}

请注意,在先前的示例中,我们使用了Yield关键字。Yield关键字是在 C# 2.0 中引入的。使用此关键字,执行将基本上记住从先前的Where函数返回的项目,并在迭代中返回下一个项目。

这在我们使用 LINQ 查询数据提供程序,如 SQL 时尤为重要。由于使用了Yield,查询只有在结果被迭代时才会发送到数据库。然而,这也意味着如果我们多次执行查询,每次都会击中数据库,从而对系统的性能产生负面影响。

在下一节中,我们将探讨如何在 XML 数据源上使用 LINQ 查询。

使用 LINQ to XML

在处理 XML 文件时,我们通常使用XmlWriterXmlReaderXmlDocument类。除了这些类之外,我们还可以使用 LINQ 来对 XML 文件执行操作。使用 LINQ 执行 XML 操作的主要优势之一是我们可以使用 LINQ 与其他数据提供程序提供的相同查询体验。

使用 LINQ,我们可以创建、编辑和解析 XML 文件。除了提供一致的查询体验外,LINQ 还帮助我们编写更强大、更紧凑的查询,这些查询比其他 XML 类更强大。让我们看看我们可以对 XML 执行的操作,并了解我们如何通过 LINQ 执行它们。

查询 XML

在使用 LINQ 对 XML 文件进行操作时,我们使用 XDocument 类将 XML 作为字符串加载到内存中。

在 LINQ 引入 .NET 之前,开发者通常使用 XmlDocument 辅助类来对 XML 文件进行操作。XDocument 是我们在 LINQ 中用于对 XML 文件进行操作的类似辅助类。使用 LINQ 进行此类 xml 操作不仅有助于提供一致的查询体验,而且还能提高应用程序的整体性能。XDocument 类包含以下元素:

  • XDeclaration:此组件表示有关 XmlDeclaration 的信息,并包含有关 XML 版本和使用的编码等信息。

  • XElement:此组件表示 XML 类中的根节点或对象。

  • XProcessingInstruction:此组件包含应用程序最终将消耗的 XML 文件的相关信息。

  • XComments:此组件包含除 XElement 组件外我们想要添加到 XML 类中的任何附加信息。

所有的前述组件都源自一个共同的抽象类 XNode,并且使用 XDocument 执行的任何操作都基于此 XNode 类。在处理 XDocument 时,我们可以以多种方式使用 XNode。例如,使用 XDocument.Nodes 语法,我们可以遍历 XML 文件中存在的所有节点。

同样,如果我们有一个搜索特定元素或节点的场景,我们也可以使用 XDocument.DescendantsXDocument.Elements 语法。使用 XNode,我们还可以直接访问 XML 文件中存在的特定元素或节点。这可以大大提高应用程序的性能,因为我们不再需要遍历整个 XML 文件,而是直接跳转到所需的节点。

请注意,在 XML 文件中,属性不被视为节点;相反,它们是属于节点的键值对。

以下代码示例显示了一个包含一组具有 NamerollNum 和联系信息的学生的示例 XML:

String xml = @"<?xml version=""1.0"" encoding=""utf-8"" ?>
                <Students>
                    <Student Name=""Simaranjit"" rollNum=""1"">
                        <contactdetails>
                            <emailaddress>sbhalla@gmail.com</emailaddress>
                            <phoneNumber>0416274824</phoneNumber>
                        </contactdetails>
                    </Student>
                    <Student Name=""James"" rollNum=""2"">
                        <contactdetails>
                            <emailaddress>jamesdonohoe@gmail.com</emailaddress>
                        </contactdetails>
                     </Student>
                 </Students>";

假设我们需要遍历此 XML 文件中所有存在的学生记录。使用 LINQ,我们可以执行查询,将 XML 文件中所有学生的名字作为字符串加载。要在 XML 文件上使用 LINQ,我们首先需要添加对 System.Xml.Linq 命名空间的引用。以下代码语法展示了我们如何使用 Descendants 方法和 Attribute 方法来加载数据:

XDocument doc = XDocument.Parse(xml);
IEnumerable<string> studentNames = from p in doc.Descendants("Student")
                                  select (string)p.Attribute("Name")
                                  + " " + (string)p.Attribute("rollNum");
foreach (string s in studentNames)
{
         Console.WriteLine(s);
}

以下为前述代码的输出:

在前面的程序中,我们使用 LINQ 查询检索 XML 文件中Student后代的全部子节点。一旦检索到所有节点,我们便选择属性节点中的NamerollNum的值。为了选择节点中相应的元素,我们使用.Attribute语法。该方法返回一个XAttribute对象的实例。尽管XAttribute有一个字符串类型的Value属性,但我们始终可以使用显式运算符将值转换为 C#中的其他数据类型。

在使用 LINQ 对 XML 文件进行操作时,我们还可以在查询中使用WhereOrderBy等运算符。以下代码语法显示了我们可以如何过滤所有有电话号码的学生:

XDocument docFil = XDocument.Parse(xml);
IEnumerable<string> studentNamesFilter = from p in docFil.Descendants("Student")
                                         where p.Descendants("phoneNumber").Any()
                                         select (string)p.Attribute("Name")
                                         + " " + (string)p.Attribute("rollNum");
foreach (string s in studentNamesFilter)
{
         Console.WriteLine(s);
}

在前面的代码中,我们添加了一个where子句,其中我们在电话号码上添加了一个条件。请注意,在 XML 字符串中,只有一个子节点有电话号码。当前面的代码执行时,我们得到以下输出:

图片

在前面的 XML 文件中,只有一个学生记录有电话号码,因此它过滤出了那个特定的记录。在下一节中,我们将探讨如何使用 LINQ 创建 XML 文件。

创建 XML

除了查询 XML 之外,我们还可以使用 LINQ 创建 XML 文件。为此,我们可以使用XElement类。该类中有一个ADD方法,我们可以用它来构建 XML 文件。以下代码语法显示了我们可以如何创建一些 XML:

XElement root = new XElement("Student",
new List<XElement>
{
     new XElement("Marks"),
     new XElement("Attendance")
},
new XAttribute("Roll Number", 1));
root.Save("StudentTestResults.xml");

在前面的代码中,我们通过名称Student定义了一个元素。在根元素中,我们添加了一个Marks子节点来表示学生获得的分数。我们还添加了一个Attendance子节点来表示学生的出勤情况。最后,我们添加了一个"Roll Number"属性来表示学生的唯一标识符。

一旦代码执行完毕,我们将观察到它创建了一个具有以下结构的 XML 文件:

<?xml version="1.0" encoding="utf-8"?>
    <Student RollNumber="1">
         <Marks />
         <Attendance />
    </Student> 

在下一节中,我们将探讨如何使用 LINQ 更新 XML。

更新 XML

在本节中,我们将探讨如何使用 LINQ 修改 XML 文件。使用 LINQ,我们可以通过以下方式修改 XML 文件:

  • 在 XML 文件中删除现有节点

  • 在 XML 文件中插入新节点

  • 修改现有节点的内容

  • 操作完成后保存 XML 文件

为了解释方便,我们将使用上一节中创建的相同 XML 文件。我们将编写一个代码,为所有学生添加一个手机号码元素。我们将在这个节点的ContactDetails元素中添加这个元素:

XElement rootUpd = XElement.Parse(xml);
foreach (XElement p in rootUpd.Descendants("Student"))
{
     XElement contactDetails = p.Element("contactdetails");
     contactDetails.Add(new XElement("MobileNumber", "12345678")); 
}
rootUpd.Save("testupd.xml"); 

在前面的代码中,我们正在遍历 XML 中所有的Students,然后遍历ChildDetails的子元素。在那个节点中,我们添加了MobileNumber元素。一旦代码执行完毕,我们将在 XML 文件中得到以下输出:

<?xml version="1.0" encoding="utf-8"?>
<Students>
     <Student Name="Simaranjit" rollNum="1">
         <contactdetails>
             <emailaddress>sbhalla@gmail.com</emailaddress>
             <phoneNumber>0416274824</phoneNumber>
             <MobileNumber>12345678</MobileNumber>
         </contactdetails>
     </Student>
     <Student Name="James" rollNum="2">
         <contactdetails>
             <emailaddress>jamesdonohoe@gmail.com</emailaddress>
             <MobileNumber>12345678</MobileNumber>
             </contactdetails>
     </Student>
</Students>

在前面的 XML 中,我们在 StudentContactDetails 节点中添加了一个 MobileNumber 元素。

摘要

在本章中,我们学习了如何使用 LINQ 对多个数据源编写一致的查询。我们了解了 LINQ 查询的不同组件,并理解了如何在查询中构建它们。然后,我们探讨了 C# 语言中的功能,这些功能使我们能够使用 LINQ,例如隐式类型、对象初始化语法、lambda 表达式、扩展方法和匿名类型。

我们接着探讨了 LINQ 中可用的不同操作符,例如 SelectSelectManyWherejoinAverage。通过代码场景,我们探讨了在不同情况下我们应该使用它们的各种情况。

我们接着探讨了 LINQ 查询基于的不同接口。最后,我们探讨了如何使用 LINQ 查询对 XML 文件执行操作。通过代码示例,我们探讨了如何执行、创建、更新和查询 LINQ 操作。

在下一章中,我们将探讨数据的序列化和反序列化。我们将探讨 C# 中可用的不同集合项,例如数组、列表和字典。

问题

  1. 哪段 LINQ 代码可以用来提取销售金额超过 5,000 美元且姓名以 A 开头的客户?

    1. FROM p IN db.Purchases WHERE p.Customer.Name.StartsWith("A") WHERE p.PurchaseItems.Sum (pi => pi.SaleAmount) = 5000 SELECT p

    2. FROM p IN db.Purchases WHERE p.Customer.Name.StartsWith("A") WHERE p.PurchaseItems.Sum (pi => pi.SaleAmount) > 5000 SELECT p

    3. FROM p IN db.Purchases WHERE p.Customer.Name.EndsWith("A") WHERE p.PurchaseItems.Sum (pi => pi.SaleAmount) < 1000 SELECT p

    4. FROM p IN db.Purchases WHERE p.Customer.Name.StartsWith("A") WHERE p.PurchaseItems.Sum (pi => pi.SaleAmount) >= 1000 SELECT p

  2. 以下关于 LINQ 的陈述中哪一项是不正确的?

    1. 与 SQL 等语言相比,LINQ 的编码更复杂。

    2. LINQ 支持 Join

    3. LINQ 可以用于对 XML 文件进行操作。

    4. 所有上述选项。

  3. 以下哪一项支持 LINQ 查询?

    1. 对象集合

    2. 实体框架

    3. XML 文档

    4. 所有上述选项

答案

  1. b

  2. a

  3. d

第十六章:序列化、反序列化和集合

当 .NET 应用程序与外部网络交互时,交换的数据必须转换为平面或二进制格式。同样,当从外部应用程序检索数据时,需要将二进制数据格式化为对象,以便可以在其上操作。这是通过使用不同方法对数据进行序列化和反序列化来完成的。将对象转换为二进制格式的过程称为序列化。反序列化是序列化的逆过程。它涉及将二进制数据转换为对象表示,以便可以在应用程序中使用。

在本章中,我们将探讨 .NET 框架中可用的不同序列化和反序列化方法。我们将研究 XML 序列化、JSON 序列化和二进制序列化。我们还将探讨如何在 Web 服务中定义数据合同,以通知消费应用程序不同应用程序之间交换的数据格式。

我们将接着探讨如何使用不同的集合对象,如数组、列表、字典、队列和栈,以及我们将学习它们如何用于存储和消费数据。最后,我们将探讨在处理 .NET 应用程序时,帮助我们选择集合对象的不同因素。

在本章中,我们将涵盖以下主题:

  • 序列化和反序列化

  • 处理集合

  • 选择集合

技术要求

本书所解释的程序将在 Visual Studio 2017 中开发。本章的示例代码可以在 GitHub 上找到,链接为 github.com/PacktPublishing/Programming-in-C-Sharp-Exam-70-483-MCSD-Guide/tree/master/Book70483Samples

序列化和反序列化

在处理对象时,我们经常发现需要将它们保存到不同的介质中,如数据库或文件,或者在某些情况下,通过网络将它们传输到其他应用程序。为此,我们必须首先将对象转换为字节流——这个过程被称为序列化

反序列化是将从外部应用程序接收到的字节转换为可以在应用程序内部使用的对象的过程。通过序列化,我们可以将对象转换为字节,并将与其状态、属性、程序集版本等相关信息保存到外部介质中,如数据库,或者在网络中将它们交换到外部应用程序。在此需要注意的是,我们只能对对象及其属性应用序列化,而不能对它们的方法应用序列化。

.NET Framework 为我们提供了System.Runtime.Serialization命名空间,其中包含帮助类,帮助我们序列化和反序列化数据。.NET 提供了三种实现此目的的机制:XML 序列化、JSON 序列化和数据合同序列化。

在下一节中,我们将学习如何使用XMLSerializer进行序列化。

XmlSerializer

XMLSerialization中,我们将数据转换为 XML 文档的格式,这样就可以轻松地在网络上传输。

在反序列化过程中,我们可以从相同的 XML 文档格式中渲染一个对象。XMLSerializer基于简单对象访问协议SOAP),这是一种与 Web 服务交换信息的协议。

当使用XmLSerlializer时,我们必须使用Serializable标记标记我们的类,以通知编译器这个类是可序列化的。请参考以下代码实现,其中我们使用此标记来通知编译器我们的类是Serializable的:

[Serializable]
public class Student
{
     public string FirstName { get; set; }
     public string LastName { get; set; }
     public int ID { get; set; }
     public Student()
     {            
     }
     public Student(string firstName, string lastName, int Id)
     {
         this.FirstName = firstName;
         this.LastName = lastName;
         this.ID = Id;     
     }
}

在前面的代码实现中,我们声明了一个Student类,并使用FirstNameLastNameID属性对其进行指定。为了通知编译器该类是可序列化的,我们在类上使用了Serializable标记。

有时,我们需要选择我们希望序列化的属性。在这些情况下,我们可以在属性上使用NonSerialized标记,并通知编译器该属性将不可序列化。以下是为此提供的代码实现:

[Serializable]
public class Student
{
     public string FirstName { get; set; } 
     public string LastName { get; set; }
     [NonSerialized()]
     public int ID;
}

我们在类名上使用了Serializable标记,但使用了NonSerialized标记来表示ID属性不能被序列化。

让我们通过一个代码实现场景来了解,我们将查看一个代码库,在这个代码库中,我们将使用XmlSerializer序列化一个类对象,并将文件保存到文件系统中。然后,该文件可以跨网络传输:

XmlSerializer serializer = new XmlSerializer(typeof(Student));
string fileName = "StudentData";
using (TextWriter writer = new StreamWriter(fileName))
{
     Student stu = new Student("Jacob", "Almeida", 78);  
     serializer.Serialize(writer, stu);
}

在前面的代码示例中,我们使用了与上一个示例中相同的Student类。我们创建了一个虚拟的Student对象,然后将对象序列化为字节。然后,使用TextWriter对象将这些字节转换为文件。

一旦执行前面的代码,系统将创建一个名为StudentData的文件:

如果我们在 Internet Explorer 中打开该文件,我们将以 XML 格式看到学生数据:

在前面的代码示例中,数据没有层次结构。所有数据都表示为 XML 文件中的一个元素。然而,在大多数情况下,我们需要表示遵循某种层次结构的数据。使用前面的例子,让我们尝试表示每个学生的课程分数。假设有五门课程:英语、数学、物理、化学和计算机。现在,让我们尝试使用以下代码实现来表示每个学生每门课程的分数:

[Serializable]
public class Student
{
 public string FirstName { get; set; }
 public string LastName { get; set; }
 public int ID;
 [XmlIgnore]
 public string Feedback { get; set; }
 [XmlArray("CourseScores")]
 [XmlArrayItem("Course")]
 public List<CourseScore> CoursePerformance { get; set; } 
 public void CreateCoursePerformance()
 {
        Course phy = new Course { Name = "Physics", Description = 
                                  "Physics Subject" };
        CourseScore phyScore = new CourseScore { Course = phy, 
                                                 Score = 80 };
        List<CourseScore> scores = new List<CourseScore>();
        scores.Add(phyScore);
        this.CoursePerformance = scores;
 }
}
[Serializable]
public class CourseScore
{
 [XmlElement("Course")]
 public Course Course;
 [XmlAttribute]
 public int Score;
}
[Serializable]
public class Course
{
 [XmlAttribute]
 public string Name;
 public string Description;
}

上一段完整代码可以在本章的 GitHub 仓库中找到。

在前面的代码实现中,我们声明了三个类:

  • Course: 用于表示科目及其描述

  • CourseScore: 用于表示学生在该特定课程中获得的分数

  • Student: 包含CourseScore列表,用于表示学生在每个科目中获得的分数

请注意我们在课程中使用的以下标签:

  • XmlIgnore: 我们使用此标签针对我们不想在生成的 XML 类中保存的属性。在上面的类示例中,我们对Feedback类使用了XmlIgnore。这将确保Feedback属性不会出现在生成的 XML 文件中。

  • XmlElement: 如果我们想在生成的 XML 中表示一个元素,可以使用这个标签。该元素可以具有属性。在上面的例子中,我们使用了XmlElement标签来表示Course属性。这将使我们能够在生成的 XML 文件中添加Course NameCourse Description属性。

  • XMLArray: 当此元素可以有多个子记录时,我们使用此标签。在上面的例子中,我们使用了XMLArray标签来表示CourseScores属性,以指示这是一个可以具有多个子记录的 XML 元素。

  • XMLArrayItem: 我们使用此标签来表示XMLArray记录中的单个子记录。在上面的例子中,我们使用了XMLArrayItem标签来表示列表集合变量CourseScores中的单个记录。

如果我们需要使用XMLSerialization序列化数据,我们可以使用以下代码。一旦代码执行,它将根据前面类声明中使用的数据结构和标签生成一个 XML 文件:

XmlSerializer serializer = new XmlSerializer(typeof(Student));
string fileName = "StudentDataWithScores";
using (TextWriter writer = new StreamWriter(fileName))
{
       Student stu = new Student("Jacob", "Almeida", 78, "Passed");
       stu.CreateCoursePerformance();
       serializer.Serialize(writer, stu);
       writer.Close();
 }

程序生成后,请注意生成了一个 XML 文件,名为StudentDataWithScores。现在,打开 XML 文件并查看以下内容:

图片

请注意生成的 XML 文件结构中的以下要点:

  • 在 XML 文件中,没有Feedback节点,因为它在Student类文件中被标记为XmlIgnore标签。

  • Student节点中,有一个与我们在CourseScore列表集合中使用的XmlArray标签一致的CourseScores元素节点。

  • 在元素节点CourseScores中,我们有一个与XmlArrayItem一致的单独节点元素Course,这是我们为CourseScores集合中的每个元素声明的。

每个子项节点都有一个Score属性。它与用于CoursePerformance的标签XmlElement一致;请注意,XML 还显示了课程的名称和描述。

尽管我们使用XMLSerialization,我们可以生成易于阅读的数据,但关于XmlSerialization存在某些问题:

  • 它消耗更多的空间。如果我们共享 XML 文件,它们最终会在文件系统中节省空间,这可能不是理想的。

  • 此外,如果我们用private访问修饰符声明一个属性,它将不会被 XML 序列化选中。例如,如果我们设置前面示例中LastName属性的访问修饰符,我们将看到生成的 XML 文件将没有这个属性。

以下代码是Student类中属性的更新访问修饰符集:

public string FirstName { get; set; }
private string LastName { get; set; }
public int ID;
[XmlIgnore]
public string Feedback { get; set; }
[XmlArray("CourseScores")]
[XmlArrayItem("Course")]
public List<CourseScore> CoursePerformance { get; set; }

LastName属性的访问修饰符已从public更改为private。如果我们执行项目并打开 XML 文件,我们将观察到LastName属性不再存在于生成的 XML 文件中:

图片

在下一节中,我们将通过 C#中的二进制序列化方法进行介绍。

二进制序列化

XmlSerialization中,序列化的输出是一个可以轻松用记事本打开的 XML 文件。然而,正如之前解释的那样,创建文件会增加应用程序所需的总体存储空间,这在所有情况下可能都不是所希望的。

我们还观察到,如果我们用private访问修饰符标记任何属性,它不会被复制到生成的 XML 文件中。这在许多情况下也可能是一个问题。

在本节中,我们将探讨一种替代方法,我们将数据序列化到一个字节流中。这些数据将无法像 XML 文件一样查看,但将节省空间,并且以更好的方式处理private属性。

.NET Framework 为我们提供了System.Runtime.SerializationSystem.Runtime.Serialization.Formatters.Binary命名空间,它们为我们提供了处理二进制序列化的辅助类。

为了理解二进制序列化是如何工作的,让我们看看以下示例。我们将使用Student类,这与我们在处理 XML 序列化时创建的类类似:

[Serializable]
public class StudentBinary
{
     public string FirstName;
     public string LastName;
     public int ID;
     public string Feedback;

     public StudentBinary(string firstName, string lastName, int Id, string feedback)
     {
         this.FirstName = firstName;
         this.LastName = lastName;
         this.ID = Id;
         this.Feedback = feedback;
     }
 }

请注意,在类声明中,就像在XmlSerialization中一样,我们在StudentBinary类的声明中使用了Serializable标签。这向编译器指示StudentBinary类可以被序列化。

我们可以使用以下代码来序列化和反序列化此类的一个对象:

StudentBinary stu = new StudentBinary("Jacob", "Almeida", 78, "Passed");            
IFormatter formatter = new BinaryFormatter();
using (Stream stream = new FileStream("StudentBinaryData.bin", FileMode.Create))
{
     formatter.Serialize(stream, stu);
}

using (Stream stream = new FileStream("StudentBinaryData.bin", FileMode.Open))
{
     StudentBinary studeseria = (StudentBinary)formatter.Deserialize(stream);
}

在代码示例中,我们创建了一个StudentBinary类的对象,然后使用一个辅助类BinaryFormatter将数据序列化为二进制数据。一旦数据被序列化,使用FileStream辅助类,我们将这些二进制数据保存到一个二进制文件中,名为StudentBinaryData.bin

在下一步中,我们打开上一步中创建的文件,并将其反序列化回StudentBinary类。如果我们尝试调试应用程序并对studeseria变量进行快速查看,我们将看到以下输出:

图片

现在让我们对前面的类进行一个更改:将LastName属性标记为private。当我们使用XmlSerialization时,我们看到了任何标记为private访问修饰符的属性都被排除在外。让我们用二进制序列化做同样的事情,并观察差异:

[Serializable]
public class StudentBinary
{
     public string FirstName;
     private string LastName;
     public int ID;
     public string Feedback;
     public StudentBinary(string firstName, string lastName, int Id, string feedback)
     {
         this.FirstName = firstName;
         this.LastName = lastName;
         this.ID = Id;
         this.Feedback = feedback;
     }
 }

如果我们现在尝试调试一个应用程序并对studeseria变量进行快速查看,我们将得到以下输出:

图片

注意,尽管我们将LastName设置为private,但这并没有对输出产生影响。这说明了二进制序列化相对于XmlSerialization的优势。

仅使用XmlSerialization,我们也可以为属性设置标签,以确保在序列化过程中忽略该属性。我们可以使用NonSerialized标签来实现。在以下代码实现中,我们正在使用此标签为Feedback属性:

[Serializable]
public class StudentBinary
{
    public string FirstName;
    private string LastName;
    public int ID;
    [NonSerialized]
    public string Feedback;

    public StudentBinary(string firstName, string lastName, int Id, string feedback)
    {
        this.FirstName = firstName;
        this.LastName = lastName;
        this.ID = Id;
        this.Feedback = feedback;
    }
}

尽管二进制序列化使我们能够克服任何标记为private访问修饰符的属性的约束,但在某些情况下,我们仍然可能故意想要限制某些数据的交换,特别是那些敏感且我们无论如何都想限制的属性。我们可以通过使用ISerializable接口来实现这一点。

在以下实现的代码中,我们使用了一个类似的类StudentBinaryInterface,并在其中实现了ISerializable接口。作为此接口的一部分,我们必须在这个类中实现一个GetObjectData方法。当类被序列化时,将调用此方法。在这个方法中,我们将进行封装,并且不会将任何敏感属性添加到序列化的流中。让我们看看如何做到这一点:

[Serializable]
public class StudentBinary:ISerializable
{
     public string FirstName;
     private string LastName;
     public int ID;
     public string Feedback;
     protected StudentBinary(SerializationInfo info, 
                             StreamingContext context)
     {
         FirstName = info.GetString("Value1");
         Feedback = info.GetString("Value2");
         ID = info.GetInt32("Value3");
     }
     public StudentBinary(string firstName, string lastName, 
                          int Id, string feedback)
     {
         this.FirstName = firstName;
         this.LastName = lastName;
         this.ID = Id;
         this.Feedback = feedback;
     }
     [System.Security.Permissions.SecurityPermission(
          SecurityAction.Demand, SerializationFormatter = true)]
     public void GetObjectData(SerializationInfo info, 
                               StreamingContext context)
     {
         info.AddValue("Value1", FirstName);
         info.AddValue("Value2", Feedback);
         info.AddValue("Value3", ID);
     }
 }

在前面的代码示例中,我们将LastName属性声明为private。通过这段代码,我们将尝试排除此属性以进行序列化。

在这个类中有两个重要的函数:

  • GetObjectData: 如前所述,当类被序列化时,将调用此函数。在这个方法中,我们将序列化FirstnameFeedbackID中存在的数据。请注意,LastName不包含在这个中。这是为了确保LastName属性中的数据不会被添加到流中。

  • 构造函数:由于类实现了 ISerializable 接口,它必须实现以下带有 SerializationInfoStreamingContext 参数的构造函数:

StudentBinary(SerializationInfo info, StreamingContext context)

当数据被反序列化到这个类对象时,将调用此构造函数。注意,在构造函数中,我们正在访问 Value1Value2Value3 属性的值,并将它们转换为相应的映射属性:

StudentBinary stu = new StudentBinary("Jacob", "Almeida", 78, "Passed");
IFormatter formatter = new BinaryFormatter();
using (Stream stream = new FileStream("StudentBinaryData.bin", FileMode.Create))
{
     formatter.Serialize(stream, stu);
}
using (Stream stream = new FileStream("StudentBinaryData.bin", FileMode.Open))
{
     StudentBinary studeseria = (StudentBinary)formatter.Deserialize(stream);
}

如果我们调试此代码并对 studeseria 变量进行快速监视,我们将得到以下输出:

注意,LastName 属性中没有值。在下一节中,我们将学习如何处理集合。

操作集合

在 .NET 中,集合定义了一组相关的数据或元素。元素可以是简单数据类型的变量,如 intfloatString,也可以是复杂的数据变量,如类或结构。在处理 .NET 应用程序时,我们经常需要处理这样的集合。我们可以执行如下操作:

  • 创建集合

  • 向集合中添加元素

  • 读取集合中的元素

  • 从集合中删除元素

在本节中,我们将了解 .NET 中可用的不同类型的集合以及程序员如何对它们执行操作。

数组

数组是 .NET Framework 中可用的最基本集合变量。数组用于存储一组数据变量,这些变量的类型与 intString 等相同。

让我们通过一个代码实现来创建一个 int 变量的数组集合,然后遍历它们:

public static void CollectionOperations()
{
     int[] arrayOfInt = new int[10];
     for (int x = 0; x < arrayOfInt.Length; x++)
     {
         arrayOfInt[x] = x;
     }
     foreach (int i in arrayOfInt)
     {
         Console.Write(i); 
     }
 }

请注意代码实现中的以下内容:

  • 数组通过在数据类型之后使用 [] 语法来声明。在实现的代码中,我们声明了一个 int 类型的数组。

  • 在声明数组集合时,我们必须定义数组的长度。在代码中,我们声明数组的长度为 10。这基本上意味着数组可以包含 10int 类型的元素。

  • 数组的索引从 0 开始。这意味着,在前面的例子中,第一个元素将从索引 0 开始,并结束于索引 9。如果我们需要引用数组中第 i 个索引的元素,我们可以使用 array[i] 表达式。

  • 每个数组都有一个 length 属性。这个属性指示数组中可以存在的最大元素数量。在代码中,我们执行了一个从 09for 循环,长度为 -1,并设置每个索引处的元素值。

  • 数组实现了 IEnumerable 接口。因此,我们可以使用 foreach 循环遍历数组。

执行前面的代码后,我们得到以下输出:

在代码中,我们在数组的每个索引处设置值——与索引本身相同的值。在我们继续前进并查看代码示例之前,我们需要了解关于数组的重要的两个概念:

  • 多维数组:这些是存储在具有行和列的矩阵结构中的元素数组。例如,我们可以使用以下代码来声明一个多维数组:
int[,] arrayInt = new int[3,2] { { 1, 2 }, { 3, 4 }, { 5, 6 } };
  • 在数组声明期间使用单个,标记表示这是一个二维数组。在二维数组的情况下,第一个维度表示数组的行数,而第二个维度表示数组中的列数。此外,请注意,通过使用3,2,我们实际上是在指示数组的行数应该是3,而列数应该是2

正如在普通数组中一样,在多维数组中,两个维度的索引都是从0开始的。因此,第一个元素将存在于索引{0,0}处,而最后一个元素将存在于索引{2,1}处。

让我们看看以下代码实现,我们将查看代码以遍历这个多维数组并读取数组中存在的每个数字:

int[,] arrayInt = new int[3,2] { { 1, 2 }, { 3, 4 }, { 5, 6 } };
for (int i=0; i < 3; i++)
{
     for (int j = 0; j < 2; j++)
     {
         Console.WriteLine(arrayInt[i, j]);
     }     
}

在前面的代码实现中,我们执行了两个嵌套循环。在第一个循环中,我们遍历多维数组中存在的行数。在第二个循环中,我们遍历多维数组中存在的列数。使用arrayInt[i, j]语法,我们打印出数组中该行和列组合的元素。

当我们执行代码时,我们得到以下结果。由于我们必须使用嵌套循环遍历二维数组,在每一步中,我们将访问位置{i, j}的元素。对于i变量的每次迭代,j变量将从0迭代到1。因此,它将从{0,0}开始,这是1,并将结束于{2,1},这是6,从而生成以下输出:

在下一节中,我们将查看另一种集合类型:列表。

列表

在处理数组时,我们了解到在声明数组时必须指定数组的长度。此外,我们无法增加数组集合的长度。

为了克服这些问题,我们可以使用列表集合。列表为我们提供了几个辅助方法,帮助我们向列表中添加和删除项目,对列表进行排序,在列表中进行搜索等等。列表集合是通过以下索引声明的:

 List<int> vs = new List<int>();

如果我们查看列表的定义,我们会意识到,在内部,它实现了许多接口,例如IEnumerableICollectionIList。由于这些不同的接口,列表集合非常强大,并提供了不同的操作。以下截图显示了在.NET 中列表集合的定义看起来是什么样子:

IEnumerable接口允许我们像处理数组一样使用foreach循环遍历列表集合。ICollection接口允许我们执行计数长度、添加新元素和删除元素等操作。

让我们看看以下代码实现,我们将对列表执行所有这些操作:

public static void ListCollectionOperations()
{
     List<int> vs = new List<int> { 1, 2, 3, 4, 5, 6 };
     for (int x = 0; x < vs.Count; x++)
     Console.Write(vs[x]); 
     vs.Remove(1);
     Console.WriteLine(vs[0]); 
     vs.Add(7);
     Console.WriteLine(vs.Count); 
     bool doesExist = vs.Contains(4);
     Console.WriteLine(doesExist); 
}

让我们看看在先前的代码中我们做了什么:

  • 我们创建了一个int类型的列表并添加了元素 1-6。

  • 我们正在使用for循环遍历列表,并打印其中的值。为了找到列表的长度或元素数量,我们使用Count属性。

  • 要从特定索引删除元素,我们可以使用Remove方法。在先前的代码实现中,我们正在删除索引1处的元素。

  • 要在列表中添加新元素,我们使用Add方法。在先前的代码实现中,我们正在向列表中添加一个元素,7

  • 要检查一个元素是否在列表中,我们使用Contains方法。在先前的代码实现中,我们正在检查列表中是否存在4这个元素。

如果我们执行这个操作,我们会得到以下输出:

列表集合的一个潜在问题是我们可以有重复的值。例如,在先前的例子中,列表中可能有两个元素具有与1相同的值。

由于这个问题,我们不能在必须确保值唯一性的场景中使用列表集合。在下一节中,我们将探讨另一个集合字典,它克服了列表对象的这个问题。

字典

当我们需要在保存的值中保持唯一性时,可以使用字典集合。字典集合由两部分组成:键和值。在.NET 中,它们一起被称为键值对。当创建字典集合时,它确保键值是唯一的,并且集合中不存在重复的键。检索也是基于键进行的,这使得操作非常快速。以下索引声明了字典集合:

Dictionary<int, int> vs = new Dictionary<int, int>();

在先前的代码实现中,我们声明了一个字典集合,其中键和值都是int格式。

让我们右键单击Dictionary类并点击转到定义。这样做后,我们将被带到字典的定义。这样做后,我们会意识到它实现了多个接口,例如IEnumerableICollection<KeyValuePair<TkeyTValue>>。请参考以下Dictionary类的定义:

由于基于KeyValuePair实现了ICollection接口,它确保了基于键的唯一性。

在代码实现中,我们将查看一个代码示例,其中我们将实现Dictionary对象上的操作:

public static void DictionaryCollectionOperations()
{
     Dictionary<int, int> vs = new Dictionary<int, int>();
     for (int x = 0; x < 5; x++)
     {
         KeyValuePair<int, int> pair = new KeyValuePair<int, int>(x, x * 100);
     }

     foreach(KeyValuePair<int, int> keyValue in vs)
     {
         Console.WriteLine(keyValue.Key + " " + keyValue.Value);
     }
     vs.Remove(1);
     Console.WriteLine(vs[0]);
     vs.Add(5, 500);
     Console.WriteLine(vs.Count);
     bool hasKey = vs.ContainsKey(4);
     bool hasValue = vs.ContainsValue(900);
     Console.WriteLine(hasKey);
     Console.WriteLine(hasValue);
 }

请注意以下前述代码实现中的要点:

  • 我们已声明一个字典集合变量。我们实现了一个运行五次的for循环。在循环中,我们创建KeyValuePair并将其添加到字典对象中。

  • 在我们将元素添加到字典对象之后,我们正在遍历字典。我们是通过在字典中存在的KeyValuePair上使用foreach循环来实现的。

  • 要从字典中删除特定元素,我们可以使用Remove方法。该方法接受一个键作为输入,并根据键删除字典中存在的相应KeyValuePair

  • 要在字典中添加特定元素,我们可以使用Add方法。该方法有两个参数,一个是键,另一个是值。

  • 要检查特定键是否存在于字典中,我们可以使用ContainsKey方法。该方法返回给定键是否存在于字典中。如果键存在,它返回true,在其他情况下返回false

  • 要检查特定值是否存在于字典中,我们可以使用ContainsValue方法。该方法返回给定值是否存在于字典中。如果值存在,它返回true,在其他情况下返回false

如果执行前面的代码,我们将得到以下输出:

图片

请注意以下代码输出的要点:

  • 值(0, 0)、(1, 100)、(2, 200)、(3, 300)和(4, 400)表示在字典中添加的关键值对。

  • 5表示字典中元素的数量或长度。

  • True表示字典包含键为4KeyValuePair

  • False表示字典不包含值为900KeyValuePair

在我们进入下一节之前,让我们尝试一下如果我们尝试在键已存在于字典中的相同字典中添加KeyValuePair会发生什么。为了举例,我们将添加KeyValue(1, 1000)。请注意,键1已经在字典中。当代码执行时,我们得到以下异常。异常表明,如果我们尝试在字典中添加具有相同键的值,它将抛出错误:

图片

在下一节中,我们将介绍另一组集合对象:队列和栈。

队列和栈

队列和栈是允许我们在程序执行期间临时保存数据的集合项。这些集合与其他集合(如列表)非常相似,主要区别在于元素如何添加和从集合中删除。

队列是一种先进先出(FIFO)类型的集合。这基本上意味着元素是按照它们添加到集合中的相同顺序访问的。当访问项目时,它们也可以在同一个操作中移除。队列有三个主要操作:

  • 向队列中添加新元素:这是通过Enqueue方法执行的。

  • 从队列中移除现有元素:这是通过Dequeue方法执行的。

  • 查看或检索队列中元素的值:这是通过Peek方法执行的。

此图显示了队列的工作原理:

图片

在前述图中,方块表示队列集合。在集合中,我们添加了五个元素:元素 A、元素 B、元素 C、元素 D 和元素 E。元素 E 是第一个添加到队列中的元素,它位于队列的前端。元素 A 是最后一个添加到队列中的元素,它位于队列的末尾。

绿色箭头表示队列中不同操作将发生的索引。新元素的添加总是在队列的末尾进行。元素的移除总是在队列的前端进行。

让我们通过代码实现来展示它是如何以编程方式完成的。在以下代码中,我们创建了一个队列集合对象。然后我们将元素添加到对象中,并对其执行不同的操作:

public static void QueueOperations()
{
    Queue<string> que = new Queue<string>();
    que.Enqueue("E");
    que.Enqueue("D");
    que.Enqueue("C");
    que.Enqueue("B");
    que.Enqueue("A");
    int index = 0;
    foreach(string s in que)
    {
        Console.WriteLine("Queue Element at index " + index + " is " + s);
        index++;
    }
    Console.WriteLine("Queue Element at top of the queue is " 
                       + que.Peek());
    que.Dequeue();
    index = 0;
    foreach (string s in que)
    {
         Console.WriteLine("Queue Element at index " + index + " is " + s);
         index++;
    }
 }

当执行前述代码时,我们得到以下输出:

图片

如队列图所示,当我们向队列中添加元素时,它们总是添加到队列的末尾。因此,当我们以相同的顺序添加EDCBA元素时,A将始终位于队列的末尾,而E将位于队列的前端。前述输出中的索引表示每个相应元素在队列中的位置。

队列中的查看操作总是在前端索引上执行,即队列的0索引。队列中的移除操作总是在前端索引上执行,即队列的0索引。因此,在前述输出中,这两个操作都是在元素E上执行的。

一旦完成这些操作并且我们再次遍历队列,我们会发现,由于移除操作,元素E不再存在于队列中。现在我们已经了解了队列的工作原理,让我们看看.NET Framework 中栈是如何工作的。

就像队列一样,栈也提供临时存储,唯一的区别在于在栈上执行操作的方式。栈遵循后进先出(LIFO)模型,这意味着最后添加到栈中的元素将是第一个被移除的。以下是我们可以在栈上执行的三种主要操作:

  • 向栈中添加新元素。这是通过Push方法执行的。

  • 从栈中移除现有元素。这是通过Pop方法执行的。

  • 通过Peek方法检索栈中元素的值。这是通过Peek方法执行的。

以下图表显示了栈的工作原理:

图片

在图表中,方块表示一个栈集合。在集合中,我们添加了五个元素:元素 A、元素 B、元素 C、元素 D 和元素 E。元素 E 是第一个添加到栈中的元素,它在栈的前端。元素 A 是最后添加到栈中的元素,它在栈的后端。

绿色箭头表示不同操作将在栈中的哪些索引处进行。新元素的添加总是发生在栈的底部。元素的移除将发生在栈的顶部。

让我们通过代码实现来展示它是如何程序化完成的。在下面的代码中,我们创建了一个栈集合对象。然后我们将向对象中添加元素并对其执行不同的操作:

public static void StackOperations()
{
     Stack<string> sta = new Stack<string>();
     sta.Push("E");
     sta.Push("D");
     sta.Push("C");
     sta.Push("B");
     sta.Push("A");
     int index = 0;
     foreach (string s in sta)
     {
         Console.WriteLine("Stack Element at index " + index + " is " + s);
         index++;
     }
     Console.WriteLine("Stack Element at top of the stack is " 
                        + sta.Peek());
     sta.Pop();
     index = 0;
     foreach (string s in sta)
     {
         Console.WriteLine("Stack Element at index " + index + " is " + s);
         index++;
     }
 }

当执行前面的代码时,我们得到以下输出:

图片

在栈上执行Peek操作总是针对最后添加到栈上的元素。元素A是最后添加到栈上的元素。因此,当在栈上执行Peek操作时,它给出输出A。同样,当在栈上执行Pop操作时,它移除了元素A

通过这种方式,我们已经了解了.NET 中可用的不同集合。每个这些集合项都有一些属性,使它们在某些场景中可用,而在其他场景中不可用。当我们试图选择要使用的集合项时,需要评估这些标准。

在下一节中,我们将探讨一些有助于我们为每个场景选择正确集合项的标准。

选择集合

在选择集合类型时,我们必须分析我们想要在应用程序中使用的场景。这些集合类型之间的主要区别在于我们访问它们元素的方式:

  • 在声明数组集合时,它需要有一个确定的大小或长度。另一方面,所有其他集合类型都可以动态地增加其大小。此外,数组支持对数据的随机访问。这基本上意味着只要元素存在于数组中指定的索引处,我们就可以访问该元素,而无需遍历整个数组。

  • 队列和栈允许我们以确定的方式访问元素。当队列以 FIFO(先进先出)方式工作时,栈以 LIFO(后进先出)方式工作。

  • 另一方面,列表和字典集合类型允许我们配置对元素的随机访问。请注意,从概念上讲,列表不支持随机访问;然而,在 C#中,列表被维护为一个数组。因此,它支持随机访问。

  • 列表和字典之间的重要区别在于它们保存数据的方式以及性能。在字典集合中,我们使用 KeyValuePair 保存数据。这允许我们保持字典中存在的键的唯一性。另一方面,列表不提供这项功能。

摘要

在本章中,我们学习了如何在通过网络交换数据时进行序列化和反序列化。我们探讨了序列化和反序列化的不同技术。我们从 XmlSerialization 开始,看到了如何将数据序列化到 XML 文件中。我们还探讨了不同的属性标签,如 XmlArrayXmlArrayItemXmlIgnore,我们可以在将类对象转换为 XML 文件时放置这些标签。然后,我们探讨了二进制序列化,并学习了它相对于 XmlSerialization 的优势。我们还探讨了 ISerializable 接口,并学习了它在通过网络交换数据时如何提供安全性。

我们随后探讨了 C# 中可用的不同集合类型以及我们应该在什么情况下使用它们。我们探讨了数组及其在数组声明时必须声明的长度或大小的限制。然后,我们探讨了其他一些复杂的集合类型,例如列表和字典。这两个集合项都允许我们在执行过程中增加集合大小,但它们在访问数据的方式上有所不同。字典将数据保存在 KeyValuePair 中,并强制集合类型中键的唯一性。

然后,我们探讨了队列和栈集合类型。与允许随机访问数据的列表和字典不同,栈和队列允许我们以特定顺序访问数据。队列遵循 FIFO 模型,而栈遵循 FILO 模型。

问题

  1. 以下哪个陈述是正确的?

    1. 在序列化过程中,XmlSerialization 会自动将私有属性包含在生成的 XML 类中。

    2. XmlSerialization 中,可以使用 XmlIgnore 标签来排除我们不想包含在生成的 XML 类中的任何属性。

    3. 在二进制序列化中,标记为私有的属性不会被序列化。

    4. 使用 ISerializable 接口,我们可以选择在序列化中要包含的属性及其标签。

  2. 以下哪个陈述是正确的?

    1. 我们可以在程序执行期间增加数组集合类型的大小。

    2. 队列遵循 LIFO 模型来访问元素。

    3. 字典在 KeyValuePair 中保存数据,确保了集合项中键值对的唯一性。

    4. 列表和字典都允许随机访问数据元素。

  3. 你正在处理一组大量的 student 对象。你需要移除所有重复项,然后按 studentid 对它们进行分组。我们应该使用哪些集合?

    1. 列表

    2. 字典

    3. 队列

答案

  1. b 和 d

  2. c 和 d

  3. c

第十七章:模拟测试 1

  1. 我们有一个名为 LogException 的类。该类使用以下代码段实现 CaptureException 方法:public static void CaptureException(Exception ex)。从以下语法中选择一个,以确保捕获类中的所有异常并重新抛出原始异常,包括堆栈:

    1. catch (Exception ex)

      {

      LogException.CaptureException(ex);

      throw;

      }

    2. catch (Exception ex)

      {

      LogException.CaptureException(ex);

      throw ex;

      }

    3. catch

      {

      LogException(new Exception());

      }

    4. catch

      {

      var ex = new Exception();

      throw ex;

      }

  2. 你正在创建一个名为 Store 的类,该类应有一个满足以下要求的 Store Type 成员:

    • 该成员必须是公开可访问的。

    • 该成员必须仅获取一组受限的值。

    • 在设置值时,该成员必须确保它验证成员中设置的输入。

在实现分数成员时应采用哪种形式?

    1. public string storeType;

    2. protected String StoreType { get{} set{} }

    3. private enum StoreType { Department, Store, Warehouse}

      public StoreType StoreTypeProperty

      {

      get{}

      set{}

      }

    4. private enum StoreType { Department, Store, Warehouse}

      private StoreType StoreTypeProperty

      {

      get{}

      set{}

      `}`

  1. 为字符串编写一个扩展方法;它应该有一个 IsEmail 方法。该方法应检查字符串是否为有效的电子邮件。选择语法并将其映射到应放置的位置:
----------------------/*Line which needs to be filled*/
{
    ------------------/*Line which needs to be filled*/
    {
         Regex regex = new Regex(@"^([\w\.\-]+)@([\w\-]+)((\.(\w){2,3})+)$");
         return regex.IsMatch(str);
    }
}
    1. protected static class StringExtensions

    2. public static class StringExtensions

    3. public static bool IsEmail(this String str)

    4. public static bool IsEmail(String str)

    5. public class StringExtensions

  1. 你需要编写一个应用程序,确保垃圾回收器在进程完成之前不释放对象的资源。以下哪种语法你会使用?

    1. WaitForFullGCComplete()

    2. RemoveMemoryPressure()

    3. SuppressFinalize()

    4. collect()

  2. 对于列表集合,有人编写了以下代码:

static void Main(string[] args)
{
    List<string> states = new List<string>()
    {
        "Delhi", "Haryana", "Assam", "Punjab", "Madhya Pradesh"
    };
}

private bool GetMatchingStates(List<string> states, string stateName)
{
    var findState = states.Exists(delegate(
    string stateNameToSearch)
    {
        return states.Equals(stateNameToSearch);
    });
    return findState;
}

以下哪个代码段是相应 Lambda 表达式的正确表示?

    1. var findState = states.First(x => x == stateName);

    2. var findState = states.Where(x => x == stateName);

    3. var findState = states.Exists(x => x.Equals(stateName));

    4. `var findState = states.Where(x => x.Equals(stateName));`

  1. 以下哪个集合对象能满足以下要求?

    • 它必须内部存储每个项目的键值对。

    • 它必须允许我们按键的顺序遍历集合。

    • 它允许我们使用键访问对象。

收集对象如下:

    1. 字典

    2. 列表

    3. 排序列表

  1. 你正在创建一个包含 Student 类的应用程序。该应用程序必须有一个 Save 方法,该方法应满足以下要求:

    • 它必须是强类型的。

    • 该方法必须仅接受从 Animal 类继承且使用不接受任何参数的构造函数的类型。

选项如下:

    1. public static void Save(Student target)

      {

      }

    2. public static void Save<T>(T target) where T : Student , new()

      {

      }

    3. public static void Save<T>(T target) where T : new(), Student

      {

      }

    4. public static void Save<T>(T target) where T : Student

      {

      `}`

  1. 我们正在编写一个应用程序,它从另一个应用程序接收以下格式的 JSON 输入:
{
  "StudentFirstName" : "James",
  "StudentLastName" : "Donohoe",
  "StudentScores" : [45, 80, 68]
}

我们在我们的应用程序中编写了以下代码来处理输入。在 ConvertFromJSON 方法中,为了确保将输入转换为等效的学生格式,正确的语法是什么?

public class Student
{ 
   public string StudentFirstName {get; set;}
   public string StudentLastName {get; set;}
   public int[] StudentScores {get; set;}
}

public static Student ConvertFromJSON(string json)
{
  var ser = new JavaScriptSerializer();
  ----------------/*Insert a line here*/
}

选项如下:

    1. Return ser.Desenalize (json, typeof(Student));

    2. Return ser.ConvertToType<Student>(json);

    3. Return ser.Deserialize<Student>(json);

    4. Return ser.ConvertToType (json, typeof (Student));

  1. 您有一个包含 studentId 值的整数数组。您将使用哪种代码逻辑来完成以下操作?

    • 仅选择唯一的 studentID

    • 从数组中删除特定的 studentID

    • 将结果按降序排序到另一个数组中

您的选项如下:

    1. int[] filteredStudentIDs = studentIDs.Distinct().Where(value => value != studentIDToRemove).OrderByDescending(x => x).ToArray();

    2. int[] filteredStudentIDs = studentIDs.Where(value => value != studentIDToRemove).OrderBy(x => x).ToArray();

    3. int[] filteredStudentIDs = studentIDs.Where(value => value != studentIDToRemove).OrderByDescending(x => x).ToArray();

    4. int[] filteredStudentIDs = studentIDs.Distinct().OrderByDescending(x => x).ToArray();

  1. 在以下代码行中识别缺失的行:
private static IEnumerable<Country> ReadCountriesFromDB(string sqlConnectionString)
{
    List countries = new List<Country>();
    SqlConnection conn = new SqlConnection();
    using (sqlConnectionString)
    {
        SqlCommand sqlCmd = new SqlCommand("Select name, continent 
        from Counties", sqlConnectionString);
        conn.Open();
        using (SqlDataReader reader = sqlCmd.ExecuteReader())
        {
            // Insert the Line Here
            {
                Country con = new Country();
                con.CountryName = (String)reader["name"];
                con.ContinentName = (String)reader["continent"];
                counties.Add(con);
            }
        }
    }
    return countries;
}
    1. while (reader.Read())

    2. while (reader.NextResult())

    3. while (reader.Being())

    4. `while (reader.Exists())`

  1. 以便使用 foreach 循环处理集合中的每个对象,请按以下方式编写 StudentCollection 类:
public class StudentCollection //Insert Code Here
{
    private Student[] students;
    public StudentCollection(Student[] student)
    {
        students = new Student[student.Length];

        for (int i=0; i< student.Length; i++)
        {
            students[i] = student[i];
        }
    }

    //Insert Code Here
    {
        //Insert Code Here
    }
}
    1. : IComparable

    2. : IEnumerable

    3. : IDisposable

    4. public void Dispose()

    5. return students.GetEnumerator();

    6. return obj == null ? 1: students.Length;

    7. `public IEnumerator GetEnumerator()`

  1. 如果您正在编写代码以按照以下条件打开文件,您会使用以下哪一行代码?

    • 不应更改文件。

    • 如果文件不存在,应用程序应抛出错误。

    • 在操作进行时,不允许其他进程更新此文件。

    1. var fs = File.Open(Filename, FileMode.OpenOrCreate, FileAccess.Read, FileShare.ReadWrite);

    2. var fs = File.Open(Filename, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);

    3. var fs = File.Open(Filename, FileMode.Open, FileAccess.Read, FileShare.Read);

    4. var fs = File.Open(Filename, FileMode.Open, FileAccess.ReadWrite, FileShare.Read);

  2. 在将浮点数转换为整数时,您会使用以下哪一行代码?您需要确保转换不会抛出 Float floatPercentage; 异常:

    1. int roundPercentage = (int)floatPercentage;

    2. int roundPercentage = (int)(double)floatPercentage;

    3. int roundPercentage = floatPercentage;

    4. int roundPercentage = (int)(float)floatPercentage;

  3. 以下哪行代码用于将浮点数转换为整数?你需要确保转换不会抛出Float floatPercentage;异常:

    1. int roundPercentage = (int)floatPercentage;

    2. int roundPercentage = (int)(double)floatPercentage;

    3. int roundPercentage = floatPercentage;

    4. int roundPercentage = (int)(float)floatPercentage;

  4. 我们正在创建一个具有StudentType属性的Student类。我们需要确保StudentType属性只能在Student类内部或由继承自Student类的类访问。以下哪种实现你会使用?

    1. public class Student

      {

      protected string StudentType

      {

      get;

      set;

      }

      }

    2. public class Student

      {

      internal string StudentType

      {

      get;

      set;

      }

      }

    3. public class Student

      {

      private string StudentType

      {

      get;

      set;

      }

      }

    4. public class Student

      {

      public string StudentType

      {

      get;

      set;

      }

      }

  5. 我们正在编写一个应用程序,其中我们声明了一个具有两个属性CarCategoryCarNameCar类。在执行过程中,我们需要将类转换为它的 JSON 字符串表示。参考以下代码片段:

public enum CarCategory
{
     Luxury,
     Sports,
     Family,
     CountryDrive
}

[DataContract]
public class Car
{
     [DataMember]
     public string CarName { get; set; }
     [DataMember]
     public enum CarCategory { get; set; }
}

void ShareCareDetails()
{
     var car = new Car { CarName = "Mazda", CarCategory = CarCategory.Family };
     var serializedCar = /// Insert the code here 
}

以下哪行代码用于获取 JSON 表示的正确结构?

    1. new DataContractSerializer(typeof(Car))

    2. new XmlSerializer(typeof(Car))

    3. new NetDataContractSerializer()

    4. new DataContractJsonSerializer(typeof(Car))

  1. 我们正在为一家银行编写一个应用程序,其中我们使用以下代码来计算指定月份的利息金额以及银行初始存入的金额:
1   private static decimal CalculateBankAccountInterest(decimal initialAmount, int numberOfMonths)
2   {
3        decimal interestAmount;
4        decimal interest;
5        if(numberOfMonths > 0 && numberOfMonths < 6 && initialAmount < 5000)
6        {
7            interest = 0.05m;
8        }
9        else if(numberOfMonths > 6 && initialAmount > 5000)
10       {
11           interest = 0.065m;
12       } 
13       else
14       {
15           interest = 0.06m;
16       }
17    
18       interestAmount = interest * initialAmount * numberOfMonths / 12;
19       return interestAmount;
20  }

我们了解到,如果月份是 6,应用程序计算出的利息金额是不正确的。如果月份是 6,利率应该是 6.2%。以下哪行代码会进行更改?

    1. 将第 7 行替换为interest = 0.062m

    2. 将第 11 行替换为interest = 0.06m

    3. 将第 4 行替换为decimal interest = 0.062m

    4. 将第 15 行替换为interest = 0.062m

  1. 我们正在编写一个应用程序,其中我们正在对三个不同的服务进行异步调用,如下例所示:
public async Task ExecuteMultipleRequestsInParallel() 
{ 
    HttpClient client = new HttpClient(); 
    Task task1 = client.GetStringAsync("ServiceUrlA"); 
    Task task2 = client.GetStringAsync("ServiceUrlB"); 
    Task task3 = client.GetStringAsync("ServiceUrlC"); 
    // Insert the call here 
}

在控制权可以返回到调用函数之前,你需要等待从前面三个服务获取结果,以下哪一行代码你会插入?

    1. await Task.Yield();

    2. await Task.WhenAll(task1, task2, task3);

    3. await Task.WaitForCompletion(task1, task2, task3);

    4. await Task.WaitAll();

  1. 我们正在编写一个应用程序,其中我们正在执行多个操作,如字符串变量的赋值、修改和替换。以下哪个关键字会用来确保操作尽可能少地消耗内存?

    1. String.Concat

    2. + operator

    3. StringBuilder

    4. `String.Add`

  2. 我们正在编写一个应用程序,其中我们在列表中维护学生的分数,如下面的代码块所示。我们需要编写一个语句来过滤出大于 75 分的分数。您会使用哪个语句?

List<int> scores = new List<int>()
{
    90,
    55,
    80,
    65
};
    1. var filteredScores = scores.Skip(75);

    2. var filteredScores = scores.Where(i => i > 75);

    3. var filteredScores = scores.Take(75);

    4. var filteredScores = from i in scores

      groupby i into tempList

      where tempList.Key > 75

      select i;

第十八章:模拟测试 2

  1. 您需要编写一个应用程序,在该应用程序中创建一个类,该类与 SQL Server 建立连接并读取某个表中的记录。我们需要确保类中的以下内容:

    • 类应在操作完成后自动释放所有连接。

    • 类应支持迭代。

在类中,您会实现以下哪个接口?

    1. IEnumerator

    2. IEquatable

    3. IComparable

    4. IDisposable

  1. 如果您需要编写一个可以接受可变数量参数的函数,您会使用什么?
    1. 接口

    2. 方法重写

    3. 方法重载

    4. Lambda 表达式

  1. 您正在编写一个需要反转字符串的应用程序。您会使用以下哪个代码片段?

    1. char[] characters = str.ToCharArray();

      for (int start = 0, end = str.Length - 1; start < end; start++, end--)

      {

      characters[end] = str[start];

      characters[start] = str[end];

      }

      string reversedstring = new string(characters);

      Console.WriteLine(reversedstring);

    2. char[] characters = str.ToCharArray();

      for (int start = 0, end = str.Length - 1; start < end; start++, end--)

      {

      characters[start] = str[end];

      characters[end] = str[start];

      }

      string reversedstring = new string(characters);

      Console.WriteLine(reversedstring);

    3. char[] characters = str.ToCharArray();

      for (int start = 0, end = str.Length; start < end; start++, end--)

      {

      characters[start] = str[end];

      characters[end] = str[start];

      }

      string reversedstring = new string(characters);

      Console.WriteLine(reversedstring);

    4. char[] characters = str.ToCharArray();

      for (int start = 0, end = str.Length; start < end; ++start, end--)

      {

      characters[start] = str[end];

      characters[end] = str[start];

      }

      string reversedstring = new string(characters);

      `Console.WriteLine(reversedstring);`

  2. 如果您需要比较应用程序不同构建版本的内存使用情况,您会使用 Visual Studio 的以下哪个功能?

    1. 智能感应

    2. 使用性能分析器的 CPU 使用情况

    3. 使用性能分析器的内存使用情况

    4. 使用性能分析器的 UI 分析

  3. 您会使用以下哪个正则表达式来确保正在验证的输入是非负十进制数?

    1. ^(?!\D+$)\+?\d*?(?:\.\d*)?$

    2. ^(?:[1-9]\d*|0)?(?:\.\d+)?$

    3. ^\d+(\.\d\d)?$

    4. ^(-)?\d+(\.\d\d)?$

  4. 我们正在开发一个应用程序,我们正在使用一个名为 X 的程序集。如果我们需要调试程序集中的代码,我们应该做什么?

    1. 对于应用程序,在项目构建属性中,设置允许不安全代码属性。

    2. 对于应用程序,在调试窗格中,在调试选项中,设置启用本地代码和继续。

    3. 对于应用程序,在调试窗格中,在调试选项中,取消选中启用仅我的代码。

    4. 对于应用程序,在项目调试属性中,选择启动外部程序单选按钮并选择程序集 X。

  5. 我们正在创建一个包含Student类的应用程序。在应用程序中,我们还声明了一个名为students的变量。以下哪个语句用于检查students变量是否为Student类型对象的列表?

    1. if(students.GetType() is List<Student>[])

    2. if(students.GetType() is List<Student>)

    3. if(students is List<Student>[])

    4. `if(students is List<Student>)`

  6. 以下哪个代码段不会导致数据丢失?

    1. public void AddDeposit(float deposit)

      {

      AddToActBalance(Convert.ToDouble(deposit));

      }

      public void AddToActBalance(Double deposit)

      {

      }

    2. public void AddDeposit(float deposit)

      {

      AddToActBalance(Convert.ToDecimal(deposit));

      }

      public void AddToActBalance(Decimal deposit)

      {

      }

    3. public void AddDeposit(float deposit)

      {

      AddToActBalance(Convert.ToInt32(deposit));

      }

      public void AddToActBalance(int deposit)

      {

      }

    4. public void AddDeposit(float deposit)

      {

      AddToActBalance((Decimal)(deposit));

      }

      public void AddToActBalance(Decimal deposit)

      {

      }

  7. 我们正在编写一个应用程序,需要将一些文本写入文件。此代码的语法如下:

public async void PerformFileWriteOperation()
{
    string path = @"InputFile.txt";
    string text = "Text to read\r\n"
    await PerformFileUpdateAsync(path, text);
}
private async Task PerformFileUpdateAsync(string path, string textToUpdate)
{
    byte[] encodedBits = Encoding.Unicode.GetBytes(textToUpdate);
    using(FileStream stream = new FileStream(
    path, FileMode.Append, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true))
    {
        /// Insert the code here
    }
}

以下哪一行代码应该插入到前面的代码中,以确保在文件操作进行之前不会停止执行?

    1. async stream.Write(encodedBits, 0, encodedBits.Length);

    2. await stream.Write(encodedBits,0, encodedBits.Length);

    3. async stream.WriteAsync(encodedBits,0, encodedBits.Length);

    4. await stream.WriteAsync(encodedBits,0, encodedBits.Length);

  1. 我们正在编写一个应用程序,其中已经编写了以下代码:
public class Car
{
     public Car()
     {
         Console.WriteLine("Inside Car");
     }
     public void Accelerate()
     {
         Console.WriteLine("Inside Acceleration of Car");
     }
 }
 public class Ferrari : Car
 {
     public Ferrari()
     {
         Console.WriteLine("Inside Ferrari");
     }
     public void Accelerate()
     {
         Console.WriteLine("Inside Acceleration of Ferrari");
     }
 }

class Program
{
     static void Main(string[] args)
     {
         Car b = new Ferrari();
         b.Accelerate();
     }
}

程序的输出会是什么?

    1. 编译时错误

    2. 运行时错误

    3. Inside Acceleration of Ferrari

    4. Inside Acceleration of Car

  1. 我们有一个应用程序,在其中我们使用while循环编写了以下逻辑:
int i = 1;
while(i < 10)
{ 
    Console.WriteLine(i);
    ++i;
}

你会如何将其转换为等效的for循环?

    1. for (int i = 0; i < 10 ; i++)

      {

      Console.WriteLine(i);

      }

    2. for (int i = 1; i < 10 ; i++)

      {

      Console.WriteLine(i);

      }

    3. for (int i = 1; i < 10 ; ++i)

      {

      Console.WriteLine(i);

      }

    4. for (int i = 1; i <= 10 ; i++)

      {

      Console.WriteLine(i);

      `}`

  1. 以下程序的输出会是什么?
try
{
    int[] input = new int[5] { 0, 1, 2, 3, 4 };
    for (int i = 1; i <= 5; i++)
    {
         Console.Write(input[i]);
    }
}
catch (System.IndexOutOfRangeException e)
{
    System.Console.WriteLine("An error has occured in collection operation");
    throw;
}
catch (System.NullReferenceException e)
{
    System.Console.WriteLine("An error has occured in null reference operation");
    throw;
}
catch (Exception e)
{
    System.Console.WriteLine("Error logged for the application");
}
    1. 01234

    2. 1234

      集合操作中发生错误

    3. 1234 集合操作中发生错误 应用程序已记录错误

    4. 1234 集合操作中发生错误 空引用操作中发生错误 应用程序已记录错误

  1. 委托可以通过以下方式实例化:

    1. 匿名方法

    2. Lambda 表达式

    3. 命名方法

    4. 所有上述选项

  2. 在挂起状态下,将线程移动到运行状态需要执行哪些操作?

    1. 恢复

    2. 中断

    3. 中断

    4. 挂起

  3. 以下程序的输出是什么?

public class DisposeImplementation : IDisposable
{
     private bool isDisposed = false;
     public DisposeImplementation()
     {
         Console.WriteLine("Creating object of DisposeImplementation");
     }
     ~DisposeImplementation()
     {
         if(!isDisposed)
         {
             Console.WriteLine("Inside the finalizer of class DisposeImplementation");
             this.Dispose();
         }
     }
     public void Dispose()
     {
         isDisposed = true;
         Console.WriteLine("Inside the dispose of class DisposeImplementation");

     }
 }

DisposeImplementation d = new DisposeImplementation();
d.Dispose();
d = null;
GC.Collect();
Console.ReadLine();
    1. Creating object of DisposeImplementation

      Inside the dispose of class DisposeImplementation

      Inside the finalizer of class DisposeImplementation

    2. 创建 DisposeImplementation 对象

      在 DisposeImplementation 类的处置内部

    3. 应用程序中的运行时错误

    4. 创建 DisposeImplementation 对象

      在 DisposeImplementation 类的终结器内部

      在 DisposeImplementation 类的处置内部

  1. 看看以下程序:
static int CalculateResult(int parameterA, int parameterB, int parameterC, int parameterD = 0)
{
    int result = ((parameterA + parameterB) / (parameterC - parameterD));
    return result;
}

当使用以下语法调用时,输出会是什么?

CalculateResult(parameterA: 20, 15, 5)
    1. -7

    2. 1

    3. 7

    4. 运行时错误

  1. 哪些可以用来验证用户输入?

    1. 对称算法

    2. 非对称算法

    3. 哈希值

    4. 数字签名

  2. 我们正在处理一组大量的学生对象。您需要使用一种数据结构,它允许以任何顺序访问元素,并且允许不需要将它们分组在特定键下时重复值。您会选择什么?

    1. 列表

    2. 字典

    3. 队列

  3. 您的应用程序正在运行多个工作线程。您如何确保应用程序等待所有线程完成它们的执行?

    1. Thread.Sleep()

    2. Thread.WaitAll()

    3. Thread.Join()

  4. 在一个应用程序中,我们在一个类中编写一个方法,该方法应该可以被同一类中的类以及继承自该类的同一程序集中的类访问。你需要什么?

    1. 私有受保护

    2. 受保护内部

    3. 受保护

    4. 内部

第十九章:模拟测试 3

  1. 在您的应用程序中,您实现了LogException(string message)方法来记录异常。当您的应用程序抛出异常时,您想记录并重新抛出原始异常。您如何实现这一点?

    1. catch(Exception ex){LogException(ex.Message); throw;}

    2. catch(Exception ex){LogException(ex.Message); throw ex;}

    3. catch{LogException(ex.Message); throw new Exception();}

    4. catch{LogException(ex.Message); rethrow;}

  2. 您创建了一个应用程序,在其中实现了自定义异常类型,并实现了多个日志方法,如下所示:

public class CustomException1 : System.Exception{}
public class CustomException2 : CustomException1 {}
public class CustomException3 : CustomException1{}

void Log(Exception ex){}
void Log(CustomException2 ex) {}
void Log(CustomException3 ex) {}

您有一个可能会抛出上述任一异常的方法。您需要确保,当捕获到异常时,通过日志方法记录异常信息;当捕获到CustomException2时,日志方法接受CustomException2;对于CustomException3也是如此。您希望如何实现这一点?请指定catch语句的顺序:

    1. catch(CustomException1 ex){...}

      catch(CustomExceotion2 ex){...}

      catch(CustomException3 ex){...}

    2. catch(Exception ex){...}

      catch(CustomExceotion2 ex){...}

      catch(CustomException3 ex){...}

    3. catch(Exception ex){...}

      catch(CustomExceotion1 ex){...}

    4. catch(CustomException3 ex){...}

      catch(CustomExceotion2 ex){...}

      catch(Exception ex){...}

  1. 您的应用程序正在使用任务工厂运行多个任务。然而,一位客户要求您在父任务抛出异常时运行特定任务。您如何实现这一点?

    1. task.when()

    2. task.whenany()

    3. task.continuewhenany()

    4. 以上都不是

  2. 您的应用程序正在运行多个工作线程。您如何确保应用程序等待所有线程完成执行?

    1. Thread.Sleep()

    2. Thread.WaitAll()

    3. Thread.Join()

    4. 以上都不是

  3. 秘密密钥加密也称为非对称加密。

    1. True

    2. `False`

  4. 在公钥加密中,任何拥有公钥的人都可以处理消息。

    1. True

    2. False

  5. 在您的示例应用程序中使用RSACryptoServiceProvider时,您如何获取您的公钥和私钥?

    1. RSACryptoServiceProvider rsa = new RSACryptoServiceProvider();

      string publicKey = rsa.ToXmlString(false);

      string pricateKey = rsa.ToXmlString(true);

    2. RSACryptoServiceProvider rsa = new RSACryptoServiceProvider();

      string publicKey = rsa.ToXmlString(true);

      string pricateKey = rsa.ToXmlString(false);

    3. RSACryptoServiceProvider rsa = new RSACryptoServiceProvider();

      string publicKey = rsa.ToXmlString(public);

      string pricateKey = rsa.ToXmlString(private);

    4. RSACryptoServiceProvider rsa = new RSACryptoServiceProvider();

      string publicKey = rsa.ToXmlString("public");

      string pricateKey = rsa.ToXmlString("private");

  6. 最佳的验证发送者的方式是什么?

    1. 加密您的消息。

    2. 签署您的消息。

    3. 使用数字签名。

    4. 以上所有。

  7. 当您对一个字符串应用哈希算法时,输出会是什么?

    1. 字符串被加密。

    2. 每个字符都被哈希到一个不同的二进制字符串。

    3. 字符串被整体哈希。

    4. 以上都不是。

  8. 您正在为您的客户向现有应用程序添加新功能。当您部署它们时,您得到一个程序集清单不匹配错误。解决此问题的最佳可能解决方案是什么?

    1. 更新当前和依赖程序集的所有主要和次要版本,然后重新构建和部署。

    2. 检查当前和依赖程序集的所有程序集版本,并确保配置或策略已更新以反映程序集版本的更改,然后重新构建和部署。

    3. 更新machine.config文件以忽略此类错误。

    4. 更新web.config并将自定义错误模式设置为off

  9. 您创建了一个发布包并将应用程序部署到生产环境。当用户开始使用应用程序时,他们收到一个错误。您无法在任何较低级别的环境中重现它,因此您决定在生产环境中调试您的应用程序。然而,应用程序从未在断点处停止。这是为什么?

    1. 您在系统上没有本地管理员权限。

    2. Visual Studio 的调试模块未加载。

    3. 发布版本不允许我们调试。

    4. 以上所有。

  10. 您创建了一个应用程序,您想在它执行时监控它。因此,您决定实现跟踪。您如何跟踪您的应用程序,以便您可以在输出窗口中看到您的跟踪消息?

    1. 使用Console.WriteLine()

    2. 使用tracelistener添加输出窗口并使用trace.write

    3. Debug.WriteLine()

    4. Output.WriteLine()

  11. 您正在创建一个应用程序,其中有一个if语句和一个else语句。在if语句中,您有两个条件。您想在执行代码块之前验证这两个条件。您如何实现这一点?

    1. 使用&&运算符。

    2. 使用&运算符。

    3. 使用|运算符。

    4. 使用||运算符。

  12. 当您的表达式返回 null 值时,您如何将默认值返回到变量中?

    1. 使用ternary运算符。

    2. 使用binary运算符。

    3. 使用条件OR

    4. 使用null合并运算符。

  13. 您的代码中有多个相同方法的版本。您的客户要求您确保所有依赖的应用程序都使用该方法的特定版本。您如何确保没有人调用任何可能引起其他异常的其他方法?

    1. 更改所有其他方法的访问修饰符。

    2. 从这些方法中抛出异常。

    3. 使用Obsolete属性让用户知道正确的使用方法。

    4. 以上所有。

  14. 您正在创建一个 C#应用程序,您需要输出多行,每行之间有一个换行符。您如何实现这一点?

    1. var sb = new StringBuilder();sb.AppendLine(Line1); sb.AppendLine(Environment.NewLine);

    2. var sb = new StringBuilder();foreach(string line in strList){sb.AppendLine(Line1); sb.AppendLine(Environment.NewLine); }

    3. var sb = new StringBuilder();sb.Append(Line1); sb.Append('\t');

    4. 以上皆是

  15. 你如何确保父类方法在继承类中不可访问?你将使用哪种访问修饰符?

    1. 私有的

    2. 内部

    3. 受保护的

    4. 摘要

  16. 指定在运行时加载程序集的代码:

    1. Assembly.Load()

    2. Assembly.Create("A1.dll");Assembly.Load();

    3. Assembly.Load("a.dll");

    4. Assembly.GetType().Load();

  17. 当你创建一个 C#控制台应用程序时,你在解决方案资源管理器中看到哪些文件?

    1. 项目、App.ConfigProgram.cs、解决方案、属性和引用

    2. App.ConfigProgram.cs

    3. 项目、App.ConfigProgram.cs、解决方案、属性

    4. App.ConfigProgram.cs、属性

  18. 当你创建一个控制台应用程序并将static Main(string[] args)更改为static main(string[] args)时,会发生什么?

    1. 会引发编译时错误。

    2. 会引发运行时错误。

    3. ab都正确。

    4. 以上皆非。

  19. 声明为内部(internal)的类或类成员只能被同一程序集中的类访问,而不能被外部程序集访问。

    1. 正确

    2. 错误

  20. 考虑以下陈述。陈述 1:值类型保持变量的地址。陈述 2:指向地址 1 的两个引用类型变量反映了更新的值。

    1. 两个都是正确的。

    2. 陈述 1 是正确的,陈述 2 是错误的。

    3. 陈述 1 是错误的,陈述 2 是正确的。

    4. 两个都是错误的。

  21. 在定义接口时,为方法使用访问修饰符是一种良好的做法。

    1. 正确

    2. 错误

  22. 如何定义可选参数?

    1. void AddNumbers(int a=1, int b)

    2. void AddNumbers(int a, int b optional)

    3. void AddNumbers(int a, int b=4)

    4. void Add numbers(int a, optional int b)

  23. 在使用指针声明的程序函数中,你使用的关键字是什么?

    1. 密封的

    2. 安全的

    3. 内部

    4. 不安全的

  24. 我们使用什么语法向文件追加文本?

    1. File.CreateText

    2. FileInfo.Create

    3. File.Create

    4. File.AppendText

  25. 哪种集合类型可以用来创建一个强类型、基于零的索引,以 FIFO 方式处理对象?

    1. Queue<T>

    2. List<T>

    3. Array

    4. Dictionary

  26. 你正在创建一个管理信息的应用程序。你在类中定义了一个保存方法,并希望确保只有这个类及其继承类可以调用该方法。你希望将保存方法定义为强类型方法。你如何实现这一点?

    1. public static void Save<T>(T target) where T : new(), ParentClass {}

    2. public static void Save<T>(T target) where T : ParentClass,new() {}

    3. public static void Save<T>(T target) where T : ParentClass {}

    4. public static void Save(ParentClass target) {}

  27. 你正在开发一个将被多个应用程序使用的程序集。你需要将其安装到 GAC 中。你将执行哪些操作来实现这一点?

    1. 对程序集进行签名并使用 Gacutil 工具将程序集安装到 GAC。

    2. 版本化程序集并使用 Regsvr32 工具将程序集安装到 GAC。

    3. 拖放到 Windows 程序集文件夹。

    4. 以上皆是。

  28. 当两个实体需要使用非对称算法进行通信时,他们需要共享哪个密钥?

    1. 私钥

    2. 公钥

    3. 两者

第二十章:评估

第十七章 – 模拟测试 1

  1. b

  2. c

  3. 第一行是 b,第二行是 c

  4. c

  5. c

  6. d

  7. c

  8. c

  9. a

  10. a

  11. b, g, e

  12. c

  13. d

  14. a

  15. a

  16. a

  17. d

  18. b

  19. c

  20. b

第十八章 – 模拟测试 2

  1. a, d

  2. c

  3. b

  4. c

  5. a

  6. d

  7. b

  8. a

  9. d

  10. c

  11. b

  12. c

  13. d

  14. a

  15. b

  16. c

  17. b, d

  18. a

  19. b

  20. a

第十九章 – 模拟测试 3

  1. a

  2. d

  3. d

  4. b

  5. a

  6. b

  7. a

  8. c

  9. b

  10. a

  11. c

  12. c

  13. b

  14. d

  15. c

  16. b

  17. a

  18. c

  19. a

  20. a

  21. a

  22. c

  23. b

  24. c

  25. d

  26. d

  27. a

  28. b

  29. a

  30. c

posted @ 2025-10-22 10:34  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报