Search code examples
swiftdecoding

Does Decoder require an external data representation that has keys?


What have I done?

I read over Apple's documentation on the topic.

Details

The Swift standard library defines a standardized approach to data encoding and decoding.

I realized that all the examples I have come across decode data from an external data representation that has keys like JSON, PLIST, and XML (custom).

I came across a use case where the external data representation doesn't have keys. For example, when reading data from bluetooth devices. You receive data formatted in an Array of UInt8.

When exploring Apple's documentation and custom Decoder implementations I realized that all external data representations have keys.

I am wondering if Apple's solution leaves enough wiggle room to work with an external data representation that doesn't use keys i.e an Array of UInt8.


Solution

  • I think you might have misunderstood what the Codable API is for. It is not for writing the logic for parsing and writing the binary data - you still have to write that yourself.

    Codable allows you to encode and decode in a more abstract way, through keyed, unkeyed, and single value containers. You can say things like:

    • get a keyed container with these keys
    • get a unkeyed container
    • encode XXX with YYY key
    • decode a value with XXX key as YYY type
    • encode XXX as the next value of this unkeyed container
    • decode the next value of this unkeyed container as XXX type

    You write how you want your models to be encoded/decoded in terms of these operations, then the Decoder/Encoder will do the corresponding thing to the underlying binary data.

    For example, if you want to decode a JSON array with 5 integers, into a struct with 5 Int properties, you write:

    struct MyModel: Decodable {
        let a, b, c, d, e: Int
        
        init(from decoder: Decoder) throws {
            var container = try decoder.unkeyedContainer()
            a = try container.decode(Int.self)
            b = try container.decode(Int.self)
            c = try container.decode(Int.self)
            d = try container.decode(Int.self)
            e = try container.decode(Int.self)
        }
    }
    

    For JSON, unkeyedContainer() means "get ready to decode a JSON array", and "decode(Int.self)" means "read the next array element as an Int". Note that you don't have to write how exactly to parse the JSON.

    Decoder/Encoder implementations doesn't have to support all of those operations. If the implementation doesn't support some method for any reason, they can throw an error when that method is called.

    For a series of bytes, you can implement your own Decoder that supports unkeyedContainer() only, and decode(T.self) would read x number of bytes depending on T, and convert that into the correct type, a bit like a BinaryReader.

    // just rough sketch of an implementation, not complete
    struct _BinaryDecoder: Decoder, UnkeyedDecodingContainer {
        var isAtEnd: Bool {
            data.count >= currentIndex
        }
        
        var currentIndex: Int = 0
        
        func hasNBytes(_ n: Int) -> Bool {
            data.count - currentIndex >= n
        }
        
        mutating func decode(_ type: Float.Type) throws -> Float {
            guard hasNBytes(4) else { throw someError }
            let f = data.withUnsafeBytes { buffer in
                buffer.loadUnaligned(fromByteOffset: currentIndex, as: Float.self)
            }
            currentIndex += 4
            return f
        }
        
        mutating func decode(_ type: Int32.Type) throws -> Int32 {
            guard hasNBytes(4) else { throw someError }
            let i = data.withUnsafeBytes { buffer in
                buffer.loadUnaligned(fromByteOffset: currentIndex, as: Int32.self)
            }
            currentIndex += 4
            return i
        }
        
        let data: Data
        
        func unkeyedContainer() throws -> UnkeyedDecodingContainer {
            self
        }
    }
    
    struct BinaryDecoder {
        func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
            try T.init(from: _BinaryDecoder(data: data))
        }
    }
    

    You can then implement init(from:) in your model like this:

    var container = try decoder.unkeyedContainer()
    let flags = try container.decode(UInt16.self)
    switch flags {
        // use container.decode to initialise different properties based on flags
        // e.g. if the next bytes represent two 32-bit signed ints, and then a float
        case 0:
            property1 = try container.decode(Int32.self)
            property2 = try container.decode(Int32.self)
            property3 = try container.decode(Float.self)
    }
    

    Though, if you are only ever going to decode this one type of binary data, it's probably easier to just write your decoding logic without using Codable.