Proposal sanity check: assigning a case statement to a boolean

Well, if it produces a binding, yes, the bound name has to have a scope. But it could also produce a result, which we could tuple together appropriately and wrap in an optional, and then the user can do whatever they like with that value. The typing rule would be very much tied to the form of the pattern, but I think that's what you'd expect.

Ideally the "regexp" syntax would have the same dual-use properties: if you used it in a case, you could bind names directly to certain matches, and otherwise you would get the matches back tupled up and wrapped in an optional.

4 Likes

Personally I quite dislike that backwards-reading case syntax:

if case .aCase = value {
    print("a case")
}

I'd love to be able to write the more natural:

if value == .aCase {
    print("a case")
}

Then the assignment looks very familiar as well:

let bool = (value == .aCase)

In all of the above, I don't care whether the enum has associated values or not, and the syntax is exactly the same either way.

To retrieve an associated value, I wish I could write something similar to this:

if value == .aCase(let associatedValue) {
    print("a case with \(associatedValue)")
}

Technically you can achieve that in your own code, with something like this:

protocol CaseEquatable {
  associatedtype RawCase: Equatable
  var rawCase: RawCase { get }
}

extension CaseEquatable {
  static func == (lhs: Self, rhs: RawCase) -> Bool {
    return lhs.rawCase == rhs
  }
  
  static func == (lhs: RawCase, rhs: Self) -> Bool {
    return lhs == rhs.rawCase
  }
  
  func isSameCase(as other: Self) -> Bool {
    return self.rawCase == other.rawCase
  }
}

Then for each enum, conform to the protocol:

enum MyEnum {
  case foo(Int)
  case bar(String)
  case baz
}

extension MyEnum: CaseEquatable {
  enum RawCase { case foo, bar, baz }
  
  var rawCase: RawCase {
    switch self {
    case .foo: return .foo
    case .bar: return .bar
    case .baz: return .baz
    }
  }
}

It’s a bit of boilerplate, but it works.

However, to avoid overloading ==, it might make more sense to use a spelling like the pattern-match operator ~=. That would work for the (Self, Self) version as well, so the isSameCase(as:) method could be removed.

1 Like

Thank you that's interesting but in my opinion is not the sort of thing that anyone should have to do. What I have written seems the more simple and natural way of expressing what I want to do here and I wish Swift would allow me to do it.

In the case of an associated value, I think it would be acceptable to force the programmer to express explicitely if he wants to ignore it, for example:

if value == .aCase(_) {
    print("a case")
}

Then you can also compare with a specific associated value:

if value == .aCase("hello") {
    print("a case with hello")
}

Writes and reads naturally. But I'm not a compiler engineer so I may be missing as to why this might be a terrible idea.

Ah that explains it.

@tclementdev wondering what would you think about if value is case .aCase it seems like that suggestion would help it to not read backwards.

This would definitely be very useful (and thank you Nevin for the workaround!) but using a straight == doesn't seem like a good idea, even if you need to explicitly ignore the payload. I mean it's not really equality, the left side is not necessarily equal to the right, it's just similar, it matches a pattern.

So I think a better solution would be something like the ~= that Nevin mentions, or a syntax like value is case .aCase. It would be even more useful if it were an expression though, so it could be used for assignments and more generally, that would be the dream scenario!

In switch statements you can already take .aCase and make it pattern matchable (using an invisible ~=) by prefixing case, so in a way the most natural syntax would be value ~= case .aCase.

2 Likes

(Sorry; just saw this thread today.)

As for the original example, this already works:

let bool = .aCase ~= value

It only stops working when you add in associated values.

I've been using the following ~= for a while, for those. It's pretty great. :smiley_cat: And definitely the right solution because of how switch works, and the existing precedent for no associated values.

Although I think that we shouldn't have to rely on my solution (i.e. a reflection-less implementation of ~= would be nice), I think all of the constituent pieces below should be in the standard library anyway (with better-enforced correctness, though this stuff is fine in practice).

func test_enum_NotEquatable() {
  enum πŸ“§ {
    case tuple(cat: String, hat: String)
    case anotherTuple(cat: String, hat: String)
    case labeled(cake: String)
    case noAssociatedValue
  }

  let tupleCase = πŸ“§.tuple(cat: "🐯", hat: "🧒")
  XCTAssertTrue(πŸ“§.tuple ~= tupleCase)
  XCTAssertFalse(πŸ“§.anotherTuple ~= tupleCase)

  XCTAssertTrue(πŸ“§.noAssociatedValue ~= .noAssociatedValue)
  XCTAssertTrue(πŸ“§.labeled ~= πŸ“§.labeled(cake: "🍰"))

  let makeTupleCase = πŸ“§.tuple
  XCTAssertFalse(makeTupleCase ~= πŸ“§.noAssociatedValue)

  switch tupleCase {
  case πŸ“§.labeled: XCTFail()
  case makeTupleCase: break
  default: XCTFail()
  }
}
func test_enum_Equatable() {
  enum πŸ“§: Equatable {
    case tuple(cat: String, hat: String)
    case anotherTuple(cat: String, hat: String)
    case labeled(cake: String)
    case noAssociatedValue
  }

  let tupleCase = πŸ“§.tuple(cat: "🐯", hat: "🧒")
  XCTAssertTrue(πŸ“§.tuple ~= tupleCase)
  XCTAssertFalse(πŸ“§.anotherTuple ~= tupleCase)

  XCTAssertTrue(πŸ“§.labeled ~= πŸ“§.labeled(cake: "🍰"))

  let makeTupleCase = πŸ“§.tuple
  XCTAssertFalse(makeTupleCase ~= πŸ“§.noAssociatedValue)

  switch tupleCase {
  case πŸ“§.labeled: XCTFail()
  case makeTupleCase: break
  default: XCTFail()
  }
}
/// Match `enum` cases with associated values, while disregarding the values themselves.
/// - Parameter case: Looks like `Enum.case`.
public func ~= <Enum: Equatable, AssociatedValue>(
  case: (AssociatedValue) -> Enum,
  instance: Enum
) -> Bool {
  Mirror.associatedValue(of: instance, ifCase: `case`) != nil
}

/// Match `enum` cases with associated values, while disregarding the values themselves.
/// - Parameter case: Looks like `Enum.case`.
public func ~= <Enum, AssociatedValue>(
  case: (AssociatedValue) -> Enum,
  instance: Enum
) -> Bool {
  Mirror.associatedValue(of: instance, ifCase: `case`) != nil
}

/// Match non-`Equatable` `enum` cases without associated values.
public func ~= <Enum>(pattern: Enum, instance: Enum) -> Bool {
  guard (
    [pattern, instance].allSatisfy {
      let mirror = Mirror(reflecting: $0)
      return
        mirror.displayStyle == .enum
        && mirror.children.isEmpty
    }
  ) else { return false }

  return .equate(pattern, to: instance) { "\($0)" }
}
public extension Mirror {
  /// Get an `enum` case's `associatedValue`.
  static func associatedValue<AssociatedValue>(
    of subject: Any,
    _: AssociatedValue.Type = AssociatedValue.self
  ) -> AssociatedValue? {
    guard let childValue = Self(reflecting: subject).children.first?.value
    else { return nil }

    if let associatedValue = childValue as? AssociatedValue {
      return associatedValue
    }

    let labeledAssociatedValue = Self(reflecting: childValue).children.first
    return labeledAssociatedValue?.value as? AssociatedValue
  }

  /// Get an `enum` case's `associatedValue`.
  /// - Parameter case: Looks like `Enum.case`.
  static func associatedValue<Enum: Equatable, AssociatedValue>(
    of instance: Enum,
    ifCase case: (AssociatedValue) throws -> Enum
  ) rethrows -> AssociatedValue? {
    try associatedValue(of: instance)
      .filter { try `case`($0) == instance }
  }

  /// Get an `enum` case's `associatedValue`.
  /// - Parameter case: Looks like `Enum.case`.
  static func associatedValue<Enum, AssociatedValue>(
    of instance: Enum,
    ifCase case: (AssociatedValue) throws -> Enum
  ) rethrows -> AssociatedValue? {
    try associatedValue(of: instance).filter {
      .equate(try `case`($0), to: instance) {
        Self(reflecting: $0).children.first?.label
      }
    }
  }
}
public extension Optional {
  /// Transform `.some` into `.none`, if a condition fails.
  /// - Parameters:
  ///   - isSome: The condition that will result in `nil`, when evaluated to `false`.
  func filter(_ isSome: (Wrapped) throws -> Bool) rethrows -> Self {
    try flatMap { try isSome($0) ? $0 : nil }
  }
}
public extension Equatable {
  /// Equate two values using a closure.
  static func equate<Wrapped, Equatable: Swift.Equatable>(
    _ optional0: Wrapped?, to optional1: Wrapped?,
    using transform: (Wrapped) throws -> Equatable
  ) rethrows -> Bool {
    try optional0.map(transform) == optional1.map(transform)
  }
}
public extension Sequence {
  typealias Tuple2 = (Element, Element)

  var tuple2: Tuple2? { makeTuple2()?.tuple }

  private func makeTuple2() -> (
    tuple: Tuple2,
    getNext: () -> Element?
  )? {
    var iterator = makeIterator()
    let getNext = { iterator.next() }

    guard
      let _0 = getNext(),
      let _1 = getNext()
    else { return nil }

    return ( (_0, _1), getNext )
  }
2 Likes

@tclementdev wondering what would you think about if value is case .aCase it seems like that suggestion would help it to not read backwards.

It's better but seems overly verbose compared to if value == .aCase(_). Also I find it really distrubing that it's so different to what you would write when there is no associated value: if value is case .aCase vs. if value == .aCase

if value == .aCase(_) has the added benefit of clearly showing that the case has an associated value but it is being ignored. With if value is case .aCase you wouldn't know by just reading this whether there is an associated value being ignored or not.

I have the same complaint here. It seems overly too different to what would be written when there is no associated value, and does not convey whether an associated value exists and is being ignored.

+100 for is case!

It feels very Swifty, and I think there would be a lot of forward transfer from switch...

I would use this all the time! Especially if we allow let result = value is case .aCase(_) to match regardless of the associated value.

Honestly, I would see myself favoring this form for if statements when I didn't need to bind the value. It just reads so much better.

if value is case .aCase(_) {
    ///Do stuff
}

To be honest, I’d love to go back in time and propose if value is case .aCase(let payload) instead of the current if case let syntax – I think it would be more understandable to people who don’t use it regularly, and it would work nicer with autocompletion.

That said, I don’t think it comes near the threshold for syntax tweaks today.

11 Likes

You generally don't need the payload tuple, i.e. just use the base identifier, if there's no sharing of case base names. And, AFAIK, sharing of case base names hasn't been activated yet.

I think I would still want to use the .aCase(_) version (as opposed to just saying .aCase) because:

  • It matches switch syntax
  • It allows you to test against specific values for the payload if you want

Switch syntax allows the use of both, so it seems like this usage should too.

1 Like

I didn't realize you could now leave off the (_) to match with the base in switch statements.

I agree that it should match whatever the switch/case allows...

It would be a syntax completion win as well, if we allowed the more natural order. Though I do appreciate that XCode will now actually complete even

if case . = self { }

if you place the cursor after . and start typing, thank you XCode team! I think this is new in XCode 12?

I believe that using the pattern matching operator ~= could be the right way forward especially since it pertains to switch statements already.

It seems like a bug to me that this compiles,

enum Enumeration {
  case a
}

let a = Enumeration.a
let bool = a ~= Enumeration.a

and this doesn't

enum Enumeration {
  case a
  case b(associatedValue: Int)
}

let a = Enumeration.a
let bool = a ~= Enumeration.a

it causes a compilation error. That would be as follows.

Referencing operator function '~=' on 'RangeExpression' requires that 'Enumeration' conform to 'RangeExpression'

1 Like
  • Your first example compiles, because enums without associated values implicitly conform to Equatable.

  • Your second example will compile, if the conformance is synthesized by the compiler
    (SE-0185 or SE-0266).

  • However, using Enumeration.b without its associated value won't compile, because it's a function type.

enum Enumeration: Equatable { // or Hashable or Comparable
  case a
  case b(associatedValue: Int)
}

let a = Enumeration.a
(a ~= Enumeration.a) //-> true

let b1 = Enumeration.b(associatedValue: 1)
let b2 = Enumeration.b(associatedValue: 2)
(b1 ~= b1) //-> true
(b1 ~= b2) //-> false

(b1 ~= Enumeration.b)
// error: Referencing operator function '~=' on 'RangeExpression'
//        requires that 'Enumeration' conform to 'RangeExpression'

(Enumeration.b ~= Enumeration.b)
// error: Type '(Int) -> Enumeration' cannot conform to 'Equatable';
//        only struct/enum/class types can conform to protocols
4 Likes

Thank you for elucidating this behavior @benrimmington. I was under the mistaken impression that the operator ~= was used for all switch pattern matching instead of just for ranges on enums.

In fact, switching on an enum value does not appear to even call an overloaded ~= operator implementation except to match ranges (i.e. func ~= (range: ClosedRange<Int>, value: Enumeration) -> Bool.)

Considering the current uses of the ~= operator to compare enum cases with associated values as functions, a new syntax, e.g. is case, would be better to not break existing source.

It might be possible to use another ~= function, if we had:

  • keypaths for enum cases (because AnyKeyPath conforms to Equatable);

  • some way (e.g. protocol or reflection) to get a keypath from an enum instance.

Terms of Service

Privacy Policy

Cookie Policy