Why use generics and opaque type when the protocol seems adequate?

Hi

I'm trying to come to grips with opaque type, reading Opaque Types — The Swift Programming Language (Swift 5.7)

Given
protocol Shape { func draw() -> String }

They give this example:

 struct JoinedShape<T: Shape, U: Shape>: Shape {
   var top: T
   var bottom: U
   func draw() -> String {
     return top.draw() + "\n" + bottom.draw()
   }
}

Why not just used protocol for JoinedShape and obviate the generics? Like this

struct JoinedShapeP: Shape {
  var top: Shape
  var bottom: Shape

  func draw() -> String {
    return top.draw() + "\n" + bottom.draw()
  }

}

Can anyone explain this? Of course this is related to SwiftUI and the View protocol, with the "some View" return type in var body.


Mark

By and large, its motivation is related to performance.

If you use opaque return type, it is as if you are using a concrete type such as struct and class. Using structs instead of protocols can reduce the number of heap allocations, the number of indirection (pointer dereferencing, if you will), and even allow the compiler to optimize the code even further.

One example in SwiftUI is also that since the entire view hierarchy is opaque type, the compiler knows the exact size for the returned body. So it only needs to allocate space the returned value once.

For more information about the performance implication of using struct vs class vs protocol, see Understanding Swift Performance - WWDC16 - Videos - Apple Developer

7 Likes

Adding to that, if your protocol had an associated type requirement (as is the case with SwiftUI's View), you wouldn't be able to use it as the type of a property or as the return type of a function, for example:

protocol Shape {
    associatedtype Canvas
    func draw(on canvas: Canvas)
}

struct JoinedShape: Shape {
    var top: Shape
    var bottom: Shape

    ...
}

For top and bottom, you'd get the error: "protocol 'Shape' can only be used as a generic constraint because it has Self or associated type requirements". This will be changed in future versions of Swift though (SE-0309).

4 Likes

Yes, I tried that in my Playground and just the addition of an associatedtype line, even though that type was not used anywhere, broke the code I copied my examples from.

I thought it was worth pushing this to get it to compile and run using an associated type.

Here I have a simple protocol with one associatedtype that is completely gratuitous. It is never referred to, and it is instantiated with a different type in every implementation, all of which are structs. Then I include func makeTrapezoid based on those types, which needs the return type to be "some Shape". If it returns just "Shape", Xcode gets grumpy and tells me
"Protocol 'Shape' can only be used as a generic constraint because it has Self or associated type requirements".

protocol Shape {
  associatedtype Sausage // Added associatedtype to demonstrate the need for "some"
  func draw() -> String
}

struct Triangle: Shape {
  typealias Sausage = Int

  var size: Int
  func draw() -> String {
    var result = [String]()
    for length in 1...size {
        result.append(String(repeating: "*", count: length))
    }
    return result.joined(separator: "\n")
  }
}

struct FlippedShape<T: Shape>: Shape {
  typealias Sausage = Double

  var shape: T
  func draw() -> String {
    let lines = shape.draw().split(separator: "\n")
    return lines.reversed().joined(separator: "\n")
  }
}


struct JoinedShape<T: Shape, U: Shape>: Shape {
  typealias Sausage = String

  var top: T
  var bottom: U
  func draw() -> String {
    return top.draw() + "\n" + bottom.draw()
  }
}


struct Square: Shape {
   typealias Sausage = Character

  var size: Int
  func draw() -> String {
    let line = String(repeating: "*", count: size)
    let result = Array<String>(repeating: line, count: size)
    return result.joined(separator: "\n")
  }
}

func makeTrapezoid(size: Int) -> some Shape {
  let top = Triangle(size: size)
  let middle = Square(size: size)
  let bottom = FlippedShape(shape: top)
  let result = JoinedShape(
    top: top,
    bottom: JoinedShape(top: middle, bottom: bottom)
  )
  return result
}

// Now use all that lot:
let trapezoid = makeTrapezoid(size: 3)
print("\nTrapezoid")
print(trapezoid.draw())

As a rough rule of thumb, it's not wrong always to prefer to use generics and opaque types over using protocol types (also known as existential types) where possible.

Existential types can be thought of as "boxes" where an instance can hold a value of any type that conforms to the corresponding protocol, but the box itself does not (and in the general case, cannot) conform to the protocol. There will be situations where you will have no choice but to use such a box (for example, if you want an array of elements of different types that conform to the same protocol). Unless you need that specific feature, you almost always want to use generics or opaque types, since after all you probably actually want to rely on the protocol's API guarantees.

SwiftUI relies on generics and opaque types extensively for the performance benefits that static typing can provide. But this is not the only benefit: the question of why to use generics instead of existential types parallels the question of why to use a statically typed language at all over an exclusively dynamically typed one.

4 Likes

I wish the language guide included similar down-to-earth motivation instead of hard-to-unpack verses like

Generally speaking, protocol types give you more flexibility about the underlying types of the values they store, and opaque types let you make stronger guarantees about those underlying types.

Once SE-0309 lifts the compiler imposed barrier, it will become even harder for the users to decide which approach is better.