Em alguns tipos de programa, principalmente em jogos, é necessário instanciar uma grande quantidade de objetos similares, como por exemplo elementos de um mapa, e isso pode acabar ocupando bastante memória.
O padrão Flyweight permite o reuso de determinadas características de um objeto, separando características únicas de outras que são repetitivas, evitando que elas ocupem memória desnecessária.
No exemplo abaixo, temos uma classe Account que tem uma relação de composição com a classe Transaction, que representam as transações de débito e crédito contendo o valor, a moeda, o meio de pagamento e a bandeira do cartão.
Pensando em Domain-Driven Design, Account forma um Aggregate com Transaction, agindo como Aggregate Root.
class Transaction {
constructor (readonly type: string, readonly amount: number, readonly currency: string, readonly paymentMethod: string, readonly brand: string) {
}
}
class Account {
private transactions: Transaction[];
constructor () {
this.transactions = [];
}
credit (amount: number, currency: string, paymentMethod: string, brand: string) {
this.transactions.push(new Transaction("credit", amount, currency, paymentMethod, brand));
}
debit (amount: number, currency: string, paymentMethod: string, brand: string) {
this.transactions.push(new Transaction("debit", amount, currency, paymentMethod, brand));
}
}
Supondo uma grande quantidade de transações, nesse caso criei 10 milhões de transações aleatórias, temos aproximadamente 1gb de memória utilizada, podendo variar já que o valor de cada transação é aleatório, ocupando uma quantidade de memória diferente. Na minha máquina o tempo de execução ficou na faixa de 1.5 segundos.
console.time("performance");
const account = new Account();
let i = 0;
while (true) {
if (i === 10000000) break;
if (i%2 === 0) {
account.credit(Math.random() * 1000, "BRL", "creditCard", "Visa");
} else {
account.debit(Math.random() * 1000, "USD", "debitCard", "Mastercard");
}
i++;
}
const formatMemoryUsage = (data: any) => `${Math.round(data / 1024 / 1024 * 100) / 100} MB`;
console.log(formatMemoryUsage(process.memoryUsage().heapUsed));
console.timeEnd("performance");
Esse padrão absorve a similaridade entre diferentes objetos criando uma cache, chamada de FlyweightFactory, que faz o reuso das instâncias, permitindo reduzir muito a quantidade de memória utilizada quando instanciamos um volume muito grande de objetos similares.
No exemplo abaixo separamos as características únicas e repetitivas da transação, derivando uma nova classe chamada TransactionInformation, um objeto Flyweight compartilhado, armazenando e retornando a mesma instância caso ela tenha exatamente os mesmos parâmetros.
class TransactionInformation {
constructor (readonly type: string, readonly currency: string, readonly paymentMethod: string, readonly brand: string) {
}
}
class Transaction {
transactionInformation: TransactionInformation;
amount: number;
constructor (type: string, amount: number, currency: string, paymentMethod: string, brand: string) {
this.transactionInformation = TransactionInformationFlyweightFactory.getTransactionInformation(type, currency, paymentMethod, brand);
this.amount = amount;
}
}
class TransactionInformationFlyweightFactory {
static transactionInformations: { [transactionInformation: string]: TransactionInformation } = {};
static getTransactionInformation (type: string, currency: string, paymentMethod: string, brand: string): TransactionInformation {
const transactionInformation = new TransactionInformation(type, currency, paymentMethod, brand);
const index = `${type}/${currency}/${paymentMethod}/${brand}`;
if (!TransactionInformationFlyweightFactory.transactionInformations[index]) {
TransactionInformationFlyweightFactory.transactionInformations[index] = transactionInformation;
}
return TransactionInformationFlyweightFactory.transactionInformations[index];
}
}
class Account {
transactions: Transaction[];
constructor () {
this.transactions = [];
}
credit (amount: number, currency: string, paymentMethod: string, brand: string) {
this.transactions.push(new Transaction("credit", amount, currency, paymentMethod, brand));
}
debit (amount: number, currency: string, paymentMethod: string, brand: string) {
this.transactions.push(new Transaction("debit", amount, currency, paymentMethod, brand));
}
}
Com isso, foi possível reduzir a memória utilizada de 1gb para 750mb, mas essa redução vai depender do volume de dados únicos e repetitivos, quanto mais repetido, maior é ganho em utilizar esse padrão. Sobre performance, nos meus testes não ocorreu aumento no tempo de execução, ele seguiu nos mesmos 1.5 segundos.
Durante a criação dos exemplos eu tentei três formas diferentes de indexação, uma criando um JSON, o que aumentou em quase 10 vezes o tempo de execução e ainda aumentou o consumo de memória. Outra criando um hash utilizando o algoritmo SHA1, o que acabou também aumentando o tempo de execução quase na mesma proporção do JSON. Por conta disso, utilizei uma simples concatenação de String, que foi mais rápido e eficiente.
Repare que foi utilizado o padrão Singleton para garantir que as instâncias sejam únicas.
Uma coisa que devemos ter em mente é que criar uma cache, indexar as informações e depois buscá-las pode consumir muitos ciclos de CPU, por isso é fundamental fazer testes de performance para avaliar se realmente as vantagens compensam as desvantagens.
Nas linguagens Java, C#, JavaScript, entre outras, existe um String Pool, que funciona de forma similar ao padrão Flyweight, onde é possível reusar as instâncias do tipo String. Isso pode ser observado pelo exemplo abaixo, escrito em Java:
String a = "Java";
String b = "Java";
System.out.println(a == b); // true
Ainda que tenhamos criado variáveis diferentes, ao comparar as suas referências elas apontam para o mesmo objeto. Claro, isso só funciona porque a String é imutável, ou seja, para modificar uma String é necessário criar uma nova. Dessa forma é possível reusar as instâncias sem correr o risco de causar um efeito colateral, por isso, se for utilizar o padrão Flyweight, faça com que os dados repetitivos dos objetos compartilhados sejam imutáveis.
Esse é um padrão que muitas vezes utilizamos sem perceber, claro que o conceito do padrão envolve não somente uma cache, mas uma separação entre a parte única e repetitiva do objeto, o design dele está justamente nesse conceito.