Swift y Objective-C

[Swift avanzado #5] Dispatch en Swift: por qué final mejora el rendimiento

Las llamadas a métodos se compilan como llamadas directas, vtable u objc_msgSend. Explicamos por qué final y private mejoran el rendimiento, la trampa de las extensiones de protocolos y cómo verificarlo con un profiler.

6 min de lectura
Imagen de portada de [Swift avanzado #5] Dispatch en Swift: por qué final mejora el rendimiento

Probablemente hayas oído que añadir final a una clase la hace más rápida. Parece casi una leyenda urbana, pero detrás hay un mecanismo preciso.

Este artículo continúa el anterior Swift avanzado #4.

La aparentemente obvia llamada a un método se compila de tres formas distintas. La elegida determina el rendimiento y las oportunidades de optimización.

Esta entrega de la serie avanzada trata sobre el dispatch.

Tres caras de una llamada: directa, por tabla y por mensaje

El código como object.doSomething() puede traducirse a código máquina de tres formas.

Dispatch estático. La función se conoce en compilación, así que la llamada salta directamente a su dirección.

Es la opción más rápida y, sobre todo, el compilador puede aplicar inlining—eliminar la llamada e insertar el cuerpo—, lo que abre la puerta a más optimizaciones. Los métodos de struct, las funciones globales y los métodos final pertenecen aquí.

Dispatch por tabla. Es la forma usada por métodos de clases que permiten herencia y sobrescritura.

Aunque el tipo en compilación de una variable sea Animal, la instancia real puede ser Dog. Solo en ejecución se sabe qué implementación llamar.

Por eso cada clase tiene una tabla de funciones virtuales (vtable). Al llamar, se busca el índice de la función en la tabla de la instancia y se salta allí. La polimorfia cuesta una indirección.

Los protocolos tienen una equivalente llamada witness table, que cumple la misma función. Es la tabla que vimos al hablar de some y any.

Dispatch por mensaje. Es el enfoque del runtime de Objective-C: las llamadas se resuelven por nombre mediante objc_msgSend.

Es el más lento de los tres, pero ofrece una flexibilidad extrema, incluso sustituir métodos en ejecución. En Swift, los métodos marcados con @objc dynamic entran en este mundo.

El funcionamiento interno de objc_msgSend se trató en otro artículo; aquí lo dejamos de lado.

En última instancia, es un intercambio entre flexibilidad y velocidad. Cuanto más se decide en ejecución, más lento; cuanto más se fija en compilación, más rápido y optimizable.

Por qué final y private mejoran el rendimiento

Ahora podemos diseccionar la leyenda urbana. ¿Cuándo puede el compilador degradar estáticamente el dispatch por tabla mediante devirtualización?

Debe demostrar que «este método no puede sobrescribirse».

final es exactamente esa prueba. final class y final func declaran que no se redefinirán en subclases.

El compilador puede eliminar la consulta a vtable y convertirla en una llamada directa, abriendo también la puerta al inlining (Swift Optimization Tips).

private produce un efecto similar. Una declaración invisible fuera del archivo permite al compilador ver todos sus usos dentro de él y demostrar que no hay sobrescritura.

Con whole-module optimization (WMO, valor predeterminado en builds de release actuales), la prueba se extiende a todo el módulo. Una clase internal sin subclases también se trata automáticamente como final.

La regla práctica es sencilla: marca como final las clases que no planees heredar.

Es correcto tanto como declaración de diseño—el tipo es una hoja del árbol de herencia—como certificado de optimización para el compilador.

No esperes que «añadí final y la app se aceleró». El coste de una llamada se mide en nanosegundos y apenas se nota fuera de un bucle caliente.

Lo valioso son las optimizaciones encadenadas después del inlining, que el compilador realiza por su cuenta. Nuestro trabajo es no bloquear la prueba.

Diagrama del proceso de optimización que elimina el peaje de la tabla de búsqueda mediante un certificado final
final demuestra «sin sobrescritura»; se elimina el peaje

La trampa de las extensiones de protocolos: ¿requisito o no?

El conocimiento del dispatch brilla más en la práctica—o, mejor dicho, más duele cuando se desconoce—con las implementaciones predeterminadas de protocolos. Veamos un cuestionario.

protocol Greeter {
    func hello()            // Declarado como requisito
}
extension Greeter {
    func hello() { print("Hola") }
    func bye() { print("Adiós") }   // No es un requisito
}
struct Korean: Greeter {
    func hello() { print("Hola") }
    func bye() { print("Adiós") }
}

let k: any Greeter = Korean()
k.hello()   // ?
k.bye()     // ?

La respuesta es «Hola» y «Adiós». hello es un requisito del protocolo, así que usa dispatch dinámico mediante la witness table, donde está registrada la implementación de Korean.

bye, en cambio, es un método exclusivo de la extensión que no figura entre los requisitos. Al llamarlo mediante el tipo del protocolo, se invoca estáticamente la implementación de la extensión, sin importar qué defina Korean.

Sin esta regla aparece el bug misterioso: «Lo implementé claramente, pero no se llama mi código». La solución es sencilla.

Todo método que los adoptantes deban poder reemplazar debe declararse como requisito en el cuerpo del protocolo. Mantén la implementación predeterminada en la extensión, pero la declaración en el cuerpo crea un lugar en la tabla.

En el artículo sobre POP (programación orientada a protocolos), presentamos las extensiones de protocolos como alternativa a la herencia. Esta es la regla de seguridad de esa herramienta.

Verificación mediante instrumentación: usa el profiler, no la intuición

La conclusión sobre dispatch debe incluir siempre la misma advertencia: es microoptimización y el orden importa.

Primero encuentra el cuello de botella real con Time Profiler de Instruments. La mayoría de los problemas de rendimiento provienen del algoritmo (bucles O(n²)), trabajo innecesario (recalcular en cada frame) o I/O, no del dispatch.

La advertencia contra la optimización prematura es la misma que en el artículo sobre Knuth.

Si el perfil muestra realmente dispatch dinámico dentro de un bucle caliente, entonces se abren estas opciones: hacer final el tipo, sustituir any por some o genéricos para inducir especialización, o sacar el límite del protocolo fuera del bucle.

Dicho de otro modo, el uso habitual de este conocimiento es entender el diseño, no optimizar.

Por qué struct es el valor predeterminado (favorece el dispatch estático), por qué se recomienda some frente a any (permite especialización) y por qué SwiftUI usa vistas struct.

Las grandes decisiones del lenguaje parten de esta capa; entender el dispatch permite leer el diseño de Swift como un todo.

Diagrama comparativo de las rutas de dispatch de métodos requisito y métodos exclusivos de extensiones
Un método de extensión que no es requisito ignora mi implementación

Resumen

  • Las llamadas a métodos se compilan como dispatch estático (directo), por tabla (vtable/witness table) o por mensaje (objc_msgSend), y flexibilidad y velocidad son inversamente proporcionales.
  • final, private y WMO demuestran «sin sobrescritura», convierten llamadas dinámicas en estáticas y abren la puerta al inlining. En clases sin herencia, final es la base.
  • Los métodos de extensiones de protocolos se despachan de forma distinta según estén declarados como requisitos. Los métodos reemplazables deben declararse en el cuerpo del protocolo.
  • Primero va el profiler. El valor habitual del conocimiento sobre dispatch no es la técnica de optimización, sino la capacidad de leer el diseño del lenguaje.

La próxima entrega baja otra capa: el layout de memoria—cómo se determina el tamaño de un struct, por qué el orden de propiedades cambia la memoria y el tamaño real de una caja any.

Seguir leyendo

Fuentes y verificación

  • Swift Optimization TipsSwift 프로젝트 · Documentación oficial · Consultado 17 de agosto de 2026Respalda: Dispatch estático y dinámico, final, whole-module optimization y características de rendimiento