How to determine the true data type of an NSNumber?

ios, json, nsnumber, objective-c

Solution

As documented in NSDecimalNumber.h, `NSDecimalNumber` always returns `"d"` for it's return type. This is expected behavior.

- (const char *)objCType NS_RETURNS_INNER_POINTER;
    // return 'd' for double

And also in the Developer Docs:

Returns a C string containing the Objective-C type of the data contained in the
receiver, which for an NSDecimalNumber object is always “d” (for double).

`CFNumberGetValue` is documented to return false if the conversion was lossy. In the event of a lossy conversion, or when you encounter an `NSDecimalNumber`, you will want to fall back to using the stringValue and then use `sqlite3_bind_text` to bind it (and use sqlite's column affinity).

Something like this:

NSNumber *number = ...
BOOL ok = NO;

if (![number isKindOfClass:[NSDecimalNumber class]]) {
    CFNumberType numberType = CFNumberGetType(number);

    if (numberType == kCFNumberFloat32Type ||
        numberType == kCFNumberFloat64Type ||
        numberType == kCFNumberCGFloatType)
    {
        double value;
        ok = CFNumberGetValue(number, kCFNumberFloat64Type, &value);

        if (ok) {
            ok = (sqlite3_bind_double(pStmt, idx, value) == SQLITE_OK);
        }

    } else {
        SInt64 value;
        ok = CFNumberGetValue(number, kCFNumberSInt64Type, &value);

        if (ok) {
            ok = (sqlite3_bind_int64(pStmt, idx, value) == SQLITE_OK);
        }
    }
}

// We had an NSDecimalNumber, or the conversion via CFNumberGetValue() was lossy.
if (!ok) {
    NSString *stringValue = [number stringValue];
    ok = (sqlite3_bind_text(pStmt, idx, [stringValue UTF8String], -1, SQLITE_TRANSIENT) == SQLITE_OK);
}

Problem

Consider this code: ``` NSNumber* interchangeId = dict[@"interchangeMarkerLogId"]; long long llValue = [interchangeId longLongValue]; double dValue = [interchangeId doubleValue]; NSNumber* doubleId = [NSNumber numberWithDouble:dValue]; long long llDouble = [doubleId longLongValue]; if (llValue > 1000000) { NSLog(@"Have Marker iD = %@, interchangeId = %@, long long value = %lld, doubleNumber = %@, doubleAsLL = %lld, CType = %s, longlong = %s", self.iD, interchangeId, llValue, doubleId, llDouble, [interchangeId objCType], @encode(long long)); } ``` The results: Have Marker iD = (null), interchangeId = 635168520811866143, long long value = 635168520811866143, doubleNumber = 6.351685208118661e+17, doubleAsLL = 635168520811866112, CType = d, longlong = q `dict` is coming from NSJSONSerialization, and the original JSON source data is `"interchangeId":635168520811866143`. It appears that all 18 digits of the value have been captured in the NSNumber, so it could not possibly have been accumulated by NSJSONSerialization as a `double` (which is limited to 16 decimal digits). Yet, objCType is reporting that it's a `double`. We find this in the documentation for NSNumber: "The returned type does not necessarily match the method the receiver was created with." So apparently this is a "feechure" (i.e., documented bug). So how can I determine that this value originated as an integer and not a floating point value, so I can extract it correctly, with all the available precision? (Keep in mind that I have some other values that are legitimately floating-point, and I need to extract those accurately as well.) I've come up with two solutions so far: The first, which does not make use of knowledge of NSDecimalNumber -- ``` NSString* numberString = [obj stringValue]; BOOL fixed = YES; for (int i = 0; i < numberString.length; i++) { unichar theChar = [numberString characterAtIndex:i]; if (theChar != '-' && (theChar < '0' || theChar > '9')) { fixed = NO; break; } } ``` The second, which assumes that we only need worry about NSDecimalNumber objects, and can trust the CType results from regular NSNumbers -- ``` if ([obj isKindOfClass:[NSDecimalNumber class]]) { // Need to determine if integer or floating-point. NSDecimalNumber is a subclass of NSNumber, but it always reports it's type as double. NSDecimal decimalStruct = [obj decimalValue]; // The decimal value is usually "compact", so may have a positive exponent even if integer (due to trailing zeros). "Length" is expressed in terms of 4-digit halfwords. if (decimalStruct._exponent >= 0 && decimalStruct._exponent + 4 * decimalStruct._length < 20) { sqlite3_bind_int64(pStmt, idx, [obj longLongValue]); } else { sqlite3_bind_double(pStmt, idx, [obj doubleValue]); } } else ... handle regular NSNumber by testing CType. ``` The second should be more efficient, especially since it does not need to create a new object, but is slightly worrisome in that it depends on "undocumented behavior/interface" of NSDecimal -- the meanings of the fields are not documented anywhere (that I can find) and are said to be "private". Both appear to work. Though on thinking about it a bit -- The second approach has some "boundary" problems, since one can't readily adjust the limits to assure that the maximum possible 64-bit binary int will "pass" without risking loss of a slightly larger number. Rather unbelievably, this scheme fails in some cases: ``` BOOL fixed = NO; long long llValue = [obj longLongValue]; NSNumber* testNumber = [[NSNumber alloc] initWithLongLong:llValue]; if ([testNumber isEqualToNumber:obj]) { fixed = YES; } ``` I didn't save the value, but there is one for which the NSNumber will essentially be unequal to itself -- the values both display the same but do not register as equal (and it is certain that the value originated as an integer). This appears to work, so far: ``` BOOL fixed = NO; if ([obj isKindOfClass:[NSNumber class]]) { long long llValue = [obj longLongValue]; NSNumber* testNumber = [[[obj class] alloc] initWithLongLong:llValue]; if ([testNumber isEqualToNumber:obj]) { fixed = YES; } } ``` Apparently `isEqualToNumber` does not work reliably between an NSNumber and an NSDecimalNumber. (But the bounty is still open, for the best suggestion or improvement.)

Original source

Related problems