Em alguns tipos de programa, principalmente em jogos, e necessario instanciar uma grande quantidade de objetos similares, como por exemplo elementos de um mapa, e isso pode acabar ocupando bastante memória.
O padrao Flyweight permite o reuso de determinadas características de um objeto, separando características unicas de outras que são repetitivas, evitando que elas ocupem memória desnecessaria.
O Problema Inicial
No exemplo abaixo, temos uma classe Account que tem uma relação de composição com a classe Transaction, que representam as transacoes de debito e credito contendo o valor, a moeda, o meio de pagamento e a bandeira do cartao.
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 transacoes, nesse caso criei 10 milhões de transacoes aleatorias, temos aproximadamente 1gb de memória utilizada, podendo variar já que o valor de cada transacao e aleatorio, ocupando uma quantidade de memória diferente. Na minha maquina 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: number) => `${Math.round((data / 1024 / 1024) * 100) / 100} MB`;
console.log(formatMemoryUsage(process.memoryUsage().heapUsed));
console.timeEnd("performance");
A Solução
Esse padrao absorve a similaridade entre diferentes objetos criando uma cache, chamada de FlyweightFactory, que faz o reuso das instancias, 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 unicas e repetitivas da transacao, derivando uma nova classe chamada TransactionInformation, um objeto Flyweight compartilhado, armazenando e retornando a mesma instância caso ela tenha exatamente os mesmos parametros.
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 unicos e repetitivos, quanto mais repetido, maior e o ganho em utilizar esse padrao. Sobre performance, nos meus testes não ocorreu aumento no tempo de execução, ele seguiu nos mesmos 1.5 segundos.
Consideracoes de Performance
Durante a criação dos exemplos eu tentei tres formas diferentes de indexacao, 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 proporcao do JSON. Por conta disso, utilizei uma simples concatenacao de String, que foi mais rápido e eficiente.
Repare que foi utilizado o padrao Singleton para garantir que as instancias sejam unicas.
Uma coisa que devemos ter em mente e que criar uma cache, indexar as informacoes e depois busca-las pode consumir muitos ciclos de CPU, por Isso é fundamental fazer testes de performance para avaliar se realmente as vantagens compensam as desvantagens.
Exemplo do Mundo Real: String Pool
Nas linguagens Java, C#, JavaScript, entre outras, existe um String Pool, que funciona de forma similar ao padrao Flyweight, onde e possível reusar as instancias 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 referencias elas apontam para o mesmo objeto. Claro, isso so funciona porque a String e imutavel, ou seja, para modificar uma String e necessario criar uma nova. Dessa forma e possível reusar as instancias sem correr o risco de causar um efeito colateral, por isso, se for utilizar o padrao Flyweight, faca com que os dados repetitivos dos objetos compartilhados sejam imutaveis.
Conclusão
Esse e um padrao que muitas vezes utilizamos sem perceber, claro que o conceito do padrao envolve não somente uma cache, mas uma separacao entre a parte única e repetitiva do objeto, o design dele esta justamente nesse conceito.