Saltar al contenido

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript... y lo que venga ;)

17 años online

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web
ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript...

¡Microsoft MVP!
martes, 6 de febrero de 2024
C#

Hace unos días, veíamos por aquí los constructores primarios de C#, una característica recientemente introducida en el lenguaje que permite omitir el constructor si en éste lo único que hacemos es asignar los valores de sus parámetros a campos de la clase.

Veamos un ejemplo a modo de recordatorio rápido:

// Clase con constructor tradicional
public class Person
{
    private readonly string _name;
    public Person(string name)
    {
        _name = name;
    }
    public override string ToString() => _name;
}

// La misma clase usando el constructor primario:
public class Person(string name)
{
    public override string ToString() => name;
}

Como comentamos en el post, los parámetros del constructor primarios eran internamente convertidos en campos privados de la clase, generados automáticamente e inaccesibles desde el código.

Sin embargo, los valores de los parámetros podían ser modificados desde los miembros de instancia, lo cual podría resultar bastante problemático, sobre todo porque es habitual que los parámetros suministrados a un constructor sean almacenados en campos de sólo lectura, como hemos visto en el ejemplo anterior de uso del constructor tradicional.

Por ejemplo, observemos el siguiente código, donde no podemos asegurar que la salida de ToString() sea el valor suministrado al constructor porque quizás se realizó una llamada previa a Set() y su valor fue modificado:

public class Person(string name)
{
    public override string ToString() => name;
    public void Set() 
    {
        name = "Otro nombre";
    }
}

Podemos comprender mejor por qué se comporta de esta forma si echamos un vistazo al código que genera el compilador durante la construcción del proyecto. En él podemos ver que internamente el método Set() está sobrescribiendo el valor directamente sobre el campo privado autogenerado y, por tanto, cualquier modificación al valor del parámetro afectará al resto de miembros que lo utilicen:

public class Person
{
    [CompilerGenerated]
    private string <name>P;

    public Person(string name)
    {
        <name>P = name;
        base..ctor();
    }

    public override string ToString()
    {
        return <name>P;
    }

    public void Set()
    {
        <name>P = "Otro nombre";
    }
}

Para los que solemos capturar en miembros readonly los parámetros proporcionados a un constructor, esta posibilidad es realmente peligrosa porque puede dar lugar a comportamientos inesperados y fallos difíciles de cazar. Por ello, me ha alegrado descubrir que, aunque obligue a teclear algo más, hay formas sencillas de evitar las modificaciones de los parámetros del constructor primario.

Cómo hacer que un parámetro del constructor primario sea de sólo lectura

Una primera idea consiste en añadir a la clase un campo readonly con el mismo nombre que el parámetro del constructor primario, y asignarle el valor del parámetro recibido. Este escenario será detectado por el compilador, que ya no tendrá necesidad de generar un nuevo campo privado para almacenar el valor del parámetro porque usará el que estamos proporcionando.

Creo que se entiende mejor si lo vemos en código:

public class Person(string name)
{
    private readonly string name = name;
    public override string ToString() => name;
}

Al compilarlo, el código generado será equivalente al siguiente. Observad que ya no existe el campo autogenerado <name>P porque se utiliza directamente el campo name que hemos añadido:

public class Person
{
    private readonly string name;

    public Person(string name)
    {
        this.name = name;
        base..ctor();
    }

    public override string ToString()
    {
        return name;
    }
}

Y obviamente, al tratarse de un campo de sólo lectura, la inserción de un método como el siguiente generará un error de compilación:

public class Person(string name)
{
    private readonly string name = name;
    public override string ToString() => name;
    public void Set()
    {
        name = "Otro nombre"; // Error CS0191: No se puede asignar 
                              // un valor a un campo readonly
    }
}

Una variante de la solución anterior, que además nos viene mejor si seguimos alguna convención específica para el nombrado de campos privados (por ejemplo, usar el guion bajo como prefijo), se basa en insertar el modificador in en el parámetro del constructor primario. Cuando se hace esto, dicho parámetro deja de estar accesible desde los miembros de la clase y, por tanto, sólo podemos usarlo durante la construcción de la instancia. Es decir, el siguiente código fallaría en compilación:

public class Person(in string name)
{
    public override string ToString() => name; // error CS9109: Cannot use ref, out, or in 
                                               // primary constructor parameter 'name' 
                                               // inside an instance member
}

De esta forma aseguramos que el valor del parámetro del constructor primario no será modificado (vaya, ni siquiera leído) desde el código de la clase. Y partiendo de esto, podríamos ahora añadir un campo privado de sólo lectura, con el nombre que nos interese, para poner el valor a disposición del miembro que lo necesite:

public class Person(in string name)
{
    private readonly string _name = name;
    public override string ToString() => _name;
}

Como curiosidad, el código generado por el compilador es el equivalente al siguiente, bastante similar al que vimos al principio del post, en el ejemplo donde usábamos constructores tradicionales:

public class Person
{
    private readonly string _name;

    public Person([In][IsReadOnly] ref string name)
    {
        _name = name;
        base..ctor();
    }

    public override string ToString()
    {
        return _name;
    }
}

¡Espero que os sea de ayuda!

Publicado en Variable not found.

2 Comentarios:

Juan dijo...


No queda tan limpio, pero por lo menos te ahorras el constructor.

Un gran aporte, como siempre.

José María Aguilar dijo...

Hola!

Pues sí, con el poco trabajo que les habría costado permitir definir el parámetro como "readonly" y ya está... pero bueno, supongo que grandes motivos habrá para que no haya sido así.

Muchas gracias!