教程:探索主构造函数

C# 12 引入了主构造函数,这是一种简明的语法,用于声明一些构造函数,它们的参数在类型主体中的任何位置可用。

本教程介绍:

  • 何时对类型声明主构造函数
  • 如何从其他构造函数调用主构造函数
  • 如何在类型的成员中使用主构造函数参数
  • 将主构造函数参数存储在哪里

先决条件

需要将计算机设置为运行 .NET 8 或更高版本,包括 C# 12 或更高版本编译器。 自 Visual Studio 2022 版本 17.7.NET 8 SDK 起,开始随附 C# 12 编译器。

主构造函数

可以将参数添加到 structclass 声明中,用于创建主构造函数。 主构造函数参数在整个类定义范围内。 请务必将主构造函数参数视为参数,即使它们在整个类定义范围内也是如此。 有几个规则阐明了它们是参数:

  1. 如果不需要主构造函数参数,可能不会存储它们。
  2. 主构造函数参数不是类的成员。 例如,名为 param 的主构造函数参数不能作为 this.param 访问。
  3. 可以对主构造函数参数进行分配。
  4. 主构造函数参数不会成为属性,record 类型除外。

这些规则与任何方法的参数相同,包括其他构造函数声明。

主构造函数参数的最常见用途包括:

  1. 作为 base() 构造函数调用的参数。
  2. 初始化成员字段或属性。
  3. 引用实例成员中的构造函数参数。

类的所有其他构造函数都必须通过 this() 构造函数调用直接或间接调用主构造函数。 该规则可确保在类型的主体中的任意位置分配主构造函数参数。

初始化属性

以下代码初始化从主构造函数参数计算的两个只读属性:

public readonly struct Distance(double dx, double dy)
{
    public readonly double Magnitude { get; } = Math.Sqrt(dx * dx + dy * dy);
    public readonly double Direction { get; } = Math.Atan2(dy, dx);
}

前面的代码演示了用于初始化计算的只读属性的主构造函数。 DirectionMagnitude 的字段初始值设定项使用主构造函数参数。 主构造函数参数不会在结构中的其他任何位置使用。 前面的结构就像编写了以下代码一样:

public readonly struct Distance
{
    public readonly double Magnitude { get; }

    public readonly double Direction { get; }

    public Distance(double dx, double dy)
    {
        Magnitude = Math.Sqrt(dx * dx + dy * dy);
        Direction = Math.Atan2(dy, dx);
    }
}

当需要参数来初始化字段或属性时,利用新功能可以更轻松地使用字段初始值设定项。

创建可变状态

前面的示例使用主构造函数参数来初始化只读属性。 如果属性不是只读的,你还可以使用主构造函数。 考虑下列代码:

public struct Distance(double dx, double dy)
{
    public readonly double Magnitude => Math.Sqrt(dx * dx + dy * dy);
    public readonly double Direction => Math.Atan2(dy, dx);

    public void Translate(double deltaX, double deltaY)
    {
        dx += deltaX;
        dy += deltaY;
    }

    public Distance() : this(0,0) { }
}

在前面的示例中,Translate 方法了更改 dxdy 组件。 这就需要在访问时计算 MagnitudeDirection 属性。 => 运算符指定一个以表达式为主体的 get 访问器,而 = 运算符指定一个初始值设定项。 此版本将无参数构造函数添加到结构。 无参数构造函数必须调用主构造函数,以便初始化所有主构造函数参数。

在前面的示例中,主构造函数属性是在方法中访问的。 因此,编译器会创建隐藏的字段来表示每个参数。 下面的代码大致展示了编译器生成的内容。 实际字段名称是有效的 CIL 标识符,但不是有效的 C# 标识符。

public struct Distance
{
    private double __unspeakable_dx;
    private double __unspeakable_dy;

    public readonly double Magnitude => Math.Sqrt(__unspeakable_dx * __unspeakable_dx + __unspeakable_dy * __unspeakable_dy);
    public readonly double Direction => Math.Atan2(__unspeakable_dy, __unspeakable_dx);

    public void Translate(double deltaX, double deltaY)
    {
        __unspeakable_dx += deltaX;
        __unspeakable_dy += deltaY;
    }

    public Distance(double dx, double dy)
    {
        __unspeakable_dx = dx;
        __unspeakable_dy = dy;
    }
    public Distance() : this(0, 0) { }
}

请务必了解,第一个示例不需要编译器创建字段来存储主构造函数参数的值。 第二个示例在方法内使用了主构造函数参数,因此需要编译器为它们创建存储。 只有在类型成员的主体中访问该参数时,编译器才会为任何主构造函数创建存储。 否则,主构造函数参数不会存储在对象中。

依赖关系注入

主构造函数的另一个常见用途是指定依赖项注入的参数。 下面的代码创建了一个简单的控制器,使用时需要有一个服务接口:

public interface IService
{
    Distance GetDistance();
}

public class ExampleController(IService service) : ControllerBase
{
    [HttpGet]
    public ActionResult<Distance> Get()
    {
        return service.GetDistance();
    }
}

主构造函数清楚地指明了类中所需的参数。 使用主构造函数参数就像使用类中的任何其他变量一样。

初始化基类

可以从派生类的主构造函数调用基类的主构造函数。 这是编写必须调用基类中主构造函数的派生类的最简单方法。 例如,假设有一个类的层次结构,将不同的帐户类型表示为一个银行。 基类类似于以下代码:

public class BankAccount(string accountID, string owner)
{
    public string AccountID { get; } = accountID;
    public string Owner { get; } = owner;

    public override string ToString() => $"Account ID: {AccountID}, Owner: {Owner}";
}

所有银行帐户(无论是什么类型)都具有帐号和所有者的属性。 在完成的应用程序中,其他常见功能将添加到基类中。

许多类型都需要对构造函数参数进行更具体的验证。 例如,BankAccountowneraccountID 参数有特定的要求:owner 不得为 null 或空格,并且 accountID 必须是包含 10 位数字的字符串。 在分配相应的属性时,可以添加此验证:

public class BankAccount(string accountID, string owner)
{
    public string AccountID { get; } = ValidAccountNumber(accountID) 
        ? accountID 
        : throw new ArgumentException("Invalid account number", nameof(accountID));

    public string Owner { get; } = string.IsNullOrWhiteSpace(owner) 
        ? throw new ArgumentException("Owner name cannot be empty", nameof(owner)) 
        : owner;

    public override string ToString() => $"Account ID: {AccountID}, Owner: {Owner}";

    public static bool ValidAccountNumber(string accountID) => 
    accountID?.Length == 10 && accountID.All(c => char.IsDigit(c));
}

前面的示例展示了如何在将构造函数参数分配给属性之前对其进行验证。 可以使用内置的方法(如 String.IsNullOrWhiteSpace(String)),也可以使用你自己的验证方法(如 ValidAccountNumber)。 在前面的示例中,当构造函数调用初始值设定项时,将引发任何异常。 如果未使用构造函数参数来分配字段,在首次访问构造函数参数时将引发任何异常。

一个派生类将呈现一个支票帐户:

public class CheckingAccount(string accountID, string owner, decimal overdraftLimit = 0) : BankAccount(accountID, owner)
{
    public decimal CurrentBalance { get; private set; } = 0;

    public void Deposit(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "Deposit amount must be positive");
        }
        CurrentBalance += amount;
    }

    public void Withdrawal(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "Withdrawal amount must be positive");
        }
        if (CurrentBalance - amount < -overdraftLimit)
        {
            throw new InvalidOperationException("Insufficient funds for withdrawal");
        }
        CurrentBalance -= amount;
    }
    
    public override string ToString() => $"Account ID: {AccountID}, Owner: {Owner}, Balance: {CurrentBalance}";
}

派生的 CheckingAccount 类具有一个主构造函数,采用基类中所需的所有参数,以及另一个具有默认值的参数。 主构造函数使用 : BankAccount(accountID, owner) 语法调用基构造函数。 此表达式同时指定基类的类型和主构造函数的参数。

派生类不需要使用主构造函数。 可以在派生类中创建一个构造函数,用于调用基类的主构造函数,如以下示例所示:

public class LineOfCreditAccount : BankAccount
{
    private readonly decimal _creditLimit;
    public LineOfCreditAccount(string accountID, string owner, decimal creditLimit) : base(accountID, owner)
    {
        _creditLimit = creditLimit;
    }
    public decimal CurrentBalance { get; private set; } = 0;

    public void Deposit(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "Deposit amount must be positive");
        }
        CurrentBalance += amount;
    }

    public void Withdrawal(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "Withdrawal amount must be positive");
        }
        if (CurrentBalance - amount < -_creditLimit)
        {
            throw new InvalidOperationException("Insufficient funds for withdrawal");
        }
        CurrentBalance -= amount;
    }

    public override string ToString() => $"{base.ToString()}, Balance: {CurrentBalance}";
}

类层次结构和主构造函数有一个潜在的问题:在派生类和基类中使用主构造函数参数时,可以创建主构造函数参数的多个副本。 下面的代码示例为每个 owneraccountID 字段创建两个副本:

public class SavingsAccount(string accountID, string owner, decimal interestRate) : BankAccount(accountID, owner)
{
    public SavingsAccount() : this("default", "default", 0.01m) { }
    public decimal CurrentBalance { get; private set; } = 0;

    public void Deposit(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "Deposit amount must be positive");
        }
        CurrentBalance += amount;
    }

    public void Withdrawal(decimal amount)
    {
        if (amount < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(amount), "Withdrawal amount must be positive");
        }
        if (CurrentBalance - amount < 0)
        {
            throw new InvalidOperationException("Insufficient funds for withdrawal");
        }
        CurrentBalance -= amount;
    }

    public void ApplyInterest()
    {
        CurrentBalance *= 1 + interestRate;
    }

    public override string ToString() => $"Account ID: {accountID}, Owner: {owner}, Balance: {CurrentBalance}";
}

突出显示的行显示 ToString 方法使用主构造函数参数(owneraccountID),而不是基类属性(OwnerAccountID)。 结果是派生类 SavingsAccount 为这些副本创建存储。 派生类中的副本与基类中的属性不同。 如果可以修改基类属性,派生类的实例将看不到该修改。 编译器对派生类中使用的、传递给基类构造函数的主构造函数参数发出警告。 在此实例中,解决方法是使用基类的属性。

总结

可以使用最适合设计的主构造函数。 对于类和结构,主构造函数参数是必须调用的构造函数的参数。 可以使用它们来初始化属性。 可以初始化字段。 这些属性或字段可以是不可变的,也可以是可变的。 可以在方法中使用它们。 它们都是参数,可以用最适合设计的方式来使用它们。 有关主构造函数的详细信息,请参阅有关实例构造函数的 C# 编程指南文章建议的主构造函数规范