Function call causes an infinite recursion

I'm getting a warning about infinite recursion that seems incorrect. Here's a demo of the issue:

protocol P {
  associatedtype Base: P
}

extension P {
  static var path: [any P.Type] {
    if Base.self == Self.self {
      return [Self.self]
    }
    else {
      return Base.path + [Self.self]
//    ^--- Warning: Function call causes an infinite recursion
    }
  }
}

struct A: P {
  typealias Base = Self
}
struct B: P {
  typealias Base = A
}
struct C: P {
  typealias Base = B
}

print(C.path)

Running this code correctly prints out [main.A, main.B, main.C]. I would think the conditional would be enough to tell the compiler there's a way out of the recursion. Is there a way to disable the warning?

1 Like

Using swift.godbolt.org to try your code with older versions of the compiler, it appears the spurious warning first appeared in Swift 5.9. Compiling under Swift 5.8 does not give a warning.

2 Likes

That's a handy tool. Looks like it still produces the diagnostic in the latest nightly build as well. Will file a bug. Thanks for the guidance!

Isn't the warning correct?

If you change typealias Base = Self to typealias Base = B, you get the infinite recursion at runtime.

I wouldn't expect the compiler to evaluate all possible chains of P/Base declarations to ensure they terminate in a type that refers to itself.

Indeed, perhaps it's best to always warn on self-call. I don't think Swift guarantees cases where it will reduce tail recursion to a loop (though it might do so in some cases...)

So perhaps the bug in 5.8 is fixed in 5.9 :)

3 Likes

It's a pity Swift doesn't currently support 'negative' constraints, like where Base != Self, as that would presumably address this spurious warning (by properly defining away its possibility).

Since the code runs correctly I would say no, the warning isn’t correct. It’s possible there’s an infinite recursion, but there’s plenty of ways to cause one that doesn’t produce the warning. Why should this one be different?

I agree that this could be defined away with some type system features we don’t have. In the meantime, I think the compiler should defer to the programmer’s intent.

1 Like

i'm surprised no one has mentioned the possible solution of declaring the path requirement as part of the protocol.

protocol P
{
    static var path:[any P.Type] { get }
}

This looks like a bug to me. Only one of the branches in the getter actually calls itself, and the conditional is not optimized away. However, the getter calls itself with a different substitution map than it was called with, and I suspect this confuses the analysis. Please file a issue for this!

3 Likes

Worth noting that as a solution this would change the semantics, though. When it’s not a requirement, the structure of path is driven entirely by the associatedtype relationships, but once it’s a requirement arbitrary conformers could subvert this and supply a custom implementation that deviates from the default.

1 Like

true. but i think an implementation that dispatches through the protocol instead of manually inspecting metatypes just makes a lot more sense to me.

protocol P
{
    associatedtype Base:P

    static
    var path:[any P.Type] { get }
}

extension P where Base == Self
{
    static
    var path:[any P.Type]
    {
        [Self.self]
    }
}
extension P
{
    static
    var path:[any P.Type]
    {
        Base.path + [Self.self]
    }
}


struct A:P
{
    typealias Base = Self
}
struct B:P
{
    typealias Base = A
}
struct C:P
{
    typealias Base = B
}

print(C.path)
[test.A, test.B, test.C]

arguably, the potential for customization itself can be useful too.

Perhaps, but figured it should be called out that if you’re relying on properties like “the nth item in path is the type of the nth type in the associatedtype chain” then allowing the semantics to deviate from that is probably not desirable.

Try it with class A instead of struct A. You’ll also have to change typealias Base = Self to typealias Base = A, but that’s fine.

After making those changes, your version goes into an infinite loop, whereas the original version by @seanmrich still works correctly.

The reason is that, since A is non-final, it may have subclasses where Base is not Self, so the conformance of A to P must use the unconditional extension.

The original version checks the types at runtime, so it still works correctly.

• • •

We might expect subclasses to set Base to their superclass, but in fact neither version allows this to work properly. If we make class B: A and class C: B, then the line typealias Base = B inside the body of C does not “count” for the associated type, so C still uses A as its Base for purposes of P.

2 Likes

good catch! but this is a consequence of requiring Base:P, which might not actually be needed.

we can make the recursive behavior conditional on Base:P, and eliminate the need to have typealias Base = Self at all.

protocol P
{
    associatedtype Base = Void

    static
    var path:[any P.Type] { get }
}
extension P
{
    static
    var path:[any P.Type]
    {
        [Self.self]
    }
}
extension P where Base:P
{
    static
    var path:[any P.Type]
    {
        Base.path + [Self.self]
    }
}


class A:P
{
}
struct B:P
{
    typealias Base = A
}
struct C:P
{
    typealias Base = B
}

print(A.path)
print(B.path)
print(C.path)
[test.A]
[test.A, test.B]
[test.A, test.B, test.C]
2 Likes

Bug filed: Spurious diagnostic: "Function call causes an infinite recursion" · Issue #70504 · apple/swift · GitHub

1 Like

Excellent example! thank you.