Share via


Tutorial: Exploración de constructores principales

C# 12 presenta constructores principales, una sintaxis concisa para declarar constructores cuyos parámetros están disponibles en cualquier parte del cuerpo del tipo.

En este tutorial, aprenderá a:

  • Cuándo declarar un constructor principal en el tipo
  • Cómo llamar a constructores principales desde otros constructores
  • Uso de parámetros de constructor principal en miembros del tipo
  • Dónde se almacenan los parámetros del constructor principal

Requisitos previos

Tendrá que configurar la máquina para ejecutar .NET 8 o posterior, incluido el compilador de C# 12 o posterior. El compilador de C# 12 está disponible a partir de la versión 17.7 de Visual Studio 2022 o del SDK de .NET 8.

Constructores principales

Puede agregar parámetros a una declaración struct o class para crear un constructor principal. Los parámetros del constructor principal están en el ámbito en toda la definición de clase. Es importante ver los parámetros del constructor principal como parámetros aunque estén en el ámbito a lo largo de la definición de clase. Varias reglas aclaran que son parámetros:

  1. Es posible que los parámetros del constructor principal no se almacenen si no son necesarios.
  2. Los parámetros del constructor principal no son miembros de la clase. Por ejemplo, no se puede tener acceso a un parámetro de constructor principal denominado param como this.param.
  3. Se pueden asignar parámetros de constructor principal.
  4. Los parámetros del constructor principal no se convierten en propiedades, excepto en los tipos record.

Estas reglas son las mismas que los parámetros de cualquier método, incluidas otras declaraciones de constructor.

Los usos más habituales para un parámetro de constructor principal son:

  1. Como argumento para una invocación de constructor base().
  2. Para inicializar un campo o propiedad miembro.
  3. Hacer referencia al parámetro constructor en un miembro de instancia.

Todos los demás constructores de una clase deben llamar al constructor principal, directa o indirectamente, a través de una invocación de constructor this(). Esa regla garantiza que los parámetros del constructor principal se asignen en cualquier lugar del cuerpo del tipo.

Inicializar propiedad

El código siguiente inicializa dos propiedades de solo lectura que se calculan a partir de los parámetros del constructor principal:

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);
}

El código anterior muestra un constructor principal que se usa para inicializar las propiedades de solo lectura calculadas. Inicializadores de campo para Magnitude y Direction usan los parámetros del constructor principal. Los parámetros del constructor principal no se usan en ningún otro lugar de la estructura. La estructura anterior es como si hubiera escrito el código siguiente:

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);
    }
}

La nueva característica facilita el uso de inicializadores de campo cuando se necesitan argumentos para inicializar un campo o una propiedad.

Creación de un estado mutable

En los ejemplos anteriores se usan parámetros de constructor principal para inicializar propiedades de solo lectura. También puede usar constructores principales cuando las propiedades no son de solo lectura. Observe el código siguiente:

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) { }
}

En el ejemplo anterior, el método Translate cambia los componentes dx y dy. Esto requiere que las propiedades Magnitude y Direction se calculen al acceder a ellas. El operador => designa un descriptor de acceso get con forma de expresión, mientras que el operador = designa un inicializador. Esta versión agrega un constructor sin parámetros a la estructura. El constructor sin parámetros debe invocar al constructor principal, de modo que se inicialicen todos los parámetros del constructor principal.

En el ejemplo anterior, se obtiene acceso a las propiedades del constructor principal en un método. Por lo tanto, el compilador crea campos ocultos para representar cada parámetro. El código siguiente muestra aproximadamente lo que genera el compilador. Los nombres de campo reales son identificadores CIL válidos, pero no identificadores de C# válidos.

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) { }
}

Es importante comprender que el primer ejemplo no requería que el compilador creara un campo para almacenar el valor de los parámetros del constructor principal. En el segundo ejemplo se ha usado el parámetro de constructor principal dentro de un método y, por tanto, ha sido necesario que el compilador creara almacenamiento para ellos. El compilador crea almacenamiento para cualquier constructor principal solo cuando se accede a ese parámetro en el cuerpo de un miembro del tipo. De lo contrario, los parámetros del constructor principal no se almacenan en el objeto.

Inserción de dependencias

Otro uso habitual de los constructores principales es especificar parámetros para la inserción de dependencias. El código siguiente crea un controlador simple que requiere una interfaz de servicio para su uso:

public interface IService
{
    Distance GetDistance();
}

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

El constructor principal indica claramente los parámetros necesarios en la clase. Los parámetros del constructor principal se usan como haría con cualquier otra variable de la clase.

Inicializar clase base

Puede invocar el constructor principal de una clase base desde el constructor principal de la clase derivada. Es la manera más fácil de escribir una clase derivada que debe invocar un constructor principal en la clase base. Por ejemplo, considere una jerarquía de clases que representan diferentes tipos de cuenta como banco. La clase base tendría un aspecto similar al código siguiente:

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}";
}

Todas las cuentas bancarias, independientemente del tipo, tienen propiedades para el número de cuenta y un propietario. En la aplicación completada, se agregaría otra funcionalidad común a la clase base.

Muchos tipos requieren una validación más específica en los parámetros del constructor. Por ejemplo, BankAccount tiene requisitos específicos para los parámetros owner y accountID: owner no puede ser null ni espacio en blanco, y accountID debe ser una cadena que contenga 10 dígitos. Puede agregar esta validación al asignar las propiedades correspondientes:

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));
}

En el ejemplo anterior se muestra cómo se pueden validar los parámetros del constructor antes de asignarlos a las propiedades. Puede usar métodos integrados, como String.IsNullOrWhiteSpace(String), o su propio método de validación, como ValidAccountNumber. En el ejemplo anterior, las excepciones se inician desde el constructor cuando invoca los inicializadores. Si no se usa un parámetro de constructor para asignar un campo, se inician excepciones cuando se accede por primera vez al parámetro constructor.

Una clase derivada presentaría una cuenta corriente:

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}";
}

La clase derivada CheckingAccount tiene un constructor principal que toma todos los parámetros necesarios en la clase base y otro parámetro con un valor predeterminado. El constructor principal llama al constructor base mediante la sintaxis : BankAccount(accountID, owner). Esta expresión especifica tanto el tipo de la clase base como los argumentos del constructor principal.

La clase derivada no es necesaria para usar un constructor principal. Puede crear un constructor en la clase derivada que invoque el constructor principal de la clase base, como se muestra en el ejemplo siguiente:

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}";
}

Hay una posible preocupación con las jerarquías de clases y los constructores principales: es posible crear varias copias de un parámetro de constructor principal, ya que se usa en clases base y derivadas. En el ejemplo de código siguiente se crean dos copias de cada uno de los campos owner y 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}";
}

La línea resaltada muestra que el método ToString usa los parámetros de constructor principal (owner y accountID) en lugar de las propiedades de clase base (Owner y AccountID). El resultado es que la clase derivada SavingsAccount crea almacenamiento para esas copias. La copia de la clase derivada es distinta de la propiedad de la clase base. Si se puede modificar la propiedad de clase base, la instancia de la clase derivada no verá esa modificación. El compilador emite una advertencia para los parámetros del constructor principal que se usan en una clase derivada y se pasan a un constructor de clase base. En este caso, la corrección consiste en usar las propiedades de la clase base.

Resumen

Puede usar los constructores principales como mejor se adapte a su diseño. En el caso de las clases y estructuras, los parámetros de constructor principal son parámetros para un constructor que se debe invocar. Puede usarlos para inicializar propiedades. Puede inicializar campos. Esas propiedades o campos pueden ser inmutables o mutables. Puede usarlos en métodos. Son parámetros y los puede usar de la manera que mejor se adapte a su diseño. Puede obtener más información sobre los constructores principales en el artículo de la guía de programación de C# sobre constructores de instancia y en la especificación del constructor principal propuesta.