C--面向对象编程实用指南-全-

C# 面向对象编程实用指南(全)

原文:zh.annas-archive.org/md5/ADAC00B29224B3ED5BF1EE522FE998CB

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

面向对象编程(OOP)是围绕对象而不是动作组织的编程范式,围绕数据而不是逻辑。随着 C#的最新版本发布,有许多新的增强功能改进了 OOP。本书旨在以引人入胜和互动的方式教授 C#中的 OOP。阅读本书后,您将了解 OOP 的四大支柱,即封装、继承、抽象和多态,并能够利用 C# 8.0 的最新功能,如可空引用类型和异步流。然后,您将探索 OOP 中的各种设计模式、原则和最佳实践。

这本书适合谁

这本书适用于初学面向对象编程的人。它假设您已经具备基本的 C#技能。不需要对其他语言中的面向对象编程有任何了解。

本书涵盖的内容

第一章,《C#作为一种语言的概述》,涵盖了 C#编程语言的基本概述,以帮助初学者理解语言构造。本章还将解释.NET 作为一个框架存在的原因,以及如何在程序中利用.NET 框架。本章最后将介绍 Visual Studio 作为开发 C#项目的编辑器。

第二章,《你好,OOP-类和对象》,解释了面向对象编程的最基本概念。我们首先解释了什么是类,以及如何编写一个类。

第三章,《C#中的面向对象编程实现》,涵盖了使 C#成为面向对象编程语言的概念。本章涵盖了 C#语言的一些非常重要的主题,以及如何在实际编程中利用这些主题。

第四章,《对象协作》,涵盖了对象协作,它是什么,程序中的对象如何相互关联,以及对象之间存在多少种类型的关系。我们还将讨论依赖协作、关联和继承。

第五章,《异常处理》,涵盖了如何在执行代码时处理异常。我们将探讨不同类型的异常以及如何使用 try/catch 块消除代码中的问题。

第六章,《事件和委托》,涵盖了事件和委托。在本章中,我们将介绍事件是什么,委托是什么,事件如何与委托连接以及它们各自的用途。

第七章,《C#中的泛型》,介绍了一个非常有趣和重要的主题-泛型。我们将学习泛型是什么,以及它们为什么如此强大。

第八章,《建模和设计软件》,涵盖了软件设计中使用的不同统一建模语言(UML)图。我们将详细讨论最流行的图,包括类图、用例图和序列图。

第九章,《Visual Studio 和相关工具》,涵盖了 C#编程的最佳编辑器。Visual Studio 是一个非常丰富的集成开发环境。它具有一些令人惊叹的功能,可以使开发人员的工作效率非常高。在本章中,我们将介绍 Visual Studio 中可用的不同项目和窗口。

第十章,《通过示例探索 ADO.NET》,涵盖了 ADO.NET 类,以及通过实体框架的基本数据适配器、存储过程和对象关系模型的基础知识。我们还将讨论 ADO.NET 中的事务。

第十一章《C# 8 的新功能》涵盖了 C#语言的新功能,这个语言正在不断改进,C#语言工程师正在将额外的功能纳入语言中。2019 年,微软宣布将发布 C# 8.0,并概述将随该版本发布的新功能。本章将讨论 C# 8.0 中即将引入的新功能。我们将讨论可空引用类型、异步流、范围、接口成员的默认实现以及其他几个主题。

第十二章《理解设计模式和原则》包含有关设计原则和一些非常流行和重要的设计模式的信息。

第十三章《Git-版本控制系统》讨论了当今最流行的版本控制系统 Git。对于所有开发人员来说,学习 Git 是必不可少的。

第十四章《准备自己,面试和未来》包括一些最常见的面试问题和对这些问题的回答,以便您为下一次面试做好准备。这一章主要是为了让您对潜在的面试问题有一个概念。

充分利用本书

读者应该具有一些关于.NET Core 和.NET Standard 的先验知识,以及对 C#、Visual Studio 2017(作为 IDE)、版本控制、关系数据库和基本软件设计的基本知识。

下载示例代码文件

您可以从www.packt.com的帐户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packt.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/Hands-On-Object-Oriented-Programming-with-CSharp。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

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

下载彩色图像

我们还提供了一份 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781788296229_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“TweetMessage对象之间的关系”。

代码块设置如下:

class Customer
{
    public string firstName;
    public string lastName;
    public string phoneNumber;
    public string emailAddress;

    public string GetFullName()
    {
        return firstName + " " + lastName;
    }
}

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

class class-name {
    // property 1
    // property 2
    // ...

    // method 1
    // method 2
    // ...
}

任何命令行输入或输出都以以下方式编写:

git config --global user.name = "john"
git config --global user.email = "john@example.com"

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“转到工具|扩展和更新”。

警告或重要说明会以这种方式出现。

提示和技巧会以这种方式出现。

第一章:C#作为一种语言的概述

随着现代编程实践的引入,显然开发人员正在寻找更先进的构造,以帮助他们以最有效的方式交付最佳软件。建立在框架之上的语言旨在增强开发人员的能力,使他们能够快速构建具有较少复杂性的代码,以便代码可维护且可读。

市场上有许多高级面向对象的编程语言,但其中我认为最有前途的是 C#。C#语言在编程世界中并不新,已经存在了十多年,但随着语言本身的动态进展创造了许多新的构造,它已经超越了一些最广泛接受的语言竞争。C#是一种面向对象的、类型安全的、通用的语言,它是建立在由微软开发并由欧洲计算机制造商协会ECMA)和国际标准化组织ISO)批准的.NET 框架之上的。它是建立在公共语言基础设施上的,并且可以与基于相同架构构建的任何其他语言进行交互。受 C++的启发,该语言在不处理过多代码复杂性的情况下提供了最优质的应用程序。

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

  • C#的演变

  • C#的架构

  • C#语言的基础和语法

  • Visual Studio 作为编辑器

  • 在 Visual Studio 中编写你的第一个程序

C#的演变

C#是近年来最具活力的语言之一。这门语言是开源的,主要由一群软件工程师推动,他们最近提出了许多重大变化,以增强语言并提供处理现有语言复杂性的功能。为该语言提出的一些主要增强功能包括泛型、LINQ、动态和异步/等待模式:

在上图中,我们可以看到这门语言是如何从 C# 1.0 的托管代码开始演变的,到 C# 5.0 引入的异步编程构造,再到现代的 C# 8。在继续之前,让我们看一下 C#在不同演变阶段的一些亮点。

托管代码

托管代码这个词是在微软宣布.NET 框架之后出现的。在托管环境中运行的任何代码都由公共语言运行时CLR)处理,它保持

泛型

泛型是在 C# 2.0 中引入的概念,允许模板类型定义和类型参数。泛型允许程序员定义具有开放类型参数的类型,这从根本上改变了程序员编写代码的方式。动态类型的泛型模板提高了可读性、可重用性和代码性能。

LINQ

C#语言的第三个版本引入了语言集成查询(LINQ),这是一种可以在对象结构上运行的新查询构造。LINQ 在编程世界中非常新颖,让我们一窥面向对象通用编程结构之上的函数式编程。LINQ 还引入了一堆新的接口,以IQueryable接口的形式,引入了许多可以使用 LINQ 与外部世界交互的库。Lambda 表达式和表达式树的引入提升了 LINQ 的性能。

动态

第四版还提供了一个全新的构造。它引入了动态语言结构。动态编程能力帮助开发人员将编程调用推迟到运行时。语言中引入了特定的语法糖,它在同一运行时编译动态代码。该版本还提出了许多增强其语言能力的新接口和类。

异步/等待

使用任何语言,线程或异步编程都是一种痛苦。在处理异步时,程序员必须面对许多复杂性,这些复杂性降低了代码的可读性和可维护性。有了 C#语言中的 async/await 功能,以异步方式编程就像同步编程一样简单。编程已经简化,所有复杂性都由编译器和框架在内部处理。

编译器作为服务

微软一直在研究如何向世界开放编译器源代码的某些部分。因此,作为程序员,您可以查询编译器的一些内部工作原理。C# 6.0 引入了许多库,使开发人员能够深入了解编译器、绑定器、程序的语法树等。尽管这些功能作为 Roslyn 项目开发了很长时间,但微软最终将其发布给外部世界。

异常过滤器

C# 6.0 装饰有许多较小的功能。其中一些功能为开发人员提供了实现简单代码的复杂逻辑的机会,而另一些则增强了语言的整体能力。异常过滤器是这个版本的新功能,它使程序能够过滤出特定的异常类型。异常过滤器作为 CLR 构造一直隐藏在语言中,但最终在 C# 6.0 中引入。

C# 8 及更高版本

随着 C#成为市场上最具动态性的语言,它不断改进。通过新功能,如可空引用类型、异步流、范围和索引、接口成员等,以及最新版本的 C#带来的许多其他功能,它增强了基本功能,并帮助程序员利用这些新构造,从而使他们的生活更轻松。

请注意,在语言的演变过程中,.NET 框架也已开源。您可以在以下链接找到.NET 框架的源代码:referencesource.microsoft.com/

.NET 架构

尽管它已有十年历史,但.NET 框架仍然构建良好,并确保将其分层、模块化和分级。每个层提供特定的功能给用户,有些是安全性方面的,有些是语言能力方面的。这些层为最终用户提供了一层抽象,并尽可能隐藏本机操作系统的大部分复杂性。.NET 框架被分成模块,每个模块都有自己独特的责任。较高层从较低层请求特定功能,因此它是分级的。

让我们来看一下.NET 架构的图表:

上图描述了.NET 框架架构的布局。在最低级别上,它是与操作系统交互的操作系统,该操作系统中存在与操作系统中的内核 API 交互的操作系统。公共语言基础设施与 CLR 连接,提供监视每个代码执行和管理内存、处理异常以及确保应用程序行为符合预期的服务。基础设施的另一个重要目标是语言互操作性。公共语言运行时再次通过.NET 类库进行抽象。该层保存了语言构建的二进制文件,所有构建在库之上的编译器提供相同的编译代码,以便 CLR 可以理解代码并轻松相互交互。

在继续之前,让我们快速看一下构建在.NET 框架上的语言的一些关键方面。

公共语言运行时

CLR 提供了底层未管理基础设施与托管环境之间的接口。这以垃圾回收、安全性和互操作性的形式提供了托管环境的所有基本功能。CLR 由即时编译器组成,该编译器将使用特定编译器生成的程序集代码编译为本机调用。CLR 是.NET 架构中最重要的部分。

公共类型系统

由于语言和框架之间存在一层抽象,因此很明显,每种语言文字都映射到特定的 CLR 类型。例如,VB.NET 的整数与 C#的整数相同,因为它们都指向相同的类型 System.Int32。始终建议使用语言类型,因为编译器会处理类型的映射。CTS 系统构建为System.Object位于其顶点的类型层次结构。公共类型系统CTS)分为两种类型,一种是值类型,它们是从System.ValueTypes派生的原始类型,而其他任何类型都是引用类型。值类型与引用类型的处理方式不同。这是因为在分配内存时,值类型在执行期间在线程堆栈上创建,而引用类型始终在堆上创建。

.NET 框架类库

框架类库位于语言和 CLR 之间,因此框架中存在的任何类型都暴露给您编写的语言。.NET 框架由大量类和结构组成,提供无穷尽的功能,您作为程序员可以从中受益。类库以可以直接从程序代码中引用的二进制形式存储。

即时编译器

.NET 语言被编译两次。在第一种编译形式中,高级语言被转换为Microsoft 中间语言MSIL),CLR 可以理解,而在程序执行时,MSIL 再次被编译。JIT 在程序运行时内部工作,并定期编译预计在执行期间需要的代码。

C#语言的基本原理和语法

作为一种高级语言,C#装饰有许多更新和更新的语法,这有助于程序员高效地编写代码。正如我们之前提到的,语言支持的类型系统分为两种类型:

  • 值类型

  • 引用类型

值类型通常是存储在堆栈中的原始类型,用于本地执行,以便更快地分配和释放内存。值类型在代码开发过程中大多被使用,因此构成了整个代码的主要范围。

数据类型

C#的基本数据类型分为以下几类:

  • 布尔类型:bool

  • 字符类型:char

  • 整数类型:sbytebyteshortushortintuintlongulong

  • 浮点类型:floatdouble

  • 小数精度:decimal

  • 字符串:string

  • 对象类型:object

这些是原始数据类型。这些数据类型嵌入在 C#编程语言中。

可空类型

在 C#中,原始类型或值类型是不可空的。因此,开发人员总是需要将类型设置为可空,因为开发人员可能需要确定值是否是显式提供的。最新版本的.NET 提供了可空类型:

Nullable<int> a = null;
int? b = a; //same as above

在前面的示例中,两行都定义了可空变量,而第二行只是第一次声明的快捷方式。当值为 null 时,HasValue属性将返回false。这将确保您可以检测变量是否显式指定为值。

文字

文字也是任何程序的重要部分。C#语言为开发人员提供了不同种类的选项,允许程序员在代码中指定文字。让我们看看支持的不同类型的文字。

布尔

布尔文字以truefalse的形式定义。除了truefalse之外,布尔类型不能分配其他值:

bool result = true;

布尔类型的默认值是false

整数

整数是一个可以有加号(+)或减号(-)作为前缀的数字,但这是可选的。如果没有给出符号,则被视为正数。您可以以 int、long 或十六进制形式定义数字文字:

int numberInDec = -16;
int numberInHex = -0x10;
long numberinLong = 200L;

您可以看到,第一个文字-16是指定为整数变量的文字,而相同的值是使用十六进制文字分配给整数的。长变量被分配了一个带有L后缀的值。

真实

实数是带有正负号的数字序列,如整数。这也使得可以指定分数值:

float realNumber = 12.5f;
realNumber = 1.25e+1f;
double realdNumber = 12.5;

正如您所看到的,最后一行中的文字12.5默认为double,因此需要分配给 double 变量,而前两行指定了浮点类型中的文字。您还可以指定dD作为后缀来定义double,例如fF用于floatm用于 decimal。

字符

字符文字需要保留在单引号内。文字的值可以如下:

  • 一个字符,例如,c

  • 字符代码,例如,\u0063

  • 转义字符,例如,\\(反斜杠是一个转义字符)

字符串

字符串是一系列字符。在 C#中,字符串由双引号表示。在 C#中有不同的创建字符串的方式。让我们看看在 C#中创建字符串的不同方式:

string s = "hello world";
string s1 = "hello \n\r world"; //prints the string with escape sequence
string s2 = @"hello \n\r world"; //prints the string without escape sequence
string s3 = $"S1 : {s1}, S2: {s2}"; // Replaces the {s1} and {s2} with values

@字符可以放在字符串前面作为前缀,以便将字符串作为原样处理,而不必担心任何转义字符。它被称为原始字符串。$字符用作字符串插值的前缀。如果您的字符串文字以$符号开头,则如果它们放在{ }括号内,变量将自动替换为值。

编程语法-条件

条件是任何程序的最常见构建块之一。程序不能只有单个维度;比较、跳转和中断是 C#中最常见的练习形式。有三种类型的条件可用:

  • if...else

  • switch-case

  • goto(无条件 lumps)

If-else 结构

最常用的条件语句是 if-else 结构。if-else 结构的基本组成部分包含一个if关键字,后面跟着一个布尔表达式和一组花括号来指定要执行的步骤。可选地,可能会有一个else关键字,后面跟着花括号,用于在if块为false时执行的代码:

int a = 5;
if (a == 5)
{
   // As a is 5, do something
}
else
{
  // As a is not 5, do something
}

if-else 结构也可以有一个 else-if 语句来指定多个执行条件。

Switch-case 结构

另一方面,switch-case 几乎与if语句类似;在这个语句中,case 将确定执行步骤。在switch的情况下,这总是落在一组离散的值中,因此,这些值可以被设置:

int a = 5;
switch (a)
{
  case 4:
     // Do something; 
     break;
  case 5:
     // Do something;
     break;
 default:
     // Do something;
     break;
}

switch case 会自动选择正确的 case 语句,取决于值,并执行块内定义的步骤。case 需要以 break 语句结束。

goto 语句

尽管它们不太受欢迎,也不建议使用,goto语句用于语言中的无条件跳转,并且被语言本身广泛使用。作为开发人员,你可以使用goto语句跳转到程序中的任何位置:

... code block
goto lbl1;
...
...
lbl1: expression body

goto语句直接跳转到指定的位置,没有任何条件或标准。

编程语法 - 循环

对于执行过程中的重复任务,循环发挥着至关重要的作用。循环允许程序员定义循环将在何时结束,或者循环应该执行到何时的条件,具体取决于循环的类型。有四种类型的循环:

  • do-while

  • 对于

  • Foreach

while 结构

在编程世界中,循环用于使一系列执行步骤重复,直到满足条件。while循环是 C#编程架构的基本组成部分之一,用于循环执行大括号中提到的循环体,直到while条件中提到的条件为true

while (condition)
{
  loop body;
}

循环中提到的条件应该评估为true,以执行下一次迭代的循环。

do-while 结构

do...while结构在执行一次步骤后检查条件。尽管do...while循环类似于while循环,但do...while循环和while循环之间唯一的区别是,do...while循环将至少执行一次循环体,即使条件为false

do
{
  loop body;
}
while (condition);

for 结构

语言中最流行的循环是for循环,它通过在块内部高效地维护循环的执行次数来处理复杂性:

for (initialization; condition; update)
{
  /* loop body */
}

for循环在条件中有几个部分。每个部分都用分号(;)分隔。第一部分定义了索引变量,在执行循环之前执行一次。第二部分是在每次for循环迭代时执行的条件。如果条件变为falsefor循环将停止执行。第三部分也在每次执行循环体后执行,并且操作了在for循环初始化和条件中使用的变量。

foreach 结构

foreach循环是语言中的新功能,用于迭代对象序列。尽管这在语言中纯粹是语法糖,但在处理集合时,foreach循环被广泛使用。foreach循环内部使用IEnumerable<object>接口,并且应该只用于实现了该接口的对象:

foreach (type variable in collection)
{
    //statements;
}

上下文 - break 和 continue 语句

如果你在使用循环,理解另外两个上下文关键字是非常重要的,它们使得与循环进行交互成为可能。

Break

这允许开发人员在条件仍然有效的情况下中断循环并将上下文带出循环。编程上下文关键字break用作绕过正在执行的循环的循环。break语句在循环和 switch 语句中有效。

Continue

这用于调用下一次迭代。上下文关键字允许开发人员继续到下一步,而不执行块中的任何其他代码。

现在,让我们看看如何在我们的程序中使用这两个上下文语句:

var x = 0;
while(x<=10)
{
   x++;
   if(x == 2)continue;
   Console.WriteLine(x);
   if(x == 5) break;
   Console.WriteLine("End of loop body");
}
Console.WriteLine($"End of loop, X : {x}");

前面的代码将跳过迭代值为2的循环体的执行,因为有continue语句。循环将一直执行直到x的值为5,因为有break语句。

在控制台应用程序中编写您的第一个 C#程序

现在您已经了解了 C#语言的基本知识和基础知识,文字,循环,条件等,我认为是时候看一个 C#代码示例了。所以,让我们通过编写一个简单的控制台应用程序,编译它,并使用 C#编译器运行它来开始本节。

打开您计算机上的任何记事本应用程序,并输入以下代码:

using System;

public  Program
{
      static void Main(string[] args)
      {
          int num, sum = 0, r;
          Console.WriteLine("Enter a Number : ");
          num = int.Parse(Console.ReadLine());
          while (num != 0)
          {
              r = num % 10;
              num = num / 10;
              sum = sum + r;
          }
          Console.WriteLine("Sum of Digits of the Number : " + sum);
          Console.ReadLine();
      }
}

上述代码是计算数字所有数字之和的经典示例。它使用Console.ReadLine()函数作为输入,解析并将其存储到变量num中,循环遍历直到数字为0,并取模10以获得除法的余数,然后将其相加以产生结果。

您可以看到代码块顶部有一个using语句,它确保可以调用Console.ReadLine()Console.WriteLine()System是代码中的一个命名空间,它使程序能够调用其中定义的类,而无需指定类的完整命名空间路径。

让我们将类保存为program.cs。现在,打开控制台并将其移动到您保存代码的位置。

要编译代码,我们可以使用以下命令:

csc Program.cs

编译将产生类似于这样的东西:

编译将产生program.exe。如果您运行此程序,它将接受数字作为输入并产生结果:

您可以看到代码正在控制台窗口中执行。

如果我们进一步分析代码的执行方式,我们可以看到.NET 框架提供了csc编译器,这是一个能够将我的 C#代码编译成托管可执行文件的可执行文件。编译器生成一个包含 MSIL 的可执行文件,然后在执行可执行文件时,.NET 框架调用一个可执行文件,并使用 JIT 进一步编译它,以便与输入/输出设备进行交互。

csc编译器提供了各种命令行钩子,可以进一步用于向程序添加动态链接库dll)引用,将输出目标设置为 dll 等。您可以在以下链接找到完整的功能文档:docs.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-options/listed-alphabetically

Visual Studio 作为编辑器

微软创建了许多改进工具集,帮助创建,调试和运行程序。其中一个工具就是Visual StudioVS)。微软 VS 是一个与微软语言一起工作的开发环境。这是开发人员可以依赖的工具,以便他们可以轻松地使用微软技术。VS 已经存在了相当长的时间,但新的 VS 已经完全重新设计,并作为 VS 2019 发布,以支持.NET 语言。

Visual Studio 的演变

随着时间的推移,微软发布了更多优势和增强功能的新版本 VS。作为托管许多服务作为插件的插件主机,VS 已经发展出许多工具和扩展。它一直是每个开发人员活动的核心部分。VS 已被许多不属于开发人员社区的人使用,因为他们发现这个 IDE 对编辑和管理文档很有益。

Visual Studio 的类型

微软推出了不同类型或版本的 VS。这些版本之间的区别在于功能和定价。其中一个版本是免费的,而其他版本需要购买。因此,了解哪个版本提供了哪些功能,哪个版本更适合哪种类型的工作,将使开发人员更容易选择合适的版本。

让我们来比较一下所有版本的 VS。

Visual Studio Community

VS 社区版是免费版。这个版本没有一些其他版本中可用的高级功能,但这个社区版完全适用于构建小型/中型项目。这对于想要探索 C#编程语言的人特别有用,因为他们可以免费下载这个版本并开始构建应用程序。

Visual Studio Professional

这个版本的 VS 是为您自己的开发而设计的,具有重要的调试工具和所有常用的开发人员工具。因此,您可以将 IDE 用作您的主要方向,然后可以继续!

Visual Studio Enterprise

VS 企业版是为需要商业级 IDE 使用的企业而设计的。它支持用于测试、调试等的特殊工具。它还可以发现常见的编码错误,生成测试数据等等。

Visual Studio Code

VS Code 是一个小型的开源工具,不是完整的 IDE,而是由微软开发的简单代码编辑器。这个编辑器非常轻量级且与平台无关。VS Code 没有大多数 VS IDE 具有的功能,但具有足够的功能来开发和调试应用程序。

对于本书,我们将在大多数情况下使用 VS 社区版,但您可以安装任何您希望的版本。您可以免费下载社区版,网址如下:www.visualstudio.com/downloads/

Visual Studio IDE 简介

安装 VS 后,VS 安装程序将为您提供关于工作负载的几个选项,这意味着您将使用此 IDE 开发的应用程序类型。对于本书,我们只会创建 C#控制台应用程序,因此您可以选择该选项。现在,让我们开始 VS IDE。加载 IDE 后,它将显示一个带有多个选项的起始页面。选择创建新项目的选项。

新项目

选择新项目后,将出现新项目对话框。在此对话框中,将基于当前与 IDE 一起安装的软件包提供一些选项,如下图所示:

在上图中,左侧的分组是您可以选择的模板类型。在这里,我选择了 Windows 桌面,并从中间窗口中选择了控制台应用程序(.NET 框架)来创建我的应用程序。屏幕底部允许您命名项目并选择存储项目文件的位置。有两个复选框可用,其中一个说“选择时创建解决方案目录”(默认情况下,此复选框保持选中状态)。这将在所选路径下创建一个目录并将文件放入其中,否则它将在文件夹内部创建文件。

使用“搜索已安装的模板”在对话框的右上角按名称搜索任何模板,如果找不到您的模板。由于一台 PC 上可以存在多个框架,新项目对话框将允许您选择一个框架;在部署应用程序时需要使用它。默认情况下,它显示.NET 框架 4.6.1 作为项目的框架,但您可以通过从下拉菜单中选择一个来更改为任何框架。

最后,单击“确定”以使用默认文件创建项目:

前面的屏幕截图显示了项目创建后基本 IDE 的外观。我们还可以看到 IDE 的每个部分。主要 IDE 由许多工具窗口组成。您可以在屏幕的各个部分看到一些工具窗口。任务列表窗口位于屏幕底部。主要 IDE 工作区位于中间,形成了 IDE 的工作区域。可以使用屏幕角落的缩放控件放大工作区。屏幕顶部的 IDE 搜索框可以帮助您更优雅、更轻松地找到 IDE 内部的选项。现在我们将整个 IDE 分成这些部分,并探索 IDE。

解决方案资源管理器

文件夹和文件在解决方案资源管理器中按层次结构显示。解决方案资源管理器是主窗口,列出了加载到 IDE 中的整个解决方案。这使您可以以树的形式轻松导航查看具有解决方案的项目和文件。解决方案资源管理器的外部节点本身就是一个解决方案,然后是项目,然后是文件和文件夹。解决方案资源管理器支持加载解决方案中的文件夹,并在第一级存储文档。设置为启动的项目以粗体标记。

解决方案资源管理器顶部有许多称为工具栏按钮的按钮。根据树中所选文件,工具栏按钮将启用或禁用。让我们逐个查看它们:

  • 折叠所有按钮:此按钮允许您折叠当前选定节点下方的所有节点。在处理大型解决方案时,通常需要完全折叠部分树。您可以使用此功能而无需手动折叠每个节点。

  • 属性:作为打开属性窗口的快捷方式,您可以选择此按钮以打开属性窗口并加载与当前选择节点相关联的元数据。

  • 显示所有文件:解决方案通常映射到文件系统中目录的文件夹结构。解决方案中包含的文件仅显示在解决方案树上。显示所有文件允许您在查看目录中的所有文件和仅添加到解决方案中的文件之间切换。

  • 刷新:刷新当前解决方案中文件的状态。刷新按钮还会检查文件系统中的每个文件,并根据需要显示其状态。

  • 查看类图:类图是命名空间和类的逻辑树,而不是文件系统中的文件。选择此选项时,VS 会启动具有其属性、方法等所有详细信息的类图。类图对于单独查看所有类及其关联非常有用。

  • 查看代码:当选择代码文件时,将出现查看代码按钮,它会加载与当前选择相关联的代码文件。例如,当选择 Windows 窗体时,它将显示其代码后端,代码需要在其中编写。

  • 查看设计器:有时,根据树中所选的文件类型,会出现查看设计器按钮。此按钮会启动与当前选择的文件类型相关联的设计器。

  • 添加新文件夹:如我已经提到的,解决方案也可以包含文件夹。您可以使用添加新文件夹按钮直接向解决方案中添加文件夹。

  • 创建新解决方案:有时,在处理大型项目时,您可能需要创建整个解决方案的子集,并仅列出您当前正在处理的项目。此按钮将创建一个与原始解决方案同步的单独的解决方案资源管理器,但会显示解决方案树的特定部分。

在 VS 中的解决方案树也以文件系统中的组织方式加载项目的类结构。如果你看到一个折叠的文件夹,你可以展开它来看看里面有什么。如果你展开一个.cs文件,那么该类的所有成员都会被列出来。如果你只想看看类是如何组织的,你可以使用类视图窗口,但是通过使用解决方案资源管理器,你可以看到类,以及其自己层次结构内的其他元素。你可以通过选择视图|类视图或按Ctrl + W 和 C来打开类视图,这样你就可以只查看类的一部分和其成员:

在解决方案中有一些文件显示为空文件(在我们的情况下,像binobj这样的文件夹)。这意味着这些文件存在于文件系统中,但没有包含在解决方案文件中。

每个文件在解决方案中的树节点右侧都显示了额外的信息。这个按钮提供了与文件相关的额外信息。例如,如果你点击与.cs文件对应的按钮,它将打开一个带有Contains的菜单。这将在解决方案中为该特定文件获取关联的类视图。菜单可能会很长,取决于不能在通用工具栏按钮中显示的项目。当解决方案加载额外信息时,会有前进和后退按钮,可以用来在解决方案的视图之间导航。

主工作区域

主工作区域是你实际编写代码或对应用程序应用不同设置的地方。这个部分将打开你项目中的不同类型的文件。作为开发人员,你会在这个区域花费大部分时间编码。你可以在这个窗口中打开多个文件。不同的文件将显示在不同的标签中,你可以通过点击标签来在不同的标签之间切换。如果需要的话,你也可以固定标签。如果你认为需要这样,你可以让标签浮动,或者也可以使其全屏大小,这样你就可以专注于你正在工作的代码。

因此,当你在解决方案资源管理器中双击文件或从文件的上下文菜单中选择打开时,该文件将在主编辑区域的标签页中打开。这样,你可以在编辑器窗口中打开多个文件,并在需要时在不同的标签之间切换。每个标签标题都包含一些固定的项目集:

在上面的截图中,你可以看到标签标题包含文件的名称(Program.cs),它显示*当项目需要保存时,并且有一个切换固定按钮(就像所有其他 IDE 工具窗口一样),它可以使标签固定在左侧,并且有一个关闭按钮。标题部分有时也会指示一些额外的状态,例如,当文件被锁定时,它会显示一个锁图标,当对象从元数据中加载时,它会在方括号中显示,就像上面的截图中一样。在这个部分,当我们不断打开文件时,它会形成一个标签页的堆栈,一直到最后。当整个区域被占满时,它最终会在工作区标题的右上角创建一个菜单,用来保存所有不能在屏幕上显示的文件列表。从这个菜单中,你可以选择需要打开的文件。Ctrl + Tab也可以用来在工作区中已加载的标签之间切换。

在选项卡标题下方和主工作区域之前有两个下拉菜单。一个加载了在 IDE 中打开的类,右边的一个加载了文件中创建的所有成员。这些下拉菜单有助于更轻松地在文件中导航,左边列出了当前文件中加载的所有类,而右边则列出了上下文中存在的所有成员。这两个下拉菜单足够智能,可以在编辑器中添加新代码时自动更新下拉值。

主工作区域由两个滚动条限定,用于处理文档的溢出。然而,在垂直滚动条之后,有一个特殊的按钮可以分割窗口,如下面的屏幕截图所示:

另一方面,水平滚动条上有另一个下拉菜单,显示编辑器的当前缩放百分比。VS 现在允许您将编辑器缩放到您喜欢的缩放级别。缩放功能的快捷键是Ctrl +滚动鼠标滚轮。

输出窗口

输出窗口通常位于 IDE 底部,并在编译、连接到各种服务、开始调试或需要 IDE 显示一些代码时打开。输出窗口用于显示日志和跟踪消息:

输出窗口停靠在页面底部,列出各种类型的输出。从顶部的下拉菜单中,您可以选择要在输出窗口中看到的输出。您还可以选择清除日志,如果您只想显示更新的日志。

命令和即时窗口

命令窗口与 Windows 操作系统的命令提示符非常相似。您可以使用此工具执行命令。在 VS 命令行中,您可以在正在处理的项目上执行命令。命令非常方便,可以提高您的生产率,因为您不必四处拖动鼠标来执行某些操作。您可以运行命令轻松实现这一点。

要在 VS 中打开命令窗口,可以单击“查看”菜单,然后选择“窗口”。然后,选择“命令窗口”。或者,您可以使用键盘快捷键Ctrl + Alt + A来打开它。当您在命令窗口中时,您会看到每个输入前面都有一个>。这称为提示符。在提示符中,当您开始输入时,它将为您显示智能感知菜单。开始输入Build.Compile,项目将为您编译。您还可以使用Debug.Start来开始调试应用程序。您可以使用命令轻松调试应用程序。我将列出一些在使用命令窗口调试时经常使用的重要命令:

  • ?: 告诉您变量的值(也可以使用Debug.Print执行相同操作)

  • ??: 将变量发送到监视窗口

  • locals: 显示本地窗口

  • autos: 显示自动窗口

  • GotoLn: 将光标设置到特定行

  • Bp: 在当前行设置断点

与命令窗口类似,中间窗口允许您测试代码而无需运行它。中间窗口用于评估、执行语句,甚至打印变量值。要打开即时窗口,请转到“调试|窗口”并选择“即时”。

IDE 中的搜索选项

在屏幕的右上角,您会找到一个新的搜索框。这称为 IDE 搜索框。VS IDE 非常庞大。其中有成千上万的选项可供配置。有时,很难找到您想要的特定选项。IDE 搜索功能可以帮助您更轻松地找到此选项:

搜索选项将列出与 VS IDE 选项相关的所有条目,您可以轻松找到您要查找的任何功能。

在 Visual Studio 中编写您的第一个程序

VS 是开发人员在使用 C#语言时主要编码的 IDE。由于您已经对 VS 的工作原理有了基本的了解,让我们在 VS 中编写我们的第一个程序。让我们创建一个控制台应用程序,将解决方案命名为MyFirstApp,然后按下 OK。默认的解决方案模板将自动添加,其中包括一个带有Main程序的Program.cs,以及其他一些文件。

让我们构建一个生成 ATM 机的程序。菜单中有三个选项:

  • 提款

  • 存款

  • 余额检查

提款将在余额(最初为$1,000)上执行,存款将向当前余额添加金额。现在,让我们看看程序的样子:

class Program
{
  static void Main(string[] args)
  {
      int balance, depositAmt, withdrawAmt;
      int choice = 0, pin = 0;
      Console.WriteLine("Enter your ledger balance");
      balance = int.Parse(Console.ReadLine());
      Console.WriteLine("Enter Your Pin Number ");
      pin = int.Parse(Console.ReadLine());

      if(pin != 1234)
      {
          Console.WriteLine("Invalid PIN");
          Console.ReadKey(false);
          return;
      }

      while (choice != 4)
      {
          Console.WriteLine("********Welcome to PACKT Payment Bank**************\n");
          Console.WriteLine("1\. Check Balance\n");
          Console.WriteLine("2\. Withdraw Cash\n");
          Console.WriteLine("3\. Deposit Cash\n");
          Console.WriteLine("4\. Quit\n");
          Console.WriteLine("*********************************************\n\n");
          Console.WriteLine("Enter your choice: ");
          choice = int.Parse(Console.ReadLine());

          switch (choice)
          {
              case 1:
                  Console.WriteLine("\n Your balance $ : {0} ", balance);
                  break;
              case 2:
                  Console.WriteLine("\n Enter the amount you want to withdraw : ");
                  withdrawAmt = int.Parse(Console.ReadLine());
                  if (withdrawAmt % 100 != 0)
                  {
                      Console.WriteLine("\n Denominations present are 100, 500 and 2000\. Your amount cannot be processed");
                  }
                  else if (withdrawAmt > balance)
                  {
                      Console.WriteLine("\n Sorry, insufficient balance.");
                  }
                  else
                  {
                      balance = balance - withdrawAmt;
                      Console.WriteLine("\n\n Your transaction is processed.");
                      Console.WriteLine("\n Current Balance is {0}", balance);
                  }
                  break;
              case 3:
                  Console.WriteLine("\n Enter amount you want to deposit");
                  depositAmt = int.Parse(Console.ReadLine());
                  balance = balance + depositAmt;
                  Console.WriteLine("Your ledger balance is {0}", balance);
                  break;
              case 4:
                  Console.WriteLine("\n Thank you for using the PACKT ATM.");
                  break;
          }
      }
      Console.ReadLine();
  }
}

现在,让我们说明一下程序。程序在打开 ATM 机之前会要求输入 PIN 码。PIN 码不会被检查,可以是任何数字。一旦程序启动,它会在控制台的前面创建一个菜单,其中包含所有所需的选项。

您可以看到整个代码都写在一个while循环中,因为它确保程序保持活动状态以进行多次执行。在执行期间,您可以选择任何可用的选项并执行与之相关的操作。

要执行程序,只需单击 IDE 工具栏上的运行按钮:

如果程序没有自动运行,您可以查看错误列表窗口以找出实际问题。如果代码中有错误,VS 将向您显示适当的错误消息,您可以双击它以导航到实际位置。

如何调试

如果您听说过 VS,您一定听说过 IDE 的调试功能。您可以按F10以调试模式启动程序。程序将以第一行的上下文启动调试模式。让我们执行几行。这将如下所示:

代码编辑器工作区中突出显示的行表示当前执行已停止的行。该行还在代码编辑器的最左边标有箭头。您可以继续按F10F11(步入)按钮来执行这些行。您必须检查本地窗口,以了解在执行期间本地变量的所有值。

通过代码调试

对于真正高级的用户,.NET 类库开放了一些有趣的调试器 API,您可以从源代码中调用调试器手动调试。

从程序的一开始,有一个DEBUG预处理变量,它确定项目是否是以调试模式构建的。

您可以按以下方式编写代码:

#IF DEBUG
/// The code runs only in debug mode
#ENDIF

预处理指令实际上是在编译时评估的。这意味着IF DEBUG内部的代码只会在以调试模式构建项目时编译到程序集中。

还有其他选项,如Debug.AssertDebug.FailDebug.Print。所有这些选项只在调试模式下工作。在发布模式下,这些 API 将不会被编译。

如果有任何可用的进程,您还可以调用附加到进程的调试器,使用Debugger.Break()方法,在当前行中断调试器。您可以检查调试器。IsAttached用于查找调试器是否附加到当前进程。

当您开始调试代码时,VS 会启动实际的进程以及一个带有.vshost文件名的进程。VS 通过启用部分信任的调试和使用.vshost文件来提高F5体验,增强了调试体验。这些文件在后台工作,将实际进程与预定义的应用程序域附加以进行调试,以实现无缝的调试体验。

.vshost文件仅由 IDE 使用,不应该在实际项目中进行部署。

VS 需要终端服务来运行这些调试器,因为它即使在同一台机器上也会与进程通信。它通过使用终端服务来保持对进程的正常和远程调试的无缝体验。

总结

在本章中,我们介绍了 C#语言的基础知识,并介绍了 VS 编辑器。我们还尝试使用命令行和 VS 编写了我们的第一个程序。

在下一章中,我们将继续讨论面向对象的概念和技术,这将使我们能够编写更多的类。

第二章:面向对象编程 - 类和对象

面向对象编程OOP)是特殊的。如果你在互联网上搜索关于 OOP 的书籍,你会发现数百本关于这个主题的书。但是这个主题永远不会变得陈旧,因为它是行业中最有效、最常用的编程方法。随着对软件开发人员的需求增加,对良好学习内容的需求也在增加。我们在这本书中的方法是以最简单的方式描述 OOP 的概念。理解 OOP 的基础对于想要使用 C#工作的开发人员来说是必须的,因为 C#是一种完全面向对象的语言。在本章中,我们将尝试理解 OOP 到底是什么,以及 OOP 的最基本概念,这些概念对我们开始编程之旅至关重要。在任何其他事情之前,让我们首先分析一下术语面向对象编程的含义。

第一个词是对象。根据词典的定义,对象是可以看到、感觉到或触摸到的东西;在现实世界中具有物理存在的东西。如果一个物品是虚拟的,这意味着它没有任何物理存在,不被视为对象。第二个词是面向,表示方向或目标。例如,当我们说我们面向建筑物时,我们的意思是我们正面朝它。第三个词是编程。我相信我不必解释什么是编程,但以防你完全不知道编程是什么并且正在阅读这本书来学习,让我简要解释一下编程是什么。编程只是给计算机指令。由于计算机不会说我们的语言,我们人类必须用计算机能理解的语言给它指令。我们人类称这些指令为计算机程序,因为我们正在引导或指导计算机做一件特定的事情。

现在我们知道了这三个关键词的定义,如果我们把所有这些词放在一起,我们就能理解短语“面向对象编程”的含义。OOP 意味着我们编写计算机程序时将对象置于思考的中心。OOP 既不是工具也不是编程语言,它只是一个概念。一些编程语言是设计遵循这个概念的。C#是最流行的面向对象语言之一。还有其他面向对象的语言,比如 Java、C++等等。

在 OOP 中,我们试图将软件组件看作小对象,并在它们之间建立关系来解决问题。你可能在编程世界中遇到过其他编程概念,比如过程式编程、函数式编程和其他类型的编程。有史以来最流行的计算机编程语言之一——C 编程语言是一种过程式编程语言。F#是函数式编程语言的一个例子。

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

  • OOP 中的类

  • 类的一般形式

  • 什么是对象?

  • 类中的方法

  • OOP 的特点

OOP 中的类

在 OOP 中,你从类中派生对象。在本节中,我们将更仔细地看看类到底是什么。

类是 OOP 中最重要的概念之一。你可以说它们是 OOP 的构建模块。类可以被描述为对象的蓝图。

类似于一个模板或蓝图,告诉我们这个类的实例将具有什么属性和行为。在大多数情况下,一个类本身实际上不能做任何事情——它只是用来创建对象的。让我们看一个例子来说明我所说的。假设我们有一个Human类。在这里,当我们说Human时,我们并不是指任何特定的人,而是指一般的人类。一个有两只手、两条腿和一个嘴巴的人,还可以走路、说话、吃饭和思考。这些属性及其行为适用于大多数人类。我知道这对于残疾人来说并非如此,但现在,我们将假设我们的一般人类是健全的,以保持我们的例子简单。因此,当我们在一个对象中看到上述属性和行为时,我们可以很容易地将该对象归类为人类对象或人。这种分类在面向对象编程中称为类。

让我们更仔细地看看Human类的属性和行为。人类可以列举数百种属性,但为了简单起见,我们可以说以下是人类的属性:

  • 高度

  • 体重

  • 年龄

我们也可以对行为属性做同样的事情。一个人可以执行数百种特定的行为,但在这里我们只考虑以下行为:

类的一般形式

要在 C#中创建一个类,必须遵循特定的语法。其一般形式如下:

class class-name {
    // this is class body
}

class短语是 C#中的保留关键字,用于告诉编译器我们想要创建一个类。要创建一个类,需要在一个空格后放置class关键字,然后是类的名称。类的名称可以是以字符或下划线开头的任何内容。类名中也可以包括数字,但不能是类名的第一个字符。在选择的类名之后,必须放置一个开放的大括号,表示类体的开始。您可以在类中添加内容,例如属性和方法,然后用一个闭合的大括号结束类,如下所示:

class class-name {
 // property 1
 // property 2
 // ...

 // method 1
 // method 2
 // ...
}

还有其他关键字可以与类一起使用,以添加更多功能,例如访问修饰符、虚方法、部分方法等。不要担心这些关键字或它们的用途,因为我们将在本书的后面讨论这些内容。

编写一个简单的类

现在让我们创建我们的第一个类。假设我们正在为一家银行开发一些软件。我们的应用程序应该跟踪银行的客户及其银行账户,并对这些银行账户执行一些基本操作。由于我们将使用 C#设计我们的应用程序,因此我们必须以面向对象的方式思考我们的应用程序。我们将需要这个应用程序的一些对象,比如客户对象、银行账户对象和其他对象。因此,为了制作这些对象的蓝图,我们必须创建一个Customer类和一个BankAccount类,以及我们将需要的其他类。让我们首先使用以下代码创建Customer类:

class Customer
{
    public string firstName;
    public string lastName;
    public string phoneNumber;
    public string emailAddress;

    public string GetFullName()
    {
        return firstName + " " + lastName;
    }
}

我们从class关键字开始,然后是Customer类的名称。之后,我们在大括号{}内添加了类体。该类拥有的变量是firstNamelastNamephoneNumberemailAddress。该类还有一个名为GetFullName()的方法,该方法使用firstNamelastName字段来准备全名并返回它。

现在让我们使用以下代码创建一个BankAccount类:

class BankAccount {
    public string bankAccountNumber;
    public string bankAccountOwnerName;
    public double amount;
    public datetime openningDate;

    public string Credit(){
        // Amount credited
    }

    public string Debit(){
        // Amount debited
    }
}

在这里,我们可以看到我们已经遵循了创建类的类似方法。我们使用了class关键字,然后是BankAccount类的名称。在名称之后,我们用一个开放的大括号开始了类体,并输入了字段,如bankAccountNumberbankAccountOwnerNameamountopenningDate,然后是两个方法,CreditDebit。通过放置一个闭合的大括号,我们结束了类体。

现在,不要担心诸如public之类的关键字;当我们讨论访问修饰符时,我们将在本书的后面学习这些关键字。

面向对象编程中的对象

我们现在知道了是什么。现在让我们来看看面向对象编程中对象是指什么。

对象是类的一个实例。换句话说,对象是类的一个实现。例如,在我们的银行应用程序中,我们有一个Customer类,但这并不意味着我们实际上在我们的应用程序中有一个客户。要创建一个客户,我们必须创建Customer类的对象。假设我们有一个名为琼斯先生的客户。对于这个客户,我们必须创建Customer类的对象,其中人的名字是杰克琼斯。

由于琼斯先生是我们的客户,这意味着他也在我们的银行有一个账户。要为琼斯先生创建一个银行账户,我们必须创建一个BankAccount类的对象。

如何创建对象

在 C#中,要创建一个类的对象,您必须使用new关键字。让我们看一个对象的例子:

Customer customer1 = new Customer();

在这里,我们首先写了Customer,这是类的名称。这代表了对象的类型。之后,我们给出了对象的名称,在这种情况下是customer1。您可以给该对象任何名称。例如,如果客户是琼斯先生,我们可以将对象命名为jackJones。在对象名称之后,我们插入了一个等号(=),这意味着我们正在给customer1对象赋值。之后,我们输入了一个称为new的关键字,这是一个特殊的关键字,告诉编译器创建给定类的新对象。在这里,我们再次给出了Customer,并在其旁边加上了()。当我们放置Customer()时,我们实际上正在调用该类的构造函数。我们将在后续章节中讨论构造函数。

我们可以使用以下代码创建jackJones

Customer jackJones = new Customer();

C#中的变量

在前面的代码中,您可能已经注意到我们创建了一些变量。变量是一种变化的东西,这意味着它不是常数。在编程中,当我们创建一个变量时,计算机实际上会为其分配内存空间,以便可以将变量的值存储在那里。

让我们为我们在上一节中创建的对象的变量分配一些值。我们将首先处理customer1对象,如下所示的代码:

using System;

namespace Chapter2
{
    public class Code_2_2
    {
        static void Main(string[] args)
        {
            Customer customer1 = new Customer();
            customer1.firstName = "Molly";
            customer1.lastName = "Dolly";
            customer1.phoneNumber = "98745632";
            customer1.emailAddress = "mollydolly@email.com";

            Console.WriteLine("First name is " + customer1.firstName);
            Console.ReadKey();
        }
    }

    public class Customer
    {
        public string firstName;
        public string lastName;
        public string phoneNumber;
        public string emailAddress;

        public string GetFullName()
        {
            return firstName + " " + lastName;
        }
    }
}

在这里,我们正在给customer1对象赋值。该代码指示计算机在内存中创建一个空间并将值存储在其中。稍后,每当您访问变量时,计算机将转到内存位置并找出变量的值。现在,如果我们编写一个语句,将打印firstName变量的值以及其前面的附加字符串,它将如下所示:

Console.WriteLine("First name is " + customer1.firstName);

这段代码的输出将如下所示:

First name is Molly

类中的方法

让我们谈谈另一个重要的话题——方法。方法是在代码文件中编写的可以重复使用的代码片段。一个方法可以包含许多行代码,在调用时将被执行。让我们来看一下方法的一般形式:

access-modifier return-type method-name(parameter-list) {
    // method body
}

我们可以看到方法声明中的第一件事是access-modifier。这将设置方法的访问权限。然后,我们有方法的return-type,它将保存方法将返回的类型,例如stringintdouble或其他类型。之后,我们有method-name,然后是括号(),表示这是一个方法。在括号中,我们有parameter-list。这可以是空的,也可以包含一个或多个参数。最后,我们有花括号{},其中包含方法体。方法将执行的代码放在这里。

按照这种结构的任何代码将被 C#编译器视为方法。

创建一个方法

既然我们知道了方法是什么,让我们来看一个例子,如下所示的代码:

public string GetFullName(string firstName, string lastName){
    return firstName + lastName;
}

这段代码将创建一个名为GetFullName的方法。这个方法接受两个参数,firstNamelastName,放在括号里。我们还可以看到,我们必须指定这些参数的类型。在这个特定的例子中,这两个参数的类型都是string

现在,让我们看一下方法体,即大括号之间的部分{}。我们可以看到,代码返回firstName + lastName,这意味着它正在连接这两个参数firstNamelastName,并返回string。因为我们打算从这个方法返回一个string,所以我们将方法的返回类型设置为string。另一个需要注意的是,这个方法的访问类型设置为public,这意味着任何其他类都可以访问它。

类的构造函数

在每个类中,都有一种特殊类型的方法,称为构造函数。你可以在一个类中创建一个构造函数并对其进行编程。如果你自己不创建一个,编译器将创建一个非常简单的构造函数并使用它。让我们来看看构造函数是什么,它的作用是什么。

构造函数是在创建类的对象时触发的方法。构造函数主要用于设置类的先决条件。例如,如果你正在创建Human类的对象,那个人的对象必须有一个出生日期。没有出生日期,就不会有人存在。你可以在构造函数中设置这个要求。你还可以配置构造函数,如果没有提供出生日期,则将出生日期设置为今天。这取决于你的应用程序的需求。另一个例子可能是bank account对象,你必须提供银行账户持有人。没有所有者,就不可能存在银行账户,所以你可以在构造函数中设置这个要求。

让我们来看一下构造函数的一般形式,如下面的代码所示:

access-modifier class-name(parameter-list) {
    // constructor body
}

在这里,我们可以看到构造函数和普通方法之间有一个区别,即构造函数没有返回类型。这是因为构造函数不能返回任何东西;它是用于初始化,而不是用于任何其他类型的操作。通常,构造函数的访问类型是public,因为否则无法实例化对象。如果你特别想阻止类的对象被实例化,你可以将构造函数设置为private。让我们看一个构造函数的例子,如下面的代码所示:

class BankAccount {
    public string owner;

    public BankAccount(){
        owner = "Some person";
    }
}

在这个例子中,我们可以看到有一个名为BankAccount的类,它有一个名为owner的变量。正如我们所知,没有所有者的银行账户是不存在的,所以我们需要在创建对象时为owner赋值。为了创建一个构造函数,我们只需将构造函数的访问类型设置为public,因为我们希望对象被实例化。我们还可以在构造函数中将银行账户所有者的姓名作为参数,并将其用于赋值给变量,如下面的代码所示:

class BankAccount {
    public string owner;

    public BankAccount(string theOwner){
        owner = theOwner;
    }
}

如果在构造函数中放入参数,那么在初始化对象时,需要传递参数,如下面的代码所示:

BankAccount account = new BankAccount("Some Person");

另一个有趣的事情是,你可以在一个类中有多个构造函数。你可能有一个构造函数带有一个参数,另一个不带任何参数。根据初始化对象的方式,将调用相应的构造函数。让我们看下面的例子:

class BankAccount {
    public string owner;

    public BankAccount(){
        owner = "Some person";
    }

    public BankAccount(string theOwner){
        owner = theOwner;
    }
}

在上面的例子中,我们可以看到BankAccount类有两个构造函数。如果在创建BankAccount对象时传递参数,它将调用第二个构造函数,这将设置值并创建对象。如果在创建对象时不传递参数,将调用第一个构造函数。如果这两个构造函数都没有,那么这种对象创建方法将不可用。

如果您不创建一个类,那么编译器会为该类创建一个空的构造函数,如下所示:

class BankAccount {
    public string owner;

    public BankAccount()
    {
    }
}

面向对象编程的特点

面向对象编程是当今最重要的编程方法之一。整个概念依赖于四个主要思想,被称为面向对象编程的支柱。这四个支柱如下:

  • 继承

  • 封装

  • 多态

  • 抽象

继承

继承一词意味着从其他地方接收或衍生出某物。在现实生活中,我们可能会谈论一个孩子从父母那里继承房子。在这种情况下,孩子对房子拥有与父母相同的权力。这种继承的概念是面向对象编程的支柱之一。在编程中,当一个类从另一个类派生时,这被称为继承。这意味着派生类将具有与父类相同的属性。在编程术语中,从另一个类派生的类被称为父类,而继承自这些类的类被称为子类

让我们看一个例子:

public class Fruit {
    public string Name { get; set; }
    public string Color { get; set; }
}

public class Apple : Fruit {
    public int NumberOfSeeds { get; set; }
}

在上面的例子中,我们使用了继承。我们有一个名为Fruit的父类。这个类包含每种水果都有的共同属性:NameColor。我们可以为所有水果使用这个Fruit类。

如果我们创建一个名为Apple的新类,这个类可以继承Fruit类,因为我们知道苹果是一种水果。Fruit类的属性也是Apple类的属性。如果Apple继承Fruit类,我们就不需要为Apple类编写相同的属性,因为它从Fruit类继承了这些属性。

封装

封装意味着隐藏或覆盖。在 C#中,封装是通过访问修饰符实现的。在 C#中可用的访问修饰符如下:

  • 公共

  • 私有

  • 保护

  • 内部

  • 内部保护

封装是当您想要控制其他类对某个类的访问时使用的。比如说您有一个BankAccount类。出于安全原因,让这个类对所有类都可访问并不是一个好主意。最好将其设为私有或使用其他类型的访问修饰符。

您还可以限制对类的属性和变量的访问。例如,您可能需要保持BankAccount类对某些原因是public的,但将AccountBalance属性设为private,这样除了BankAccount类之外,其他类都无法访问这个属性。您可以这样做:

public class BankAccount {
    private double AccountBalance { get; set; }
}

像变量和属性一样,您还可以为方法使用访问修饰符。您可以编写不需要其他类使用的private方法,或者您不希望向其他类公开的方法。让我们看下面的例子:

public class BankAccount{
    private double AccountBalance { get; set; }
    private double TaxRate { get; set; }

    public double GetAccountBalance() {
        double balanceAfterTax = GetBalanceAfterTax();
        return balanceAfterTax;
    }

    private double GetBalanceAfterTax(){
        return AccountBalance * TaxRate;
    }
}

在上面的例子中,GetBalanceAfterTax方法是一个其他类不需要的方法。我们只想提供税后的AccountBalance,所以我们可以将这个方法设为私有。

封装是面向对象编程的一个非常重要的部分,因为它让我们对代码有控制权。

抽象

如果某物是抽象的,意味着它在现实中没有实例,但作为一个想法或概念存在。在编程中,我们使用这种技术来组织我们的思想。这是面向对象编程的支柱之一。在 C#中,我们有abstract类,它实现了抽象的概念。抽象类是没有任何实例的类,实现abstract类的类将实现该abstract类的属性和方法。让我们看一个abstract类的例子,如下面的代码所示:

public abstract class Vehicle {
    public abstract int GetNumberOfTyres();
}

public class Bicycle : Vehicle {
    public string Company { get; set; }
    public string Model { get; set; }
    public int NumberOfTyres { get; set; }

    public override int GetNumberOfTyres() {
        return NumberOfTyres;
    }
}

public class Car : Vehicle {
    public string Company { get; set; }
    public string Model { get; set; }
    public int FrontTyres { get; set; }
    public int BackTyres { get; set; }

    public override int GetNumberOfTyres() {
        return FrontTyres + BackTyres;
    }
}

在前面的例子中,我们有一个名为Vehicle的抽象类。它有一个名为GetNumberOfTyres()的抽象方法。由于它是一个抽象方法,这个方法必须被实现抽象类的类所覆盖。我们的BicycleCar类实现了Vehicle抽象类,因此它们也覆盖了抽象方法GetNumberOfTyres()。如果你看一下这两个类中这些方法的实现,你会发现实现是不同的,这是由于抽象性。

多态性

多态一词意味着许多形式。要正确理解多态的概念,让我们举个例子。让我们想想一个人,比如比尔·盖茨。我们都知道比尔·盖茨是一位伟大的软件开发者、商人、慈善家,也是一位伟大的人。他是一个人,但他有不同的角色和执行不同的任务。这就是多态性。当比尔·盖茨正在开发软件时,他扮演着软件开发者的角色。他在思考他正在编写的代码。后来,当他成为微软的首席执行官时,他开始管理人员并思考如何发展业务。他是同一个人,但担任不同的角色和不同的责任。

在 C#中,有两种多态性:静态多态性和动态多态性。静态多态性是一种多态性,其中方法的角色在编译时确定,而在动态多态性中,方法的角色在运行时确定。静态多态性的例子包括方法重载和运算符重载。让我们看一个方法重载的例子:

public class Calculator {
    public int AddNumbers(int firstNum, int secondNum){
        return firstNum + secondNum;
    }

    public double AddNumbers(double firstNum, double secondNum){
        return firstNum + secondNum;
    }
}

在这里,我们可以看到我们有两个同名的方法AddNumbers。通常情况下,我们不能有两个同名的方法;然而,由于这些方法的参数是不同的,编译器允许方法具有相同的名称。编写一个与另一个方法同名但参数不同的方法称为方法重载。这是一种多态性。

像方法重载一样,运算符重载也是一种静态多态性。让我们看一个运算符重载的例子来证明这一点:

public class MyCalc
{
    public int a;
    public int b;

    public MyCalc(int a, int b)
    {
        this.a = a;
        this.b = b;
    }

    public static MyCalc operator +(MyCalc a, MyCalc b)
    {
        return new MyCalc(a.a * 3 ,b.b * 3);
    }
}

在前面的例子中,我们可以看到加号(+)被重载为另一种计算。因此,如果你对两个MyCalc对象求和,你将得到一个重载的结果,而不是正常的和,这种重载发生在编译时,因此它是静态多态性。

动态多态性指的是使用抽象类。当你编写一个抽象类时,不能从该抽象类创建实例。当任何其他类使用或实现该抽象类时,该类也必须实现该抽象类的抽象方法。由于不同的类可以实现抽象类并且可以有不同的抽象方法实现,因此实现了多态行为。在这种情况下,我们有相同名称但不同实现的方法。

总结

这一章涵盖了类和对象,这是面向对象编程中最重要的构建模块。这些是我们在跳入面向对象编程的任何其他主题之前应该学习的两件事。在继续其他想法之前,确保我们的思想中清楚了这些概念是很重要的。在这一章中,我们了解了类是什么,以及为什么在面向对象编程中需要它。我们还看了如何在 C#中创建一个类以及如何定义一个对象。之后,我们看了类和对象之间的关系以及如何实例化一个类并使用它。我们还讨论了类中的变量和方法。最后,我们涵盖了面向对象编程的四大支柱。在下一章中,我们将学习更多关于继承和类层次结构的知识。

第三章:在 C#中实现面向对象编程

在前一章中,我们看了类、对象和面向对象编程的四个原则。在本章中,我们将学习一些使 C#语言成为面向对象编程语言的语言特性。如果不了解这些概念,使用 C#编程写面向对象的代码可能会很困难,或者会阻止你充分发挥其潜力。在第二章,Hello OOP - Classes and Objects中,我们学到了抽象、继承、封装和多态是面向对象编程的四个基本原则,但我们还没有学习 C#语言如何实现这些原则。我们将在本章讨论这个话题。

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

  • 接口

  • 抽象类

  • 部分类

  • 封闭类

  • 元组

  • 属性

  • 类的访问修饰符

接口

类是一个蓝图,这意味着它包含了实例化对象将具有的成员和方法。接口也可以被归类为蓝图,但与类不同,接口没有任何方法实现。接口更像是实现接口的类的指南。

C#中接口的主要特点如下:

  • 接口不能有方法体;它们只能有方法签名。

  • 接口可以有方法、属性、事件和索引。

  • 接口不能被实例化,因此不能创建接口的对象。

  • 一个类可以扩展多个接口。

接口的一个主要用途是依赖注入。通过使用接口,可以减少系统中的依赖关系。让我们看一个接口的例子:

interface IBankAccount {
    void Debit(double amount);
    void Credit(double amount);
}
class BankAccount : IBankAccount {
    public void Debit(double amount){
        Console.WriteLine($"${amount} has been debited from your account!");
    } 
    public void Credit(double amount){
        Console.WriteLine($"${amount} has been credited to your account!");
    }
}

在前面的例子中,我们可以看到我们有一个接口,名为IBankAccount,它有两个成员:DebitCredit。这两个方法在接口中没有实现。在接口中,方法签名更像是实现这个接口的类的指南或要求。如果任何类实现了这个接口,那么这个类必须实现方法体。这是面向对象编程概念继承的一个很好的用法。类将不得不给出在接口中提到的方法的实现。如果类没有实现接口的任何方法,编译器将抛出一个错误,表示类没有实现接口的所有方法。按照语言设计,如果一个类实现了一个接口,那么这个类的所有成员都必须在类中得到处理。因此,在前面的代码中,BankAccount类实现了IBankAccount接口,这就是为什么DebitCredit这两个方法必须被实现的原因。

抽象类

抽象类是 C#编程语言中的一种特殊类。这个类与接口有类似的功能。例如,抽象类可以有带有和不带有实现的方法。因此,当一个类实现一个抽象类时,这个类必须重写抽象类的抽象方法。抽象类的一个主要特征是它不能被实例化。抽象类只能用于继承。它可能有也可能没有抽象方法和访问器。封闭和抽象修饰符不能放在同一个类中,因为它们有完全不同的含义。

让我们看一个抽象类的例子:

abstract class Animal {
    public string name;
    public int ageInMonths;
    public abstract void Move();
    public void Eat(){
        Console.WriteLine("Eating");
    }
}
class Dog : Animal {
    public override void Move() {
        Console.WriteLine("Moving");
    }
} 

在前面的例子中,我们看到Dog类实现了Animal类,而Animal类有一个名为Move()的抽象方法,Dog类必须重写它。

如果我们尝试实例化抽象类,编译器将抛出一个错误,如下所示:

using System;
namespace AnimalProject {
    abstract class Animal {
        public string name;
        public int ageInMonths;
        public abstract void Move();
        public void Eat(){
            Console.WriteLine("Eating");
        }
    }
    static void Main(){
        Animal animal = new Animal(); // Not possible as the Animal class is abstract class
    }
}

部分类

您可以将一个类、结构体或接口分割成可以放在不同代码文件中的较小部分。如果要这样做,必须使用关键字partial。即使代码在单独的代码文件中,编译时它们将被视为一个整体类。部分类有许多好处。一个好处是不同的开发人员可以同时在不同的代码文件上工作。另一个好处是,如果您正在使用自动生成的代码,并且想要扩展该自动生成的代码的某些功能,可以在单独的文件中使用部分类。因此,您不是直接触及自动生成的代码,而是在类中添加新功能。

部分类有一些要求,其中之一是所有类必须在其签名中有关键字partial。所有部分类还必须具有相同的名称,但文件名可以不同。部分类还必须具有相同的可访问性,如 public、private 等。

以下是部分类的示例:

// File name: Animal.cs
using System;
namespace AnimalProject {
    public partial class Animal {
        public string name;
        public int ageInMonths;

        public void Eat(){
            Console.WriteLine("Eating");
        }
     }
}
// File name: AnimalMoving.cs
using System;
namespace AnimalProject {
    public partial class Animal {

        public void Move(){
            Console.WriteLine("Moving");
        }
    }
}

如前面的代码所示,您可以创建一个类的许多部分类。这将增加代码的可读性,使代码组织更加结构化。

密封类

面向对象编程的原则之一是继承,但有时您可能需要限制代码中的继承,以符合应用程序的架构。C#提供了一个名为sealed的关键字。如果在类的签名之前放置这个关键字,该类被视为密封类。如果一个类是密封的,那个特定的类就不能被其他类继承。如果任何类尝试继承一个密封类,编译器将抛出一个错误。结构体也可以是密封的,在这种情况下,没有类可以继承该结构体。

让我们看一个密封类的示例:

sealed class Animal {
    public string name;
    public int ageInMonths;
    public void Move(){
        Console.WriteLine("Moving");
    }
    public void Eat(){
        Console.WriteLine("Eating");
    }
}
public static void Main(){
    Animal dog = new Animal();
    dog.name = "Doggy";
    dog.ageInMonths = 1;

    dog.Move();
    dog.Eat();
}

在前面的示例中,我们可以看到如何创建一个密封类。只需在class关键字之前使用sealed关键字即可使类成为密封类。在前面的示例中,我们创建了一个Animal密封类,在main方法中,我们实例化了该类并使用了它。现在一切都运行正常。然而,如果我们尝试创建一个将继承Animal类的Dog类,如下面的代码所示,那么编译器将抛出一个错误,说密封的Animal类不能被继承:

class Dog : Animal {
    public char gender;
}

这是编译器将显示的屏幕截图:

元组

元组是一种保存一组数据的数据结构。当您想要对数据进行分组和使用时,元组通常很有帮助。通常,C#方法只能返回一个值。通过使用元组,可以从方法中返回多个值。Tuple类位于System.Tuple命名空间下。可以使用Tuple<>构造函数或Tuple类附带的名为Create的抽象方法来创建元组。

您可以固定元组中的任何数据类型,并使用Item1Item2等进行访问。让我们看一个例子,以更好地理解这一点:

var person = new Tuple<string, int, string>("Martin Dew", 42, "Software Developer"); // name, age, occupation
or 
var person = new Tuple.Create("Martin Dew", 42, "Software Developer");

让我们看看如何通过以下代码从方法中返回一个元组:

public static Tuple<string, int, string> GetPerson() {
    var person = new Tuple<string, int, string>("Martin Dew", 42, "Software Developer");
    return person;
}
static void Main() {
    var developer = GetPerson();
    Console.WriteLine("The person is {0}. He is {1} years old. He is a {2}", developer.Item1, developer.Item2, developer.Item3 );
}

属性

出于安全原因,类的所有字段不应该暴露给外部世界。因此,在 C#中通过属性来暴露私有字段,这些属性是该类的成员。属性下面是称为访问器的特殊方法。属性包含两个访问器:getsetget访问器从字段获取值,而set访问器将值设置到字段。属性有一个特殊的关键字,名为value。这代表了字段的值。

通过使用访问修饰符,属性可以具有不同的访问级别。属性可以是 publicprivateread onlyopen for read and writewrite only。如果只实现了 set 访问器,这意味着只有写入权限。如果同时实现了 setget 访问器,这意味着该属性对读和写都是开放的。

C# 提供了一种聪明的方式来编写 settergetter 方法。如果你在 C# 中创建一个属性,你不需要为特定字段手动编写 settergetter 方法。因此,在 C# 中的常见做法是在类中创建属性,而不是为这些字段创建字段和 settergetter 方法。

让我们看看如何在 C# 中创建属性,如下面的代码所示:

class Animal {
    public string Name {set; get;}
    public int Age {set; get;}
}

Animal 类有两个属性:NameAge。这两个属性都有 Public 访问修饰符以及 settergetter 方法。这意味着这两个属性都对读和写操作是开放的。约定是属性应该使用驼峰命名法。

如果你想修改你的 setget 方法,你可以这样做:

class Animal {
    public string Name {
        set {
            name = value;
        }
        get {
            return name;
        }
    }
    public int Age {set; get;}
}

在上面的例子中,我们没有使用为 Name 属性创建 settergetter 的快捷方式。我们广泛地写了 setget 方法应该做什么。如果你仔细看,你会看到 name 字段是小写的。这意味着当你使用驼峰命名法创建属性时,一个同名的字段会在内部创建,但是是以帕斯卡命名法。value 是一个特殊关键字,实际上代表了该属性的值。

属性在后台工作,这使得代码更加清晰和易于使用。强烈建议您使用属性而不是本地字段。

类的访问修饰符

访问修饰符,或者访问修饰符,是一些保留关键字,用于确定类、方法、属性或其他实体的可访问性。在 C# 中,使用这些访问修饰符实现了面向对象的封装原则。总共有五个访问修饰符。让我们看看这些是什么,它们之间的区别是什么。

公共

公共访问修饰符意味着对正在修改的实体没有限制。如果将类或成员设置为 public,则可以被同一程序集中的其他类或程序、其他程序集甚至安装在运行该程序的操作系统中的其他程序访问。通常,应用程序的起点或主方法被设置为 public,这意味着它可以被其他人访问。要使类为 public,只需在关键字 class 前面放置一个 public 关键字,如下面的代码所示:

public class Animal {
}

上述的 Animal 类可以被任何其他类访问,而且由于成员 Name 也是公共的,它也可以从任何位置访问。

私有

私有修饰符是 C# 编程语言中最安全的访问修饰符。通过将类或类的成员设置为 private,你确定该类或成员将不允许其他类访问。private 成员的范围在类内。例如,如果你创建一个 private 字段,那个字段就不能在类外部被访问。那个 private 字段只能在该类内部使用。

让我们看一个带有 private 字段的类的例子:

public class Animal {
    private string name;
    public string GetName() {
        return name;
    }
}

在这里,由于 GetName() 方法和 private 字段 name 在同一个类中,该方法可以访问该字段。但是,如果 Animal 类之外的另一个方法尝试访问 name 字段,它将无法访问。

例如,在以下代码中,Main 方法正在尝试设置 private 字段 name,这是不允许的:

using System;
namespace AnimalProject {
    static void Main(){
        Animal animal = new Animal();
        animal.name = "Dog"; // Not possible, as the name field is private
        animal.GetName(); // Possible, as the GetName method is public
    }
}

内部

如果将internal设置为访问限定符,这意味着该实体只能在同一程序集内访问。程序集中的所有类都可以访问该类或成员。在.NET 中构建项目时,它会创建一个程序集文件,可以是dllexe。一个解决方案中可能有多个程序集,而内部成员只能被那些特定程序集中的类访问。

让我们看一个示例,如下所示的代码:

using System;
namespage AnimalProject {
    static void Main(){
        Dog dog = new Dog();
        dog.GetName();
    }

    internal class Dog {
        internal string GetName(){
            return "doggy";
        }
    }
}

受保护的

受保护的成员可以被类本身访问,以及继承该类的子类。除此之外,没有其他类可以访问受保护的成员。受保护的访问修饰符在继承发生时非常有用。

让我们通过以下代码来学习如何使用这个:

using System;
namespage AnimalProject {
    static void Main(){
        Animal animal = new Animal();
        Dog dog = new Dog();
        animal.GetName(); // Not possible as Main is not a child of Animal
        dog.GetDogName();
    }

    class Animal {
        protected string GetName(){
            return "doggy";
        }
    }
    class Dog : Animal {
        public string GetDogName() {
            return base.GetName();
        }
    }
}

受保护的内部

受保护的内部是受保护的访问修饰符和内部访问修饰符的组合。其访问修饰符为protected internal的成员可以被同一程序集中的所有类访问,以及任何继承它的类,无论程序集如何。例如,假设您在名为Assembly1.dll的程序集中有一个名为Animal的类。在Animal类中,有一个受保护的内部方法叫做GetNameAssembly1.dll中的任何其他类都可以访问GetName方法。现在,假设还有另一个名为Assembly2.dll的程序集。在Assembly2.dll中,有一个名为Dog的类,它扩展了Animal类。由于GetName是受保护的内部,即使Dog类在一个单独的程序集中,它仍然可以访问GetName方法。

让我们通过以下示例来更清楚地理解这一点:

//Assembly1.dll
using System;
namespace AnimalProject {
    public class Animal {
        protected internal string GetName(){
            return "Nice Animal";
        }
    }
}
//Assembly2.dll
using System;
namespace AnimalProject2 {
    public class Dog : Animal {
        public string GetDogName(){
            return base.GetName(); // This will work
        }
    }
    public class Cat {
        Animal animal = new Animal();

        public string GetCatName(){
            return animal.GetName(); // This is not possible, as GetName is protected internal
        }
    }
}

总结

在本章中,我们看了类层次结构和一些其他特性,使 C#编程语言成为面向对象的语言。了解这些概念对于 C#开发人员至关重要。通过了解类层次结构,您可以设计系统,使其解耦且灵活。您需要知道如何在应用程序中使用继承来充分发挥面向对象的优势。接口、抽象类、密封类和部分类将帮助您更好地控制应用程序。在团队中工作时,正确定义类层次结构将有助于您维护代码质量和安全性。

了解元组和属性将提高您的代码清晰度,并在开发应用程序时使您的生活更加轻松。访问限定符是封装的面向对象编程概念的实现。熟悉这些概念非常重要。您需要知道哪些代码片段应该是公开的,哪些应该是私有的,哪些应该是受保护的。如果滥用这些访问限定符,您可能会陷入应用程序存在安全漏洞和代码重复的境地。

在下一章中,我们将讨论对象协作的重要和有趣的主题。

第四章:对象协作

正如我们在前几章中看到的,面向对象编程的重点是对象。当我们使用这种方法设计软件时,我们会牢记面向对象编程的概念。我们还会尝试将软件组件分解为更小的对象,并创建对象之间的适当关系,以便它们可以共同工作,为我们提供所需的输出。对象之间的这种关系称为对象协作

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

  • 什么是对象协作?

  • 不同类型的协作

  • 什么是依赖协作?

  • 什么是关联?

  • 什么是继承?

对象协作的示例

对象协作是面向对象编程中最重要的主题之一。如果对象在程序中不相互协作,就无法实现任何目标。例如,如果我们考虑一个简单的 Web 应用程序,我们可以看到不同对象之间的关系在构建应用程序中起着重要作用。例如,Twitter 有许多对象彼此相关,以使应用程序正常运行。User对象包括 Twitter 用户的用户名、密码、名字、姓氏、图片和其他用户相关信息。可能还有另一个名为Tweet的对象,其中包括消息、日期和时间、发布推文的用户的用户名以及其他一些属性。还可能有另一个名为Message的对象,其中包含消息的内容、消息的发送者和接收者、日期和时间。这是对 Twitter 这样一个大型应用程序的最简单的分解;它几乎肯定包含许多其他对象。但现在,让我们只考虑这三个对象,并尝试找到它们之间的关系。

首先,我们将看一下User对象。这是 Twitter 中最重要的对象之一,因为它保存了用户信息。在 Twitter 中,一切都是由用户制作或为用户执行的,因此我们可以假设应该有一些其他对象需要与User对象有关系。现在让我们尝试看看Tweet对象是否与User对象有关系。推文是一条消息,如果Tweet对象是公开的,所有用户都应该能看到它。如果是私密的,只有该用户的关注者才能看到。正如我们所看到的,Tweet对象与User对象有着非常紧密的关系。因此,根据面向对象编程的方法,我们可以说User对象在 Twitter 应用程序中与Tweet对象协作。

如果我们也尝试分析UserMessage对象之间的关系,我们会发现Message对象也与User对象有着非常强的关系。消息是由一个用户发送给另一个用户的;因此,没有用户,Message对象就没有合适的实现。

TweetMessage对象之间有关系吗?从已经说过的内容来看,我们可以说这两个对象之间没有关系。并不是每个对象都必须与所有其他对象相关联,但一个对象通常至少与另一个对象有关系。现在让我们看看 C#中有哪些不同类型的对象协作。

C#中不同类型的对象协作

在编程中,对象可以以许多种方式与其他对象协作。然而,在本章中,我们只会讨论三个最重要的协作规则。

我们将首先尝试解释每种类型,看一些示例来帮助我们理解它们。如果你无法将这些概念与你的工作联系起来,你可能很难理解对象协作的重要性,但相信我,这些概念对你成为一名优秀的软件开发人员非常重要。

当你与其他人讨论软件设计时,或者当你设计自己的软件时,所有这些概念和术语都会派上用场。因此,我的建议是专注于理解这些概念,并将它们与你的工作联系起来,以便从这些信息中获益。

现在,让我们看看我们将在本章中讨论的三种协作类型,如下列表所示:

  • 依赖

  • 联想

  • 继承

让我们想象一个应用程序,并尝试将这些协作概念与该应用程序的对象联系起来。当你能将概念与现实世界联系起来时,学习会更容易、更有趣,因此这是我们在接下来的章节中将采取的方法。

案例研究

由于本章的主要目标是学习对象协作涉及的概念,而不是设计一个完全成熟的、超级棒的应用程序,我们将以简单和最小的方式设计我们的对象。

对于我们的示例,我们将开发一些餐厅管理软件。这可以是豪华餐厅,也可以是人们来喝咖啡放松的小咖啡馆。在我们的情况下,我们考虑的是价格中等的餐厅。要开始构建这个应用程序,让我们考虑我们需要哪些类和对象。我们将需要一个Food类,一个Chef类,一个Waiter类,也许还需要一个Beverage类。

当你读完本章后,不要直接跳到下一章。相反,花一些时间思考一些在本章中没有提到的对象,并尝试分析你所想到的对象之间的关系。这将帮助你发展对对象协作概念的了解。记住:软件开发不是一份打字的工作,它需要大量的脑力工作。因此,你越多地思考这些概念,你在软件开发方面就会变得更加优秀。

现在,让我们看看当我考虑了应该包括在我们想象的餐厅应用程序中的对象时,我想到了哪些对象:

  • 食品

  • 牛肉汉堡

  • 意面

  • 饮料

  • 可乐

  • 咖啡

  • 订单

  • 订单项目

  • 员工

  • 厨师

  • 服务员

  • 食品存储库

  • 饮料存储库

  • 员工存储库

现在,有些对象可能对你来说并没有太多意义。例如,FoodRepositoryBeverageRepositoryStaffRepository对象实际上并不是业务对象,而是帮助不同模块在应用程序中相互交互的辅助对象。例如,FoodRepository对象将用于从数据库和 UI 保存和检索Food对象。同样,BeverageRepository对象将处理饮料。我们还有一个名为Food的类,它是一种通用类型的类,以及更具体的食品对象,如Beef BurgerPasta。这些对象是Food对象的子类别。作为软件开发人员,我们已经确定了开发此软件所需的对象。现在,是时候以解决软件将被用于的问题的方式使用这些对象了;然而,在我们开始编写代码之前,我们需要了解并弄清楚对象之间如何关联,以便应用程序能够达到最佳状态。让我们从依赖关系开始。

依赖

当一个对象使用另一个无关的对象来执行任务时,它们之间的关系被称为依赖。在软件世界中,我们也将这种关系称为使用关系。现在,让我们看看我们为餐厅应用程序所考虑的对象之间是否存在任何依赖关系。

如果我们分析一下FoodRepository对象,它将从数据库中保存和检索Food对象并将其传递给 UI,我们可以说FoodRepository对象必须使用Food对象。这意味着FoodFoodRepository对象之间的关系是一种依赖关系。如果我们考虑在前端创建新的Food对象时的流程,该对象将被传递给FoodRepository。然后,FoodRepository将把Food对象序列化为数据库数据以便将其保存在数据库中。如果FoodRepository不使用Food对象,那它怎么知道要序列化和存储在数据库中的内容呢?在这里,FoodRepository必须与Food对象存在依赖关系。让我们看看这段代码:

public class Food {
 public int? FoodId {get;set;}
 public string Name {get;set;}
 public decimal Price {get;set;}
}

public class FoodRepository {
 public int SaveFood(Food food){
 int result = SaveFoodInDatabase(food);
 return result;
 }

 public Food GetFood(int foodId){
 Food result = new Food();
 result = GetFoodFromDatabaseById(foodId);
 return result;
 }
}

在上面的代码中,我们可以看到FoodRepository类有两个方法。一个方法是SaveFood,另一个是GetFood

SaveFood方法涉及获取一个Food对象并将其保存在数据库中。在将食品项目保存在数据库后,它将新创建的foodId返回给FoodRepository。然后,FoodRepository将新创建的FoodId传递给 UI,通知用户食品项目创建成功。另一方面,另一个GetFood方法从 UI 获取一个 ID 作为参数,并检查该 ID 是否是有效输入。如果是,FoodRepositoryFoodId传递给databasehandler对象,该对象在数据库中搜索食品并将其映射回作为Food对象。之后,将Food对象返回给视图。

在这里,我们可以看到FoodRepository对象需要使用Food对象来完成其工作。这种关系称为依赖关系。我们还可以使用uses a短语来识别这种关系。FoodRepository使用Food对象来保存食品在数据库中。

FoodRepository一样,BeverageRepositoryBeverage对象做了同样的事情:它在数据库和 UI 中保存和检索饮料对象。现在让我们看看BeverageRepository的代码是什么样的:

public class Beverage {
    public int? BeverageId {get;set;}
    public string Name { get;set;}
    public decimal Price {get;set;}
}

public class BeverageRepository {
    public int SaveBeverage(Beverage beverage){
        int result = SaveBeverageInDatabase(beverage);
        return result;
    }

public Beverage GetBeverage(int beverageId) {
        Beverage result = new Beverage();
        result = GetBeverageFromDatabaseById(beverageId);
        return result;
    }
}

如果你看一下前面的代码,你会发现BeverageRepository有两个方法:SaveBeverageGetBeverage。这两个方法都使用Beverage对象。这意味着BeverageRepositoryBeverage对象存在依赖关系。

现在让我们来看一下我们迄今为止创建的两个类,如下所示的代码:

public class FoodRepository {
    public int SaveFood(Food food){
        int result = SaveFoodInDatabase(food);
        return result;
    }

    public Food GetFood(int foodId){
        Food result = new Food();
        result = GetFoodFromDatabaseById(foodId);
        return result;
    }
}

public class BeverageRepository {
    public int SaveBeverage(Beverage beverage){
        int result = SaveBeverageInDatabase(beverage);
        return result;
    }

public Beverage GetBeverage(int beverageId){
        Beverage result = new Beverage();
        result = GetBeverageFromDatabaseById(beverageId);
        return result;
    }
}

一个对象可以使用依赖关系与多个对象相关联。在面向对象编程中,这种关系非常常见。

让我们来看另一个依赖关系的例子。程序员计算机之间的关系可能是一种依赖关系。怎么样?我们知道程序员很可能是一个人,而计算机是一台机器。程序员使用计算机来编写计算机程序,但计算机不是程序员的属性。程序员使用计算机,并且这不一定是一个特定的计算机——可以是任何计算机。那么我们可以说程序员计算机之间的关系是一种依赖关系吗?是的,我们当然可以。让我们看看如何在代码中表示这一点:

public class Programmer {
    public string Name { get; set; }
    public string Age { get; set; }
    public List<ProgrammingLanguages> ProgrammingLanguages { get; set; }
    public ProgrammerType Type { get; set; } // Backend/Frontend/Full Stack/Web/Mobbile etc

    public bool WorkOnAProject(Project project, Computer computer){
        // use the provided computer to do the project
        // here we can see that the programmer is using a computer
    }
}

public class Computer {
    public int Id { get; set; }
    public string ModelNumber { get; set; }
    public Company Manufacturer { get; set; }
    public Ram Ram { get; set; }
    public MotherBoard MotherBoard { get; set; }
    public CPU CPU { get; set; }
}

在上面的例子中,我们可以清楚地看到程序员计算机之间有一种依赖关系,但这并不总是如此:这取决于你如何设计你的对象。如果你设计了程序员类,使得每个程序员都必须有一台专用的计算机,你可以在程序员类中使用计算机作为属性,那么程序员和计算机之间的关系将会改变。因此,关系取决于对象的设计方式。

我在本节的主要目标是澄清依赖关系。我希望依赖关系的本质现在对你来说是清楚的。

现在让我们看看依赖关系在统一建模语言UML)图表中是如何绘制的,如下图所示:

用实线表示依赖关系。

关联

另一种关系类型是关联关系。这种关系类型不同于依赖关系。在这种关系类型中,一个对象知道另一个对象并与之相关联。这种关系是通过将一个对象作为另一个对象的属性来实现的。在软件社区中,这种关系类型也被称为拥有关系。例如,汽车拥有引擎。如果你能想到任何可以用拥有短语来关联的对象,那么这种关系就是关联关系。在我们的汽车例子中,引擎是汽车的一部分。没有引擎,汽车无法执行任何功能。虽然引擎本身是一个独立的对象,但它是汽车的一部分,因此汽车和引擎之间存在关联。

这种关联关系可以分为以下两类:

  • 聚合

  • 组合

让我们看看这两种关系是什么,它们之间有什么不同。

聚合

当一个对象在其属性中有另一个独立的对象时,这被称为聚合关系。让我们看看前一节中的例子,试着看看这是否是一个聚合关系。

前面的例子是关于汽车和引擎之间的关系。我们都知道汽车必须有引擎,这就是为什么引擎是汽车的属性,如下代码所示:

public class Car {
    public Engine Engine { get; set; }
    // Other properties and methods
}

现在的问题是,这种关系是什么类型?决定因素是引擎是一个独立的对象,可以独立于汽车运行。制造商在制造汽车的其他零件时并不制造引擎:他们可以单独制造它。即使没有汽车,引擎也可以进行测试,甚至用于其他目的。因此,我们可以说汽车与引擎之间的关系是一种聚合关系

现在让我们来看一下我们的餐厅管理软件的例子。如果我们分析FoodChef对象之间的关系,很明显没有厨师就没有食物。必须有人来烹饪、烘焙和准备食物,食物本身无法做到这一点。因此,我们可以说食物有厨师。这意味着Food对象应该有一个名为Chef的属性,用来保存该FoodChef对象。让我们来看一下这种关系的代码:

public class Food {
    public int? FoodId {get;set;}
    public string Name { get; set; }
    public string Price { get; set; }
    public Chef Chef { get; set; }
}

如果我们考虑Beverage对象,每种饮料都必须有一个公司或制造商。例如,商业饮料是由百事公司、可口可乐公司等公司生产的。这些公司生产的饮料是它们的合法财产。饮料也可以在本地制造,这种情况下公司名称将是当地商店的名称。然而,这里的主要观点是饮料必须有一个制造商公司。让我们看看Beverage类在代码中是什么样子的:

public class Beverage {
    public int? BeverageId {get;set;}
    public string Name { get; set; }
    public string Price { get; set; }
    public Manufacturer Manufacturer { get; set; }
}

在这两个例子中,ChefManufacturer对象都是FoodBeverage的属性。我们也知道ChefManufacturer公司是独立的。因此,FoodChef之间的关系是一种聚合关系。BeverageManufacturer也是如此。

为了让事情更清晰,让我们看另一个聚合的例子。我们用于编程或执行任何其他任务的计算机由不同的组件组成。我们有主板、RAM、CPU、显卡、屏幕、键盘、鼠标和许多其他东西。一些组件与计算机具有聚合关系。例如,主板、RAM 和 CPU 是构建计算机所需的内部组件。所有这些组件都可以独立存在于计算机之外,因此所有这些组件都与计算机具有聚合关系。让我们看看Computer类如何与MotherBoard类相关联的以下代码:

public class Computer {
    public int Id { get; set; }
    public string ModelNumber { get; set; }
    public Company Manufacturer { get; set; }
    public Ram Ram { get; set; }
    public MotherBoard MotherBoard { get; set; }
    public CPU CPU { get; set; }
}

public class Ram {
    // Ram properties and methods
}

public class CPU {
    // CPU properties and methods
}

public class MotherBoard {
    // MotherBoard properties and methods
}

现在,让我们看看在 UML 图中如何绘制聚合关系。如果我们尝试用 RAM、CPU 和主板显示前面的计算机类聚合关系,那么它看起来会像下面这样:

实线和菱形用于表示聚合关系。菱形放在持有属性的类的一侧,如下图所示:

组合

组合关系是一种关联关系。这意味着一个对象将另一个对象作为其属性,但与聚合的不同之处在于,在组合中,作为属性的对象不能独立存在;它必须借助另一个对象才能发挥作用。如果我们考虑ChefManufacturer类,这些类的存在并不完全依赖于FoodBeverage类。相反,这些类可以独立存在,因此具有聚合关系。

然而,如果我们考虑OrderOrderItem对象之间的关系,我们会发现OrderItem对象没有没有Order就没有意义。让我们看一下Order类的以下代码:

public class Order {
    public int OrderId { get; set; }
    public List<OrderItem> OrderItems { get; set; }
    public DateTime OrderTime { get; set; }
    public Customer Customer { get; set; }
}

在这里,我们可以看到Order对象中有一个OrderItems列表。这些OrderItems是顾客订购的Food项目。顾客可以订购一个菜或多个菜,这就是为什么OrderItems是一个列表类型。现在是时候证明我们的想法了。OrderItem是否真的与Order有组合关系?我们有没有犯任何错误?我们是否把聚合关系当作组合关系了?

要确定它是哪种类型的关联关系,我们必须问自己一些问题。OrderItem可以在没有Order的情况下存在吗?如果不能,那为什么?它是一个独立的对象!然而,如果你再深入思考一下,你会意识到没有Order,没有OrderItem可以存在,因为顾客必须订购商品,没有Order对象,OrderItem对象就无法跟踪。OrderItem无法提供给任何顾客,因为没有关于OrderItem是为哪个顾客的数据。因此,我们可以说OrderItemOrder对象有组合关系。

让我们看另一个组合的例子。在我们的学校系统中,我们有学生、老师、科目和成绩,对吧?现在,我会说Subject对象和Grade对象之间的关系是组合关系。让我证明我的答案。看看这两个类的以下代码:

public class Subject {
    public int Id { get; set; }
    public string Name { get; set; }
    public Grade Grade { get; set; }
}

public class Grade {
    public int Id { get; set; }
    public double Mark { get; set; }
    public char GradeSymbol { get; set; } // A, B, C, D, F etc
}

在这里,我们可以看到Grade对象保存了学生在特定科目的考试成绩。它还保存了GradeSymbol,比如ABF,取决于学校的评分规则。我们可以在Subject类中看到有一个叫做Grade的属性。这个属性保存了特定Subject对象的成绩。如果我们只是单独考虑Grade而不是与Subject类关联,我们会有点困惑,想知道成绩是为哪个科目的。

因此,GradeSubject之间的关系是组合关系。

让我们看看如何在 UML 图中展示组合关系,使用SubjectGrade的前面的例子:

使用实线和黑色菱形表示组合关系。菱形放置在持有属性的类的一侧:

继承

这是面向对象编程的四大支柱之一。继承是一个对象继承或重用另一个对象的属性或方法。被继承的类称为基类,继承基类的类通常称为派生类。继承关系可以被视为一个是一个的关系。例如,意大利面是一种FoodPasta对象在数据库中有一个唯一的 ID,还有其他属性,比如名称、价格和厨师。因此,由于Pasta满足Food类的所有属性,它可以继承Food类并使用Food类的属性。让我们看一下代码:

public class Pasta : Food {
    public string Type { get; set; }
    public Sauce Sauce { get; set; }
    public string[] Spices { get; set; }
}

对于饮料也是一样的。例如,Coffee是一种饮料,具有Beverage对象具有的所有属性。咖啡有名称和价格,可能有糖、牛奶和咖啡豆。让我们编写Coffee类,看看它是什么样子的:

public class Coffee : Beverage {
    public int Sugar { get; set; }
    public int Milk { get; set; }
    public string LocationOfCoffeeBean { get; set; }
}

因此,我们可以说Coffee正在继承Beverage类。在这里,Coffee是派生类,Beverage是基类。

在之前的例子中,我们使用了Programmer对象。在这种情况下,你认为Programmer类实际上可以继承Human类吗?是的,当然可以。在这个例子中,程序员无非就是一个人。如果我们看一下Programmer的属性和Human的属性,我们会发现有一些共同的属性,比如姓名、年龄等。因此,我们可以修改Programmer类的代码如下:

public class Programmer : Human {
 // Name, Age properties can be inherited from Human
 public List<ProgrammingLanguages> ProgrammingLanguages { get; set; }
 public ProgrammerType Type { get; set; } // Backend/Frontend/Full Stack/Web/Mobbile etc

 public bool WorkOnAProject(Project project, Computer computer){
 // use the provided computer to do the project
 // here we can see that the programmer is using a computer
 }
}

现在,让我们看看如何为我们的Programmer类绘制 UML 图:

继承由一条实线和一个三角形符号表示。这个三角形指向超类的方向:

总结

我们在本章中看到的对象协作类型是 C#中最常用的类型。在设计应用程序或架构软件时,对象协作非常重要。它将定义软件的灵活性,可以添加多少新功能,以及维护代码的难易程度。对象协作非常重要。

在下一章中,我们将讨论异常处理。这也是编程中非常重要的一部分。

第五章:异常处理

让我们从两个词开始:异常和处理。在英语中,exception一词指的是不经常发生的异常情况。在编程中,异常一词有类似的含义,但与软件代码有关。根据它们的性质,计算机程序应该只执行我们指示它们执行的操作,当计算机不能或无法遵循我们的指示时,这被认为是异常。如果计算机程序无法遵循我们的指示,它在软件世界中被归类为异常。

错误是编程中经常使用的另一个词。重要的是我们要明白错误和异常不是同一回事。错误指的是软件甚至无法运行的情况。更具体地说,错误意味着编写的代码包含错误,这就是为什么编译器无法编译/构建代码。另一方面,异常是发生在运行时的事情。区分这两个概念的最简单方法是:如果代码无法编译/构建,那么你的代码中有错误。如果代码编译/构建了,但当你运行它时出现了一些异常行为,那么这就是一个异常。

异常处理意味着在运行程序时处理/控制/监督发生的异常。本章我们将探讨以下主题:

  • 为什么我们需要在编程中处理异常

  • C#编程中的异常处理

  • 异常处理的基础知识

  • trycatch

  • 如果不处理异常会发生什么

  • 多个catch

  • throw关键字的用途

  • finally块的作用

  • 异常类

  • 一些常见的异常类

  • 异常处理最佳实践

为什么我们需要在编程中处理异常

想象一下你已经写了一些代码。代码应该按照你的指示执行,对吧?但由于某种原因,软件无法执行你给出的命令。也许软件面临一些问题,使得它无法运行。

例如,假设您已经指示软件读取文件,收集数据并将其存储在数据库中。然而,软件无法在文件应该存在的位置找到文件。文件找不到的原因可能有很多:文件可能已被某人删除,或者可能已被移动到另一个位置。现在,你的软件会怎么做?它不够聪明以自动处理这种情况。如果软件对自己的工作不清楚,它会抛出异常。作为软件开发人员,我们有责任告诉软件在这种情况下该怎么做。

软件会通过传递消息告诉我们它被卡住了,无法解决这种情况。但它应该对我们说什么?“救命!救命!”不是一个合适的消息,这种消息不会让开发人员的生活变得更容易。我们需要更多关于情况的信息,以便我们可以指导计算机相应地工作。因此,.NET 框架创建了一些在编程中经常发生的非常常见的异常。如果软件面临的问题有预定义的异常,它会抛出该异常。例如,假设有一个程序试图将一个数字除以零。从数学上讲,这是不可能的,但计算机必须这样做,因为你已经指示它这样做。现在计算机陷入了大麻烦;它感到困惑和无助。它试图按照你的指示将数字除以零,但编译器会阻止它并说“向程序先生求助!”,这意味着“向你的主人抛出一个DivideByZeroException来寻求帮助”。程序将抛出一个DivideByZeroException,并期望程序员编写的一些代码来处理它。这就是我们实际上会知道我们需要在程序中处理哪些异常。这就是为什么我们在编程中需要异常。

C#编程中的异常处理

.NET 框架和 C#编程语言已经开发了一些强大的方法来处理异常。System.Exceptions是.NET 中的一个类,在系统命名空间下具有一些功能,可以帮助您管理运行时发生的异常,并防止程序崩溃。如果您在代码中没有正确处理异常,您的软件将崩溃。这就是为什么异常处理在软件开发中非常重要。

现在,您可能想知道如何在代码中处理异常。异常是意外的事情。您如何知道在您的代码中会发生哪种异常并导致程序崩溃?这是一个很好的问题,我相信在设计语言时也会提出这个问题。这就是为什么他们为.NET 提出了一个解决方案,它创建了一个非常美妙的机制来处理异常。

异常处理的基础知识

C#中的异常处理主要通过四个关键字实现:trycatchthrowfinally。稍后,我们将详细讨论这些关键字。但是,为了让您对这些关键字的含义有一个基本的了解,让我们简要讨论一下:

  • try:当您不确定代码的预期行为或存在异常可能性时,应将该代码放入try块中。如果该块内部发生异常,try块将抛出异常。如果没有异常发生,try块将像普通代码块一样。try块实际上是设计用来抛出异常的,这是它的主要任务。

  • catch:当捕获到异常时,将执行catch块。try块抛出的异常将由接下来的catch块处理。对于try块可以有多个catch块。每个catch块可以专门处理特定的异常。因此,我们应该为不同类型的异常编写不同的catch块。

  • throw:当您希望手动抛出异常时使用。可能存在您希望控制特定情况的情况。

  • finally:这是一段代码,将被强制执行。不管try块是否抛出异常,finally块都将被执行。这主要用于编写一些在任何情况下都必须处理的任务。

尝试和捕获

trycatch关键字是 C#异常处理中最重要的两个关键字。如果您编写一个没有catch块的try块,那么它就没有任何意义,因为如果try块抛出异常而没有catch块来处理它,那么有什么好处呢?异常仍然未处理。catch块实际上依赖于try块。如果没有与之关联的try块,catch块就不能存在。让我们看一下如何编写try-catch块:

try 
{
  int a = 5 / 0; 
} 
catch(DivideByZeroException ex)
{
  Console.WriteLine(“You have divided by zero”);
}

我们也可以为try块有更多的catch块。让我们看一个例子:

try 
{
  int a = 5 / 0; 
} 
catch(DivideByZeroException ex)
{ 
  Console.WriteLine(“You have divided by zero”); 
} 
catch(Exception ex) 
{ 
  Console.WriteLine(“Normal exception”); 
}

如果不处理异常会发生什么?

异常真的很重要吗?在逻辑中存在大量复杂性时,处理它们是否值得花费时间?是的,它们非常重要。让我们探讨一下如果不处理异常会发生什么。当触发异常时,如果没有代码处理它,异常将传递到系统运行时。

此外,当系统运行时遇到异常时,它会终止程序。所以,现在您明白为什么您应该处理异常了。如果您未能这样做,您的应用程序可能会在运行中间崩溃。我相信您个人不喜欢在使用它们时程序崩溃,所以我们必须小心编写无异常的软件。让我们看一个例子,看看如果未处理异常会发生什么:

Using system;

class LearnException {
    public static void Main()
    {
        int[] a = {1,2,3,4};
        for (int i=0; i<10; i++)
        {
            Console.WriteLine(a[i]);
        }
    }
}

如果我们运行这段代码,那么前四次运行时,它将完美执行并打印出从一到四的一些数字。但之后,它将抛出IndexOutOfRangeException的异常,并且系统运行时将终止程序。

多个 catch 块

在一个try块中获得不同类型的异常是正常的。但是你该如何处理它们呢?您不应该使用通用异常来做这个。如果您抛出通用异常而不是抛出特定异常,您可能会错过有关异常的一些重要信息。因此,C#语言为try块引入了多个catch块。您可以指定一个catch块,它将被一个类型的异常调用,并且您可以创建其他catch块,每个后面都有不同的异常类型。当抛出特定异常时,只有那个特定的catch块将被执行,如果它有一个专门的catch块来处理这种类型的异常。让我们看一个例子:

using System;

class ManyCatchBlocks 
{     
    public static void Main()
    {
        try
        {
            var a = 5;
            var b = 0;
            Console.WriteLine("Here we will divide 5 by 0");
            var c = a/b;
        }
        catch(IndexOutOfRangeException ex)
        {
            Console.WriteLine("Index is out of range " + ex);
        }
        catch(DivideByZeroException ex)
        {
            Console.WriteLine("You have divided by zero, which is not correct!");
        }
    }
}

如果运行上述代码,您将看到只有第二个catch块被执行。如果您打开控制台窗口,您将看到以下行已被打印出来:

You have divided by zero, which is not correct!

因此,我们可以看到,如果有多个catch块,只有与抛出的异常类型匹配的特定catch块将被执行。

现在你可能会想,“你说我们不应该使用通用异常处理程序。但为什么呢?是的,我们可能会错过一些信息,但我的系统没有崩溃!这样做不是更好吗?”实际上,这个问题的答案并不直接。这可能因系统而异,但让我告诉你为什么有时候你希望系统崩溃。假设你有一个处理非常复杂和敏感数据的系统。当这样的系统发生异常时,允许客户继续使用软件可能非常危险。客户可能会对数据造成严重破坏,因为异常没有得到适当处理。但是,如果你认为即使出现未知异常,如果允许用户继续使用系统也不会有问题,你可以使用通用的catch块。现在让我告诉你如何做到这一点。如果你希望catch块捕获任何类型的异常,无论异常类型如何,那么你的catch块应该接受Exception类作为参数,如下面的代码所示:

using System;

namespace ExceptionCode
{
  class Program
  {
    static void Main(string[] args)
    {
      try
      {
        var a = 0;
        var b = 5;
        var c = b / a;
      }
      catch (IndexOutOfRangeException ex)
      {
        Console.WriteLine("Index out of range " + ex);
      }
      catch (Exception ex)
      {
        Console.WriteLine("I will catch you exception! You can't hide from me!" + ex);
      }

      Console.WriteLine("Hello");
      Console.ReadKey();
     }
   }
}

或者,您还可以向catch块传递一个no参数。这也将捕获每种类型的异常并执行主体中的代码。以下代码给出了一个示例:

using System;

namespace ExceptionCode
{
  class Program
  {
    static void Main(string[] args)
    {
      try
      {
        var a = 0;
        var b = 5;
        var c = b / a;
      }
      catch (IndexOutOfRangeException ex)
      {
        Console.WriteLine("Index out of range " + ex);
      }
      catch
      {
        Console.WriteLine("I will catch you exception! You can't hide from me!");
      }

      Console.WriteLine("Hello");
      Console.ReadKey();
     }
   }
}

但是,请记住,这必须是最后一个catch块,否则将会出现运行时错误。

使用 throw 关键字

有时,在您自己的程序中,您必须自己创建异常。不,不是为了报复用户,而是为了您的应用程序。有时,有些情况下,您需要抛出异常来绕过困难,记录一些东西,或者只是重定向软件的流程。不用担心:通过这样做,您不会成为坏人;实际上,您是在拯救程序免受麻烦的英雄。但是,您如何创建异常呢?为此,C#有一个名为throw的关键字。这个关键字将帮助您创建异常类型的实例并抛出它。让我给你一个throw关键字的例子:

using System;

namespace ExceptionCode
{
 class Program
 {
 public static void Main(string[] args)
 {
 try
 {
 Console.WriteLine("You are the boss!");
 throw new DivideByZeroException();
 }
 catch (IndexOutOfRangeException ex)
 {
 Console.WriteLine("Index out of range " + ex);
 }
 catch (DivideByZeroException ex)
 {
 Console.WriteLine("Divide by zero " + ex);
 }
 catch
 {
 Console.WriteLine("I will catch you exception! You can't hide from me!");
 }

 Console.WriteLine("See, i told you!");
 Console.ReadKey();
 }
 }
}

输出如下:

您可以看到,如果运行上述代码,将执行DivideByZeroException catch块。

因此,如果你想抛出异常(因为你希望上层的catch块来处理它,例如),你只需抛出一个新的异常实例。这可以是任何类型的异常,包括系统异常或自定义异常。只需记住有一个catch块将处理它。

finally 块是做什么的?

当我们说“最后”,我们指的是我们一直在等待的或者将要结束进程的东西。在异常处理中也是差不多的。finally 块是一段无论 trycatch 块中发生了什么都会执行的代码。无论抛出了什么类型的异常,或者是否被处理,finally 块都会执行。现在你可能会问,"为什么我们需要 finally 块呢?如果程序中有任何异常,我们会用 catch 块来处理它!我们不能把代码写在 catch 块里而不是 finally 块里吗?"

是的,你可以,但是如果抛出了异常而 catch 块没有被触发会发生什么?这意味着 catch 块内的代码将不会被执行。因此,finally 块很重要。无论是否有异常,finally 块都会运行。让我给你展示一个 finally 块的例子:

using System;

namespace ExceptionCode
{
 class Program
 {
 static void Main(string[] args)
 {
 try
 {
 int a = 0;
 int b = 5;
 int c = b / a;
 }
 catch (IndexOutOfRangeException ex)
 {
 Console.WriteLine("Index out of range " + ex);
 }
 catch (DivideByZeroException ex)
 {
 Console.WriteLine("Divide by zero " + ex);
 }
 catch
 {
 Console.WriteLine("I will catch you exception! You can't hide from me!");
 }
 finally
 {
 Console.WriteLine("I am the finally block i will run by hook or by crook!");
 }
 Console.ReadLine();
 }
 }
}

输出如下:

finally 块的一个重要用例可能是在 try 块中打开数据库连接!你必须关闭它,否则该连接将一直保持打开状态,会占用大量资源。此外,数据库可以建立的连接数量是有限的,所以如果你打开了一个连接却没有关闭它,那么这个连接字符串就浪费了。最佳实践是在完成与连接的工作后立即关闭连接。

finally 块在这里发挥了最好的作用。不管在 try 块中发生了什么,finally 块都会关闭连接,如下面的代码所示:

using System;

namespace ExceptionCode
{
  class Program
  {
    static void Main(string[] args)
    {
      try
      {
        // Step 1: Established database connection

        // Step 2: Do some activity in database
      }
      catch (IndexOutOfRangeException ex)
      {
        // Handle IndexOutOfRangeExceptions here
      }
      catch (DivideByZeroException ex)
      {
        // Handle DivideByZeroException here
      }
      catch
      {
        // Handle All other exception here
      }
      finally
      {
        // Close the database connection
      }
    }
  }
}

在这里,我们在 try 块中执行了两个主要任务。首先,我们打开了数据库连接,其次,我们在数据库中执行了一些活动。现在,如果在执行任何这些任务时发生了异常,那么异常将被 catch 块处理。最后,finally 块将关闭数据库连接。

finally 块不是处理异常必须要有的东西,但如果需要的话,应该使用它。

异常类

exception 简单来说就是 C# 中的一个类。它有一些属性和方法。最常用的四个属性如下:

属性 描述
Message 这包含了异常的内容。
StackTrace 这包含了方法调用堆栈信息。
TargetSite 这提供了一个包含发生异常的方法的对象。
InnerException 这提供了引起异常的异常实例。

异常类的属性和方法

这个类中最受欢迎的方法之一是 ToString()。这个方法返回一个包含异常信息的字符串。当以字符串格式表示时,异常更容易阅读和理解。

让我们看一个使用这些属性和方法的例子:

using System;

namespace ExceptionCode
{
 class Program
 {
 static void Main(string[] args)
 {
 try
 {
 var a = 0;
 var b = 5;
 var c = b / a;
 }
 catch (DivideByZeroException ex)
 {
 Console.WriteLine("Message:");
 Console.WriteLine(ex.Message);
 Console.WriteLine("Stack Trace:");
 Console.WriteLine(ex.StackTrace);
 Console.WriteLine("String:");
 Console.WriteLine(ex.ToString());
 }

 Console.ReadKey();
 }
 }
}

输出如下:

在这里,我们可以看到异常的 message 属性包含了信息 Attempted to divide by zero。此外,ToString() 方法提供了大量关于异常的信息。这些属性和方法在处理程序中处理异常时会帮助你很多。

一些常见的异常类

.NET Framework 中有许多异常类可用。.NET Framework 团队创建了这些类来简化开发人员的生活。.NET Framework 提供了关于异常的具体信息。以下是一些常见的异常类:

异常类 描述
DivideByZeroException 当任何数字被零除时,会抛出此异常。
IndexOutOfRangeException 当应用程序尝试使用不存在的数组索引时,会抛出此异常。
InvalidCastException 当尝试执行无效转换时,会引发此异常。
NullReferenceException 当尝试使用或访问空引用类型时,会引发此异常。

.NET 框架的不同异常类

让我们看一个示例,其中使用了这些异常类中的一个。在这个例子中,我们使用了IndexOutOfRange异常类:

using System;

namespace ExceptionCode
{
 class Program
 {
 static void Main(string[] args)
 {
 int[] a = new int[] {1,2,3};

 try
 {
 Console.WriteLine(a[5]);
 }
 catch (IndexOutOfRangeException ex)
 {
 Console.WriteLine("Message:");
 Console.WriteLine(ex.Message);
 Console.WriteLine("Stack Trace:");
 Console.WriteLine(ex.StackTrace);
 Console.WriteLine("String:");
 Console.WriteLine(ex.ToString());
 }

 Console.ReadKey();
 }
 }
}

输出如下:

用户定义的异常

有时,您可能会遇到一种情况,认为预定义的异常不满足您的条件。在这种情况下,您可能希望有一种方法来创建自己的异常类并使用它们。值得庆幸的是,在 C#中,实际上有一种机制可以创建自定义异常,并且可以编写适用于该类型异常的任何消息。让我们看一个创建和使用自定义异常的示例:

using System;

namespace ExceptionCode
{

 class HelloException : Exception
 {
 public HelloException() { }
 public HelloException(string message) : base(message) { }
 public HelloException(string message, Exception inner) : base(message, inner) { }
 }

 class Program
 {
 static void Main(string[] args)
 {
 try
 {
 throw new HelloException("Hello is an exception!");
 }
 catch (HelloException ex)
 {
 Console.WriteLine("Exception Message:");
 Console.WriteLine(ex.Message);
 }

 Console.ReadKey();
 }
 }
}

输出如下:

因此,我们可以从上面的示例中看到,您只需创建一个将扩展Exception类的类。该类应该有三个构造函数:一个不应该带任何参数,一个应该带一个字符串并将其传递给基类,一个应该带一个字符串和一个异常并将其传递给基类。

使用自定义异常就像使用.NET Framework 提供的任何其他内置异常一样。

异常筛选器

在撰写本文时,异常筛选器功能并不是很古老——它是在 C# 6 中引入的。其主要好处是可以在一个块中捕获更具体的异常。让我们看一个例子:

using System;

namespace ExceptionCode
{
 class Program
 {
 static void Main(string[] args)
 {

 int[] a = new int[] {1,2,3};

 try
 {
 Console.WriteLine(a[5]);
 }
 catch (IndexOutOfRangeException ex) when (ex.Message == "Test Message")
 {
 Console.WriteLine("Message:");
 Console.WriteLine("Test Message");
 }
 catch (IndexOutOfRangeException ex) when (ex.Message == "Index was outside the bounds of the array.")
 {
 Console.WriteLine("Message:");
 Console.WriteLine(ex.Message);
 Console.WriteLine("Stack Trace:");
 Console.WriteLine(ex.StackTrace);
 Console.WriteLine("String:");
 Console.WriteLine(ex.ToString());
 }

 Console.ReadKey();
 }
 }
}

输出如下:

要筛选异常,必须在catch声明行的旁边使用when关键字。因此,当抛出任何异常时,它将检查异常的类型,然后检查when关键字之后提供的条件。在我们的示例中,异常类型是IndexOutOfRangeException,条件是ex.Message == "Index was outside the bounds of the array."。我们可以看到,当代码运行时,只有满足所有条件的特定catch块被执行。

异常处理最佳实践

正如您所看到的,处理异常有不同的方式:有时可以抛出异常,有时可以使用finally块,有时可以使用多个catch块。因此,如果您对异常处理没有足够的经验,可能会在开始时感到困惑。但幸运的是,C#社区为异常处理提供了一些最佳实践。让我们看看其中一些:

  • 使用finally块关闭/清理可能会在将来引起问题的依赖资源。

  • 捕获特定异常并正确处理。如果需要,可以使用多个catch块。

  • 如有需要,创建并使用自定义异常。

  • 尽快处理异常。

  • 如果可以使用特定处理程序处理异常,则不要使用通用异常处理程序。

  • 异常消息应该非常清晰。

总结

我们都梦想着一个没有错误或意外情况的完美世界,但现实中这是不可能的。软件开发也不免于错误和异常。软件开发人员不希望他们的软件崩溃,但意外异常时有发生。因此,处理这些异常对于开发出色的软件是必要的。在本章中,我们熟悉了软件开发中异常的概念。我们还学习了如何处理异常,为什么需要处理异常,如何创建自定义异常以及许多其他重要主题。在应用程序中实施异常处理时,请尽量遵循最佳实践,以确保应用程序运行顺畅。

第六章:事件和委托

事件和委托可能看起来像复杂的编程主题,但实际上并不是。在本章中,我们将首先通过分析它们各自名称的含义来学习这些概念。然后我们将把这些词的一般含义与编程联系起来。在本章中,我们将看到很多示例代码,这将帮助我们轻松理解这些概念。在我们深入讨论之前,让我们先看一下本章将涵盖的主题:

  • 如何创建和使用委托

  • 方法组转换

  • 多播

  • 协变和逆变

  • 事件和多播事件

  • .NET 事件指南

什么是委托?

委托是一个代理,一个替代者,或者某人的代表。例如,我们可能在报纸上看到另一个国家的代表来到我们国家会见高级官员。这个人是一个代表,因为他们来到我们国家代表他们自己的国家。他们可能是总统、总理或者那个国家的任何其他高级官员的代表。让我们想象一下,这个代表是总统的代表。也许总统因某种原因无法亲自出席这次会议,这就是为什么派遣了一个代表代表他们。这个代表将会做总统应该在这次旅行中做的工作,并代表总统做出决定。代表不是一个固定的个人;可以是总统选择的任何合格的人。

委托的概念在软件开发中是类似的。我们可以有一个功能,其中一个方法不执行它被要求执行的实际工作,而是调用另一个方法来执行那项工作。此外,在编程中,那个不执行实际工作而是将其传递给另一个方法的方法被称为委托。因此,委托实际上将持有一个方法的引用。当调用委托时,引用的方法将被调用和执行。

现在,你可能会问,"如果委托要调用另一个方法,为什么我不直接调用这个方法呢?" 好吧,我们这样做是因为如果你直接调用方法,你会失去灵活性,使你的代码耦合在一起。你在代码中硬编码了方法名,所以每当那行代码运行时,该方法就会被执行。然而,使用委托,你可以在运行时决定调用哪个方法,而不是在编译时。

如何创建和使用委托

要创建一个委托,我们需要使用delegate关键字。让我向你展示如何以一般形式声明一个委托:

delegate returnType delegateName(parameters)

现在让我给你展示一些真实的示例代码:

using System;

namespace Delegate1
{
  delegate int MathFunc(int a, int b);

  class Program
  {
    static void Main(string[] args)
    {
      MathFunc mf = new MathFunc(add);

      Console.WriteLine("add");
      Console.WriteLine(mf(4, 5));

      mf = new MathFunc(sub);

      Console.WriteLine("sub");
      Console.WriteLine(mf(4, 5));

      Console.ReadKey();
    }

    public static int add(int a, int b)
    {
      return a + b;
    }

    public static int sub(int a, int b)
    {
      return (a > b) ? (a - b) : (b - a);
    }
  }
}

上述代码的输出将如下所示:

现在让我们讨论上述代码。在命名空间内的顶部,我们可以看到委托的声明,如下所示:

delegate int MathFunc(int a, int b);

我们使用了delegate关键字来告诉编译器我们在声明一个delegate。然后我们将返回类型设置为int,并命名了委托为MathFunc。我们还在这个委托中传递了两个int类型的参数。

之后,program类开始运行,在该类中,除了主方法外,我们还有两个方法。一个是add,另一个是sub。如果你仔细观察这些方法,你会发现它们与委托具有相同的签名。这是故意这样做的,因为当方法具有与委托相同的签名时,方法可以使用delegate

现在,如果我们看一下主方法,我们会发现以下有趣的代码:

MathFunc mf = new MathFunc(add);

在主方法的第一行,我们创建了一个代理对象。在这样做时,我们将add方法传递给构造函数。这是必需的,因为你需要传递一个你想要使用代理的方法。然后我们可以看到,当我们调用代理mf(4,5)时,它返回9。这意味着它实际上调用了add方法。之后,我们将sub分配给delegate。在调用mf(4,5)时,这次我们得到了1。这意味着调用了sub方法。通过这种方式,一个delegate可以用于具有相同签名的许多方法。

方法组转换

在上一个例子中,我们看到了如何创建一个代理对象并在构造函数中传递方法名。现在我们将看另一种实现相同目的的方法,但更简单。这被称为方法组转换。在这里,你不需要初始化delegate对象,而是可以直接将方法分配给它。让我给你举个例子:

using System;

namespace Delegate1
{
 delegate int MathFunc(int a, int b);

 class Program
 {
 static void Main(string[] args)
 {
 MathFunc mf = add;

 Console.WriteLine("add");
 Console.WriteLine(mf(4, 5));

 mf = sub;

 Console.WriteLine("sub");
 Console.WriteLine(mf(4, 5));
 Console.ReadKey();
 }

 public static int add(int a, int b)
 {
 return a + b;
 }

 public static int sub(int a, int b)
 {
 return (a > b) ? (a - b) : (b - a);
 }
 }
}

在这里,我们可以看到,我们直接将方法分配给它,而不是在构造函数中传递方法名。这是在 C#中分配代理的一种快速方法。

使用静态和实例方法作为代理

在之前的例子中,我们在代理中使用了静态方法。然而,你也可以在代理中使用实例方法。让我们看一个例子:

using System;

namespace Delegate1
{
  delegate int MathFunc(int a, int b);

  class Program
  {
    static void Main(string[] args)
    {
      MyMath mc = new MyMath();

      MathFunc mf = mc.add;

      Console.WriteLine("add");
      Console.WriteLine(mf(4, 5));

      mf = mc.sub;

      Console.WriteLine("sub");
      Console.WriteLine(mf(4, 5));

      Console.ReadKey();
    }
  }
  class MyMath
  {
    public int add(int a, int b)
    {
      return a + b;
    }

    public int sub(int a, int b)
    {
      return (a > b) ? (a - b) : (b - a);
    }
  }
}

在上面的例子中,我们可以看到我们在MyMath类下有实例方法。要在代理中使用这些方法,我们首先必须创建该类的对象,然后简单地使用对象实例将方法分配给代理。

多播

多播是代理的一个很好的特性。通过多播,你可以将多个方法分配给一个代理。当执行该代理时,它依次运行所有被分配的方法。使用++=运算符,你可以向代理添加方法。还有一种方法可以从代理中删除添加的方法。要做到这一点,你必须使用--=运算符。让我们看一个例子来清楚地理解多播是什么:

using System;

namespace MyDelegate
{
  delegate void MathFunc(ref int a);

  class Program
  {
    static void Main(string[] args)
    {
      MathFunc mf;
      int number = 10;
      MathFunc myAdd = MyMath.add5;
      MathFunc mySub = MyMath.sub3;

      mf = myAdd;
      mf += mySub;

      mf(ref number);

      Console.WriteLine($"Final number: {number}");

      Console.ReadKey();
    }
  }

  class MyMath
  {
    public static void add5(ref int a)
    {
      a = a + 5;
      Console.WriteLine($"After adding 5 the answer is {a}");
    }

    public static void sub3(ref int a)
    {
      a = a - 3;
      Console.WriteLine($"After subtracting 3 the answer is {a}");
    }
  }
}

上面的代码将给出以下输出:

在这里,我们可以看到我们的代理依次执行了两种方法。我们必须记住它的工作原理就像一个队列,所以你添加的第一个方法将是第一个执行的方法。现在让我们看看如何从代理中删除一个方法:

using System;

namespace MyDelegate
{
  delegate void MathFunc(ref int a);

  class Program
  {
    static void Main(string[] args)
    {
      MathFunc mf;
      MathFunc myAdd = MyMath.add5;
      MathFunc mySub = MyMath.sub3;
      MathFunc myMul = MyMath.mul10;

      mf = myAdd;
      mf += mySub;
      int number = 10;

      mf(ref number);

      mf -= mySub;
      mf += myMul;
      number = 10;

      mf(ref number);

      Console.WriteLine($"Final number: {number}");

      Console.ReadKey();
    }
  }

  class MyMath
  {
    public static void add5(ref int a)
    {
      a = a + 5;
      Console.WriteLine($"After adding 5 the answer is {a}");
    }

    public static void sub3(ref int a)
    {
      a = a - 3;
      Console.WriteLine($"After subtracting 3 the answer is {a}");
    }

    public static void mul10(ref int a)
    {
      a = a * 10;
      Console.WriteLine($"After multiplying 10 the answer is {a}");
    }
  }
}

上面的代码将给我们以下输出:

在这里,我们首先向代理添加了两种方法。然后,我们删除了sub3方法并添加了mul10方法。在进行了所有这些更改后,当我们执行了代理时,我们看到5被加到了数字上,然后10被乘以数字。没有发生减法。

协变和逆变

有两个重要的代理特性。到目前为止,我们学到的是通常情况下,要向代理注册一个方法,该方法必须与代理的签名匹配。这意味着方法和代理的返回类型和参数必须相同。然而,通过协变和逆变的概念,你实际上可以向代理注册不具有相同返回类型或参数的方法。然后在调用时,代理将能够执行它们。

协变是指当你将一个返回类型是委托返回类型的派生类型的方法分配给委托时。例如,如果类B是从类A派生出来的,并且如果委托返回类A,那么可以向委托注册返回类B的方法。让我们看看以下代码中的例子:

using System;

namespace EventsAndDelegates
{
 public delegate A DoSomething();

 public class A
 {
 public int value { get; set; }
 }

 public class B : A {}

 public class Program
 {
 public static A WorkA()
 {
 A a = new A();
 a.value = 1;
 return a;
 }

 public static B WorkB()
 {
 B b = new B();
 b.value = 2;
 return b;
 }

 public static void Main(string[] args)
 {
 A someA = new A();

 DoSomething something = WorkB;

 someA = something();

 Console.WriteLine("The value is " + someA.value);

 Console.ReadLine();
 }
 }
}

上面代码的输出将如下所示:

另一方面,逆变是指当一个方法传递给委托时,该方法的参数与委托的参数不匹配。在这里,我们必须记住,方法的参数类型至少必须派生自委托的参数类型。让我们看一个逆变的例子:

using System;

namespace EventsAndDelegates
{
 public delegate int DoSomething(B b);

 public class A
 {
 public int value = 5;
 }

 public class B : A {}

 public class Program
 {
 public static int WorkA(A a)
 {
 Console.WriteLine("Method WorkA called: ");
 return a.value * 5;
 }

 public static int WorkB(B b)
 {
 Console.WriteLine("Method WorkB called: ");
 return b.value * 10;
 }

 public static void Main(string[] args)
 {
 B someB = new B();

 DoSomething something = WorkA;

 int result = something(someB);

 Console.WriteLine("The value is " + result);

 Console.ReadLine();
 }
 }
}

上面的代码将产生以下输出:

在这里,我们可以看到委托以类型B作为参数。然而,当WorkA方法被注册为委托中的一个方法时,它并没有给出任何错误或警告,尽管WorkA方法的参数类型是A类型。它能够工作的原因是因为B类型是从A类型派生出来的。

事件

你可以将事件看作是在某些情况下执行的一种方法,并通知处理程序或委托有关该事件的发生。例如,当你订阅电子邮件时,你会收到来自网站的关于最新文章、博客帖子或新闻的电子邮件。这些电子邮件可以是每天、每周、每月、每年,或者根据你选择的其他指定时间段。这些电子邮件不是由人手动发送的,而是由自动系统/软件发送的。可以使用事件来开发这种自动电子邮件发送器。现在,你可能会想,为什么我需要一个事件来做这个,我不能通过普通方法发送电子邮件给订阅者吗?是的,你可以。但是,假设在不久的将来,你还想引入一个功能,即在移动应用程序上收到通知。你将不得不更改代码并添加该功能。几天后,如果你想进一步扩展你的系统并向特定订阅者发送短信,你又必须再次更改代码。不仅如此,如果你使用普通方法编写代码,那么你编写的代码将非常紧密耦合。你可以使用event来解决这类问题。你还可以创建不同的事件处理程序,并将这些事件处理程序分配给一个事件,这样,每当该事件被触发时,它将通知所有注册的处理程序来执行它们的工作。现在让我们看一个例子来使这一点更清晰:

using System;

namespace EventsAndDelegates
{
  public delegate void GetResult();

  public class ResultPublishEvent
  {
    public event GetResult PublishResult;

    public void PublishResultNow()
    {
      if (PublishResult != null)
      {
        Console.WriteLine("We are publishing the results now!");
        Console.WriteLine("");
        PublishResult();
      }
    }
  }

  public class EmailEventHandler
  {
    public void SendEmail()
    {
      Console.WriteLine("Results have been emailed successfully!");
    }
  }

  public class Program
  {
    public static void Main(string[] args)
    {
      ResultPublishEvent e = new ResultPublishEvent();

      EmailEventHandler email = new EmailEventHandler();

      e.PublishResult += email.SendEmail;
      e.PublishResultNow();

      Console.ReadLine();
    }
  }
}

上面代码的输出如下:

在上面的代码中,我们可以看到,当调用PublishResultNow()方法时,它基本上触发了PublishResult事件。此外,订阅了该事件的SendMail()方法被执行,并在控制台上打印出Results have been emailed successfully!

多播事件

在事件中,你可以像在委托中一样进行多播。这意味着你可以注册多个事件处理程序(订阅事件的方法)到一个事件中,当事件被触发时,所有这些处理程序都会依次执行。要进行多播,你必须使用+=符号来注册事件处理程序到事件中。你也可以使用-=运算符从事件中移除事件处理程序。当应用多播时,首先注册的事件处理程序将首先执行,然后是第二个,依此类推。通过多播,你可以在应用程序中轻松扩展或减少事件处理程序而不需要做太多工作。让我们看一个多播的例子:

using System;

namespace EventsAndDelegates
{
 public delegate void GetResult();

 public class ResultPublishEvent
 {
 public event GetResult PublishResult;

 public void PublishResultNow()
 {
 if (PublishResult != null)
 {
 Console.WriteLine("");
 Console.WriteLine("We are publishing the results now!");
 Console.WriteLine("");
 PublishResult();
 }
 }
 }

 public class EmailEventHandler
 {
 public void SendEmail()
 {
 Console.WriteLine("Results have been emailed successfully!");
 }
 }

 public class SmsEventHandler
 {
 public void SmsSender()
 {
 Console.WriteLine("Results have been messaged successfully!");
 }
 }

 public class Program
 {
 public static void Main(string[] args)
 {
 ResultPublishEvent e = new ResultPublishEvent();

 EmailEventHandler email = new EmailEventHandler();
 SmsEventHandler sms = new SmsEventHandler();

 e.PublishResult += email.SendEmail;
 e.PublishResult += sms.SmsSender;

 e.PublishResultNow();

 e.PublishResult -= sms.SmsSender;

 e.PublishResultNow();

 Console.ReadLine();
 }
 }
}

上面代码的输出如下:

现在,如果我们分析上面的代码,我们可以看到我们创建了另一个类SmsEventHandler,这个类有一个名为SmsSender的方法,它的签名与我们的委托GetResult相同,如下面的代码所示:

public class SmsEventHandler
{
  public void SmsSender()
  {
    Console.WriteLine("Results have been messaged successfully!");
  }
}

然后,在主方法中,我们创建了这个SmsEventHandler类的一个实例,并将SmsSender方法注册到事件中,如下面的代码所示:

e.PublishResult += sms.SmsSender;

触发事件一次后,我们使用-=运算符从事件中移除SmsSender事件处理程序,如下所示:

e.PublishResult -= sms.SmsSender;

当我们再次触发事件时,可以在输出中看到只有电子邮件事件处理程序被执行。

.NET 中的事件准则

为了更好的稳定性,.NET Framework 提供了一些在 C# 中使用事件的准则。并不是说你一定要遵循这些准则,但遵循这些准则肯定会使你的程序更加高效。现在让我们看看需要遵循哪些准则。

事件应该有以下两个参数:

  • 生成事件的对象的引用

  • EventArgs 的类型将保存事件处理程序所需的其他重要信息

代码的一般形式应该如下:

void eventHandler(object sender, EventArgs e)
{
}

让我们看一个遵循这些准则的例子:

using System;

namespace EventsAndDelegates
{
  class MyEventArgs : EventArgs
  {
    public int number;
  }

  delegate void MyEventHandler(object sender, MyEventArgs e);

  class MyEvent
  {
    public static int counter = 0;

    public event MyEventHandler SomeEvent;

    public void GetSomeEvent()
    {
      MyEventArgs a = new MyEventArgs();

      if (SomeEvent != null)
      {
        a.number = counter++;
        SomeEvent(this, a);
      }
    }

  }

  class X
  {
    public void Handler(object sender, MyEventArgs e)
    {
      Console.WriteLine("Event number: " + e.number);
      Console.WriteLine("Source Object: " + sender);
      Console.WriteLine();
    }
  }

  public class Program
  {
    public static void Main(string[] args)
    {
      X x = new X();

      MyEvent myEvent = new MyEvent();

      myEvent.SomeEvent += x.Handler;

      myEvent.GetSomeEvent();
      myEvent.GetSomeEvent();

      Console.ReadLine();
    }
  }
}

上述代码的输出如下:

如果我们分析上述代码,我们会看到我们使用 EventArgs 参数传递了计数器的值,使用 object 参数传递了对象的引用。

摘要

在本章中,我们学习了委托和事件。这些主题在软件开发中非常重要,因为它们提供了在特定场合自动化代码的功能。这些概念在 Web 开发领域都被广泛使用。

在下一章中,我们将学习 C# 中的泛型和集合。这些是 C# 编程语言非常有趣的特性,你可以使用它们在程序中编写通用的委托。

第七章:C#中的泛型

泛型是 C#编程语言中非常重要的一个主题。据我所知,很难找到任何不使用泛型的 C#编写的现代软件。

本章中我们将涵盖的主题如下:

  • 什么是泛型?

  • 我们为什么需要泛型?

  • 泛型的不同约束

  • 泛型方法

  • 泛型中的协变和逆变

什么是泛型?

在 C#中,泛型用于创建不特定但通用的类、方法、结构和其他组件。这使我们能够为不同的原因使用通用组件。例如,如果您有一种通用的肥皂,您可以用它来进行任何类型的清洗。您可以用它来洗手,洗衣服,甚至洗脏碗。但是,如果您有一种特定类别的肥皂,比如洗衣粉,它只能用来洗衣服,而不能用来做其他事情。因此,泛型为我们的代码提供了一些额外的可重用性,这对于应用程序是有益的,因为会有更少的代码来执行类似的工作。泛型并不是新开发的;它们自 C# 2 以来就已经可用。因此,经过这么多年的使用,泛型已经成为程序员常用的工具。

让我们来看一个Generic类的例子:

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

namespace Chapter7
{
  class Price<T>
  {
    T ob;

    public Price(T o)
    {
      ob = o;
    }

    public void PrintType()
    {
      Console.WriteLine("The type is " + typeof(T));
    }

    public T GetPrice()
    {
      return ob;
    }
  }

  class Code_7_1
  {
    static void Main(string[] args)
    {
      Price<int> price = new Price<int>(55);

      price.PrintType();

      int a = price.GetPrice();

      Console.WriteLine("The price is " + a);

      Console.ReadKey();
    }
  }
}

前面代码的输出如下:

如果您对泛型的语法完全不熟悉,您可能会对在Price类旁边看到的尖括号<>感到惊讶。您可能还想知道<>中的T是什么。这是 C#中泛型的语法。通过将<>放在类名旁边,我们告诉编译器这是一个泛型类。此外,<>中的T是一个类型参数。是的,我知道您在问:“'什么是类型参数?'”类型参数就像 C#编程中的任何其他参数一样,只是它传递的是类型而不是值或引用。现在,让我们分析前面的代码。

我们创建了一个泛型Price类。为了使它成为泛型,我们在类名旁边放置了<T>。这里,T是一个类型参数,但它并不是固定的,您可以使用任何东西来表示类型参数,而不一定非要使用T。但是,传统上使用T来表示类型参数。如果有更多的类型参数,会使用VE。在使用两个或更多参数时,还有另一种常用的约定,即将参数命名为TValueTKey,而不仅仅是VE,这样做可以提高可读性。但是,正如您所看到的,我们在ValueKey之前加了T前缀,这是为了区分类型参数和一般参数。

Price<T>类中,我们首先创建了一个名为ob的变量,它是T类型的:

T ob;

当我们运行前面的代码时,我们在类中传递的类型将是这个对象的类型。因此,我们可以说T是一个占位符,在运行时将被一些其他具体的 C#类型(intdoublestring或任何其他复杂类型)替换。

在接下来的几行中,我们创建了一个构造函数:

public Price(T o)
{
    ob = o;
}

在构造函数中,我们传递了一个T类型的参数,然后将传递的参数o的值分配给局部变量ob。我们可以这样做是因为在构造函数中传递的参数也是T类型。

然后,我们创建了第二个方法:

public void PrintType()
{
    Console.WriteLine("The type is " + typeof(T));
}

public T GetPrice()
{
    return ob;
}

这里,第一个方法打印T的类型。这将有助于在运行程序时识别类型。另一个方法是返回局部变量ob。在这里,我们注意到我们从GetPrice方法中返回了T

现在,如果我们专注于我们的主方法,我们会看到在第一行中我们正在用int作为类型参数实例化我们的泛型类Price,并将整数值55传递给构造函数:

Price<int> price = new Price<int>(55);

当我们这样做时,编译器将Price类中的每个T视为int。因此,局部参数ob将是int类型。当我们运行PrintType方法时,应该在屏幕上打印 System.Int32,当我们运行GetPrice方法时,应该返回一个Int类型的值。

现在,由于Price方法是泛型的,我们也可以将此Price方法用于字符串类型。为此,我们必须将类型参数设置为string。让我们在前面的例子中添加一些代码,这将创建一个处理字符串的Price对象:

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

namespace Chapter7
{
  class Price<T>
  {
    T ob;

    public Price(T o)
    {
      ob = o;
    }

    public void PrintType()
    {
      Console.WriteLine("The type is " + typeof(T));
    }

    public T GetPrice()
    {
      return ob;
    }
  }

  class Code_7_2
  {
    static void Main(string[] args)
    {
      Price<int> price = new Price<int>(55);

      price.PrintType();

      int a = price.GetPrice();

      Console.WriteLine("the price is " + a);

      Price<string> priceStr = new Price<string>("Hello People");

      priceStr.PrintType();

      string b = priceStr.GetPrice();

      Console.WriteLine("the string is " + b);

      Console.ReadKey();
    }
  }
}

上述代码的输出如下:

我们为什么需要泛型?

看到前面的例子后,您可能会想知道为什么我们需要泛型,当我们可以使用object类型时。object类型可以用于 C#中的任何类型,并且可以通过使用object类型实现前面的例子。是的,可以通过使用对象类型实现前面的例子,但不会有类型安全。相反,泛型确保了在代码执行时存在类型安全。

如果你和我一样,肯定想知道什么是类型安全。类型安全实际上是指在程序执行任何任务时保持类型安全或不可更改。这有助于减少运行时错误。

现在,让我们使用对象类型而不是泛型来编写前面的程序,看看泛型如何处理类型安全,而对象类型无法处理:

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

namespace Chapter7
{
  class Price
  {
    object ob;

    public Price(object o)
    {
      ob = o;
    }

    public void PrintType()
    {
      Console.WriteLine("The type is " + ob.GetType());
    }

    public object GetPrice()
    {
      return ob;
    }
  }

  class Code_7_3
  {
    static void Main(string[] args)
    {
      Price price = new Price(55);

      price.PrintType();

      int a = (int)price.GetPrice();

      Console.WriteLine("the price is " + a);

      Console.ReadKey();
    }
  }
}

上述代码的输出如下:

泛型的不同约束

在 C#泛型中有不同类型的约束:

  • 基类约束

  • 接口约束

  • 引用类型和值类型约束

  • 多个约束

最常见和流行的类型是基类约束和接口约束,因此我们将在以下部分重点关注它们。

基类约束

这种约束的想法是只有扩展基类的类才能用作泛型类型。例如,如果您有一个名为Person的类,并且将此Person类用作Generic约束的基类,那么只有Person类或继承Person类的任何其他类才能用作该泛型类的类型参数。让我们看一个例子:

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

namespace Chapter7
{
  public class Person
  {
    public void PrintName()
    {
      Console.WriteLine("My name is Raihan");
    }
  }

  public class Boy : Person
  {

  }

  public class Toy
  {

  }

  public class Human<T> where T : Person
  {
    T obj;

    public Human(T o)
    {
      obj = o;
    }

    public void MustPrint()
    {
      obj.PrintName();
    }
  }

  class Code_7_3
  {
    static void Main(string[] args)
    {
      Person person = new Person();
      Boy boy = new Boy();
      Toy toy = new Toy();

      Human<Person> personTypeHuman = new Human<Person>(person);
      personTypeHuman.MustPrint();

      Human<Boy> boyTypeHuman = new Human<Boy>(boy);
      boyTypeHuman.MustPrint();

      /* Not allowed
      Human<Toy> toyTypeHuman = new Human<Toy>(toy);
      toyTypeHuman.MustPrint();
      */

      Console.ReadKey();
    }
  }
}

接口约束

与基类约束类似,当您的泛型类约束设置为接口时,我们看到接口约束。只有实现该接口的类才能在泛型方法中使用。

引用类型和值类型约束

当您想要区分泛型类和引用类型和值类型时,您需要使用此约束。当您使用引用类型约束时,泛型类将只接受引用类型对象。为了实现这一点,您必须使用class关键字扩展您的泛型类:

... where T : class

此外,当您想要使用值类型时,您需要编写以下代码:

... where T : struct

正如我们所知,class是引用类型,struct是值类型。因此,当您设置值类型约束时,这意味着泛型只能用于值类型,如intdouble。不会有任何引用类型,如字符串或任何其他自定义类。

多个约束

在 C#中,可以在泛型类中使用多个约束。当这样做时,需要注意顺序。实际上,您可以包含多少约束都没有限制;您可以使用您需要的多少个。

泛型方法

Generic类一样,可以有泛型方法,泛型方法不一定要在泛型类中。泛型方法也可以在非泛型类中。要创建泛型方法,必须在方法名之后和括号之前放置类型参数。一般形式如下:

access-modifier return-type method-name<type-parameter>(params){ method-body }

现在,让我们看一个泛型方法的例子:

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

namespace Chapter7
{
  class Hello
  {
    public static T Larger<T>(T a, T b) where T : IComparable<T>
    {
      return a.CompareTo(b) > 0 ? a : b;
    }
  }

  class Code_7_4
  {
    static void Main(string[] args)
    {
      int result = Hello.Larger<int>(3, 4);

      double doubleResult = Hello.Larger<double>(4.3, 5.6);

      Console.WriteLine("The Large value is " + result);
      Console.WriteLine("The Double Large value is " + doubleResult);

      Console.ReadKey();
    }
  }
}

上述代码的输出如下:

在这里,我们可以看到我们的Hello类不是一个泛型类。然而,Larger方法是一个泛型方法。这个方法接受两个参数并比较它们,返回较大的值。这个方法还实现了一个约束,即IComparable<T>。在主方法中,我们多次调用了这个泛型方法,一次使用int值,一次使用double值。在输出中,我们可以看到该方法成功地比较并返回了较大的值。

在这个例子中,我们只使用了一种类型的参数,但是在泛型方法中可以有多个参数。在这个示例代码中,我们还创建了一个static方法,但是泛型方法也可以是非静态的。静态/非静态与是否为泛型方法无关。

类型推断

编译器变得更加智能。一个例子就是泛型方法中的类型推断。类型推断意味着调用泛型方法而不指定类型参数,并让编译器确定使用哪种类型。这意味着在前面的例子中,当调用方法时,我们无法指定类型参数。

让我们看一些类型推断的示例代码:

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

namespace Chapter7
{
  class Hello
  {
    public static T Larger<T>(T a, T b) where T : IComparable<T>
    {
      return a.CompareTo(b) > 0 ? a : b;
    }
  }

  class Code_7_5
  {
    static void Main(string[] args)
    {
      int result = Hello.Larger(3, 4);

      double doubleResult = Hello.Larger(4.3, 5.6);

      Console.WriteLine("The Large value is " + result);
      Console.WriteLine("The Double Large value is " + doubleResult);

      Console.ReadKey();
    }
  }
}

上述代码的输出如下:

在这段代码中,我们可以看到在泛型方法中没有指定类型参数。然而,代码仍然编译并显示正确的输出。这是因为编译器使用类型推断来确定传递给方法的参数类型,并执行方法,就好像参数类型已经给编译器了。因此,当使用类型推断时,不允许在泛型方法中提供不同类型的参数。如果需要传递不同类型的参数,应该明确指定。也可以对可以应用于类的方法应用约束。

泛型中的协变和逆变

如果你学过委托,我相信你一定听说过协变和逆变。这些主要是为非泛型委托引入的。然而,从 C# 4 开始,这些也适用于泛型接口和委托。泛型中的协变和逆变概念几乎与委托中的相同。让我们通过示例来看一下。

协变

这意味着具有T类型参数的通用接口可以返回T或任何派生自T的类。为了实现这一点,参数应该与out关键字一起使用。让我们看看通用形式:

access-modifier interface-name<out T>{}

逆变

逆变是泛型中实现的另一个特性。"逆变"这个词听起来可能有点复杂,但其背后的概念非常简单。通常,在创建泛型方法时,我们传递给它的参数与T的类型相同。如果尝试传递另一种类型的参数,将会得到编译时错误。然而,使用逆变时,可以传递类型参数实现的基类。此外,要使用逆变,我们必须遵循一种特殊的语法。让我们看看泛型语法:

access-modifier interface interface-name<in T>{}

如果分析上述语句,会发现在T之前使用了一个关键字,即in。这个关键字告诉编译器这是逆变。如果不包括in关键字,逆变将不适用。

现在,让我们看一些示例代码,以便更清楚地理解我们的理解:

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

namespace Chapter7
{
  public interface IFood<in T>
  {
    void PrintMyName(T obj);
  }

  class HealthyFood<T> : IFood<T>
  {
    public void PrintMyName(T obj)
    {
      Console.WriteLine("This is " + obj);
    }
  }

  class Vegetable
  {
    public override string ToString()
    {
      return "Vegetable";
    }
  }

  class Potato : Vegetable
  {
    public override string ToString()
    {
      return "Potato";
    }
  }

  class Code_7_6
  {
    static void Main(string[] args)
    {
      IFood<Potato> mySelf = new HealthyFood<Potato>();
      IFood<Potato> mySelf2 = new HealthyFood<Vegetable>();

      mySelf2.PrintMyName(new Potato());

      Console.ReadKey();
    }
  }
}

上述代码的输出如下:

如果现在分析这段代码,会发现我们创建了一个名为IFood的接口,它使用了逆变。这意味着如果这个接口在一个泛型类中实现,该类将允许提供的类型参数的基类

IFood接口有一个方法签名:

void PrintMyName(T obj);

这里,T被用作方法的参数。

现在,一个名为HealthyFood的类实现了接口,而类中实现的方法只打印一个字符串:

class HealthyFood<T> : IFood<T>
{
  public void PrintMyName(T obj)
  {
    Console.WriteLine("This is " + obj);
  }
}

然后,我们创建了两个类:VegetablePotatoPotato扩展Vegetable。两个类都重写了ToString()方法,并且如果类是Potato,则返回Potato,如果类是Vegetable,则返回Vegetable

在主方法中,我们创建了一个Potato类的对象和一个Vegetable类的对象。这两个对象都保存在IFood<Potato>变量中:

IFood<Potato> mySelf = new HealthyFood<Potato>();
IFood<Potato> mySelf2 = new HealthyFood<Vegetable>();

有趣的部分在于mySelf2变量是IFood<Potato>类型,但它持有HealthyFood<Vegetable>类型的对象。这只有因为逆变性才可能。

请查看以下语句:

mySelf2.PrintMyName(new Potato());

当我们执行它时,可以看到输出如下:

This is Potato

如果删除in关键字并尝试再次运行程序,您将失败,并且编译器将抛出错误,表示这是不可能的。之所以能够运行代码,仅仅是因为逆变性。

摘要

C#中的泛型是一个非常强大的功能,它减少了代码重复,使程序更加结构化,并提供了可扩展性。一些重要的数据结构是基于泛型概念创建的;例如,List(集合)是 C#中的一种泛型类型。这是现代开发中最常用的数据结构之一。

在下一章中,我们将学习如何使用图表来设计和建模我们的软件,以便更好地进行沟通。在开发软件时,如果软件设计没有清晰地传达给开发人员,那么软件很可能无法达到其建立的目的。因此,理解重要的模型和图表非常重要。

第八章:软件建模和设计

随着土木工程的出现和大型结构的创建,建模和设计实践变得非常重要。软件开发也是如此。如今,软件无处不在:在你的电脑、手机、电视、汽车等等。随着软件的使用范围扩大,软件开发变得越来越复杂和昂贵,需要时间和金钱。

软件建模和设计是软件开发生命周期的重要部分。如果你有一个想法,计划开始一个软件项目,你应该做的第一件事是设计和建模软件,而不是直接开始编写代码。这将为你提供软件的高层视图,并有机会以便于扩展和修改的方式设计架构。如果你不事先进行建模,可能会陷入需要重构软件架构的情况,这可能非常昂贵。

本章将涵盖的主题如下:

  • 设计图的重要性

  • 不同的统一建模语言UML)图

  • 类图

  • 用例图

  • 序列图

设计图的重要性

UML 是一种设计语言,是用于软件建模和设计的标准语言。它最初由 Grady Booch,Ivar Jacobson 和 James Rumbaugh 于 1994-1995 年在 Rational Software 开发。1997 年,对象管理组OMG)将其采纳为建模的标准语言。后来,2005 年,国际标准化组织ISO)批准 UML 作为 ISO 标准,自那时起,它已被每个软件社区采用。

UML 图允许开发人员向其他人传达软件设计。这是一种具有一套规则的语言,鼓励简单的交流。如果你学会了阅读 UML,你就能理解任何用 UML 编写的软件模型。用普通英语解释软件模型将会非常困难。

不同的 UML 图

有许多类型的 UML 图,但在本章中我们只讨论最重要的几种。UML 图分为以下两个主要类别:

  • 结构图

  • 行为图

以下列表显示了属于结构图类别的图表:

  • 类图

  • 组件图

  • 组合结构图

  • 部署图

  • 对象图

  • 包图

  • 配置文件图

行为图包括以下内容:

  • 活动图

  • 通信图

  • 交互概述图

  • 序列图

  • 状态图

  • 时序图

  • 用例图

类图

类图是一种结构图,主要用于提供面向对象软件的设计。该图表演示了软件的结构,类的属性和方法,以及系统中类之间的关系。它可用于开发和文档编写;软件开发人员经常使用该图表快速了解代码,并帮助其他开发人员理解系统。它也偶尔被公司业务方面的员工使用。

以下是类图的三个主要部分:

  • 类名

  • 属性部分

  • 方法部分

类图由不同的类组成,表示为方框或矩形。矩形通常分为上述部分。第一部分包含类的名称,第二部分包含属性,第三部分包含方法。

让我们来看一个类图的例子:

在这里,我们可以看到一个名为Car的类,如顶部框所示。在下面,我们有该类的属性。我们可以看到color是一个属性的名称,前面有一个+号,表示它是一个公共变量。我们还可以看到变量名称旁边有一个:(冒号),这是一个分隔符。冒号后面给出的内容表示变量的类型。在这种情况下,我们可以看到color变量是string类型。下一个属性是company,也是string类型的变量。它前面有一个-号,表示它是一个私有变量。第三个属性是fuel,我们可以看到这是一个integer类型的私有变量。

如果我们查看属性下面,我们会看到Car类的方法。我们可以看到它有三个方法:move(direction: string)IsFuelEmpty()RefilFuel(litre: int)。与属性一样,我们可以看到方法后面有一个:(冒号)。在这种情况下,冒号后面给出的类型是方法的返回类型。第一个方法move不返回任何东西,所以类型是 void。在IsFuelEmpty()方法中,返回类型是布尔值,第三个方法也是如此。这里要注意的另一件事是方法的参数,它们放在方法名后的括号中。例如,move方法有一个名为directionstring类型参数。RefilFuel(litre: int)方法有一个int类型参数,即litre

在前面的例子中,我们看到了类在类图中的表示。通常,一个系统有多个相互关联的类。类图也展示了类之间的关系,这给观察者提供了系统对象关系的完整图景。在第四章中,对象协作,我们学习了面向对象软件中类和对象之间的不同关系。现在让我们看看如何使用类图表示这些不同的对象关系。

继承

继承是一种类似于另一个类的关系,就像 BMW i8 Roadster 是一种汽车一样。这种关系使用一条线和一个空心箭头表示。箭头从类指向超类,如下图所示:

关联

关联关系是对象之间最基本的关系。当一个对象与另一个对象有某种逻辑或物理关系时,称为关联关系。它由一条线和一个箭头表示。如果两侧都有箭头,表示双向关系。关联的一个例子可能是以下内容:

聚合

聚合 关系是一种特殊类型的关联关系。这种关系通常被称为拥有 关系。当一个类包含另一个类/对象时,这是一种聚合关系。这是用一条线和一个空心菱形表示的。例如,一辆车有一个轮胎。轮胎和车有一个聚合关系,如下图所示:

组合

当一个类包含另一个类,并且依赖类不能没有超类而存在时,这是一种组合关系。例如,银行账户不能没有银行而存在,如下图所示:

依赖

当一个类有一个依赖类,但是这个类本身不依赖于它自己的依赖类时,这些类之间的关系被称为依赖关系。在依赖关系中,依赖类的任何改变对其所依赖的类没有任何影响。但是如果它所依赖的类发生变化,依赖类将会受到影响。

这种关系用虚线表示,末端有一个箭头。例如,让我们想象一下我们手机上有一个主题。如果我们改变主题,手机的图标会改变,所以图标对主题有依赖。这种关系在下面的图中显示:

类图的一个例子

让我们来看一个项目的类图的例子。在这里,我们有一些成绩管理软件,被学校的老师和学生使用。这个软件允许老师更新特定学生在不同学科的成绩。它也允许学生查看他们的成绩。对于这个软件,我们有以下的类:

  • Person:

人员类图

  • 老师:

老师类图

  • Student:

学生类图

  • Subject:

学科类图

在这里,我们使用 Visual Studio 生成我们的类图,所以箭头可能不匹配前面部分给出的箭头。如果你使用其他绘图软件绘制你的类图,或者你手绘,那么请使用前面部分指定的箭头。

让我们来看下面的完整类图:

在这里,我们可以看到我们有一个Person类,有两个属性,FirstNameLastNameStudentTeacher类继承了Person类,所以我们可以看到箭头是空心的。Student类有两个属性,emailstudentId。它还有一个名为GetExamGrade的方法(string subject),它接受学科的名称并返回char类型的成绩。我们可以看到另一个类SubjectStudent有合成关系。Student有一个学科列表,而Subject类有三个属性,gradenamesubjectIdTeacher类有一个emailphoneNumberteacherId,它们分别是stringstringint类型。Teacher类与Student类有一个关联关系,因为老师有一组学生在他们下面。Teacher类还有一个名为GiveExamGrade的方法,它接受三个参数,studentIdsubjectgrade。这个方法将设置学生学科的成绩。

仅仅通过查看类图,我们就可以清楚地了解系统。我们知道学科与学生的关系,以及学生与老师的关系。我们还知道一个学科对象不能没有学生对象存在,因为它们有合成关系。这就是类图的美妙之处。

用例图

用例图是在软件开发中非常常用的行为图。这个图的主要目的是说明软件的功能使用。它包含了系统的用例,并且可以用来提供功能的高层视图,甚至是软件的非常具体的低级模块。通常对于一个系统,会有多个用例图,专注于系统的不同层次。用例图不应该用来显示系统的实现细节;它们被开发出来只是为了显示系统的功能需求。用例图对于业务人员来传达他们从系统中需要什么非常有用。

用例图的四个主要部分如下列表所示:

  • 角色

  • 用例

  • 通信链接

  • 系统边界

角色

用例图中的角色不一定是一个人,而是系统的用户。它可以是一个人,另一个系统,甚至是系统的另一个模块。角色的可视表示如下图所示:

角色负责提供输入。它向系统提供指令,系统会相应地工作。角色所做的每一个动作都有一个目的。用例图向我们展示了一个角色可以做什么,以及角色的期望是什么。

用例

用例图的视觉部分或表示被称为用例。这代表了系统的功能。角色将执行一个用例来实现一个目标。用例由一个带有功能名称的椭圆表示。例如,在餐厅应用程序中,下订单可能是一个用例。我们可以表示如下:

通信链接

通信链接是从角色到用例的简单线条。这个链接用于显示角色与特定用例的关系。角色无法访问所有用例,因此在显示哪些用例可以被哪个角色访问时,通信链接非常重要。让我们看一个通信链接的例子,如下图所示:

系统边界

系统边界主要用于显示系统的范围。能够确定哪些用例属于我们的系统,哪些不属于是很重要的。在用例图中,我们只关注我们系统中的用例。在大型系统中,如果这些模块足够独立,可以独立运行,那么系统的每个模块有时会被视为一个边界。这通常用一个包含用例的矩形框来表示。角色不是系统的一部分,因此角色将在系统边界之外,如下图所示:

用例图的一个例子

让我们现在想象一下,我们有一个餐厅系统,顾客可以点餐。厨师准备食物,经理跟踪销售情况,如下图所示:

从上图可以看出,我们有三个角色(顾客、厨师和经理)。我们还有不同的用例——查看菜单、点餐、烹饪食物、上菜、支付和销售报告,这些用例与一个或多个角色相连。顾客参与了查看菜单、点餐和支付用例。厨师必须访问点餐以了解订单情况。厨师还参与了烹饪食物和上菜用例。与厨师和顾客不同,经理能够查看餐厅的销售报告。

通过查看这个用例图,我们能够确定系统的功能。它不会给出任何实现细节,但我们可以很容易地看到系统的概述。

序列图

序列图是行为图中的一种交互图。顾名思义,它显示了系统活动的顺序。通过查看序列图,您可以确定在特定时间段内发生了哪些活动,以及接下来发生了哪些活动。它使我们能够理解系统的流程。它表示的活动可能是用户与系统之间的交互,两个系统之间的交互,或者系统与子系统之间的交互。

序列图的水平轴显示时间从左到右流逝,而垂直轴显示活动的流程。不同的活动以顺序的方式放置在图中。序列图不一定显示时间流逝的持续时间,而是显示从一个活动到另一个活动的步骤。

在接下来的部分中,我们将看一下序列图中使用的符号。

一个参与者

序列图中的参与者与用例图中的参与者非常相似。它可以是用户、另一个系统,甚至是用户组。参与者不是系统的一部分,而是在外部执行命令。不同的操作是在接收用户命令时执行的。参与者用一个棒状图表示,如下图所示:

一个生命线

序列图中的生命线是系统的一个实体或元素。每个生命线都有自己的逻辑和任务要完成。通常,一个系统有多个生命线,并且命令是从一个生命线传递到另一个生命线的。

一个生命线由一个从底部发出的带有垂直线的框表示,如下图所示:

一个激活

激活是生命线上的一个小矩形框。这个激活框代表了一个活动处于活动状态的时刻。框的顶部代表活动的开始,框的底部代表活动的结束。

让我们看看在图中是什么样子的:

一个呼叫消息

一个呼叫消息表示生命线之间的交互。它从左到右流动,并且以一条箭头表示在线的末端,如下图所示。一个消息呼叫代表了一些信息或触发下一个生命线的触发器:

一个返回消息

序列图中的正常消息流是从左到右的,因为这代表了动作命令;然而,有时消息会返回给调用者。一个返回消息从右到左流动,并且以一个带箭头头的虚线表示,如下图所示:

一个自消息

有时,消息是从一个生命线传递到它自己,比如内部通信。它将以与消息呼叫类似的方式表示,但是它不是指向另一个活动或另一个生命线,而是返回到相同生命线的相同活动,如下图所示:

一个递归消息

当发送一个自消息用于递归目的时,它被称为递归消息。在同一时间线上为此目的绘制另一个小活动,如下图所示:

一个创建消息

这种类型的消息不是普通的消息,比如一个呼叫消息。当一个生命线由另一个生命线创建时,会使用一个创建消息,如下图所示:

一个销毁消息

当从一个活动发送一个销毁消息到一个生命线时,意味着接下来的生命线不会被执行,流程将停止,如下图所示。它被称为销毁消息,因为它销毁了活动流程:

一个持续消息

我们使用一个持续消息来显示当一个活动将消息传递给下一个活动时有一个时间持续。它类似于一个呼叫消息,但是是向下倾斜的,如下图所示:

一个注释

备注用于包含与元素或操作相关的任何必要备注。它没有特定的规则。可以将其放置在适合清楚表示事件的任何位置。任何类型的信息都可以写在备注中。备注表示如下:

序列图示例

学习任何东西的最佳方法是通过查看其示例。让我们来看一个简单餐厅系统的序列图示例:

在这里,我们可以看到客户首先从 UI 请求菜单。UI 将请求传递给控制器,然后控制器将请求传递给经理。经理获取菜单并回应控制器。控制器回应 UI,UI 在显示器上显示菜单。

客户选择商品后,订单逐步传递给经理。经理调用另一个方法来准备食物,并向客户发送响应,通知他们订单已收到。食物准备好后,将其送到客户那里。客户收到食物后,支付账单并领取付款收据。

通过查看序列图,我们可以看到流程中涉及的不同活动。系统是如何一步一步地工作的非常清楚。这种类型的图表在展示系统流程方面非常有用,非常受欢迎。

摘要

在本章中,您学习了如何使用 UML 图表对软件进行建模和设计的基础知识。这对每个软件开发人员来说都非常重要,因为我们需要能够与企业进行沟通,反之亦然。您还会发现,当与其他开发人员或软件架构师讨论系统时,这些图表也很有用。在本章中,我们没有涵盖所有可用于建模和设计软件的不同图表,因为这超出了本书的范围。在本章中,我们涵盖了类图、用例图和序列图。我们看到了每个图表的一个示例,并了解了如何绘制它们。

在下一章中,我们将学习如何使用 Visual Studio。我们将看到一些技巧和窍门,这些将帮助您在使用 Visual Studio 时提高生产力。

第九章:Visual Studio 和相关工具

Visual Studio 是微软的集成开发环境IDE)。它是一种计算机软件,可以用来编写、调试和执行代码。Visual Studio 是行业中最受欢迎的 IDE 之一,主要用于.NET 应用程序。由于它来自微软,因此使.NET 开发变得非常简单和顺畅。您可以使用 Visual Studio 进行其他编程语言,但我不能保证它会是最有用的选择;然而,对于像我这样的 C#开发人员来说,这是最好的 IDE。作为开发人员,我大部分时间都在 Visual Studio 中度过。

在撰写本书时,Visual Studio 的最新版本是 Visual Studio 2017。微软推出了不同版本的 Visual Studio。其中之一是社区版,是免费的。还有另外两个版本:Visual Studio 专业版和 Visual Studio 企业版。专业版和企业版是收费的,更适合大型项目。在本书中,我们将探讨社区版的功能,因为它是免费的,并且具有足够的功能来满足本书的目的。

在本章中,我们将学习 Visual Studio 的特性。我们将涵盖以下主题:

  • Visual Studio 项目类型和模板

  • Visual Studio 编辑器和不同的窗口

  • 调试窗口

  • 断点、调用堆栈跟踪和监视

  • Visual Studio 中的 Git

  • 重构和代码优化技术

Visual Studio 项目类型和模板

Visual Studio 是与微软相关技术堆栈的最佳 IDE。无论您是计划为 Windows 开发桌面应用程序还是为 Windows Server 开发 Web 应用程序,都可以使用 Visual Studio。使用 Visual Studio 的最佳部分是,如果您没有使用它,IDE 将帮助您完成许多常见任务,否则您将不得不手动执行这些任务。例如,如果您计划使用 ASP.NET Model-View-ControllerMVC)创建 Web 应用程序,Visual Studio 可以为您提供 MVC 应用程序的模板。您可以从模板开始,并根据您的要求进行修改。如果没有这个,您将不得不下载包,创建文件夹,并为应用程序设置 Web 配置。要充分利用 Visual Studio,您必须了解它提供的不同项目和模板,以便加快开发过程。

让我们来看看 Visual Studio 提供的不同项目类型。打开 Visual Studio 后,如果单击“新建项目”,将弹出以下窗口:

在左侧,我们可以看到项目的主要类别:最近、已安装和在线。在“最近”选项卡中,您可以看到最近使用过的项目类型,因此您不必每次都搜索常用的项目类型。在“已安装”选项卡中,您将找到已经安装在计算机上的项目类型。安装 Visual Studio 时,您可以选择要安装哪些工作负载。

在安装 Visual Studio 时会出现的工作负载窗口如下所示:

您选择的工作负载选项与安装的项目类型直接相关。在“在线”选项卡下,您将找到在安装 Visual Studio 时未安装的项目。Visual Studio 提供了许多项目模板,这就是为什么它们不会一次全部安装的原因。

现在,如果我们展开“已安装”选项卡,我们将看到不同的编程语言显示为子选项卡:Visual C#、Visual Basic、Visual C++等。由于本书涉及 C#,我们将只关注 Visual C#区域,如下面的屏幕截图所示:

如果我们展开 Visual C#选项卡,我们将看到与更具体类型的项目相关的更多选项卡,例如 Windows 桌面、Web、.NET Core、测试等。但是,如果我们专注于窗口的中间部分,我们将看到不同的项目模板,例如 Windows 窗体应用程序(.NET Framework)、控制台应用程序(.NET Core)、控制台应用程序(.NET Framework)、类库(.NET 标准)、类库(.NET Framework)、ASP .NET Core Web 应用程序、ASP.NET Web 应用程序(.NET Framework)等。在窗口的右侧,我们可以看到您在中间窗格中选择的项目模板的简短描述,如下图所示:

让我们来看一下 Visual Studio 2017 中提供的一些最常见的项目模板:

  • 控制台应用程序: 用于创建命令行应用程序的项目。这种类型的项目有两种不同的类型:一个用于.NET Core,另一个用于.NET Framework。

  • 类库: 如果您正在开发可以用作另一个项目的扩展代码的类库项目,则可以使用此模板。在 Visual Studio 2017 中,您再次获得两个选项:一个用于.NET 标准,另一个用于.NET Framework。

  • ASP.NET Core Web 应用程序: 用于使用.NET Core 的 Web 应用程序。您可以使用此类型的项目创建 MVC、Web API 和 SPA 应用程序。

  • ASP.NET Web 应用程序(.NET Framework): 此项目模板用于使用.NET Framework 开发 Web 应用程序。与 ASP.NET Core Web 应用程序模板类似,使用此项目模板,您可以选择 MVC、Web API 或 SPA 项目。

  • WCF 服务器应用程序: 您可以使用此项目类型来创建Windows 通信基础WCF)服务。

  • WPF 应用程序(.NET Framework): 如果您正在创建Windows 演示基础WPF)项目,可以选择此模板。

  • 单元测试项目(.NET Framework): 这是一个用于单元测试的项目。如果您创建此项目,您将获得一个预制的测试类,并且您可以使用它来编写您的单元测试。

还有许多其他可供.NET 开发人员使用的项目模板。如果您确定应用程序的目的,最好从项目模板开始,而不是从空白模板开始。

Visual Studio 编辑器和不同的窗口

Visual Studio 不像简单的文本编辑器。它有许多工具和功能,因此可能有点压倒性。但是,要开始,您不需要了解每个工具和功能:您只需要基础知识。随着您对其了解的增加,您可以充分利用其功能,使您的生活更轻松,提高您的生产力。在本章的后面,我们还将学习一些非常有用的键盘快捷键。我们首先来看一下基础知识。

编辑器窗口

在 Visual Studio 中创建或打开项目后,您将看到一个屏幕,看起来像下面的截图所示,除非您有不同的环境设置。在左侧,显示代码的窗口称为编辑器窗口。这是您将编写代码的窗口。这个编辑器窗口非常智能;当文件在编辑器中打开时,它会出现在左上角。如果有多个文件打开,活动文件将具有蓝色背景,而非活动文件将是黑色,如下图所示:

行号显示在每行代码的左侧,代码以不同的颜色表示。蓝色的单词是 C#中的保留关键字,白色的文本是您的活动可修改的代码,绿色的文本表示类名,橙色的文本表示字符串文本。Visual Studio 中还有一些其他颜色、下划线标记和符号可帮助您更好地理解代码。如果您正在阅读本书的黑白副本,我建议您打开 Visual Studio 并编写代码以检查颜色表示。例如,看看以下屏幕截图中的using语句。除了System命名空间外,所有其他命名空间都是较暗的颜色,这意味着这些命名空间在此文件中尚未使用。System命名空间是明亮的白色,因为我们在代码中使用了Console.WriteLine()方法,该方法属于System命名空间。您还可以看到代码左侧带有-符号的方框,下面有一条水平线。这显示了代码折叠选项。

您可以轻松折叠代码以更清晰地查看特定代码:

从左花括号到右花括号的虚线显示了括号覆盖的区域。因此,即使您没有将左右花括号放在同一垂直线上,您也能够看到这些花括号覆盖的行,如下面的屏幕截图所示:

编辑器窗口还有一些其他有用的功能,如智能感知重构。智能感知在编写代码时建议其他选项或组件的更多细节,包括代码完成、有关代码的信息、代码的使用和代码要求。例如,如果您正在编写Console,它将建议您可能想要编写的不同选项,并告诉您该特定代码的作用以及如何使用它,如下面的屏幕截图所示。在学习不同方法及其用法时,这非常有帮助:

不同的控制台方法

重构意味着改进代码而不改变其功能。本章后面,我们将详细讨论重构。

编辑器窗口中另一个非常有趣的功能是快速操作,它是所选代码行左侧的灯泡。它建议 Visual Studio 认为您应该更改有关该特定代码行的内容。您还可以使用此功能重构代码。例如,如果我们在编写Console的过程中停下来看看灯泡,它将在灯泡底部显示一个红色叉,这意味着这行代码无效,Visual Studio 有一些建议。让我们看看它推荐了什么,以及我们是否可以使用它来修复我们的代码。

如果我们点击气泡,它将显示您在以下屏幕截图中可以看到的选项。从那里,将“Conso”更改为“Console”是我们要执行的选项。如果我们点击它,Visual Studio 将为您修复代码:

让我们看看如何使用快速操作重构我们的代码。如果我们尝试创建一个在代码库中不存在的类的对象,它会显示一个带有红色叉的气泡。如果您查看选项,您会看到 Visual Studio 正在询问是否应该为您创建一个类,如下面的屏幕截图所示:

编辑器窗口中还有许多其他功能可使您作为开发人员的生活更加高效。我建议您尝试更多这些功能,并阅读更多文档以了解更多。

解决方案资源管理器

如果您看一下 Visual Studio 右侧,您将看到一个名为 Solution Explorer 的窗口。这是 Visual Studio 中非常重要的窗口;它显示了您正在工作的解决方案中的文件和文件夹。在 Visual Studio 中,解决方案就像是不同项目的包装器。这个术语可能有点令人困惑,因为我们通常会使用“项目”这个词来标识特定的工作。在 Visual Studio 中,解决方案被创建为包装器,项目被创建在解决方案中。一个解决方案中可以有多个项目。这种分解有助于创建模块化应用程序。在这个 Solution Explorer 窗口中,您可以看到解决方案中有哪些项目,项目中有哪些文件。

您可以展开或最小化项目和文件夹以获得更好的视图,如下面的屏幕截图所示:

在上面的屏幕截图中,您可以看到我们有一个名为 ExploreVS 的解决方案,里面有一个名为 ExploreVS 的项目。这里项目和解决方案的名称相同,因为在创建解决方案时,我们选择使用相同的名称。如果需要,您可以为解决方案和项目使用不同的名称。

在 Solution Explorer 窗口中,您可以右键单击解决方案并轻松添加另一个项目。如果要将文件或文件夹添加到项目中,可以右键单击并添加。在下面的屏幕截图中,您可以看到我们已经将另一个名为 TestApp 的项目添加到解决方案中,以及在 ExploreVS 项目中添加了一个名为 Person 的类。您还可以看到解决方案名称旁边包含的项目数量。Solution Explorer 中还有一个搜索选项,可以在大型解决方案中轻松搜索文件,以及一些其他功能隐藏在顶部的图标后面。圆形箭头刷新 Solution Explorer。其旁边的堆叠框折叠项目以获得解决方案的高级视图。之后,具有三个文档的图标显示 Solution Explorer 中的所有文档。这是必要的,因为并非每个文件都始终可供查看,Visual Studio 给我们提供了将文件从解决方案中排除的选项。这不会从文件系统中删除文件,而只是在解决方案中忽略它。然后,在该图标旁边,我们有一个查看代码的图标,它将在代码编辑器中打开代码。我们还有一个属性图标,它将显示文件或项目的属性。

在左侧,我们有主页图标,它将带您到主页面板。旁边是解决方案和文件夹切换器。如果单击它,您将看到文件系统的文件夹,而不是解决方案,如下面的屏幕截图所示:

输出窗口

输出窗口对于开发人员来说是非常重要的窗口,因为所有构建和调试的日志和输出都可以在这里查看。如果构建应用程序失败,您可以使用输出窗口找出问题所在并解决问题。如果构建成功运行,您将在输出窗口中收到构建成功的消息,如下面的屏幕截图所示:

您可以在此窗口中查看不同类型的日志,例如版本控制日志。要更改选项,请转到“显示输出来源”旁边的下拉菜单,并查看特定输出的日志。您可以通过单击具有水平线和红色叉的图标来清除日志,并使用下一个图标切换换行功能。

调试窗口

调试是软件开发的非常重要的部分。当您编写一些代码时,很有可能您的代码不会第一次构建。即使它构建了,您可能也得不到预期的结果。这就是调试派上用场的地方。如果您使用文本编辑器,调试一些代码可能会很困难,因为普通的文本编辑器不提供任何调试工具,因此您可能需要使用控制台。然而,Visual Studio 为调试提供了一些出色的工具和功能,这可以让您的工作效率大大提高。要找到这些工具,请从 Visual Studio 菜单栏中转到“调试”菜单,然后单击“窗口”,如下面的屏幕截图所示:

从此列表中,我们可以看到不同的窗口如下:

  • 断点

  • 异常设置

  • 输出

  • 显示诊断工具

  • 立即

  • Python 调试交互

断点窗口

断点窗口列出了您在代码库中放置的断点。它显示有关标签、条件、过滤器、文件名、函数名和代码库中的其他属性的信息,如下面的屏幕截图所示:

如果您不了解断点的标签、条件和操作,让我们简要地看一下它们的列表:

  • 标签:您可以为断点命名或给断点添加标签,以便轻松识别其目的。您可以右键单击断点,然后选择“编辑标签”以添加标签或从以前的标签中选择,如下面的屏幕截图所示:

  • 条件:您可以在断点上设置条件。这意味着只有在这些条件为真时,断点才会停止。要向断点添加条件,请右键单击断点,然后单击“条件”,如下面的屏幕截图所示:

  • 操作:与条件一样,您可以向断点添加操作。操作的一个示例可能是在日志系统或控制台中写入。

断点窗口还具有一些其他功能。您可以删除解决方案的所有断点,禁用或启用断点,导入或导出断点,转到断点的代码位置,或搜索断点。

异常设置

异常设置窗口显示可用的不同异常。如果打开窗口,您将看到异常列表和每个项目旁边的复选框。如果要在 Visual Studio 中使调试器中断该异常,请选中复选框,如下面的代码所示:

输出

我们已经在前一节讨论了输出窗口。您可以在输出窗口中输出不同的值,以检查它们是否正确。您可以在输出窗口中读取有关异常的信息,以了解更多关于异常的信息,如下面的屏幕截图所示:

诊断工具

诊断工具窗口将显示应用程序的性能。您可以检查它使用了多少内存和 CPU,以及其他一些与性能相关的数字,如下面的屏幕截图所示:

立即窗口

立即窗口可帮助您在运行应用程序时调试变量、方法和其他代码短语的值。您可以手动检查运行程序的某一点上不同变量的值。您可以通过在此窗口中执行方法来检查方法的返回值。在下面的屏幕截图中,您可以看到我们将值1设置为名为xint变量。然后,我们执行一个名为Add(x,5)的方法,该方法返回两个数字的和。在这里,我们将x5作为参数传递,并得到6作为返回值:

Python 调试器窗口

使用 Python 调试器窗口,您可以在 Visual Studio 中运行您正在工作的应用程序上的 Python 脚本。由于本书与 Python 编程语言无关,我们不会详细讨论此窗口。

断点、调用堆栈跟踪和监视

在前一节中,我们看了在 Visual Studio 中用于调试的窗口。现在我们将详细看一些很酷的功能——断点、调用堆栈跟踪和监视。

断点

断点不是 C#编程语言的功能,而是 Visual Studio 自带的调试器的功能。断点是您想要暂停调试器以检查代码的代码中的一个位置。在 Visual Studio 中,断点可以在代码编辑器窗口的左侧窗格中找到。要添加断点,请单击适当的代码行,将出现一个代表断点的红色球。您还可以使用F9键(或功能 9 键)作为切换断点的键盘快捷键。

下面的屏幕截图显示了 Visual Studio 中断点的外观:

在您设置断点之后,调试器将在该位置暂停,并为您提供查看数据的选项。当调试器在断点处暂停时,您可以选择 Step Into、Step Over 或 Step Out 来浏览代码,如顶部栏中的箭头所示。在圆圈中,您将看到一个箭头指示调试器当前指向的位置,如下面的屏幕截图所示:

断点的主要目的是检查数据,并查看特定代码在运行时的反应。Visual Studio 提供了一种非常简单的方法来使用断点调试代码。

调用堆栈跟踪

调用堆栈是调试应用程序时非常有用的窗口。它显示应用程序的流程,并告诉您已调用哪些方法以达到某一点。例如,如果您有一个可以由两个不同来源调用的方法,那么通过查看调用堆栈,您可以轻松地确定哪个来源调用了该方法,并更好地了解程序流程。

监视窗口

监视窗口是 Visual Studio 中调试的另一个非常有用的功能。在您的代码库中,您可能会遇到需要检查特定变量值的情况。每次悬停查看值都很耗时。相反,您可以将这些变量添加到监视列表中,并在 Visual Studio 中保持监视窗口打开,以查看这些变量在那一刻的值。

在下面的屏幕截图中,您可以看到监视窗口是如何用来监视变量值的:

Visual Studio 中的 Git

版本控制现在是软件开发的必要部分。无论项目大小如何,版本控制对每个软件应用程序都是必不可少的。有许多版本控制系统可用,但 Git 是最流行的。对于远程存储库,您可以使用 Microsoft Team Foundation Server、Microsoft Azure、GitHub 或任何其他远程存储库。由于 GitHub 也是最受欢迎的远程存储库,我们将在本节中看一下如何将其与 Visual Studio 集成。

目前,默认情况下,Visual Studio 没有与 GitHub 连接的功能,因此您必须使用扩展。要获取扩展,转到工具|扩展和更新。然后,在在线类别中搜索 GitHub。您将看到一个名为 Github Extension for Visual Studio 的扩展,如下面的屏幕截图所示。安装扩展并重新启动 Visual Studio:

现在,如果你打开 Team Explorer 窗口,你可以看到 GitHub 的一个部分。输入你的 GitHub 凭据并连接,如下截图所示。连接确认后,你就可以通过 Visual Studio 与 GitHub 进行通信了:

你可以从 Visual Studio 创建或克隆存储库,并继续提交代码并将其推送到 GitHub 的远程存储库。你还可以在 Visual Studio 中执行所有主要的 Git 任务。你可以创建分支,推送和拉取代码,并发送拉取请求。

下面的截图显示了 Visual Studio Team Explorer 窗口中的 Git 面板:

能够使用 IDE 处理版本控制而无需使用任何外部软件非常有用。你也不需要使用 CLI 进行版本控制。

重构和代码优化技术

如果你不了解重构的概念,我建议你进行进一步的研究;这是一个非常有趣的话题,对于软件开发的质量至关重要。基本上,重构是指修改现有代码以改进代码而不改变其功能的过程。

Visual Studio 提供了一些出色的重构功能和工具。我们将在接下来的部分中看一些这些功能。

重命名

你可以使用 Visual Studio 的重命名功能来更改方法、字段、属性、类或其他任何内容的名称,如下截图所示。要做到这一点,选中实体,然后按两次Ctrl + R。或者,转到编辑|重构|重命名。通过这种方式更改名称后,它将在使用的任何地方更新。这个简单的重构步骤允许你随时更改名称:

更改方法签名

假设你有一个在解决方案中多处使用的方法。现在,如果你更改该方法的参数,你的代码将在你修复每处使用该方法之前都会出错。手动操作这样做很耗时,而且很可能会产生错误。Visual Studio 提供了一个重构功能,可以用来在代码中使用的地方重构方法签名,如下截图所示。

如果你想要更改方法中的参数顺序,你可以使用Ctrl + RCtrl + O,或者点击编辑|重构|重新排序参数。要从方法中删除参数,你可以使用Ctrl + RCtrl + V,或者点击编辑|重构|删除参数:

建议始终使用 Visual Studio 重构工具,而不是手动重构。

封装字段

你可以使用 Visual Studio 重构工具将字段转换为属性,而不是手动操作。选中字段,然后按Ctrl + RCtrl + E,或者转到编辑|重构|封装字段。

这将更改代码中使用变量的所有位置,如下截图所示:

提取方法

如果你看到一段代码,认为它应该在一个方法中,你可以使用提取方法重构来提取选定的代码,并为其创建一个新的方法,如下截图所示。重构工具非常智能,它还可以确定方法是否应该返回特定的值。要做到这一点,选择要提取到方法中的代码,然后按下Ctrl + RCtrl + M,或者转到编辑|重构|提取方法:

Visual Studio 中还有许多其他重构功能。这里不可能覆盖所有内容;我建议你查看 Visual Studio 文档以获取更多信息。

摘要

Visual Studio 是 C#开发人员的必备工具;正确理解它将提高您的生产力。在本章中,我们讨论了与 Visual Studio 相关的各种概念,包括其项目和模板,不同的编辑器和窗口,以及其调试功能。我们还研究了断点、调用堆栈跟踪和监视窗口,以及如何利用这些来优化调试过程。之后,我们探讨了 Git 和 GitHub 与 Visual Studio 的集成。最后,我们谈到了 Visual Studio 中可用的不同重构功能。在一本书的一章中很难涵盖与这样一个非凡的集成开发环境相关的所有概念;我建议您尝试使用它并进一步探索,以便学会如何以最佳方式使用它。在下一章中,我们将讨论数据库和 ADO.NET。

第十章:使用示例探索 ADO.NET

如果您有任何与 Web 开发的经验,您可能听说过 ASP.NET,这是一个用于 Web 开发的框架。同样,如果您以前在.NET 项目中使用过数据库,您应该听说过或使用过 ADO.NET。ADO.NET 是一个类似于 ASP.NET 的框架,但是与 Web 开发不同,这个框架用于与数据库相关的工作。ActiveX Data ObjectADO)是微软创建的一个旧技术,但是演变为 ADO.NET 是非凡的。ADO.NET 包含可以用于轻松与数据库管理系统(如 SQL Server 或 Oracle)建立连接的类和方法。不仅如此,它还提供了帮助在数据库中执行命令的方法和对象,比如 select、insert、update 和 delete。

我们需要一个单独的框架来进行数据库连接和活动,因为在开发应用程序时可以使用许多不同的数据库系统。数据库是应用程序的一个非常重要的部分;应用程序需要数据,数据需要存储在数据库中。由于数据库如此重要且有如此多的数据库可用,开发人员要编写所有必要的代码将会非常困难。当我们可以编写可重用的代码时,写入单独的代码片段是不值得的。这就是为什么微软推出了 ADO.NET 框架。这个框架有不同的数据提供程序、数据集、数据适配器和与数据库相关的各种其他东西。

本章将涵盖以下主题:

  • ADO.NET 的基础知识

  • DataProviderConnection、Command、DataReaderDataAdapter

  • 连接 SQL Server 数据库和 Oracle 数据库

  • 存储过程

  • 实体框架

  • SQL 中的事务

ADO.NET 的基础知识

要了解 ADO.NET,我们需要知道应用程序如何与数据库交互。然后,我们需要了解 ADO.NET 如何支持这个过程。让我们先学习一些重要的概念。

数据提供程序

ADO.NET 中有不同类型的数据提供程序。最流行的数据提供程序是 SQL Server、Open Database ConnectivityODBC)、Object Linking and Embedding DatabaseOLE DB)和Java Database ConnectivityJDBC)。这些数据提供程序具有非常相似的代码结构,这使得开发人员的生活变得更加轻松。如果您以前使用过其中一个,您将能够在不太困难的情况下使用其他任何一个。这些数据提供程序可以分为不同的组件:连接、命令、DataReader 和 DataAdapter。

连接对象

连接是一个组件,用于与数据库建立连接以在数据库上执行命令。无论您想连接哪个数据库,都可以使用 ADO.NET。即使没有特定的数据提供程序用于特定的数据库,您也可以使用 OLE DB 数据提供程序与任何数据库连接。这个连接对象有一个名为connectionstring的属性。这是连接的最重要的元素之一。connection字符串是一个包含数据的键值对的字符串。例如,connection字符串包含有关数据库所在服务器、数据库名称、用户凭据以及一些其他信息。如果数据库在同一台计算机上,您必须使用localhost作为服务器。ConnectionString包含数据库名称和授权数据,例如访问数据库所需的用户名和密码。让我们看一个 SQL Server 的connectionString的例子:

SqlConnection con = new SqlConnection();
Con.connectionString = "Data Source=localhost; database=testdb; Integrated Security=SSPI";

在这里,Data Source是服务器名称,因为数据库位于同一台计算机中。connection字符串中的database关键字保存了数据库的名称,在这个例子中是testdb。您会在一些connection字符串中看到Initial Catalog而不是connection字符串中的database关键字用于存储数据库的名称。您可以在connection字符串中使用Initial Catalogdatabase来指定数据库的名称。我们在这里的connectionString属性的最后一部分是Integrated Security,它用作身份验证。如果将其设置为TRUESSPI,这意味着您正在指示程序使用 Windows 身份验证来访问数据库。如果您有特定的数据库用户要使用,您可以通过在connection字符串中添加user关键字和password关键字来指定。您还可以提供一些其他数据,包括连接超时和连接超时。这个connection字符串包含了所需的最少信息。

Command 对象

Command 对象用于向数据库发出指令。每个数据提供程序都有其自己的command对象,该对象继承自DbCommand对象。SQL 数据提供程序中的command对象是SqlCommand,而 OLE DB 提供程序具有OleDbCommand对象。命令对象用于执行任何类型的 SQL 语句,如SELECTUPDATEINSERTDELETE。命令对象还可以执行存储过程。稍后在使用存储过程部分,我们将看看如何做到这一点。它们还有一些方法,用于让编译器知道我们正在执行的命令类型。例如,ExecuteReader方法在数据库中查询并返回一个DataReader对象:

using System.Data.SqlClient;
using System;
using System.Data;

public class Program
{
    public static void Main()
    {
        string connectionString = "Data source = localhost;Initial Catalog=  TestDBForBook;Integrated Security = SSPI;";
        SqlConnection conn = new SqlConnection(connectionString);
        string sql = "SELECT * FROM Person";
        SqlCommand command = new SqlCommand(sql, conn);
        conn.Open();
        SqlDataReader reader = command.ExecuteReader();
        while (reader.Read())
        {
            Console.WriteLine("FirstName " + reader[1] + " LastName " +  reader[2]);
        }
        conn.Close();
    }
}

输出如下:

数据库表如下所示:

ExecuteNonQuery是另一个主要用于执行非查询方法的方法,例如INSERTUPDATEDELETE。当您向数据库中插入一些数据时,您不会在数据库中查询任何内容,您只是想要插入数据。更新和删除也是一样。ExecuteNonQuery方法返回一个INT值,表示命令影响了数据库中多少行。例如,如果您在Person表中插入一个人,您将在表中插入一行新数据,因此只有一行受到影响。该方法将因此向您返回1

让我们看看ExecuteNonQuery()方法的示例代码:

using System.Data.SqlClient;
using System;
using System.Data;
public class Program
{
    public static void Main()
    {
        string connectionString = "Data source = localhost;Initial Catalog=  TestDBForBook;Integrated Security = SSPI;";
        SqlConnection conn = new SqlConnection(connectionString);
        string sql = "INSERT INTO Person (FirstName, LastName, Age) VALUES  ('John', 'Nash', 34)";
        SqlCommand command = new SqlCommand(sql, conn);
        conn.Open();
        int rowsAffected = command.ExecuteNonQuery();
        conn.Close();
        Console.WriteLine("Number of rows inserted: " + rowsAffected);
    }
}

输出如下:

假设您想要更新 John Nash 先生的Age。当您执行UPDATE查询时,它将只影响表的一行,因此它将返回1。但是,例如,如果您执行一个条件匹配多个不同行的查询,它将更新所有行并返回受影响的总行数。看看以下示例。在这里,我们有一个Food表,其中有不同的食物项目。每个项目都有一个类别:

在这里,我们可以看到任何食物项目都没有折扣。假设我们现在想要在每个早餐项目上打 5%的折扣。要更改Discount值,您将需要执行UPDATE命令来更新所有行。从表中,我们可以看到表中有两个早餐项目。如果我们运行一个带有条件的UPDATE命令,该条件仅适用于Category= 'Breakfast',它应该影响两行。让我们看看这个过程的 C#代码。我们将在这里使用ExecuteNonQuery命令:

using System.Data.SqlClient;
using System;
using System.Data;
public class Program
{
    public static void Main()
    {
        string connectionString = "Data source = localhost;Initial Catalog=  TestDBForBook;Integrated Security = SSPI;";
        SqlConnection conn = new SqlConnection(connectionString);
        string sql = "UPDATE Food SET Discount = 5 WHERE Category = 'Breakfast'";
        SqlCommand command = new SqlCommand(sql, conn);
        conn.Open();
        int rowsAffected = command.ExecuteNonQuery();
        conn.Close();
        Console.WriteLine("Number of rows inserted: " + rowsAffected);
    }
}

输出如下:

从输出中我们可以看到影响了2行。现在,让我们看看数据库表:

我们可以看到有两行被更改了。

如果您使用ExecuteNonQuery方法执行DELETE命令,它将返回受影响的行数。如果结果为0,这意味着您的命令未成功执行。

SQLCommand对象中还有许多其他方法。ExecuteScalar从查询中返回一个标量值。ExecuteXMLReader返回一个XmlReader对象。还有其他以异步方式工作的方法。所有这些方法的工作方式都类似于这里显示的示例。

命令对象中有一个名为CommandType的属性。CommandType是一个枚举类型,表示命令的提供方式。枚举值为TextStoredProcedureTableDirect。如果选择文本,SQL 命令将直接在数据源中执行为 SQL 查询。在StoredProcedure中,您可以设置参数并执行storedprocedures以在数据库中执行命令。默认情况下,值设置为TEXT。这就是为什么在之前的示例中,我们没有设置CommandType的值。

DataReader 对象

DataReader 对象提供了一种从数据库中读取仅向前流的行的方法。与其他对象一样,DataReader 是数据提供程序的对象。每个数据提供程序都有不同的 DataReader 对象,这些对象继承自DbDataReader。当您执行ExecuteReader命令时,它会返回一个DataReader对象。您可以处理此DataReader对象以收集您查询的数据。如果您正在使用 SQL Server 作为您的数据库,您应该使用SqlDataReader对象。SqlDataReader有一个名为Read()的方法,当您在DataReader对象中有可用数据时,它将返回true。如果SqlDataReader对象中没有数据,Read()方法将返回false。首先检查Read()方法是否为true,然后读取数据是一种常见的做法。以下示例显示了如何使用SqlDataReader

using System.Data.SqlClient;
using System;
using System.Data;

public class Program
{
    public static void Main()
    {
        string connectionString = "Data source = localhost;Initial Catalog=  TestDBForBook;Integrated Security = SSPI;";
        SqlConnection conn = new SqlConnection(connectionString);
        string sql = "SELECT * FROM Person";
        SqlCommand command = new SqlCommand(sql, conn);
        conn.Open();
        SqlDataReader reader = command.ExecuteReader();
        while (reader.Read())
        {
            Console.WriteLine("FirstName " + reader[1] + " LastName " +  reader[2]);
        }
        conn.Close();
    }
}

在这里,command.ExecuteReader()方法返回一个SqlDataReader对象,它保存了查询的结果:

SELECT * FROM Person

首先,我们将返回的对象保存在一个名为reader的变量中,它是SqlDataReader类型。然后,我们检查它的Read()方法是否为true。如果是,我们执行以下语句:

Console.WriteLine("FirstName " + reader[1] + " LastName " +  reader[2]);

在这里,读取器作为一个数组在工作,并且我们按顺序从索引中获取数据库表列的值。正如我们从数据库中的以下表结构中看到的那样,它有四列,Id,FirstName,LastName 和 Age:

这些列将依次映射。reader[0]指的是 Id 列,reader[1]指的是 FirstName 列,依此类推。

我们写的语句将打印出 FirstName 列的值,在那里它会找到reader[1]。然后它将打印出 LastName 列的值,在那里它会找到reader[2]

如果这个数组索引对您来说很困惑,如果您想要更可读性,可以自由地使用命名索引而不是数字:

Console.WriteLine("FirstName " + reader["FirstName"] + " LastName " +  reader["LastName"])

这将打印相同的内容。我们没有使用reader[1],而是写成了reader["FirstName"],这样更清楚地表明我们正在访问哪一列。如果您使用这种方法,请确保名称拼写正确。

DataAdapter

DataAdapter是从数据源读取和使用数据的另一种方式。DataAdapter 为您提供了一种直接将数据存储到数据集中的简单方法。您还可以使用 DataAdapter 将数据从数据集写回数据源。每个提供程序都有自己的 DataAdapter。例如,SQL 数据提供程序有SqlDataAdapter

连接到各种数据库

让我们看一些使用 ADO.NET 连接到不同数据库的示例。如果使用 ADO.NET,您最有可能使用的数据库系统是 SQL Server 数据库,因为在使用 Microsoft 堆栈时这是最佳匹配。但是,如果使用其他源,也不会降低性能或遇到问题。让我们看看如何使用 ADO.NET 连接到其他数据库。

SQL Server

要连接到 SQL Server,我们需要在 ADO.NET 中使用 SQL Server 提供程序。看一下以下代码:

using System.Data.SqlClient;
using System;
using System.Data;
public class Program
{
    public static void Main()
    {
        string connectionString = "Data source = localhost;Initial Catalog= TestDBForBook;Integrated Security = SSPI;";
        SqlConnection conn = new SqlConnection(connectionString);
        string sql = "SELECT * FROM Person";
        SqlCommand command = new SqlCommand(sql, conn);
        conn.Open();
        SqlDataReader reader = command.ExecuteReader();
        while (reader.Read())
        {
            Console.WriteLine("FirstName " + reader["FirstName"] + " LastName " +  reader["LastName"]);
        }
        conn.Close();
    }
}

Oracle 数据库

要连接到 Oracle 数据库,我们需要在 ADO.NET 中使用 ODBC 提供程序。看一下以下代码:

using System.Data.SqlClient;
using System;
using System.Data;
using System.Data.Odbc;
public class Program
{
    public static void Main()
    {
        string connectionString = "Data Source=Oracle9i;User ID=*****;Password=*****;";
        OdbcConnection odbcConnection = new OdbcConnection(connectionString);
        string sql = "SELECT * FROM Person";
        OdbcCommand odbcCommand = new OdbcCommand(sql, odbcConnection);
        odbcConnection.Open();
        OdbcDataReader odbcReader = odbcCommand.ExecuteReader();
        while (odbcReader.Read())
        {
            Console.WriteLine("FirstName " + odbcReader["FirstName"] + " LastName  " + odbcReader["LastName"]);
        }
        odbcConnection.Close();
    }
}

使用 DataReaders 和 DataAdapters

DataReadersDataAdapter是数据提供程序的核心对象。这些是 ADO.NET 提供的一些最重要的功能。让我们看看如何使用这些对象。

DataReaders

每个提供程序都有数据读取器。在底层,所有类都执行相同的操作。SqlDataReaderOdbcDataReaderOleDbDataReader都实现了IDataReader接口。DataReader 的主要用途是在数据来自流时从数据源读取数据。让我们看看数据读取器具有的不同属性:

属性 描述
Depth 行的嵌套深度
FieldCount 返回行中的列数
IsClosed 如果DataReader已关闭,则返回TRUE
Item 返回列的值
RecordsAffected 受影响的行数

DataReader 具有以下方法:

方法 描述
Close 此方法将关闭DataReader对象。
Read 此方法将读取DataReader中的下一个数据片段。
NextResult 此方法将将头移动到下一个结果。
GetStringGetChar GetString方法将以字符串格式返回值。GetChar将以Char格式返回值。还有其他方法将以特定类型返回值。

以下代码片段显示了DataReader的示例:

using System;
using System.Collections.Generic;
using System.Text;
using System.Data.SqlClient;
namespace CommandTypeEnumeration
{
    class Program
    {
        static void Main(string[] args)
        {
            // Create a connection string
            string ConnectionString = "Integrated Security = SSPI; " +
            "Initial Catalog= Northwind; " + " Data source = localhost; ";
            string SQL = "SELECT * FROM Customers";
            // create a connection object
            SqlConnection conn = new SqlConnection(ConnectionString);
            // Create a command object
            SqlCommand cmd = new SqlCommand(SQL, conn);
            conn.Open();
            // Call ExecuteReader to return a DataReader
            SqlDataReader reader = cmd.ExecuteReader();
            Console.WriteLine("customer ID, Contact Name, " + "Contact Title, Address ");
            Console.WriteLine("=============================");
            while (reader.Read())
            {
                Console.Write(reader["CustomerID"].ToString() + ", ");
                Console.Write(reader["ContactName"].ToString() + ", ");
                Console.Write(reader["ContactTitle"].ToString() + ", ");
                Console.WriteLine(reader["Address"].ToString() + ", ");
            }
            //Release resources
            reader.Close();
            conn.Close();
        }
    }
}

DataAdapters

DataAdapters 的工作原理类似于断开连接的 ADO.NET 对象和数据源之间的桥梁。这意味着它们帮助建立连接并在数据库中执行命令。它们还将查询结果映射回断开连接的 ADO.NET 对象。Data Adapters 使用DataSetDataTable在从数据源检索数据后存储数据。DataAdapter有一个名为Fill()的方法,它从数据源收集数据并填充DataSetDataTable。如果要检索模式信息,可以使用另一个名为FillSchema()的方法。另一个名为Update()的方法将DataSetDataTable中所做的所有更改传输到数据源。

使用数据适配器的好处之一是不会将有关连接、数据库、表、列或与数据源相关的任何其他信息传递给断开连接的对象。因此,在向外部源传递值时使用是安全的。

使用存储过程

存储过程是存储在数据库中以便重用的 SQL 语句批处理。ADO.NET 支持存储过程,这意味着我们可以使用 ADO.NET 调用数据库中的存储过程并从中获取结果。向存储过程传递参数(可以是输入或输出参数)是非常常见的做法。ADO.NET 命令对象具有参数,这些参数是参数类型的对象。根据提供程序的不同,参数对象会发生变化,但它们都遵循相同的基本原则。让我们看看如何在 ADO.NET 中使用存储过程而不是普通的 SQL 语句。

要使用存储过程,应在SQLCommand中传递的 SQL 字符串应为存储过程的名称:

string ConnectionString = "Integrated Security = SSPI;Initial Catalog=Northwind;Data source=localhost;";
SqlConnection conn = new SqlConnection(ConnectionString);
String sql = “InsertPerson”;
SqlCommand command = new SqlCommand(sql, conn);

我们通常按以下方式向存储过程传递参数:

using System.Data.SqlClient;
using System;
using System.Data;

public class Program
{
    public static void Main()
    {
        string ConnectionString = "Integrated Security = SSPI; Initial Catalog= Northwind; Data source = localhost; ";
        SqlConnection conn = new SqlConnection(ConnectionString);
 String sql = "InsertPerson";
 SqlCommand command = new SqlCommand(sql, conn);
 command.CommandType = CommandType.StoredProcedure;
 SqlParameter param = command.Parameters.Add("@FirstName", SqlDbType.NVarChar, 11);
 param.Value = "Raihan";
 param = command.Parameters.Add("@LastName", SqlDbType.NVarChar, 11);
 param.Value = "Taher";
 conn.Open();
 int rowsAffected = command.ExecuteNonQuery();
 conn.Close();

 Console.WriteLine(rowsAffected);
    }
}

现在让我们看一下存储过程,以了解参数的使用方式:

CREATE procedure InsertPerson (
@FirstName nvarchar (11),
@LastName nvarchar (11)
)
AS
INSERT INTO Person (FirstName, LastName) VALUES (@FirstName, @LastName);
GO

使用 Entity Framework

Entity FrameworkEF)是由 Microsoft 开发的对象关系映射器ORM)框架。它是为.NET 开发人员开发的,以便使用实体对象轻松地与数据库一起工作。它位于后端代码或业务逻辑与数据库之间。它允许开发人员使用应用程序语言 C#编写代码与数据库交互。这意味着不需要手动使用和编写 ADO.NET 代码,而我们在前面的部分中所做的。EF 具有不同类型的命令,用于普通 SQL 命令。EF 命令看起来非常类似于 C#代码,将使用后台的 SQL 与数据库通信。它可以与任何类型的数据源通信,因此您无需担心为每个 DBMS 设置或编写不同的代码。

在 Entity Framework 中,什么是实体?

实体是应用程序域中的一个类,也包括在派生的DbContext类中作为DbSet属性。实体在执行时被转换为表,实体的属性被转换为列:

public class Student{
}

public class StudentClass{
}

public class Teacher{
}

public class SchoolContext : DbContext {
    public SchoolContext(){}
    public DbSet<Student> Students { get; set; }
    public DbSet<StudentClass> StudentClasses { get; set; }
    public DbSet<Teacher> Teachers { get; set; }
}

不同类型的实体属性

让我们看看实体可以具有哪些不同类型的属性:

  • 标量属性

  • 导航属性。这些包括以下内容:

  • 引用导航属性

  • 集合导航属性

标量属性

这些属性直接在数据库中用作列。它们用于在数据库中保存和查询。让我们看一个这些属性的示例:

public class Student{
    public int StudentID { get; set; }
    public string StudentName { get; set; }
    public DateTime? DateOfBirth { get; set; }
    public byte[]  Photo { get; set; }
    public decimal Height { get; set; }
    public float Weight { get; set; }

    public StudentAddress StudentAddress { get; set; }
    public Grade Grade { get; set; }
}

以下属性是标量属性:

public int StudentID { get; set; }
public string StudentName { get; set; }
public DateTime? DateOfBirth { get; set; }
public byte[]  Photo { get; set; }
public decimal Height { get; set; }
public float Weight { get; set; }

导航属性

这种类型的属性表示实体之间的关系。它们与特定列没有直接关联。导航属性有两种类型:

  • 引用导航属性:如果另一个实体类型用作属性,则称为引用导航属性

  • 集合导航属性:如果实体被包括为集合类型,则称为集合导航属性

导航属性的一个示例如下:

public Student Student { get; set; }
public ICollection<Student> Students { get; set; }

在这里,Student是一个引用导航属性,Students是一个集合导航属性。

现在让我们看看使用 EF 的两种方法:代码优先方法数据库优先方法

代码优先方法

这可以被认为类似于领域驱动设计。在这种方法中,您编写实体对象和域,然后使用域使用 EF 生成数据库。使用实体对象中的不同属性,EF 可以理解要对数据库执行的操作以及如何执行。例如,如果您希望模型中的特定属性被视为主键,可以使用数据注释或流畅 API 指示 EF 在创建数据库中的表时将此列视为主键。

数据库优先方法

在这种方法中,您首先创建数据库,然后要求 EF 为您生成实体。您在数据库级别进行所有更改,而不是在后端应用程序中的实体中进行更改。在这里,EF 的工作方式与代码优先方法不同。在数据库优先方法中,EF 通过数据库表和列生成 C#类模型,其中每个列都被视为属性。EF 还负责不同数据库表之间的关系,并在生成的模型中创建相同类型的关系。

使用 Entity Framework

这两种方法都有其好处,但代码优先方法在开发人员中更受欢迎,因为您不必过多处理数据库,而是更多地在 C#中工作。

EF 不会默认随.NET 框架一起提供。您必须从 NuGet 软件包管理器下载库并将其安装在您正在使用的项目中。要下载和安装实体框架,您可以打开 Nuget 软件包管理器控制台并编写以下命令:

Install-Package EntityFramework

此命令将在您的项目中下载并安装 Entity Framework:

如果您不熟悉包管理器控制台,也可以使用 GUI 的解决方案包管理器窗口来安装实体框架。转到浏览选项卡,搜索Entity Framework。您将在搜索结果的顶部看到它。单击它并在您的项目中安装它。

使用 Nuget 包管理器安装 Entity Framework

在本书中,我们更专注于 C#,因此我们将更仔细地看一下代码优先方法,而不是数据库优先方法。在代码优先方法中,由于我们不会触及数据库代码,因此我们需要以一种可以在创建数据库时遵循的方式创建我们的实体对象。在创建了数据库表之后,如果我们想要更新表或更改表,我们需要使用迁移。数据库迁移会创建数据库的新实例,并在新实例中应用新的更改。通过使用迁移,更容易操作数据库。

现在让我们更多地了解一下 EF 的历史和流程。它首次发布于 2008 年,与.NET 3.5 一起。在撰写本书时,EF 的最新版本是版本 6。EF 还有一个称为Entity Framework Core的.NET Core 版本。这两个框架都是开源的。当您在项目中安装实体框架并编写POCO类(Plain Old CLR Object)时,该 POCO 类将被实体框架使用。首先,EF 从中创建Entity Data ModelEDM)。稍后将使用此 EDM 来保存和查询数据库。语言集成查询LINQs)和 SQL 都可以用来向 EF 发出指令。当一个实体对象在 EDM 中使用时,它会被跟踪。当它被更新时,数据库也会被更新。

我们可以使用SaveChanges()方法来执行数据库中的插入、更新和删除操作。对于异步编程,使用SaveChangesAsync()方法。为了获得更好的查询体验,EF 具有一级缓存,因此当执行重复查询时,EF 会从缓存中返回结果,而不是去数据库中收集相同的结果。

EF API 主要做四件事:

  • 将类映射到数据库模式

  • 将 LINQ 转换为实体查询到 SQL 并执行它们

  • 跟踪更改

  • 在数据库中保存更改

EF 将实体对象和上下文类转换为 EDM,并且 EDM 在数据库中使用。例如,假设我们有以下类:

public class Person {
    public int PersonId { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

EF 将其转换为 EDM,如下所示:

Table Name: Person
PersonId(PK,int,not null)
FirstName (nvarchar(50),null)
LastName (nvarchar(50),null)

然后,这个 EDM 将用于创建或更新Person数据库表。

SQL 中的交易

事务是一个单独的工作单元,要么完成整个工作,要么回滚到其先前的状态。事务不能在工作的中间停止。这是一个非常重要的特性,用于处理敏感数据。事务的最佳用途之一是处理转账过程。当一个人向另一个人的账户转账一些钱时,如果在过程中发生任何错误,整个过程应该被取消或回滚。

SQL 中交易的四个属性:原子、一致、隔离和持久ACID)。

原子

原子意味着组中的所有语句必须被执行。如果组中的语句之一没有被执行,那么没有一个语句应该被执行。整个语句组应该作为一个单一单元工作。

一致

当执行事务时,数据库应该从一个状态达到另一个状态。我们称初始点为起点,执行后的点为终点。在事务中,起点和终点应该是清晰的。如果事务成功,数据库状态应该在终点,否则应该在起点。保持这种一致性就是这个属性的作用。

隔离

作为事务一部分的一组语句应该与另一个事务或手动语句中的其他语句隔离开来。当一个事务正在运行时,如果另一个语句改变了特定的数据,整个事务将产生错误的数据。当一个事务运行时,所有其他外部语句都不被允许在数据库中运行在特定的数据上。

持久

一组语句执行后,结果需要存储在一个永久的位置。如果在事务中间发生错误,这些语句可以被回滚,数据库回到之前的位置。

事务在 SQL 中扮演着非常重要的角色,因此 SQL 数据提供程序提供了SQLTransaction类,可以用于使用 ADO.NET 执行事务。

总结

数据是软件应用程序的一个非常重要的部分。为了维护数据,我们需要一种数据库来以结构化的方式存储数据,以便可以轻松地检索、保存、更新和删除数据。我们的软件能够与数据源通信以使用数据是至关重要的。ADO.NET 框架为.NET 开发人员提供了这种功能。学习和理解 ADO.NET 是任何.NET 开发人员的基本要求之一。在本章中,我们涵盖了 ADO.NET 元素的基础知识,如DataProviderConnectionCommandDataReaderDataAdapter。我们还学习了如何使用 ADO.NET 连接到 SQL Server 数据库和 Oracle 数据库。我们讨论了存储过程,并解释了实体框架是什么以及如何使用它。

在下一章中,我们将讨论一个非常有趣的话题:反射。

第十一章:C# 8 的新功能

几十年来,我们见证了各种各样的编程语言的发展。有些现在几乎已经消亡,有些被少数公司使用,而其他一些在市场上占据主导地位多年。C#属于第三类。C#的第一个版本发布于 2000 年。当 C#发布时,许多人说它是 Java 的克隆。然而,随着时间的推移,C#变得更加成熟,并开始占据市场主导地位。这尤其适用于微软技术栈,C#无疑是第一编程语言。随着每一个新版本的发布,微软都引入了令人惊叹的功能,使语言变得非常强大。

在 2018 年底,微软宣布了一些令人兴奋的功能将在 C# 8 中可用。在我写作本书时,C# 8 仍未正式发布,因此我无法保证所有这些功能将在最终版本中可用。然而,这些功能很有可能在最终版本中可用。在本章中,我们将看看这些功能,并试图理解语言如何演变成一个非凡的编程语言。让我们来看看我们将要讨论的功能:

  • 可空引用类型

  • 异步流

  • 范围和索引

  • 接口成员的默认实现

  • Switch 表达式

  • 目标类型的新表达式

环境设置

要执行本章的代码,你需要Visual Studio 2019。在我写作本书时,Visual Studio 2019 尚未正式发布。然而,预览版本已经可用,要执行本章的代码,你至少需要 Visual Studio 2019 预览版。另一件需要记住的事情是,在测试本章的代码时,要创建.NET Core控制台应用程序项目。

要下载 Visual Studio 2019 预览版,请访问此链接:visualstudio.microsoft.com/vs/preview/

Visual Studio 2019 预览下载页面

可空引用类型

如果你在编写 C#代码时曾遇到异常,很可能是空引用异常。空引用异常是程序员在开发应用程序时最常见的异常之一,因此 C#语言开发团队努力使其更易于理解。

在 C#中,有两种类型的数据:值类型引用类型。当你创建值类型时,它们通常有默认值,而引用类型默认为 null。Null 意味着内存地址不指向任何其他内存地址。当程序试图查找引用但找不到时,会抛出异常。作为开发人员,我们希望发布无异常的软件,因此我们尽量在代码中处理所有异常;然而,有时在开发应用程序时很难找到空引用异常。

在 C# 8 中,语言开发团队提出了可空引用类型的概念,这意味着你可以使引用类型可空。如果这样做,编译器将不允许你将 null 赋给非可空引用变量。如果你使用 Visual Studio,当你尝试将 null 值赋给非可空引用变量时,你也会收到警告。

由于这是一个新功能,不在旧版本的 C#中可用。C#编程语言团队提出了通过编写一小段代码来启用该功能的想法,以便旧系统不会崩溃。你可以为整个项目或单个文件启用此功能。

要在代码文件中启用可空引用类型,你必须在源代码顶部放置以下代码:

#nullable enable

让我们看一个可空引用类型的例子:

class Hello {
    public string name;
    name = null;
    Console.WriteLine($"Hello {name}");
}

如果你运行上面的代码,当尝试打印该语句时会得到一个异常。尝试使用以下代码启用可空引用类型:

#nullable enable

class Hello {
    public string name;
    name = null;
    Console.WriteLine($"Hello {name}");
}

上面的代码会显示一个警告,指出名称不能为空。为了使其可行,你必须将代码更改如下:

#nullable enable

class Hello {
    public string? name;
    name = null;
    Console.WriteLine($"Hello {name}");
}

通过将字符串名称更改为nullable,你告诉编译器可以将该字段设置为可空。

异步流

如果你在 C#中使用异步方法,你可能会注意到返回流是不可能的,或者很难通过现有的特性实现。然而,这将是一个有用的特性,可以使开发任务变得更简单。这就是为什么 C# 8 引入了一个新的接口叫做IAsyncEnumerable。通过这个新的接口,可以返回异步数据流。让我再详细解释一下。

在异步流之前,在 C#编程语言中,异步方法不能返回数据流,它只能返回单个值。

让我们看一个不使用异步流的代码示例:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ExploreVS
{
 class Program
 {
 public static void Main(string[] args)
 {
 var numbers = GetNumbersAsync();
 foreach(var n in GetSumOfNums(numbers))
 {
 Console.WriteLine(n);
 }
 Console.ReadKey();
 }
 public static IEnumerable<int> GetNumbersAsync()
 {
 List<int> a = new List<int>();
 a.Add(1);
 a.Add(2);
 a.Add(3);
 a.Add(4);
 return a;
 }
 public static IEnumerable<int> GetSumOfNums(IEnumerable<int> nums)
 {
 var sum = 0;
 foreach(var num in nums)
 {
 sum += num;
 yield return sum;
 }
 }

 }
}

通过异步流,现在可以使用IAsyncEnumerable返回数据流。让我们看一下下面的代码:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ExploreVS
{
 class Program
 {
 public static async void Main(string[] args)
 {
 var numbers = GetNumbersAsync();
 await foreach(var n in GetSumOfNums(numbers))
 {
 Console.WriteLine(n);
 }
 Console.ReadKey();
 }
 public static IEnumerable<int> GetNumbersAsync()
 {
 List<int> a = new List<int>();
 a.Add(1);
 a.Add(2);
 a.Add(3);
 a.Add(4);
 return a;
 }
 public static async IAsyncEnumerable<int> GetSumOfNums(IAsyncEnumerable<int> nums)
 {
 var sum = 0;
 await foreach(var num in nums)
 {
 sum += num;
 yield return sum;
 }
 }

 }
}

从上面的例子中,我们可以看到如何使用 C#的这个新特性来返回异步流。

范围和索引

C# 8 带来了范围,它允许你获取数组或字符串的一部分。在此之前,如果你想要获取数组的前三个数字,你必须遍历数组并使用条件来找出你想要使用的值。让我们看一个例子:

using System;
namespace ConsoleApp6
{
    class Program
    {
        static void Main(string[] args)
        {
            var numbers = new int[] { 1, 2, 3, 4, 5 };
            foreach (var n in numbers)
            {
                if(numbers[3] == n) { break; } 
                Console.WriteLine(n);
            }
            Console.ReadKey();
        }
    }
}

通过范围,你可以轻松地切片数组并获取你想要的值,就像下面的代码所示:

using System;
namespace ConsoleApp6
{
 class Program
 {
 static void Main(string[] args)
 {
 var numbers = new int[] { 1, 2, 3, 4, 5 };
 foreach (var n in numbers[0..3])
 {
 Console.WriteLine(n);
 }
 Console.ReadKey();
 }
 }
}

在上面的例子中,我们可以看到在foreach循环中给出了一个范围([0..3])。这意味着我们应该只取数组中索引 0 到索引 3 的值。

还有其他切片数组的方法。你可以使用^来表示索引应该向后取值。例如,如果你想要获取从第二个元素到倒数第二个元素的值,你可以使用[1..¹]。如果你应用这个范围,你将得到2, 3, 4

让我们看一下下面的代码中范围的使用:

using System;
namespace ConsoleApp6
{
 class Program
 {
 static void Main(string[] args)
 {
 var numbers = new int[] { 1, 2, 3, 4, 5 };
 foreach (var n in numbers[1..¹])
 {
 Console.WriteLine(n);
 }
 Console.ReadKey();
 }
 }
}

当运行上面的代码时,你需要在你的项目中使用一个特殊的 Nuget 包。这个包的名称是Sdcb.System.Range。要安装这个包,你可以在 Visual Studio 中的 Nuget 包管理器中安装它。

安装 Sdcb.System.Range Nuget 包

如果你仍然遇到构建错误,有可能是你的项目仍在使用 C# 7,要升级到 C# 8,你可以将鼠标悬停在被红色下划线标记的地方,然后点击弹出的灯泡。然后,Visual Studio 会询问你是否要在你的项目中使用 C# 8。你需要点击“将此项目升级到 C#语言版本'8.0 beta'”。这将把你的项目从 C# 7 升级到 C# 8,然后你就可以运行你的代码了。

图:将项目升级到 C# 8

接口成员的默认实现

我们都知道,在 C#中,接口没有任何方法实现;它们只包含方法签名。然而,在 C# 8 中,接口允许有实现的方法。如果需要,这些方法可以被类重写。接口方法也可以访问修饰符,比如 public、virtual、protected 或 internal。默认情况下,访问级别被设置为 virtual,除非它被固定为 sealed 或 private。

还有一件重要的事情要注意。接口中还不允许使用属性或字段。这意味着接口方法不能在方法中使用任何实例字段。接口方法可以接受参数作为输入并使用它们,但不能使用实例变量。让我们看一个接口方法的例子:

using System;
namespace ConsoleApp7
{
 class Program
 {
 static void Main(string[] args)
 {
 IPerson person = new Person();
 person.PrintName("John", "Nash");
 Console.ReadKey();
 }
 }
 public class Person : IPerson
 {
 }
 public interface IPerson
 {
 public void PrintName(string FirstName, string LastName)
 {
 Console.WriteLine($"{FirstName} {LastName}");
 }
 }
}

在撰写本书时,这个功能在 C# 8 预览版本中还不可用。这仍然被标记为一个拟议的功能,但希望它会在最终发布中实现。因此,即使您使用 Visual Studio 2019 预览版本,上面给出的代码可能也无法工作。

Switch 表达式

多年来,我们一直在使用 switch 语句。每当我们想到或听到 switch 时,我们会想到 case 和 break。然而,C# 8 将通过引入 switch 表达式来迫使我们改变这种思维方式。这意味着 switch 语句将不再与过去一样。

让我们看看我们以前的switch语句是什么样子的:

using System;
namespace ConsoleApp7
{
    class Program
    {
        static void Main(string[] args)
        {
            string person = "nash";
            switch (person)
            {
                case "john":
                    Console.WriteLine("Hi from john!");
                    break;
                case "smith":
                    Console.WriteLine("Hi from smith!");
                    break;
                case "nash":
                    Console.WriteLine("Hi from nash!");
                    break;
                case "harrold":
                    Console.WriteLine("Hi from harrold!");
                    break;
                default:
                    Console.WriteLine("Hi from None!");
                    break;
            }
            Console.ReadKey();
        }
    }
}

通过新的方法,我们不会在switch后面将person放在括号中,而是将switch放在person变量的右侧,不需要case关键字。让我们看看我们如何以新的方式使用switch表达式:

{
 "john" j => Console.WriteLine("Hi from john!"),
 "smith" s => Console.WriteLine("Hi from smith!"),
 "nash" n => Console.WriteLine("Hi from nash!"),
 "harrold" h => Console.WriteLine("Hi from harrold!"),
 _ => Console.WriteLine("Hi from None!")
};

在这里,我们还可以看到,对于默认情况,我们只使用下划线(_)。

目标类型的新表达式

在 C# 8 中,另一个新功能是目标类型的新表达式。这个功能将使代码赋值更加清晰。让我们从一个示例代码开始,我们在其中创建了一个带有值的字典:

person switch
Dictionary<string, List<int>> student = new Dictionary<string, List<int>> {
   { "john", new List<int>() { 98, 75 } }
};

有了目标类型的新表达式,前面的代码可以这样写:

Dictionary<string, List<int>> student = new() {
   { "john", new() { 98, 75 } }
};

当您放置new()时,变量将采用左侧的类型并创建一个新的实例。

总结

每当微软宣布推出新版本的 C#编程语言时,我都会对他们带来了什么感到兴奋,而每一次,我都对结果感到印象深刻。C# 8 也不例外。特别是可空引用类型是一个令人惊叹的功能,因为它可以防止一个非常常见的异常。异步流是另一个很棒的功能,特别适用于物联网的开发。范围、接口成员、switch 表达式以及其他所有的新增功能都是朝着重大进步迈出的小步。这些新功能使开发人员的生活变得更加轻松,并通过减少软件崩溃为企业带来了好处。在下一章中,我们将讨论设计原则和不同的设计模式。

第十二章:理解设计模式和原则

多年来,软件变得越来越复杂。现在,软件不仅用于数学计算或简单的创建、读取、更新和删除(CRUD)操作:我们正在使用它来执行复杂的任务,如控制火箭发动机或每天管理大量数据。来自各行各业的企业已经开始采用软件系统,包括银行、保险公司、研究机构、教育机构和政府机构。对软件的需求越高,越多的人开始在软件开发领域建立职业。从汇编语言编程开始,经过过程式编程,然后引入了面向对象编程(OOP)时代,尽管出现了其他类型的编程,如函数式编程,但 OOP 仍然是最受欢迎的模型。OOP 帮助开发人员编写良好的、模块化的软件,易于维护和扩展。在本章中,我们将讨论一些最重要的设计原则和模式,这些原则和模式被成千上万的开发人员遵循,我们将涵盖以下主题:

  • 软件开发中的设计原则

  • 软件开发中的不同设计模式

  • 创建设计模式

  • 行为设计模式

  • 结构设计模式

  • 模型-视图-控制器(MVC)模式

设计原则

在我们开始讨论设计原则之前,让我们思考一下在软件开发中我们所说的设计原则是什么意思。当我们开发软件时,我们首先设计其架构,然后开始编写其代码。我们希望以这样的方式编写我们的代码,使其不会产生错误,或者如果有错误,很容易找到。当我们阅读代码时,我们也希望代码易于理解,并且希望它的结构能够在以后需要时进行更改。虽然编写最佳代码是困难的,但有许多在软件开发中由经验丰富的计算机科学家制定的原则。使用这些原则,开发人员可以编写非常干净的代码。

软件开发人员罗伯特·C·马丁,也被称为 Uncle Bob,提出了五个软件设计原则。这些原则对开发人员非常有效和有帮助,以至于它们已经成为软件行业的一种规范。它们统称为 SOLID 原则,代表以下不同的定义:

  • 代表单一职责原则

  • 代表开闭原则

  • 代表里氏替换原则

  • 代表接口隔离原则

  • 代表依赖反转原则

让我们逐一讨论这些原则。

单一职责原则

"一个类应该只有一个改变的原因。"

罗伯特·C·马丁

这意味着当我们编写一个类时,我们应该以只有一个职责的方式设计它。你应该只需要为一个原因更改类。如果你有多个原因更改类,那么它违反了单一职责原则。

如果一个类有多个职责,并且你对一段代码进行了更改,这可能会破坏另一段代码,因为它们在同一个类中并共享一些依赖关系。你的代码可能并不是非常解耦的。

开闭原则

代码需要以这样的方式编写,即在软件实体(如类、模块或函数)中添加新内容是好的,但不应允许修改实体本身。这减少了产生错误的可能性。

里氏替换原则

"派生类型必须完全可替代其基本类型。"

芭芭拉·里斯科夫

这个原则规定,当你编写一个类时,如果它是从另一个类派生的,它应该可以被基类替换。否则,你的代码将非常脆弱和耦合。这个原则是由芭芭拉·利斯科夫首次发现的,因此以她的名字命名。

接口隔离原则

有时,开发人员会创建包含过多信息的大接口。许多类可能会使用这个接口,但它们可能并不需要其中的所有内容。这就是你应该避免的,以便遵循这个原则。这个原则支持小接口而不是大接口,如果必要,一个类可以继承多个适用于该类的小接口。

依赖反转原则

"高层模块不应该依赖于低层模块;两者都应该依赖于抽象。抽象不应该依赖于细节。细节应该依赖于抽象"

  • 罗伯特·C·马丁

我们知道,在软件开发中,我们使用层。为了使这些层解耦,我们必须以这样一种方式设计这些层的依赖关系,即这些层不应该相互依赖,而应该依赖于抽象。因此,如果你改变高层模块或低层模块中的某些东西,它不会伤害系统。当我们创建这些抽象时,我们必须以这样一种方式设计它们,即它们不依赖于实现细节。这些抽象应该是独立的,而实现这些接口或抽象类的类应该依赖于这些抽象。

创建型设计模式

在面向对象编程中,所有的东西都被视为对象,跟踪对象的创建和管理方式非常重要。如果开发人员不太关注这个话题,软件的对象可能会使软件变得脆弱和耦合。保持对象适当地以保持应用程序易于扩展非常重要。创建型设计模式是帮助以避免对象创建方面的最常见问题的模式。

在创建型设计模式中存在两个主要概念:

  • 封装关于系统使用的具体类的知识

  • 隐藏创建和组合具体类的实例

创建型设计模式分为对象创建模式和类创建模式,其中对象创建模式处理对象的创建,类创建模式处理类的发现。

行业中有五种主要的创建型设计模式:

  • 抽象工厂模式

  • 建造者模式

  • 工厂方法模式

  • 原型模式

  • 单例模式

抽象工厂模式

《设计模式:可复用面向对象软件的元素》一书中对这个模式的定义是提供一种组合来构建类似或相关对象族,而不指定它们的具体类。

这个模式提供的最重要的东西是对象创建的分离或抽象。如果你不遵循任何模式,当你创建一个对象时,最简单的方法就是在需要的地方使用new关键字创建一个对象。例如,如果我在我的Bank类中需要一个Person对象,最简单的方法就是在Bank类中使用new关键字实例化一个Person对象。然而,使用这种方法有时会使软件变得复杂。为了避免这种情况,我们可以使用抽象工厂模式。

抽象工厂模式主要用于具有相同家族的对象,或者以某种方式相关或依赖的情况。其思想是创建工厂类来执行对象的创建工作。如果一个对象 A 需要另一个对象 B 的实例,对象 A 应该要求对象 B 的工厂创建一个 B 对象并将其传递给对象 A。这样,对象 A 独立于对象 B 的创建。在抽象工厂模式中,还有另一层抽象。工厂类也被抽象化了。这意味着对象 A 不会直接调用对象 B 的工厂,而是使用一个抽象。应该有一个机制来确定需要调用哪个工厂类。这意味着对象 A 不依赖于另一个对象的任何特定工厂。

建造者模式

将复杂对象的计划与其实现分离是建造者模式的主要思想。在面向对象的软件开发中,我们有时需要创建相当复杂的对象。例如,我们可能创建一个使用其他对象的对象,而这些对象又使用其他对象。当你只需要该对象执行另一种工作时,创建或实例化这种对象可能会很困难。这也可能使代码更复杂,降低其可读性。

让我们想想一个例子。想象一下,你正在制作一些汉堡包,其中一些是鸡肉汉堡,另一些是牛肉汉堡。在创建鸡肉汉堡对象时,每次都必须创建鸡肉汉堡肉饼对象、番茄酱对象、奶酪对象和面包对象,这导致代码混乱。创建牛肉汉堡对象时也必须遵循相同的过程。这是一种处理和创建这些对象的非常复杂的方式。

建造者模式提供了一种解决这种复杂性的好方法。使用这种模式,我们创建一个称为 Builder 的类,其主要任务是创建复杂对象并返回新创建的对象。使用建造者模式,我们使用另一种类型的类,通常称为 director 类。这个类的任务是调用 Builder 类并从中获取对象。

让我们回到我们的汉堡例子。我们可以有一个 ChickenBurgerBuilder 类和一个 BeefBurgerBuilder 类。它们将在类中设置汉堡肉饼、面包、番茄酱和奶酪。当 BurgerDirector 类想要创建一个鸡肉汉堡时,它将调用 ChickenBurgerBuilder。要创建一个牛肉汉堡,它将调用 BeefBurgerBuilder。创建汉堡肉饼和其他配料的复杂性将由 Builder 类处理。

工厂方法模式

工厂方法模式与抽象工厂模式非常相似。不同之处在于,在工厂方法模式中,工厂层不是抽象的。使用这种模式意味着你将创建一个处理实现相同抽象的类的创建的工厂类。这意味着,如果有一个由许多子类定义的接口,Factory 类可以根据传递给 Factory 的逻辑创建任何这些子类中的任何一个。

让我们想一个例子。我们将使用工厂方法模式来解决我们在“生成器模式”示例中提到的制作汉堡的问题。我们将创建一个名为BurgerFactoryFactory,它将接受一个输入,比如typeOfBurger(鸡肉或牛肉)。然后,BurgerFactory将决定应该创建哪种Burger类型的对象。假设我们有一个名为Burger的接口,ChickenBurgerBeefBurger都实现了这个接口。这意味着BurgerFactory将返回一个Burger类型的对象。客户端将不知道将创建和返回哪个Burger对象。通过使用这种模式,我们将客户端与特定对象隔离开来,从而增加了代码的灵活性。

原型模式

当您想要避免使用传统的对象创建机制(如 new 关键字)创建相同类型或子类型的新类时,可以使用这种设计模式。简而言之,这种模式规定我们应该克隆一个对象,然后使用克隆的对象作为另一个新创建的对象。这样就避免了传统的对象创建方法。

单例模式

单例模式是一种非常简单的设计模式。它涉及在整个应用程序中只创建一个类的对象。单例对象是一个不能有多个实例的对象。每当一段代码需要使用这个单例对象时,它不会创建一个新对象;相反,它将使用已经存在的旧对象。

这种设计模式适用于当您只想处理来自一个来源的一些信息时。使用单例模式的最佳示例是在数据库连接字符串中。在应用程序中,如果使用多个数据库连接,数据库可能会损坏并导致应用程序异常。在这种情况下,最好将连接字符串设置为单例对象,这意味着所有通信都只使用一个实例。这减少了出现差异的机会。

结构设计模式

在软件开发中可用的一些设计模式与代码结构有关。这些模式可以帮助您以一种避免常见结构问题的方式设计代码。在《设计模式:可复用面向对象软件的元素》一书中,Gang of Four 提出了七种结构设计模式。在本节中,我们将讨论其中的四种,分别是:

  • 适配器模式

  • 装饰器模式

  • 外观模式

  • 代理模式

如果您想了解其他三种模式的更多信息,请参阅 Gang of Four 的《设计模式:可复用面向对象软件的元素》一书。起初,开始使用这些模式可能会有点困惑,但随着经验的增加,识别哪种模式适合哪种情况将变得更容易。

适配器模式

通常,当我们想到适配器这个词时,我们会想到一个小设备,它可以帮助我们将电子设备插入具有不同接口的电源插座。适配器设计模式实际上在软件代码中也是这样的。这种设计模式规定,如果软件的两个模块想要相互通信,但一个模块期望的接口与另一个模块具有的接口不同,那么应该使用适配器,而不是改变一个接口以匹配另一个接口。这样做的好处是,将来如果您希望您的代码与另一个接口进行通信,您不必更改您的代码,只需使用另一个适配器。

例如,想象一下你有一个接口A,但你想要与之交流的代码需要另一个接口B。你可以使用一个适配器将接口A转换为接口B,而不是将接口A更改为接口B。这样,使用接口A的代码不会出错,你将能够与要求接口B的代码进行通信。

装饰者模式

装饰者模式允许我们动态地向对象添加新的行为。当这种新行为被添加到一个对象时,它不应该影响该对象上已经存在的任何其他行为。当你需要在运行时向对象添加新的行为时,这种模式提供了一个解决方案。它还消除了创建子类只是为了向任务添加行为的需要。

外观模式

有时,如果你有复杂的对象关系,很难将它们全部映射并在代码中使用。外观模式表明,你应该使用一个中间对象来处理对象关系问题,并给客户端一个简单的接触点。让我们想想一个例子:当你去餐厅点餐时,你实际上不会去找厨师或厨房里的人收集食物,然后自己做饭;你告诉服务员你想要什么食物。你不知道食物将如何准备或谁会准备它。你无法控制食物的制作,你只知道你会得到你要的东西。在这里,接受订单的人就是一个外观。他们接受你的订单,并要求不同的人准备你要的东西。

假设你点了一份牛肉汉堡。你调用一个GetBeefBurger()方法,外观实际上会调用以下内容:

Bread.GetBread()
Sauce.PutSauceOnBread(Bread)
SliceTomato()
PutTomatoOnBread()
Beef.FryBeefPatty()
PutBeefPattyOnBread()
WrapTheBurger()
ServeTheBurger()

上述方法并不是真正的方法。我只是想给你一个概念,即外观的工作实际上是为了隐藏客户端的复杂性。

代理模式

这种模式与我们讨论过的其他结构设计模式非常相似。如果有一种情况,代码不应该直接调用另一段代码,无论出于什么原因,都可以使用代理模式。代理模式在代码没有权限调用另一段代码或直接调用一段代码在资源方面昂贵时特别有用。我们可能想使用代理模式的一个例子是,如果我们想在应用程序中使用第三方库,但出于安全原因,我们不希望我们的代码直接调用该库。在这种情况下,我们可以创建一个代理,让它与第三方代码进行通信。

行为设计模式

行为设计模式是处理对象之间通信的设计模式。这些设计模式允许你的对象以一种避免开发人员面临的与对象行为相关的常见问题的方式进行通信。在这个类别中有许多模式:

  • 责任链模式

  • 命令模式

  • 解释器模式

  • 迭代器模式

  • 中介者模式

  • 备忘录模式

  • 观察者模式

  • 状态模式

  • 策略模式

  • 模板方法模式

  • 访问者模式

然而,在这本书中,我们只会讨论以下行为设计模式:

  • 命令模式

  • 观察者模式

  • 策略模式

如果你想了解更多,请参考我们之前提到的《设计模式:可复用面向对象软件的元素》一书,作者是四人组。

命令模式

这种模式规定,当一个对象想要通知另一个对象或调用另一个对象的方法时,应该使用另一个对象而不是直接这样做。建立通信的对象被称为命令对象。命令将封装持有要调用的方法、要调用的方法名以及要传递的参数(如果有的话)的对象。命令模式有助于解耦调用者和接收者之间的关系。

观察者模式

观察者模式是解决一个问题的解决方案,即许多对象需要知道特定对象何时发生变化,因为它们可能需要更新其端上的数据。一种方法是,所有对象或观察者都应该询问对象或可观察对象数据是否发生了变化。如果可观察对象中的数据发生了变化,观察者将执行其工作。然而,如果这样做,观察者必须经常询问可观察对象关于数据变化,以避免减慢应用程序的速度。这需要大量的资源。

观察者模式表示可观察对象应该知道想要了解主题中数据变化的观察者列表,并在主题中的数据发生变化时通知每个观察者。这可以通过调用观察者的方法来实现。这种模式的一个很好的应用是 C#中的事件和委托。

策略模式

让我们来看一下《设计模式:可复用面向对象软件的元素》一书中四人帮对策略模式的定义:

例如,一个方法可以根据使用它的类的不同类型有不同的实现。因此,这个定义意味着我们需要使这些不同的算法实现一个基类或接口,以便它们属于同一个家族,并可以被客户端互换使用。定义的最后一部分意味着这种模式将允许客户端使用不同的算法而不影响其他客户端。

假设我们有一个名为Animal的类,它具有一些常见属性,如eatwalknoise。现在,假设你想添加另一个属性,比如fly。你的类中的大多数动物都会飞,但有一些不会。你可以将Animal类分成两个不同的类,比如AnimalWhichCanFlyAnimalWhichCantFly。然而,将Animal类分成两个可能会使事情变得过于复杂,因为这些动物可能还有其他不同的属性。因此,你可以使用组合而不是继承,在Animal类中添加一个名为fly的属性,并用它来指示这种行为。

策略模式规定,我们应该使用接口(如IFly)而不是固定类型fly作为属性类型,然后创建实现IFly并具有不同算法的子类。然后,我们可以利用多态性,在创建Animal类的子类时在运行时分配特定的子类。

让我们尝试在前面的例子中应用这一点。在Animal类中,我们将使用IFly而不是Fly属性,然后实现实现IFly的不同类。例如,我们创建CanFly:IFlyCannotFly:IFly类。CanFlyCannotFly将有不同的Fly方法实现。如果我们创建一个实现Animal类的Dog类,我们将把Fly属性设置为CannotFly类。如果我们创建一个Bird类,我们将创建CanFly的实例并将其分配给Fly属性。通过应用这种模式,我们实现了一个不那么复杂的对象结构和易于更改的算法。

MVC 模式

MVC 模式是行业中最流行的设计模式之一。即使你是行业的新手,你可能已经听说过它。这种模式在 web 开发中被广泛使用。许多流行的 web 开发框架都使用这种设计模式。一些使用 MVC 模式的流行框架如下:

  • C#: ASP.NET MVC Web Framework

  • Java: Spring 框架

  • PHP: Laravel 框架,Codeigniter 框架

  • Ruby: Rails 框架

MVC 设计模式规定我们应该将 web 应用程序分为三个部分:

  • 模型

  • 视图

  • 控制器

模型 是将保存数据模型或对象并用于数据库事务的部分。视图 指的是应用程序的前端,用户或客户所看到的部分。最后,控制器 是处理应用程序所有业务逻辑的部分。所有逻辑和决策部分都将在控制器中。

MVC 模式的好处是你的应用程序是解耦的。你的视图独立于你的业务逻辑,你的业务逻辑独立于你的数据源。这样,你可以轻松地更改应用程序的一部分而不影响应用程序的其他部分。

总结

软件开发很有趣,因为它一直在变化。你可以用许多方式来开发、设计或编写某些东西。这些方式都不能被归类为最好的方式,因为你的代码可能需要根据情况进行更改。然而,因为软件开发是一种工程类型,有各种规则可以使你的软件更加强大和可靠。软件设计原则和设计模式就是这些规则的例子。了解这些概念并将它们应用到你自己的情况中将会让你作为开发者的生活变得更加容易。

本章节希望给你一个设计模式基础的概念,并告诉你在哪里可以寻找更多信息。在下一章中,我们将了解一个非常强大和有趣的软件,叫做 Git。Git 是一个版本控制系统,可以帮助跟踪软件代码的变化。

第十三章:Git - 版本控制系统

如今,软件开发已经达到了一个新的水平。它不再仅仅涉及编写代码——软件开发人员现在还必须熟悉一系列重要的工具。没有这些工具,要在团队中工作或高效工作就变得非常困难。版本控制就是其中之一。在众多可用的版本控制系统中,Git 是最流行和强大的。Git 版本控制已经在行业中存在了相当长的时间,但最近已经成为几乎所有软件公司的一部分。了解 Git 现在对开发人员来说是必不可少的。在本章中,我们将学习关于 Git 版本控制系统的知识。让我们来看看我们将要涵盖的主题:

  • 什么是版本控制系统?

  • Git 的工作原理

  • 在 Windows 中安装 Git

  • Git 的基础知识

  • Git 中的分支

什么是版本控制?

版本控制系统是一种在开发过程中跟踪软件代码变化的系统或应用程序。软件开发人员过去通过将代码复制到另一个文件夹或机器中来备份他们的代码。如果开发人员或生产机器崩溃,他们可以从备份中取出代码并运行。然而,手动保留和维护备份是麻烦的,容易出错,备份系统容易受损。因此,开发人员开始寻找一个能够保护他们代码的系统或应用程序。

版本控制在多个程序员共同开发项目的情况下也很有用。过去,程序员不得不要么在不同的文件上工作以避免冲突,要么在一段时间后仔细地合并代码。手动合并代码是非常危险和耗时的。

在版本控制系统中,代码文件的每一次更改实际上都是代码的一个新版本。在软件行业中,有许多版本控制系统可用,包括 Git、Subversion、Mercurial 和 Perforce。Git 是最流行和强大的版本控制系统,由软件开发人员 Linus Torvalds 开发。它是一个非常出色的应用程序,现在几乎在世界上每家软件公司中都在使用。

Git 是如何工作的

Git 的主要任务是跟踪代码版本并允许开发人员在必要时返回到任何先前的状态。这是通过对每个版本进行快照并将其保存在本地文件存储系统中来实现的。与其他系统不同,Git 使用本地文件存储来存储快照,这意味着即使没有互联网连接,也可以在本地使用 Git。有了 Git 的本地版本,你几乎可以做任何你可以用互联网连接版本的 Git 做的事情。

安装 Git 后,你可以选择将文件系统中的哪个目录纳入 Git 版本控制。通常,Git 中的一个实体——项目或目录——被称为存储库。存储库可能包含不同的项目、一个项目或只是一些项目文件,具体取决于你想在 Git 版本控制中保留什么。你可以有两种方式在本地机器上拥有一个 Git 存储库。你可以自己初始化一个 Git 存储库,或者你可以从远程服务器克隆一个存储库。无论哪种方式,你都会在创建或克隆存储库的同一个文件夹中创建一个名为.git的文件夹。这个.git文件是本地存储文件,所有与该存储库相关的信息都将存储在那里。Git 以非常高效的方式存储数据,所以即使有大量的快照,文件也不会变得很大。

Git 有三种主要状态,我们将在接下来的部分中探讨:

  • 修改

  • 暂存

  • 提交

修改

当您初始化了一个 Git 仓库,然后添加一个新文件或编辑一个现有文件时,该文件将在 Git 中标记为 Modified。这意味着该文件包含了与 Git 在其本地存储/数据库中已存储的快照的一些更改。例如,如果您在 Git 仓库中创建一个 C#控制台应用程序项目,那么该解决方案的所有文件都将被标记为 Modified,因为它们都不在 Git 仓库历史记录中。

Staged

在 Git 中,Staged 指的是准备提交的文件。为了防止意外提交不需要的文件到 Git 仓库,Git 在 Modified 和 Committed 之间引入了这一步骤。当您将文件标记为 Staged 时,这意味着您希望这些文件在下一次提交中被提交。这也给了您编辑文件并不使它们成为 Staged 的选项,这样更改就不会保存在仓库中。如果您想在本地机器上应用一些配置,但不希望这些更改出现在仓库中,这个功能非常方便。

已提交

Committed 状态是指文件的一个版本已保存在本地数据库中。这意味着已经拍摄了一个快照,并将其存储在 Git 历史记录中以供将来参考。在远程使用仓库时,您将推送的代码实际上只是已提交的代码。

让我们看一下以下图表,以了解这些状态之间的流程:

在 Windows 上安装 Git

Git 最初是为基于 Linux 或 Unix 的操作系统开发的。当它在 Windows 用户中变得流行并开始要求 Git 时,Git for Windows 应运而生。在 Windows 上安装 Git 现在是一个非常简单的过程。要安装 Git,请转到git-scm.com/download/win

您将被带到以下截图所示的页面:

Git for Windows 应该会自动开始下载。如果没有开始,您可以点击网站上提供的链接。下载文件将是一个可执行文件,所以要开始安装,执行可执行文件。在安装过程中,如果不确定要选择什么,最好的选择是保持一切默认。

以下截图显示了您可以安装哪些组件:

有一个部分可以选择用于 Git 的默认编辑器。选择的默认编辑器是 Vim,如下面的截图所示。如果您不习惯使用 Vim,可以将其更改为您喜欢的编辑器:

按照步骤。安装 Git 后,要测试安装是否成功,请转到命令行或 PowerShell,然后输入以下内容:

git --version

您应该会看到类似以下内容的输出:

如果您能看到版本号,这意味着安装成功了。

Git 的基础知识

如前所述,Git 最初是为 Linux 系统开发的,这就是为什么使用这个工具的主要方式是通过命令行。在 Windows 上,我们不像 Linux 或 Unix 用户那样经常使用命令行,但使用它可以让您访问 Git 的所有功能。对于 Windows,有一些 GUI 工具可以用于 Git 操作,但它们通常有一些限制。由于命令行是 Git 的首选方法,因此我们将在本书中只涵盖命令行命令。

Git config

git config命令是用来配置 Git 设置的命令。Git 的最小设置是设置用户名和电子邮件地址。您可以为每个 Git 仓库单独配置,也可以全局配置设置。如果您全局设置配置,您就不必每次初始化 Git 仓库时都配置电子邮件地址和用户名。如果有必要,您可以在每个仓库中覆盖这些设置。

要配置您的电子邮件地址和用户名,请运行以下命令:

git config user.name = "john"
git config user.email = "john@example.com"

如果要全局设置配置,需要添加--global关键字,如下所示:

git config --global user.name = "john"
git config --global user.email = "john@example.com"

如果要查看其他全局配置设置可用的内容,可以使用以下命令:

git config --list

然后,您可以更改您想要更改的设置。

Git init

如果您有一个当前未使用 Git 版本控制的项目,可以使用以下命令初始化项目:

git init

当您运行上述命令时,您在计算机上安装的 Git 程序将在项目目录中创建一个.git目录,并开始跟踪该项目的源代码。在新项目中初始化 Git 后,所有文件都显示为已修改,您必须将这些文件暂存以提交这些更改。

Git clone

如果要使用位于远程服务器上的项目,必须克隆该项目。要克隆项目,必须使用以下命令:

git clone [repo-url]

例如,如果要克隆 Angular 项目,必须键入以下内容:

git clone https://github.com/angular/angular.git

当您将存储库克隆到本地环境时,将下载.git文件夹。这包括提交历史记录,分支,标签和远程服务器中包含的所有其他信息。基本上是远程服务器版本的副本。如果您在本地副本中提交更改,然后将其推送到远程存储库,则本地副本将与远程副本同步。

Git status

在工作时,您会想要检查当前代码的状态。这意味着找出哪些文件已修改,哪些文件已暂存。您可以使用以下命令获取所有这些信息:

git status

让我们看一个例子。如果我们向我们的项目中添加一个名为hello.txt的新文件,并且该文件已被 Git 跟踪,并检查其状态,我们将看到以下内容:

在这里,我们可以看到一个名为hello.txt的文件位于Untracked文件下,这意味着此文件尚未被 Git 跟踪。git status命令还会告诉您当前所在的分支。在这种情况下,我们在master分支中。

Git add

git add命令是一个将已修改的文件/文件夹添加到 Git 跟踪系统的命令。这意味着文件和文件夹将被暂存。命令如下所示:

git add <file-name/folder-name>

让我们继续我们的示例,看看当我们在 Git 中添加hello.txt文件时会发生什么。为此,我们将执行以下命令:

git add hello.txt

输出如下:

在这里,我们看到了关于换行符LF)和回车,换行符CR+LF**)的警告,这些都是某种格式。替换的原因是我们在这里使用的是 Windows 操作系统,但目前我们不需要担心这个问题。这里的主要问题是文件已经被正确暂存。现在,如果我们检查状态,我们将看到以下内容:

在这里,我们可以看到hello.txt文件被放置在Changes to be committed部分。这意味着该文件已被暂存。

在真实项目中,您可能会在暂存文件之前同时处理多个不同的文件。逐个添加文件或甚至用逗号分隔文件名可能非常繁琐。如果要将所有修改的文件暂存,可以使用以下命令将所有文件添加到暂存区域中:

git add *

Git commit

当您想要将代码提交到 Git 历史记录时,使用git commit命令。这意味着对代码库进行快照,并将其存储在 Git 数据库中以供将来参考。要提交文件/文件夹,您必须使用以下命令:

git commit

如果执行上述代码,Git 设置的默认编辑器将打开并要求您输入提交的消息。还有一种更简洁的方法。如果要直接输入提交的消息,可以运行以下命令:

git commit -m "your message"

现在让我们提交我们的hello.txt文件到 Git 存储库。为此,我们将运行以下命令:

git commit -m "committing the hello.txt file with hello message" 

输出应该如下屏幕截图所示:

成功提交后,我们将看到1 file changed, 1 insertion(+)。如果再次检查状态,将看到没有要提交的内容,如下面的屏幕截图所示:

Git log

要检查在存储库中进行了哪些提交,可以使用以下命令:

git log

输出将如下所示:

从日志中,我们可以看到到目前为止只进行了一次提交。我们可以看到提交的哈希值,即紧跟在commit单词旁边的数字。我们可以看到commit是由Raihan Tahermaster分支上进行的。我们还可以在日志中看到commit消息。这是一个非常有用的命令,可以检查已提交了什么。

Git remote

git remote命令用于查看是否与远程存储库建立了连接。如果运行以下命令,它将显示远程存储库的名称。通常,远程名称设置为Origin。您可以有多个远程存储库。让我们看一下这个命令:

git remote

如果执行此命令,我们将看不到任何内容,因为还没有远程存储库,如下面的屏幕截图所示:

让我们添加一个远程存储库。我们将使用 GitHub 作为我们的远程服务器。在 GitHub 上创建存储库后,我复制了该存储库的 URL。我们将把它添加到我们的本地存储库。为此,我们使用以下命令:

git remote add <remote-name> <repository-link-remote>

在我们的示例中,命令如下:

git remote add origin https://github.com/raihantaher/bookgitexample.git

在添加了远程存储库后,如果执行git remote,我们将看到origin被列为远程存储库,如下面的屏幕截图所示:

如果要查看有关远程存储库的更多详细信息,可以执行以下命令:

git remote -v

这将显示您添加的远程存储库的 URL,如下面的屏幕截图所示:

Git push

当您想要将本地提交上传或推送到远程服务器时,可以使用以下命令:

git push <remote-repo-name> <local-branch-name>

以下是如何使用此命令的示例:

git push origin master

执行此命令后,如果推送成功,将看到以下消息:

Git pull

git pull命令用于从远程存储库获取最新代码。由于 Git 是一个分布式版本控制系统,多人可以在一个项目上工作,有可能其他人已经使用最新代码更新了远程服务器。要访问最新代码,请运行以下命令:

git pull <remote-repo-name> <local-branch-name>

以下是如何使用此代码的示例:

git pull origin master

如果运行此代码,弹出的消息如下:

这意味着我们的本地存储库已经与远程存储库同步。如果远程存储库中有新的提交,git pull命令将把这些更改拉到我们的本地存储库,并指示已拉取更改。

Git fetch

git fetch命令是一个与git pull非常相似的命令,但是当你使用git fetch时,代码将从远程仓库获取到本地仓库,但不会与你的代码合并。在检查了远程代码后,如果你觉得想要将其与本地代码合并,你必须显式运行git merge命令。执行此命令如下:

git fetch <remote-repo>

如果运行上述命令,将更新来自远程仓库的所有分支。如果指定一个本地分支,只会更新该分支:

git fetch <remote-repo> <local-branch>

让我们尝试在我们的示例代码中执行git fetch命令:

git fetch origin master

你会看到以下输出:

Git 中的分支

分支经常被认为是 Git 最好的特性之一。分支使 Git 与所有其他版本控制系统不同。它非常强大且易于使用。在我们学习不同的分支命令之前,让我简要解释一下 Git 如何处理提交,因为这将帮助你理解 Git 分支。在 Git 中,我们已经知道每个提交都有一个唯一的哈希值,并且该哈希值存储在 Git 数据库中。使用哈希值,每个提交都存储了先前提交的哈希值,这被称为该提交的父提交。除此之外,还存储了另一个哈希值,该哈希值存储了在该提交上暂存的文件,以及提交消息和有关提交者和作者的信息。对于存储库的第一个提交,父提交为空。

以下图表显示了 Git 中哈希的示例:

我们称提交中的所有信息为快照。如果我们做了三次提交,我们可以说我们有快照 A快照 B快照 C,如下图所示:

默认情况下,当你初始化一个本地 Git 仓库时,会创建一个名为master的分支。这是大多数开发人员将其视为 Git 树中的主分支的分支。这是可选的;你可以将任何分支视为你的主分支或生产分支,因为所有分支具有相同的能力和权限。如果你从快照 C提交 3C3简称)创建一个名为feature的分支,一个分支将从C3提交 3)开始,测试分支上的下一个提交将把 C3 视为父提交。

以下图表显示了分支情况:

HEAD是一个指针,指向活动的提交或分支。这是开发人员和 Git 版本控制的指示器。当你做一个新的提交时,HEAD 会移动到最新的提交,因为这是将作为下一个提交的父提交的快照。

创建分支

让我们现在来看一下在 Git 中创建分支的命令。创建分支非常容易,因为它不会将整个代码库复制到一个新的位置,而是只保持与 Git 树的关系。有几种创建分支的方法,但最常见的方法如下:

git branch feature

在命令行上应该如下所示:

查看可用的分支

要查看本地 Git 仓库中有哪些分支可用,可以输入以下命令:

git branch

执行上述代码后,你应该看到以下输出:

我们可以看到我们的本地仓库中有两个分支。一个是master分支,另一个是feature分支。*字符表示 HEAD 指向的位置。

切换分支

在前面的例子中,我们看到,即使创建了 feature 分支,HEAD 仍然指向 master。切换到另一个分支的命令如下:

git checkout <branch-name>

在我们的例子中,如果我们想从master切换到feature分支,我们必须输入以下命令:

git checkout feature

输出如下:

运行命令后,我们可以看到 Git 已经切换到了feature分支。现在我们可以再次运行git branch命令来查看 HEAD 指向的位置,如下截图所示:

很可能的情况是,当您创建一个分支时,您会希望立即在该分支上工作,因此有一个快捷方式可以创建一个分支,然后切换到它,如下面的代码所示:

git checkout -b newFeature

删除分支

要删除一个分支,您必须执行以下命令:

git branch -d feature

如果分支成功删除,您应该会看到类似于以下截图中显示的消息:

在 Git 中合并

要将一个分支与另一个分支合并,您必须使用merge命令。请记住,您需要在要将代码合并的分支上,而不是将要合并的分支或任何其他分支上。命令如下:

git merge newFeature

输出应该如下所示:

总结

在本章中,我们学习了一个与 C#编程语言不直接相关,但对于 C#开发人员来说仍然是一个必不可少的工具的概念。微软最近收购了 GitHub,这是基于 Git 的最大远程代码存储库网站,并将大多数 Microsoft 的 IDEs/编辑器与之集成,包括最新的代码编辑器 Visual Code。这显示了 Git 对我们行业有多么重要。我相信每个开发人员,无论是新手还是资深人员,都应该为他们的代码使用版本控制。如果您不使用 Git,您可以使用市场上的任何其他版本控制系统。然而,Git 是最好的,即使您在工作中没有使用 Git,我也建议您在个人项目中使用它。Git 命令非常简单,所以您只需要练习几次就能完全理解它。

下一章有点不同。我们将看一些在求职面试中常被问到的问题。

第十四章:为面试做好准备-面试和未来

这是一本面向对象编程OOP)书中不同寻常的一章。面试是软件开发人员职业生涯的重要组成部分。面试就像对你知识的一次考验。它让你了解自己的知识水平和你应该学习更多的内容。这也是向其他公司的经验丰富的开发人员学习的一种方式。

本章的主要目的是让你了解在工作面试中会问到哪些类型的问题,以及你如何为此做好准备。请记住,工作面试问题取决于你申请的职位、公司、面试官的知识以及公司正在使用的技术栈。虽然不是所有这些问题都会被问到,但有可能会问到其中一些,因为这些问题决定了你的基本面向对象编程和 C#知识。

让我们回顾一下本章将涵盖的主题:

  • 面试问题

  • 面试和职业建议

  • 接下来要学习的东西

  • 阅读的重要性

面试问题

在本节中,我们将讨论一些初学者到中级开发人员最常见的面试问题。由于本书是关于 C#的,我们还将提出与 C#编程语言直接相关的问题。

面向对象编程的基本原则是什么?

面向对象编程有四个基本原则:

  • 继承

  • 封装

  • 抽象

  • 多态

什么是继承?

继承意味着一个类可以继承另一个类的属性和方法。例如,Dog是一个类,但它也是Animal的子类。Animal类是一个更一般的类,具有所有动物都具有的基本属性和方法。由于狗也是一种动物,Dog类可以继承Animal类,因此Animal类的所有属性和方法也可以在Dog类中使用。

什么是封装?

封装意味着隐藏类的数据。C#中的访问修饰符主要用于封装的目的。如果我们将一个方法或字段设置为私有,那么该方法或字段在类外部是不可访问的。这意味着我们将数据隐藏在外部世界之外。封装的主要原因是我们希望隐藏更复杂的实现,只向外部世界展示简单的接口以便于使用。

什么是抽象?

抽象是一个概念,不是真实的东西。抽象意味着向外界提供某个对象的概念,但不提供其实现。接口和抽象类是抽象的例子。当我们创建一个接口时,我们不在其中实现方法,但当一个类实现接口时,它也必须实现该方法。这意味着接口实际上给出了类的抽象印象。

什么是多态?

多态意味着多种形式。在面向对象编程中,我们应该有创建一种东西的多种形式的选项。例如,您可以有一个addition方法,它可能具有不同的实现,具体取决于它接收的输入。一个接收两个整数并返回这些整数的和的addition方法可能是一种实现。还可能有另一种形式的addition方法,它可能接受两个双精度值并返回这些双精度值的和。

什么是接口?

接口是 C#编程语言中用于在程序中应用抽象的实体或特性。它就像类和接口本身之间的合同。合同是,将继承接口的类必须实现接口本身内部的方法签名。接口不能被实例化,它只能被类或结构实现。

什么是抽象类?

抽象类是一种特殊的类,不能被初始化。不能从抽象类创建对象。抽象类可以有具体方法和非具体方法。如果一个类实现了一个抽象类,那么这个类必须实现抽象方法。如果需要的话,它可以重写非抽象方法。

什么是密封类?

密封类是一个不能被继承的类。它主要用于阻止 C#中的继承特性。

什么是部分类?

部分类是源代码分布在不同文件中的类。通常,一个类的所有字段和方法都在同一个文件中。在部分类中,你可以将类代码分开放在不同的文件中。编译时,所有来自不同文件的代码都被视为单个类。

接口和抽象类之间有什么区别?

接口和抽象类之间的主要区别如下:

  • 一个类可以实现任意数量的接口,但只能实现一个抽象类。

  • 抽象类既可以有抽象方法,也可以有非抽象方法,而接口不能有非抽象方法。

  • 在抽象类中,数据成员默认为私有,而在接口中,所有数据成员都是公共的,这是无法更改的。

  • 在抽象类中,我们需要使用abstract关键字使方法抽象,而在接口中不需要。

方法重载和方法重写之间有什么区别?

方法重载是指具有相同名称但具有不同输入参数的方法。例如,假设我们有一个名为Sum的方法,它接受两个整数类型的输入并返回一个整数类型的输出。Sum的重载方法可以接受两个双精度类型的输入并返回一个双精度输出。

方法重写是指在子类中实现具有相同名称、相同参数和相同返回类型的方法,用于不同的实现。例如,假设我们在一个名为Sales的类中有一个名为Discount的方法,其中折扣按照总购买额的 2%计算。如果我们有Sales的另一个子类称为NewYearSales,其中折扣按照 5%计算,使用方法重写,NewYearSales类可以轻松应用新的实现。

访问修饰符是什么?

访问修饰符用于设置编程语言中不同实体的安全级别。通过设置访问修饰符,我们可以为不同级别的类隐藏数据。

在 C#中,有六种类型的访问修饰符:

  • 公共

  • 私有

  • 受保护的

  • 内部

  • 受保护的内部

  • 私有受保护

什么是装箱和拆箱?

装箱是将值类型转换为对象的过程。拆箱是从对象中提取值类型的过程。装箱可以隐式进行,但拆箱必须在代码中显式进行。

结构体和类之间有什么区别?

结构体和类是非常相似的概念,但有一些区别:

  • 结构体是值类型,类是引用类型。

  • 结构体通常用于小量数据,而类用于大量数据。

  • 结构体不能被其他类型继承,而类可以被其他类继承。

  • 结构体不能是抽象的,而类可以是抽象的。

C#中的扩展方法是什么,我们如何使用它?

扩展方法是一种方法,它被添加到现有类型中,而不创建新的派生类型或编译或更改现有类型。它的工作原理类似于扩展。例如,默认情况下,我们从.NET 框架中获得字符串类型。如果我们想要向这个字符串类型添加另一个方法,要么我们必须创建一个扩展这个字符串类型并在那里放置方法的派生类型,要么我们在.NET 框架中添加代码并编译和重建库。然而,使用扩展方法,我们可以轻松地扩展现有类型中的方法。为此,我们必须创建一个静态类,然后创建一个静态的扩展方法。这个方法应该以类型作为参数,但是在字符串之前应该放置this关键字。现在这个方法将作为该类型的扩展方法工作。

什么是托管代码和非托管代码?

在.NET 框架中开发的代码称为托管代码。公共语言运行时CLR)可以直接执行这段代码。非托管代码不是在.NET 框架中开发的。

C#中的虚方法是什么?

虚方法是在基类中实现的方法,但也可以在子类中重写的方法。虚方法不能是抽象的、静态的、私有的或重写的。

你对 C#.NET 中的值类型和引用类型有什么理解?

在 C#中,有两种类型的数据。一种称为值类型,另一种称为引用类型。值类型是直接在内存位置中保存值的类型。如果值被复制,新的内存位置保存相同的值,两者相互独立。引用类型是指值不直接放置在内存位置中,而是设置对该值的引用。值类型和引用类型之间的另一个主要区别是值类型位于堆栈中,而引用类型位于堆中。值类型的例子是int,而引用类型的例子是string

什么是设计原则?

有五个设计原则组成了缩写SOLID

  • 单一责任原则

  • 开闭原则

  • 里氏替换原则

  • 接口隔离原则

  • 依赖反转原则

单一责任原则是什么?

"一个类应该有一个,只有一个改变的理由。"

  • 罗伯特·C·马丁

这意味着一个类应该只有一个责任。如果一个类做了多件事情,这就违反了单一责任原则SRP)。例如,如果我们有一个名为Student的类,它应该只负责与学生相关的数据。如果Student类需要在Teacher类中更改任何内容时进行修改,Student类就违反了 SRP。

开闭原则是什么?

软件组件应该对扩展开放,但对修改关闭。这意味着组件应该设计成这样,如果需要添加新的规则或功能,就不需要修改现有的代码。如果必须修改现有的代码来添加新功能,这意味着组件违反了开闭原则

什么是里氏替换原则?

派生类型必须完全可替代其基类型。这意味着如果在某个地方使用了基类的实例,应该能够用该基类的子类实例替换基类实例而不会破坏任何功能。例如,如果有一个名为Animal的基类和一个名为Dog的子类,应该能够用Dog类的实例替换Animal类的实例而不会破坏任何功能。

接口隔离原则是什么?

客户不应该被迫依赖他们不使用的接口。有时,接口包含了许多可能不被实现它们的类使用的信息。接口隔离原则建议你保持接口的小型化。类不应该实现一个大接口,而应该实现多个小接口,其中类中的所有方法都是需要的。

依赖反转原则是什么?

高级模块不应该依赖低级模块;两者都应该依赖抽象。这意味着,当你开发模块化软件代码时,高级模块不应该直接依赖低级模块,而应该依赖低级模块实现的接口或抽象类。通过这样做,系统中的模块是独立的,将来如果你用另一个模块替换低级模块,高级模块不会受到影响。

这个原则的另一个部分是抽象不应该依赖细节,细节应该依赖抽象。这意味着接口或抽象类不应该依赖类,而实现接口和抽象类的类应该依赖接口或抽象类。

面试和职业技巧

既然我们已经涵盖了一些面试中可能被问到的最常见的问题,我还有一些提示,可以帮助你在面试和职业生涯中表现更好。

提高你的沟通技巧

人们普遍认为软件开发人员不合群,沟通能力不强。然而,现实情况却截然不同。所有成功的开发人员都必须具备良好的沟通能力。

作为软件开发人员,你会有时候需要向非技术人员解释技术理念或情况。为了能够做到这一点,你必须以一种使信息对每个人都易于访问和理解的方式进行沟通。这可能包括口头(会议或讨论)和书面沟通(文档或电子邮件)。

在你职业生涯的开始阶段,你可能并不一定理解沟通的重要性,因为你只是被分配任务来完成。然而,随着你的经验积累和职业发展,你会意识到有效沟通的重要性。

作为一名高级开发人员,你可能需要与初级开发人员沟通,解释问题或解决方案,或者与业务团队沟通,以确保你充分理解业务需求。你可能还需要进行技术培训以分享知识。

因此,请确保你与人们保持互动,并阅读能帮助你有效沟通并教你如何应对听众的资源。良好的沟通技巧不仅会帮助你在面试中脱颖而出,而且在整个职业生涯中也会对你有价值。

继续练习

虽然没有完美的软件开发人员,但通过定期练习,你可以成为一个知识渊博、经验丰富的软件开发人员。

计算机编程是一门艺术。通过犯错误,你会培养出对错与对的感觉。你编写的代码越多,你就会经历更多不同的情况。这些情况将帮助你积累经验,因为你很可能在未来的项目中再次遇到它们。

学习或掌握编程的最佳方法是实践

尝试将你在本书中学到的概念应用到你的实际项目中。如果在你当前的项目中不可能这样做,那就创建演示项目并在那里应用它们。技术概念非常实用;如果你进行实际实现,这些概念将变得非常清晰。

接下来要学习的东西

阅读完这本书后,你应该对面向对象编程和 C#编程语言有更好的理解。然而,这还不够。你必须努力学习更多关于软件开发的知识。你应该学习 C#的其他语言特性,以及如何使用它们来完成工作。你还应该学习数据结构和算法以应对专业工作。在下面的列表中,我建议了一些接下来可以研究的主题和技术:

  • C#编程语言特性,如运算符、控制语句、数组、列表、运算符重载、Lambda 表达式、LINQ、字符串格式化和线程

  • 诸如链表、二叉树、排序和搜索算法之类的数据结构和算法

  • 诸如 ASP.NET MVC、ASP.NET Web API、WPF 和 WCF 之类的 Web/桌面框架

  • 前端技术,如 HTML、CSS 和 JavaScript,以及 JavaScript 框架,如 reactjs/angular

  • 诸如 MS SQL Server、Oracle 和 MySQL 之类的数据库技术

  • 设计模式及其影响

  • 软件架构和设计

  • 代码整洁、代码重构和代码优化

还有许多其他要学习的东西,但我已经涵盖了我认为每个软件开发人员都应该了解的主题。这个列表相当长,主题相当技术,所以要仔细规划你的学习。

养成阅读的习惯

我最后的建议是成为一个热心的读者。阅读对于软件开发人员非常重要。信息通常通过文本或语音分发给人们。虽然视频教程是学习的好方法,但阅读可以给你时间思考,并为你提供数以百万计的资源。

以下是我必读的一些书籍:

  • 《实用程序员:从学徒到大师》作者安德鲁·亨特和大卫·托马斯

  • 《代码整洁之道》作者罗伯特·塞西尔·马丁

  • 《代码大全 2》作者史蒂夫·麦康奈尔

  • 《重构》作者马丁·福勒和肯特·贝克

  • 《算法导论》作者查尔斯·E·莱斯森、克利福德·斯坦、罗纳德·李维斯特和托马斯·H·科尔门

  • 《设计模式:可复用面向对象软件的元素》四人组合著

  • 《C# 7.0 权威指南》作者约瑟夫·阿尔巴哈里

  • 《深入 C#》作者乔恩·斯基特

总结

软件开发是一个非常有趣的领域。你可以开发能够改变世界的惊人应用。像 Facebook 和 Maps 这样的应用,以及谷歌和 Windows 等数字巨头的众多产品,对我们的生活产生了重大影响。程序可以通过提高生产力来让人们的生活更加便利。

作为一名软件开发人员,我请求你写出优秀的代码并开发出惊人的应用。如果你有正确的意图、对软件开发有激情和强烈的职业道德,你一定会在你的职业生涯中取得成功。

让我们通过创建能够促进人类文明进步的惊人软件,让这个世界变得更美好。

第十五章:理解设计模式和原则

多年来,软件变得越来越复杂。现在,软件不仅用于数学计算或简单的创建、读取、更新和删除(CRUD)操作:我们正在使用它来执行复杂的任务,如控制火箭发动机或每天管理大量数据。来自各个行业的企业已经开始采用软件系统,包括银行、保险公司、研究机构、教育机构和政府机构。对软件的需求越高,越多的人开始在软件开发领域建立职业。从汇编语言编程开始,经过了过程式编程,然后是面向对象编程(OOP)时代的介绍,尽管出现了其他类型的编程,如函数式编程,但 OOP 仍然是最受欢迎的模型。OOP 帮助开发人员编写易于维护和扩展的良好模块化软件。在本章中,我们将讨论一些最重要的设计原则和模式,这些原则和模式被成千上万的开发人员遵循,我们将涵盖以下主题:

  • 软件开发中的设计原则

  • 软件开发中的不同设计模式

  • 创建设计模式

  • 行为设计模式

  • 结构设计模式

  • 模型-视图-控制器(MVC)模式

设计原则

在我们开始讨论设计原则之前,让我们思考一下在软件开发中我们所说的设计原则是什么意思。当我们开发软件时,我们首先设计其架构,然后开始编写其代码。我们希望以这样的方式编写我们的代码,即它不会产生错误,或者如果有错误,很容易找到。我们还希望在阅读代码时能够轻松理解它,并且希望它的结构能够在以后需要时进行更改。虽然很难编写最佳代码,但有许多在软件开发中由经验丰富的计算机科学家开发的原则。使用这些原则,开发人员可以编写非常干净的代码。

软件开发人员 Robert C. Martin,也被称为 Uncle Bob,提出了五个软件设计原则。这些原则对开发人员非常有效和有帮助,以至于它们已经成为软件行业的一种规范。它们统称为 SOLID 原则,代表以下不同的定义:

  • S代表单一职责原则

  • O代表开闭原则

  • L代表Liskov 替换原则

  • I代表接口隔离原则

  • D代表依赖反转原则

让我们逐一讨论这些原则。

单一职责原则

"一个类应该有一个,只有一个更改的原因。"

Robert C. Martin

这意味着当我们编写一个类时,我们应该以只有一个职责的方式设计它。您应该只需要更改类的一个原因。如果您有多个更改类的原因,它就违反了单一职责原则。

如果一个类具有多个职责,并且您对一段代码进行更改,这可能会破坏另一段代码,因为它们位于同一个类中并共享一些依赖关系。您的代码可能不太解耦。

开闭原则

代码需要以这样的方式编写,即在软件实体(如类、模块或函数)中添加新内容是好的,但不应允许修改实体本身。这减少了产生错误的可能性。

Liskov 替换原则

"派生类型必须完全可替代其基本类型。"

Barbara Liskov

该原则规定,当你编写一个类时,如果它是从另一个类派生的,它应该能够被基类替换。否则,你的代码将非常脆弱和耦合。这个原则是由芭芭拉·利斯科夫首次发现的,因此以她的名字命名。

接口隔离原则

有时,开发人员会创建包含太多信息的大接口。许多类可能使用这个接口,但它们可能并不需要其中的所有内容。为了遵循这个原则,你应该避免这种情况。这个原则支持小接口而不是大接口,如果必要,一个类可以继承多个适用于该类的小接口。

依赖反转原则

“高层模块不应该依赖低层模块;两者都应该依赖抽象。抽象不应该依赖细节。细节应该依赖抽象。”

  • 罗伯特·C·马丁

我们知道,在软件开发中,我们使用层。为了使这些层解耦,我们必须以这样的方式设计这些层的依赖关系,以便这些层不是相互依赖,而是依赖于抽象。因此,如果你改变高层模块或低层模块中的某些内容,它不会损害系统。当我们创建这些抽象时,我们必须以这样的方式设计它们,使它们不依赖于实现细节。这些抽象应该是独立的,实现这些接口或抽象类的类应该依赖于这些抽象。

创造性设计模式

在面向对象编程中,一切都被视为对象,因此跟踪对象的创建和管理非常重要。如果开发人员不太关注这个话题,软件的对象可能会使软件变得脆弱和耦合。保持对象适当地维护对于保持应用程序易于扩展非常重要。创造性设计模式是帮助以避免对象创建的最常见问题的方式创建对象的模式。

创造性设计模式中存在两个主要概念:

  • 封装系统使用的具体类的知识

  • 隐藏具体类的创建和组合实例

创造性设计模式分为对象创建模式和类创建模式,其中对象创建模式处理对象的创建,类创建模式处理类的发现。

行业中有五种主要的创造性设计模式:

  • 抽象工厂模式

  • 建造者模式

  • 工厂方法模式

  • 原型模式

  • 单例模式

抽象工厂模式

《设计模式:可复用面向对象软件的元素》一书中,四人组提出的这种模式的定义是提供一种组合来构建类似或依赖对象家族,而不指定它们的具体类。

这种模式提供的最重要的东西是对象创建的分离或抽象。如果你不遵循任何模式,当你创建一个对象时,最简单的方法就是在需要的地方使用new关键字创建一个对象。例如,如果我在我的Bank类中需要一个Person对象,最简单的方法就是在Bank类中使用new关键字实例化一个Person对象。然而,使用这种方法有时会使软件变得复杂。为了避免这种情况,我们可以使用抽象工厂模式。

抽象工厂模式主要用于有着相同家族的对象,或者以某种方式相关或依赖的情况。其思想是创建工厂类来执行对象创建的工作。如果一个对象A需要另一个对象B的实例,对象A应该要求对象B的工厂创建一个B的对象并将其传递给对象A。这样,对象A独立于对象B的创建。现在,在抽象工厂模式中,还有另一层抽象。工厂类也被抽象化了。这意味着对象A不会直接调用对象B的工厂,而是使用一个抽象。应该有一个机制来确定需要调用哪个Factory类。这意味着对象A不依赖于另一个对象的任何特定工厂。

建造者模式

将复杂对象的计划与其实现分离是建造者模式的主要思想。在面向对象的软件开发中,我们有时需要创建相当复杂的对象。例如,我们可能创建一个使用其他对象的对象,而这些对象又使用其他对象。当你只需要该对象执行另一种工作时,创建或实例化这种对象可能会很困难。这可能使代码变得更加复杂,降低其可读性。

让我们想想一个例子。想象一下,你正在制作一些汉堡,其中一些是鸡肉汉堡,一些是牛肉汉堡。在创建鸡肉汉堡对象时,你必须每次创建一个鸡肉汉堡对象时创建一个鸡肉汉堡肉饼对象、一个番茄酱对象、一个奶酪对象和一个面包对象,这会导致混乱的代码。当创建牛肉汉堡对象时,你也必须遵循相同的流程。这是一种处理和创建这些对象的非常复杂的方式。

建造者模式提供了一种解决这种复杂性的好方法。使用这种模式,我们创建一个名为Builder的类,其主要任务是创建复杂对象并返回新创建的对象。使用建造者模式,我们使用另一种类型的类,通常称为director类。这个类的任务是调用Builder类并从中获取对象。

让我们回到我们的汉堡示例。我们可以有一个ChickenBurgerBuilder类和一个BeefBurgerBuilder类。这些类将在类中设置项目、汉堡肉饼、面包、番茄酱和奶酪。当BurgerDirector类想要创建一个鸡肉汉堡时,它将调用ChickenBurgerBuilder。要创建一个牛肉汉堡,它将调用BeefBurgerBuilder。创建汉堡肉饼和其他配料的复杂性将由Builder类处理。

工厂方法模式

工厂方法模式与抽象工厂模式非常相似。不同之处在于,在工厂方法模式中,工厂层不是抽象的。使用这种模式意味着你将创建一个工厂类,该类将处理实现相同抽象的类的创建。这意味着,如果有一个由许多子类定义的接口,Factory类可以根据传递给Factory的逻辑创建任何这些子类中的任何一个。

让我们来想一个例子。我们将使用工厂方法模式来解决我们在生成器模式示例中提到的制作汉堡的问题。我们将创建一个名为BurgerFactoryFactory,它将接受一个输入,比如typeOfBurger(鸡肉或牛肉)。然后,BurgerFactory将决定应该创建哪种Burger类型的对象。假设我们有一个名为Burger的接口,ChickenBurgerBeefBurger都实现了这个接口。这意味着BurgerFactory将返回一个Burger类型的对象。客户端将不知道将创建和返回哪个Burger对象。通过使用这种模式,我们将客户端与特定对象隔离开来,从而增加了代码的灵活性。

原型模式

当你想要避免使用传统的对象创建机制(如 new 关键字)创建相同类型或子类型的新类时,可以使用这种设计模式。简而言之,这种模式规定我们应该克隆一个对象,然后将克隆的对象作为另一个新创建的对象来处理。这样就避免了传统的对象创建方法。

单例模式

单例模式是一个非常简单的设计模式。它涉及在整个应用程序中只创建一个类的对象。单例对象是一个不能有多个实例的对象。每当一段代码需要使用这个单例对象时,它不会创建一个新对象,而是使用已经存在的旧对象。

当你只想处理来自一个来源的一些信息时,可以使用这种设计模式。单例模式的最佳示例是数据库连接字符串。在应用程序中,如果使用了多个数据库连接,数据库可能会损坏并导致应用程序异常。在这种情况下,最好将连接字符串作为单例对象,这意味着所有通信都使用同一个实例。这减少了出现差异的机会。

结构设计模式

在软件开发中有一些与代码结构相关的设计模式。这些模式可以帮助你以一种能够避免常见结构问题的方式设计你的代码。在《设计模式:可复用面向对象软件的元素》一书中,由四人组成的设计模式一书中有七种结构设计模式。在本节中,我们只讨论其中的四种,分别是:

  • 适配器模式

  • 装饰器模式

  • 外观模式

  • 代理模式

如果你想了解其他三种模式的更多信息,请参阅四人组的《设计模式:可复用面向对象软件的元素》一书。起初,开始使用这些模式可能会有点困惑,但随着经验的增加,识别哪种模式适合哪种情况将变得更容易。

适配器模式

通常,当我们想到适配器这个词时,我们会想到一个小设备,它可以帮助我们将电子设备插入具有不同接口的电源插座。适配器设计模式实际上在软件代码中做同样的事情。这种设计模式规定,如果软件的两个模块想要相互通信,但一个模块期望的接口与另一个模块具有的接口不同,那么应该使用适配器,而不是改变一个接口以匹配另一个接口。这样做的好处是,将来如果你想让你的代码与另一个接口通信,你不需要改变你的代码,只需要使用另一个适配器。

例如,想象一下,你有一个接口A,但你想要与之通信的代码需要另一个接口B。你可以使用一个适配器将接口A转换为接口B,而不是将接口A更改为接口B。这样,使用接口A的代码不会出错,你将能够与要求接口B的代码进行通信。

装饰者模式

装饰者模式允许我们动态地向对象添加新的行为。当这种新行为被添加到对象时,它不应该影响对象上已经存在的任何其他行为。当你需要在运行时向对象添加新行为时,这种模式提供了一种解决方案。它还消除了创建子类只是为了向任务添加行为的需要。

外观模式

有时,如果你有复杂的对象关系,很难将它们全部映射并在代码中使用。外观模式指出,你应该使用一个中间对象来处理对象关系问题,并为客户端提供一个简单的联系点。让我们想想一个例子:当你去餐厅点餐时,你实际上不会去找厨师或厨房里的人收集食物,然后自己做饭;你告诉服务员你想要什么食物。你不知道这个项目将如何准备或者谁会准备它。你无法控制食物的制作,你只知道你会得到你要求的项目。在这里,接受订单的人就是一个外观。他们接受你的订单,并要求不同的人准备你要求的项目。

假设你点了一份牛肉汉堡。你调用一个GetBeefBurger()方法,外观实际上会调用以下内容:

Bread.GetBread()
Sauce.PutSauceOnBread(Bread)
SliceTomato()
PutTomatoOnBread()
Beef.FryBeefPatty()
PutBeefPattyOnBread()
WrapTheBurger()
ServeTheBurger()

上述方法并不是真正的方法。我只是想给你一个想法,外观的工作实际上是隐藏客户端的复杂性。

代理模式

这种模式与我们讨论过的其他结构设计模式非常相似。如果有一种情况,一个代码片段不应该直接调用另一个代码片段,不管出于什么原因,都可以使用代理模式。代理模式在代码片段没有权限调用另一个代码片段或者直接调用代码片段在资源方面是昂贵的情况下特别有用。如果我们想在应用程序中使用第三方库,但出于安全原因不希望我们的代码直接调用该库,我们可以创建一个代理并让它与第三方代码进行通信。

行为设计模式

行为设计模式是处理对象之间通信的设计模式。这些设计模式允许你的对象以一种避免开发人员面临的与对象行为相关的常见问题的方式进行通信。在这个类别中有许多模式:

  • 责任链模式

  • 命令模式

  • 解释器模式

  • 迭代器模式

  • 中介者模式

  • 备忘录模式

  • 观察者模式

  • 状态模式

  • 策略模式

  • 模板方法模式

  • 访问者模式

然而,在本书中,我们只会讨论以下行为设计模式:

  • 命令模式

  • 观察者模式

  • 策略模式

如果你想了解更多,请参考我们之前提到的四人帮的《设计模式:可复用面向对象软件的元素》一书。

命令模式

这种模式规定,当一个对象想要通知另一个对象或调用另一个对象的方法时,它应该使用另一个对象而不是直接这样做。建立通信的对象称为命令对象。命令将封装持有要调用的方法、要调用的方法名称以及要传递的参数(如果有的话)的对象。命令模式有助于解耦调用者和接收者之间的关系。

观察者模式

观察者模式是解决一个问题的解决方案,即许多对象需要知道特定对象何时发生变化,因为它们可能需要更新其端上的数据。一种方法是所有对象或观察者应该询问对象或可观察对象数据是否已更改。如果可观察对象中的数据已更改,观察者将执行其工作。然而,如果这样做,观察者必须经常询问可观察对象关于数据变化,以避免减慢应用程序的速度。这需要大量资源。

观察者模式表示可观察对象应该知道想要了解主题数据变化的观察者列表,并在主题数据发生变化时通知每个观察者。这可以通过调用观察者的方法来实现。这种模式的一个很好的应用是 C#中的事件和委托。

策略模式

让我们来看一下《设计模式:可重用面向对象软件的元素》一书中的策略模式的定义:

例如,一个方法可以根据使用它的类的不同类型的实现而有所不同。因此,这个定义意味着我们需要使这些不同的算法实现一个基类或接口,以便它们属于同一个家族,并可以被客户端互换使用。定义的最后一部分意味着这种模式将允许客户端使用不同的算法而不影响其他客户端。

假设我们有一个名为Animal的类,它具有一些常见属性,如eatwalknoise。现在,假设您想添加另一个属性,如fly。您的类中的大多数动物都可以飞,但有一些不能。您可以将Animal类分成两个不同的类,如AnimalWhichCanFlyAnimalWhichCantFly。然而,将Animal类分成两个可能会使事情过于复杂,因为这些动物可能还具有其他不同的属性。因此,您可以使用组合而不是继承,这意味着您可以在Animal类中添加一个名为fly的属性,并使用它来指示此行为。

策略模式规定,我们应该使用接口(例如IFly)而不是固定类型(fly)作为属性类型,然后创建实现IFly并具有不同算法的子类。然后,我们可以利用多态性,并在创建Animal类的子类时在运行时分配特定的子类。

让我们尝试将这种模式应用于前面的例子。在Animal类中,我们将使用IFly而不是使用Fly属性,然后实现实现IFly的不同类。例如,我们创建CanFly:IFlyCannotFly:IFly类。CanFlyCannotFly将有Fly方法的不同实现。如果我们创建一个实现Animal类的Dog类,我们将把Fly属性设置为CannotFly类。如果我们创建一个Bird类,我们将创建CanFly的实例并将其分配给Fly属性。通过应用这种模式,我们实现了一个不那么复杂的对象结构和易于更改的算法。

MVC 模式

MVC 模式是行业中最流行的设计模式之一。您可能已经听说过它,即使您是行业的新手。这种模式在 Web 开发中被广泛使用。许多流行的 Web 开发框架使用这种设计模式。以下是一些使用 MVC 模式的流行框架:

  • C#: ASP.NET MVC Web Framework

  • Java: Spring 框架

  • PHP: Laravel 框架,Codeigniter 框架

  • Ruby: Rails 框架

MVC 设计模式规定我们应该将 Web 应用程序分为三个部分:

  • 模型

  • 视图

  • 控制器

模型是将保存数据模型或对象并将用于数据库事务的部分。视图指的是应用程序的前端,用户或客户所看到的部分。最后,控制器是处理应用程序所有业务逻辑的部分。所有逻辑和决策部分都将在控制器中。

MVC 模式的好处在于您的应用程序是解耦的。您的视图独立于您的业务逻辑,您的业务逻辑独立于您的数据源。这样,您可以轻松地更改应用程序的一部分,而不会影响应用程序的其他部分。

摘要

软件开发之所以有趣,是因为它一直在变化。您可以以许多方式开发、设计或编写代码。这些方式都不能被归类为最佳方式,因为您的代码可能需要根据情况进行更改。然而,由于软件开发是一种工程类型,有各种规则可以使您的软件更加强大和可靠。软件设计原则和设计模式就是这些规则的例子。了解这些概念并将其应用于您自己的情况将使您作为开发人员的生活更加轻松。

本章希望给您一个设计模式基础的概念,并向您展示可以查找更多信息的地方。在下一章中,我们将了解一个非常强大和有趣的软件,叫做 Git。Git 是一个版本控制系统,有助于跟踪软件代码。

第十六章:Git - 版本控制系统

如今,软件开发已经达到了一个新的水平。它不再仅仅涉及编写代码——软件开发人员现在还必须熟悉一系列重要的工具。没有这些工具,要在团队中工作或高效工作就变得非常困难。版本控制就是其中之一。在众多可用的版本控制系统中,Git 是最流行和最强大的。Git 版本控制已经在行业中存在了相当长的时间,但最近已经成为几乎所有软件公司的一部分。了解 Git 现在对开发人员来说是必不可少的。在本章中,我们将学习关于 Git 版本控制系统的知识。让我们来看看我们将要涵盖的主题:

  • 什么是版本控制系统?

  • Git 的工作原理

  • 在 Windows 中安装 Git

  • Git 的基础知识

  • Git 中的分支

什么是版本控制?

版本控制系统是在开发过程中跟踪软件代码更改的系统或应用程序。软件开发人员过去会通过将代码复制到另一个文件夹或机器中来保留他们的代码备份。如果开发人员或生产机器崩溃,他们可以从备份中取出代码并运行。然而,手动保留和维护备份是麻烦的,容易出错,并且备份系统容易受损。因此,开发人员开始寻找一个能够保护他们代码的系统或应用程序。

版本控制在多个程序员一起工作的情况下也很有用。过去,程序员必须要么在不同的文件上工作以避免冲突,要么在一段时间后仔细地合并代码。手动合并代码是非常危险和耗时的。

在版本控制系统中,代码文件中的每个更改实际上都是代码的一个新版本。在软件行业中,有许多版本控制系统可用,包括 Git、Subversion、Mercurial 和 Perforce。Git 是最流行的版本控制系统,由软件开发者 Linus Torvalds 开发。它是一个非常出色的应用程序,现在几乎在世界上的每个软件公司中使用。

Git 的工作原理

Git 的主要任务是跟踪代码版本并允许开发人员在必要时返回到任何以前的状态。这是通过对每个版本进行快照并在本地文件存储系统中维护来完成的。与其他系统不同,Git 使用本地文件存储来存储快照,这意味着即使没有互联网连接,也可以在本地使用 Git。有了本地版本的 Git,你几乎可以做任何你可以在连接互联网的 Git 版本中做的事情。

在你的项目中安装 Git 之后,你可以选择你的文件系统中想要保留在 Git 版本控制下的目录。通常,Git 中的一个项目或目录被称为仓库。一个仓库可能包含不同的项目,一个项目,或者只是一些项目文件,这取决于你想要在 Git 版本控制中保留什么。你可以有两种方式在本地机器上拥有一个 Git 仓库。你可以自己初始化一个 Git 仓库,或者你可以从远程服务器克隆一个仓库。无论哪种方式,你都会在创建或克隆仓库的同一个文件夹中创建一个名为.git的文件夹。这个.git文件是本地存储文件,所有与该仓库相关的信息都将存储在那里。Git 以非常高效的方式存储数据,因此即使有大量的快照,文件也不会变得很大。

Git 中有三种主要状态,我们将在接下来的章节中探讨:

  • 修改

  • 暂存

  • 提交

修改

当您初始化了一个 Git 仓库,然后添加一个新文件或编辑一个现有文件时,该特定文件将在 Git 中标记为已修改。这意味着该文件包含了 Git 在其本地存储/数据库中已存储的快照中的一些更改。例如,如果您在 Git 仓库中创建一个 C#控制台应用程序项目,那么该解决方案的所有文件都将被标记为已修改,因为它们都不在 Git 仓库历史记录中。

暂存

在 Git 中,暂存指的是准备提交的文件。为了防止不需要的文件意外提交到 Git 存储库中,Git 在已修改和已提交之间引入了这一步骤。当您将文件标记为已暂存时,这意味着您希望在下一次提交中提交这些文件。这也为您提供了编辑文件并不使其处于已暂存状态的选项,以便更改不会保存在存储库中。如果您想在本地机器上应用一些配置,但不希望这些更改出现在存储库中,这个功能非常方便。

已提交

已提交状态是指文件的一个版本已保存在本地数据库中。这意味着已经拍摄并存储在 Git 历史记录中以供将来参考。在远程处理存储库时,您将推送的代码实际上只是已提交的代码。

让我们看一下下面的图表,以了解这些状态之间的流程:

在 Windows 上安装 Git

Git 最初是为基于 Linux 或 Unix 的操作系统开发的。当它在流行度上升并且 Windows 用户开始要求 Git 时,推出了 Git for Windows。在 Windows 上安装 Git 现在是一个非常简单的过程。要安装 Git,请转到git-scm.com/download/win

您将被带到以下截图所示的页面:

Git for Windows 应该会自动开始下载。如果没有开始,您可以点击网站上给出的链接。下载文件将是一个可执行文件,因此要开始安装,请执行可执行文件。在安装过程中,如果您不确定选择什么,这里最好的选择是保持一切默认。

以下截图显示了可以安装哪些组件:

有一个部分可以选择用于 Git 的默认编辑器。所选择的默认编辑器是 Vim,如下截图所示。如果您不习惯使用 Vim,可以将其更改为您喜欢的编辑器:

按照步骤。安装 Git 后,要测试安装是否成功,请转到命令行或 PowerShell 并输入以下内容:

git --version

您应该看到类似以下的输出:

如果您能看到版本号,这意味着安装成功了。

Git 的基础知识

如前所述,Git 最初是为 Linux 系统开发的,这就是为什么使用这个工具的主要方式是通过命令行。在 Windows 上,我们不像 Linux 或 Unix 用户那样经常使用命令行,但使用它可以让您访问 Git 的所有功能。对于 Windows,有一些 GUI 工具可以用于 Git 操作,但它们通常有一些限制。由于命令行是 Git 的首选方法,因此本书中只涵盖命令行命令。

Git 配置

git config命令是用于配置 Git 设置的命令。Git 的最小设置是设置用户名和电子邮件地址。您可以为每个 Git 仓库单独配置,也可以全局配置设置。如果您全局设置配置,您就不必每次初始化 Git 仓库时都配置电子邮件地址和用户名。如果有必要,您可以在每个仓库中覆盖这些设置。

要配置您的电子邮件地址和用户名,请运行以下命令:

git config user.name = "john"
git config user.email = "john@example.com"

如果您想要全局设置配置,您需要添加--global关键字,如下所示:

git config --global user.name = "john"
git config --global user.email = "john@example.com"

如果您想查看其他全局配置设置的可用性,可以使用以下命令:

git config --list

然后您可以更改您想要更改的设置。

Git 初始化

如果您有一个当前尚未使用 Git 版本控制的项目,可以使用以下命令初始化项目:

git init

当您运行上述命令时,您在计算机上安装的 Git 程序会在项目目录中创建一个.git目录,并开始跟踪该项目的源代码。在新项目中初始化 Git 后,所有文件都显示为已修改,您必须将这些文件暂存以提交这些更改。

Git 克隆

如果您想要使用位于远程服务器上的项目,您必须克隆该项目。要克隆项目,您必须使用以下命令:

git clone [repo-url]

例如,如果您想要克隆 Angular 项目,您必须输入以下内容:

git clone https://github.com/angular/angular.git

当您将存储库克隆到本地环境时,将下载.git文件夹。这包括提交历史、分支、标签和远程服务器中包含的所有其他信息。基本上是远程服务器版本的副本。如果您在本地副本中提交更改,然后将其推送到远程存储库,则您的本地副本将与远程副本同步。

Git 状态

在工作时,您会想要检查当前代码的状态。这意味着找出哪些文件被修改了,哪些文件被暂存了。您可以使用以下命令获取所有这些信息:

git status

让我们来看一个例子。如果我们向项目中添加一个名为hello.txt的新文件,并且该文件被 Git 跟踪,并检查其状态,我们将看到如下内容:

在这里,我们可以看到一个名为hello.txt的文件位于未跟踪文件下,这意味着该文件尚未被 Git 跟踪。git status命令还会告诉您当前所在的分支。在这种情况下,我们在master分支中。

Git 添加

git add命令是一个将修改的文件/文件夹添加到 Git 跟踪系统的命令。这意味着这些文件和文件夹将被暂存。命令如下:

git add <file-name/folder-name>

让我们继续我们的例子,看看当我们在 Git 中添加hello.txt文件时会发生什么。为此,我们将执行以下命令:

git add hello.txt

输出如下:

在这里,我们看到了关于换行符LF)和回车换行符CR+LF)的警告,这些是某种格式。替换的原因是我们在这里使用的是 Windows 操作系统,但目前我们不需要担心这个问题。这里的重点是文件已经被正确暂存。现在,如果我们检查状态,我们将看到以下内容:

在这里,我们可以看到hello.txt文件被放置在要提交的更改部分。这意味着该文件已经被暂存。

在一个真实的项目中,您可能会在暂存文件之前同时处理多个不同的文件。逐个添加文件或者用逗号分隔的方式写入文件名可能会非常繁琐。如果您希望将所有修改过的文件都暂存,可以使用以下命令将所有文件添加到暂存区域:

git add *

Git 提交

git commit命令用于将代码提交到 Git 历史记录中。这意味着对代码基础进行快照,并将其存储在 Git 数据库中以供将来参考。要提交文件/文件夹,您必须使用以下命令:

git commit

如果执行上述代码,将打开为 Git 设置的默认编辑器,并要求您输入提交的消息。还有一种更简洁的方法。如果要直接输入提交的消息,可以运行以下命令:

git commit -m "your message"

现在让我们提交我们的hello.txt文件到我们的 Git 存储库。为此,我们将运行以下命令:

git commit -m "committing the hello.txt file with hello message" 

输出应如下屏幕截图所示:

成功提交后,将看到1 file changed, 1 insertion(+)。如果再次检查状态,将看到没有要提交的内容,如下面的屏幕截图所示:

Git log

要检查存储库中进行了哪些提交,可以使用以下命令:

git log

输出将如下所示:

从日志中,我们可以看到目前只有一个提交。我们可以看到提交的哈希值,即紧跟在commit后面的数字。我们可以看到commit是由Raihan Tahermaster分支上进行的。我们还可以在日志中看到commit的消息。这是一个非常有用的命令,可以检查已提交了什么。

Git remote

git remote命令用于查看是否与远程存储库建立了连接。如果运行以下命令,将显示远程存储库的名称。通常,远程名称设置为Origin。您可以有多个远程存储库。让我们看看这个命令:

git remote

如果我们执行此命令,将看不到任何内容,因为还没有远程存储库,如下面的屏幕截图所示:

让我们添加一个远程存储库。我们将使用 GitHub 作为我们的远程服务器。在 GitHub 上创建存储库后,我复制了该存储库的 URL。我们将把它添加到我们的本地存储库。为此,我们使用以下命令:

git remote add <remote-name> <repository-link-remote>

在我们的示例中,命令如下:

git remote add origin https://github.com/raihantaher/bookgitexample.git

添加了远程存储库后,如果执行git remote,将看到origin被列为远程存储库,如下面的屏幕截图所示:

如果要查看有关远程存储库的更多详细信息,可以执行以下命令:

git remote -v

这将显示您添加的远程存储库的 URL,如下面的屏幕截图所示:

Git push

当您想要将本地提交上传或推送到远程服务器时,可以使用以下命令:

git push <remote-repo-name> <local-branch-name>

以下是如何使用此命令的示例:

git push origin master

执行此命令后,如果推送成功,将看到以下消息:

Git pull

git pull命令用于从远程存储库获取最新的代码。由于 Git 是一个分布式版本控制系统,多人可以在一个项目上工作,所以有可能其他人已经用最新的代码更新了远程服务器。要访问最新的代码,请运行以下命令:

git pull <remote-repo-name> <local-branch-name>

以下是如何使用此代码的示例:

git pull origin master

如果运行此代码,将弹出以下消息:

这意味着我们的本地存储库与远程存储库保持同步。如果远程存储库中有新的提交,git pull命令将把这些更改拉到我们的本地存储库,并指示已拉取更改。

Git fetch

git fetch命令与git pull命令非常相似,但是当您使用git fetch时,代码将从远程存储库获取到本地存储库,但不会与您的代码合并。在检查了远程代码后,如果您想要将其与本地代码合并,您必须显式运行git merge命令。执行此操作的命令如下:

git fetch <remote-repo>

如果您运行上述命令,远程存储库中的所有分支将被更新。如果您指定一个本地分支,只有该分支将被更新:

git fetch <remote-repo> <local-branch>

让我们尝试在我们的示例代码中执行git fetch命令:

git fetch origin master

您将看到以下输出:

在 Git 中分支

分支通常被认为是 Git 的最佳功能之一。分支使 Git 与所有其他版本控制系统不同。它非常强大且易于使用。在我们学习不同的分支命令之前,让我简要解释一下 Git 如何处理提交,因为这将帮助您理解 Git 分支。在 Git 中,我们已经知道每个提交都有一个唯一的哈希值,并且该哈希值存储在 Git 数据库中。使用哈希值,每个提交都存储了先前提交的哈希值,这被称为该提交的父提交。除此之外,还存储了另一个哈希值,该哈希值存储了在该提交上暂存的文件,以及提交消息和提交者和作者的信息。对于存储库的第一个提交,父提交为空。

以下图显示了 Git 中哈希的示例:

我们将提交中的所有信息称为快照。如果我们做了三次提交,我们可以说我们有快照 A快照 B快照 C,如下图所示,依次排列:

默认情况下,当您初始化本地 Git 存储库时,会创建一个名为master的分支。这是大多数开发人员将其视为 Git 树中的主要分支的分支。这是可选的;您可以将任何分支视为主分支或生产分支,因为所有分支具有相同的能力和功能。如果您从快照 C提交 3C3简称)创建一个名为feature的分支,分支将从C3提交 3)开始,并且测试分支上的下一个提交将将 C3 视为父提交。

以下图显示了分支:

HEAD是一个指向活动提交或分支的指针。这对开发人员以及 Git 版本控制是一个指示器。当您进行新的提交时,HEAD 将移动到最新的提交,因为这是将作为下一个提交的父提交创建的快照。

创建分支

现在让我们来看一下在 Git 中创建分支的命令。创建分支非常容易,因为它不会将整个代码库复制到一个新的位置,而是只保持与 Git 树的关系。有几种创建分支的方法,但最常见的方法如下:

git branch feature

在命令行上应该如下所示:

查看可用分支

要查看本地 Git 存储库中有哪些分支可用,可以输入以下命令:

git branch

执行上述代码后,您应该看到以下输出:

我们可以看到我们的本地存储库中有两个分支。一个是master分支,另一个是feature分支。*字符表示 HEAD 指向的位置。

切换分支

在前面的示例中,我们看到,即使创建了 feature 分支,HEAD 仍然指向 master。切换到另一个分支的命令如下:

git checkout <branch-name>

在我们的示例中,如果我们想从master切换到feature分支,我们必须输入以下命令:

git checkout feature

输出如下:

运行命令后,我们可以看到 Git 已经切换到了feature分支。现在我们可以再次运行git branch命令来查看 HEAD 指向的位置,如下截图所示:

很可能,当你创建一个分支时,你会想立即在该分支上工作,所以有一个快捷方式可以创建一个分支然后切换到它,如下代码所示:

git checkout -b newFeature

删除一个分支

要删除一个分支,你需要执行以下命令:

git branch -d feature

如果分支成功删除,你应该会看到类似下面截图中显示的消息:

在 Git 中合并

合并一个分支到另一个分支,你需要使用merge命令。记住,你需要在你要将代码合并的分支上执行该命令,而不是在将要被合并的分支上,或者其他任何分支上。命令如下:

git merge newFeature

输出应该如下:

总结

在本章中,我们学习了一个与 C#编程语言不直接相关,但对 C#开发人员来说仍然是一个必不可少的工具的概念。微软最近收购了 GitHub,这是基于 Git 的最大远程代码仓库网站,并将大多数微软的 IDEs/编辑器与之集成,包括最新的代码编辑器 Visual Code。这显示了 Git 对我们行业有多么重要。我相信每个开发人员,无论是新手还是资深人员,都应该为他们的代码使用版本控制。如果你不使用 Git,你可以使用市场上的任何其他版本控制系统。然而,Git 是最好的,即使你在工作中没有使用 Git,我也建议你在个人项目中使用它。Git 命令非常简单,所以你只需要练习几次就能完全理解它。

下一章有点不同。我们将看一些在面试中常被问到的问题。

第十七章:为面试和未来做准备

这是一本面向对象编程OOP)书中不同寻常的一章。面试是软件开发人员职业生涯中的重要组成部分。面试就像是对你知识的一次考验。它让你了解自己的知识水平以及你应该学习更多的内容。这也是向其他公司的经验丰富的开发人员学习的一种方式。

本章的主要目的是让你了解在工作面试中会被问到的问题类型,以及如何为此做好准备。请记住,工作面试问题取决于你申请的职位、公司、面试官的知识以及公司正在使用的技术栈。虽然不是所有这些问题都会被问到,但有可能会问到其中一些,因为这些问题决定了你的基本面向对象编程和 C#知识。

让我们回顾一下本章将涵盖的主题:

  • 面试问题

  • 面试和职业技巧

  • 接下来要学习的事情

  • 阅读的重要性

面试问题

在本节中,我们将讨论初学者到中级开发人员的一些最常见的面试问题。由于本书是关于 C#的,我们还将提出与 C#编程语言直接相关的问题。

面向对象编程的基本原则是什么?

面向对象编程有四个基本原则:

  • 继承

  • 封装

  • 抽象

  • 多态性

什么是继承?

继承意味着一个类可以继承另一个类的属性和方法。例如,Dog是一个类,但它也是Animal的子类。Animal类是一个更一般的类,具有所有动物都具有的基本属性和方法。由于狗也是一种动物,Dog类可以继承Animal类,因此Animal类的所有属性和方法也可以在Dog类中使用。

什么是封装?

封装意味着隐藏类的数据。C#中的访问修饰符主要用于封装的目的。如果我们将方法或字段设为私有,那么该方法或字段在类外部是不可访问的。这意味着我们将数据隐藏在外部世界之外。封装的主要原因是我们希望隐藏更复杂的实现,只向外部世界展示简单的接口以便于使用。

什么是抽象?

抽象是一个概念,不是真实的东西。抽象意味着向外部世界提供某个对象的概念,但不提供它的实现。接口和抽象类是抽象的例子。当我们创建一个接口时,我们不实现其中的方法,但当一个类实现接口时,它也必须实现该方法。这意味着接口实际上给出了类的抽象印象。

什么是多态性?

多态性意味着多种形式。在面向对象编程中,我们应该有创建一种东西的多种形式的选项。例如,你可以有一个addition方法,它可能有不同的实现,取决于它接收的输入。一个接收两个整数并返回这些整数的和的addition方法可能是一种实现。还可能有另一种形式的addition方法,它可能接受两个双精度值并返回这些双精度值的和。

什么是接口?

接口是 C#编程语言中用于在程序中应用抽象的实体或特性。它就像是类和接口本身之间的合同。合同是继承接口的类必须实现接口本身具有的方法签名。接口不能被实例化,只能由类或结构实现。

什么是抽象类?

抽象类是一种特殊类型的类,不能被初始化。无法从抽象类创建对象。抽象类可以有具体方法和非具体方法。如果一个类实现了抽象类,那么这个类必须实现抽象方法。如果需要,它可以重写非抽象方法。

什么是密封类?

密封类是一种不能被继承的类。它主要用于阻止 C#中的继承特性。

什么是部分类?

部分类是源代码分布在不同文件中的类。通常,一个类的所有字段和方法都在同一个文件中。在部分类中,可以将类代码分开放在不同的文件中。编译时,所有来自不同文件的代码被视为单个类。

接口和抽象类之间有哪些区别?

接口和抽象类之间的主要区别如下:

  • 一个类可以实现任意数量的接口,但只能实现一个抽象类。

  • 抽象类既可以有抽象方法,也可以有非抽象方法,而接口不能有非抽象方法。

  • 在抽象类中,数据成员默认为私有,而在接口中,所有数据成员都是公共的,这是无法更改的。

  • 在抽象类中,我们需要使用abstract关键字使方法成为抽象的,而在接口中不需要这样做。

方法重载和方法重写之间有什么区别?

方法重载是指具有相同名称的方法具有不同的输入参数。例如,假设我们有一个名为Sum的方法,它接受两个整数类型的输入并返回一个整数类型的输出。Sum的重载方法可以接受两个双精度类型的输入并返回一个双精度输出。

方法重写是指在子类中实现具有相同名称、相同参数和相同返回类型的方法,但具有不同的实现方式。例如,假设我们在一个名为Sales的类中有一个名为Discount的方法,其中折扣是总购买额的 2%。如果我们有Sales的另一个子类NewYearSales,其中折扣是总购买额的 5%,使用方法重写,NewYearSales类可以轻松应用新的实现。

访问修饰符是什么?

访问修饰符用于设置编程语言中不同实体的安全级别。通过设置访问修饰符,我们可以隐藏不同级别类的数据。

在 C#中,有六种类型的访问修饰符:

  • 公共

  • 私有

  • 受保护的

  • 内部的

  • 受保护的内部

  • 私有受保护

什么是装箱和拆箱?

装箱是将值类型转换为对象的过程。拆箱是从对象中提取值类型的过程。装箱可以隐式进行,但拆箱必须在代码中显式进行。

结构体和类之间有哪些区别?

结构体和类是非常相似的概念,但有一些区别:

  • 结构体是值类型,而类是引用类型。

  • 结构体通常用于少量数据,而类用于大量数据。

  • 结构体不能被其他类型继承,而类可以被其他类继承。

  • 结构体不能是抽象的,而类可以是抽象的。

C#中的扩展方法是什么,我们如何使用它?

扩展方法是一种在不创建新派生类型或编译或更改现有类型的情况下添加到现有类型的方法。它就像一个扩展。例如,默认情况下,我们从.NET 框架中得到字符串类型。如果我们想要向这个字符串类型添加另一个方法,要么我们必须创建一个派生类型来扩展这个字符串类型并在那里放置方法,要么我们在.NET 框架中添加代码并编译和重建库。然而,使用扩展方法,我们可以轻松地扩展现有类型中的方法。为此,我们必须创建一个静态类,然后创建一个静态的扩展方法。这个方法应该以类型作为参数,但在字符串之前应该放置this关键字。现在这个方法将作为该类型的扩展方法工作。

托管代码和非托管代码是什么?

在.NET 框架中开发的代码称为托管代码。公共语言运行时CLR)可以直接执行这些代码。非托管代码不是在.NET 框架中开发的。

C#中的虚方法是什么?

虚方法是在基类中实现的方法,但也可以在子类中被重写。虚方法不能是抽象的、静态的、私有的或重写的。

你对 C#中的值类型和引用类型理解如何?

在 C#中,有两种类型的数据。一种称为值类型,另一种称为引用类型。值类型是直接在内存位置中保存值的类型。如果值被复制,一个新的内存位置保存相同的值,两者相互独立。引用类型是指值不直接放在内存位置中,而是设置对值的引用。值类型和引用类型之间的另一个主要区别是,值类型位于堆栈中,而引用类型位于堆中。值类型的例子是int,而引用类型的例子是string

设计原则是什么?

有五个设计原则组成了SOLID的首字母缩写:

  • 单一责任原则

  • 开闭原则

  • 里氏替换原则

  • 接口隔离原则

  • 依赖反转原则

单一责任原则

“一个类应该只有一个改变的理由。”

  • 罗伯特·C·马丁

这意味着一个类应该只有一个责任。如果一个类做了多件事情,这就违反了单一责任原则SRP)。例如,如果我们有一个名为Student的类,它应该只负责与学生相关的数据。如果Student类在Teacher类中的任何更改时需要修改,那么Student类就违反了 SRP。

开闭原则

软件组件应该对扩展开放,但对修改关闭。这意味着组件应该被设计成这样,如果需要添加新的规则或功能,就不需要修改现有的代码。如果需要修改现有的代码来添加新功能,这意味着组件违反了开闭原则

里氏替换原则是什么?

派生类型必须完全可替代其基类型。这意味着如果你有一个基类的实例在某处使用,你应该能够用该基类的子类实例替换基类实例。例如,如果你有一个名为Animal的基类和一个名为Dog的子类,你应该能够用Dog类的实例替换Animal类的实例而不会破坏任何功能。

接口隔离原则是什么?

客户不应该被迫依赖于他们不使用的接口。有时,接口包含许多可能不被实现它们的类使用的信息。接口隔离原则建议你保持接口的小型化。类不应该实现一个大接口,而应该实现多个小接口,其中类中的所有方法都是需要的。

什么是依赖反转原则?

高级模块不应该依赖于低级模块;两者都应该依赖于抽象。这意味着,当你开发模块化软件代码时,高级模块不应该直接依赖于低级模块,而应该依赖于低级模块实现的接口或抽象类。通过这样做,系统中的模块是独立的,将来如果你用另一个模块替换低级模块,高级模块不会受到影响。

这个原则的另一个部分是抽象不应该依赖于细节,细节应该依赖于抽象。这意味着接口或抽象类不应该依赖于类,而实现接口和抽象类的类应该依赖于接口或抽象类。

面试和职业技巧

现在我们已经涵盖了一些你在面试中可能会被问到的最常见问题,我还有一些提示,可以帮助你在面试和职业生涯中表现更好。

提高你的沟通技巧

人们普遍认为软件开发人员不合群,沟通能力不强。然而,现实情况却截然不同。所有成功的开发人员必须具备良好的沟通能力。

作为一名软件开发人员,你会有时需要向非技术人员解释技术理念或情况。为了能够做到这一点,你必须以一种使信息对每个人都易于访问和理解的方式进行沟通。这可能包括口头(会议或讨论)和书面沟通(文档或电子邮件)。

在你的职业生涯开始时,你可能并不一定理解沟通的重要性,因为你只是被分配任务来完成。然而,随着你的经验积累和职业发展,你会意识到有效沟通的重要性。

作为一名资深开发人员,你可能需要与初级开发人员沟通,解释问题或解决方案,或者与业务团队沟通,以确保你充分理解业务需求。你可能还需要进行技术培训,以进行知识分享。

因此,请确保你与人们保持互动,并阅读资源,这些资源将帮助你有效沟通,并教你如何与你的听众交流。良好的沟通技巧不仅将帮助你在面试中脱颖而出,而且在整个职业生涯中也将对你非常有价值。

继续练习

虽然没有完美的软件开发人员,但通过定期练习,你可以成为一个知识渊博、经验丰富的软件开发人员。

计算机编程是一门艺术。通过犯错误,你会培养出对错与对的感觉。你编写的代码越多,你会经历更多不同的情况。这些情况将帮助你积累经验,因为你很可能在未来的项目中再次遇到它们。

而学习或掌握编程的最佳方法是实践

尝试将你在本书中学到的概念应用到你的实际项目中。如果在你当前的项目中不可能做到这一点,那就创建演示项目并在那里应用它们。技术概念是非常实用的;如果你进行实际的实现,这些概念将变得非常清晰。

接下来要学习的事情

阅读完这本书后,你应该对面向对象编程和 C#编程语言有更好的理解。然而,这还不够。你必须努力学习更多关于软件开发的知识。你应该学习 C#的其他语言特性,以及如何使用它们来完成工作。你还应该学习数据结构和算法来应对你的专业工作。在下面的列表中,我建议你研究一些主题和技术:

  • C#编程语言特性,如运算符、控制语句、数组、列表、运算符重载、Lambda 表达式、LINQ、字符串格式化和线程

  • 诸如链表、二叉树、排序和搜索算法等数据结构和算法

  • 诸如 ASP.NET MVC、ASP.NET Web API、WPF 和 WCF 等 Web/桌面框架

  • 诸如 HTML、CSS 和 JavaScript 等前端技术,以及 reactjs/angular 等 JavaScript 框架

  • 诸如 MS SQL Server、Oracle 和 MySQL 等数据库技术

  • 设计模式及其影响

  • 软件架构和设计

  • 清晰的代码、代码重构和代码优化

还有许多其他需要学习的东西,但我已经涵盖了我认为每个软件开发人员都应该了解的主题。这个列表相当长,主题也相当技术化,所以要仔细规划你的学习。

养成阅读的习惯

我最后的建议是成为一个热心的读者。阅读对软件开发人员非常重要。信息通常通过文本或语音分发给人们。虽然视频教程是学习的好方法,但阅读可以给你时间思考,并为你提供数百万资源的访问。

以下是我必读的一些书籍:

  • Andrew Hunt 和 David Thomas 的《实用程序员:从学徒到大师》

  • Robert Cecil Martin 的《代码整洁之道》

  • Steve McConnell 的《代码大全 2》

  • Martin Fowler 和 Kent Beck 的《重构》

  • Charles E. Leiserson、Clifford Stein、Ronald Rivest 和 Thomas H. Cormen 的《算法导论》

  • 《设计模式:可复用面向对象软件的元素》

  • Joseph Albahari 的《C# 7.0 权威参考》

  • Jon Skeet 的《深入理解 C#》

总结

软件开发是一个非常有趣的领域。你可以开发出可以改变世界的惊人应用。像 Facebook 和地图这样的应用,以及谷歌和 Windows 等数字巨头的众多产品,对我们的生活产生了重大影响。程序可以通过提高生产力来让人们的生活变得更加轻松。

作为一名软件开发人员,我请求你写出优秀的代码,开发出令人惊叹的应用。如果你有正确的意图、对软件开发的热情和强烈的职业道德,你一定会在你的职业生涯中取得成功。

让我们通过创建能够促进人类文明进步的惊人软件,让这个世界变得更美好。

posted @ 2024-05-17 17:50  绝不原创的飞龙  阅读(4)  评论(0编辑  收藏  举报