10. IMUTABILIDADE & OBJETOS DE VALOR
“Os melhores programas são aqueles escritos quando o programador deveria estar trabalhando em outra coisa.”
Melinda Varian
Os objetos são, por padrão, mutáveis. Eles começam com um estado e sofrem transformações ao longo do seu tempo de vida num programa. No entanto, existem benefícios em projetar objetos que nunca mudam após terem seu estado inicial inicializado. O fato de um objeto ser imutável não significa que ele não faz mudanças no sistema. Isto parece paroxal, mas este capítulo abordará como objetos imutáveis podem ser projetados, implementados e testados, ainda baseando-se nas propriedades básicas dos objetos: retenção de um estado, exibição de um comportamento, e uma identidade.
10.1 Conceito de Imutabilidade
Se diz que são imutáveis as estruturas de dados que não alteram seu estado após terem sido inicializadas. Objetos são estruturas de dados e, portanto, podem ser mutáveis ou imutáveis. Um objeto imutável é aquele que após construído não poderá ter seu estado alterado ou, em outras palavras, é um objeto que não muda.
A imutabilidade não começa com os objetos. Na verdade, começa com as “partículas”, as variáveis e constantes. Enquanto pode-se declarar int x = 1; e mais tarde reatribuir x = 1;, não se pode fazer o mesmo com final int x = 1; (em Java) ou readonly int x = 1; (C#) ou const x = 1 (JavaScript) onde x é constante ou imutável, isto é, não pode ser reatribuído.
Um objeto com todos os seus atributos contantes torna-se imutável. Por exemplo, retomando o exemplo de um ponto no espaço 2D, porém imutável:
// Ponto.java
public class Ponto {
// constantes são marcadas como "final" em Java
public final int X; // geralmente são usadas letras maiúsculas para constantes
public final int Y; // os atributos não estão encapsulados, mas não podem ser alterados
// atributos constantes obrigam um construtor para inicializá-los
public Ponto(int x, int y) {
this.X = x; // a inicialização é obrigatória para constantes
this.Y = y;
}
@Override
public String toString() {
return String.format("(%d, %d)", this.X, this.Y);
}
}
// App.java
public class App {
public static void main(String[] args) {
Ponto p1 = new Ponto(20, 30);
System.out.println(p1.X); // 20
System.out.println(p1.Y); // 30
System.out.println(p1); // (20, 30)
// p1.X = 5; // não é possível
// O único meio de obter um ponto (5, 30) é criando um novo Ponto
p1 = new Ponto(5, 30);
System.out.println(p1); // (5, 30)
}
}
Conforme o exemplo anterior, a classe Ponto com atributos constantes resulta em objetos imutáveis. O fato de serem imutáveis implica na necessidade de criar novos objetos se for necessário valores diferentes para os atributos, como o que foi realizado com o exemplo do Ponto, para um ponto com novas coordenadas X e Y é necessário um novo ponto.
Projetar um objeto imutável não é uma tarefa complexa, basta uma classe com todos os atributos constantes. Ademais, se objetos imutáveis exigem que sejam criados novos objetos para obter novos estados e, portanto, consome mais memória e processamento, para que servem os objetos imutáveis?
10.2 Motivação para a Imutabilidade
Objetos imutáveis garantem que o estado lido de uma instância em qualquer momento será sempre o mesmo. Isto quer dizer que os objetos podem ser compartilhados sem o risco de que leituras subsequentes possam ler valores diferentes.
Por exemplo, considere novamente Ponto, porém usado por outros objetos e na sua versão padrão mutável:
// Ponto.java // Ponto MUTÁVEL
public class Ponto {
public int x; // atributos mutáveis (não-constantes)
public int y;
public Ponto(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public String toString() {
return String.format("(%d, %d)", this.x, this.y);
}
}
// Figura.java
public class Figura {
private Ponto pos;
public Figura(Ponto pos) {
this.pos = pos;
}
@Override
public String toString() {
return "Figura na posição " + this.pos;
}
}
// App.java
public class App {
public static void main(String[] args) {
Ponto p = new Ponto(10, 10);
Figura f = new Figura(p);
System.out.println(f); // Figura na posição (10, 10)
p.x = 15; // ponto é mutável
// portanto Figura não tem a garantia de estar na mesma
// posição exceto por sua ações:
System.out.println(f); // Figura na posição (15, 10)
// isto é, Figura é afetada pela mutabilidade de ponto e
// referências externas
}
}
Os objetos mutáveis permitem alterações em qualquer lugar do programa que tenha uma referência. Isto é, instâncias de objetos compartilhadas e espalhadas no programa podem sofrer alterações e, logo, não são garantidos os mesmo valores em leituras subsequentes.
No exemplo anterior, se Ponto fosse imutável a Figura não teria sua posição alterada por instruções externas. A esta garantia de mesmos resultados em leituras subsequentes é dado o nome de integridade referencial, que é um dos benefícios da imutabilidade.
Com a imutabilidade não há, também, os efeitos colaterais. Eles são comuns em objetos mutáveis, quando são passados como argumentos para funções, métodos e agregados a outras instâncias. Com a ausência de efeito colateral é garantido que a leitura do objeto será a mesma após passá-lo adiante, como no exemplo a seguir:
// Movimento.java
public class Movimento {
// reatribui a pos x de um ponto
public static void direita(Ponto p, int d) {
p.x = p.x + d; // se ponto fosse imutável esta linha seria inválida
}
}
// App.java
public class App {
public static void main(String[] args) {
Ponto p = new Ponto(10, 10);
System.out.println(p); // (10, 10)
Movimento.direita(p, 5);
// Como ponto é mutável:
System.out.println(p); // (15, 10)
// Se ponto fosse imutável:
// System.out.println(p); // (10, 10)
}
}
Conforme o exemplo anterior, o efeito colateral na reatribuição de x em p.x = p.x + d; afeta App. Isto é, não há a garantia de que ponto se mantenha em (10. 10) durante a execução de um programa se a instância for utilizada em outros lugares. Justamente esta garantia do estado do objeto quando ele é espalhado pelo programa a principal vantagem dos objetos imutáveis.
Por fim, objetos imutáveis podem ter a mesma identidade. Os runtime systems das linguagens podem reaproveitar a instância mesmo que novos new sejam invocados. Não é o caso em Java, mas conforme o exemplo:
// App.java
public class App {
public static void main(String[] args) {
Ponto p1 = new Ponto(10, 10);
Ponto p2 = new Ponto(10, 10);
// acusa false
System.out.println(p1 == p2);
// mas poderia acusar true se Ponto fosse imutável
// e o Java Runtime implementasse cache
}
}
O objeto ponto em p1 poderia ser reciclado em p2, isto é, o primeiro new criaria um ponto e, embora com novo new, a variável p2 receberia o ponto criado anteriormente. Isto só seria possível se ponto for imutável, garantido que não haveriam efeitos colaterais entre referências.
10.3 Objetos de Valor
A imutabilidade equipa um tipo especial de objeto chamado de objeto de valor e, por isso, é importante aborda-los neste capítulo.
Objetos de valor são objetos pequenos (com poucos atributos) que representam uma entidade simples que não é baseada na identidade mas no seu valor (o conjunto do estado de seu estado). Isto quer dizer que dois objetos de valor podem ter a mesma identidade, já que seus valores determinam sua identidade.
Algumas linguagens de programação suportam nativamente objetos de valor, que já são imutáveis por padrão. Na linguagem C#, por exemplo, há o struct, que é um objeto de valor imutável, como no exemplo a seguir:
// Ponto.cs
public struct Ponto // structs são objetos de valor imutáveis
{
public int X; // portanto não é necessário declarar readonly
public int Y; // nos atributos
public Ponto(int x, int y)
{
this.X = x;
this.Y = y;
}
public override string ToString() {
return $"({this.X}, {this.Y})";
}
}
// App.cs
public class App {
public static void Main() {
Point a = new Point(10, 10);
// "b" recebe uma cópia do objeto referenciado por "a"
Point b = a;
// portanto alterações em "a"
a.x = 100;
// não refletem em "b"
System.Console.WriteLine(b.x); // continua 10
}
}
O struct Ponto no exemplo anterior é imutável por padrão em C#. No entanto, para implementar o mesmo na linguagem Java é necessário declarar com classes normais, em vez de structs e declarar todos os atributos como final, como já visto anteriormente.
A questão mais importante, neste tópico, é que tipos de entidades deveriam ser objetos de valor. Tipicamente, eles tem poucos atributos e representam valores quantificáveis e comparáveis como peso, comprimento, dinheiro, coordenadas e outros. Por exemplo, em vez de usar double, conhecido pela imprecisão, para representar dinheiro, pode ser projetado um objeto de valor como a seguir:
// Dinheiro.java
public class Dinheiro {
// um atributo, fácil de armazenar e comparar
private final int centavos;
public Dinheiro(int reais, int centavos) {
this.centavos = reais * 100 + centavos;
}
public int getReais() {
return this.centavos / 100;
}
public int getCentavos() {
return this.centavos % 100;
}
@Override
public boolean equals(Object outroObjeto) {
if (this == outroObjeto) return true;
if (outroObjeto == null) return false;
if (outroObjeto instanceof Dinheiro) {
Dinheiro outroDinheiro = (Dinheiro) outroObjeto;
return this.centavos == outroDinheiro.centavos;
}
return false;
}
@Override
public String toString() {
return String.format("R$ %d,%02d", this.getReais(), this.getCentavos());
}
}
// App.java
public class App {
public static void main(String[] args) {
Dinheiro d1 = new Dinheiro(3, 99);
Dinheiro d2 = new Dinheiro(1, 250);
System.out.println(d1); // R$ 3,99
System.out.println(d2); // R$ 3,50
}
}
O exemplo anterior está completo, ele inclui a igualdade e a representação string. Talvez, a questão em aberto é: como fazer operações com este objeto de valor?
Operações em objetos de valor acontecem de forma diferente das classes normais devido à sua imutabilidade. O estado não pode ser alterado, logo é preciso construir novos objetos. Por exemplo, considere que seja necessário somar Dinheiro, portanto um método somar deve ser implementado da seguinte maneira em objetos de valor:
// Dinheiro.java
public class Dinheiro {
// ... atributos, construtor, getters, equals e toString omitidos
// Dinheiro é imutável, logo é necessário que as operações
// retornem novos objetos resultados das operações como este:
public Dinheiro somar(Dinheiro d) {
return new Dinheiro(0, this.centavos + d.centavos);
}
}
// App.java
public class App {
public static void main(String[] args) {
Dinheiro d1 = new Dinheiro(3, 99);
Dinheiro d2 = new Dinheiro(1, 250);
System.out.println(d1); // R$ 3,99
System.out.println(d2); // R$ 3,50
// d3 recebe uma nova instância de Dinheiro
Dinheiro d3 = d1.somar(d2);
System.out.println(d3); // R$ 7,49
// as instâncias d1 e d2 não mudam!
System.out.println(d1); // R$ 3,99
System.out.println(d2); // R$ 3,50
}
}
O exemplo anterior apresenta o método somar. Se dinheiro fosse mutável, bastaria realizar this.centavos = this.centavos + d.centavos, alterando o estado de dinheiro. No entanto, com objetos imutáveis é necessário que os métodos que realizam alterações, os comandos, retornem novas instâncias.
10.4 Imutabilidade Fraca e Forte
Tornar todos os atributos de um objeto constantes torna o objeto inteiro imutável, mas não livre de mudanças que revertam este comportamento. Quer dizer, uma classes pode descrever um objeto imutável, porém pode ser estendida o subclassificada de modo que a imutabilidade seja violada.
Por exemplo, considere o exemplo da classe Dinheiro, vista no tópico anterior. Ela foi projetada para gerar objetos imutáveis, mesmo quando o método somar é invocado ela mantém a imutabilidade por retornar um objeto novo, em vez de alterar o próprio estado. No entanto, o que acontecer se Dinheiro for estendida:
// Dinheiro.java
public class Dinheiro {
// ... implementação vista no tópico anterior
}
public class DinheiroMutavel extends Dinheiro {
private int centavos;
public DinheiroMutavel(int reais, int centavos) {
super(reais, centavos);
this.centavos = reais * 100 + centavos;
}
// DinheiroImutavel sobrescreve
// todo o comportamento de Dinheiro
// para torná-lo mutável.
@Override
public int getReais() { return this.centavos / 100; }
@Override
public int getCentavos() { return this.centavos % 100; }
@Override
public Dinheiro somar(Dinheiro d) {
this.centavos += d.getCentavos() + d.getReais() * 100;
return this;
}
}
// App.java
public class App {
public static void main(String[] args) {
Dinheiro d1 = new Dinheiro(1, 50);
Dinheiro d2 = new DinheiroMutavel(1, 50);
System.out.println(d1); // R$ 1,50
System.out.println(d2); // R$ 1,50
// embora d1 e d2 sejam do tipo declarado Dinheiro
// d2 não respeita a imutabilidade
d1.somar(d2); // d1 não muda
System.out.println(d1); // R$ 1,50
d2.somar(d1); // d2 muda, mesmo sendo Dinheiro
System.out.println(d2); // R$ 3,00
}
}
A imutabilidade definida na classe Dinheiro é conhecida como imutabilidade fraca porque ela pode ser estendida e ter o comportamento alterado (sobrescrito). A imutabilidade forte é alcançada quando a classe, além de projetada para gerar objetos imutáveis, também é protegida de ter este comportamento revertido através da proibição da sua subclassificação, isto é, o impedimento de que a classe Dinheiro seja estendida. Na prática, para ser fortemente imutável este tipo de instrução: extends Dinheiro, deve ser bloqueada.
Projetar classes fortemente imutáveis é bem simples e custa apenas uma palavra-chave na declaração da classe que indique que ela não pode ser estendida, como:
// Dinheiro.java
public final class Dinheiro {
// a adição de "final" à classe,
// mais a declaração de todos os atributos como final,
// projeta a classe para instanciar objetos imutáveis!
private final int centavos;
// ... implementação vista no tópico anterior
}
10.5 Considerações
A imutabilidade é um recurso importante e deve ser considerado ao projetar classes. Objetos de valor têm sua utilidade e, mais frequentemente, a imutabilidade deve ser considerada em oposição ao estado mutável. No paradigma de programação funcional, por exemplo, todas as estruturas são imutáveis. No paradigma orientado a objetos, no entanto, a imutabilidade deve ser projetada.
10.6 Exercícios
A seguir estão dois exercícios para treinar a imutabilidade. Considere declarar ambas classes como final para assegurar a imutabilidade forte.
Implementar o objeto Coordenada
Instâncias de Coordenada devem representar uma posição geográfica no formato de latitude e longitude em graus decimais, sendo que a latitude vai de -90.0 a +90.0 e a longitude de -180.0 a +180.0. A construção sem argumentos de uma coordenada deve instanciar latitude 0 e longitude 0. Após a construção não devem ser permitidas alterações na latitude e longitude a não ser que outra instância seja construída, em outras palavras, os objetos devem ser imutáveis.
Casos de Teste:
public class App {
public static void main(String[] args) {
// construtores:
Coord c1 = new Coord();
System.out.println(c1.getLat() == 0.0);
System.out.println(c1.getLong() == 0.0);
Coord c2 = new Coord(50.0, 134.0);
System.out.println(c2.getLat() == 50.0);
System.out.println(c2.getLong() == 134.0);
Coord c3 = new Coord(-90.0, -180.0);
System.out.println(c3.getLat() == -90.0);
System.out.println(c3.getLong() == -180.0);
// estas coordenadas são inválidas e devem lançar exceção
// faça serem rejeitadas e depois comente-as para não parar o programa
Coord e1 = new Coord(-91.0, 0.0);
Coord e2 = new Coord(100.0, 0.0);
Coord e3 = new Coord(10.0, -182.0);
Coord e4 = new Coord(10.0, 200.0);
Coord e5 = new Coord(-95.0, -200.0);
// imutabilidade: as linhas a seguir devem causar erro de compilação
// verifique se está de acordo e depois comente-as
Coord c4 = new Coord();
c4.getLat() = 30.0; // não deve permitir reatribuição
c4.getLong() = 80.0; // não deve permitir reatribuição
// operações/comandos:
Coord in = new Coord(30.0, 50.0);
Coord out = in.moveNorth(5.0); // deslocamento
System.out.println(in.getLat() == 30.0); // deve ser imutável
System.out.println(out.getLat() == 35.0);
out.moveNorth(5.0); // sem reatribuição sem alteração
System.out.println(out.getLat() == 35.0);
out = out.moveNorth(5.0); // reatribuindo
System.out.println(out.getLat() == 40.0);
out = out.moveSouth(60.0);
System.out.println(out.getLat() == -20.0);
out = out.moveSouth(30.0);
System.out.println(out.getLat() == -50.0);
out = out.moveSouth(-10.0);
System.out.println(out.getLat() == -40.0);
out = out.moveNorth(-10.0);
System.out.println(out.getLat() == -50.0);
System.out.println(out.getLong() == 50.0);
out = out.moveEast(50.0);
System.out.println(out.getLong() == 100.0);
out = out.moveWest(180.0);
System.out.println(out.getLong() == -80.0);
out = out.moveWest(-10.0);
System.out.println(out.getLong() == -70.0);
out = out.moveEast(-10.0);
System.out.println(out.getLong() == -80.0);
// consultas:
Coord q = new Coord();
System.out.println(q.getLat() == 0);
System.out.println(q.getLong() == 0);
System.out.println(q.isEquatorLine() == true);
System.out.println(q.isGreenwich() == true);
q = q.moveNorth(10.0);
System.out.println(q.getLat() == 10);
System.out.println(q.isEquatorLine() == false);
q = q.moveEast(10.0);
System.out.println(q.isGreenwich() == false);
q = q.moveEast(170.0);
System.out.println(q.getLong() == 180.0);
System.out.println(q.isGreenwich() == false);
q = q.moveWest(200.0);
System.out.println(q.getLong() == -20.0);
System.out.println(q.isGreenwich() == false);
q = q.moveWest(160.0);
System.out.println(q.getLong() == -180.0);
System.out.println(q.isGreenwich() == false);
Coord r = new Coord(30.0, 70.0);
System.out.println(r.getLat() == 30.0);
System.out.println(r.getLong() == 70.0);
System.out.println(r.isNorth() == true);
System.out.println(r.isSouth() == false);
System.out.println(r.isOrient() == true);
System.out.println(r.isOcident() == false);
r = r.moveWest(140.0).moveSouth(60.0);
System.out.println(r.getLat() == -30.0);
System.out.println(r.getLong() == -70.0);
System.out.println(r.isNorth() == false);
System.out.println(r.isSouth() == true);
System.out.println(r.isOrient() == false);
System.out.println(r.isOcident() == true);
// toString:
System.out.println(c1.toString().equals("0.0°, 0.0°"));
System.out.println(c2.toString().equals("50.0°, 134.0°"));
System.out.println(c3.toString().equals("-90.0°, -180.0°"));
System.out.println(out.toString().equals("-50.0°, -80.0°"));
System.out.println(q.toString().equals("10.0°, -180.0°"));
System.out.println(r); // -30.0°, -70.0°
System.out.println(r.toString().equals("-30.0°, -70.0°"));
}
}
Implementar a ideia de Tempo Decorrido
Tempo decorrido é chamado de “time span” em Inglês. Diferente de Time, TimeSpan representa um intervalo de tempo em dias, horas, minutos e segundos. TimeSpan deve ser imutável.
Considere os Casos de Teste:
public class App {
public static void main(String[] args) {
// construtores
TimeSpan ts1 = new TimeSpan(7, 3, 45, 35); // dias, horas, minutos, segundos
System.out.println(ts1.getDays() == 7);
System.out.println(ts1.getHours() == 3);
System.out.println(ts1.getMinutes() == 45);
System.out.println(ts1.getSeconds() == 35);
ts1 = new TimeSpan(8, 12, 9);
System.out.println(ts1.getDays() == 0);
System.out.println(ts1.getHours() == 8);
System.out.println(ts1.getMinutes() == 12);
System.out.println(ts1.getSeconds() == 9);
ts1 = new TimeSpan(4, 18, 110); // entradas adaptáveis
System.out.println(ts1.getDays() == 0);
System.out.println(ts1.getHours() == 4);
System.out.println(ts1.getMinutes() == 19);
System.out.println(ts1.getSeconds() == 50);
ts1 = new TimeSpan(4, 68, 110); // entradas adaptáveis
System.out.println(ts1.getDays() == 0);
System.out.println(ts1.getHours() == 5);
System.out.println(ts1.getMinutes() == 9);
System.out.println(ts1.getSeconds() == 50);
TimeSpan ts2 = new TimeSpan(1, 1, 1, 1);
System.out.println(ts2.getDays() == 1);
System.out.println(ts2.getHours() == 1);
System.out.println(ts2.getMinutes() == 1);
System.out.println(ts2.getSeconds() == 1);
// IMUTABILIDADE
TimeSpan ts3 = ts2.plus(ts1);
// ts2 é imutável e não deve ter sofrido alteração
System.out.println(ts2.getDays() == 1);
System.out.println(ts2.getHours() == 1);
System.out.println(ts2.getMinutes() == 1);
System.out.println(ts2.getSeconds() == 1);
// No entanto, o objeto recebido da operação reflete o novo estado do sistema
System.out.println(ts3.getDays() == 1);
System.out.println(ts3.getHours() == 6);
System.out.println(ts3.getMinutes() == 10);
System.out.println(ts3.getSeconds() == 55);
// validade e exceções
try {
ts1 = new TimeSpan(-1, 4, 68, 110);
// se essa linha for impressa uma exceção não foi lançada
// falhando no caso de teste
System.out.println(false);
} catch (IllegalArgumentException e) {
System.out.println(true); // se for impresso a exceção foi lançada! ok!
System.out.println(e.getMessage()); // Can't be negative
}
try {
ts1 = new TimeSpan(1, -4, 68, 110); System.out.println(false);
} catch (IllegalArgumentException e) {
System.out.println(true);
}
try {
ts1 = new TimeSpan(1, 4, -68, 110); System.out.println(false);
} catch (IllegalArgumentException e) {
System.out.println(true);
}
try {
ts1 = new TimeSpan(1, 4, 68, -110); System.out.println(false);
} catch (IllegalArgumentException e) {
System.out.println(true);
}
try {
ts1 = new TimeSpan(0, 0, 0, 0); System.out.println(false);
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage()); // Can't be zero
System.out.println(true);
}
// toString
System.out.println(ts1); // deve imprimir 5 hours, 9 minutes e 50 seconds
System.out.println(ts1.toString().equals("5 hours, 9 minutes e 50 seconds"));
TimeSpan ts2 = new TimeSpan(1, 12, 45, 1);
System.out.println(ts2.toString().equals("1 day, 12 hours, 45 minutes e 1 second"));
TimeSpan ts3 = new TimeSpan(0, 0, 0, 15);
System.out.println(ts3.toString().equals("15 seconds"));
ts3 = new TimeSpan(0, 1, 0, 0);
System.out.println(ts3.toString().equals("1 hour"));
ts3 = new TimeSpan(0, 0, 25, 0);
System.out.println(ts3.toString().equals("25 minutes"));
// toString em português
System.out.println(ts1.toString("pt").equals("5 horas, 9 minutos e 50 segundos"));
System.out.println(ts2.toString("pt")); //1 dia, 12 horas, 45 minutos e 1 segundo
System.out.println(ts2.toString("pt").equals("1 dia, 12 horas, 45 minutos e 1 segundo"));
System.out.println(ts3.toString("pt").equals("25 minutos"));
// equals, greaterThan, lessThan (igual, maior que, menor que)
TimeSpan ts4 = new TimeSpan(1, 12, 45, 1);
System.out.println(ts2.equals(ts4) == true);
System.out.println(ts4.equals(ts2) == true);
System.out.println(ts4.equals(ts3) == false);
System.out.println(ts4.greaterThan(ts3) == true);
System.out.println(ts4.lessThan(ts3) == false);
System.out.println(ts3.lessThan(ts2) == true);
System.out.println(ts4.lessThan(ts2) == false);
System.out.println(ts4.greaterThan(ts2) == false);
}
}