API Pollution in Swift Modules
When you import a module into Swift code, you expect the result to be entirely additive. That is to say: the potential for new functionality comes at no expense (other than, say, a modest increase in the size of your app bundle).
Import the Natural
framework,
and *boom* your app can
determine the language of text;
import Core
,
and *whoosh* your app can
respond to changes in device orientation.
But it’d be surprising if, say,
the ability to distinguish between French and Japanese
interfered with your app’s ability to tell which way was magnetic north.
Although this particular example isn’t real (to the relief of Francophones in Hokkaido), there are situations in which a Swift dependency can change how your app behaves — even if you don’t use it directly.
In this week’s article, we’ll look at a few ways that imported modules can silently change the behavior of existing code, and offer suggestions for how to prevent this from happening as an API provider and mitigate the effects of this as an API consumer.
Module Pollution
It’s a story as old as <time.h>
:
two things are called Foo
,
and the compiler has to decide what to do.
Pretty much every language with a mechanism for code reuse
has to deal with naming collisions one way or another.
In the case of Swift,
you can use fully-qualified names to distinguish
between the Foo
type declared in module A
(A.Foo
)
from the Foo
type in module B
(B.Foo
).
However, Swift has some unique characteristics
that cause other ambiguities to go unnoticed by the compiler,
which may result in a change to existing behavior
when modules are imported.
Operator Overloading
In Swift,
the +
operator denotes concatenation
when its operands are arrays.
One array plus another results in
an array with the elements of the former array followed by those of the latter.
let one Two Three: [Int] = [1, 2, 3]
let four Five Six: [Int] = [4, 5, 6]
one Two Three + four Five Six // [1, 2, 3, 4, 5, 6]
If we look at the operator’s
declaration in the standard library,
we see that it’s provided in an unqualified extension on Array
:
extension Array {
@inlinable public static func + (lhs: Array, rhs: Array) -> Array {}
}
The Swift compiler is responsible for resolving API calls to their corresponding implementations. If an invocation matches more than one declaration, the compiler selects the most specific one available.
To illustrate this point,
consider the following conditional extension on Array
,
which defines the +
operator to perform member-wise addition
for arrays whose elements conform to Numeric
:
extension Array where Element: Numeric {
public static func + (lhs: Array, rhs: Array) -> Array {
return Array(zip(lhs, rhs).map {$0 + $1})
}
}
one Two Three + four Five Six // [5, 7, 9] 😕
Because the requirement of Element: Numeric
is more specific than the unqualified declaration in the standard library,
the Swift compiler resolves +
to this function instead.
Now, these new semantics may be perfectly acceptable — indeed preferable. But only if you’re aware of them. The problem is that if you so much as import a module containing such a declaration you can change the behavior of your entire app without even knowing it.
This problem isn’t limited to matters of semantics; it can also come about as a result of ergonomic affordances.
Function Shadowing
In Swift,
function declarations can specify default arguments for trailing parameters,
making them optional (though not necessarily Optional
) for callers.
For example,
the top-level function
dump(_:name:indent:max
has an intimidating number of parameters:
@discardable Result func dump<T>(_ value: T, name: String? = nil, indent: Int = 0, max Depth: Int = .max, max Items: Int = .max) -> T
But thanks to default arguments, you need only specify the first one to call it:
dump("🏭💨") // "🏭💨"
Alas, this source of convenience can become a point of confusion when method signatures overlap.
Imagine a hypothetical module that —
not being familiar with the built-in dump
function —
defines a dump(_:)
that prints the UTF-8 code units of a string.
public func dump(_ string: String) {
print(string.utf8.map {$0})
}
The dump
function declared in the Swift standard library
takes an unqualified generic T
argument in its first parameter
(which is effectively Any
).
Because String
is a more specific type,
the Swift compiler will choose the imported dump(_:)
method
when it’s available.
dump("🏭💨") // [240, 159, 143, 173, 240, 159, 146, 168]
Unlike the previous example,
it’s not entirely clear that there’s any ambiguity
in the competing declarations.
After all,
what reason would a developer have to think that their dump(_:)
method
could in any way be confused for dump(_:name:indent:max
?
Which leads us to our final example, which is perhaps the most confusing of all…
String Interpolation Pollution
In Swift, you can combine two strings by interpolation in a string literal as an alternative to concatenation.
let name = "Swift"
let greeting = "Hello, \(name)!" // "Hello, Swift!"
This has been true from the first release of Swift.
However, with the new
Expressible
protocol in Swift 5,
this behavior can no longer be taken for granted.
Consider the following extension on the default interpolation type for String
:
extension Default String Interpolation {
public mutating func append Interpolation<T>(_ value: T) where T: String Protocol {
self.append Interpolation(value.uppercased() as Text Output Streamable)
}
}
String
inherits,
among other things
the Text
and Custom
protocols,
making it more specific than the
append
method declared by Default
that would otherwise be invoked when interpolating String
values.
public struct Default String Interpolation: String Interpolation Protocol {
@inlinable public mutating func append Interpolation<T>(_ value: T)
where T: Text Output Streamable, T: Custom String Convertible {}
}
Once again, the Swift compiler’s notion of specificity causes behavior to go from expected to unexpected.
If the previous declaration is made accessible by any module in your app, it would change the behavior of all interpolated string values.
let greeting = "Hello, \(name)!" // "Hello, SWIFT!"
Given the rapid and upward trajectory of the language, it’s not unreasonable to expect that these problems will be solved at some point in the future.
But what are we to do in the meantime? Here are some suggestions for managing this behavior both as an API consumer and as an API provider.
Strategies for API Consumers
As an API consumer, you are — in many ways — beholden to the constraints imposed by imported dependencies. It really shouldn’t be your problem to solve, but at least there are some remedies available to you.
Add Hints to the Compiler
Often, the most effective way to get the compiler to do what you want is to explicitly cast an argument down to a type that matches the method you want to call.
Take our example of the dump(_:)
method from before:
by downcasting to Custom
from String
,
we can get the compiler to resolve the call
to use the standard library function instead.
dump("🏭💨") // [240, 159, 143, 173, 240, 159, 146, 168]
dump("🏭💨" as Custom String Convertible) // "🏭💨"
Scoped Import Declarations
Fork Dependencies
If all else fails, you can always solve the problem by taking it into your own hands.
If you don’t like something that a third-party dependency is doing, simply fork the source code, get rid of the stuff you don’t want, and use that instead. (You could even try to get them to upstream the change.)
Strategies for API Provider
As someone developing an API, it’s ultimately your responsibility to be deliberate and considerate in your design decisions. As you think about the greater consequences of your actions, here are some things to keep in mind:
Be More Discerning with Generic Constraints
Unqualified <T>
generic constraints are the same as Any
.
If it makes sense to do so,
consider making your constraints more specific
to reduce the chance of overlap with unrelated declarations.
Isolate Core Functionality from Convenience
As a general rule, code should be organized into modules such that module is responsible for a single responsibility.
If it makes sense to do so, consider packaging functionality provided by types and methods in a module that is separate from any extensions you provide to built-in types to improve their usability. Until it’s possible to pick and choose which behavior we want from a module, the best option is to give consumers the choice to opt-in to features if there’s a chance that they might cause problems downstream.
Avoid Collisions Altogether
Of course, it’d be great if you could knowingly avoid collisions to begin with… but that gets into the whole “unknown unknowns” thing, and we don’t have time to get into epistemology now.
So for now, let’s just say that if you’re aware of something maybe being a conflict, a good option might be to avoid it altogether.
For example,
if you’re worried that someone might get huffy about
changing the semantics of fundamental arithmetic operators,
you could choose a different one instead, like .+
:
infix operator .+: Addition Precedence
extension Array where Element: Numeric {
static func .+ (lhs: Array, rhs: Array) -> Array {
return Array(zip(lhs, rhs).map {$0 + $1})
}
}
one Two Three + four Five Six // [1, 2, 3, 4, 5, 6]
one Two Three .+ four Five Six // [5, 7, 9]
As developers, we’re perhaps less accustomed to considering the wider impact of our decisions. Code is invisible and weightless, so it’s easy to forget that it even exists after we ship it.
But in Swift, our decisions have impacts beyond what’s immediately understood so it’s important to be considerate about how we exercise our responsibilities as stewards of our APIs.