Statically-Typechecked Duck Types in Swift
This post is written with the caveat that I am not a compiler engineer; there may well be Hard Problems which I can’t foresee in implementing this feature.
tl;dr, added on 19 January: we can use protocols to substitute one type for another in Swift. In some cases it would make sense to let the programmer say “these types are interchangeable here” and let the compiler work out what that protocol would need to look like (and enforce conformance), instead of forcing the programmer to write that protocol manually.
“Duck typing” in programming is the idea that one object might, in some use case, be replaced by another (a “duck”) with a similar shape. If the replacement object provides the API required by that use case, it’s functionally indistinguishable from the original object. Or, to rephrase, if an Object
walks and quacks like a Duck
it may as well be a Duck
.
Duck typing is typically used by programmers in languages like Ruby or even Objective-C, where types are checked at runtime if at all1. This idea is commonly used in mocking & stubbing libraries like OCMock, where we’d like to test an API using a test double instead of a “real” instance of, say, a database controller; and there are myriad other uses in languages like Ruby and Python.
The Problem
Swift doesn’t allow duck typing as it’s done in Objective-C. This is because Swift is designed to be a safe language; to this end it provides a strong, statically-checked type system. The Swift feature most analogous to duck typing is the widespread use of protocols , but as we’ll see protocols are a poor replacement for duck typing.
Consider this discussion of mocking classes you don’t own, for testing in Swift2: one of the first steps is to “create a protocol that Apple’s NSURLSession
can conform to.” Then you design your application’s classes to accept that URLSessionProtocol
, rather than an NSURLSession
instance, and your tests can provide an alternate implementation of the protocol in order to verify interactions.
This approach is problematic. Having to manually duplicate part of another type’s interface in your protocol is boring busy work. It creates room for error, and there’s a maintenance cost: it will break if the original (shadowed) type’s interface changes in the future.
For the remainder of this post, I’ll use the term “shadow protocol” to describe this sort of protocol: one that duplicates some or all of the interface of an existing type.
Protocols and Duck Typing
Explicitly creating a shadow protocol clearly defeats the idea behind duck typing — that when we want to substitute a similarly-shaped object for another, we can do so smoothly. But it seems clear that Swift’s protocols are key to allowing a Swift interface to accept interchangeable, similarly-shaped types.
Perhaps new language features can enable something like duck typing, built on protocols. If we could ease the burden of creating and using shadow protocols — perhaps even hide that burden entirely behind a compiler feature — we could allow duck typing for certain calls while preserving the safety that Swift’s statically-checked type system provides.
Let’s explore a hypothetical new language feature which would allow trivially creating and using shadow protocols, without boilerplate. Our end goal is to enable opting into something like duck typing for certain calls in Swift, maintaining the safety the type system provides: if Duck
doesn’t quack like the original Object
, the error should be detected at compile time.
Checked Duck Typing Using a Shadow Protocol
Let’s consider an API which, as declared, accepts Object
. Call this the “target API”:
// the target API:
func doSomething(with: Object) { … }
// a call site:
let x = Object()
doSomething(x)
Let’s focus on the call site, where a programmer would like to use a Duck
instead of an Object
, perhaps as a test double:
let x = Duck()
doSomething(x) // compiler error, since x is not an Object
We could introduce a new language feature, which I’ll call @like
. This feature would opt into duck-typing-like behavior where it’s used, and it would allow calling the target API like so:
let x = Duck()
doSomething(x as @like(Object))
In my imagination, this is what happens under the hood when this code is compiled:
- The compiler examines exactly which parts of
Object
’s interfacedoSomething
uses, and constructs a one-off protocol — let’s call it_doSomething_Object
— based on those usages. This could be complex (reminder that I am not a compiler engineer) but generally Swift is able to understand how a reference (or value) is used; after all, the type checker and optimizer use this information. - We now have a protocol describing exactly what interface the target API expects from its parameter. The compiler declares that
Duck
andObject
now conform to_doSomething_Object
. - The compiler verifies that
Duck
does, in fact, conform to this new_doSomething_Object
protocol. If it does, we know that as far asdoSomething
is concerned,Object
andDuck
are interchangeable — they have the same shape. If it doesn’t conform, we know that these types are not interchangeable; in ObjC we’d discover this through a runtime error, but here we discover it via a type checking failure.
Under the hood, once the compiler has finished these steps, this system looks more like this — except in actuality, the complexity around _doSomething_Object
has been hidden away:
// the shadow protocol (exists only at compile time):
protocol _doSomething_Object {
// duplicates the parts of Object's interface which doSomething uses
// (generated at compile time)
}
// shadow protocol conformances (exists only at compile time):
extension Object: _doSomething_Object
extension Duck: _doSomething_Object
// the target API:
func doSomething(with: _doSomething_Object) { … }
// various call sites:
let x = Duck()
doSomething(x)
let y = Object()
doSomething(x)
The call site can now use something like duck typing, without the busy work of manually building a shadow protocol, and with type checking to boot. The target API, meanwhile, doesn’t require a new user-facing protocol.
Clearly the target API’s method’s signature has changed — it now needs to accept a protocol as its parameter. With some exceptions, discussed in the following sections, this is straightforward: the compiler simply treats doSomething
as if it accepts the _doSomething_Object_
protocol as a parameter.
(Before we continue, a brief reminder that I am not a compiler engineer.)
Considerations in Access Control, Modules, and Inheritance
The proposed approach has some clear problems when we consider inheritance — or at least there are some questions which need to be resolved.
Open
The open
access level, applied to the target API, is problematic. It indicates that any programmer may override the target API, and given a parameter, they might want to use some part of its API which isn’t used in the original implementation.
The ability to generate a shadow protocol, allowing for safe substitution of a similarly-shaped duck, depends on the ability to analyze the target API’s implementation. This isn’t possible with an open
API, and thus @like
types cannot be passed to open
target APIs.
The Same-Module Constraint
Shadow protocols generated by the compiler to support @like
should remain invisible outside the target API’s Swift module. This is necessary for two reasons:
First, they effectively leak details about the implementation of the target API (recall, the a shadow protocol contains the minimum API surface necessary for a duck to be used by the target API). Changes to implementation details will thus result in changes to shadow protocols; and exposing these in the module’s public API would effectively elevate changes in implementation details to breaking changes of public API.
Second, exposing shadow protocols in a module’s public API would result in an inconsistent API. Consider:
- Support for
@like
requires changes in how the target API’s module is compiled. This is not always possible or desirable; thus certain APIs in a module might accept a shadow protocol, while others might not, with no rationale presentable to the user of the module. - Shadow protocols are generated on-demand when a caller wishes to use them; their existence is thus a side effect of how an API is called, rather than some intrinsic property of the API. Again, exposing such a side effect outside the module will result in a confusingly inconsistent API.
Thus, calling another module’s API with @like
must be disallowed.
Testable Imports
Test doubles are an extremely common use case for duck typing. Therefore, it seems clear that Swift’s @testable import
would allow calling into @testable
APIs with ducks via @like
.
Of course, the compiler would generate the necessary shadow protocols, and they’d need to be visible to the test module in order for the compiler to apply them to the requisite test doubles.
Notably, shadow protocol generation should occur for a target API only if a caller is actually calling the API using @like
. Given an API in an application target, which is tested with @like
and test doubles but isn’t used with @like
from within the application target, I would expect Swift tooling to build the application target on its own without shadow protocols for that API.
I don’t know any implementation details about how @testable
works or whether such functionality is feasible; I’ll leave this as an exercise for the future.
Public
The public
access level, applied to the target API, is not as problematic as it may appear. Consider that one of two cases apply to all usage of the target API: the caller is either part of the same Swift module, or part of another module.
To support both of these cases, the compiler will emit two versions of the target API function. One accepts the original parameter type, and the other accepts the shadow protocol parameter type. (It effectively becomes an overloaded function.)
Code outside the module (or other code that doesn’t use @like
at the call site) can call the target API function which accepts the original parameter type. Code inside the module, using @like
, can use the function accepting a shadow protocol.
Extending to Properties
The same principles apply when a programmer would like to assign a Duck
to a property:
class Foo {
// the target API:
var bar: Object
}
// call site:
let x = Foo()
let duck = Duck()
x.bar = duck as @like(Object)
In this case the compiler will examine all usage of bar
to understand how much of Object
’s API surface will be used, and then to build a sufficient shadow protocol. The property is then, under the hood, a shadow protocol type.
Of course, a property can’t be duck-typed if the type containing it is open
or if the property itself is public
.
Safety
Does this proposal violate the Liskov substitution principle?
No. The shadow protocols constructed by the compiler in this proposal are constructed using knowledge about what parts of the original type’s interface are required; and the type checker verifies that the “duck” type is able to fulfill that contract.
Does this proposal treat interfaces as simple bags of syntax?
No. Rather than noticing familiar syntax and attaching behavior to it, this proposal requires that a programmer make a deliberate assertion that a duck type fulfills a similar semantic role.
In traditional duck typing, “if it walks like a duck and quacks like a duck, it probably is a duck.” But — to continue with this metaphor — this proposal intentionally does not assume that all types which walk and quack like ducks, are ducks. Rather, the programmer asserts that a type is a duck, and then the compiler examines the implementation to determine the expected shape of this particular duck, and the type checker ensures that the alleged duck does in fact seem to be a duck.
Conclusions
In this post I’ve presented a hypothetical new Swift language feature which would allow something like duck typing in many (but not all) use cases. This feature would be particularly useful in tests, where currently no good mocking/stubbing solution exists. The solution I’ve proposed achieves flexibility while maintaining the safety provided by Swift’s static type checking.
I discussed at a very high level how such a feature might be implemented in the Swift compiler, leveraging the existing robust protocol infrastructure and static type checker.
Finally, I discussed relevant considerations for this approach given Swift’s access controls and inheritance model, modules, tests.
Open Questions
Would this be useful?
What am I missing? I assume there are problems and complexities here which I’ve failed to anticipate.
Have others put forth related proposals, not just in Swift but in any programming language? I’m interested in any reading you’d like to send my way.
If nothing else, I hope I can get others in the Swift community to consider: Are there other ways Swift could bring us some of the benefits of more dynamic languages, while providing the safety that comes with static type checking?
Future Experiments
I would like to try some experiments, which won’t give me quite the elegance of a language feature but would nonetheless be interesting:
Can I offload the shadow protocol generation problem to code generation as an early build phase? Then the only manual change is changing the target API to accept the shadow protocol — this is not ideal particularly for testing, but at least we don’t have to manually maintain shadow protocols.
An implementation would likely lean on Sourcery and SourceKitten. It might even be possible to perform the requisite usage analysis with SourceKitten in order to build the minimum-viable shadow protocol for a given function/type.
A code-generation approach to shadow protocols for testing would be especially interesting. One could imagine a template which builds a shadow protocol and a test double for it, and allows customizing verification behaviors via eg. closure properties.
This would pair well with a full-fledged @like
implementation as described in this article; but it’s an interesting avenue to explore regardless, since using mocks to verify interactions is currently tedious in Swift.
1: Objective-C provides some type checking but no real enforcement at compile time; for the purposes of this discussion we can say it allows “duck typing”.
2: I’m not picking on this article to be mean. This is still probably the best way to solve this problem in Swift.
As always, I welcome discussion and feedback; I’m @cdzombak on Twitter.