Skip to content

Instantly share code, notes, and snippets.

@erica
Last active February 15, 2021 14:10
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save erica/96d9c5bb4eaa3ed3b2ff82dc35aa8dae to your computer and use it in GitHub Desktop.
Save erica/96d9c5bb4eaa3ed3b2ff82dc35aa8dae to your computer and use it in GitHub Desktop.

Introducing with to the Standard Library

Introduction

This proposal introduces a with function to the standard library. This function simplifies the initialization of objects and modification of value types.

Swift-evolution thread: What about a VBA style with Statement?

Motivation

When faced with modifying a value-type constant or initializing Cocoa objects, many developers find themselves using closure-based solutions. For example, they may duplicate and modify a constant value type using a closure:

struct Person { var name: String, favoriteColor: UIColor }
let john = Person(name: "John", favoriteColor: .blueColor())
let jane: Person = { var copy = $0; copy.name = "Jane"; return copy }(john)
print(jane) // Person(name: "Jane", favoriteColor: UIDeviceRGBColorSpace 0 0 1 1)

Or they may create then customize a Cocoa object:

let questionLabel: UILabel = {
    $0.textAlignment = .Center
    $0.font = UIFont(name: "DnealianManuscript", size: 72)
    $0.text = questionText
    $0.numberOfLines = 0
    mainView.addSubview($0)
    return $0
}(UILabel())

The technique consistently draws interest from frustrated developers, and Swift Evolution and Github are rife with variations on this theme. Taking a broader view, this can be seen as an example of a more general operation: modifying or using a value in passing. There are several other places where this same operation would be useful, especially since the adoption of SE-0003. SE-0003 eliminated var parameters, replacing them with shadowed var copies which must be modified and used.

Using a closure technique like the ones demonstrated above has significant drawbacks:

  • Swift can't infer the return type, so you must state it explicitly.
  • You must put the call to the instance's initializer after the code that uses it.
  • You must explicitly return the instance.
  • If the instance is a value type, you must explicitly assign it to a var to make it mutable.

Nevertheless, its benefits are compelling:

  • By giving the instance a short, temporary name, it reduces the noise of the longhand form.
  • It visually groups together all the setup code for a given object.

We propose to introduce a new global with function that preserves the closure approach's benefits while eliminating its drawbacks. Here are some examples where this function may be used.

The Value Types Constant Problem

Consider the situation where you need a copy-and-return method and the only available method mutates self, as in the following example:

// Desired behavior:
let fewerFoos = foos.removing(at: i)

Swift enables you to work around this limitation by creating a var copy, which you mutate:

var fewerFoos = foos 
fewerFoos.remove(at: i)

Alternatively, you can embed these steps into a closure and assign the results to a new constant instead of using a variable:

let fewerFoos: Array<Foo> = {
    var copy = $0
    copy.remove(at: i)
    return copy
}(foos)

By introducing with, you streamline this update, creating a simple version that drops the explicit return and brings the modified item to the front of the call:

let fewerFoos: Array<Foo> = with(foos) {
    $0.remove(at: i)
}

The Person example shown earlier in this proposal streamlines down to:

let jane = with(john) { $0.name = "Jane" }

Although these examples use closures with $0, developers may use named parameters or pass a named function instead of a closure.

SE-0003 and Shadowing

The copy-mutate-and-return pattern is used widely both in the standard library and in third party code as a result of the acceptance of SE-0003. Many functions now shadow previous var parameters in order to mutate them, as in this RangeReplaceableCollection code:

public func +<
  RRC1 : RangeReplaceableCollection,
  RRC2 : RangeReplaceableCollection
  where RRC1.Iterator.Element == RRC2.Iterator.Element
>(lhs: RRC1, rhs: RRC2) -> RRC1 {
  var lhs = lhs
  // FIXME: what if lhs is a reference type?  This will mutate it.
  lhs.reserveCapacity(lhs.count + numericCast(rhs.count))
  lhs.append(contentsOf: rhs)
  return lhs
}

Our proposed rewrite looks like this:

public func +<
  RRC1 : RangeReplaceableCollection,
  RRC2 : RangeReplaceableCollection
  where RRC1.Iterator.Element == RRC2.Iterator.Element
>(lhs: RRC1, rhs: RRC2) -> RRC1 {
  // FIXME: what if lhs is a reference type?  This will mutate it.
  return with(lhs) {
    $0.reserveCapacity($0.count + numericCast(rhs.count))
    $0.append(contentsOf: rhs)
  }
}

The FIXME comment in raises an important issue about reference types. In this example, as with our proposed solution, you must take care with reference types. Swift does not offer a native copy for reference types. Using copy-and-mutate changes the original when used with references.

Mutation isn't an issue when limiting with to NSObject object set-up or modifying an NSObject instance created by copying. When using native Swift, you must supply your own class-based copying or you may mutate the passed instance.

Inspecting an Intermediate Value

Suppose you want to inspect a value in the middle of a long method chain You're not sure this is retrieving the type of cell you expect:

let view = tableView.cellForRow(at: indexPath)?.contentView.withTag(42)

To log the cell, you must split the statement in two:

let cell = tableView.cellForRow(at: indexPath)
print("Got cell \(cell)")
let view = cell?.contentView.withTag(42)

Or use a closure workaround:

let cell = tableView.cellForRow(at: indexPath)
print("Got cell \(cell)")
let view = ({ 
    print("Got cell \($1)")
    return $1    
}()?.contentView.withTag(42)

Our proposed solution simplifies this to the following parsimonious and chainable solution:

let view = with(tableView.cellForRow(at: indexPath)){ print($0) }?.contentView.withTag(42)

This updated version is more fluent and Swift-like than the previous workarounds.

Cocoa Initialization

After creating a Cocoa instance, you must often set several properties on it. Writing this in the most obvious, straightforward style results in a lot of repetition and visual constipation.

Using with streamlines the questionLabel example from earlier in this proposal to:

let questionLabel = with(UILabel()){
    $0.textAlignment = .Center
    $0.font = UIFont(name: "DnealianManuscript", size: 72)
    $0.text = questionText
    $0.numberOfLines = 0
    mainView.addSubview($0)
}

Proposed Solution

This proposal introduces with, a function that accepts a value of T and a closure of (inout T) -> Void. with assigns the value to a new variable, passes that variable as a parameter to the closure, and then returns the potentially modified variable. That means:

  • When used with value types, the closure can modify a copy of the original value.
  • When used with reference types, the closure can substitute a different instance for the original, perhaps by calling copy() or some non-Cocoa equivalent.

The closure does not actually have to modify the parameter; it can merely use it, or (for a reference type) modify the object without changing the reference.

Detailed Design

This proposal adds with(_:update:) to the standard library as an ordinary free function:

@discardableResult
public func with<T>(_ item: T, update: @noescape (inout T) throws -> Void) rethrows -> T {
    var this = item
    try update(&this)
    return this
}

@discardableResult permits the use of with(_:update:) to create a scoped temporary copy of the value with a shorter name.

Impact on Existing Code

This proposal is purely additive and has no impact on existing code.

Alternatives Considered

Doing nothing: with is a mere convenience; anything written with it could be written another way. If rejected, users could continue to write code using the longhand form, the various closure-based techniques, or homegrown versions of with.

Using method syntax: Some list members preferred a syntax that looked more like a method call with a trailing closure:

let questionLabel = UILabel().with {
    $0.textAlignment = .Center
    $0.font = UIFont(name: "DnealianManuscript", size: 72)
    $0.numberOfLines = 0
    addSubview($0)
}

This would require a more drastic solution as it's not possible to add methods to all Swift types. Nor does it match the existing design of functions like withExtendedLifetime(_:_:), withUnsafePointer(_:_:), and reflect(_:).

Adding self rebinding: Some list members wanted a way to bind self to the passed argument, so that they can use implicit self to eliminate $0.:

let supView = self
let questionLabel = with(UILabel()) { 
    self in
    textAlignment = .Center
    font = UIFont(name: "DnealianManuscript", size: 72)
    numberOfLines = 0
    supView.addSubview(self)
}

We do not believe this is practical to propose in the Swift 3 timeframe, and we believe with would work well with this feature if it were added later.

Adding method cascades: A competing proposal was to introduce a way to use several methods or properties on the same instance; Dart and Smalltalk have features of this kind.

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

Like rebinding self, we do not believe method cascades are practical for the Swift 3 timeframe. We also believe that many of with's use cases would not be subsumed by method cascades even if they were added.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment