2

What is the idiomatically correct way to mutate a dictionary/other collection asynchronously in Swift?

The following type of situation often arises while coding:

func loadData(key: String, dict: inout [String: String]) {
    // Load some data. Use DispatchQueue to simulate async request
    DispatchQueue.main.async {
        dict[key] = "loadedData"
    }
}

var dict = [String:String]()

for x in ["a", "b", "c"] {
    loadData(key: x, dict: &dict)
}

Here, I am loading some data asynchronously and adding it to a collection passed in as a parameter.

However, this code does not compile in Swift because of the copy semantics of inout.

I have thought of two workarounds to this problem:

  1. Wrap the dictionary in a class, and pass this class to the function instead. Then I can mutate the class, as it is not a value type.
  2. Use unsafe pointers

Which is the idiomatically correct way to do this?

I saw that there is some discussion on this topic in this question: Inout parameter in async callback does not work as expected. However, none of the answers focused on how to actually solve the problem, only why the code as it is now doesn't work.

Hannes Hertach
  • 499
  • 5
  • 23

2 Answers2

2

This (hack) seems to work:

func loadData(key: String, applyChanges: @escaping ((inout [String: String]) -> Void) -> Void) {
    DispatchQueue.main.async {
        applyChanges { dict in
            dict[key] = "loadedData"
        }
    }
}

...

for x in ["a", "b", "c"] {
    loadData(key: x) { $0(&dict) }
}

Not idiomatic though... I would say the idiomatic thing to do here is to not asynchronously mutate things. You can always return the changes you want to do to a collection in a completion handler. In the case of dictionaries, this could be another dictionary that you then merge with the original.

Sweeper
  • 213,210
  • 22
  • 193
  • 313
1

To put the asynchronous update in loadData might seem convenient, but it is brittle and imposes limitations on how we interact with this dictionary:

  • It entangles our network code with our model and business logic (loadData only works with dictionaries; it assumes there is no other interaction with the dictionary on other threads; it assumes that we’re not actually working with local stores, etc.).

  • It it offers no way to keep track of multiple requests and know when they’re all done; etc.).

While you theoretically could address these concerns by providing a thread-safe wrapper for the collection (moving the synchronization and asynchronous updates to layer where it can be accessed everywhere, not just from the network layer), we really want our network code to be far more loosely coupled with our code for updating the model and/or local stores.

The idiomatic solution is that loadData should simply limit its role to the retrieving of the data only. That’s its sole responsibility. A routine to request data from a server should not be involved in the updating of the local model or local stores. It should just retrieve the data and supply that information to the caller via a completion handler:

func loadData(key: String, completion: @escaping (Result<String, Error>) -> Void) {
    // Load some data. Use DispatchQueue to simulate async request
    DispatchQueue.main.async {
        completion(.success(resultString))
    }
}

And then caller would do the updating of the collection.

let group = DispatchGroup()

for x in ["a", "b", "c"] {
    group.enter()
    loadData(key: x) { result in 
        defer { group.leave() }

        switch result {
        case .success(let value): dict[x] = value
        case .failure(let error): ...
        }
    }
}

group.notify(queue: .main) {
    // all done updating collection here
}

But one would generally strive to avoid tightly coupling the network class from this layer that is updating the model.

Rob
  • 415,655
  • 72
  • 787
  • 1,044