Swift e Objective-C

[Swift intermediário #8] KeyPath do Swift: como funciona map(\.name)

\.name não é uma sintaxe abreviada de map, mas um valor do tipo independente KeyPath. Este artigo explica o que significa transformar o acesso a propriedades em um valor, as três camadas—somente leitura, escrita em tipos por valor e escrita em tipos por referência—e os usos práticos.

6 min de leitura
Imagem de capa de [Swift intermediário #8] KeyPath do Swift: como funciona map(\.name)

users.map(\.name). Esta é uma linha comum no código Swift moderno. Ao perguntar o que é o \.name iniciado por barra invertida, é comum ouvir: “Não é uma sintaxe abreviada de map?”. Isso está apenas parcialmente certo. Ele é um valor do tipo independente KeyPath. Essa sintaxe funciona não porque map seja especial, mas porque KeyPath pode ocupar o lugar de uma função.

Este é o oitavo e último artigo da série intermediária. Vamos explicar o que significa KeyPath “transformar o acesso a propriedades em um valor”, comparar os três tipos de KeyPath e mostrar onde eles revelam seu valor na prática.

O que é KeyPath — Transformando o caminho até uma propriedade em valor

No artigo sobre closures, vimos o poder de tratar funções como valores. KeyPath aplica a mesma ideia ao acesso a propriedades. Na operação de acesso user.name, ele remove user e deixa apenas “o caminho até .name”, transformando-o no valor \User.name.

let path = \User.name        // KeyPath<User, String>
let user = User(name: "Kim", age: 30)
let name = user[keyPath: path]   // "Kim"

A assinatura do tipo revela o essencial. KeyPath<User, String> é “um caminho que começa em User e termina em String”. Como ainda não está vinculado a nenhuma instância, você pode armazená-lo em uma variável, passá-lo para uma função ou reuni-lo em um array. A diferença decisiva em relação às linguagens dinâmicas que acessam propriedades com chaves de string, como “name”, é a segurança de tipos. Um erro de digitação como \User.nmae gera erro de compilação, e os tipos de origem e destino do caminho são verificados em tempo de compilação. É trazer para o sistema de tipos o trabalho que as chaves de string do KVC (Key-Value Coding) do Objective-C faziam; aqui também se repete a filosofia de priorizar a segurança apresentada na parte 1.

Os caminhos podem ser encadeados. É possível atravessar propriedades aninhadas com \User.address.city e continuar até uma propriedade da biblioteca padrão com \User.name.count. Também dá para unir dois caminhos em tempo de execução usando appending(path:).

KeyPath no lugar de uma função — Como funciona map(.name)

users.map(\.name) é compilado graças à proposta SE-0249 do Swift Evolution. Ao passar KeyPath<Root, Value> onde se espera uma função (Root) -> Value, o compilador o converte automaticamente na closure { $0[keyPath: path] }. Por isso, as duas linhas abaixo são o mesmo código.

let names = users.map { $0.name }
let names = users.map(\.name)

Qual é melhor? Para uma simples extração de propriedade, a opinião predominante favorece KeyPath. { $0.name } exige três etapas: “ler a closure → entender o que $0 significa → perceber que ela extrai uma propriedade”. Já \.name tem “extrair name” como a própria sintaxe. É uma forma ainda mais compacta de declarar a intenção, como vimos no artigo sobre funções de ordem superior. Porém, se houver qualquer lógica de transformação ($0.name.uppercased() + "님"), use uma closure. KeyPath serve para extração, não para transformação.

Essa conversão automática funciona não só em map, mas em qualquer lugar que receba uma função: filter(\.isActive), compactMap(\.thumbnail), KeyPathComparator usado com sorted(by:) e até contains(where:). As receitas de funções de ordem superior ficam ainda mais curtas quando encontram KeyPath.

Diagrama das três camadas de KeyPath: somente leitura, escrita em tipos por valor e escrita em destinos por referência
São três camadas: somente leitura, escrita em tipos por valor e escrita em destinos por referência

Três tipos de KeyPath — somente leitura ou também escrita?

KeyPath tem uma hierarquia. O compilador cria tipos diferentes conforme o acesso permitido pelo caminho.

KeyPath<Root, Value> — somente leitura. É um caminho até uma propriedade let ou uma propriedade computada somente para leitura.

WritableKeyPath<Root, Value> — leitura e escrita. É um caminho até uma propriedade armazenada var (ou uma propriedade computada com setter), permitindo modificar propriedades de tipos por valor pelo caminho.

ReferenceWritableKeyPath<Root, Value> — escrita em um destino por referência. É um caminho até uma propriedade var de uma instância de classe. A semântica de valor e referência—o conteúdo pode mudar mesmo quando a referência é mantida com let—também aparece nos tipos KeyPath.

Essa distinção importa na prática quando você escreve código que altera um valor por meio de um caminho.

func update<T, V>(_ items: inout [T], path: WritableKeyPath<T, V>, to value: V) {
    for i in items.indices {
        items[i][keyPath: path] = value
    }
}

update(&cells, path: \.isSelected, to: false)   // Desmarcar tudo⟧

Passar um KeyPath somente para leitura resulta em erro de compilação. O contrato “esta função modifica essa propriedade” fica registrado na assinatura, seguindo a mesma filosofia sintática de throws, que registra a possibilidade de falha na assinatura da função.

O verdadeiro lugar do KeyPath — Separando configuração e lógica

A abreviação de map é apenas a introdução ao KeyPath. Seu verdadeiro valor aparece em designs que transformam “qual propriedade tratar” em dados.

Externalização dos critérios de ordenação. Ao criar uma UI de ordenação de tabelas, declare os critérios como um array de KeyPathComparator em vez de escrever uma função de ordenação para cada coluna. Por exemplo, [KeyPathComparator(\.name), KeyPathComparator(\.date, order: .reverse)]. Altere apenas os comparators conforme a coluna escolhida pelo usuário e passe-os para items.sorted(using:); a lógica de ordenação fica fixa em uma linha, enquanto apenas os critérios mudam como dados.

Tabelas de binding e validação de formulários. Ao declarar com KeyPath que “este campo corresponde a esta propriedade de User”, percorrer, validar e salvar campos vira código table-driven. A lógica não cresce quando novos campos são adicionados.

A base do SwiftUI e do Observation. O parâmetro id de List(users, id: \.id) é um KeyPath, e o framework Observation também usa KeyPath para rastrear “quais propriedades foram lidas”. Sempre que um framework precisa saber “qual propriedade do seu tipo”, KeyPath funciona como a moeda padrão.

O padrão comum fica claro: mantenha a lógica fixa e injete como valor a propriedade à qual ela se aplica. Assim como os genéricos transformam o tipo em parâmetro, KeyPath transforma a seleção de propriedades em parâmetro. É razoável vê-lo como uma versão ultraleve do padrão Strategy.

Um cuidado é o uso excessivo. APIs genéricas que misturam \.self com caminhos de várias etapas ficam rapidamente difíceis de entender. Seguindo o critério de Progressive Disclosure, quando a complexidade começa a vazar para os pontos de uso, é melhor voltar a uma closure comum ou a uma função explícita.

Ilustração de uma máquina sorted using trocando cartões de critérios de ordenação
Mantenha a lógica única e troque apenas a propriedade-alvo como um cartão

Resumo

  • KeyPath é um tipo que transforma o caminho de acesso a uma propriedade em valor. \User.name é KeyPath<User, String>, e erros de digitação e incompatibilidades de tipos são detectados em tempo de compilação.
  • map(\.name) é a conversão automática da SE-0249. Use KeyPath para extrações simples e uma closure quando houver transformação.
  • São três camadas: KeyPath somente para leitura, WritableKeyPath para escrever em tipos por valor e ReferenceWritableKeyPath para escrever em destinos por referência. Uma função que modifica dados exige Writable, registrando o contrato na assinatura.
  • Seu verdadeiro valor está em parametrizar a seleção de propriedades. É a ferramenta padrão para critérios de ordenação, binding de formulários, definição de id e qualquer situação que exija “uma lógica, apenas a propriedade-alvo substituível”.

Com isso, a série intermediária de oito artigos chega ao fim. A seguir vem a série avançada sobre Swift Concurrency. O primeiro artigo explicará quais problemas dos callbacks async/await resolveu e como, além do significado exato do conceito de suspensão.


Materiais de referência

Continue lendo