ASP.NET Core技术内幕与项目实战:基于DDD与前后端分离
上QQ阅读APP看书,第一时间看更新

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合并赋值、分部方法、源代码生成器等,读者可以参考微软的官方文档了解这些语法。