Swift y Objective-C

Delegate vs. Closure: 3 criterios para elegir un callback

Los delegates y las closures resuelven las mismas necesidades de callback, pero tienen características distintas. Implementamos el mismo ejemplo en paralelo para mostrar cinco diferencias prácticas y resumir cómo elegir según el número de eventos, la duración de la relación y la necesidad de un valor de retorno.

7 min de lectura
Imagen de portada de Delegate vs. Closure: 3 criterios para elegir un callback

«¿Deberíamos extraer este callback a un delegate o recibirlo como closure?» Es una pregunta especialmente frecuente en las revisiones de código iOS.

Ambas herramientas resuelven la misma necesidad: «avísame cuando ocurra algo», así que, desde el punto de vista funcional, cualquiera de las dos sirve.

UIKit está lleno de delegates (UITableViewDelegate, UITextFieldDelegate). Sin embargo, las nuevas API de Apple reciben closures y las convenciones varían entre equipos.

Este artículo implementa ambos enfoques en paralelo con el mismo ejemplo para mostrar sus diferencias y establecer criterios de elección.

La sintaxis del patrón delegate y su comparación con los observers se tratan en otro artículo; la captura de closures y el funcionamiento de las referencias circulares se explican en el artículo sobre closures. Aquí nos centramos en el punto común: la elección.

El mismo problema, dos respuestas — Comparación lado a lado

Supongamos que creamos una pantalla de selección de imágenes. Cuando el usuario elija una foto, debemos avisar a la pantalla que la abrió.

Esta es la versión con 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 que realiza la llamada
extension ProfileVC: ImagePickerDelegate {
    func imagePicker(_ picker: ImagePickerVC, didSelect image: UIImage) {
        avatarView.image = image
    }
    func imagePickerDidCancel(_ picker: ImagePickerVC) { /* Ignorar */ }
}

Esta es la versión con closure.

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

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

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

El comportamiento es el mismo. La diferencia está en la estructura.

El delegate declara primero el contrato de comunicación como un tipo protocol, y el receptor adopta el contrato completo. La closure intercambia cada evento como un valor, sin contrato.

Esta diferencia estructural da lugar a las diferencias prácticas que veremos a continuación.

Cinco diferencias prácticas

Primero, la escalabilidad cuando aumenta el número de eventos. Con un delegate, aunque los eventos pasen de 5 a 10, basta con añadir métodos al protocolo.

Los eventos relacionados quedan agrupados en un contrato, y quien lo adopta implementa en una sola extension toda la «conversación con esta pantalla». Por eso UITableViewDelegate sigue siendo manejable aunque tenga decenas de métodos.

Con closures, cada evento añade una propiedad. Cuando superan tres o cuatro, el código de configuración se dispersa y el compilador no detecta qué callback quedó sin conectar.

La falta de un método obligatorio del delegate provoca un error de compilación, mientras que una propiedad closure puede quedarse en nil sin que ocurra nada.

Segundo, la distancia entre los puntos de configuración. La ventaja de una closure es que el código que consume el evento queda junto a la llamada que lo desencadena.

El código que abre el picker y el que recibe el resultado están a menos de tres líneas, así que el flujo se entiende de un vistazo. Con un delegate, la configuración (delegate = self) y la implementación (extension) quedan separadas en el archivo.

Para una interacción puntual, ese ritual es excesivo. Por eso las closures son estándar en eventos que «ocurren una vez y terminan», como completar una petición de red, responder a un botón de alerta o finalizar una animación.

Tercero, el estado y la identidad. Por convención, los métodos del delegate reciben el emisor como primer argumento (imagePicker(_:didSelect:)el picker).

Cuando una pantalla usa dos table views, esta convención permite distinguir de cuál procede el evento.

Una closure no transmite el emisor automáticamente, así que en la misma situación hay que conectar dos closures o diseñar explícitamente el emisor como argumento.

Cuarto, dónde aparecen las trampas de gestión de memoria. Ambos tienen riesgo de referencias circulares, pero las trampas adoptan formas distintas.

En un delegate, esto se decide una vez al declararlo con weak var delegate. Como la convención es muy sólida, los errores son poco frecuentes. Con closures, hay que decidir repetidamente si usar [weak self] en cada punto de conexión.

Como se explica en el artículo sobre ARC (Automatic Reference Counting, conteo automático de referencias), weak no es necesario para una ejecución puntual sin ciclo de ownership, pero un callback almacenado en una propiedad puede formar un ciclo.

Esa decisión debe repetirse en cada lugar de uso. Las closures ofrecen más superficie para cometer errores.

Quinto, las pruebas y la reutilización. Las closures son ligeras en las pruebas.

Puedes conectar el callback directamente en el cuerpo de la prueba y verificar si se llamó, sin un objeto mock. Con un delegate necesitas una clase spy para pruebas, lo que añade código de preparación.

A cambio, el protocolo delegate documenta «cómo comunicarse con este componente». Cuando varias pantallas reutilizan el mismo componente, contar con un contrato explícito es una ventaja.

Ilustración comparativa que representa delegates y closures como un contrato en una sala de reuniones y una nota en un mostrador
El delegate es el contrato de la sala de reuniones; la closure, la nota del mostrador

Criterios de elección — Decide según el número, la duración y la dirección de los eventos

Con las diferencias claras, resumamos los criterios. En la mayoría de los casos bastan tres preguntas.

Pregunta 1 — ¿Cuántos eventos hay? Si son uno o dos, usa una closure; si superan tres o la relación seguirá creciendo, usa un delegate.

Cuanto más se parezca el conjunto de eventos a una «conversación», más valor aporta el contrato (protocolo).

Pregunta 2 — ¿Cuánto dura la relación? Si termina rápido, como una solicitud-respuesta, usa una closure; si dura mientras la pantalla está activa (eventos de scroll o validación durante la edición de texto), usa un delegate.

Cuanto más duradera sea la relación, más ventajosa resulta la seguridad de un único weak delegate frente a decidir sobre [weak self] en cada uso.

Pregunta 3 — ¿Necesitas recuperar un valor? Los métodos del delegate pueden devolver valores.

textField(_:shouldChangeCharactersIn:) es el ejemplo típico. La comunicación basada en consultas que pregunta «¿se puede hacer?» es el terreno natural del delegate.

También puedes diseñar un tipo de retorno para una closure, pero gestionar el valor devuelto por una closure opcional almacenada —¿cuál es el valor predeterminado si es nil?— resulta extraño.

Por supuesto, también hay casos ambiguos. En esos casos, la dirección reciente de Apple puede servir como señal externa.

Los handlers de botones basados en UIAction y el proveedor de closures de UICollectionViewDiffableDataSource son ejemplos. La migración a async/await —el relevo generacional de los completion handlers tratado en el artículo sobre manejo de errores— sigue la misma tendencia.

La comunicación puntual y orientada a proporcionar datos continúa desplazándose hacia closures y async.

En cambio, el estándar para las interacciones continuas sigue siendo el delegate. UITableViewDelegate y UINavigationControllerDelegate son ejemplos.

Esta división de responsabilidades en los frameworks coincide exactamente con las tres preguntas anteriores.

Señalemos un único antipatrón: crear en cada pantalla un protocolo delegate con un solo método.

Pasar por cuatro capas de ceremonia —declaración del protocolo, adopción, propiedad weak y extension— para un único evento suele ser sobreingeniería. Ahí encaja una sola línea de closure.

En el extremo opuesto, una clase con seis o siete propiedades closure indica que conviene agruparlas en un delegate.

Diagrama de flujo para elegir callbacks según tres preguntas: número de eventos, duración de la relación y valor de retorno
Número de eventos, duración de la relación y valor de retorno: tres preguntas bastan para decidir

Resumen

  • Delegates y closures son dos implementaciones de la misma necesidad («avísame cuando ocurra algo»). La raíz de la diferencia está en declarar el contrato como tipo (protocolo) o intercambiar eventos como valores.
  • Las cinco diferencias prácticas: el delegate ofrece escalabilidad de eventos y detección de implementaciones ausentes; la closure mantiene cohesionada la configuración; la identificación del emisor sigue la convención del delegate; la closure expone más superficie a trampas de memoria; y la closure facilita las pruebas.
  • Elige con tres preguntas: ¿hay al menos tres eventos?, ¿la relación es duradera?, ¿se necesita un valor de retorno? Si ninguna respuesta es afirmativa, usa una closure; si alguna lo es claramente, usa un delegate.
  • Un protocolo delegate de un solo método y cinco o seis propiedades closure son señales de sobreingeniería en direcciones opuestas.

Si quieres conocer los detalles de ambas herramientas, sigue leyendo. El artículo sobre el patrón delegate compara su uso con observers y trata la forma canónica de la comunicación 1:1.

El artículo de repaso completo de closures explica la captura, weak self y escaping.

Seguir leyendo