swiftswift-keypath

Swift - Mutate values of enum associate type properties


I have a 2 structs conforming to one protocol. I created enum with associated types as struct types. When I try to access properties of protocol type A using dynamicmember lookup, getters are working fine. But Set methods are throwing error: Key path with root type 'A' cannot be applied to a base of type 'A1'/'A2'

Sometimes I get this error as well: cannot assign through subscript: 'a' is immutable

import Foundation

protocol A {
    var id: String { get }
}

struct A1 : A {
    var id: String
    var type: String
}

struct A2 : A {
    var id: String
    var test: String
}

@dynamicMemberLookup
enum AType {
    case a1(A1)
    case a2(A2)
}

extension AType {
    subscript<T>(dynamicMember keyPath: WritableKeyPath<A, T>) -> T {
        get {
            switch self{
            case .a1(let a):
                return a[keyPath: keyPath]
            case .a2(let a):
                return a[keyPath: keyPath]
            }
        }

        set {
            switch self{
            case .a1(var a):
                a[keyPath: keyPath] = newValue
            case .a2(var a):
                a[keyPath: keyPath] = newValue
            }
        }
    }
}


let a1struct = A1(id: "123", type: "Test")
var atype = AType.a1(a1struct)

print(atype.id)

Any thoughts would be helpful


Solution

  • First, probably a transcription bug, but id needs to be settable for this to make sense.

    protocol A {
        var id: String { get set }  // <=== Add set
    }
    

    For the setter, there are two issues; one that is very clear, and the other a bit subtle. The clear issue is that even if this worked, it would do nothing:

            case .a1(var a):
                a[keyPath: keyPath] = newValue
    

    This makes a copy of the associated data, then modifies that copy, then throws it away. What you need here is (though this code won't work either):

            case .a1(var a):
                a[keyPath: keyPath] = newValue
                self = .a1(a)
    

    You need to modify a and then create a new enum value to hold it.

    The more subtle problem is the type WritableKeyPath<A, T>. This says "a writable KeyPath rooted to something of type A." You mean "a writable KeyPath rooted to something of a type which conforms to A" which is not the same thing. For this code to work, the cases would need to be like .a1(A) rather than .a1(A1).

    Swift does allow you to read through a KeyPath that is rooted to a protocol the type conforms to. But it won't allow you to write through it. See WritableKeyPath + inout argument of placeholder type doesn't compile for a short discussion.

    The way you would write "something that conforms to A" is:

    subscript<Container: A, T>(dynamicMember keyPath: WritableKeyPath<Container, T>) -> T {
    

    However, that won't work because the type of Container is decided by the caller, and this would have to accept any A-conforming type passed (which you can't do).

    I have not figured out how to fix this without an as! cast, but it can be done this way:

        set {
            switch self{
            case .a1(var a as A):
                a[keyPath: keyPath] = newValue
                self = .a1(a as! A1)
            case .a2(var a as A):
                a[keyPath: keyPath] = newValue
                self = .a2(a as! A2)
            }
        }
    

    (I probably would recommend reconsidering the use of an enum here, and see if you could just use A1 and A2 directly and use is rather than switch to distinguish them. Swift enums are often not as powerful as we'd like them to be compared to structs. But sometimes enums are still best.)