C#学习笔记——各版本新功能语法(伍)
前言:C#各版本新功能和语法
这里不会列举所有 C# 版本的新功能和语法,只会列举一些比较常用的。并且暂时只列到C# 8,后续等有机会再补充后面版本的吧。
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#进阶知识点——特性)
- 异步方法
async和await(本文章——异步方法)
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 - 文件使用:
StreamReader、StreamWriter、JsonSerializer、XmlReader、XmlWriter等等 - 图像处理:
BitmapEncoder、BitmapDecoder
这里提一嘴,虽然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);
我们还可以使用弃元(_),来忽略掉不想使用的参数,比如这里我们只想获取a,b我们并不关心,则可以这样:
Func(out int a, out _);
九、ref 修饰变量与函数返回值
我们可以使用ref修饰临时变量或函数返回值,让赋值变为引用传递。
比如下面这个例子,我们知道i和j是两个互不影响的变量,因为赋值的时候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