Skip to content

Instantly share code, notes, and snippets.

@erica
Last active April 12, 2022 06:38
  • Star 8 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save erica/6794d48d917e2084d6ed to your computer and use it in GitHub Desktop.

Adding Method Cascades

  • Proposal: TBD
  • Author(s): Erica Sadun
  • Status: TBD
  • Review manager: TBD

Introduction

Method cascades offer a method-based counterpart to functional chaining. In functional chaining, partial results pass from one step to the next. In cascades, object scope is maintained through a series of sequential calls. Both approaches support fluent interfaces, providing readable streamlined code.

Related Reading

Cascades currently appear in languages including Dart and Smalltalk. The following write-ups motivate and explain the inclusion of this feature in other programming languages.

Motivation

It helps to start a discussion of method cascading with initialization. Under Cocoa and Cocoa touch, many Apple-supplied classes won't set up an instance during the normal Swift initialization phase. Here's one example using NSTask. This snippet customizes a new instance, manually assigning a launch path, arguments, and an output pipe.

let task = NSTask()
task.launchPath = "/usr/bin/mdfind"
task.arguments = ["kMDItemDisplayName == *.playground"]
task.standardOutput = pipe

This build-then-specialize pattern extends throughout Cocoa/Cocoa Touch. This next interface-building example uses UILabel.

let questionLabel = UILabel()
questionLabel.textAlignment = .Center
questionLabel.font =  UIFont(name:"DnealianManuscript", size: 72)
questionLabel.text = currentQuestion.questionText
questionLabel.numberOfLines = 0

These examples demonstrate common issues that motivate method cascades:

  • Unnecessary redundancy. Similar lines follow one after another, to the point one could say "we get it already, you're setting up a task or a label".
  • Inappropriate visual focus Repeated symbols (task and questionLabel) actually draw attention from the set-up these lines of code are intended to perform. When visually scanning the code, a reader's attention is more naturally drawn to the repeated block than the particular programmatic details. This cognitive overload can negatively impact code inspection.
  • Extra verbiage The extra text goes against Swift common succinct style. Compare, for example, .whitespaceAndNewlineCharacterSet with NSCharacterSet.whitespaceAndNewlineCharacterSet.
  • Ungrouped code blocks When sequentially setting up many instances, Swift provides no clear way to differentiate scope between unrelated set-up groups other than inserting whitespace gaps or building custom factory functions.

Moving beyond initialization

Although the preceding examples extend initialization, method cascades are not limited to setup. When working with NSTask, applications commonly launch() the instance and then waitUntilExit(). These non-initializer calls would also benefit from cascading:

let task = NSTask()
task.launchPath = "/usr/bin/mdfind"
task.arguments = ["kMDItemDisplayName == *.playground"]
task.standardOutput = pipe
task.launch()
task.waitUntilExit()

Advantages of Method Cascading

The advantages to method cascading are as follows:

  • Method cascades produce fluent sequential calls that match the simplicity of functional chaining
  • Code is streamlined, a general Swift ideal
  • Cascades can extend setup, which is common when working with Cocoa classes.
  • An indented scope provides a visual emphasis of the single task being addressed allows easier top-level initialization for global values and in Swift playgrounds.
  • Cascades provide a natural alternative for serial calls that do not lend themselves to functional chaining.
  • Cascades could be extended for optionals as an alternative to if-let binding.

Proposed solution

I propose to introduce a with keyword followed by an instance, a variable binding, or expression and a scope with multiple expressions using that item as a default receiver. This approach transforms following sequence of statements:

instance.expression1; instance.expression2; instance.expression3; ...

to either a non-binding version:

with instance { expression1; expression2; expression3; ... }

or a binding version:

with let symbol = value { expression1; expression2; expression3; ... }

The self receiver in the braced scope corresponds to the instance or the newly bound symbol.

The refactored NSTask example

Once introduced, the NSTask example refactors to:

with let task = NSTask() {
    launchPath = "/usr/bin/mdfind"
    arguments = ["kMDItemDisplayName == *.playground"]
    standardOutput = pipe
    launch()
    waitUntilExit()
}

The result is cleaner, easier to read, and more succinct. 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.

Reference conflicts

Binding into a new scope introduces potential reference conflicts. Consider the following example:

class MyClass {
   var sharedName: ... (1)

   func something() {
       var sharedName: ... (2)

       with var myInstance: SomethingWithAnSharedNameProperty { (3)
          sharedName = newValue // sharedName is myInstance (4)
          sharedName = self // myInstance.sharedName is myInstance (5)
       }
   }
}

Method cascading must address two overlap scenarios:

  1. The sharedName symbol may refer to the instance property (1), the local variable (2), or the property of myInstance (3).
  2. self may refer to myInstance or the instance of MyClass

Resolving reference conflicts

This proposal uses the following scoping rules:

  1. Internal scope always wins for symbol resolution
  2. To access a namespace outside the scope, you must prefix a symbol with _.

Here is the proposed resolution:

class MyClass {
   var sharedName: ... (1)

   func something() {
       var sharedName: ... (2)

       with var myInstance: SomethingWithAnSharedNameProperty { (3)

          // Symbol resolution
          sharedName = newValue      // myInstance.sharedName = newValue (3)
          _.sharedName = newValue    // the locally scoped newValue (2)
          _._.sharedName = newValue  // I don't actually propose this, but (1)

          // Self resolution
          self.doSomething()   // self is myInstance
          _.self.doSomething() // self is an instance of MyClass
       }
   }
}

Potential conflict areas

Assume, for example, that the next OS extends NSTask to include a new path property. Here is cascaded code before the OS release:

let path = "/bin/ls"
with let task = NSTask() {
    launchPath = path
}

This snippet compiles both before and after the OS update but its meaning would significantly change. This introduces a notable error. After the update, the assignment continues to use rule 1 (internal scope wins for symbol resolution) and assigns a potentially uninitialized value to launchPath in preference to the external symbol. Since the Swift-language would not change, this could not be addressed through migration.

As OS changes breaking code is not limited to this proposal, I'd suggest introducing a tool that marked newly-introduced symbol conflicts, which would extend beyond this scenario: "Warning: NSTask path introduced in OS X 10.12 Malibu Barbie creates potential ambiguity on line 571."

Impact on existing code

As a newly introduced feature method cascades would not affect existing code and Swift would not require migration to accommodate its inclusion. Instead, I see this method cascading as offering a positive way to refactor code bases for greater readability and simplicity.

Alternatives considered

Method cascading can be approximated in current Swift using custom operators, for example:

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

Cascading and Optionals

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."

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.

Swift Evolution sanity checks

In a preliminary straw poll for this proposal:

  • 62% of 52 respondents were familiar with method cascading (11% neutral)
  • 62% agreed they made method calls more fluent (30% neutral)
  • 82% placed a priority on fluent APIs (14% neutral)
  • 68% would use method cascades if they existed in Swift (19% neutral)
  • 49% would object to method cascades being removed from Swift if they existed (23% netural)
  • 64% would support adding method cascades to Swift (no neutral option offered for this question)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment