Swift e Objective-C

Delegate vs. Closure: 3 critérios para escolher um callback

Delegates e closures atendem às mesmas necessidades de callback, mas têm características diferentes. Implementamos o mesmo exemplo lado a lado para revelar cinco diferenças práticas e resumir como escolher com base na quantidade de eventos, na duração da relação e na necessidade de um valor de retorno.

7 min de leitura
Imagem de capa de Delegate vs. Closure: 3 critérios para escolher um callback

“Devemos extrair este callback para um delegate ou recebê-lo como closure?” Essa é uma pergunta especialmente frequente em revisões de código iOS.

Ambas as ferramentas atendem à mesma necessidade — “me avise quando algo acontecer” — portanto, do ponto de vista funcional, qualquer uma pode ser usada.

UIKit está cheio de delegates (UITableViewDelegate, UITextFieldDelegate). Porém, as novas APIs da Apple recebem closures, e as convenções variam entre as equipes.

Este artigo implementa as duas abordagens lado a lado, usando o mesmo exemplo para revelar as diferenças e organizar os critérios de escolha.

A sintaxe do padrão delegate e a comparação com observers são tratadas em outro artigo, enquanto a captura de closures e o funcionamento das referências cíclicas são explicados no artigo sobre closures. Aqui, o foco é o ponto de interseção: a escolha.

O mesmo problema, duas respostas — Comparação lado a lado

Suponha que estamos criando uma tela de seleção de imagens. Quando o usuário escolher uma foto, precisamos avisar a tela que a abriu.

Esta é a versão com delegate.

protocol ImagePickerDelegate: AnyObject {
    func imagePicker(_ picker: ImagePickerVC, didSelect image: UIImage)
    func imagePickerDidCancel(_ picker: ImagePickerVC)
}

final class ImagePickerVC: UIViewController {
    weak var delegate: ImagePickerDelegate?

    private func selectionDone(_ image: UIImage) {
        delegate?.imagePicker(self, didSelect: image)
    }
}

// Lado chamador
extension ProfileVC: ImagePickerDelegate {
    func imagePicker(_ picker: ImagePickerVC, didSelect image: UIImage) {
        avatarView.image = image
    }
    func imagePickerDidCancel(_ picker: ImagePickerVC) { /* Ignorar */ }
}

Esta é a versão com closure.

final class ImagePickerVC: UIViewController {
    var onSelect: ((UIImage) -> Void)?
    var onCancel: (() -> Void)?

    private func selectionDone(_ image: UIImage) {
        onSelect?(image)
    }
}

// Lado chamador
let picker = ImagePickerVC()
picker.onSelect = { [weak self] image in
    self?.avatarView.image = image
}

O comportamento é o mesmo. A diferença está na estrutura.

O delegate declara primeiro o contrato de comunicação como um tipo protocol, e o receptor adota o contrato inteiro. A closure troca cada evento como um valor, sem contrato.

Essa diferença estrutural resulta nas diferenças práticas abaixo.

Cinco diferenças práticas

Primeiro, a escalabilidade quando o número de eventos aumenta. Com um delegate, mesmo que os eventos passem de 5 para 10, basta adicionar métodos ao protocolo.

Os eventos relacionados ficam agrupados em um único contrato, e quem o adota implementa em uma extension toda a “conversa com esta tela”. É por isso que UITableViewDelegate continua gerenciável mesmo com dezenas de métodos.

Com closures, cada evento adiciona uma propriedade. Quando passam de três ou quatro, o código de configuração se espalha, e o compilador não detecta qual callback não foi conectado.

A falta de um método obrigatório do delegate gera erro de compilação, enquanto uma propriedade closure pode permanecer nil silenciosamente.

Segundo, a distância entre os pontos de configuração. A força da closure é manter o código que consome o evento junto da chamada que o dispara.

O código que abre o picker e recebe o resultado fica a até três linhas de distância, então o fluxo é fácil de entender. No delegate, a configuração (delegate = self) e a implementação (extension) ficam separadas no arquivo.

Para uma interação pontual, esse ritual é exagerado. Por isso closures se tornaram padrão em eventos que “acontecem uma vez e terminam”, como a conclusão de uma requisição de rede, a resposta a um botão de alerta e o fim de uma animação.

Terceiro, estado e identidade. Por convenção, os métodos do delegate recebem o emissor como primeiro argumento (imagePicker(_:didSelect:)o picker).

Quando uma tela usa dois table views, essa convenção permite distinguir de qual deles veio o evento.

A closure não transmite o emissor automaticamente, então, na mesma situação, você precisa conectar duas closures ou incluir explicitamente o emissor nos argumentos.

Quarto, onde ficam as armadilhas do gerenciamento de memória. Ambos têm risco de referências cíclicas, mas as armadilhas assumem formas diferentes.

No delegate, isso é decidido uma vez na declaração com weak var delegate. Como a convenção é forte, os erros são raros. Com closures, é preciso decidir repetidamente se deve usar [weak self] em cada ponto de conexão.

Como explicado no artigo sobre ARC (Automatic Reference Counting, contagem automática de referências), weak é desnecessário em uma execução única sem ciclo de ownership, mas um callback armazenado em uma propriedade pode formar um ciclo.

Essa decisão precisa ser refeita em cada local de uso. As closures oferecem uma superfície maior para erros.

Quinto, testes e reutilização. Closures são leves nos testes.

Basta conectar o callback diretamente no corpo do teste e verificar se ele foi chamado, sem um objeto mock. Com um delegate, é preciso criar uma classe spy para testes, adicionando código de preparação.

Por outro lado, o protocolo delegate serve como documentação de “como se comunicar com este componente”. Quando várias telas reutilizam o mesmo componente, ter um contrato explícito é uma vantagem.

Ilustração comparando delegates e closures a um contrato em uma sala de reunião e a um bilhete no balcão
O delegate é o contrato da sala de reunião; a closure é o bilhete do balcão

Critérios de escolha — Decida pela quantidade, duração e direção dos eventos

Agora que conhecemos as diferenças, vamos reduzi-las a critérios. Na maioria dos casos, três perguntas bastam.

Pergunta 1 — Quantos eventos existem? Para um ou dois, use uma closure; acima de três, ou quando a relação tende a crescer, use um delegate.

Quanto mais um conjunto de eventos se aproxima de uma “conversa”, mais valioso se torna o contrato (protocolo).

Pergunta 2 — Qual é a duração da relação? Se termina rapidamente, como uma requisição-resposta, use uma closure; se dura enquanto a tela estiver viva (eventos de rolagem, validação durante a edição de texto), use um delegate.

Quanto mais duradoura a relação, mais vantajosa é a segurança de um único weak delegate em comparação com decidir sobre [weak self] em cada uso.

Pergunta 3 — Você precisa receber um valor de volta? Métodos de delegate podem ter valores de retorno.

textField(_:shouldChangeCharactersIn:) é o exemplo clássico. A comunicação em formato de consulta, que pergunta “pode fazer?”, é o território natural do delegate.

Também é possível definir um tipo de retorno para uma closure, mas tratar o valor retornado por uma closure opcional armazenada — qual é o padrão quando ela é nil? — fica estranho.

É claro que também existem casos ambíguos. Neles, a direção recente da Apple pode servir como sinal externo.

Handlers de botões baseados em UIAction e o closure provider de UICollectionViewDiffableDataSource são exemplos. A migração para async/await — a substituição geracional dos completion handlers tratada no artigo sobre tratamento de erros — segue o mesmo fluxo.

A comunicação pontual e orientada ao fornecimento de dados continua migrando para closures e async.

Em contrapartida, o padrão para interações contínuas continua sendo o delegate. UITableViewDelegate e UINavigationControllerDelegate são exemplos.

Essa divisão de responsabilidades nos frameworks coincide exatamente com as três perguntas acima.

Vale destacar apenas um antipadrão: criar, em cada tela, um novo protocolo delegate com um único método.

Passar por quatro camadas de cerimônia — declaração do protocolo, adoção, propriedade weak e extension — para um único evento geralmente é overengineering. Nesse caso, uma linha de closure é suficiente.

No extremo oposto, uma classe com seis ou sete propriedades closure é um sinal para agrupá-las em um delegate.

Fluxograma de escolha de callbacks dividido por três perguntas: quantidade de eventos, duração da relação e valor de retorno
Quantidade de eventos, duração da relação e valor de retorno: três perguntas bastam para decidir

Resumo

  • Delegates e closures são duas implementações da mesma necessidade (“me avise quando algo acontecer”). A diferença fundamental é declarar o contrato como um tipo (protocolo) ou trocar eventos como valores.
  • As cinco diferenças práticas: delegates oferecem escalabilidade de eventos e detecção de implementações ausentes; closures mantêm a configuração coesa; a identificação do emissor segue a convenção dos delegates; closures expõem mais armadilhas de memória; e closures são mais leves nos testes.
  • Escolha com três perguntas: há três eventos ou mais, a relação é duradoura, e é necessário um valor de retorno? Se nenhuma resposta for afirmativa, use uma closure; se pelo menos uma for claramente afirmativa, use um delegate.
  • Um protocolo delegate de método único e cinco ou seis propriedades closure são sinais de overengineering em direções opostas.

Se quiser conhecer os detalhes das duas ferramentas, continue lendo. O artigo sobre o padrão delegate compara-o com observers e aborda a forma clássica da comunicação 1:1.

O artigo de revisão completa de closures organiza captura, weak self e escaping.

Continue lendo