Make Float80 Codable

Hi there!

Double and Float both conform to Codable. Why does Float80 not conform to Codable? Is there any specific reason?

Best regards!
strike

There's nothing really keeping Float80 from conforming to Codable short of us needing to decide what the encoded representation might look like. Because it isn't a Codable primitive like Float and Double, it would need a concrete implementation of init(from:) and encode(to:).

There are a few options there but we'd need to decide on the best way to do it. Any thoughts?

1 Like

Ok.

Float80 conforms to LosslessStringConvertible. Well. My private extension to make Float80 conforming to Codable looks just like a crowbar and fits for my needs ("There is never ever a optional float outta here."):

Please do not laugh!

extension Float80: Codable {
    private enum CodingKeys: String, CodingKey {
    case string = "stringValue"
   }
   public func encode(to encoder: Encoder) throws {
       var container = encoder.container(keyedBy: CodingKeys.self)
       let stringRep: String = "\(self)"  /* Float80 conforms to LosslessStringConvertible */
       try container.encode(stringRep, forKey: .string)
   }

   public init(from decoder: Decoder) throws {
       let container = try decoder.container(keyedBy: CodingKeys.self)
       let string = try container.decode(String.self, forKey: .string)
       if let f = Float80.init(string) {
           self = f
       }
       else {
           self = Float80.nan
       }
   }
}

That's certainly a legitimate way to encode a Float80, and may be slightly less ambiguous than encoding as a string directly (e.g. encoder.singleValueContainer().encode("\(self)")); it also gives us the flexibility to change representations in the future in a container-type-compatible way.

Another option would be to somehow separate the value into two Double components and encode as a two-value array (since we don't have tuples), which may save a bit on space. /cc @scanon Thoughts/preferences on how we might represent this efficiently?

You can't easily split a Float80 into two Doubles. If you wanted to do something like that, splitting the encoding into a UInt16 and a UInt64 would be a better option, and somewhat more efficient than encoding as a string. The downside would be that you'd give up the easy human-readability, and it would generally be not very useful for anything else. So string seems like a good solution.

2 Likes

Hi!

Would it be worth to make a proposal?

Best regards!

strike

This might even be something that could be taken as a bug-fix (no proposal needed). @itaiferber?

1 Like

I think we would need a proposal here as this is an API change (and a breaking one at that for anyone who’s written their own Float80 conformance to Codable). Should be a simple pitch though!

Is there some way that Codable could handle everything that is LosslessStringConvertible?

Sure! We could have extension LosslessStringConvertible where Self: Codable { ... } to give a default implementation to all LosslessStringConvertible types. The question would be whether a generalization like this would be more appropriate than requiring those types to define their conformances.

Another consideration would be whether this extension would conflict with other similar extensions — for instance, we have extensions on RawRepresentable types whose RawValue types are primitive Codable types. If you have a type which matches that criteria and is also LosslessStringConvertible, which implementation wins out? (It’d be ambiguous and you’d have to define your own, IIRC, losing out on the benefit of both default implementations.)

1 Like

Right, and more generally, I think it's fair to say that even if it could be done without ambiguity, it's still a question whether it should be done.

In general, I would argue that the two are independent features. It's likely the minority case that both converge such that the string description is also the exact way one prefers to encode a type independent of output format. It's probably quite appropriate for standard library primitive types not otherwise supported by Codable. But for third-party types, I think a user should actively choose this implementation if it makes sense and override any synthesized conformance, rather than having a default conformance make that assumption and trump the synthesized one.

2 Likes

Agreed! To clarify, more strongly, I think Codable and LosslessStringConvertible are orthogonal features which may overlap, but I don't think should be made to. (This topic has come up in the past in a slightly different context but in general I think it would be more appropriate for developers to provide appropriate intent by disambiguating on their own.)

2 Likes

I think the following is a viable implementation:

public extension LosslessStringConvertible where Self: Encodable {
    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode("\(self)")
    }
}
public extension LosslessStringConvertible where Self: Decodable {
    init(from decoder: Decoder) throws {
        let stringRep = try decoder.singleValueContainer().decode(String.self)
        self.init(stringRep)!
    }
}

// Opt in.
extension Float80: Codable {}

// Test.
import Foundation

let f80 = Float80(3.142)
let encodedData = try JSONEncoder().encode(f80)
print(String(data: encodedData, encoding: .utf8)!)

let decodedF80 = try JSONDecoder().decode(Float80.self, from: encodedData)
print(decodedF80 == f80)

The difference is that you have to opt in. Therefore concerns about all LosslessStringConvertibles suddenly becoming Codable are mitigated and you can still do your own implementation if you wish.

Unfortunately I think there is a Codable bug, see Problem with Encoder.singleValueContainer, and so the above currently doesn't work :(.

1 Like

The issue with doing this unfortunately is not that it opts LosslessStringConvertible types into Codable automatically (since that can’t be done unless we make LosslessStringConvertible inherit from Codable) but that you cannot be Codable and LosslessStringConvertible and ask the compiler to synthesize an implementation of Codable (since the implementation is already given by the extension). This tightly couples the intent of adopting LosslessStringConvertible with the intent of adopting Codable, which is not necessarily correct (nor what developers want).

(As far as the JSONEncoder issue specifically goes — see my reply in the linked thread.)

2 Likes

First a version that doesn't use a single container and therefore works with Swift 4:

fileprivate enum StringCoding: String, CodingKey {
    case key = "stringLiteral"
}
public extension LosslessStringConvertible where Self: Encodable {
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: StringCoding.self)
        try container.encode("\(self)", forKey: .key)
    }
}
public extension LosslessStringConvertible where Self: Decodable {
    init(from decoder: Decoder) throws {
        let stringRep = try decoder.container(keyedBy: StringCoding.self).decode(String.self, forKey: .key)
        self.init(stringRep)!
    }
}

// Opt in.
extension Float80: Codable {}

// Test.
import Foundation

let f80 = Float80(3.142)
let encodedF80 = try JSONEncoder().encode(f80)
print(String(data: encodedF80, encoding: .utf8)!) // {"stringLiteral":"3.141999999999999904"}

let decodedF80 = try JSONDecoder().decode(Float80.self, from: encodedF80)
print(decodedF80 == f80) // T

You have to opt in, e.g. the following is LosslessStringConvertible but not Codable because it is not declared as Codable:

// Check that you can still write a Lossless... that isn't Codable.
struct StringOnly: LosslessStringConvertible { // Not Codable.
    let f: Float80
    init?(_ description: String) {
        let optional = Float80(description)
        guard let value = optional else {
            return nil
        }
        f = value
    }
    var description: String {
        return f.description
    }
}

let sO = StringOnly("3.141999999999999904")!
print(sO is Codable) // F

Also you can still write your own implementation if you need to:

struct StringAndCodable: LosslessStringConvertible, Codable, Equatable {
    let f: Float80
    init?(_ description: String) {
        let optional = Float80(description)
        guard let value = optional else {
            return nil
        }
        f = value
    }
    var description: String {
        return f.description
    }
    enum Key: String, CodingKey {
        case key = "key"
    }
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: Key.self)
        try container.encode("\(self)", forKey: .key)
    }
    init(from decoder: Decoder) throws {
        let stringRep = try decoder.container(keyedBy: Key.self).decode(String.self, forKey: .key)
        self.init(stringRep)!
    }
}

let sAC = StringAndCodable("3.141999999999999904")
let encodedSAC = try JSONEncoder().encode(sAC)
print(String(data: encodedSAC, encoding: .utf8)!) // {"key":"3.141999999999999904"} - "key" not "stringLiteral"

let decodedSAC = try JSONDecoder().decode(StringAndCodable.self, from: encodedSAC)
print(sAC == decodedSAC) // T

Therefore I think the extension as proposed are viable. The tricky bit of the code is that extensions to Lossless... constrain Self to be Codable and are therefore opt-in.

1 Like

Indeed — you can always override an implementation given in an extension this way, which is good! The non-starter here is a situation like the following:

import Foundation

struct S : Codable {
    let foo: Int
    let bar: String
}

let data = try JSONEncoder().encode(S(foo: 42, bar: "hello"))
try data.write(to: URL(fileURLWithPath: "/tmp/somefile"))

// elsewhere
let stored = try Data(contentsOf: URL(fileURLWithPath: "/tmp/somefile"))
let s = try JSONDecoder().decode(S.self, from: stored)
print(s) // => S(foo: 42, bar: "hello")

In version 2 of my app, LosslessStringConvertible conformance is added to S for completely unrelated purposes:

// Given these extensions:
extension LosslessStringConvertible where Self : Encodable { ... }
extension LosslessStringConvertible where Self : Decodable { ... }

extension S : LosslessStringConvertible { ... }

// Oops: Error raised at top level: Swift.EncodingError.invalidValue(<s foo:42 bar:"hello">, Swift.EncodingError.Context(codingPath: [], debugDescription: "Top-level S encoded as string JSON fragment.", underlyingError: nil))
let data = try JSONEncoder().encode(S(foo: 42, bar: "hello"))
try data.write(to: URL(fileURLWithPath: "/tmp/somefile"))

// Elsewhere:
let stored = try Data(contentsOf: URL(fileURLWithPath: "/tmp/somefile"))
let s = try JSONDecoder().decode(S.self, from: stored) // Oops: Error raised at top level: Swift.DecodingError.typeMismatch(Swift.String, Swift.DecodingError.Context(codingPath: [], debugDescription: "Expected to decode String but found a dictionary instead.", underlyingError: nil))
print(s)

Where S was previously depending on the compiler-synthesized implementation of Codable, it now gets the implementation as defined by the LosslessStringConvertible extensions, with no way to request the old serialization format without overriding and implementing it manually. Worse is that this is an implicit change — there is no indication that anything is different until I actually try to encode and decode, at which point it might be too late: files written to disk may be in a new, undesired format, and old files are no longer readable.

Perhaps this behavior is a good indicator for needing a strong, explicit signal to the compiler that synthesized behavior is desired, rather than the more implicit request-via-adoption.

5 Likes

Hi There!
Thank you very much for your thoughts. I see that it''s rather complicated.
Best regards!

strike

Yes you are right. Unlikely as the scenario is, it is possible and therefore needs to be guarded against. I see this as a bug/limitation of Swift though, because:

protocol A {}
extension A {
    func f() -> String {
        return "A"
    }
}
protocol B {}
extension B {
    func f() -> String {
        return "B"
    }
}
struct S: A, B {}
let s = S()
s.f() // Ambiguous

Will report the ambiguity and you can resolve it (I view this as much the same - two competing implementations are inherited):

struct S: A, B {
    func f() -> String {
        return (self as A).f()
    }
}

For Codable the compiler should report the ambiguity and allow a resolution, e.g:

struct S: LosslessStringConvertible, Codable {
    func encode(to encoder: Encoder) throws {
        (self as LosslessStringConvertible).encode(to: encoder)
    }
}

Or

struct S:  LosslessStringConvertible, Codable {
    func encode(to encoder: Encoder) throws {
        (self as #GeneratedEncodable).encode(to: encoder)
    }
}

Or a shorthand for the latter could be:

struct S: LosslessStringConvertible, #GeneratedCodable {}

Where #GeneratedCodable is a pseudo compiler generated protocol with the default version of codable.

Your first example shows protocol extension methods that are statically dispatched, hence the ambiguity, whereas encode(to:) is a protocol requirement and dynamically dispatched--even if you could spell it, both spellings would necessarily dispatch to the same method (if it would work at all--"as LosslessStringConvertible" would not as written because there is no encode(to:) method for that type). This goes back to the point that these are distinct protocols for distinct purposes.

1 Like

Yes you are right. You need something like:

struct S: LosslessStringConvertible, Codable {
    func encode(to encoder: Encoder) throws {
        super.LosslessStringConvertible.encode(to: encoder)
    }
}

or

struct S:  LosslessStringConvertible, Codable {
    func encode(to encoder: Encoder) throws {
        super.#GeneratedEncodable.encode(to: encoder)
    }
}

This ability to specify which super has come up already in a number of threads as a useful feature.