Static and Dynamic Callable Types in Swift
Last week,
Apple released the first beta of Xcode 11.4,
and it’s proving to be one of the most substantial updates in recent memory.
XCTest
got a huge boost,
with numerous quality of life improvements,
and Simulator, likewise, got a solid dose of
TLC.
But it’s the changes to Swift that are getting the lion’s share of attention.
In Xcode 11.4,
Swift compile times are down across the board,
with many developers reporting improvements of 10 – 20% in their projects.
And thanks to a new diagnostics architecture,
error messages from the compiler are consistently more helpful.
This is also the first version of Xcode to ship with the new
sourcekit-lsp
server,
which serves to empower editors like VSCode
to work with Swift in a more meaningful way.
Yet, despite all of these improvements (which are truly an incredible achievement by Apple’s Developer Tools team), much of the early feedback has focused on the most visible additions to Swift 5.2. And the response from the peanut galleries of Twitter, Hacker News, and Reddit has been — to put it charitably — “mixed”.
If like most of us, you aren’t tuned into the comings-and-goings of Swift Evolution, Xcode 11.4 was your first exposure to two new additions to the language: key path expressions as functions and callable values of user-defined nominal types.
The first of these allows key paths to replace
one-off closures used by functions like map
:
// Swift >= 5.2
"🧁🍭🍦".unicode Scalars.map(\.properties.name)
// ["CUPCAKE", "LOLLIPOP", "SOFT ICE CREAM"]
// Swift <5.2 equivalent
"🧁🍭🍦".unicode Scalars.map { $0.properties.name }
The second allows instances of types with a method named call
to be called as if they were a function:
struct Sweetener {
let additives: Set<Character>
init<S>(_ sequence: S) where S: Sequence, S.Element == Character {
self.additives = Set(sequence)
}
func call As Function(_ message: String) -> String {
message.split(separator: " ")
.flat Map { [$0, "\(additives.random Element()!)"] }
.joined(separator: " ") + "😋"
}
}
let dessertify = Sweetener("🧁🍭🍦")
dessertify("Hello, world!")
// "Hello, 🍭 world! 🍦😋"
Granted, both of those examples are terrible. And that’s kinda the problem.
Too often, coverage of “What’s New In Swift” amounts to little more than a regurgitation of Swift Evolution proposals, interspersed with poorly motivated (and often emoji-laden) examples. Such treatments provide a poor characterization of Swift language features, and — in the case of Swift 5.2 — serves to feed into the popular critique that these are frivolous additions — mere syntactic sugar.
This week, we hope to reach the ooey gooey center of the issue by providing some historical and theoretical context for understanding these new features.
Syntactic Sugar in Swift
If you’re salty about “key path as function” being too sugary, recall that the status quo isn’t without a sweet tooth. Consider our saccharine example from before:
"🧁🍭🍦".unicode Scalars.map { $0.properties.name }
That expression relies on at least four different syntactic concessions:
- Trailing closure syntax, which allows a final closure argument label of a function to be omitted
-
Anonymous closure arguments,
which allow arguments in closures to be used positionally (
$0
,$1
, …) without binding to a named variable. - Inferred parameter and return value types
- Implicit return from single-expression closures
If you wanted to cut sugar out of your diet completely, you’d best get Mavis Beacon on the line, because you’ll be doing a lot more typing.
"🧁🍭🍦".unicode Scalars.map(transform: { (unicode Scalar: Unicode.Scalar) -> String in
return unicode Scalar.properties.name
})
In fact, as we’ll see in the examples to come, Swift is a marshmallow world in the winter, syntactically speaking. From initializers and method calls to optionals and method chaining, nearly everything about Swift could be described as a cotton candy melody — it really just depends on where you draw the line between “language feature” and “syntactic sugar”.
To understand why, you have to understand how we got here in the first place, which requires a bit of history, math, and computer science. Get ready to eat your vegetables 🥦.
The λ-Calculus and Speculative Computer Science Fiction
All programming languages can be seen as various attempts to represent the λ-calculus. Everything you need to write code — variables, binding, application — it’s all in there, buried under a mass of Greek letters and mathematical notation.
Setting aside syntactic differences, each programming language can be understood by its combination of affordances for making programs easier to write and easier to read. Language features like objects, classes, modules, optionals, literals, and generics are all just abstractions built on top of the λ-calculus.
Any other deviation from pure mathematical formalism can be ascribed to real-world constraints, such as a typewriter from the 1870s, a punch card from the 1920s, a computer architecture from the 1940s, or a character encoding from the 1960s.
Among the earliest programming languages were Lisp, ALGOL*, and COBOL, from which nearly every other language derives.
(defun square (x)
(* x x))
(print (square 4))
;; 16
pure function square(x)
integer, intent(in) :: x
integer :: square
square = x * x
end function
program main
integer :: square
print *, square(4)
end program main
! 16
IDENTIFICATION DIVISION.
PROGRAM-ID. example.
DATA DIVISION.
WORKING-STORAGE SECTION.
01 x PIC 9(3) VALUE 4.
01 y PIC 9(9).
PROCEDURE DIVISION.
CALL "square" USING
BY CONTENT x
BY REFERENCE y.
DISPLAY y.
STOP RUN.
END PROGRAM example.
IDENTIFICATION DIVISION.
PROGRAM-ID. square.
DATA DIVISION.
LINKAGE SECTION.
01 x PIC 9(3).
01 y PIC 9(3).
PROCEDURE DIVISION USING x, y.
MULTIPLY x BY x GIVING y.
EXIT PROGRAM.
END PROGRAM square.
* 016000000
Here you get a glimpse into three very different timelines; ours is the reality in which ALGOL’s syntax (option #2) “won out” over the alternatives. From ALGOL 60, you can draw a straight line from CPL in 1963, to BCPL in 1967 and C in 1972, followed by Objective-C in 1984 and Swift in 2014. That’s the lineage that informs what types are callable and how we call them.
Now, back to Swift…
Function Types in Swift
Functions are first-class objects in Swift, meaning that they can be assigned to variables, stored in properties, and passed as arguments or returned as values from other functions.
What distinguishes function types from other values is that they’re callable, meaning that you can invoke them to produce new values.
Closures
Swift’s fundamental function type is the closure, a self-contained unit of functionality.
let square: (Int) -> Int = { x in x * x }
As a function type,
you can call a closure by passing the requisite number of arguments
between opening and closing parentheses ()
—
a la ALGOL.
square(4) // 16
Closures are so called because they close over and capture references to any variables from the context in which they’re defined. However, capturing semantics aren’t always desirable, which is why Swift provides dedicated syntax to a special kind of closure known as a function.
Functions
Functions defined at a top-level / global scope
are named closures that don’t capture any values.
In Swift,
you declare them with the func
keyword:
func square(_ x: Int) -> Int { x * x }
square(4) // 16
Compared to closures, functions have greater flexibility in how arguments are passed.
Function arguments can have named labels instead of a closure’s unlabeled, positional arguments — which goes a long way to clarify the effect of code at its call site:
func deposit(amount: Decimal,
from source: Account,
to destination: Account) throws { … }
try deposit(amount: 1000.00, from: checking, to: savings)
Functions can be generic, allowing them to be used for multiple types of arguments:
func square<T: Numeric>(_ x: T) -> T { x * x }
func increment<T: Numeric>(_ x: T) -> T { x + 1 }
func compose<T>(_ f: @escaping (T) -> T, _ g: @escaping (T) -> T) -> (T) -> T {
{ x in g(f(x)) }
}
compose(increment, square)(4 as Int) // 25 ((4 + 1)²)
compose(increment, square)(4.2 as Double) // 27.04 ((4.2 + 1)²)
Functions can also take variadic arguments,
implicit closures,
and default argument values
(allowing for magic expression literals like #file
and #line
):
func print(items: Any...) { … }
func assert(_ condition: @autoclosure () -> Bool,
_ message: @autoclosure () -> String = String(),
file: Static String = #file,
line: UInt = #line) { … }
And yet,
despite all of this flexibility for accepting arguments,
most functions you’ll encounter operate on an implicit self
argument.
These functions are called methods.
Methods
A method is a function contained by a type.
Methods automatically provide access to self
,
allowing them to effectively capture the instance on which they’re called
as an implicit argument.
struct Queue<Element> {
private var elements: [Element] = []
mutating func push(_ new Element: Element) {
self.elements.append(new Element)
}
mutating func pop() -> Element? {
guard !self.elements.is Empty else { return nil }
return self.elements.remove First()
}
}
Putting everything together, these syntactic affordances allow Swift code to be expressive, clear, and concise:
var queue = Queue<Int>()
queue.push(1)
queue.push(2)
queue.pop() // 1
Compared to more verbose languages like Objective-C, the experience of writing Swift is, well, pretty sweet. It’s hard to imagine any Swift developers objecting to what we have here as being “sugar-coated”.
But like a 16oz can of Surge, the sugar content of something is often surprising. Turns out, that example from before is far from innocent:
var queue = Queue<Int>() // desugars to `Queue<Int>.init()`
queue.push(1) // desugars to `Queue.push(&queue)(1)`
All this time,
our so-called “direct” calls to methods and initializers
were actually shorthand for function currying
partially-applied functions.
With this in mind, let’s now take another look at callable types in Swift more generally.
{Type, Instance, Member} ⨯ {Static, Dynamic}
Since their introduction in Swift 4.2 and Swift 5, respectively,
many developers have had a hard time keeping
@dynamic
and @dynamic
straight in their minds —
made even more difficult by the introduction of call
in Swift 5.2.
If you’re also confused, we think the following table can help clear things up:
Static | Dynamic | |
---|---|---|
Type | init |
N/A |
Instance | call |
@dynamic |
Member | func |
@dynamic |
Swift has always had static callable types and type members. What’s changed in new versions of Swift is that instances are now callable, and both instances and members can now be called dynamically.
Let’s see what that means in practice, starting with static callables.
Static Callable
struct Static {
init() {}
func call As Function() {}
static func function() {}
func function() {}
}
This type can be called statically in the following ways:
let instance = Static() // ❶ desugars to `Static.init()`
Static.function() // ❷ (no syntactic sugar!)
instance.function() // ❸ desugars to Static.function(instance)()
instance() // ❹ desugars to `Static.call As Function(instance)()`
- ❶
- Calling the
Static
type invokes an initializer - ❷
- Calling
function
on theStatic
type invokes the corresponding static function member, passingStatic
as an implicitself
argument. - ❸
- Calling
function
on an instance ofStatic
invokes the corresponding function member, passing the instance as an implicitself
argument. - ❹
- Calling an instance of
Static
invokes thecall
function member, passing the instance as an implicitAs Function() self
argument.
Dynamic Callable
@dynamic Callable
@dynamic Member Lookup
struct Dynamic {
func dynamically Call(with Arguments args: [Int]) -> Void { () }
func dynamically Call(with Keyword Arguments args: Key Value Pairs<String, Int>) -> Void { () }
static subscript(dynamic Member member: String) -> (Int) -> Void { { _ in } }
subscript(dynamic Member member: String) -> (Int) -> Void { { _ in } }
}
This type can be called dynamically in a few different ways:
let instance = Dynamic() // desugars to `Dynamic.init()`
instance(1) // ❶ desugars to `Dynamic.dynamically Call(instance)(with Arguments: [1])`
instance(a: 1) // ❷ desugars to `Dynamic.dynamically Call(instance)(with Keyword Arguments: ["a": 1])`
Dynamic.function(1) // ❸ desugars to `Dynamic[dynamic Member: "function"](1)`
instance.function(1) // ❹ desugars to `instance[dynamic Member: "function"](1)`
- ❶
- Calling an instance of
Dynamic
invokes thedynamically
method, passing an array of arguments andCall(with Arguments:) Dynamic
as an implicitself
argument. - ❷
- Calling an instance of
Dynamic
with at least one labeled argument invokes thedynamically
method, passing the arguments in aCall(with Keyword Arguments:) Key
object andValue Pairs Dynamic
as an implicitself
argument. - ❸
- Calling
function
on theDynamic
type invokes the staticdynamic
subscript, passingMember "function"
as the key; here, we call the returned anonymous closure. - ❹
- Calling
function
on an instance ofDynamic
invokes thedynamic
subscript, passingMember "function"
as the key; here, we call the returned anonymous closure.
Dynamism by Declaration Attributes
@dynamic
and @dynamic
are declaration attributes,
which means that they can’t be applied to existing declarations
through an extension.
So you can’t, for example,
spice up Int
with Ruby-ish
natural language accessors:
@dynamic Member Lookup // ⚠︎ Error: '@dynamic Member Lookup' attribute cannot be applied to this declaration
extension Int {
static subscript(dynamic Member member: String) -> Int? {
let string = member.replacing Occurrences(of: "_", with: "-")
let formatter = Number Formatter()
formatter.number Style = .spell Out
return formatter.number(from: string)?.int Value
}
}
// ⚠︎ Error: Just to be super clear, this doesn't work
Int.forty_two // 42 (hypothetically, if we could apply `@dynamic Member Lookup` in an extension)
Contrast this with call
,
which can be added to any type in an extension.
There’s much more to talk about with
@dynamic
, @dynamic
, and call
,
and we look forward to covering them all in more detail
in future articles.
But speaking of RubyPython…
Swift ⨯ _______
Adding to our list of “What code is like”:
Code is like fan fiction.
Sometimes to ship software, you need to pair up and “ship” different technologies.
In building these features, the “powers that be” have ordained that Swift replace Python for Machine Learning. Taking for granted that an incremental approach is best, the way to make that happen is to allow Swift to interoperate with Python as seamlessly as it does with Objective-C. And since Swift 4.2, we’ve been getting pretty close.
import Python
let numpy = Python.import("numpy")
let zeros = numpy.ones([2, 4])
/* [[1, 1, 1, 1]
[1, 1, 1, 1]] */
The Externalities of Dynamism
The promise of additive changes is that they don’t change anything if you don’t want them to. You can continue to write Swift code remaining totally ignorant of the features described in this article (most of us have so far). But let’s be clear: there are no cost-free abstractions.
Economics uses the term negative externalities to describe indirect costs incurred by a decision. Although you don’t pay for these features unless you use them, we all shoulder the burden of a more complex language that’s more difficult to teach, learn, document, and reason about.
A lot of us who have been with Swift from the beginning
have grown weary of Swift Evolution.
And for those on the outside looking in,
it’s unfathomable that we’re wasting time on inconsequential “sugar” like this
instead of features that will really move the needle,
like async
/ await
.
In isolation, each of these proposals is thoughtful and useful — genuinely. We’ve already had occasion to use a few of them. But it can be really hard to judge things on their own technical merits when they’re steeped in emotional baggage.
Everyone has their own sugar tolerance, and it’s often informed by what they’re accustomed to. Being cognizant of the drawbridge effect, I honestly can’t tell if I’m out of touch, or if it’s the children who are wrong…