C#12 口袋参考(三)
原文:
zh.annas-archive.org/md5/97bc15629f1b51a0671040c56db61b92译者:飞龙
第十六章:结构体
结构体类似于类,但具有以下关键区别:
-
结构体是一个值类型,而类是一个引用类型。
-
结构体不支持继承(除了隐式从
object或更确切地说是System.ValueType派生)。
一个结构体可以拥有类似于类的所有成员,但不能有析构函数、虚拟或受保护成员。
警告
在 C# 10 之前,结构体进一步禁止定义字段初始化程序和无参数构造函数。尽管现在这种禁止已经放宽,主要是为了记录结构体的利益(参见“记录”),但在定义这些构造函数之前值得仔细考虑,因为它们可能导致混乱的行为,我们将在“结构体构造语义”中描述。
当需要值类型语义时,结构体是合适的选择。良好的示例是数值类型,其中赋值复制值而不是引用更为自然。因为结构体是值类型,每个实例不需要在堆上实例化对象(以及随后的收集);在创建许多类型实例时,这可以节省有用的开销。
与任何值类型一样,结构体可以间接地最终出现在堆上,通过装箱或者作为类中字段的一部分。如果我们在以下示例中实例化SomeClass,字段Y将引用堆上的一个结构体:
struct SomeStruct { public int X; }
class SomeClass { public SomeStruct Y; }
同样地,如果您声明了一个SomeStruct的数组,该实例将位于堆上(因为数组是引用类型),尽管整个数组只需要单个内存分配。
从 C# 7.2 开始,您可以将ref修饰符应用于结构体,以确保它只能以将其放置在堆栈上的方式使用。这不仅能够进一步优化编译器,还允许使用Span<T>类型。
结构体构造语义
注意
在 C# 11 之前,结构体中的每个字段都必须在构造函数(或字段初始化程序)中显式赋值。现在这个限制已经放宽。
除了您定义的任何构造函数外,结构体始终具有一个隐式的无参数构造函数,该构造函数对其字段执行位清零操作(将它们设置为它们的默认值):
Point p = new Point(); // p.x and p.y will be 0
struct Point { int x, y; }
即使您定义了自己的无参数构造函数,隐式的无参数构造函数仍然存在,并且可以通过default关键字访问:
Point p1 = new Point(); // p1.x and p1.y will be 1
Point p2 = default; // p2.x and p2.y will be 0
struct Point
{
int x = 1; int y;
public Point() => y = 1;
}
在本例中,我们通过字段初始化器将x初始化为 1,通过无参数构造函数将y初始化为 1。然而,使用default关键字,我们仍然能够创建一个绕过这两个初始化的Point。默认构造函数也可以通过其他方式访问,正如下面的例子所示:
var points = new Point[10]; // Each point will be (0,0)
var test = new Test(); // test.p will be (0,0)
class Test { Point p; }
对于结构体,一个好的策略是设计它们的default值是一个有效状态,从而使初始化变得多余。
只读结构体和函数
您可以将readonly修饰符应用于结构体,以强制所有字段都是readonly;这有助于声明意图,并允许编译器更多优化自由度:
readonly struct Point
{
public readonly int X, Y; // X and Y must be readonly
}
如果您需要在更精细的层次上应用readonly,可以将readonly修饰符(从 C# 8 开始)应用于结构的函数。这样可以确保如果函数尝试修改任何字段,将生成编译时错误:
struct Point
{
public int X, Y;
public readonly void ResetX() => X = 0; // Error!
}
如果一个readonly函数调用一个非readonly函数,编译器会生成警告(并防御性地复制结构体,以避免突变的可能性)。
第十七章:访问修饰符
为了促进封装性,类型或类型成员可以通过在声明中添加访问修饰符来限制其对其他类型和其他程序集的可访问性:
public
完全可访问。这是枚举或接口成员的隐式可访问性。
internal
只能在包含的程序集或友元程序集中访问。这是非嵌套类型的默认可访问性。
private
只能在包含类型内部访问。这是类或结构体成员的默认可访问性。
protected
只能在包含类型或子类中访问。
protected internal
protected和internal可访问性的并集(这比单独的protected或internal更宽松,因为它使成员在两个方面更易访问)。
private protected
protected和internal可访问性的交集(这比单独的protected或internal更严格)。
file(从 C# 11 开始)
只能从同一文件内部访问。用于源代码生成器(见“扩展局部方法”)。此修饰符只能应用于类型声明。
在以下示例中,Class2可以从其所在的程序集外部访问;Class1则不行:
class Class1 {} // Class1 is internal (default)
public class Class2 {}
ClassB将字段x公开给同一程序集中的其他类型;ClassA则不会:
class ClassA { int x; } // x is private
class ClassB { internal int x; }
当您覆盖基类函数时,覆盖函数的可访问性必须相同。编译器阻止使用访问修饰符的不一致使用,例如,子类本身可以比基类不可访问,但不能更可访问。
友元程序集
您可以通过添加System.Runtime.CompilerServices.InternalsVisibleTo程序集属性,指定友元程序集的名称,来将internal成员暴露给其他友元程序集:
[assembly: InternalsVisibleTo ("Friend")]
如果友元程序集使用强名称签名,您必须指定其完整的 160 字节公钥。您可以通过语言集成查询(LINQ)提取此密钥—可以在 LINQPad 的* C# 12 简明教程*的第三章“访问修饰符”中的免费示例库中找到交互式示例。
可访问性封装
类型限制其声明成员的可访问性。最常见的封装示例是当您有一个带有public成员的internal类型时。例如:
class C { public void Foo() {} }
C的(默认)internal可访问性限制了Foo的可访问性,从效果上讲使得Foo是internal。将Foo标记为public的常见原因是为了更轻松地进行重构,如果以后将C更改为public。
第十八章:接口
接口类似于类,但仅指定行为而不保存状态(数据)。因此:
-
接口成员都是隐式抽象的。(有一些例外情况,我们将在“默认接口成员”和“静态接口成员”中描述。)
-
类(或结构)可以实现多个接口。相比之下,类只能从单个类继承,结构不能继承(除了从
System.ValueType派生)。
接口声明类似于类声明,但它(通常)不为其成员提供实现,因为其所有成员都是隐式抽象的。这些成员将由实现接口的类和结构实现。接口只能包含方法、属性、事件和索引器,这不是巧合,这些成员恰好是类的成员可以是抽象的。
这是System.Collections中定义的IEnumerator接口的稍微简化版本:
public interface IEnumerator
{
bool MoveNext();
object Current { get; }
void Reset();
}
接口成员始终隐式公共,并且不能声明访问修饰符。实现接口意味着为其所有成员提供一个public实现:
internal class Countdown : IEnumerator
{
int count = 6;
public bool MoveNext() => count-- > 0 ;
public object Current => count;
public void Reset() => count = 6;
}
您可以将对象隐式转换为其实现的任何接口:
IEnumerator e = new Countdown();
while (e.MoveNext())
Console.Write (e.Current + " "); // 5 4 3 2 1 0
扩展一个接口
接口可以派生自其他接口。例如:
public interface IUndoable { void Undo(); }
public interface IRedoable : IUndoable { void Redo(); }
IRedoable“继承”了IUndoable的所有成员。
显式接口实现
实现多个接口有时会导致成员签名冲突。您可以通过显式实现接口成员来解决这种冲突。例如:
interface I1 { void Foo(); }
interface I2 { int Foo(); }
public class Widget : I1, I2
{
public void Foo() // Implicit implementation
{
Console.Write ("Widget's implementation of I1.Foo");
}
int I2.Foo() // Explicit implementation of I2.Foo
{
Console.Write ("Widget's implementation of I2.Foo");
return 42;
}
}
因为I1和I2都具有冲突的Foo签名,所以Widget显式实现了I2的Foo方法。这样可以让这两个方法在同一个类中共存。调用显式实现的成员的唯一方法是将其转换为其接口:
Widget w = new Widget();
w.Foo(); // Widget's implementation of I1.Foo
((I1)w).Foo(); // Widget's implementation of I1.Foo
((I2)w).Foo(); // Widget's implementation of I2.Foo
显式实现接口成员的另一个原因是隐藏高度专业化且对类型正常使用案例有干扰的成员。例如,实现ISerializable的类型通常希望避免在未明确转换为该接口时展示其ISerializable成员。
虚拟实现接口成员
隐式实现的接口成员默认为密封的。必须在基类中将其标记为virtual或abstract以便重写:通过基类或接口调用接口成员然后调用子类的实现。
显式实现的接口成员不能标记为virtual,也不能以通常的方式重写。但是可以重新实现。
在子类中重新实现接口
子类可以重新实现任何已由基类实现的接口成员。重新实现会劫持成员实现(通过接口调用时),无论成员在基类中是否为virtual都有效。
在以下示例中,TextBox显式实现了IUndoable.Undo,因此不能标记为virtual。要“覆盖”它,RichTextBox必须重新实现IUndoable的Undo方法:
public interface IUndoable { void Undo(); }
public class TextBox : IUndoable
{
void IUndoable.Undo()
=> Console.WriteLine ("TextBox.Undo");
}
public class RichTextBox : TextBox, IUndoable
{
public new void Undo()
=> Console.WriteLine ("RichTextBox.Undo");
}
通过接口调用重新实现的成员会调用子类的实现:
RichTextBox r = new RichTextBox();
r.Undo(); // RichTextBox.Undo
((IUndoable)r).Undo(); // RichTextBox.Undo
在这种情况下,TextBox显式实现了Undo方法。如果TextBox改为隐式实现Undo方法,则RichTextBox仍然可以重新实现该方法,但在通过基类调用成员时,效果不会普遍:
RichTextBox r = new RichTextBox();
((TextBox)r).Undo(); // TextBox.Undo
默认接口成员
从 C# 8 开始,您可以为接口成员添加默认实现,使其成为可选实现:
interface ILogger
{
void Log (string text) => Console.WriteLine (text);
}
如果您希望在流行库中定义的接口中添加成员而不破坏(可能有数千个)实现,则这是有利的。
默认实现始终是显式的,因此如果实现ILogger的类未定义Log方法,则唯一调用它的方法是通过接口:
class Logger : ILogger { }
...
((ILogger)new Logger()).Log ("message");
这样可以防止多重实现继承的问题:如果将同一个默认成员添加到一个类实现的两个接口中,那么调用成员时不会存在歧义。
静态接口成员
接口还可以声明静态成员。有两种类型的静态接口成员:
-
静态非虚拟接口成员
-
静态虚拟/抽象接口成员
静态非虚拟接口成员
静态非虚拟接口成员主要用于帮助编写默认接口成员。它们不由类或结构体实现;而是直接使用。除了方法、属性、事件和索引器之外,静态非虚拟成员还允许字段,这些字段通常从默认成员实现中的代码访问:
interface ILogger
{
void Log (string text) =>
Console.WriteLine (Prefix + text);
static string Prefix = "";
}
静态非虚拟接口成员默认为公共,因此可以从外部访问:
ILogger.Prefix = "File log: ";
您可以通过添加访问修饰符(例如private、protected或internal)来限制此功能。
实例字段(仍然)被禁止。这符合接口的原则,即定义行为而非状态。
静态虚拟/抽象接口成员
静态虚拟/抽象接口成员(来自 C# 11)标记为static abstract或static virtual,并启用静态多态性,这是我们将在“静态多态性”中全面讨论的高级特性:
interface ITypeDescribable
{
static abstract string Description { get; }
static virtual string Category => null;
}
实现类或结构必须实现静态抽象成员,并可选择实现静态虚拟成员:
class CustomerTest : ITypeDescribable
{
public static string Description => "Customer tests";
public static string Category => "Unit testing";
}
除了方法、属性和事件之外,运算符和转换也是静态虚拟接口成员的合法目标(参见“运算符重载”)。静态虚拟接口成员通过约束的类型参数调用;我们将在“静态多态性”和“泛型数学”中演示这一点,之后会讨论泛型。
第十九章:枚举
枚举是一种特殊的值类型,允许您指定一组命名的数字常量。例如:
public enum BorderSide { Left, Right, Top, Bottom }
我们可以如下使用这种枚举类型:
BorderSide topSide = BorderSide.Top;
bool isTop = (topSide == BorderSide.Top); // true
每个枚举成员都有一个基础的整数类型值。默认情况下,基础值的类型为int,枚举成员被分配为常量0、1、2...(按其声明顺序)。您可以指定另一种整数类型,如下所示:
public enum BorderSide : byte { Left,Right,Top,Bottom }
您还可以为每个成员指定显式整数值:
public enum BorderSide : byte
{ Left=1, Right=2, Top=10, Bottom=11 }
编译器还允许您显式分配一些枚举成员。未分配的枚举成员从最后一个显式值递增。前面的示例等效于:
public enum BorderSide : byte
{ Left=1, Right, Top=10, Bottom }
枚举转换
您可以使用显式转换将enum实例转换为其基础整数值,反之亦然:
int i = (int) BorderSide.Left;
BorderSide side = (BorderSide) i;
bool leftOrRight = (int) side <= 2;
您还可以将一种枚举类型明确转换为另一种;翻译将使用成员的基础整数值。
数字文字0在特殊处理中不需要显式转换:
BorderSide b = 0; // No cast required
if (b == 0) ...
在此特定示例中,BorderSide没有具有整数值0的成员。这不会生成错误:枚举的限制是,编译器和 CLR 不会阻止分配超出成员范围的整数值:
BorderSide b = (BorderSide) 12345;
Console.WriteLine (b); // 12345
标志枚举
您可以组合枚举成员。为了避免歧义,可组合枚举的成员需要显式分配的值,通常是二的幂次方。例如:
[Flags]
public enum BorderSides
{ None=0, Left=1, Right=2, Top=4, Bottom=8 }
根据惯例,可组合的枚举类型应该使用复数而不是单数名称。要使用组合枚举值,您可以使用按位操作符,例如|和&。这些操作符适用于基础整数值:
BorderSides leftRight =
BorderSides.Left | BorderSides.Right;
if ((leftRight & BorderSides.Left) != 0)
Console.WriteLine ("Includes Left"); // Includes Left
string formatted = leftRight.ToString(); // "Left, Right"
BorderSides s = BorderSides.Left;
s |= BorderSides.Right;
Console.WriteLine (s == leftRight); // True
对于可组合的枚举类型,应将Flags属性应用于其上;如果未执行此操作,则在enum实例上调用ToString会输出数字而不是一系列名称。
为方便起见,您可以在枚举声明本身中包含组合成员:
[Flags] public enum BorderSides
{
None=0,
Left=1, Right=2, Top=4, Bottom=8,
LeftRight = Left | Right,
TopBottom = Top | Bottom,
All = LeftRight | TopBottom
}
枚举操作符
适用于枚举的操作符包括:
= == != < > <= >= + - ^ & | ˜
+= -= ++ -- sizeof
按位、算术和比较操作符返回处理基础整数值的结果。枚举与整数类型之间允许进行加法运算,但不允许两个枚举之间进行加法运算。
第二十章:嵌套类型
嵌套类型 声明在另一个类型的范围内。例如:
public class TopLevel
{
public class Nested { } // Nested class
public enum Color { Red, Blue, Tan } // Nested enum
}
嵌套类型具有以下特征:
-
它可以访问封闭类型的私有成员以及封闭类型可以访问的其他所有内容。
-
它可以声明具有全范围访问修饰符,而不仅限于
public和internal。 -
嵌套类型的默认可访问性是
private而不是internal。 -
从封闭类型外部访问嵌套类型需要使用封闭类型名称进行限定(就像访问静态成员时一样)。
例如,要从我们的 TopLevel 类外部访问 Color.Red,你需要这样做:
TopLevel.Color color = TopLevel.Color.Red;
所有类型都可以嵌套;但是,只有类和结构可以嵌套。
第二十一章:泛型
C# 有两种分离的机制用于编写可在不同类型间重复使用的代码:继承 和 泛型。继承通过基类型表达重用性,而泛型通过包含“占位符”类型的“模板”表达重用性。与继承相比,泛型可以 增加类型安全性 和 减少强制转换和装箱。
泛型类型
泛型类型 声明 类型参数 —— 消费泛型类型的内容提供 类型参数。这是一个泛型类型 Stack<T>,设计用于堆叠类型为 T 的实例。Stack<T> 声明了一个类型参数 T:
public class Stack<T>
{
int position;
T[] data = new T[100];
public void Push (T obj) => data[position++] = obj;
public T Pop() => data[--position];
}
我们可以如下使用 Stack<T>:
var stack = new Stack<int>();
stack.Push (5);
stack.Push (10);
int x = stack.Pop(); // x is 10
int y = stack.Pop(); // y is 5
注意
注意,在最后两行不需要向下转换,避免了运行时错误的可能性并消除了装箱/拆箱的开销。这使得我们的泛型栈优于使用 object 替代 T 的非泛型栈(参见“对象类型”的示例)。
Stack<int> 在类型参数 T 中填充了类型参数 int,隐式地在运行时创建了一个类型(综合在运行时发生)。Stack<int> 的定义如下(替换项以粗体显示,为了避免混淆,类名已经被隐藏):
public class ###
{
int position;
int[] data = new int[100];
public void Push (int obj) => data[position++] = obj;
public int Pop() => data[--position];
}
从技术上讲,我们称 Stack<T> 是一个 开放类型,而 Stack<int> 是一个 闭合类型。在运行时,所有泛型类型实例都是闭合的,占位符类型已填充。
泛型方法
泛型方法 在方法的签名中声明类型参数。通过泛型方法,可以以通用方式实现许多基本算法。这是一个交换任意类型 T 变量内容的泛型方法示例:
static void Swap<T> (ref T a, ref T b)
{
T temp = a; a = b; b = temp;
}
你可以如下使用 Swap<T>:
int x = 5, y = 10;
Swap (ref x, ref y);
通常情况下,不需要为泛型方法提供类型参数,因为编译器可以隐式推断类型。如果存在歧义,泛型方法可以如下调用带有类型参数:
Swap<int> (ref x, ref y);
在泛型 类型 中,除非以尖括号语法 引入 类型参数,否则方法不被视为泛型方法。在我们的泛型栈中,Pop 方法仅消耗类型的现有类型参数 T,并不被视为泛型方法。
方法和类型是唯一可以引入类型参数的构造。属性、索引器、事件、字段、构造函数、操作符等不能声明类型参数,尽管它们可以参与其封闭类型已声明的任何类型参数。例如,在我们的通用堆栈示例中,我们可以编写一个返回通用项的索引器:
public T this [int index] { get { return data[index]; } }
类似地,构造函数可以参与现有的类型参数,但不能引入它们。
声明类型参数
类型参数可以在类、结构、接口、委托(参见“委托”)和方法的声明中引入。您可以通过逗号分隔它们来指定多个类型参数:
class Dictionary<TKey, TValue> {...}
实例化:
var myDict = new Dictionary<int,string>();
泛型类型名称和方法名称可以重载,只要类型参数的数量不同即可。例如,以下三个类型名称不会冲突:
class A {}
class A<T> {}
class A<T1,T2> {}
注意
按照惯例,具有单一类型参数的泛型类型和方法将其参数命名为 T,只要参数的意图清楚即可。对于多个类型参数,每个参数都有一个更具描述性的名称(以 T 为前缀)。
typeof 和未绑定的泛型类型
开放的泛型类型在运行时不存在:开放的泛型类型作为编译的一部分被关闭。然而,未绑定的泛型类型可以在运行时存在——纯粹作为一个 Type 对象。在 C# 中指定未绑定的泛型类型的唯一方法是使用 typeof 运算符:
class A<T> {}
class A<T1,T2> {}
...
Type a1 = typeof (A<>); // *Unbound* type
Type a2 = typeof (A<,>); // Indicates 2 type args
Console.Write (a2.GetGenericArguments().Count()); // 2
您还可以使用 typeof 运算符来指定封闭类型:
Type a3 = typeof (A<int,int>);
它还可以指定一个开放类型(在运行时关闭):
class B<T> { void X() { Type t = typeof (T); } }
默认通用值
您可以使用 default 关键字获取泛型类型参数的默认值。引用类型的默认值为 null,值类型的默认值是对类型字段进行按位零操作的结果:
static void Zap<T> (T[] array)
{
for (int i = 0; i < array.Length; i++)
array[i] = default(T);
}
从 C# 7.1 开始,可以省略类型参数,编译器能够推断出的情况下:
array[i] = default;
泛型约束
默认情况下,类型参数可以用任何类型替换。约束 可以应用于类型参数,以要求更具体的类型参数。约束有八种类型:
where *T* : *base-class* // Base class constraint
where *T* : *interface* // Interface constraint
where *T* : class // Reference type constraint
where *T* : class? // (See "Nullable Reference Types")
where *T* : struct // Value type constraint
where *T* : unmanaged // Unmanaged constraint
where *T* : new() // Parameterless constructor
// constraint
where *U* : *T* // Naked type constraint
where *T* : notnull // Non-nullable value type
// or non-nullable reference type
在下面的示例中,GenericClass<T,U> 要求 T 派生自(或与)SomeClass 相同,并实现 Interface1,并要求 U 提供一个无参数的构造函数:
class SomeClass {}
interface Interface1 {}
class GenericClass<T,U> where T : SomeClass, Interface1
where U : new()
{ ... }
约束可以应用于类型参数定义的任何地方,无论是在方法中还是在类型定义中。
注意
约束 是一种限制;然而,类型参数约束的主要目的是允许禁止的事情。
例如,约束 T:Foo 允许您将 T 的实例视为 Foo,约束 T:new() 允许您构造 T 的新实例。
基类约束指定类型参数必须是某个类的子类(或匹配);接口约束指定类型参数必须实现该接口。这些约束允许类型参数的实例被隐式转换为该类或接口。
类约束和结构约束指定T必须是引用类型或(非空)值类型。未管理的约束是结构约束的更强版本:T必须是简单值类型或递归地不含任何引用类型的结构体。无参构造约束要求T必须有一个公共的无参构造函数,并允许你在T上调用new():
static void Initialize<T> (T[] array) where T : new()
{
for (int i = 0; i < array.Length; i++)
array[i] = new T();
}
裸类型约束要求一个类型参数派生自(或匹配)另一个类型参数。
泛型类型的子类化
泛型类可以像非泛型类一样被子类化。子类可以将基类的类型参数保持开放,如以下示例:
class Stack<T> {...}
class SpecialStack<T> : Stack<T> {...}
或子类可以用具体类型关闭泛型类型参数:
class IntStack : Stack<int> {...}
子类型也可以引入新的类型参数:
class List<T> {...}
class KeyedList<T,TKey> : List<T> {...}
自引用泛型声明
类型可以在关闭类型参数时命名自身作为具体类型:
public interface IEquatable<T> { bool Equals (T obj); }
public class Balloon : IEquatable<Balloon>
{
public bool Equals (Balloon b) { ... }
}
以下也是合法的:
class Foo<T> where T : IComparable<T> { ... }
class Bar<T> where T : Bar<T> { ... }
静态数据
静态数据对于每个封闭类型都是唯一的:
Console.WriteLine (++Bob<int>.Count); // 1
Console.WriteLine (++Bob<int>.Count); // 2
Console.WriteLine (++Bob<string>.Count); // 1
Console.WriteLine (++Bob<object>.Count); // 1
class Bob<T> { public static int Count; }
协变性
注意
协变性和逆变性是高级概念。它们引入到 C#中的动机是允许泛型接口和泛型(特别是.NET 中定义的那些,如IEnumerable<T>)工作得更像你期望的那样。你可以在不理解协变性和逆变性背后的细节的情况下受益于此。
假设A可转换为B,如果X<A>可转换为X<B>,则X具有协变类型参数。
(根据 C#的协变性概念,可转换意味着通过隐式引用转换可转换,比如A是B的子类,或A实现了B。数值转换、装箱转换和自定义转换不包括在内。)
例如,类型IFoo<T>如果以下内容合法,则具有协变T:
IFoo<string> s = ...;
IFoo<object> b = s;
接口(和委托)允许协变类型参数。举例来说,假设我们在本节开始编写的Stack<T>类实现了以下接口:
public interface IPoppable<out T> { T Pop(); }
对T上的out修饰符表示T仅在输出位置(例如方法的返回类型)中使用,并标志类型参数为协变,允许以下代码:
// Assuming that Bear subclasses Animal:
var bears = new Stack<Bear>();
bears.Push (new Bear());
// Because bears implements IPoppable<Bear>,
// we can convert it to IPoppable<Animal>:
IPoppable<Animal> animals = bears; // Legal
Animal a = animals.Pop();
编译器允许从bears到animals的转换—这是通过接口的类型参数是协变而允许的。
注意
接口IEnumerator<T>和IEnumerable<T>(见“枚举和迭代器”)标记为协变T。这允许你将IEnumerable<string>转换为IEnumerable<object>,例如。
如果在输入位置使用协变类型参数(例如方法的参数或可写属性),编译器将生成错误。此限制的目的是确保编译时类型安全性。例如,它阻止我们向接口添加Push(T)方法,消费者可能会误用,试图将骆驼推入IPoppable<Animal>(请记住,我们示例中的底层类型是一堆熊)。要定义Push(T)方法,T必须实际上是逆变的。
注意
C#仅支持引用转换的元素的协变性(和逆变性)—不支持装箱转换。因此,如果您编写了一个接受IPoppable<object>类型参数的方法,可以使用IPoppable<string>但不能使用IPoppable<int>调用它。
逆变性
我们之前看到,假设A允许将隐式引用转换为B,则类型X具有协变类型参数,如果X<A>允许将引用转换为X<B>。当类型参数仅出现在输入位置时,带有in修饰符。扩展我们之前的示例,如果Stack<T>类实现以下接口:
public interface IPushable<in T> { void Push (T obj); }
我们可以合法地执行这样的操作:
IPushable<Animal> animals = new Stack<Animal>();
IPushable<Bear> bears = animals; // Legal
bears.Push (new Bear());
反映协变性,如果您尝试在输出位置使用逆变类型参数(例如作为返回值或可读属性),编译器将报告错误。
第二十二章:委托
委托将方法调用者在运行时连接到其目标方法。委托有两个方面:类型和实例。委托类型定义了调用者和目标将符合的协议,包括参数类型列表和返回类型。委托实例是指向符合该协议的一个(或多个)目标方法的对象。
委托实例确实充当调用者的代表:调用者调用委托,然后委托调用目标方法。这种间接性将调用者与目标方法解耦。
委托类型声明之前使用关键字delegate,但在其他方面类似于(抽象)方法声明。例如:
delegate int Transformer (int x);
要创建委托实例,可以将方法分配给委托变量:
Transformer t = Square; // Create delegate instance
int result = t(3); // Invoke delegate
Console.Write (result); // 9
int Square (int x) => x * x;
调用委托就像调用方法一样(因为委托的目的仅是提供一级间接):
t(3);
语句Transformer t = Square的速记法如下:
Transformer t = new Transformer (Square);
而t(3)是以下的速记法:
t.Invoke (3);
委托类似于回调,是一个通用术语,涵盖了诸如 C 函数指针之类的结构。
使用委托编写插件方法
委托变量在运行时分配一个方法。这对编写插件方法非常有用。在本示例中,我们有一个名为Transform的实用方法,它对整数数组中的每个元素应用变换。Transform方法具有一个委托参数,用于指定插件变换:
int[] values = { 1, 2, 3 };
Transform (values, Square); // Hook in the Square method
foreach (int i in values)
Console.Write (i + " "); // 1 4 9
void Transform (int[] values, Transformer t)
{
for (int i = 0; i < values.Length; i++)
values[i] = t (values[i]);
}
int Square (int x) => x * x;
delegate int Transformer (int x);
实例方法和静态方法的目标
委托的目标方法可以是本地方法、静态方法或实例方法。
当将实例方法分配给委托对象时,后者必须保持对方法和方法所属实例的引用。System.Delegate类的Target属性表示此实例(对于引用静态方法的委托,此属性将为 null)。
多路委托
所有委托实例都具有多路能力。这意味着委托实例不仅可以引用单个目标方法,还可以引用目标方法列表。+和+=运算符结合委托实例。例如:
SomeDelegate d = SomeMethod1;
d += SomeMethod2;
最后一行在功能上与这一行相同:
d = d + SomeMethod2;
调用d现在将调用SomeMethod1和SomeMethod2。委托按添加顺序调用。
-和-=运算符从左委托操作数中移除右委托操作数。例如:
d -= SomeMethod1;
调用d现在只会调用SomeMethod2。
在委托变量上调用+或+=并具有null值是合法的,就像在具有单个目标的委托变量上调用-=一样(这将导致委托实例为null)。
注意
委托是不可变的,因此当您调用+=或-=时,实际上是创建一个新的委托实例并将其分配给现有变量。
如果多路委托具有非void返回类型,则调用者将接收最后一个调用的方法的返回值。之前的方法仍会被调用,但它们的返回值会被丢弃。在大多数使用多路委托的场景中,它们具有void返回类型,因此不会出现此微妙之处。
所有委托类型都隐式从System.MulticastDelegate派生,后者继承自System.Delegate。C#将委托上的+、-、+=和-=操作编译为System.Delegate类的静态Combine和Remove方法。
泛型委托类型
委托类型可以包含泛型类型参数。例如:
public delegate T Transformer<T> (T arg);
这是我们如何使用此委托类型的方式:
Transformer<double> s = Square;
Console.WriteLine (s (3.3)); // 10.89
double Square (double x) => x * x;
Func 和 Action 委托
使用泛型委托,可以编写一小组非常通用的委托类型,这些委托类型可以适用于任何返回类型和任何(合理的)参数数量的方法。这些委托是在System命名空间中定义的Func和Action委托(in和out注释指示变化,我们很快将在委托上下文中涵盖它):
delegate TResult Func <out TResult> ();
delegate TResult Func <in T, out TResult> (T arg);
delegate TResult Func <in T1, in T2, out TResult>
(T1 arg1, T2 arg2);
*... and so on, up to T16*
delegate void Action ();
delegate void Action <in T> (T arg);
delegate void Action <in T1, in T2> (T1 arg1, T2 arg2);
*... and so on, up to T16*
这些委托非常通用。在我们之前的示例中,Transformer委托可以替换为一个接受类型为T的单一参数并返回相同类型值的Func委托:
public static void Transform<T> (
T[] values, Func<T,T> transformer)
{
for (int i = 0; i < values.Length; i++)
values[i] = transformer (values[i]);
}
这些委托未涵盖的唯一实际场景是ref/out和指针参数。
委托兼容性
委托类型彼此都是不兼容的,即使它们的签名相同:
delegate void D1(); delegate void D2();
...
D1 d1 = Method1;
D2 d2 = d1; // Compile-time error
然而,下面是允许的:
D2 d2 = new D2 (d1);
如果委托实例具有相同的类型和方法目标,则它们被视为相等。对于多播委托,方法目标的顺序是重要的。
返回类型变异
当您调用一个方法时,您可能会得到比您请求的更具体的类型。这是普通的多态行为。保持一致,委托目标方法可能返回比委托描述的更具体的类型。这称为协变:
ObjectRetriever o = new ObjectRetriever (RetrieveString);
object result = o();
Console.WriteLine (result); // hello
string RetrieveString() => "hello";
delegate object ObjectRetriever();
ObjectRetriever期望返回一个object,但是object的子类也可以,因为委托的返回类型是协变。
参数变异
当您调用一个方法时,您可以提供比该方法参数更具体的参数。这是普通的多态行为。保持一致,委托目标方法的参数类型可能比委托描述的要更少具体。这称为逆变:
StringAction sa = new StringAction (ActOnObject);
sa ("hello");
void ActOnObject (object o) => Console.WriteLine (o);
delegate void StringAction (string s);
注意
标准事件模式旨在通过其对共同EventArgs基类的使用帮助您利用委托参数的逆变性。例如,您可以有一个单一方法由两个不同的委托调用,一个传递MouseEventArgs,另一个传递KeyEventArgs。
泛型委托的类型参数变异
我们在“泛型”中看到了如何对泛型接口的类型参数进行协变和逆变。相同的能力也适用于泛型委托。如果您正在定义一个泛型委托类型,最好按照以下做法:
-
将仅用于返回值的类型参数标记为协变的(
out) -
将仅用于参数的类型参数标记为逆变的(
in)
执行此操作允许通过尊重类型之间的继承关系自然进行转换。下面的委托(定义在System命名空间中)对于TResult是协变的:
delegate TResult Func<out TResult>();
这允许:
Func<string> x = ...;
Func<object> y = x;
下面的委托(定义在System命名空间中)对于T:是逆变的:
delegate void Action<in T> (T arg);
这允许:
Action<object> x = ...;
Action<string> y = x;
第二十三章:事件
当您使用委托时,通常会出现两个新兴角色:广播者和订阅者。广播者是包含委托字段的类型。广播者通过调用委托来决定何时进行广播。订阅者是方法目标接收者。订阅者通过在广播者的委托上调用+=和-=来决定何时开始和停止监听。订阅者不知道或干扰其他订阅者。
事件是一种语言特性,正式化了这种模式。event是一种只暴露委托功能子集的构造,用于广播者/订阅者模型。事件的主要目的是防止订阅者互相干扰。
声明事件的最简单方式是在委托成员前面加上 event 关键字:
public class Broadcaster
{
public event ProgressReporter Progress;
}
Broadcaster 类型内部的代码可以完全访问 Progress 并将其视为委托。在 Broadcaster 外部的代码只能对 Progress 事件执行 += 和 -= 操作。
在下面的示例中,Stock 类在每次 Stock 的 Price 改变时触发其 PriceChanged 事件:
public delegate void PriceChangedHandler
(decimal oldPrice, decimal newPrice);
public class Stock
{
string symbol; decimal price;
public Stock (string symbol) => this.symbol = symbol;
public event PriceChangedHandler PriceChanged;
public decimal Price
{
get => price;
set
{
if (price == value) return;
// Fire event if invocation list isn't empty:
if (PriceChanged != null)
PriceChanged (price, value);
price = value;
}
}
}
如果我们从示例中删除 event 关键字,使 PriceChanged 变为普通委托字段,我们的示例将给出相同的结果。但是,Stock 在订阅者之间可能会更不健壮,因为订阅者可以执行以下操作之一来干扰彼此:
-
通过重新分配
PriceChanged(而不是使用+=运算符)替换其他订阅者 -
清除所有订阅者(通过将
PriceChanged设置为null) -
通过调用委托向其他订阅者广播
事件可以是虚拟的、被重写的、抽象的或密封的。它们也可以是静态的。
标准事件模式
几乎所有在 .NET 库中定义事件的情况下,它们的定义都遵循一种标准模式,旨在提供对库和用户代码的一致性。以下是使用此模式重构的前述示例:
public class PriceChangedEventArgs : EventArgs
{
public readonly decimal LastPrice, NewPrice;
public PriceChangedEventArgs (decimal lastPrice,
decimal newPrice)
{
LastPrice = lastPrice; NewPrice = newPrice;
}
}
public class Stock
{
string symbol; decimal price;
public Stock (string symbol) => this.symbol = symbol;
public event EventHandler<PriceChangedEventArgs>
PriceChanged;
protected virtual void OnPriceChanged
(PriceChangedEventArgs e) =>
// Shortcut for invoking PriceChanged if not null:
PriceChanged?.Invoke (this, e);
public decimal Price
{
get { return price; }
set
{
if (price == value) return;
OnPriceChanged (new PriceChangedEventArgs (price,
value));
price = value;
}
}
}
标准事件模式的核心是 System.EventArgs,这是一个预定义的 .NET 类,没有其他成员(除了静态的 Empty 字段)。 EventArgs 是传递事件信息的基类。在这个示例中,我们子类化 EventArgs 来传递价格变化前后的旧值和新值。
泛型 System.EventHandler 委托也是 .NET 的一部分,定义如下:
public delegate void EventHandler<TEventArgs>
(object source, TEventArgs e)
注意
在 C# 2.0 之前(当泛型添加到语言中时),解决方案是为每个 EventArgs 类型编写自定义事件处理委托:
delegate void PriceChangedHandler
(object sender,
PriceChangedEventArgs e);
基于历史原因,.NET 库中的大多数事件使用了这种方式定义的委托。
一个名为 On-*event-name* 的受保护虚拟方法集中了事件的触发。这允许子类触发事件(通常是可取的),还允许子类在事件触发前后插入代码。
这是我们如何使用我们的 Stock 类:
Stock stock = new Stock ("THPW");
stock.Price = 27.10M;
stock.PriceChanged += stock_PriceChanged;
stock.Price = 31.59M;
static void stock_PriceChanged
(object sender, PriceChangedEventArgs e)
{
if ((e.NewPrice - e.LastPrice) / e.LastPrice > 0.1M)
Console.WriteLine ("Alert, 10% price increase!");
}
对于不携带附加信息的事件,.NET 还提供了一个非泛型的 EventHandler 委托。我们可以通过重写我们的 Stock 类来演示这一点,使得 PriceChanged 事件在价格改变后触发。这意味着事件无需传递额外的信息:
public class Stock
{
string symbol; decimal price;
public Stock (string symbol) => this.symbol = symbol;
public event EventHandler PriceChanged;
protected virtual void OnPriceChanged (EventArgs e) =>
PriceChanged?.Invoke (this, e);
public decimal Price
{
get => price;
set
{
if (price == value) return;
price = value;
OnPriceChanged (EventArgs.Empty);
}
}
}
注意,我们还使用了 EventArgs.Empty 属性——这样可以节省实例化 EventArgs 的开销。
事件访问器
事件的 访问器 是其 += 和 -= 函数的实现。默认情况下,访问器由编译器隐式实现。考虑以下事件声明:
public event EventHandler PriceChanged;
编译器将其转换为以下内容:
-
一个私有委托字段
-
一对公共事件访问器函数,其实现将
+=和-=操作转发到私有委托字段
你可以通过定义显式事件访问器来接管这个过程。这里是我们先前示例中PriceChanged事件的手动实现:
EventHandler priceChanged; // Private delegate
public event EventHandler PriceChanged
{
add { priceChanged += value; }
remove { priceChanged -= value; }
}
这个示例在功能上与 C#的默认访问器实现相同(除了 C#还确保在更新委托时线程安全)。通过定义事件访问器,我们指示 C#不生成默认字段和访问器逻辑。
使用显式事件访问器时,可以对底层委托的存储和访问应用更复杂的策略。当事件访问器仅仅是向另一个广播事件的类中继时,或者显式实现声明事件的接口时,这非常有用:
public interface IFoo { event EventHandler Ev; }
class Foo : IFoo
{
EventHandler ev;
event EventHandler IFoo.Ev
{
add { ev += value; } remove { ev -= value; }
}
}
第二十四章:Lambda 表达式
Lambda 表达式是在委托实例的位置上写的无名方法。编译器立即将 lambda 表达式转换为以下之一:
-
一个委托实例。
-
表达式树,类型为
Expression<TDelegate>,表示 lambda 表达式内部的代码为可遍历的对象模型。这允许稍后在运行时解释 lambda 表达式(我们在《C# 12 精要》的第八章中描述了这个过程)。
在以下示例中,x => x * x是一个 lambda 表达式:
Transformer sqr = x => x * x;
Console.WriteLine (sqr(3)); // 9
delegate int Transformer (int i);
注意
在内部,编译器通过编写一个私有方法并将表达式的代码移动到该方法中来解析这种类型的 lambda 表达式。
Lambda 表达式具有以下形式:
(*parameters*) => *expression-or-statement-block*
为了方便起见,如果且仅当有一个可推断类型的参数时,可以省略括号。
在我们的示例中,有一个参数x,表达式是x * x:
x => x * x;
Lambda 表达式的每个参数对应于一个委托参数,并且表达式的类型(可以是void)对应于委托的返回类型。
在我们的示例中,x对应于参数i,表达式x * x对应于返回类型int,因此与Transformer委托兼容。
Lambda 表达式的代码可以是语句块而不是表达式。我们可以将我们的示例重写如下:
x => { return x * x; };
Lambda 表达式最常与Func和Action委托一起使用,因此你会经常看到我们之前的表达式写成如下形式:
Func<int,int> sqr = x => x * x;
编译器通常可以推断lambda 参数的类型。当情况不是这样时,可以显式指定参数类型:
Func<int,int> sqr = (int x) => x * x;
这是一个接受两个参数的表达式示例:
Func<string,string,int> totalLength =
(s1, s2) => s1.Length + s2.Length;
int total = totalLength ("hello", "world"); // total=10;
假设Clicked是EventHandler类型的事件,以下通过 lambda 表达式附加事件处理程序:
obj.Clicked += (sender,args) => Console.Write ("Click");
这是一个接受零个参数的表达式示例:
Func<string> greeter = () => "Hello, world";
从 C# 10 开始,编译器允许使用可以通过Func和Action委托解析的 lambda 表达式进行隐式类型推断,因此我们可以将此语句简化为:
var greeter = () => "Hello, world";
如果 lambda 表达式有参数,则必须指定它们的类型才能使用 var:
var sqr = (int x) => x * x;
编译器推断 sqr 的类型为 Func<int,int>。
默认 Lambda 参数(C# 12)
就像普通方法可以有可选参数一样:
void Print (string info = "") => Console.Write (info);
因此,lambda 表达式也可以:
var print = (string info = "") => Console.Write (info);
print ("Hello");
print ();
此功能对于像 ASP.NET Minimal API 这样的库非常有用。
捕获外部变量
Lambda 表达式可以引用在其定义的地方可访问的任何变量。这些称为外部变量,可以包括局部变量、参数和字段:
int factor = 2;
Func<int, int> multiplier = n => n * factor;
Console.WriteLine (multiplier (3)); // 6
被 lambda 表达式引用的外部变量称为捕获变量。捕获变量在实际调用委托时进行评估,而不是在捕获变量时:
int factor = 2;
Func<int, int> multiplier = n => n * factor;
factor = 10;
Console.WriteLine (multiplier (3)); // 30
Lambda 表达式本身可以更新捕获的变量:
int seed = 0;
Func<int> natural = () => seed++;
Console.WriteLine (natural()); // 0
Console.WriteLine (natural()); // 1
Console.WriteLine (seed); // 2
捕获变量的生命周期延长到委托的生命周期。在以下示例中,局部变量 seed 在 Natural 执行完成时通常会从作用域中消失。但因为 seed 被捕获,其生命周期延长到捕获委托 natural 的生命周期:
Func<int> natural = Natural();
Console.WriteLine (natural()); // 0
Console.WriteLine (natural()); // 1
static Func<int> Natural()
{
int seed = 0;
return () => seed++; // Returns a *closure*
}
变量也可以被匿名方法和本地方法捕获。在这些情况下,捕获变量的规则是相同的。
静态 lambda
从 C# 9 开始,您可以通过应用 static 关键字来确保 lambda 表达式、本地方法或匿名方法不会捕获状态。这在微优化场景中很有用,以防止(可能是无意的)闭包内存分配和清理。例如,我们可以将 static 修饰符应用于 lambda 表达式如下:
Func<int, int> multiplier = static n => n * 2;
如果后来试图修改 lambda 表达式,以便捕获一个局部变量,编译器将会生成一个错误。这个特性在本地方法中更有用(因为 lambda 表达式本身会引起内存分配)。在以下示例中,Multiply 方法无法访问 factor 变量:
void Foo()
{
int factor = 123;
static int Multiply (int x) => x * 2;
}
在此应用 static 也可以作为文档工具,指示减少耦合的程度。静态 lambda 仍然可以访问静态变量和常量(因为这些不需要闭包)。
注意
static 关键字仅作为检查;它不影响编译器生成的 IL。如果没有 static 关键字,编译器不会生成闭包,除非需要(即使如此,它也有技巧来减少成本)。
捕获迭代变量
当在 for 循环中捕获迭代变量时,C# 将迭代变量视为在循环外部声明。这意味着每次迭代中都会捕获同一个变量。以下程序输出 333 而不是 012:
Action[] actions = new Action[3];
for (int i = 0; i < 3; i++)
actions [i] = () => Console.Write (i);
foreach (Action a in actions) a(); // 333
每个闭包(以粗体显示)捕获同一个变量 i。(这实际上在考虑到 i 是一个在循环迭代之间保持其值的变量时是有道理的;你甚至可以在循环体内显式更改 i 的值。)其结果是,当稍后调用委托时,每个委托都会看到调用时 i 的值—这是 3. 如果我们想写 012,解决方法是将迭代变量分配给循环内部范围的本地变量:
Action[] actions = new Action[3];
for (int i = 0; i < 3; i++)
{
int loopScopedi = i;
actions [i] = () => Console.Write (loopScopedi);
}
foreach (Action a in actions) a(); // 012
这会导致闭包在每次迭代时捕获不同的变量。
请注意(从 C# 5 开始),foreach 循环中的迭代变量是隐式局部的,因此可以安全地在其上进行闭包,而无需临时变量。
Lambda 表达式与本地方法对比
本地方法(参见“本地方法”)的功能与 Lambda 表达式重叠。本地方法的优点在于允许递归并避免指定委托的混乱。避免委托的间接引用也使它们稍微更有效,并且它们可以访问包含方法的局部变量,而无需编译器将捕获的变量提升到隐藏类中。
然而,在许多情况下,你需要一个委托,最常见的情况是调用高阶函数(即,带有委托类型参数的方法):
public void Foo (Func<int,bool> predicate) { ... }
在这种情况下,你无论如何都需要一个委托,而恰恰在这些情况下,Lambda 表达式通常更简洁、更清晰。
第二十五章:匿名方法
匿名方法是 C# 2.0 的一个特性,大多数情况下已被 Lambda 表达式取代。匿名方法类似于 Lambda 表达式,但缺少隐式类型化的参数、表达式语法(匿名方法必须始终是一个语句块)以及编译为表达式树的能力。要编写匿名方法,你需要使用 delegate 关键字,后面(可选)是参数声明,然后是方法体。例如:
Transformer sqr = delegate (int x) {return x * x;};
Console.WriteLine (sqr(3)); // 9
delegate int Transformer (int i);
第一行在语义上等同于以下 Lambda 表达式:
Transformer sqr = (int x) => {return x * x;};
或者简单地:
Transformer sqr = x => x * x;
匿名方法的一个独特特性是,你可以完全省略参数声明—即使委托需要它。这在声明带有默认空处理程序的事件时非常有用:
public event EventHandler Clicked = delegate { };
这样可以避免在触发事件前进行空检查。以下也是合法的(注意没有参数):
Clicked += delegate { Console.Write ("clicked"); };
匿名方法像 Lambda 表达式一样捕获外部变量。
第二十六章:try 语句和异常
try 语句指定一个可能包含错误处理或清理代码的代码块。try 块 后必须跟随一个或多个 catch 块 和/或一个 finally 块。当 try 块中抛出错误时,执行 catch 块。finally 块在执行离开 try 块(或如有的话,catch 块)后执行清理代码,无论是否抛出异常。
catch 块可以访问包含有关错误信息的 Exception 对象。你可以使用 catch 块来补偿错误或重新抛出异常。如果仅想记录问题,或者想要重新抛出新的更高级别的异常类型,则重新抛出异常。
finally 块通过始终执行来为程序添加确定性,无论如何。它对于如关闭网络连接等清理任务非常有用。
try 语句如下所示:
try
{
... // exception may get thrown within execution of
// this block
}
catch (ExceptionA ex)
{
... // handle exception of type ExceptionA
}
catch (ExceptionB ex)
{
... // handle exception of type ExceptionB
}
finally
{
... // cleanup code
}
考虑以下代码:
int x = 3, y = 0;
Console.WriteLine (x / y);
因为 y 为零,运行时抛出 DivideByZeroException 并终止我们的程序。我们可以通过以下方式捕获异常来防止这种情况:
try
{
int x = 3, y = 0;
Console.WriteLine (x / y);
}
catch (DivideByZeroException)
{
Console.Write ("y cannot be zero. ");
}
// Execution resumes here after exception...
注意
这是一个简单的示例,用于说明异常处理。在实践中,我们可以通过在调用 Calc 之前显式检查除数是否为零来更好地处理这种情况。
处理异常相对昂贵,需要数百个时钟周期。
当在 try 语句内抛出异常时,CLR 进行测试:
try 语句有兼容的catch块吗?
-
如果有,则执行跳转到兼容的
catch块,然后是finally块(如果存在),然后执行正常继续。 -
如果不是,则执行直接跳转到
finally块(如果存在),然后 CLR 查找调用堆栈中的其他try块,如果找到,则重复测试。
如果调用堆栈中没有任何函数负责异常,则向用户显示错误对话框并终止程序。
catch 子句
catch 子句指定要捕获的异常类型。这必须是 System.Exception 或 System.Exception 的子类。捕获 System.Exception 可以捕获所有可能的错误。这在以下情况下非常有用:
-
你的程序可能会无论特定异常类型如何都能够恢复。
-
你计划重新抛出异常(可能在记录日志后)。
-
在程序终止之前,你的错误处理程序是最后的救援。
不过,更典型的情况是,你捕获特定的异常类型,以避免处理处理程序未设计的情况(例如 OutOfMemoryException)。
你可以通过多个 catch 子句处理多个异常类型:
try
{
DoSomething();
}
catch (IndexOutOfRangeException ex) { ... }
catch (FormatException ex) { ... }
catch (OverflowException ex) { ... }
对于给定异常,只有一个 catch 子句执行。如果要包含一个安全网以捕获更一般的异常(如 System.Exception),则必须先放置更具体的处理程序第一。
不需要指定变量即可捕获异常:
catch (OverflowException) // no variable
{ ... }
此外,你可以省略变量和类型(意味着捕获所有异常):
catch { ... }
异常过滤器
你可以通过添加when子句在catch子句中指定异常过滤器:
catch (WebException ex)
when (ex.Status == WebExceptionStatus.Timeout)
{
...
}
如果在此示例中抛出 WebException,则会评估 when 关键字后面的布尔表达式。如果结果为假,则忽略相应的 catch 块,并考虑任何后续的 catch 子句。使用异常筛选器时,捕获相同的异常类型可能很有意义:
catch (WebException ex) when (ex.Status == *something*)
{ ... }
catch (WebException ex) when (ex.Status == *somethingelse*)
{ ... }
when 子句中的布尔表达式可能具有副作用,例如记录异常以进行诊断目的的方法。
finally 块
finally 块始终执行 —— 无论是否抛出异常以及 try 块是否完整运行。finally 块通常用于清理代码。
finally 块执行:
-
在
catch块完成后 -
由于
jump语句(例如return或goto)导致控制离开try块后。 -
在
try块结束后
finally 块有助于为程序增加确定性。在以下示例中,无论是否:
-
try块正常结束。 -
由于文件为空(
EndOfStream),执行提前返回。 -
在读取文件时抛出
IOException。
static void ReadFile()
{
StreamReader reader = null; // In System.IO namespace
try
{
reader = File.OpenText ("file.txt");
if (reader.EndOfStream) return;
Console.WriteLine (reader.ReadToEnd());
}
finally
{
if (reader != null) reader.Dispose();
}
}
在此示例中,我们通过在 StreamReader 上调用 Dispose 来关闭文件。在 .NET 中,调用对象的 Dispose 方法是一个标准惯例,并且在 C# 中通过 using 语句得到了显式支持。
using 声明
许多类封装了不受管理的资源,例如文件句柄、图形句柄或数据库连接。这些类实现了 System.IDisposable,它定义了一个名为 Dispose 的无参数方法,用于清理这些资源。using 语句提供了一个优雅的语法,用于在 finally 块内调用 IDisposable 对象的 Dispose 方法。
以下:
using (StreamReader reader = File.OpenText ("file.txt"))
{
...
}
等价于:
{
StreamReader reader = File.OpenText ("file.txt");
try
{
...
}
finally
{
if (reader != null) ((IDisposable)reader).Dispose();
}
}
using 声明
如果省略 using 语句后面的括号和语句块,它就成为using 声明(C# 8+)。当执行流离开封闭的语句块时,资源将被处理:
if (File.Exists ("file.txt"))
{
using var reader = File.OpenText ("file.txt");
Console.WriteLine (reader.ReadLine());
...
}
在这种情况下,当执行流离开 if 语句块时,reader 将被处理。
抛出异常
异常可以由运行时或用户代码抛出。在此示例中,Display 抛出 System.ArgumentNullException:
static void Display (string name)
{
if (name == null)
throw new ArgumentNullException (nameof (name));
Console.WriteLine (name);
}
抛出表达式
从 C# 7 开始,throw 可以作为表达式出现在表达体函数中:
public string Foo() => throw new NotImplementedException();
throw 表达式也可以出现在三元条件表达式中:
string ProperCase (string value) =>
value == null ? throw new ArgumentException ("value") :
value == "" ? "" :
char.ToUpper (value[0]) + value.Substring (1);
重新抛出异常
可以按如下方式捕获并重新抛出异常:
try { ... }
catch (Exception ex)
{
// Log error
...
throw; // Rethrow same exception
}
以这种方式重新抛出异常,可以在不吞噬异常的情况下记录错误。它还允许你在情况超出预期时退出异常处理。
注意
如果我们将 throw 替换为 throw ex,示例仍将正常工作,但异常的 StackTrace 属性将不再反映原始错误。
另一种常见的情况是重新抛出更具体或有意义的异常类型:
try
{
... // parse a date of birth from XML element data
}
catch (FormatException ex)
{
throw new XmlException ("Invalid date of birth", ex);
}
在重新抛出不同的异常时,可以将 InnerException 属性填充为原始异常以帮助调试。几乎所有类型的异常都提供了此目的的构造函数(例如我们的示例中)。
System.Exception 的关键属性
System.Exception 的最重要属性如下:
StackTrace
一个字符串,表示从异常的起源到 catch 块调用的所有方法。
Message
一个带有错误描述的字符串。
InnerException
内部异常(如果有)引发了外部异常。这本身可能有另一个 InnerException。
第二十七章:枚举和迭代器
枚举
枚举器 是一种只读的、单向的值序列游标。如果 C# 做了以下任何一件事情,则将类型视为枚举器:
-
具有名为
MoveNext的公共无参数方法和名为Current的属性 -
实现了
System.Collections.Generic.IEnumerator<T> -
实现了
System.Collections.IEnumerator
foreach 语句迭代 可枚举 对象。可枚举对象是序列的逻辑表示。它本身不是游标,而是生成自身上的游标的对象。如果 C# 做了以下任何一件事情,则将类型视为可枚举(按照此顺序进行检查):
-
具有公共的无参数方法
GetEnumerator,返回一个枚举器 -
实现了
System.Collections.Generic.IEnumerable<T> -
实现了
System.Collections.IEnumerable -
(自 C# 9 起)可以绑定到名为
GetEnumerator的 扩展方法,该方法返回一个枚举器(参见 “扩展方法”)
枚举模式如下:
class *Enumerator* // Typically implements IEnumerator<T>
{
public *IteratorVariableType* Current { get {...} }
public bool MoveNext() {...}
}
class *Enumerable* // Typically implements IEnumerable<T>
{
public *Enumerator* GetEnumerator() {...}
}
这是使用 foreach 语句迭代单词 beer 中字符的高级方法:
foreach (char c in "beer") Console.WriteLine (c);
这是在不使用 foreach 语句的情况下低级迭代单词 beer 中字符的方法:
using (var enumerator = "beer".GetEnumerator())
while (enumerator.MoveNext())
{
var element = enumerator.Current;
Console.WriteLine (element);
}
如果枚举器实现了 IDisposable,则 foreach 语句还充当 using 语句,隐式处理枚举器对象。
集合初始化器和集合表达式
您可以一步实例化和填充可枚举对象。例如:
using System.Collections.Generic;
List<int> list = new List<int> {1, 2, 3};
自 C# 12 起,您可以使用 集合表达式 进一步缩短最后一行(请注意方括号):
List<int> list = [1, 2, 3];
注意
集合表达式是 目标类型化 的,这意味着 [1,2,3] 的类型取决于它被分配的类型(在本例中为 List<int>)。在下面的示例中,目标类型是数组:
int[] array = [1, 2, 3];
目标类型化意味着您可以在编译器可以推断类型的其他情况下省略类型,例如在调用方法时:
Foo ([1, 2, 3]);
void Foo (List<int> numbers) { ... }
编译器将其转换为以下内容:
List<int> list = new List<int>();
list.Add (1); list.Add (2); list.Add (3);
这要求可枚举对象实现 System.Collections.IEnumerable 接口,并且具有 Add 方法,该方法对于调用具有适当数量参数的方法非常重要。您可以像下面这样初始化字典(实现了 System.Collections.IDictionary 接口的类型):
var dict = new Dictionary<int, string>()
{
{ 5, "five" },
{ 10, "ten" }
};
或者更简洁地说:
var dict = new Dictionary<int, string>()
{
[5] = "five",
[10] = "ten"
};
后者不仅适用于字典,还适用于任何具有索引器的类型。
迭代器
而 foreach 语句是枚举器的 消费者,迭代器则是枚举器的 生产者。在这个例子中,我们使用迭代器来返回斐波那契数列(其中每个数字是前两个数字的和):
foreach (int fib in Fibs (6))
Console.Write (fib + " ");
IEnumerable<int> Fibs (int fibCount)
{
for (int i=0, prevFib=1, curFib=1; i<fibCount; i++)
{
yield return prevFib;
int newFib = prevFib+curFib;
prevFib = curFib;
curFib = newFib;
}
}
*OUTPUT: 1 1 2 3 5 8*
而 return 语句表达“这是你要求我从这个方法返回的值”,yield return 语句则表达“这是你要求我从这个枚举器中 yield 的下一个元素”。在每个 yield 语句上,控制权返回给调用者,但被调用者的状态保持不变,以便方法可以在调用者枚举下一个元素时继续执行。此状态的生命周期与枚举器绑定,因此当调用者完成枚举时,状态可以被释放。
注意
编译器将迭代器方法转换为实现 IEnumerable<T> 和/或 IEnumerator<T> 的私有类。迭代器块内部的逻辑被“反转”并插入到编译器生成的枚举器类的 MoveNext 方法和 Current 属性中,它们实际上成为了状态机。这意味着当您调用迭代器方法时,实际上只是实例化了编译器生成的类;您的代码只有在您开始枚举生成的序列时才会运行,通常是使用 foreach 语句。
迭代器语义
迭代器是一个方法、属性或索引器,其中包含一个或多个 yield 语句。迭代器必须返回以下四个接口之一(否则编译器将生成错误):
System.Collections.IEnumerable
System.Collections.IEnumerator
System.Collections.Generic.IEnumerable<T>
System.Collections.Generic.IEnumerator<T>
返回 enumerator 接口的迭代器通常使用较少。它们在编写自定义集合类时很有用:通常,您将迭代器命名为 GetEnumerator 并让您的类实现 IEnumerable<T>。
返回 enumerable 接口的迭代器更常见,并且使用起来更简单,因为您不需要编写集合类。编译器在幕后会生成一个实现 IEnumerable<T>(以及 IEnumerator<T>)的私有类。
多个 yield 语句
一个迭代器可以包含多个 yield 语句:
foreach (string s in Foo())
Console.Write (s + " "); // One Two Three
IEnumerable<string> Foo()
{
yield return "One";
yield return "Two";
yield return "Three";
}
yield break
在迭代器块中使用 return 语句是非法的;相反,您必须使用 yield break 语句来指示迭代器块应该提前退出,而不返回更多元素。我们可以修改 Foo 如下所示来演示:
IEnumerable<string> Foo (bool breakEarly)
{
yield return "One";
yield return "Two";
if (breakEarly) yield break;
yield return "Three";
}
组合序列
迭代器非常易于组合。我们可以通过将以下方法添加到类中来扩展我们的斐波那契示例:
Enumerable<int> EvenNumbersOnly (
IEnumerable<int> sequence)
{
foreach (int x in sequence)
if ((x % 2) == 0)
yield return x;
}
}
我们可以如下输出偶数斐波那契数:
foreach (int fib in EvenNumbersOnly (Fibs (6)))
Console.Write (fib + " "); // 2 8
每个元素直到最后一刻才计算——在被请求的 MoveNext() 操作时。图 5 展示了随时间推移的数据请求和数据输出。
图 5. 组合序列
迭代器模式的组合性在构建 LINQ 查询中至关重要。