Protocolos y genéricos en Swift: diseño orientado a protocolos en la práctica

Swift no es un lenguaje orientado a objetos en el sentido clásico. Aunque tiene clases y herencia, el diseño idiomático de Swift gira en torno a protocolos y genéricos, un enfoque que Apple bautizó como Protocol-Oriented Programming (POP) en la WWDC 2015. Desde entonces, el lenguaje ha madurado bastante: los tipos opacos (some), los tipos existenciales con any y las mejoras del sistema de tipos en Swift 5.7 y posteriores han hecho que POP sea más expresivo y menos propenso a errores de diseño. Este artículo muestra cómo funciona en la práctica.

Protocolos: más que interfaces

Un protocolo en Swift define un contrato que un tipo debe cumplir. A diferencia de las interfaces en Java o C#, los protocolos en Swift pueden incluir implementaciones por defecto a través de extensiones:

protocol Describible {
    var descripcion: String { get }
    func imprimir()
}

extension Describible {
    // Implementación por defecto
    func imprimir() {
        print(descripcion)
    }
}

struct Producto: Describible {
    let nombre: String
    var descripcion: String { "Producto: (nombre)" }
    // No necesita implementar imprimir()
}

Esta capacidad de extensión es la clave del POP: puedes añadir comportamiento a tipos sin modificarlos ni crear jerarquías de herencia. Puedes incluso añadir implementaciones a tipos de la biblioteca estándar:

extension Collection where Element: Comparable {
    func estaOrdenado() -> Bool {
        zip(self, dropFirst()).allSatisfy { $0 <= $1 }
    }
}

let numeros = [1, 3, 5, 8]
print(numeros.estaOrdenado()) // true

Genéricos: código que funciona con cualquier tipo

Los genéricos permiten escribir funciones y tipos que funcionan con cualquier tipo que cumpla ciertos requisitos, sin perder información de tipo:

func mayor<T: Comparable>(_ a: T, _ b: T) -> T {
    return a > b ? a : b
}

mayor(3, 7)         // 7: Int
mayor("abc", "xyz") // "xyz": String
mayor(3.14, 2.71)   // 3.14: Double

La clave está en el tipo genérico T con la restricción Comparable. El compilador genera código específico para cada tipo en tiempo de compilación (especialización), lo que da rendimiento equivalente a código no genérico.

Restricciones múltiples con where

func sincronizarYGuardar<T>(_ items: [T]) async throws
    where T: Codable, T: Identifiable, T.ID == UUID
{
    let datos = try JSONEncoder().encode(items)
    try await almacenamiento.guardar(datos, clave: "items")
}

Tipos opacos: some

Los tipos opacos, introducidos en Swift 5.1, permiten devolver «algún tipo que cumple este protocolo» sin exponer el tipo concreto. El compilador sabe cuál es el tipo real y puede optimizarlo:

protocol Figura {
    func area() -> Double
}

struct Círculo: Figura {
    let radio: Double
    func area() -> Double { .pi * radio * radio }
}

// some Figura: el compilador sabe que es Círculo, pero el llamador no
func crearFigura() -> some Figura {
    Círculo(radio: 5)
}

En SwiftUI, some View es el uso más conocido de tipos opacos: cada vista devuelve un tipo concreto que el compilador puede optimizar, aunque desde el punto de vista del llamador sea opaco.

Tipos existenciales: any

Swift 5.7 introdujo la sintaxis any Protocolo para dejar claro cuándo estás usando un tipo existencial (una caja que puede contener cualquier tipo que cumpla el protocolo):

// Existencial: puede contener cualquier Figura en runtime
var figura: any Figura = Círculo(radio: 3)
figura = Cuadrado(lado: 4) // Válido

// Opaco: tipo fijo en compilación
var figura2: some Figura = Círculo(radio: 3)
// figura2 = Cuadrado(lado: 4) // Error: tipo fijo

Los existenciales tienen un coste en rendimiento (boxing, dispatch dinámico) que los tipos opacos no tienen. La regla: usa some cuando puedas, any cuando necesites heterogeneidad real en runtime.

Primary Associated Types y protocolos con restricciones

Swift 5.7 también añadió los primary associated types, que permiten especificar restricciones al usar protocolos como tipos:

protocol Contenedor<Elemento> {
    associatedtype Elemento
    func agregar(_ elemento: Elemento)
    func obtener(en índice: Int) -> Elemento
}

// Ahora puedes escribir:
func procesar(_ contenedor: some Contenedor<Int>) {
    // contenedor contiene Int
}

// O con existencial:
func inspeccionar(_ contenedor: any Contenedor<String>) {
    // contenedor contiene String, pero puede ser cualquier implementación
}

Diseño práctico: composición sobre herencia

El POP favorece la composición: un tipo puede conformar múltiples protocolos, recibiendo implementaciones por defecto de todos ellos, sin los problemas del diamante de la herencia múltiple:

protocol Persistible {
    func guardar() throws
    func cargar() throws
}

protocol Cacheable {
    var cacheKey: String { get }
}

protocol Sincronizable: Persistible, Cacheable {
    func sincronizar() async throws
}

extension Sincronizable {
    func sincronizar() async throws {
        try cargar()
        // lógica por defecto de sincronización
        try guardar()
    }
}

Este enfoque de composición de protocolos es similar al que Rust usa con sus traits, como se describe en la serie sobre async fn y traits en Rust, aunque con diferencias importantes en cómo el compilador gestiona la monomorphization.

Para proyectos donde el tipado estático importa tanto como en Swift, el artículo sobre TypeScript 5.8 y su sistema de tipos ofrece una perspectiva complementaria desde el mundo JavaScript.

Imagen: Pexels / Myburgh Roux

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP