Implementing RawRepresentable, RawValue == String using Codable: keep it DRY for implementation of multiple types: did I do this right?

public protocol StringRawRepresentable: RawRepresentable { }

extension StringRawRepresentable where Self: Codable {
    public var rawValue: String {
        String(data: try! JSONEncoder().encode(self), encoding: .utf8)!
    }

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

// simply this for every type that can be implemented
extension Date: StringRawRepresentable { }
extension Decimal: StringRawRepresentable { }

You should use LosslessStringConvertible for this purpose. Numeric types already conform, except for Decimal, but it should. I think you better file a bug report for missing LosslessStringConvertible conformance on Decimal, if not already there.

In the short term, you can conform it yourself, but it is not a good practice to conform the types that you don't own to protocols that you don't own.

Can you clarify what you mean? I need RawRepresentable so I can store Date and Decimal in SwiftUI's @AppStorage. @AppStorage only support RawRepresentable where RawValue == String.

I think you mean to use LosslessStrong onvertible instead of Codable?

Decimal is already CustomStringConvertible, to conform to LosslessStringConvertible, we can use this convert from a string into a decimal:

Decimal(string:locale)

but the locale == nil is different on non-Apple platform: Why unable to debug "Step Into" `Decimal(string:locale:)`? - #8 by young

this?

extension Locale {
    static let en_us = Locale(identifier: "EN_US")
}

extension Decimal: RawRepresentable {
    public init?(rawValue: String) {
        self.init(string: rawValue, locale: .en_us)
    }
    public var rawValue: String {
        var v = self
        return NSDecimalString(&v, Locale.en_us)
    }
}

extension Date: RawRepresentable {
    public init?(rawValue: String) {
        fatalError("TODO")
    }
    public var rawValue: String {
        fatalError("TODO")
    }
}

Ah, I missed Date in your post and yes, I meant using LosslessStringConvertible instead of Codable when available.

Your original post wasn't clear about the purpose being @AppStorage support.

Supporting Date is tricky in general as the differences between locales and time zones might be significant. The proper solution for date requires more information about the origin of the date value and the purpose of the date storage in User Defaults.

Inside your own application it is OK to conform a type to RawRepresentable with string raw value as you did, as long as the type is semantically LosslessStringConvertible. But if you want to make a framework or library, I would advise avoiding such ad-hoc conformances.

"A Date value encapsulate a single point in time, independent of any particular calendrical system or time zone". There is no timezone involve with Date.

I prefer using Codable as Date and Decimal both implement it. I just rather let them deal with encode/decode.

And if I did it correctly, I can make them both work as RawRepresentable, RawValue == String with one default extension for both to conform to (which is my original question).

I meant date instead of Date, my bad. As long as the Foundation type works for you, it is fine.

Note that any computer date representation, even a calendar independent point-in-time like Foundation.Date, is not as clear-cut as a decimal number when it comes to converting to/from string. Dates are also subject to a reference clock to measure them which is a source of a host of issues. In your particular case, (storing it in user defaults, hence accessing it on the same computer with the same program) you should be fine with the assumption of string <-> date roundtrip via JSON string representation of Date.

I have not used Codable conformance of Decimal so I can't tell if it works well or not.

1 Like

Your implementation is alright.
Is DRY your only priority here? Other things you might be interested to consider:

  • conversion speed (the above custom Decimal rawValue implementation is about 10x faster than Encodable method)
  • date formatted as "660905301.08746004" with Encodable vs the other possible format of your choice like "2021-12-12/08:51:50.36276".
  • YAGNI

Oh no. That's not good. I want my UI fast and conserve energy.

I wonder why JSON encoding for Decimal is so much slower? I was just listening to the "Swift by Sundell" podcast talking about server side Swift and the guest mentioned the latest Swift version JSON codable speed has been much improved. I wonder why Decimal encode is 10x slow than your version of RawRepresentable? Can its encoding be improved to close the gap?

Here is Decimal Codable for non-Apple platform. What's it doing that's slow? The Apple version may not be the same, though.

It doesn't matter in my case. As long as it goes out and comes back correctly.

What is this?

You Ain't Gonna Need It. (Search for DRY KISS YAGNI.)

1 Like

I'd say it's reasonably quick. There's just bigger overhead due to dynamic memory allocation, ARC traffic, jumping through a few levels of calls and utf8 conversion, to name just a few but there are more. By the same account NSDecimalString with nil locale parameter should run quicker than with non nil locale parameter (but double check if this gives correct "nonlocalized" results beforehand).

I wonder are you getting actual practical benefits using Decimal vs Double in your project? You know that you are not getting "infinite precision" when storing, say, 4/3, right?

This gives quite different encoding (key-valued container with subfields "exponent", "length", etc vs the single-valued container that Apple's implementation results. Meaning that encoded format is not cross platform (which is probably not your concern as this is for the purposes of storing in user defaults and decoding on the same machine that did the encoding).

YAGNI. What I mean by this is that investing in the "then simply use this one-liner to conform a type you want to be 'AppStorageable' to this protocol" infrastructure might be either passively harmful (as in reality you won't need it beyond a couple of types), or it might be actively harmful as it is in this case in fact, as you didn't test it properly and one day your colleague or a future you conforms this benign looking type:

struct S: Codable {
    var x: Int = 123
}

to your StringRawRepresentable protocol just to make it AppStorageable (nothing can possibly go wrong, right?) and ... get an infinite recursion + crash on every attempt to encode this type (as json encoder's encode calls rawValue for things that are raw representable and rawValue in turn calls encode.) This is quite scary actually, so I am reverting what I wrote before: Your implementation is alright. it is a footgun.

1 Like

Thank you for the caution! I only need to make Date and Decimal work with @AppStorage by conforming them to RawRepresentable. But I didn't know the pitfall of naively conforming the same to any Codable can cause problem. Good to know!

Edit: ok, so the problem is caused by compiler generated Encodable conformance calls rawValue, so it's not a good idea to depend on JSON encoding in RawRepresentable implementation to be carelessly apply to anything.

I wish @AppStorage can be easily extended to support new type, so we can avoid having to go through RawRepresentable and String.

Probably it can and it's just a matter of overriding:

extension AppStorage where Value == YourTypeHere {
    public init(wrappedValue: Value, _ key: String, store: UserDefaults? = nil) {
        // TODO
    }
}

(haven't tried it myself).

If you're concerned about performance and efficiency, you might want to consider storing your properties as something other than String so that you don't have the overhead of converting the property to a String. (That being said, converting to-and-from String probably won't significantly impact your app's performance, and String can have a more compact representation than the raw data.)

Unfortunately, this doesn't seem to be possible right now. However, you can create custom property wrappers.

Here's what I would write:

@propertyWrapper
struct DateAppStorage: DynamicProperty {
    @AppStorage var storage: Double
    
    init(wrappedValue: Date, _ key: String, store: UserDefaults? = nil) {
        _storage = AppStorage(wrappedValue: wrappedValue.timeIntervalSinceReferenceDate, key, store: store)
    }
    
    var wrappedValue: Date {
        get {
            return Date(timeIntervalSinceReferenceDate: storage)
        }
        nonmutating set {
            storage = newValue.timeIntervalSinceReferenceDate
        }
    }
    
    var projectedValue: Binding<Date> {
        return Binding(
            get: { wrappedValue },
            set: { wrappedValue = $0 })
    }
}

@propertyWrapper
struct DecimalAppStorage: DynamicProperty {
    @AppStorage var storage: Data
    
    init(wrappedValue: Decimal, _ key: String, store: UserDefaults? = nil) {
        var data = Data(capacity: 36)
        Self.writeData(from: wrappedValue, into: &data)
        _storage = AppStorage(wrappedValue: data, key, store: store)
    }
    
    var wrappedValue: Decimal {
        get {
            return Self.decimal(from: storage)
        }
        nonmutating set {
            Self.writeData(from: newValue, into: &storage)
        }
    }
    
    var projectedValue: Binding<Decimal> {
        return Binding(
            get: { wrappedValue },
            set: { wrappedValue = $0 })
    }
    
    static func writeData(from decimal: Decimal, into data: inout Data) {
        data.removeAll(keepingCapacity: true)
        
        func write<I: FixedWidthInteger>(trivialInteger: I, into data: inout Data) {
            withUnsafePointer(to: trivialInteger.littleEndian) {
                data.append(UnsafeBufferPointer(start: $0, count: 1))
            }
        }
        
        write(trivialInteger: decimal._exponent, into: &data)
        write(trivialInteger: decimal._length, into: &data)
        write(trivialInteger: decimal._isNegative, into: &data)
        write(trivialInteger: decimal._isCompact, into: &data)
        write(trivialInteger: decimal._reserved, into: &data)
        withUnsafePointer(to: decimal._mantissa) {
            let contents = UnsafeBufferPointer(
                start: UnsafeRawPointer($0).assumingMemoryBound(to: UInt16.self),
                count: 8)
            for instance in contents {
                write(trivialInteger: instance, into: &data)
            }
        }
    }
    
    static func decimal(from data: Data) -> Decimal {
        return data.withUnsafeBytes {
            guard $0.count == 36, var base = $0.baseAddress else {
                // handle the error
                print("Unexpected data count; defaulting to zero.")
                return Decimal()
            }
            
            /// - Warning: `T.bitWidth` must be a multiple of 8.
            func read<T: FixedWidthInteger>(as type: T.Type, from pointer: inout UnsafeRawPointer) -> T {
                var integer = T()
                
                // There are no guarantees about the alignment of `Data`'s buffer, so we shouldn't
                // use `pointer.load(as: T.self)`
                for shift in stride(from: 0, to: T.bitWidth, by: 8) {
                    integer |= T(truncatingIfNeeded: pointer.load(as: UInt8.self)) << shift
                    
                    pointer += 1
                }
                
                return integer
            }
            
            let exponent   = read(as: Int32 .self, from: &base)
            let length     = read(as: UInt32.self, from: &base)
            let isNegative = read(as: UInt32.self, from: &base)
            let isCompact  = read(as: UInt32.self, from: &base)
            let reserved   = read(as: UInt32.self, from: &base)
            
            let mantissa = (
                read(as: UInt16.self, from: &base),
                read(as: UInt16.self, from: &base),
                read(as: UInt16.self, from: &base),
                read(as: UInt16.self, from: &base),
                read(as: UInt16.self, from: &base),
                read(as: UInt16.self, from: &base),
                read(as: UInt16.self, from: &base),
                read(as: UInt16.self, from: &base))
            
            return Decimal(
                _exponent: exponent,
                _length: length,
                _isNegative: isNegative,
                _isCompact: isCompact,
                _reserved: reserved,
                _mantissa: mantissa)
        }
    }
}

You can use DateAppStorage and DecimalAppStorage the same way you'd use AppStorage.

There are two drawbacks to this approach, though:

  1. DecimalAppStorage uses unsafe code, so the compiler won't warn you if it will cause memory issues. However, I believe it works correctly since I wrote it and have tested it.
  2. DecimalAppStorage uses some of Decimal's underscored properties. I'm not sure these are supposed to be used, but they are public properties. The underscored properties don't appear in the documentation, but they are used in the corelibs-swift-foundation version's Codable conformance and the documentation does include this memberwise initializer for them. It would be helpful if somebody could confirm that the underscored properties are usable, though.

Thank you for creating this.

It's missing one functionality of @AppStorage: its a published so views are notified and updated when it change.

Here is my small test, note the name field is an @AppStorage, when you edit it, all the views update on each edit change. But the date and decimal views do not update when you edit them.

I am not sure why your's is not working as you use a @AppStorage, it should publish when it change.

Anyway, DateAppStorage and DecimalAppStorage need this publish functionality to be equivalent to @AppStorage. I don't know how to make this happen.

There is this drop in replacement of @AppStorage, it supports many more different types, including Date and Decimal and can be easily extended to support new type: GitHub - Pyroh/DefaultsWrapper: Yet another property wrapper for UserDefaults. It works just like @AppStorage. I try to see how he does the publish part but cannot understand :frowning:

import SwiftUI

final class AppData: ObservableObject {
    @DateAppStorage("date") var date = Date()   // change not published
    @DecimalAppStorage("decimal") var decimal = Decimal.zero    // change not published
    @AppStorage("name") var name = "Paul"   // change to this is published!
}

struct Child: View {
    @EnvironmentObject private var appData: AppData
    var body: some View {
        VStack {
            Text(appData.date, format: .iso8601)    // not updated
            Text(appData.decimal, format: .number)  // not updated
            Text(appData.name)      // yes updated!
        }
        .background(Color.indigo)
    }
}

struct ContentView: View {
    @StateObject private var appData = AppData()

    var body: some View {
        VStack {
            Text("Hello, world!")
                .padding()
            Text(appData.date, format: .iso8601)            // not updated as it's editted
            DatePicker("Date", selection: $appData.date)
            Text(appData.decimal, format: .number)          // not updated as it's editted
            TextField("Enter", value: $appData.decimal, format: .number, prompt: Text("Decimal"))
            Text(appData.name)      // @AppStorage is updated as it's editted
            TextField("Name", text: $appData.name)
            Divider()
            Child()
        }
        .environmentObject(appData)
    }
}

// ======================================================================

@propertyWrapper
struct DateAppStorage: DynamicProperty {
    @AppStorage var storage: Double

    init(wrappedValue: Date, _ key: String, store: UserDefaults? = nil) {
        _storage = AppStorage(wrappedValue: wrappedValue.timeIntervalSinceReferenceDate, key, store: store)
    }

    var wrappedValue: Date {
        get {
            return Date(timeIntervalSinceReferenceDate: storage)
        }
        nonmutating set {
            storage = newValue.timeIntervalSinceReferenceDate
        }
    }

    var projectedValue: Binding<Date> {
        return Binding(
            get: { wrappedValue },
            set: { wrappedValue = $0 })
    }
}

@propertyWrapper
struct DecimalAppStorage: DynamicProperty {
    @AppStorage var storage: Data

    init(wrappedValue: Decimal, _ key: String, store: UserDefaults? = nil) {
        var data = Data(capacity: 36)
        Self.writeData(from: wrappedValue, into: &data)
        _storage = AppStorage(wrappedValue: data, key, store: store)
    }

    var wrappedValue: Decimal {
        get {
            return Self.decimal(from: storage)
        }
        nonmutating set {
            Self.writeData(from: newValue, into: &storage)
        }
    }

    var projectedValue: Binding<Decimal> {
        return Binding(
            get: { wrappedValue },
            set: { wrappedValue = $0 })
    }

    static func writeData(from decimal: Decimal, into data: inout Data) {
        data.removeAll(keepingCapacity: true)

        func write<I: FixedWidthInteger>(trivialInteger: I, into data: inout Data) {
            withUnsafePointer(to: trivialInteger.littleEndian) {
                data.append(UnsafeBufferPointer(start: $0, count: 1))
            }
        }

        write(trivialInteger: decimal._exponent, into: &data)
        write(trivialInteger: decimal._length, into: &data)
        write(trivialInteger: decimal._isNegative, into: &data)
        write(trivialInteger: decimal._isCompact, into: &data)
        write(trivialInteger: decimal._reserved, into: &data)
        withUnsafePointer(to: decimal._mantissa) {
            let contents = UnsafeBufferPointer(
                start: UnsafeRawPointer($0).assumingMemoryBound(to: UInt16.self),
                count: 8)
            for instance in contents {
                write(trivialInteger: instance, into: &data)
            }
        }
    }

    static func decimal(from data: Data) -> Decimal {
        return data.withUnsafeBytes {
            guard $0.count == 36, var base = $0.baseAddress else {
                // handle the error
                print("Unexpected data count; defaulting to zero.")
                return Decimal()
            }

            /// - Warning: `T.bitWidth` must be a multiple of 8.
            func read<T: FixedWidthInteger>(as type: T.Type, from pointer: inout UnsafeRawPointer) -> T {
                var integer = T()

                // There are no guarantees about the alignment of `Data`'s buffer, so we shouldn't
                // use `pointer.load(as: T.self)`
                for shift in stride(from: 0, to: T.bitWidth, by: 8) {
                    integer |= T(truncatingIfNeeded: pointer.load(as: UInt8.self)) << shift

                    pointer += 1
                }

                return integer
            }

            let exponent   = read(as: Int32 .self, from: &base)
            let length     = read(as: UInt32.self, from: &base)
            let isNegative = read(as: UInt32.self, from: &base)
            let isCompact  = read(as: UInt32.self, from: &base)
            let reserved   = read(as: UInt32.self, from: &base)

            let mantissa = (
                read(as: UInt16.self, from: &base),
                read(as: UInt16.self, from: &base),
                read(as: UInt16.self, from: &base),
                read(as: UInt16.self, from: &base),
                read(as: UInt16.self, from: &base),
                read(as: UInt16.self, from: &base),
                read(as: UInt16.self, from: &base),
                read(as: UInt16.self, from: &base))

            return Decimal(
                _exponent: exponent,
                _length: length,
                _isNegative: isNegative,
                _isCompact: isCompact,
                _reserved: reserved,
                _mantissa: mantissa)
        }
    }
}

The value is reconstructed on every get:

    var wrappedValue: Decimal {
        get {
            Self.decimal(from: storage)
        }
        nonmutating set {
            Self.writeData(from: newValue, into: &storage)
        }
    }

is there anyway to avoid this overhead? Is there anyway to "cache" the new value on set and just return this value on get so to avoid repeatedly reconstructing a new one? Adding:

    var cached: Decimal

doesn't work because nonmutating set:

self is immutable, make set mutating

I am not fluent with property wrappers and would also like to know the best way. Until that's found I'd use something concise and simple like this:

class AppData: ObservableObject {
    @AppStorage("date") var dateValue = Date().timeIntervalSince1970
    @AppStorage("decimal") var decimalValue = 0.0
    @AppStorage("name") var name = "Paul"
    
    var date: Date {
        get { Date(timeIntervalSince1970: dateValue) }
        set { dateValue = newValue.timeIntervalSince1970 }
    }
    
    var decimal: Decimal {
        get { Decimal(decimalValue) }
        set { decimalValue = (newValue as NSDecimalNumber).doubleValue }
    }
}

or this, which is more "DRY":

import SwiftUI

class AppData: ObservableObject {
    @AppStorage("date") var dateValue = Date().timeIntervalSince1970
    @AppStorage("decimal") var decimalValue = 0.0
    @AppStorage("name") var name = "Paul"
}

struct Child: View {
    @EnvironmentObject private var appData: AppData
    var body: some View {
        VStack {
            Text(appData.dateValue.dateProxy, format: .iso8601)
            Text(appData.decimalValue.decimalProxy, format: .number)
            Text(appData.name)
        }
        .background(Color.indigo)
    }
}

struct ContentView: View {
    @StateObject private var appData = AppData()

    var body: some View {
        VStack {
            Text("Hello, world!")
                .padding()
            Text(appData.dateValue.dateProxy, format: .iso8601)
            DatePicker("Date", selection: $appData.dateValue.dateProxy)
            Text(appData.decimalValue.decimalProxy, format: .number)
            TextField("Enter", value: $appData.decimalValue.decimalProxy, format: .number, prompt: Text("Decimal"))
            Text(appData.name)
            TextField("Name", text: $appData.name)
            Divider()
            Child()
        }
        .environmentObject(appData)
    }
}

// Utils.swift
extension Double {
    var decimalProxy: Decimal {
        get { Decimal(self) }
        set { self = (newValue as NSDecimalNumber).doubleValue }
    }
    var dateProxy: Date {
        get { Date(timeIntervalSince1970: self) }
        set { self = newValue.timeIntervalSince1970 }
    }
}
  • Are you concerned with conversion done on every get but not set?
  • Is the number of extra gets for every set significant?
  • Are you sure this overhead is significant (CPU / energy consumption wise) and checked this in the profiler?

if the answer to these questions is yes, then this can be a solution:

    var date: Date {
        get {
            if _cachedDate == nil {
                _cachedDate = dateValue.dateProxy
            }
            return _cachedDate
        }
        set {
            _cachedDate = nil
            dateValue.dateProxy = newValue
        }
    }
    private var _cachedDate: Date!

    var decimal: Decimal {
        get {
            if _cachedDecimal == nil {
                _cachedDecimal = decimalValue.decimalProxy
            }
            return _cachedDecimal
        }
        set {
            _cachedDecimal = nil
            decimalValue.decimalProxy = newValue
        }
    }
    private var _cachedDecimal: Decimal!

Note, it doesn't optimise repetitive sets, only gets. Hopefully this solution can be wrapped in some nice property wrappers.

I'd only use this optimisation if it impacts performance significantly (I'd be very surprised if it does).

I think set not as big a problem. But get is because this Decimal value is used a lot. Max efficiency is the reason for going through this route. If this is doing so much repeated reconstruction of Decimal in get, then I might as well use your Decimal: RawRepresentable you posted up above.

this is problematic: you can get different value going back and forth through Double. So again, I will just use your Decimal: RawRepresentable: value goes out and come back exactly the same.