A method or function that has an opaque return type can hide the type information of its return value by providing a supported protocol to describe its return type rather than providing an exact type as a function. Hiding type information is useful at the boundary between the module and the code calling the module, because the underlying type of the return value can remain private. Instead of returning a protocol type, opaque types retain the type identifiers — so that the compiler can access the type information, but not the module’s caller.

This article is translated from the official document of Swift, which answers the doubts generated when I encountered some View in SwiftUI, discusses the shortcomings of protocol in some aspects and the advantages of non-transparent type, and expounds the connection and difference between non-transparent type, paradigm and protocol.

What does an opaque type solve?

For example, suppose you are writing a module that draws AN ASCII art shape. The basic features of an ASCII art shape can be represented by a draw() function, which returns a string representation of the art shape. You can use this function as a requirement of the Shape protocol:

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

struct Triangle: Shape {
    var size: Int
    func draw(a) -> String {
        var result = [String] ()for length in 1.size {
            result.append(String(repeating: "*", count: length))
        }
        return result.joined(separator: "\n")}}let smallTriangle = Triangle(size: 3)
print(smallTriangle.draw())
/ / *
/ / * *
/ / * * *
Copy the code

You can use generics to do things like flip vertically, as shown in the code below. However, this approach has an important limitation: the rollover result exposes the exact generic type used to create it.

struct FlippedShape<T: Shape> :Shape {
    var shape: T
    func draw(a) -> String {
        let lines = shape.draw().split(separator: "\n")
        return lines.reversed().joined(separator: "\n")}}let flippedTriangle = FlippedShape(shape: smallTriangle)
print(flippedTriangle.draw())
/ / * * *
/ / * *
/ / *
Copy the code

Define JoinShape

structure, which can join two shapes vertically together, as shown in the code below. The result is a type like JoinShape

, Triangle> that joins a flipped Triangle with another Triangle.

struct JoinedShape<T: Shape.U: Shape> :Shape {
    var top: T
    var bottom: U
    func draw(a) -> String {
        return top.draw() + "\n" + bottom.draw()
    }
}
let joinedTriangles = JoinedShape(top: smallTriangle, bottom: flippedTriangle)
print(joinedTriangles.draw())
/ / *
/ / * *
/ / * * *
/ / * * *
/ / * *
/ / *
Copy the code

Exposing the details of creating shapes exposes types that are not part of the public interface of the ASCII art Shapes module because the function needs to specify the full return type. Code inside a module can build the same shape in a variety of ways, and other code outside the module that uses that shape should not have to explain implementation details about transformation lists. Wrapper types like JoinShape and FlippedShape are not important to the user of the module, they should not be visible. The module’s public interface includes operations such as adding and flipping shapes, which should return another Shape value.

Returns an opaque type

You can think of opaque types as the opposite of generics. Generic types let the code calling a function choose a type for the function’s arguments and return a value in a way that abstracts from the function’s implementation. For example, the type returned by a function in the following code depends on its caller.

func max<T> (_ x: T._ y: T) -> T where T: Comparable { . }
Copy the code

The code that calls Max (_:_:) selects values for x and y, the types of which determine the specific type of T, and in fact the code that is called can use any type that complies with the Comparable protocol. The code inside the function is written in a generic way so that it can handle any type provided by the caller, and the Max (_:_:) implementation uses only the capabilities common to the Comparable type.

For a function with an opaque return type, the opposite is true. An opaque type enables a function implementation to choose a type for the value it returns in a way that is abstracted from the code calling the function. For example, the function in the following example returns a trapezoid but does not expose the underlying type of the shape.

struct Square: Shape {
    var size: Int
    func draw(a) -> String {
        let line = String(repeating: "*", count: size)
        let result = Array<String>(repeating: line, count: size)
        return result.joined(separator: "\n")}}func makeTrapezoid(a) -> some Shape {
    let top = Triangle(size: 2)
    let middle = Square(size: 2)
    let bottom = FlippedShape(shape: top)
    let trapezoid = JoinedShape(
        top: top,
        bottom: JoinedShape(top: middle, bottom: bottom)
    )
    return trapezoid
}
let trapezoid = makeTrapezoid()
print(trapezoid.draw())
/ / *
/ / * *
/ / * *
/ / * *
/ / * *
/ / *
Copy the code

In this case, the makeTrapezoid() method declares that its return type is some Shape, meaning that the method will return a value of a specific type that conforms to the Shape protocol without specifying which type. Write makeTrapezoid() so that it represents basic information about its public interface —- its return value is a Shape, without requiring the exact type of the Shape as part of its public interface. This method is implemented using two triangles and a square, but the function can also be rewritten to draw a trapezoid in various ways without changing its return type.

This example emphasizes that an opaque return type is like the opposite of a stereotype. The code inside makeTrapezoid() can return any type it needs, as long as it conforms to Shape, just like calling generic function code. The code that calls this function needs to be written in a generic way to implement a generic function, so that it can work with any Shape values returned from makeTrapezoid().

You can also combine an opaque return type with a stereotype. Each of the following functions returns a value of some type that conforms to the Shape protocol.

func flip<T: Shape> (_ shape: T) -> some Shape {
    return FlippedShape(shape: shape)
}
func join<T: Shape.U: Shape> (_ top: T._ bottom: U) -> some Shape {
    JoinedShape(top: top, bottom: bottom)
}

let opaqueJoinedTriangles = join(smallTriangle, flip(smallTriangle))
print(opaqueJoinedTriangles.draw())
/ / *
/ / * *
/ / * * *
/ / * * *
/ / * *
/ / *
Copy the code

In this example, the value of opaqueJoinedTriangles is different from the question earlier in this chapter, “what does opaque types solve?” The same applies to the generic example in the section, using joinTriangles. However, unlike in that example, flip(_:) and Join (_:_:) wrap the underlying types returned by the generic operation in an opaque return type, which prevents them from being visible. Both of these functions are generic because the type they rely on is generic, and the type parameter of the function passes the type information required by FlippedShape and JoinShape.

If a function with an opaque return type returns from more than one place, all possible return values must have the same type. For a generic function, the return type may use the generic arguments of the function, but it must still be a single concrete type. For example, here is an invalid version of the shape flip function that contains a special case of flipping a square:

func invalidFlip<T: Shape> (_ shape: T) -> some Shape {
    if shape is Square {
        return shape // Error: return types don't match
    }
    return FlippedShape(shape: shape) // Error: return types don't match
}
Copy the code

If you call this function with a Square, it returns a Square, otherwise, it returns a FlippedShape. This violates the requirement to return only one type of value and makes invalidFlip(_:) invalid code. One way to fix invalidFlip(_:) is to move the special case of the square into the FlippedShape implementation so that the function always returns a FlippedShape value:

struct FlippedShape<T: Shape> :Shape {
    var shape: T
    func draw(a) -> String {
        if shape is Square {
            return shape.draw()
        }
        let lines = shape.draw().split(separator: "\n")
        return lines.reversed().joined(separator: "\n")}}Copy the code

The requirement to always return a single type does not prevent you from using generics in opaque return types. Here is an example of a function that incorporates its type argument into the underlying type of the return value.

func `repeat`<T: Shape> (shape: T.count: Int) -> some Collection {
    return Array<T>(repeating: shape, count: count)
}
Copy the code

In this case, the underlying type of the returned value varies from T to T: Whatever shape is passed to it, repeat(Shape: Count 🙂 creates and returns an array of that shape. However, the return value always has the same underlying type [T], so it follows the requirement that functions with opaque return types must return only values of a single type.

The difference between opaque types and protocols

Returning an opaque type looks very similar to a return type that uses a protocol type as a function, but the two return types differ in whether they retain the type identifier. An opaque type refers to a specific type, although the function caller cannot see which type; Protocol type Indicates the type that matches the protocol. In simple terms, protocol types give you more flexibility about the underlying types of the values they store, whereas opaque types give you stronger guarantees about what those underlying types need to be.

For example, here’s a flip(_:) method that uses a protocol type as its return type instead of an opaque type:

func protoFlip<T: Shape> (_ shape: T) -> Shape {
    return FlippedShape(shape: shape)
}
Copy the code

This version of the protoFlip(_:) function has the same body as flip(_:), and it always returns a value of the same type. Unlike flip(_:), the value returned by a protoFlip(_:) doesn’t always need to be of the same type — it just needs to conform to the Shape protocol. In other words, protoFlip(_:) has much looser API requirements than Flip(_:) does, and retains the flexibility to return multiple types of values:

func protoFlip<T: Shape> (_ shape: T) -> Shape {
    if shape is Square {
        return shape
    }

    return FlippedShape(shape: shape)
}
Copy the code

The modified version of the code returns either an instance of Square or an instance of FlippedShape, depending on the shape passed in. The two flipped shapes returned by this function may have completely different types, and when multiple instances of the same shape are flipped, other valid versions of the function may return values of different types. The protoFlip(_:) return type information is less specific, which means that many operations that rely on type information are not available on the return value. For example, it is not possible to write a == operator to compare the results returned by this function.

let protoFlippedTriangle = protoFlip(smallTriangle)
let sameThing = protoFlip(smallTriangle)
protoFlippedTriangle = = sameThing  // Error
Copy the code

The last line error in this example occurs for several reasons. The most immediate problem is that Shape does not include the == operator as part of the protocol requirements. If you try to add it, the next problem is that the == operator needs to know the types of its left and right hand arguments. To match any specific type of the adopted protocol, adding a Self requirement to the protocol also does not allow type erasing to occur when you use the protocol as a type.

Using the protocol type as the return type of a function gives you the flexibility to return any type that matches the protocol. The price of this flexibility, however, is that some operations are impossible on the return value. This example shows how the == operator is not available — it depends on specific type information that is not stored by using protocol types.

Another problem with this approach is that shape transformations cannot be nested. The flip triangle results in a Shape value, and the protoFlip(_:) function takes an argument of a type that conforms to the Shape protocol. However, a value of a protocol type does not conform to that protocol, and the value returned by protoFlip(_:) does not conform to Shape. This means that code that applies multiple transformations like protoFlip(protoFlip(smallTriange)) is invalid because the flipped shape is not a valid argument to protoFlip(_:).

In contrast, opaque types retain the identity of the underlying type. Swift can infer association types, which allows you to use opaque types as return values where protocol types do not apply. For example, here’s a Container protocol from Generics:

protocol Container {
    associatedtype Item
    var count: Int { get }
    subscript(i: Int) -> Item { get}}extension Array: Container {}Copy the code

You cannot use Container as the return type of a function because the protocol has an association type, nor can you use it as a constraint in a generic return type because there is not enough information outside the function body to infer what the generic needs to be.

// Error: Protocol with associated types can't be used as a return type.
func makeProtocolContainer<T> (item: T) -> Container {
    return [item]
}

// Error: Not enough information to infer C.
func makeProtocolContainer<T.C: Container> (item: T) -> C {
    return [item]
}
Copy the code

Using the opaque type some Container as the return type expresses the required API requirement — the function returns a Container, but refuses to specify the type of Container:

func makeOpaqueContainer<T> (item: T) -> some Container {
    return [item]
}
let opaqueContainer = makeOpaqueContainer(item: 12)
let twelve = opaqueContainer[0]
print(type(of: twelve))
// Prints "Int"
Copy the code

The type of twelve is inferred to be Int, indicating that type inference works for opaque types. In the implementation of makeOpaqueContainer(item:), the underlying type of an opaque container is [T]. In this case, T is Int, so the return value is an array of integers, and the Item association type is inferred to be Int. The subscript on the Container returns Item, which means that the type of twelve is also inferred to be Int.