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
Person
pega 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
name
e, 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
Person
destina 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 setName
leva 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
Person
e definir uma interface PersonStorage
:
interface PersonStorage {
public void store(Person person);
public Person getByName(String name);
}
Nota: isso
PersonStorage
nã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
PersonStorage
irá 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,
setName
agora é usado um método withName
que 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.