Swift e Objective-C

[Filosofia do Swift #3] Por que o Swift usa structs: tipos por valor

A biblioteca padrão do Swift revela um fato marcante: não apenas tipos básicos como Int, Double e Bool, mas também String, Array, Dictionary e Set são todos structs. O que seria naturalmente classe em outras linguagens é tipo por valor no Swift…

7 min de leitura
Imagem de capa de [Filosofia do Swift #3] Por que o Swift usa structs: tipos por valor

A biblioteca padrão do Swift revela um fato marcante: não apenas tipos básicos como Int, Double e Bool, mas também String, Array, Dictionary e Set são todos structs. O que seria naturalmente classe em outras linguagens é tipo por valor no Swift. String é uma classe em Java, e no Python tudo é uma referência a objeto.

Isso não é acidental. Durante o projeto, o Swift definiu “tipos por valor como padrão” e declarou oficialmente essa direção nas famosas sessões da WWDC 2015, “Protocol-Oriented Programming” e “Building Better Apps with Value Types”. Este artigo resume por que o Swift usa valores em vez de referências como padrão e como essa escolha repercutiu na linguagem inteira.

Esta é a parte 3 da série sobre a filosofia do Swift. As diferenças de sintaxe entre classes e structs são tratadas em outro artigo; aqui, o foco é por que essa diferença foi criada.

Os problemas crônicos de um mundo baseado em referências

Para entender a prioridade dos tipos por valor, primeiro precisamos analisar os problemas de um mundo em que tipos por referência são o padrão.

A essência dos tipos por referência é o compartilhamento. Mesmo copiando uma variável, existe um único objeto, apontado pelas duas variáveis. Quando intencional, compartilhar é um recurso; quando não, vira terreno fértil para bugs. O padrão clássico é este.

// Suponha um tipo por referência (classe)
let settings = defaultSettings
settings.fontSize = 20   // Eu não pretendia mexer nas configurações padrão
// defaultSettings.fontSizee 20isso aconteceu

Você achou que tinha copiado, mas estava compartilhando. A parte desagradável desses bugs é que sintoma e causa ficam distantes. O código que corrompeu o valor pode estar a vários arquivos de distância, transformando a depuração em “rastrear todas as referências a este objeto”.

Desenvolvedores de Objective-C conheciam bem esse problema e se protegiam dele por convenção. Declaravam propriedades NSString com copy, separavam as versões mutável (NSMutableArray) e imutável de NSArray e incluíam cópias defensivas por hábito. Eram remendos que usavam disciplina do desenvolvedor para compensar problemas causados por referências serem o padrão.

A visão da equipe do Swift era: se um problema exige proteção por convenções todas as vezes, será que o padrão da linguagem não está errado?

O que os tipos por valor protegem — raciocínio local

A propriedade essencial de um tipo por valor é que copiá-lo cria algo realmente independente.

var a = [1, 2, 3]
var b = a
b.append(4)
// aainda [1, 2, 3]

Isso garante mais que conveniência: permite o raciocínio local. Ao passar um array para uma função, se ele for um tipo por valor, você não precisa se preocupar com a possibilidade de a função alterá-lo às escondidas. O estado da sua variável pode ser totalmente compreendido lendo apenas o seu bloco de código. Em um mundo de tipos por referência, era preciso pensar, no programa inteiro, quem mantinha um objeto e quando poderia alterá-lo; tipos por valor reduzem esse escopo à função diante de você.

Isso se conecta diretamente à filosofia Safe abordada na parte 1. Assim como optionals detectam “esquecer nil” em tempo de compilação, tipos por valor eliminam no nível de tipos a categoria de bugs do “compartilhamento não intencional”. Além disso, um struct declarado com let é realmente imutável. Para uma instância de classe, let significa apenas que a referência não muda; seu conteúdo ainda pode mudar.

Essa propriedade ficou ainda mais valiosa com o tempo. Em um ambiente multithread, uma condição de corrida de dados surge quando “várias threads compartilham a mesma memória”, mas tipos por valor não são compartilhados desde o início, removendo a premissa da corrida. É natural que Swift Concurrency considere tipos por valor exemplos representativos de tipos Sendable capazes de atravessar limites entre threads. Uma decisão de projeto de 2014 foi aproveitada pelo modelo de concorrência de 2021.

Referências compartilham um balão; valores têm cada um o seu
Referências compartilham um balão; valores têm cada um o seu

“Copiar não é caro?” — A resposta do Copy-on-Write

A primeira objeção à prioridade dos tipos por valor é sempre desempenho. Se um array com 100.000 elementos fosse copiado inteiro toda vez que passasse por uma função, isso não seria inviável?

A resposta do Swift é Copy-on-Write (CoW). Coleções padrão como Array, Dictionary, Set e String compartilham o armazenamento interno no momento da atribuição e só fazem uma cópia real quando um dos lados é modificado. Semanticamente, são valores completos cujas alterações não ficam visíveis entre si, com custo semelhante ao de referências quando apenas são lidos.

var a = hugeArray   // Nenhuma cópia; armazenamento compartilhado
let x = a[0]        // Ainda sem cópia
a.append(1)         // A primeira cópia acontece neste momento

O ponto importante é que CoW é uma técnica de implementação de biblioteca, não um recurso da linguagem. Ele existe nas coleções padrão, mas não é adicionado automaticamente aos structs que criamos. Se você precisar de um tipo por valor personalizado que contenha dados grandes, terá de implementá-lo usando isKnownUniquelyReferenced. Esse detalhe de implementação é explicado em outro artigo sobre CoW.

Há também uma vantagem de desempenho no sentido oposto. Structs pequenos podem ficar na stack sem alocação no heap e não têm contagem de referências, sendo até mais baratos que classes. É por isso que tipos como CGPoint são structs. Portanto, a intuição de que “tipos por valor são lentos” geralmente funciona ao contrário no Swift.

Como viver sem herança — protocolos e composição

Priorizar tipos por valor tem um custo: structs não permitem herança. Como é difícil implementar polimorfismo parcial sem referências, como obtemos reutilização de código e polimorfismo?

A resposta do Swift é a programação orientada a protocolos (POP). Declaramos interfaces comuns como protocolos, colocamos implementações comuns em extensões de protocolos e combinamos capacidades fazendo os tipos adotarem vários protocolos. Herança é uma estrutura vertical, em que tudo vem de um único pai; a adoção de protocolos é horizontal, permitindo escolher e encaixar as capacidades necessárias.

struct Player: Codable, Equatable, Comparable {
    let name: String
    let score: Int

    static func < (lhs: Self, rhs: Self) -> Bool {
        lhs.score < rhs.score
    }
}

Este struct não herda nada, mas oferece conversão para JSON, comparação de igualdade e ordenação. Além disso, o compilador sintetiza Codable e Equatable automaticamente. A combinação de tipos por valor e protocolos substitui a maior parte dos usos práticos da herança.

Por isso, prioridade dos tipos por valor e programação orientada a protocolos formam um conjunto. Não foi por acaso que as duas sessões foram apresentadas lado a lado na WWDC 2015. “Structs e protocolos em vez de herança de classes” é a combinação padrão proposta pelo Swift, e toda a biblioteca padrão foi construída assim. O POP é explicado em detalhes em outro artigo.

Copy-on-Write compartilha na leitura e copia apenas na escrita
Copy-on-Write compartilha na leitura e copia apenas na escrita

Então, quando usar classes?

Priorizar tipos por valor não significa “não use classes”. A regra exata é “struct por padrão; classe quando houver motivo para precisar de referências”. Há aproximadamente três casos.

Quando a identidade importa. Coisas que são “entidades diferentes mesmo quando seus valores são iguais”, como conexões de banco de dados, views na tela e descritores de arquivo, naturalmente usam referências. Duas conexões com as mesmas configurações não são a mesma conexão.

Quando o compartilhamento é o objetivo. Modelos compartilhados que várias telas precisam observar e managers que devem existir como uma única instância global do app se beneficiam do compartilhamento das referências como recurso.

Quando é preciso gerenciar o ciclo de vida. Use uma classe quando deinit precisar liberar recursos ou quando houver interação com frameworks Objective-C, como UIKit.

A documentação oficial da Apple recomenda a mesma direção: use structs e enums por padrão e escolha classes quando essas condições se aplicarem. Na prática, o código de apps da era SwiftUI está convergindo para views como structs, dados de estado como structs e poucos modelos por referência (classes @Observable). A filosofia de que valores são o padrão e referências são exceção chega ao nível dos frameworks de UI.

Resumo

  • O fato de a biblioteca padrão do Swift ser quase toda composta por structs é uma decisão de projeto. A escolha padrão é um tipo por valor.
  • O objetivo é eliminar no nível de tipos os bugs distantes causados pelo compartilhamento não intencional em linguagens baseadas em referências.
  • Tipos por valor preservam o raciocínio local, e essa propriedade foi recuperada pelo Sendable no Swift Concurrency.
  • Copy-on-Write nas coleções padrão e alocação na stack para structs pequenos respondem à objeção de desempenho.
  • A programação orientada a protocolos preenche o espaço deixado pela herança, e as duas foram projetadas como um conjunto.
  • Classes foram redefinidas como a ferramenta escolhida quando são necessários identidade, compartilhamento ou gerenciamento do ciclo de vida.

Até aqui, a série sobre a filosofia do Swift abordou segurança (parte 1), curva de aprendizado (parte 2) e tipos por valor (parte 3). Na próxima parte, trataremos do Swift Evolution, o processo pelo qual essas filosofias entram na linguagem. É a jornada desde uma proposta de sintaxe receber um número SE-XXXX até ser incorporada ao Swift.

Leituras recomendadas