O Fardo da Mutabilidade

Mariana Castanheira
Flutter Portugal
Published in
10 min readOct 9, 2020

Na Flutter Portugal acreditamos que o conhecimento tem de ser acessível a todos, independentemente do grau de conhecimento de línguas estrangeiras. Assim, vamos lançar uma pequena série de artigos que vão abranger vários tópicos: Dados persistidos, State Management, Navegação, etc…

Este artigo foi originalmente escrito por David Morgan com o título “The Mutability tax” e traduzido com autorização do autor.

Engenharia de software é complicada e eu sou preguiçoso. Não gosto mesmo nada de trabalho desnecessário. Por isso, se o código vai ficar cá por uns tempos, eu construo-o de forma a que seja fácil de manter.

Eu trabalho com linguagens orientadas a objetos, normalmente Dart, e uma consideração fundamental que se deve ter em tais linguagens é a de se os dados vão ser mutáveis ou imutáveis. Muitos, comigo incluído, recomendam dados imutáveis; ver, por exemplo, Effective java item 15, “minimize mutability” (“minimizar mutabilidade”).

Aqui está um trecho de código Dart que ilustra o porquê da mutabilidade poder ser problemática:

// Biggest first.
var cities = [‘Tokyo’, ‘Delhi’, ‘Shanghai’, ‘Mumbai’, ‘Beijing’];
print(‘Biggest cities of the world in alphabetical order:\n’);
DisplayAlphabetically().display(cities);
print(‘And the biggest is ${cities.first}.’);

E aqui está o resultado:

Biggest cities in the world in alphabetical order:
Beijing, Delhi, Mumbai, Shanghai, Tokyo
And the biggest is Beijing.

— Ups! A maior cidade é Tóquio, não Beijing (Pequim). O que é que correu mal? DisplayAlphabeticallyestá a falhar; modifica a lista que lhe é dada, ordenando-a:

void display(List<String> strings) {
(strings..sort()).forEach(print);
}

Mas não é aqui que está o verdadeiro problema. É garantido que, em engenharia de software, onde quer que seja possível errar, vai errar-se. O problema é que, ao passar uma lista mutável ao método display — o qual pode ter sido escrito por outra pessoa, ou por nós num dia mau, ou copiado e colado do código com características diferentes — , criamos uma oportunidade para bugs.

Para solucionar o problema, podemos utilizar a imutável BuiltList:

// Biggest first.
var cities = BuiltList.of(
['Tokyo', 'Delhi', 'Shanghai', 'Mumbai', 'Beijing']);
print('Biggest cities of the world in alphabetical order:\n');
DisplayAlphabetically().display(cities);
print('And the biggest is ${cities.first}.');

— e agora sabemos que DisplayAlphabeticallynão pode modificar cities, deixando de fora toda a categoria de bugs.

Isto é um exemplo simples, fácil de solucionar e corrigir os bugs. O código do mundo real é muito mais complexo e os problemas muito mais sérios. Em particular, vejo problemas com:

  • Código de UI (Interface do Utilizador), o qual normalmente é constituído por módulos folgadamente acoplados (loosely coupled) que partilham dados. A mutabilidade cria oportunidades para bugs graves, que são difíceis de resolver, implicando um intensivo trabalho de manutenção.
  • Código de servidor de alta performance, o qual usa módulos acoplados de forma folgada que partilham dados para permitir paralelismo. Este é um ambiente muito diferente, mas o problema da mutabilidade é o mesmo: bugs graves que são difíceis de resolver e uma manutenção significativa.

Dados mutáveis nestas linguagens são a predefinição e funcionam bem o suficiente para casos simples; mas torna-se num problema à medida que a base de código aumenta. Por isso, é uma armadilha em que é muito comum cair. Em resumo:

Sistemas com módulos folgadamente acoplados que passam dados mutáveis entre eles ficam com o fardo da mutabilidade. Eles apoiam-se na convenção e na sorte para evitar bugs graves e difíceis de resolver, devido aos indesejados efeitos secundários da mutação. À medida que estes sistemas ficam maiores e mais complexos, a sua sorte acaba e os custos da manutenção aumentam.

Obviamente, se a mutabilidade é o problema, então a imutabilidade é a resposta! Mas esta vem com os seus próprios problemas.

O fardo da Imutabilidade

A maioria das linguagens orientadas a objetos não é, infelizmente, desenhada com a imutabilidade em mente. O caminho óbvio é construir convenções próprias e bibliotecas para dados imutáveis e, surpreendentemente, existem muitas armadilhas.

Analisemos essas armadilhas. Elas são semelhantes transversalmente a todas as linguagens orientadas a objetos mas, para efeitos deste artigo, vamos considerar Dart. A forma mais simples de ter dados imutáveis em Dart é esta:

class Customer {
final String name;
final int age;
Customer({this.name, this.age});
}

Toda a gente concorda com usar os campos (fields) namee age. Mas já tivemos de escolher entre usar parâmetros posicionais (positional parameters) ou parâmetros designados (named parameters). À medida que a lista de parâmetros aumenta, é provável que se queiram parâmetros designados para legibilidade, por isso a convenção que escolhemos para o exemplo é para os usar desde o início.

O próximo problema é como criar novas instâncias imutáveis, baseadas noutras já existentes — como as “atualizar”. A predefinição, e aquilo que regularmente vemos na prática, é forçar as pessoas a usarem o construtor:

var customer = Customer(name: 'John Smith', age: 34);
var updatedCustomer = Customer(
name: customer.name, age: customer.age + 1);

Infelizmente, o código a utilizar este construtor desta forma quebra se adicionarmos um campo:

var customer = Customer(
name: 'John Smith', age: 34, visits: 12);
// ... later on ... whoops! This doesn't work, resets 'visits'.
var updatedCustomer = Customer(
name: customer.name, age: customer.age + 1);

Poderíamos contornar esta situação ao criar todos os parâmetros necessários, ou como parâmetros posicionais ou usando a anotação @required , mas isto não é muito melhor: agora qualquer chamada ao construtor na base de código precisa de ser atualizada para adicionar um novo campo.

O que é que fazemos? Não podemos utilizar o construtor, precisamos de um método:

class Customer {
final String name;
final int age;
Customer({this.name, this.age});
Customer copyWith({String name, String age}) =>
Customer(name: name ?? this.name, age: age ?? this.age);
}

Então agora podemos escrever:

var updatedCustomer = customer.copyWith(age: customer.age + 1);

— e este código continua a ter o comportamento correto se um campo for adicionado.

Infelizmente, assim que a um campo é permitido ser null, este padrão falha em Dart. Parâmetros designados opcionais sem uma pré-definição explícita simplesmente voltam a nulle não há forma de verificar se um nullfoi explicitamente passado ou se foi recebido, porque nada foi especificado:

// Doesn't work! The method can't tell that 'null' was explicitly
// passed for 'visits', so nothing is updated.
var customerWithoutVisits = customer.copyWith(visits: null);

Campos que podem ser null podem ser corrigidos ao usar um método withpor cada campo:

class Customer{
final String name;
final int age;
final int visits;
Customer({this.name, this.age, this.visits});
Customer withName(String name)
=> Customer(name: name, age: age, visits: visits);
Customer withAge(int age)
=> Customer(name: name, age: age, visits: visits);
Customer withVisits(int visits)
=> Customer(name: name, age: age, visits: visits);
}

— o que, pelo menos, funciona, mas está longe de ser satisfatório. É muito código boilerplate (https://pt.wikipedia.org/wiki/Boilerplate_code) para escrever; é fácil enganarmo-nos no código boilerplate; e é lento: atualizar múltiplos campos requer uma cópia completa de objeto por campo.

Mesmo com tipos primitivos, manter objetos imutáveis em Dart manualmente não tem piada nenhuma. Este exemplo tem três campos; num sistema real, Customertem facilmente dez ou mais. Isto significa pelo menos dez métodos with, cada um a passar pelo menos dez argumentos de volta ao construtor. Ouch.

Mas o pior ainda está para vir.

Vai ser preciso suportar coleções e tipos aninhados (nested types):

class ShoppingBasket {
final Customer customer;
final List<Item> items;
final List<Offer> offers;
ShoppingBasket(
this.customer,
Iterable<Item> items,
Iterable<Offer> offers)
// Copy defensively to ensure immutability.
: this.items = List.unmodifiable(items),
this.offers = List.unmodifiable(offers);
// TODO(davidmorgan): add "with" method per field.
}

De maneira a aceitar as coleções mutáveis de Dart no nosso mundo imutável, temos de os copiar defensivamente. Uma das supostas vantagens de tipos imutáveis é a de serem rápidos. Nós conseguimos torná-los lentos.

E, se avançarmos com os simples métodos withque tínhamos antes, atualizar um campo aninhado (nested field) torna-se numa tarefa:

var updatedBasket = basket
.withCustomer(basket.customer.withName(updatedName))
.withItems([...basket.items, newItem, newItem2]);

Podemos emendar isto com métodos de conveniência adicionais:

var updatedBasket = basket
.withCustomerName(updatedName)
.addItem(newItem)
.addItem(item2);

— mas isso significaria ainda mais código boilerplate. E estaríamos mais uma vez a fazer com que cada atualização fizesse uma cópia completa, tornando os nossos dados imutáveis lentos.

A única forma de responder a todos estas questões — para fornecer dados imutáveis em Dart que sejam fáceis e rápidos de “atualizar”, que suportem campos null, e que não quebrem código existente quando são adicionados campos — é com o padrão builder. Cada tipo de dados tem um tipo adicional associado, o seu builder, o qual tem os mesmos dados mas é mutável. Usamos um builder para “construir” uma instância imutável.

Também precisamos de uma nova biblioteca com coleções de categorias baseadas em builders. Isto permite que coleções imutáveis sejam geridas sem cópias desnecessárias, por isso são rápidas.

Ao utilizar categorias de builders aninhados (nested builder classes) e builders de coleção (collection builders), o nosso código de exemplo pode ser tanto conveniente como eficiente, e as “atualizações” podem parecer-se com isto:

var updatedBasket = basket.rebuild((b) => b
..customer.name = updatedName
..items.addAll([newItem, item2]));

Infelizmente (outra vez!), o padrão builder precisa de mais código boilerplate do que qualquer outro exemplo e é extremamente difícil de obter corretamente. Existem muitas versões, muitas convenções possíveis e muitos erros possíveis de se fazer. Podemos usar o padrão builder, mas mesmo assim não suportar corretamente os campos que podem ser null; ou continuar a ter um desempenho fraco; ou digitar o código boilerplate com bugs. Tais bugs podem ser tão graves como mutabilidade acidental — a qual é, claro, pior do que a mutabilidade conhecida que tínhamos antes de mudar para dados imutáveis!

Isto traz repetição: digitar milhares e milhares de linhas de código boilerplate à mão, de forma a evitar carregar o fardo da mutabilidade, deixa-nos abertos a exatamente o mesmo tipo de bug que estávamos a tentar evitar, assim que cometemos um erro no código boilerplate.

A maneira certa de fazer imutabilidade é através de padrões builder, mas escrevê-los à mão simplesmente não vale a pena.

Imutabilidade através de Geração de Código (Codegen)

Então vamos dar a que, em linguagens orientadas a objetos que não são desenhadas para imutabilidade, a forma correta de alcançar a imutabilidade e evitar o fardo da imutabilidade é deixar outra pessoa fazer o trabalho por nós, através de uma biblioteca que origina código. Só com a geração de código (codegen) podemos reduzir o trabalho acrescido que é requerido na criação de dados imutáveis nestas linguagens.

Infelizmente (uma última vez!), a geração de código vem com os seus próprios defeitos: continua a precisar de código boilerplate para ser posta a funcionar; poderá ser preciso trabalho adicional para pôr o nosso projeto em build; e o nosso IDE (Ambiente de Desenvolvimento Integrado) pode não ser tão útil quando chega ao código gerado. Mas é o melhor que conseguimos fazer hoje. Em Dart existe a minha própria biblioteca, built_value, a qual vai mais além e pode serializar os dados; escrevi um artigo sobre isso aqui. Tem este aspeto, com o código boilerplate realçado a negrito:

abstract class Customer implements Built<Customer, CustomerBuilder> {
String get name;
int get age;
@nullable
int get visits;factory Customer(void Function(CustomerBuilder) updates) =
_$Customer;
Customer._();

}

O código boilerplate é desagradável e assustador, mas é fácil de preservar: não é necessário adicionar nenhum código boilerplate quando se adiciona um novo campo, e é impossível existirem bugs no código boilerplate porque a geração de código também o verifica por nós. Alcançámos dados imutáveis sustentáveis. Aquilo que agora nos é imposto já não é tanto um fardo contínuo, mas mais uma taxa de entrada.

Em Java existe o AutoValue.Builder, que tem uma abordagem parecida e vem com um código boilerplate semelhante. A propósito disto, essa equipa fez um power point muito bom acerca do porquê desta geração de código ser necessária.

Em suma:

Em linguagens não desenhadas para imutabilidade, os sistemas que evitam o fardo da mutabilidade ao implementar estruturas de dados imutáveis à mão, por outro lado acarretam o fardo da imutabilidade. Estruturas de dados imutáveis nestas linguagens são difíceis de obter corretamente e, por definição, são inconvenientes e lentas; isto resulta num custo mais elevado de manutenção, bugs e problemas de desempenho. A maneira certa de contornar o fardo da mutabilidade nestas linguagens é usar uma biblioteca que origine e esconda o código boilerplate necessário para dados imutáveis convenientes e rápidos.

Pensamentos finais

Pode-me ser perguntado se pratico aquilo que digo; se realmente faço o trabalho adicional de usar geração de código para dados imutáveis no meu trabalho diário.

A resposta é: se vou ter de manter o código, sim, uso os dados imutáveis. Se é pequeno e “escreve uma vez, corre uma vez, apaga”, provavelmente não me vou dar ao trabalho.

Houve uma exceção recente a esta regra. Estava a trabalhar em código, nas profundezas do build em Dart; e não podemos usar facilmente codegen em código dependente de codegen. Por isso, voltei a dados imutáveis feitos à mão.

Estava demasiado preguiçoso para digitar métodos with; usei só o construtor. Depois adicionei um campo. Depois, passei um tempo considerável a corrigir bugs, porque o código existente estava a apagar o novo campo, exatamente como foi descrito neste artigo.

Isto lembrou-me do quanto desgosto de ambos os fardos da mutabilidade e da imutabilidade, e daí nasceu a ideia para este artigo.

Vamos concluir com os dois sumários para que possam coexistir ao lado um do outro.

O Fardo da Mutabilidade

Sistemas que tenham módulos folgadamente acoplados (loosely coupled) que transmitem dados mutáveis entre si, acarretam o fardo da mutabilidade. Eles dependem da convenção e de sorte para evitar bugs graves e difíceis de resolver devido a efeitos secundários indesejados da mutação. À medida que estes sistemas crescem e se tornam mais complexos, a sua sorte acaba e o custo de manutenção aumenta.

O Fardo da Imutabilidade

Em linguagens que não foram desenhadas para imutabilidade, os sistemas que contornam o fardo da mutabilidade ao implementar arquiteturas de dados imutáveis à mão, acarretam o fardo da imutabilidade. É difícil acertar nas arquiteturas de dados imutáveis nestas linguagens e são, por definição, inconvenientes e lentas; isto resulta num maior custo de manutenção, bugs e problemas de desempenho. A forma correta de evitar o fardo da imutabilidade nestas linguagens é usar uma biblioteca que faça a gestão e esconda o código boilerplate necessário para dados imutáveis convenientes e rápidos.

Dados mutáveis levam a problemas de manutenção. Em linguagens não-desenhadas para imutabilidade, tais como Java e Dart, dados imutáveis mantidos manualmente trazem ainda mais problemas. Geração de código (Codegen) é a melhor resposta que temos hoje em dia. Fornece o código boilerplate necessário para dados imutáveis sem bugs, eficientes e convenientes.

--

--

Mariana Castanheira
Flutter Portugal

Translator (English-Portuguese/Portuguese-English). Helping Flutter Portugal for the time being.