Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SR-5820] Optional-chained KeyPath not Writable #48390

Closed
stephencelis opened this issue Sep 1, 2017 · 15 comments
Closed

[SR-5820] Optional-chained KeyPath not Writable #48390

stephencelis opened this issue Sep 1, 2017 · 15 comments
Assignees
Labels
bug A deviation from expected or documented behavior. Also: expected but undesirable behavior. compiler The Swift compiler in itself

Comments

@stephencelis
Copy link
Contributor

Previous ID SR-5820
Radar None
Original Reporter @stephencelis
Type Bug
Status Resolved
Resolution Won't Do
Environment

Xcode 9 beta 6

Additional Detail from JIRA
Votes 1
Component/s Compiler
Labels Bug
Assignee @stephencelis
Priority Medium

md5: d5bab50f4bbe008d51698b5316bf271d

Issue Description:

The following code dumps a KeyPath when I'd expect it to dump a WritableKeyPath:

struct Book {
  var name: String
}
struct User {
  var favoriteBook: Book?
}
dump(
  \User.favoriteBook?.name
)

Traditional optional chaining allows for writability, so I'd expect the same to be true of optional chain-derived key paths.

@belkadan
Copy link
Contributor

belkadan commented Sep 5, 2017

cc @jckarter

@jckarter
Copy link
Member

jckarter commented Sep 6, 2017

This is by design. You can't write back through optional chains, since there's nowhere to write to if the element is nil.

@stephencelis
Copy link
Contributor Author

@jckarter Can you elaborate? Wouldn't the writing semantics be the same as general optional chaining?

@jckarter
Copy link
Member

jckarter commented Sep 6, 2017

Writing through an optional chain puts the assignment inside the chain, so that it only happens when the chain succeeds, and you can only assign a non-optional back into the chain. You can't represent that with a key path since it evaluates the LHS when the object is formed, so you'd end up doing (x?.foo) = 1 rather than x?(.foo = 1).

@stephencelis
Copy link
Contributor Author

Ah, that makes more sense, thanks! Would the ability to destructure a key path into its smaller components (an idea mentioned in https://bugs.swift.org/browse/SR-5821) allow for this writability?

@jckarter
Copy link
Member

jckarter commented Sep 6, 2017

Fundamentally I think it'd require the KeyPath interface to be generalized a bit to allow prism-like traversals instead of only lens-like. If the "reading" and "writing" directions didn't need to have the same type, then you could have a key path project Aggregate -> Component? for reading, but then take Component -> Aggregate for writing.

@bobergj
Copy link

bobergj commented Oct 6, 2017

> You can't represent that with a key path since it evaluates the LHS when the object is formed, so you'd end up doing (x?.foo) = 1 rather than x?(.foo = 1).

Sorry I don't understand this part of the explanation, would you mind to elaborate? Also, is the issue with the public KeyPath API as designed, or is it an implementation detail? If the public API design is problematic, is a improvement proposal in scope for Swift 5, since I guess it affects the ABI?

I understand that there is nowhere to write if the element is nil. But just like Objective-C Key-Value-Coding for keys-paths, I would expect the write to simply not happen.

@interface ObjectB : NSObject
@property (nonatomic, nullable) NSString *string;
@end

@interface ObjectA : NSObject
@property (nonatomic, nullable) ObjectB *b;
@end

@implementation ObjectA
@end

@implementation ObjectB
@end

- (void)test {
   ObjectA *a = [ObjectA new];
   [a setValue:@"foo" forKeyPath:@"b.string"]; // OK, @"foo" went to outer space
}

Of course normally one wouldn't do that, but this can happen when using key paths in a generic context. For example, I envisioned using the type-safe key paths for a input-form binding component that is currently written in Objc using KVC. The form data may have chains of optionals, but when the form field is visible and the property is written to, all values in the optional chain are present.

Without this capability, KeyPaths feel wing-clipped. As a stop-gap measure, even something like

x.setImplicitlyUnwrappedKeyPath(fooKeyPath, 1)

which would behave the same as:

x!.foo = 1

would be of great help.

@jckarter
Copy link
Member

jckarter commented Oct 6, 2017

The limitation is inherent in the language model as it exists today, which doesn't allow for asymmetric mutations. The language expects that, anything you can read a T from, you can write a T to, but that doesn't generally hold for a value that was conditionally projected out of an optional chain, since if the chain short-circuits on nil, there's nowhere to write nil back to. x?.foo = 1 works because it's notionally equivalent to:

if inout x2 = x {
  x2.foo = 1
}

since the assignment is part of the conditional and only happens at all if the chain succeeds. Any improvements to the language I see happening here would be additive as far as ABI is concerned.

@jckarter
Copy link
Member

jckarter commented Oct 6, 2017

Note that you can already compose key path application with ! or ?:

x![keyPath: fooKeyPath] = 1 // crash if x == nil, otherwise assign x!.foo = 1
x?[keyPath: fooKeyPath] = 1 // no-op if x == nil, otherwise assign x!.foo = 1

@swift-ci
Copy link
Collaborator

swift-ci commented Nov 8, 2017

Comment by Rafael Nobre Rocha (JIRA)

This behavior is very unfortunate. As the OP, I would expect to be able to break a Smart KeyPath in smaller components and be able to act on nil intermediate elements (i.e creating default values to proceed or simply aborting the operation). Read-only optional chains strongly limit the general applicability of smart key paths IMHO.

@jckarter
Copy link
Member

jckarter commented Nov 8, 2017

@JadenGeller and Anandabits (JIRA User) suggested some extensions to Optional that could make working with writable key paths more expressive within the current model:

extension Optional {
  // Produce the `default` value if `self` is nil.
  subscript(default value: Wrapped) -> Wrapped {
    get { return self ?? value }
    set { self = newValue }
  }

  // Act like optional chaining on read, while allowing "writes" that drop 
  // the value on the floor if `self` is nil.
  subscript<T>(droppingWritesOnNil path: WritableKeyPath<Wrapped, T>) -> T? {
    get { return self?[keyPath: path] }
    set {
      if let newValue = newValue {
        self?[keyPath: path] = newValue
      }
    }
  }
}

@swift-ci
Copy link
Collaborator

swift-ci commented Nov 8, 2017

Comment by Matthew Johnson (JIRA)

@jckarter I think that's subtly different than what I had in mind but is probably the closest we can get with a subscript. The problem is that it accepts writes of `T?` where optional chaining does not. This could be problematic - I would need to give it more thought before I decide how I feel about it if it's our only option for writing through key paths with optional chains.

What I had in mind was behavior that matches optional chaining exactly and a subscript's getter and setter must work with the same type. If we could express a setter that requires a subtype of the getter we could implement behavior that matches optional chaining precisely. That obviously adds a bunch of complexity that may not carry its weight so it may not be worth it.

Here's an example of how I would expect a couple of cases to behave:

struct Person {
    var name: String
}
struct Company {
    var owner: Person?
}
 
var company = Company(owner: Person(name: "Bob"))
company.owner?.name = nil // Nil cannot be assigned to type `String`
// Intuitively, I would expect Nil cannot be assigned to type `String` here as well
// but the key path has a value of type `String?` so nil is accepted
company[\.owner?.name] = nil 
 
var noOwner = Company()
noOwner.owner?.name = "Bob" // compiles, but write is dropped because `owner` is nil
noOwner[\.owner?.name] = "Bob" // compiles, but write is dropped because `owner` is nil

@jckarter
Copy link
Member

jckarter commented Nov 8, 2017

As I explained previously, it is impossible to exactly replicate the chained-assignment behavior, because assignments in an expression go inside the chain—foo?.x = y is foo?(.x = y)—but a key path expression can only "parenthesize" the left-hand side, so you get at best (foo?.x) = y, which would require a more flexible property model to support.

@swift-ci
Copy link
Collaborator

swift-ci commented Nov 8, 2017

Comment by Matthew Johnson (JIRA)

Sorry, I didn't read the upthread before replying. I'm not sure how I feel about dropping writes unless we also generalize to support prism-like traversals as you mention above. If that's a possibility I think it would be best to wait and see how that plays out.

@swift-ci
Copy link
Collaborator

Comment by Michel Donais (JIRA)

Four years later, I am asking the same question. With SwiftUI proposing to link a Binding to a WritableKeyPath, it's possible to create a Binding by writing it directly in the binding, but it's not possible to send a WritableKeyPath to an optional. Also, without using SwiftUI tools, and using the highly efficient, native, and compiler-verified KeyPaths, I cannot have a writer as soon as there is an optional, unless I force-unwrap it.

Ref: link to my question in the forums : WritableKeyPath to an Optional

@swift-ci swift-ci transferred this issue from apple/swift-issues Apr 25, 2022
This issue was closed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug A deviation from expected or documented behavior. Also: expected but undesirable behavior. compiler The Swift compiler in itself
Projects
None yet
Development

No branches or pull requests

5 participants