Generics em Swift: Ampliando a Flexibilidade e Reusabilidade do Código

Giovanna Moeller
5 min readFeb 6, 2024

--

Photo by Abdelrahman Sobhy on Unsplash

If you want to read this story in English, please click here.

Introdução

Generics são uma poderosa característica de Swift que permitem a criação de código flexível, reutilizável e tipo-seguro, evitando duplicações e aumentando a clareza. Neste artigo, vou explorar a motivação por trás do uso de generics, introduzir sua sintaxe e conceitos fundamentais como funções, classes e structs genéricas, restrições de tipo, e mostrar uma curiosidade super interessante da linguagem: a relação entre generics e optionals.

Motivação

Você já deve saber que Swift é uma linguagem fortemente tipada, isto é, você deve definir explicitamente o tipo de cada variável, constante, parâmetro e valor de retorno em seu código.

Por mais que o Swift também tenha a funcionalidade de inferência de tipo (quando o compilador deduz automaticamente o tipo de uma variável com base no valor que lhe é atribuído), os tipos ainda são bem definidos. Se você declara que uma função recebe um valor do tipo String, você deve passar uma string e nada mais.

Veja abaixo uma função que retorna o primeiro elemento de um array:

let names = ["Paul", "Steve", "Anna"]

func firstElement(_ array: [String]) -> String? {
return array.first
}

if let firstName = firstElement(names) {
print(firstName)
}

No exemplo acima, estamos definindo que o tipo de elementos que deve compor o array é do tipo String. E se eu quiser pegar o primeiro elemento de um array de números? Eu precisaria criar outra função:

let numbers = [5, 10, 15]

func firstElement(_ array: [Int]) -> Int? {
return array.first
}

if let firstNumber = firstElement(numbers) {
print(firstNumber)
}

Muita repetição de código, certo? Pois bem, sem o uso do generics, teríamos que escrever múltiplas funções, uma para cada tipo, o que leva a uma duplicação desnecessária de código.

Conhecendo os Generics

Generics permitem escrever funções e tipos que podem trabalhar com qualquer tipo, mantendo a segurança de tipos que a linguagem Swift nos traz. O mesmo problema de retornar o primeiro elemento do array pode ser resolvido com generics, como segue o exemplo abaixo:

let names = ["Gi", "Daniel", "Ana"]
let numbers = [5, 10, 15]

func firstElement<T>(_ array: [T]) -> T? {
return array.first
}

if let firstName = firstElement(names) {
print(firstName)
}

if let firstNumber = firstElement(numbers) {
print(firstNumber)
}

Aqui, T é um placeholder de tipo, representando qualquer tipo que será fornecido quando a função for chamada. Você pode usar qualquer nome de placeholder que desejar, mas T é comumente usado para representar um "tipo" genérico.

Funções genéricas

O exemplo que você viu acima é uma função genérica. Vamos ver mais um exemplo?

Considere o seguinte problema: você precisa criar uma função que irá criar e retornar um array contendo um determinado valor repetido um número específico de vezes. Isso pode ser útil em várias situações, como inicializar um array com um valor padrão ou gerar dados de teste com valores repetidos.

Este valor pode ser qualquer tipo. Aqui está um exemplo de como você pode criar essa função usando um tipo genérico:

func makeArray<T>(repeating item: T, numberOfTimes: Int) -> [T] {
var result: [T] = []

for i in 0..<numberOfTimes {
result.append(item)
}

return result
}
print(makeArray(repeating: 3, numberOfTimes: 4))
// [3, 3, 3, 3]

print(makeArray(repeating: "Hello", numberOfTimes: 3))
// ["Hello", "Hello", "Hello"]

O que está acontecendo aqui? Eu estou criando uma função genérica que recebe um tipo genérico chamado de T e retorna um array do tipo T. Dentro desta função, eu crio um array do tipo T, inicialmente vazio, e preencho com o item que foi recebido como parâmetro. Simples, não?

Classes e structs genéricas

Swift também permite definir classes, structs e até mesmo enums genéricos. Veja um exemplo abaixo de uma classe genérica que implementa uma pilha:

class Stack<T> {
public var items = [T]()

func push(_ item: T) {
self.items.append(item)
}
func pop() -> T? {
return self.items.popLast()
}
}
}

let stack = Stack<Int>()
stack.push(3)
stack.push(10)
stack.push(12)
stack.pop()
print(stack.items) // [3, 10]

Observe a instância dessa classe Stack, realizada pela constante stack. Não podemos instanciar simplesmente com um Stack(), como faríamos em uma classe normal, precisamos definir o tipo T, por isso utilizamos Stack<Int>(). Caso optássemos pelo tipo String em vez de Int, faríamos Stack<String>().

A implementação com struct seria da mesma maneira.

Restrições de tipo

Swift permite que você especifique restrições de tipo em definições genéricas, o que restringe os tipos que podem ser usados com um tipo genérico ou função. Veja o exemplo abaixo:

func findIndex<T: Equatable>(from valueToBeFound: T, in array: [T]) -> Int? {
for (index, value) in array.enumerated() {
if value == valueToBeFound {
return index
}
}
return nil
}

if let index = findIndex(from: 3, in: [1, 2, 3, 4]) {
print(index) // 2
}

No código acima, T: Equatable garante que T implementa o protocolo Equatable, permitindo que os valores sejam comparados usando ==.

Se você não incluísse a restrição de tipo T: Equatable na definição da função findIndex, o compilador Swift geraria um erro porque não seria capaz de garantir que os tipos passados para a função suportam o operador de igualdade (==). Swift é uma linguagem fortemente tipada e requer que as operações realizadas nos tipos sejam conhecidas e seguras em tempo de compilação.

Portanto, sem a restrição T: Equatable, Swift não tem como saber se T pode ser comparado com ==. O protocolo Equatable declara o operador de igualdade ==, o que significa que qualquer tipo que conforme a Equatable pode ser comparado para igualdade. Sem essa conformidade, não há garantia de que T suporte essa operação.

Vamos ver um outro exemplo abaixo:

func encode<T: Encodable>(_ item: T) {
JSONEncoder().encode(item)
}

No exemplo, a função encode é uma função genérica que aceita qualquer tipo T como seu parâmetro, com a condição de que T deve conformar ao protocolo Encodable.

Lembrando que o protocolo Encodable faz parte da biblioteca de codificação e decodificação de dados da Swift, conhecida como Codable.

Para assegurar que a função JSONEncoder().encode(item) funcione, o nosso parâmetro item precisa ser um tipo que conforme ao protocolo Encodable. Por esse motivo, a função possui uma restrição de tipo, para assegurar que esta função funcione como esperado e não cause erro de compilação.

A relação entre optionals e generics

Você sabia que optionals são, na verdade, um enum genérico? Pois é! A definição de Optional é algo semelhante a:

enum Optional<Wrapped> { 
case none
case some(Wrapped)
}

Isso significa que quando você declara uma variável como Int?, é uma forma abreviada de Optional<Int>.

var optionalInt: Int? = 42
// É equivalente a:
var anotherOptionalInt: Optional<Int> = .some(42)

switch anotherOptionalInt {
case .none:
print("nil")
case .some(let value):
print(value) // 42
}

Conclusão

Generics são fundamentais em Swift, permitindo escrever código mais limpo, flexível e reutilizável. Eles nos ajudam a evitar duplicações e mantêm a segurança dos tipos. Ao aprender e aplicar generics, você pode aproveitar ao máximo as poderosas características de tipo de Swift. Generics, portanto, não são apenas uma ferramenta para criar código eficiente, mas também uma ponte para entender profundamente como Swift opera com tipos e valores.

Muito obrigada por ter lido até aqui.

--

--