Generics in Swift: Expanding Flexibility and Code Reusability

Generics in Swift: Expanding Flexibility and Code Reusability

Understanding Generics.

Introduction

Generics are a powerful feature in Swift that allow for the creation of flexible, reusable, and type-safe code, avoiding duplication and enhancing clarity. In this article, I will explore the motivation behind using generics, introduce their syntax, and fundamental concepts such as generic functions, classes, and structs, type constraints, and share an interesting curiosity of the language: the relationship between generics and optionals.

Motivation

You might already know that Swift is a strongly typed language, meaning you must explicitly define the type of each variable, constant, parameter, and return value in your code.

Although Swift also has type inference functionality (where the compiler automatically deduces the type of a variable based on the value assigned to it), the types are still well defined. If you declare that a function receives a value of type String, you must pass a string and nothing else.

See below a function that returns the first element of an array:

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

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

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

In the example above, we are defining that the type of elements that should compose the array is String. And if I want to get the first element of an array of numbers? I would need to create another function:

let numbers = [5, 10, 15]

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

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

A lot of code repetition, right? Well, without the use of generics, we would have to write multiple functions, one for each type, which leads to unnecessary code duplication.

Understanding Generics

Generics allow writing functions and types that can work with any type, maintaining the type safety that Swift brings us. The same problem of returning the first element of the array can be solved with generics, as follows:

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)
}

Here, T is a type placeholder, representing any type that will be provided when the function is called. You can use any placeholder name you wish, but T is commonly used to represent a "type" generic.

Generic Functions

The example you saw above is a generic function. Shall we see another example?

Consider the following problem: you need to create a function that will create and return an array containing a certain value repeated a specific number of times. This can be useful in various situations, such as initializing an array with a default value or generating test data with repeated values.

This value can be any type. Here's an example of how you can create this function using a generic type:

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"]

What's happening here? I'm creating a generic function that takes a generic type called T and returns an array of type T. Inside this function, I create an array of type T, initially empty, and fill it with the item that was received as a parameter. Simple, right?

Generic Classes and Structs

Swift also allows defining generic classes, structs, and even enums. See an example below of a generic class that implements a stack:

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]

Notice the instance of this class Stack, realized by the constant stack. We cannot simply instantiate with a Stack() as we would with a normal class; we need to define the type T, hence we use Stack<Int>(). If we opted for the type String instead of Int, we would do Stack<String>().

The implementation with

struct would be the same way.

Type Constraints

Swift allows you to specify type constraints on generic definitions, which restricts the types that can be used with a generic type or function. See the example below:

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
}

In the code above, T: Equatable ensures that T implements the Equatable protocol, allowing the values to be compared using ==.

If you did not include the type constraint T: Equatable in the definition of the function findIndex, the Swift compiler would generate an error because it would not be able to guarantee that the types passed to the function support the equality operator (==). Swift is a strongly typed language and requires that the operations performed on types are known and safe at compile time.

Therefore, without the constraint T: Equatable, Swift has no way of knowing if T can be compared with ==. The Equatable protocol declares the equality operator ==, meaning that any type that conforms to Equatable can be compared for equality. Without this conformity, there's no guarantee that T supports this operation.

Let's see another example below:

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

In the example, the function encode is a generic function that accepts any type T as its parameter, with the condition that T must conform to the Encodable protocol.

Remember, the Encodable protocol is part of Swift's encoding and decoding data library, known as Codable.

To ensure that the function JSONEncoder().encode(item) works, our parameter item needs to be a type that conforms to the Encodable protocol. For this reason, the function has a type constraint, to ensure that this function works as expected and does not cause a compilation error.

The Relationship Between Optionals and Generics

Did you know that optionals are actually a generic enum? That's right! The definition of Optional is something similar to:

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

This means that when you declare a variable as Int?, it's shorthand for Optional<Int>.

var optionalInt: Int? = 42
// Is equivalent to:
var anotherOptionalInt: Optional<Int> = .some(42)

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

Conclusion

Generics are fundamental in Swift, allowing you to write cleaner, more flexible, and reusable code. They help us avoid duplication and maintain type safety. By learning and applying generics, you can take full advantage of Swift's powerful type features. Generics, therefore, are not just a tool for creating efficient code, but also a bridge to deeply understanding how Swift operates with types and values.

Thank you so much for reading this far.