声明类和结构的主要构造函数

C# 12 引入了 主构造函数,它提供简洁的语法来声明其参数在类型正文中的任意位置可用的构造函数。

本文介绍如何在类型上声明主构造函数并识别存储主构造函数参数的位置。 可以从其他构造函数调用主构造函数,并在类型的成员中使用主构造函数参数。

先决条件

了解主要构造函数的规则

可以将参数添加到 structclass 声明以创建 主构造函数。 主要构造函数参数在整个类定义范围内。 请务必将主要构造函数参数视为 参数 ,即使它们在整个类定义范围内也是如此。

多个规则阐明了这些构造函数是参数:

  • 如果不需要主构造函数参数,则可能无法存储这些参数。
  • 主构造函数参数不是类的成员。 例如,无法访问名为 <a0/> 的主构造函数参数。
  • 主要构造函数参数可以分配给该参数。
  • 主构造函数参数不会成为属性, 记录类型除外

这些规则是已为任何方法(包括其他构造函数声明)的参数定义的相同规则。

下面是主要构造函数参数的最常见用途:

  • 作为参数传递给 base() 构造函数调用
  • 初始化成员字段或属性
  • 引用实例成员中的构造函数参数

类的所有其他构造函数 都必须 通过 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);
}

此示例使用主构造函数初始化计算的只读属性。 属性的字段初始值设定项MagnitudeDirection使用主构造函数参数。 主要构造函数参数不会在结构中的其他任何位置使用。 代码将创建一个结构,就像以以下方式编写结构一样:

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 组件,这要求 Magnitude 在访问时计算属性 Direction 。 lambda 运算符 (=>) 指定表达式-bodied 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}";
}

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

许多类型需要对构造函数参数进行更具体的验证。 例如,该 BankAccount 类具有特定 owner 要求和 accountID 参数。 参数 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}";
}

类层次结构和主要构造函数有一个潜在问题。 可以创建主构造函数参数的多个副本,因为参数用于派生类和基类。 以下代码创建每个 owner 副本和 accountID 参数的两个副本:

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为参数副本创建存储。 派生类中的副本不同于基类中的属性。 如果可以修改基类属性,则派生类的实例看不到修改。 编译器针对派生类中使用的主构造函数参数发出警告,并将其传递给基类构造函数。 在此实例中,修复是使用基类的属性。