主构造器

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),指的是主构造器参数被编译器底层生成了一个字段,然后你自己又定义了一个字段,造成的特殊现象。老实说这一点有点反人类,也不好说清楚。先来说一下主构造器参数怎么样会被编译器底层生成一个字段。

照前面的理解来说,主构造器参数都是被直接赋值给了一个自动属性的(或者定义的字段,就直接赋值给字段)。在编译器层面,这些参数就只是一个普通的参数,不会有任何的必要将其额外当成字段处理。

但是,有一种特殊的捕获用法,会有一个隐式行为——编译器会生成一个独立的处理字段,来处理这个参数,就是它在用于方法体的时候。

被捕获的参数直接放进方法体处理(如属性的 getsetinit 访问器的方法体和方法本身的方法体)的时候,比如这样的代码:

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 带引用相关的修饰符的参数不可被捕获

如果一个主构造器参数带有 inref 的话,则你在使用前文提及的捕获机制的时候就要注意了。他可以赋值给字段等直接使用,但不能使用捕获机制,使用类似第(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 弊端

这种写法不是没有问题的。它简化了我们书写代码的缩进级别,但它也带来了很多问题。

第一个问题是,recordrecord struct 类型里的这种构造器语法形式,在等价到 classstruct 上时,底层本身就自带一个自动属性的声明,因此他们的标识符命名格式就成了不一致的地方。在普通数据类型(这里只指 classstruct,而 interfaceenumdelegate 都不能有主构造器)里,因为是构造器参数,因此遵循驼峰命名法;而对于 record 类型来说,则因为底层带有一个帕斯卡命名法的自动属性,因此定义的时候,即使也是构造器参数,但这里用的是帕斯卡命名法,主要是为了契合底层属性的书写风格。

第二个问题是,这种写法使得属性和字段不能再次分配初始值。因为他们占用了 = 数值 的写法,导致他们以后不再能使用初始化语句进行默认数值的赋值了。

前面两个问题都不算特别严重。主要还是这个捕获机制太鸡肋了,非常不好理解。如果我们不正确地使用这个捕获机制,使用 sizeofSizeOf<T> 方法的时候,我们就没有一个比较方便的方式去判断内存大小了,只能去查找一下。

Part 7 带来的语法——分号结尾的类型声明

虽说这玩意儿弊端不少,但是它也带来了很多方便的地方。比如,因为它的语法可以简化构造器定义了,因此 C# 12 开始,同时允许我们定义普通数据类型的时候,可以以分号结尾了,不再需要必须定义一对大括号,即使大括号里没写东西:

public sealed class CustomAttribute : Attribute;

比如这种定义。

最后更新于