← Volver al blog

Generics en Swift

Qué son los generics, cómo se escriben, cuándo restringir tipos con Comparable o Equatable, y por qué es normal llegar a ellos después de haber escrito código repetitivo primero.

En el post de property wrappers usamos Clamped<Value: Comparable> y en el de feature flags UserDefault<Value>. Ambos funcionaron, ambos tenían sentido en contexto — pero ninguno de los dos fue explicado a detalle. Los pusimos ahí, hicieron su trabajo, y seguimos adelante como si nada.

Es un patrón fácil de caer: cuando algo funciona, la explicación puede esperar. El problema es que espera demasiado, y terminas usando una herramienta sin entender bien qué tienes entre manos.

Igual que la compilación condicional necesitaba su propio post para complementar el de feature flags, los generics merecen lo mismo para completar el de property wrappers. Son uno de esos temas que aparecen en todas partes en Swift — en la stdlib, en código de terceros, en el tuyo propio — sin que nadie se detenga a explicar por qué. Aquí está esa explicación.

Por qué existen

Sin generics, si quieres una pila (stack) que funcione con enteros, la escribes para Int. Si la quieres para strings, la vuelves a escribir para String. El código es idéntico — solo cambia el tipo.

struct IntStack {
    private var items: [Int] = []
    mutating func push(_ item: Int) { items.append(item) }
    mutating func pop() -> Int? { items.popLast() }
}

struct StringStack {
    private var items: [String] = []
    mutating func push(_ item: String) { items.append(item) }
    mutating func pop() -> String? { items.popLast() }
}

Misma lógica, tipos distintos. Los generics permiten escribirla una sola vez y dejar que el compilador haga el resto.

La sintaxis básica

El tipo genérico se declara entre <> después del nombre.

struct Stack<Element> {
    private var items: [Element] = []

    mutating func push(_ item: Element) {
        items.append(item)
    }

    mutating func pop() -> Element? {
        items.popLast()
    }

    var top: Element? {
        items.last
    }
}

Element es un placeholder — un nombre que le das al tipo que venga. Podría llamarse T, Item, Value, lo que quieras. En Swift, T es la convención para casos genéricos, y nombres descriptivos (Element, Value, Key) para cuando el rol es claro.

En uso:

var numbers = Stack<Int>()
numbers.push(1)
numbers.push(2)
numbers.pop() // 2

var words = Stack<String>()
words.push("hola")
words.push("mundo")
words.top // "mundo"

El compilador genera una versión de Stack para cada tipo que uses. Tú escribiste el código una sola vez.

Restricciones de tipo

El placeholder acepta cualquier tipo por default. A veces necesitas que el tipo tenga ciertas capacidades — que se pueda comparar, hashear, o implementar algún protocolo tuyo. Para eso están las restricciones con :.

struct SortedStack<Element: Comparable> {
    private var items: [Element] = []

    mutating func push(_ item: Element) {
        items.append(item)
        items.sort()
    }

    var top: Element? { items.last }
}

Element: Comparable le dice al compilador que solo acepta tipos que implementen Comparable. Sin eso, items.sort() no compilaría — el compilador no sabe cómo ordenar algo de tipo desconocido.

Esto es exactamente lo que hicimos con Clamped<Value: Comparable> en el post de property wrappers. La restricción era necesaria para poder llamar min() y max().

La cláusula where

Para restricciones más complejas, where permite añadirlas sin amontonarlas dentro de los <>.

func areEqual<T>(_ a: T, _ b: T) -> Bool where T: Equatable {
    a == b
}

También sirve para restringir tipos asociados de protocolos. Un ejemplo más concreto: una función que suma los elementos de cualquier colección, siempre que sus elementos sean numéricos.

func sum<C: Collection>(_ collection: C) -> C.Element
    where C.Element: Numeric {
    collection.reduce(0, +)
}

sum([1, 2, 3])        // 6
sum([1.5, 2.5, 3.0])  // 7.0

Si sacas el where, el compilador se queja: no sabe si los elementos de C soportan suma. La restricción le da esa garantía.

Funciones genéricas

Los generics también funcionan en funciones sueltas. Uno que escribí antes de encontrarlo en la stdlib: buscar el primer elemento que cumple una condición, sin importar el tipo.

func first<T>(in array: [T], where condition: (T) -> Bool) -> T? {
    for element in array {
        if condition(element) { return element }
    }
    return nil
}

first(in: [1, 3, 5, 8, 9]) { $0.isMultiple(of: 2) } // 8
first(in: ["a", "bb", "ccc"]) { $0.count > 1 }      // "bb"

Ya existe como array.first(where:) en la stdlib. Haberlo construido primero fue lo que me ayudó a entender cómo funciona internamente — y a tener el instinto de buscarlo después.

Es normal llegar aquí después

A veces hay que escribir el mismo código dos o tres veces para sentir la incomodidad. Ese momento en que algo no escala, en que copias y pegas y sabes que no está bien, es el que te lleva a buscar una mejor forma. Eso es normal, especialmente cuando estás aprendiendo. No hay que optimizar todo desde el primer intento — y la mayoría de las veces no conviene.

Lo que sí ayuda es revisar cómo lo resuelven otros, leer código bien escrito, ver ejemplos reales. Así es como llegas a los generics: no de un manual, sino de haber sentido el problema primero. Si hoy tienes código repetitivo que sabes que podría mejorar, no te preocupes demasiado. Escríbelo, hazlo funcionar, y cuando sientas que ya no escala, ese es el momento de investigar. Hacer código que luego vas a mejorar es parte de crecer como desarrollador, no una señal de que lo estás haciendo mal.