C# 11 的新特性和改进前瞻
前言
.NET 7 的开发还剩下一个多月就要进入 RC,C# 11 的新特性和改进也即将敲定。在这个时间点上,不少新特性都已经实现完毕并合并入主分支
C# 11 包含的新特性和改进非常多,类型系统相比之前也有了很大的增强,在确保静态类型安全的同时大幅提升了语言表达力。
那么本文就按照方向从 5 个大类来进行介绍,一起来提前看看 C# 11 的新特性和改进都有什么。
1. 类型系统的改进
抽象和虚静态方法
C# 11 开始将 abstract
和 virtual
引入到静态方法中,允许开发者在接口中编写抽象和虚静态方法。
接口与抽象类不同,接口用来抽象行为,通过不同类型实现接口来实现多态;而抽象类则拥有自己的状态,通过各子类型继承父类型来实现多态。这是两种不同的范式。
在 C# 11 中,虚静态方法的概念被引入,在接口中可以编写抽象和虚静态方法了。
interface IFoo
{
// 抽象静态方法
abstract static int Foo1();
// 虚静态方法
virtual static int Foo2()
{
return 42;
}
}
struct Bar : IFoo
{
// 隐式实现接口方法
public static int Foo1()
{
return 7;
}
}
Bar.Foo1(); // ok
由于运算符也属于静态方法,因此从 C# 11 开始,也可以用接口来对运算符进行抽象了。
interface ICanAdd<T> where T : ICanAdd<T>
{
abstract static T operator +(T left, T right);
}
这样我们就可以给自己的类型实现该接口了,例如实现一个二维的点 Point
:
record struct Point(int X, int Y) : ICanAdd<Point>
{
// 隐式实现接口方法
public static Point operator +(Point left, Point right)
{
return new Point(left.X + right.X, left.Y + right.Y);
}
}
然后我们就可以对两个 Point
进行相加了:
var p1 = new Point(1, 2);
var p2 = new Point(2, 3);
Console.WriteLine(p1 + p2); // Point { X = 3, Y = 5 }
除了隐式实现接口之外,我们也可以显式实现接口:
record struct Point(int X, int Y) : ICanAdd<Point>
{
// 显式实现接口方法
static Point ICanAdd<Point>.operator +(Point left, Point right)
{
return new Point(left.X + right.X, left.Y + right.Y);
}
}
不过用显示实现接口的方式的话,+
运算符没有通过 public
公开暴露到类型 Point
上,因此我们需要通过接口来调用 +
运算符,这可以利用泛型约束来做到:
var p1 = new Point(1, 2);
var p2 = new Point(2, 3);
Console.WriteLine(Add(p1, p2)); // Point { X = 3, Y = 5 }
T Add<T>(T left, T right) where T : ICanAdd<T>
{
return left + right;
}
对于不是运算符的情况,则可以利用泛型参数来调用接口上的抽象和静态方法:
void CallFoo1<T>() where T : IFoo
{
T.Foo1();
}
Bar.Foo1(); // error
CallFoo<Bar>(); // ok
struct Bar : IFoo
{
// 显式实现接口方法
static void IFoo.Foo1()
{
return 7;
}
}
此外,接口可以基于另一个接口扩展,因此对于抽象和虚静态方法而言,我们可以利用这个特性在接口上实现多态。
CallFoo<Bar1>(); // 5 5
CallFoo<Bar2>(); // 6 4
CallFoo<Bar3>(); // 3 7
CallFooFromIA<Bar4>(); // 1
CallFooFromIB<Bar4>(); // 2
void CallFoo<T>() where T : IC
{
CallFooFromIA<T>();
CallFooFromIB<T>();
}
void CallFooFromIA<T>() where T : IA
{
Console.WriteLine(T.Foo());
}
void CallFooFromIB<T>() where T : IB
{
Console.WriteLine(T.Foo());
}
interface IA
{
virtual static int Foo()
{
return 1;
}
}
interface IB
{
virtual static int Foo()
{
return 2;
}
}
interface IC : IA, IB
{
static int IA.Foo()
{
return 3;
}
static int IB.Foo()
{
return 4;
}
}
struct Bar1 : IC
{
public static int Foo()
{
return 5;
}
}
struct Bar2 : IC
{
static int IA.Foo()
{
return 6;
}
}
struct Bar3 : IC
{
static int IB.Foo()
{
return 7;
}
}
struct Bar4 : IA, IB { }
同时,.NET 7 也利用抽象和虚静态方法,对基础库中的数值类型进行了改进。在 System.Numerics
中新增了大量的用于数学的泛型接口,允许用户利用泛型编写通用的数学计算代码:
using System.Numerics;
V Eval<T, U, V>(T a, U b, V c)
where T : IAdditionOperators<T, U, U>
where U : IMultiplyOperators<U, V, V>
{
return (a + b) * c;
}
Console.WriteLine(Eval(3, 4, 5)); // 35
Console.WriteLine(Eval(3.5f, 4.5f, 5.5f)); // 44
泛型 attribute
C# 11 正式允许用户编写和使用泛型 attribute,因此我们可以不再需要使用 Type
来在 attribute 中存储类型信息,这不仅支持了类型推导,还允许用户通过泛型约束在编译时就能对类型进行限制。
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
class FooAttribute<T> : Attribute where T : INumber<T>
{
public T Value { get; }
public FooAttribute(T v)
{
Value = v;
}
}
[Foo<int>(3)] // ok
[Foo<float>(4.5f)] // ok
[Foo<string>("test")] // error
void MyFancyMethod() { }
ref 字段和 scoped ref
C# 11 开始,开发者可以在 ref struct
中编写 ref
字段,这允许我们将其他对象的引用存储在一个 ref struct
中:
int x = 1;
Foo foo = new(ref x);
foo.X = 2;
Console.WriteLine(x); // 2
ref struct Foo
{
public ref int X;
public Foo(ref int x)
{
X = ref x;
}
}
可以看到,上面的代码中将 x
的引用保存在了 Foo
中,因此对 foo.X
的修改会反映到 x
上。
如果用户没有对 Foo.X
进行初始化,则默认是空引用,可以利用 Unsafe.IsNullRef
来判断一个 ref
是否为空:
ref struct Foo
{
public ref int X;
public bool IsNull => Unsafe.IsNullRef(ref X);
public Foo(ref int x)
{
X = ref x;
}
}
这里可以发现一个问题,那就是 ref field
的存在,可能会使得一个 ref
指向的对象的生命周期被扩展而导致错误,例如:
Foo MyFancyMethod()
{
int x = 1;
Foo foo = new(ref x);
return foo; // error
}
ref struct Foo
{
public Foo(ref int x) { }
}
上述代码编译时会报错,因为 foo
引用了局部变量 x
,而局部变量 x
在函数返回后生命周期就结束了,但是返回 foo
的操作使得 foo
的生命周期比 x
的生命周期更长,这会导致无效引用的问题,因此编译器检测到了这一点,不允许代码通过编译。
但是上述代码中,虽然 foo
确实引用了 x
,但是 foo
对象本身并没有长期持有 x
的引用,因为在构造函数返回后就不再持有对 x
的引用了,因此这里按理来说不应该报错。于是 C# 11 引入了 scoped
的概念,允许开发者显式标注 ref
的生命周期,标注了 scoped
的 ref
表示这个引用的生命周期不会超过当前函数的生命周期:
Foo MyFancyMethod()
{
int x = 1;
Foo foo = new(ref x);
return foo; // ok
}
ref struct Foo
{
public Foo(scoped ref int x) { }
}
这样一来,编译器就知道 Foo
的构造函数不会使得 Foo
在构造函数返回后仍然持有 x
的引用,因此上述代码就能安全通过编译了。如果我们试图让一个 scoped ref
逃逸出当前函数的话,编译器就会报错:
ref struct Foo
{
public ref int X;
public Foo(scoped ref int x)
{
X = ref x; // error
}
}
如此一来,就实现了引用安全。
利用 ref
字段,我们可以很方便地实现各种零开销设施,例如提供一个多种方法访问颜色数据的 ColorView
:
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
var color = new Color { R = 1, G = 2, B = 3, A = 4 };
color.RawOfU32[0] = 114514;
color.RawOfU16[1] = 19198;
color.RawOfU8[2] = 10;
Console.WriteLine(color.A); // 74
[StructLayout(LayoutKind.Explicit)]
struct Color
{
[FieldOffset(0)] public byte R;
[FieldOffset(1)] public byte G;
[FieldOffset(2)] public byte B;
[FieldOffset(3)] public byte A;
[FieldOffset(0)] public uint Rgba;
public ColorView<byte> RawOfU8 => new(ref this);
public ColorView<ushort> RawOfU16 => new(ref this);
public ColorView<uint> RawOfU32 => new(ref this);
}
ref struct ColorView<T> where T : unmanaged
{
private ref Color color;
public ColorView(ref Color color)
{
this.color = ref color;
}
[DoesNotReturn] private static ref T Throw() => throw new IndexOutOfRangeException();
public ref T this[uint index]
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
get
{
unsafe
{
return ref (sizeof(T) * index >= sizeof(Color) ?
ref Throw() :
ref Unsafe.Add(ref Unsafe.AsRef<T>(Unsafe.AsPointer(ref color)), (int)index));
}
}
}
}
在字段中,ref
还可以配合 readonly
一起使用,用来表示不可修改的 ref
,例如:
ref int
:一个int
的引用readonly ref int
:一个int
的只读引用ref readonly int
:一个只读int
的引用readonly ref readonly int
:一个只读int
的只读引用
这将允许我们确保引用的安全,使得引用到只读内容的引用不会被意外更改。
当然,C# 11 中的 ref
字段和 scoped
支持只是其完全形态的一部分,更多的相关内容仍在设计和讨论,并在后续版本中推出。
文件局部类型
C# 11 引入了新的文件局部类型可访问性符号 file
,利用该可访问性符号,允许我们编写只能在当前文件中使用的类型:
// A.cs
file class Foo
{
// ...
}
file struct Bar
{
// ...
}
如此一来,如果我们在与 Foo
和 Bar
的不同文件中使用这两个类型的话,编译器就会报错:
// A.cs
var foo = new Foo(); // ok
var bar = new Bar(); // ok
// B.cs
var foo = new Foo(); // error
var bar = new Bar(); // error
这个特性将可访问性的粒度精确到了文件,对于代码生成器等一些要放在同一个项目中,但是又不想被其他人接触到的代码而言将会特别有用。
required 成员
C# 11 新增了 required
成员,标记有 required
的成员将会被要求使用时必须要进行初始化,例如:
var foo = new Foo(); // error
var foo = new Foo { X = 1 }; // ok
struct Foo
{
public required int X;
}
开发者还可以利用 SetsRequiredMembers
这个 attribute 来对方法进行标注,表示这个方法会初始化 required
成员,因此用户在使用时可以不需要再进行初始化:
using System.Diagnostics.CodeAnalysis;
var p = new Point(); // error
var p = new Point { X = 1, Y = 2 }; // ok
var p = new Point(1, 2); // ok
struct Point
{
public required int X;
public required int Y;
[SetsRequiredMembers]
public Point(int x, int y)
{
X = x;
Y = y;
}
}
利用 required
成员,我们可以要求其他开发者在使用我们编写的类型时必须初始化一些成员,使其能够正确地使用我们编写的类型,而不会忘记初始化一些成员。
2. 运算改进
checked 运算符
C# 自古以来就有 checked
和 unchecked
概念,分别表示检查和不检查算术溢出:
byte x = 100;
byte y = 200;
unchecked
{
byte z = (byte)(x + y); // ok
}
checked
{
byte z = (byte)(x + y); // error
}
在 C# 11 中,引入了 checked
运算符概念,允许用户分别实现用于 checked
和 unchecked
的运算符:
struct Foo
{
public static Foo operator +(Foo left, Foo right) { ... }
public static Foo operator checked +(Foo left, Foo right) { ... }
}
var foo1 = new Foo(...);
var foo2 = new Foo(...);
var foo3 = unchecked(foo1 + foo2); // 调用 operator +
var foo4 = checked(foo1 + foo2); // 调用 operator checked +
对于自定义运算符而言,实现 checked
的版本是可选的,如果没有实现 checked
的版本,则都会调用 unchecked
的版本。
无符号右移运算符
C# 11 新增了 >>>
表示无符号的右移运算符。此前 C# 的右移运算符 >>
默认是有符号的右移,即:右移操作保留符号位,因此对于 int
而言,将会有如下结果:
-1 >> 1 = -1
-1 >> 2 = -1
-1 >> 3 = -1
-1 >> 4 = -1
// ...
而新的 >>>
则是无符号右移运算符,使用后将会有如下结果:
-1 >>> 1 = 2147483647
-1 >>> 2 = 1073741823
-1 >>> 3 = 536870911
-1 >>> 4 = 268435455
// ...
这省去了我们需要无符号右移时,需要先将数值转换为无符号数值后进行计算,再转换回来的麻烦,也能避免不少因此导致的意外错误。
移位运算符放开类型限制
C# 11 开始,移位运算符的右操作数不再要求必须是 int
,类型限制和其他运算符一样被放开了,因此结合上面提到的抽象和虚静态方法,允许我们声明泛型的移位运算符了:
interface ICanShift<T> where T : ICanShift<T>
{
abstract static T operator <<(T left, T right);
abstract static T operator >>(T left, T right);
}
当然,上述的场景是该限制被放开的主要目的。然而,相信不少读者读到这里心中都可能会萌生一个邪恶的想法,没错,就是 cin
和 cout
!虽然这种做法在 C# 中是不推荐的,但该限制被放开后,开发者确实能编写类似的代码了:
using static OutStream;
using static InStream;
int x = 0;
_ = cin >> To(ref x); // 有 _ = 是因为 C# 不允许运算式不经过赋值而单独成为一条语句
_ = cout << "hello" << " " << "world!";
public class OutStream
{
public static OutStream cout = new();
public static OutStream operator <<(OutStream left, string right)
{
Console.WriteLine(right);
return left;
}
}
public class InStream
{
public ref struct Ref<T>
{
public ref T Value;
public Ref(ref T v) => Value = ref v;
}
public static Ref<T> To<T>(ref T v) => new (ref v);
public static InStream cin = new();
public static InStream operator >>(InStream left, Ref<int> right)
{
var str = Console.Read(...);
right.Value = int.Parse(str);
}
}
IntPtr、UIntPtr 支持数值运算
C# 11 中,IntPtr
和 UIntPtr
都支持数值运算了,这极大的方便了我们对指针进行操作:
UIntPtr addr = 0x80000048;
IntPtr offset = 0x00000016;
UIntPtr newAddr = addr + (UIntPtr)offset; // 0x8000005E
当然,如同 Int32
和 int
、Int64
和 long
的关系一样,C# 中同样存在 IntPtr
和 UIntPtr
的等价简写,分别为 nint
和 nuint
,n 表示 native,用来表示这个数值的位数和当前运行环境的内存地址位数相同:
nuint addr = 0x80000048;
nint offset = 0x00000016;
nuint newAddr = addr + (nuint)offset; // 0x8000005E
3. 模式匹配改进
列表模式匹配
C# 11 中新增了列表模式,允许我们对列表进行匹配。在列表模式中,我们可以利用 [ ]
来包括我们的模式,用 _
代指一个元素,用 ..
代表 0 个或多个元素。在 ..
后可以声明一个变量,用来创建匹配的子列表,其中包含 ..
所匹配的元素。
例如:
var array = new int[] { 1, 2, 3, 4, 5 };
if (array is [1, 2, 3, 4, 5]) Console.WriteLine(1); // 1
if (array is [1, 2, 3, ..]) Console.WriteLine(2); // 2
if (array is [1, _, 3, _, 5]) Console.WriteLine(3); // 3
if (array is [.., _, 5]) Console.WriteLine(4); // 4
if (array is [1, 2, 3, .. var remaining])
{
Console.WriteLine(remaining[0]); // 4
Console.WriteLine(remaining.Length); // 2
}
当然,和其他的模式一样,列表模式同样是支持递归的,因此我们可以将列表模式与其他模式组合起来使用:
var array = new string[] { "hello", ",", "world", "~" };
if (array is ["hello", _, { Length: 5 }, { Length: 1 } elem, ..])
{
Console.WriteLine(elem); // ~
}
除了在 if
中使用模式匹配以外,在 switch
中也同样能使用:
var array = new string[] { "hello", ",", "world", "!" };
switch (array)
{
case ["hello", _, { Length: 5 }, { Length: 1 } elem, ..]:
// ...
break;
default:
// ...
break;
}
var value = array switch
{
["hello", _, { Length: 5 }, { Length: 1 } elem, ..] => 1,
_ => 2
};
Console.WriteLine(value); // 1
对 Span<char> 的模式匹配
在 C# 中,Span<char>
和 ReadOnlySpan<char>
都可以看作是字符串的切片,因此 C# 11 也为这两个类型添加了字符串模式匹配的支持。例如:
int Foo(ReadOnlySpan<char> span)
{
if (span is "abcdefg") return 1;
return 2;
}
Foo("abcdefg".AsSpan()); // 1
Foo("test".AsSpan()); // 2
如此一来,使用 Span<char>
或者 ReadOnlySpan<char>
的场景也能够非常方便地进行字符串匹配了,而不需要利用 SequenceEquals
或者编写循环进行处理。
4. 字符串处理改进
原始字符串
C# 中自初便有 @
用来表示不需要转义的字符串,但是用户还是需要将 "
写成 ""
才能在字符串中包含引号。C# 11 引入了原始字符串特性,允许用户利用原始字符串在代码中插入大量的无需转移的文本,方便开发者在代码中以字符串的方式塞入代码文本等。
原始字符串需要被至少三个 "
包裹,例如 """
和 """""
等等,前后的引号数量要相等。另外,原始字符串的缩进由后面引号的位置来确定,例如:
var str = """
hello
world
""";
此时 str
是:
hello
world
而如果是下面这样:
var str = """
hello
world
""";
str
则会成为:
hello
world
这个特性非常有用,例如我们可以非常方便地在代码中插入 JSON 代码了:
var json = """
{
"a": 1,
"b": {
"c": "hello",
"d": "world"
},
"c": [1, 2, 3, 4, 5]
}
""";
Console.WriteLine(json);
/*
{
"a": 1,
"b": {
"c": "hello",
"d": "world"
},
"c": [1, 2, 3, 4, 5]
}
*/
UTF-8 字符串
C# 11 引入了 UTF-8 字符串,我们可以用 u8
后缀来创建一个 ReadOnlySpan<byte>
,其中包含一个 UTF-8 字符串:
var str1 = "hello world"u8; // ReadOnlySpan<byte>
var str2 = "hello world"u8.ToArray(); // byte[]
UTF-8 对于 Web 场景而言非常有用,因为在 HTTP 协议中,默认编码就是 UTF-8,而 .NET 则默认是 UTF-16 编码,因此在处理 HTTP 协议时,如果没有 UTF-8 字符串,则会导致大量的 UTF-8 和 UTF-16 字符串的相互转换,从而影响性能。
有了 UTF-8 字符串后,我们就能非常方便的创建 UTF-8 字面量来使用了,不再需要手动分配一个 byte[]
然后在里面一个一个硬编码我们需要的字符。
字符串插值允许换行
C# 11 开始,字符串的插值部分允许换行,因此如下代码变得可能:
var str = $"hello, the leader is {group
.GetLeader()
.GetName()}.";
这样一来,当插值的部分代码很长时,我们就能方便的对代码进行格式化,而不需要将所有代码挤在一行。
5. 其他改进
struct 自动初始化
C# 11 开始,struct
不再强制构造函数必须要初始化所有的字段,对于没有初始化的字段,编译器会自动做零初始化:
struct Point
{
public int X;
public int Y;
public Point(int x)
{
X = x;
// Y 自动初始化为 0
}
}
支持对其他参数名进行 nameof
C# 11 允许了开发者在参数中对其他参数名进行 nameof
,例如在使用 CallerArgumentExpression
这一 attribute 时,此前我们需要直接硬编码相应参数名的字符串,而现在只需要使用 nameof
即可:
void Assert(bool condition, [CallerArgumentExpression(nameof(condition))] string expression = "")
{
// ...
}
这将允许我们在进行代码重构时,修改参数名 condition
时自动修改 nameof
里面的内容,方便的同时减少出错。
自动缓存静态方法的委托
C# 11 开始,从静态方法创建的委托将会被自动缓存,例如:
void Foo()
{
Call(Console.WriteLine);
}
void Call(Action action)
{
action();
}
此前,每执行一次 Foo
,就会从 Console.WriteLine
这一静态方法创建一个新的委托,因此如果大量执行 Foo
,则会导致大量的委托被重复创建,导致大量的内存被分配,效率极其低下。在 C# 11 开始,将会自动缓存静态方法的委托,因此无论 Foo
被执行多少次,Console.WriteLine
的委托只会被创建一次,节省了内存的同时大幅提升了性能。
总结
从 C# 8 开始,C# 团队就在不断完善语言的类型系统,在确保静态类型安全的同时大幅提升语言表达力,从而让类型系统成为编写程序的得力助手,而不是碍手碍脚的限制。
本次更新还完善了数值运算相关的内容,使得开发者利用 C# 编写数值计算方法时更加得心应手。
另外,模式匹配的探索旅程也终于接近尾声,引入列表模式之后,剩下的就只有字典模式和活动模式了,模式匹配是一个非常强大的工具,允许我们像对字符串使用正则表达式那样非常方便地对数据进行匹配。
总的来说 C# 11 的新特性和改进内容非常多,每一项内容都对 C# 的使用体验有着不小的提升。在未来的 C# 中还计划着角色和扩展等更加令人激动的新特性,让我们拭目以待。