Genéricos variádicos: parameter packs y each
Packs de tipos y de valores con each y repeat: declarar una función que acepta cualquier número de parámetros heterogéneos, expandir packs, restringirlos y jubilar las torres de sobrecargas.
Durante años, cada vez que una API necesitaba aceptar un número arbitrario de argumentos de tipos distintos, la respuesta era la misma: escribir la sobrecarga para dos, para tres, para cuatro, y rendirse en diez. Los packs de parámetros acaban con eso. Una sola declaración, cualquier aridad, tipos heterogéneos y seguridad completa. Es la incorporación más ambiciosa al sistema genérico desde su diseño original.
- Reconocer el problema de la explosión de sobrecargas por aridad.
- Declarar packs de tipos con
eachy packs de valores conrepeat. - Expandir un pack en llamadas, tuplas y bucles, y restringirlo con protocolos.
- Identificar los límites del mecanismo y cuándo una solución homogénea es mejor.
La aritmética de las sobrecargas
Antes de los packs, el sistema genérico solo sabía hablar de un número fijo de parámetros de tipo. Una función que combinara dos secuencias necesitaba dos marcadores; una que combinara tres, tres declaraciones distintas. La consecuencia está por todas partes: la función de la librería estándar que empareja secuencias solo acepta dos, los combinadores reactivos se detienen en cuatro y los constructores de vistas de SwiftUI mantenían una torre de sobrecargas hasta diez hijos, cada una idéntica salvo por la cuenta.
// La forma vieja: una declaracion por aridad, para siempre
func juntar<A, B>(_ a: A, _ b: B) -> String { "\(a)\(b)" }
func juntar<A, B, C>(_ a: A, _ b: B, _ c: C) -> String { "\(a)\(b)\(c)" }
func juntar<A, B, C, D>(_ a: A, _ b: B, _ c: C, _ d: D) -> String { "\(a)\(b)\(c)\(d)" }
El parámetro variádico clásico no resuelve el problema porque es homogéneo: obliga a que todos los argumentos compartan un único tipo, y para admitir tipos distintos hay que borrarlos a un existencial, perdiendo exactamente la información que querías conservar.
Un variádico clásico declara un solo tipo repetido: la lista completa es un array de ese tipo. Un pack declara una secuencia de tipos, potencialmente distintos entre sí, y conserva cada uno por separado hasta el punto de uso. Esa es toda la diferencia, y lo cambia todo.
Declarar y expandir
Un pack de tipos se declara anteponiendo each al nombre del parámetro genérico. Un pack de valores se declara aplicando repeat al pack de tipos en la posición del parámetro. La expansión, tanto en tipos como en expresiones, se escribe también con repeat seguido de un patrón que menciona los packs con each.
func imprimirTodos<each T>(_ valores: repeat each T) {
for valor in repeat each valores {
print(valor)
}
}
imprimirTodos(1, "dos", 3.0, true)
Fíjate en la simetría: each T nombra el pack de tipos, repeat each T es el tipo del parámetro, repeat each valores expande el pack de valores. El bucle se despliega en compilación, de modo que cada iteración se comprueba con el tipo concreto de esa posición, no con un existencial.
each
Marca un pack. En la lista de parámetros declara el pack de tipos; dentro de un patrón de expansión, referencia un elemento de ese pack.
repeat
Introduce la expansión. El patrón que lo sigue se repite una vez por elemento, sustituyendo cada each por el elemento correspondiente.
Forma
Dos packs expandidos juntos deben tener la misma longitud. El compilador comprueba esa igualdad de forma antes de aceptar el patrón.
La expansión también funciona en posición de tipo, y ahí es donde el mecanismo empieza a lucir: puedes devolver una tupla cuyo número de componentes depende de la aridad de la llamada.
struct Caja<Valor> {
let valor: Valor
}
func enCajas<each T>(_ valores: repeat each T) -> (repeat Caja<each T>) {
(repeat Caja(valor: each valores))
}
let r = enCajas(1, "dos") // tipo: (Caja<Int>, Caja<String>)
flowchart TD A[Pack de tipos each T] --> B[Parametro de valor repeat each T] B --> C[Patron de expansion con repeat] C --> D[Expansion en llamadas] C --> E[Expansion en tuplas] C --> F[Expansion en bucle desplegado] D --> G[Aridad libre y tipos conservados] E --> G F --> G style G fill:#a6e3a1,color:#11111b
Restringir un pack
Un pack se restringe igual que cualquier parámetro genérico, y la restricción se aplica a todos sus elementos. La sintaxis de dos puntos funciona directamente, y la cláusula where admite la forma con expansión cuando la condición es más elaborada.
func codificarTodos<each T: Encodable>(_ valores: repeat each T) throws -> [Data] {
var salida: [Data] = []
let codificador = JSONEncoder()
for valor in repeat each valores {
salida.append(try codificador.encode(valor))
}
return salida
}
func sonIguales<each T: Equatable>(
_ a: repeat each T, _ b: repeat each T
) -> Bool {
for (x, y) in repeat (each a, each b) {
if x != y { return false }
}
return true
}
El segundo ejemplo muestra el patrón de expansión con dos packs a la vez: repeat (each a, each b) produce la secuencia de pares posición a posición, y el compilador exige que ambos tengan la misma forma. Esa comprobación es estática, no una aserción en ejecución.
No puedes preguntar por su longitud, ni indexarlo, ni recorrerlo hacia atrás, ni guardarlo en una propiedad como si fuera un array. Un pack solo existe en el sistema de tipos y solo se consume mediante expansión. Si necesitas manipularlo como datos, expándelo primero hacia una tupla o hacia un array de un tipo común.
Cuándo usarlos y cuándo no
Los packs son la herramienta correcta cuando la aridad es variable y los tipos son heterogéneos y quieres conservarlos. Si alguna de las tres condiciones falla, hay opciones más simples y más legibles.
- Tipos homogéneos. Un array o un variádico clásico expresa lo mismo con menos maquinaria y mejores diagnósticos.
- Aridad fija y pequeña. Dos o tres sobrecargas explícitas siguen siendo más claras que un pack, sobre todo en API pública que otros van a leer.
- Los tipos no importan. Si vas a borrarlos a un existencial de todas formas, el pack no aporta nada.
Cuando sí encajan, la ganancia es doble: desaparece la torre de sobrecargas y desaparecen sus límites arbitrarios. Ya no hay una API que funciona con diez elementos y falla con once porque nadie escribió esa línea.
Los casos donde el mecanismo brilla comparten una silueta reconocible: constructores de resultados que combinan fuentes heterogéneas, envoltorios que transforman cada componente conservando su tipo, y adaptadores que reciben una lista de dependencias distintas y las inyectan. En todos ellos, el tipo del resultado es una función del tipo de las entradas, y esa función es exactamente lo que un patrón de expansión sabe expresar.
// Adaptador: transforma cada componente y conserva la aridad y los tipos
func mapear<each Entrada, each Salida>(
_ valores: repeat each Entrada,
con transformar: (repeat (each Entrada) -> each Salida)
) -> (repeat each Salida) {
(repeat (each transformar)(each valores))
}
Léelo con la anatomía de la lección 1: dos packs de tipos, un pack de valores, un pack de funciones y un patrón de expansión que los consume a la vez. La igualdad de forma entre los tres es lo que hace que la firma sea segura.
Merece la pena entender la magnitud de lo que acabas de aprender, porque no es azúcar sintáctico. Hasta los packs, el sistema genérico de Swift cuantificaba sobre tipos: para todo T que cumpla un contrato. Con los packs cuantifica también sobre secuencias de tipos de longitud arbitraria, y eso lo mueve a otra categoría expresiva, la de los sistemas con genericidad variádica que hasta ahora eran territorio de lenguajes de investigación y de las plantillas variádicas de C++ con toda su fragilidad. La diferencia con esas plantillas es que aquí la comprobación sigue siendo por adelantado: la igualdad de forma entre dos packs, la conformidad de cada elemento y la validez de cada patrón de expansión se demuestran al definir, no al instanciar, así que los errores siguen apareciendo donde escribes y no en una cascada ilegible de mil líneas. Y hay una lectura de diseño todavía más profunda. Cada torre de sobrecargas por aridad que existe en una librería es una confesión: el sistema de tipos no era lo bastante expresivo y alguien pagó la diferencia escribiendo código a mano y poniendo un techo arbitrario. Los packs eliminan esa deuda de raíz. Cuando en tu propio código sustituyas una de esas torres por una sola declaración con each, no estarás ahorrando líneas: estarás cerrando el hueco entre lo que querías expresar y lo que el lenguaje te dejaba decir. Ese es el sentido último de este nivel entero.
- Escribe
imprimirTodoscon un pack y verifica que funciona con cero, uno y seis argumentos heterogéneos. - Implementa
enCajasy comprueba en el REPL que el tipo devuelto es una tupla con la aridad exacta de la llamada. - Escribe una versión variádica de la función que empareja secuencias y compárala con la de dos parámetros de la librería estándar.
- Provoca un error de forma pasando packs de longitudes distintas a un patrón con dos expansiones y lee el diagnóstico.
- Busca en tu propio código o en una dependencia una torre de sobrecargas por aridad y reescríbela con
each. Mide cuántas líneas desaparecen.