Search code examples
swiftdecoratornsuserdefaults

Implement RawRepresentable on UnitMass


I'm trying to implement RawRepresentable on Measurement<UnitMass> and UnitMass in order to replace the following code with the @AppStorage decorator:

var unitOfMeasure: UnitMass {
    get { AppSettings.defaults.string(forKey: "unitOfMeasure").flatMap { UnitMass.fromSymbol(rawValue: $0) }! }
    set { AppSettings.defaults.set(newValue.symbol, forKey: "unitOfMeasure") }
}

var weightOverwrite: Measurement<UnitMass> {
    get { .init(value: AppSettings.defaults.double(forKey: "weightOverwrite"), unit: unitOfMeasure) }
    set { AppSettings.defaults.set(newValue.value, forKey: "weightOverwrite") }
}

How can I do this? I kinda achieved it using JSONEncoder/JSONDecoder:

extension Measurement: RawRepresentable {
    public init?(rawValue: String) {
        guard let data = rawValue.data(using: .utf8),
            let result = try? JSONDecoder().decode(Measurement.self, from: data)
        else {
            return nil
        }
        self = result
    }

    public var rawValue: String {
        guard let data = try? JSONEncoder().encode(self),
            let result = String(data: data, encoding: .utf8)
        else {
            return "{}"
        }
        return result
    }
}

But I fail to do it for UnitMass:

extension UnitMass: RawRepresentable {
    public init?(rawValue: String) {
        for unitMass in UnitMass.allCases where rawValue == unitLength.symbol {
            self = unitLength
        }
        
        return nil
    }
}

I get Designated initializer cannot be declared in an extension of 'UnitMass'. What am I doing wrong?


Solution

  • Here's why you cannot conform UnitMass to RawRepresentable.

    RawRepresentable has the requirement that conforming classes must have an init(rawValue:) initialiser.

    UnitMass is not final, so it can have subclasses.

    Subclasses of UnitMass also conform to RawRepresentable if UnitMass conformed to RawRepresentable, so they must also have init(rawValue:).

    How would init(rawValue:) in the subclasses be implemented? Note that they can't just inherit the implementation in UnitMass, because subclasses could have their own stored properties that needs to be initialised in the initialiser.

    So your extension requires all subclasses of UnitMass to implement this new initialiser. Well, extensions aren't supposed to add requirements - they are supposed to add functionality!

    Even if extensions can do that, it would be impractical for you to go to every subclass of UnitMass and add an implementation of init(rawValue:) :)

    Anyway, here are some workarounds:

    Use a wrapper class:

    class MyUnitMass: RawRepresentable {
        let unitMass: UnitMass
        
        var rawValue: String {
            unitMass.symbol
        }
        
        required init?(rawValue: String) {
            // assuming fromSymbol actually uses the correct converter
            unitMass = UnitMass.fromSymbol(rawValue: rawValue)
        }
    }
    

    Alternatively, save the UnitMass in UserDefaults as Data rather than String, because UnitMass conforms to NSSecureCoding.

    let data = try NSKeyedArchiver.archivedData(withRootObject: UnitMass.grams, requiringSecureCoding: false)
    
    // save "data" to UserDefaults instead
    
    let unitMass = try NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as! UnitMass
    

    This has the advantage of also encoding the converter, without you having to hard code it in fromSymbol (presumably what you are doing right now).

    Also note that if you are using the unitOfMeasure setting only as the unit for weightOverwrite, you should just save weightOverwrite in user defaults, and declare unitOfMeasure like this:

    @AppStorage("hello", store: UserDefaults.standard)
    var weightOverwrite: Measurement<UnitMass> = Measurement(value: 1, unit: .grams)
    
    var unitOfMeasure: UnitMass {
        get { weightOverwrite.unit }
        set { weightOverwrite.convert(to: newValue) }
    }