There have been a fair number of proposals for making multiple closure arguments to a function look nicer and be more readable—most recently SE-279. These have all come up against ergonomic issues and lack of community consensus because they assume something close to the trailing closure syntax is what’s needed. I’d like to propose a very different approach, inspired directly by a hitherto unique feature of the language Ceylon, and which counterintuitively makes a complicated call site easier to read by increasing its length and verbosity (but also its expressiveness and formattability). I call these Declaration-Like Argument Blocks (or DLABs for short), and the intuitive idea is to make a complicated call site look a lot like a class or struct declaration.
For example, let’s say we have a function of this signature:
// Does some complicated iterative calculation,
// using initial bounds and updating them along the way
func messy(count: Int,
inout bounds: (Double, Double),
onStart: (Int, Double) -> Double?,
onCrossBound: (Int, Double, Double) -> (Double?, Double),
onEnd: (Int, Double) -> Bool,
initial: Double,
morphIntermediates: Double -> Double?)
-> (Double?, [Double])
Traditionally, we’d need to call it like this, and even the previous multiple trailing closure proposals couldn’t save us because initial
is in the way:
var bounds: (Double, Double) = (0.0,1.0)
messy(count: 200,
bound: &bounds,
onStart: { numDiscs, guess in
// give a better initial estimate based on the guess
},
onCrossBound: { numDiscs, estimate, boundCrossed in
// give a better estimate and a new bound
},
onEnd: { numDiscs, estimate in
// decide whether the final estimate is good enough
},
initial: 0.5,
morphIntermediates: { estimate in
// fix up the estimate between each iteration
})
With DLABs, it instead looks like this:
messy() where {
let count: Int = 200
public var bounds: (Double, Double) = (100, 200)
func onStart(numDiscs: Int, guess: Double) -> Double? {
// give a better initial estimate based on the guess
}
func onCrossBound(numDiscs: Int,
estimate: Double,
boundCrossed: Double)
-> (Double?, Double)
{
// give a better estimate and a new bound
}
func onEnd(numDiscs: Int, estimate: Double) -> Bool {
// decide whether the final estimate is good enough
}
let initial: Double = 0.5
func morphIntermediates(estimate: Double) -> Double? {
// fix up the estimate between each iteration
}
}
This syntax not only allows for multiple “trailing closures”, but allows all arguments to be passed this way. You could also opt to pass some of the arguments the “normal” way too, like so:
var bounds: (Double, Double) = (0.0,1.0)
messy(count: 200, bounds: &bounds) where {
func onStart(numDiscs: Int, guess: Double) -> Double? {
// give a better initial estimate based on the guess
}
func onCrossBound(numDiscs: Int,
estimate: Double,
boundCrossed: Double)
-> (Double?, Double)
{
// give a better estimate and a new bound
}
func onEnd(numDiscs: Int, estimate: Double) -> Bool {
// decide whether the final estimate is good enough
}
let initial: Double = 0.5
func morphIntermediates(estimate: Double) -> Double? {
// fix up the estimate between each iteration
}
}
You could even have both a (single) trailing closure and a DLAB:
var bounds: (Double, Double) = (0.0,1.0)
messy(count: 200, bounds: &bounds) { numDiscs, guess in
// give a better initial estimate based on the guess
} where {
func onCrossBound(numDiscs: Int,
estimate: Double,
boundCrossed: Double)
-> (Double?, Double)
{
// give a better estimate and a new bound
}
func onEnd(numDiscs: Int, estimate: Double) -> Bool {
// decide whether the final estimate is good enough
}
let initial: Double = 0.5
func morphIntermediates(estimate: Double) -> Double? {
// fix up the estimate between each iteration
}
}
This gives a lot of options for expressive call sites, and consequently for DSLs. The use of the keyword where
is pretty bikesheddable, but something has to be used to distinguish a DLAB from a standard single trailing closure and that choice of keyword seems the most readable to me in context.
Note that we can support pretty much all of the basic struct/class member definition syntax other than init
, deinit
, and subscript
. Properties that we care about using afterwards we can use public
for, properties that we don’t care about using afterwards we can use no visibility for, and we can define computed properties of both read-only and read-write forms and have them work just fine, even making use of observers if we want. Function-typed parameters can be satisfied using func
declaration syntax or using a property of the appropriate type. Type parameters, when we aren’t letting them be inferred, can be specified with typealias
declarations in the DLAB just as easily as in the traditional way before the parameter list. Autoclosure parameters fulfilled by a DLAB-supplied computed property have appropriately deferred evaluation. Function builders can be used by explicitly declaring them in exactly the same way you would in an actual struct, class, or extension declaration. Local function and property declarations that aren’t used as arguments to the call can be declared in a DLAB as private
items.
We have a whole world of existing functionally available for usage, at no syntactic cost; our call sites can look like large parts of Swift have always looked like!
I’d love to hear what the community thinks of this, and if I should go ahead and make this a proper proposal.