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

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.

Got you. I still 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?

Can you provide an example of Decimal -> Double -> Decimal conversion giving a different decimal than the original? This looks like a bug worth looking at.

We decided to support either Double or Decimal with:

typealias DataType = Double or Decimal

and write code that allow this change. I can write the same code for either types, except with @AppStorage, which I fix with your implementation of Decimal: RawRepresentable.

Take a look at this and this thread and tell me if I should worry.

It's not particularly hard if we use values outside of Double's range. It sounds contrived, but something as simple as 1 as Decimal / 3 would already get you there.

TBH, that is a very curious requirement. The fact that you are ok with doing Decimal -> Double -> Decimal to me sounds like you don't actually require Decimal precision.

1 Like

I need to check these mentioned examples. Naïve thinking suggests that if you pick "the closest Double" when doing Decimal to Double conversion and then choose "the closest Decimal" when doing the reverse Double to Decimal conversion you should return back to the original number, but apparently it's not so rosy.

The OP didn't answer why they want that. I would assume this has something to do with, say, edit field feeding the value into the model, and if model hold Double your entered 1.23 could be changed to some 1.230000001 when the view is updated back from model, unless you do some rounding, which you want to avoid doing to simplify things. That's a speculation, I didn't have a first hand experience with Decimals to know when they are useful.

The main difference (aside from the apparent precision) is that Decimal uses decimal arithmetic, for lack of a better word, while Double uses floating-point arithmetic. 1.23 won't turn into 1.230000001 on its own. Such transformation usually is a result from some operations.

let x = 1.23
let y = x + 1.01
let z = y - 1.01
print(x, y, z, x == z)
// 1.23 2.24 1.2300000000000002 false

That's where Decimal would shine the most.

let x: Decimal = 1.23
let y = x + 1.01
let z = y - 1.01
print(x, y, z, x == z)
// 1.23 2.24 1.23 true
1 Like

Here's a reimplementation of SwiftUI's AppStorage, complete with initializers for Codable types:

1 Like