Working implementation of sealed protocols in Swift 5.3

The topic of "sealed protocols" has come up many times over the years, and I've recently had a colleague ask about it. So I thought to make a post and explain why it's not really a problem in current Swift.

Below I will provide the following:

  1. A brief definition and history of "sealed protocols"
  2. An analysis of the problems that motivated the 2019 "sealed protocols" pitch
  3. An example of how to solve the same problem a different way simply using an additional protocol (I called it "Sealed"), without having to modify the Swift language.

The basic idea of sealed protocols:

  • mark a protocol as "sealed"
  • limit the scope in which to allow conformances, requirements, and/or extensions to that sealed protocol

On the 2019 pitch "Sealed protocols":

  • the original pitch from @Karl proposed to add a "sealed" attribute that blocks any code outside a module from adding a new conformance
  • @Karl mentioned this was "part of a larger goal to split visibility from conformance" (post link)
  • @Joe_Groff and @Chris_Lattner3 had performance concerns and did not not see much benefit or serious problems from not doing this
  • @anandabits also wanted exhaustive switching over types conforming to a sealed protocol, towards feature-parity with Kotlin's "sealed classes"

Note: I will post another thread later to discuss the idea of being able to switch over some types exhaustively, to get parity with Kotlin's "sealed class". It's an interesting and valid topic, but I believe it can and should be handled separately, and I also believe that the solution I provide below effectively mitigates the risks that some people had associated with the way Swift currently works.

The Perceived Drawbacks to Public Protocols

First lets try to understand what actual problems motivated the pitch for "sealed protocols". Here is what people said about the problems:

  1. The standard library's warning: "Do not declare new conformances to StringProtocol."
    Due to: "we did not have time to finalize the design of the protocol for Swift 4, so it may be a source compatibility liability if code outside the standard library tries to conform to it and we change the design in Swift 5." - @Joe_Groff (post link, 2017)

  2. "It's really part of a larger goal to split visibility from conformance, which would allow all kinds of great enhancements like the ability to add requirements while retaining source stability. If you can't add a reasonable default, so just create something which traps, you lose conformance checking in your own types." - @Karl (post link)

  3. "my coworkers (some of which might be working on different subsystems) accidentally misusing a part of the code that I wrote as a separate module. - @yxckjhasdkjh (post link)

  4. To prevent "the need for this awful hack that prevents conformances from being added outside a module" because "Preventing conformances outside the module [...] cannot be expressed in the language [Swift]." - @anandabits (post link 1, post link 2)

So there are really two problems:

  • the library stability problem: the problem where some else's code incurs a degree of implicit dependency on your module's non-public implementation details in how it handles conforming types
  • the unwanted guest problem: you base control flow on the type of a generic variable in a module that others will use, but you the only constraint you place on that generic variable is a public protocol

Note: the first problem seems to possibly be (at least in part) a consequence of the second.

Library Stability Problem. Not a problem?

The first problem, as it turns out, is not much of a problem.

A library author has many tools at their disposal to help provide a smooth experience to consumers.

The most commonly used solution is the underscore key. It's an unspoken understanding within the Apple dev community, than when "_" appears at the beginning of a symbol, that means, "this is an undocumented part of the API, and the library author does not support its use."

For example SwiftUI has _FormatSpecifiable, which has led to some minor confusion but hasn't harmed anyone.

The Uninvited Guest Problem

During the 2019 Sealed Protocols pitch thread, many times it was claimed that there is no way in current Swift to prevent another module from calling one of your functions with their own new conformance of some public protocol from your code. I call this the "uninvted guest" problem: you want to restrict access to people on a guest list.

So lets try to understand this problem specifically.

So, taking a look at the gist that @anandabits linked to:

  • Foo is a public protocol
  • func takesFoo exists alongside Foo in the same file
  • Foo1 and Foo2 also exist alongside these, in the same file
  • func takesFoo switches over the type of the generic variable it accepts, and if it doesn't recognize this type, it calls fatalError()
  • A consumer of the module could adopt Foo in one of their types, resulting in some object other than Foo1 or Foo2 being received by takesFoo—which would lead to a crash
func takesFoo<F: Foo>(_ foo: F) {
    switch F.self {
    case is Foo1.Type: print("foo1")
    case is Foo2.Type: print("foo2")
    default:           fatalError("unfortunately the exhaustiveness checker is not as smart as we would like it to be.")
    }
}

The reason for having "fatalError" here, is so that, during testing of the module, we'll notice a crash if we accidentally pass in a Foo3 but we forgot to add it to the switch. Ideally this kind of Switch could be exhaustive, but that's a separate issue.

However the problem arises when our module gets used by a consumer, they add a new conformance to Foo, and pass it into our takesFoo function, triggering that fatalError() again.

This led people to think, "The only solution to this, is if we add sealed protocols so we can block anyone outside our module from conforming to Foo."

Maybe back when those messages were posted (in early 2019/late 2018, when Swift 4.2 was current), perhaps there wasn't a way.

Today though, there's definitely a solution in Swift 5.3 that nobody seems to have considered yet, so I thought I'd share it.

"Sealed" Protocol in Swift 5.3 Using Type-Erasure and Composition

The problem is that we don't want consumers of a library calling into our own methods with their own custom conformances to our public protocols.

The solution is to make that function internal; add a second, "Sealed" protocol that's internal to our module, that encapsulates access to this internal function; and also check for this when things are passed into the function.

Of course, we can't have a public function with an internal protocol.

But we can have a closure on public instances of our module's conformances, that gets configured at init time with the ability to call into our internal function that we don't want outsiders to call.

In their own custom conformances, consumers will have to re-implement this closure, and when they do, they won't be able to configure it to call our module's internal methods.

Now we can trap for unhandled cases in our own code without worrying that a consumer might hit that.

Lets take a look at how this works.

Module A

(Scroll inside the code view, it's a bit long):

import Foundation

fileprivate protocol Sealed: Foo {
    var box: AnyFooBox<Self>? { get }
}

public protocol Foo {
    var check: String { get }
    init()
}

extension Foo where Self: Sealed {
    var _check: String { box?.unboxify() ?? "" }
}

open class AnyFooBox<F: Foo> {
    var unboxify: () -> String
    public init(foo: F) { unboxify = {""}}
}

fileprivate final class FooBox<F>: AnyFooBox<F> where F: Sealed {
    typealias Box = () -> String
    override init(foo: F) {
        super.init(foo: foo)
        self.unboxify = { Take().foo(foo) }
    }
}

public struct Foo1: Foo & Sealed  {
    var box: AnyFooBox<Self>?
    public var check: String { _check }
    public init() {
        self.box = FooBox(foo: self) as AnyFooBox<Self>
    }
}

private struct Foo2: Foo & Sealed  {
    var box: AnyFooBox<Self>?
    public var check: String { _check }
    public init() {
        self.box = FooBox(foo: self) as AnyFooBox<Self>
    }
}

public func makeFoo2() -> Foo {
    Foo2()
}

public final class Foo3: Foo & Sealed {
    var box: AnyFooBox<Foo3>?
    public var check: String { _check }
    public init() {
        self.box = FooBox(foo: self) as AnyFooBox<Foo3>
    }
}

class Take {
    var a: String!
    fileprivate func foo<F: Foo & Sealed>(_ foo: F) -> String {
        if (foo is Foo1) { a = "Sweet! Foo1 baby" } else
        if (foo is Foo2) { a = "Pinch Meh, Foo2!" } else
        if (foo is Foo3) { a = "A class, badass!" }
        return a
    }
}

(There's probably an even cleaner way to do this, not using a second protocol... Let me know if you find a way.)

Module B

let foo = Foo1()
print(foo.check) // prints "Sweet! Foo1 baby"

let foo2 = makeFoo2()
print(foo2.check) // prints "Pinch Meh, Foo2!"

let foo3 = Foo3()
print(foo3.check) // prints "A class, badass!"

struct Foo4: Foo {
    var check: String {
        "Custom conformance!"
    }
    
    public init() {}
}

let foo4 = Foo4()
print(foo4.check) // prints "Custom conformance!"

As you can see, this technique lets us completely block off the custom functionality of our module that we really didn't want to expose to consumers in the first place.

It's the same effect as "sealed protocol" but works within existing Swift.

I'm sure you can find ways to adapt this to your own code, such as by adding additional arguments to the "check" closure as needed.

Hope this helps someone :D Happy Swifting

6 Likes

I'd also note that, if you are working with simple protocols that:

  • don't use associated types or self requirements
  • don't require any functions with generic parameters or opaque types etc.
  • won't be conformed to by a struct or enum (classes only)

then you can use an @objc protocol, and use the Obj. C runtime to dynamically get a list of all the classes that conform to your protocol at runtime:

import Foundation 

class Runtime {
	
	public static func allClasses() -> [AnyClass] {
		let numberOfClasses = Int(objc_getClassList(nil, 0))
		if numberOfClasses > 0 {
			let classesPtr = UnsafeMutablePointer<AnyClass>.allocate(capacity: numberOfClasses)
			let autoreleasingClasses = AutoreleasingUnsafeMutablePointer<AnyClass>(classesPtr)
			let count = objc_getClassList(autoreleasingClasses, Int32(numberOfClasses))
			assert(numberOfClasses == count)
			defer { classesPtr.deallocate() }
			let classes = (0 ..< numberOfClasses).map { classesPtr[$0] }
			return classes
		}
		return []
	}

	public static func subclasses(of `class`: AnyClass) -> [AnyClass] {
		return self.allClasses().filter {
			var ancestor: AnyClass? = $0
			while let type = ancestor {
				if ObjectIdentifier(type) == ObjectIdentifier(`class`) { return true }
				ancestor = class_getSuperclass(type)
			}
			return false
		}
	}

	public static func classes(conformToProtocol `protocol`: Protocol) -> [AnyClass] {
		let classes = self.allClasses().filter { aClass in
			var subject: AnyClass? = aClass
			while let aClass = subject {
				if class_conformsToProtocol(aClass, `protocol`) { print(String(describing: aClass)); return true }
				subject = class_getSuperclass(aClass)
			}
			return false
		}
		return classes
	}

	public static func classes<T>(conformTo: T.Type) -> [AnyClass] {
		return self.allClasses().filter { $0 is T }
	}
}

I'm not suggesting this is a "good" alternative, but from a purely practical standpoint, it may be worth a try if all else is failing.