Scoped Functions are functions that enhance the leading dot syntax inside their body, so that it gives short-hand access to the members of a specific value. Such functions exist so that API designers and their consumers can collaborate towards a way to write focused code where an agreed implicit and obvious context does not have to be spelled out.
This pitch considers that developers often want to use short-hand identifier resolution in particular contexts, and api designers often want to make it possible.
The Swift language already provides much help in this regard. We have the implicit self
, the current (and evolving) rules for the leading dot syntax, the scoping rules for accessing nested types, etc.
But developers always want more:
- SE-0299 has revealed a way to better control the scope of the leading dot syntax.
- Several threads in the Swift Forums request a Kotlin feature named Type-safe builders, the latest being https://forums.swift.org/t/can-i-make-certain-functions-available-within-a-closure-arg/45115.
- Several threads also request a feature where members of a variable can be configured without repeating the name of the variable.
- (Insert other examples here)
In all those examples, developers express a desire to enhance identifier resolution in a lexically scoped context: a value, or the body of a closure, where both are arguments of a function.
Considering:
- The braces of a closure body are a well delimited lexical scope.
- A function call is a strong semantic signal that is able to define an implicit and obvious context that both api designers and consumers can agree on.
- Naked identifier resolution in Swift is already pretty busy with local variables,
self
, type names, module names, etc, so we do not propose modifying this. - The leading dot syntax is a beloved short-hand syntax of Swift.
We modify the rules that govern leading dot syntax inside the body of a particular closure argument. When the closure is an autoclosure, the leading dot syntax is modified for the value that feeds this autoclosure.
This gives:
// The build(_:configure:) function accepts a closure
// scoped on its first argument:
let label = build(UILabel()) {
.text = "Hello scoped functions!"
.font = .preferredFont(forTextStyle: .body)
}
// The `filter(_:)` and `order(_:)` functions accept an
// autoclosure scoped on the generic type of the request
// (here, `Player`).
// `Player` collaborates, and has defined two
// `Player.score` and `Player.team` static constants.
let players = try Player.all()
.filter(.team == "Red")
.order(.score.desc)
.limit(10)
.fetchAll(from: database)
// A revisit of SE-0299: the argument of toggleStyle(_:) is
// an autoclosure scoped on an ad-hoc enum type:
Toggle("Wi-Fi", isOn: $isWiFiEnabled)
.toggleStyle(.switch)
Without scoped functions, developers face various problems:
// The `label` variable is repeated.
let label = UILabel()
label.text = "Hello World!"
label.font = .preferredFont(forTextStyle: .body)
// The `Player` type is repeated.
let players = try Player.all()
.filter(Player.team == "Red")
.order(Player.score.desc)
.limit(10)
.fetchAll(from: database)
// As before SE-0299
Toggle("Wi-Fi", isOn: $isWiFiEnabled)
.toggleStyle(SwitchToggleStyle())
Personal motivation: as the author of GRDB, which defines a type-safe API for building SQL queries, and relies of static properties defined on user-defined "record types", I'd love to simplify some user-land code:
// CURRENT GRDB CODE struct Team: Codable, FetchableRecord { static let players = hasMany(Player.self) static let awards = hasMany(Award.self, through: players, using: Player.awards) } struct Player: Codable, FetchableRecord { static let awards = hasMany(Award.self) } struct Award: Codable, FetchableRecord { } // All red teams with their awarded players let request = Team .filter(Team.Columns.name == "Red") .including(all: Team.players.having(Player.awards.count > 0)) // WITH SCOPED FUNCTIONS struct Team: Codable, FetchableRecord { static let players = hasMany(Player.self) static let awards = hasMany(Award.self, through: players, using: .awards) // <- } struct Player: Codable, FetchableRecord { static let awards = hasMany(Award.self) } struct Award: Codable, FetchableRecord { } // All red teams with their awarded players let request = Team .filter(.name == "Red") // <- .including(all: .players.having(.awards.count > 0)) // <-Sorry if I provided a contrieved example. But contrieved examples are precisely those where unnecessary clutter obscures the intent the most.
A new function attribute is introduced: @scoped
. The @scoped
attribute can only be used for functions that accept at least one argument.
@scoped f(T, ...) -> ...
is a scoped function, and T
is the scope type. The first argument is the scope value. Inside the body of the function, the scope value feeds the leading dot syntax. The function is said to be T
-scoped.
For example:
let f: @scoped (String) -> Int // A
f = { return .count } // B
f("Hello") // C: prints "5"
-
In (A), f is defined as a String-scoped function.
-
In (B), the compiler is able to resolve
.count
asString.count
. -
In (C), the function is called with a particular String.
We are able to address some of the above examples:
-
The
build(_:_:)
functionfunc build<T>(_ initial: T, update: @scoped (inout T) throws -> ()) rethrows -> T { var value = initial update(&value) return value } let label = build(UILabel()) { .text = "Hello scoped functions!" .font = .preferredFont(forTextStyle: .body) } label.text // "Hello"
Woot, we'll have to define how the compiler deals with leading dot syntax for
.font
,.preferredFont
, and.body
:-) -
The database query
struct Request<T> { func filter(_ expression: @autoclosure @scoped (T.Type) -> Expression) -> Self { ... } func order(_ ordering: @autoclosure @scoped (T.Type) -> Ordering) -> Self { ... } func limit(_ limit: Int) -> Self { ... } } extension Player { static let team = Column(...) static let score = Column(...) } let request: Request<Player> = ... request.filter(.team == "Red").order(.score.desc)
The
@autoclosure
qualifier has the compiler change the leading dot scoping for values.
TBD
Scoped function are an additive feature that creates no source compatibility issue.
A scoped function, at runtime, is a plain function that accepts its scope as the first argument. Its a compiler-only feature, without any effect on the ABI.
TBD
Use another sigil than .
? For example, '
or ..
:
let label = build(UILabel()) {
'text = "Hello scoped functions!"
'font = .preferredFont(forTextStyle: .body)
}
let label = build(UILabel()) {
..text = "Hello scoped functions!"
..font = .preferredFont(forTextStyle: .body)
}