swiftgenericsnssortdescriptorkeypaths

Swift Generics, Constraints, and KeyPaths


I'm aware of the limitations of generics in Swift and why they exist so this is not a question about compiler errors. Rather, I occasionally run into situations that seem as though they should be possible with some combination of the resources available in (i.e. generics, associatedTypes/protocols, etc) but can't seem to work out a solution.

In this example, I'm trying to come up with a Swift replacement for NSSortDescriptor (just for fun). It works perfect when you only have one descriptor but, as is often done with the NS version, it would be nice to create an array of SortDescriptors to sort on multiple keys.

The other trial here is using Swift KeyPaths. Because those require a Value type and the comparison requires a Comparable value, I'm running into trouble figuring out where/how to define the types to satisfy everything.

Is this possible? Here is one of the closest solutions I've come up with, but, as you can see at the bottom, it falls short when building an array.

Again, I understand why this doesn't work as is, but am curious if there is a way to achieve the desired functionality.

struct Person {
    let name : String
    let age : Int

}
struct SortDescriptor<T, V:Comparable> {
    let keyPath: KeyPath<T,V>
    let ascending : Bool
    init(_ keyPath: KeyPath<T,V>, ascending:Bool = true) {
        self.keyPath = keyPath
        self.ascending = ascending
    }
    func compare(obj:T, other:T) -> Bool {
        let v1 = obj[keyPath: keyPath]
        let v2 = other[keyPath: keyPath]
        return ascending ? v1 < v2 : v2 < v1
    }
}

let jim = Person(name: "Jim", age: 30)
let bob = Person(name: "Bob", age: 35)
let older = SortDescriptor(\Person.age).compare(obj: jim, other: bob) // true

// Heterogeneous collection literal could only be inferred to '[Any]'; add explicit type annotation if this is intentional
var descriptors = [SortDescriptor(\Person.age), SortDescriptor(\Person.name)]

Solution

  • The problem here is that SortDescriptor is generic on both T and V, but you only want it to be generic on T. That is, you want a SortDescriptor<Person>, because you care that it compares Person. You don't want a SortDescriptor<Person, String>, because once it's created, you don't care that it's comparing on some String property of Person.

    Probably the easiest way to “hide” the V is by using a closure to wrap the key path:

    struct SortDescriptor<T> {
        var ascending: Bool
    
        var primitiveCompare: (T, T) -> Bool
    
        init<V: Comparable>(keyPath: KeyPath<T, V>, ascending: Bool = true) {
            primitiveCompare = { $0[keyPath: keyPath] < $1[keyPath: keyPath] }
            self.ascending = ascending
        }
    
        func compare(_ a: T, _ b: T) -> Bool {
            return ascending ? primitiveCompare(a, b) : primitiveCompare(b, a)
        }
    }
    
    var descriptors = [SortDescriptor(keyPath: \Person.name), SortDescriptor(keyPath: \.age)]
    // Inferred type: [SortDescriptor<Person>]
    

    After that, you may want a convenient way to use a sequence of SortDescriptor to compare to objects. For that, we'll need a protocol:

    protocol Comparer {
        associatedtype Compared
        func compare(_ a: Compared, _ b: Compared) -> Bool
    }
    
    extension SortDescriptor: Comparer { }
    

    And then we can extend Sequence with a compare method:

    extension Sequence where Element: Comparer {
    
        func compare(_ a: Element.Compared, _ b: Element.Compared) -> Bool {
            for comparer in self {
                if comparer.compare(a, b) { return true }
                if comparer.compare(b, a) { return false }
            }
            return false
        }
    
    }
    
    descriptors.compare(jim, bob)
    // false
    

    If you're using a newer version of Swift with conditional conformances, you should be able to conditionally conform Sequence to Comparer by changing the first line of the extension to this:

    extension Sequence: Comparer where Element: Comparer {