Objetos imutáveis

Um animal interessante, que aparece em linguagens como Java e Python, é o objeto imutável. Como tais linguagens, em particular Java, encorajam o programador a construir suas próprias classes de objetos, é importante entender o porquê de sua existência.

Como já foi abordado em outro artigo, algumas linguagens, em particular o C++, permitem ao programador criar seus próprios tipos. O tipo é criado como uma classe; é a forma de usar a classe que "converte-a" num tipo. Exemplo:

Classe a; 	// variáveis são objetos
Classe b;

b = a;		// valor de "a" é copiado em "b", porém
		// continuam sendo variáveis independentes

--

Classe* a;	// variáveis são ponteiros para objetos
Classe* b;


a = new Classe;	// "a" aponta agora para objeto em heap

b = a;		// "b" aponta para o mesmo objeto que "a",
		// portanto não é mais independente

Creio que C++ é a linguagem que oferece a ferramenta mais poderosa para criação de tipos. É realmente o Latim das linguagens: incrivelmente completo, e incrivelmente complexo...

Em outras linguagens, como Java, Object Pascal, Python, C#, etc. não existem tipos criados pelo usuário; apenas classes e seus objetos.

Exceto pela sintaxe de ponteiros, que não transparece nessas outras linguagens, a semântica de objetos é exatamente igual ao segundo exemplo em C++. Segue um exemplo em Python:

class qualquer:
	pass

a = qualquer()
b = a		// "b" aponta para a mesma coisa que "a"

a.modifica()	// o estado do objeto apontado por "a" muda,
		// e portanto também o objeto apontado por "b".
A semântica acima não seria muito popular se aplicada a objetos tipo string:
class string_quebrada:
	pass

a = "rato"
b = a

a[3] = "a"	// muda "rato" para "rata", mas a variável "b"
		// também será alterada pois aponta para o mesmo
		// objeto!

A priori, isso significa que, nestas linguagens, os únicos tipos existentes são os primitivos. Mas existe um truque que permite simular um tipo criado pelo usuário, e usando apenas objetos. Este truque é o objeto imutável.

Objeto imutável é o que não pode sofrer qualquer modificação em seu estado, depois de sua construção. A única forma de modificar um objeto contido por uma variável, é criar um novo e colocar no lugar do velho.

Vejamos o que acontece quando o objeto é imutável:

a = qualquer()
b = a
c = b		// neste ponto, as 3 variáveis apontam para o
		// mesmo objeto.

a = a.modifica() // métodos que antes modificavam o objeto agora
		 // retornam um novo objeto.

		 // Agora, "a" aponta para um objeto novo,
		 // que é independente de "b" e "c"

b = b.modifica() // "b" também é desvinculado do objeto original
		 // apenas "c" ainda aponta para o original.

Com objetos imutáveis, temos um comportamento muito mais intuitivo, semelhante ao de um tipo primitivo. Esse mecanismo permite que Python e Java implementem strings como classes sem modificar a linguagem. Object Pascal preferiu oferecer um tipo String primitivo.

Idealmente, toda classe deve produzir objetos imutáveis, pois são mais seguros. Mas em alguns casos, isso não faz muito sentido. Exemplo: quando o objeto representa um recurso do sistema, como um arquivo aberto. Seria ruim que esse objeto se "multiplicasse". Esse tipo de objeto deve permanecer único, e ser mutável, mesmo que referenciado por diversas variáveis diferentes.

Um possível aspecto negativo de objetos imutáveis é a performance. Veja o que acontece com objetos string em C++ e Python:

// C++

std::string a;

for(int x = 0; x < 100000; ++x) {
	a += "x";
}

O objeto-tipo "a" só precisa ser criado uma vez. As chamadas a "operator +=" apenas atualizam o objeto existente. Uma classe C++ bem-feita pode comportar-se como um tipo com máxima performance.

O mesmo acontece com Object Pascal onde string é um tipo primitivo. O compilador é esperto o suficiente para evitar a criação de novas strings.

// Python 

string a;

for i in range(0,100000):
	a += "x"
Como o objeto "a" é imutável, a operação
a += "x"
é transformada internamente em
a = string.__add__(a, "x")

O método __add__ retorna uma nova string, que é a concatenação do objeto "a" e da string "x". Portanto, haverá pelo menos 100000 criações de objetos novos nesse loop. Nesse benchmark em particular, Java e Python perdem muito feio para C++.

Se, em C++, eu substituísse

a += "x"
por
a = a + "x"
a performance fica grandemente prejudicada, pois o "operator +" tem obrigação de retornar um objeto novo sem tocar nos originais. Com essa modificação, C++ praticamente empata com Python na performance (e C++ é compilado!).

Filosofando um pouco agora, podemos dizer que todos os tipos primitivos (inteiros, floats) são "imutáveis", mesmo em C++. Observe:

a = 5

Podemos dizer que o número 5 como um objeto imutável fornecido pelo compilador. Ao fazermos

a = a + 1
não estaremos reformando 5, e sim somando 5 + 1, e atribuindo um novo objeto imutável que será o valor 6.

O compilador garante que os objetos imutáveis literais são "read-only", de modo que escrever

5 = a
não seja aceitável.

Se fizermos o mesmo loop com o tipo Inteiro:

a = 0

for i in range(0, 100000):
	a = a + 1

O trabalho imposto sobre a CPU é o mesmo que no caso das strings; há 100000 criações de novos objetos inteiros. Mas por que, quando utilizamos variáveis inteiras, isso é tão rápido?

A resposta é simples: inteiros, caracteres e floats são pequenos e simples de criar. Também são suportados diretamente pela CPU, o que é muito mais rápido que suporte por software... Em linguagens compiladas, a = a + 1 vira uma única instrução de CPU.

Object Pascal e Clipper utilizam uma técnica de contagem de referências para otimizar o seu tipo primitivo string. As variáveis são, internamente, ponteiros, tais como as variáveis de objetos.

let a := "rato"
let b := a;		// aponta para a mesma string que "a"
let c := "";		// otimização: string vazia é internamente
			// um ponteiro nulo!

delete(a,4,1);		// apaga 4o caractere

			// como a string tem 2 variáveis penduradas,
			// é necesário criar outra nova, e atribuir
			// para a variável "a"

			// neste ponto, a = "rat", b = "rato"

Let a := a + "a";	// como a contagem de referência é igual a 1,
			// a soma pode atuar sobre "rat" diretamente

			// neste ponto, a = "rata", b = "rato", 
			// e "rat" é liberado da memória.

delete(b,4,1);		// apaga 4o caractere

			// atua sobre a própria string "rato" pois
			// só existe uma referência para ela

Let b := b + "os";

Let c := b;		// Super rápido pois c era um ponteiro nulo
			// String "ratos" volta a ter 2 referências

É uma otimização possível para objetos imutáveis, pelo menos em linguagens compiladas como Pascal e C++, bem como código nativo para Java ou Python.

No exemplo, a função "delete" tem de ter comportamento duplo, conforme a contagem de referência da string. Isso pode ser trabalhoso e não valer a pena, se o objeto for pouco usado. Apenas objetos como strings geralmente valem tais cuidados com performance.

Google