Assert-let for special cases at runtime

There are times, especially when handling special cases inside a function, that I would like to perform an action if a let statement evaluates to false and then continue with the normal code flow. Essentially it's a guard-let without a return or an if-let-else with an empty top block. I could logically negate a guard statement and put it in an if, but I can't negate an if-let. Here's an ugly example of code that isn't as clean as I'd like:

struct State {
  let result: SomeResult?
}
func updateState() {
  let currentState = computeState()
  guard let lastPosted, lastPosted.result == currentState.result else {
    currentState.post()
    lastPosted = currentState
    currentState.cleanUp()
    return
  }
  currentState.cleanUp()
}

As you can see I want to ensure something is posted the first time updateState() is called even if State.result is nil, so I use a guard-let that fails if lastPosted is nil. But using guard means I have to return from inside the let, so my cleanUp() call is duplicated in each return path. This is just an example, you can imagine cases in which there is a lot more code that I would like to run either way.
One way to achieve this is to use an if statement:

func updateState() {
  let currentState = computeState()
  if lastPosted == nil || lastPosted!.result != currentState.result {
    currentState.post()
    lastPosted = currentState
  }
  currentState.cleanUp()
}

but that means I can't use if-let and I need to force-unwrap lastPosted which I don't like doing.
I could add an empty block to the if and do my work in the else:

func updateState() {
  let currentState = computeState()
  if let lastPosted, lastPosted.result == currentState.result {} else {
    currentState.post()
    lastPosted = currentState
  }
  currentState.cleanUp()
}

but that really doesn't read cleanly - it looks backwards until you notice the 'else' at the end of the if-let line.
Things could get really complicated with a private error or state enum or do/catch blocks. What I would really like is to simply make an assertion, perform an action if it fails, and move right along with the rest of my func:

func updateState() {
  let currentState = computeState()
  assert let lastPosted, lastPosted.result == currentState.result else {
    currentState.post()
    lastPosted = currentState
  }
  currentState.cleanUp()
}

Unfortunately 'assert' has a connotation which might cause some to question whether or not the code is compiled in a release build so I'm open to other terms or other thoughts on making the original guard cleaner with the existing language. Thanks.

1 Like

This is, in fact, a perfectly legitimate use of force-unwrapping, but if you don't want to do that you can also just use optional chaining:

if lastPosted == nil || lastPosted?.result != currentState.result

Indeed, if your result wasn't itself of Optional type—or if you wanted to treat the case where lastPosted is nil equivalently to the case where lastPosted.result is nil—you could compress the condition even further and write if lastPosted?.result != currentState.result.

2 Likes

There are two reasons I don't think of that as a complete solution. One: there is the potential for a race between testing lastPosted for nil and accessing the variable in the chain. Two: This is a very simple example. If lastPosted is a complex computed property or function call I would end up having to call it twice. Adding a line above let lastPosted = lastPosted is of course possible but (in my opinion) it's still not as elegant as assert let

1 Like

Have you considered using a defer statement?

func updateState() {
  let currentState = computeState()
  defer {
    currentState.cleanUp()
  }
  guard let lastPosted, lastPosted.result == currentState.result else {
    currentState.post()
    lastPosted = currentState
    return
  }
}

If defer doesn't work for you, then would extracting the condition's logic to a function work?

func meetsCondition(lastPosted: State, currentState: State) -> Bool {
  guard let lastPosted else {
    return false
  }
  return lastPosted.result == currentState.result
}
func updateState() {
  let currentState = computeState()
  if !meetsCondition(lastPosted: lastPosted, currentState: currentState) {
    currentState.post()
    lastPosted = currentState
  }
  currentState.cleanUp()
}

Just FYI, if there's a race condition, then if let isn't any more safe than checking and force unwrapping unless it's an atomic or it's guarded by a lock. The best thing to do is to prevent the race condition from happening in the first place.

2 Likes

Yes, defer is a good solution for the sample code provided, but what if updateState() has a return value that I want returned in all paths? I can't defer a return and I'd have to duplicate return xyz in all my guard let else {} returns. What if there is code that I don't want to run given another guard statement farther down? Imagine a function with more than one assert-let test. Defer would work but it's not as legible as assert let.

Yes, it would need to be atomic.

It would work, but it's nowhere near as simple or concise as assert let ... would be.

func updateStateDefer() {
    let currentState = computeState()
    defer { currentState.cleanUp() }
    guard
        lastPosted?.result != currentState.result
        else { return }
    currentState.post()
    lastPosted = currentState
}

func updateStateIfMap() {
    let currentState = computeState()
    if lastPosted.map({ $0.result == currentState.result }) != true {
        currentState.post()
        lastPosted = currentState
    }
    currentState.cleanUp()
}

My problem with defer is that it forces code out of order. In the simple example it does make sense but taken beyond the scope of this particular example it is often undesirable.

While clever it's really far from obvious why I want to enter the block of code compared to assert let.

Agreed, but if you don't want clever, why not explicitly switch over the three cases, nil/equal/non-equal:

func updateStateSwitch() {
    let currentState = computeState()

    switch lastPosted?.result {
    case currentState.result:
        break // short-circuit same-result
    case nil, .some(_):
        currentState.post()
        lastPosted = currentState
    }

    currentState.cleanUp()
}
1 Like

IMO, that's the cleanest solution:

func updateState() {
  let currentState = computeState()
  if let lastPosted, lastPosted.result == currentState.result {
    // No need to post
  } else {
    currentState.post()
    lastPosted = currentState
  }
  currentState.cleanUp()
}
1 Like

Another reasonable approach but so much cruft! It should only take a single line to clearly explain the desired state and why the block is being executed.

I agree that this is the cleanest yet. It only introduces 2 superfluous lines of code that are very benign and clearly explains the flow of code. It would be nice to be able to remove those two extraneous lines though.