现代化您的 C# 代码 - 第 IV 部分:类型






4.97/5 (15投票s)
想现代化您的C#代码库?让我们从类型开始。
目录
引言
近年来,C#已经从一个仅有一个特性来解决问题的语言,发展成为一个针对单一问题提供多种潜在(语言)解决方案的语言。这既是好事也是坏事。好事在于,它赋予我们开发者自由和强大的能力(同时不损害向后兼容性);坏事在于,它带来了做出决策时需要承担的认知负荷。
在本系列文章中,我们希望探索现有的选项以及这些选项之间的区别。当然,在特定条件下,某些选项可能具有优势和劣势。我们将探讨这些场景,并提供一个指南,使我们在重构现有项目时生活更加轻松。
这是本系列的第四部分。您可以在CodeProject上找到第一部分、第二部分以及第三部分。
背景
过去,我曾写过许多专门针对C#语言的文章。我写过入门系列、高级指南,以及关于特定主题的文章,例如异步/await或即将推出的功能。在本系列文章中,我希望将所有先前的主题以一种连贯的方式结合起来。
我认为讨论新语言特性在哪里大放异彩,以及旧的——我们称之为成熟的——特性在哪些方面仍然更受欢迎,这一点很重要。我可能并不总是正确的(特别是,因为我的一些观点肯定会更加主观/是品味问题)。一如既往,欢迎您在评论区留下您的看法以供讨论!
让我们从一些历史背景开始。
经典类型系统
历史上,引入类型是为了告知开发者分配多少字节。此外,编译器也能理解简单的加法等运算。早期,在纯汇编语言中,开发者必须决定哪个操作适合给定的值。现在,编译器不仅能够知道两个值分配了4个字节,还能知道它们应该被视为整数。会应用Int32加法。
后来,引入自定义类型的需求被提出。第一个结构体(struct)诞生了。虽然标准操作(从机器角度来看)可能意义不大,但分配和*结构*(因此得名)是关键。我们不仅可以拥有“名称”(通常在编译时被擦除,即仅编译器知道,方便开发者),而且所有部分都已按位置和类型正确指定。
随着面向对象编程及其早期实现的引入,我们看到了类型概念(当时主要与类相关)及其与函数(当时称为方法)关系的更重要性。运行时类型信息检查/访问的相关性增加,导致了反射等功能。虽然经典原生系统通常具有非常有限的运行时能力(例如 C++),但托管系统出现了,具有巨大的可能性(例如 JVM 或 .NET)。
现在,这种方法今天的缺点之一是,许多类型不再源自底层系统——它们来自某些数据的反序列化(例如,Web API 的传入请求)。虽然基本的验证和反序列化可能来自系统中定义的类型,但通常它只是源自该类型的派生(例如,省略某些属性,添加新属性,更改某些属性的类型,……)。因此,在处理此类数据时会出现重复和限制。因此,需要动态编程语言,它们在此方面提供更大的灵活性——以牺牲开发时的类型安全为代价。
每个问题都有解决方案,在过去的10年里,我们看到类型系统和类型理论在各地重新受到青睐。TypeScript 等流行语言将多年的研究成果和其他(更奇特的)编程语言带到了主流。希望一些更经典和历史悠久的编程语言也能从这些进步中学习。
剖析C#类型系统
这也可以称为.NET的类型系统,但是,虽然肯定有一些来自.NET的通用基础层,但许多构造和可能性仅来自语言本身。从另一个角度看,如果我们看看F#如何使用.NET的类型系统,我们就会知道.NET没有自然限制——系统可以极大地弯曲和扩展。
C#喜欢使用静态类型系统。这里的“静态”是有意义的。让我们假设我们有以下类型
public class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime Birthday { get; set; }
}
如果我们想强制所有属性都是可选的呢?嗯,实际上在某种意义上,它们已经是可选的了,因为没有人强迫我们设置它们。但让我们假设本文已经介绍了可空类型(稍后会介绍),我们想要的如下所示
public class PartialPerson
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
public DateTime? Birthday { get; set; }
}
现在我们有一个问题。一旦第一个类发生变化,我们也需要在第二个类中做出一些改变。如果我们能写成这样呢
public type PartialPerson = Partial<Person>;
TypeScript就是这样工作的。在TypeScript中,Partial<T>只是一个别名,它遍历所有属性并将可选修饰符(?)添加到每个属性上。
好的,C#不喜欢这样。C#更侧重于运行时。因此,我们应该使用反射来做到这一点。
在运行时,这可能看起来像这样
var PartialPersonType = Partial<Person>();
其中Partial方法可以以直接的方式实现。
public static Type Partial<T>()
{
var type = typeof(T);
var builder = GetTypeBuilder<T>();
foreach (var property in type.GetProperties())
{
CreateProperty(builder, property);
}
return builder.CreateType();
}
private static TypeBuilder GetTypeBuilder<T>()
{
var name = $"Partial<{typeof(T).Name}>";
var an = new AssemblyName(name);
var assemblyBuilder = AppDomain.CurrentDomain.DefineDynamicAssembly
(an, AssemblyBuilderAccess.Run);
var moduleBuilder = assemblyBuilder.DefineDynamicModule("MainModule");
return moduleBuilder.DefineType(name,
TypeAttributes.Public |
TypeAttributes.Class |
TypeAttributes.AutoClass |
TypeAttributes.AnsiClass |
TypeAttributes.BeforeFieldInit |
TypeAttributes.AutoLayout,
null);
}
private static void CreateProperty
(TypeBuilder tb, PropertyInfo property, string? ignore = null)
{
var propertyName = property.Name;
var propertyType = property.PropertyType;
var attributes = property.Attributes;
var customAttributes = property.CustomAttributes;
var addNullable = false;
if (propertyType.IsInterface || propertyType.IsClass)
{
// we require the custom attribute
addNullable = true;
}
else if (propertyType.IsValueType &&
(!propertyType.IsGenericType ||
propertyType.GetGenericTypeDefinition() != typeof(Nullable<>)))
{
// for values there is no attribute but the Nullable type
// we only apply it if its not yet wrapped in such a type
propertyType = typeof(Nullable<>).MakeGenericType(propertyType);
}
var fieldBuilder = tb.DefineField
("_" + propertyName, propertyType, FieldAttributes.Private);
var propertyBuilder = tb.DefineProperty(propertyName, attributes, propertyType, null);
foreach (var customAttribute in customAttributes)
{
// Append all custom attributes (as beforehand) except the Nullable one
if (customAttribute.Constructor.ReflectedType.Name != "NullableAttribute")
{
AppendAttribute(customAttribute, propertyBuilder);
}
}
// if the nullable attribute should be added we can abuse some magic ...
if (addNullable)
{
var customAttribute =
MethodBase.GetCurrentMethod().GetParameters().Last().CustomAttributes.Last();
AppendAttribute(customAttribute, propertyBuilder);
}
var getPropMthdBldr = tb.DefineMethod("get_" + propertyName,
MethodAttributes.Public | MethodAttributes.SpecialName |
MethodAttributes.HideBySig, propertyType, Type.EmptyTypes);
var getIl = getPropMthdBldr.GetILGenerator();
getIl.Emit(OpCodes.Ldarg_0);
getIl.Emit(OpCodes.Ldfld, fieldBuilder);
getIl.Emit(OpCodes.Ret);
var setPropMthdBldr = tb.DefineMethod("set_" + propertyName,
MethodAttributes.Public | MethodAttributes.SpecialName |
MethodAttributes.HideBySig,
null, new[] { propertyType });
var setIl = setPropMthdBldr.GetILGenerator();
var modifyProperty = setIl.DefineLabel();
var exitSet = setIl.DefineLabel();
setIl.MarkLabel(modifyProperty);
setIl.Emit(OpCodes.Ldarg_0);
setIl.Emit(OpCodes.Ldarg_1);
setIl.Emit(OpCodes.Stfld, fieldBuilder);
setIl.Emit(OpCodes.Nop);
setIl.MarkLabel(exitSet);
setIl.Emit(OpCodes.Ret);
propertyBuilder.SetGetMethod(getPropMthdBldr);
propertyBuilder.SetSetMethod(setPropMthdBldr);
}
private static void AppendAttribute
(CustomAttributeData customAttribute, PropertyBuilder propertyBuilder)
{
var args = customAttribute.ConstructorArguments.Select(m => m.Value).ToArray();
var cab = new CustomAttributeBuilder(customAttribute.Constructor, args);
propertyBuilder.SetCustomAttribute(cab);
}
虽然确实可以像展示的那样动态创建类型,但事实仍然是这是一个运行时机制。因此,许多潜在的类型转换用例要么难以实现,要么不可能实现。
然而,社区项目如Fody可以用来在编译时修改程序集和/或IL代码以添加这些功能。这些的主要问题是编译器辅助/工具。了解实际情况通常并不容易。
现代方式
类型始终是类型。但等等!还有更多。我们拥有许多能力,这些能力要么直接来自C#,要么来自.NET,要么来自生态系统。在本节中,我们将尝试探索所有这些。
实际上,虽然C#中使用的许多语法直接转化为某种IL代码或代码结构,但C#的一些部分(主要是较新的,但正如我们将看到的,也很老的)倾向于在自然层面上与类型系统紧密协作。它们要么使用现有的接口、类型或其他元素——有时会在我们不知情的情况下创建新类型。例如,我们已经看到了用于委托的类(例如,Func<T>)或本地函数。让我们看看还有什么可用的!
生成迭代器
自从C#的早期版本以来,我们就可以动态生成类型。使用yield,我们可以创建自己的迭代器。这样的迭代器由实现IEnumerable接口的类型表示。事实证明,要做的一切就是创建IEnumerator实例。然后,所有的逻辑(和状态)都包含在IEnumerator实例中。
让我们首先编写我们自己的实现。我们想要一个包含前三个数字(1、2、3)的可枚举集合。
class MyEnumerable : IEnumerable<int>
{
public IEnumerator<int> GetEnumerator() => new MyIterable();
IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator();
class MyIterable : IEnumerator<int>
{
public int Current => current;
object IEnumerator.Current => current;
private int current = 0;
public void Dispose() {}
public bool MoveNext() => ++current < 4;
public void Reset() => current = 0;
}
}
C#语言有许多处理枚举器的不错特性。当然,最常用的是foreach循环结构
var enumerable = new MyEnumerable();
foreach (var item in enumerable)
{
item.Dump(); // 1, 2, 3
}
显然,这个只是为了方便的语法糖,相当于以下代码
var enumerable = new MyEnumerable();
var iterable = enumerable.GetEnumerator();
while (iterable.MoveNext())
{
var item = iterable.Current;
item.Dump();
}
快速比较MSIL代码很容易证实这一点。使用foreach循环的隐式版本如下所示
IL_0000: nop
IL_0001: newobj UserQuery+MyEnumerable..ctor
IL_0006: stloc.0 // enumerable
IL_0007: nop
IL_0008: ldloc.0 // enumerable
IL_0009: callvirt UserQuery+MyEnumerable.GetEnumerator
IL_000E: stloc.1
IL_000F: br.s IL_0021
IL_0011: ldloc.1
IL_0012: callvirt System.Collections.Generic.IEnumerator<System.Int32>.get_Current
IL_0017: stloc.2 // item
IL_0018: nop
IL_0019: ldloc.2 // item
IL_001A: call LINQPad.Extensions.Dump<Int32>
IL_001F: pop
IL_0020: nop
IL_0021: ldloc.1
IL_0022: callvirt System.Collections.IEnumerator.MoveNext
IL_0027: brtrue.s IL_0011
IL_0029: leave.s IL_0036
IL_002B: ldloc.1
IL_002C: brfalse.s IL_0035
IL_002E: ldloc.1
IL_002F: callvirt System.IDisposable.Dispose
IL_0034: nop
IL_0035: endfinally
IL_0036: ret
作为对比,显式版本编译为
IL_0000: nop
IL_0001: newobj UserQuery+MyEnumerable..ctor
IL_0006: stloc.0 // enumerable
IL_0007: ldloc.0 // enumerable
IL_0008: callvirt UserQuery+MyEnumerable.GetEnumerator
IL_000D: stloc.1 // iterable
IL_000E: br.s IL_0020
IL_0010: nop
IL_0011: ldloc.1 // iterable
IL_0012: callvirt System.Collections.Generic.IEnumerator<System.Int32>.get_Current
IL_0017: stloc.2 // item
IL_0018: ldloc.2 // item
IL_0019: call LINQPad.Extensions.Dump<Int32>
IL_001E: pop
IL_001F: nop
IL_0020: ldloc.1 // iterable
IL_0021: callvirt System.Collections.IEnumerator.MoveNext
IL_0026: stloc.3
IL_0027: ldloc.3
IL_0028: brtrue.s IL_0010
IL_002A: ret
由于IEnumerator实现了IDisposable接口,我们还应该正确地处置资源。foreach语法为我们处理了这个问题。总是使用生成代码的另一个原因是——它通过做正确的事情而使我们的生活更轻松,而无需我们记住。
然而,让我们印象深刻的不是foreach部分,而是为IEnumerable / IEnumerator实现生成的类。
让我们使用yield关键字来实现这一点。
static IEnumerable<int> GetNumbers()
{
var current = 0;
while (++current < 4)
{
yield return current;
}
}
有趣的是,这小段代码已经代表了上面指定的完整迭代器。C#编译器为我们生成了所有必要的类型使其正常工作。用法也相同,只是我们不是调用显式构造函数(new MyEnumerable()),而是调用函数(GetNumbers())。太棒了!
让我们回顾一下迭代器的优点。
适用于 | 避免用于 |
---|---|
|
|
丢弃(Discards)
C#编译器对开发者施加了相当多的限制。其中一些限制是为了防止明显的错误,而另一些是为了保护用户免于运行可能无用的代码。其中一项限制禁止在没有赋值的情况下使用某些表达式。
考虑以下代码:
static void Main()
{
2 + 3;
}
现在这真是一段奇怪的代码。它会计算2+3的结果,但不会对其做任何事情。简而言之,编译器要么会优化掉这条语句,要么我们只是浪费了一些CPU周期。
我个人认为这是一个奇怪的限制。是的,上面的代码是无用的,但由于C#允许运算符重载,可能会出现简单的加法表达式实际上具有有意义的副作用的情况。
一个更实际地展示这种设计选择的(负面或恼人的)影响的场景是简单的可空性测试。
static void Run(Action action)
{
action ?? throw new ArgumentNullException(nameof(action));
action();
}
这段代码将不起作用。而是,以下代码可以:
static void Run(Action action)
{
(action ?? throw new ArgumentNullException(nameof(action)))();
}
根据设计,调用表达式被认为是可接受的。显然,方法调用的副作用倾向性被高度重视——尤其是在运算符的“不太可能”评级方面。
但是,我们仍然希望保留版本1,因为它更具可读性。因此,为了缓解这种历史性设计选择的后果,引入了一种特殊的构造:丢弃(Discards)。
丢弃背后的想法很简单:引入一个特殊的变量,名为_,它可以始终被赋值。它永远不能被读取——它是一个只写变量,并且最终会被编译器优化掉。
使用这个变量,我们可以回到版本1
static void Run(Action action)
{
_ = action ?? throw new ArgumentNullException(nameof(action));
action();
}
_是一种特殊的构造,这在多个场合可以看到。比方说,我们有多个这样的检查
static void Run<T>(Action<T> action, T arg)
where T : class
{
_ = action ?? throw new ArgumentNullException(nameof(action));
_ = arg ?? throw new ArgumentNullException(nameof(arg));
action(arg);
}
显然,action和arg的类型很可能不同。无论如何,赋值都是被接受的。对于不必要的out参数也可以这样做
if (int.TryParse(str, out _))
{
// parsed successfully, but we don't care about the result
}
另一个有用的实例是“立即执行并忘记”任务。以前,我通常会引入一个如下所示的扩展方法
public static class TaskExtensions
{
public static void Forget(this Task task)
{
// Empty on purpose; maybe log something?
}
}
这样做的优点是,我现在可以很容易地告知C#编译器,使用的任务是故意未使用的
public Task StartTask()
{
// ...
}
public void OnClick()
{
StartTask().Forget();
}
使用丢弃,我们不需要额外的扩展方法来传递这种信息。而且,用户已经知道任务“将会怎样”(提示:答案是什么都没有)。
public void OnClick()
{
_ = StartTask();
}
我们还将在模式匹配部分使用丢弃。
适用于 | 避免用于 |
---|---|
|
|
处理异步代码
我们在上一节简要触及了异步代码。自.NET 4以来,我们有了Task类型,它对于管理多个工作流非常方便。与任务并行库(TPL)以及async/await(C# 5 / .NET 4.5)结合使用,我们拥有了一个强大的工具集,并且多年来不断改进。
但是,为什么async/await需要特定版本的.NET呢?这不只是一个语言特性吗?像往常一样(例如,内插字符串、元组),如果我们要求特定版本的基类库(BCL),我们立刻就知道某些代码是通过使用BCL中的类型生成的。对于一个被标记为async的方法,它会生成一个实现IAsyncStateMachine接口的新类。
IAsyncStateMachine接口如下所示
public interface IAsyncStateMachine
{
void MoveNext();
void SetStateMachine(IAsyncStateMachine stateMachine);
}
有趣的是,它有一个MoveNext方法,就像IEnumerator接口一样。事实上,我们可以使用IEnumerator的一个特殊版本来编写我们自己的async/await实现。来自JavaScript,我们知道生成器(JavaScript中枚举器/yield语法糖的名称)在语言中引入async/await功能之前就被(滥用)了。即使在今天,polyfils仍然使用它(或者在生成器不可用时甚至回退到更低级别)。
让我们看一个使用async和await方法的简单示例
async static Task Run(Func<Task> action)
{
await action();
}
这段小片段生成的类如下所示
在MSIL中,生成的类随后在给定的Run方法中使用
IL_0000: newobj UserQuery+<Run>d__1..ctor
IL_0005: stloc.0
IL_0006: ldloc.0
IL_0007: ldarg.0
IL_0008: stfld UserQuery<Run>d__1.action
IL_000D: ldloc.0
IL_000E: call System.Runtime.CompilerServices.AsyncTaskMethodBuilder.Create
IL_0013: stfld UserQuery+<Run>d__1.<>t__builder
IL_0018: ldloc.0
IL_0019: ldc.i4.m1
IL_001A: stfld UserQuery+<Run>d__1.<>1__state
IL_001F: ldloc.0
IL_0020: ldfld UserQuery+<Run>d__1.<>t__builder
IL_0025: stloc.1
IL_0026: ldloca.s 01
IL_0028: ldloca.s 00
IL_002A: call System.Runtime.CompilerServices.AsyncTaskMethodBuilder.Start<<Run>d__1>
IL_002F: ldloc.0
IL_0030: ldflda UserQuery+<Run>d__1.<>t__builder
IL_0035: call System.Runtime.CompilerServices.AsyncTaskMethodBuilder.get_Task
IL_003A: ret
简而言之,我们实例化生成的类,存储使用的参数(捕获),并设置将在异步状态机中使用的状态。异步任务方法构建器的静态Create方法用于构造关联的构建器状态。然后,我们运行异步任务方法构建器来为我们构建一个任务。最后,我们从构建器返回Task属性。
不用说,上面代码的一个更简单的版本将是
static Task Run(Func<Task> action)
{
return action();
}
这两个变体并不完全等价。在此之前,我们返回了一个新生成的任务,“包装”了原始任务。现在,我们返回原始任务。从性能上看,它们肯定不一样。在此版本中,我们省略了完整的类生成。而且,运行方法的MSIL与此相比非常简短
IL_0000: nop
IL_0001: ldarg.0
IL_0002: callvirt System.Func<System.Threading.Tasks.Task>.Invoke
IL_0007: stloc.0
IL_0008: br.s IL_000A
IL_000A: ldloc.0
IL_000B: ret
显然,只有在我们有多个await或复杂结构时(例如,只在某个if块中await)才会使用async方法。在任何其他情况下,我们都应该尝试使用更轻量级的方法。编译时和运行时都会感谢我们更快的执行速度。
对于将标准项包装到任务中,这一点更是如此。考虑我们创建一个实现需要以下方法的接口的类
public Task<Task> GetNameAsync()
{
// ...
}
如果我们已经知道*名称*,我们可以直接返回它,但如何将其包装到任务中?最简单的情况是将方法标记为async,但我们可以使用Task.FromResult
public Task<Task> GetNameAsync() => Task.FromResult("constant name");
经验法则:始终首先寻找非生成解决方案。
此时,我们可以谈论某些好处,例如何时使用ConfigureAwait(false),以及我们现在可以与async/await一起做的所有事情(例如,在try-catch块中),但我认为许多文章(包括我自己的)已经做到了这一点。相反,我想触及迭代器await的话题。
在C# 8之前,我们没有好的方法来处理异步流。await流等同于只在流完成后才作出反应。另一种选择是等待直到第一个数据来自流。然而,这也不能解决问题,因为数据不存在。我们想要的是一个隐式循环,它可以await直到一定数量的数据可用。当流结束时,循环结束。
所有这些听起来像是对迭代器的一种增强。同样,以下不是解决方案
foreach (var item in await GetItems())
{
// ...
}
我们可能会包装流,得到
foreach (var getNextItem in GetItems())
{
var item = await getNextItem();
// ...
}
但是如果没有下一个项目呢?现在我们设置了一个回调。如果没有,我们可能会收到null或抛出异常。这两种情况都有明显的缺点。因此,让我们使用一个自定义数据类型。
foreach (var getNextItem in GetItems())
{
var state = await getNextItem();
if (state.Finished)
{
break;
}
var item = state.Current;
// ...
}
从样板代码的角度来看,这一切仍然有点混乱。因此,我们现在有了await foreach。这个可以与新的IAsyncEnumerable接口结合使用。
public async IAsyncEnumerable<int> GetNumbersAsync()
{
var current = 0;
while (++current < 4)
{
await Task.Delay(500);
yield return current;
}
}
我现在想省略关于它是如何生成的(以及确切生成了什么)的细节,但您可以猜到它类似于我们之前检查过的结构。最终,它只是async/await的状态机与迭代器的结合。
所有这些都很相似,可以通过直接检查async enumerable来看到——我们看到它很像一个“普通”enumerable。它只是现在依赖于一个名为IAsyncEnumerator的async enumerator(谁能想到呢?)
public interface IAsyncEnumerator<out T> : IAsyncDisposable
{
T Current { get; }
ValueTask<bool> MoveNextAsync();
}
太好了,那么这个语法糖看起来怎么样?
await foreach (var item in GetNumbersAsync())
{
// ...
}
太棒了,这样这个差距也弥补了!async迭代器可以非常强大,尤其适用于事件流。
适用于 | 避免用于 |
---|---|
|
|
模式匹配
近年来,C#的方向无疑发生了一些变化。它越来越多地融入了函数式编程的概念。其中一个更有趣的概念是模式匹配。C#中的模式匹配有多种方式,例如,改进的is运算符。官方称之为“模式表达式”。
以前,我们不得不使用各种不同的运算符来实现类型转换和后续检查。对于类,我们可以使用as
var element = node as IElement;
if (element != null)
{
// ...
}
但是随着is运算符的新功能,这些片段/临时变量不再是必需的了。我们可以编写易于阅读的代码。
if (node is IElement element)
{
// ...
}
完美,对吧?你说无聊。好吧,也许新的switch控制结构更合你口味。我个人很想看到一个新的match构造,但是,我能理解为什么避免使用新的保留关键字,而且我喜欢这种渐进式的方法。
switch (node)
{
case IElement element:
// ...
break;
}
让我们检查一下这个构造生成的MSIL代码
IL_0000: nop
IL_0001: ldarg.1
IL_0002: stloc.3
IL_0003: ldloc.3
IL_0004: stloc.0
IL_0005: ldloc.0
IL_0006: brtrue.s IL_000A
IL_0008: br.s IL_0016
IL_000A: ldloc.0
IL_000B: isinst IElement
IL_0010: dup
IL_0011: stloc.1
IL_0012: brfalse.s IL_0016
IL_0014: br.s IL_0018
IL_0016: br.s IL_001E
IL_0018: ldloc.1
IL_0019: stloc.2 // element
IL_001A: br.s IL_001C
IL_001C: br.s IL_001E
IL_001E: ret
好了,没什么魔法。它与我们写的一样
if (node is IElement)
{
var element = (IElement)node;
// ...
}
不过,有一些细微的差别。最值得注意的是,在生成的MSIL代码中有一个显式转换。使用前面提到的模式表达式,我们将更接近于使用新的switch构造生成的MSIL代码。因此,我们可以说switch纯粹是语法糖,用于避免重复。
它仅仅是模式表达式的语法糖吗?嗯,至少,它是很不错的糖。尤其是因为它带有扩展。在switch分支的上下文中,我们可以使用when关键字引入更多条件。
C#文档提供了一个很好的例子
switch (shape)
{
case Square s when s.Side == 0:
case Circle c when c.Radius == 0:
return 0;
case Square s:
return s.Side * s.Side;
case Circle c:
return c.Radius * c.Radius * Math.PI;
}
太棒了——这样,我们就可以避免使用goto或局部函数来避免重复的复杂构造。
C#团队甚至更进一步。除了显式类型(这将检查转换是否可能)之外,我们还可以隐式地使用当前类型。一如既往,var是触发类型推断的关键字。
官方文档提到以下示例
switch (shapeDescription)
{
case "circle":
return new Circle(2);
case "square":
return new Square(4);
case "large-circle":
return new Circle(12);
case var o when (o?.Trim().Length ?? 0) == 0:
return null;
}
因此,特定的空白符用例使用隐式类型来通过when触发附加检查。作为替代,我们可以写case string o when。尽管如此,var应该被优先使用,因为它在重构时也能经受住时间的考验。此外,它会向读者传达“嘿,我不想在这里检查转换,我只想引入更多条件”。毕竟,向读者传达意图很重要。
适用于 | 避免用于 |
---|---|
|
|
可空类型
最后,关于类型!可能是C#开发多年来(或历来)最重要的变化出现了。可空类型!
什么?我的意思是,每个类都代表一个堆分配对象,必须先创建,否则它指向一个称为“null指针”或简称为null的默认地址。null引用异常可能是最令人震惊的异常,它说明了语言(或框架)的年代久远,以至于它没有默认包含在类型系统中。幸运的是,C#语言团队中有一些非常聪明的人,他们想出了一个既进步又合适的解决方案。
以前,我们只是从我们调用的方法中收到一些类型信息。例如,以下代码
var element = document.QuerySelector("a");
// element is of IElement, but can it be null ?
有了可空类型,每个类型T都是非null的。这现在与值类型一致,值类型需要一个包装器才能(伪)可空(T?或Nullable<T>)。对于引用类型,不存在这样的包装器,但是信息通过元数据进行传输。
由于这是一个相当敏感的功能,需要先启用。以下行必须出现在我们要引入可空类型的项目的csproj文件中。
<LangVersion>8.0</LangVersion>
<Nullable>enable</Nullable>
现在,我们不再仅仅返回类型,还可以使用问号对其进行修饰,以指示可能为null的返回。
var element = document.QuerySelector("a");
// element is of IElement?, we should introduce checks!
同样的情况也适用于方法签名。让我们考虑以下签名
public void Insert(IElement element);
使用IElement?实例调用该方法是不允许的。相反,我们必须引入类型守卫。
public void InsertMaybe(IElement? element)
{
if (!(element is null))
{
// type transformed to IElement from IElement?
Insert(element);
}
}
长话短说:可空类型通过检测我们在哪里需要守卫以及在哪里不需要来使我们的生活更轻松。我们应该将可空性违规视为错误。
所有在可空上下文中工作的方都会相应地用NonNullTypes属性进行注解。此外,可空的引用也会被显式标记为Nullable。结果是,C#编译器能够正确推断使用,即使没有源代码,也能从第三方库或BCL推断。
除了生成的元数据,一切都保持不变。没有MSIL影响。这只是一个升级,旨在使*所有*应用程序更加健壮和更好。
适用于 | 避免用于 |
---|---|
|
|
重要提示:这是一个仅用于*引用*类型的(即类)编译时机制,与Nullable<T>无关,后者代表一个可以赋值为null的值类型(即结构体)。
Outlook(展望)
这是本系列的最后一部分。能够将这组内容汇集在一起,我感到非常高兴和荣幸。
在类型方面,C#的演进似乎尚未结束。F#(基于CLR)或TypeScript(通过JS转译)等语言展示了什么是可能的以及如何实现。我们可以期待在未来几年内生态系统带来更多改进。
结论
C#的演进并未止步于已使用和生成的类型。我们看到C#为我们提供了一些更高级的技术,可以在没有外部工具太多帮助的情况下获得灵活性。尽管如此,外部工具的帮助使我们拥有了更多的可能性,而没有太多的牺牲。
我个人希望TypeScript的灵活类型系统能够作为榜样,为我们的工具集带来高级的编译时类型操作、创建和求值。
兴趣点
我始终展示的是未优化(non-optimized)的MSIL代码。一旦MSIL代码被优化(甚至在运行时),它看起来可能会略有不同。这里,不同方法之间实际观察到的差异可能会消失。尽管如此,由于我们在本文中侧重于开发者的灵活性和效率(而不是应用程序性能),因此所有建议仍然有效。
如果您在其他模式(例如,发布模式、x86 等)中发现有趣的东西,请发表评论。任何额外的见解总是受欢迎的!
历史
- v1.0.0 | 初始发布 | 2019年9月28日
- v1.1.0 | 添加了目录 | 2019年9月30日
- v1.1.1 | 添加了关于partial的说明 | 2019年10月1日
- v1.2.0 | 包含Nullable类型 | 2019年10月2日