«where» clauses on contextually generic declarations

Introduction

The objective is to lift the restriction on attaching where clauses to declarations that themselves do not carry a generic parameter list explicitly, but inherit the surrounding generic environment. Simply put, this means you no longer have to worry about the 'where' clause cannot be attached error within a generic context.

struct Box<Wrapped> {
    func sequence() -> [Box<Wrapped.Element>] where Wrapped: Sequence { ... }
}

Note: Only declarations that can already be generic and support being constrained via a conditional
extension are covered by this enhancement. Properties and new constraint kinds are out
of scope for this document. For example, the following remains an error:

protocol P {
    // error: Instance method requirement 'foo(arg:)' cannot add constraint 'Self: Equatable' on 'Self'
    func foo() where Self: Equatable  
}

Motivation

Today, where clauses on contextually generic declarations can only be expressed indirectly by placing them on conditional extensions. Unless the constraints are identical, every such declaration requires a separate extension. This apparent dependence on extensions is an obstacle to stacking up constraints or grouping semantically related APIs and usually becomes a pain point in heavily generic code that unnecessarily complicates the structure of a program for the developer and the compiler.

Leaving ergonomic shortcomings behind, it is only natural for a where clause to work anywhere a constraint can be meaningfully imposed, meaning both of these layout variants should be possible:

// 'Foo' can be any kind of nominal type declaration. For a protocol,
// 'T' would be an associatedtype.
struct Foo<T> {}  

extension Foo where T: Sequence, T.Element: Equatable {
    func slowFoo() { ... }
}
extension Foo where T: Sequence, T.Element: Hashable {
    func optimizedFoo() { ... }
}
extension Foo where T: Sequence, T.Element == Character {
    func specialCaseFoo() { ... }
}

extension Foo where T: Sequence, T.Element: Equatable {
    func slowFoo() { ... }

    func optimizedFoo() where T.Element: Hashable { ... }

    func specialCaseFoo() where T.Element == Character { ... }
}

A move towards «untying» generic parameter lists and where clauses is an obvious and farsighted improvement to the generics system with numerous future applications, including contextually generic computed properties, opaque types, generalized existentials and constrained protocol requirements.

Source compatibility and ABI stability

This is an additive change with no impact on the ABI and existing code.

Effect on API resilience

For public declarations in resilient libraries, switching between a constrained extension and a «direct» where clause will not be source-breaking, but will break the ABI due to subtle mangling differences.

29 Likes

I like the idea of lifting this restriction. The decision to use a where clause or constrained extension should be a stylistic choice left up to the programmer.

That is correct.

7 Likes

The proposal says that constraints like this will still remain invalid. Is this a general restriction or something that can be lifted in the future as well? Even though this example seems invalid in its nature, I think it shouldn't be (in the future).

If you would re-write it differently it can start to make sense. Let's say we don't have the foo method as a protocol requirement to simplify things a bit.

// Conditional extension where `foo` will become visible/available on
// conforming types that also conform to `Equatable`. This pattern
// is already known and well understood by the Swift community.
extension P where Self: Equatable {
  func foo() { ... }
  func bar() { ... }
}
// If we did not had any conditional conformances then I assume we
// would instead duplicate 'where' clause conditions on a lot of type
// members.
extension P {
  // Always visible to `P` conforming types, but is rejected by the
  // compiler when `Self` does not conform to `Equatable`. This
  // behavior is slightly different from the above as it exposes the
  // type members regardless their constraints.
  // The only thing we did here was 
  func foo() where Self: Equatable { ... }
  func bar() where Self: Equatable { ... }
}
// Analogous example (with parameterized extensions in mind):
extension<T> Optional where Wrapped == T? {
  // Is not visible/available through `Optional` until `Wrapped` is 
  // identified as `T?`.
  func bar() { ... }
}

extension Optional {
  // Always visible from `Optional` but is rejected by the compiler
  // when `Wrapped` is not `T?` with an error: 'Generic parameter
  // 'T' could not be inferred'
  func foo<T>() where Wrapped == T? {}
}

With that in mind I think allowing the original invalid example in the future would be okay. I'm not arguing that we should do that as I don't quite see any good use-cases for this, because by the end of the day we're talking about the expressiveness of the same constraint just in a different way.


+1 for the original proposal.

Semantically, a conditional extension and a direct where clause are equivalent. When the compiler is capable of knowing whether the constraints are satisfied beforehand, foo must only be visible when the receiver satisfies those constraints. As you demonstrate with foo<T>() where Wrapped == T?, sometimes this isn't possible, so you are allowed to access the member and specialize any generic parameters before the compiler can jump to any conclusions on whether you can actually use it.

The key difference in placing constraints on a requirement is that we can dynamically dispatch to a constrained declaration. Answering your first question, it is also a limitation we can pass through Evolution and implement in the future.

Asking from a developer perspective, is it necessary to mangle these differently, that is, is it possible to make this an ABI-compatible change?

It would be possible but I don't think we want to do that. Mangling for nested entities intentionally reflects the lexical structure, so that, for example, Demangle::getTypeForMangling() can easily find the declaration. If you mangle declarations with a where clause as if they were part of a fake constrained extension it would complicate code like that.

Also, we already mangle generic declarations with where clauses that reference outer parameters differently from constrained extension members, so introducing a special case here is even more error prone.

2 Likes

Updating the pitch to let everyone know this is implemented and ready for review.

9 Likes

@core-team Would anyone like to step up to manage the review of this proposal? There doesn't seem to be too much going on in the Proposal Reviews category right now.

3 Likes

@anthonylatsis, @Slava_Pestov, @Joe_Groff
Would it be more convenient to rather introduce an Adopter keyword to specify type constraints on concrete type, instead of ambiguous Self, since protocol can only be conforming by concrete types?

struct Foo<T>: Sequence 
extension Sequence where Adopter == Foo  {
    func regular() where Adopter.T: HomogeneousCollection
    func specialCase() where Adopter.T == Character
    func optimized() where Adopter.T: MutableCollection, 
        Adopter.T.Element: Numeric
}

I'm not sure I understand what ambiguity you're referring to.

protocol P {
    // error: Instance method requirement 'foo(arg:)' cannot add constraint 'Self: Equatable' on 'Self'
    func foo() where Self: Equatable  
}

This one.

That has nothing to do with ambiguity. It makes a protocol requirement conditional. What would it even mean to have a conditional requirement? This would be much more cleanly modeled with a refining protocol:

protocol Q: P, Equatable {
   func foo()
}

With this approach, the constraint and requirements that depend on it are moved to the refining protocol Q and there is no need to have a notion of “conditional protocol requirement” in the language.

Yeah, Self constraints on protocol requirements is a feature we haven't modelled or implemented yet, so they do not work with or without this proposal. To clarify this is the purpose of the introduction note demonstrating the error.

I’m sorry if this question has been asked before or if it’s off-topic here, but I was wondering if “where” clauses on contextually generic declaration includes protocol typealiases. Countless times I find myself yearning for the ability to do this:

typealias ArrayLike = RandomAccessCollection where Index: BinaryInteger

func binarySearch<C>(in collection: C, comparing: (C.Element) -> ComparisonResult) -> Range<C.Index> where C: ArrayLike {
    // ...
}

I know that it's just a throwaway example, and sorry for being offtopic, but I would be wary of that typealias - Having integer indices isn't sufficient to be arraylike. For example ArraySlice doesn't have to start with zero, and LazyFilterSequence can have gaps between indices

1 Like

This is more about generalized existentials. In this case the real issue is that no one has taught the compiler how to look up Index. So the where clause cannot be attached is currently just masking the real issue. This is what happens if you declare such a type alias inside a generic context with this proposal implemented:

struct Wrapper<T> {
  // error: use of undeclared type 'Index'
  typealias ArrayLike = RandomAccessCollection where Index: BinaryInteger
}

As an alternative, you can use an empty protocol:

protocol ArrayLike: RandomAccessCollection where Index: BinaryInteger {}

func binarySearch<C>(
  in collection: C, comparing: (C.Element) -> ComparisonResult
) -> Range<C.Index> where C: ArrayLike {
    // ...
}

I’ve actually resorted to doing this in cases, where the number of implementors of the protocol are limited and I can just conditionally conform all the implementors to this stub protocol. However, this approach won’t work if the original protocol isn’t supposed to be “sealed”, because then the implementor will have to remember to explicitly conform to the stub protocol.

I’m not familiar with compiler internals, so I can‘t tell if this is a purely syntactic issue, or there is some crucial shortcoming in the compiler’s internal representation of the code being compiled that prevents this feature from happening. Syntactically, it looks like, a protocol type alias doesn’t “spill” its associated types into the current syntactic context (which ends with the end of the type alias declaration), as opposed to a generic type alias, which does so with its generic parameters. It seems to me like opaque result types suffer from the same problem, where the opaque result type doesn’t “spill” its associated types into the syntactic context of the function declaration, making it impossible to declare a function that returns an opaque collection of non-opaque elements.

By the way, is this the same reason why we still have to manually implement type-erasing wrappers like AnyIterator?

It is by far not just a syntactic issue. As you noted, opaque types do not support any direct constraints yet. This is something that would require a lot of effort to implement altogether.

To some extent, yes.

1 Like