Conditionally further constrain a generic parameter: if <T: Constrained> { ... }

another convenient aspect of the if <T: Constrained> syntax is that parsing can't conflict in any way with what is currently allowed if/guard condition clauses — even if < is made into a prefix operator, T is a type and : can't be in any operators ever.

I'm not against the proposed feature/syntax, however I wonder if that is really needed for the original problem. At least, if I understood the need correctly, the following code works just fine in recent master builds from swift.org

protocol Protocol {
  func foo()
}

extension Protocol {
  func foo() { print("Proto") }
}

protocol SubProtocol: Protocol {}
extension SubProtocol {
  func foo() { print("SubProto") }
}

func f<T: Protocol>(x: T) {
  print("Protocol f()")
  x.foo()
}

func f<T: SubProtocol>(x: T) {
  print("SubProtocol f()")
  x.foo()
}

struct A: Protocol {}
struct B: SubProtocol {}

f(x: A())
// Protocol f()
// Proto

f(x: B())
// SubProtocol f()
// SubProto
func test<T: Protocol>(x: T) {
  f(x: x)
}
test(x: A())
test(x: B())

@jrose indeed, did not think about the "as requirement" -part well enough.

func test<T: Protocol>(x: T) {
  f(x: x)
}
test(x: A())
// Protocol f()
// Proto

test(x: B())
// Protocol f()
// SubProto

For simple protocols, not using Self or associated type, this is solvable by ditching the generics.

func f(x: SubProtocol) {
  print("SubProtocol f()")
  x.foo()
}

func test<T: Protocol>(x: T) {
  if let y = x as? SubProtocol {
    f(x: y)
  } else {
    f(x: x)
  }
}

But for the rest of the protocols, it would be really nice to have a solution for this.

Also, if foo isn't a protocol requirement, it doesn't get dynamic dispatch, so the extension of SubProtocol will go unused (remove line 2 from your example to see this behaviour).

It is worth mentioning a workaround which works for class types but not protocols:

class Class { }

extension Class {
    @objc func foo() { print("Class") }
}

class SubClass: Class { }

extension SubClass {
    @objc override func foo() { print("SubClass") }
}

func f<T: Class>(x: T) {
    x.foo()
}

f(x: Class())
// Class
f(x: SubClass())
// SubClass

However, it requires @objc to make it overridable, which is far from ideal. Trying to get around this by creating a protocol with foo() as a requirement, and implementing conformance in extensions results in either Class printing for the subclass as well, or requires @objc for the function.

Trying to change the language to get around restrictions like this does not sound like a good solution to the problem, and would change existing code behaviour in some cases.


I'll probably get started on a formal proposal for this addition soon.

1 Like

Overridable methods in extensions is also a reasonable language feature, but I'd keep it separate from this.

4 Likes

Your syntax will also need to permit an arbitrary where clause, e.g.,

func foo<T: Equatable, U>(_ t: T, _ u: T) -> Bool {
  if <where T == U> { return t == u }
  return false
}

That somewhat implies that if where is the right syntax here, because in the general case you want to be able to express arbitrary additional constraints on the existing generic parameters:

if where T: SubProtocol { ... }

Doug

2 Likes

Ooh, I thought about ==, but thought it didn't matter because for a normal concrete type you can usually use a normal cast. T == U is a good point. That said, I'm not sure I like where being different here from how it's used with case or for, where it's a normal expression:

for x in numbers where x > 0 { print(x) }

switch numbers.first {
case let x? where x > 0: print(x)
default: break
}

How about if <T, U> where T == U, to keep up the mirroring of the syntax around functions? It'll also look sufficiently visually different from the current uses of if ... where because of the <...> in between.

This adds a bit of repetition to the syntax, but IMO it adds readability in the case of longer expressions. Consider

if <where T: Sequence, T.Element == U> { ... }

vs

if <T: Sequence, U> where T.Element == U { ... }

The second makes it easier to visually parse which generic parameters are actually being affected by the if statement.


There is the issue of what happens when I write if <T> where T == U, which I think can reasonably be solved by requiring everything present in the where to also be present in the <...> clause in that if statement, i.e. an error like

func foo<T: Equatable, U>(_ t: T, u: U) -> Bool {
    if <T> where T == U { ... } // error: Use of type 'U' not listed in conditional clause.
}

This would still allow things like

func foo<T, U, V>(_ t: T, _ u: U, _ v: V) -> Bool {
    if <T, U> where T == U {
        // T and U have the additional constraint reflected in type checking,
        // while V remains unchanged from its introduction in the function's type
    }
}

Edit: I see your point about <> being used to introduce new parameters, I’ll have another think on this

Yes, that's a reasonable point. I'd be fine with something with angle brackets containing a where, although I'm suspicious of specifically this syntax:

if <T: SubProtocol> { ... }

because everywhere else, that would introduce a new type parameter named T. That's not what we want here: we want to further constraint the existing T, which I think it better represented as:

if <where T: SubProtocol> { ... }

A (long) while back, I'd been thinking about this as a way of writing multiple (distinct) bodies for a function, each with its own where clause:

func f<T: Protocol>(_ t: T) {
  // general implementation
} where T: SubProtocol {
  // specific implementation when T conforms to SubProtocol
}

My thought there was based on simplifying the implementation in the compiler: this is effectively two different functions, with a little dynamic check at the beginning to jump to the most-specific function body that applies.

Just to complicate things, there's a related place where we probably want this kind of dispatch: protocol conformances. Imagine this code:

protocol P {
  func foo()
}

protocol Q: P { }

struct X<T: P>: P {
  func foo() { /* #1 */ }
}

extension X: Q where T: Q {
  func foo() { /* #2 */ }
}

struct Y: P, Q { }

func callFoo<T: P>(_ t: T) { t.foo() }

callFoo(X<Y>()) // calls #1

One would probably expect to call #2 here, but it doesn't because the conformance of X: P chooses #1 (since that works for all X's). Swift could be extended to make the choice between #1 and #2 depending on the T in X<T>.

Doug

Would it hurt to get rid of the if altogether, and replace this with where as a control structure?

where T: SubProtocol {
    ...
}

This is like multiple function bodies, but a little more flexible...

Also, since Swift allows nested function declarations, wouldn’t the above be equivalent to compiling this (assuming the dispatch rules are the same for nested functions)?

func _wrap<T: OriginalConstraint>(_ t: T) {
    // do nothing
} where T: SubProtocol {
    // the body of the where statement
}
_wrap(t)

Follow-up reasoning for using where instead of if:

  • An if statement mentally reads as a sort of "dynamic" control flow, dependent on the actual values passed into the function.
  • A where distinction feels less dynamic in that it's affected only by the type, and so could be (even though it isn't) determined at compile time. The more extreme solution of multiple function bodies expresses this even more clearly.
  • Any dynamic cast you can currently do in an if statement is fundamentally different from this operation:
    • if let T_ = T as? SubProtocol.Type produces a variable T_ (we could just as well use if var, and it would make sense).
    • t is T is a valid expression, while t is T_ is not.
    • I can even write T() (if such an initialiser exists), while I can't write T_() and must write T_.init().
  • Any dynamic check performed with an if statement is also different:
    • if T is SubProtocol.Type is just a check, and doesn't do anything magical with T.
    • if <where T: SubProtocol> seems like a weird syntax (where isn't used inside angle brackets anywhere else in the language), and might not be obviously different enough.
1 Like

How could Swift be extended to do this? Would generic types potentially have different witness tables for different type parameters? It would be nice to be able to provide optimized implementations for more refined constraints. Wouldn't changing this be a huge source compatibility concern though?

Re: the topic of this thread, I have mixed feelings. When I'm writing library code I would love to be able to perform this kind of check. However, when I pass an instance of a type declared by my library to user code I do not want the user code to be able to "escape" the constraints provided by the signature under which the instance was passed.

This is of course already possible with dynamic casts (which will become much more powerful when generalized existentials arrive). For this reason, I have sometimes wondered whether the dynamic cast operators should be part of an AnyCastable protocol to which all types conform and which must be present as a constraint on the type of a value on which a dynamic cast is performed.

Of course, even if we wanted to change that it wouldn't be possible at this point due to source compatibility concerns. Given that reality, I fall on the side of supporting this feature as I don't think it makes the potential to "escape the constraints" materially worse than it already is.

1 Like

Users who want to escape your constraints for whatever "good" reasons somehow always seem to find a way, no matter how hard you try.

I don't think the possibility of [more easily] accomplishing something in a language serves as an implication that it's a good idea; if we designed languages to safeguard against users intentionally doing the wrong thing, I don't think they would ever get close to Turing-complete :stuck_out_tongue:

That may be, but it would be nice if they had to work harder than just typing as?. I don't want to protect users bent on violating the constraints for a "good" reason from themselves. But I do think it would be useful to have a type system where all possible operations must be represented by constraints instead of having a handful of privileged operators that work regardless of constraints.

That's true, but this is a harder (and separate) problem to solve

If you're paranoid, I guess you could wrap every single object you expose to the user in a struct that just forwards public functions and instance variables and has no secret properties for the user to discover, or propose a language feature that synthesises these...

The main reason not to replace if is that this would be a useful condition to also support in guard and while statements, and currently all three share the same condition support.

1 Like

You're right, I completely forgot about that. This does make if where / guard where sound like the best option, however it doesn't make sense at all to support this in while (and also repeat) statements, except for convenience, in fact,

while where T: Constrained {
    ...
}

Is a great way to write an infinite loop without it immediately looking like one...

Side note, if we do end up going this route, and the presence of angle brackets is preferred, I'd prefer if <> where ... instead of if <where ...>

Solving it is separate, but it is not completely orthogonal. If Swift were designed that way this pitch would need to be designed such that the constraints in the declaration allow constraint refinement in the body. Swift is unlikely to ever be quite that strict so I don't think you need to worry about it in designing this feature.

Sure, and in some cases that might make sense. But most of the time it would require way too much boilerplate to be worth the increase in safety over documentation describing correct and incorrect usage. If the type system were designed to support this it would only be necessary to specify the intended constraints.

as? already violates parametricity, and this is IMO strictly a better feature than as? currently is. as? loses type information, is complected by bridging and subtyping rules, and only supports is-subclass and is-protocol queries. A feature for conditionally testing generic constraints to refine a type addresses all of those concerns.

One thing that might also be nice in this vein, though, is a way to specify the constraints you want to conditionally test in the function signature, something like:

func foo<T>(...) where T: Collection, T :? BidirectionalCollection, T.Element ==? Int {...}

If you could do this, it would signal to callers what non-parametric axes the function conditionally changes behavior on. Furthermore, tests for protocol conformance could be more efficient inside the function body, since the conformance could be passed in as a nullable pointer at the ABI level instead of having to be looked up globally. This would also avoid the problems with global coherence of post-hoc protocol conformances that are inherent to dynamic casting, since the conditional conformance could be picked contextually at compile time. However, being able to do ad-hoc protocol conformance checks is still very useful for expression problem sorts of situations, and for things like printing that we really don't want every function to have to provide explicit conformance to support.

3 Likes

I agree, that's why I concluded that I support this feature despite it violating parametricity.

Oooh, this is nice!

There are definitely some contexts where ad-hoc checks are useful. I just wish the signature had to acknowledge that ad-hoc checks (including as? casts) may be performed on an argument in the body (or something the body passes the argument to).

If I could see a viable way to require AnyCastable or something like that at this point in Swift's evolution for dynamic casts I would want to see it happen. Unfortunately, I think it's pretty clear that it would break way too much code to make a change like that at this point.

Terms of Service

Privacy Policy

Cookie Policy