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
