Add `modify…(…)` methods to `Dictionary` / `MutableCollection` / `Optional`

Add modify…(…) methods to Dictionary / MutableCollection / Optional

Full proposal text | Preliminary implementation

This proposal adds the following APIs to the Swift standard library:

  • Optional:
    • modifyIfNotNil(_:)
  • Dictionary:
    • modifyValue(forKey:_:)
    • modifyValue(forKey:default:_:)
  • MutableCollection:
    • modifyElement(at:_:)

The methods provide a no-surprises API for efficiently modifying a collection's specific value, preventing the user from accidentally triggering unwanted copy-on-write semantics, reducing the number of required key-lookups, while also making the user's intention clearer (i.e. "modify a value" vs. "get/remove a value, then insert a modified value").

Before

// only update optional's wrapped value, if not nil:
if var modifiedValue = valueOrNil {
    // ...
    valueOrNil = modifiedValue
}

// only update a dictionary's value if its keys exists:
if var modifiedValue = dictionary[key] {
    // ...
    dictionary[key] = modifiedValue
}

// update a dictionary's value, inserting a default value if its key doesn't yet exist:
var modifiedValue = dictionary[key] ?? …
// ...
dictionary[key] = modifiedValue

// update an array's element:
var modifiedElement = array[index]
// ...
array[index] = modifiedElement

After

// only update optional's wrapped value, if not nil:
valueOrNil.modifyIfNotNil { value in
    // ...
}

// only update a dictionary's value if its keys exists:
dictionary.modifyValue(forKey: key) { value in
    // ...
}

// update a dictionary's value, inserting a default value if its key doesn't yet exist:
dictionary.modifyValue(forKey: key, default: …) { value in
    // ...
}

// update an array's element:
array.modifyElement(at: index) { value in
    // ...
}

:warning: Note: The authors are well-aware of the modify subscript accessor and its short-circuiting capabilities, which cover 80% of the use-cases. This proposal aims to fill the remaining 20% by providing an API that covers all 100%.

Usage

To use this library in a Swift Package Manager project,
add the following to your Package.swift file's dependencies:

.package(
    url: "https://github.com/regexident/swift-evolution-modify-value.git",
    .branch("master")
),

Full proposal text | Preliminary implementation

Thanks to @lorentey @anandabits, @DevAndArtist and @max_desiatov and everybody else who made helpful editorial suggestions. :pray:t2:

Looking forward to reading your feedback on this draft proposal!

9 Likes

I didn't do anything though :smiley:

We already have a much more concise spelling for this on both Optional and Dictionary:

var a: Int? = nil
var b: Int? = 1

a? = 2      // Does nothing because a is nil
b? = 3      // Sets b because b is not nil

print(a)    // nil
print(b)    // Optional(3)
var pets = ["cat": 3]

pets["cat"]? += 2    // Adds 2
pets["dog"]? += 1    // Does nothing

print(pets)          // ["cat": 5]

I’m not sure what you want the MutableCollection version to do, but you can already call mutating methods directly on subscripts thereof:

var list = [false]
list[0].toggle()
print(list)      // [true]
17 Likes

@Nevin thanks for your quick feedback! While agreeing with you in general terms the proposal goes into detail why we think the existing subscript API is not sufficient for many needs and may sometimes even lead developers astray, resulting in sub-optimal performance.

If your mutation is complex, then extract it into a mutating method on the element type. That will make the call-site shorter, cleaner, and easier to understand:

extension Foo {
  mutating func frobnicate() {...}
}

var myFoos: MyMutableCollection<Foo> = ...

myFoos[startIndex].frobnicate()
1 Like

That might work sometimes, but adding extensions to types that one does not own is fragile.

Additionally adding a corresponding closure's logic as a method to the element type may end up polluting that type's API with methods that are highly localized to one specific use-case and useless anywhere else. With explicit methods on element one would also have to pass any variables accessed within the closure as an argument, which may make the code in question hard to read. Lots of reasons why the supposedly cleanest solution might not always be desirable, let alone applicable.

Again, agree in principle. :slight_smile:

But most of the time one isn't programming in a vacuum and is subject to external constraints

In addition to that this proposal also aims to provide a no-surprises API with consistent semantics (value vs class), that the current subscript API simply does not provide (as discussed in the proposal).

I’m ambivalent about the proposed API however I would like to say I had no idea you could use optional chaining like this. When did this become possible?

12 Likes

This seems like a very sensible addition. It uses a familiar idiom to add an operation that may not see everyday use, but is definitely in the category of "it ought to be possible".

One question. I see that the methods and their arguments are rethrows/throws respectively, but the semantics of throwing don't seem to be specified. As with _modify accessors that seems both important and potentially intricate.

Relatedly, the question "Can I cancel the operation?" also occurred to me while reading.

I assume that throwing is how one cancels the modification. If so, this should be added to the docs. And more, I think the exact semantics should be written out -- i.e., if I throw after assigning to the closure's argument, does that abort the write back to the collection? (Presumably not.)

2 Likes

Having previously made a case for this in a related area (the desire to have a modifying loop), I think this is a good idea. I suspect however that you’ll get pushback asking how this can be made into a language feature rather than solving it with library code.

For my part I think we should solve it with library code first and then worry about language features second because library code is (comparatively) easy and language features are hard, but I’m sure someone will push back on that idea.

4 Likes

The preferable option would of course be to have something more generalized — like Rust's &mut — in Swift. However since we don't have a public concrete timeline for when (if at all) such a non-trivial feature might land in Swift this proposal aims to thus solve a problem in today's Swift with what we have at our disposal today.

Introducing a language feature as a library implementation (experimental and unstable first, then stabilized, and eventually deprecated) first, before turning it into a true language feature also has worked great for Rust so far, so I would consider this a net win, not a net loss.

Especially when combined with the recently introduced preview standard library (which Rust has had from the beginning via release channels and benefited greatly from) this allows for experimentation without putting burden on the compiler team or introducing changes to the compiler that would have to be reverted in case the feature is deemed unfavorable. The important part is to not immediately stabilize a feature when initially released. But that's the whole point of introducing the preview standard library anyway.

Examples from Rust

The ?-operator (language) began its live as the try!-macro (library) and has since been generalized with the trait Try, which allows custom types to support ?, too. (The try! macro has since been deprecated afaik.)

  • before: let value = try!(fallible_fn());
  • after: let value = fallible_fn()?;

The async keyword (language) began its live as the #[async]-macro-attribute (library), combined with -> Future<…>. (The #[async]-macro-attribute has since been deprecated afaik.)

  • before: #[async] fn foo() -> Future<T> { … }
  • after: async fn foo() -> T { … }

The await-keyword (language) began its life as the await!()-macro (library). (The await!()-macro has since been deprecated afaik.)

  • before: await!(async_fn());
  • after: await async_fn();

The examples here all went the route of "macro" -> "language feature", not "plain-old boilerplate API" -> "language feature". However it is important to keep in mind that macros are merely a convenient syntactical way to hide the uglyness of "plain-old boilerplate API", but semantically they are the same.

2 Likes

I'm not disagreeing, only preflighting some pushback you may well receive (because I've received it on similar proposals in the past).

I see the motivation, I agree that it should ultimately be a language feature, and would love to have it in the preview package.

I would have issues with it if it’s moved to the standard library though, as even if deprecated at some point it will need to be kept there for stability concerns

1 Like

The preview package isn't an "extras" package; anything vended in that package is slated for inclusion into the standard library.

1 Like

Not even if what is in the preview package is slated for inclusion but in a different form? I suppose it's not the point though...
Then I change my position and say I'd be okay for it to be in a separate package :)

Well this is a TIL moment and very cool - would be nice if it worked on arrays too though to prevent OOB errors!

Another big blind spot of the "solution" to "extract it into a mutating method on the element type" is tuples: one simply cannot implement custom methods for them. Yet there are commonly used as collection elements in Swift.

The sample implementation provided with this proposal simply wraps the existing subscript operators and thus inherits their semantics:

@inlinable
@inline(__always)
public mutating func modifyValue(
    forKey key: Key,
    default defaultValue: @autoclosure () -> Value,
    _ modifications: (inout Value) throws -> Void
) rethrows {
    try modifications(&self[key, default: defaultValue()])
}

@inlinable
@inline(__always)
public mutating func modifyValue(
    forKey key: Key,
    _ modifications: (inout Value?) throws -> Void
) rethrows {
    try modifications(&self[key])
}

The only semantic difference to the direct use of bare-bones subscript is that by internally passing the value to the closure as inout the mutation gets recognized for all values, regardless of whether it's a value type or not.

The ownership manifesto does discuss the idea of a local inout binding, which I think achieves what you want here (ad hoc mutations) without requiring every interface to be extended.

5 Likes

I of course would greatly prefer a universal solution over additional APIs.

Terms of Service

Privacy Policy

Cookie Policy