7

What is the best way to scale a System.Decimal value by a power of 10 when the number of places is known?

value * (decimal)Math.Pow(10, places) comes to mind, but it suffers from two disadvantages:

  1. It introduces floating point, which makes round-off errors difficult to reason about as the numbers get big.

  2. It seems like overkill to do exponentiation when all you're trying to do is change the simple scale component already encoded in the decimal data structure.

Is there is a better way?

Edward Brey
  • 40,302
  • 20
  • 199
  • 253
  • See http://stackoverflow.com/questions/4005011/is-it-ok-to-use-math-pow-10-n – Magnus May 02 '17 at 21:04
  • Out of interest, why do you need this method? – Wai Ha Lee May 03 '17 at 10:07
  • I'm porting an Android app to Xamarin.Forms. The app uses [Protocol Buffers](https://developers.google.com/protocol-buffers/) to talk to the back end and encodes decimal values as scaled int32s. For some values, the scale is variable and is transmitted as the number of places. On Android, scaling by places is directly supported via [BigDecimal.movePointRight](https://developer.android.com/reference/java/math/BigDecimal.html#movePointRight(int)). – Edward Brey May 03 '17 at 10:28

3 Answers3

3

You could create a table of powers of 10's like:

var pow10s = new int [] { 1, 10, 100, 1000, 10000, 100000, ... };

And then use places as an index into this table:

return value * pow10s[place]

Update: If you don't want the thing to crash when you attempt to index the array past N places, you could take a slightly more sophisticated approach, like so:

public class Power10Scale
{
    private static readonly int[] Pow10s = {
        1, 10, 100, 1000, 10000, 100000,
    };

    public static int Up(int value, int places)
    {
        return Scale(value, places, (x, y) => x * y);
    }

    public static int Down(int value, int places)
    {
        return Scale(value, places, (x, y) => x / y);
    }

    private static int Scale(int value, int places, Func<int, int, int> operation)
    {
        if (places < Pow10s.Length)
            return operation(value, Pow10s[places]);

        return Scale(
            operation(value, Pow10s[Pow10s.Length - 1]),
            places - (Pow10s.Length - 1),
            operation);
    }
}
rtlayzell
  • 608
  • 4
  • 20
  • I could even copy and paste from the [table](https://referencesource.microsoft.com/#mscorlib/system/decimal.cs,0a229c940fc2268a) in the .NET source, just so that I feel better - or would that be worse? :-) – Edward Brey May 02 '17 at 19:27
  • Only remember not to pass anything above 9 as `places` :) – Zdeněk Jelínek May 02 '17 at 19:43
  • You could, I don't see copying a table as particular bad. I might also try to allow overflow – rtlayzell May 02 '17 at 20:10
1

There is no easy way of using the scale component of decimal directly. It represents the divisor exponent: you'd need to subtract from it to get the behavior you want. The value is very often 0 however and the range cannot go below it - you would need to shuffle the bits in mantissa which would hurt a lot.

It seems that the best for readability and precision would be using a powers-of-ten decimals table and multiplication.

Another approach could be taking decimal 1e+28, subtracting the scale from the exponent component and multiply with the scaled number:

public static decimal Scale(decimal number, int places)
{
    const byte MaxDivisorExponent = 28;
    const int e28_low = 268435456;
    const int e28_middle = 1042612833;
    const int e28_high = 542101086;
    var power = new Decimal(e28_low, e28_middle, e28_high, false, (byte)(MaxDivisorExponent - places));

    return number * power;
}

The constants in the constructor are the low, middle and high int-parts that compose the 96bits mantissa of decimal 1e+28 and can be acquired by simply calling Decimal.GetBits().

Not sure I'd like to see this in production though.

Zdeněk Jelínek
  • 2,611
  • 1
  • 17
  • 23
1

You can use decimal.Parse to get the powers of ten out from a string made using Enumerable.Repeat.

One special case which will need to be taken care of is when the absolute value of scale exceeds 28, which should work, e.g.

Scale(1e-28m, +56) == 1e+28
Scale(1e+28m, -56) == 1e-28

because calling decimal.Parse will fail (the intermediate power cannot be represented in a decimal value).


Code:

I've made a fiddle of the code below here.

/// <summary>
/// Scales value to move the decimal point by a certain number of places
/// </summary>
public static decimal Scale(decimal value, int places)
{
    // Handle degenerate case
    if ( value == 0 )
        return 0;

    // Handle the case when the power of ten will overflow.
    // Split the problem up into two calls to Scale.
    if ( Math.Abs(places) > 28 )
    {
        var intermediateNumberOfPlaces = places / 2;
        var intermediateValue = Scale(value, intermediateNumberOfPlaces);
        return Scale(intermediateValue, places - intermediateNumberOfPlaces);
    }

    // Normal cases
    var powerOfTen = getPowerOfTen(Math.Abs(places));
    if ( places > 0 )
        return value * powerOfTen;

    return value / powerOfTen;
}

private static ConcurrentDictionary<int, decimal> powersOfTen = new ConcurrentDictionary<int, decimal>();

private static decimal getPowerOfTen(int power)
{
    return powersOfTen.GetOrAdd(power, p =>
    {
        var powerAsString = "1" + string.Concat(Enumerable.Repeat("0", p));
        return decimal.Parse(powerAsString, CultureInfo.InvariantCulture);
    });
}

This method will handle cases like scale(1e-28m, 56) or scale(1e28m, -56) which (while probably won't happen much) should probably be accounted for.


Tests for Scale:

Here are the validation tests I used to write the code:

Assert.AreEqual(1, Scale(1, 0), "Invariant scale failed");
Assert.AreEqual(0, Scale(0, 100), "Scale of 0 failed");

Assert.AreEqual(100, Scale(1, 2), "Scale(1, 2) failed");
Assert.AreEqual(0.01, Scale(1, -2), "Scale(1, -2) failed");

Assert.AreEqual(1, Scale(0.01m, 2), "Scale(0.01, 2) failed");
Assert.AreEqual(1, Scale(100, -2), "Scale(100, -2) failed");

var large = Scale(1, 28);
var small = Scale(1, -28);

var shouldBeLarge = Scale(small, 56);
var shouldBeSmall = Scale(large, -56);

Assert.AreEqual(large, shouldBeLarge, "scaling 1e-28 by 56 failed");
Assert.AreEqual(small, shouldBeSmall, "scaling 1e28 by -56 failed");
Wai Ha Lee
  • 8,598
  • 83
  • 57
  • 92