7

I have a swift struct somewhat like this:

struct LogicalState {
  let a: String?
  let b: Bool
  let c: Int
}

and a mutable instance of this state. Note the properties within the state are all let, so the struct itself is immutable.

var _state: LogicalState

What I'd like to do is enforce a pattern where updating the state is allowed, but all updates must be atomic - I do NOT want to simply make a, b, and c mutable, as that would allow a and b to change independently. I need to control the updates and apply validation (for example, to enforce that if you change a, you also must change b at the same time)

I can do this by simply overwriting the whole struct

_state = LogicalState(a: "newA", b: false, c: _state.c)

However, as you can see, having to explicitly reference the old state for the properties that don't change (_state.c) is annoying and problematic, especially when you have more properties. My real-world example has something like 10.

In kotlin, they have "data classes" which expose a "copy" method, which lets you change only the parameters you want. If swift supported such a thing, the syntax would look like this

func copy(a: String? = self.a, b:Bool = self.b, c:Int = self.c) ...

The problem is, the = self.a syntax doesn't exist in swift, and I'm not sure of what other options I have?

Any solution on how to solve this would be much appreciated

Orion Edwards
  • 121,657
  • 64
  • 239
  • 328
  • related: https://stackoverflow.com/questions/38331277/how-to-copy-a-struct-and-modify-one-of-its-properties-at-the-same-time – pkamb Oct 03 '20 at 04:01

2 Answers2

15

Think, you can extend the struct with a copy(...) method taking nil values as default and replacing them with instance ones while using non-nil otherwise. E.g. something like this:

extension LogicalState {
    func copy(a: String? = nil, b: Bool? = nil, c: Int? = nil) -> LogicalState {
        return LogicalState(a: a ?? self.a, b: b ?? self.b, c: c ?? self.c)
    }
}

So you can use it to copy instance while varying the needed params:

let state = LogicalState(a: "A", b: false, c: 10)
let stateCopy1 = state.copy(c: 30)
let stateCopy2 = state.copy(a: "copy 2")
Mikhail Churbanov
  • 4,436
  • 1
  • 28
  • 36
  • 1
    Why do you need the default values to be `nil`, only to default them to `self.x` later? Why can't you just set the default values to be `self.x`, directly? – Alexander May 13 '18 at 23:26
  • @Alexander Isn't that exactly what the OP said he wanted to do but couldn't? So this is supposed to be a solution for that. – matt May 13 '18 at 23:32
  • @matt Ooops, missed that. Curious, that should be made possible, there's no technical reason I can see against it – Alexander May 14 '18 at 00:00
  • 1
    If one of the properties in LogicalState is optional itself, then your copy method needs to use a double-optional. I've never seen that done, and didn't realise it was a possibility in swift, but it seems that is is – Orion Edwards May 14 '18 at 01:01
  • I dismissed the double-optional because I didn't think it was possible, but in fact it seems to work just fine. Will edit my question (and your answer) to include that – Orion Edwards May 14 '18 at 01:14
  • 1
    OK, the double-optional works trivially for assigning optional values, but to clear an optional value, you need to use this optional, eg: `state.copy(a: .some(nil))`, which is still the least-yuck thing I can think of – Orion Edwards May 14 '18 at 01:48
  • 1
    This deserves million upvotes! ...should be built-into the Swift though, like in Kotlin. – shelll Jul 30 '18 at 14:02
  • For the 2nd and 3rd use of `LogicalState` in your extension you can use `Self` instead. This makes the code somewhat more generic. – ThomasW Nov 04 '21 at 02:39
  • @OrionEdwards I get the compiler error "Type of expression is ambiguous without more context" when I use `.some(nil)`. – ThomasW Nov 04 '21 at 02:53
3

Another option would be to use a builder:

struct LogicalState {
  let a: String?
  let b: Bool
  let c: Int
}


extension LogicalState {
    func copy(build: (inout Builder) -> Void) -> LogicalState {
        var builder = Builder(state: self)
        build(&builder)
        
        return builder.toLogicalState()
    }
    
    struct Builder {
        var a: String?
        var b: Bool
        var c: Int
        
        fileprivate init(state: LogicalState) {
            self.a = state.a
            self.b = state.b
            self.c = state.c
        }
        
        fileprivate func toLogicalState() -> LogicalState {
            return LogicalState(a: a, b: b, c: c)
        }
    }
}

let state = LogicalState(a: "a", b: true, c: 0)
let nextState = state.copy { $0.a = nil }

Then we wouldn't have to deal with double optionals as mentioned by Orion Edwards.

  • Thanks, this is great. – sbirksted Jun 21 '21 at 20:08
  • We encountered the same challenge, with a more simplistic version of .copy; not being able to set an optional value to `nil` if it has a non-optional value already. I have a sourcery template for creating a copy function, I updated the implementation to use your [strategy](https://gist.github.com/sbirksted/142070ae555bbf50ed70b066418e3ba2) – sbirksted Jun 21 '21 at 20:20