Why does my explicit init crash?

Hi, first post here :wave:

A colleague and I have run into an interesting scenario and I've not yet found resources to help explain what is happening.

For any struct that has at least one defaulted property, the behaviour is observably different between using the generated memberwise init and a seemingly identical explicit init (both using internal access levels – I'm not opening that can of worms).
What seems to be happening is that those properties that have default values are assigned their default values before entering our explicit init. To me, this doesn't make sense, but I'd like to learn why :innocent:

struct ThisWorks {
    var number: Int = { fatalError("ThisWorks::'default' number loaded! 😳") }()
}
struct ThisCrashes {
    var number: Int = { fatalError("ThisCrashes::'default' number loaded! 😳") }()

    init() {}
    init(number: Int) {
        self.number = number
    }
}

// These two obviously invoke the code with the fatalError
//_ = ThisWorks()
//_ = ThisCrashes()

// These two are more interesting. Externally they seem identical, 
// but behave very differently
_ = ThisWorks.init(number: 5) // Works ✅ default value not loaded
_ = ThisCrashes.init(number: 5) // Crashes 💥 "Fatal error: ThisCrashes::'default' number loaded! 😳"

In most cases, this wouldn't be an issue – you rarely have properties whose default value is (or causes) a "fatal error".
In our case however, some default values may be injected from a different module and, when running unit tests, that module may not be connected*, prompting a fatal error.

*when unit testing, we want to mock/stub all dependencies, including the existence of other modules or external libraries

A watered-down real life example

In our Business module:

class GetCurrentUser {

  private let dependencies: Dependencies
  struct Dependencies {
    var storage: UserStorageProtocol = ServiceLocator.userStorage
  }

  init(dependencies: Dependencies = .init()) { /* ... */ }

  func execute() -> User? {
    return dependencies.storage.currentUser
  }
}

enum ServiceLocator {
  static var userStorage: UserStorageProtocol! // To be set when app launches
}

class GetCurrentUserTests: XCTestCase {
  func testFoo() {
    let dependencies = GetCurrentUser.Dependencies(storage: /* some mocked storage */)
    let sut = GetCurrentUser(dependencies: dependencies)
    // Carry on with test
  }
}

An implementation conforming to UserStorageProtocol is defined in a different module, "Storages". The Storage module is not a dependency of the Business module (i.e. framework is not linked) but is injected by the containing app target at run time.
When running unit tests for the Business module, the app is not (and should not be) launched so therefore ServiceLocator is not set up (i.e. ServiceLocator.userStorage is nil). Accessing it within unit tests is a programmer error.

The default memberwise initializer does not evaluate variable-initialization expressions when explicit values are passed in. User-written initializers do evaluate them in all cases.

This is a known behavior, which was first discussed during the SE–0242 review thread:

Quote

The core team described the behavior as a bug, however I don’t know of a corresponding bug report issue for it.

• • •

A few more recent threads have mentioned this as well:

Pre-pitch: remove the implicit initialization of Optional variables

What is a ‘variable initialization expression’?

5 Likes

Thanks for sharing the history @Nevin. Much appreciated!

It seems there's some different opinions about whether this is a bug or a feature:

I can see both sides of the argument. Now that I understand what's going on, I can work around it :+1:

Another take on it: consider if the property was a reference type. In that case the replacement of that property would require the old value to be released (under ARC semantics) and the new value to be retained. That means that the compiler must generate some sort of code that does load the existing value to replace it with a new value to maintain that semantic. Obviously it could be argued that the optimizer paths should eliminate that in the cases of trivial types like Int and such but the closure based initializer could very well have some other sort of side effect.