Swift and iOS dev with Igor
The ride of the iOS developer

Enhancing Swift with Decorators: Balancing Weak and Strong References

Decorators are a powerful design pattern used to add behavior to functions or objects without modifying their structure. They are particularly useful in event-driven systems where side effects and additional processing steps are common. In this blog post, I'll walk you through an advanced technique for implementing decorators in Swift, focusing on managing weak and strong references to avoid retain cycles and ensure reliable execution.

The Challenge of Retain Cycles

In Swift, retain cycles are a common issue when using closures that capture self. If not handled correctly, these cycles can lead to memory leaks, as the objects involved in the cycle are never released. To address this, we often use weak references. However, there are cases where a strong reference is necessary to ensure that certain actions complete even if the original object is deallocated.

Introducing the HandleEffectDecorator

Let's consider a scenario where we have an event-driven system that processes effects and dispatches events. We want to add additional behavior before and after the main effect processing. We'll use a decorator to achieve this, and our decorator will need both weak and strong reference handling.

Here's a simplified version of our HandleEffectDecorator class:

final class HandleEffectDecorator<Event, Effect> {
    
    private let decoratee: Decoratee
    private let decoration: Decoration
    
    init(decoratee: @escaping Decoratee, decoration: Decoration) {
        self.decoratee = decoratee
        self.decoration = decoration
    }

    func handleEffect(_ effect: Effect, _ dispatch: @escaping Dispatch) {
    	
        decoratee(effect) { [weak self] event in
        
            guard let self else { return }
            
            decoration.onEffectStart()
            dispatch(event)
            decoration.onEffectFinish()
        }
    }

    func callAsFunction(_ effect: Effect, _ dispatch: @escaping Dispatch) {
    	
        handleEffect(effect) { [self] event in
        
            dispatch(event)
            _ = self
        }
    }
}

extension HandleEffectDecorator {

    struct Decoration {
    	
        let onEffectStart: () -> Void
        let onEffectFinish: () -> Void
    }
    
    typealias Decoratee = (Effect, @escaping Dispatch) -> Void
    typealias Dispatch = (Event) -> Void
}

You can find the full implementation in the HandleEffectDecorator.swift file on GitHub.

Understanding the Decorator

Our HandleEffectDecorator class has two primary methods for handling effects: handleEffect and callAsFunction.

  1. handleEffect: Using Weak References
func handleEffect(_ effect: Effect, _ dispatch: @escaping Dispatch) {
	
    decoratee(effect) { [weak self] event in
    
        guard let self else { return }
        
        decoration.onEffectStart()
        dispatch(event)
        decoration.onEffectFinish()
    }
}

The handleEffect method uses a weak reference to self to avoid retain cycles. This is crucial when the effect handling might create circular references, as it ensures that the decorator instance can be deallocated when no longer needed. If self is deallocated before the decorated function completes, the completion handler simply doesn't execute, preventing any potential memory leaks.

  1. callAsFunction: Using Strong References
func callAsFunction(_ effect: Effect, _ dispatch: @escaping Dispatch) {
	
    handleEffect(effect) { [self] event in
    
        dispatch(event)
        _ = self
    }
}

The callAsFunction method provides an alternative that uses a strong reference to self. By capturing self strongly within the closure, we ensure that the decorator instance remains alive until the decorated function completes. This guarantees that all necessary actions, such as dispatching the event and finishing the decoration, are performed reliably.

When to Use Each Method

  • handleEffect (Weak Reference): Use this method when you want to avoid retain cycles and can tolerate the possibility that the decorated function may not complete if the decorator instance is deallocated early. This is suitable for scenarios where the effect processing is optional or non-critical.
  • callAsFunction (Strong Reference): Use this method when it is crucial that the decorated function completes, regardless of the lifecycle of the decorator instance. This ensures that all side effects and dispatch actions are carried out fully.

Practical Example

Let's put this into context with a practical example. Imagine we have an effect that triggers a network request and dispatches an event based on the response. We want to log the start and end of the effect processing.

struct NetworkEffect: Equatable {

    let request: URLRequest
}

struct NetworkEvent: Equatable {

    let response: URLResponse
}

let logDecoration = HandleEffectDecorator<NetworkEvent, NetworkEffect>.Decoration(
    onEffectStart: { print("Effect started") },
    onEffectFinish: { print("Effect finished") }
)

let networkEffectHandler: (NetworkEffect, @escaping (NetworkEvent) -> Void) -> Void = { effect, dispatch in

    // Simulate network request
    let response = URLResponse() // Simplified for example purposes
    dispatch(NetworkEvent(response: response))
}

let decorator = HandleEffectDecorator(
    decoratee: networkEffectHandler,
    decoration: logDecoration
)

let effect = NetworkEffect(request: URLRequest(url: URL(string: "https://example.com")!))

// Using handleEffect with weak reference
decorator.handleEffect(effect) { event in

    print("Event received: \(event.response)")
}

// Using callAsFunction with strong reference
decorator(effect) { event in

    print("Event received: \(event.response)")
}

Instead of logging, you could imagine showing and hiding a spinner. To achieve this, the callAsFunction method can delegate execution to handleEffect while ensuring a strong reference to self. This ensures that the spinner is shown and hidden correctly even if the decorator instance is deallocated.

Testing the Decorator

To ensure our decorator works correctly, we can write unit tests. The tests can be found in the following files on GitHub:

These tests cover various scenarios, including:

  • Ensuring the decorated effect handler is called.
  • Verifying that events are correctly delivered.
  • Confirming that decoration actions are performed.
  • Handling deallocation of the decorator instance.

Conclusion

The HandleEffectDecorator pattern we've explored provides a flexible and powerful way to manage side effects in an event-driven system. By offering both weak and strong reference handling, we can choose the appropriate strategy based on the specific requirements of our application.

Understanding and applying this technique helps us build more robust, memory-efficient, and maintainable Swift applications. Whether dealing with network requests, user interactions, or any other side effects, this pattern ensures that our additional behaviors are seamlessly integrated and safely managed.

For more details, you can explore the complete code and tests in the swift-imrx repository on GitHub. Happy coding!

Published on: May 18, 2024
Tagged with: