wandres.dev
SOME Y ANY · opacos y existenciales

Cuándo cada uno: la regla y los casos forzados

La regla práctica cabe en una frase: some por defecto, any solo cuando la heterogeneidad o la decisión en ejecución lo exijan. Los casos en que únicamente compila el existencial, los casos en que únicamente compila el opaco, y la zona gris de parámetros, propiedades almacenadas y APIs públicas donde la elección tiene consecuencias de diseño.

⏱ 18 min

Las dos palabras clave se parecen tanto en la sintaxis que invitan a tratarlas como sinónimos con distinto sabor, y no lo son: eligen mundos distintos. Afortunadamente, la decisión no requiere análisis caso por caso la mayoría de las veces, porque existe una regla por defecto sólida y un puñado corto de situaciones donde el compilador decide por ti. Este es el mapa: primero la regla, luego los casos forzados en ambas direcciones, y por último la zona gris —parámetros, propiedades almacenadas, superficie pública— donde la elección deja de ser técnica y pasa a ser de diseño.

🎯 Al terminar esta lección sabrás
  • Aplicar la regla por defecto: usar some salvo que algo obligue a lo contrario.
  • Reconocer las cuatro situaciones donde únicamente compila un existencial.
  • Reconocer las situaciones donde únicamente compila un opaco y por qué la identidad de tipo es la causa.
  • Decidir con criterio en parámetros, propiedades almacenadas y firmas públicas de una biblioteca.

La regla en una frase

Empieza siempre por some; cambia a any solo cuando el compilador te lo exija o cuando necesites explícitamente que el tipo varíe en ejecución.

// Por defecto
func procesar(_ figura: some Figura) -> Double { figura.area }
func construir() -> some Figura { Circulo(radio: 1) }

// Excepcion justificada
var escena: [any Figura] = []      // tipos mezclados: no hay alternativa

La razón de que el opaco sea el valor por defecto no es dogmática: es que some conserva más información. Preserva la identidad del tipo, permite requisitos con Self, habilita la especialización, evita la caja y el conteo de referencias, y no te cierra ninguna puerta salvo la heterogeneidad. Elegir any sin necesitarlo es descartar información gratuitamente y pagar por el descarte.

Los casos en que solo compila any

Son cuatro, y merece la pena memorizarlos porque cubren casi todo el uso legítimo del existencial.

Colecciones heterogéneas. Un array genérico es homogéneo por construcción; mezclar conformantes exige un tipo que los abarque.

let figuras: [any Figura] = [Circulo(radio: 1), Rect(l: 2)]

Almacenamiento cuyo tipo cambia con el tiempo. Una propiedad opaca queda ligada a un único tipo durante toda la vida del programa; si la reasignación debe cambiar de tipo, necesitas una caja.

final class Lienzo {
    var actual: any Figura = Circulo(radio: 1)
    func reemplazar(con nueva: any Figura) { actual = nueva }
}

Recursión y grafos. Un nodo que apunta a otro nodo cuyo contenido varía no puede fijar ese tipo en compilación sin propagar parámetros genéricos por toda la estructura hasta hacerla inservible.

Decisión en ejecución. Fábricas, registros de plugins, descodificación de datos externos, tablas de estrategias indexadas por una cadena: en todos ellos el tipo concreto es un resultado del programa, no una entrada del compilador.

func figuraDesde(_ nombre: String) -> any Figura {
    nombre == "circulo" ? Circulo(radio: 1) : Rect(l: 1)
}

Fíjate en que este último ejemplo no puede escribirse con some: dos ramas de retorno con tipos distintos violan la identidad de tipo.

Los casos en que solo compila some

La dirección contraria tiene una causa única de la que todo lo demás se deriva: la caja borra la identidad del tipo, y hay requisitos que la necesitan.

Requisitos con Self en posición binaria. Comparar, sumar, combinar dos valores exige que ambos sean del mismo tipo, y dos cajas no lo garantizan.

func maximo(_ a: some Comparable, _ b: some Comparable) -> Bool { true }  // ojo
func maximoBien<T: Comparable>(_ a: T, _ b: T) -> T { a > b ? a : b }     // asi si

Ese primer ejemplo revela un matiz importante: dos some distintos en la misma firma son dos parámetros genéricos independientes, no uno compartido. Cuando necesitas que dos argumentos sean del mismo tipo, la sintaxis genérica explícita sigue siendo la única que lo expresa.

Tipos asociados en posición de entrada. Insertar en una colección existencial es imposible sin abrirla, porque el elemento entra y su tipo es desconocido.

func rellenar<C: RangeReplaceableCollection>(_ c: inout C, con x: C.Element) {
    c.append(x)          // con un existencial cerrado esto no compilaria
}

Conformidad a otro protocolo. Si necesitas que el valor satisfaga un requisito genérico o alimente una restricción, recuerda que la caja no conforma al protocolo que contiene.

Rutas críticas de rendimiento. No es una imposibilidad del compilador sino una del presupuesto, pero funciona igual de bien como criterio: en un bucle que se ejecuta millones de veces, el existencial introduce indirección, bloquea la inserción en línea y añade tráfico de conteo de referencias.

flowchart TB
Q1{Necesitas mezclar tipos distintos en una coleccion o variable}
Q1 -->|Si| ANY[Usa any]
Q1 -->|No| Q2{El tipo concreto se decide con datos de ejecucion}
Q2 -->|Si| ANY
Q2 -->|No| Q3{Hay recursion o referencias ciclicas entre tipos}
Q3 -->|Si| ANY
Q3 -->|No| SOME[Usa some o un generico explicito]
SOME --> N1[Identidad de tipo preservada]
SOME --> N2[Sin caja y sin despacho indirecto]
ANY --> N3[Flexibilidad a cambio de borrado y coste]
style SOME fill:#a6e3a1,color:#11111b
style ANY fill:#89b4fa,color:#11111b
style N3 fill:#f9e2af,color:#11111b

La zona gris: parámetros, almacenamiento y API pública

En parámetros, la respuesta es casi siempre some. Un parámetro existencial obliga a quien llama a construir una caja aunque tenga el tipo concreto a mano, y a ti a operar con despacho dinámico dentro. La excepción es cuando el parámetro se va a almacenar en una propiedad heterogénea: entonces la caja se construirá igualmente y recibirla ya encajada evita una conversión redundante.

En propiedades almacenadas, la pregunta correcta es si el tipo debe poder cambiar. Si el tipo es fijo para cada instancia, un parámetro genérico en el tipo contenedor es superior; si debe variar por instancia o con el tiempo, el existencial es la herramienta.

// El tipo es fijo por instancia: parametrizalo
struct Marco<F: Figura> { let contenido: F }

// El tipo varia: encajalo
struct MarcoDinamico { var contenido: any Figura }

En API pública de biblioteca, la elección tiene un efecto adicional que no aparece en el rendimiento: la superficie de compromiso. Un retorno some te deja cambiar el tipo interno en la próxima versión sin romper a nadie; un retorno concreto no. Y un parámetro some acepta cualquier conformante sin obligar a tus usuarios a encajar nada.

Queda una asimetría que sorprende a quien llega desde otros lenguajes: no existe un some en la declaración de un requisito de protocolo. Si lo que quieres es que cada conformante fije su propio tipo de salida, la herramienta no es el opaco sino el tipo asociado, y el opaco aparece después, al implementarlo.

protocol Fabrica {
    associatedtype Producto: Figura
    func crear() -> Producto
}

struct FabricaCirculos: Fabrica {
    func crear() -> some Figura { Circulo(radio: 1) }   // resuelve el asociado
}
💡
Prueba del bucle y prueba del array

Dos comprobaciones rápidas resuelven casi cualquier duda. Prueba del array: si el valor va a convivir con otros de tipo distinto en una misma colección, es any. Prueba del bucle: si el valor se va a tocar dentro de un bucle caliente y su tipo es único, es some. Si ninguna de las dos aplica, la regla por defecto decide y no vale la pena darle más vueltas.

Elegir no es optimizar: es decidir dónde vive la incertidumbre

La tentación al llegar aquí es leer la elección entre some y any como una decisión de rendimiento, con el opaco en el papel de opción rápida y el existencial en el de opción cómoda. Es una lectura pobre, porque confunde la consecuencia con la causa. Lo que realmente eliges al escribir una de las dos palabras es en qué momento del ciclo de vida del programa se resuelve una incertidumbre. Con some declaras que la variedad de tipos es una variedad del código fuente: existen muchos conformantes, pero cada punto del programa trata con uno solo, y el compilador puede saber cuál. Con any declaras que la variedad es una variedad de los datos: el mismo punto del programa verá tipos distintos en ejecuciones distintas, o incluso en iteraciones distintas del mismo bucle. Todo lo demás —la caja, el despacho indirecto, el borrado de los tipos asociados, la imposibilidad de comparar— no son penalizaciones arbitrarias sino las consecuencias mecánicas inevitables de haber trasladado esa incertidumbre a la ejecución. Por eso la regla por defecto es some y no una preferencia estilística: la mayoría de los programas tienen mucha menos variedad genuina en ejecución de la que su código sugiere, y usar el existencial por comodidad equivale a declarar dinámico algo que era estático, tirando información que el compilador tenía y que ya no podrá recuperar. Un diseño maduro empuja la frontera del existencial lo más tarde y lo más lejos posible: mantiene el interior del sistema estático y tipado, y coloca las cajas justo en los bordes donde el mundo exterior —datos descodificados, plugins, configuración— introduce variedad real. Las palabras clave no son dos velocidades del mismo mecanismo; son la marca visible de dónde has decidido que tu programa deje de saber.

⚔️ Decide y justifica
  1. Recorre un archivo tuyo y marca cada aparición de un existencial; para cada una, aplica la prueba del array y anota si sobrevive.
  2. Escribe una función con dos parámetros some Comparable y otra con un único genérico compartido; provoca el error que distingue ambos casos.
  3. Convierte un struct con una propiedad existencial en un struct parametrizado por un genérico y describe qué pierdes y qué ganas.
  4. Diseña una fábrica que devuelva conformantes distintos según una cadena y explica por qué su retorno no puede ser opaco.
  5. Elige una función pública de tu código con retorno concreto, cámbiala a un retorno opaco y enumera qué llamadas del cliente dejan de compilar: esa es tu superficie de compromiso real.