Reducir el coste: final, private y tipos concretos
Cómo devolverle al compilador la información que necesita para elegir despacho estático. El efecto real de final y de private, la optimización de módulo completo, la sustitución de existenciales por genéricos y opacos, y un criterio para decidir dónde aplicar cada herramienta sin convertir el código en un búnker.
La lección anterior terminó con un diagnóstico: la llamada indirecta no es cara por el salto sino por las premisas que le retira al optimizador. De ahí se sigue el programa de trabajo de esta lección, que no consiste en escribir código más rápido sino en escribir código más conocible. Cada palabra clave que vas a ver aquí funciona igual: cierra una puerta que estaba abierta por defecto y, al cerrarla, le permite al compilador afirmar algo que antes solo podía suponer. final cierra la herencia; private cierra la visibilidad; un tipo concreto cierra la identidad. Ninguna de las tres cambia la lógica del programa, y las tres pueden cambiar su rendimiento en un orden de magnitud, porque desbloquean en cadena la inserción en línea, la especialización y todo lo que viene detrás.
- Explicar qué le permite deducir al compilador cada uno de
final,privateyfileprivate. - Distinguir lo que la optimización de módulo completo infiere sola de lo que hay que declarar a mano.
- Sustituir existenciales por genéricos o tipos opacos y justificar el cambio en términos de especialización.
- Aplicar un criterio de dónde intervenir que evite el endurecimiento gratuito de todo el código.
Cerrar puertas que no usabas
Una clase no marcada final es, para el compilador, una clase potencialmente heredada por alguien a quien no ve. Cada método sobrescribible que declara ocupa una entrada en la tabla virtual y cada llamada a ese método debe leerla. Marcarla final elimina la posibilidad de sobrescritura y convierte todas sus llamadas en directas.
final class Cache { // toda la clase deja de ser sobrescribible
private var mapa: [String: Data] = [:]
func leer(_ k: String) -> Data? { mapa[k] }
}
class Servicio {
final func normalizar(_ s: String) -> String { s.lowercased() } // solo este metodo
private func hash(_ s: String) -> Int { s.hashValue } // implicitamente estatico
}
private y fileprivate producen el mismo efecto por otra vía: si un miembro no es visible fuera de su ámbito léxico, el compilador puede enumerar exhaustivamente sus sobrescrituras dentro de ese ámbito. Si no hay ninguna, lo trata como final sin que tú lo escribas. Es la razón por la que un método auxiliar private dentro de una clase suele despacharse estáticamente sin ningún atributo.
El nivel de acceso también gobierna qué puede deducir el compilador entre módulos. Un miembro internal es invisible fuera del módulo, así que con la optimización de módulo completo el compilador ve todos sus usos y puede inferir la ausencia de sobrescrituras y marcarlo efectivamente final por su cuenta. Un miembro public en una biblioteca con estabilidad de ABI no permite esa inferencia: hay que declarar final de forma explícita, y esa declaración pasa a formar parte del contrato de la biblioteca.
La optimización de módulo completo es el interruptor con mejor relación entre esfuerzo y ganancia de toda esta lección. Sin ella, el compilador procesa cada archivo por separado y ni siquiera puede insertar en línea una función del archivo de al lado. En Swift Package Manager corresponde al modo de lanzamiento; en Xcode, al ajuste de modo de optimización del objetivo. Medir sin esto activado no mide tu código.
Del existencial al tipo concreto
La segunda familia de intervenciones es más profunda porque no cambia una palabra clave sino la forma de la abstracción. Un parámetro declarado any P obliga a construir un contenedor existencial, posiblemente a asignar memoria en el montón, y a despachar cada requisito por tabla de testigos. El mismo código escrito con un genérico o con un opaco conserva la identidad del tipo y abre la puerta a la especialización: el compilador emite una copia del cuerpo por cada tipo concreto que lo usa, con todas las llamadas resueltas estáticamente.
// Borra el tipo: contenedor, tabla de testigos y ninguna especializacion
func dibujar(_ f: any Figura) { f.trazar() }
// Conserva el tipo: candidato a especializacion y a insercion en linea
func dibujar<F: Figura>(_ f: F) { f.trazar() }
// Equivalente en el punto de retorno, sin revelar cual
func figuraPorDefecto() -> some Figura { Circulo(radio: 1) }
La diferencia se multiplica en las colecciones. Un [any Figura] guarda contenedores de cinco palabras, con posible caja en el montón por elemento y tráfico de conteo de referencias en cada copia; un array homogéneo guarda valores en línea y permite recorrer memoria contigua. Cuando la heterogeneidad es real y necesaria, el existencial es la herramienta correcta y no hay nada que optimizar. Cuando el array acaba conteniendo un único tipo en tiempo de ejecución, el existencial es un impuesto que nadie pidió.
flowchart TB
P[La llamada se despacha por tabla] --> Q1{Por que hay indireccion}
Q1 -->|Clase sobrescribible| C1[Marcar final o reducir visibilidad a private]
Q1 -->|Existencial any P| C2[Cambiar a generico o a some P]
Q1 -->|Marcado dynamic o expuesto a ObjC| C3[Quitar dynamic si no hay KVO ni swizzling]
C1 --> S[Despacho estatico]
C2 --> ES{Consigue el compilador especializar}
ES -->|Si| S
ES -->|No: cruza frontera resiliente| I[Sigue habiendo indireccion: ver inlinable]
C3 --> S
S --> R[Insercion en linea y optimizaciones en cadena]
style S fill:#a6e3a1,color:#11111b
style I fill:#f9e2af,color:#11111b
style P fill:#f38ba8,color:#11111bLa especialización tiene un límite que conviene conocer antes de confiar en ella: ocurre con naturalidad dentro de un módulo, y a través de fronteras solo cuando el cuerpo genérico es visible al otro lado. Una función genérica public de una biblioteca resiliente no se especializa en el módulo cliente salvo que su cuerpo esté expuesto, que es exactamente el problema de la lección siguiente.
El resto del arsenal y sus letras pequeñas
Hay tres herramientas más que aparecen constantemente en discusiones de rendimiento y que conviene situar con precisión, porque cada una tiene una letra pequeña que la hace menos universal de lo que parece.
@inline(__always) fuerza la inserción en línea aunque el heurístico del compilador la hubiera descartado por tamaño. Sirve para envoltorios diminutos cuya única razón de existir es desaparecer. Aplicado a cuerpos grandes multiplica el código emitido y empeora la caché de instrucciones, y el compilador puede ignorarlo cuando la inserción no es posible. Su hermano @inline(never) es más útil de lo que parece: impide que una función se inserte, que es justo lo que quieres en un sumidero de benchmark o para conservar un marco de pila legible en un perfil.
@inline(__always) func clamp(_ x: Int, _ a: Int, _ b: Int) -> Int { min(max(x, a), b) }
@inline(never) func sumidero<T>(_ x: T) { withExtendedLifetime(x) { } }
@frozen congela la disposición de un struct o el conjunto de casos de un enum en una biblioteca resiliente, permitiendo al cliente conocer su tamaño y manipularlo directamente en lugar de a través de su tabla de testigos de valor. Es una promesa irrevocable: no podrás añadir un campo ni un caso nunca más.
@_specialize pide explícitamente instanciaciones para una lista de tipos concretos, útil cuando sabes que una función genérica de biblioteca se usará con dos o tres tipos y quieres las versiones especializadas disponibles al otro lado de la frontera. Al empezar por guion bajo no es API estable del lenguaje y su comportamiento puede cambiar entre versiones del compilador.
Un error frecuente es alcanzar estos atributos antes de haber corregido la forma del código. Forzar la inserción de una función que recibe un existencial no elimina el existencial: sigue habiendo contenedor, tabla de testigos y posible caja en el montón, solo que ahora replicados en cada punto de llamada. El orden correcto es primero la forma, después el atributo, y solo si la medición lo justifica.
Dónde intervenir y dónde no
La tentación después de leer lo anterior es marcar final todo, sustituir todos los existenciales y declarar private cada miembro. Es mala idea por dos razones, y una de ellas no es de rendimiento.
La razón de diseño es que cada cierre elimina un punto de extensión. Un final mal puesto obliga a los consumidores de tu biblioteca a copiar código en lugar de heredar; un existencial sustituido por un genérico puede propagar parámetros de tipo por media base de código y complicar firmas que eran legibles. La abstracción existía por algo.
La razón de rendimiento es que la especialización tiene un coste propio: cada instanciación es código nuevo. Especializar agresivamente una función genérica grande usada con veinte tipos multiplica el binario, y un binario mayor significa más fallos de caché de instrucciones y arranques más lentos. En rutas frías, la versión no especializada suele ser la elección correcta.
// Ruta caliente: merece tipo concreto y cuerpo pequeno
@inline(__always)
func acumular(_ v: inout Double, _ x: Double) { v += x }
// Ruta fria de configuracion: el existencial es mas legible y no cuesta nada medible
func registrar(_ destinos: [any Destino]) { destinos.forEach { $0.abrir() } }
Hay además un orden recomendable para intervenir, porque las herramientas no son independientes. Primero comprueba que compilas con optimización de módulo completo, ya que sin ella ninguna de las demás se aprecia. Después corrige la forma del dato, que es donde están las ganancias de orden de magnitud. Solo entonces añade atributos, y siempre midiendo antes y después de cada cambio por separado, porque dos cambios simultáneos pueden compensarse y dejarte con la conclusión equivocada sobre cuál funcionó.
El criterio operativo es el mismo que rige toda esta disciplina: interviene donde el perfil señale, y solo ahí. Marca final por defecto en clases internas que no diseñaste para heredar, porque ahí el cierre no cuesta nada y documenta la intención. Reduce la visibilidad al mínimo que compile, porque es buena higiene con independencia del rendimiento. Y reserva la conversión de existenciales a genéricos para los bucles que aparecen en el perfil, no para el código de arranque.
final por defecto en lo interno
Una clase interna que nadie hereda no pierde nada al cerrarse y gana llamadas directas. Documenta además que la herencia no forma parte del diseño.
Visibilidad mínima
private y fileprivate permiten al compilador enumerar las sobrescrituras y tratarlas como inexistentes. Es higiene y rendimiento a la vez.
Módulo completo primero
Sin optimización de módulo completo el compilador ni siquiera inserta una función del archivo contiguo. Es el requisito previo de todo lo demás.
Existencial solo si hay heterogeneidad
Si el array contiene siempre el mismo tipo, el borrado no compra nada y cuesta contenedor, tabla y posible asignación en el montón.
Lo que estas tres herramientas tienen en común es más profundo que su efecto sobre el despacho, y entenderlo cambia la manera de leer todo el resto del nivel. Ninguna de ellas hace nada más rápido. final no acelera un método, private no acelera un acceso y un genérico no acelera una llamada. Lo único que hacen es eliminar posibilidades, y al eliminarlas convierten en demostrable algo que antes solo era probable. El optimizador es un demostrador de teoremas con presupuesto limitado: cada transformación que aplica requiere probar que es segura en todos los casos posibles, y basta un caso posible que no pueda descartar para que abandone. Cuando marcas final, no le das velocidad, le das una premisa. Esta manera de verlo tiene una consecuencia práctica que ahorra mucho tiempo perdido: explica por qué las micro-optimizaciones de instrucciones casi nunca funcionan en Swift mientras que los cambios de forma sí lo hacen. Reordenar operaciones aritméticas dentro de un bucle rara vez mueve la aguja, porque el optimizador ya sabía todo lo necesario para hacerlo él. Cambiar un any por un genérico sí la mueve, porque le devuelves una premisa que había perdido, y esa premisa desbloquea en cascada media docena de transformaciones que ya estaban implementadas y esperando. Y explica también la asimetría más incómoda de las bibliotecas: la estabilidad de ABI es, exactamente, la promesa de que el compilador no puede asumir lo que hay al otro lado de la frontera. Toda la lección siguiente trata de cómo comprar de vuelta, deliberadamente y con un precio explícito, una porción de esa información renunciada.
final elimina la sobrescritura y convierte las llamadas en directas; private y fileprivate consiguen lo mismo por visibilidad, y con optimización de módulo completo el compilador infiere solo lo que es internal. Sustituir any P por un genérico o por some P conserva la identidad del tipo y habilita la especialización, que es la que trae la inserción en línea. Ninguna de estas herramientas acelera nada por sí misma: eliminan posibilidades y con ello devuelven premisas al optimizador. Aplícalas por defecto donde no cuesten diseño, y con perfil en la mano donde sí lo cuesten.
- Recorre un módulo de tu proyecto y marca
finaltodas las clases internas que nadie hereda; mide el tamaño del binario y un caso de uso caliente antes y después. - Localiza un método que hayas declarado
internalsin necesidad y bájalo aprivate; comprueba en el SIL si su llamada pasó de indirecta a directa. - Escribe la misma función con
any Py con un parámetro genérico, y compara el SIL optimizado buscando la aparición de una versión especializada. - Toma un
[any P]de tu código, determina cuántos tipos distintos contiene realmente en ejecución y decide si el borrado está comprando algo. - Construye un caso en el que especializar empeore el resultado por crecimiento del binario, y explica qué señal del perfil te habría avisado.