Non-actors isolated to an actor

Hi fellow Swifters! :wave:

I would like to pitch isolated non-actor types (struct, enum, and, if it makes sense, class types) in a similar fashion to functions with an isolated parameter.

The Problem

I am working on a graph-building library where the graph is declared as an actor and where nodes are value types that reference the graph. Clients only interact with the nodes; the graph is mostly an implementation detail of the library. A node has some properties and operations in its public API.

actor Graph {
    var nodesByLocation: [Location : Node] = [:]
    func degreeOfNode(at location: Location) -> Int { … }
    func processNode(at location: Location) { … }
}
public struct Node {
    var graph: Graph
    var location: Location
    public var degree: Int {
        get async { await graph.degreeOfNode(at: location) }
    }
    public func process() async {
        await graph.processNode(at: location)
    }
}

A client wants to access some properties and run some operations on the node. We assume that this combination of steps is client-specific and not something the library would offer. Since the graph is an isolated, the client has to introduce a suspension point at each step.

let node: Node = …
if await node.degree > 0 {  // client-specific condition
    await node.process()
}

This is undesirable: the degree might change between the if-branch and the process() call, possibly violating a precondition that the client wants to satisfy!

To avoid the extra suspension point, the client would need to perform the two operations directly on the Graph and from within an isolated function.

func processConditionally(graph: isolated Graph, location: Location) {
    if graph.degreeOfNode(at: location) > 0 {
        graph.processNode(at: location)
    }
}

let node: Node = …
await processConditionally(graph: node.graph, location: node.location)

The library needs to expand its public API more and we lose the view-like abstraction that Node provides. It becomes unworkable as the library evolves.

A Possible Solution

The client should be able to define a Node-isolated function. Node is not an actor but can get its isolation from Graph by declaring (exactly) one stored isolated Graph property, akin to an isolated parameter.

Methods and accessors can opt in to the isolation with an isolated modifier, unlike in actors where you need to opt out with nonisolated. This provides consistency with other non-actor types with an isolated property.

public struct Node {
    var graph: isolated Graph
    var location: Location
    public isolated var degree: Int { // isolated self, implies isolated graph
        graph.degreeOfNode(at: location)
    }
    public isolated func process() { // isolated self, implies isolated graph
        graph.processNode(at: location)
    }
    func myNonisolatedMethod() async { // nonisolated
        await graph.processNode(at: location)
    }
}

The client can now define a Node-isolated function.

func checkAndProcess(_ node: isolated Node) {
    if node.degree > 0 {
        node.process()
    }
}

let node: Node = …
await checkAndProcess(node)

Thoughts? :slight_smile:

2 Likes

I have had similar thoughts, and I think this is interesting!

But, have you had a look at this proposal here? I think it will have a pretty dramatic impact on exactly this kind of problem.