[Pitch] Add `span` properties to Optional

Hello all, this is a new proposal to add span and mutableSpan properties to Optional, along with an edit(_:) function similar to UniqueArray.edit(_:). The swift-evolution pull request is at [pitch] Add span properties to Optional by glessard · Pull Request #3489 · swiftlang/swift-evolution · GitHub, and most of its contents follows.

Summary of changes

Adds span and mutableSpan computed properties to Optional. They provide in-place access to the wrapped value as a one-element Span or MutableSpan, or as an empty span when there is no wrapped value. Also adds an edit function that provides access to the storage through the OutputSpan parameter of a closure, allowing a wrapped value to be added, replaced or removed.

Motivation

We would like to extend Optional so that it can vend its storage to API that expects to receive a Span value, without requiring the programmer to explicitly deal with both cases of the Optional.

var array: UniqueArray<Person> = ...
let someone: Optional<Person> = ...
// currently required:
if let someone {
  array.append(someone)
}
// with the proposed API:
array.append(copying: someone.span)

Proposed solution

An Optional<Wrapped> is, in storage terms, something that holds either zero or one instance of Wrapped. We have previously established the span and mutableSpan properties for containers to vend safe access to the storage they own ([SE-0456][SE-0456], [SE-0467][SE-0467]), and therefore we propose to add those same properties to Optional.

The span and mutableSpan properties will provide enhanced ergonomics when using noncopyable types, alongside the ref and mutableRef properties ([SE-0532][SE-0532]). The span properties will serve use cases where existing code is written in terms of contiguous storage, and the empty case doesn't require special handling.

The span property will allow the implementation of BorrowingIteratorAdapter ([SE-0516][SE-0516]) to be expressed entirely in terms of public API, rather than the internal helper it relies on today.

Span and MutableSpan can view an existing wrapped value, but neither can add or remove one. An operation that inspects the current wrapped value and dynamically decides whether to keep, remove or replace it, is complicated to write for noncopyable values. UniqueArray ([SE-0527][SE-0527]) calls that operation edit, and it vends an OutputSpan over its whole capacity. edit's closure can inspect, add and remove elements during an exclusive access. We propose adding the edit function, vending Optional's storage via an OutputSpan with a capacity of one.

Detailed design

extension Optional where Wrapped: ~Copyable & Escapable {
  /// A span over the wrapped value of this instance.
  ///
  /// The span contains a single element when this instance has a wrapped
  /// value, and is empty when this instance is `nil`.
  ///
  /// - Complexity: O(1)
  var span: Span<Wrapped> {
    @_lifetime(borrow self) borrowing get
  }

  /// A mutable span over the wrapped value of this instance.
  ///
  /// The span contains a single element when this instance has a wrapped
  /// value, and is empty when this instance is `nil`.
  ///
  /// - Complexity: O(1)
  var mutableSpan: MutableSpan<Wrapped> {
    @_lifetime(&self) mutating get
  }

  /// Edit this instance through a closure with an output span over its storage.
  ///
  /// This method calls its function argument exactly once, allowing it to
  /// change or remove the wrapped value, or to supply one if this instance
  /// is `nil`. The span it is given has a capacity of one, and initially holds
  /// one element if this instance has a wrapped value, or none if it is `nil`.
  /// The argument is free to remove or add an item; however, it is not
  /// allowed to replace the span or change its capacity. Appending more than
  /// one item is a runtime error.
  ///
  /// When the function argument finishes (whether by returning or throwing an
  /// error) this instance is updated to match the final contents of the output
  /// span: it becomes `nil` if the span was left empty, and it wraps the item
  /// the span holds otherwise.
  ///
  ///     var number: Int? = nil
  ///
  ///     number.edit { n in
  ///       if n.isEmpty { n.append(6) }
  ///     }
  ///     print(number)
  ///     // Prints "Optional(6)"
  ///
  ///     number.edit { n in
  ///       n.removeAll()
  ///     }
  ///     print(number)
  ///     // Prints "nil"
  ///
  /// - Parameter body: A function that edits the wrapped value of this
  ///   instance through an `OutputSpan` argument. This method invokes this
  ///   function exactly once.
  /// - Returns: This method returns the result of its function argument.
  /// - Complexity: Adds O(1) overhead to the complexity of the function
  ///   argument.
  mutating func edit<E: Error, R: ~Copyable>(
    _ body: (inout OutputSpan<Wrapped>) throws(E) -> R
  ) throws(E) -> R
}

These additions are restricted to Wrapped: Escapable because Span, MutableSpan and OutputSpan cannot support nonescapable elements at this time.

6 Likes

These two operations are completely different if Person is ~Copyable. The former consumes the optional and lets you push an owned value into the array whereas the latter assumes that Person is Copyable (which means you can't do it at all if ~C).

That example implies that Person is copyable, because of the UniqueArray API being used. I can change the type name to something with less identity if that will help.

I prefer the if let … spelling over using Span-API for the examples in the motivation.

Optional is currently mostly interacted with using syntax sugar. Dot-syntax is commonly autocompleted to include the ? before the . to access symbols of the wrapped type. This makes uses of Optional-specific API like map already a hindrance for some people when reading Swift code.

Therefore, I do not think adding additional API without a strong motivation is a good idea. Span is a great abstraction when working with multiple elements, but Optional always has zero or one element, so manual unrolling of Span-API just requires a nil check, not code repetition.

1 Like

Yeah, I agree with this.

I think having a span property definitely could be useful in cases where you have a Span-taking API and you want to do a zero-cost pass of an Optional to it in the zero-or-one sense, but the motivating example is something I would never want to see someone write in my codebase for 99% of the constructions like the one presented.

2 Likes

Related to this, an often-unstated source stability issue on any Optional api is that they shadow the api of Optional.Wrapped in implicitly-unwrapped contexts. In the case at hand:

var iuo: [Int]! = [1, 2, 3]
expectEqual(iuo.span.count, 1)    // It's Span<[Int]>.count
expectEqual(iuo.span[0].count, 3) // It's [Int].count
expectEqual(iuo!.span.count, 3)   // It's Span<Int>.count

Implicitly Unwrapped Optionals are generally discouraged, so that seems like a nonissue to me.

Personally, I want Optional to have functionality beyond unwrapping. I wouldn't pitch a Collection (or Container) conformance for it, but we can make it more usable for the cases where we need to interoperate between abstractions. Usability improvements are especially needed when the wrapped type is noncopyable!

2 Likes

I find the if let spelling to be super annoying in non-trivial use, because it forces you to create a binding and a name for something that you ought to be able to just pass through transiently. To take an example from some test code I was writing last week:

if let failure = ItoAny(tst: { cvtf($0, scale: 1) }, ref: {
  (a: SIMD4<Int32>) in SIMD4<Float>(a) * 0x1.0p-1
}) { failures.append(failure) }

why do I need a name for failure? You can spell it as a map instead:

ItoAny(tst: { cvtf($0, scale: 1) }, ref: {
  (a: SIMD4<Int32>) in SIMD4<Float>(a) * 0x1.0p-1
}).map { failures.append($0) }

and some of the functional crowd would tell you this is great, but why am I confusing things with another closure?

I just want to append it (or not). I ended up writing an extension on Array:

failures.append(orDont: ItoAny(tst: { cvtf($0, scale: 1) }, ref: {
  (a: SIMD4<Int32>) in SIMD4<Float>(a) * 0x1.0p-1
}))

and was much, much happier with this. No need for a binding. No need to read it from back to front. Obviously, we don't actually need a Span property to make this work, but it's a nice way to make a lot of other API compose in the same straightforward manner.

2 Likes

I think this example

reads well, as it spells out the nil-or-not behavior with the orDont label.

However, the code matching this pitch is closer to

failures.append(copying: ItoAny(tst: { cvtf($0, scale: 1) }, ref: {
  (a: SIMD4<Int32>) in SIMD4<Float>(a) * 0x1.0p-1
}).span)

which to me is much more difficult to understand as an optional operation. The “No need to read it from back to front.” is also compromised by the .span at the very end.

Compared to a manual nil check, I need to rely on the optimizer to keep the nil case performant. append(copying:) or whatever other consumer of the Span could do non-trivial setup and teardown work that often can be skipped in the nil case. APIs assuming arbitrary-length input via Span often have different priorities in doing fast-path checking than APIs dealing exclusively with 0/1 element Optional values. Advanced optimizations should handle most of those cases, but it requires more manual verification (or hoping) than an if let does.

I don’t deny that this pitch adds features that can be convenient, but this kind of convenience tends to favor writing code over reading code. Optional does not conform to Sequence and thus cannot conveniently plug into many of the Sequence/Collection APIs, and I think the same is true for convenient vending of a Span.

2 Likes