Swift e Objective-C

[Swift #7] Wrappers: por que @State não é magia

Wrappers de propriedades não são sintaxe do SwiftUI, mas um recurso da linguagem incluído no Swift 5.1 pela SE-0258. Explicamos como o compilador traduz @Clamped, o que são wrappedValue e projectedValue($) e quais são os limites de uso.

6 min de leitura
Imagem de capa de [Swift #7] Wrappers: por que @State não é magia

Quem desenvolve com SwiftUI digita @State e @Published dezenas de vezes por dia. Mas, quando perguntamos exatamente o que essa arroba faz, as respostas variam. «Não é sintaxe do SwiftUI?» é um erro comum. Não. Um wrapper de propriedade (property wrapper) é um recurso da linguagem incluído no Swift 5.1 pela proposta SE-0258 do Swift Evolution; o SwiftUI é apenas um usuário famoso desse recurso.

Este é o episódio 7 da série intermediária. Vamos entender o funcionamento dos wrappers de propriedades implementando um deles e resumir o que são wrappedValue e projectedValue($), além de como evitar seu uso excessivo.

Motivação — lógica de encapsulamento repetida em cada propriedade

No episódio sobre propriedades, vimos o padrão de validar valores com didSet. Mas e se várias propriedades precisarem da mesma validação? Se você quiser limitar volume, brilho e progresso ao intervalo 0…1, acabará copiando três blocos didSet.

var volume: Double = 0.5 {
    didSet { volume = min(max(volume, 0), 1) }
}
var brightness: Double = 0.5 {
    didSet { brightness = min(max(brightness, 0), 1) }
}
// O mesmo código continua...

É a mesma lógica reescrita para cada propriedade: a versão aplicada a propriedades do problema «duplicação ou segurança?» visto no episódio sobre genéricos. Um wrapper de propriedade dá nome a essa lógica de encapsulamento para que ela possa ser reutilizada.

@propertyWrapper
struct Clamped {
    private var value: Double = 0
    var wrappedValue: Double {
        get { value }
        set { value = min(max(newValue, 0), 1) }
    }
}

struct Player {
    @Clamped var volume: Double
    @Clamped var brightness: Double
}

Mesmo atribuindo player.volume = 1.5, na prática 1.0 será armazenado. A lógica de validação fica apenas em Clamped.

Como funciona — a tradução feita pela arroba

Para confirmar que @Clamped não é magia, basta observar o resultado da tradução do compilador. @Clamped var volume: Double é expandido aproximadamente assim.

private var _volume = Clamped()          // Armazenamento real: instância do wrapper
var volume: Double {                     // O nome que usamos: propriedade computada
    get { _volume.wrappedValue }
    set { _volume.wrappedValue = newValue }
}

O essencial está em duas linhas. O que realmente é armazenado é a instância do wrapper com sublinhado, e o nome que acessamos é uma propriedade computada que aponta para o wrappedValue do wrapper. Dá para ver como a distinção entre armazenamento e computação, trabalhada no episódio sobre propriedades, é usada diretamente como matéria-prima. No fim, o wrapper empacota «propriedade armazenada + propriedade computada + lógica repetida» em um único tipo e a disponibiliza por meio da arroba.

Ao entender essa tradução, as restrições dos wrappers também ficam naturais. O comportamento confuso ao adicionar didSet a uma propriedade com wrapper, assim como a impossibilidade de aplicá-lo a variáveis locais ou propriedades computadas (variáveis locais são permitidas desde o Swift 5.5), tudo se resume a «onde o armazenamento com sublinhado é criado».

Diagrama do compilador que traduz a declaração de @Clamped em um armazenamento com sublinhado e uma propriedade computada
@Clamped var volume é traduzido em um armazenamento com sublinhado e uma propriedade computada

projectedValue — o que representa o cifrão

No SwiftUI, você provavelmente já viu um cifrão, como em $text. Isso também é um recurso de wrapper. Quando você declara uma propriedade chamada projectedValue no tipo wrapper, o compilador cria um terceiro caminho chamado $이름.

  • volume → wrappedValue (o valor em si)
  • _volume → a instância do wrapper (acessível apenas dentro do tipo que o declarou)
  • $volume → projectedValue (algo que o wrapper expõe adicionalmente)

O que é esse “algo que o wrapper expõe adicionalmente” fica a critério de quem o projeta. O @State do SwiftUI expõe um Binding —um canal de leitura e escrita— por meio de $, enquanto o @Published do Combine expõe um Publisher, um fluxo de mudanças. A mesma sintaxe $ produz objetos diferentes porque isso é uma decisão de design de cada wrapper, não uma regra da linguagem. O mesmo vale para wrappers criados por você. Se você adicionar ao Clamped um projectedValue que informe se o valor foi limitado, $volume se torna uma API para verificar se a atribuição anterior ficou fora do intervalo.

Receita prática — Aprendendo design com um wrapper de UserDefaults

Um dos padrões de wrapper personalizado mais usados em produção é o acesso ao UserDefaults. Vamos criar um enquanto confirmamos o princípio.

@propertyWrapper
struct UserDefault<T> {
    let key: String
    let defaultValue: T

    var wrappedValue: T {
        get { UserDefaults.standard.object(forKey: key) as? T ?? defaultValue }
        set { UserDefaults.standard.set(newValue, forKey: key) }
    }
}

enum Settings {
    @UserDefault(key: "hasSeenOnboarding", defaultValue: false)
    static var hasSeenOnboarding: Bool
}

A lógica repetitiva de erros de digitação nas chaves, conversão de tipos e tratamento de valores padrão fica dentro do wrapper, e o ponto de uso vira uma única linha: Settings.hasSeenOnboarding = true. Outro ponto deste exemplo é que o genérico <T> e os parâmetros de init (key, defaultValue) também se aplicam ao wrapper sem alterações. Os parênteses depois do @ chamam o init do wrapper.

Esses são os cenários naturais para um wrapper: mudar o armazenamento (UserDefaults, chaveiro), envolver o acesso (locks de thread, logging) e ajustar valores (limitar intervalos, fazer trim). O ponto em comum é serem preocupações técnicas de armazenamento e acesso, sem relação com o significado do valor. Pela perspectiva da separação de responsabilidades, o wrapper é uma ferramenta para separar preocupações técnicas da declaração da propriedade.

Limites do abuso — A complexidade escondida atrás do @

O risco de um wrapper vem diretamente de seu ponto forte: código arbitrário pode se esconder atrás de uma única atribuição. É um recurso em tensão direta com o princípio da menor surpresa.

Proponho três critérios. Primeiro, dentro de um wrapper, faça apenas o que for previsível. Ajustar valores e mudar o armazenamento é válido, mas esconder efeitos colaterais pesados, como requisições de rede ou transições de tela, atrás de uma atribuição transforma a depuração em um inferno. Segundo, use apenas wrappers conhecidos pela equipe. Padrões do ecossistema, como @State, e wrappers documentados na base de código são ativos; mas, quando surge um @ novo em cada arquivo, a revisão de código vira um jogo de procurar definições de wrappers. Terceiro, deixe a lógica usada uma única vez em didSet. Wrappers existem para reutilização, então o correto é promovê-la quando surgir um segundo uso. É o princípio YAGNI (You Aren’t Gonna Need It — não crie até precisar).

Por fim, uma direção recente. À medida que Swift e SwiftUI avançam para uma abordagem baseada em macros, surgiram @ que são macros, e não wrappers, como @Observable do framework Observation. Um @ já não significa necessariamente um property wrapper. O que são macros e como diferem de wrappers será tratado em uma série avançada.

Ilustração de um cofre de propriedades com três portas: nome, sublinhado e cifrão
Nome, sublinhado e cifrão: uma propriedade ganha três portas

Resumo

  • Property wrappers não são exclusivos do SwiftUI; são um recurso da linguagem do Swift 5.1 (SE-0258) que reúne em um tipo reutilizável a lógica de encapsulamento repetida de cada propriedade.
  • O princípio é uma tradução. @Wrapper var x se expande para “uma instância do wrapper com sublinhado (armazenamento) + uma propriedade computada chamada x (canal de acesso)”.
  • $x é um terceiro canal de acesso chamado projectedValue, e o que ele fornece é definido pelo projetista do wrapper (@State fornece Binding, enquanto @Published fornece Publisher).
  • Eles são adequados para responsabilidades técnicas como alterar o local de armazenamento, encapsular o acesso e ajustar valores; efeitos colaterais pesados e lógica de uso único não devem ser colocados em wrappers.

O próximo capítulo é KeyPath, o último da série intermediária. Ele explica como a sintaxe de barra invertida \.name trata propriedades como valores e por que map(.name) se tornou possível.


Referências

Continue lendo