Non-isolated protocols for types with actor-isolated dependencies

Let's say I have a @MainActor isolated type that provides efficient access to an underlying data set:

 struct Thing: Identifiable {
   let id = UUID()
   // etc...
 }

@MainActor
final class GiantStore {
  var numberOfItems: Int { /* implementation omitted */ }
  func item(at index: Int) -> Thing { /* implementation omitted */ }
}

Which I browse using a View generic over some Collection type.

@MainActor
struct ThingBrowser<T: RandomAccessCollection>: View where T.Element == Thing { 
  // etc...
}

Pre Swift Concurrency I would have written a simple Collection wrapper that handled accessing data within the underlying store without incurring a copy.

@MainActor struct GiantStoreCollection: RandomAccessCollection {
  
  let store: GiantStore
  
  var startIndex: Int { 0 }
  var endIndex: Int { store.numberOfItems + 1 }  // ERROR: Main actor-isolated instance methods cannot be used to satisfy nonisolated protocol requirement
  func index(after i: Int) -> Int { i + 1 }
  subscript(position: Int) -> Thing { store.item(at: position) }  // ERROR: Main actor-isolated instance methods cannot be used to satisfy nonisolated protocol requirement
}

Now however, this fails because you can neither 1) access the isolated store synchronously, nor 2) satisfy non-isolated protocol requirements with an isolated instance method.

Is there a feature/technique I've missed that somehow makes this possible, or some kind of evolution in the works that would somehow permit overriding the isolation context of a protocol?

Does this object intended to be used from different threads? I don’t see within this example need to isolate it to main actor at all.

This is example code to illustrate the issue. If you’re familiar with the Apple eco system you can imagine it wrapping NSFetchedResultsController or some other actor-isolated equivalent.

I haven’t worked with CoreData in new concurrency, yet there are also no async calls IIRC, and it’s objects are not thread safe, so it’s OK if wrapper also will be not thread safe. So I’d go with just having it nonisolated at all.

As for more general solution, I suppose, it could be nonisolated(unsafe) with internal MainActor.assumeIsolated check on call so it will ensure at least at runtime API called from the valid concurrency domain.

Yeah, I can work around it.

What I’m saying is it would be good if there were some elegant way of using actor isolated data types with built in collection types without incurring a copy, but currently there doesn’t seem to be.

I think using MainActor.assumeIsolated would be fine if there were some way that I could restrict the type from leaving the @MainActor concurrency context. For example, with the following implementation:

struct ThingCollection: RandomAccessCollection {
  
  let store: Store
  
  @MainActor init(store: Store) { // constrained to main actor
    self.store = store
  }
  
  subscript(position: Int) -> Store.Thing {
    MainActor.assumeIsolated { store.item(at: position) }
  }

  // etc...
}

It's still possible to break strict concurrency guarantees:

func makeThing() async {
  let store = Store()
  let collection = await ThingCollection(store: store)
  print(collection[0]) // ❌ uh-oh, we're not on the main actor. Assertion failure.
}

However, if we had something like a `noasync' we could effectively prevent a type from leaving the targeted isolation context:

struct ThingCollection: RandomAccessCollection {
  
  @MainActor init(store: Store) noasync { // can only be called synchronously from the main actor
    self.store = store
  }

  // etc...
}

If I'm understanding the implications of the semantics, assuming all the initialisers of the type followed the same format, and assuming the type remained non-Sendable, this would allow us to express a type that can conform to a non-isolated protocol, while still being safely restricted to a chosen isolation context by the compiler.

func makeThing() async {
  let store = Store()
  let collection = await ThingCollection(store: store) // ❌ ERROR: ThingCollection can't be instantiated in the current isolation context
}

Basically, I'm trying to find a safe way to access all the non-isolated protocols for types with actor-isolated dependencies.

EDIT: Maybe you could also make a whole type noasync with something like @MainActor(noasync) struct ThingCollection: RandomAccessCollection { ... } that would do the same as above, but also prevents adding Sendable conformance, and negate the need for calls to MainActor.assumeIsolated as, by definition, the whole type is synchronous to the MainActor.

EDIT 2: Nevermind, clearly I've been too liberal with my @MainActor annotations!

1 Like