Effects of explicit nonisolated on isolation inference

I’m having difficulty explaining some behavior I’ve noticed. The following code compiles. But if you mark either protocol as nonisolated, it appears to cut off the MainActor isolation inference for MyClass.requirement.

I find this a little surprising when applied to A, and very surprising when applied to B.

// default: nonisolated

protocol A {
	func requirement()
}

protocol B: A {
}


@MainActor
final class MyClass: @MainActor B {
	var state = 1

	init() {
	}

	func requirement() {
		// if either protocol is explicitly nonisolated:
		// Error: Main actor-isolated property 'state' can not be referenced from a nonisolated context
		print(state)
	}
}

This came up in the context of using default MainActor, which can motivate adding explicit nonisolated where you otherwise wouldn’t need it.

I’ve re-read SE-0449, but I wasn’t able to find a clear justification for this behavior. Did I miss something?

5 Likes

not an answer, but to further add to the confusion... indirecting through another protocol refinement produces different behavior (which also makes me think there is a bug involved somewhere):

protocol A {
	func requirement()
}
protocol B: A {}
nonisolated protocol C: B {}

@MainActor
final class MyClass: @MainActor C
{
	var state = 1
	init() {}

	func requirement() {
		print(state) // ✅ – still inferred to be @MainActor
	}
}

also, if you declare the conformance via an extension, it appears to behave differently:

protocol A {
	func requirement()
}
nonisolated protocol B: A {}

@MainActor
final class MyClass
{
	var state = 1
	init() {}

	func requirement() {
		print(state) // ✅
	}
}

extension MyClass: @MainActor B {}

i find myself currently struggling to formulate how i think these feature interactions even should work... do you feel you have a sense for that? my intuition is that the explicit isolated conformance being written on the class declaration should 'get priority' over the protocols being marked nonisolated.

cc @hborla @Douglas_Gregor – curious if either of you could shed light on what's happening in these cases, or what the intended interaction between isolated conformances and 'global actor inference cutoff' should be.

5 Likes

I'm glad you brought this up! I've been meaning to revisit some of the longstanding quirks of how explicit nonisolated modifiers behave. I believe this is due to a discrepancy with how explicit nonisolated works when written on protocol requirements that has always existed as far as I can tell. Here's an example, showing the Swift 6.0 behavior before we had isolated conformances:

// compiling with Swift 6.0 under '-language-mode 6'

protocol P {
  nonisolated func f()
}

protocol Q {
  func g()
}

@MainActor struct S1: P {
  // Okay, 'f' is inferred to be 'nonisolated'
  func f() {}
}

@MainActor struct S2: Q {
  // Error, 'g' is inferred to be '@MainActor' and can't satisfy a 'nonisolated' requirement
  func g() {}
}

With isolated conformances in Swift 6.2, both conformances compile, but S1 has a nonisolated conformance to P and S2 has a main actor isolated conformance to Q.

I find this behavior extremely surprising. Both protocol requirements are nonisolated, and whether or not you write it explicitly shouldn't be load bearing for how inference works on the conforming type.

Isolation inference for class overrides is not different between explicit vs implicit nonisolated, but it's still surprising:

class C1 {
  nonisolated func f() {}
}

class C2 {
  func g() {}
}

@MainActor class D1: C1 {
  // Okay, 'f' is inferred to be 'nonisolated'
  override func f() {}
}

@MainActor class D2: C2 {
    // Okay, 'g' is inferred to be 'nonisolated'
  override func g() {}
}

For class overrides, an explicit nonisolated does not make a difference, and nonisolated is always inferred on the overridden method if the superclass method is nonisolated. If we want to have the notion of isolated subclasses and overrides, this is not the inference rule that we would want.

The representation of actor isolation in the compiler does distinguish between "default isolation" and "nonisolated", which is how the compiler treats these cases differently. I have no idea how protocol refinement is somehow messing with whether the original requirement is default isolated or nonisolated, but my guess is there's some funny behavior that tries to cut off isolation inference in a case like this:

@MainActor protocol A {
  func f()
}

nonisolated protocol B: A {}

struct S: B {
  // infer 'f' as 'nonisolated'
  func f()
}

My opinion is that we should make the semantics consistent regardless of whether nonisolated is explicit or inferred via the default isolation, and unify these cases in the implementation as well. In your example, I think func requirement() inside @MainActor final class MyClass should always have@MainActor inferred from the context, and never have nonisolated inferred from the protocol requirement it satisfies. This is actually implemented behind an experimental feature (NoExplicitNonisolated) that "just" needs some source compatibility testing to determine whether we really need an upcoming feature to fix these bugs! I do plan to write up a proposal for this (and would welcome collaborators!) because as you both mention, the rules are not written down anywhere and they're pretty nuanced.

This is just because isolation inference from protocol requirements only happens if the implementation is written in the same scope as the conformance.

8 Likes

Hello Holly, do you think this would change too then (for consistency)?

1 Like