Swift e Objective-C

[Swift avançado #5] Dispatch em Swift: por que final melhora o desempenho

Chamadas de métodos são compiladas como chamadas diretas, vtable ou objc_msgSend. Este artigo explica por que final e private melhoram o desempenho, a armadilha das extensões de protocolos e como verificar tudo com um profiler.

5 min de leitura
Imagem de capa de [Swift avançado #5] Dispatch em Swift: por que final melhora o desempenho

Você provavelmente já ouviu que adicionar final a uma classe a torna mais rápida. Parece quase uma lenda urbana, mas existe um mecanismo preciso por trás disso.

Este artigo dá continuidade ao anterior Swift avançado #4.

A aparentemente óbvia chamada de método é compilada de três formas diferentes. A forma escolhida determina o desempenho e as possibilidades de otimização.

Este capítulo da série avançada é sobre dispatch.

Três faces de uma chamada — direta, por tabela e por mensagem

Um código como object.doSomething() pode ser traduzido para código de máquina de três formas.

Dispatch estático. A função é conhecida em tempo de compilação, então a chamada salta diretamente para seu endereço.

É o mais rápido e, principalmente, o compilador pode fazer inline—eliminar a chamada e inserir o corpo—, abrindo caminho para otimizações posteriores. Métodos de struct, funções globais e métodos final pertencem a esse grupo.

Dispatch por tabela. É a forma usada por métodos de classes que permitem herança e sobrescrita.

Mesmo que o tipo em tempo de compilação de uma variável seja Animal, a instância real pode ser Dog. Só em tempo de execução sabemos qual implementação chamar.

Por isso, cada classe tem uma tabela de funções virtuais (vtable). Na chamada, o índice da função é localizado na tabela da instância e o fluxo salta para lá. O custo do polimorfismo é uma indireção.

Protocolos têm uma equivalente chamada witness table, com a mesma função. É a tabela que vimos ao falar de some e any.

Dispatch por mensagem. É o modelo do runtime do Objective-C, em que as chamadas são resolvidas por nome via objc_msgSend.

É o mais lento dos três, mas oferece flexibilidade extrema, inclusive substituir métodos em tempo de execução. No Swift, métodos marcados com @objc dynamic entram nesse mundo.

O funcionamento interno de objc_msgSend foi tratado em outro artigo; vamos deixá-lo de lado aqui.

No fim, é uma troca entre flexibilidade e velocidade. Quanto mais a decisão fica para o runtime, mais lento; quanto mais é fixada na compilação, mais rápido e otimizado.

Por que final e private melhoram o desempenho

Agora podemos dissecar a lenda urbana. Em que condições o compilador pode rebaixar estaticamente o dispatch por tabela usando devirtualização?

Ele precisa provar que “este método não pode ser sobrescrito”.

final é exatamente essa prova. final class e final func declaram que subclasses não os redefinirão.

O compilador pode remover a consulta à vtable e transformá-la em uma chamada direta, abrindo também caminho para o inline (Swift Optimization Tips).

private produz efeito semelhante. Uma declaração invisível fora do arquivo permite ao compilador ver todos os usos dentro dele e provar que não há sobrescrita.

Com whole-module optimization (WMO, padrão nos builds de release atuais), a prova se estende ao módulo inteiro. Uma classe internal sem subclasses também é tratada automaticamente como final.

A regra prática é simples: marque como final as classes que você não planeja subclassificar.

Isso é correto tanto como declaração de design—o tipo é uma folha da árvore de herança—quanto como prova de otimização para o compilador.

Não espere que “adicionei final e o app ficou mais rápido”. O overhead de chamada é medido em nanossegundos e quase não é percebido fora de loops quentes.

O valor real está nas otimizações encadeadas após o inline, que o compilador faz automaticamente. Nosso trabalho é apenas não bloquear a prova.

Diagrama do processo de otimização que remove o pedágio da tabela de busca usando um certificado final
final prova “sem sobrescrita”; o pedágio é removido

A armadilha das extensões de protocolos — requisito ou não?

O conhecimento de dispatch mais brilha na prática—ou, melhor, mais dói quando ignorado—nas implementações padrão de protocolos. Vamos a um quiz.

protocol Greeter {
    func hello()            // Declarado como requisito
}
extension Greeter {
    func hello() { print("Olá") }
    func bye() { print("Tchau") }   // Não é requisito
}
struct Korean: Greeter {
    func hello() { print("Oi") }
    func bye() { print("Tchau") }
}

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

A resposta é “Olá” e “Tchau”. hello é um requisito do protocolo, então usa dispatch dinâmico pela witness table, onde a implementação de Korean está registrada.

Já bye é um método exclusivo da extensão, não listado como requisito. Chamado pelo tipo do protocolo, ele salta estaticamente para a implementação da extensão, independentemente do que Korean definir.

Sem essa regra, surge o bug misterioso: “Eu implementei claramente, mas meu código não é chamado”. A solução é simples.

Todo método que os adotantes precisam poder substituir deve ser declarado como requisito no corpo do protocolo. Mantenha a implementação padrão na extensão, mas a declaração no corpo cria uma posição na tabela.

No artigo sobre POP (programação orientada a protocolos), apresentamos extensões de protocolos como alternativa à herança. Esta é a regra de segurança dessa ferramenta.

Verifique com instrumentação — use o profiler, não a intuição

A conclusão sobre dispatch deve sempre trazer o mesmo alerta: isso é micro-otimização, e a ordem importa.

Primeiro, encontre o gargalo real com o Time Profiler do Instruments. A maioria dos problemas de desempenho vem do algoritmo (loops O(n²)), de trabalho desnecessário (recalcular a cada frame) ou de I/O, não do dispatch.

O alerta contra otimização prematura é o mesmo do artigo sobre Knuth.

Se o profiling realmente encontrar dispatch dinâmico dentro de um loop quente, aí entram as opções: tornar o tipo final, trocar any por some ou genéricos para induzir especialização, ou levar o limite do protocolo para fora do loop.

Em outras palavras, o uso normal desse conhecimento é entender o design, não otimizar.

Por que struct é o padrão (favorece dispatch estático), por que some é recomendado em vez de any (permite especialização) e por que o SwiftUI usa views struct.

As grandes decisões da linguagem nascem todas nessa camada; entender dispatch permite ler o design do Swift como uma imagem única.

Diagrama comparando os caminhos de dispatch de métodos requisito e métodos exclusivos de extensões
Um método de extensão que não é requisito ignora minha implementação

Resumo

  • Chamadas de métodos são compiladas como dispatch estático (direto), por tabela (vtable/witness table) ou por mensagem (objc_msgSend), e flexibilidade e velocidade são inversamente proporcionais.
  • final, private e WMO provam “sem sobrescrita”, transformam chamadas dinâmicas em estáticas e abrem caminho para o inline. Em classes sem herança, final é o básico.
  • Métodos de extensões de protocolos usam dispatch diferente conforme sejam ou não declarados como requisitos. Métodos substituíveis devem ser declarados no corpo do protocolo.
  • A ordem é: profiler primeiro. O valor cotidiano do conhecimento de dispatch não é a técnica de otimização, mas a capacidade de entender o design da linguagem.

No próximo capítulo, descemos mais uma camada: layout de memória—como o tamanho de um struct é definido, por que a ordem das propriedades altera a memória e o tamanho real de uma caixa any.

Continue lendo

Fontes e verificação

  • Swift Optimization TipsSwift 프로젝트 · Documentação oficial · Consultado 17 de agosto de 2026Evidência: Dispatch estático e dinâmico, final, whole-module optimization e características de desempenho