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-11212] Ill-typed patterns are permitted to compile (implicit tupling/untupling) #53611

Closed
typesanitizer opened this issue Jul 25, 2019 · 14 comments
Assignees
Labels
compiler The Swift compiler in itself improvement

Comments

@typesanitizer
Copy link

Previous ID SR-11212
Radar rdar://problem/53684698
Original Reporter @typesanitizer
Type Improvement
Status Resolved
Resolution Done
Additional Detail from JIRA
Votes 0
Component/s Compiler
Labels Improvement
Assignee @typesanitizer
Priority Medium

md5: 9bdf821f2c7b13effd8e86771aa93699

blocks:

  • SR-11160 Inconsistent behavior with exhaustive switching over optionals of tuples

Issue Description:

Ideally, the following code shouldn't compile:

func f() {
  switch Optional<(Int, Int)>((5, 6)) {
  case .some(_, _): ()
  case .none: ()
  }
}

Since it does compile today, and we need to preserve source compatibility, we can't emit an error. However, I think we should emit a warning asking the user to fix it.

There are several cases to consider, as shown in the table below. Checkmarks ✔️ mean that the compiler's behavior is correct*, cross marks ❌ mean that behavior is incorrect. The first emoji is for master, and the second emoji is for GitHub PR #26357, which has a fix for a related issue SR-11160.

*correctness here is w.r.t. compilation pass/failure, not whether "do we emit all the diagnostics that we should".

Pattern\Contents Tupled [.a((1, 2))] Untupled [.a(1, 2)]
Tupled [.a((let x, let y))] Ok ✔️ ✔️ Not ok ❌ ✔️
Tupled-nested [.a(let (x, y))] Ok ✔️ ✔️ Not ok ❌ ✔️
Untupled [.a(let x, let y)] Not ok ❌ ❌ Ok ✔️ ✔️
Ambiguous [.a(let b)] Ok ✔️ ✔️ Not ok ❌ ❌

(Tupled-nested is internally equivalent to Tupled, but I've shown it here for the sake of completeness.)

Q: Are people actually relying on this in the wild?
A: Yes, and it seems like an easy mistake to make. For example, this comment notes that SwiftPM is relying on our incorrect behavior for Pattern=Tupled-nested, Contents=Untupled.

enum Z { case z(Int, Int) }
func f() {
  switch Z.z(1, 2) {
  case .z(let (a, b)): print(a, b)
  }
}
@belkadan
Copy link
Contributor

SwiftPM's code is the correct version, isn't it?

@typesanitizer
Copy link
Author

I don't think so? It says:

public enum ModuleMapError: Swift.Error {
    case unsupportedIncludeLayoutForModule(String, UnsupportedIncludeLayoutType)
}
    switch self {
    // Ought to be .unsupportedIncludeLayoutForModule(let name, let problem)
    case .unsupportedIncludeLayoutForModule(let (name, problem)):
        return "target '\(name)' failed modulemap generation; \(problem)"
    }

@belkadan
Copy link
Contributor

Oh, whoops, I misread your z. Looks like we have to preserve that behavior too, then.

@belkadan
Copy link
Contributor

What about case .unsupportedIncludeLayoutForModule(let theWholeThing). Ugh.

cc CodaFi (JIRA User)

@typesanitizer
Copy link
Author

That continues to compile. I will add it to the issue description. I've added a full table concerning all the cases for ease of understanding.

@typesanitizer
Copy link
Author

@swift-ci create

@typesanitizer
Copy link
Author

Test cases merged with PR #26397.

@rnapier
Copy link

rnapier commented Feb 26, 2020

For future readers, can you explain why the above code shouldn't compile? It seems fine. Given an optional tuple, there's a .some case and a .none case. This might be related to the problem I'm currently facing (SR-12278), but I don't understand the problem it was resolving.

@typesanitizer
Copy link
Author

The above code shouldn't compile because there is a difference between an enum case with 1 associated value with N elements (in this case that is `.some` with N = 2), and an enum case with N associated values (which corresponds to the pattern `.some(_, _)` with N = 2).

I've added a longer explanation with more context in my comment on SR-12278.

@swift-ci
Copy link
Collaborator

Comment by Matthew Johnson (JIRA)

If we're committed to supporting this I don't think it should produce a warning. I didn't know this works, but implicit packing of associated values into a tuple could be very useful in some cases - it currently requires a bit of boilerplate. I see it roughly in the same vane as allowing multi-argument functions to be used where a single tuple-argument function is required.

@typesanitizer
Copy link
Author

> If we're committed to supporting this

We aren't committed to supporting it. Since this is a bug that we noticed that many people are relying on, we made it a warning instead of an error.

I'm assuming you read my longer comment in SR-12278: please let me know if there's a concern that I failed to address there.

@swift-ci
Copy link
Collaborator

Comment by Matthew Johnson (JIRA)

I just read the longer comment

patterns should behave analogous to function arguments – there is a difference between an enum case which has 1 associated value which is a tuple of N elements and an enum case which has N associated values.

There is also a difference between a function with one argument which is a tuple of n elements and a function with n arguments but we allow the latter to be compatible with the former, for good ergonomic reasons.

I'm not a compiler expert and if it truly cannot be made to work reliably then of course it can't be supported. But at least on the surface it looks like something that could in theory be made to work. I looked at SR-11160 and don't see why there is an inherent problem with making that work.

@typesanitizer
Copy link
Author

There is also a difference between a function with one argument which is a tuple of n elements and a function with n arguments but we allow the latter to be compatible with the former, for good ergonomic reasons.

The compatibility there, to my understanding, is limited to call expressions as per SE-0110. It's much more limited in scope compared to what is being asked here.

I'm not a compiler expert and if it truly cannot be made to work reliably then of course it can't be supported.

Few things are super cut-and-dry 🙂.

I apologize if this is too long of an answer but here's how I think about it.

Part 1: Pathological examples

One can certainly create pathological examples such as

enum X {
  case x(Int, Int)   // Case 1
  case x((Int, Int)) // Case 2
}

func f(v: X) {
  switch v {
  case .x(let a, let b): print(a, b)
  default: ()
  }
}

Without the implicit untupling, the semantics of the function f code are clear – it will print something for case 1 and not print something for case 2. With the implicit untupling, there is no good answer. There is a tempting answer, that it should do the same thing. But making that actually work is... [well, I talk more about that in part 2]

Part 2: Inconsistent user model / Uncanny valley

enum Point {
  case .xy(Int, Int)
  case .xz(Int, Int)
}

func change_plane(pt: Point) -> Point {
  switch pt {
  case .xy(let pair): return .xz(pair) // error: enum case 'xz' expects two separate arguments
  case .xz(let pair): return .xy(pair) // error: enum case 'xy' expects two separate arguments
  }
}

One would think that if you just obtained the payload from pattern matching, you should be able to pass it to the case and have it just work. The syntax makes it seem like it should work. But it doesn't.

Another question is what should happen in nested positions?

enum E {
  case e(Int, (Int, Int))
}

func e_func(v : E) {
  switch v {
  case let .e(a, (b, c)): () // OK, this is standard form
  // case let .e(a, b, c): () // Should this also work?
  // case let .e((a, b), c): () // Should this also work?
  // case let .e(k): print(k) // If this works, should k have type (Int, Int, Int) or (Int, (Int, Int))
  }
}

enum F<T> {
 case f(Int, T)
}

func f_func(v: F<(Int, Int)>) {
 switch v {
  case let .f(a, (b, c)): () // OK, this is standard form
  // case let .f(a, b, c): () // Should this also work?
  // case let .f((a, b), c): () // Should this also work?
  // case let .f(k): print(k) // If this works, should k have type (Int, Int, Int) or (Int, (Int, Int))
}

// It is tempting to answer that the generic example should behave analogous to the monomorphic case, but it's not super obvious that the implementation of this will just "fall out" or if it requires serious plumbing

enum Line {
  case pts(Float, Float, Float, Float) // x1, y1, x2, y2
  case slopeIntercept(Float, Float) // m, c
}

func getpts(line: Line) -> ((Float, Float), (Float, Float)) {
  switch line {
  case let pts(pt1, pt2): return (pt1, pt2) // Should this also work?
  default: fatalError("Expected pts")
  }
}

Essentially, what is happening with the implicit tupling/untupling is that there is a non-trivial notion of equality between patterns, kinda' how the addition of generics and associated types means that we non-trivial equalities between types.

The way to work with something like that in the compiler is introduce a notion of "canonical" things – we have canonical types and we kinda' have canonical patterns too (even though they aren't really referred to as "canonical patterns"). The problem here is (to the best of my understanding, I'm open to ideas) that having these conversions makes it extremely hard, if not impossible to pin down a notion of what a canonical version of a pattern is. The exhaustiveness checker is based on the premise that every pattern can be decomposed into a canonical form and we can check if the union of all the forms in a switch statement covers the whole space.

Adding this kind of non-trivial "associated values ~= tuple" breaks that assumption, which is why we saw seemingly similar patterns behaving differently in SR-11160.

Part 3: Umm... but can we really not have this feature, even in a limited form?

One might think "well, this used to work before, so can we add a hack or two to fix SR-11160 and let this continue to work". I mean, the answer is... kinda' yes? We could. All that would involve (barring a fix for SR-12035, which has the potential to break stuff) is disabling the warning we have today. Then we'd be fine, for some definition of "fine".

I've already laid out what the problems are (there's also the question of implementation/maintenance complexity – adding support for this in a reliable way is certainly a non-trivial task and I'm sure we'll create a bunch of bugs along the way). I think the question here is a matter of how many people are relying on this behavior. Based on what we've seen so far, the answer seems to be that not a lot of people are relying on it (and some of them are relying on it accidentally). This is different from the case for implicit conversion of functions, where we had widespread use of the feature, including standard library functions relying on it for ergonomics.

Part 4: Where do we go from here?

All that said, I think there is room here perhaps for a small language feature where you can explicitly but concisely ask for the payload to be tupled (and correspondingly splat a tuple in an expression context). That would solve the usability problem without creating the problems that I mentioned earlier.

If you're still unconvinced, perhaps you could create a forum post and tag me and Robert Widmann, since he played a key role in writing the exhaustiveness checker. I think he is of the same opinion as me here, although I don't know if he shares the same reasons. Either he'll end up convincing you or you'll end up convincing us. 🙂

@swift-ci
Copy link
Collaborator

Comment by Matthew Johnson (JIRA)

I apologize if this is too long of an answer but here's how I think about it.

Not at all, thank you for the detailed explanation!

Without the implicit untupling, the semantics of the function f code are clear

Tbh, I'm not that interested in implicit untupling. Swift already supports nested pattern matching and that works fine. What I think would be convenient in some cases is implicit tupling - i.e. the ability to use a single binding (to a tuple) for a case with multiple associated values. Sometimes we are currently required to write boilerplate to bind all associated values when we just pack them all back up into a tuple immediately.

One would think that if you just obtained the payload from pattern matching, you should be able to pass it to the case and have it just work. The syntax makes it seem like it should work. But it doesn't.

This is a fair point. In the example you gave, I think we'll eventually have syntax for tuple splatting. I agree that symmetry is desirable in the example you gave. That indicates that implicit tupling probably isn't the right idea. We could get rid of the boilerplate with an "unsplat" binding which would match all associated values as a tuple.

> // case let .e(a, b, c): () // Should this also work?
> // case let .e((a, b), c): () // Should this also work?

I wouldn't expect them to - as I mentioned above I don't think "untupling" would be beneficial.

> // case let .e(k): print(k) // If this works, should k have type (Int, Int, Int) or (Int, (Int, Int))

I would expect the latter

> // It is tempting to answer that the generic example should behave analogous to the monomorphic case, but it's not super obvious that the implementation of this will just "fall out" or if it requires serious plumbing

Given the answers I gave I don't think this would be a problem, would it? However:

All that said, I think there is room here perhaps for a small language feature where you can explicitly but concisely ask for the payload to be tupled (and correspondingly splat a tuple in an expression context). That would solve the usability problem without creating the problems that I mentioned earlier.

Agree, I'm on board with this being the better direction. Thanks for laying it out in so much detail for me!

@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
compiler The Swift compiler in itself improvement
Projects
None yet
Development

No branches or pull requests

4 participants