Skip to content
Learn Netverks
1

Unexpected behavior in protocol inheritance

asked 8 hours ago by @qa-fngnovxpqlwkoeikttqq 0 rep · 52 views

ios swift concurrency

I use main actor by default in project settings, and use Swift 6 mode. Here's the code:

public protocol Main {}
nonisolated public protocol NonIso {}

// Foo is on main
// As proven by the compilation failure in `testNonIso()`
public protocol Foo: NonIso, Main {
  func foo()
}

nonisolated func testNonIso() {
  let f: Foo! = nil
  // Call to main actor-isolated instance method 'foo()' in a synchronous nonisolated context
  f.foo()
}

As verified by my testNonIso() function, the Foo protocol is isolated to main.

Then I mark Foo as nonisolated, and the error is gone:

public protocol Main {}

nonisolated public protocol NonIso {}

// Foo is on nonisolated
// As proven by no compilation error in `testNonIso()`
nonisolated public protocol Foo: NonIso, Main {
  func foo()
}

nonisolated func testNonIso() {
  let f: Foo! = nil
  // this compiles fine, since Foo is non-isolated
  f.foo()
}

The above all makes sense.

Now things will get weird - instead of using NonIso protocol, let's use Codable protocol, which is also non-isolated:

public protocol Main {}

// Now `Foo` becomes non-isolated for some reason. In the previous example Foo was main isolated. 
// This is verified by the compilation error when conforming struct `F` to `Foo`
public protocol Foo: Codable, Main {
  func foo()
}

// Conformance of 'F' to protocol 'Foo' crosses into main actor-isolated code and can cause data races
struct F: Foo {
  func foo() {}
}

The above code gives compilation error, indicating that Foo becomes non-isolated this time.

Now let's mark Foo as @MainActor:

public protocol Main {}
nonisolated public protocol NonIso {}

// Still non-isolated, despite of @MainActor here
@MainActor public protocol Foo: Codable, Main {
  func foo()
}

// Conformance of 'F' to protocol 'Foo' crosses into main actor-isolated code and can cause data races
struct F: Foo {
  func foo() {}
}

I got exactly the same error, meaning that even if I mark Foo as main actor, it's still non-isolated.

Now just double check and for fun, let's replace Codable back to NonIso in the above code, and the compilation error is gone:

public protocol Main {}
nonisolated public protocol NonIso {}

// Foo is main isolated, with or without @MainActor
@MainActor public protocol Foo: NonIso, Main {
  func foo()
}

// Compiles fine this time
struct F: Foo {
  func foo() {}
}

I wonder what's the difference between my NonIso protocol vs Codable protocol that results in this behavior?

I am using Xcode 26.2

Comments on this question (0)

Use comments to ask for clarification — answers go in the answer box below.

Log in to comment on this question.

2 answers

2

There are 2 important points that I realized via this experiment:

  1. Don't think about the type itself (e.g. NonIso/SubPro/S, etc) being main actor or nonisolated. They are just containers to functions. What really matters is the functions being main actor or nonisolated. Type is just a shorthand.

  2. "Implicit" isolated conformance only works if direct conformance, and not via an intermediate sub protocol.

With this in mind, going through my example above:

public protocol Main {
  // main isolated
  func main()
}
nonisolated public protocol NonIso {
  // non isolated
  func nonIso()
}

public protocol SubPro: NonIso, Main {
  func foo()
}

Here, foo() is main isolated, but the inherited main() and nonIso() requirements are still according to the super-protocol. So this below will give warning, because main isolated nonIso() doesn't satisfy the requirement from NonIso protocol

struct S: SubPro {
  func foo() {}
  func main() {}
  func nonIso() {}
}

To fix this, we have 2 solutions:

Solution 1. make nonIso() nonisolated:

struct S: SubPro {
  func foo() {}
  func main() {}
  nonisolated func nonIso() {}
}

Solution 2. use "isolated conformance" to make the requirement main isolated:

struct S: @MainActor SubPro {
  func foo() {}
  func main() {}
  func nonIso() {}
}

Note that in Solution 2, even tho SubPro itself is main isolated, its super-protocol still has nonisolated requirement, so @MainActor annotation actually affects the super-protocol.

Now you may wonder, isn't isolated conformance the default behavior? Well, it turns out that this kind of "implicit" isolated conformance only works if a type conforms directly, without an "intermediate" layer.

// This is equivalent to: 
// struct S: SubPro, @MainActor NonIso {...
struct S: SubPro, NonIso {
  func foo() {}
  func main() {}
  func nonIso() {}
}

The above code works, because it "implicitly" uses isolated conformance for NonIso, since S directly conforms to NonIso, not via SubPro.

Casey Garcia · 0 rep · 8 hours ago

0

Accepted answer

After changing Foo to inherit from Codable, you say

The above code gives compilation error, indicating that Foo becomes non-isolated this time.

This is not true. Foo is still main actor isolated. The compiler error is referring to the Codable requirements, which are non-isolated. If Foo had really become non-isolated, I should not be able to satisfy its requirement foo() using a @MainActor method, but I can!

// Just to make it very clear that this is not due to an isolated conformance to Foo, 
// we write 'nonisolated Foo' explicitly to disable any inference of isolated conformance
struct F: nonisolated Foo {
    // this compiles!
    @MainActor
    func foo() {}
    
    // these are required to be non-isolated by Codable
    nonisolated init(from decoder: any Decoder) throws {
    }
    
    nonisolated func encode(to encoder: any Encoder) throws {
    }
}

After all, @MainActor protocols can still have non-isolated requirements. Foo effectively has one main actor isolated requirement foo, and two non-isolated requirements init(from:) and encode(to:).

Rather than reading protocol Foo: Codable, Main as "Foo inherits from Codable and Main", read it as "conforming to Foo requires you to also conform to Codable and Main".

The reason why the NonIso case compiles is because NonIso does not have any requirements, so F conforms to it (and therefore conforms to Foo), even if F is main actor isolated.

Skyler Patel · 0 rep · 8 hours ago

Your answer