Fractional Part of NSDecimalNumber
Asked Answered
D

5

7

I'm using NSDecimalNumber to store a value for currency. I'm trying to write a method called "cents" which returns the decimal portion of the number as an NSString with a leading 0 if the number is < 10. So basically

NSDecimalNumber *n = [[NSDecimalNumber alloc] initWithString:@"1234.55"];

NSString *s = [object cents:n];

And I'm trying to craft a method that will return 01, 02, etc...up to 99 as a string. I can't seem to figure out how to return the mantissa as a string in this format. I feel like I'm missing a convenience method but I looked at the documentation again and don't see anything jumping out at me.

Dilks answered 18/3, 2011 at 17:34 Comment(0)
L
11

Update: This is a relatively old answer, but it looks like people are still finding it. I want to update this for correctness — I originally answered the question as I did simply to demonstrate how one could pull out specific decimal places from a double, but I do not advocate this as a way to represent currency information.

Never use floating-point numbers to represent currency information. As soon as you start dealing with decimal numbers (as you would with dollars with cents), you introduce possible floating point errors into your code. A computer cannot represent all decimal values accurately (1/100.0, for instance, 1 cent, is represented as 0.01000000000000000020816681711721685132943093776702880859375 on my machine). Depending on which currencies you plan on representing, it is always more correct to store a quantity in terms of its base amount (in this case, cents).

If you store your dollar values in terms of integral cents, you'll never run into floating-point errors for most operations, and it's trivially easy to convert cents into dollars for formatting. If you need to apply tax, for instance, or multiply your cents value by a double, do that to get a double value, apply banker's rounding to round to the nearest cent, and convert back to an integer.

It gets more complicated than that if you're trying to support multiple different currencies, but there are ways of dealing with that.

tl;dr Don't use floating-point numbers to represent currency and you'll be much happier, and more correct. NSDecimalNumber is able to accurately (and precisely) represent decimal values, but as soon as you convert to double/float, you run the risk of introducing floating-point errors.


This can be done relatively easily:

  1. Get the double value of the decimal number (this will work assuming the number is not too large to store in a double).
  2. In a separate variable, cast the double to an integer.
  3. Multiply both numbers by 100 to account for loss of precision (essentially, convert to cents) and subtract dollars from the original to get the number of cents.
  4. Return in a string.

(This is a general formula for working with all decimal numbers – working with NSDecimalNumber just requires a little bit of glue code to get it to work).

In practice, it would look like this (in an NSDecimalNumber category):

- (NSString *)cents {
    double value = [self doubleValue];
    unsigned dollars = (unsigned)value;
    unsigned cents = (value * 100) - (dollars * 100);

    return [NSString stringWithFormat:@"%02u", cents];
}
Laic answered 18/3, 2011 at 18:44 Comment(7)
Clever. Though I'm surprised Cocoa doesn't offer a better solution.Anarch
I agree with Stephen. Thanks, I thought I was missing some type of function that would wrap this logic. Works well, despite the hoops.Dilks
using float/double for currency is most probably never a good ideaReaper
@Reaper This was a pretty old answer of mine, but you're correct. I've updated it for reference.Laic
Thanks for caring - your tl;dr is wrong though. NSDecimalNumber does not store values as a floating point number. Using NSDecimalNumber, and never converting it to a float/double gives you exact decimal places. There are also methods on NSDecimalNumber for exact arithmetic.Reaper
@Reaper You're totally right — I don't know why, but I had a completely different implementation of NSDecimal in my head that isn't how it's really implemented at all. I don't know why I remembered it so differently. Fixing right now.Laic
@Reaper I really should have checked before posting. D'oh.Laic
A
11

This is a safer implementation which will work with values that do not fit into a double:

@implementation NSDecimalNumber (centsStringAddition)

- (NSString*) centsString;
{
    NSDecimal value = [self decimalValue];

    NSDecimal dollars;
    NSDecimalRound(&dollars, &value, 0, NSRoundPlain);

    NSDecimal cents;
    NSDecimalSubtract(&cents, &value, &dollars, NSRoundPlain);

    double dv = [[[NSDecimalNumber decimalNumberWithDecimal:cents] decimalNumberByMultiplyingByPowerOf10:2] doubleValue];
    return [NSString stringWithFormat:@"%02d", (int)dv];
}

@end

This prints 01:

    NSDecimalNumber* dn = [[NSDecimalNumber alloc] initWithString:@"123456789012345678901234567890.0108"];
    NSLog(@"%@", [dn centsString]);
Avernus answered 12/8, 2011 at 8:13 Comment(0)
E
2

I don't agree with Itai Ferber's method, because using of doubleValue method of NSNumber can cause lost of precision like 1.60 >> 1.5999999999, so your cents value will be "59". Instead I cut "dollars"/"cents" from string, obtained with NSNumberFormatter:

+(NSString*)getSeparatedTextForAmount:(NSNumber*)amount centsPart:(BOOL)cents
{
    static NSNumberFormatter* fmt2f = nil;
    if(!fmt2f){
        fmt2f = [[NSNumberFormatter alloc] init];
        [fmt2f setNumberStyle:NSNumberFormatterDecimalStyle];
        [fmt2f setMinimumFractionDigits:2]; // |
        [fmt2f setMaximumFractionDigits:2]; // | - exactly two digits for "money" value
    }
    NSString str2f = [fmt2f stringFromNumber:amount];
    NSRange r = [str2f rangeOfString:fmt2f.decimalSeparator];
    return cents ? [str2f substringFromIndex:r.location + 1] : [str2f substringToIndex:r.location];
}
Eer answered 2/3, 2013 at 11:17 Comment(1)
This is the best answer, no conversion to double or any other incorrect currency type and uses NSNumberFormatterPractical
M
2

Swift version, with bonus function for obtaining just the dollar amount too.

extension NSDecimalNumber {

    /// Returns the dollars only, suitable for printing. Chops the cents.
    func dollarsOnlyString() -> String {
        let behaviour = NSDecimalNumberHandler(roundingMode:.RoundDown,
                                               scale: 0, raiseOnExactness: false,
                                               raiseOnOverflow: false, raiseOnUnderflow: false, raiseOnDivideByZero: false)

        let rounded = decimalNumberByRoundingAccordingToBehavior(behaviour)
        let formatter = NSNumberFormatter()
        formatter.numberStyle = .NoStyle
        let str = formatter.stringFromNumber(rounded)
        return str ?? "0"

    }

    /// Returns the cents only, e.g. "00" for no cents.
    func centsOnlyString() -> String {
        let behaviour = NSDecimalNumberHandler(roundingMode:.RoundDown,
                                               scale: 0, raiseOnExactness: false,
                                               raiseOnOverflow: false, raiseOnUnderflow: false, raiseOnDivideByZero: false)

        let rounded = decimalNumberByRoundingAccordingToBehavior(behaviour)
        let centsOnly = decimalNumberBySubtracting(rounded)
        let centsAsWholeNumber = centsOnly.decimalNumberByMultiplyingByPowerOf10(2)
        let formatter = NSNumberFormatter()
        formatter.numberStyle = .NoStyle
        formatter.formatWidth = 2
        formatter.paddingCharacter = "0"
        let str = formatter.stringFromNumber(centsAsWholeNumber)
        return str ?? "00"
    }
}

Tests:

class NSDecimalNumberTests: XCTestCase {

    func testDollarsBreakdown() {
        var amount = NSDecimalNumber(mantissa: 12345, exponent: -2, isNegative: false)
        XCTAssertEqual(amount.dollarsOnlyString(), "123")
        XCTAssertEqual(amount.centsOnlyString(), "45")

        // Check doesn't round dollars up.
        amount = NSDecimalNumber(mantissa: 12365, exponent: -2, isNegative: false)
        XCTAssertEqual(amount.dollarsOnlyString(), "123")
        XCTAssertEqual(amount.centsOnlyString(), "65")

        // Check zeros
        amount = NSDecimalNumber(mantissa: 0, exponent: 0, isNegative: false)
        XCTAssertEqual(amount.dollarsOnlyString(), "0")
        XCTAssertEqual(amount.centsOnlyString(), "00")

        // Check padding
        amount = NSDecimalNumber(mantissa: 102, exponent: -2, isNegative: false)
        XCTAssertEqual(amount.dollarsOnlyString(), "1")
        XCTAssertEqual(amount.centsOnlyString(), "02")
    }
}
Mokas answered 7/3, 2016 at 21:15 Comment(1)
I believe this doesn't work when the number is negative, since RoundDown rounds away from zero then according to <developer.apple.com/reference/foundation/…>. And would be simple to switch between Up or Down rounding modes based on negative value except testing for a negative value is not straightforward, easiest to do doubleValue >= 0. Decimal in Swift 3 now exposes a sign property however (progress!).Ballinger
S
1

I have a different approach. It Works for me but I am not sure if it might cause any problems? I let NSDecimalNumber return the value directly by returning an integer value...

e.G.:

// n is the NSDecimalNumber from above 
NSInteger value = [n integerValue];
NSInteger cents = ([n doubleValue] *100) - (value *100);

Would this approach be more expensive since I am converting NSDecimalNumber two times?

Swetiana answered 12/8, 2011 at 7:21 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.