C# 8.0 添加和增强的功能【基础篇】

2022-12-07,,,,

.NET Core 3.x.NET Standard 2.1支持C# 8.0

一、Readonly 成员

可将 readonly 修饰符应用于结构的成员,来限制成员为不可修改状态。这比在C# 7.2中将 readonly 修饰符仅可应用于 struct 声明更精细。

public struct Point
{
public double X { get; set; }
public double Y { get; set; }
public double Distance => Math.Sqrt(X * X + Y * Y);
public override string ToString() =>
$"({X}, {Y}) is {Distance} from the origin";
}

与大多数结构一样,ToString() 方法不会修改状态。 可以通过将 readonly 修饰符添加到 ToString() 的声明来对此进行限制:

public readonly override string ToString() =>// 编译器警告,因为 ToString 访问未标记为 readonly 的 Distance 属性
$"({X}, {Y}) is {Distance} from the origin";
// Distance 属性不会更改状态,因此可以通过将 readonly 修饰符添加到声明来修复此警告
public readonly double Distance => Math.Sqrt(X * X + Y * Y);

注意:readonly 修饰符对于只读属性是必需的。

编译器会假设 get 访问器可以修改状态;必须显式声明 readonly

自动实现的属性是一个例外;编译器会将所有自动实现的 Getter 视为 readonly,因此,此处无需向 X 和 Y 属性添加 readonly 修饰符。

通过此功能,可以指定设计意图,使编译器可以强制执行该意图,并基于该意图进行优化。

二、默认接口方法

.NET Core 3.0 上的 C# 8.0 开始,可以在声明接口成员时定义实现。 最常见的方案是,可以将成员添加到已经由无数客户端发布并使用的接口。示例:

// 先声明两个接口
// 客户接口
public interface ICustomer
{
IEnumerable<IOrder> PreviousOrders { get; }
DateTime DateJoined { get; }
DateTime? LastOrder { get; }
string Name { get; }
IDictionary<DateTime, string> Reminders { get; } // 在客户接口中加入新的方法实现
public decimal ComputeLoyaltyDiscount()
{
DateTime TwoYearsAgo = DateTime.Now.AddYears(-2);
if ((DateJoined < TwoYearsAgo) && (PreviousOrders.Count() > 10))
{
return 0.10m;
}
return 0;
}
}
// 订单接口
public interface IOrder
{
DateTime Purchased { get; }
decimal Cost { get; }
} // 测试代码
// SampleCustomer:接口 ICustomer 的实现,可不实现方法 ComputeLoyaltyDiscount
// SampleOrder:接口 IOrder 的实现
SampleCustomer c = new SampleCustomer("customer one", new DateTime(2010, 5, 31))
{
Reminders =
{
{ new DateTime(2010, 08, 12), "childs's birthday" },
{ new DateTime(1012, 11, 15), "anniversary" }
}
}; SampleOrder o = new SampleOrder(new DateTime(2012, 6, 1), 5m);
c.AddOrder(o);//添加订单
o = new SampleOrder(new DateTime(2103, 7, 4), 25m);
c.AddOrder(o); // 验证新增的接口方法
ICustomer theCustomer = c; // 从 SampleCustomer 到 ICustomer 的强制转换
Console.WriteLine($"Current discount: {theCustomer.ComputeLoyaltyDiscount()}");
// 若要调用在接口中声明和实现的任何方法,该变量的类型必须是接口类型,即:theCustomer

三、模式匹配的增强功能

C# 8.0扩展了C# 7.0中的词汇表(isswitch),这样就可以在代码中的更多位置使用更多模式表达式。

3.1 switch 表达式

区别与 switch 语句:

  变量位于 switch 关键字之前;

  将 case 和 : 元素替换为 =>,更简洁、直观;

  将 default 事例替换为 _ 弃元;

  实际语句是表达式,比语句更加简洁。

public static RGBColor FromRainbow(Rainbow colorBand) =>
colorBand switch
{
Rainbow.Red => new RGBColor(0xFF, 0x00, 0x00),
Rainbow.Orange => new RGBColor(0xFF, 0x7F, 0x00),
Rainbow.Yellow => new RGBColor(0xFF, 0xFF, 0x00),
Rainbow.Green => new RGBColor(0x00, 0xFF, 0x00),
Rainbow.Blue => new RGBColor(0x00, 0x00, 0xFF),
Rainbow.Indigo => new RGBColor(0x4B, 0x00, 0x82),
Rainbow.Violet => new RGBColor(0x94, 0x00, 0xD3),
_ => throw new ArgumentException(message: "invalid enum value", paramName: nameof(colorBand)),
};

3.2 属性模式

借助属性模式,可以匹配所检查的对象的属性。

如下电子商务网站的示例,该网站必须根据买家地址(Address 对象的 State 属性)计算销售税。

// Address:地址对象;salePrice:售价
public static decimal ComputeSalesTax(Address location, decimal salePrice) =>
location switch
{
{ State: "WA" } => salePrice * 0.06M,
{ State: "MN" } => salePrice * 0.075M,
{ State: "MI" } => salePrice * 0.05M,
// other cases removed for brevity...
_ => 0M
};

此写法,使得整个语法更为简洁。

3.3 元组模式

日常开发中,存在算法依赖于多个输入。使用元组模式,可根据 表示为元组 的多个值进行切换。

// 游戏“rock, paper, scissors(石头剪刀布)”的切换表达式
public static string RockPaperScissors(string first, string second)
=> (first, second) switch
{
("rock", "paper") => "rock is covered by paper. Paper wins.",
("rock", "scissors") => "rock breaks scissors. Rock wins.",
("paper", "rock") => "paper covers rock. Paper wins.",
("paper", "scissors") => "paper is cut by scissors. Scissors wins.",
("scissors", "rock") => "scissors is broken by rock. Rock wins.",
("scissors", "paper") => "scissors cuts paper. Scissors wins.",
(_, _) => "tie" // 此处弃元 表示平局(石头剪刀布游戏)的三种组合或其他文本输入
};

3.4 位置模式

某些类型包含 Deconstruct 方法,该方法将其属性解构为离散变量。 如果可以访问 Deconstruct 方法,就可以使用位置模式检查对象的属性并将这些属性用于模式。

// 位于象限中的 点对象
public class Point
{
public int X { get; }
public int Y { get; }
public Point(int x, int y) => (X, Y) = (x, y);
public void Deconstruct(out int x, out int y) => (x, y) = (X, Y);
}
public enum Quadrant// 象限
{
Unknown, Origin, One, Two, Three, Four, OnBorder
}
// 下面的方法使用位置模式来提取 x 和 y 的值。 然后,它使用 when 子句来确定该点的 Quadrant
static Quadrant GetQuadrant(Point point) => point switch
{
(0, 0) => Quadrant.Origin,
var (x, y) when x > 0 && y > 0 => Quadrant.One,
var (x, y) when x < 0 && y > 0 => Quadrant.Two,
var (x, y) when x < 0 && y < 0 => Quadrant.Three,
var (x, y) when x > 0 && y < 0 => Quadrant.Four,
var (_, _) => Quadrant.OnBorder,// 当 x 或 y 为 0(但不是两者同时为 0)时,前一个开关中的弃元模式匹配
_ => Quadrant.Unknown
};

如果没有在 switch 表达式中涵盖所有可能的情况,编译器将生成一个警告。

四、using 声明

using 声明是前面带 using 关键字的变量声明。它指示编译器声明的变量应在封闭范围的末尾进行处理。

static int WriteLinesToFile(IEnumerable<string> lines)
{
using var file = new System.IO.StreamWriter("WriteLines2.txt");
int skippedLines = 0;
foreach (string line in lines)
{
if (!line.Contains("Second"))
file.WriteLine(line);
else
skippedLines++;
}
return skippedLines;
// 当代码运行到此位置时,file 被销毁
// 相当于 using (var file = new System.IO.StreamWriter("WriteLines2.txt")){ ... }
}

如果 using 语句中的表达式不可用,编译器将生成一个错误。

五、静态本地函数

C# 8.0中可以向本地函数添加 static 修饰符,以确保本地函数不会从封闭范围捕获(引用)任何变量。 若引用了就会生成报错:CS8421-“静态本地函数不能包含对 <variable> 的引用”。

// 本地方法 LocalFunction 访问了方法 M() 这个封闭空间的变量 y
// 因此,不能用 static 修饰符来声明
int M()
{
int y;
LocalFunction();
return y;
void LocalFunction() => y = 0;
}
// Add 方法可以是静态的,因为它不访问封闭范围内的任何变量
int M()
{
int y = 5;
int x = 7;
return Add(x, y);
static int Add(int left, int right) => left + right;
}

六、可处置的 ref 结构

用 ref 修饰符声明的 struct 可能无法实现任何接口,也包括接口 IDisposable

class Program
{
static void Main(string[] args)
{
using (var book = new Book())
Console.WriteLine("Hello World!");
}
}
// 错误写法
// Error CS8343 'Book': ref structs cannot implement interfaces
ref struct Book : IDisposable
{
public void Dispose()
{ }
}
// 正确写法
class Program
{
static void Main(string[] args)
{
// 根据 using 新特性,简洁的写法,默认在当前代码块结束前销毁对象 book
using var book = new Book();
// ...
}
}
ref struct Book
{
public void Dispose()
{
}
}

因此,若要能够处理 ref struct,就必须有一个可访问的 void Dispose() 方法。

此功能同样适用于 readonly ref struct 声明。

七、可为空引用类型

若要指示一个变量可能为 null,必须在类型名称后面附加 ?,以将该变量声明为可为空引用类型。否则都被视为不可为空引用类型。

对于不可为空引用类型,编译器使用流分析来确保在声明时将本地变量初始化为非 Null 值。 字段必须在构造过程中初始化。 如果没有通过调用任何可用的构造函数或通过初始化表达式来设置变量,编译器将生成警告。

此外,不能向不可为空引用类型分配一个可以为 Null 的值。

编译器使用流分析,来确保可为空引用类型的任何变量,在被访问或分配给不可为空引用类型之前,都会对其 Null 性进行检查。

八、异步流

异步流,可针对流式处理数据源建模 。 数据流经常异步检索或生成元素,因此它们为异步流式处理数据源提供了自然编程模型。

// 异步枚举,核心对象是:IAsyncEnumerable
[HttpGet("syncsale")]
public async IAsyncEnumerable<Product> GetOnSaleProducts()
{
var products = _repository.GetProducts();
await foreach (var product in products) // 消费异步枚举,顺序取决于 IAsyncEnumerator 算法
{
if (product.IsOnSale)
yield return product;// 持续异步逐个返回,不用等全部完成
}
}

另一个实例:模拟异步抓取 html 数据

// 这是一个【相互独立的长耗时行为的集合(假设分别耗时 5,4,3,2,1s)】
static async Task Main(string[] args)
{
Console.WriteLine(DateTime.Now + $"\tThreadId:{Thread.CurrentThread.ManagedThreadId}\r\n");
await foreach (var html in FetchAllHtml()) // 默认按照任务加入的顺序输出
{
Console.WriteLine(DateTime.Now + $"\tThreadId:{Thread.CurrentThread.ManagedThreadId}\t" + $"\toutput:{html}");
}
Console.WriteLine("\r\n" + DateTime.Now + $"\tThreadId:{Thread.CurrentThread.ManagedThreadId}\t");
Console.ReadKey();
}
// 这里已经默认实现了一个 IEnumerator 枚举器: 以 for 循环加入异步任务的顺序
static async IAsyncEnumerable<string> FetchAllHtml()
{
for (int i = 5; i >= 1; i--)
{
var html = await Task.Delay(i* 1000).ContinueWith((t,i)=> $"html{i}",i); // 模拟长耗时
yield return html;
}
}

  

接着,其实五个操作是分别开始执行的,那么当耗时短的任务处理好后,能否直接输出呢?这样的话交互体验就更好了!

static async IAsyncEnumerable<string> FetchAllHtml()
{
var tasklist= new List<Task<string>>();
for (int i = 5; i >= 1; i--)
{
var t= Task.Delay(i* 1000).ContinueWith((t,i)=>$"html{i}",i);// 模拟长耗时任务
tasklist.Add(t);
}
while(tasklist.Any()) // 监控已完成的操作,立即处理
{
var tFinlish = await Task.WhenAny(tasklist);
tasklist.Remove(tFinlish);
yield return await tFinlish; // 完成即输出
}
}

以上总耗时取决于 耗时最长的那个异步任务5s。

  

  参考自:C# 8.0 宝藏好物 Async streams

九、异步可释放(IAsyncDisposable)

IAsyncDisposable 接口,提供一种用于异步释放非托管资源的机制。与之对应的就是提供同步释放非托管资源机制的接口 IDisposable

提供此类及时释放机制,可使用户执行资源密集型释放操作,从而无需长时间占用 GUI 应用程序的主线程。

同时更好的完善.NET异步编程的体验,IAsyncDisposable诞生了。它的用法与IDisposable非常的类似:

public class ExampleClass : IAsyncDisposable
{
private Stream _memoryStream = new MemoryStream();
public ExampleClass()
{ }
public async ValueTask DisposeAsync()
{
await _memoryStream.DisposeAsync();
}
}
// using 语法糖
await using var s = new ExampleClass()
{
// doing
};
// 优化 同样是对象 s 只存在于当前代码块
await using var s = new ExampleClass();
// doing

  参考于:熟悉而陌生的新朋友——IAsyncDisposable

十、索引和范围

索引和范围,为访问序列中的单个元素或范围,提供了简洁的语法。

新增了两个类型(System.Index & System.Range)和运算符(末尾运算符"^" & 范围运算符“..”)。

用例子说话吧:

var words = new string[]
{
// index from start index from end
"The", // 0 ^9
"quick", // 1 ^8
"brown", // 2 ^7
"fox", // 3 ^6
"jumped", // 4 ^5 "over", // 5 ^4
"the", // 6 ^3
"lazy", // 7 ^2
"dog" // 8 ^1 }; // 9 (or words.Length) ^0

运算实例:

Console.WriteLine($"The last word is {words[^1]}");
// “dog” // 使用 ^1 索引检索最后一个词 var quickBrownFox = words[1..4];
//“quick”、“brown”、“fox” 子范围 var lazyDog = words[^2..^0];
// “lazy”、“dog” 子范围 var allWords = words[..];
// “The”、“dog”子范围
var firstPhrase = words[..4];
// “The”、“fox”子范围
var lastPhrase = words[6..];
// “the”、“lazy”、“dog”子范围

另外可将范围声明为变量:

Range phrase = 1..4;
var text = words[phrase];

十一、 Null 合并赋值

Null 合并赋值运算符:??=

仅当左操作数计算为 null 时,才能使用运算符 ??= 将其右操作数的值分配给左操作数。

List<int> numbers = null;
int? i = null;
numbers ??= new List<int>();
numbers.Add(i ??= 17);
numbers.Add(i ??= 20);
Console.WriteLine(string.Join(" ", numbers)); // output: 17 17
Console.WriteLine(i); // output: 17

十二、非托管构造类型

在 C# 7.3 及更低版本中,构造类型(包含至少一个类型参数的类型)不能为非托管类型。 从 C# 8.0 开始,如果构造的值类型仅包含非托管类型的字段,则该类型不受管理。

public struct Coords<T>
{
public T X;
public T Y;
}
// Coords<int> 类型为 C# 8.0 及更高版本中的非托管类型
// 与任何非托管类型一样,可以创建指向此类型的变量的指针,或针对此类型的实例在堆栈上分配内存块
Span<Coords<int>> coordinates = stackalloc[]
{
new Coords<int> { X = 0, Y = 0 },
new Coords<int> { X = 0, Y = 3 },
new Coords<int> { X = 4, Y = 0 }
};

Span 简介

  在定义中,Span 就是一个简单的值类型。它真正的价值,在于允许我们与任何类型的连续内存一起工作。

  在使用中,Span 确保了内存和数据安全,而且几乎没有开销。

  要使用 Span,需要设置开发语言为 C# 7.2 以上,并引用System.Memory到项目。

  Span 使用时,最简单的,可以把它想象成一个数组,有一个Length属性和一个允许读写的index

// 常用的一些定义、属性和方法
Span(T[] array);
Span(T[] array, int startIndex);
Span(T[] array, int startIndex, int length);
unsafe Span(void* memory, int length);
int Length { get; }
ref T this[int index] { get; set; }
Span<T> Slice(int start);
Span<T> Slice(int start, int length);
void Clear();
void Fill(T value);
void CopyTo(Span<T> destination);
bool TryCopyTo(Span<T> destination);
// 从 T[] 到 Span 的隐式转换
char[] array = new char[] { 'i', 'm', 'p', 'l', 'i', 'c', 'i', 't' };
Span<char> fromArray = array;
// 复制内存
int Parse(ReadOnlySpan<char> anyMemory);
int Copy<T>(ReadOnlySpan<T> source, Span<T> destination);

  Span 参考: 关于C# Span的一些实践

十三、嵌套表达式中的 stackalloc

从 C# 8.0 开始,如果 stackalloc 表达式的结果为 System.Span<T> 或 System.ReadOnlySpan<T> 类型,则可以在其他表达式中使用 stackalloc 表达式:

Span<int> numbers = stackalloc[] { 1, 2, 3, 4, 5, 6 };
var ind = numbers.IndexOfAny(stackalloc[] { 2, 4, 6, 8 });
Console.WriteLine(ind); // output: 1

stackalloc 表达式简介:

  stackalloc 关键字用于不安全的代码上下文中,以便在堆栈上分配内存块。

// 关键字仅在局部变量的初始值中有效,正确写法:
int* block = stackalloc int[100];
// 错误写法:
int* block;
block = stackalloc int[100];

  由于涉及指针类型,因此 stackalloc 要求不安全上下文。

  以下代码示例计算并演示 Fibonacci 序列中的前 20 个数字。 每个数字是先前两个数字的和。 在代码中,大小足够容纳 20 个 int 类型元素的内存块是在堆栈上分配的,而不是在堆上分配的。
  该块的地址存储在 fib 指针中。 此内存不受垃圾回收的制约,因此不必将其钉住(通过使用 fixed)。 内存块的生存期受限于定义它的方法的生存期。 不能在方法返回之前释放内存。

class Test
{
static unsafe void Main()
{
const int arraySize = 20;
int* fib = stackalloc int[arraySize];
int* p = fib;
*p++ = *p++ = 1;// The sequence begins with 1, 1.
for (int i = 2; i < arraySize; ++i, ++p)
*p = p[-1] + p[-2];// Sum the previous two numbers.
for (int i = 0; i < arraySize; ++i)
Console.WriteLine(fib[i]);
// Keep the console window open in debug mode.
System.Console.WriteLine("Press any key to exit.");
System.Console.ReadKey();
}
}
/*
Output
1
1
2
3
5
8
13
21
34
55
89
144
233
377
610
987
1597
2584
4181
6765
*/

  不安全代码的安全性低于安全替代代码。 但是,通过使用 stackalloc 可以自动启用公共语言运行时 (CLR) 中的缓冲区溢出检测功能。 如果检测到缓冲区溢出,进程将尽快终止,以最大限度地减小执行恶意代码的机会。

  stackalloc 表达式参考:C#不安全代码和stackalloc

十四、内插逐字字符串的增强功能

内插逐字字符串中 $ 和 @ 标记的顺序可以任意安排:$@"..." 和 @$"..." 均为有效的内插逐字字符串。

在早期 C# 版本中,$ 标记必须出现在 @ 标记之前。

注:暂时整理这些,欢迎指正和补充。

C# 8.0 添加和增强的功能【基础篇】的相关教程结束。

《C# 8.0 添加和增强的功能【基础篇】.doc》

下载本文的Word格式文档,以方便收藏与打印。