Design: Imutabilidade

William Santos - May 20 - - Dev Community

Olá!

Este é mais um post da seção Design e, nele, falaremos sobre um conceito fundamental da programação funcional cada vez mais presente no universo orientado a objetos: imutabilidade.

A ideia é trazer as motivações, benefícios, e mostrar como é possível implementá-la usando C#.

Vamos lá!

O que é imutabilidade

Imutabilidade é a característica de uma dada instância de uma estrutura de dados de ter seu estado preservado, e imune a mudanças, uma vez criado. Isso significa que, quando um novo valor se fizer necessário a uma de suas propriedade, uma nova cópia desta instância.

Essa abordagem traz algumas vantagens, e algumas preocupações, algumas não muito óbvias, e vamos explorá-las a seguir.

Multi-threading e Paralelismo

A primeira grande vantagem trazida pela imutabilidade a preservação do estado de um objeto quando acessado por múltiplas threads (thread safety) ou em paralelo (concorrência). Em cenários onde um objeto é mutável é necessário criar mecanismo de sincronização (a forma mais comum é via lock) o que acaba trazendo complexidade à aplicação dado que o programador precisa se preocupar em checar se todo lugar que acessa esse objeto com a finalidade de mudar seu estado está sendo sincronizado adequadamente.

Uma vez que um dado objeto é imutável, todas as threads poderão acessá-lo para fins de leitura e, caso seja necessário promover uma mudança de estado, um novo objeto é criado e passa a ser compartilhável, sem a necessidade de se preocupar com locks.

Legibilidade, Debugging e Testes

Outra vantagem muito interessante, embora não tão óbvia, é o aumento da legibilidade do código, e da facilitação de testes e debugging.
Isso porque, como o objeto não sofre mudanças, basta procurar os pontos onde novos objetos são criados para verificar se há erro em sua inicialização, o que aumenta a eficiência já que é algo mais fácil de encontrar do que pontos de mudança, principalmente se essas mudanças não indicam o tipo do objeto que está sendo modificado (quando var é utilizado, por exemplo).

Desempenho

Dado que mudanças não são possíveis em um objeto, e a criação de uma nova instância é exigida, há um potencial uso aumentado de memória, sobretudo no caminho crítico da aplicação se houver um uso intensivo de recursos.
Nestes casos um design que considere esse uso intensivo e a necessidade de evitar mudanças é fundamental.
Linguagens funcionais são otimizadas para este cenário, com recursos como compartilhamento do endereço de memória das propriedades das instâncias, assim uma instância nova não precisa copiar dados, apenas referências, algo muito mais eficiente.

Imutabilidade e C#

Em suas últimas versões o time do .NET tem investido muito em recursos para tornar o C# compatível com imutabilidade, e vamos conferir os principais.

Readonly Structs

Este foi um dos primeiros recursos da linguagem para tratar imutabilidade. Quando declaradas com a palavra chave readonly a instância passa a ter mudanças impedidas. Desta forma, qualquer tentativa de mudança, inclusive por métodos da própria instância, não funcionará.

Vamos ver um exemplo em código:

public readonly struct Struct
{
    int _value;

    public Struct(int value) =>
        _value = value;

    public void Add(int value) =>
        _value += value;
}

public static void Main()
{
    var @struct = new Struct(3);
    @struct.Add(7);
    Console.Write($"Value: {@struct}"); //3
}
Enter fullscreen mode Exit fullscreen mode

Nota: a forma pela qual o compilador garante a imutabilidade de readonly structs são as chamadas cópias defensivas (defensive copies). Em termos simples, toda vez que a instância precisa ser acessada, uma cópia dela em seu estado original é criada. Desta forma a instância original tem seu estado protegido, ainda que ao custo de maior consumo da stack. Há formas de resolver este problema, basicamente passando a instância por referência usando a palavra-chave in na assinatura do método que irá recebê-la, ou por meio do uso de ref readonly ao utilizar uma instância que esteja em um escopo maior.

Records

Outro tipo que traz imutabilidade por design é o record. Uma vez inicializado seus valores não podem mais ser alterados, o que torna possível ter classes imutáveis (uma vez que records são reference types).

Um detalhe que precisa ser levado em consideração, no entanto, é que records podem se tornar mutáveis caso suas propriedades sejam declaradas com setters. Ou seja, para tirar vantagem da imutabilidade as propriedades devem ser declaradas com initializers ou de forma posicional.

Vejamos exemplos:

public record Person
{
    public string Name { get; init; }
    public byte Age { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

É o equivalente a:

public record Person (string Name, byte Age);
Enter fullscreen mode Exit fullscreen mode

Desta forma há a garantia de que nenhuma mudança possa ser realizada.

Campos Read-only

Este é um recurso bastante conhecido que, basicamente, informa que um dado campo de um objeto pode ser apenas lido e que, obrigatoriamente, será inicializado (ou por atribuição no momento da declaração, ou via construtor).

Vejamos como funciona:

public class Person
{
    public readonly String _name = "Person Name";
}
Enter fullscreen mode Exit fullscreen mode

Que equivale a:

public class Person
{
    public readonly String _name;

    public Person(string name) =>
        _name = name;
}
Enter fullscreen mode Exit fullscreen mode

Desta forma, uma vez inicializado, name não pode ter seu valor modificado.

Criando Novas Instâncias

Para facilitar a criação de novas instâncias, atribuindo apenas os valores novos, é a palavra chave with. Ela faz com que o compilador atribua à nova instância todos as propriedades que não forem declaradas em seu escopo.

Vejamos um exemplo:

public record Person(string Name, byte Age);

public static void Main()
{
    var john = new ("John", 30);
    var robert = john with { Name = "Robert" };
    Console.Write($"John's age: {john.Age}, Robert's age: {robert.Age}");
    //John's age: 30, Robert's age: 30
}
Enter fullscreen mode Exit fullscreen mode

Repare que, no exemplo acima, john permanece inalterado e robert tem apenas seu nome atribuído, tendo como sua idade a mesma de john.

Considerações Finais

Imutabilidade é um recurso excelente para conter bugs por concorrência, a preocupação com gestão de estado, simplicidade, previsibilidade e legibilidade do código.

Essas vantagens tem um valor enorme e, ao mesmo tempo, os recursos oferecidos no C# permitem explorá-las com facilidade.

Recomendo muito considerar como ferramenta de design utilizando ao mesmo tempo como métrica de qualidade, porque se um dado objeto está sofrendo muitas mudanças é provável que sua forma de uso esteja inadequada.

Gostou? Deixe-me saber pelos indicadores. Tem dúvidas ou sugestões? Deixe um comentário ou me procure em minhas redes sociais.

Até a próxima!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .