1

There are already a lot of questions on how to do type erasure in Swift, and I've seen type erasure often described as an important pattern for working with protocols with associated types and generic types.

However, it seems to me like needing type erasure is often symptomatic of design problems — you're inherently "throwing away" type information (i.e. to put a value in a container or to pass it to a function), which often ultimately needs to be recovered later on anyway through verbose and brittle downcasting. Perhaps what I don't understand is the use case for a "type" like AnyHashable—PATs/protocols with self can only be used as generic constraints because they aren't reified types, which makes me wonder what compelling reasons there are to want to reify them.

In short, when is it a good idea to use type erasure in Swift? I'm looking for some general guidelines on when to use this pattern, and possibly examples of some practical use cases where type erasure is preferable to its alternatives.

jweightman
  • 328
  • 1
  • 12
  • 1
    Maybe this article from John Sundell is useful for you https://www.swiftbysundell.com/articles/different-flavors-of-type-erasure-in-swift/ – Gustavo Conde Apr 30 '21 at 17:57
  • @GustavoConde personally I found that article confusing, but that's probably just because I don't understand generics at all... – aheze Apr 30 '21 at 17:59
  • [What is difference between Any , Hashable , AnyHashable in Swift 3?](https://stackoverflow.com/q/44513675/643383) might help. – Caleb Jun 24 '21 at 06:25

1 Answers1

3

I've tried to find a minimalistic example of type erasure. From my experience, it often gets more complex, and I try to avoid it as much as possible. But sometimes that's the way.

It is finally the same complexity as before, with old-school language. Excepted that old-school language was hurting you by crashing, while swift hurts you at build time.

It is meant to be a strongly typed language, so it does not fit well with generics.

Suppose you need to manage some shapes in a document. These shapes are Identifiables, meaning they have an id which type is determined by an associated type. Int in this case.

The code below won't build because it can't use the Shape protocol directly since the type of id is an associated type that is defined by object conforming to the Shape protocol

import Foundation

protocol Shape: AnyShape, Identifiable {
    var name: String { get }
}

struct Square: Shape {
    var id: Int = 0
    var name: String { "Square" }
}

func selectShape(_ shape: Shape) {
    print("\(shape.name) selected")
}

By adding a type erased shape, you can then pass it to functions. Thus, this will build:

import Foundation

protocol AnyShape {
    var name: String { get }
}

protocol Shape: AnyShape, Identifiable {
    
}

struct Square: Shape {
    var id: Int = 0
    var name: String { "Square" }
}

func selectShape(_ shape: AnyShape) {
    print("\(shape.name) selected")
}

Simple use case.

Suppose now our app connects to two shapes manufacturers servers to fetch their catalog and sync with our's.

We know shapes are shapes, all around the world, but the ACME Shape Factory index in database is an Int, while the Shapers Club use UUID..

That's at this point we need to 'recover' the type, as you say. It is exactly what's explained when looking in the AnyHashable source doc.

Cast can't be avoided, and it is finally a good thing for the security of the app and the solidity of the models.

The first part is the protocols, and it may be verbose and become complex as the number of situations grows, but it will be in the communication foundation framework of the app, and should not change very often.

import Foundation

// MARK: - Protocols

protocol AnyShape {
    var baseID: Any { get }
    var name: String { get }
}

// Common functions to all shapes

extension AnyShape {
    
    func sameObject(as shape: AnyShape) -> Bool {
        switch shape.baseID.self {
        case is Int:
            guard let l = baseID as? UUID , let r = shape.baseID as? UUID else { return false }
            return l == r
        case is UUID:
            guard let l = baseID as? UUID , let r = shape.baseID as? UUID else { return false }
            return l == r
        default:
            return false
        }
    }

    func sameShape(as shape: AnyShape) -> Bool {
        return name == shape.name
    }

    func selectShape(_ shape: AnyShape) {
        print("\(shape.name) selected")
    }
}

protocol Shape: AnyShape, Identifiable {
    
}

extension Shape {
    var baseID: Any { id }
}

The second part is the models - this will hopefully evolve as we work with more shape manufacturers.

The sensitive operation that can be done on shapes are not in this code. So no problem to create and tweak models and apis.

// MARK: - Models

struct ACME_ShapeFactory_Model {
    struct Square: Shape {
        var id: Int = 0
        var name: String { "Square" }

        var ACME_Special_Feature: Bool
    }
}

struct ShapersClub_Model {
    struct Square: Shape {
        var id: UUID = UUID()
        var name: String { "Square" }

        var promoCode: String
    }
}

Test

let shape1: AnyShape = ACME_ShapeFactory_Model.Square()
let shape2: AnyShape = ShapersClub_Model.Square()
let shape3: AnyShape = ShapersClub_Model.Square()

Compare two different shapes references from different manufacturers

shape1.sameObject(as: shape2) : false
-> Logic, it can't be the same item if it comes from different manufacturers

Compare two different shapes references from same manufacturers

shape2.sameObject(as: shape3) : false
-> This is a new shape, sync in catalog

Compare two identical shapes references from same manufacturers 

shape2.sameObject(as: shape2) : true
-> We already have this one in the catalog

Compare two shapes from different manufacturers

shape1.sameShape(as: shape2) : true
-> Dear customer, we have two kind of squares from two manufacturers

That's all. I hope this may be of any help. Any correction or remark is welcome.

Last word, I am quite proud of the name of my Shapes manufactures :)

aheze
  • 24,434
  • 8
  • 68
  • 125
Moose
  • 2,607
  • 24
  • 23
  • Haha, ACME Shape Factory and Shapers Club are fantastic! For this shape example, though, I would consider using a "sum type" pattern. consider: `struct ACMEShape { ... } struct ShapersClubShape { ... } enum Shape { case acme(ACMEShape) case shapersClub(ShapersClubShape) var baseID: Any { switch(self) { ... } } ... }` This has the benefit of allowing "exhaustive switches," eliminates the need for casting, and there's no need to distinguish between a `Shape` and an `AnyShape`. What advantages do you see to the type erasure approach? When would you prefer it? – jweightman Apr 30 '21 at 20:59
  • Yes, that's a valid approach too. First, I would consider `structs` instead of `enum`, because `structs` can work as enums in swift ( that's awesome! ), but has the advantage of being extensible outside of the source. It makes architecture more scalable. Then, doing a `switch(self)` is a kind of casting. Honestly, I could not answer to your question here, in comments. And maybe I don't have a proper answer. There is a lot of articles on the subject. I think the best is to experiment. I personally choose to always try strong typing, few generics, and no type erasing first. Then I see… 8) – Moose May 01 '21 at 09:29