将任何浮点数格式化为小数形式的PHP函数

9
我希望创建一个名为formatFloat()的函数,它可以将任何浮点数格式化为十进制扩展字符串。例如:
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"

最初的想法

仅将浮点数转换为字符串是不行的,因为对于大于约 1.0E+14 或小于约 1.0E-4 的浮点数,PHP 会用科学计数法来渲染它们,而非十进制展开

number_format() 是尝试解决此问题的明显 PHP 函数。但是,对于大浮点数仍然存在此问题:

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"

对于小数,难点在于选择要求多少位小数。一种想法是要求大量的小数位数,然后使用rtrim()去除多余的0。然而,这个想法是有缺陷的,因为小数扩展通常不以0结尾。
number_format(1.000001,     30);  // "1.000000999999999917733362053696"
number_format(1.000001E-10, 30);  // "0.000000000100000099999999996746"
number_format(1.000001E-11, 30);  // "0.000000000010000010000000000321"

问题在于浮点数具有有限的精度,通常无法存储文字值的确切值(例如:1.0E+25)。相反,它存储最接近可表示的可能值。 number_format()显示这些“最接近的近似值”。

Timo Frenay的解决方案

我发现这个评论深埋在sprintf()页面中,令人惊讶的是没有赞:

以下是如何打印具有16位有效数字的浮点数,无论大小:

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

关键部分是使用log10()来确定浮点数的数量级,从而计算所需的小数位数。
有几个需要修复的错误:
  • 该代码无法处理负浮点数。
  • 该代码无法处理极小的浮点数(例如:1.0E-100)。PHP会报告以下通知:“sprintf(): 请求的精度为116位数字,已被截断为PHP最大的53位数字”
  • 如果$value0.0,那么log10($value)将为-INF
  • 由于PHP float的精度大约为“14位小数”,因此应显示14个有效数字,而不是16个。

我的最佳尝试

这是我想出的最佳解决方案。它基于Timo Frenay的解决方案,修复了错误,并使用ThiefMaster的正则表达式来修剪多余的0

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;
}

这里有一个包含200个随机浮点数的Ideone演示。该代码似乎对所有小于约1.0E+15的浮点数都能正确运行。

有趣的是,即使对极小的浮点数,number_format()也能正确工作:

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

问题

我最好的 formatFloat() 尝试仍然存在这个问题:

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"

有没有一种优雅的方式来改进代码以解决这个问题?

你所需要的是类似于bcmath这样的东西;我自己为php编写了一个decimal_number接口;15年前;我仍然有代码,但我不知道它是否仍然准确...通过log10只会让你回到最初的问题。祝一切顺利。 - guest
3个回答

2
这段代码似乎也能胜任。我认为我没有比你更优雅的写法,但我花了很多时间在上面,所以不能就这样扔掉它 :)
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;

}

是的,我意识到PHP浮点数不能精确表示 1.0E+25。所以需要一个算法,它将不精确的浮点数表示形式 1.0E+25 转换为舍入到14个有效数字的字符串表示形式。可以创建一个函数来实现这一点,并且仅接受原生的PHP浮点数作为输入参数(正如我的不太优雅的答案所示)。 - TachyonVortex
@TachyonVortex...我意识到我一开始并没有理解你的问题。现在从你的代码中我明白了。我尝试很努力地找到更好的方法来做这件事,但这是我能够组合的全部(请参见编辑)。 - RandomSeed
非常感谢您的代码,以及您投入其中的所有时间和精力 - 我们非常感激!我很感兴趣看到您的方法,使用 number_format() 进行预格式化,然后从尾数中复制数字,或者如果没有尾数数字,则复制 0 - TachyonVortex
链接页面上的脚本存在一些问题:警告,第83行除以零。警告,第110行的str_repeat()函数期望参数2为整数,但实际传入了浮点数。0 --> 0.0 --> 0.NAN 警告,第83行除以零。警告,第110行的str_repeat()函数期望参数2为整数,但实际传入了浮点数。0 --> 0.0 --> 0.NAN 警告,第83行除以零。警告,第110行的str_repeat()函数期望参数2为整数,但实际传入了浮点数。 - MERT DOĞAN
我没有太多时间来编辑和测试,以确保它完全正确。如果你有一个解决的版本,我将感谢你的分享。 - MERT DOĞAN

2

我设法创建了这个(相当不雅的)解决方案。

如果浮点数小于1.0E+14,则使用我在问题中的“最佳尝试”代码。否则,会将整数部分四舍五入为14个有效数字。

这里有一个Ideone演示,包含500个随机浮点数,代码似乎对所有浮点数都可以正确工作。

正如我所说,这并不是一个非常优雅的实现,所以我仍然非常希望有人能够设计出更好的解决方案。

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;
}

-2
number_format($result, 14, '.', '');

1
通常来说,简短的解释比仅有代码的答案更好。 - IdeaHat
@IdeaHat:非常正确。Scott:你的回答似乎没有解决我原来问题中的问题。请你能否添加一些解释。 - TachyonVortex

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接