Swift y Objective-C

objc_msgSend: por qué un método no es una función

En el código Objective-C, la sintaxis [receiver message] entre corchetes parece una simple llamada a un método.

5 min de lectura
Imagen de portada de objc_msgSend: por qué un método no es una función

En el código Objective-C, la sintaxis [receiver message] entre corchetes parece una simple llamada a un método.

Pero es fundamentalmente distinta de una llamada a un método en C++ o Java.

Empecemos por la conclusión.

Una llamada a un método de Objective-C no es una «llamada a una función», sino un «envío de mensajes».

El código que se ejecutará se determina en tiempo de ejecución, no durante la compilación.

Al entender esta diferencia, el swizzling de métodos, KVO (Key-Value Observing) y unrecognized selector de los registros de fallos quedan conectados. Es también el tema de los puntos 11 y 12 de Effective Objective-C 2.0.


Resumen de lo esencial (3 puntos)

  1. [obj foo] se convierte, al compilarse, en una llamada a la función objc_msgSend(obj, @selector(foo)).
  2. objc_msgSend busca en la tabla de métodos de la clase en tiempo de ejecución para encontrar la función que debe ejecutar (IMP).
  3. Si no la encuentra, no se produce un fallo inmediato: se activa un procedimiento de recuperación de tres etapas llamado reenvío de mensajes.

Todos los mensajes convergen en objc_msgSend

Las funciones virtuales de C++ tienen su índice en la vtable determinado durante la compilación. En cambio, en Objective-C casi todos los mensajes pasan por la puerta de enlace objc_msgSend. Más exactamente, [super foo] se compila como objc_msgSendSuper; en la era de x86-64 también existían funciones hermanas como objc_msgSend_stret para devolver estructuras, aunque en arm64 se integraron en objc_msgSend. El principio de funcionamiento es el mismo.

NSString *result = [greeting uppercaseString];

Tras pasar por el compilador, este código se convierte en una llamada a una función de C como esta.

NSString *result = objc_msgSend(greeting, @selector(uppercaseString));

Lo que hace objc_msgSend es sencillo.

  1. Sigue el puntero isa de receiver para encontrar la clase
  2. Busca primero en la caché de métodos de la clase (muy rápido)
  3. Si no está en la caché, recorre la lista de métodos y, si tampoco lo encuentra, sube a la clase padre
  4. Salta al puntero de función (IMP) encontrado

El resultado se guarda en la caché, así que desde el segundo envío del mismo mensaje la velocidad se aproxima a la de una llamada a una función. Por eso la preocupación de que «el despacho dinámico es lento» rara vez supone un problema real.

Aunque falle la búsqueda, aún quedan tres oportunidades de recuperación
Aunque falle la búsqueda, aún quedan tres oportunidades de recuperación

¿No se encuentra? Las 3 etapas del reenvío de mensajes

Si la búsqueda del método llega hasta la clase raíz sin éxito, el runtime ofrece tres oportunidades antes del fallo.

Etapa 1: resolución dinámica de métodos (resolveInstanceMethod:)

Pregunta a la clase: «¿Quieres añadir ahora el método correspondiente a este selector?». La propiedad @dynamic de Core Data crea y adjunta el accessor en tiempo real durante esta etapa.

+ (BOOL)resolveInstanceMethod:(SEL)sel {
    if (sel == @selector(dynamicMethod)) {
        class_addMethod(self, sel, (IMP)dynamicIMP, "v@:");
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}

Etapa 2: receptor alternativo (forwardingTargetForSelector:)

Pregunta: «Si no puedes procesarlo, ¿puedes indicar un objeto que lo haga por ti?». Si devuelve otro objeto, el mensaje se transfiere a ese objeto. Este punto se usa para imitar una especie de «herencia múltiple» sin herencia.

Etapa 3: reenvío completo (forwardInvocation:)

Antes de entrar en esta etapa, el runtime solicita la firma del método mediante methodSignatureForSelector:. Si devuelve nil, el reenvío ni siquiera se inicia y se pasa directamente a doesNotRecognizeSelector:, provocando un fallo. Si existe una firma, el mensaje completo se empaqueta como un objeto NSInvocation y se reenvía. Aquí se pueden cambiar argumentos, distribuir el mensaje entre varios objetos o incluso ignorarlo en silencio. Es la opción más flexible, pero también la más costosa.

El famoso fallo solo se produce cuando las tres etapas fracasan.

-[MyViewController buttonTapped:]: unrecognized selector sent to instance 0x7f8a2c400c50

Ahora queda claro por qué el registro de fallos dice «selector no reconocido» y no «no existe la función». Durante la compilación no había ningún problema: es el resultado de que fallen tanto la búsqueda en tiempo de ejecución como el reenvío.


Lo que esta estructura hizo posible

Sobre esta estructura del runtime funcionan varias capacidades habituales del desarrollo para iOS.

  • KVO: al añadir un observador, el runtime sustituye discretamente el isa del objeto por el de una subclase
  • Swizzling de métodos: sustituye en tiempo de ejecución la conexión entre el selector y el IMP
  • NSProxy: crea objetos proxy que funcionan solo mediante reenvío (también aparece al resolver referencias circulares de NSTimer)
  • @dynamic: resuelve el accessor durante el reenvío de la etapa 1, sin crearlo en tiempo de compilación

Swift eligió el despacho estático y mediante vtable (tabla) en lugar del despacho de mensajes, cambiando flexibilidad por velocidad y seguridad. Por eso, para usar KVO en Swift, hay que añadir @objc dynamic. Es la marca que devuelve esa propiedad al mundo de mensajería del runtime de Objective-C.

Ahora este registro de fallos se lee de otra manera
Ahora este registro de fallos se lee de otra manera

Resumen

  • La esencia de las llamadas a métodos de Objective-C es el envío de mensajes mediante objc_msgSend
  • El código que se ejecutará se decide en tiempo de ejecución; gracias a la caché, no hace falta preocuparse por el rendimiento
  • Si falla la búsqueda, hay oportunidades de recuperación en este orden: resolución dinámica → receptor alternativo → reenvío completo
  • Un fallo por selector no reconocido significa que todas estas etapas han fallado

Aunque no tengas que tocar código heredado de Objective-C, este runtime sigue funcionando bajo UIKit. Cuando una línea del registro de fallos empieza a leerse de otra forma, también cambia la calidad de la depuración.

Seguir leyendo