Expanding the capabilities of enum cases

One of the biggest issues I have had with enum cases is ensuring in-place mutation. You have to spell things in a very particular way to avoid making extra copies:

struct TreeNode {
  enum Storage {
    case internal(children: [TreeNode])
    case leaf(values: [Int])
  }
  var _storage: Storage

  mutating func insert(value: Int) {
    switch _storage {
      // The optimizer recognizes this magic incantation and eliminates redundant copies.
      case .leaf(var values) { // 1: capture payload as a var
        values.append(value) // 2: mutate var
        _storage = Storage.leaf(values) // 3: assign a new value of the same enum case with the mutated payload
      }
      case .internal(var children) {
        // appropriate recursive logic here
      }
    }
  }
}

In the past, I’ve seen suggestions of an inout capture specifically targeted at this use case. But upon further consideration, if enums supported mutating methods like structs do, this entire pattern could be avoided by reusing a concept one has already learned. Borrowing the syntax from structs, the above example could be rewritten:

indirect enum TreeNode {
  case leaf {
    /// The values stored in this leaf node.
    /// - Note: Look, DocC comments!
    var values: [Int]

    mutating func insert(value: Int) {
      values.append(value)
    }
  }
  case internal {
    var children: [TreeNode]
    mutating func insert(value: Int) {
      // appropriate recursive logic here
    }
  }
}

Of course, without turning each case into a nominal type, this would require all cases carry the same set of instance methods. While it is appealing to turn each case into a nominal type, it’s not clear how one would invoke case-specific methods in practice:

indirect enum TreeNode {
  case child {
    var values: [Int]
    mutating func shuffleValues() { ... }
  }
  case internal {
    var children: [TreeNode]
  }
}

func doSomething(with aNode: TreeNode) {
  switch aNode {
    case child {
      child.shuffleValues() // error: aNode is still statically typed TreeNode here
    }
  }
}

We could contemplate a rebinding case let syntax for use within switch, but this increased scope is all superfluous to the original goal of making it convenient and straightforward to implement in-place mutations on enums, even if those operations have to be well-defined for all cases.

3 Likes

The pattern we use in this situation in SwiftNIO is to create structs as associated data for each enum case, and give them mutating methods. Then the outer switch is trivial, and always expressed with the magic "do the right thing" pattern.

The tricky part of that pattern is when a method should change the case. Your proposal can't really solve that very easily, but it's equally important, especially when writing state machines.

I think the ideal solution here is to take advantage of move semantics. @Michael_Gottesman is going to have better thoughts on this topic than I will, but I'd like to see a way for us to explicitly move an associated value in a switch case into a local binding. That will force us to move it back into self, and lets us express the lifecycle to the compiler much more clearly.

6 Likes

It seems unfortunate that the best practice seems to be to wrap an enum in a struct, and also wrap the enum’s payload in a struct.

Maybe it is worth contemplating enum cases as nominal types after all.