Fui passear no Twitter e uma coisa (nominalmente a conta do HTMX) me gravitou rumo ao Uncle Bob (Clean Code, Clean Architecture, signatário do manifesto ágil etc). E eis que ele lançou um dos rants de roupão:

https://x.com/unclebobmartin/status/1919727590488588400

Em resumo, ele fala do GoF e do estudo que foi feito pelos 4 para catalogar os 23 padrões iniciais com nome, caso de uso etc, e o rant foi sobre os functional bros que falam “é tudo função, mano”.

Tem partes que concordo? Sim, quando ele fala no meio do rant que esses functional bros dizem “num liga pra isso não, isso é tão década de 1990”. Eu particularmente acho que tem seu aspecto relevante sim, por isso estou aqui oferecendo minha visão de “é tudo função, mano”.

Estou com o GoF em mãos, então vou seguir na ordem que ele coloca os padrões. A propósito, vou ilustrar as funções com TypeScript.

Em diversos pontos vou pegar textos do livro, principalmente na descrição dos padrões de projeto. Basicamente tudo que tiver em um bloco de citação neste post adveio do GoF, reimpressão de 2004 pela Bookman, versão localizada para o Brasil.

Padrões de criação

Parafraseando do livro:

Os padrões de criação abstraem o processo de instanciação.

Hmmm, então aqui vamos ter algo no formato geral:

const factory = ...;
const item1 = factory();
const item2 = factory();

Abstract factory

Fornecer uma interface para a criação de famílias de objetos […] sem especificar suas classes concretas

No exemplo, ele utiliza componentes para o look-and-feel “Motif” e para o “Presentation Manager”. Como isso para mim soa vazio, imagina que seja para TCL/TK e para HTML. Ele lida com janelas e barra de rolagem, window e scrollbar.

O cliente (que vai exibir visualmente as coisas) precisa ser capaz de entender o que deveria dar o display. Como seria isso com funções? Pois bem:

type WidgetCreation = {
    createWindow : (properties: WindowProperties) => Window,
    createScrollBar : (properties: ScrollBarProperties) => ScrollBar,
}

E… é isso. Na hora de instanciar o client só precisa criar um objeto que respeite o shape WidgetCreation e pronto, finito. O typesystem garante pra você as coisas. E inclusive o Window ou ScrollBar que ele gera nem precisa ser uma “classe”, precisa ter um formato mínimo reconhecível pra dizer que é uma window ou uma scrollbar. O client manipula windows e scrollbar e fim.

Builder

Separar a construção de um objeto complexo da sua representação de modo que o mesmo processo de construção possa criar diferentes representações.

O exemplo mais clássico que temos é o da simples criação de um objeto complexo com muitos campos. Vamos fazer um exemplo de construção matemática complexa? Fórmulas com operadores binários. O tipo de um operador é BinOp e vamos definir para os 4 básicos: adição, subtração, multiplicação e divisão. Como vamos seguir o padrão de projeto? Com clausuras!

type BinOp = (lhs: number) => LackRhs

type LackRhs = {
    (rhs: number): OpReady,
    lhs : (newLhs: number) => LackRhs
}

type OpReady = {
    () : number,
    lhs : (newLhs: number) => LackRhs,
    rhs : (newRhs: number): OpReady
}

A ideia aqui é construir o número no final. Além disso, dei a possibilidade de reescrever o operando anterior. E por fim, o builder vai estar pronto quando estiver no tipo OpReady, que aí basta chamar ele que ele irá criar o objeto, que no caso é o número alvo.

Mas, antes, vamos pegar o caso mais trivial e depois chegar nisso? Vamos ver implementado?

type BinOpTrivial = (lhs: number) => (rhs: number) => number

const soma : BinOpTrivial = (lhs) => (rhs) => lhs + rhs
const sub : BinOpTrivial = (lhs) => (rhs) => lhs - rhs
const mult : BinOpTrivial = (lhs) => (rhs) => lhs * rhs
// tratando o caso do denominador 0 só por via das dúvidas
const div : BinOpTrivial = (lhs) => (rhs) => rhs == 0 ? 0 :lhs / rhs


// ((6-1)*2)   /   (1+1)
// no final imprime 5
console.log(div(mult(sub(6)(1))(2))(soma(1)(1)))

Agora vamos ver como é para dar a opção do backtracking? Primeiro, adicionar a camada no momento desnecessário da operação pronta para construção, o OpReady, ainda sem opção de backtracking:

type BinOp_OpReadyTrivial = (lhs: number) => (rhs: number) => OpReadyTrivial
type OpReadyTrivial = () => number

const soma : BinOp_OpReadyTrivial = (lhs) => (rhs) => () => lhs + rhs
const sub : BinOp_OpReadyTrivial = (lhs) => (rhs) => () => lhs - rhs
const mult : BinOp_OpReadyTrivial = (lhs) => (rhs) => () => lhs * rhs
// tratando o caso do denominador 0 só por via das dúvidas
const div : BinOp_OpReadyTrivial = (lhs) => (rhs) => () => rhs == 0 ? 0 :lhs / rhs


// ((6-1)*2)   /   (1+1)
// no final imprime 5
console.log(div(mult(sub(6)(1)())(2)())(soma(1)(1)())())

Ok, vamos adicionar no OpReady a opção do rhs:

type BinOp_OpReadyRhs = (lhs: number) => (rhs: number) => OpReadyRhs
type OpReadyRhs = {
    (): number,
    rhs: (newRhs: number) => OpReadyRhs
}

const soma : BinOp_OpReadyRhs = (lhs) => (rhs) => {
  const baseOp: OpReadyRhs = () => lhs + rhs
  const rhsOverride = (newRhs: number) => {
    const baseOp2: OpReadyRhs = () => lhs + newRhs;
    baseOp2.rhs = rhsOverride;
    return baseOp2
  }
  baseOp.rhs = rhsOverride
  return baseOp
}

// 2 + ~~2~~  3
// no final imprime 5
console.log(soma(2)(2).rhs(3)())

Isso demanda um pouco mais de explicação. Vamos já para a operação que retorna um OpReadyRhs:

// lhs e rhs via clausura
const baseOp: OpReadyRhs = () => lhs + rhs
const rhsOverride = (newRhs: number) => {
    const baseOp2: OpReadyRhs = () => lhs + newRhs;
    baseOp2.rhs = rhsOverride;
    return baseOp2
}
baseOp.rhs = rhsOverride
return baseOp

No primeiro lugar, a operação pura. Apenas o LHS somado ao RHS. Como visto no Recursão a moda clássica em TS: auto referência do tipo de função, para um tipo que é uma função e um objeto com atributo, para declarar e o compilador ficar feliz, uma estratégia é começar pela função

const baseOp: OpReadyRhs = () => lhs + rhs

e então posteriormente adicionar o atributo

baseOp.rhs = rhsOverride

Para rhsOverride, eu comecei declarando uma nova operação base:

const baseOp2: OpReadyRhs = () => lhs + newRhs;

e então, aproveitando a criação do rhsOverride, usei ele recursivamente dentro dele para terminar de povoar baseOp2:

baseOp2.rhs = rhsOverride

E pronto, magia!

Agora, basta replicar esse mesmo pensamento para as outras operações:

type BinOp_OpReadyRhs = (lhs: number) => (rhs: number) => OpReadyRhs
type OpReadyRhs = {
    (): number,
    rhs: (newRhs: number) => OpReadyRhs
}

const soma : BinOp_OpReadyRhs = (lhs) => (rhs) => {
  const baseOp: OpReadyRhs = () => lhs + rhs
  const rhsOverride = (newRhs: number) => {
    const baseOp2: OpReadyRhs = () => lhs + newRhs;
    baseOp2.rhs = rhsOverride;
    return baseOp2
  }
  baseOp.rhs = rhsOverride
  return baseOp
}

const sub : BinOp_OpReadyRhs = (lhs) => (rhs) => {
  const baseOp: OpReadyRhs = () => lhs - rhs
  const rhsOverride = (newRhs: number) => {
    const baseOp2: OpReadyRhs = () => lhs - newRhs;
    baseOp2.rhs = rhsOverride;
    return baseOp2
  }
  baseOp.rhs = rhsOverride
  return baseOp
}

const mult : BinOp_OpReadyRhs = (lhs) => (rhs) => {
  const baseOp: OpReadyRhs = () => lhs * rhs
  const rhsOverride = (newRhs: number) => {
    const baseOp2: OpReadyRhs = () => lhs * newRhs;
    baseOp2.rhs = rhsOverride;
    return baseOp2
  }
  baseOp.rhs = rhsOverride
  return baseOp
}
// tratando o caso do denominador 0 só por via das dúvidas
const div : BinOp_OpReadyRhs = (lhs) => (rhs) => {
  const baseOp: OpReadyRhs = () => rhs == 0 ? 0 : lhs / rhs
  const rhsOverride = (newRhs: number) => {
    const baseOp2: OpReadyRhs = () => newRhs == 0 ? 0 : lhs / newRhs;
    baseOp2.rhs = rhsOverride;
    return baseOp2
  }
  baseOp.rhs = rhsOverride
  return baseOp
}


// 2 + ~~3~~  2
// no final imprime 5
console.log(soma(2)(2).rhs(3)())

// ((6-1)*2)   /   (1+1)
// no final imprime 5
console.log(div(mult(sub(6)(1)())(2)())(soma(1)(1)())())

Ok, agora para o experimento em que o OpReady está completo! Podendo sobrescrever LHS e RHS! Começar na adição:

type BinOp_OpReady = (lhs: number) => (rhs: number) => OpReady
type OpReady = {
    (): number,
    lhs: (newlhs: number) => OpReady
    rhs: (newRhs: number) => OpReady
}

const soma : BinOp_OpReady = (lhs) => (rhs) => {
  const baseOp: OpReady = () => lhs + rhs
  baseOp.lhs = (newLhs) => soma(newLhs)(rhs)
  const rhsOverride = (newRhs: number) => {
    const baseOp2: OpReady = () => lhs + newRhs;
    baseOp2.lhs = (newLhs) => soma(newLhs)(newRhs)
    baseOp2.rhs = rhsOverride;
    return baseOp2
  }
  baseOp.rhs = rhsOverride
  return baseOp
}

// 2 + ~~3~~  2
// no final imprime 5
console.log(soma(2)(2).rhs(3)())

// 2 + 2         ... ?
// ~~2~~ 3 + 2   ... ?
// 3 + ~~2~~ 3   ... ?
// ~~3~~ 2 + 3   ... ?
// 2 + 3         ... !
// no final imprime 5
console.log(soma(2)(2).lhs(3).rhs(3).lhs(2)())

Bora lá explicar? Bora! A parte do RHS se mantém intacta. Afinal, só mudou a adição do LHS. E o que é adicionar o LHS se não começar a operação tudo de novo? Só aproveitando o RHS corrente? Pois foi isso que fiz. No caso externo:

const baseOp: OpReady = () => lhs + rhs
baseOp.lhs = (newLhs) => soma(newLhs)(rhs)

E no caso interno, que só preicsei tomar cuidado de usar o newRhs no lugar do rhs da clausura:

const baseOp2: OpReady = () => lhs + newRhs;
baseOp2.lhs = (newLhs) => soma(newLhs)(newRhs)

Aplicando o mesmo conceito nos outros operadores:

type BinOp_OpReady = (lhs: number) => (rhs: number) => OpReady
type OpReady = {
    (): number,
    lhs: (newlhs: number) => OpReady
    rhs: (newRhs: number) => OpReady
}

const soma : BinOp_OpReady = (lhs) => (rhs) => {
  const baseOp: OpReady = () => lhs + rhs
  baseOp.lhs = (newLhs) => soma(newLhs)(rhs)
  const rhsOverride = (newRhs: number) => {
    const baseOp2: OpReady = () => lhs + newRhs;
    baseOp2.lhs = (newLhs) => soma(newLhs)(newRhs)
    baseOp2.rhs = rhsOverride;
    return baseOp2
  }
  baseOp.rhs = rhsOverride
  return baseOp
}

const sub : BinOp_OpReady = (lhs) => (rhs) => {
  const baseOp: OpReady = () => lhs - rhs
  baseOp.lhs = (newLhs) => sub(newLhs)(rhs)
  const rhsOverride = (newRhs: number) => {
    const baseOp2: OpReady = () => lhs - newRhs;
    baseOp2.lhs = (newLhs) => sub(newLhs)(newRhs)
    baseOp2.rhs = rhsOverride;
    return baseOp2
  }
  baseOp.rhs = rhsOverride
  return baseOp
}

const mult : BinOp_OpReady = (lhs) => (rhs) => {
  const baseOp: OpReady = () => lhs * rhs
  baseOp.lhs = (newLhs) => mult(newLhs)(rhs)
  const rhsOverride = (newRhs: number) => {
    const baseOp2: OpReady = () => lhs * newRhs;
    baseOp2.lhs = (newLhs) => mult(newLhs)(newRhs)
    baseOp2.rhs = rhsOverride;
    return baseOp2
  }
  baseOp.rhs = rhsOverride
  return baseOp
}
// tratando o caso do denominador 0 só por via das dúvidas
const div : BinOp_OpReady = (lhs) => (rhs) => {
  const baseOp: OpReady = () => rhs == 0 ? 0 : lhs / rhs
  baseOp.lhs = (newLhs) => div(newLhs)(rhs)
  const rhsOverride = (newRhs: number) => {
    const baseOp2: OpReady = () => newRhs == 0 ? 0 : lhs / newRhs;
    baseOp2.lhs = (newLhs) => div(newLhs)(newRhs)
    baseOp2.rhs = rhsOverride;
    return baseOp2
  }
  baseOp.rhs = rhsOverride
  return baseOp
}

// 2 + ~~3~~  2
// no final imprime 5
console.log(soma(2)(2).rhs(3)())

// 2 + 2         ... ?
// ~~2~~ 3 + 2   ... ?
// 3 + ~~2~~ 3   ... ?
// ~~3~~ 2 + 3   ... ?
// 2 + 3         ... !
// no final imprime 5
console.log(soma(2)(2).lhs(3).rhs(3).lhs(2)())

// ((6-1)*2)   /   (1+1)
// no final imprime 5
console.log(div(mult(sub(6)(1)())(2)())(soma(1)(1)())())

Agora vamos para a versão final? Não só o OpReady pode redefinir o LHS, como a operação logo no começo dela pode fazer isso. Basicamente vou pegar a ideia de usar .lhs = (newLhs) => OPERACAO(newLhs) e aplicar no resultado intermediário. O final não importa, não vai ser alterado.

type BinOp = (lhs: number) => LackRhs
type LackRhs = {
    (rhs: number): OpReady,
    lhs: (newLhs: number) => LackRhs
}
type OpReady = {
    (): number,
    lhs: (newlhs: number) => OpReady
    rhs: (newRhs: number) => OpReady
}

const soma : BinOp = (lhs) => {
    const baseLackRhs: LackRhs = (rhs) => {
        const baseOp: OpReady = () => lhs + rhs
        baseOp.lhs = (newLhs) => soma(newLhs)(rhs)
        const rhsOverride = (newRhs: number) => {
            const baseOp2: OpReady = () => lhs + newRhs;
            baseOp2.lhs = (newLhs) => soma(newLhs)(newRhs)
            baseOp2.rhs = rhsOverride;
            return baseOp2
        }
        baseOp.rhs = rhsOverride
        return baseOp
    }
    baseLackRhs.lhs = (newLhs) => soma(newLhs)
    return baseLackRhs
}

// 2 + ~~3~~  2
// no final imprime 5
console.log(soma(2)(2).rhs(3)())

// 2 + 2         ... ?
// ~~2~~ 3 + 2   ... ?
// 3 + ~~2~~ 3   ... ?
// ~~3~~ 2 + 3   ... ?
// 2 + 3         ... !
// no final imprime 5
console.log(soma(2)(2).lhs(3).rhs(3).lhs(2)())

// 5            ... ?
// ~~5~~ 2      ... !
// 2 + 2        ... ?
// 2 + ~~2~~  3 ... !
// no final imprime 5
console.log(soma(5).lhs(2)(2).rhs(3)())

Mas isso porque eu quis dificultar o builder e dar acesso a coisas de trás! Normalmente builders são mais lineares.

Factory Method

Definir uma interface para criar um objeto, mas deixar as subclasses decidirem que classe instanciar. O Facotry Method permite adiar a instanciação.

Tive de recorrer ao Refactoring Guru para o guaxinim me dar um exemplo mais concreto.

Pegando o exemplo fornecido pelo Refactoring Guru: em um software de logística, preciso definir se devo enviar uma carega via caminhão ou navio. O caminhão precisa ser carregado com as informações de viagens terrestres e o navio com as informações de viagens marítimas. Enquanto isso o por cima do software só precisa se preocupar com “vai ter algo que vai viajar e entregar”.

Então…

type AbstractFactory<T> = () => T

Ou qualquer combinação de input. Por exemplo, origem e destino:

type FactoryRota = (origem: Lugar, destino: Lugar) => Rota

E basicamente precisa atender a essa assinatura. Só isso. Muitos problemas simplesmente somem quando você lida com tipagem estrutural no lugar de tipagem nominal.

Prototype

Especificar os tipos de objetos a serem criados usando uma instância protótipo e criar novos objetos pela cópia desse protótipo.

Pois bem, esse parece legal. Vamos pegar um protótipo de carro vermelho, quero gerar um carro igual a esse vermelho mudando que agora eu quero que seja amarelo. Ou que rode baseado em álcool/elétrico. Enfim.

Basicamente o operador spread permite isso de maneira muito simples. Para dar a opção de trabalhar com todos os parâmetros de customização possível, usemos o tipo Partial do TS:

type Carro = {
    cor: string,
    motor: ("elétrico" | "gasolina" | "diesel" | "álcool")[]
}

type CarroCustom = Partial<Carro>

const prototipado = (carroPrototipo: Carro) => (custom: CarroCustom) => ({...carroPrototipo, ...custom})

const carroVermelho: Carro = {
    cor: "vermelho",
    motor: ["gasolina"]
}

const fabrica = prototipado(carroVermelho)

const carroAmarelo = fabrica({cor: "amarelo"})
console.log(carroAmarelo)

const carroEletroAlcool = fabrica({motor: ["elétrico", "álcool"]})
console.log(carroEletroAlcool)

Basicamente o prototipado vai garantir que os objetos criados sejam feitos com base na semelhança do objeto protótipo passado como opção.

Novamente aqui a tipagem estrutural ajudando a combater coisas que teríamos com tipagem nominal.

No livro os objetos implementam clone. Mas esse detalhe é irrelevante para TS e sua tipagem estrutural. A escolha continuaria sendo usar um spread para representar a operação de clone.

Singleton

Garantir que uma classe tenha somente uma instância e fornecer um ponto global de acesso.

import {instance} from "./modulo.js"

E claro que no modulo devo exportar a constante instance.

Esse é um tipo de padrão de projeto que foi resolvido no mundo Java, por exemplo, através de injeção de dependência do Spring. Existem casos como em dependências circulares que você precisaria obter esse objeto, mas existem outros caminhos para isso que não a injeção a priori para ter um objeto bem formado em tempo de instanciação, mas esse tipo de dependência deveria ser muito rara.

O maior problema do lazy singleton é a questão de “e se chegarem duas threads ao mesmo tempo?”, situação em que muitas pessoas vão adotar o padrão “double checked lock”.

Mas para isso a solução é delegar a uma parte da própria linguagem que ela vai ter mais ferramentas de controle do que você. Por exemplo, em Java, é comum fazer uma volta dessas para obter o singleton:

public class MustBeSingleton {

    private MustBeSingleton() {
    }

    public static MustBeSingleton getInstance() {
        return SINGLETON_LOADER.singleton;
    }

    private static class SINGLETON_LOADER {
        static final MustBeSingleton singleton = new MustBeSingleton();
    }
}

Isso funciona em Java porque você só pode mencionar a classe MustBeSingleton. Ela é então carregada e inicializada, e ela não guarda instância de si mesma, não é um eager singleton. No momento em que alguém for dar um getInstance(), o que vai acontecer é que ele vai tentar identificar MustBeSingleton$SINGLETON_LOADER, a classe interna, e vai verificar que a classe não foi carregada. Nesse momento o classloader do Java vai se travar e nenhuma outra thread mais vai poder carregar nenhuma classe (lock). Logo, carregamentos em paralelo não vão ocorrer, vai sequenciar.

Logo em seguida, ao inicializar a classe MustBeSingleton$SINGLETON_LOADER o campo MustBeSingleton$SINGLETON_LOADER.singleton vai ficar preenchido e então o classloader vai liberar. Quem estava esperando o classloader soltar vai encontrar uma classe carregada e vai retornar esse campo.

Padrões estruturais

Parafraseando da primeira frase do capítulo 4:

Os padrões estruturais se preocupam com a forma como classes e objetos são compostos para formar estruturas maiores.

Ele menciona que “estruturas de classes” resolve na herança, incluindo herança múltipla. E que “estruturas de objetos” ele faz pondo objetos em cima do outro sem mexer com a classe em si.

Adapter

Adapter permite que classes com interfaces incompatíveis trabalhem em conjunto o que, de certa forma, seria impossível.

Aqui ele está falando de interface. E tem dois pensamentos para isso:

  • shape
  • conjunto de métodos que podem ser chamados

Ok, muito bem. Para adaptar o shape é algo fácil: só criar uma função de mapeamento: f(input: I): O.

Por exemplo: peguemos aqui 2 objetos bem distintos:

  • triângulo
  • círculo

Como seriam os shapes deles?

type Triangulo = {
    a: number,
    b: number,
    c: number
}

type Circulo = {
    r: raio
}

Onde o triângulo é fornecido pelos seus lados e o círculos descrito pelo seu raio. Podemos precisar adaptar para um componente de preço que vai pegar um ElementoGeometrico e gerar o preço a partir do perímetro e da área. No caso, vamos considerar no exemplo que todo triângulo é bem formado (ie, a soma de quaisquer dois lados é maior do que o terceiro lado).

Vamos definir o tipo para ElementoGeometrico e então fazer os adaptadores:

type ElementoGeometrico = {
    perimetro : number,
    area: number
}

function perimetroTriangulo(t: Triangulo): number {
    return t.a + t.b + t.c
}

function areaTriangulo(t: Triangulo): number {
    // fórmulan de Herón
    const s = perimetroTriangulo(t)/2

    return Math.sqrt(s * (s-a) * (s-b) * (s-c))
}

function perimetroCirculo(c: Circulo): number {
    return 2*Math.PI*c.r
}

function areaCirculo(c: Circulo): number {
    return Math.PI * c.r * c.r
}

function adapterTriangulo2ElementoGeometrico(t: Triangulo): ElementoGeometrico {
    return {
        perimetro: perimetroTriangulo(t),
        area: areaTriangulo(t)
    }
}

function adapterCirculo2ElementoGeometrico(c: Circulo): ElementoGeometrico {
    return {
        perimetro: perimetroCirculo(c),
        area: areaCirculo(c)
    }
}

Muito bem, adaptamos o shape, falta agora a questão de comportamento. Imagina que você precisa receber um objeto do tipo Repository, e nele temos o seguinte comportamento:

  • pode inserir novos elementos
  • pode remover elemento (dado uma condição)
  • pode atualizar elemento (dada uma função de transformação e uma condição de ativação)
  • resgata elementos que satisfazem uma condição

Em outras palavras:

type Repository<T> = {
    insert : (el: T) => void,                                             // insere um novo
    removeIf : (condition : (el:T) => boolean) => void,                   // remove os que preciso
    getAll : (condition : (el:T) => boolean) => T[],                      // resgata tudo que satisfaz
    update : (condition : (el:T) => boolean, map : (el:T) => T) => void   // resgata tudo que satisfaz
}

Muito bem. Vamos começar com um array? Vamos criar um adaptador para o array ser usado tal qual esse Repository acima descrito.

function adapter2array<T>(elements: T[]): Repository<T> {
    type Conditional = (el:T) => boolean;
    const insert = (t: T) => {
        elements.push(t)
    }
    const removeIf = (cond: Conditional) => {
        elements = elements.filter(e => !cond(e))
    }
    const getAll = (cond: Conditional) => {
        return elements.filter(cond)
    }
    const update = (cond: Conditional, map: (el:T) => T) => {
        elements = elements.map(e => cond(e) ? map(e): e)
    }

    return {
        insert,
        removeIf,
        getAll,
        update
    }
}

E como usar? Bem, imagina que eu quero manipular um repositório de números:

const repository: Repository<number> = ...
repository.insert(6)

const pares = arrayRepository.getAll(e => e % 2 == 0)

arrayRepository.update(e => e % 2 != 0, e => 3*e + 1)
arrayRepository.removeIf(e => e >= 5)

Mas como eu posso criar um repositório? Uma das alternativas é adaptar um array! E nós já sabemos como adaptar o array!

const numbers = [ 1, 2, 3 ]

const arrayRepository = adapter2array(numbers)
console.log(arrayRepository.getAll(_ => true))
// 1, 2, 3

arrayRepository.insert(6) // [1, 2, 3, 6]
console.log(arrayRepository.getAll(_ => true))
// 1, 2, 3, 6
console.log(arrayRepository.getAll(e => e % 2 == 0))
// 2, 6

arrayRepository.update(e => e % 2 != 0, e => 3*e + 1) // [4, 2, 10, 6]
console.log(arrayRepository.getAll(_ => true))
// 4, 2, 10, 6


arrayRepository.removeIf(e => e >= 5) // [4, 2]
console.log(arrayRepository.getAll(_ => true))
// 4, 2

O array não tinha suporte a nada disso, mas nada como por o adaptar no lugar, né? Só uma função que adapte o básico do objeto e permita ele obedecer outra sequência de protocolos.

Apesar de um livro focar que adaptador é de uma classe para outra, podemos compor também para uma entrada de múltiplos elementos. Tudo questão de modelagem.

Bridge

Desacoplar uma abstração de sua implementação, de modo que as duas possam evoluir independentemente.

Na minha leitura não vi grande diferença entre o bridge e o adapter, então agora recorri ao Stack Overflow, e eis que encontro essa resposta do Jeff Wilcox:

O padrão bridge vai permitir que você tenha implementações alternativas de um algoritmo/sistema.

O exemplo que ele menciona: para salvar, quem vê de fora só chama “salvar”, eles chamam a bridge que vai de fato chamar quem faz as operações finais. E eu posso ter implementações completamente contrárias: uma que se baseia em tempo e outra que vai salvar ocupando menos espaço em disco.

A interface vai continuar sendo a mesma, e então colocamos uma ponte pra ligar a interface as implementações. Em outras palavras:

  • Adapter: você já tem algo que funciona e se molda a ele
  • Bridge: desenho upfront para o funcionamento desacoplado

No fundo, coisas bem semelhantes, mas motivações distintas. Então o exemplo de adaptador que eu coloquei acima pode ser usado para a bridge sem problema.

Como aqui estamos falando de tipagem estrutural e não tipagem nominal, a questão da necessidade de que o adapter seja uma subclasse de algo some, tornando efetivamente um ponto de vista/ponto de origem de pensamento o que causa a distinção. Isso se reflete no GoF logo ao falar da motivação da bridge, que demonstra que é um language issue:

Quando uma abstração pode ter uma entre várias implementações possíveis, a maneira usual de acomodá-las é usando a herança.

E de lá ele continua explicando a questão da herança e reuso do código e também dos problemas. Mas isso é uma das mentiras do OOP que eu expus neste artigo As mentiras que te contaram sobre OOP. Especificamente a mentira #2: Reuso de software através de métodos. Me parafraseando: “Isso aí é desculpa pra herança”.

Por fim, defendendo o que foi pontuado, logo no final da seção sobre bridge, no GoF está escrito algo que corrobora com o que pesquei das respostas do Stack Overflow (ênfase minha):

O padrão adapter é orientado para fazer com que classes não-relacionadas trabalhem em conjunto. Ele é normalmente aplicado a sistemas que já foram projetados. Por outro lado, bridge é usado em um projeto, desde o início, para permitir que abstrações e implementações possam variar independentemente.

Composite

Compor objetos em estruturas de árvore para representarem uma hierarquia partes-todo. Composite permite aos clientes tratarem de maneira uniforme objetos individuais e composição de objetos.

Na parte da motivação no GoF tem uma explicação um pouco mais aprofundada:

O padrão composite descreve como usar a composição de maneira recursiva de maneira que os clientes não tenham que fazer essa distinção.

Ou seja: basicamente um shape em que eu tenho primitivos e containeres que por sua vez são compostos por elementos desse mesmo shape. Aqui esse padrão é muito mais relacionado com tipagem do que com funções.

O exemplo motivador do composite é um componente gráfico:

type LeafComponent = {
    draw: () => void
};

type ContainerComponent = {
    addComponent : (c: Component) => void,
    removeComponent : (c: Component) => void,
    children : () => Component[],
    draw: () => void
};

type Component = {
    draw: () => void
};

Note que a tipagem estrutural não faz com que seja necessário listar os components individuais como sumtype, apenas que eles tenham o shape. Aqui um modelo bem simples colocando containeres e elementos dentro do container:

type LeafComponent = {
    draw: () => void
};

type ContainerComponent = {
    addComponent : (c: Component) => void,
    removeComponent : (c: Component) => void,
    children : () => Component[],
    draw: () => void
};

type Component = {
    draw: () => void
};

function simpleContainer() : ContainerComponent {
    let children: Component[] = []
    return {
        addComponent : (c) => {
            children.push(c)
        },
        removeComponent : (c) => {
            children = children.filter(e => e != c)
        },
        children: () => {
            return children
        },
        draw: () => {
            console.log("[")
            for (const c of children) {
                c.draw();
            }
            console.log("]")
        }
    }
}

function labelComponent(s: string) {
    return {
        draw : () => console.log(s)
    }
}

const container1 = simpleContainer();
const container2 = simpleContainer();

container1.addComponent(labelComponent("before child"))
container1.addComponent(container2)
container1.addComponent(labelComponent("after child"))

container2.addComponent(labelComponent("child"))

container1.draw()

/*

 [
 before child
 [
 child
 ]
 after child
 ]

 */

Decorator

Dinamicamente, agregar responsabilidades adicionais a um objeto. Os decorators fornecem uma alternatival flexível ao uso de subclasses para extensão de funcionalidades.

A ideia do decorator é alterar o comportamento de um objeto de uma classe. Como que se faz isso? Vamos primeiro dar uma olhada em um simples wrapper que passa adiante uma chamada que recebe dois inteiros:

function myFunction(base: string, m: number, n: number): string {
    return `${base}: ${m} + ${n}`
}

function decorated<T, R>(base: T, m: number, n: number, notDecorated: (b: T, m: number, n: number) => R): R {
    return notDecorated(t, m, n)
}

console.log(myFunction("soma", 2, 3))
console.log(decorated("soma", 2, 3, myFunction))

Note que aqui estou fazendo uma “decoração explícita”, o que o decorator não deveria transparecer, mas é só pelo exemplo. Além disso, tem outro ponto no decorator: estou tratando aqui uma função que recebe o objeto como argumento como sendo um método. Isso é uma aproximação preguiçosa a fim de mostrar o exemplo. Afinal, o que é decorar uma chamada?

Basicamente, tem algumas coisas acontecendo na chamada da função não decorada:

  • tem um retorno específico
  • estou ativamente escolhendo chamar a função não decorada
  • estou ativamente escolhendo passar os argumentos para a função não decorada
  • ativamente não tem nada antes
  • ativamente não tem nada depois
  • ativamente está sendo usado o retorno da função sendo chamada

Agora, vamos desenhar um decorator real, o decorator noop, para uma função que recebe um elmento base e dois parâmetros inteiros:

function myFunction(base: string, m: number, n: number): string {
    return `${base}: ${m} + ${n}`
}

function noopDecorator<T, R>(baseFunction: (base: T, m: number, n: number) => R): (base: T, m: number, n: number) => R {
    return (base: T, m: number, n: number) => baseFunction(base, m, n)
}

const decorated = noopDecorator(myFunction)


console.log(myFunction("soma", 2, 3))
console.log(decorated("soma", 2, 3))

O que podemos fazer pra decorar? Vamos olhar os pontos levantados anteriormente de coisas sendo feitas e para cada ponto fazer uma decoração distinta.

Tem um retorno específico

Ok, que tem um retorno tem. Podemos alterar o tipo do retorno, mas para isso precisamos também mapear o retorno possivelmente. Isso vai ser tratado posteriormente, e normalmente para o cenário de decoração que o GoF traz não é algo válido.

Uma decoração que muda o tipo do retorno pode ser usado em um adapter, por sinal.

Estou ativamente escolhendo chamar a função não decorada

Vamos ativamente não chamar a função decorada? Imagina que para a entrada m == 0 querendo simplesmente retornar chamada ruim, aqui retornando apenas strings:

function mNot0<T, string>(baseFunction: (base: T, m: number, n: number) => string): (base: T, m: number, n: number) => string {
    return (base: T, m: number, n: number) => {
        if (m === 0) {
            return "chamada ruim"
        }
        return baseFunction(base, m, n)
    }
}

Com isso, decoramos quando que ocorrerá a invocação, apenas para quando o m não for 0.

Estou ativamente escolhendo passar os argumentos para a função não decorada

Posso simplesmente fixar os valores e permitir quem chame de continuar chamando sem medo:

function params_0_0<T, R>(baseFunction: (base: T, m: number, n: number) => R): (base: T, m: number, n: number) => R {
    return (base: T, m: number, n: number) => baseFunction(base, 0, 0)
}

Ok, isso foi um exemplo extremo. Eu posso passar qualquer mudança nos parâmetros:

function paramsDobrados<T, R>(baseFunction: (base: T, m: number, n: number) => R): (base: T, m: number, n: number) => R {
    return (base: T, m: number, n: number) => baseFunction(base, 2*m, 2*n)
}

Isso pode ser útil quando quero, por exemplo, decorar um elemento para ficar “mais vermelho”, adicionando uma camada de “vermelhidão” nas cores (essa camada normalmente é uma multiplicação/soma no vetor de cores RGB seguindo alguma lógica).

Ou então também dá para simplesmente trocar os parâmetros de ordem

function paramsDobrados<T, R>(baseFunction: (base: T, m: number, n: number) => R): (base: T, m: number, n: number) => R {
    return (base: T, m: number, n: number) => baseFunction(base, n, m)
}

Enfim, a bagunça que se quiser fazer nas chamadas.

Ativamente não tem nada antes

Se aqui a escolha é não ter nada antes, o contrário é ter algo antes!

function logaAntes<T, R>(baseFunction: (base: T, m: number, n: number) => R): (base: T, m: number, n: number) => R {
    return (base: T, m: number, n: number) => {
        console.log(`chamando com m: ${m} e n: ${n}`)
        return baseFunction(base, m, n)
    }
}

Isso pode ser feito para gerar efeitos colaterais, algumas vezes para ter um trace debug. Assim, se você quiser manter um log de chamadas você pode decorar uma função para fazer isso, diminuindo o quanto de mudança seria necessária na codebase e podendo focar em “e se o objeto que estiver sendo atingido for esse, que é alcançado nessa rota afinal?”.

Ativamente não tem nada depois

Literalmente o que foi dito na seção anterior, mas agora aplicada a o que acontece depois da chamada:

function logaDepois<T, R>(baseFunction: (base: T, m: number, n: number) => R): (base: T, m: number, n: number) => R {
    return (base: T, m: number, n: number) => {
        const r = baseFunction(base, m, n)
        console.log(`chamou com m: ${m} e n: ${n} e o resultado foi ${r}`)
        return r
    }
}

Logar depois permite criar uma base bem legal de entrada/saída obtida para fazer migração de código. Esse conjunto de informações é utilizado para testar uma substituição feita no padrão strangler fig.

Ativamente está sendo usado o retorno da função sendo chamada

E se eu quiser multiplicar por 3 e somar um no resultado?

function tres_r_mais_1<T>(baseFunction: (base: T, m: number, n: number) => number): (base: T, m: number, n: number) => number {
    return (base: T, m: number, n: number) => {
        const r = baseFunction(base, m, n)
        return 3*r + 1
    }
}

Note que, por facilidade, estou decorando agora funções que retornam números.

Retornando ao assunto…

Vimos aqui onde interceptar as coisas de uma chamada de função com uma decoração. Sabe onde é muito aplicado a questão de usar decoradores? Em programação orientada a aspecto!

Por exemplo, eu posso fazer uma decoração para iniciar uma transação com o banco de dados. Ou então para logar quando entrou/saiu para ter uma ideia de performance (ou só logar quando saiu, contanto o tempo desde antes da chamada até o depois da chamada).

No cenário do GoF, eles colocam decorators para adicionar comportamentos específicos em um método. Vou pegar o exemplo de repositório usado no adapters para fazer uma decoração nele:

type Repository<T> = {
    insert : (el: T) => void,                                             // insere um novo
    removeIf : (condition : (el:T) => boolean) => void,                   // remove os que preciso
    getAll : (condition : (el:T) => boolean) => T[],                      // resgata tudo que satisfaz
    update : (condition : (el:T) => boolean, map : (el:T) => T) => void   // resgata tudo que satisfaz
}

function decorateRepositoryWithLoggingInsert<T>(base: Repository<T>): Repository<T> {
    const novoInsert = (el: T) => {
        console.log(`inserindo novo elemento >${el}<`)
        base.insert(el)
    }
    return {
        ...base,
        insert: novoInsert
    }
}

function decorateRepositoryWithLoggingRemovedCount<T>(base: Repository<T>): Repository<T> {
    const novoRemove = (condition : (el:T) => boolean) => {
        console.log(`removendo >${base.getAll(condition)}< elementos`)
        base.update(condition, map)
    }
    return {
        ...base,
        removeIf: novoRemove
    }
}

A decoração pode ser de quantos métodos quiser, não precisa ser um por um:

function decorateRepositoryWithLoggingChangedCount<T>(base: Repository<T>): Repository<T> {
    const novoRemove = (condition : (el:T) => boolean) => {
        console.log(`removendo >${base.getAll(condition)}< elementos`)
        base.removeIf(condition)
    }
    const novoUpdate = (condition : (el:T) => boolean, map : (el:T) => T) => {
        console.log(`atualizando >${base.getAll(condition)}< elementos`)
        base.update(condition, map)
    }
    return {
        ...base,
        removeIf: novoRemove,
        update: novoUpdate
    }
}

Façade

Fornecer uma interface unificada para um conjunto de interfaces em um subsistema. Façade define uma interface de nível mais alto que torna o subsistema mais fácil de ser usado.

Basicamente, o façade é uma espécie de adapter invertido: eu chamo a fachada e deixo que ela delibere quem chamar e como chamar. O exemplo do GoF é um compilador que conhece quem é o scanner/parser/analisar sintático/etc. Ele pode ser um delegador e/ou orquestrador.

Um exemplo de fachada que se usa no mundo Java? Usar Spring Boot para gerenciar os end-points HTTP. O Spring Boot está criando a fachada para que se chegue uma requisição HTTP pelo código cliente e ele saiba como direcionar para a função correta.

Vamos fazer uma fachada bobinha? Para um sistema de cache, vamos fornecer um ID e retornar o objeto. Temos um cache local, um cache no Redis (abstraído já em uma função adequada) e finalmente no banco de dados. Vamos procurar?

// as variáveis não estão exportadas
let proximaInsercao = 0;
const sizeMemoria = 30
const memoriaCurta: ([string, CoisaComplexa]|null)[] = inicia(sizeMemoria)

// inicia: procedimento para iniciar a memória de curto prazo
// fornecido por fora

// não preciso expor isso
function buscaMemoriaCurta(id: string): CoisaComplexa|null {
    for (const e of memoriaCurta) {
        if (e == null) {
            continue
        }
        const [idEl, el] = e
        if (idEl == id) {
            return el
        }
    }
    return null
}

// nem preciso expor isso
function insereMemoria(id: string, el: CoisaComplexa) {
    memoriaCurta[proximaInsercao] = [id, el]
    proximaInsercao += 1
    if (proximaInsercao >= sizeMemoria) {
        proximaInsercao = 0
    }
}

// exporto isso
// buscaRedis e buscaBanco são funções importadas na fachada
function fachadaDeAcesso(id: string): CoisaComplexa|null {
    const pelaMemoriaCurta = buscaMemoriaCurta(id)
    if (pelaMemoriaCurta != null) {
        return pelaMemoriaCurta
    }
    const peloRedis = buscaRedis(id)
    if (peloRedis != null) {
        return peloRedis
    }
    return buscaBanco(id)
}

// e exporto isso
// insereBanco e insereRedis são funções importadas na fachada
function fachadaDeEscrita(id: string, el: CoisaComplexa) {
    insereBanco(id, el)
    insereRedis(id, el)
    insereMemoria(id, el)
}

Flyweight

Usar compartilhamento para suportar eficientemente grandes quantidades de objetos de granularidade fina.

Vamos imaginar a seguinte situação: estamos em um joguinho, e precisamos carregar a imagem de um inimigo. Um Koopa Troopa. Essa imagem é dada através de uma sprite, sprite essa que está em um arquivo.

Pra uma fase que tem vários Koopa Troopas, o jeito, digamos, mais naïve de se implementar seria fazer uma carga de sprite por Koopa Troopa. Isso significa que eu gastaria mais tempo de carregamento de disco (potencialmente otimizado por rotinas de cache de leitura do sistema operacional), mais processamento para fazer a carga e mais memória pois cada inimigo ocuparia um espaço de memória com sua sprite.

Mas, os sprites são os mesmos, não são? Então por que não carregar o mesmo sprite no lugar de ficar fazendo esforço desnecessário? Posso identificar pelo nome do arquivo e tratar o carregamento dessa forma:

// função privada
function loadSprite(path: string): Sprite | null {
    // ...
}

type PlaceholderType = {}
const placeholder: PlaceholderType = {}
const cache: Record<string, Sprite | PlaceholderType> = {}

function isPlaceholder(o: Sprite | PlaceholderType | null): o is PlaceholderType {
    return o === placeholder
}

// função exportada
function getSprite(path: string): Sprite | null {
    const prev = cache[path]
    if (isPlaceholder(prev)) {
        return null
    }
    if (prev) {
        return prev
    }
    const created = loadSprite(path)
    cache[path] = created != null? created : placeholder
    return created
}

Com isso, posso colocar todos os sprites para pegar da mesma fonte. Além desse uso, tem outras coisinhas que posso aplicar com flyweight: por exemplo, Koopas vermelhos são apenas Koopas verdes que mudou a palheta de cores da sprite. A priori a palheta de cores está no carregamento da sprite, mas também é possível indicar que, naquele instante, a renderização do sprite vai ser feito com uma decoração da palheta, no lugar de pegar a palheta direta do sprite.

Na hora de renderizar, é importante que cada elemento do jogo tenha um “estado de animação”, pois assim eles podem usar o mesmo sprite cortanto “frames” distintos justamente para poder emular animações na pixel art. Então no final eu teria algo assim:

type VisualElement = {
    coord: Coord,
    graphic: Graphic,
    state: number
}

type Graphic = Sprite | ColorSwappedSprite

type ColorSwappedSprite = {
    baseSprite: Sprite,
    pallete: Pallete
}

...

Tal qual os sprites foram cacheados em termos de path para o arquivo, os inimigos com color swap eu posso pegar de outra facotry com cache que pega não só o sprite base, como também aplica a palheta passada.

Note que apesar de que o desenho final necessite saber qual a posição do elemento visual, o desenho do sprite em si é bem dizer o mesmo, no máximo com a palheta de cores trocadas. E para desenhar é necessário o estado do sprite para pegar o quadro certo para renderizar.

No GoF ele fala de “estado intrínseco” e “estado extrínseco”, usando como exemplo fonte tipográfica. Uma fonte Times 12 vai ser sempre uma fonte Times 12, não importa a posição do texto. A posição portanto se torna algo extrínseco às propriedades usadas no desenho da fonte, assim como aqui no caso do sprite, tanto o estado quanto as coordenadas (x,y) são propriedades não inerentes ao sprite em si, mas que pertencem ao ato de renderizar.

Um outro exemplo para flyweight? Que tal… prepared statements? Podemos manter na mesma conexão do banco de dados um conjunto de statements preparadas, de modo que, ao chegar uma nova requisição de preparar uma statement, se por acaso for referente a uma query que já foi preparada antes, podemos reusar a statement! Isso vai evitar uma round trip nova para o banco de dados para fazer novamente o mesmo trabalho.

Aqui, o flyweight é um pouco mais do que uma função apenas:

  • precisa da função de entrada
  • precisa de uma memória
  • precisa de uma factory

A memória é um chaveamento condições de valores para o elemento em si. A única coisa necessária expor para o código que consome o flyweight é a função de entrada, a factory e a memória podem ser internalizadas.

Proxy

Fornecer um substituo (surrogate) ou marcador da localização de outro objeto para controlar o acesso ao mesmo.

Já usamos proxy pelo menos duas vezes aqui no Computaria:

No caso do post sobre reflexão em Java foi abordado o tema de proxy de maneira bem ampla e dado alguns exemplos. No Hikari Connection Pool? Bem, nesse caso aqui as conexões obtidas eram proxies, pois o objeto retornado por hikariDataSource.getConnection() retornava uma conexão que estava disponível no pool, removendo-a de lá. Toda interação com essa conexão obtida é repassada para a conexão real, exceto uma: o .close(). Nesse caso, a conexão era “magicamente” reposta no pool de conexões. O objeto de HikariConnection é um proxy.

Sabe outro exemplo de proxy que mencionamos aqui mesmo neste post? Logo atrás? Na seção anterior? Um cache de prepared statements dentro de uma conexão com o banco! Aqui, temos todas as características para o flyweight e também o proxy no meio do caminho:

  • a conexão que o código interage envelopa a conexão de verdade; portanto é uma conexão proxy
  • a conexão recebe uma string com a query
  • a conexão proxy mantém uma memória chaveando as queries às suas respectivas prepared statements
  • ela tem uma factory de prepared statement (só passar a query para a conexão de baixo)
  • ela tem a função de entrada (a função da conexão proxy)

No GoF ele cita alguns tipos de proxy, mas achei a lista do Refactoring Guru mais completa (e não é exaustiva!). Leia mais em Proxy no Refactoring Guru:

  • virtual proxy (aqui no contexto de instanciar lazily)
  • proxy de proteção
  • proxy remoto (voltado pra RPC)
  • proxy de logging
  • proxy de cache
  • smart reference

No caso do Hikari, o proxy é usado para smart reference. No exemplo de statements preparadas, foi um caso de proxy de cache.

Proxy de proteção, de logging, de cache poodem ser implementados tais quais decorators, em que você adiciona comportamentos adicionais em uma função. Inclusive, como o Refatoring Guru fala, proxies e decorators tem bastante coisa em comum.

Para smart pointers, você necessita de um pool ou de uma factory.

O proxy de lazy loading oferece uma coisa diferente. Ele permite que você não instancie o objeto a priori, só sob demanda. Mas mesmo assim você consegue usar e posicionar o objeto de proxy. Por exemplo, não preciso instanciar uma imagem a partir do arquivo PNG para saber o tamanho dela. Eu posso colocar em um proxy de lazy loading para perguntar qual o tamanho da imagem (imagem passada como um path do PNG) e isso permite que eu pergunte a altura de um documento (por exemplo) sem precisar de fato instanciar a imagem dentro dele. Eu posso inclusive determinar quantas páginas a renderização em PDF vai ter sem precisar de nada real.

E se a imagem for usada em múltiplos pontos, posso usar flyweight para retornar a referência do proxy no lugar de instanciar o objeto real.

Como o mecanismo de proxy de caching/de logging/diversos outros é o mesmo do decorator, não vou exemplificar. Mas e para o lazy loading?

type BitMap = ...

type Image = {
    height: () => number,
    bmp: () => BitMap
}

function createImageFromPath(arg: string): Image {
    return ...
}

function getHeightFromPath(arg: string): number {
    return ...
}

type Lazy<T> = {
    get: () => T
}

function cacheFunction<I, T>(input: I, factory: (i: I) => T): Lazy<T> & { loaded: boolean } {
    let value: T | null = null
    let loaded = false
    const get = () => {
        if (!value) {
            value = factory(input)
            loaded = true
        }
        return value
    }
    return {
        get,
        loaded
    }
}

function lazyImageProxy(path: string): Image {
    const cache = cacheFunction(path, createImageFromPath)
    const height = cacheFunction(cache, (t: Lazy<Image> & { loaded: boolean }) => {
        if (t.loaded) {
            return t.get().height()
        }
        return getHeightFromPath(path)
    })
    return {
        height: () => height.get(),
        bmp : () => cache.get().bmp()
    }
}

Vamos destrinchar? Eu tenho uma função para o lazy loading de imagem, o lazyImageProxy. Ele tem acesso ao tamanho da imagem através do height, e ao bitmap carregando à imagem.

O height, por sua vez, é um objeto que ainda não foi carregado. Ele depende do cache, e pergunta ao cache se ele já foi carregado ou não. Em já tendo sido carregado, já era, a imagem já está formada posso pegar dela mesmo o valor. Agora, caso ela não esteja formada, pega através do caminho usando getHeightFromPath.

E note que o próprio height ele é lazy e também guarda valor cacheado, então ao ser computado pela primeira vez, ele não precisa ser computado novamente.

Todos esses caches só funcionam por conta da função cacheFunction, que retorna um objeto do tipo Lazy<T> & { loaded: boolean }. Isso quer dizer que o objeto atende tanto ao requisito de ser Lazy<T> como também em ter um campo booleano chamado loaded. Ele é um tipo de interseção: precisa atender às duas tipagens. E ele controla tudo colocando a função de carga (chamada de get) e o objeto loaded dentro de sua clausura.

Finalmente, a interface Lazy<T> só diz que ela tem uma função chamada get que, ao ser chamada, retorna um objeto do tipo T. Portanto, o cacheFunction pode muito bem implementar ela de modo que aproveite o resultado anterior. Isso é dado assim:

// params input: I, factory: (i: I) => T
let value: T | null = null
const get = () => {
    if (!value) {
        value = factory(input)
    }
    return value
}

return {
    get
}

Mas cacheFunction retorna a interseção de Lazy<T> com { loaded: boolean }. Mas por quê? Porque é útil saber se o objeto está carregado. Por exemplo, no caso do tamanho da imagem, isso foi usado para saber se faz uma leitura de disco específica para esse fim ou se utiliza o que já vem da imagem. Por isso que foi colocado o loaded, para identificar isso e para poder adaptar o comportamento:

// params input: I, factory: (i: I) => T
let value: T | null = null
let loaded = false
const get = () => {
    if (!value) {
        value = factory(input)
        loaded = true
    }
    return value
}

return {
    get,
    loaded
}

Padrões comportamentais

Parafraseando do livro:

Os padrões comportamentais se preocupam com algoritmos e a atribuição de responsabilidades entre objetos. Os padrões comportamentais não descrevem apenas padrões de objetos ou classes, mas também os padrões de comunicação entre eles.

Então, pelo que espero aqui, as funções ficam cada vez mais claras. Talvez algum malabarismo de tipo, mas mais fortemente creio que vamos ver mais e mais funções.

Chain of responsability

Evitar o acoplamento do remetente de uma solicitação ao seu receptor […] passando a solicitação ao longo da cadeia até que um objeto a trate.

Mais uma vez, o exemplo motivador do GoF é o de uma aplicação gráfica. Isso também foi implementado no DOM: sair borbulhando o evento, até que alguém o trate.

Então, como implementamos a cadeia de responsabilidade?

Basicamente, temos uma Chain que trata elementos do tipo T:

type Handler<T> = (e: T) => void;
type EndOfChain<T> = Handler<T>;

type Link<T> = {
    shouldHandle : (e: T) => boolean,
    handler: Handler<T>
}

type Chain<T> = {
    link: Link<T>,
    nextLink: Chain<T> | EndOfChain<T>
}

E então cada elo é adicionado no começo da corrente. Então, nesse esquema, como ficaria uma implementação? Primeiro, precisamos delimitar quando o nextLink vai ser um Chain ou um EndOfChain. Enquando for chain, testa para ver se é possível lidar, e se for possível lida e termina a execução.

EndOfChain é o caso em que não há ninguém que faça o tratamento. Existem outras maneiras de expressar, como por exemplo o elo nulo (que aí deixaria escapar), ou então um elo final especial que é garantido shouldHandle(any) => true. O jeito mais fácil de garantir é tornar o EndOfChain um elemento especial que vai tratar o elemento:

function isChain<T>(nextLink: Chain<T> | EndOfChain<T>): nextLink is Chain<T> {
    return (nextLink as any)['link'] && (nextLink as any)['nextLink'] != null
}

function bubbleEvent<T>(event: T, chain: Chain<T>) {
    let current: Chain<T> | EndOfChain<T> = chain
    while (isChain(current)) {
        const link = current.link
        if (link.shouldHandle(event)) {
            link.handler(event)
            return
        }
        current = current.nextLink
    }

    current(event)
}

Mas, como seria usado isso? Por exemplo, ao colocar um componente de tela que precisa borbulhar, por exemplo, um comando de alteração de um carrinho de compras?

A priori a ação é informar que não deu certo tomar a ação. Mas poderia ser um drop da ação silencioso. Então temos o nosso EndOfChain:

const droparAction: EndOfChain<Action> = (a: Action) => {}
const logarAction: EndOfChain<Action> = (a: Action) => console.log(`ignorando ação ${a}`)

Então inserimos as regras de lidar com isso: passando uma condição, um handler e o elo seguinte da sequência. Por exemplo, se a ação for “enviar orçamento” e tiver um email, ele enviar o orçamento por email:

function addLinkToChain<T>(accepter: (t: T) => boolean, handler: Handler<T>, link: Chain<T> | EndOfChain<T>): Chain<T> {
    return {
        link: {
            shouldHandle: accepter,
            handler
        },
        nextLink: link
    }
}

function ehParaEnviarOrcamentoViaEmail(action: Action): boolean {
    return ...
}

function enviaOrcamentoViaEmail(action: Action) {
    ...
}

const base = logarAction;

const chain: Chain<Action> = addLinkToChain(ehParaEnviarOrcamentoViaEmail, enviaOrcamentoViaEmail, base);


const a: Action = ...
bubbleEvent(a, chain)

Aqui, ao inserir o componente de enviar orçamento via email, precisamos agora apenas manter a cadeia e pronto.

Mas, se eu tenho a cadeia e preciso passar ela para cima… será que eu não poderia, no lugar de borbulhar via vários níveis de uma lista encadeada, só fazer um find/fire?

Ainda com o pensamento do Link, poderia até aproveitar a interface dele: um elo na corrente é algo que pode lidar com aquilo. Assim, posso simplesmente passar a corrente e a lida padrão. Então bastaria passar a lista de elos, a tratativa padrão e a ação, né? Algo assim:

function handle<T>(chain: Link<T>[], defaultHandler: EndOfChain<T>, t: T) {
    ...
}

Sabendo quem vou ter, basta agir agora. Vamos pegar o link adequado? É o primeiro que conseguir lidar com o objeto:

const link: Link<T> | undefined = chain.find(l => l.shouldHandle(t))

Ok, agora eu precido do handler dele:

const handler: Handler<T> | undefined = chain.find(l => l.shouldHandle(t))?.handler

Só lidar com o caso de não encontrar:

const handler: Handler<T> = chain.find(l => l.shouldHandle(t))?.handler ?? defaultHandler

Juntando tudo:

function handle<T>(chain: Link<T>[], defaultHandler: EndOfChain<T>, t: T) {
    (chain.find(l => l.shouldHandle(t))?.handler ?? defaultHandler)(t)
}

Isso lida com o uso da corrente, mas e a criação? Basicamente é uma função que pega Link<T>[], Handler<T>, (t:T) => boolean e concatena em um único array de Link<T>. Algo como:

function adicionaElo<T>(accepter: (t: T) => boolean, handler: Handler<T>, chain: Link<T>[]): Link<T>[] {
    return [ {
        shouldHandle: accepter,
        handler
    }, ...chain]
}

E, para usar, só precisa passar o componente da corrente de responsabilidade para cima.

Command

Encapsular uma solicitação como um objeto, desta forma permitindo parametrizar clientes com diferentes solicitações, enfileirar ou fazer o registro (log) de solicitações e suportar operaçòes que podem ser desfeitas.

Basicamente, o padrão de projeto command é mandar uma mensagem de comando “pelo fio”. Um tipo e um interpretador. No caso, como no GoF o pensamento era voltado ainda muito a efeitos colaterais (vide a questão de economia de memória, preocupação muito comum e pertinente na época), o próprio objeto que será alterado é o interpretador do comando, alterando seu estado interno.

Mas aqui a ideia maior é que o command é aplicado para alterar um objeto. Especificamente:

type CommandHandler<T> = (t: T, c: Command) => T

Se quiser tratativa de erro no command (por exemplo, se o estado que o objeto novo assumir for irreconciliavelmente), podemos retornar uma tagged union com o sucesso/erro:

type CommandHandler<T, E> = (t: T, c: Command) => { success: T } | { error: E }

“Ah, e o desfazer?” Bem, mudamos o estado e podemos guardar o estado anterior. Nenhum problema nisso. Daí só restaurar.

Para um exemplo? Vamos pegar um pedido, que tem itens. O comando é “aumentar a quantidade do item #3 em 2 unidades”.

Primeiro, as coisas básicas: os objetos que serão alterados pelo comando:

type Pedido = {
    valor: number, // valor total do pedido
    itens : Item[]
}

type Item = {
    produto: Produto,
    valorUnitario: number,
    quantidade: number
}

Ok, e o comando? O comando bem dizer pega a posição do item (totalmente arbitrário, poderia ser outra coisa) e o quanto vai aumentar. Como é um comando com potencialmente diversos outros, vamos desenhar ele pronto para estar em uma tagged-union através do campo command:

type Command = {
    command: string
}

type AlteraQuantidadeItemCommand = {
    command: "AlteraQuantidadeItem",
    item: number, // posição do item
    delta: number // variação da quantidade do item
}

Tem coisa que pode dar errado com esse comando? Sim! Duas, na verdade:

  • o item não existir (por exemplo, alterar o 10º item de 3)
  • passar um delta não positivo

Para criar o comando “aumentar a quantidade do item #3 em 2 unidades”:

const comando: AlteraQuantidadeItemCommand = {
    command: "AlteraQuantidadeItem",
    item: 3,
    delta: 2
}

Como seria para lidar com esse comando?

type CommandReturn = {
    success: Pedido
} | {
    error: string
}

function handleAlteraQuantidadeItem(pedido: Pedido, command: AlteraQuantidadeItemCommand): CommandReturn {
    return ...
}

function alteraPedido(pedido: Pedido, command: Command): CommandReturn {
    switch (command.command) {
        case "AlteraQuantidadeItem": {
            return handleAlteraQuantidadeItem(pedido, command as AlteraQuantidadeItemCommand);
        }
    }

    return {
        error: `comando não reconhecido ${command.command}`
    }
}

Precisava criar a função handleAlteraQuantidadeItem? Não, poderia resolver em alteraPedido mesmo. Mas o pattern-matching deixou claro, pelo menos para mim, que isso seria uma boa estratégia: faz a multiplexação para saber qual interpretador de comando chamar e invocar o interpretador de comando.

Enfim, e a implementação de como lidar com essa alteração de quantidade? Vamos primeiro desconsiderar erros:

function handleAlteraQuantidadeItem(pedido: Pedido, command: AlteraQuantidadeItemCommand): CommandReturn {
    const {
        item,
        delta
    } = command

    const itens = [ ...pedido.itens ]

    const itemAntigo = itens[item]
    const itemNovo: Item = {
        ...itemAntigo,
        quantidade: itemAntigo.quantidade + delta
    }

    itens[item] = itemNovo
    const valor = pedido.valor + delta*itemAntigo.valorUnitario
    return { success: { valor, itens } }
}

Aqui estou favorecendo não alterar o objeto que veio de fora, tentando deixar o mais imutável possível. Abri mão levemente ali no itens[item] = itemNovo porque é mais prático.

O itemNovo eu crio com base no itemAntigo (lembra do padrão prototype?), mas aqui eu altero a quantidade:

const itemNovo = {
    ...itemAntigo, // usando o itemAntigo como protótipo
    quantidade: itemAntigo.quantidade + delta
}

Declarei que const itemNovo: Item por dois motivos:

  1. garantir que eu estou escrevendo de fato o tipo correto
  2. habilitar o intelissense do VSCode descobrir que tem o campo quantidade

Fora isso, eu apenas calculei o valor do pedido novo.

Mas, e inserir os casos de erro? Bem simples, na real:

function handleAlteraQuantidadeItem(pedido: Pedido, command: AlteraQuantidadeItemCommand): CommandReturn {
    const {
        item,
        delta
    } = command

    const itens = [ ...pedido.itens ]

    if (item >= itens.length) {
        return { error: `item ${item} inexistente` }
    }
    if (delta <= 0) {
        return { error: `delta ${delta} inválido` }
    }

    const itemAntigo = itens[item]
    const itemNovo: Item = {
        ...itemAntigo,
        quantidade: itemAntigo.quantidade + delta
    }

    itens[item] = itemNovo
    const valor = pedido.valor + delta*itemAntigo.valorUnitario
    return { success: { valor, itens } }
}

Hmmm, é só isso o command?

Sim, é só isso. Vou colocar o exemplo com o comando de adicionar um item além do alterar a quantidade:

type Command = {
    command: string
}

type AlteraQuantidadeItemCommand = {
    command: "AlteraQuantidadeItem",
    item: number, // posição do item
    delta: number // variação da quantidade do item
}

type InserirItemCommand = {
    command: "InserirItem",
    produto: Produto,
    valorUnitario: number,
    quantidade: number
}

const altera: AlteraQuantidadeItemCommand = {
    command: "AlteraQuantidadeItem",
    item: 3,
    delta: 2
}

type Produto = string // apenas para exemplificar

type Pedido = {
    valor: number, // valor total do pedido
    itens : Item[]
}

type Item = {
    produto: Produto,
    valorUnitario: number,
    quantidade: number
}

type CommandReturn = {
    success: Pedido
} | {
    error: string
}

function handleAlteraQuantidadeItem(pedido: Pedido, command: AlteraQuantidadeItemCommand): CommandReturn {
    const {
        item,
        delta
    } = command

    const itens = [ ...pedido.itens ]

    if (item >= itens.length) {
        return { error: `item ${item} inexistente` }
    }
    if (delta <= 0) {
        return { error: `delta ${delta} inválido` }
    }

    const itemAntigo = itens[item]
    const itemNovo: Item = {
        ...itemAntigo,
        quantidade: itemAntigo.quantidade + delta
    }

    itens[item] = itemNovo
    const valor = pedido.valor + delta*itemAntigo.valorUnitario
    return { success: { valor, itens } }
}

function handleInserirItem(pedido: Pedido, command: InserirItemCommand): CommandReturn {
    const {
        produto, valorUnitario, quantidade
    } = command

    if (valorUnitario <= 0) {
        return {
            error: "valor unitário precisa ser positivo"
        }
    }
    if (quantidade <= 0) {
        return {
            error: "quantidade precisa ser positiva"
        }
    }
    const item: Item = {
        produto,
        valorUnitario,
        quantidade
    }
    return {
        success: {
            valor: pedido.valor + (valorUnitario * quantidade),
            itens: [
                ...pedido.itens,
                item
            ]
        }
    }
}

function alteraPedido(pedido: Pedido, command: Command): CommandReturn {
    switch (command.command) {
        case "AlteraQuantidadeItem": {
            return handleAlteraQuantidadeItem(pedido, command as AlteraQuantidadeItemCommand);
        }
        case "InserirItem": {
            return handleInserirItem(pedido, command as InserirItemCommand);
        }
    }

    return {
        error: `comando não reconhecido ${command.command}`
    }
}

let pedido: Pedido = {
    valor: 0,
    itens: []
}

const inserirItem1 : InserirItemCommand = {
    command: "InserirItem",
    produto: "coca cola lata",
    valorUnitario: 4.00,
    quantidade: 2
}

const inserirItem2 : InserirItemCommand = {
    command: "InserirItem",
    produto: "brigadeiro",
    valorUnitario: 1.00,
    quantidade: 1
}

const inserirItem3 : InserirItemCommand = {
    command: "InserirItem",
    produto: "salgado",
    valorUnitario: 9.00,
    quantidade: 1
}

const inserirItem4 : InserirItemCommand = {
    command: "InserirItem",
    produto: "café",
    valorUnitario: 3.00,
    quantidade: 1
}


let maybePedido = alteraPedido(pedido, inserirItem1);
pedido = (maybePedido as { success: Pedido }).success

maybePedido = alteraPedido(pedido, inserirItem2);
pedido = (maybePedido as { success: Pedido }).success

maybePedido = alteraPedido(pedido, inserirItem3);
pedido = (maybePedido as { success: Pedido }).success

maybePedido = alteraPedido(pedido, inserirItem4);
pedido = (maybePedido as { success: Pedido }).success

console.log(pedido)

maybePedido = alteraPedido(pedido, altera);
console.log(maybePedido)

pedido = (maybePedido as { success: Pedido }).success
console.log(pedido)

maybePedido = alteraPedido(pedido, { ...altera, item: 15} as AlteraQuantidadeItemCommand); // erro por item inexistente
console.log(maybePedido)

maybePedido = alteraPedido(pedido, { ...altera, delta: -1} as AlteraQuantidadeItemCommand); // erro por quantidade negativa
console.log(maybePedido)

maybePedido = alteraPedido(pedido, { command: "marmota" });
console.log(maybePedido)

Restaurar o estado anterior é basicamente falando só manter o histórico dos objetos na mão.

“Ah, mas isso não é o padrão COMMAND do jeito que tá no Gof!”

Sim, de fato não é exato. É uma versão simplificado e que, aqui, assumi que era tranquilo manipular o objeto daquela maneira. Normalmente faz sentido. Se fosse um active record? Bem, aí active record da vida é outro conceito, orientado a efeitos colaterais. Aqui não necessitei dos efeitos colaterais.

Aqui, diferente do GoF, o comando em si foi separado em 3 partes:

  • o objeto com a intenção da alteração (e posíveis argumentos)
  • a função que lida com o polimorfismo dos comandos
  • a função que executa a intenção da alteração

No GoF, o objeto e a função que lida com a alteração eram intrínsecos um no outro, indissociáveis. Devido a isso, o próprio polimorfismo das funções virtuais lidavam com a questão da escolha da função, juntando tudo em uma coisa só. Como eu já tive experiência de necessitar enviar commands over the wire, preferi essa abordagem que não força a serialização da função.

Outro ponto de distinção é que no GoF o objeto a ser alterado já está contido no command, enquanto que eu descrevi isso através da chamada de função, passando o objeto sujeito aos comandos e o comando em si. Isso implicou, por exemplo, que quando a alteração não é diretamente no objeto em si, mas sim em algum nível mais interno, que se faça a busca pelo objeto sujeito da manipulação. Com isso, preciso passar nos argumentos do meu objeto de command as coordenadas para buscar tal elemento.

Interpreter

Dada uma linguagem, definir uma representação para a sua gramática juntamente com um interpretador que usa representação para interpretar sentenças da linguagem.

Basicamente a função eval do Lisp, mas para uma linguagem arbitrária e não somente Lisp.

O GoF determina que é, bem dizer, montar a árvore de sintaxe e depois descer ela usando um visitor (padrão a ser definido a frente). Na real o GoF tenta se esquivar das árvores de sintaxe, mas na prática é isso (elemento raiz, elemento não terminal, elemento folha).

Além das questões do visitor e da árvore de sintaxe, tem também um contexto que ficam informações sobre a interpretação.

Tal qual foi no command, o interpreter aqui na definição do GoF tem, para cada nó da árvore ele tem um método chamado Execute, que por sua vez o próprio nó é o visitor.

Uma coisa que ele não cita no GoF é a capacidade de fazer a árvore via DSL. Tomando como exemplo o Builder ali de cima, em que fizemos uma árvore de expressões numéricas em cima de operadores binários.

Vamos definir uma pequena linguagem interpretada? Essa linguagem tem variáveis booleanas, variáveis de funções unárias, constantes true e false, um jeito de definir input em variáveis, e clausuras. As funções vão retornar ou booleanos ou funções unárias. Claro que, além de setar os valores das variáveis, posso ler eles. E também teria branches.

Vamos focar aqui no mínimo para a linguagem? A função “XOR”. Vou fazer via retorno de funções de acordo com o primeiro argumento, e outra que passa o argumento via clausura.

Como seria essa linguagem? Vamos ver um exemplo:

let xorClojure := (a) => (b) => {
    if (a) {
        return !b
    } else {
        return b
    }
}
let xorBranching := (a) => {
    if (a) {
        return (b) => !b
    } else {
        return (b) => b
    }
}

let fromClosure := xorClosure(true)(false)
let fromBranching := xorBranching(true)(false)

Os específicos de como fazer a tokenização dessa linguagem, ou o parsing dela, não interessa. Vamos pegar apenas a nível de estrutura sintática e fazer o interpretador baseado nesses nós!

Antes de entrar nos detalhes, vamos ver uma possível árvore para a última linha do exemplo: let fromBranching := xorBranching(true)(false).

Primeiro: temos um let. Essa expressão precisa de um nome. Vamos fazer com que let var := value seja um syntax sugar para let var; var := value? Assim, para a gente expressar logo o começo dessa expressão, precisamos já declarar dois elementos sintáticos: o let e o attr.

Então, bora lá, como seria a criação desses objetos?

const program: BoolNode[] = []
program.push(nodeLet("fromBranching"))
program.push(nodeAttr("fromBranching").value(...))

Hmmm, parece bacana. agora precisamos lidar com a questão de chamada de função, mas pra isso precisamos também resgatar o valor da função (como se fosse uma variável):

const program: BoolNode[] = []
program.push(nodeLet("fromBranching"))

const callXorBranching: BoolNodeValue = nodeVar("xorBranching")
                                           .call(nodeValue(true))
                                           .call(nodeValue(false))
program.push(nodeAttr("fromBranching").value(callXorBranching))

Aproveitei também e coloquei o valor de uma constante: nodeValue. Essa a DSL, mas como seria a estrutura gramatical que isso representa?

Bem, o call na real tem do lado esquerdo um valor (que é esperado que o retorno seja uma função) e do lado direito um valor. A representação seria mais ou menos isso:

[
    {
        nodetype: "let",
        name: "fromBranching"
    },
    {
        nodetype: "attr",
        name: "fromBranching",
        value: {
            nodetype: "call",
            function: {
                nodetype: "call",
                function: {
                    nodetype: "var",
                    name: "xorBranching"
                },
                param:{
                    nodetype: "value",
                    value: {
                        kind: "boolean",
                        value: true
                    }
                }
            },
            param: {
                nodetype: "value",
                value: {
                    kind: "boolean",
                    value: false
                }
            }
        }
    }
]

Muito bem. E a definição de função? Vamos fazer aqui para o xorBranching:

let xorBranching := (a) => {
    if (a) {
        return (b) => !b;
    } else {
        return (b) => b;
    }
}

Temos aqui a definição de let seguido de atribuição. Então o valor passa a ser uma função. A função tem uma variável como argumento, usada para ser mencionada abaixo. Vamos dar um nome a esse argumento, por simplicidade. Então, ela passa a ter um conjunto de nós. Aqui temos o if para permitir branching e também o return, o que causa o fim prematuro da função. Note que aqui o retorno é uma função anônima, e que essa função não faz uso de captura de variáveis locais. A clausura dela é vazia.

Como seria isso expreso na DSL?

const program: BoolNode[] = []
program.push(nodeLet("xorBranching"))

const xorBranchingFunction = nodeFunction("a")
                                .execution([
                                    nodeIf(nodeVar("a"))
                                        .ifBranch(
                                            nodeReturn(
                                                nodeFunction("b")
                                                    .execution([
                                                        nodeReturn(
                                                            nodeNot(nodeVar("b"))
                                                        )
                                                    ])
                                            )
                                        )
                                        .elseBranch(
                                            nodeReturn(
                                                nodeFunction("b")
                                                    .execution([
                                                        nodeReturn(
                                                            nodeVar("b")
                                                        )
                                                    ])
                                            )
                                        )
                                ])
program.push(nodeAttr("xorBranching", xorBranchingFunction))

Aqui o nodeFunction recebe como parâmetro o nome do argumento, e então vem o código com a execução da função. A função retorna de modo explícito, então precisa de um nodeReturn com o valor. Note que os primeiros retornos são funções anônimas, portanto eles retornam nodeFunction.

Finalmente, temos uma chamada de nodeIf, passando a condicional (o resultado da variável a), o ifBranch para informar o caso de a condição ser satisfeita e elseBranch com o caso contrário. Neles temos os nodeReturn, que retornam a função anônima.

O total fica assim:

const program: BoolNode[] = []
program.push(nodeLet("xorBranching"))

const xorBranchingFunction = nodeFunction("a")
                                .execution([
                                    nodeIf(nodeVar("a"))
                                        .ifBranch(
                                            nodeReturn(
                                                nodeFunction("b")
                                                    .execution([
                                                        nodeReturn(
                                                            nodeNot(nodeVar("b"))
                                                        )
                                                    ])
                                            )
                                        )
                                        .elseBranch(
                                            nodeReturn(
                                                nodeFunction("b")
                                                    .execution([
                                                        nodeReturn(
                                                            nodeVar("b")
                                                        )
                                                    ])
                                            )
                                        )
                                ])
program.push(nodeAttr("xorBranching", xorBranchingFunction))
program.push(nodeLet("fromBranching"))

const callXorBranching: BoolNodeValue = nodeVar("xorBranching")
                                           .call(nodeValue(true))
                                           .call(nodeValue(false))
program.push(nodeAttr("fromBranching").value(callXorBranching))

E o xorClosure? Bem, podemos adicionar coisas na clausura com um nodeWithClosure(... nome das variáveis) e sair passando ela para baixo. Em seguida dentro da clausura precisa de uma declaração de função, essa função vai ter como clausura aquilo que foi declarado. Vamos ver como que fica?

const program: BoolNode[] = []
program.push(nodeLet("xorClosure"))

const xorClosureFunction = nodeFunction("a")
                                       .execution([
                                        nodeReturn(
                                            nodeWithClosure("a")
                                                    .nodeFunction("b")
                                                            .execution([
                                                                nodeIf(nodeVar("a"))
                                                                        .ifBranch(
                                                                            nodeReturn(nodeNot(nodeVar("b")))
                                                                        )
                                                                        .elseBranch(
                                                                            nodeReturn(nodeVar("b"))
                                                                        )
                                                            ])
                                        )
                                       ])

program.push(nodeAttr("xorClosure", xorClosureFunction))

Tudo isso para gerar esses objetos:

[
    {
        nodetype: "let",
        name: "xorClosure"
    },
    {
        nodetype: "attr",
        name: "xorClosure",
        value: {
            nodetype: "value"
            value: {
                kind: "function",
                arg: "a",
                execution: [
                    {
                        nodetype: "return",
                        value: {
                            nodetype: "capturing",
                            closure: [ "a" ],
                            function: {
                                kind: "lambda",
                                closure: [ "a" ],
                                arg: "b",
                                execution: [
                                    {
                                        nodetype: "if",
                                        condition: {
                                            nodetype: "var",
                                            name: "a"
                                        },
                                        ifBranch: [
                                            {
                                                nodetype: "return"
                                                value: {
                                                    nodetype: "not"
                                                    value: {
                                                        nodetype: "var",
                                                        name: "b"
                                                    }
                                                }
                                            }
                                        ],
                                        elseBranch: [
                                            {
                                                nodetype: "return"
                                                value: {
                                                    nodetype: "var",
                                                    name: "b"
                                                }
                                            }
                                        ]
                                    }
                                ]
                            }
                        }
                    }
                ]
            }
        }
    }
]

Muito bem, agora que temos desenhado o programa, onde que entra o padrão interpreter? Na hora de fazer a computação desses valores!

Deixe os tipos guiarem até as funções, depois eu faço um artigo apenas para esse interpretador. Por agora, fiquem com os tipos pensados para satisfazer a linguagem:

type BoolNodeLet = {
    nodetype: "let",
    name: string
}

type BoolNodeAttr = {
    nodetype: "let",
    name: string,
    value: BoolNodeComputable
}

type BoolNodeValue = {
    nodetype: "value"
    value: BoolNodeBool | BoolNodeFunction
}

type BoolNodeBool = {
    kind: "boolean",
    value: true|false
}

type BoolNodeFunction = {
    kind: "function",
    arg: string,
    execution: BoolNode[]
}

type BoolNodeVar = {
    nodetype: "var",
    name: string
}

type BoolNodeNot = {
    nodetype: "not",
    value: BoolNodeComputable
}

type BoolNodeCall = {
    nodetype: "call",
    param: BoolNodeComputable,
    function: BoolNodeComputable
}

type BoolNodeAnd = {
    nodetype: "and",
    lhs: BoolNodeComputable,
    rhs: BoolNodeComputable
}

type BoolNodeOr = {
    nodetype: "or",
    lhs: BoolNodeComputable,
    rhs: BoolNodeComputable
}

type BoolNodeReturn = {
    nodetype: "return",
    value: BoolNodeComputable
}

type BoolNodeClosure = {
    nodetype: "capturing",
    closure: string[],
    function: {
        kind: "lambda",
        closure: string[],
        arg: string,
        execution: BoolNode[]
    }
}

type BoolNodeIf = {
    nodetype: "if",
    condition: BoolNodeComputable,
    ifBranch: BoolNode[],
    elseBranch: BoolNode[]
}

type BoolNodeComputable = BoolNodeValue | BoolNodeVar | BoolNodeNot |
                          BoolNodeCall | BoolNodeAnd | BoolNodeOr |
                          BoolNodeClosure

type BoolNode = BoolNodeLet | BoolNodeAttr | BoolNodeValue |
                BoolNodeVar | BoolNodeNot | BoolNodeCall |
                BoolNodeReturn | BoolNodeClosure | BoolNodeAnd |
                BoolNodeOr | BoolNodeIf

Aqui, BoolNodeLet cria uma variável, BoolNodeAttr escreve no valor da variável, BoolNodeValue define constantes que podem ser substituídas na execução, BoolNodeVar faz a leitura de uma variável que foi povoado (seja essa variável do contexto de execução atual, da clausura, dos parâmetros).

No caso quando se encontra BoolNodeClosure, a transformação é mais sutil. Antes de poder retornar esse valor, precisamos resolver ele. Pegando o caso atual:

{
    nodetype: "capturing",
    closure: [ "a" ],
    function: {
        kind: "lambda",
        closure: [ "a" ],
        arg: "b",
        execution: [
            {
                nodetype: "if",
                condition: {
                    nodetype: "var",
                    name: "a"
                },
                ifBranch: [
                    {
                        nodetype: "return"
                        value: {
                            nodetype: "not"
                            value: {
                                nodetype: "var",
                                name: "b"
                            }
                        }
                    }
                ],
                elseBranch: [
                    {
                        nodetype: "return"
                        value: {
                            nodetype: "var",
                            name: "b"
                        }
                    }
                ]
            }
        ]
    }
}

A variável a no final das contas ao chegar nesse momento tem o valor CONST qualquer. Então, como sabemos o valor de a, “removemos” ele da lista de coisas sendo capturadas e de coisas sendo esperadas na clausura, transformando em uma espécie de let junto de attr na primeira linha da função:

{
    nodetype: "capturing",
    closure: [ ],
    function: {
        kind: "lambda",
        closure: [ ],
        arg: "b",
        execution: [
            {
                nodetype: "let",
                name: "a"
            },
            {
                nodetype: "attr",
                name: "a",
                value: {
                    nodetype: "value",
                    kind: "boolean",
                    value: CONST
                }
            },
            {
                nodetype: "if",
                condition: {
                    nodetype: "var",
                    name: "a"
                },
                ifBranch: [
                    {
                        nodetype: "return"
                        value: {
                            nodetype: "not"
                            value: {
                                nodetype: "var",
                                name: "b"
                            }
                        }
                    }
                ],
                elseBranch: [
                    {
                        nodetype: "return"
                        value: {
                            nodetype: "var",
                            name: "b"
                        }
                    }
                ]
            }
        ]
    }
}

Agora que o capturing está com o closure vazio, só transformar em valor. E note que agora não precisa mais ser um lambda, pode ser uma function comum. E é assim que vamos implementar:

{
    nodetype: "value",
    value:  {
        kind: "function",
        arg: "b",
        execution: [
            {
                nodetype: "let",
                name: "a"
            },
            {
                nodetype: "attr",
                name: "a",
                value: {
                    nodetype: "value",
                    kind: "boolean",
                    value: CONST
                }
            },
            {
                nodetype: "if",
                condition: {
                    nodetype: "var",
                    name: "a"
                },
                ifBranch: [
                    {
                        nodetype: "return"
                        value: {
                            nodetype: "not"
                            value: {
                                nodetype: "var",
                                name: "b"
                            }
                        }
                    }
                ],
                elseBranch: [
                    {
                        nodetype: "return"
                        value: {
                            nodetype: "var",
                            name: "b"
                        }
                    }
                ]
            }
        ]
    }
}

Iterador

Fornecer um meio de acessar, sequencialmente, os elementos de um objeto agregado sem expor a sua representação subjacente.

Isso aqui na prática era uma falta na linguagem. Agora, simplesmente temos em muitas linguagens. Pedimos um iterador para um objeto e iteramos em cima. Basicamente, precisa ter um algo que pegue o iterando atual e algo que pegue o próxio para continuar processando.

Algo que satisfaça o seguinte:

// dado iterable como input
let iterator = pegaIterator(iterable)

while (iterator.hasValue) {
    const value = iterator.value
    iterator = iterator.next()

    // faz coisas com value
}

O pegaIterator é dependente de quem ele está tentando iterar, mas a ideia básica é a seguinte (pegando como exemplo um vetor):

type IteratorType<T> = {
    value: T,
    hasValue: true
    next: () => IteratorType<T>
} | {
    hasValue: false
}

function pegaIterador<T>(colecao: T[]): IteratorType<T> {
    let i = 0
    let len = colecao.length

    const currentStep = (idx: number): IteratorType<T> => {
        if (idx == len) {
            return {
                hasValue: false
            }
        }
        return {
            hasValue: true,
            value: colecao[idx],
            next: () => currentStep(idx+1)
        }
    } 
    return currentStep(0)
}

Para a maioria dos casos, o próprio JS já fornece um jeito de iterar em uma lista de modo escondido:

for (const v of [1, 2, 3]) {
    console.log(v)
}
// imprime:
// 1
// 2
// 3

Para o padrão de projeto o próprio iterável deveria ter uma maneira de retornar o seu iterador.

Mediator

Definir um objeto que encapsula a forma como um conjunto de objetos interage. O Mediator promove acoplamento fraco ao evitar que os objetos se refiram uns aos outros excplicitamente e permite variar suas interações independentemente.

Aqui, os objetos interagem com o mediator, que por sua vez repassa para outros objetos em cena. As interações são entre os objetos de cena e a cena, e depois da cena com os objetos de cena.

Sabe um canto bem comum de encontrar isso? Nos controllers! Quando você coloca os componentes da camada de view, eles falam com o controller, mandando eventos e recebendo eventos. O mediator em si não tem tanta relevância na visão do padrão.

“Ah, mas no GoF ele fala que…”

Calma, gafanhoto, vem comigo. A função do mediator é receber eventos e distribuir ações. E sabe como ele pode fazer isso? Acoplando no objeto algo que será disparado na situação específica!

Por exemplo, podemos construir uma tela com um botão e, ao pressionar esse botão, consultar um serviço remoto (pegando a URL de uma text box) e escrever o resultado em uma área específica para isso!

Um jeito de permitir fazer isso é fazer com que cada um desses componentes conheça o barramento, o que deixaria esses elementos conhecendo mais do que deveria. Outra, entretanto, é dizendo que esses elementos tem eventos que podem ser interceptados. Por exemplo, o botão tem o onClick. Eu posso fazer isso:

btn.onClick = () => {
    const url = textBox.getText()
    const answer = await (await fetch(url)).text()
    answerArea.setInnerText(answer)
}

Quem adiciona essas questões? O mediator! Ele que determinou como que cada evento vai desenrolar. O exemplo do mediator do GoF é baseado em um barramento em que cada par evento/originador é disparado no mediator. O código acima ficaria assim:

const btn = ...
btn.publisher = mediator

btn.onClick = (event) => {
    if (!!mediator) {
        mediator.click(btn, event)
    }
}

mediator.click = (origin, event) => {
    if (origin === btn) {
        const url = textBox.getText()
        const answer = await (await fetch(url)).text()
        answerArea.setInnerText(answer)
        return
    }
}

Memento

Sem violar o encapsulamento, capturar e externalizar um estado interno de um objeto, de maneira que o objeto possa ser restaurado para este estado mais tarde.

Memento tira uma foto do estado do objeto. No padrão do GoF, ele não expõe nada dos dados originais.

Basicamente o problema que o memento ataca existe por conta do acoplamento dados/métodos com mutabilidade. Ele bem dizer para de existir quando temos imutabilidade.

Mais pra frente, no padrão “State”, temos uma espécie de “memento”. A entidade chamada Breakpoint é uma espécie de memento para um objeto do tipo Importacao.

Observer

Definir uma dependência um-para-muitos entre objetos, de maneira que quando um objeto muda de estado todos os seus dependentes são notificados e atualizados automaticamente.

O GoF diz que esse padrão também é conhecido como pub/sub.

Então, como ele funciona? Basicamente, quando um estado interno muda, ele avisa a quem está subscrito a ele que houve alteração. Só isso.

Esse padrão é quem motivou o setter no mundo java. Alterar um estado, notificar que foi alterado.

Existem níveis e níveis de como se submeter a um publisher. O mais básico o subscriver assiste a todos os episódios de alteração do publisher. Mas podem ter esquemas mais sofisticados em que o subscriver informa o que ele quer escutar.

De modo geral, o padrão pub/sub tem uma coleção de subscribers para um evento de alteração. Se os subscribers vão receber ou não o evento é questão de como o problema se apresenta. Vamos fazer sem lidar com o evento:

type Subscriber = () => void
function publishEvent(subs: Subscriber[]) {
    for (const sub of subs) {
        sub()
    }
}

function fazAlteracaoCampoX<V>(alvo: { x: V, subs: Subscriber[] }, novoX: V) {
    alvo.x = novoX
    publishEvent(alvo.subs)
}

State

Permite alterar o comportamento do objeto quando seu estado interno muda. O objeto parecerá ter mudado de classe.

Note que aqui “ter mudado de classe” não se refere necessariamente a interface, mas sim o comportamento observável do objeto através de seus efeitos colaterais e retornos.

Um exemplo de state (ou coisa semelhante) feita para garantir a construção de um objeto foi implementado no Fluent Builder com região crítica. Mas nesse caso aqui usamos o estado através do tipo do objeto para fazer com que ele mudasse a interface. Logo, as transições de estado são gerenciadas internamente de modo que seja impossível bater em uma API se o estado não for o adequado.

Mas vamos lá, e quando não muda a interface? Vamos pegar um caso baseado em implementações reais?

Eu trabalhei na integração de dados vindos do ERP para serem povoados dentro das estruturas internas da firma. Essa integração era feita enviando pacotes de delta do ERP para o sistema em que eu trabalhava.

Mas isso implicava que o ERP precisava calcular o delta. E isso nem sempre era possível. Então foi feito uma solução para os casos de “importação big bang”: um pequeno serviço no meio do caminho que recebia os dados do ERP, todos eles, e então calculava qual seria o delta para enviar o delta.

O caso mais comum de se usar esse sistema é para detectar quando uma relação entre duas entidades deixou de existir. Essa relação era possível obter através de uma projeção dos dados do ERP, mas para detectar que ela não existia mais a questão ficava complicada. Complicava porque aí o ERP precisaria manter o estado da última integração, calcular o estado desejado através de SELECTS e então determinar quais deixaram de existir.

Fazer isso dentro do ERP era algo invasivo e que (com razão) nem todo mundo queria fazer. Como o processamento era feito tabela-a-tabela, não tinha muito segredo nisso: uma solução de meio do caminho seria mais do que o suficiente para generalizar e atender os clientes.

Assim, foi colocado o sistema de meio de campo. Ele recebia a requisição completa do ERP, comparava com os dados antigos, enviava para o sistema o que necessitava ser enviado.

O workflow básico era:

  • recebia o JSON (isso indicava o começo do trabalho)
  • inseria o JSON na base de dados local
  • comparava os novos dados com o anterior, determinando o delta
  • enviava o delta
  • consolidava os novos dados

Eventualmente algo poderia dar errado, e eventualmente também poderia haver o cancelamento do fluxo do workflow. E também poderiam inserir outros workflows com outros tipos de controle. Por exemplo, o ERP poderia necessitar fazer várias requisições para povoar uma tabela (pois ele iria cair caso tentasse povoar completamente uma tabela). Ou então parte do que foi enviado era incremental e parte o delta deveria ser calculado localmente pelo meio de campo.

E por fim, o meio de campo tinha um desafio adicional: ele precisava ser resiliente contra ser derrubado. Caso ele fosse derrubado, ele precisaria ser capaz de continuar trabalhando como se nada tivesse acontecido. Como se fosse um “breakpoint”, que foi parado naquele momento mas que poderia ser dado um step a qualquer momento.

Com isso, nós temos o seguinte:

  • workflow (abstrato)
  • importação
  • estágios
  • breakpoints
  • status

O ponto central é a importação. Ao receber uma requisição do ERP, inicia ou dá continuidade em uma importação. Quando uma importação é criada, a ela é atribuída um workflow e ela começa no estágio enfileirado. O resgate do breakpoint dessa importação vai dar que ela está no estágio enfileirado, com o status “esperando”, e que ela segue um workflow específico.

Nesse momento, um scheduler vai pegar uma importação para dar continuidade. Então, ao definir que precisa trabalhar com aquela importação, ele vai dar um “step”. E quem define o que é esse “step”? O estágio! A partir desse momento, o código fica mais ou menos assim:

function realizaImportacao(importacao) {
    while (importacao.goon()) {
        importacao.step()
        salvaBreakpoint(importacao)
    }
}

O comportamento para cada chamada de step é definido única e exclusivamente pelo estágio do workflow no qual a importação se encontra. De modo geral, o resgate vindo do breakpoint até virar uma importação propriamente dita é assim:

type StatusImportacao = "ESPERANDO" | "EM_PROCESSAMENTO" | "ACEITE" | "RECUSA" | "CANCELADO"
type Breakpoint = {
    id: string,
    workflow_id: string,
    estagio_id: string,
    status: StatusImportacao
    // ... mais dados
}

type Workflow = {
    id: string,
    nome: string,
    estagios: Estagio[]
}

type Estagio = {
    id: string,
    nome: string,
    status: StatusImportacao,
    step: (importacao: Importacao) => Estagio
}

type Importacao = {
    id: string,
    workflow: Workflow,
    estagio: Estagio,
    goon: () => boolean,
    step: () => void

    // ... mais dados
}

function resgataWorkflow(id: string): Workflow {
    // ...
}

function statusGoon(status: StatusImportacao): boolean {
    return status == "ESPERANDO" || status ==  "EM_PROCESSAMENTO"
}

function importacao2breakpoint(importacao: Importacao): Breakpoint {
    return {
        id: importacao.id,
        workflow_od: importacao.workflow.id,
        estagio_id: importacao.estagio.id,
        status: importacao.estagio.status

        // ... mais dados
    }
}

function salvaBreakpoimt(importacao: Importacao) {
    const breakpoint = importacao2breakpoint(importacao)

    repository.salva(breakpoint)
}

function breakpoint2importacao(breakpoint: Breakpoint): Importacao {
    const step = (importacao: Importacao) => {
        const proxEstagio = importacao.estagio.step(importacao)
        importacao.estagio = proxEstagio
    }
    const goon = (importacao: Importacao): boolean => {
        return statusGoon(importacao.estagio.status)
    }

    const workflow = resgataWorkflow(breakpoint.workflow_id)
    const estagioInvalido: Estagio = {
        id: breakpoint.estagio_id,
        nome: "inválido",
        status: "RECUSA",
        step: (importacao: Importacao) => {
            return estagioInvalido
        }
    }

    const estagio = workflow.estagios.find(e => e.id == breakpoint.estagio_id) ?? estagioInvalido
    const imp: Importacao = {
        id: breakpoint.id,
        estagio,
        workflow,
        step: () => step(imp),
        goon: () => goon(imp),
    }

    return imp
}

O padrão de projeto state dessa importação é representado pelo código contido dentro de Estagio.

Strategy

Definir uma família de algoritmos, encapsular cada uma delas e torná-las intercambiáveis. Strategy permite que o algoritmo varie independentemente dos clientes que o utilizam.

Tudo isso para defender o princípio de substituição de Liskov. Tomemos como exemplo um algoritmo de busca em grafo, como os do post Meu take sobre o algoritmo de Dijkstra. Lá, eu preciso passar uma estratégia de como pegar o próximo nó a ser visitado.

Como que faço para selecionar o próximo nó? Defino uma estratégia! Vamos pegar o código original e transpor para TypeScript:

type Nodo = {
    vizinhos: Nodo[]
}

type EstruturaBusca = {
    push: (n: Nodo) => void,
    pop: () => Nodo,
    empty: () => boolean
}

function busca(origem: Nodo, destino: Nodo): boolean {
    const estruturaDados: EstruturaBusca = createEstruturaDados()
    estruturaDados.push(origem)

    const jaVisitados = new Set<Nodo>()

    while (!estruturaDados.empty()) {
        const sendoVisitado = estruturaDados.pop()
        if (sendoVisitado == destino) {
            return true
        }
        if (!jaVisitados.has(sendoVisitado)) {
            jaVisitados.add(sendoVisitado)
            for (const vizinho of sendoVisitado.vizinhos) {
                if (!jaVisitados.has(vizinho)) {
                    estruturaDados.push(vizinho)
                }
            }
        }
    }
    return false
}

Como explicado no artigo, basta mudar essa EstruturaBusca que eu tenho algoritmos distintos. Por exemplo, se for FIFO, temoa uma busca em largura, se for LIFO temos uma busca em profundidade, se tiver uma alternativa para acumular distâncias percorridas temos Dijkstra, se tiver uma heurística para estimar a distância entre um ponto x qualquer e o destino temos uma busca A*. E o que define o que que eu vou usar? A estratégia.

Basicamente, posso posso a estratégia como argumeto:

type Nodo = {
    vizinhos: Nodo[]
}

type EstruturaBusca = {
    push: (n: Nodo) => void,
    pop: () => Nodo,
    empty: () => boolean
}

function busca(origem: Nodo, destino: Nodo, createEstruturaDados: () => EstruturaBusca): boolean {
    const estruturaDados: EstruturaBusca = createEstruturaDados()
    estruturaDados.push(origem)

    const jaVisitados = new Set<Nodo>()

    while (!estruturaDados.empty()) {
        const sendoVisitado = estruturaDados.pop()
        if (sendoVisitado == destino) {
            return true
        }
        if (!jaVisitados.has(sendoVisitado)) {
            jaVisitados.add(sendoVisitado)
            for (const vizinho of sendoVisitado.vizinhos) {
                if (!jaVisitados.has(vizinho)) {
                    estruturaDados.push(vizinho)
                }
            }
        }
    }
    return false
}

E pronto, defini a estratégia de busca. Apenas funções de alta ordem e o princípio de substituição de Liskov.

Template method

Definir o esqueleto de um algoritmo em uma operaçãp, postergando (deffering) alguns passos para subclasses. Template method (gabarito de método) permite que subclasses redefinam certos passos de um algoritmo sem mudar a estrutura do mesmo.

Na real, esse padrão foi pensado por ausência de função de alta ordem. E ele é bem dizer uma desculpa para usar herança como estratégia de reuso de código, verificar a segunda mentira em As mentiras que te contaram sobre OOP.

Mas, vamos lá, defender meu ponto, né?

Basicamente a ideia é ter um arcabouço no qual eu possa trocar alguns métodos via herança. Por exemplo, eu posso estar sincronizando uma base de dados em um formato proprietário. Imagina que isso seja feito tabela a tabela. Nele, você precisa avisar ao usuário que está lendo determinada tabela, em qual linha (aproximadamente) ele está:

// dentro da classe tem `lidaNovaTabela` e `lidaNovaLinhaUpsert` como métodos abertos
function sync(dadaStream) {
    while (!dataStream.vazio()) {
        const b = dataStream.leByte();
        // trabalha com o byte b e cria o event

        if (event == NEW_TABLE) {
            lidaNovaTabela(nomeTabela)
        } else if (event == NEW_LINHA) {
            lidaNovaLinhaUpsert(data)
        // ...
        }
    }
}

Pois bem, essa função é o template method. Só falta agora configurar as ações.

Mas… sabe aquele papo de que é função? Então. Para isso, basta passar as funções no lugar de delegar para métodos:

// dentro da classe tem `lidaNovaTabela` e `lidaNovaLinhaUpsert` como métodos abertos
function sync(dadaStream, lidaNovaTabela, lidaNovaLinhaUpsert) {
    while (!dataStream.vazio()) {
        const b = dataStream.leByte();
        // trabalha com o byte b e cria o event

        if (event == NEW_TABLE) {
            lidaNovaTabela(this, nomeTabela)
        } else if (event == NEW_LINHA) {
            lidaNovaLinhaUpsert(this, data)
        // ...
        }
    }
}

E um exeplo de chamada disso?

const syncer = ...
const dataStream = ...

const payload = {
    tabela: null,
    numeroLinhas: 0
}

syncer.sync(dataStream, (syncer, novaTabela) => {
    payload.tabela = novaTabela
    payload.numeroLinhas = 0
    console.log(novaTabela)
}, (syncer, dadosLinha) => {
    payload.numeroLinhas++
    console.log(payload)
})

E pronto, passei funções como argumentos e obtive assim o template method.

Percebeu como o template method e o strategy são bem semelhantes? Então, segundo essa resposta no StackOverflow a maior diferença se dá porque o template method seria “definido em compile-time”, já o strategy em runtime. Basicamente, o template method do jeito que foi pensado para C++ no GoF envolve subclasse. Aqui, para alcançar o mesmo efeito, foi usado passagem de função de alta ordem, foi usado uma strategy.

Visitor

Representar uma operação a sewr executada nos elementos de uma estrutura de objetos. Visitor permite definir uma nova operação sem mudar as classes dos elementos sobre os quais opera.

Basicamente, o visitor vai ser aplicado em uma estrutura, que é um grafo de objetos. O visitor é algo que o GoF deixa mais importante devido a tipagem nominal. Com a tipagem estrutural, é bem dizer redundante o visitor. O visitor precisa conhecer a estrutura de quem ele está visitando e, para cada um deles, visitar o elemento.

A solução para Java/C++ que o GoF oferece implica que a solução do método que vai ser executado é escolhido em tempo de compilação (sigle dispatch). Com o visitor, isso vira double dispatch.

Basicamente, em java, o visitor vai chegar em um nó. Vai fazer o que precisa lá e, então, começar a visitar na descida os outros nós. Só que no lugar de chamar visitor.visita(noFilho) ele chama noFilho.aceitaVisita(visitor). Por sua vez, o código em todo nó filho é apenas isso:

@Override // afinal, precisa em todos os que implementam um componente
public void aceitaVisita(Visitor v) {
    v.visita(this);
}

Ah, mas qual o grande ganho disso? Uma maneira tediosa de declarar a mesma coisa em mais métodos?

Bem, não. O visitor não sabe visitar a classe abstrata de componente, apenas as classes concretas. Logo, se não souber logo de cara a classe do noFilho, temos que visitar.visita(noFilho) não existe para o nó pai, logo é impossível fazer essa chamada. Por isso o accept, pois agora ao chamar v.visita(this), o método visita chamado já vai estar em um contexto em que this tem o tipo adequado para que .visita seja chamado corretamnte.

Padrões fora do GoF

Aqui temos alguns padrões que não podem simplesmente ser exercitados como funções. São padrões de uma camada acima do código, por exemplo.

Double checked lock

Já citado, tem o padrão double checked lock. A ideia desse padrão é evitar gastar tempo com um lock sendo que trivialmente se deveria saber que aquilo está resolvido. Normalmente usado para singletons, ou para um ambiente em que a resposta é única mesmo para várias chamadas distintas, e precisa ser único. Não é algo que se deve ser levado em consideração em aplicações que são efetivamente monoprocessadas, portanto você vai encontrar mais em C#, Java, e outras langs que favorecem a criação de threads selvagens.

Basicamente, tem um formato assim:

private static final Object lockObj = new Object();
private static SomeType singletonianValue = null; // null é inválido, precisa povoar

public static SomeType getSingleton() {
    if (singletonianValue == null) {
        synchronize (lockObj) {
            if (singletonianValue != null) {
                return singletonianValue;
            }
            // imagine uma criação complexa abaixo
            singletonianValue = new SomeType();
        }
    }
    return singletonianValue;
}

A nível de Java, essa solução pode até parecer ok, mas ela está errada. O erro acontece poruqe como a variável em si não é volatile, não há garantia do que se chama de happens before: talvez a leitura dessa variável traga nulo em um outro núcleo do processador, mesmo que o valor já tenha sido calculado, porque em cache o valor daquela região de memória estava nulo. Como evitar? Usando volatile já garante:

private static final Object lockObj = new Object();
private static volatile SomeType singletonianValue = null; // null é inválido, precisa povoar

public static SomeType getSingleton() {
    if (singletonianValue == null) {
        synchronize (lockObj) {
            if (singletonianValue != null) {
                return singletonianValue;
            }
            // imagine uma criação complexa abaixo
            singletonianValue = new SomeType();
        }
    }
    return singletonianValue;
}

Mas a leitura de valores volatile são caros, justamente porque eles fazem um flush no cache, garantindo que, depois da leitura daquele valor volatile, todas as operações de escrita em memória que aconteceram antes também estejam visíveis para futuras leituras.

Podemos complicar um pouco mais a checagem para evitar passar pela leitura de volatile:

private static final Object lockObj = new Object();
private static SomeType singletonianValue = null; // null é inválido, precisa povoar
private static volatile SomeType singletonianValueVolatile = null; // null é inválido, precisa povoar

public static SomeType getSingleton() {
    if (singletonianValue == null) {
        if (singletonianValueVolatile == null) {
            synchronize (lockObj) {
                final var singletonVolatileRead = singletonianValueVolatile;
                if (singletonVolatileRead != null) {
                    return singletonVolatileRead;
                }
                // imagine uma criação complexa abaixo
                singletonianValue = new SomeType();
                singletonianValueVolatile = singletonianValue;
            }
        }
    }
    return singletonianValue;
}

Strangling fig

Esse padrão é um padrão de engenharia de software, independente de linguagem. Inclusive algumas vezes usado para trocar a linguagem do programa, sem fazer uma FFI pra comunicar a nova versão com a antiga, normalmente resolvendo isso a nível de gateway.

Basicamente, o strangling fig é uma estratégia em que você começa a reescrever uma pequena parte do código antigo. E sempre comparando o resultado do código novo com o antigo. Inicialmente pode ser feito um encaminhamento das requisições comparando os resultados obtidos, tanto da versão nova quanto da antiga.

Aí a ideia é substituir a versão antiga pela nova, aos poucos e constantemente. Esse padrão não tem como ser uma função porque ele está acima do código.

Material do Martin Fowler.

Object Pool

Um pool é um objeto especial, em que objetos podem ser tirados dele até um esgotamento. Após usado, o objeto é retornado ao pool para poder ser reusado.

Exemplos de object pool: HikariCP, reuso de sprites em jogos.

Mais detalhes nesse post: O que é o hikari pool?

Active record

O active record é um padrão de bind do estado do objeto com a camada de persistência. Até onde me consta, Ruby on Rails foi um grande popularizador e utilizador desse padrão.

Basicamente, existem duas versões para esse padrão:

  1. buffered: em que você precisa explicitar para ele se salvar
  2. automático: a cada alteração o banco é afetado

Leia mais. E leia mais aqui também.