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
.countasString.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
@autoclosurequalifier 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)
}