Opaque types in Swift are a language feature introduced in Swift 5.1. They provide a way to define and work with types that hide their implementation details while still offering a clear interface. The primary problem that opaque types solve is the need for abstraction and encapsulation. In this article, we will explore opaque types in Swift, understand the problems they solve, and discover how they can benefit our codebases.
The Need for Abstraction and Encapsulation
When designing software systems, abstraction and encapsulation play crucial roles. Abstraction allows us to define interfaces and protocols that capture the essential capabilities of a type without exposing its implementation details. Encapsulation, on the other hand, ensures that the internal workings of a type remain hidden and can only be accessed through a well-defined interface.
Traditionally, achieving proper abstraction and encapsulation in Swift required careful design decisions, such as using protocols and structuring your codebase accordingly. However, these approaches didn’t provide a clean way to hide the specific type behind an abstraction while still allowing dynamic dispatch.
The Problem Opaque Types Help us Solve
To address the challenges of abstraction and encapsulation, Swift 5.1 introduced the concept of opaque
types. Opaque types empower us with the ability to define types that hide their implementation details, providing a clear interface to work with. This provides several benefits that promote code modularity, flexibility, and maintainability. They truly unleashed our abilities to write well encapsulated, abstract and dynamically dispatched systems in swift. So how do we work with opaque
types?
Working with Opaque Types
We can define an opaque type using the some
keyword in a type declaration. Let’s take a look at a simple example to illustrate how opaque types work:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
protocol Vehicle {
func start()
func stop()
}
struct Car: Vehicle {
func start() {
print("Car started")
}
func stop() {
print("Car stopped")
}
}
func getVehicle() -> some Vehicle {
return Car()
}
let vehicle = getVehicle()
vehicle.start()
vehicle.stop()
What’s Going On
In the above example, we have a Vehicle protocol that defines the methods start() and stop(). The Car struct conforms to the Vehicle protocol and provides its implementation. The getVehicle() function returns an opaque type some Vehicle, hiding the specific type of the returned object. However, because it adheres to the Vehicle protocol, we can invoke the start() and stop() methods on it without knowledge of its concrete type.
Another One ☝️
To further drive the point home let’s take a look at another example to illustrate the flexibility and powers that opaque types truly give us:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
protocol DataProvider {
associatedtype DataType
func fetchData() -> DataType
}
struct StringDataProvider: DataProvider {
func fetchData() -> String {
return "Data fetched from StringDataProvider"
}
}
struct IntDataProvider: DataProvider {
func fetchData() -> Int {
return 42
}
}
func getDataProvider() -> some DataProvider {
if someCondition {
return StringDataProvider()
} else {
return IntDataProvider()
}
}
let dataProvider = getDataProvider()
let data = dataProvider.fetchData()
print(data)
What’s Going On
In this example, we have a DataProvider
protocol that defines a generic associated type DataType
and a method fetchData()
that returns the associated type. We then have two structs, StringDataProvider
and IntDataProvider
, that conform to the DataProvider
protocol and provide their respective implementations of fetchData()
.
The getDataProvider()
function returns an opaque type some DataProvider
, which means it can return either a StringDataProvider
or an IntDataProvider
depending on some condition. The concrete type is hidden behind the opaque type, but we can still call the fetchData()
method on it.
When we invoke getDataProvider()
and assign the result to dataProvider
, Swift automatically infers the appropriate opaque type based on the return type of the function thanks to our buddy called dynamic dispatch
. We can then call fetchData()
on dataProvider without knowing its concrete type.
The power of this example lies in the flexibility it offers. The getDataProvider()
function can dynamically choose the appropriate data provider implementation at runtime based on a condition. This allows us to abstract away the specific data fetching logic and work with the fetched data without being concerned about the concrete implementation. We can seamlessly switch between different data providers without modifying the code that consumes the data.
Conclusion
By leveraging opaque types, we can create modular and flexible code that separates the implementation details from the interface, promotes code reuse, and enables dynamic dispatch based on runtime conditions or requirements.
Hope you found this article useful, thanks for reading.