Propiedades

Las propiedades son ciudadanos de primera clase en C#. El lenguaje define la sintaxis que permite a los desarrolladores escribir código que exprese con precisión su intención de diseño.

Las propiedades se comportan como campos cuando se obtiene acceso a ellas. Pero, a diferencia de los campos, las propiedades se implementan con descriptores de acceso que definen las instrucciones que se ejecutan cuando se tiene acceso a una propiedad o se asigna.

Sintaxis de las propiedades

La sintaxis para propiedades es una extensión natural de los campos. Un campo define una ubicación de almacenamiento:

public class Person
{
    public string? FirstName;

    // Omitted for brevity.
}

Una definición de propiedad contiene las declaraciones para un descriptor de acceso get y set que recupera y asigna el valor de esa propiedad:

public class Person
{
    public string? FirstName { get; set; }

    // Omitted for brevity.
}

La sintaxis anterior es la sintaxis de propiedades automáticas. El compilador genera la ubicación de almacenamiento para el campo que respalda a la propiedad. El compilador también implementa el cuerpo de los descriptores de acceso get y set.

A veces, necesita inicializar una propiedad en un valor distinto del predeterminado para su tipo. C# permite esto estableciendo un valor después de la llave de cierre de la propiedad. Puede que prefiera que el valor inicial para la propiedad FirstName sea la cadena vacía en lugar de null. Debe especificarlo como se muestra a continuación:

public class Person
{
    public string FirstName { get; set; } = string.Empty;

    // Omitted for brevity.
}

La inicialización específica es más útil en las propiedades de solo lectura, como verá posteriormente en este artículo.

También puede definir su propio almacenamiento, como se muestra a continuación:

public class Person
{
    public string? FirstName
    {
        get { return _firstName; }
        set { _firstName = value; }
    }
    private string? _firstName;

    // Omitted for brevity.
}

Cuando una implementación de propiedad es una expresión única, puede usar miembros con forma de expresión para el captador o establecedor:

public class Person
{
    public string? FirstName
    {
        get => _firstName;
        set => _firstName = value;
    }
    private string? _firstName;

    // Omitted for brevity.
}

Esta sintaxis simplificada se usará cuando sea necesario en todo el artículo.

La definición de propiedad anterior es una propiedad de lectura y escritura. Observe la palabra clave value en el descriptor de acceso set. El descriptor de acceso set siempre tiene un único parámetro denominado value. El descriptor de acceso get tiene que devolver un valor que se pueda convertir al tipo de la propiedad (string en este ejemplo).

Estos son los conceptos básicos de la sintaxis. Hay muchas variantes distintas que admiten diversos lenguajes de diseño diferentes. Vamos a explorarlas y a aprender las opciones de sintaxis de cada una.

Validación

Los ejemplos anteriores mostraron uno de los casos más simples de definición de propiedad: una propiedad de lectura y escritura sin validación. Al escribir el código que quiere en los descriptores de acceso get y set, puede crear muchos escenarios diferentes.

Puede escribir código en el descriptor de acceso set para asegurarse de que los valores representados por una propiedad siempre son válidos. Por ejemplo, suponga que una regla para la clase Person es que el nombre no puede estar en blanco ni tener espacios en blanco. Se escribiría de esta forma:

public class Person
{
    public string? FirstName
    {
        get => _firstName;
        set
        {
            if (string.IsNullOrWhiteSpace(value))
                throw new ArgumentException("First name must not be blank");
            _firstName = value;
        }
    }
    private string? _firstName;

    // Omitted for brevity.
}

El ejemplo anterior se puede simplificar usando una expresión throw como parte de la validación del establecedor de propiedad:

public class Person
{
    public string? FirstName
    {
        get => _firstName;
        set => _firstName = (!string.IsNullOrWhiteSpace(value)) ? value : throw new ArgumentException("First name must not be blank");
    }
    private string? _firstName;

    // Omitted for brevity.
}

En el ejemplo anterior, se aplica la regla de que el nombre no debe estar en blanco ni tener espacios en blanco. Si un desarrollador escribe:

hero.FirstName = "";

Esa asignación produce una excepción ArgumentException. Dado que un descriptor de acceso set de propiedad debe tener un tipo de valor devuelto void, los errores se notifican en el descriptor de acceso set iniciando una excepción.

Se puede extender esta misma sintaxis para todo lo que se necesite en el escenario. Se pueden comprobar las relaciones entre las diferentes propiedades o validar con respecto a cualquier condición externa. Todas las instrucciones de C# válidas son válidas en un descriptor de acceso de propiedad.

Control de acceso

Hasta ahora, todas las definiciones de propiedad que se vieron son propiedades de lectura y escritura con descriptores de acceso públicos. No es la única accesibilidad válida para las propiedades. Se pueden crear propiedades de solo lectura, o proporcionar accesibilidad diferente a los descriptores de acceso set y get. Suponga que su clase Person solo debe habilitar el cambio del valor de la propiedad FirstName desde otros métodos de esa clase. Podría asignar al descriptor de acceso set la accesibilidad private en lugar de public:

public class Person
{
    public string? FirstName { get; private set; }

    // Omitted for brevity.
}

Ahora, se puede obtener acceso a la propiedad FirstName desde cualquier código, pero solo puede asignarse desde otro código de la clase Person.

Puede agregar cualquier modificador de acceso restrictivo al descriptor de acceso set o get. Ningún modificador de acceso que se coloque en el descriptor de acceso concreto debe ser más limitado que el modificador de acceso en la definición de la propiedad. El ejemplo anterior es válido porque la propiedad FirstName es public, pero el descriptor de acceso set es private. No se puede declarar una propiedad private con un descriptor de acceso public. Las declaraciones de propiedad también se pueden declarar como protected, internal, protected internal o incluso private.

También es válido colocar el modificador más restrictivo en el descriptor de acceso get. Por ejemplo, se podría tener una propiedad public, pero restringir el descriptor de acceso get a private. Ese escenario raramente se aplica en la práctica.

Solo lectura

También puede restringir las modificaciones de una propiedad, de manera que solo pueda establecerse en un constructor. Puede modificar la clase Person de la manera siguiente:

public class Person
{
    public Person(string firstName) => FirstName = firstName;

    public string FirstName { get; }

    // Omitted for brevity.
}

Solo inicialización

En el ejemplo anterior se requieren llamadas para usar el constructor que incluye el parámetro FirstName. Los autores de llamadas no pueden usar inicializadores de objetos para asignar un valor a la propiedad. Para admitir inicializadores, puede convertir el descriptor de acceso set en un descriptor de acceso init, como se muestra en el código siguiente:

public class Person
{
    public Person() { }
    public Person(string firstName) => FirstName = firstName;

    public string? FirstName { get; init; }

    // Omitted for brevity.
}

En el ejemplo anterior se permite que un autor de la llamada cree Person mediante el constructor predeterminado, incluso cuando ese código no establece la propiedad FirstName. A partir de C# 11, puede requerir que los autores de llamadas establezcan esa propiedad:

public class Person
{
    public Person() { }

    [SetsRequiredMembers]
    public Person(string firstName) => FirstName = firstName;

    public required string FirstName { get; init; }

    // Omitted for brevity.
}

El código anterior aporta dos adiciones a la clase Person. En primer lugar, la declaración de la propiedad FirstName incluye el modificador required. Esto significa que cualquier código que cree un nuevo Person debe establecer esta propiedad. En segundo lugar, el constructor que toma un parámetro firstName tiene el atributo System.Diagnostics.CodeAnalysis.SetsRequiredMembersAttribute. Este atributo informa al compilador de que este constructor establece todos los miembros de required.

Importante

No confunda required con que no acepta valores NULL. Es válido establecer una propiedad required en null o default. Si el tipo no acepta valores NULL, como string en estos ejemplos, el compilador emite una advertencia.

Los autores de llamadas deben usar el constructor con SetsRequiredMembers o establecer la propiedad FirstName mediante un inicializador de objeto, como se muestra en el código siguiente:

var person = new VersionNinePoint2.Person("John");
person = new VersionNinePoint2.Person{ FirstName = "John"};
// Error CS9035: Required member `Person.FirstName` must be set:
//person = new VersionNinePoint2.Person();

Propiedades calculadas

Una propiedad no tiene por qué devolver únicamente el valor de un campo de miembro. Se pueden crear propiedades que devuelvan un valor calculado. Vamos a ampliar el objeto Person para que devuelva el nombre completo, que se calcula mediante la concatenación del nombre y el apellido:

public class Person
{
    public string? FirstName { get; set; }

    public string? LastName { get; set; }

    public string FullName { get { return $"{FirstName} {LastName}"; } }
}

En el ejemplo anterior se usa la característica de interpolación de cadenas para crear la cadena con formato para el nombre completo.

También se pueden usar un miembro con forma de expresión, que proporciona una manera más concisa de crear la propiedad FullName calculada:

public class Person
{
    public string? FirstName { get; set; }

    public string? LastName { get; set; }

    public string FullName => $"{FirstName} {LastName}";
}

Los miembros con forma de expresión usan la sintaxis de expresión lambda para definir métodos que contienen una única expresión. En este caso, esa expresión devuelve el nombre completo para el objeto person.

Propiedades de evaluación en caché

Se puede combinar el concepto de una propiedad calculada con almacenamiento de información y crear una propiedad de evaluación en caché. Por ejemplo, se podría actualizar la propiedad FullName para que la cadena de formato solo apareciera la primera vez que se obtuvo acceso a ella:

public class Person
{
    public string? FirstName { get; set; }

    public string? LastName { get; set; }

    private string? _fullName;
    public string FullName
    {
        get
        {
            if (_fullName is null)
                _fullName = $"{FirstName} {LastName}";
            return _fullName;
        }
    }
}

Pero el código anterior contiene un error. Si el código actualiza el valor de la propiedad FirstName o LastName, el campo evaluado previamente fullName no es válido. Hay que modificar los descriptores de acceso set de la propiedad FirstName y LastName para que el campo fullName se calcule de nuevo:

public class Person
{
    private string? _firstName;
    public string? FirstName
    {
        get => _firstName;
        set
        {
            _firstName = value;
            _fullName = null;
        }
    }

    private string? _lastName;
    public string? LastName
    {
        get => _lastName;
        set
        {
            _lastName = value;
            _fullName = null;
        }
    }

    private string? _fullName;
    public string FullName
    {
        get
        {
            if (_fullName is null)
                _fullName = $"{FirstName} {LastName}";
            return _fullName;
        }
    }
}

Esta versión final da como resultado la propiedad FullName solo cuando sea necesario. Si la versión calculada previamente es válida, es la que se usa. Si otro cambio de estado invalida la versión calculada previamente, se vuelve a calcular. No es necesario que los desarrolladores que usan esta clase conozcan los detalles de la implementación. Ninguno de estos cambios internos afectan al uso del objeto Person. Es el motivo principal para usar propiedades para exponer los miembros de datos de un objeto.

Asociar atributos a propiedades implementadas automáticamente

Los atributos de campo se pueden conectar al campo de respaldo generado por el compilador en las propiedades implementadas automáticamente. Por ejemplo, pensemos en una revisión de la clase Person que agrega una propiedad Id de entero único. Escribe la propiedad Id usando una propiedad implementada automáticamente, pero el diseño no requiere que la propiedad Id se conserve. NonSerializedAttribute solo se puede asociar a campos, no a propiedades. NonSerializedAttribute se puede asociar al campo de respaldo de la propiedad Id usando el especificador field: en el atributo, como se muestra en el siguiente ejemplo:

public class Person
{
    public string? FirstName { get; set; }

    public string? LastName { get; set; }

    [field:NonSerialized]
    public int Id { get; set; }

    public string FullName => $"{FirstName} {LastName}";
}

Esta técnica funciona con cualquier atributo que se asocie al campo de respaldo en la propiedad implementada automáticamente.

Implementar INotifyPropertyChanged

Un último escenario donde se necesita escribir código en un descriptor de acceso de propiedad es para admitir la interfaz INotifyPropertyChanged que se usa para notificar a los clientes de enlace de datos el cambio de un valor. Cuando se cambia el valor de una propiedad, el objeto genera el evento INotifyPropertyChanged.PropertyChanged para indicar el cambio. A su vez, las bibliotecas de enlace de datos actualizan los elementos de visualización en función de ese cambio. El código siguiente muestra cómo se implementaría INotifyPropertyChanged para la propiedad FirstName de esta clase person.

public class Person : INotifyPropertyChanged
{
    public string? FirstName
    {
        get => _firstName;
        set
        {
            if (string.IsNullOrWhiteSpace(value))
                throw new ArgumentException("First name must not be blank");
            if (value != _firstName)
            {
                _firstName = value;
                PropertyChanged?.Invoke(this,
                    new PropertyChangedEventArgs(nameof(FirstName)));
            }
        }
    }
    private string? _firstName;

    public event PropertyChangedEventHandler? PropertyChanged;
}

El operador ?. se denomina operador condicional NULL. Comprueba si existe una referencia nula antes de evaluar el lado derecho del operador. El resultado final es que si no hay ningún suscriptor para el evento PropertyChanged, no se ejecuta el código para generar el evento. En ese caso, se producirá una NullReferenceException sin esta comprobación. Para obtener más información, vea events. En este ejemplo también se usa el nuevo operador nameof para convertir el símbolo de nombre de propiedad en su representación de texto. Con nameof se pueden reducir los errores en los que no se escribió correctamente el nombre de la propiedad.

De nuevo, la implementación de INotifyPropertyChanged es un ejemplo de un caso en el que se puede escribir código en los descriptores de acceso para admitir los escenarios que se necesitan.

Resumen

Las propiedades son una forma de campos inteligentes en una clase o un objeto. Desde fuera del objeto, parecen campos en el objeto. Pero las propiedades pueden implementarse mediante la paleta completa de funcionalidad de C#. Se puede proporcionar validación, tipos diferentes de accesibilidad, evaluación diferida o los requisitos que se necesiten para cada escenario.