主构造器
Primary Constructor
Part 1 用法
主构造器是一种特殊的构造器,它和 C# 9 和 C# 10 里给出的、写在 record 类型声明上的那对小括号是一样的写法:
class Student(string name, int age, Gender gender)
{
}但是这么做还不够,因为这是普通的 class 类型。它需要手动声明属性或字段这些数据成员,然后进行赋值:
class Student(string name, int age, Gender gender)
{
public string Name { get; } = name;
public int Age { get; } = age;
public Gender Gender { get; } = gender;
}使用 C# 6 提供的属性初始化器即可。这种写法将原有的、尚未定义成 record 书写格式进行了简化。如果不定义成 record,那么类型将多一层构造器的定义:
class Student
{
public Student(string name, int age, Gender gender)
{
Name = name;
Age = age;
Gender = gender;
}
public string Name { get; }
public int Age { get; }
public Gender Gender { get; }
}Part 2 捕获
主构造器的参数允许捕获机制。所谓捕获,和 lambda 外部的一些玩意儿(this 啊、临时变量啊这些)运用到 lambda 内部比较类似。它指的是,在不使用初始化赋值给属性或字段以外的使用方式。
比如,我们如果把主构造器的参数 name 改成 string? name 的话,则我们需要校验可空性。我们拿这一点举例:
class Student(string? name, int age, Gender gender)
{
public string Name { get; } = name ?? throw new ArgumentNullException(nameof(name));
public int Age { get; } = age;
public Gender Gender { get; } = gender;
}当你的赋值语句除了参与基础的赋值外,还参与一些运算符、方法调用等行为,将其作为数值取值的表达式在使用,这种机制就称为主构造器参数的捕获。这种是允许的,而且也是 C# 允许的写法。但是有一种捕获就不行了:
class Student(string? name, int age, Gender gender)
{
public string Name { get; } = name ?? throw new ArgumentNullException(nameof(name));
public int Age { get; } = ++age; // 这里
public Gender Gender { get; } = gender;
}这种捕获下,age 参数被自增了一个单位。这种写法是带有副作用的捕获。我们强烈不建议使用这种语法,主要是怕它被多次捕获的时候,后面再一次使用到它产生理解上的复杂度变高;相反,它的允许只是因为它和构造器参数一样,我可以用在方法体内,所以本身没有问题。
当然,C# 没有所谓的未定义行为,所以上述的用法其实是有一种正确而严谨的理解方式的,但它脱离了我们书写代码的思维方式和合理性,所以就不阐述了。
Part 3 主构造器必须被其他构造器调用
当你需要声明带有主构造器的类型的时候,所有额外的构造器全都必须调用主构造器。
比如 Student 类型。如果你无关学生的年龄的话,可以不给赋值,此时假设你再次定义一个构造器:
class Student(string? name, int age, Gender gender)
{
public Student(string? name, Gender gender) // 这里
{
}
}此时,编译器要求你必须调用主构造器。这是必须的行为,你不能禁止掉:
class Student(string? name, int age, Gender gender)
{
public Student(string? name, Gender gender) : this(name, int.MaxValue, gender)
{
}
}比如这样。
Part 4 主构造器参数和双重存储
所谓双重存储(Double Storage),指的是主构造器参数被编译器底层生成了一个字段,然后你自己又定义了一个字段,造成的特殊现象。老实说这一点有点反人类,也不好说清楚。先来说一下主构造器参数怎么样会被编译器底层生成一个字段。
照前面的理解来说,主构造器参数都是被直接赋值给了一个自动属性的(或者定义的字段,就直接赋值给字段)。在编译器层面,这些参数就只是一个普通的参数,不会有任何的必要将其额外当成字段处理。
但是,有一种特殊的捕获用法,会有一个隐式行为——编译器会生成一个独立的处理字段,来处理这个参数,就是它在用于方法体的时候。
被捕获的参数直接放进方法体处理(如属性的 get、set 和 init 访问器的方法体和方法本身的方法体)的时候,比如这样的代码:
class Student(string? name, int age, Gender gender)
{
public string Id => $"{age}1001A";
}可以看到,内插字符串表达式 $"{age}1001A" 里用到了 age 参数值。假设这段代码是 Student 里的全部内容的话,那么这个类型似乎是没有定义任何成员的,此时的 age 照着我们理解的逻辑来看,无法以任何形式传到属性里去处理。编译器也是这么认为的,因此编译器会针对于这种情况,生成一个底层字段,然后这里的 age 用于内插字符串时,就是调用的这个字段。这就会造成隐式行为:字段的生成。
但是,如果你又自己定义一个 age 的字段或自动属性,这就会引起双重存储:
class Student(string? name, int age, Gender gender)
{
public int Age { get; } = age; // 自己定义出来的自动属性会生成一个字段
public string Id => $"{age}1001A"; // 方法体内捕获参数会隐式生成一个字段
}此时编译器会告知你这种用法是不科学的(它是编译器警告,不是编译器错误;因为它不影响程序运行,但只是会有两处字段的重复存储,引起内存使用不当)。
Part 5 带引用相关的修饰符的参数不可被捕获
如果一个主构造器参数带有 in 和 ref 的话,则你在使用前文提及的捕获机制的时候就要注意了。他可以赋值给字段等直接使用,但不能使用捕获机制,使用类似第(4)点里所说的内容。
因为他等价于翻译成了一个完整的构造器,而上述的规则只能让编译器产生一个赋值字段。但字段本身是没有引用的机制的;如果带有引用的修饰符的话,编译器将会产生赋值失败等的操作,因而直接报错,提示用户不能这么用。
// 可以的情况
readonly ref struct Span<T>(in T reference)
{
private readonly ref T _reference = ref reference;
}
// 不可以的情况
class Student(ref int ageReference)
{
public string Id => $"{ageReference}1001A"; // 错误
}Part 6 弊端
这种写法不是没有问题的。它简化了我们书写代码的缩进级别,但它也带来了很多问题。
第一个问题是,record 和 record struct 类型里的这种构造器语法形式,在等价到 class 和 struct 上时,底层本身就自带一个自动属性的声明,因此他们的标识符命名格式就成了不一致的地方。在普通数据类型(这里只指 class 和 struct,而 interface、enum 和 delegate 都不能有主构造器)里,因为是构造器参数,因此遵循驼峰命名法;而对于 record 类型来说,则因为底层带有一个帕斯卡命名法的自动属性,因此定义的时候,即使也是构造器参数,但这里用的是帕斯卡命名法,主要是为了契合底层属性的书写风格。
第二个问题是,这种写法使得属性和字段不能再次分配初始值。因为他们占用了 = 数值 的写法,导致他们以后不再能使用初始化语句进行默认数值的赋值了。
前面两个问题都不算特别严重。主要还是这个捕获机制太鸡肋了,非常不好理解。如果我们不正确地使用这个捕获机制,使用 sizeof 或 SizeOf<T> 方法的时候,我们就没有一个比较方便的方式去判断内存大小了,只能去查找一下。
Part 7 带来的语法——分号结尾的类型声明
虽说这玩意儿弊端不少,但是它也带来了很多方便的地方。比如,因为它的语法可以简化构造器定义了,因此 C# 12 开始,同时允许我们定义普通数据类型的时候,可以以分号结尾了,不再需要必须定义一对大括号,即使大括号里没写东西:
public sealed class CustomAttribute : Attribute;比如这种定义。
最后更新于