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

generics

(Greg) #1

Occasionally, we have scenarios where we want to do this:

protocol Protocol { ... }
protocol SubProtocol: Protocol { ... }

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

func f<T: SubProtocol>(x: T) {
    // do something different when T is a more specific type
    ...
}

But this isn't always possible, for example, if f<T: Protocol>(x:) is a protocol requirement, there won't be any dynamic dispatch for this, so the second f will never get called.

We can instead try to write the above like this:

// unrelated function that needs to exist for this example
func g<T: SubProtocol>(x: T) { ... }

func f<T: Protocol>(x: T) {
    // if SubProtocol has Self or associated type requirements, this won't even work
    if let specialX = x as? SubProtocol {
        // specialX: SubProtocol
        g(x: specialX) // error: cannot invoke 'g' with an argument list of type '(x: SubProtocol)'
    }
}

Now, generics are determined at compile-time, so there isn't really a need for this whole dynamic casting business. I propose the following:

func f<T: Protocol>(x: T) {
    #if <T: SubProtocol>
        // x: T, but also T: SubProtocol
        g(x: x) // success
    #endif
    g(x: x) // error: argument type 'T' does not conform to expected type 'SubProtocol'
}

I don't entirely know how generics are implemented in Swift, but if they're fully (or even partially) specialised at compile-time, this check can simply be evaluated for each specialisation and generate different code as needed, which doesn't sound extremely hard (correct me if I'm wrong...).

I used #if syntax because this is a compile-time thing (edit: I was wrong, working out more appropriate syntax in the replies); suggestions for improvements are very welcome.

What do people think of this? Does it sound like a useful/appropriate feature?

(Inspired in part by this discussion, and my StackOverflow question)


Allow `self = x` in class convenience initializers
Generics: optional conformance
(Jordan Rose) #2

Generics are not determined at compile-time. We do specialize sometimes as an optimization, but there would ultimately still be a runtime check in here. (That doesn't make your idea here unreasonable, but it would make me suggest not using #if.)


(Joe Groff) #3

Like Jordan said, #if would not be appropriate, but it would be reasonable to check for additional constraints as an if/guard condition. This would be more expressive than the existing as? check support, since it could test arbitrary constraints, and would preserve the identity of the involved types.


(Greg) #4

@jrose TIL :stuck_out_tongue:

With this new information in hand, I guess it makes more sense for the syntax to be something like

if T: SubProtocol { ... }

or

if <T: SubProtocol> { ... }

or some combination of the above:

  • having if/guard let remains consistent with currently allowed expressions like if let T = T as? Subtype, not having it means there's no indication that T is changing in some way

  • on the other hand, the let is a little misleading, because if let expressions also permit if var, which would not make sense here

  • assigning T a new name like if let S = T: Subtype doesn't make sense, because T is effectively just a less useful version of S; it makes more sense if the type of T itself becomes more constrained inside the block.

  • having the <> brackets around the constraint make it look like the one in front of the function, which is immediately recognisable

  • functions can also be written as f<T>(...) where T: Protocol, but writing if where T: SubProtocol honestly just looks a bit weird :stuck_out_tongue:

So the syntax would need to convey that the type of T will be different in the if/guard, but without making it look like it's a metatype variable (i.e. let type = T.self as? Subtype.Type), and ideally make it visually parse as related to generics (hence the idea of <>).


(Joe Groff) #5

I like the if <...> syntax, since it echoes the generic constraint syntax. The type T isn't really changing, it's the same type in or out of the scope; what changes is only how much we know about the type, so I think it doesn't make sense to make a new name binding.


(Greg) #6

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.


(Mox) #7

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

(Jordan Rose) #8
func test<T: Protocol>(x: T) {
  f(x: x)
}
test(x: A())
test(x: B())

(Mox) #9

@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.


(Greg) #10

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.


(Jordan Rose) #11

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


(Douglas Gregor) #12

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


(Jordan Rose) #13

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
}

(Greg) #14

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


(Douglas Gregor) #15

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


(Greg) #16

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.

(Matthew Johnson) #17

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.


(Greg) #18

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:


(Matthew Johnson) #19

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.


(Greg) #20

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...