Swift e Objective-C

objc_msgSend: por que um método não é uma função

No código Objective-C, a sintaxe [receiver message] entre colchetes parece apenas uma chamada de método.

5 min de leitura
Imagem de capa de objc_msgSend: por que um método não é uma função

No código Objective-C, a sintaxe [receiver message] entre colchetes parece apenas uma chamada de método.

Mas ela é fundamentalmente diferente de uma chamada de método em C++ ou Java.

Vamos direto à conclusão.

A chamada de método em Objective-C não é uma “chamada de função”, mas um “envio de mensagem”.

O código que será executado é definido em tempo de execução, não no momento da compilação.

Ao entender essa diferença, method swizzling, KVO (Key-Value Observing) e unrecognized selector dos logs de crash passam a fazer sentido em conjunto. É também o assunto dos itens 11 e 12 de Effective Objective-C 2.0.


Resumo do essencial (3 pontos)

  1. [obj foo] vira uma chamada à função objc_msgSend(obj, @selector(foo)) após a compilação.
  2. objc_msgSend procura em a tabela de métodos da classe, em tempo de execução, para encontrar a função a executar (IMP).
  3. Se não encontrar, não ocorre um crash imediato: entra em ação um procedimento de recuperação em três etapas chamado encaminhamento de mensagens.

Todas as mensagens passam por objc_msgSend

As funções virtuais de C++ têm o índice na vtable definido durante a compilação. Já no Objective-C, quase todas as mensagens passam pelo gateway objc_msgSend. Mais precisamente, [super foo] é compilado como objc_msgSendSuper; na era do x86-64, também havia funções irmãs como objc_msgSend_stret para retorno de estruturas, mas no arm64 elas foram integradas ao objc_msgSend. O princípio de funcionamento é o mesmo.

NSString *result = [greeting uppercaseString];

Depois de passar pelo compilador, este código se torna uma chamada de função C como esta.

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

O que objc_msgSend faz é simples.

  1. Segue o ponteiro isa de receiver para encontrar a classe
  2. Consulta primeiro o cache de métodos da classe (muito rápido)
  3. Se não estiver no cache, percorre a lista de métodos e, se não encontrar, sobe para a classe pai
  4. Salta para o ponteiro da função encontrada (IMP)

O resultado encontrado é armazenado no cache. Por isso, a partir do segundo envio da mesma mensagem, a velocidade fica próxima à de uma chamada de função. É por isso que a preocupação de que “o despacho dinâmico é lento” raramente é um problema na prática.

Mesmo que a busca falhe, ainda restam três chances de recuperação
Mesmo que a busca falhe, ainda restam três chances de recuperação

Se não encontrar: as 3 etapas do encaminhamento de mensagens

Se a busca do método chegar à classe raiz sem sucesso, o runtime oferece três chances antes do crash.

Etapa 1: resolução dinâmica de método (resolveInstanceMethod:)

A classe é questionada: “Quer adicionar agora o método correspondente a este seletor?”. A propriedade @dynamic do Core Data cria e anexa o accessor em tempo real nessa 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:)

A pergunta é: “Se você não consegue processar, pode indicar um objeto que faça isso por você?”. Se outro objeto for retornado, a mensagem é encaminhada para ele. É aqui que se imita uma espécie de “herança múltipla” sem usar herança.

Etapa 3: encaminhamento completo (forwardInvocation:)

Antes de entrar nessa etapa, o runtime solicita a assinatura do método com methodSignatureForSelector:. Se nil for retornado, o encaminhamento nem começa e segue direto para doesNotRecognizeSelector:, causando um crash. Se houver uma assinatura, a mensagem inteira é empacotada como um objeto NSInvocation e encaminhada. Aqui é possível alterar argumentos, distribuir a mensagem para vários objetos ou até ignorá-la silenciosamente. É a opção mais flexível, mas também a mais cara.

O famoso crash só acontece quando as três etapas falham.

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

Agora fica claro por que o log de crash diz “seletor não reconhecido”, e não “função inexistente”. Não havia problema no momento da compilação: isso é resultado da falha completa da busca e do encaminhamento em tempo de execução.


O que essa estrutura criou

É sobre essa estrutura do runtime que funcionam recursos conhecidos do desenvolvimento para iOS.

  • KVO: ao adicionar um observador, o runtime substitui secretamente o isa do objeto pelo de uma subclasse
  • Method swizzling: troca em tempo de execução a ligação entre o seletor e o IMP
  • NSProxy: cria objetos proxy que funcionam apenas por encaminhamento (também aparece na resolução de referências cíclicas de NSTimer)
  • @dynamic: resolve o accessor no encaminhamento da etapa 1, sem criá-lo durante a compilação

Ao escolher o despacho estático e por vtable (tabela) em vez do despacho de mensagens, Swift trocou flexibilidade por velocidade e segurança. Por isso, para usar KVO em Swift, é preciso adicionar @objc dynamic. É o marcador que devolve essa propriedade ao mundo de mensagens do runtime do Objective-C.

Agora este log de crash é lido de outra forma
Agora este log de crash é lido de outra forma

Resumo

  • A essência das chamadas de método em Objective-C é o envio de mensagens por objc_msgSend
  • O código a executar é definido em tempo de execução; graças ao cache, você não precisa se preocupar com a velocidade
  • Quando a busca falha, há chances de recuperação nesta ordem: resolução dinâmica → receptor alternativo → encaminhamento completo
  • Um crash de seletor não reconhecido significa que todas essas etapas falharam

Mesmo que você nunca precise mexer em código legado de Objective-C, esse runtime continua funcionando sob o UIKit. Quando uma linha do log de crash começa a ser lida de outra maneira, a depuração também muda de qualidade.

Continue lendo