Friday, November 8, 2024

Singleton Design Pattern - Swift

Singleton design pattern is the go-to pattern when one requires to manage a single state accross the board. In Swift initializing a singleton is very easy. In this post I want to talk about some concepts the documentation doesn't explicitly describe.

As you saw in the documentation in Swift, you typically implement a singleton using a static constant to store the shared instance. The static let ensures that the instance is lazily instantiated, meaning it will be created only when it is first accessed. This also guarantees thread safety.


class MySingleton {
    // The shared instance, guaranteed to be unique.
    static let shared = MySingleton()

    // Private initializer to prevent external initialization.
    private init() {
        // Initialize the instance here.
    }

    // Your properties and methods here.
    func doSomething() {
        print("Doing something...")
    }
}

Private Initializer

Private initializer (private init()) ensures that no other part of the code can create a new instance of MySingleton, enforcing the singleton property.

Thread Safety

Thread safety is an important consideration when implementing a singleton in Swift (or any other programming language) because multiple threads can attempt to access or modify the singleton instance simultaneously. If the singleton is not thread-safe, you may encounter race conditions or unexpected behaviors, which could lead to bugs that are difficult to reproduce and fix.

As documentation suggests when you use static let for your singleton instance in Swift, the Swift runtime ensures that the initialization of this singleton is thread-safe by default. This means that no matter how many threads try to access the shared instance concurrently, the system guarantees that only one instance will be created, and any subsequent access to it will simply return the already created instance.

While static let ensures that the singleton is safely created, thread safety may still be relevant if your singleton object manages shared mutable state. For instance, if the singleton holds resources like a cache, network connection, or a shared data model that could be modified by multiple threads, you need to ensure that these resources are accessed and modified safely. In such cases, you might need to implement additional synchronization mechanisms like locks or queues to prevent race conditions when accessing or modifying shared mutable data.


class MySingleton {
    static let shared = MySingleton()
    
    private var sharedData: [String] = []
    private let queue = DispatchQueue(label: "com.myapp.singletonQueue")

    private init() {}

    func addData(_ data: String) {
        queue.async { [weak self] in
            self?.sharedData.append(data)
        }
    }

    func getData() -> [String] {
        return sharedData
    }
}

In this example a serial dispatch queue is used to synchronize access to the shared data, ensuring that only one thread can modify sharedData at a time. queue.async {} ensures that access to sharedData is serialized, preventing multiple threads from modifying it concurrently.

When to Be Careful

Overuse: Singleton patterns should be used sparingly. Overusing them can lead to tightly coupled code and make testing harder.

Global state: Singletons introduce global state, which can make it difficult to track dependencies, and introduce hidden side effects.

Testability: Singletons can complicate unit testing because they maintain state across different tests unless you explicitly reset their state or use dependency injection.

Performance: Adding synchronization mechanisms (e.g., locks or queues) can introduce some performance overhead, so it’s important to evaluate whether your singleton requires synchronization for each shared resource it manages.

No comments: