C#12 口袋参考(五)
原文:
zh.annas-archive.org/md5/97bc15629f1b51a0671040c56db61b92译者:飞龙
第三十七章:运算符重载
你可以重载运算符以为自定义类型提供更自然的语法。运算符重载最适合用于实现代表相当原始数据类型的自定义结构体。例如,自定义数值类型非常适合运算符重载。
你可以重载以下符号运算符:
+ - * / ++ -- ! ~ % & | ^
== != < << >> >
你可以重写隐式和显式转换(使用implicit和explicit关键字),以及true和false运算符。
当你重载非复合运算符(例如+,/)时,复合赋值运算符(例如+=,/=)会自动被重载。
运算符函数
要重载运算符,你需要声明一个运算符函数。运算符函数必须是静态的,并且其中至少一个操作数必须是声明运算符函数的类型。
在下面的示例中,我们定义了一个名为Note的结构体,代表一个音符,并重载了+运算符:
public struct Note
{
int value;
public Note (int semitonesFromA)
=> value = semitonesFromA;
public static Note operator + (Note x, int semitones)
=> new Note (x.value + semitones);
}
此重载允许我们将一个int加到一个Note上:
Note B = new Note (2);
Note CSharp = B + 2;
因为我们重载了+,所以我们也可以使用+=:
CSharp += 2;
从 C# 11 开始,你也可以声明一个checked版本,它将在检查表达式或块内调用。这被称为checked 运算符:
public static Note operator checked + (Note x, int semis)
=> checked (new Note (x.value + semis));
重载相等性和比较运算符
当编写结构体时,经常会重写相等性和比较运算符,在类中则很少见。当重载这些运算符时,会应用特殊的规则和义务:
配对
C# 编译器强制要求逻辑对的运算符都要定义。这些运算符包括(== !=),(< >)以及(<= >=)。
Equals和GetHashCode
如果你重载了==和!=,通常需要重写object的Equals和GetHashCode方法,以便类型可以可靠地与集合和哈希表一起工作。
IComparable和IComparable<T>
如果你重载了<和>,通常还需要实现IComparable和IComparable<T>。
扩展前面的示例,这是如何重载Note的相等运算符的方法:
public static bool operator == (Note n1, Note n2)
=> n1.value == n2.value;
public static bool operator != (Note n1, Note n2)
=> !(n1.value == n2.value);
public override bool Equals (object otherNote)
{
if (!(otherNote is Note)) return false;
return this == (Note)otherNote;
}
// value’s hashcode will work for our own hashcode:
public override int GetHashCode() => value.GetHashCode();
自定义隐式和显式转换
隐式和显式转换是可重载的运算符。通常通过重载这些转换来使得在强关联类型(如数值类型)之间转换更加简洁和自然。
正如类型讨论中所解释的,隐式转换的理念在于它们应始终成功,并且在转换过程中不丢失信息。否则,应定义显式转换。
在下面的示例中,我们定义了我们的音乐Note类型和double之间的转换(表示该音符的频率以赫兹为单位):
...
// Convert to hertz
public static implicit operator double (Note x)
=> 440 * Math.Pow (2,(double) x.value / 12 );
// Convert from hertz (accurate to nearest semitone)
public static explicit operator Note (double x)
=> new Note ((int) (0.5 + 12 * (Math.Log(x/440)
/ Math.Log(2)) ));
...
Note n =(Note)554.37; // explicit conversion
double x = n; // implicit conversion
注意
这个示例有些假设性:在实际应用中,这些转换可能更适合通过ToFrequency方法和(静态)FromFrequency方法来实现。
自定义转换被as和is运算符忽略。
第三十八章:特性
您已经熟悉了使用修饰符(如 virtual 或 ref)为程序的代码元素添加属性的概念。这些构造已内置于语言中。属性是一种可扩展的机制,用于向代码元素(程序集、类型、成员、返回值和参数)添加自定义信息。此可扩展性对于深度集成到类型系统中而无需特殊关键字或构造的服务非常有用。
属性类
属性由从抽象类 System.Attribute 直接或间接继承的类定义。要将属性附加到代码元素,请在代码元素之前的方括号中指定属性的类型名称。例如,以下示例将 ObsoleteAttribute 附加到 Foo 类:
[ObsoleteAttribute]
public class Foo {...}
编译器识别此特定属性,并且如果引用了标记为过时的类型或成员,则会导致编译器警告。按照惯例,所有属性类型的名称以 Attribute 结尾。C# 识别此规则,并允许您在附加属性时省略后缀:
[Obsolete]
public class Foo {...}
ObsoleteAttribute 是在 System 命名空间中声明的类型,如下所示(为简洁起见已简化):
public sealed class ObsoleteAttribute : Attribute {...}
命名和位置属性参数
属性可以具有参数。在以下示例中,我们将 XmlElementAttribute 应用于一个类。此属性指示 XmlSerializer(位于 System.Xml.Serialization 中)如何在 XML 中表示对象,并接受几个属性参数。以下属性将 CustomerEntity 类映射到名为 Customer 的 XML 元素,属于 http://oreilly.com 命名空间:
[XmlElement ("Customer", Namespace="http://oreilly.com")]
public class CustomerEntity { ... }
属性参数分为两类:位置参数或命名参数。在上述示例中,第一个参数是位置参数;第二个是命名参数。位置参数对应于属性类型的公共构造函数的参数。命名参数对应于属性类型的公共字段或公共属性。
当指定属性时,必须包含对应于属性构造函数之一的位置参数。命名参数是可选的。
属性目标
隐式地,属性的目标是它所紧随的代码元素,通常是类型或类型成员。但是,您也可以将属性附加到程序集。这要求您明确指定属性的目标。以下是使用 CLSCompliant 属性指定整个程序集的公共语言规范(CLS)兼容性的示例:
[assembly:CLSCompliant(true)]
从 C# 10 开始,您可以将属性应用于 lambda 表达式的方法、参数和返回值:
Action<int> a =
[Description ("Method")]
[return: Description ("Return value")]
([Description ("Parameter")]int x) => Console.Write (x);
指定多个属性
您可以为单个代码元素指定多个属性。您可以在同一对方括号中列出每个属性(用逗号分隔),也可以分别在不同的方括号对中列出(或两者结合)。以下两个示例在语义上是相同的:
[Serializable, Obsolete, CLSCompliant(false)]
public class Bar {...}
[Serializable] [Obsolete] [CLSCompliant(false)]
public class Bar {...}
编写自定义属性
您可以通过子类化System.Attribute来定义自己的属性。例如,您可以使用以下自定义属性来标记用于单元测试的方法:
[AttributeUsage (AttributeTargets.Method)]
public sealed class TestAttribute : Attribute
{
public int Repetitions;
public string FailureMessage;
public TestAttribute () : this (1) { }
public TestAttribute (int repetitions)
=> Repetitions = repetitions;
}
下面是如何应用该属性的示例:
class Foo
{
[Test]
public void Method1() { ... }
[Test(20)]
public void Method2() { ... }
[Test(20, FailureMessage="Debugging Time!")]
public void Method3() { ... }
}
AttributeUsage 本身就是一个属性,指示可以应用自定义属性的结构(或结构组合)。 AttributeTargets 枚举包括Class、Method、Parameter和Constructor(以及All,它结合了所有目标)等成员。
在运行时检索属性
在运行时检索属性的两种标准方法:
-
在任何
Type或MemberInfo对象上调用GetCustomAttributes -
调用
Attribute.GetCustomAttribute或Attribute.GetCustomAttributes
这两个后续方法都已重载,以接受与有效属性目标(Type、Assembly、Module、MemberInfo或ParameterInfo)对应的任何反射对象。
下面是如何枚举前述Foo类中具有TestAttribute的每个方法:
foreach (MethodInfo mi in typeof (Foo).GetMethods())
{
TestAttribute att = (TestAttribute)
Attribute.GetCustomAttribute
(mi, typeof (TestAttribute));
if (att != null)
Console.WriteLine (
"{0} will be tested; reps={1}; msg={2}",
mi.Name, att.Repetitions, att.FailureMessage);
}
下面是输出:
Method1 will be tested; reps=1; msg=
Method2 will be tested; reps=20; msg=
Method3 will be tested; reps=20; msg=Debugging Time!
第三十九章:调用者信息属性
您可以使用三种调用者信息属性之一来标记可选参数,这些属性指示编译器将来自调用方源代码的信息传递到参数的默认值中:
-
[CallerMemberName]应用于调用方的成员名称。 -
[CallerFilePath]应用于调用方的源代码文件路径。 -
[CallerLineNumber]应用于调用方的源代码文件中的行号。
下面的程序中的Foo方法演示了所有三种方法:
using System;
using System.Runtime.CompilerServices;
class Program
{
static void Main() => Foo();
static void Foo (
[CallerMemberName] string memberName = null,
[CallerFilePath] string filePath = null,
[CallerLineNumber] int lineNumber = 0)
{
Console.WriteLine (memberName);
Console.WriteLine (filePath);
Console.WriteLine (lineNumber);
}
}
假设我们的程序位于c:\source\test\Program.cs,则输出将是:
Main
c:\source\test\Program.cs
6
与标准可选参数一样,替换是在调用点完成的。因此,我们的Main方法对于此是语法糖:
static void Main()
=> Foo ("Main", @"c:\source\test\Program.cs", 6);
调用者信息属性对编写日志函数和实现更改通知模式非常有用。例如,我们可以在属性的set访问器中调用以下方法,而无需指定属性的名称:
void RaisePropertyChanged (
[CallerMemberName] string propertyName = null)
{
...
}
CallerArgumentExpression
将[CallerArgumentExpression]属性应用于方法参数,以捕获调用点的参数表达式:
Print (Math.PI * 2);
void Print (double number,
[CallerArgumentExpression("number")] string expr = null)
=> Console.WriteLine (expr);
// Output: Math.PI * 2
此功能的主要应用程序是在编写验证和断言库时。在以下示例中,抛出异常,其消息包含文本“2 + 2 == 5”。这有助于调试:
Assert (2 + 2 == 5);
void Assert (bool condition,
[CallerArgumentExpression ("condition")]
string msg = null)
{
if (!condition)
throw new Exception ("Assert failed: " + msg);
}
您可以多次使用[CallerArgumentExpression]以捕获多个参数表达式。
第四十章:异步函数
await 和 async 关键字支持 异步编程,这是一种编程风格,长时间运行的函数在返回给调用者后继续完成大部分或全部工作。这与正常的 同步 编程相反,长时间运行的函数会 阻塞 调用者直到操作完成。异步编程意味着 并发,因为长时间运行的操作在调用者的同时 并行 运行。异步函数的实现者通过多线程(用于计算绑定的操作)或通过回调机制(用于 I/O 绑定的操作)启动这种并发。
注意
多线程、并发和异步编程是一个广泛的话题。我们在 C# 12 in a Nutshell 中专门为它们分配了两章,并在线上讨论它们,网址是 http://albahari.com/threading。
例如,考虑以下长时间运行且计算绑定的 同步 方法:
int ComplexCalculation()
{
double x = 2;
for (int i = 1; i < 100000000; i++)
x += Math.Sqrt (x) / i;
return (int)x;
}
这个方法在运行时会阻塞调用者几秒钟,然后将计算结果返回给调用者:
int result = ComplexCalculation();
// Sometime later:
Console.WriteLine (result); // 116
CLR 定义了一个称为 Task<TResult>(在 System.Threading.Tasks 中)的类来封装未来完成的操作概念。通过调用 Task.Run,你可以为计算绑定的操作生成一个 Task<TResult>,该方法指示 CLR 在一个单独的线程上执行指定的委托,并与调用者并行执行:
Task<int> ComplexCalculationAsync()
=> Task.Run ( () => ComplexCalculation() );
这个方法是 异步 的,因为它立即返回给调用者,同时并发执行。然而,我们需要一些机制来允许调用者指定在操作完成并且结果变得可用时应该发生什么。Task<TResult> 通过公开 GetAwaiter 方法来解决这个问题,该方法允许调用者附加一个 延续:
Task<int> task = ComplexCalculationAsync();
var awaiter = task.GetAwaiter();
awaiter.OnCompleted (() => // Continuation
{
int result = awaiter.GetResult();
Console.WriteLine (result); // 116
});
这告诉操作:“当你完成时,执行指定的委托。” 我们的延续首先调用 GetResult,它返回计算的结果。(或者,如果任务 失败 —— 抛出异常 —— 调用 GetResult 会重新抛出异常。)然后我们的延续通过 Console.WriteLine 输出结果。
await 和 async 关键字
await 关键字简化了连接延续的操作。从一个基本场景开始考虑以下情况:
var *result* = await *expression*;
*statement(s)*;
编译器将这个展开为与以下功能类似的内容:
var awaiter = *expression*.GetAwaiter();
awaiter.OnCompleted (() =>
{
var *result* = awaiter.GetResult();
*statement(s)*;
});
注意
编译器还会生成代码来优化操作立即完成(同步完成)的场景。异步操作立即完成的常见原因是它实现了内部缓存机制并且结果已经被缓存。
因此,我们可以像这样调用我们之前定义的 ComplexCalculationAsync 方法:
int result = await ComplexCalculationAsync();
Console.WriteLine (result);
要编译,我们需要在包含方法上添加 async 修饰符:
async void Test()
{
int result = await ComplexCalculationAsync();
Console.WriteLine (result);
}
async 修饰符指示编译器将 await 视为关键字,而不是标识符,以防在方法内出现歧义(这确保了在 C# 5.0 之前可能使用 await 作为标识符的代码仍能正常编译)。async 修饰符仅适用于返回 void 或(稍后您将看到的)Task 或 Task<TResult> 的方法(和 lambda 表达式)。
注意
async 修饰符与 unsafe 修饰符类似,它不会影响方法的签名或公共元数据;它只影响方法内部发生的事情。
带有 async 修饰符的方法被称为异步函数,因为它们本身通常是异步的。要了解原因,请看异步函数如何通过执行过程。
遇到 await 表达式时,执行(通常)会返回给调用者——类似于迭代器中的 yield return。但在返回之前,运行时会将一个继续操作附加到等待的任务上,确保任务完成时,执行会跳回到方法中,并继续之前的位置。如果任务出现故障,则会重新抛出其异常(通过调用 GetResult 方法);否则,其返回值将分配给 await 表达式。
注意
CLR 对任务等待器的 OnCompleted 方法的实现默认确保,如果有当前的 同步上下文,则会通过它发布继续操作。实际上,这意味着在富客户端 UI 场景(如 WPF、WinUI 和 Windows Forms)中,如果在 UI 线程上进行 await,您的代码将继续在同一线程上执行。这简化了线程安全性。
您 await 的表达式通常是一个任务;然而,任何具有返回一个 可等待对象 的 GetAwaiter 方法的对象——实现了 INotifyCompletion.OnCompleted 和具有适当类型的 GetResult 方法(以及测试同步完成的 bool IsCompleted 属性)——都将满足编译器的要求。
注意我们的 await 表达式评估为 int 类型;这是因为我们等待的表达式是一个 Task<int>(其 GetAwaiter().GetResult() 方法返回一个 int)。
等待非泛型任务是合法的,并生成一个 void 表达式:
await Task.Delay (5000);
Console.WriteLine ("Five seconds passed!");
Task.Delay 是一个返回在指定毫秒数后完成的 Task 的静态方法。Task.Delay 的同步等效方法是 Thread.Sleep。
Task 是 Task<TResult> 的非泛型基类,并且在功能上等效于 Task<TResult>,除了它没有结果。
捕获局部状态
await 表达式真正的威力在于它们几乎可以出现在代码的任何地方。具体而言,在异步函数中,await 表达式可以出现在除了 lock 语句或 unsafe 上下文之外的任何表达式的位置。
在下面的示例中,我们在循环中使用了 await:
async void Test()
{
for (int i = 0; i < 10; i++)
{
int result = await ComplexCalculationAsync();
Console.WriteLine (result);
}
}
第一次执行ComplexCalculationAsync时,由于await表达式,执行返回给调用者。当方法完成(或故障)时,执行会在离开时保持本地变量和循环计数器的值不变。编译器通过将这样的代码转换为状态机来实现这一点,就像它在迭代器中所做的那样。
如果没有await关键字,手动使用延续意味着你必须编写等效于状态机的代码。这通常是异步编程困难的根源。
编写异步函数
对于任何异步函数,可以将void返回类型替换为Task,使方法本身变得有用地异步(并且可await)。不需要进一步的更改:
async Task PrintAnswerToLife()
{
await Task.Delay (5000);
int answer = 21 * 2;
Console.WriteLine (answer);
}
注意,我们在方法体中没有显式返回任务。编译器会创建任务,并在方法完成时(或未处理异常时)发出信号。这使得创建异步调用链变得容易:
async Task Go()
{
await PrintAnswerToLife();
Console.WriteLine ("Done");
}
(因为Go返回一个Task,所以Go本身是可以await的。)编译器将返回任务的异步函数展开为使用TaskCompletionSource间接创建任务的代码。
注意
TaskCompletionSource是一个 CLR 类型,允许你创建可以手动控制的任务,并使用结果(或异常)标记其完成。与Task.Run不同,TaskCompletionSource不会在操作期间阻塞线程。它还用于编写 I/O 绑定的任务返回方法(例如Task.Delay)。
目标是确保当返回任务的异步方法完成时,执行可以通过延续跳回到等待它的地方。
返回Task<TResult>
如果方法体返回TResult,可以返回一个Task<TResult>:
async Task<int> GetAnswerToLife()
{
await Task.Delay (5000);
int answer = 21 * 2;
// answer is int so our method returns Task<int>
return answer;
}
我们可以通过从Go中调用PrintAnswerToLife来演示GetAnswerToLife:
async Task Go()
{
await PrintAnswerToLife();
Console.WriteLine ("Done");
}
async Task PrintAnswerToLife()
{
int answer = await GetAnswerToLife();
Console.WriteLine (answer);
}
async Task<int> GetAnswerToLife()
{
await Task.Delay (5000);
int answer = 21 * 2;
return answer;
}
异步函数使异步编程类似于同步编程。以下是我们调用图的同步等效版本,调用Go()会在阻塞五秒后给出相同的结果:
void Go()
{
PrintAnswerToLife();
Console.WriteLine ("Done");
}
void PrintAnswerToLife()
{
int answer = GetAnswerToLife();
Console.WriteLine (answer);
}
int GetAnswerToLife()
{
Thread.Sleep (5000);
int answer = 21 * 2;
return answer;
}
这也展示了如何在 C#中设计异步函数的基本原则,即先以同步方式编写方法,然后将同步方法调用替换为await的异步方法调用。
并行性
我们刚刚演示了最常见的模式,即在调用后立即await返回任务的函数。这导致了逻辑上类似于同步等效的顺序程序流。
调用异步方法而不等待它允许随后的代码并行执行。例如,以下代码并发执行PrintAnswerToLife两次:
var task1 = PrintAnswerToLife();
var task2 = PrintAnswerToLife();
await task1; await task2;
在之后等待这两个操作,我们在那一点上“结束”了并行性(并重新抛出这些任务的任何异常)。 Task 类提供了一个名为 WhenAll 的静态方法,以稍微更有效地实现相同的结果。 WhenAll 返回一个任务,在你传递给它的所有任务完成时完成:
await Task.WhenAll (PrintAnswerToLife(),
PrintAnswerToLife());
WhenAll 被称为一个任务组合器。(Task 类还提供了一个任务组合器称为 WhenAny,它在提供给它的任何任务完成时完成。)我们在C# 12 in a Nutshell中详细介绍任务组合器。
异步 Lambda 表达式
我们知道普通的命名方法可以是异步的:
async Task NamedMethod()
{
await Task.Delay (1000);
Console.WriteLine ("Foo");
}
同样地,未命名方法(lambda 表达式和匿名方法),如果前面加上 async 关键字,也可以是异步的:
Func<Task> unnamed = async () =>
{
await Task.Delay (1000);
Console.WriteLine ("Foo");
};
你可以以相同的方式调用和等待它们:
await NamedMethod();
await unnamed();
当附加事件处理程序时,你可以使用异步 lambda 表达式:
myButton.Click += async (sender, args) =>
{
await Task.Delay (1000);
myButton.Content = "Done";
};
这比以下的更简洁,但效果相同:
myButton.Click += ButtonHandler;
...
async void ButtonHandler (object sender, EventArgs args)
{
await Task.Delay (1000);
myButton.Content = "Done";
};
异步 lambda 表达式也可以返回 Task<TResult>:
Func<Task<int>> unnamed = async () =>
{
await Task.Delay (1000);
return 123;
};
int answer = await unnamed();
异步流
使用 yield return,你可以编写一个迭代器;使用 await,你可以编写一个异步函数。异步流(来自 C# 8)结合了这些概念,让你能够编写一个等待的迭代器,异步地生成元素。此支持建立在以下一对接口的基础上,它们是我们在“枚举和迭代器”中描述的枚举接口的异步对应物:
public interface IAsyncEnumerable<out T>
{
IAsyncEnumerator<T> GetAsyncEnumerator (...);
}
public interface IAsyncEnumerator<out T>: IAsyncDisposable
{
T Current { get; }
ValueTask<bool> MoveNextAsync();
}
ValueTask<T> 是包装 Task<T> 的结构体,行为上等效于 Task<T>,除了当任务同步完成时能够实现更高效的执行(这在枚举序列时经常发生)。 IAsyncDisposable 是 IDisposable 的异步版本,提供了在手动实现接口时执行清理操作的机会:
public interface IAsyncDisposable
{
ValueTask DisposeAsync();
}
注意
从序列获取每个元素的操作 (MoveNextAsync) 是一个异步操作,因此当元素逐个到达时,异步流非常合适(例如处理来自视频流的数据)。相比之下,以下类型在序列整体延迟时更为合适,但元素到达时却是一次性到达:
Task<IEnumerable<T>>
要生成一个异步流,你需要编写一个结合了迭代器和异步方法原理的方法。换句话说,你的方法应该包括 yield return 和 await,并且应该返回 IAsyncEnumerable<T>:
async IAsyncEnumerable<int> RangeAsync (
int start, int count, int delay)
{
for (int i = start; i < start + count; i++)
{
await Task.Delay (delay);
yield return i;
}
}
要消费一个异步流,请使用 await foreach 语句:
await foreach (var number in RangeAsync (0, 10, 100))
Console.WriteLine (number);
第四十一章:静态多态
在“静态虚拟/抽象接口成员”中,我们介绍了一个高级功能,即接口可以定义static virtual或static abstract成员,然后由类和结构体作为静态成员来实现。稍后,在“泛型约束”中,我们展示了将接口约束应用于类型参数使方法可以访问该接口成员的方式。在本节中,我们将演示如何通过这种方式实现静态多态,从而实现泛型数学功能。
为了说明这一点,考虑以下接口,它定义了一个静态方法来创建某种类型T的随机实例:
interface ICreateRandom<T>
{
static abstract T CreateRandom();
}
现在假设我们希望在以下记录中实现此接口:
record Point (int X, int Y);
在System.Random类的帮助下(其Next方法生成随机整数),我们可以实现静态的CreateRandom方法如下:
record Point (int X, int Y) : ICreateRandom<Point>
{
static Random rnd = new();
public static Point CreateRandom() =>
new Point (rnd.Next(), rnd.Next());
}
要通过接口调用此方法,我们使用受限类型参数。以下方法使用这种方法创建测试数据的数组:
T[] CreateTestData<T> (int count)
where T : ICreateRandom<T>
{
T[] result = new T[count];
for (int i = 0; i < count; i++)
result [i] = T.CreateRandom();
return result;
}
此代码行展示了其使用方式:
Point[] testData = CreateTestData<Point>(50);
在CreateTestData中对静态CreateRandom方法的调用是多态的,因为它不仅适用于Point,还适用于任何实现ICreateRandom<T>的类型。这与实例多态不同,因为我们不需要在其上调用CreateRandom的ICreateRandom<T>的实例;我们在类型本身上调用CreateRandom。
多态操作符
因为操作符本质上是静态函数(参见“运算符重载”),所以操作符也可以声明为静态虚拟接口成员:
interface IAddable<T> where T : IAddable<T>
{
abstract static T operator + (T left, T right);
}
注意
在这个接口定义中的自引用类型约束是为了满足编译器对运算符重载的规则。回顾一下,当定义运算符函数时,操作数至少有一个必须是声明该运算符函数的类型。在这个例子中,我们的操作数是类型为T,而包含类型是IAddable<T>,因此我们需要一个自引用类型约束来允许将T视为IAddable<T>。
这是我们如何实现接口的方法:
record Point (int X, int Y) : IAddable<Point>
{
public static Point operator +(Point left, Point right)
=> new Point (left.X + right.X, left.Y + right.Y);
}
通过受限类型参数,我们可以编写一个方法,以多态方式调用我们的加法运算符(为了简洁起见,省略了边缘情况处理):
T Sum<T> (params T[] values) where T : IAddable<T>
{
T total = values[0];
for (int i = 1; i < values.Length; i++)
total += values[i];
return total;
}
我们对+运算符的调用(通过+=运算符)是多态的,因为它绑定到IAddable<T>,而不是Point。因此,我们的Sum方法适用于所有实现IAddable<T>的类型。
当然,像IAddable<T>这样的接口如果在.NET 运行时中定义,并且所有.NET 数值类型都实现了它,那将会更加有用。幸运的是,从.NET 7 开始,System.Numerics命名空间包含了(更复杂版本的)IAddable,以及许多其他算术接口——大部分都包含在INumber<TSelf>中。
泛型数学
在 .NET 7 之前,执行算术运算的代码必须硬编码为特定的数字类型,例如int或double。从 .NET 7 开始,新增了INumber<TSelf>接口,统一了各种数字类型的算术操作,允许编写如下泛型方法:
T Sum<T> (params T[] numbers) where T : INumber<T>
{
T total = T.Zero;
foreach (T n in numbers)
total += n; // Invokes addition for any numeric type
return total;
}
int intSum = Sum (3, 5, 7);
double doubleSum = Sum (3.2, 5.3, 7.1);
decimal decimalSum = Sum (3.2m, 5.3m, 7.1m);
INumber<TSelf> 被 .NET 中所有的实数和整数数字类型(以及char)实现。它可以被视为一个总称接口,包括了其他更细粒度的接口,用于每种算术操作(加法、减法、乘法、除法、取模运算、比较等),以及用于解析和格式化的接口。这些接口定义了静态抽象运算符和方法——这些允许在我们的Sum方法中使用+=运算符(和对T.Zero的调用)。
第四十二章:不安全代码和指针
C# 支持在标记为unsafe的代码块内通过指针进行直接内存操作。指针类型对于与本机 API 进行交互、访问托管堆之外的内存以及在性能关键的热点中实现微优化非常有用。
包含不安全代码的项目必须在项目文件中指定<AllowUnsafeBlocks>true</AllowUnsafeBlocks>。
指针基础知识
对于每种值类型或引用类型V,都有相应的指针类型*V**。指针实例保存变量的地址。指针类型可以(不安全地)转换为任何其他指针类型。以下是主要的指针运算符:
| 运算符 | 含义 |
|---|---|
& | 取地址运算符返回指向变量地址的指针。 |
* | 解引用运算符返回指针地址处的变量。 |
-> | 成员指针运算符是一种语法快捷方式,其中x->y等同于(*x).y。 |
不安全代码
通过使用unsafe关键字标记类型、类型成员或语句块,您被允许在该作用域内使用指针类型,并执行 C++ 风格的指针操作。以下是使用指针快速处理位图的示例:
unsafe void BlueFilter (int[,] bitmap)
{
int length = bitmap.Length;
fixed (int* b = bitmap)
{
int* p = b;
for (int i = 0; i < length; i++)
*p++ &= 0xFF;
}
}
不安全代码可能比相应的安全实现运行更快。在这种情况下,代码需要具有带有数组索引和边界检查的嵌套循环。不安全的 C# 方法也可能比调用外部 C 函数更快,因为没有离开托管执行环境的开销。
fixed 语句
fixed语句用于固定管理对象,例如前面示例中的位图。在程序执行期间,许多对象从堆中分配和释放。为了避免内存的不必要浪费或碎片化,垃圾回收器会移动对象。如果在引用时对象的地址可能会更改,则指向对象是徒劳的,因此fixed语句指示垃圾回收器“固定”对象并防止其移动。这可能会影响运行时的效率,因此您应该仅在短时间内使用固定块,并避免在固定块内分配堆内存。
在fixed语句中,您可以获取值类型、值类型数组或字符串的指针。对于数组和字符串,指针实际上将指向第一个元素,该元素是值类型。
在引用类型内部声明的值类型需要引用类型被固定,如下所示:
Test test = new Test();
unsafe
{
fixed (int* p = &test.X) // Pins test
{
*p = 9;
}
Console.WriteLine (test.X);
}
class Test { public int X; }
成员指针运算符
除了&和*运算符外,C#还提供了 C++风格的->运算符,您可以在结构体上使用它:
Test test = new Test();
unsafe
{
Test* p = &test;
p->X = 9;
System.Console.WriteLine (test.X);
}
struct Test { public int X; }
stackalloc关键字
您可以使用stackalloc关键字显式在堆栈上分配内存块。因为它是在堆栈上分配的,所以它的生存期仅限于方法的执行,就像任何其他局部变量一样。该块可以使用[]运算符索引到内存中:
int* a = stackalloc int [10];
for (int i = 0; i < 10; ++i)
Console.WriteLine (a[i]); // Print raw memory
固定大小的缓冲区
要在结构体中分配一块内存块,请使用fixed关键字:
unsafe struct UnsafeUnicodeString
{
public short Length;
public fixed byte Buffer[30];
}
unsafe class UnsafeClass
{
UnsafeUnicodeString uus;
public UnsafeClass (string s)
{
uus.Length = (short)s.Length;
fixed (byte* p = uus.Buffer)
for (int i = 0; i < s.Length; i++)
p[i] = (byte) s[i];
}
}
固定大小的缓冲区不是数组:如果Buffer是数组,它将由存储在结构体内部的对象引用组成,而不是结构体本身的 30 个字节。
此示例中还使用了fixed关键字来固定包含缓冲区的堆上对象(这将是UnsafeClass的实例)。
void*
void 指针(void*)对底层数据类型不做任何假设,并且对处理原始内存的函数非常有用。任何指针类型都可以隐式转换为void*。void*无法被解引用,并且不能对 void 指针执行算术运算。例如:
short[] a = {1,1,2,3,5,8,13,21,34,55};
fixed (short* p = a)
{
//sizeof returns size of value-type in bytes
Zap (p, a.Length * sizeof (short));
}
foreach (short x in a)
Console.WriteLine (x); // Prints all zeros
unsafe void Zap (void* memory, int byteCount)
{
byte* b = (byte*) memory;
for (int i = 0; i < byteCount; i++)
*b++ = 0;
}
函数指针
函数指针(来自 C# 9)类似于委托,但没有委托实例的间接性;而是直接指向方法。函数指针仅指向静态方法,缺乏多播功能,并且需要unsafe上下文(因为它绕过运行时类型安全性)。其主要目的是简化和优化与不受管 API 的互操作(我们在C# 12 in a Nutshell中介绍了互操作)。
函数指针类型声明如下(返回类型最后出现):
delegate*<int, char, string, void>
这与具有此签名的函数匹配:
void SomeFunction (int x, char y, string z)
&运算符从方法组创建函数指针。这里有一个完整的示例:
unsafe
{
delegate*<string, int> functionPointer = &GetLength;
int length = functionPointer ("Hello, world");
static int GetLength (string s) => s.Length;
}
在此示例中,functionPointer 不是您可以调用 Invoke 方法的 对象。而是一个直接指向目标方法内存地址的变量:
Console.WriteLine ((IntPtr)functionPointer);
第四十三章:预处理器指令
预处理器指令为编译器提供关于代码区域的附加信息。最常见的预处理器指令是条件指令,提供了一种在编译时包含或排除代码区域的方式。例如:
#define DEBUG
class MyClass
{
int x;
void Foo()
{
#if DEBUG
Console.WriteLine ("Testing: x = {0}", x);
#endif
}
...
}
在此类中,Foo 中的语句在编译时条件依赖于 DEBUG 符号的存在。如果我们移除 DEBUG 符号,则不会编译该语句。预处理符号可以在源文件内(正如我们所做的)或在项目文件中的 <DefineConstants> 元素内定义。
使用 #if 和 #elif 指令,您可以在多个符号上执行 或、与 和 非 操作使用 ||、&& 和 ! 操作符。以下指令指示编译器在定义 TESTMODE 符号且未定义 DEBUG 符号时包括随后的代码:
#if TESTMODE && !DEBUG
...
请记住,您并非在构建普通的 C# 表达式,并且您操作的符号与 变量 —— 无论是静态还是其他类型的 —— 没有任何连接。
#error 和 #warning 符号通过使编译器生成警告或错误来防止条件指令的意外误用,给定一个不良的编译符号集。
表 14 描述了预处理器指令的完整列表。
表 14. 预处理器指令
| 预处理器指令 | 动作 | ||
|---|---|---|---|
#define `*symbol*` | 定义 *symbol*。 | ||
#undef `*symbol*` | 取消定义 *symbol*。 | ||
#if *symbol* [*operator symbol2*]... | 条件编译 (*operator* 包括 ==、!=、&& 和 ` | `)。 | |
#else | 执行到后续 #endif 的代码。 | ||
#elif *symbol* [*operator symbol2*] | 结合 #else 分支和 #if 测试。 | ||
#endif | 结束条件指令。 | ||
#warning `*text*` | *text* 的警告将出现在编译器输出中。 | ||
#error `*text*` | *text* 的错误将出现在编译器输出中。 | ||
#line [`*number*` ["`*file*`"] | hidden] | *number* 指定源代码中的行;*file* 是要出现在计算机输出中的文件名;hidden 指示调试器跳过此点到下一个 #line 指令。 | ||
#region `*name*` | 标记大纲的开始。 | ||
#endregion | 结束大纲区域。 | ||
#pragma warning | 参见下一节。 | ||
#nullable `*option*` | 查看“可空引用类型”。 |
Pragma Warning
当编译器发现您的代码中出现看似不经意的东西时,它会生成警告。与错误不同,警告通常不会阻止应用程序的编译。
编译器警告在发现错误时非常有价值。然而,当出现虚假警告时,它们的有效性会受到损害。在大型应用程序中,保持良好的信噪比对于注意到“真正”的警告至关重要。
因此,编译器允许您使用#pragma warning指令有选择地禁止警告。在此示例中,我们指示编译器不要警告我们未使用的字段Message:
public class Foo
{
#pragma warning disable 414
static string Message = "Hello";
#pragma warning restore 414
}
在#pragma warning指令中省略编号会禁用或恢复所有警告代码。
如果您在应用此指令时彻底,可以使用/warnaserror开关进行编译——这指示编译器将任何剩余的警告视为错误。
第四十四章:XML 文档
文档注释是嵌入的 XML 片段,用于文档化类型或成员。文档注释紧接在类型或成员声明之前,并以三斜杠开始:
/// <summary>Cancels a running query.</summary>
public void Cancel() { ... }
可以这样编写多行注释:
/// <summary>
/// Cancels a running query
/// </summary>
public void Cancel() { ... }
或者像这样(注意开头的额外星号):
/**
<summary> Cancels a running query. </summary>
*/
public void Cancel() { ... }
如果使用/doc指令进行编译(或在项目文件中启用 XML 文档),编译器会提取并整理文档注释到一个单独的 XML 文件中。这有两个主要用途:
-
如果放置在与编译后程序集相同的文件夹中,Visual Studio 会自动读取 XML 文件,并使用信息为同名程序集的消费者提供 IntelliSense 成员列表。
-
第三方工具(如 Sandcastle 和 NDoc)可以将 XML 文件转换为 HTML 帮助文件。
标准 XML 文档标签
这里是 Visual Studio 和文档生成器识别的标准 XML 标签:
<summary>
<summary>*...*</summary>
指示 IntelliSense 应显示的工具提示,用于类型或成员。通常是一个短语或句子。
<remarks>
<remarks>*...*</remarks>
额外描述类型或成员的文本。文档生成器将其捡起并合并到类型或成员描述的主体中。
<param>
<param name="*name*">*...*</param>
解释方法的参数。
<returns>
<returns>*...*</returns>
解释方法的返回值。
<exception>
<exception [cref="*type*"]>*...*</exception>
列出方法可能抛出的异常(cref指向异常类型)。
<permission>
<permission [cref="*type*"]>*...*</permission>
指示文档化类型或成员所需的IPermission类型。
<example>
<example>*...*</example>
表示一个示例(由文档生成器使用)。通常包含描述文本和源代码(源代码通常在 <c> 或 <code> 标签内)。
<c>
<c>*...*</c>
指示内联代码片段。此标签通常用于<example>块内。
<code>
<code>*...*</code>
指示多行代码示例。此标签通常用于<example>块内。
<see>
<see cref="*member*">*...*</see>
插入对另一个类型或成员的内联交叉引用。HTML 文档生成器通常会将其转换为超链接。如果类型或成员名称无效,编译器会发出警告。
<seealso>
<seealso cref="*member*">*...*</seealso>
交叉引用另一个类型或成员。文档生成器通常会将此写入到单独的“另请参阅”部分在页面底部。
<paramref>
<paramref name="*name*"/>
引用 <summary> 或 <remarks> 标记中的参数。
<list>
<list type=[ bullet | number | table ]>
<listheader>
<term>*...*</term>
<description>*...*</description>
</listheader>
<item>
<term>*...*</term>
<description>*...*</description>
</item>
</list>
指导文档生成器生成项目符号、编号或表格样式的列表。
<para>
<para>*...*</para>
指导文档生成器将内容格式化为单独的段落。
<include>
<include file='*filename*' path='*tagpath*[@name="*id*"]'>
...
</include>
合并包含文档的外部 XML 文件。路径属性表示该文件中特定元素的 XPath 查询。