swiftgenericsswift-keypath

In Swift, how can I implement a generic system that processes a type by registering processors for different key paths?


I want to implement a system that allows me to generically process a type by registering processors for different key paths.

One trait of the system should be composition, so every processor should extend one common generic protocol Processor.

Example of usage:

struct Language {
    var name = "Swift"
    var version = 5.3
}

var processor = TypeProcessor<Language>()
processor.add(procesor: VersionProcessor(), keypath: \.version)
processor.add(procesor: NameProcessor(), keypath: \.name)

var input = Language()
processor.process(value: input)

// Languge version:     5.3
// Languge name:        Swift


Solution

  • I've created a playground showing how this can be solved with function composition and then using what we've learned there to recreate your example.

    Function composition allows you to create new functions by chaining together existing ones, as long as the types match up.

    precedencegroup CompositionPrecedence {
        associativity: left
    }
    
    infix operator >>>: CompositionPrecedence
    
    func >>> <T, U, V>(lhs: @escaping (T) -> U, rhs: @escaping (U) -> V) -> (T) -> V {
        return { rhs(lhs($0)) }
    }
    

    The Processor can be translated to a function that takes an object O, transforms it in some way, and returns a new object. Creating the function could be done like this:

    func process<O, K>(keyPath: WritableKeyPath<O, K>, _ f: @escaping (K) -> K) -> (O) -> O {
        return { object in
            var writable = object
            writable[keyPath: keyPath] = f(object[keyPath: keyPath])
            return writable
        }
    }
    
    let reverseName = process(keyPath: \Person.name, reverse)
    let halfAge = process(keyPath: \Person.age, half)
    

    Now we can compose those two functions. The resulting functions still keeps the signature `(Person) -> Person). We can compose as many functions as we like, creating a processing pipeline.

    let benjaminButton = reverseName >>> halfAge
    let youngBradPitt = benjaminButton(bradPitt)
    

    Moving on to recreating your example. As this answer mentions, the type is generic over the root object. This is just like in the function composition example, and it allows us to group all the processors in an array for example.

    protocol Processor {
        associatedtype T
        func process(object: T) -> T
    }
    

    When erasing an object, it's important to keep a reference to the original, so that we can use it to implement the required functionality. In this case, we're keeping a reference to its process(:) method.

    extension Processor {
        func erased()-> AnyProcessor<T> {
            AnyProcessor(base: self)
        }
    }
    
    struct AnyProcessor<T>: Processor {
        private var _process: (T) -> T
        
        init<Base: Processor>(base: Base) where Base.T == T {
            _process = base.process
        }
        
        func process(object: T) -> T {
            _process(object)
        }
    }
    

    Here's two types implementing the Processor protocol. Notice the first one has two placeholder types. The second placeholder will get erased.

    struct AgeMultiplier<T, K: Numeric>: Processor {
        let multiplier: K
        let keyPath: WritableKeyPath<T, K>
        
        private func f(_ value: K) -> K {
            value * multiplier
        }
        
        func process(object: T) -> T {
            var writable = object
            writable[keyPath: keyPath] = f(object[keyPath: keyPath])
            return writable
        }
    }
    
    struct NameUppercaser<T>: Processor {
        let keyPath: WritableKeyPath<T, String>
        
        private func f(_ value: String) -> String {
            value.uppercased()
        }
        
        func process(object: T) -> T {
            var writable = object
            writable[keyPath: keyPath] = f(object[keyPath: keyPath])
            return writable
        }
    }
    

    Finally, the ObjectProcessor that uses object composition. Notice the array holds object of the same type. An instance of this struct will only be able to process Persons for example. What each child processor does is hidden away in the implementation and the fact it may run on different kinds of data does not affect the ObjectProcessor.

    struct ObjectProcessor<T>: Processor {
        private var processers = [AnyProcessor<T>]()
        
        mutating func add(processor: AnyProcessor<T>) {
            processers.append(processor)
        }
        
        func process(object: T) -> T {
            var object = object
            
            for processor in processers {
                object = processor.process(object: object)
            }
            
            return object
        }
    }
    

    And here it is in action. Notice I add two processors for the same key.

    var holyGrail = ObjectProcessor<Person>()
    holyGrail.add(processor: NameUppercaser(keyPath: \Person.name).erased())
    holyGrail.add(processor: AgeMultiplier(multiplier: 2, keyPath: \Person.age).erased())
    holyGrail.add(processor: AgeMultiplier(multiplier: 3, keyPath: \Person.age).erased())
    
    let bradPitt = Person(name: "Brad Pitt", age: 57)
    let immortalBradPitt = holyGrail.process(object: bradPitt)