Adding Method Cascades
- Proposal: TBD
- Author(s): Erica Sadun
- Status: TBD
- Review manager: TBD
Current Straw Poll
Proposal state is in limbo and has been redirected to this bug report
Introduction
Method cascades offer a (primarily) procedural counterpart to functional chaining. In the latter, which already exists in Swift, partial results are passed from one step to the next. In the former, the object scope is maintained through a series of sequential calls. Method cascades can return a value, so while their component calls may be procedural, the self
value can be passed through to the caller.
Rather than re-invent the wheel, here is some existing motivation for such a feature in the context of Dart and Python:
- Dart language feature request: method cascades
- Method Cascades in Dart
- Dart-like method cascading operator in Python
To adapt the gnuplot example from the Python feature request, a Swift implementation might look like:
var plot = GnuPlotType()
with plot {
xrange = 0..<5
yrange = 0..<20
addSeries("Linear", [1, 2, 3])
addSeries("Quadratic", [1, 4, 6])
run()
}
This example is purely procedural example with no return values assigned to plot at the end. The method cascade that streamlines the calls to manipulate the new instance and calls functions on it. You can imagine, as well, a functional version that returns the same type T
as the self
that is bound using with
:
with let plot = GnuPlotType() {
xrange = 0..<5
yrange = 0..<20
addSeries("Linear", [1, 2, 3])
addSeries("Quadratic", [1, 4, 6])
run()
}
Sanity Check
A few quick points, if this feature were already in Swift, would I be using it? Definitely. Would I fight a proposal to remove it from the language? Almost certainly. Do I feel it fits the existing patterns of Swift fluency? Yes.
Motivation
Since working with Swift, I've noticed that many Apple-supplied classes, often fail to fully set up an instance for use. Here's one example that uses NSTask
.
let task = NSTask()
task.launchPath = "/usr/bin/mdfind"
task.arguments = ["kMDItemDisplayName == *.playground"]
task.standardOutput = pipe
The launch path, the arguments, and the output pipe must be manually assigned after the declaration. In this example, the next few steps are invariably to launch()
and then waitUntilExit()
. These are perfect examples of non-initializer calls that would benefit from cascading, especially for a cascade that returned a value for variable binding to task
.
Here's another common scenario using UILabel.
let questionLabel = UILabel()
questionLabel.textAlignment = .Center
questionLabel.font = UIFont(name:"DnealianManuscript", size: 72)
questionLabel.text = currentQuestion.questionText
questionLabel.numberOfLines = 0
Without some kind of language change, you end up with stodgy repetitive code that turns into a blocky hard-to-follow clump.
- This set-up code feels unnecessarily redundant, with the task and questionLabel symbols overwhelming and drawing attention from the actual set-up these lines of code are intended to perform.
- The extra symbol verbiage goes against common Swift style. For example, when the context is clear, one should prefer
.whitespaceAndNewlineCharacterSet
toNSCharacterSet.whitespaceAndNewlineCharacterSet
. - If you have many instances to set up there's no clear way to differentiate unrelated set-up groups other than inserting whitespace gaps or building custom factory functions. A braced method cascade could bypass these issues.
Method cascades like those in Smalltalk and Dart, cascades could alleviate these issues. In a method cascade, multiple methods can be called on the same object, for a more fluent interface. Cascades eliminate the need to list an object over and over again or use a temporary stand-in variable to represent the object being customized.
Although I tried to come up with a similar initialization example for Enumerations and Structure value types, I couldn't find anywhere in my code that was really needed as these items typically provide full set-up through built-in and convenience initializers. That said, I found many circumstances in which I repeatedly used the same item in sequential, blocking steps, which would benefit from method cascades.
Proposed solution
Method cascades automatically introduce self-references for the primary object. For example the NSTask()
initialization might look something like this instead:
with let task = NSTask() {
launchPath = "/usr/bin/mdfind"
arguments = ["kMDItemDisplayName == *.playground"]
standardOutput = pipe
}
In this example, the braces are scoped to the instance as self
, enabling the properties to entirely drop their prefixes and be grouped together for set-up. You could extend this to include the calls that execute the task and wait for its completion:
with let task = NSTask() {
launchPath = "/usr/bin/mdfind"
arguments = ["kMDItemDisplayName == *.playground"]
standardOutput = pipe
launch()
waitUntilExit()
}
I first mocked up a similar system for playgrounds This implementation creates a pass-through closure that performs set-up on an object and then returns that object:
infix operator •-> {}
// prepare class instance
func •-> <T>(object: T, f: (T) -> Void) -> T {
f(object)
return object
}
// e.g.
class MyClass {var (x, y, z) = ("x", "y", "z")}
let myInstance = MyClass() •-> {
$0.x = "NewX"
$0.y = "NewY"
}
I dislike this implementation for several reasons:
- It lacks the clarity and fluency of a no-prefix solution that automatically establishes self.
- It is limited to reference types
- It requires anonymous arguments that visually stack
A slight tweak can add value type support but with minimal further benefits:
func •-> <T>(object: T, @noescape f: (inout T) -> Void) -> T {
var newValue = object
f(&newValue)
return newValue
}
Advantages of Method Cascading
The advantages to method cascading are as follows:
- fluent sequential calls that match the simplicity of functional chaining
- code is streamlined, a general Swift ideal
- can be used to add additional setup tasks for initialization
- the indented scope provides a visual emphasis of the single task being addressed allows easier top-level initialization for global values, especially for Swift playgrounds.
- the cascades can be extended for more general use for serial calls that do not lend themselves naturally to functional chaining.
- could be extended for optionals as an alternative to if-let binding. Sean Heber writes, "If you used “with” with an optional, it could just skip the entire block if the value was nil. For cases where you only have things to do when the optional is non-nil, and being nil is perfectly okay, this would allow you to pretty naturally write that code without using forced unwrapping or creating a shadowed unwrapped variable."
Complications of this proposal
Potential complications would include:
- the need for a new keyword such as
with
as suggested by Sean Heber or operator like Joe Groff's Dart-inspired..
I have not had sufficient time to cover the double-dot operator but it may play an important role, as Alex Lew points out, especially when working with trailing closure calls. Tino Heth would prefer a single dot operator followed by a closure over the keyword approach. - compiler tweaking so
self
-references within the closure work without constant copies for value types. - compiler adjustment to check for possible symbol conflicts, forcing
self
prefixes for any conflict.
The implementation must disambiguate any symbol conflicts that could break code, for example, given the following code, as pointed out by Michel Fortin.
let launchPath = "a.out"
let description = "blah blah"
with let task = NSTask() {
self.launchPath = launchPath // would require mandatory checking for where self is needed
arguments = [launchPath, self.description] // is that task.description?
}
Without self differentiation, it can also lead to code breakage, if for example, in the next OS release NSTask gets a new "path" property:
let path = "a.out"
with let task = NSTask() {
launchPath = path
}
One proposed solution would be to mandate a period prefix for self-references, disambiguating while allowing
developers to drop the Joe Groff points out that a leading self
prefix:.
is syntactically ambiguous and banned from Swift.
About Optionals
Lukas Stabe writes:
One thing I found myself thinking about is: How would
with
work when used with optionals (think failable initializers)? Would the block just not be executed (making it similar to mapping over an optional)? That would sound like a big plus for this feature, since you wouldn’t need to either use optional chaining or check if the value was nil while doing further setup.
About Partial Configuration
Lukas Stabe writes:
Another question for me is this: Say I want to create an instance of a class, configure it, and then assign it to a property on some other object (it can’t be assigned before it’s fully configured). Could we adapt this proposal to handle that case, too (e.g.
with obj.prop = MyClass() { … }
)? I think it might feel more natural to move thewith
after the assignment (e.g. `obj.prop = with MyClass() { … }, so it could be used not just to assign to a local variable but in any context you want to use it, including method calls etc.
Disadvantages to this proposal
Adding significant new features to a language should be undertaken cautiously. Method cascading is not widely used in many programming languages although I believe it would offer a positive and immediate advantage to Swift users working with Cocoa libraries.
Arguments against this proposal
Jacob Bandes-Storch writes:
It seems like setting properties just after init is the main use case here.
I'm not against this idea, but I want to point out that this doesn't need to be solved by a change to the language. You can easily define a convenience init for UILabel that takes textAlignment, font, text, and numberOfLines as parameters. They could have default values so you can specify just the ones you need.
I like the idea of being able to do configure objects/values conveniently, but I'm not sure how to justify a language change for it. Perhaps we just need better autogeneration of initializers during Obj-C header import.
Impact on existing code
I see this initialization strategy as offering a positive way to refactor code bases for greater readability and simplicity.
Alternatives approaches
One proposal suggested by David Waite involves binding self to closure parameters. Instead of:
with let task = NSTask() {…}
you could transform ‘with' into standard function:
func with<T>(argument:T, apply:(T) throws->()) rethrows -> T {
try apply(argument)
return argument
}
let task = with(NSTask()) {
$0.launchPath = …
}
let task = with(NSTask()) {
self in
launchPath = …
arguments = …
standardOutput = ...
}
All examples seem to have something in common:
There is not only the cascade, but also an assignment that (imho) shouldn't be required.
I think it wouldn't be good to add another way to declare values with full scope (beside let, var and guard), and if the assignment only lasts for the body of the cascade, it's quite useless, as the main goal is to make that name superfluous.