Chapters

Hide chapters

Swift Apprentice: Beyond the Basics

First Edition · iOS 16 · Swift 5.8 · Xcode 14.3

Section I: Beyond the Basics

Section 1: 13 chapters
Show chapters Hide chapters

11. Advanced Protocols & Generics
Written by Ehab Amer

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

This chapter covers more advanced uses of protocols and generics. Expanding on what you’ve learned in Swift Apprentice: Fundamentals and previous chapters, you’ll make generic protocols with constraints. You’ll also see how to hide unimportant implementation details using type erasure and opaque types while emphasizing the important ones with primary associated types.

Existential Protocols

This chapter will introduce some new terminology that may be confusing initially, but they’re important concepts for you to understand. Existential type is one such term. It’s a name for something you already know and have used — it’s merely a concrete type accessed through a protocol.

Put this into a playground:

protocol Pet {
  var name: String { get }
}
struct Cat: Pet {
  var name: String
}

In this code, the Pet protocol says that pets must have a name. Then, you defined a concrete type Cat, which conforms to Pet. Now, create a Cat like so:

var pet: any Pet = Cat(name: "Kitty")

Here, you defined the variable pet with a type of any Pet instead of the concrete type Cat. Here any Pet is an existential type or boxed type— it’s an abstract concept, a protocol, that refers to a concrete type, such as a struct, that exists. The compiler automatically creates a boxed type and wires up the concrete type inside of it.

These boxed types look like abstract base classes in object-oriented programming, but you can also apply them to enums and structs.

Note: Strictly speaking, for simple protocols with no associated types, you do not need to use the any keyword before the protocol. However, the need to write any may change in future versions of Swift and become required. The any makes clear you are paying a small but non-zero cost of accessing the concrete type through the compiler-generated box type.

Protocols with Associated Types

As you saw in Chapter 17 of Swift Apprentice, some protocols are naturally associated with other types. You specify these with the associatedtype keyword. If a protocol has any associated types, you must use the any keyword when you refer to a protocol as a type. For example, change Pet like so:

protocol Pet {
  associatedtype Food
  var name: String { get }
}
struct DogFood { }

struct Dog: Pet {
    typealias Food = DogFood
    var name: String
}

var pet: any Pet = Dog(name: "Mattie")
protocol WeightCalculatable {
  associatedtype WeightType
  var weight: WeightType { get }
}
class Truck: WeightCalculatable {
  // This heavy thing only needs integer accuracy
  var weight: Int {
    100
  }
}

class Flower: WeightCalculatable {
  // This light thing needs decimal places
  var weight: Double {
    0.0025
  }
}
class Flower: WeightCalculatable {
  typealias WeightType = Double
  var weight: Double {
    0.0025
  }
}
class StringWeightThing: WeightCalculatable {
  typealias WeightType = String

  var weight: String {
    "Superheavy" // Difficult to compute with!
  }
}

class DogWeightThing: WeightCalculatable {
  typealias WeightType = Dog

  var weight: Dog {
    Dog(name: "Rufus") // What is a dog doing here?
  }
}

Constraining the Protocol to a Specific Type

When you first thought about creating this protocol, you wanted it to define a weight through a number, and it worked perfectly when used that way. It simply made sense!

protocol WeightCalculatable {
  associatedtype WeightType: Numeric
  var weight: WeightType { get }
}

extension WeightCalculatable {
  static func + (left: Self, right: Self) -> WeightType {
    left.weight + right.weight
  }
}

var heavyTruck1 = Truck()
var heavyTruck2 = Truck()
heavyTruck1 + heavyTruck2 // 200

var lightFlower1 = Flower()
heavyTruck1 + lightFlower1

Expressing Relationships Between Types

Next, look at how to use type constraints to express a relationship between types.

protocol Product {}

protocol ProductionLine  {
  func produce() -> any Product
}

protocol Factory {
  var productionLines: [any ProductionLine] { get }
}

extension Factory {
  func produce() -> [any Product] {
    var items: [any Product] = []
    productionLines.forEach { items.append($0.produce()) }
    print("Finished Production")
    print("-------------------")
    return items
  }
}
struct Car: Product {
  init() {
    print("Car 🚘")
  }
}

struct CarProductionLine: ProductionLine {
  func produce() -> any Product {
    Car()
  }
}

struct CarFactory: Factory {
  var productionLines: [any ProductionLine] = []
}
var carFactory = CarFactory()
carFactory.productionLines = [CarProductionLine(), CarProductionLine()]
carFactory.produce()
struct Chocolate: Product {
  init() {
    print("Chocolate bar 🍫")
  }
}

struct ChocolateProductionLine: ProductionLine {
  func produce() -> any Product {
    Chocolate()
  }
}

var oddCarFactory = CarFactory()
oddCarFactory.productionLines = [CarProductionLine(), ChocolateProductionLine()]
oddCarFactory.produce()
protocol Product {
  init()
}

protocol ProductionLine {
  associatedtype ProductType: Product
  func produce() -> ProductType
}

protocol Factory {
  associatedtype ProductType: Product
  associatedtype LineType: ProductionLine
  var productionLines: [LineType] { get }
  func produce() -> [ProductType]
}

extension Factory where ProductType == LineType.ProductType {
  func produce() -> [ProductType] {
    var newItems: [ProductType] = []
    productionLines.forEach { newItems.append($0.produce()) }
    print("Finished Production")
    print("-------------------")
    return newItems
  }
}
struct Car: Product {
  init() {
    print("Car 🚘")
  }
}

struct Chocolate: Product{
  init() {
    print("Chocolate bar 🍫")
  }
}
struct GenericProductionLine<P: Product>: ProductionLine {
  func produce() -> P {
    P()
  }
}

struct GenericFactory<P: Product>: Factory {
  typealias ProductType = P
  var productionLines: [GenericProductionLine<P>] = []
}
var carFactory = GenericFactory<Car>()
carFactory.productionLines = [GenericProductionLine<Car>(),
                              GenericProductionLine<Car>()]
carFactory.produce()

Mini-Exercise

Here’s a little challenge for you. Try to do these two things:

More Constraints Using a where Clause

You can set up useful and powerful constraints using a where clause. For example, suppose you want to write a generic function that sums the values of a collection and returns that sum as the same type as the element of the input collection. You could write this:

func sum<C: Collection>(_ input: C) -> C.Element where C.Element: Numeric {
  input.reduce(0, +)
}

sum([1, 2, 3]) // Returns Int (6)
sum([1.25, 2.25, 3.25]) // Returns Double (6.75)

Primary Associated Types

Think of associated types for protocols as generic parameters without angle brackets. Because of this, they are hidden as an implementation detail of a conforming type.

protocol ProductionLine<ProductType> {
  associatedtype ProductType: Product
  func produce() -> ProductType
}
func produceCars(line: any ProductionLine<Car>, count: Int) -> [Car] {
  (1...count).map { _ in line.produce() }
}
produceCars(line: GenericProductionLine<Car>(), count: 5)

Type Erasure

Type erasure is a technique for erasing type information that is not important. The type Any is the ultimate type erasure. It expunges all type information. As a consequence, it is lengthy and error-prone to use. As an example, consider the following collection types:

let array = Array(1...10)
let set = Set(1...10)
let reversedArray = array.reversed()
for e in reversedArray {
 print(e)
}

let arrayCollections = [array, Array(set), Array(reversedArray)]
let collections = [AnyCollection(array),
                   AnyCollection(set),
                   AnyCollection(array.reversed())]
let total = collections.reduce(0) { $0 + $1.reduce(0, +) } // 165
let collections: [any Collection] = [array, set, reversedArray]
let collections: [any Collection<Int>] = [array, set, reversedArray]
let total = collections.reduce(0) { $0 + $1.reduce(0, +) } // 165

any Collection versus AnyCollection

There’s a fundamental difference between type erasure types like AnyCollection and existential types like any Collection–protocol conformance.

let collections = [AnyCollection(array),
                   AnyCollection(set),
                   AnyCollection(array.reversed())]
let total = collections.flatMap { $0 }.reduce(0, +) // 165

Opaque Types

Using any SomeProtocol erases type information by putting the original type in a box. The box can hold any type that conforms to SomeProtocol and even change during runtime. That dynamicity comes with a cost both in complexity and runtime.

func  makeValue() -> some FixedWidthInteger {
  42
}
print("Two makeValues summed", makeValue() + makeValue())

func makeValueRandomly() -> some FixedWidthInteger {
  if Bool.random() {
    return Int(42)
  }
  else {
    return Int(24)
  }
}
func makeEquatableNumericInt() -> some Numeric & Equatable { 1 }
func makeEquatableNumericDouble() -> some Numeric & Equatable { 1.0 }

let value1 = makeEquatableNumericInt()
let value2 = makeEquatableNumericInt()

print(value1 == value2) // prints true
print(value1 + value2) // prints 2
print(value1 > value2) // error
// Compiler error, types don't match up
makeEquatableNumericInt() == makeEquatableNumericDouble()
var someCollection: some Collection = [1, 2, 3]
print(type(of: someCollection)) // Array<Int>
someCollection.append(4) // Compiler error
var intArray = [1, 2, 3]
var intSet = Set([1, 2, 3])
var arrayOfSome: [some Collection] = [intArray, intSet] // Compiler error
var arrayOfAny: [any Collection] = [intArray, intSet]
var someArray: some Collection = intArray
var someSet: some Collection = intSet
someArray = someSet // Compiler error
someSet = someArray // Compiler error

var anyElement: any Collection = intArray
anyElement = intSet
var intArray2 = [1, 2, 3]
var someArray2: some Collection = intArray2
someArray = someArray2 // Compiler Error

Using Opaque Types Instead of Angle Brackets

You can use opaque types for generic programming. Consider the following:

func product<C: Collection>(_ input: C) -> Double where C.Element == Double {
  input.reduce(1, *)
}
product([1,2,3,4]) // 24
func product(_ input: some Collection<Double>) -> Double {
  input.reduce(1, *)
}

Challenges

Congratulations on making it this far! But before you come to the end of this chapter, here are some challenges to test your knowledge of advanced protocols and generics. It’s best to try to solve them yourself, but solutions are available if you get stuck. You can find the solutions with the download or the printed book’s source code link listed in the introduction.

Challenge 1: Robot vehicle builder

Using protocols, define a robot that makes vehicle toys.

Challenge 2: Toy Train Builder

Declare a function that constructs robots that make toy trains.

Challenge 3: Monster Truck Toy

Create a monster truck toy with 120 pieces and a robot to make this toy. The robot is less sophisticated and can only assemble 200 pieces per minute. Next, change the makeToyBuilder() function to return this new robot.

Challenge 4: Shop Robot

Define a shop that uses a robot to make the toy that this shop will sell.

Key Points

  • You can use protocols as existential types, opaque types and generic constraints.
  • Existentials use the keyword any and are boxed types that can be used polymorphically, like a base class.
  • Generic constraints express the capabilities required by a type.
  • Associated types make protocols generic. They provide greater generality and can be type-checked.
  • Type erasure is a way to hide concrete details while preserving important type information.
  • You can mark associated types as primary associated types, which lets you specify them explicitly as constraints in angle brackets.
  • some keyword creates an opaque type that lets you access only protocol information from a concrete type.
  • The more generic you write your code, the more places you can reuse it.
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now