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

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.

It's true that while is mostly useless today. However, we do want to support re-opening the types inside protocol types someday, which might make something like this interesting:

var x: Collection = []
while where x.Self: BidirectionalCollection {
}

That's not justification for the feature in and of itself, but currently while shares the same condition support as the other conditional instructions do.

2 Likes

Another possibility I didn't think of!

But this is expanding the scope of the feature to include non-"constant" expressions in the constraints. My original idea was exclusive to just the generic type parameters that existed in the function.
Does this mean that the expression would also admit something like

if where type(of: x): Equatable { ... }

or is there a subtle distinction between that and the hypothetical x.Self that I'm missing?

On another note, I'm still not sold on if where T: ... / guard where T: ... / while where T: ... as being the best syntax for this, but I'm failing to come up with anything that makes more sense, aside from maybe if <> where T: ...

There were quite a few different examples of the where clause here and there, which makes me wonder if the extension I'm proposing here touches some of your general ideas?

switch someCase {
case where type(of: x) : Equatable in .a(let x), .b(let x):
  ...
}

Well, we haven't even really started seriously discussing what the language design for working with opened dynamic types will be, but we would want to be able to support things like this in some form or another.

So for the purposes of this pitch, it’s something to keep in mind when deciding on the syntax (so that it can be extended later), but not an active concern in terms of thinking through edge cases and implementation?

That sounds like a fair assessment. My hope is that whatever we come up with for dynamic types will work mostly the same as types currently do in the language today.

No, this does not directly affect your proposal, but it did lead me to discover that the where clause in switch cases also shares the same condition support as if/while/guard (thanks for pointing it out!).

This means that if this pitch is implemented, it'll be possible (assuming something like what @Joe_Groff described also happens) to write something like

switch whatever {
    // uh-oh, do we have our first ambiguous parsing issue?
    case .something(let x) where x.Self: Sequence:
}

and, if your pitch is implemented, will benefit from the reduction in duplication of conditions.

TL;DR it's not related, but will add another thing that can go in case's where clause, wherever that appears.

The where clause in switch cases currently only supports boolean expressions, not general conditions.

1 Like

True, I saw condition in the syntax examples and missed the fact that it was different in the actual grammar.

Regardless, this is probably a case to keep in mind for the future, as it might be useful when dynamic types happen.

This looks good, but I wonder if the angle-brackets and “where” are really necessary. Why not just “if T == U”?

I think it would be important to support both cases: entire function bodies with different implementations based on constraints, and small parts of a function with some optimised paths which make use of other conformances.

Additionally: lots of us have been asking for a long time for something like this for entire structs/classes (including stored properties). For example: perhaps I’m writing a UI component which is generic for any Collection. I may want to create an index cache for faster lookup. Such a cache would be unnecessary if the Collection conforms to RAC. Rather than wasting storage and littering my code with dynamic checks, it would be cleaner to write 2 separate implementations with the same user-visible name (kind of like a class cluster).

2 Likes

Without the angle brackets or the where it's grammatically ambiguous whether it's a same-type constraint or comparing two metatype values. We could semantically disambiguate it (the latter should have been written as T.self == U.self), but I think it's better for it to be unambiguous which kind of if we're talking about.

Having different definitions for a nominal type doesn't match well with Swift's implementation model, because we assume that the core definition and layout of the type is something everyone agrees on. If your layout depended on some other requirement---say, whether the type parameter T was bound to something conforming to RandomAccessCollection---what happens when two parts of the program have different information about whether that type is a RandomAccessCollection? For example, we form some type UIComponent<Foo> and Foo is not a RandomAccessCollection, but then we dlopen() some shared library that includes the conformance of Foo to RandomAccessCollection: now we have an inconsistent state.

Doug

The type constraint and the expression interpretation would at least have the same dynamic behavior (given the default implementation of == for metatypes in the standard library).

2 Likes

Instances of UIComponent<Foo> which were created before the dlopen() would have the layout with the cache, and instances which are created afterwards would not have the cache.

Essentially what I'm thinking of would look like the following, but with less boilerplate:

protocol UIComponent { /*...*/ }
private class UIComponent_CollectionOnly<T: Collection>: UIComponent { /*...*/ }
private class UIComponent_RandomAccessCollection<T: RandomAccessCollection>: UIComponent { /*...*/ }

extension UIComponent {
  static func makeComponent<T: Collection>(_ source: T) -> UIComponent {
    if T: RandomAccessCollection { return UIComponent_RandomAccessCollection(source) }
    return UIComponent_CollectionOnly(source)
  }
}

In fact, doing the dynamic conformance check once when the UIComponent is instantiated would probably lead to fewer bugs than if an object suddenly gained conformances which the already-constructed UIComponent previously checked for and which returned false.