Por que a imutabilidade é tão importante

Olá, Habr!



Hoje queremos tocar no tópico da imutabilidade e ver se esse problema merece uma consideração mais séria.



Objetos imutáveis ​​são um fenômeno incomensuravelmente poderoso na programação. A imutabilidade ajuda a evitar todos os tipos de problemas de simultaneidade e um monte de bugs, mas entender construções imutáveis ​​pode ser complicado. Vamos dar uma olhada no que são e como usá-los.



Primeiro, dê uma olhada em um objeto simples:



class Person {
    public String name;
    
    public Person(
        String name
    ) {
        this.name = name;
    }
}


Como você pode ver, o objeto Personpega um parâmetro em seu construtor e o coloca em uma variável pública name. Assim, podemos fazer coisas como:



Person p = new Person("John");
p.name = "Jane";


Simples, certo? A qualquer momento, leia ou modifique os dados como quisermos. Mas existem alguns problemas com esse método. O primeiro e mais importante deles é que usamos uma variável em nossa classe namee, assim, introduzimos irrevogavelmente o armazenamento interno da classe na API pública. Em outras palavras, não há como alterar a maneira como o nome é armazenado dentro da classe, a menos que reescrevamos uma parte significativa de nosso aplicativo.



Algumas linguagens (por exemplo, C #) fornecem a capacidade de inserir uma função getter para contornar esse problema, mas na maioria das linguagens orientadas a objetos, você deve agir explicitamente:



class Person {
    private String name;
    
    public Person(
        String name
    ) {
        this.name = name;
    }
    
    public String getName() {
        return name;
    }
}


Por enquanto, tudo bem. Se agora você quiser alterar o armazenamento interno do nome, por exemplo, para o nome e o sobrenome, você pode fazer o seguinte:



class Person {
    private String firstName;
    private String lastName;
    
    public Person(
        String firstName,
        String lastName
    ) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
    
    public String getName() {
        return firstName + " " + lastName;
    }
}


Se você não se aprofundar nos sérios problemas associados a essa representação de nomes , é óbvio que a API getName()não mudou externamente .



Que tal definir nomes? O que você precisa adicionar não apenas para obter o nome, mas também para defini-lo assim?



class Person {
    private String name;
    
    //...
    
    public void setName(String name) {
        this.name = name;
    }
    
    //...
}


À primeira vista, parece ótimo, porque agora podemos alterar o nome novamente. Mas há uma falha fundamental nessa maneira de modificar os dados. Tem dois lados: filosófico e prático.



Vamos começar com um problema filosófico. O objeto se Persondestina a representar uma pessoa. Na verdade, o sobrenome de uma pessoa pode mudar, mas seria melhor nomear uma função para esse fim changeName, uma vez que tal nome implica que estamos mudando o sobrenome da mesma pessoa. Também deve incluir lógica de negócios para alterar o sobrenome de uma pessoa, e não apenas agir como um definidor. O nome setNameleva a uma conclusão completamente lógica de que podemos mudar voluntária e compulsoriamente o nome armazenado no objeto pessoa e não obteremos nada por ele.



A segunda razão tem a ver com a prática: o estado mutável (dados armazenados que podem mudar) está sujeito a bugs. Vamos pegar este objeto Persone definir uma interface PersonStorage:



interface PersonStorage {
    public void store(Person person);
    public Person getByName(String name);
}


Nota: isso PersonStoragenão indica exatamente onde o objeto está armazenado: na memória, no disco ou em um banco de dados. A interface também não requer uma implementação para criar uma cópia do objeto que armazena. Portanto, pode surgir um bug interessante:



Person p = new Person("John");
myPersonStorage.store(p);
p.setName("Jane");
myPersonStorage.store(p);


Quantas pessoas estão atualmente na loja pessoal? Um ou dois? Além disso, se você aplicar o método agora getByName, com qual pessoa ele irá retornar?



Como você pode ver, duas opções são possíveis aqui: ou PersonStorageirá copiar o objeto Person, caso em que dois registros serão salvos Person, ou não fará isso, e irá salvar apenas a referência ao objeto passado; no segundo caso, apenas um objeto com um nome será salvo “Jane”. A implementação da segunda opção pode ser assim:



class InMemoryPersonStorage implements PersonStorage {
    private Set<Person> persons = new HashSet<>();

    public void store(Person person) {
        this.persons.add(person);
    }
}


Pior ainda, os dados armazenados podem ser alterados sem mesmo chamar a função store. Como o repositório contém apenas uma referência ao objeto original, alterar o nome também alterará a versão salva:



Person p = new Person("John");
myPersonStorage.store(p);
p.setName("Jane");


Então, em essência, os bugs invadem nosso programa precisamente porque estamos lidando com um estado mutável. Não há dúvida de que esse problema pode ser contornado anotando explicitamente o trabalho de criação de uma cópia no armazenamento, mas há uma maneira muito mais simples: trabalhar com objetos imutáveis. Vamos considerar um exemplo:



class Person {
    private String name;
    
    public Person(
        String name
    ) {
        this.name = name;
    }
    
    public String getName() {
        return name;
    }
    
    public Person withName(String name) {
        return new Person(name);
    }
}


Como você pode ver, em vez de um método, setNameagora é usado um método withNameque cria uma nova cópia do objeto Person. Se criarmos uma nova cópia a cada vez, faremos sem o estado mutável e sem os problemas correspondentes. Claro, isso vem com alguma sobrecarga, mas os compiladores modernos podem lidar com isso e, se você tiver problemas de desempenho, poderá corrigi-los mais tarde.



Lembrar:

A otimização prematura é a raiz de todos os males (Donald Knuth)


Pode-se argumentar que o nível de persistência que faz referência ao objeto ativo é um nível de persistência quebrado, mas este é um cenário realista. Código ruim existe, e a imutabilidade é uma ferramenta valiosa para ajudar a prevenir esse tipo de falha.



Em cenários mais complexos, onde os objetos são passados ​​por várias camadas do aplicativo, os bugs inundam facilmente o código e a imutabilidade impede a ocorrência de bugs de estado. Exemplos desse tipo incluem, por exemplo, cache na memória ou chamadas de função fora de ordem.



Como a imutabilidade ajuda no processamento paralelo



Outra área importante onde a imutabilidade é útil é no processamento paralelo. Mais precisamente, multithreading. Em aplicações multi-threaded, várias linhas de código são executadas em paralelo, as quais, ao mesmo tempo, acessam a mesma área de memória. Considere uma lista muito simples:



if (p.getName().equals("John")) {
    p.setName(p.getName() + "Doe");
}


Este código não apresenta erros por si só, mas quando executado em paralelo, ele começa a se antecipar e pode ficar confuso. Confira a aparência do snippet de código acima com um comentário:



if (p.getName().equals("John")) {

    //     ,     John
    
    p.setName(p.getName() + "Doe");
}


Esta é uma condição de corrida. O primeiro thread verifica se o nome é igual “John”, mas o segundo thread altera o nome. O primeiro thread continua a ser executado, ainda assumindo que o nome é igual John.



Obviamente, pode-se usar o bloqueio para garantir que apenas uma thread entre na parte crítica do código a qualquer momento; no entanto, pode haver um gargalo. Porém, se os objetos são imutáveis, tal cenário não pode se desenvolver, uma vez que o mesmo objeto está sempre armazenado em p. Se outro encadeamento deseja influenciar a mudança, ele cria uma nova cópia que não estará no primeiro encadeamento.



Resultado



Basicamente, meu conselho seria sempre garantir que o estado mutável seja minimizado em seu aplicativo. Se você usar, restrinja-o fortemente com APIs bem projetadas, não deixe que vaze para outras áreas do aplicativo. Quanto menos trechos de código você tiver contendo estado, menor será a probabilidade de detectar erros de estado.



É claro que a maioria dos problemas de programação não tem solução se você não recorrer ao estado. Mas se considerarmos todas as estruturas de dados como imutáveis ​​por padrão, haverá muito menos bugs aleatórios no código. Se você realmente for forçado a introduzir mutabilidade em seu código, terá que fazer isso com cuidado e refletir sobre as consequências, e não iniciar todo o código com isso.



All Articles