C#学习笔记——各版本新功能语法(伍)

目录

目录

前言:C#各版本新功能和语法

这里不会列举所有 C# 版本的新功能和语法,只会列举一些比较常用的。并且暂时只列到C# 8,后续等有机会再补充后面版本的吧。

C# 发展历史 | Microsoft Learn

1、C# 1

  • 委托与事件(C#进阶知识点——事件、委托)

2、C# 2

  • 泛型(C#进阶知识点——泛型)
  • 匿名方法(C#进阶知识点——匿名函数)
  • 迭代器(C#进阶知识点——迭代器)
  • 可空类型(C#进阶知识点——特殊语法 可空类型和Null传播器

3、C# 3

  • 隐式类型var(C#进阶知识点——特殊语法 隐式类型
  • 对象集合初始化(C#进阶知识点——特殊语法 设置集合初始值
  • Lambda表达式(C#进阶知识点——lambda表达式)
  • 匿名类型(C#进阶知识点——特殊语法 匿名类型
  • 自动属性(C#面向对象——成员属性)
  • 拓展方法(C#面向对象——拓展方法)
  • 分部类(C#面向对象——内部类与分部类)
  • Linq表达式树(暂未出文章)

4、C# 4

  • 泛型的协变和逆变(C#进阶知识点——协变逆变)
  • 命名参数(本文章——命名参数)
  • 动态类型(本文章——动态类型)

5、C# 5

  • 特性(C#进阶知识点——特性)
  • 异步方法asyncawait(本文章——异步方法)

6、C# 6

  • =>运算符(C#进阶知识点——特殊语法 单句逻辑简略写法
  • Null传播器(C#进阶知识点——特殊语法 可空类型和Null传播器
  • 内插字符串(C#进阶知识点——特殊语法 内插字符串
  • 静态导入(本文章——静态导入)
  • 异常筛选器(本文章——异常筛选器)
  • nameof运算符(本文章——nameof运算符)

7、C# 7

  • 字面值改进(本文章——字面值改进)
  • out 快捷使用 和 弃元(本文章——out 快捷使用 和 弃元)
  • ref 修饰变量与函数返回值(本文章——ref 修饰变量与函数返回值)
  • 本地函数(本文章——本地函数)
  • 抛出表达式(本文章——抛出表达式)
  • 元组(本文章——元组)
  • 解构函数Deconstruct(本文章——解构函数)
  • 模式匹配(本文章——模式匹配)

8、C# 8

  • using 声明(本文章——using 声明)
  • 静态本地函数(本文章——本地函数)
  • Null 合并赋值(本文章——Null合并赋值)
  • 模式匹配增强功能(本文章——模式匹配)

一、命名参数

有了命名参数,我们将不用匹配参数在所调用方法中的顺序,每个参数可以按照参数名字进行指定

语法:参数名:参数值

如下面这个例子,两者调用结果是一致的:

public static void Main()
{
    Func(1, 2.5f, "Hello World"); // 原本的方式
    Func(b: 2.5f, c: "Hello World", a: 1); // 命名参数
}

public static void Func(int a, float b, string c)
{
    Console.WriteLine($"{a}, {b}, {c}");
}

命名参数可以配合可选参数使用,让我们做到跳过其中的默认参数直接赋值后面的默认参数,比如下面这个情况,我们有时候不想修改中间的b的默认值,而是只需要修改c的默认值,传统写法必须将c前面的b的默认值也写进去,而使用命名参数则可以不用再为b指定值:

public static void Main()
{
    Func(1, 2f, "HelloWorld"); // 传统写法
    Func(1, c: "HelloWorld"); // 命名参数
}

public static void Func(int a, float b = 2f, string c = "myString")
{
    Console.WriteLine($"{a}, {b}, {c}");
}

作用:可以更方便的调用函数,少写一些重载函数

二、动态类型

关键字:dynamic

核心是跳过编译时类型检查,把类型检查推迟到运行时。任何非Null表达式都可以转换为dynamic类型,反之,类型为dynamic的任何表达式也能够隐式转换为其他类型。

  • 编译时:dynamic 变量会被直接当作 object 处理,编译器不做语法 / 类型校验
  • 运行时:根据变量的实际类型,动态解析调用的方法 / 属性,错了就直接抛异常

应用场景

当不确定对象类型,但是确定对象成员时,可以使用动态类型,使用dynamic还可以简化反射的书写,我们来看这个例子:

假设我们有一个玩家类和怪物类,两者没有任何关联,只是里面都有同一个CallMe方法:

public class Player
{
    public void CallMe(string msg)
    {
        Debug.Log("玩家:" + msg);
    }
}

public class Monster
{
    public void CallMe(string msg)
    {
        Debug.Log("怪物:" + msg);
    }
}

再假设有一个随机获取玩家和怪物的方法:

object GetRandomObj()
{
    if (Random.value > 0.5f)
        return new Player();
    else
        return new Monster();
}

之后在主函数中调用这个方法,由于我们编译时无法知道具体调用的是哪个类对象,但是我们知道里面一定有一个CallMe方法,因此正常可以使用反射来调用:

// 反射调用
object obj = GetRandomObj();
MethodInfo callMe = obj.GetType().GetMethod("CallMe");
callMe.Invoke(obj, new object[] { "反射打印信息" });

但是反射书写还是比较繁琐的,我们可以使用dynamic来简化书写:

// 动态类型
dynamic dynamicObj = GetRandomObj();
dynamicObj.CallMe("dynamic打印信息");

注意事项

  • Unity使用dynamic功能,需要将Unity的.Net API兼容级别切换为.Net 4.x
  • IL2CPP 不支持dynamic关键字,因为它需要 JIT 编译,而 IL2CPP 无法实现
  • 动态类型是无法自动补全方法的,我们在书写时一定要保证方法的拼写正确性

三、异步方法

1、异步编程

(1)同步和异步

同步和异步主要用于修饰方法

  • 同步方法:调用者必须等待方法执行完毕返回后,才能继续执行后续代码
  • 异步方法:调用时立即返回,由另一个线程执行方法内部逻辑,调用者无需等待方法执行完毕

(2)异步编程

异步编程的核心思路就是,将不需要立即得到结果、耗时的逻辑设为异步执行,提升程序运行效率,避免复杂逻辑导致的线程阻塞。比如复杂的逻辑计算、网络下载、网络通讯、资源加载等等。

2、异步方法 async 和 await

(1)概述

async 和 await 一般需要配合 Task 进行使用,async 用于修饰函数、lambda 表达式、匿名函数,而 await 用于在函数中和 async 配对使用,用于等待某个逻辑结束。此时逻辑会返回函数外部继续执行,直到等待的内容执行结束后,再继续执行异步函数内部逻辑。

async修饰后的方法就是异步方法,当异步方法中遇到await时,异步方法会被挂起,然后控制权返回给调用者。当await修饰的内容执行完成后,才会继续通过调用者线程执行异步方法后面的内容。

注意事项

  • 若异步方法中未使用 await 关键字,则异步方法会以同步方式执行
  • 异步方法名称建议以 Async 结尾,如 Test_Async
  • 异步方法的返回值只能是 void、Task、Task<>
  • 异步方法中不能声明使用 ref 或 out 关键字修饰的变量,因为如果 await 返回出去时可能 out 参数还未赋值,此时外部使用会报错

(2)使用

下面来举个例子加深理解:

首先是异步方法,执行到一半我们先让它休眠1秒

public async void TestAsync()
{
    print("进入异步方法");
    await Task.Run(() =>
    {
        Thread.Sleep(1000);
    });
    print("异步方法后面的逻辑");
}

之后主线程调用这个方法:

TestAsync();
print("主线程执行");

执行结果就是这样的,首先执行print("进入异步方法");,遇到await后方法挂起,返回到外部调用者继续执行,即执行print("主线程执行");,等待1秒后,await修饰的内容执行完毕,继续执行后面的逻辑,即print("异步方法后面的逻辑");,最终打印如下:

进入异步方法
主线程执行
异步方法后面的逻辑

3、关于Unity的异步关键字支持

因为异步方法是C# 5推出的,Unity早期的一些API不太支持await,而我们查看下支持await的类,比如Task,会发现内部实现了public askAwaiter GetAwaiter()这个方法,因此如果需要支持await,需要类内部实现GetAwaiter方法才行。

虽然 Unity 中的各种异步加载对异步方法支持不太好,但是当我们用到了.Net库中提到的一些API,可以考虑使用异步方法:

  • Web访问:HttpClient
  • 文件使用:StreamReaderStreamWriterJsonSerializerXmlReaderXmlWriter等等
  • 图像处理:BitmapEncoderBitmapDecoder

这里提一嘴,虽然Unity不支持异步关键字,只能使用协同程序,但是可以使用第三方工具(插件)解决,通过为这些类拓展一些方法,让Unity内部一些异步加载方法支持异步关键字。

四、静态导入

以Unity中的Mathf结构体为例,我们知道Mathf内置了很多静态方法和静态变量,而正常我们使用时可以这样:

Mathf.Abs(-1);

而静态导入就可以帮我们把前面的Mathf都给省了,用于节约代码量,可以写出更简洁的代码,用法如下:

using static UnityEngine.Mathf;

静态导入后,所有的静态成员都会导入进来,我们就可以直接访问静态类中的静态成员:

Abs(-1);

值得注意的是,静态导入指定的类后,如果该类有内部类,我们就无需再嵌套一层了,而是可以直接使用,比如这样:

public class A
{
    public class B
    {

    }
}

以前外部使用必须A.B才能使用,而使用静态导入后就可以直接使用B

五、异常筛选器

在异常捕获语句块中的catch语句后加入when关键字来筛选异常,when表达式返回的是bool值,为true才继续执行异常处理,否则不执行。

举个例子,对于同一个异常,我们通过错误信息的不同分别执行不同的处理,如包含301的错误处理和包含404的错误处理逻辑不同:

try
{
    // 异常捕获语句块
}
catch (System.Exception e) when(e.Message.Contains("301"))
{
    // 301错误处理
    print(e.Message);
}
catch (System.Exception e) when (e.Message.Contains("404"))
{
    // 404错误处理
    print(e.Message);
}

六、nameof运算符

通过nameof表达式可以将变量、类、函数等等的名称转换为字符串

int i = 0;
print(nameof(i)); // 打印i,变量名
print(nameof(List<int>)); // 打印List,类名
print(nameof(List<int>.Add)); // 打印Add,方法名

七、字面值改进

可以在数值之间插入_作为分隔符,方便数值的阅读:

int num = 9_999_999; // 这样数字大小更清晰

八、out 的快捷使用 和 弃元

假设我们有这么一个函数,有两个带out的形参:

public void Func(out int a, out int b)
{
    a = 10;
    b = 20;
}

以前我们需要在提前声明这个变量才能使用:

int a, b;
Func(out a, out b);

但既然out不必在外部赋初始值,那么外部的声明就显得比较多余,因此现在可以直接在传入参数的时候声明:

Func(out int a, out int b);

如果不确定参数类型,可以替换成var,当然有可能会影响重载函数的使用

Func(out var a, out var b);

我们还可以使用弃元_),来忽略掉不想使用的参数,比如这里我们只想获取ab我们并不关心,则可以这样:

Func(out int a, out _);

九、ref 修饰变量与函数返回值

我们可以使用ref修饰临时变量或函数返回值,让赋值变为引用传递

比如下面这个例子,我们知道ij是两个互不影响的变量,因为赋值的时候j被赋予的是i的副本:

int i = 100;
int j = i;
j = 200;
print(i); // 100
print(j); // 200

而如果使用ref修饰,则是引用传递,相当于为i取了个别名j,两者指向同一块内存:

int i = 100;
ref int j = ref i;
j = 200;
print(i); // 200
print(j); // 200

ref除了修饰变量,还可以修饰函数返回值,表示获取的是返回值的引用。

注意除了变量赋值需要加上ref,声明函数时也要加上ref

// 函数声明
public ref int GetNumber(int[] nums, int index)
{
    return ref nums[index];
}
int[] nums = new int[] { 1, 2, 3 };
ref int num = ref GetNumber(nums, 1); // 获取引用
num = 8;
print(nums[1]); // 8

十、本地函数

1、本地函数

本地函数就是在函数内部声明一个临时函数,本地函数只能在声明该函数的函数内部使用(外部函数),本地函数可以使用外部函数的的变量,本地函数不需要访问修饰符。

举个例子:

public int FuncOut(int a)
{
    int b = 10;
    FuncIn(); // 调用本地函数
    return a;

    // 本地函数
    void FuncIn()
    {
        b = 20; // 可以使用外部函数的变量
        a += b;
    }
}

打印结果是这样的:

print(FuncOut(10)); // 30

本地函数的作用就是方便函数逻辑的封装,编写时可以把本地函数都放在主要逻辑的后面,方便阅读。

2、静态本地函数

C# 8中引入了静态本地函数,在本地函数前加上static,让本地函数无法访问封闭范围内(上层方法中)的任何变量,让本地函数只能处理逻辑,避免修改上层变量造成逻辑混乱。

public int FuncOut()
{
    int i = 10;
    FuncIn();
    return i;

    static void FuncIn()
    {
        // i = 20; 报错!!!
    }
}

十一、抛出表达式

抛出表达式就是抛出一个错误,我们知道throw可以用来抛出错误:

throw new System.Exception("出错了");

在C# 7中,可以在更多地方抛出错误,用来节约代码量,比如这些地方:

  • 空合并操作符
private string jsonStr;

private void InitInfo(string str)
{
	jsonStr = str ?? throw new ArgumentNullException();
}
  • 三目运算符
private string GetInfo(string str,int index)
{
    string[] strs = str.Split(',');
    return strs.Length > index ? strs[index] : throw new IndexOutOfRangeException(); 
}
  • =>后面
private void ThrowTest() => throw new Exception("Test Exception");

十二、元组

1、概念

元组是一种包含不同数据类型的元素序列的数据结构,可以简单理解为多个值的集合。一般用于函数的多返回值,主要作用就是提升开发效率,能够更方便的处理多返回值的情况。

2、语法

声明时使用()包裹,每个元素对应一个数据类型,获取时通过变量名.Item{i}获取,其中i从1开始:

(int, float) tuple = (1, 2.5f);
print(tuple.Item1); // 1
print(tuple.Item2); // 2.5

当然,声明时可以加上变量名,这样就可以不用通过Item{i}来获取了:

(int a, float b) tuple = (1, 2.5f);
print(tuple.a); // 1
print(tuple.b); // 2.5

3、元组的比较

元组只能进行==!=比较。

元组只有两者数量相同、类型相同才进行比较。比较时,每一个参数都通过==比较,只有所有参数都为true,才认为两个元组相等。

4、元组的应用

(1)函数多返回值

函数声明的返回值的元组也可以带上变量名

  • 函数声明:
public (int, float) GetInfo()
{
    return (1, 2.5f);
}
  • 函数调用:
(int, float) tuple = GetInfo();
print(tuple.Item1); // 1
print(tuple.Item2); // 2.5

(2)元组的解构赋值

int a;
float b;
(a, b) = GetInfo(); // 或者 (int a, float b) = GetInfo();

print(a);
print(b);

(3)弃元

和out参数类似,我们可以使用弃元_丢弃不想要的参数:

(int a, _) = GetInfo();

(4)字典中包含多个值的键

有时候键只有一个值可能满足不了需求,可以使用这个方式(当然复杂的话可以使用类)

Dictionary<(int,float), string> dic = new Dictionary<(int, float), string>();

十三、解构函数

我们上面学习过元组的解构赋值,把多返回值元组拆分到不同的变量中,而现在,我们来学习解构函数Deconstruct,在自定义类中声明解构函数,这样就可以将该自定义类对象利用元组的写法对其进行变量的获取。

语法如下,在类内部声明:

public void Deconstruct(out 变量类型 变量名, out 变量类型 变量名, ...)

一个类中可以有多个Deconstruct,但是参数数量不能相同,声明完后,我们就可以对该对象利用元组将其具体的变量值解构出来。

举个例子:

声明一个Person类,写好了对应的构造和解构函数:

public class Person
{
    public string name;
    public bool gender;

    public Person(string name, bool gender)
    {
        this.name = name;
        this.gender = gender;
    }

    public void Deconstruct(out string name, out bool gender)
    {
        name = this.name;
        gender = this.gender;
    }
}

之后就可以使用元组解构这个Person类对象了:

Person p = new Person("Tom", true);
(string name, bool gender) = p;
Debug.Log(name + " " + gender); // Tom True

当然解构函数还有简便的写法,使用lambda表达式+元组:

public void Deconstruct(out string name, out bool gender) => (name, gender) = (this.name, this.gender);

十四、模式匹配

模式匹配用于检查表达式是否符合特定模式,并基于匹配结果执行操作。模式匹配由 C# 7引入,并在后续版本不断加强。

可以简单理解为模式匹配是一种表达式判定工具,用来检查一个对象是否与某种“模式”吻合,如果吻合,还允许对其分解、绑定成员变量。模式判断可以是类型检查、常量判断、属性结构匹配等。

这里我可能讲的不是特别详细,想具体了解可以看官方文档:

模式 - 使用 is 和 switch 表达式进行模式匹配。 - C# reference | Microsoft Learn

C#7 中,模式匹配增强了两个现有的语言结构,主要是为了节约代码量,提高编程效率

  • is表达式,is表达式可以在右侧写一个模式语法,而不仅仅是一个类型

  • switch 语句中的 case

1、常量模式

用于判断输入值是否等于某个值(以前is只能判断是否是某个类型),语法:(is 常量)

object obj = 1;
if (obj is 1)
{
    print("obj is 1"); // 打印成功
}

2、类型模式

用于判断输入值类型,如果类型相同,将输入值提取出来,语法:(is 类型 变量名/case 类型 变量名)

下面的例子中,先判断obj是否是int类型,如果是,则将obj的值提取到变量num

object obj = 1;
if (obj is int num)
{
    print(num); // 1
}

当然用switch...case也是可以的:

object obj = 1;
switch (obj)
{
    case int num:
        print(num); // 1
        break;
}

3、var 模式

用于将 输入值 放入与 输入值相同类型的新变量中,比如下面例子中,把obj的值用num存了起来,num的类型就是int

object obj = 1;
if (obj is var num)
{
    print(num); // 1
    print(num.GetType()); // System.Int32
}

C# 8 中,对模式匹配进行了增强,添加或增强了如下4个功能:

switch 表达式、属性模式、元组模式、位置模式

4、switch 表达式

switch表达式是对有返回值,且只有一句代码的switch语句的缩写。switch表达式与is表达式、switch语句一样,都支持模式匹配。

语法:使用=>代替case:,使用_弃元符号代替default

变量 switch {
	常量 => 返回值表达式,
    常量 => 返回值表达式,
    ...
    _ => 返回值表达式
}

举个例子,假设有一个方向枚举和一个获取方向的方法,正常switch的方法如下:

public Vector2 GetPos(Direction direction)
{
    switch (direction)
    {
        case Direction.Up:
            return new Vector2(0, 1);
        case Direction.Down:
            return new Vector2(0, -1);
        case Direction.Left:
            return new Vector2(-1, 0);
        case Direction.Right:
            return new Vector2(1, 0);
        default:
            return new Vector2(0, 0);
    }
}

而使用了switch表达式则可以简化书写:

public Vector2 GetPos(Direction direction) => direction switch
{
    Direction.Up => new Vector2(0, 1),
    Direction.Down => new Vector2(0, -1),
    Direction.Left => new Vector2(-1, 0),
    Direction.Right => new Vector2(1, 0),
    _ => new Vector2(0, 0)
};

5、属性模式

在常量模式基础上,判断对象上的各属性。主要作用就是可以判断多个条件。

语法:变量 is {属性:值, 属性:值, ...}

举个例子,假设我们有一个折扣类:

public class DiscountInfo
{
    public string discount; // 折扣
    public bool isDiscount; // 是否打折
}

我们需要判断是否打折并且折扣为五折,则可以这样:

DiscountInfo info = new DiscountInfo() { discount = "5折", isDiscount = true };

if (info is { discount: "5折", isDiscount: true })
{
    print("当前打折并且折扣为5折");
}

当然也可以搭配刚学的switch表达式,简单举个例子:

public float GetMoney(DiscountInfo info, float money) => info switch 
{
    { discount: "5折", isDiscount: true } => money * 0.5f,
    { discount: "7折", isDiscount: true } => money * 0.7f,
    { discount: "9折", isDiscount: true } => money * 0.9f,
    _ => money
};

6、元组模式

上面的属性模式可以判断多个条件,但是对象必须是一个数据结构类对象,而元组模式则可以直接进行判断,不需要声明类:

int a = 10;
bool b = true;
if ((a,b) is (10, true))
{
    print("元组值相同");
}

同样,可以搭配switch,这里就不举例子了

7、位置模式

如果自定义类实现了解构函数,那么可以直接使用类对象与元组进行判断。

举个例子,我们为之前的打折类声明解构函数:

public class DiscountInfo
{
    public string discount; // 折扣
    public bool isDiscount; // 是否打折

    public void Deconstruct(out string discount,out bool isDiscount)
    {
        discount = this.discount;
        isDiscount = this.isDiscount;
    }
}

则位置模式的使用如下:

DiscountInfo info = new DiscountInfo() { discount = "5折", isDiscount = true };

if (info is ("五折",true))
{
    print("当前打折并且折扣为5折");
}

十五、using声明

如果学习文件读取相关知识的话,我们知道using可以这样使用:

using中声明对象,语句块结束后,会自动调用对象的Dispose方法(对象需继承IDisposable接口),让其进行销毁,一般都是用于内存占用比较大 或者 有读写操作 的时候。

using (对象声明){
    // 使用对象,语句块结束后自动销毁
}

C# 8中,可以有更简便的写法,可以省略(){},写法如下:

using 对象声明;
// 使用对象,当前函数执行完毕后自动销毁

使用这种方式之后,释放时机就不是using包裹的语句块内了,而是上层语句块。比如外层函数,当函数执行完毕后,自动调用对象的Dispose方法。

举个例子:

using StreamWriter sw = new StreamWriter("test.txt");
sw.Write(8);
sw.Flush();
sw.Close();

十六、Null合并赋值

我们在C#进阶知识点学习过了空合并操作符??,当左边值为null时返回右边值,否做返回左边值:

左边值 ?? 右边值

而 C# 8 新增了空合并赋值??=当左侧为null时才会把右边值赋值给左侧变量,也就是左侧变量不为空则不做任何事:

左边值 ??= 右边值

举个例子:

string str = null;
str ??= "hello";
print(str); // hello
str ??= "world"; // str不为空,不会再赋值了
print(str); // hello