pointfreeco / swift-case-paths

🧰 Case paths extends the key path hierarchy to enum cases.
https://www.pointfree.co/collections/enums-and-structs/case-paths
MIT License
921 stars 108 forks source link

Add AssociatedValueAccessible #62

Closed rock88 closed 2 years ago

rock88 commented 2 years ago

Hi!

I just added AssociatedValueAccessible protocol which allow more easily and convenience methods for get/set values over subscript with CasePath.

Example:

enum Foo: AssociatedValueAccessible {
   case bar(Int)
}

let foo = Foo.bar(42)
let value = foo[/Foo.bar] // Optional<Int>(42)

var foo = Foo.bar(42)
foo[/Foo.bar] = 84
let value = foo[/Foo.bar] // Optional<Int>(84)
stephencelis commented 2 years ago

Thanks for taking the time to open a PR, and sorry for the delayed response!

We definitely see the appeal of such a shorthand, but unfortunately, because of current limitation in Swift's accessors, it introduces nil writability, which is a bit confusing.

foo[/Foo.bar] = nil // no-op

What we'd really want is behavior like optional chaining, where the getter is optional and the setter is non-optional:

struct Foo { var bar: Int }

var foo: Foo? = .init(bar: 123)
foo?.bar = 42  // ✅
foo?.bar = nil // 🛑 forbidden by the compiler

But this just isn't possible in Swift today. 😔 Because of this, for the moment I think we want to avoid introducing such an API to the library. We're definitely open to further discussion, though. If you're interested, please open a GitHub discussion where others can weigh in and offer their suggestions.

iampatbrown commented 2 years ago

@stephencelis maybe something like this could work

extension AssociatedValueAccessible {
  public subscript<Value>(casePath: CasePath<Self, Value>) -> Value? {
    casePath.extract(from: self)
  }

  @_disfavoredOverload
  public subscript<Value>(casePath: CasePath<Self, Value>) -> Value {
    @available(*, unavailable, message: "only available as optional getter")
    get { fatalError() }
    set { self = casePath.embed(newValue) }
  }
}

var foo = Foo.bar(123)
foo[/Foo.bar] = 42 // ✅
foo[/Foo.bar] = nil // 🛑  forbidden by the compiler
let bar: Int = foo[/Foo.bar] // 🛑  forbidden by the compiler

I think this might be better suited for OptionalPaths though, which should allow setting nested values.

I'm not sure if the above is a good idea. More just sharing in case others wanted to implement themselves.

iampatbrown commented 2 years ago

I just realised you might be able to use _modify to allow setting optionally chained values

extension AssociatedValueAccessible {
  public subscript<Value>(casePath: CasePath<Self, Value>) -> Value? {
    get { casePath.extract(from: self) }
    _modify {
      var value = casePath.extract(from: self)
      yield &value
      guard let value = value else { return }
      self = casePath.embed(value)
    }
    @available(*, unavailable, message: "only available as non-optional setter")
    set { fatalError() }
  }

  public subscript<Value>(casePath: CasePath<Self, Value>) -> Value {
    @available(*, unavailable, message: "only available as optional getter")
    get { fatalError() }
    set { self = casePath.embed(newValue) }
  }
}

Edit: This might not be viable because of the ambiguous getter

iampatbrown commented 2 years ago

okay... the experiment may have gotten slightly out of hand... This seems to keep the compiler relatively happy...

public protocol CaseAccessible {}

extension CaseAccessible {
  public subscript<Value>(casePath: CasePath<Self, Value>) -> Value? {
    casePath.extract(from: self)
  }

  @_disfavoredOverload
  public subscript<Value>(casePath: CasePath<Self, Value>) -> Never? {
    @available(*, unavailable, message: "only available as optional getter")
    get { fatalError() }
    @available(*, unavailable, message: "only available as non-optional setter")
    set { fatalError() }
  }

  @_disfavoredOverload
  public subscript<Value>(casePath: CasePath<Self, Value>?) -> Value? {
    get { casePath?.extract(from: self) }
    set { try? casePath?.modify(&self) { $0 = newValue ?? $0 } }
  }

  @_disfavoredOverload
  public subscript<Value>(casePath: CasePath<Self, Value>) -> Value {
    @available(*, unavailable, message: "only available as optional getter")
    get { fatalError() }
    set { self = casePath.embed(newValue) }
  }
}

Which allows the following:

struct Baz: Equatable { var array: [Int] = [1, 2, 3], string: String = "Blob" }
indirect enum Foo: CaseAccessible {
  case foo(Foo)
  case bar(Int)
  case baz(Baz)
}

var foo = Foo.bar(42)
foo[/Foo.foo] = .baz(Baz()) // foo == Foo.foo(.baz(Baz())
foo[/Foo.baz]?.string = "Blobby" // no-op
foo[/Foo.foo .. /Foo.bar] = 42 // foo == Foo.foo(.bar(42))
foo[/Foo.baz] = Baz()  // foo == Foo.baz(Baz())
foo[/Foo.foo]?[/Foo.baz] = Baz() // no-op
foo[/Foo.foo .. /Foo.baz] = Baz() // foo == Foo.foo(.baz(Baz())
foo[/Foo.foo]?[/Foo.baz]?.array = [3, 2, 1] // foo == Foo.foo(.baz(Baz(array: [3, 2, 1]))

foo[/Foo.foo] = nil // 🛑 forbidden by the compiler
foo[/Foo.foo]?[/Foo.baz] = nil // 🛑 forbidden by the compiler
foo[/Foo.foo .. /Foo.baz] = nil // 🛑 forbidden by the compiler
foo[/Foo.foo] = .none // 🛑 forbidden by the compiler
foo[/Foo.foo] = Optional<Foo>.none  //❗️ work around no-op

I don't really think this should be used. Just exploring what's possible.

@stephencelis is this your most recent proposal for derived properties? I might start digging into the compiler a bit and see what I can learn. nonoptional set seems like a sensible first step.

stephencelis commented 2 years ago

@iampatbrown Sorry! Thought I responded, but that proposal is very old :) I don't know if it captures the nuance around nonoptional set.

@rock88 I'm going to close this out for now, as the optional writability requires us to make a choice that we're not sure the main library should have an opinion on. If you release a companion library that introduces this protocol, please do share it with us in the GitHub discussions!