DEV Community

Brandon Ventura
Brandon Ventura

Posted on

Constructor Primario en las Clases C#

Constructores primarios y refactorización con C# 8

¿Qué es un constructor?

Si bien, esto es un término que quizá la mayoría ya conozcamos, es importante definirlo para aquellos que vienen de un lenguaje de programación funcional o que no han tenido mucha cercanía con términos relacionados con la Programación Orientada a Objetos (POO), en este caso, relacionados con las clases y los objetos.

Para empezar una clase es como la plantilla o plano que nos permite definir que es lo que compone un objeto (su estructura) y aquello que este puede hacer, por ejemplo, un gato tiene un nombre, género, edad, color y puede respirar, comer, correr y dormir. Ahora bien, esto como vemos solo es describir de forma simplificada lo que todos los gatos pueden tener, pero si yo tengo un gato en específico, por ejemplo, el gato Tom, este gato en específico es un objeto, una instancia de la clase Gato, ya que tiene sus propias características o atributos que lo diferencian de otros gatos, como el nombre, género, edad, y color, a esto se le conoce como campos de la clase. Por otra parte, todos los gatos hacen de manera generalizada lo mismo, respiran, comen, corren y duermen, esto se convierte en su comportamiento, lo que en términos de clases y objetos serían los métodos de la clase. Ambas cosas, los campos y los métodos son tratados como los miembros de la clase y definen lo que se conoce como estado (información específica de cada objeto almacenado en los campos) y comportamiento (métodos que utilizan el estado para “hacer algo”).

¿No estábamos hablando de constructores? Sí, sin embargo, los constructores toman sentido cuando conocemos lo que compone una clase, esto es porque los constructores son una especie de métodos especiales que se utilizan para “construir” nuestros objetos de tal forma que permiten definir todo aquello que nuestro objeto necesita al momento de ser creado, es decir, al momento de crear una instancia de la clase.

En C# podemos tener diversos tipos de constructores como:

  • Constructores por defecto y sin parámetros: Es un constructor que no toma ningún parámetro y en el cual sus miembros tendrán el valor por defecto al crear el objeto. En C# todas las clases tienen un constructor por defecto que no recibe parámetros si no creamos ningún otro constructor.
  • Constructores con parámetros: Son constructores que reciben información a través de este para ser asignada a los campos de la clase. Son ideales cuando queremos restringir que los valores que componen el objeto deban ser adicionados al momento de crearlos.
  • Constructores privados: Constructores que simplemente están restringidos utilizando el modificador private, es decir, impiden que los objetos sean creados desde fuera de la clase. Esto es útil por ejemplo, para controlar la forma en que nuestros objetos se crean desde dentro de la propia clase, creando ciertas restricciones al momento de crearlos.
  • Constructores estáticos: Son constructores especiales que permiten asignar o inicializar datos estáticos en nuestra clase. Al ser estáticos significa que no podemos llamarlos al momento de crear nuestra instancia, sino que, el entorno de ejecución se encarga de hacerlo previamente, asignando así la información antes a la creación de nuestro objeto.
  • Constructores de copia: Es un tipo de constructor que en lugar de recibir los valores que vamos a asignar, recibe una instancia/objeto de la clase como argumento, permitiendo así tomar un objeto existente para extraer su información y crear un nuevo objeto a partir de este, es decir, una copia o clon. En resumen, pretenderíamos hacer esto para así tener dos objetos “iguales” (no necesariamente) para modificar uno sin perder la información que este tenía (esta se encuentra en la copia) y utilizar ambos.
  • Constructor primario: Lo veremos a detalle a continuación.

Cada uno de estos constructores puede tener unos cuantos detalles adicionales que vale la pena revisar, sin embargo, no vamos a definir cada uno de estos, puesto que, el objetivo de la publicación es conocer los constructores primarios, aunque, la lista puede permitirte conocer que cuando hablamos de constructores pueden existir en varios colores y sabores (al menos en C#).

public class Gato
{
    public string Nombre { get; set; }
    public string Genero { get; set; }
    public int Edad { get; set; }
    public string Color { get; set; }
    private static int _cantidadDePatas;

    // Constructor sin parametos.
    // Sí no vamos a definir otro constructor o no lo necesitamos
    // podemos borrarlo.
    public Gato()
    {

    }

    // Constructor con parametros
    public Gato(string nombre, string genero, int edad, string color)
    {
        Nombre = nombre;
        Genero = genero;
        Edad = edad;
        Color = color;
    }

    // Constructor privado
    private Gato(string nombre)
    {
        Nombre = nombre;
    }

    // Constructor estatico
    static Gato()
    {
        _cantidadDePatas = 4;
    }

    // Constructor de copia
    public Gato(Gato gato)
    {
        Nombre = gato.Nombre;
        Genero = gato.Genero;
        Edad = gato.Edad;
        Color = gato.Color;
    }

    public string ObtenerInformacionGato()
    {
        return $"El gato se llama {Nombre} tiene {Edad} años y es color {Color} y tiene {_cantidadDePatas} patas.";
    }
}
Enter fullscreen mode Exit fullscreen mode

Los constructores por diseño siempre devuelven una nueva instancia de la clase, esto es importante tenerlo en cuenta, porque es su objetivo y no debemos limitar su comportamiento, si lo hacemos, estaríamos yendo en contra de lo que todos los programadores entienden por constructor de una clase introduciendo inconsistencias en nuestro código, esto es lo que permite, por ejemplo, que los constructores de copia funcionen, ya que aunque nosotros recibimos un objeto en el constructor, este siempre si o si devolverá un nuevo objeto, sea si utilizamos lo que recibimos en el constructor o no, al contrario, en un método, podríamos devolver el mismo objeto que recibimos y no nos daríamos cuenta de que se trata del mismo objeto (hasta que ocurran los errores, claro).

// Siempre sera un nuevo gato aunque sea una copia.
public Gato(Gato gato)
{
    Nombre = gato.Nombre;
    Genero = gato.Genero;
    Edad = gato.Edad;
    Color = gato.Color;
}

// No hay seguridad que obtenemos una copia real del gato.
public Gato CopiarGato()
{
    return this;
}
Enter fullscreen mode Exit fullscreen mode

Constructores primarios (primary constructors)

Los constructores primarios son un tipo especial de constructor que se coloca junto a la definición del tipo, es decir, del nombre de la clase. Este tipo especial de constructor fue agregado junto con C# 9 y .NET 5 en el 2020 y se ven de la siguiente forma:

public record PersonaRecord(string Nombre, int Edad);
Enter fullscreen mode Exit fullscreen mode

Aunque con los records no es muy común lo siguiente la sintaxis convencional sería:

public record PersonaRecord2
{
    public string Nombre { get; init; }
    public int Edad { get; init; }

    public PersonaRecord2(string nombre, int edad)
    {
        Nombre = nombre;
        Edad = edad;
    }
}
Enter fullscreen mode Exit fullscreen mode

Si bien los records son otro tipo de dato distinto a las clases (aunque muy similares) y no vamos a entrar en detalle respecto a ellos en esta publicación, con estos llegaron los constructores primarios, y en C# 12 y .NET 8 ahora pueden ser utilizados con las clases y estructuras (struct).

Estos constructores no son más que una forma/sintaxis especial disponible en C# para definir nuestro constructor en la definición del tipo o declaración/nombre de la clase. Su sintaxis es prácticamente la misma que con los records, solo cambiando record por class:

public class Persona(string Nombre, int Edad);
Enter fullscreen mode Exit fullscreen mode

El equivalente a esto en su forma convencional sería lo siguiente:

public class Persona2
{
    public string Nombre { get; set; }
    public int Edad { get; set; }

    public Persona2(string nombre, int edad)
    {
        Nombre = nombre;
        Edad = edad;
    }
}
Enter fullscreen mode Exit fullscreen mode

Como puedes ver, la sintaxis de constructor primario es mucho más reducida y cómoda en cuanto a experiencia de desarrollo respecta.

¿Ambas formas son equivalente?

Si y no, en cuanto a records respecta, si lo son, debido a que el compilador se hará cargo de tomar los parámetros del constructor y generar los miembros y propiedades públicas correspondientes para los parámetros del constructor primario. Estas propiedades serán un espejo de los parámetros del constructor (nombre y tipo).

Con las clases funciona igual, ¿no?, pues no, el compilador no hará lo mismo con cualquier otra cosa que utilice un constructor primario y no sea un record, es decir, las clases y los structs no tendrán este comportamiento. En resumen:

var persona = new Persona("John Doe", 30);
// Error en tiempo de compilación, los miembros no existen en Persona.
persona.nombre;
persona.edad;

var personaRecord = new PersonaRecord("John Doe", 30);
// Los miembros son accesibles en el tipo PersonaRecord
personaRecord.Nombre;
personaRecord.Edad;
Enter fullscreen mode Exit fullscreen mode

Entonces, ¿Cómo puedo acceder al nombre y edad de mi objeto creado a partir de la clase Persona? Pues, prácticamente agregando las propiedades a mano, de la siguiente forma:

public class Persona(string nombre, int edad)
{
    public string Nombre { get => nombre; set => nombre = value; }
    public int Edad { get => edad; set => edad = value; }
}
// o
public class Persona(string nombre, int edad)
{
    public string Nombre { get; set; } = nombre;
        public int Edad { get; set; } = edad;
}
Enter fullscreen mode Exit fullscreen mode

Así ya tendríamos acceso nuevamente a los miembros de nuestra clase. Quizá esto te recuerda a algo, y sí, así es como se definían las propiedades antes, teniendo un campo privado el cual manipulábamos o encapsulábamos utilizando un método getter y otro setter, o como en este caso utilizando las properties de C#.

Esta no equivalencia de comportamiento entre records y clases o structs se debe a que los primary constructors en las clases/structs define sus parámetros como “privados”, lo que significa que no son accesibles desde ninguna parte fuera de la propia definición de la clase, por eso debemos crear los getters y setters correspondientes para que estos sean el acceso público al estado de nuestra clase.
Además de esto, los records en términos generales son inmutables por diseño, reforzando esto a través del propio constructor primario que define los miembros automáticamente para evitar suprimir este comportamiento, aunque siempre es posible hacerlo si definimos el record de manera convencional.

Consideraciones importantes

Hasta ahora se ha visto esta nueva sintaxis como una alternativa a la sintaxis convencional donde no parece aportar mucho, y en una comparativa muy superficial con los records parece que estos últimos son mejores, y aunque se han venido describiendo a la par hay muchas diferencias que los separan y no serán mencionadas aquí, solo se han mencionado porque los records son con los que se dio origen a esta nueva forma de definir nuestros constructores y aunque esta característica es similar para ambos, el propósito de las clases difiere del de los records, por lo tanto, hay ciertas implicaciones con el uso de constructores primarios en las clases.

Sintaxis

Aunque ya la vimos anteriormente, es bueno destacarla nuevamente y aislada de la comparativa con los records.

public class Persona(string nombre, int edad)
{
    public string Nombre { get => nombre; set => nombre = value; }
    public int Edad { get => edad; set => edad = value; }
}
Enter fullscreen mode Exit fullscreen mode

Alcance, acceso y nombramiento

Lo que vemos en la definición del constructor primario no es más que los parámetros del constructor, es decir, solo es una sintaxis adicional para definir un constructor, sin embargo, puede que te preguntes ¿Cuál es el alcance de estos parámetros? ¿Cuál es su nivel de acceso?, y ¿Cuál es la convención o nomenclatura para nombrarlos?

Bueno, en realidad son solo parámetros, piensa en ellos como los parámetros del constructor convencional o los de una función o método cualquiera, la única diferencia es que los parámetros del constructor primario tienen un alcance global, es decir, pueden ser accedidos y modificados como cualquier otro parámetro, pero en este caso desde cualquier parte de la clase, estos no están limitados solo al bloque (de forma local) como en los constructores convencionales o las funciones.

Por lo tanto, podrías hacer algo como:

public class Persona(string nombre, int edad)
{
    public string Nombre { get => nombre; set => nombre = value; }
    public int Edad { get => edad; set => edad = value; }

    public void Formatear()
    {
        nombre = nombre.ToUpper(); // Modificamos el parametro no la propiedad
    }
}
Enter fullscreen mode Exit fullscreen mode

Como vemos, estamos modificando el parámetro nombre asignándole un nuevo valor al ejecutar el método Formatear(), y dirás, bueno, no pasa nada, ¿no?, total si ya hemos asignado el parámetro a la propiedad Nombre, veamos lo que sucede con el siguiente código:

var nombrePersona = "John Doe";
var persona = new Persona(nombrePersona, 40);
Console.WriteLine(persona.Nombre);
Console.WriteLine($"Los nombres son iguales: {nombrePersona.Equals(persona.Nombre)}");

persona.Formatear();

Console.WriteLine(persona.Nombre);
Console.WriteLine($"Los nombres son iguales: {nombrePersona.Equals(persona.Nombre)}");
Enter fullscreen mode Exit fullscreen mode

Aquí tenemos un nombre asignado a una variable y luego utilizamos esta variable para crear nuestro objeto de Persona, imprimimos el nombre y comprobamos si los nombres son iguales.

Luego ejecutamos el método Formatear() y hacemos lo mismo de nuevo, lo que veremos por consola es lo siguiente:

John Doe
Los nombres son iguales: True
JOHN DOE
Los nombres son iguales: False
Enter fullscreen mode Exit fullscreen mode

Como ves, el valor ha cambiado, no hemos modificado la propiedad Nombre, sino el parámetro nombre, pero como la propiedad accede o devuelve directamente el parámetro, cualquier alteración del parámetro nombre se vera reflejada dentro de cualquier parte de la clase que acceda a él, porque este es mutable, es decir, puede cambiarse.

Respondiendo a las preguntas:

  • ¿Cuál es el alcance de estos parámetros? El alcance es global dentro del tipo o clase, es decir, puede ser accedido desde cualquier parte de la propia clase, como cualquier parámetro lo haría dentro del constructor o función.
  • ¿Cuál es su nivel de acceso? No es public/protected/private/internal, es solo un parámetro, su nivel de acceso está limitado a la clase, como cualquier parámetro de función que estaría limitado solo a esa función. Por este comportamiento podríamos decir que es privado, puedes verlo de esta forma si te resulta más fácil.
  • ¿Cuál es la convención o nomenclatura para nombrarlos? Al ser parámetros, se nombran como tal, usando la nomenclatura camelCase, es decir, el nombre inicia en minúscula y cada cambio de palabra con letra mayúscula, nada de PascalCase ni con guion bajo como los campos privados (~~_nombre~~).

Inyección de dependencias

Por lo anteriormente dicho y comprobado, tenemos que tener mucho cuidado con el uso que les daremos a estos parámetros, si por ejemplo vamos a utilizarlos para la inyección de dependencias, porque hasta este momento no existe ningún tipo de restricción que pueda ser aplicada sobre estos para evitar que sean modificados dentro de la clase, por lo que, cualquier sentencia dentro de nuestra clase puede alterar su valor o contenido.


public class ServicioFalso 
{
    public string RetornaTextoMayuscula(string texto)
    {
        return texto.ToUpper();
    }
}

public class OtroServicio(ServicioFalso servicioFalso)
{

    public string ObtenerMensaje()
    {
        return servicioFalso.RetornaTextoMayuscula("Hola Mundo");
    }

    public void HacerAlgo()
    {
        servicioFalso = null;
    }
}
Enter fullscreen mode Exit fullscreen mode

El código anterior expresa de manera muy simplificada una posible inyección de dependencias, como vez, el método ObtenerMensaje() llama al método RetornaTextoMayuscula() de la clase inyectada o pasada como parámetro ServicioFalso y convierte el texto a mayúscula, haciendo lo siguiente:

var servicioFalso = new ServicioFalso();
var otroServicio = new OtroServicio(servicioFalso);

var mensaje = otroServicio.ObtenerMensaje();
Console.WriteLine(mensaje);
Enter fullscreen mode Exit fullscreen mode

Veríamos por consola el texto en mayúscula “HOLA MUNDO”, pero si en cambio, hacemos lo siguiente:

otroServicio.HacerAlgo();
mensaje = otroServicio.ObtenerMensaje();
Console.WriteLine(mensaje);
Enter fullscreen mode Exit fullscreen mode

El programa nos lanzaría una excepción NullReferenceException, esto es debido a que cuando ejecutamos el método HacerAlgo(), este asigna el valor del parámetro servicioFalso a null, por lo que al llamar nuevamente al método ObtenerMensaje() que utiliza este parámetro, su valor será null y se lanzará la excepción.

Dicho esto, no puedes confiar en el valor que fue pasado como argumento a estos parámetros, a menos que, hayas inicializado campos o propiedades de solo lectura.

Lo ideal sería utilizar la forma convencional:


public class OtroServicio2
{
    private readonly ServicioFalso _servicioFalso;

    public OtroServicio2(ServicioFalso servicioFalso)
    {
        _servicioFalso = servicioFalso;
    }

    public string ObtenerMensaje()
    {
        return _servicioFalso.RetornaTextoMayuscula("Hola Mundo");
    }

    public void HacerAlgo()
    {
        // Error de compilación, no se puede asignar un valor a un campo de solo lectura, solo durante la inicialización
        _servicioFalso = null; 
    }
}
Enter fullscreen mode Exit fullscreen mode

Pero, ¿Si la forma convencional es la correcta, para qué utilizaría un constructor primario para la inyección de dependencias? Y es que es posible encontrar un punto intermedio, donde se utilicen los constructores primarios y apliquen restricciones sobre estos valores de la siguiente forma:

public class OtroServicio3(ServicioFalso servicioFalso)
{
    private readonly ServicioFalso _servicioFalso = servicioFalso;

    public string ObtenerMensaje()
    {
        return _servicioFalso.RetornaTextoMayuscula("Hola Mundo");
    }

    public void HacerAlgo()
    {
        // Error de compilación, no se puede asignar un valor a un campo de solo lectura, solo durante la inicialización
        _servicioFalso = null;
        // Es posible al parametro pero no al campo de solo lectura
        servicioFalso = null;
    }
}

Enter fullscreen mode Exit fullscreen mode

En el código anterior utilizamos los parámetros del constructor primario para asignarlos a un campo de solo lectura, de la misma forma en que lo hacemos con un constructor convencional, y dentro de la clase utilizamos este campo de solo lectura para las operaciones que necesitemos y nos olvidamos de lo que sea que haya dentro del parámetro del constructor, de esta forma aseguramos la seguridad del código y además evitamos el código repetitivo del constructor.

Esta forma sería la ideal de trabajar con los constructores primarios y la inyección de dependencias mientras no exista ningún tipo de atributo o característica dentro de C# que nos permita indicar un comportamiento distinto dentro del constructor primario.

Para la mayoría de los casos es preferible el uso del constructor convencional en las clases y structs antes que el constructor primario. En el caso de los records, el constructor primario siempre es preferido a menos que vayamos a hacer algo especial con el record.

DTOs

Si bien un buen uso para los constructores primarios en las clases puede ser en la definición de DTOs, puede ser buena idea utilizarlos si lo único necesario es mover datos a lo largo de la aplicación que no tengan ningún tipo de restricción y además, por alguna razón quieras mantener esta información mutable, que pueda cambiar en algún momento:


public class PersonaDto(string nombre, int edad)
{
    public string Nombre { get; set; } = nombre;
    public int Edad { get; set; } = edad
}
Enter fullscreen mode Exit fullscreen mode

Aunque, siendo este el caso, en lo personal prefiero el uso de records, ya que estos tienen una sintaxis mucho más simplificada, y además refuerzan el diseño inmutable, haciendo que nuestros datos sean consistentes a lo largo del uso que les demos, además que nos brindan grandes características como la igualdad en los objetos, una mejor lectura en cuanto a logs respecta, entre más:

public record PersonaRecordDto(string Nombre, int Edad);
Enter fullscreen mode Exit fullscreen mode

Conclusión

Con la llegada de los records a partir de C# 9 es indiscutible que estos introdujeron características y comportamientos interesantes al lenguaje, siendo la nueva sintaxis de los constructores primarios algo que llamo mucho la atención, hasta ser incorporados en C# 12 a las clases y los structs, convirtiéndose en una forma mucho más simplificada de crear los constructores y reduciendo el código repetitivo de nuestras clases en cuanto a constructores respecta, sin embargo, el mayor problema de los constructores primarios en las clases y structs es que se comportan distinto a como lo hacen en los records lo que puede ocasionar confusiones y problemas al momento de utilizarlos al no conocer como funcionan en realidad, y aunque esto puede ser resuelto de múltiples maneras como hemos visto a lo largo de esta publicación siempre es importante estar al tanto de estos detalles y muchos más que puede surgir con las nuevas características del lenguaje y mantener informados a los miembros del equipo en cuanto al uso de estas respecta.

Algunas veces más es menos y menos es más. Si es más cómodo seguir trabajando de la forma convencional, hazlo, si decides utilizar los constructores primarios ten al tanto a tus compañeros de equipo sobre esa decisión y comparte tu conocimiento con ellos para que su experiencia de desarrollo no se vea frustrada por algún problema de como estos funcionan.

Como recomendación: Utiliza los valores pasados como argumentos a los parámetros de tu constructor primario solo para la inicialización de campos y propiedades, evita utilizarlos, acceder a ellos o modificarlos en cualquier otro lugar, a menos que sea algo trivial y sin restricciones. Piensa en ellos como los parámetros de un constructor convencional, normalmente solo se usan para asignar campos y propiedades, validaciones u operaciones muy sencillas, nada más.

Además, estos son nombrados como parámetros convencionales y no pueden ser distinguidos de los parámetros utilizados dentro de las funciones/métodos de la clase lo que puede ocasionar problemas, contrario a los campos privados de la clase que por convención (regla no escrita en piedra) se les coloca el guion bajo “_” para diferenciarlos y saber donde encontrarlos. Puede parecer una tontería, pero al momento de hacer code reviews o ver el código de un proyecto que estás trabajando ayuda a aclarar dudas y facilitar la búsqueda de estos. Por cierto, si piensas que puedes nombrar los parámetros del constructor primario con guion bajo (_servicio) en realidad puedes hacerlo, pero recuerda, son parámetros no campos de la clase, en resumen, si haces algo como this._servicio para acceder a ellos no funcionara, es un parámetro no un campo o miembro de la clase.

Está claro que hay beneficios de utilizar los constructores primarios, pero no los sobreutilices o los veas como una regla escrita en piedra. Hasta que no podamos agregar restricciones o anotaciones especiales a estos parámetros, por ejemplo para limitar su mutabilidad (no podemos marcarlos como readonly, init, etc), pueden representar más un problema que una solución si no los utilizas correctamente y con cuidado.

Código fuente

Recursos adicionales

https://youtu.be/y3--fmLLmQA?si=AySAchjj3FdkjPA6

https://code-maze.com/csharp-constructors/

Top comments (0)