2.1 C#的新语法
C# 8.0、C# 9.0和C# 10.0中增加了很多新语法,这些新语法能够帮助开发人员更好地编写代码。本节将会对几个常用的语法进行讲解。
2.1.1 顶级语句
在以前版本的C#语法中,即使只编写一行输出“Hello world”的C#代码,也需要创建一个C#类,并且需要为这个C#类添加Main方法,才能在Main方法中编写代码。从C# 9.0开始,C#增加了“顶级语句”语法,它使得可以直接在C#文件中编写入口代码,不再需要声明类和方法。
在Visual Studio 2022中,新建一个控制台程序之后,向导生成的Program.cs如代码2-1所示。
代码2-1 最简单的C#代码
Console.WriteLine("Hello,World!");
可以看到,这样的代码比以前版本C#中的代码简洁了很多。
反编译上面的程序生成的程序集,可以得到代码2-2。
代码2-2 反编译后的代码
1 [CompilerGenerated] 2 internal class Program 3 { 4 private static void <Main>$(string[] args) 5 { 6 Console.WriteLine("Hello, World!"); 7 } 8 }
可以看到,编译器自动生成了一个Program类,并且把我们编写的代码放到这个类中。因此,顶级语句语法的功能是让编译器帮助我们简化工作。
由于顶级语句只是让编译器帮助开发人员简化工作,因此同一个项目中只能有一个文件具有顶级语句。顶级语句并不是用来替代原本的Main方法的,我们仍然可以用传统的Main方法编写入口代码。
在顶级语句中,可以直接使用await语法调用异步方法,而且在顶级语句文件中也可以声明方法,如代码2-3所示。
代码2-3 顶级语句的高级用法
1 int i = 1, j = 2; 2 int w = Add(i,j); 3 await File.WriteAllTextAsync("e:/1.txt", "hello"+w); 4 int Add(int i1,int i2) 5 { 6 return i1 + i2; 7 }
2.1.2 全局using指令
在编写项目代码的时候,我们经常需要引用非常多的.NET官方及第三方的类库,而这些类库通常位于不同的命名空间下,这样就需要在每个C#文件头部编写重复的using语句来引入这些命名空间,非常烦琐。
C# 10.0中增加了“全局using指令”语法,我们可以将global修饰符添加到任何using关键字前,这样通过using语句引入的命名空间就可以应用到这个项目的所有源代码中,因此同一个项目中的C#代码就不需要再去重复引入这个命名空间了。在实践中,通常创建一个专门用来编写全局using代码的C#文件,然后把所有在项目中经常用到的命名空间声明到这个C#文件中。
比如,项目中经常要用到Microsoft.Data.Sqlite、System.Text.Json这两个命名空间,那么可以在该项目中创建一个Usings.cs文件(文件名没有特殊限制),文件内容如代码2-4所示。
代码2-4 Usings.cs文件
1 global using Microsoft.Data.Sqlite; 2 global using System.Text.Json;
使用全局using指令,项目中的其他C#文件不需要再去单独声明这些命名空间的using语句。更令人兴奋的是,只要在*.csproj文件中加入了<ImplicitUsings>enable</ImplicitUsings>,编译器会根据项目类型自动为项目隐式地增加对System、System.Linq、Microsoft.AspNetCore.Http等常用命名空间的引入。可见,全局using指令大大减少了项目中引入命名空间的代码量。
2.1.3 using声明
我们知道,C#中可以用using关键字来简化非托管资源的释放,当变量离开using作用的范围后,会自动调用对象的Dispose方法,从而完成非托管资源的释放。但是,如果一段代码中有很多非托管资源需要被释放,代码中就会存在多个嵌套的using语句。代码2-5中使用了传统的using语法对ADO.NET对象进行释放。
代码2-5 using的嵌套
1 using (var conn = new SqlConnection(connStr)) 2 { 3 conn.Open(); 4 using (var cmd = conn.CreateCommand()) 5 { 6 cmd.CommandText = "select * from T_Articles"; 7 using (SqlDataReader reader = cmd.ExecuteReader()) 8 { 9 while (reader.Read()){} 10 } 11 } 12 }
可以看到,代码2-5中存在多层using作用域的嵌套,因此代码结构比较复杂。在C# 8.0及之后的版本中,可以使用简化的“using声明”语法来避免代码的嵌套,如代码2-6所示。在声明变量的时候,如果类型实现了IDisposable或IAsyncDisposable接口,那么可以在变量声明前加上using关键字,这样当代码执行离开被using修饰的变量作用域的时候,变量指向的对象的Dispose方法就会被调用。
代码2-6 简化的using声明
1 using var conn = new SqlConnection(connStr); 2 conn.Open(); 3 using var cmd = conn.CreateCommand(); 4 cmd.CommandText = "select * from T_Articles"; 5 using var reader = cmd.ExecuteReader(); 6 while (reader.Read()){}
由此可见,using声明语法在保证资源回收的前提下,保持了代码的优美。当然,由于使用“using声明”语法声明的变量是在离开变量作用域的时候,比如方法执行结束时,才进行资源的回收,而不是像之前使用传统using语法那样可以由开发人员定义资源的回收时机,因此在使用它的时候要避免一些可能的陷阱。
如代码2-7所示,先使用File.OpenWrite方法创建一个用于写入数据到文件的流,然后再创建StreamWriter对象,并把一个字符串写入文件,最后,调用ReadAllText方法读取刚才写入的文件内容。
代码2-7有问题的代码
1 using var outStream = File.OpenWrite("e:/1.txt"); 2 using var writer = new StreamWriter(outStream); 3 writer.WriteLine("hello"); 4 string s = File.ReadAllText("e:/1.txt"); 5 Console.WriteLine(s);
执行这段代码后,会因为第4行代码抛出如下的异常:
System.IO.IOException:“The process cannot access the file 'e:\1.txt' because it is being used by another process.”
由于outStream和writer两个变量在方法执行结束后才被释放资源,程序在执行到第4行代码的时候,文件仍然被占用,因此第4行代码抛出了异常。如果希望上面的代码能够正常执行,要么使用传统的using语法进行资源的释放,要么手动添加花括号把需要释放的资源放到单独的作用域中,如代码2-8所示。
代码2-8 正确的代码
1 { 2 using var outStream = File.OpenWrite("e:/1.txt"); 3 using var writer = new StreamWriter(outStream); 4 writer.WriteLine("hello"); 5 } 6 string s = File.ReadAllText("e:/1.txt"); 7 Console.WriteLine(s);
可以看到,在第1~5行代码中,通过手动添加一个花括号构建了一个独立的代码块。根据C#语法的规范,第1~5行代码组成的代码块就是一个独立的作用域,因此程序在离开这个作用域以后,outStream、writer两个变量指向的对象就会被释放。
2.1.4 文件范围的命名空间声明
在之前版本的C#中,类型必须定义在命名空间中,而从C# 10.0开始,C#允许编写独立的namespace代码行声明命名空间,文件中所有的类型都是这个命名空间下的成员。这种语法能够减少C#源代码文件的嵌套层次,如代码2-9所示。
代码2-9 简化的命名空间声明
1 namespace TMS.Admin; 2 class Teacher 3 { 4 public int Id { get; set; } 5 public string Name { get; set; } 6 }
2.1.5 可为空的引用类型
我们知道,在C#中,数据类型分为值类型和引用类型,值类型的变量不可以为空,而引用类型的变量可以为空。但是,在使用引用类型的时候,如果不注意检查引用类型变量是否可为空,程序中就有可能出现NullReferenceException异常。
C# 8.0中提供了“可为空的引用类型”语法,可以在引用类型后添加“?”修饰符声明这个类型是可为空的。对于没有添加“?”修饰符的引用类型的变量,当编译器发现存在为这个变量赋值null的可能性的时候,编译器会给出警告信息。在Visual Studio 2022中,这个特性是默认启用的,可以通过删除项目*.csproj文件中的<Nullable>disable</Nullable>关闭这个特性。
如代码2-10所示,编写一个包含Name、PhoneNumber两个属性的Student类。
代码2-10 没有应用可为空的引用类型的类
1 public class Student 2 { 3 public string Name { get; set; } 4 public string PhoneNumber { get; set; } 5 public Student(string name) 6 { 7 this.Name = name; 8 } 9 }
上面的代码在编译的时候,编译器会给出“在退出构造方法时,不可为null的属性PhoneNumber必须包含非null值”这样的警告信息。Name、PhoneNumber两个属性都是string类型,因此它们都是“不可为空的string类型”,但是Student类的构造方法中只为Name属性赋值了,这样就存在PhoneNumber属性没有被赋值,从而导致其属性值为空的可能性,因此编译器给出了这样的警告信息。如果想消除这个警告信息,可以将构造方法声明为Student(string name,string phoneNumber),并为两个属性都赋值,但是如果PhoneNumber属性确实可以为空,就可以把PhoneNumber属性声明为string?类型,也就是允许为空的string类型,如代码2-11所示。
代码2-11 使用可为空的引用类型的类
1 public class Student 2 { 3 public string Name { get; set; } 4 public string? PhoneNumber { get; set; } 5 public Student(string name) 6 { 7 this.Name = name; 8 } 9 }
由于上面定义的Student类的PhoneNumber属性可能为空,因此代码2-12中的第3行代码执行后会出现“解引用可能出现空引用”这样的警告信息。
代码2-12 没有进行可为空处理的代码
1 Student s1 = GetData(); 2 Console.WriteLine(s1.Name.ToLower()); 3 Console.WriteLine(s1.PhoneNumber.ToLower()); 4 Student GetData() 5 { 6 Student s1 = new Student("Zack"); 7 s1.PhoneNumber = "999"; 8 return s1; 9 }
可以用代码2-13所示的方法对PhoneNumber属性进行非空检查来避免这个警告。
代码2-13对可为空类型的成员进行检查
1 Student s1 = GetData(); 2 Console.WriteLine(s1.Name.ToLower()); 3 if (s1.PhoneNumber != null) 4 { 5 Console.WriteLine(s1.PhoneNumber.ToLower()); 6 } 7 else 8 { 9 Console.WriteLine("手机号为空"); 10 }
当然,如果确认被访问的变量、成员不会出现为空的情况,也可以在访问可为空的变量、成员的时候加上!来抑制编译器的警告,如代码2-14所示。当然,要尽量避免使用!抑制警告。
代码2-14 使用!抑制警告
1 Student s1 = GetData(); 2 Console.WriteLine(s1.Name.ToLower()); 3 Console.WriteLine(s1.PhoneNumber!.ToLower());
对于可为空的引用类型的属性,编译器会在属性上添加NullableAttribute,因此可以在运行时通过反射判断一个引用类型属性的可空性。很多.NET下的框架都充分利用了可为空的引用类型,从而对引用类型的属性、参数等进行更加智能化的处理。
2.1.6 记录类型
编写程序的时候,有时候需要比较两个对象是否相等,C#中的==运算符默认判断两个变量指向的是否是同一个对象。如果两个对象是同一种类型,并且所有属性完全相等,但是它们是两个不同的对象,导致==运算符的比较结果是false,则可以通过重写Equals方法、重写==运算符等来解决这个问题,不过这要求编写非常多的额外代码。
在C# 9.0中增加了记录(record)类型的语法,编译器会自动生成Equals、GetHashcode等方法。代码2-15中定义了一个record类型。
代码2-15 典型的record类型的用法
1 public record Person(string FirstName, string LastName);
可以看到,上面的Person类型的定义前面写的是record,而不是熟悉的class或者interface。Person类型中定义了FirstName和LastName两个属性。
下面就可以用Person类型来编写代码了,如代码2-16所示。
代码2-16 调用record类型的代码
1 Person p1 = new Person("Zack ", " Yang "); 2 Person p2 = new Person("Zack "," Yang "); 3 Person p3 = new Person("Kim", "Yoo"); 4 Console.WriteLine(p1); 5 Console.WriteLine(p1==p2); 6 Console.WriteLine(p1==p3); 7 Console.WriteLine(p1.FirstName);
程序执行结果如图2-1所示。
图2-1 程序执行结果
编译器会根据Person类型中的属性定义,自动为Person类型生成包含全部属性的构造方法。默认情况下,编译器会生成一个包含所有属性的构造方法,因此,new Person()、new Person("Yang")这两种写法都是不可以的。
编译器同样会为record类型生成ToString方法和Equals方法等,因此代码2-16中的第4行代码会输出对象的所有属性值,而第5行和第6行代码会根据对象属性的值进行比较。
下面反编译由代码2-15生成的程序集。为了避免反编译器的优化让我们看不到编译器生成的代码,需要把反编译器生成的代码改成C# 8.0的语法。反编译后的主干内容如代码2-17所示。
代码2-17 反编译后的主干内容
1 public class Person : IEquatable<Person> 2 { 3 public string FirstName { get; set /*init*/; } 4 public string LastName { get; set /*init*/; } 5 public Person(string FirstName, string LastName) 6 { 7 this.FirstName = FirstName; 8 this.LastName = LastName; 9 } 10 public override string ToString() 11 { 12 //省略代码 13 } 14 public virtual bool Equals(Person? other) 15 { 16 //省略代码 17 } 18 }
可以看到,编译器确实把record类型的Person类型编译成一个Person类,并且提供了构造方法、属性、ToString方法、Equals方法等。因此record类型编译后仍然只是一个普通的类,record是编译器提供的一个语法糖[1]。
代码2-15所示的是典型的record类型的用法,在这种定义方法中,编译器会为类型生成一个包含所有属性的构造方法,这样在初始化对象的时候,它可以确保对象的所有属性都被赋值。record所有的属性默认都是只读的,因此编写p1.FirstName=“Meng”这样的代码修改属性的值是不可以的。
综上所述,record类型提供了为所有属性赋值的构造方法,所有属性都是只读的,对象之间可以进行值的相等性比较,并且编译器为类型提供了可读性强的ToString方法。在需要编写不可变类并且需要进行对象值比较的时候,使用record可以把代码的编写难度大大降低。
当然,record类型的定义是比较灵活的,比如可以用代码2-18所示的方法实现部分属性只读而部分属性可以读写的效果。
代码2-18 高级的record类型的用法
1 public record Person(string LastName) 2 { 3 public string FirstName { get; set; } 4 public void SayHello() 5 { 6 Console.WriteLine($"Hello,我是{LastName} {FirstName}"); 7 } 8 }
可以用代码2-19所示的方法调用Person类型。
代码2-19 调用Person类型的代码
1 Person p1 = new Person("Yang"); 2 Person p2 = new Person("Yang"); 3 Console.WriteLine(p1); 4 Console.WriteLine(p1==p2); 5 p1.FirstName = "Zack"; 6 p1.SayHello(); 7 Console.WriteLine(p1==p2);
可以看到,Person类型的LastName属性仍然是用record类型语法定义的只读属性,编译器为Person类型生成了包含为LastName属性赋值的构造方法。而FirstName属性是用传统的语法定义的普通属性,这个属性是可读可写的。程序执行结果如图2-2所示。
图2-2 程序执行结果
在record类型中,也可以为类型提供多个构造方法,从而提供多种创造对象的途径。如代码2-20所示,为User类型声明了一个额外的构造方法。
代码2-20 为record类型提供一个额外的构造方法
1 public record User(string UserName,string? Email,int Age) 2 { 3 public User(string userName, int age) 4 :this(userName,null,age) 5 { 6 } 7 }
上面的User类型中声明了3个属性:不可为空的string类型的UserName属性,可为空的string类型的Email属性,以及Age属性。编译器自动生成一个包含这3个属性的构造方法,同时提供了一个为userName和age赋值的构造方法,这个构造方法通过this关键字调用编译器默认的构造方法完成对象的初始化。
可以通过如代码2-21所示的方法调用不同的构造方法来创建User类型的对象。
代码2-21 调用不同的构造方法
1 User u1 = new User("Zack", 18); 2 User u2 = new User("Zack", "yzk@example.com", 18);
record类型的对象的属性默认都是只读的,而且我们也推荐使用属性都为只读的类型。所有属性、成员变量都为只读的类型叫作“不可变类型”,不可变类型可以简化程序逻辑,并且可以减少并发访问、状态管理等麻烦。
由于record类型属于不可变类型,而在使用record类型的时候,有时候需要生成一个对象的副本,这个副本的其他属性值与原对象的相同,只有一个或者少数几个属性改变,此时可以用代码2-22所示的方法来实现想要的效果。
代码2-22 手动创建record对象的副本
1 User u1 = new User("Zack", "yzk@example.com", 18); 2 User u2 = new User(u1.UserName, "test@example", u1.Age);
C#中提供了更简单的语法完成这项工作,那就是with关键字,用法如代码2-23所示。
代码2-23 使用with关键字简化代码
1 User u1 = new User("Zack", "yzk@example.com", 18); 2 User u2 = u1 with { Email= "test@example" }; 3 Console.WriteLine(u2); 4 Console.WriteLine(u1); 5 Console.WriteLine(Object.ReferenceEquals(u1,u2));
程序执行结果如图2-3所示。
图2-3 程序执行结果
可以看到,在第2行代码中,使用with关键字创建了u1对象的一个副本,其他属性都复制自u1对象,只有Email属性采用不同的值。从图2-3所示的程序执行结果来看,with操作生成的u2对象是u1对象的一个副本,原有的u1对象的Email属性值并没有改变。
除了本节讲解的这些语法,C#还有很多优秀的新增语法,比如元组、解构、本地方法、模式匹配、默认接口方法、索引和范围、null合并赋值、分部方法、源代码生成器等,读者可以参考微软的官方文档了解这些语法。