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

译者:飞龙

协议:CC BY-NC-SA 4.0

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

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

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

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

  • 理解代理

  • 处理和引发事件

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

技术要求

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

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

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

理解代理

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

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

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

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

delegate的一般语法如下:

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

代理声明的示例如下:

public delegate string delegateexample (string strVariable);

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

实例化一个委托

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

使用命名方法初始化委托

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

使用匿名函数初始化委托

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

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

有两种匿名函数:

  • Lambda 表达式

  • 匿名方法

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

Lambda 表达式

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

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

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

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

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

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

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

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

//Output:
drum
drum

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

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

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

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

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

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

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

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

Input-Parameters => Expression

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

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

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

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

}

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

匿名方法

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

委托中的方差

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

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

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

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

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

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

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

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

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

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

//Output:
ChildMessage1
ChildMessage2

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

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

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

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

delegate void contravrianceDelegate(Child1ReturnClass variable1);

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

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

}

//Output:
ChildMessage1

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

内置委托

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

  • Func

  • Action

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

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

public delegate TResult Func<out TResult>();

Action 可以定义为以下内容:

public delegate void Action();

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

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

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

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

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

多播委托

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

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

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

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

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

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

处理和引发事件

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

摘要

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

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

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

问题

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

    1. 多播代理

    2. 内置委托

    3. 回调

    4. 事件

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

    1. 匿名方法

    2. Lambda 表达式

    3. 命名方法

    4. 所有上述选项

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

    1. 匿名方法

    2. 协变

    3. 匿名函数

    4. Lambda 表达式

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

    1. Action

    2. Func

    3. event

    4. delegate

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

    1. event

    2. delegate

    3. EventHandler

    4. class

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

    1. True

    2. False

答案

  1. 回调

  2. 所有上述选项

  3. 协变

  4. Action

  5. event

  6. False

进一步阅读

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

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

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

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

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

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

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

  • 理解线程和线程过程

  • 多线程中的数据同步

  • 多线程

技术要求

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

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

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

理解线程和线程过程

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

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

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

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

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

线程管理

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

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

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

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

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

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

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

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/fca1f269-d3ff-4f46-8c44-8455d1ac33a3.png

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

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

线程属性

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

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

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

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

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

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

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/aca30cc8-553f-4a9a-8a8f-e5bc29f52fa6.png

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

参数化线程

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

public delegate void ParameterizedThreadStart(object obj)

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

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

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

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

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/f28de47d-495f-4785-a190-8d100e291557.png

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

前台和后台线程

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

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

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

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

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/67377582-684b-4fc3-ac09-29a9b7eb8ac8.png

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

线程状态

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

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/575bba21-2b4f-4c23-8c09-30d501edae13.png

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

销毁线程

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

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

线程池

线程池提供了一组线程,这些线程可以用作工作线程并由系统管理。这使我们能够专注于应用程序逻辑而不是管理线程。这是我们使用多线程的简单方法。从 .NET 框架 4 开始,使用线程池变得容易,因为它们允许我们创建任务并执行异步任务。任务并行库TPL)和异步方法调用主要依赖于线程池。

从线程池创建的线程是后台线程。每个线程使用默认属性。当线程完成任务时,它将返回到等待线程的队列中,以便可以重用。反过来,这减少了为每个任务创建新线程的成本。每个进程可以有一个线程池。

.NET 框架允许我们为线程池设置和获取 MaxThread,尽管可以排队的线程数量受可用内存限制。一旦线程池中的线程忙碌,其他任务将排队,直到线程可用。

重要的是要理解,线程池中任何未处理的异常都将终止此进程。有关线程池的更多信息,请参阅docs.microsoft.com/en-us/dotnet/standard/threading/the-managed-thread-pool

以下示例展示了我们如何使用线程池创建多个线程:

 public static void PoolOfThreads()
 {
     Console.WriteLine("Primary Thread Id: {0}" + Environment.NewLine + "Thread State: {1}" + Environment.NewLine + "Priority {2}" ,
                              Thread.CurrentThread.ManagedThreadId,
                              Thread.CurrentThread.ThreadState,
                              Thread.CurrentThread.Priority);
    PoolProcessmethod();
    //Thread.CurrentThread.Join();
 }
private static void PoolProcessmethod()
{
    for (int i = 0; i < 5; i++)
    {
        ThreadPool.QueueUserWorkItem(new WaitCallback(PoolMethod)); 
    }
}
private static void PoolMethod(object callback)
{
    Thread.Sleep(1000);
    Console.WriteLine("ThreadPool Thread Id: {0}" + Environment.NewLine + "Thread State: {1}" + Environment.NewLine + "Priority {2}" + Environment.NewLine + "IsBackground: {3}" +Environment.NewLine + "IsThreadPoolThread: {4}",
                              Thread.CurrentThread.ManagedThreadId,
                              Thread.CurrentThread.ThreadState,
                              Thread.CurrentThread.Priority,
                              Thread.CurrentThread.IsBackground,
                              Thread.CurrentThread.IsThreadPoolThread);

}

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

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/edce518e-f26b-4179-8b24-7f39e97f9179.png

在这里,我们使用线程池创建了五个工作线程。如果你在前面代码中取消注释 Thread.CurrentThread.Join,主线程将不会退出,直到所有线程都已被处理。

线程存储

线程相关的静态字段和数据槽是我们存储线程和应用域唯一数据的两种方式。线程相关的静态字段在编译时定义,并提供最佳性能。另一个好处是它们在编译时进行类型检查。当事先明确需要存储哪种类型的数据时,使用这些字段。

可以使用 ThreadStaticAttribute 创建线程相关的静态字段。

在某些场景中,这些存储需求可能在运行时出现。在这种情况下,我们可以选择数据槽。这些比静态字段慢一些。由于它们是在运行时创建的,因此它们以对象类型存储信息。在使用它们之前,将对象转换为它们相应的类型对我们来说很重要。

.NET 框架允许我们创建两种类型的数据槽:命名数据槽和未命名数据槽。命名数据槽使用 GetNamedDataSlot 方法,这样我们就可以在需要时检索它。然而,NamedDataslot 的一个缺点是,当来自同一应用程序域的两个线程在两个不同的代码组件中使用相同的数据槽并在同一时间执行时,它们可能会互相破坏数据。

ThreadLocal<T> 可以用来创建局部数据存储。

这两种存储数据的方式可以被称为 线程局部存储TLS)。管理 TLS 的几个好处如下:

  • 在一个应用程序域内,一个线程不能修改另一个线程的数据,即使两个线程使用相同的字段或槽位。

  • 当一个线程从多个应用程序域访问相同的字段或槽位时,每个应用程序域都维护一个单独的值。

现在,我们将进入一个示例,看看如何使用 ThreadStatic 属性。在下面的示例中,定义了一个静态变量,并用 ThreadStatic 属性进行了装饰。这确保了每个线程都有自己的变量副本。当你执行以下程序时,你会观察到 _intvariable 对每个线程都增加到 6:

[ThreadStatic]
public static int _intvariable;
public static void ThreadStaticSample()
{
    //Start three threads
    new Thread(() =>
    {
        for (int i = 0; i <= 5; i++)
        {
            _intvariable++;
            Console.WriteLine($"Thread Id:{Thread.CurrentThread.ManagedThreadId}, Int field Value:{_intvariable}");
        }
    }).Start();

    new Thread(() =>
    {
        for (int i = 0; i <= 5; i++)
        {
            _intvariable++;
            Console.WriteLine($"Thread Id:{Thread.CurrentThread.ManagedThreadId}, Int field Value:{_intvariable}");
        }
    }).Start();

    new Thread(() =>
    {
        for (int i = 0; i <= 5; i++)
        {
            _intvariable++;
            Console.WriteLine($"Thread Id:{Thread.CurrentThread.ManagedThreadId}, Int field Value:{_intvariable}");
        }
    }).Start();

}

以下截图显示了运行前面程序的结果。注释掉 ThreadStatic 属性并再次运行程序——你会发现 _intvariable 的值增加到 18,因为每个线程都会更新其值:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/f843c56b-b2b7-4ab2-9fd3-998b1398a21c.png

让我们看看如何使用 ThreadLocal<T> 来创建局部存储:

 public static ThreadLocal<string> _threadstring = new ThreadLocal<string>(() => {
    return "Thread " + Thread.CurrentThread.ManagedThreadId; });
public static void ThreadLocalSample()
{

    //Start three threads
    new Thread(() =>
    {
        for (int i = 0; i <= 5; i++)
        {
            Console.WriteLine($"First Thread string :{_threadstring}");
        }
    }).Start();

    new Thread(() =>
    {
        for (int i = 0; i <= 5; i++)
        {
            Console.WriteLine($"Second Thread string :{_threadstring}");
        }
    }).Start();

    new Thread(() =>
    {
        for (int i = 0; i <= 5; i++)
        {
            Console.WriteLine($"Third Thread string :{_threadstring}");
        }
    }).Start();

}

上述代码的输出如下:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/8d01b785-5fbc-4c3a-a685-ca0dcaeb507e.png

现在我们已经了解了如何管理线程,让我们看看如何在多线程中同步数据。

多线程中的数据同步

多个线程可以调用一个对象的方法或属性,这可能会使对象的状态无效。对同一对象进行两个或更多线程的冲突更改是可能的。这使得同步这些调用变得很重要,这将使我们能够避免此类问题。当一个类的成员受到冲突更改的保护时,它们被认为是 线程安全的

CLR 提供了多种方式,我们可以通过这些方式同步对对象实例和静态成员的访问:

  • 同步代码区域

  • 手动同步

  • 同步上下文

  • 线程安全集合

默认情况下,对象没有同步,这意味着任何线程都可以在任何时候访问方法和属性。

同步代码区域允许我们同步代码块、方法和静态方法。然而,不支持同步静态字段。如果我们使用 Monitor 类或关键字,则可以进行同步。C# 支持使用 lock 关键字来标记需要同步的代码块。

当应用时,线程在执行代码时会尝试获取锁。如果另一个线程已经获取了此代码块的锁,则该线程会阻塞,直到锁可用。当线程执行代码块或以其他方式退出时,锁会被释放。

MethodImplAttributeMethodImplOptions.Synchronized 与使用 Monitor 或关键字锁定代码块得到相同的结果。

让我们通过一个示例来了解使用任务进行锁定语句。在接下来的章节中,我们将了解更多关于任务的内容。

为了本例的目的,我们创建了一个 Account 类,通过将其锁定到实例来同步其私有字段余额。这确保了没有两个线程同时更新此字段:

 internal class BankAcc
    {
        private readonly object AcountBalLock = new object();
        private decimal balanceamount;
        public BankAcc(decimal iBal)
        {
            balanceamount = iBal;
        }
        public decimal Debit(decimal amt)
        {
            lock (AcountBalLock)
            {
                if (balanceamount >= amt)
                {
                    Console.WriteLine($"Balance before debit :{balanceamount,5}");
                    Console.WriteLine($"Amount to debit     :{amt,5}");
                    balanceamount = balanceamount - amt;
                    Console.WriteLine($"Balance after debit  :{balanceamount,5}");
                    return amt;
                }
                else
                {
                    return 0;
                }
            }
        }
        public void Credit(decimal amt)
        {
            lock (AcountBalLock)
            {
                Console.WriteLine($"Balance before credit:{balanceamount,5}");
                Console.WriteLine($"Amount to credit        :{amt,5}");
                balanceamount = balanceamount + amt;
                Console.WriteLine($"Balance after credit :{balanceamount,5}");
            }
        }
    }

TestLockStatements( ) 方法如下所示:

//Create methods to test this Account class
public static void TestLockStatements()
{
    var account = new BankAcc(1000);
    var tasks = new Task[2];
    for (int i = 0; i < tasks.Length; i++)
    {
        tasks[i] = Task.Run(() => UpdateAccount(account));
    }
    Task.WaitAll(tasks);
}
private static void UpdateAccount(BankAcc account)
{
    var rnd = new Random();
    for (int i = 0; i < 10; i++)
    {
        var amount = rnd.Next(1, 1000);
        bool doCredit = rnd.NextDouble() < 0.5;
        if (doCredit)
        {
            account.Credit(amount);
        }
        else
        {
            account.Debit(amount);
        }
    }
}

我们创建了两个任务,每个任务都调用 UpdateMethod。此方法循环 10 次,并使用信用或借记方法更新账户余额。因为我们使用的是实例级别的 lock(obj) 字段,所以余额字段不会同时更新。

以下代码显示了所需的输出:

Balance before debit : 1000
Amount to debit : 972
Balance after debit : 28
Balance before credit: 28
Amount to credit : 922
Balance after credit : 950
Balance before credit: 950
Amount to credit : 99
Balance after credit : 1049
Balance before debit : 1049
Amount to debit : 719
Balance after debit : 330
Balance before credit: 330
Amount to credit : 865
Balance after credit : 1195
Balance before debit : 1195
Amount to debit : 962
Balance after debit : 233
Balance before credit: 233
Amount to credit : 882
Balance after credit : 1115
Balance before credit: 1115
Amount to credit : 649
Balance after credit : 1764
Balance before credit: 1764
Amount to credit : 594
Balance after credit : 2358
Balance before debit : 2358
Amount to debit : 696
Balance after debit : 1662
Balance before credit: 1662
Amount to credit : 922
Balance after credit : 2584
Balance before credit: 2584
Amount to credit : 99
Balance after credit : 2683
Balance before debit : 2683
Amount to debit : 719
Balance after debit : 1964
Balance before credit: 1964
Amount to credit : 865
Balance after credit : 2829
Balance before debit : 2829
Amount to debit : 962
Balance after debit : 1867
Balance before credit: 1867
Amount to credit : 882
Balance after credit : 2749
Balance before credit: 2749
Amount to credit : 649
Balance after credit : 3398
Balance before credit: 3398
Amount to credit : 594
Balance after credit : 3992
Balance before debit : 3992
Amount to debit : 696
Balance after debit : 3296
Press any key to exit.

在多个线程之间访问共享变量可能会导致数据完整性问题。这些问题可以通过使用同步原语来解决。这些原语由 System.Threading.WaitHandle 类派生。在执行手动同步时,原语可以保护对共享资源的访问。不同的同步原语实例用于保护对资源或某些代码访问部分的访问,这允许多个线程并发访问资源。

你可以在 docs.microsoft.com/en-us/dotnet/standard/threading/overview-of-synchronization-primitives 上了解更多关于同步原语的信息。

.NET Framework 引入了 System.Collections.Concurrent 命名空间,可以在用户代码中无需额外同步的情况下使用。此命名空间包括几个线程安全和可扩展的集合类。这允许多个线程向这些集合添加或从中删除项目。

更多关于这些线程安全集合的信息可以在 docs.microsoft.com/en-us/dotnet/standard/collections/thread-safe/index 上找到。

多线程

开发者可以在进程内创建多个线程,并在整个程序执行过程中管理它们。这使我们能够专注于应用程序逻辑,而不是管理线程。然而,从 .NET Framework 4 开始,我们可以使用以下方法创建多线程程序:

  • TPL

  • 并行语言集成查询PLINQ

为了理解这两个功能,我们需要讨论并行编程。

并行编程

并行编程帮助开发者利用工作站上的硬件,这些工作站拥有多个 CPU 核心。它们允许多个线程并行执行。

在之前的版本中,并行化需要低级线程和锁的操作。从.NET Framework 4 开始,提供了对并行编程的增强支持,形式为运行时、类库类型和诊断工具。

下面的图示显示了并行编程的高级架构:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/955ad58e-8fd8-4cce-9825-93e9914c6db6.png

在接下来的章节中,我们将讨论前面架构图中列出的一些组件。

TPL

TPL 通过创建并行和并发应用程序,使开发者更加高效。这些类型作为System.ThreadingSystem.Threading.Tasks命名空间中的公共类型提供。TPL 允许我们在关注程序工作的同时,最大化代码性能。TPL 基于任务,代表一个线程或线程池。当一个或多个任务并发运行时,这被称为任务并行。任务有几个好处:可扩展性和效率,以及比线程更多的程序控制能力。

因为 TPL 处理工作的分割、调度、取消、状态和其他底层细节,它可以动态地调整并发程度,并使用可用的系统资源或处理器。

了解何时应用并行编程很重要,否则并行化的开销会降低代码执行速度。对线程概念如锁和死锁的基本理解很重要,这样我们才能有效地使用 TPL。

数据并行

当操作可以在源集合元素上并发执行时,这被称为数据并行。在这个过程中,源集合被分割成多个线程并行执行。.NET Framework 通过System.Threading.Tasks.Parallel类支持数据并行。Parallel.ForParallel.ForEach等方法定义在这个类中。当你使用这些方法时,框架会为我们管理所有底层工作。

任务代表一个可能返回也可能不返回值的异步操作,并在System.Threading.Tasks类中定义。

使用任务

任务代表一个可能返回也可能不返回值的操作,并异步执行。由于它们是异步执行的,因此它们作为线程池中的工作线程而不是主线程来执行。这允许我们使用isCanceledIsCompleted属性来了解任务的状态。您还可以使任务同步运行,这将执行在主线程或主要线程上。

任务可以实现IAsyncResultIDisposable接口,如下所示:

public class Task : IAsyncResult, IDisposable

让我们通过一个示例来了解我们如何以不同的方式创建和启动任务。在这个例子中,我们将使用一个接受object类型参数的操作委托:

public static void Run()
{
    Action<object> action = (object obj) =>
    {
        Console.WriteLine("Task={0}, Milliseconds to sleep={1}, Thread={2}",Task.CurrentId, obj,
        Thread.CurrentThread.ManagedThreadId);
        int value = Convert.ToInt32(obj);
        Thread.Sleep(value);
    };

    Task t1 = new Task(action, 1000);
    Task t2 = Task.Factory.StartNew(action, 5000);
    t2.Wait();
    t1.Start();
    Console.WriteLine("t1 has been started. (Main Thread={0})",
                      Thread.CurrentThread.ManagedThreadId);
    t1.Wait();

    int taskData = 4000;
    Task t3 = Task.Run(() => {
        Console.WriteLine("Task={0}, Milliseconds to sleep={1}, Thread={2}",
                          Task.CurrentId, taskData,
                           Thread.CurrentThread.ManagedThreadId);
    });
    t3.Wait();

    Task t4 = new Task(action, 3000);
    t4.RunSynchronously();
    t4.Wait();
}

在这里,我们创建了四个不同的任务。对于第一个任务,我们使用了启动方法,而对于第二个任务,我们使用了task.factory.startnew方法。第三个任务使用run(Action)方法启动,而第四个任务使用同步运行方法在主线程上同步执行。在这里,任务 1、2 和 3 是使用线程池的工人线程,而任务 4 在主线程上执行。

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

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/2796ffa5-1d98-4b42-b575-49621395d9e5.png

Wait方法类似于Thread.Join,它等待任务完成。这在同步调用线程和异步任务执行时很有用,因为我们可以等待一个或多个线程完成。Wait方法还接受某些参数,允许我们有条件地等待任务完成。

以下表格显示了线程在等待时可以使用的不同选项:

Wait 等待任务执行完成。
Wait(int32) 使任务在执行前等待指定数量的毫秒。
Wait(Timespan) 在指定的时间间隔内等待任务执行完成。
Wait(CancellationToken) 等待任务执行完成。如果cancellationToken在任务执行完成前发出,则等待终止。
Wait(Int32, CancellationToken) 等待任务执行完成。等待在超时或任务完成前发出取消令牌时终止。
WaitAll 等待所有提供的任务完成其执行。类似于Wait方法,WaitAll接受多个参数并相应地执行它们。
WaitAny 等待提供的任务完成其执行。类似于Wait方法,WaitAny接受多个参数并相应地执行它们。

任务支持另外两种方法:WhenAllWhenAny。现在,WhenAll用于创建一个任务,当所有提供的任务都完成时,该任务将完成其执行。同样,WhenAny创建任务并在提供的任务完成其执行时完成。

任务也可以返回一个值。然而,读取任务的输出意味着等待其执行完成。在没有完成执行的情况下,无法使用结果对象。以下是一个示例:

public static void TaskReturnSample()
{
    Task<int> t = Task.Run(() => { return 30 + 40; });
    Console.WriteLine($"Result of 30+40: {t.Result}");
}

通过执行前面的代码,您将看到主线程会等待任务返回一个值。然后,它显示一个按任意键退出的消息:

Result of 30+40: 70
Press any key to exit.

还可以添加一个后续任务。.NET Framework 提供了一个名为ContinueWith的关键字,它允许你在前一个任务执行完毕后创建并执行一个新任务。在以下代码中,我们指示任务使用父任务的结果继续执行:

public static void TaskContinueWithSample()
{
    Task<int> t = Task.Run(() => 
        {
            return 30 + 40;
        }
    ).ContinueWith((t1) => 
    {
        return t1.Result * 10;
    });
    Console.WriteLine($"Result of two tasks: {t.Result}");
}

当任务t完成其执行时,结果被用于第二个任务t1,并显示最终结果:

Result of two tasks: 700
Press any key to exit.

ContinueWith有几个重载方法,允许我们配置后续任务何时执行,例如在任务取消或成功完成后。为了使此配置生效,我们将使用TaskContinuationOptions。更多可用的选项可以在docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskcontinuationoptions?view=netframework-4.7.2找到。

以下代码块展示了如何使用continuationOptions

Task<int> t = Task.Run(() => 
{
    return 30 + 40;
}
).ContinueWith((t1) => 
{
    return t1.Result * 10;
},TaskContinuationOptions.OnlyOnRanToCompletion);

TaskFactory支持创建和调度任务。它还允许我们执行以下操作:

  • 使用StartNew方法创建一个任务并立即启动它

  • 通过调用ContinueWhenAny方法创建一个任务,该任务将在数组中的任何一个任务完成时启动

  • 通过调用ContinueWhenAll方法创建一个任务,该任务将在数组中的所有任务完成时启动

更多关于TaskFactory的阅读资料可以在docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskfactory?view=netframework-4.7.2找到。

使用 Parallel 类

System.Threading类中还有一个名为Parallel的类。这个类为ForForEach循环提供了并行实现。它们的实现与顺序循环类似。当你使用ParallelForParallelForEach时,系统会自动将过程分割成多个任务,并在需要时获取锁。所有这些底层工作都由 TPL 处理。

顺序循环可能看起来如下:

foreach (var item in sourceCollection) 
{     
    Process(item); 
} 

同样的循环可以使用Parallel表示如下:

Parallel.ForEach(sourceCollection, item => Process(item)); 

TPL 管理数据源并创建分区,以便循环可以并行操作多个部分。每个任务将由任务调度器根据系统资源和负载进行分区。然后,如果负载变得不平衡,任务调度器将通过多个线程和进程重新分配工作。

当你有大量并行工作要做时,并行编程可以提高性能。如果不是这种情况,它可能会变得成本高昂。

在给定的场景中理解并行工作方式非常重要。在以下示例中,我们将探讨如何使用Parallel.For,并在顺序循环和并行循环之间进行时间比较。

在这里,我们定义了一个整数数组,并计算数组中每个元素的求和和乘积。在主程序中,我们使用顺序和并行循环调用此方法,并计算每个循环完成过程所需的时间:

static int[] _values = Enumerable.Range(0, 1000).ToArray();

private static void SumAndProduct(int x)
{
    int sum = 0;
    int product = 1;
    foreach (var element in _values)
    {
        sum += element;
        product *= element;
    }
}

public static void CallSumAndProduct()
{
    const int max = 10;
    const int inner = 100000;
    var s1 = Stopwatch.StartNew();
    for (int i = 0; i < max; i++)
    {
        Parallel.For(0, inner, SumAndProduct);
    }
    s1.Stop();

    Console.WriteLine("Elapsed time in seconds for ParallelLoop: " + s1.Elapsed.Seconds);

    var s2 = Stopwatch.StartNew();
    for (int i = 0; i < max; i++)
    {
        for (int z = 0; z < inner; z++)
        {
            SumAndProduct(z);
        }
    }
    s2.Stop();

    Console.WriteLine("Elapsed time in seconds for Sequential Loop: " + s2.Elapsed.Seconds );
}

在前面的代码中,我们执行了两个循环:一个使用并行循环,另一个使用顺序循环。结果显示了每个操作所花费的时间:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/2683561f-3fd8-40d0-855f-e7275065851f.png

System.Threading.Tasks.Parallel 包含多个辅助类,例如 ParallelLoopResultParallelLoopStateParallelOptions

ParallelLoopResult 提供并行循环的完成状态,如下所示:

ParallelLoopResult result = Parallel.For(int i, ParallelLoopState loopstate) =>{});

ParallelLoopState 允许并行循环的迭代与其他迭代交互。最后,LoopState 允许您识别迭代中的任何异常,从迭代中退出,停止迭代,识别是否有任何迭代调用了退出或停止,以及退出长时间运行的迭代。

PLINQ

语言集成查询 (LINQ) 在 .NET Framework 3.5 中引入。它允许我们对内存中的集合,如 List<T> 进行查询。您将在第十五章 使用 LINQ 查询中了解更多关于 LINQ 的信息。然而,如果您想早点了解更多,更多信息可以在 docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/linq/index 找到。

PLINQ 是 LINQ 模式的并行实现。它们类似于 LINQ 查询,并操作任何内存中的集合,但在执行方面有所不同。PLINQ 使用系统中的所有可用处理器。然而,处理器限制在 64 位。这是通过将数据源分区成更小的任务,并在多个处理器的单独工作线程上执行每个任务来实现的。

大多数标准查询运算符都实现在 System.Linq.ParallelEnumerable 类中。以下表格列出了各种并行执行特定的方法:

AsParallel 当您希望系统在可枚举集合上执行并行执行时,可以向系统提供 AsParallel 指令。
AsSequential 使用 AsSequential 指示系统顺序运行。
AsOrdered 要在结果集中保持顺序,请使用 AsOrdered
AsUnordered 要在结果集中不保持顺序,请使用 AsUnordered
WithCancellation 取消标记携带用户的取消执行请求。这必须被监控,以便可以在任何时候取消执行。
WithDegreeofParallelism 控制并行查询中使用的处理器数量。
WithMergeOptions 提供选项,以便我们可以将结果合并到父任务/线程/结果集中。
WithExecutionMode 强制运行时使用并行或顺序模式。
ForAll 允许通过不合并到父线程来并行处理结果。
Aggregate 一个独特的 PLINQ 重载,用于在线程局部分区上启用中间聚合。同时允许我们将最终聚合合并以组合所有分区的结果。

让我们尝试使用其中一些方法,以便我们可以更详细地理解它们。AsParallel扩展方法将whereselect等查询运算符绑定到parallelEnumerable实现。通过简单地指定AsParallel,我们告诉编译器并行执行查询:

public static void PrintEvenNumbers()
{
    var numbers = Enumerable.Range(1, 20);
    var pResult = numbers.AsParallel().Where(i => i % 2 == 0).ToArray();

    foreach (int e in pResult)
    {
        Console.WriteLine(e);
    }

}

当执行时,前面的代码块识别所有偶数并在屏幕上打印它们:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/ac50e5c7-f5b7-4416-9ae0-d113a7fa112a.png

如您所见,偶数并没有按顺序打印。关于并行处理,有一点需要记住的是,它不保证任何特定的顺序。尝试多次执行代码块并观察输出。由于它基于执行时的处理器数量,所以每次都会有所不同。

通过使用AsOrdered运算符,代码块接受 1 到 20 之间的数字范围。然而,使用AsOrdered将排序数字:

public static void PrintEvenNumbersOrdered()
{
    var numbers = Enumerable.Range(1, 20);
    var pResult = numbers.AsParallel().AsOrdered()
        .Where(i => i % 2 == 0).ToArray();

    foreach (int e in pResult)
    {
        Console.WriteLine(e);
    }

}

此示例展示了我们如何在使用Parallel时保持结果集的顺序:

2
4
6
8
10
12
14
16
18
20
Press any key to exit.

当您使用 PLINQ 执行代码块时,运行时会分析查询是否可以并行化。如果是,它会将查询分区成任务然后并发运行。如果不安全并行化查询,它会以顺序模式执行查询。在性能方面,使用顺序算法比使用并行算法更好,因此默认情况下,PLINQ 选择顺序算法。使用ExecutionMode将允许我们指示 PLINQ 选择并行算法。

以下代码块展示了我们如何使用ExecutionMode

public static void PrintEvenNumbersExecutionMode()
{
    var numbers = Enumerable.Range(1, 20);
    var pResult = numbers.AsParallel().WithExecutionMode(ParallelExecutionMode.ForceParallelism)
        .Where(i => i % 2 == 0).ToArray();

    foreach (int e in pResult)
    {
        Console.WriteLine(e);
    }
}

如我们之前提到的,PLINQ 默认使用所有处理器。然而,通过使用WithDegreeofParallelism方法,我们可以控制要使用的处理器数量:

public static void PrintEvenNumbersDegreeOfParallel()
{
    var numbers = Enumerable.Range(1, 20);
    var pResult = numbers.AsParallel().WithDegreeOfParallelism(3)
        .Where(i => i % 2 == 0).ToArray();

    foreach (int e in pResult)
    {
        Console.WriteLine(e);
    }

}

通过更改处理器数量来执行前面的代码块并观察输出。在第一种情况下,我们让系统使用可用的核心/处理器,但在第二种情况下,我们指示系统使用三个核心。您将看到性能差异取决于您的系统配置。

PLINQ 还提供了一个名为AsSequential的方法。这是用来指示 PLINQ 在调用AsParallel之前以顺序方式执行查询。

forEach可以用来遍历 PLINQ 查询的所有结果并将每个任务的输出合并到父线程。在先前的示例中,我们使用forEach来显示偶数。

可以使用 forEach 来保留 PLINQ 查询结果的顺序。因此,当不需要保留顺序并且我们想要实现更快的查询执行时,我们可以使用 ForAll 方法。ForAll 不执行最终的合并步骤;相反,它并行化处理结果。以下代码块正在使用 ForAll 将输出打印到屏幕上:

public static void PrintEvenNumbersForAll()
{
    var numbers = Enumerable.Range(1, 20);
    var pResult = numbers.AsParallel().Where(i => i % 2 == 0);

    pResult.ForAll(e => Console.WriteLine(e));
}

在这种情况下,I/O 正被多个任务使用,因此数字将以随机顺序出现:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/5d0ec129-6fe0-48d5-a435-dd2e500666d9.png

当 PLINQ 在多个线程中执行时,随着代码的运行,应用程序逻辑可能在一个或多个线程中失败。PLINQ 使用 Aggregate 异常来封装查询抛出的所有异常,并将它们发送回调用线程。在这样做的时候,你需要在调用线程上有一个 try..catch 块。当你从查询中获取结果时,开发者可以遍历 AggregatedException 中封装的所有异常:

public static void PrintEvenNumbersExceptions()
{
    var numbers = Enumerable.Range(1, 20);
    try
    {
        var pResult = numbers.AsParallel().Where(i => IsDivisibleBy2(i));

        pResult.ForAll(e => Console.WriteLine(e));
    }
    catch (AggregateException ex)
    {
        Console.WriteLine("There were {0} exceptions", ex.InnerExceptions.Count);
        foreach (Exception e in ex.InnerExceptions)
        {
            Console.WriteLine("Exception Type: {0} and Exception Message: {1}", e.GetType().Name,e.Message);
        }
    }
}

private static bool IsDivisibleBy2(int num)
{
    if (num % 3 == 0) throw new ArgumentException(string.Format("The number {0} is divisible by 3", num));
   return num % 2 == 0;
}

上述代码块正在从 PLINQ 中抛出的异常中写入所有详细信息。在这里,我们正在遍历并展示所有六个异常:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/a0f88c43-4592-4dbc-9f2e-836dd9016e98.png

你可以通过遍历 InnerExceptions 属性并采取必要的行动。我们将在 第七章 中更详细地探讨内部异常,实现异常处理。然而,在这种情况下,当 PLINQ 执行时,它不会在异常上终止执行,而是会运行所有迭代并提供最终结果。

使用 async 和 await 进行异步编程

异步编程可以帮助你提高应用程序的响应性和性能。在传统方法中,编写和维护异步代码比较困难。然而,C# 5 引入了两个新的关键字,简化了异步编程:asyncawait。当遇到这些关键字时,C# 编译器会为你完成所有困难的工作。它类似于同步代码。TaskTask<T> 是异步编程的核心。

任何 I/O 密集型或 CPU 密集型代码都可以利用异步编程。在 I/O 密集型代码的情况下,当你想从 async 方法返回一个任务时,我们使用 await 操作,而在 CPU 密集型代码中,我们使用 Task.Run 等待启动后台线程的操作。

当使用 await 关键字时,它将控制权返回给调用方法,从而允许 UI 保持响应。

在内部,当编译器遇到 async 关键字时,它会将方法分割成任务,并且每个任务都会被标记上 await 关键字。await 关键字生成代码,用于检查异步操作是否已经完成;也就是说,C# 编译器将代码转换成一个状态机,它跟踪与每个任务/线程相关的元数据,以便在后台任务执行完毕后恢复执行:

private readonly HttpClient _httpClient = new HttpClient();

public async Task<int> GetDotNetCountAsync()
{
    var html = await _httpClient.GetStringAsync("https://dotnetfoundation.org");

    return Regex.Matches(html, @"\.NET").Count;
}

public void TestAsyncMethods()
{
    Console.WriteLine("Invoking GetDotNetCountAsync method");
    int count = GetDotNetCountAsync().Result;
    Console.WriteLine($"Number of times .NET keyword displayed is {count}");
}

在前面的代码块中,我们正在尝试找出一个特定单词在网站上被使用了多少次。前一个代码的输出如下:

Invoking GetDotNetCountAsync method
Number of times .NET keyword displayed is 22
Press any key to exit.

在这里,我们在 GetDotnetCountAsync 方法上使用了 async 关键字。尽管方法是以同步方式执行的,但 await 关键字允许我们返回到调用方法并等待直到 async 方法完成执行,此时它返回结果。

重要的是要理解,异步方法体应该始终包含一个 await,否则此方法永远不会释放。编译器也不会引发错误。

当编写异步方法时,你应该始终使用 async 作为后缀。请注意,async 必须用于事件处理器。这是唯一允许 async 事件处理器像事件一样工作的方法,因为事件没有返回类型。

你可以从 MSDN 在 docs.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/task-based-asynchronous-pattern-tap 上了解更多关于基于任务的异步模式TAP)的信息。

摘要

在本章中,我们探讨了线程、它们的属性、如何使用参数化线程,以及通过详细示例说明了前台线程和后台线程之间的区别。我们还学习了线程状态以及线程如何在多个线程之间存储和共享数据。这就是我们讨论不同同步方法的地方。我们重点介绍了并行编程、任务以及使用任务的异步编程,如何使用并行类,以及 PLINQ。

在下一章中,我们将探讨 C# 中的异常处理。异常处理帮助我们处理程序执行过程中出现的任何意外或异常情况。异常处理使用 trycatchfinally 块。这些块分别帮助开发者尝试可能成功或失败的操作,处理如果发生失败的情况,以及清理不需要的资源。

问题

  1. 默认情况下,你的代码块的主方法以以下哪种方式运行?

    1. 工作线程

    2. 主线程

    3. 后台线程

    4. 以上皆非

  2. 当线程被暂停时,需要执行什么操作才能将其移动到运行状态?

    1. 中断

    2. 恢复

    3. 中断

    4. 暂停

  3. 在编写同步代码区域时,应该使用哪个正确的关键字?

    1. 释放

    2. 获取锁

    3. 解锁

  4. 一个任务可能返回或不返回值。

  5. 当使用 PLINQ 时,结果将按顺序返回。

答案

  1. 主线程

  2. 恢复

进一步阅读

在本章中,我们讨论了 .NET Framework 提供的许多功能,这些功能我们可以用于我们的应用程序。然而,我们没有详细涵盖这个主题。因此,你可能需要阅读几篇 MSDN 文章,以便更好地理解这些概念。查看以下链接:

第七章:实现异常处理

异常处理帮助开发者以有助于处理预期和意外情况的方式构建他们的程序。通常,应用程序逻辑可能会抛出某种未处理的异常,例如,尝试向一个系统中的文件写入代码块,最终导致文件使用异常。如果设置了适当的异常处理,这些场景是可以处理的。

异常处理使用trycatchfinally关键字,使我们能够编写可能不会成功且在需要时可以处理的代码,以及帮助我们清理try块执行后的资源。这些异常可以由 CLR、.NET Framework 或您代码中使用的任何外部库抛出。

在本章中,我们将通过查看以下主题来尝试理解我们如何使用、创建和抛出异常:

  • 代码中的异常及其处理

  • 编译器生成的异常

  • 自定义异常

阅读本章后,您将能够构建应用程序程序并处理应用程序逻辑可能抛出的所有类型的异常。

技术要求

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

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

本章的相同代码可以在 GitHub 上找到,链接为github.com/PacktPublishing/Programming-in-C-sharp-Exam-70-483-MCSD-Guide/tree/master/Chapter07

代码中的异常及其处理

异常是派生自System.Exception类的类型。我们使用try块包围可能抛出异常的语句。当发生异常时,控制权跳转到catch语句,在那里 CLR 收集所有必要的堆栈跟踪信息,然后终止程序并向用户显示消息。如果没有进行异常处理,程序将带错误终止。在处理异常时,重要的是要理解,如果我们无法处理异常,我们就不应该捕获它。这确保了应用程序将处于已知状态。当您定义一个catch块时,您定义一个异常变量,可以用来获取更多信息,例如异常的来源、代码中的哪一行抛出了这个异常、异常的类型等等。

程序员可以使用 throw 关键字从应用程序逻辑中创建和抛出异常。每个 try 块可以定义也可以不定义 finally 块,无论是否抛出异常,该块都将执行。这个块帮助我们释放代码块中使用的资源。或者,如果您希望一段代码在所有情况下都执行,则可以将其放在 finally 块中。

在接下来的章节中,我们将探讨如何使用异常、try-catch-finally 块的语法、使用 finally 块、何时可以销毁未使用的对象、不同类型的系统异常以及创建我们自己的异常。

使用异常

如我们之前提到的,C# 程序中的错误是通过异常在运行时传播的。当应用程序代码遇到错误时,它会抛出一个异常,然后由另一个代码块捕获,该代码块收集有关异常的所有信息并将其推送到调用方法,其中提供了 catch 块。如果您使用的是通用异常处理程序,系统将显示一个对话框来显示任何未捕获的异常。

在以下示例中,我们尝试将一个空字符串解析为 int 变量:

public static void ExceptionTest1()
{
    string str = string.Empty;
    int parseInt = int.Parse(str);
}

当执行时,运行时会抛出一个格式异常,其消息指出输入字符串格式不正确。由于这个异常没有被捕获,我们可以看到通用处理程序在对话框中显示这个错误消息:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/ba893895-c52d-4363-91ad-b06cd309b8ff.png

下面是异常的详细信息:

System.FormatException occurred
  HResult=0x80131537
  Message=Input string was not in a correct format.
  Source=<Cannot evaluate the exception source>
  StackTrace:
   at System.Number.StringToNumber(String str, NumberStyles options, NumberBuffer& number, NumberFormatInfo info, Boolean parseDecimal)
   at System.Number.ParseInt32(String s, NumberStyles style, NumberFormatInfo info)
   at System.Int32.Parse(String s)
   at Chapter7.ExceptionSamples.ExceptionTest1() in C:\Users\srini\source\repos\Programming-in-C-Exam-70-483-MCSD-Guide2\Book70483Samples\Chapter7\ExceptionSamples.cs:line 14
   at Chapter7.Program.Main(String[] args) in C:\Users\srini\source\repos\Programming-in-C-Exam-70-483-MCSD-Guide2\Book70483Samples\Chapter7\Program.cs:line 13

每个 catch 块定义了一个异常变量,它为我们提供了更多关于正在抛出的异常的信息。exception 类定义了多个属性,所有这些属性都包含以下额外信息:

属性 描述
Data 获取关于异常的自定义详细信息,以键/值对集合的形式。
HelpLink 获取或设置与异常相关的帮助链接。
HResult 获取或设置与异常关联的 HRESULT,这是一个数值。
InnerException 获取触发异常的异常实例。
Message 从异常中获取详细信息。
Source 获取或设置导致错误的程序/实例名称或对象/变量。
StackTrace 以字符串格式获取调用堆栈。
TargetSite 获取触发异常的方法。

现在,我们将尝试处理格式异常,并查看每个属性将提供给我们什么。在以下示例中,我们有一个 try 块,其中字符串被解析为整数,以及一个用于捕获格式异常的 catch 块。在 catch 块中,我们显示了我们捕获的异常的所有属性:

public static void ExceptionTest2()
{
    string str = string.Empty;
    try
    {
        int parseInt = int.Parse(str);
    }
    catch (FormatException e)
    {
        Console.WriteLine($"Exception Data: {e.Data}");
        Console.WriteLine($"Exception HelpLink: {e.HelpLink}");
        Console.WriteLine($"Exception HResult: {e.HResult}");
        Console.WriteLine($"Exception InnerException: 
                          {e.InnerException}");
        Console.WriteLine($"Exception Message: {e.Message}");
        Console.WriteLine($"Exception Source: {e.Source}");
        Console.WriteLine($"Exception TargetSite: {e.TargetSite}");
        Console.WriteLine($"Exception StackTrace: {e.StackTrace}");
    }
}

我们试图将一个字符串解析为整数变量。然而,这是不允许的,因此系统抛出异常。当我们捕获异常时,我们正在显示异常的每个属性以观察它存储的内容:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/aa6c6e08-4d29-47cc-8eb8-eea6dc13b351.png

每个异常都是继承自System.Exception基类,它定义了异常的类型并详细说明了所有提供更多异常信息的属性。当你需要抛出异常时,你需要创建异常类的实例,设置所有或部分这些属性,并使用throw关键字抛出它们。

对于一个try块,你可以有多个catch块。在执行过程中,当抛出异常时,首先执行处理该异常的特定catch语句,而任何其他通用的catch语句都将被忽略。因此,按照从最具体到最不具体的顺序组织catch块是很重要的:

public static void ExceptionTest3()
{
    string str = string.Empty;
    try
    {
        int parseInt = int.Parse(str);
    }
    catch (ArgumentException ex)
    {
        Console.WriteLine("Argument Exception caught");
    }
    catch (FormatException e)
    {
        Console.WriteLine("Format Exception caught");

    }
    catch (Exception ex1)
    {
        Console.WriteLine("Generic Exception caught");
    }
}

当程序执行时,尽管存在多个catch块,系统会识别一个合适的catch块并消耗异常。因此,你会在输出中看到“捕获到格式异常”的消息:

Format Exception caught
Press any key to exit.

在调用catch块之前会检查finally块。当在try-catch块中使用资源时,这些资源可能会移动到一个模糊的状态,并且只有在框架的垃圾回收器被调用时才会被收集。程序员可以通过使用finally块来清理这些资源:

public static void ExceptionTest4()
{
    string str = string.Empty;
    try
    {
        int parseInt = int.Parse(str);
    }
    catch (ArgumentException ex)
    {
        Console.WriteLine("Argument Exception caught");
    }
    catch (FormatException e)
    {
        Console.WriteLine("Format Exception caught");

    }
    catch (Exception ex1)
    {
        Console.WriteLine("Generic Exception caught");
    }
    finally
    {
        Console.WriteLine("Finally block executed");
    }
}

如您所见,finally块被执行,但在抛出并捕获异常之前:

Format Exception caught
Finally block executed
Press any key to exit.

尽管我们有三不同的catch块,格式异常被执行,并且之后执行了finally块。

异常处理

程序员将可能抛出异常的应用逻辑分区到try块中,随后是处理这些异常的catch块。如果存在,可选的finally块将执行,无论try块是否抛出异常。你不能只有一个try块——它必须由一个catch块或一个finally块伴随。

在本节中,我们将查看不同的代码块,以便了解try-catch语句、try-finally语句和try-catch-finally语句的用法。

你可以这样使用没有finally块的try-catch语句:

try
{
    //code block which might trigger exceptions
}
catch (SpecificException ex)
{
   //exception handling code block

}

系统还允许你使用带有finally块的try块——不需要捕获异常。这在下述代码中显示:

try
{
    // code block which might trigger exceptions
}
finally
{
    // Dispose resources here.
    //Block you want to execute all times irrespective of try block is executed or not.
}

最后但同样重要的是,有try-catch-finally块:

try
{
    // Code that you expect to throw exceptions goes here.
}
catch (SpecificException ex)
{
    // exception handling code block
}
finally
{
    // code block that you want to run in all scenarios
}

如果运行时在try块中识别到不正确的语法,则会抛出一个编译时错误;例如,在代码编译期间没有catchfinally块的try块。当你没有提供catchfinally块时,编译器会在try块的闭合括号旁边放置一个红色标记,并抛出一个错误,如下面的截图中的错误列表窗口所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/1bedb9a2-e322-45ac-aa37-b0072f16f25a.png

异常过滤器是一种用于在catch块中捕获的异常类型。System.Exception是任何异常类型类的基类。作为基类,它可以持有代码中的任何异常。我们使用它在我们有处理每个异常的代码或在我们调用method()时抛出异常时。

我们已经讨论过,一个try块可以有多个带有不同异常过滤器的catch块。当运行时评估catch块时,它采取自上而下的方法,并执行最适合已捕获异常的最具体的catch块。如果catch块中的exception过滤器与已抛出的异常匹配,或者与已抛出异常的基类匹配,则执行它。作为一个考试提示,始终记住将最具体的catch语句放在顶部,将通用的放在底部。

理解异常处理的重要性有助于你编写能够处理所有可能场景并执行而不会出现意外行为的正确代码。例如,假设你的程序正在尝试打开并写入一个文件,而你收到了一个如文件未找到文件正在使用中的异常。异常处理使我们能够处理这些场景。在第一种情况下,提示会要求用户提供正确的文件名,而在第二种情况下,提示会检查是否可以创建一个新文件。

在下面的示例中,一个for循环抛出了一个索引超出范围的异常:

public static void ExceptionTest5()
{
     string[] strNumbers = new string[] {"One","Two","Three","Four" };
     try
     {
         for (int i = 0; i <= strNumbers.Length; i++)
         {
             Console.WriteLine(strNumbers[i]);
         }
     }
     catch (System.IndexOutOfRangeException e)
     {
         Console.WriteLine("Index is out of range.");
         throw new System.ArgumentOutOfRangeException(
                     "Index is out of range.", e);
     }
 }

代码处理它,并在抛出之前在屏幕上显示一条消息,以便调用方法可以处理它,如下所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/3af8902a-b937-4084-8b0f-7993dbf386a7.png

然而,我们的主程序不处理异常系统。相反,它使用默认设置并显示一个对话框:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/691694c8-8895-4d6f-b177-50a9b4f6b3ad.png

finally块释放了在try块中创建的任何变量或对象。此块最后执行,如果存在则始终运行:

public static void ExceptionTest6()
{
    FileStream inputfile= null;
    FileInfo finfo = new FileInfo("Dummyfile.txt");
    try
    {
        inputfile = finfo .OpenWrite();
        inputfile.WriteByte(0xH);
    }
    finally
    {
        // Check for null because OpenWrite() method might return null.
        if (inputfile!= null)
        {
            inputfile.Close();
        }
    }
}

在前面的示例中,我们在try块中创建了一个文件对象,并尝试向其中写入一些字节。当运行时完成try块的执行后,它执行一个finally块并释放了在try块中创建的file对象。

编译器生成的异常

让我们回顾一下.NET Framework 支持的几个运行时生成的异常。框架在执行有效语句时使用这些异常。然后,根据它们的类型,从以下表格中抛出相应的异常。例如,如果编译器尝试执行除法操作,并且如果除数为零,则会抛出DividebyZeroException异常:

Exception 描述
ArithmeticException 在执行算术操作时触发的异常可以被捕获。
ArrayTypeMismatchException 当数组的值和类型不匹配时,将抛出此异常。
DivideByZeroException 当尝试将整数值除以零时,将抛出此异常。
IndexOutOfRangeException 当使用超出其边界的索引访问数组时,将抛出此异常。
InvalidCastException 在运行时将基类型转换为接口或派生类型将导致此异常。
NullReferenceException 当你尝试访问一个null对象时,将抛出此异常。
OutOfMemoryException 当 CLR 可用内存被利用时,新操作符会抛出此类异常。
OverflowException 在执行除法操作时,例如,如果输出是长整型并且你尝试将其推送到int,则会抛出此异常。
StackOverflowException 递归调用通常会导致此类异常,并指示非常深的或无限递归。
TypeInitializationException 如果你尝试实例化一个抽象类,例如,将抛出此异常。

现在我们已经了解了编译器生成的异常,让我们看看自定义异常。

自定义异常

所有异常都源自.NET Framework 中的System.Exception类。因此,在这些预定义异常不符合我们的需求的情况下,框架允许我们通过从Exception类派生我们的异常类来创建自己的异常。

在以下示例中,我们正在创建一个自定义异常,并从Exception类继承。我们可以为它使用不同的构造函数:

public class MyCustomException : Exception
{
    public MyCustomException():base("This is my custom exception")
    {

    }

    public MyCustomException(string message) 
           : base($"This is from the method : {message}")
    {

    }

    public MyCustomException(string message, Exception innerException) 
       : base($"Message: {message}, InnerException: {innerException}")
    {
    } 
}

当你创建自己的异常类时,从System.Exception类派生,并实现基类,你将获得四个构造函数;实现这三个是最佳实践。在第一种情况下,基类消息属性默认初始化并显示一条消息。然而,在第二种和第三种情况下,抛出此自定义异常的方法需要传递这些值。

摘要

在本章中,我们探讨了如何在程序中使用异常类,如何创建自定义异常以满足我们的需求,以及不同类型的异常。我们还了解了有关如何在应用程序中规划和实现异常的行业标准。

在下一章中,我们将了解类型以及如何创建和消费类型。

问题

  1. C# 支持使用 try 块而不带 catchfinally 块。

    1. 真的

    2. 假的

  2. catch 块需要按照从最通用到最通用的模式使用。

    1. 真的

    2. 假的

  3. 如果存在,finally 块总是会执行。

    1. 真的

    2. 假的

答案

  1. 假的

  2. 假的

  3. 真的

进一步阅读

在实现应用程序代码中的异常处理时,理解行业标准非常重要。请查看以下链接以了解这些最佳实践:docs.microsoft.com/en-us/dotnet/standard/exceptions/best-practices-for-exceptions

第八章:在 C# 中创建和使用类型

类型是 C# 程序的构建块。即使在编写基本的 C# 程序时,我们也必须在创建程序时使用正确的类型。在第二章“理解类、结构和接口”中,我们学习了 C# 程序中类型的基础知识。我们学习了 C# 程序中存在的值类型和引用类型变量。

除了了解不同的类型外,我们还应该理解,在尽可能好的情况下或情境中使用每种类型对我们来说非常重要。我们还应该了解有关创建和使用这些类型的最佳实践。我们将在本章中介绍这一点。

我们将在本章中探讨以下主题:

  • 创建类型

  • 消费类型

  • 如何使用属性来强制封装

  • 使用可选和命名参数

  • 创建索引属性

  • C# 中与字符串操作相关的不同操作

我们将对反射有一个概述,并尝试了解它如何帮助我们查找、执行和创建运行时类型。在第十章“使用反射查找、执行和创建运行时类型”中,我们将深入探讨反射。

技术要求

与本书中前面章节所涵盖的章节一样,本书中解释的程序将在 Visual Studio 2017 中开发。

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

创建类型

当我们在 C# 中创建一个变量时,它为我们提供了许多选项来选择变量的适当类型。例如,我们可以选择以下:

  • 如果我们希望变量获取一组定义的变量,我们可以选择 enum 类型。例如,如果我们定义 Dayenum 类型,它可以获取 MondayTuesdayWednesdayThursdayFridaySaturdaySunday 等值。

  • 同样,如果我们选择 int 类型,我们告诉公共语言运行时CLR)它不能有十进制数字。

因此,在为任何变量定义类型时,我们必须逻辑地分析变量的使用情况,然后在 C# 中声明其类型。在下一节中,我们只需简要回顾一下我们在第二章“理解类、结构和接口”中的“C# 数据类型”部分所涵盖的不同类型。

C# 中的类型

在第二章“理解类、结构和接口”中,我们了解到变量可以获取以下类型的值:

  • 值类型:在值类型中,变量包含变量的实际值。这基本上意味着,如果在程序的不同作用域中对值类型变量进行更改,更改在控制权转移到调用函数后不会反映回来。

  • 引用类型:数据成员包含变量在内存中的确切地址。由于变量仅包含对内存地址的引用,两个单独的引用类型变量可以指向相同的内存地址。因此,如果对引用类型变量进行更改,更改将直接在变量的内存位置进行,因此会传播到程序执行中的不同作用域。

  • 指针类型:指针是 C# 中可能存在的另一种变量类型。指针类型用于保存变量的内存地址,使我们能够执行涉及变量内存位置的任何操作。

在下一节中,我们将深入研究指针,并了解它们在我们应用程序中使用时的影响和好处。

不安全代码和指针类型的使用

在 C 或 C++ 等语言中,开发者具有创建 指针* 的功能,这是一个存储另一个变量内存地址的对象。该对象允许应用程序对内存进行非常低级别的访问。然而,由于存在 悬垂指针 的可能性,应用程序的性能会大大降低。悬垂指针是 C 中可能存在的一种潜在情况,即指针对象仍然指向应用程序中不再分配的内存位置。请参考以下图表:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/802e881c-7877-43ee-bb98-865128cbac44.png

在图中,我们有一个运行在 C 或 C++ 中的应用程序,它声明了指针 B 并将其指向变量 A 的内存地址。指针保存了变量的内存地址。换句话说,指针 B 不会包含变量 A 的内存地址。现在,在程序运行期间某个时刻,应用程序释放了内存位置 A。尽管内存已被释放,但可能存在我们未明确清除包含相应内存地址的指针内容的情形。由于这个错误或疏忽,指针 B 没有更新以指向新的内存块或将其指向 null。因此,该指针仍然引用着应用程序中不再存在的内存位置。这种情况被称为 悬垂指针

C# 通过明确不允许使用指针来消除悬垂指针的可能性。相反,它鼓励人们使用 引用类型。引用类型的内存管理由垃圾回收器管理。

在 第九章,管理对象生命周期 中,我们将进一步探讨垃圾回收器在 .NET 中的工作原理。

然而,在某些情况下,开发者仍然觉得需要在他们的 C# 应用程序中使用指针。这在需要与底层操作系统(如 Windows 或 Linux)进行某些操作的场景中很有用,在这些操作中应用程序正在运行。在这种情况下,我们将需要指针。为了适应这些场景,C# 有一个名为 unsafe代码 概念,它允许开发者在代码中使用指针。使用指针的代码必须明确地用 unsafe 标识符进行分类。这个关键字向 公共语言运行时CLR)传达信息,即代码块是不受管理的或是不安全的——换句话说,已经使用了指针。让我们通过一个代码示例来看看我们如何在 C# 中使用指针类型。

在代码示例中,我们创建了一个函数块,在其中我们使用指针变量。我们将保存 int 类型地址的 int 指针类型变量。请参考以下截图。请注意,当用户尝试编译程序时,他们会得到一个错误:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/510e540a-326a-4c58-aedf-aa5e70fec8a2.png

原因是,默认情况下,C# 编译器不会允许任何包含指针或 unsafe 代码块的代码执行。我们可以通过在函数块中使用 unsafe 关键字来覆盖 C# 的这种行为:

class Program
 {
     static void Main(string[] args)
     {
         UnSafeExample();
     }
     unsafe static private void UnSafeExample()
     {
         int i = 23;
         int* pi = &i;
         Console.WriteLine(i);
         Console.WriteLine(*pi);
         Console.ReadLine();
     }
 }

要允许编译 unsafe 代码,我们需要更改 Visual Studio 的构建设置。要更新设置,我们需要右键单击项目并单击属性。现在,导航到构建部分。请参考以下截图,它突出显示了我们需要指定的 Visual Studio 设置,以允许编译 unsafe 代码:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/8ace30ca-abea-4c55-9fe6-ed2b5b31f0ee.png

现在我们已经回顾了 C# 中可能的不同类型。下一节将解释帮助我们选择特定变量类型而不是其他类型的指导原则。

选择变量类型

在 第二章 的 理解类、结构和接口 部分,在 C# 中的数据类型 部分中,我们看到了值类型和引用类型可能的不同数据类型。我们还进行了代码实现,以查看 Struct(值类型)和 Class(引用类型)的行为差异。在本节中,我们将深入探讨这种行为差异,以及它如何帮助我们为变量选择正确的类型。

让我们分析以下代码语句中的值类型和引用类型,看看它们在实现上的差异:

// Value Type
int x = 10;
int y = x 

// Reference Type
Car c = new Car();
Car c2 = c;

在前面的代码中,我们声明了值类型变量 xy。在声明时,x 变量已被赋予一个值。在下一步中,我们将 x 赋予 y。同样,我们有一个名为 Class 的类,我们创建了 c 的对象。在下一个语句中,我们声明了同一类的另一个对象,并将 c 赋予 c2

请参考以下图表,它显示了这些类型如何在内存中实现和管理:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/73206554-2d6c-496d-be16-c0840aab7814.png

在前面的图表中,我们已将变量x声明为int数据类型,将c声明为Car类的对象。现在,我们知道int是值类型,而Class是引用类型。所以让我们尝试分析为什么两者的行为不同:

  • 对于x,在第一个语句中,即int x = 10,应用程序为其保留了一块内存。声明下方的矩形块表示这一点。

  • 现在,当我们执行int y = x语句时,我们正在声明另一个变量y,并将其分配给x当前值。它内部所做的就是为y在内存中分配另一块内存。因此,由于xy不指向相同的内存位置,它们将持有不同的值。

  • 另一方面,如果我们看看Car类,我们刚刚在其中声明了两个属性:注册号和颜色。现在,当我们使用new语句时,它所做的就是为该类创建一个对象并为其分配内存。然而,与值类型实现相反,它不会在对象中保存值。相反,在对象中,它只保存对分配的内存块的引用。在前面的矩形图中,您将看到,一旦为Car类创建了c对象,就会在创建的对象中保存一个指针。

  • 现在,当我们执行Car c2 = c;语句时,内部会创建一个新的对象c2,但不会为该对象分配新的内存块。相反,它只是保存了对与对象c共享的内存位置的引用。

如前所述的实现所示,每当声明一个新的值类型变量时,应用程序都会为其保留一块新的内存,这与引用类型变量不同。

因此,用更简单的术语来说,以下因素可以帮助我们选择值类型和引用类型:

  • 值类型变量的逻辑不可变性:用非常简单的话来说,这意味着在每次声明值类型时,应用程序都会为其保留一块新的内存。由于它们是不同的内存分配,这意味着如果我们对某个内存位置执行任何操作,该变化不会传递到另一个内存位置。

  • 对象的数量:如果应用程序中创建了大量的对象,那么最好不将它们作为值类型创建,因为这会指数级增加应用程序的内存需求。

  • 对象的大小:如果对象很小,那么将它们作为值类型变量可能是有意义的。然而,如果我们认为对象可能有很多属性,那么引用类型变量将更有意义。

  • 内存管理:值类型变量在栈上管理,而引用类型变量在堆上管理。当我们进入第九章 Chapter 9,管理对象生命周期时,我们将进一步探讨内存管理以及垃圾回收器的工作原理。

现在我们对如何在 C#应用程序中创建和消费不同数据类型有了相当的了解,我们将探讨一些 C#的特性,这些特性帮助我们为应用程序中使用的不同类型设置正确的行为。在下一节中,我们将探讨静态变量以及它们在 C#中的实现方式。

静态变量

当我们讨论值类型与引用类型时,我们了解到在 C#中创建的所有对象在程序执行中都有确定的范围。然而,在某些情况下,我们可能希望变量获取一个在所有对象实例中一致的常量值。我们可以使用Static关键字来实现这一点。在 C#中,Static关键字作为修饰符确保只创建一个变量的实例,并且其作用域是整个程序的运行。我们可以使用Static变量针对类、其成员变量、其成员方法和构造函数。

现在我们来看一些涉及Static关键字的代码示例。

静态成员变量

在本节中,我们将探讨如何使用Static关键字针对类及其成员变量。在下面的代码示例中,我们创建了一个名为ConfigurationStatic类。仅为了解释,我们不会在其中的成员变量上使用Static关键字:

internal static class Configuration
{
     public string ConnectionString;
}

让我们尝试编译程序。我们得到一个错误,指出ConnectionString成员变量也必须声明为static

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/6928a105-8aa5-4b1c-8cd7-77d02081d970.png

一旦我们将static关键字应用于ConnectionString成员变量,错误就会消失。这是类的正确表示:

internal static class Configuration
{
    public static string ConnectionString;
}

如果我们需要在成员变量中使用Set/Get值,我们可以通过使用类的名称直接访问它。以下是这个代码片段:

Configuration.ConnectionString = "Sample Connection String";

在前面的代码示例中,我们有一个名为ConfigurationStatic类,其中所有成员变量和属性都必须有static修饰符。然而,在某些情况下,我们可能不希望整个类都是静态的,而只是其中的某个成员变量。

我们可以通过在 C#中使用static修饰符而不是类来达到这一点,而是针对特定的成员变量。如果我们需要在前面代码中使用它,以下将是更新的代码:

internal class Configuration
{
    public static string ConnectionString;
}

然而,访问这个属性的方式将不会改变。我们仍然可以通过使用类的名称来完成。

静态方法

在 C#中,一个类可以有两种类型的方法:静态方法和非静态方法。静态方法在类的不同实例对象之间共享,而非静态方法对每个实例都是唯一的。就像静态成员变量一样,我们可以使用static关键字声明一个方法为静态,并通过直接使用类名来访问它们。

以下代码示例表明我们如何在类中创建一个static方法:

internal class Configuration
{
    public static string ConnectionString;
    public static void CreateConnectionString()
    {      
    }
}

要执行静态方法,我们可以使用以下代码片段:

Configuration.CreateConnectionPath(); 

在下一节中,我们将探讨构造函数及其在 C#中的实现。

构造函数

每当为classstruct类型创建对象时都会调用构造函数。它们可以帮助我们在这些类型中设置一些默认值。

在第二章“理解类、结构和接口”中,当我们理解classstruct类型之间的区别时,提到与类不同,结构体现在没有默认构造函数。在编程术语中,这个构造函数被称为无参构造函数。如果一个程序员没有为类指定任何构造函数,那么每当创建类的对象时,默认构造函数就会触发,并为类中存在的成员变量设置默认值。这些默认值是根据这些成员变量的类型默认值设置的。

在语法方面,构造函数只是名称与其相应类型相同的函数。在方法签名中,它有一个参数列表,可以映射到类型中存在的成员变量。它没有返回类型。

请注意,一个类或结构体可以有多种构造函数,每种构造函数的参数列表都不同。

让我们看看一个代码示例,我们将在这个示例中实现构造函数:

public class Animal
{
     public string Name;
     public string Type;

     public Animal(string Name, string Type)
     {
         this.Name = Name;
         this.Type = Type;
     }
 }

在前面的代码示例中,我们声明了一个具有两个成员变量NameTypeAnimal类。我们还声明了一个接受NameType作为字符串参数的两个参数构造函数。然后,我们使用this运算符将传递给成员变量的值赋给类中存在的成员变量。

我们可以使用以下代码实现来调用这个构造函数:

Animal animal = new Animal("Bingo", "Dog"); 

在下一节中,我们将探讨如何在 C#中实现命名参数。

命名参数

命名参数是在 C# 4.0 中引入的,它允许我们使用参数名而不是参数传递的顺序将参数传递给方法/构造函数/委托/索引器。

使用命名参数,开发者不再需要担心传递参数的顺序。只要他们将与传递的值关联正确的参数名称,顺序就无关紧要。参数名称将与方法定义中参数的名称进行比较。让我们通过以下代码示例来了解它是如何工作的:

internal Double CalculateCompoundInterest(Double principle, Double interestRate, int noOfYears)
{
     Double simpleInterest = (principle) * Math.Pow((1 + 
      (interestRate)/100), noOfYears);
     return simpleInterest;
}

在前面的代码示例中,我们通过传递本金、利率和金额存入银行的时间长度来计算复利。

如果我们不使用命名参数来调用方法,我们将使用以下代码片段:

Double interest = CalculateCompoundInterest(500.5F, 10.5F, 1);            

如果我们仔细观察前面的示例,在调用函数时,开发者需要完全清楚本金和利率参数的顺序。这是因为如果开发者在调用函数时出错,结果输出将是不正确的。

使用命名参数,我们可以使用以下语法调用方法:

Double namedInterest = CalculateCompoundInterest(interestRate: 10.5F, noOfYears: 1, principle: 500.5F); 

注意,在前面的代码中,我们不是按照方法中定义的顺序传递参数值,而是使用参数名称将传递的值与方法中声明的参数进行映射。在下一节中,我们将探讨 C# 4.0 中引入的另一个特性,即 可选参数,它与命名参数一起引入。

可选参数

C# 中的可选参数允许我们以这种方式定义方法,即某些参数是可选的。换句话说,在定义可选参数的函数时,我们指定了一个默认值。

如果在调用方法时未为可选参数传递值,它将假定一个默认值。让我们通过一个代码示例来了解 C# 中可选参数的工作方式:

static float MultiplyNumbers(int num1, int num2 = 2, float num3 = 0.4f)
{
     return num1 * num2 * num3;
}

在前面的代码示例中,我们定义了一个具有三个参数的 MultiplyNumbers 方法,分别是 num1num2num3num1 参数是必需的,而其他两个参数 num2num3 是可选的。

请注意,在定义函数时,如果存在可选参数,则必须将它们放在所有必需参数之后指定。

如果我们需要执行前面的方法,我们可以使用以下任何代码片段:

float result = MultiplyNumbers(2); // output = 1.6f
float result1 = MultiplyNumbers(2, 5); // output = 4f
float result2 = MultiplyNumbers(2, 4, 5); // output = 40f

注意,如果没有传递任何可选参数,编译器将不会出现错误,并且如果未传递任何可选参数,则将使用函数声明中定义的默认值。在下一节中,我们将探讨泛型类型在 C# 中的实现方式。

泛型类型

泛型允许我们设计不涉及数据类型概念的类和方法。换句话说,当我们谈论方法时,泛型允许我们定义方法而不指定输入变量的类型。

让我们通过以下代码实现来了解它如何帮助我们。在以下示例中,我们创建了一个函数,用于比较两个 int 变量 AB 之间的值。如果值相同,它返回 true;如果值不同,它返回 false

static private bool IsEqual(int A, int B)
{
     if(A== B)
     {
         return true;
     }
     else
     {
         return false;
     }
 }

现在,假设我们尝试传递一个不是 int 类型的变量。在以下屏幕截图中,我们尝试传递 string 而不是 int,编译器给出错误:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/584e7232-e0d1-4df1-9649-492b26818b65.png

如以下屏幕截图所示,它将给出以下错误:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/ff83eaec-b056-4d41-a548-7d51c30582fe.png

如前一个屏幕截图所示,IsEqual 函数接受 int 类型的输入。然而,在调用函数时,我们传递的是 string 类型的变量。由于类型不匹配,编译器显示错误。

为了纠正这个错误,我们需要将 IsEqual 函数泛型化。我们可以通过修改函数,使其不再接受 int 类型的输入变量,而是接受 object 类型的输入变量。

请注意,C# 中的所有变量都继承自 object

在此代码示例中,我们两次调用 IsEqual 函数并传递不同的输入参数。在第一次调用中,我们传递 string;然而,在第二次调用中,我们传递 int。请注意,当我们编译项目时,没有检索到编译时错误,并且函数比较传递的变量,而不考虑类型:

static void Main(string[] args)
{
     UnSafeExample();
     IsEqual("string", "string");
     IsEqual(10, 10);
}

static private bool IsEqual(object A, object B)
{
     if (A == B)
     {
         return true;
     }
     else
     {
         return false;
     }
 }

尽管前面的代码实现对所有数据类型都是泛型的,但它会导致以下问题:

  • 性能下降:在 IsEqual 函数定义中,变量的数据类型是 object。由于这个原因,对于所有调用此函数的情况,变量都需要从其原始类型(即 intstring)转换为 object。这种转换将给应用程序增加额外的负担,从而导致性能下降。在编程术语中,这种转换被称为 装箱和拆箱,我们将在本章稍后讨论。

  • 类型不安全:这种方法不会导致类型不安全。例如,我将通过传递以下变量来调用该函数:

IsEqual(10, "string");

如果我这样做,编译器不会给出任何错误,尽管我们知道这个调用没有意义。为了避免这些问题,同时仍然提供给我们进行泛型调用的能力,C# 提供了使用 泛型类型 的工具。

使用泛型类型,我们可以避免指定函数输入变量的任何数据类型。因此,IsEqual 的实现将如下所示:

static private bool IsEqual<T>(T A, T B)
{
     if (A.Equals(B))
     {
         return true;
     }
     else
     {
         return false;
     }
 }

在前面的代码示例中,请注意,我们使用 T 来表示数据类型,因此使其对所有数据类型都是泛型的。

由于我们没有使用 object,因此不会有变量的装箱和拆箱。如果我们仍然尝试向此函数传递错误的数据类型,如以下截图所示,编译器将给出错误:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/dce39b3e-cc88-4d9e-bc76-a67b944712cc.png

在下一个主题中,我们不会介绍 C# 使用来处理数据变量类型的不同概念。我们将介绍如何在 C# 中使用装箱和拆箱将一种数据类型转换为另一种类型,以及我们在消费不同类型的变量时应注意的不同事项。

C# 中的数据类型消费

C# 是一种强类型语言。这基本上意味着,当我们声明一个特定数据类型的变量时,如以下示例所示,我们不能再次声明 x 变量:

int x = 5;

此外,我们无法将任何非整数值赋给此 x 变量。因此,以下语句将给出错误:

x = "Hello";

为了克服这种强类型特性,C# 在我们消费类型时提供了一些功能。这包括值类型变量的装箱和拆箱、使用动态关键字以及将一个数据类型的变量隐式或显式转换为另一个数据类型的变量。让我们逐一了解这些概念,并理解它们在 C# 中的工作原理。

装箱和拆箱

在 C# 中,装箱意味着将值类型变量转换为引用类型变量。拆箱是装箱的相反操作。它指的是将引用类型变量转换为值类型变量。装箱和拆箱对应用程序的性能有害,因为它们是编译器的开销。作为开发者,我们应该尽可能地避免它们;然而,这并不总是可能的,我们在编程过程中会遇到一些情况,迫使我们使用这个概念。

让我们通过以下示例来了解装箱和拆箱是如何工作的:

static private void BoxAndUnBox()
{
     int i = 3;
     // Boxing conversion from value to reference type
     object obj = i;
     // Unboxing conversion from reference type to value type
     i = (int)obj;
 }

在代码实现中,我们可以看到以下内容:

  • 我们已声明一个 i 变量,其类型为 int,并已赋予它 3 的值。现在我们知道,作为 int,这是一个值类型的引用。

  • 接下来,我们声明一个 obj 变量,其类型为 object,并将其赋值为 i 中的值。我们知道 object 是一个引用类型变量。因此,在内部,CLR 将执行装箱,并将 i 变量中的值转换为引用类型变量。

  • 接下来,在第三条语句中,我们正在进行相反的操作。我们试图将一个引用类型变量中的值,即 obj,赋给一个值类型变量,i。在这个阶段,CLR 将执行拆箱操作。

请注意,在进行装箱操作时,我们不需要显式地将值类型转换为引用类型。然而,当我们进行拆箱操作时,我们需要显式指定要转换到的变量类型。这种显式指定要转换到的变量类型的方法被称为类型转换。要进行类型转换,我们可以使用以下语法:

i = (int)obj;

它基本上意味着这种转换可能导致InvalidCastException类型的异常。例如,在上面的例子中,我们知道obj中的值是10。然而,如果它获取一个无法转换为int值的值,例如string,编译器将给出运行时错误。

现在,在下一节中,我们将探讨 C#为我们提供用于在数据类型之间进行转换的不同技术。

C#中的类型转换

C#中的类型转换基本上意味着将变量从一个数据类型转换为另一个数据类型。现在我们将探讨 C#中可用的不同类型转换。

隐式转换

隐式转换是由编译器自动完成的。编译器在没有任何开发者干预或命令的情况下执行隐式类型转换。编译器执行隐式类型转换必须满足以下两个条件:

  • 无数据丢失:编译器必须确定如果它隐式执行转换,将不会发生数据丢失。在第二章,“理解类、结构和接口”,在“数据类型”部分,我们看到了每种数据类型都会在内存中占用空间。因此,如果我们尝试将一个类型为float的变量(占用 32 字节内存)赋值给一个类型为double的变量(占用 64 字节内存),我们可以确信在转换过程中不会发生数据丢失。

  • 无转换异常的可能性:编译器必须确定在将值从一种数据类型转换为另一种数据类型的过程中不会发生异常。例如,如果我们尝试将一个string值设置到一个float变量中,编译器将不会执行隐式转换,因为这将会是一个无效的转换。

现在,让我们看一下以下代码实现,以了解 C#中隐式转换是如何工作的:

 int i = 100;
 float f = i;

在前面的代码示例中,我们声明了一个int类型的变量i,并给它赋值为100。在下一个语句中,我们声明了一个float类型的变量f,并将其值赋给i

现在,编译器会确定隐式转换所需的两个条件都已满足,即float占用的内存比int多,并且不存在无效转换异常的可能性——int值也是float变量中的一个有效值。因此,编译器不会报错,并执行隐式转换。

然而,如果我们进行反向操作,即尝试将 float 值赋给 int,编译器将确定条件未满足,并将给出编译时错误。请参考以下截图:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/dcd025b4-b567-4c1f-8397-14a238c0ad19.png

然而,在某些情况下,即使有可能数据丢失,我们仍然希望进行这些转换。C# 提供了 显式转换,允许我们明确指示编译器让转换发生。让我们看看 显式转换 是如何进行的。

显式转换

当编译器无法隐式更改变量的类型,但我们仍然希望进行转换时,我们需要明确指示编译器进行转换。这被称为显式转换

在 C# 中,有两种进行显式转换的方法:

  • 使用类型转换操作:在这种情况下,我们使用基本数据类型来指示编译器进行显式转换。例如,对于前面示例中尝试的代码实现,以下将是语法:
float k = 100.0F;
int j = (int)k;

在前面的代码中,我们通过在浮点变量之前使用 int 类转换来明确告诉编译器进行类型转换。

  • 使用 Convert:C# 提供了 Convert 类,我们可以使用它来进行多种数据类型之间的类型转换。如果我们使用 Convert 类而不是 int 关键字,以下将是语法:
float k = 100.0F;
int j = Convert.ToInt32(k);

Convert 类可用于不同数据类型之间的类型转换。请参考以下截图以了解 Convert 类中可用的不同选项。根据使用情况,我们可以使用 Convert 类中的适当方法:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/3f5171d1-5c82-499b-85c0-85c8a71d192d.png

因此,程序的总体实现将如下所示:

float k = 100.67F;
int j = (int)k;
int a = Convert.ToInt32(k);
Console.WriteLine(j);
Console.WriteLine(a);
Console.ReadLine();

现在,让我们尝试运行这个程序来看看它给出的输出:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/54cf7e2a-e0a1-420a-8833-6692490ed6f2.png

这意味着当我们使用类型转换关键字,即 (int)k,编译器尝试从 float 变量 k 中提取整数部分,结果为 100

另一方面,当我们使用 Convert 类,即 Convert.ToInt32(k),它会尝试提取与浮点变量 k 最接近的整数,结果为 101。这是开发者在决定使用类型转换和 Convert 类之间需要了解的关键区别之一。

当我们查看显式类型转换时,我们需要注意两个辅助方法,这些方法帮助我们进行转换:

  • Parse

  • TryParse

ParseTryParse 方法都用于将 string 转换为不同的数据类型。然而,在处理无效情况异常的方式上存在细微差别。让我们通过以下示例来看看它们是如何工作的以及它们之间的区别:

string number = "100";
int num = int.Parse(number); 

在前面的例子中,我们声明了一个字符串对象,并给它赋值为100。现在,我们正在尝试使用Parse方法将这个值转换为整数。当我们运行程序时,我们看到以下输出:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/f3d439ed-e0cf-44b3-9532-2f306ed94e51.png

这意味着解析方法将字符串转换为它的整数等价物,并将值赋给另一个变量,num

现在,假设数字中的值是100wer。现在很明显,number字符串中的值不能转换为int,因为它包含一些无法归类到整数对象中的字符。当我们运行这个程序时,我们得到以下异常:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/f5cbe848-768a-4b09-9e8b-09bc6c571ba4.png

为了避免这种情况,我们使用TryParse。在TryParse中,CLR 尝试将字符串对象转换为指定的数据类型。然而,如果转换返回错误,TryParse返回false,换句话说,转换失败。在其他情况下,它返回true。因此,如果我们用TryParse编写相同的实现,我们会这样做:

 string number = "100wer"; 
 int num;
 bool parse = int.TryParse(number, out num);
 if(parse)
 {
     Console.WriteLine(num);
 }
 else
 {
     Console.WriteLine("Some error in doing conversion");
 }
 Console.ReadLine(); 

在前面的程序中,我们声明了一个string类型的变量,并使用TryParse将其值转换为int类型的变量。我们正在检查转换是否成功。如果成功,我们打印出数字;在其他情况下,我们打印一条语句来显示在类型转换过程中出现了错误。当我们运行程序时,我们得到以下输出:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/8d07724f-5c34-4655-b6b4-a5936b275a7e.png

从输出中我们可以看到,编译器告诉我们TryParse操作出现了错误;然而,与在相同场景下抛出无效案例异常的Parse方法不同,它并没有在应用程序中抛出异常。

在下一节中,我们将快速回顾封装的概念,这是我们已经在第三章《理解面向对象编程》中讨论过的,我们将看到如何为类的成员变量对象实现属性,这样我们就可以在不用担心隐藏复杂性的情况下消费它们。

强制封装

在之前,我们在第二章《理解类、结构和接口》和第三章《理解面向对象编程》中讨论了以下概念:

  • 访问修饰符及其如何帮助我们控制同一类、同一程序集和派生类中方法和字段的访问。

  • 封装及其如何帮助我们将在同一对象中相关联的字段和方法组合在一起。

然而,封装中还有一个叫做属性的概念,它确保没有人可以直接访问类外的数据字段。这有助于我们确保我们对数据字段的修改有控制权。

属性与类的字段非常相似。就像类的字段一样,它有一个类型、名称和访问修饰符。然而,使其不同的地方在于存在访问器。访问器是允许我们从字段设置和检索值的getset关键字。

属性的语法如下:

class SampleProperty
{ 
     private string name;
     public string Name
     {
         set { if(value != null)
                 {
                     this.name = value;
                 }
               else
                 {
                     throw new ArgumentException();
                 }    
             }
         get { return this.name; }
     }
 }

在前面的代码中,请注意以下几点:

  • 对于SampleProperty类,我们已声明了一个name字段和一个Name属性。

  • name字段已被标记为private,因此它不会在SampleProperty类外部被访问。

  • Name属性已被标记为public,并具有getset访问器。

  • set方法中,我们正在检查传递的值是否为 null。如果是 null,我们将引发一个参数异常。因此,我们在name字段上可以设置的值周围设置了规则。

以这种方式,属性帮助我们消费类的字段。

字符串操作

字符串是 C#中一个非常重要的数据类型。字符串数据类型用于保存文本为string。在编程术语中,它是一系列字符。字符串是一个引用类型变量,与其他基本数据类型变量(如intfloatdouble,它们是值类型变量)不同。此外,字符串在本质上是不变的,也就是说,它们中存在的值不能改变。在本节中,我们将探讨与该数据类型相关的不同操作。

因此,请看以下代码示例:

string s = "Hello";
s = "world";

当我们将Test值分配给已声明的string对象时,CLR 内部会为修改后的string对象分配一个新的内存块。因此,对于我们在字符串上进行的每个操作,而不是修改相同的string对象,CLR 会声明一个新的string对象。由于这个原因,我们在对字符串进行操作时需要非常小心,例如,如果我们在一个字符串对象上执行以下循环操作:

string s = String.Empty;
for(int z = 0; z < 100; z++)
{
    s = + "a";
}

在前面的代码中,我们在循环中将字符串对象s与一个字符a连接起来。这个循环将运行100次。因此,CLR 将不断为string对象分配更多的内存。因此,由于内存使用,从性能角度来看,前面的操作并不好。

为了帮助改进string中的这个特性,C#为我们提供了两个内置类,StringbuilderStringWriter,我们将在下一节中讨论它们。我们还将查看 C#中可用于执行字符串搜索的一些功能。

StringBuilder

Stringbuilder 是 C# 提供的一个内部类,它帮助我们改进 string 操作函数。为了解释这个想法,我们将执行一个从 0100for 循环,并在每个循环中将结果输出与字母 a 连接起来。内部,字符串构建器使用缓冲区来修改字符串值,而不是在每次字符串操作时分配内存。以下代码示例展示了我们如何使用字符串构建器进行字符串操作:

StringBuilder sb = new StringBuilder(string.Empty);
for (int z = 0; z < 100; z++)
{
     sb.Append("a"); 
}

在前面的代码中,我们声明了一个 StringBuilder 对象 sb,并在循环中将其值与 a 连接。内部,StringBuilder 将使用内部缓冲区来管理这些操作。

字符串读取器和字符串写入器

StringReaderStringWriter 类分别从 TextReaderTextWriter 类派生。TextReaderTextWriter 用于处理诸如从 XML 文件读取、生成 XML 文件或从文件读取等 API。

我们将在第十四章 执行 I/O 操作中更详细地研究 TextReaderTextWriter 类。

使用 StringReaderStringWriter 类,我们可以通过操作字符串和字符串构建器的对象来与这些 I/O 操作进行交互。

让我们通过以下示例来更好地理解这些方法。在以下示例中,我们使用 StringWriter 首先创建一个 XML 文件的摘录,然后我们将结果 XML 表示传递给 StringReader,它将尝试读取其中的元素。

在以下代码示例中,我们使用 XMLWriter 创建一个以 Student 为起始元素并具有 Name 属性的 XML 文件。我们使用 StringWriter 保存 XML 文件的字符串表示:

static private string CreateXMLFile()
{
     string xmlOutput = string.Empty;
     var stringWriter = new StringWriter();
     using (XmlWriter writer = XmlWriter.Create(stringWriter))
     {
         writer.WriteStartElement("Student");
         writer.WriteElementString("Name", "Rob");
         writer.WriteEndElement();
         writer.Flush();
     }
     xmlOutput = stringWriter.ToString();
     return xmlOutput;
}

假设我们打印程序的输出;我们将得到以下结果:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/a68b5ea5-6854-4116-92f4-e517e562ee0d.png

现在,在以下代码片段中,我们将使用 StringReader 来读取这个 XML 文件:

static private void ReadXMLFile(string xml)
{
     var stringReader = new StringReader(xml);
     using (XmlReader reader = XmlReader.Create(stringReader))
     {
         reader.ReadToFollowing("Name");
         string studentName = reader.ReadInnerXml();
         Console.WriteLine(studentName);
     }
 }

请注意,我们向函数传递了一个字符串参数,该参数首先被转换为 StringReader 对象。从那个 StringBuilder 对象,我们创建了一个 XmlReader 对象。

ReadToFollowing 函数读取 XML 文件,直到找到具有相应名称的元素,该名称作为参数传递给函数。在前面的代码示例中,我们将 Name 参数传递给 XmlReader 对象。根据我们传递给它的 XML 文件,它将带我们到 Rob 元素。为了读取元素的文本表示,我们可以使用 reader 对象上的 ReadInnerXml 函数。因此,在前面的示例中,studentName 变量将被分配 Rob 的值。如果我们执行代码片段,我们将得到以下输出:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/ca8789ae-ce24-45c5-8943-a61418924161.png

在下一节中,我们将介绍一些我们可以用来在字符串对象中搜索特定字符的函数。

字符串搜索

如其名所示,字符串搜索涉及在另一个字符串中搜索特定字母或字符串的存在。C# 提供了多种方法来完成这项工作。

请注意,C# 是一种区分大小写的语言。因此,搜索字符,比如 C,与在字符串中搜索字符 c 是不同的。

请参考以下使用 string 对象可以进行的不同类型的搜索:

  • Contains: 当我们想要检查一个特定字符是否存在于字符串中时,我们使用 Contains 函数。以下示例检查字符 z 是否存在于字符串对象中。如果存在,它返回 true;否则,返回 false

让我们看看以下示例:

string s = "hello australia";
var contains = s.Contains("z");
if(contains)
{
   Console.WriteLine(" z is present in it.");
}
else
{
   Console.WriteLine(" z is not present");
}  

在前面的代码中,使用 Contains 函数,我们正在检查 z 是否出现在我们调用函数的字符串中。由于我们为具有值 hello australia 的变量调用它,因此它将返回 false 值,因为 z 不在字符串中出现。因此,当代码执行时,我们得到以下输出:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/e726d1e7-4fb5-4bbc-8a35-05a8f098a627.png

  • IndexOf: 如果我们想要找出字符串中特定字符出现的位置,我们会使用这个函数。

例如,在下面的代码示例中,我们正在寻找字符串 hello australia 中字符 a 的首次和末次出现的位置:

 string s = "hello australia";
 var firstIndexOfA = s.IndexOf("a");
 Console.WriteLine(firstIndexOfA);
 var lastIndexOfA = s.LastIndexOf("a");
 Console.WriteLine(lastIndexOfA);

当我们执行程序时,我们将得到首次出现的位置为 6,末次出现的位置为 14。IndexOf 函数检索字符或字符串在字符串中首次出现的位置,请注意,它不会忽略空格。因此,空白也被计为一个字符。同样,LastIndexOf 函数检索相应字符或字符串出现的最后一个索引:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/ad91476c-0e17-41d2-8c33-6ccd430b106c.png

请注意,在 C# 中,对于任何数组或字符串,第一个字符的索引为零。

  • StartsWith/EndsWith: 如果我们想要检查一个字符串是否以特定的字符开始或结束,我们会使用这个函数。

以下代码示例显示了一个场景,其中我们正在检查之前使用的相同字符串对象是否以 h 开始和结束。在以下代码中,在第一个语句中,我们正在检查 s 字符串变量是否以 h 开始。根据评估结果,我们在控制台窗口中打印输出。同样,在下一个语句中,我们正在检查相同的字符串变量是否以 h 结束。根据评估结果,我们再次在控制台窗口中打印输出:

if(s.StartsWith("h"))
{
     Console.WriteLine("It Starts with h.");
}
else
{
     Console.WriteLine("It does not starts with h.");
}

if (s.EndsWith("h"))
{
     Console.WriteLine("It ends with h.");
}
else
{
     Console.WriteLine("It does not ends with h.");
}

请参考以下输出以了解前面的代码示例:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/d1b02d4e-a82a-4a9e-82e7-ff5db9ae53ef.png

  • Substring:如果我们想从一个特定的字符串对象中提取子字符串,我们将使用此函数。在 C# 中,可能的子字符串有两种变体。在一个中,我们只指定起始索引并从该特定索引提取子字符串。在另一个变体中,我们指定起始和结束索引,并提取该子字符串中的字符。

下面是这个代码示例:

 string subString = s.Substring(3, 6);
 string subString2 = s.Substring(3);
 Console.WriteLine(subString);
 Console.WriteLine(subString2);

在前面的代码示例中,我们正在寻找字符串对象 hello australia 的两个子字符串。

在第一个子字符串中,我们传递了起始索引为 3,结束索引为 6。因此,子字符串将返回值,lo aus

在第二个子字符串中,我们只传递了起始索引,3。因此,它将从该索引返回整个字符串。以下是此执行输出的截图:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/prog-csp-exam-70483-mcsd-gd/img/6ce71ca3-d860-434c-b634-e699c5b69c1a.png

这些是 C# 中可用的不同字符串操作函数。在下一节中,我们将概述反射,并了解它是如何帮助我们从程序集获取结构——换句话说,类及其方法和属性。

反射概述

在 C# 中,反射意味着在运行时检查程序集的内容。它返回程序集中每个类的元数据——因此,它返回以下内容:

  • 类的名称

  • 类中存在的所有属性

  • 所有方法及其返回类型和函数参数

  • 类中存在的所有属性

在 第十章,使用反射在运行时查找、执行和创建类型,我们将深入探讨反射;然而,在本章中,我们只会通过一个代码示例来展示如何在 C# 中实现反射,以解码程序集中存在的所有元数据。

要使用反射,我们需要包含 System.Reflection 命名空间,它帮助我们使用所需的类,例如 Assembly。请参考以下函数,该函数根据其路径读取特定的程序集,并读取程序集中存在的所有类、方法和参数:

static private void ReadAssembly()
{
     string path = @"C:\UCN Code Base\Programming-in-C-Exam-70-483-
      MCSD-Guide\Book70483Samples\Chapter8\bin\Debug\ABC.dll";
     Assembly assembly = Assembly.LoadFile(path);
     Type[] types = assembly.GetTypes();
     foreach(var type in types)
     {
         Console.WriteLine("Class : " + type.Name);
         MethodInfo[] methods = type.GetMethods();
         foreach(var method in methods)
         {
             Console.WriteLine("--Method: " + method.Name);
             ParameterInfo[] parameters = method.GetParameters();
             foreach (var param in parameters)
             {
                 Console.WriteLine("--- Parameter: " + param.Name + " : 
                  " + param.ParameterType); 
             }
         }
     }
    Console.ReadLine();
}

在前面的代码库中,我们已声明了一个 C# 中程序集的完全限定路径。接下来,我们已声明了一个 Assembly 类的对象,并检索了程序集中所有 Types 的数组。然后,我们正在遍历每个类型,并找出这些类型中的方法。一旦我们为每个类型获取了方法列表,我们就检索该方法中存在的参数列表及其参数类型。

摘要

在本章中,我们学习了如何在 C#中管理类型。我们对 C#中可用的不同数据类型进行了回顾。我们深入探讨了值类型和引用类型。我们还回顾了指针数据类型,并学习了它是如何工作的。我们还查看了一些用户可以选择变量类型的实践。我们还查看了一般类型,并学习了它们如何帮助我们提高系统的性能。

然后,我们探讨了我们在 C#中声明类型时使用的不同技术。我们学习了 C#中的装箱和拆箱是如何工作的。然后,我们查看如何消费这些数据类型。我们还探讨了类型转换,包括隐式和显式转换,并学习了它们如何帮助我们转换一种数据类型到另一种数据类型。

然后,我们查看了一下Properties以及它是如何帮助我们更好地控制从类的字段属性设置和检索值的。然后,我们研究了字符串以及它们是如何工作的。我们探讨了字符串的不可变特性。我们探讨了使用StringBuilderStringWriterStringReader,这些工具帮助我们提高使用字符串的性能。然后,我们查看 C#中帮助我们在字符串上执行不同操作函数的不同函数。最后,我们对反射进行了高级回顾,并通过代码示例学习了我们如何检索程序集中的元数据。

在下一章中,我们将探讨 C#中垃圾回收是如何执行的。我们将探讨 CLR 如何在 C#中管理不同数据类型的内存。我们将探讨 C#如何允许我们管理“非托管资源”或本章中我们看到的“指针类型”。我们还将探讨我们如何实现IDisposable接口来管理非托管资源。

问题

  1. 在使用指针声明时,我们在程序函数中使用的关键字是什么?

    1. 密封

    2. 安全

    3. 内部受保护

    4. 不安全

  2. 以下代码片段的输出会是什么?

 float f = 100.23f;
 int i = f;
 Console.WriteLine(i);
    1. 100

    2. 编译时错误

    3. 101

    4. 运行时错误

  1. 以下代码片段的输出会是什么?
string s = "hello australia";
var contains = s.Contains("A");
if(contains)
{
     Console.WriteLine("it's present");
}
else
{
     Console.WriteLine("it's not present");
}
  1. 它存在

  2. 它不存在

答案

  1. 不安全

  2. 编译时错误

  3. 它不存在

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐