Opaque Return Types and Type Erasure
Learn how to use opaque return types and type erasure to improve your understanding of intuitive and effective APIs, both as a developer and a consumer. By Tom Elliott.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Opaque Return Types and Type Erasure
35 mins
- Getting Started
- Sharing Secrets
- Interesting Images
- Protocols Primer
- Fun With Filters
- Getting Generics
- Associated Types
- Paltry Protocols
- Generics to the Rescue?
- Tape and String — Apply a Temporary Fix
- Terrible Types
- Taming Types
- Hiding Filters
- Enter Type Erasure
- Finishing Filtergram
- Where to Go From Here?
Enter Type Erasure
Earlier, you changed the Filtergram app so the list of filters was an array of type [IdentityFilter]
. This won’t let you provide different filters for the user to choose from. It’s time to fix that! :]
The problem is that the compiler requires you to specify the exact type of MIFilter
in the filters array because MIFilter
has a self-type requirement. But you want to store filters with different types in an array. To work around this, you’ll use a technique known as type erasure.
You likely have used type erasure before without realizing it. Apple uses it frequently in the Swift standard library. Examples include AnyView
, which is a type erased View
in SwiftUI, and AnyCancellable
, which is a type erased Cancellable
in Combine.
Unlike opaque return types, which are a feature of Swift, type erasure is a catch-all term for several techniques you can apply in any strongly typed language. Here, you’ll use a technique known as Boxing to type erase MIFilter
.
The general idea is to create a concrete wrapper type (a box) that wraps either an instance of the wrapped type or any properties and methods of that type. Anytime a method is called on the wrapper type, it proxies the call to the wrapped type. Time to give it a go!
Create a new Swift file in the MagicImage Xcode project called AnyFilter.swift. Add the following code:
// 1
public struct AnyFilter: MIFilter {
// 2
public static func == (lhs: AnyFilter, rhs: AnyFilter) -> Bool {
lhs.id == rhs.id
}
// 3
public let name: String
public var id: String { "AnyFilter(\(self.name))" }
// 4
private let wrappedApply: (MIImage) -> MIImage
// 5
public init<T: MIFilter>(_ filter: T) {
name = filter.name
wrappedApply = filter.apply(to:)
}
// 6
public func apply(to miImage: MIImage) -> MIImage {
return wrappedApply(miImage)
}
}
In this code, you:
- Define a new structure called
AnyFilter
, which conforms toMIFilter
. - Implement
==
, required for conformance toEquatable
. - Define
name
andid
properties, as required by theMIFilter
andIdentifiable
protocols, respectively. - Define a property called
wrappedApply
, which is typed as a function receiving anMIImage
and returning anMIImage
. This is the same definition as theapply(to:)
method defined in theMIFilter
protocol. - Create the default initializer for
AnyFilter
. You wrap the filter provided to the initializer by storing references to its name andapply(to:)
method in the properties ofAnyFilter
. - Finally, when the
apply(to:)
method ofAnyFilter
is called, you proxy the call to theapply(to:)
method of the wrapped filter.
Now, anywhere in your code you want to erase the type of an MIFilter
, you can simply wrap it in an AnyFilter
, like so:
Before:
// Type is Posterize
let posterize = Posterize()
After:
// Type is AnyFilter
let posterize = AnyFilter(Posterize())
You might find it annoying to have to keep wrapping filters with AnyFilter()
. In that case, a simple trick is to define a method asAnyFilter()
in an extension of MIFilter
. Add the following to AnyFilter.swift at the end of the file:
public extension MIFilter {
func asAnyFilter() -> AnyFilter {
return AnyFilter(self)
}
}
When using AnyFilter
in her app, Abbie prevents Liam and Corinne from seeing the underlying type of the wrapped filter. In the game of “Who knows the secret?”, type erasure is a bit like “reverse protocols”:
- 🤓 Liam the library author ❌
- 🤖 Corinne the Compiler ❌
- 🦸♀️Abbie the App Author ✅
AnyView
structure. However, you should limit your use of AnyView
as much as possible because SwiftUI’s view hierarchy diffing algorithm is significantly less efficient when dealing with AnyView
. Using AnyView
too often will create performance problems for your app.
Finishing Filtergram
It’s time to put AnyFilter
to good use.
⚠️ Like when you updated all the types to [IdentityFilter]
earlier, you’ll need to make several changes before the demo app will compile. Don’t panic!
Open FiltergramView.swift. Update the definition of the selected filter state:
@State private var selectedFilter = IdentityFilter().asAnyFilter()
Along with the type of the filters array:
let filters: [AnyFilter]
And replace the contents of the initializer with the following:
let identity = IdentityFilter()
let sepia = Sepia()
let olde = Olde()
let posterize = Posterize()
let crystallize = Crystallize()
let flipHorizontally = HorizontalFlip()
filters = [
identity.asAnyFilter(),
sepia.asAnyFilter(),
olde.asAnyFilter(),
AnyFilter(posterize),
AnyFilter(crystallize),
AnyFilter(flipHorizontally)
]
Here, you define some filters and add them to the filters array. Note how it doesn’t matter if you use .asAnyFilter()
or the AnyFilter
initializer. Feel free to use whichever you prefer.
Next, open FilterBarView.swift and update the type for the selectedFilter
binding:
@Binding var selectedFilter: AnyFilter
Next, update the type for the allFilters
array.:
let allFilters: [AnyFilter]
And the same for the selectedFilter
and filters
values in the preview:
let selectedFilter = IdentityFilter().asAnyFilter()
let filters: [AnyFilter] = [
IdentityFilter().asAnyFilter()
]
Next, open FilterButton.swift and update the type of the SelectedFilter
binding:
@Binding var selectedFilter: AnyFilter
As well as the Filter
property:
let filter: AnyFilter
In the body property, update the definition of isSelectedFilter
:
let isSelectedFilter = selectedFilter == AnyFilter(filter)
Here, you use the Identifiable
protocol to update the look of the button when the filter it represents is selected.
Also, update FilterButton
in the preview to type erase the selected filter and filter to AnyFilter
:
FilterButton(
selectedFilter: .constant(IdentityFilter().asAnyFilter()),
filter: IdentityFilter().asAnyFilter())
Build and run. The app now compiles without any errors.
The app now has a list of filter buttons along the bottom of the screen. You were able to store filters of different types in the same array by erasing the type of each filter.
Woo-hoo! Tap the buttons to change the filter applied to the butterfly image.
Where to Go From Here?
You can download the completed project files by clicking the Download Materials button at the top or bottom of this tutorial.
You’ve successfully used opaque return types in the Magic Image library and seen how you can apply type erasure in the Filtergram app to hide concrete types from the compiler.
Along with generics and protocols, opaque return types and type erasure allow you to hide the “secret” type information from different parties. As a reminder:
Both opaque return types and type erasure are advanced topics you might not use regularly. But they’re also both used extensively in common Swift frameworks like SwiftUI and Combine, so understanding what they are and why they’re necessary is valuable for every budding Swift developer.
You can learn more about opaque return types in the Swift documentation. Although you might not define functions that return opaque types often, you’ll use them every time you create a view with SwiftUI.
Apple doesn’t cover type erasure specifically anywhere in its official documents because it’s not a feature of the language per se. But it’s a technique that’s used heavily in the standard library. When using Combine, you’ll come across AnyCancellable
and AnyPublisher
frequently in the APIs. SwiftUI provides AnyView
to allow you to type erase views.
We hope you enjoyed this tutorial, and if you have any questions or comments, please join the forum discussion below!