9

I'd like to create a function formatFloat() which takes any float and formats it as a decimal expansion string. For example:

formatFloat(1.0E+25);  // "10,000,000,000,000,000,000,000,000"
formatFloat(1.0E+24);  // "1,000,000,000,000,000,000,000,000"

formatFloat(1.000001);      // "1.000001"
formatFloat(1.000001E-10);  // "0.0000000001000001"
formatFloat(1.000001E-11);  // "0.00000000001000001"

Initial ideas

Simply casting the float to a string won't work, because for floats larger than about 1.0E+14, or smaller than about 1.0E-4, PHP renders them in scientific notation instead of decimal expansion.

number_format() is the obvious PHP function to try. However, this problem occurs for large floats:

number_format(1.0E+25);  // "10,000,000,000,000,000,905,969,664"
number_format(1.0E+24);  // "999,999,999,999,999,983,222,784"

For small floats, the difficulty is choosing how many decimal digits to ask for. One idea is to ask for a large number of decimal digits, and then rtrim() the excess 0s. However, this idea is flawed because the decimal expansion often doesn't end with 0s:

number_format(1.000001,     30);  // "1.000000999999999917733362053696"
number_format(1.000001E-10, 30);  // "0.000000000100000099999999996746"
number_format(1.000001E-11, 30);  // "0.000000000010000010000000000321"

The problem is that a floating point number has limited precision, and is usually unable to store the exact value of the literal (eg: 1.0E+25). Instead, it stores the closest possible value which can be represented. number_format() is revealing these "closest approximations".

Timo Frenay's solution

I discovered this comment buried deep in the sprintf() page, surprisingly with no upvotes:

Here is how to print a floating point number with 16 significant digits regardless of magnitude:

$result = sprintf(sprintf('%%.%dF', max(15 - floor(log10($value)), 0)), $value);

The key part is the use of log10() to determine the order of magnitude of the float, to then calculate the number of decimal digits required.

There are a few bugs which need fixing:

  • The code doesn't work for negative floats.
  • The code doesn't work for extremely small floats (eg: 1.0E-100). PHP reports this notice: "sprintf(): Requested precision of 116 digits was truncated to PHP maximum of 53 digits"
  • If $value is 0.0, then log10($value) is -INF.
  • Since the precision of a PHP float is "roughly 14 decimal digits", I think 14 significant digits should be displayed instead of 16.

My best attempt

This is the best solution I've come up with. It's based on Timo Frenay's solution, fixes the bugs, and uses ThiefMaster's regex for trimming excess 0s:

function formatFloat($value)
{
    if ($value == 0.0)  return '0.0';

    $decimalDigits = max(
        13 - floor(log10(abs($value))),
        0
    );

    $formatted = number_format($value, $decimalDigits);

    // Trim excess 0's
    $formatted = preg_replace('/(\.[0-9]+?)0*$/', '$1', $formatted);

    return $formatted;
}

Here's an Ideone demo with 200 random floats. The code seems to work correctly for all floats smaller than about 1.0E+15.

It's interesting to see that number_format() works correctly for even extremely small floats:

formatFloat(1.000001E-250);  // "0.0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000001"

The question

My best attempt at formatFloat() still suffers from this problem:

formatFloat(1.0E+25);  // "10,000,000,000,000,000,905,969,664"
formatFloat(1.0E+24);  // "999,999,999,999,999,983,222,784"

Is there an elegant way to improve the code to solve this problem?

Community
  • 1
  • 1
TachyonVortex
  • 8,242
  • 3
  • 48
  • 63
  • what you are looking for, is something like bcmath ; I myself wrote a decimal_number interface for php ; 15 years ago ; still have the code but I don't know if it is still accurate... ; going thru log10 is only making you looping-back to your first issue. Best. – guest Oct 16 '16 at 18:51

3 Answers3

2

This piece of code seems to do the job too. I don't think I managed to make it any more elegant than yours, but I spent so much time on it that I can't just throw it away :)

function formatFloat(
    $value,
    $noOfDigits = 14,
    $separator = ',',
    $decimal = '.'
) {

    $exponent = floor(log10(abs($value)));
    $magnitude = pow(10, $exponent);

    // extract the significant digits
    $mantissa = (string)abs(round(($value /  pow(10, $exponent - $noOfDigits + 1))));
    $formattedNum = '';

    if ($exponent >= 0) { // <=> if ($value >= 1)

        // just for pre-formatting
        $formattedNum = number_format($value, $noOfDigits - 1, $decimal, $separator);

        // then report digits from $mantissa into $formattedNum
        $formattedLen = strlen($formattedNum);
        $mantissaLen = strlen($mantissa);
        for ($fnPos = 0, $mPos = 0; $fnPos <  $formattedLen; $fnPos++, $mPos++) {

            // skip non-digit
            while($formattedNum[$fnPos] === $separator || $formattedNum[$fnPos] === $decimal || $formattedNum[$fnPos] === '-') {
                $fnPos++;
            }
            $formattedNum[$fnPos] = $mPos < $mantissaLen ? $mantissa[$mPos] : '0';

        }

    } else { // <=> if ($value < 1)

        // prepend minus sign if necessary
        if ($value < 0) {
            $formattedNum = '-';
        }
        $formattedNum .= '0' . $decimal . str_repeat('0', abs($exponent) - 1) . $mantissa;

    }

    // strip trailing decimal zeroes
    $formattedNum = preg_replace('/\.?0*$/', '', $formattedNum);

    return $formattedNum;

}
RandomSeed
  • 29,301
  • 6
  • 52
  • 87
  • Yes I realise that a PHP float can't represent `1.0E+25` precisely. So what's needed is an algorithm which takes the imprecise float representation of `1.0E+25`, and converts it to a string representation rounded to 14 significant digits. It _is_ possible to create a function which does this and only accepts a native PHP float as the input parameter (as [my inelegant answer](http://stackoverflow.com/a/22390446/1851186) demonstrates). – TachyonVortex Mar 13 '14 at 21:15
  • @TachyonVortex ... and I realise I didn't understand your question in the first place. Now I understand from your code. I tried very hard to find a better way to do it, but this is all I could put together (see edit). – RandomSeed Mar 14 '14 at 04:51
  • Many thanks for your code, and for all the time and effort that you put into it - it's much appreciated! I'm interested to see your approach, using `number_format()` to do the pre-formatting, and then either copying digits from the mantissa, or copying `0`s if there are no mantissa digits left. – TachyonVortex Mar 16 '14 at 11:55
  • Script on linked page has some problems: WARNING Division by zero on line number 83 WARNING str_repeat() expects parameter 2 to be integer, float given on line number 110 0 --> 0.0 --> 0.NAN WARNING Division by zero on line number 83 WARNING str_repeat() expects parameter 2 to be integer, float given on line number 110 0 --> 0.0 --> 0.NAN WARNING Division by zero on line number 83 WARNING str_repeat() expects parameter 2 to be integer, float given on line number 110 – MERT DOĞAN Mar 01 '18 at 13:11
  • I don't have so much time to edit and test that is it corrected perfectly. If you have a solved version, i will thank you for your share. – MERT DOĞAN Mar 01 '18 at 13:16
2

I've managed to create this (rather inelegant) solution.

If the float is smaller than 1.0E+14, then it uses my "best attempt" code from my question. Otherwise, it rounds the integer part to 14 significant digits.

Here's an Ideone demo with 500 random floats, and the code seems to work correctly for all of them.

As I say, this is not a very elegant implementation, so I'm still very interested to know if someone can devise a better solution.

function formatFloat($value)
{
    $phpPrecision = 14;

    if ($value == 0.0)  return '0.0';

    if (log10(abs($value)) < $phpPrecision) {

        $decimalDigits = max(
            ($phpPrecision - 1) - floor(log10(abs($value))),
            0
        );

        $formatted = number_format($value, $decimalDigits);

        // Trim excess 0's
        $formatted = preg_replace('/(\.[0-9]+?)0*$/', '$1', $formatted);

        return $formatted;

    }

    $formattedWithoutCommas = number_format($value, 0, '.', '');

    $sign = (strpos($formattedWithoutCommas, '-') === 0) ? '-' : '';

    // Extract the unsigned integer part of the number
    preg_match('/^-?(\d+)(\.\d+)?$/', $formattedWithoutCommas, $components);
    $integerPart = $components[1];

    // Split into significant and insignificant digits
    $significantDigits   = substr($integerPart, 0, $phpPrecision);
    $insignificantDigits = substr($integerPart, $phpPrecision);

    // Round the significant digits (using the insignificant digits)
    $fractionForRounding = (float) ('0.' . $insignificantDigits);
    $rounding            = (int) round($fractionForRounding);  // Either 0 or 1
    $rounded             = $significantDigits + $rounding;

    // Pad on the right with zeros
    $formattingString = '%0-' . strlen($integerPart) . 's';
    $formatted        = sprintf($formattingString, $rounded);

    // Insert a comma between every group of thousands
    $formattedWithCommas = strrev(
        rtrim(
            chunk_split(
                strrev($formatted), 3, ','
            ),
            ','
        )
    );

    return $sign . $formattedWithCommas;
}
TachyonVortex
  • 8,242
  • 3
  • 48
  • 63
-2
number_format($result, 14, '.', '');
IdeaHat
  • 7,641
  • 1
  • 22
  • 53