Search code examples
jsonswiftfloating-pointcodablejsondecoder

Swift not converting specific values to a float


My code is not working to convert 1.1 to a float. It works to convert 1.0, 1.25, and 1.5 to floats but doesn't convert 1.1. I do not understand why this would happen as they are all decimals with very few digits of precision required. This is when decoding from a JSON.

The code in question:

if let path = Bundle.main.path(forResource: "moves", ofType: "json") {
    do {
        let data = try Data(contentsOf: URL(fileURLWithPath: path))
        let json = try JSONSerialization.jsonObject(with: data, options: [])
        if let array = json as? [[String:Any]] {
            for dict in array {
                if let actionName = dict["name"] as? String,
                   let type = dict["type"] as? String,
                   let amt = dict["amtlost"] as? Int,
                   let val = dict["atkdefval"] as? Float,
                   let dtype = dict["element"] as? String,
                   let target = dict["target"] as? Bool,
                   let steal = dict["stealAmt"] as? Float{
                    
                    let cmove = Actions(name: actionName, type: type, amtlost: amt, atkdefval: val, damageType: dtype, target: target, stealAmt: steal)
                    self.moves.append(cmove)
                } else {
                    print(dict)
                    let val = dict["atkdefval"] as? Float
                    print(val)
                }
            }
        }
    } catch {
        print("Error reading location JSON file: \(error)")
    }
} else {
    print("Could not read JSON")
}
print(self.moves.count)
for move in moves {
    print(move.name)
    print(move.atkdefval)
}

The JSON:

[
    {
        "name": "Slash",
        "type": "phys",
        "amtlost": 0,
        "atkdefval": 1.5,
        "element": "slashing",
        "target": false,
        "stealAmt": 0
    },
    {
        "name": "Punch",
        "type": "phys",
        "amtlost": 0,
        "atkdefval": 1.1,
        "element": "bludgeoning",
        "target": false,
        "stealAmt": 0
    },
    {
        "name": "Magic Missile",
        "type": "magic",
        "amtlost": 2,
        "atkdefval": 1.75,
        "element": "force",
        "target": false,
        "stealAmt": 0
    },
    {
        "name": "Block",
        "type": "defense",
        "amtlost": 0,
        "atkdefval": 1.5,
        "element": "bludgeoning",
        "target": false,
        "stealAmt": 0
    },
    {
        "name": "Healing Word",
        "type": "healing",
        "amtlost": 2,
        "atkdefval": 1.25,
        "element": "light",
        "target": false,
        "stealAmt": 0
    },
    {
        "name": "Shield",
        "type": "shield",
        "amtlost": 1,
        "atkdefval": 1.5,
        "element": "force",
        "target": false,
        "stealAmt": 0
    }
]

The class code (in case it helps):

enum ActionType:String, Codable{
    case phys = "phys"
    case magic = "magic"
    case defense = "defense"
    case healing = "healing"
    case shield = "shield"
}

enum DamageElement: String, Codable{
    case slash = "slashing"
    case pierce = "piercing"
    case bludgeon = "bludgeoning"
    case fire = "fire"
    case ice = "ice"
    case earth = "earth"
    case lightning = "lightning"
    case light = "light"
    case dark = "dark"
    case force = "force"
}

class Actions: Codable, Hashable, Equatable{
    var name: String
    var type: ActionType
    var amtlost: Int
    var atkdefval: Float
    var element: DamageElement
    var target: Bool // false is hp, true is mp
    var stealAmt: Float
    
    static func == (lhs: Actions, rhs: Actions) -> Bool {
        return lhs.name == rhs.name
    }
    
    func hash(into hasher: inout Hasher) { return hasher.combine(ObjectIdentifier(self))}
    
    init(){
        self.name = "punch"
        self.type = .phys
        self.amtlost = 0
        self.atkdefval = 4
        self.element = .bludgeon
        self.target = false
        self.stealAmt = 0
    }
    
    init(name: String, type: String, amtlost: Int, atkdefval: Float, damageType: String, target: Bool, stealAmt: Float) {
        self.name = name
        self.type = ActionType(rawValue: type.lowercased())!
        self.amtlost = amtlost
        self.atkdefval = atkdefval
        self.element = DamageElement(rawValue: damageType.lowercased())!
        self.target = target
        self.stealAmt = stealAmt
    }
    
}

I tried to have it convert from a JSON dict as a float but it converted as nil instead of the expected 1.1. I tried converting it to a string from the JSON and to a float from there and it worked but it is confusing as to why the first way doesn't work.


Solution

  • You should use JSONDecoder as McKinley describes, but the reason this fails for 1.1 is because the default internal type is Double and the (rounded) Double value 1.1 doesn't fit in Float exactly. Consider:

    import Foundation
    NSNumber(1.1) as? Double    // 1.1
    NSNumber(1.1) as? Float     // nil
    NSNumber(1.5) as? Double    // 1.5
    NSNumber(1.5) as? Float     // 1.5
    

    1.5 can be expressed precisely in both Float and Double, so it converts.

    JSONSerialization packs everything into an NSNumber, and that won't as? bridge to Float if it requires rounding. You could write it this way (but I don't recommend it):

        if let actionName = dict["name"] as? String,
           let type = dict["type"] as? String,
           let amt = dict["amtlost"] as? Int,
           let val = (dict["atkdefval"] as? NSNumber)?.floatValue, // <==
           let dtype = dict["element"] as? String,
           let target = dict["target"] as? Bool,
           let steal = (dict["stealAmt"] as? NSNumber)?.floatValue // <==
    

    You can also fix this by using Double everywhere rather than Float, and you should do that anyway. When in doubt, use Double in Swift for floating point.

    But in addition, the better solution is to use JSONDecoder which avoids these subtle problems. Since all the types are already Decodable, your entire decoding logic can be replaced by:

    self.moves = try JSONDecoder().decode([Actions].self, from: data)