Wu-Hsien Yu/ 文章
文章

PHP 8.4 浮點數運算處理:將 BCMath Object API 整合進 Laravel

2025.01.22phplaravel

開發企業應用程式時,尤其是牽涉到金融交易、會計系統或庫存管理的場景,精確的數字計算往往沒有妥協的空間。一個小小的四捨五入誤差,可能累積成帳目上的顯著落差以及除錯的惡夢。

PHP 8.4 引進全新的 BCMath Object API 來處理任意精度的浮點數,來看看如何讓這類計算既精確又優雅。


寫過 PHP 一段時間的話,有很大的機率遇過這樣的浮點數精度問題:

php
$a = 0.1;
$b = 0.2;
var_dump($a + $b);  // 輸出:0.30000000000000004

這種誤差在金融計算裡是不被接受的。這不只是好不好看的問題 — 微小的誤差會持續累積,最終在應用程式裡變成真實世界的帳目落差。

基礎:資料庫結構

處理精確浮點數計算的第一步,從資料庫層開始。使用 DECIMAL 型別至關重要:

php
// Laravel Migration
Schema::create('items' function (Blueprint $table) {
    $table->id();
    $table->decimal('quantity' 10, 3);  // 總長度 10,小數 3 位
    $table->decimal('price' 10, 3);     // 總長度 10,小數 3 位
    $table->decimal('discount' 10, 3);  // 總長度 10,小數 3 位
    $table->decimal('tax' 10, 3);       // 總長度 10,小數 3 位
    // 其他欄位 ...
});

DECIMAL 型別確保:

  • 精確的浮點數精度
  • 可設定的長度與浮點數位數
  • 適合金融計算

雖然 DECIMAL 可能比 FLOAT 稍慢,但在攸關業務正確性的場景裡,用一點效能換取精確度是值得的取捨。

Laravel 的 Decimal Casting

如果你使用 Laravel,可以利用它的 casting 系統來處理浮點數:

php
class Item extends Model
{
    protected function casts(): array
    {
        return [
            'quantity' => 'decimal:3'
            'price' => 'decimal:3'
            'discount' => 'decimal:3'
            'tax' => 'decimal:3'
        ];
    }
}

不過要理解的是,Laravel 的 casting 主要處理的是:

  • 資料格式化
  • 值的一致呈現

隱藏的型別轉換陷阱

即使資料庫型別與 Laravel casting 都設定正確,實際計算時仍可能踩到精度問題:

php
// 來自資料庫的值
$item1 = Item::find(1);  // price: "99.99"
$item2 = Item::find(2);  // price: "149.99"

// 未使用 BCMath 的計算
$subtotal = $item1->price + $item2->price;
$tax = $subtotal * 0.05;  // 5% 稅金

var_dump($tax);  // 輸出:float(12.499000000000002) 而不是 12.499

會這樣是因為 PHP 在做算術運算時,會自動把字串轉成數字:

php
// 資料庫回傳的字串值
$price1 = "99.99";
$price2 = "149.99";
echo gettype($price1);  // string

// PHP 在運算過程自動轉成 float
$total = $price1 + $price2;
echo gettype($total);   // double (float)

PHP 8.4 之前的 BCMath:精確,但冗長

解法是 PHP 的 BCMath 擴充:

php
// 資料庫的值
$item1 = Item::find(1);  // price: "99.99"
$item2 = Item::find(2);  // price: "149.99"

// 使用 BCMath 函式
$subtotal = bcadd($item1->price, $item2->price, 3);
$tax = bcmul($subtotal, $item2->tax, 3);

var_dump($tax);  // 精確輸出:string(5) "12.499"

但當計算變得複雜,程式碼就越來越難讀、難維護:

php
// 複雜的訂單計算
$subtotal = bcmul($item1->price, $item1->quantity, 3);  // 計算數量小計
$discount = bcmul($subtotal, $item1->discount, 3);      // 10% 折扣
$afterDiscount = bcsub($subtotal, $discount, 3);        // 套用折扣
$tax = bcmul($afterDiscount, $item1->tax, 3);           // 加上 5% 稅金
$total = bcadd($afterDiscount, $tax, 3);                // 最終金額

PHP 8.4 的 BCMath Object API

PHP 8.4 引入了全新的 BCMath Object API,讓精確計算變得優雅又直覺。使用 BcMath\Number 時,值必須以 字串 傳入才能維持精度 — 傳入 float 會被轉成 整數,造成精度流失:

php
$num = new Number(3.5);    // 輸出:"3"
$num = new Number('4.1');  // 輸出:"4.1"

這就是為什麼要把資料庫的欄位型別設定成 DECIMAL,並確保 PHP PDO 設定正確或做明確的 casting 的原因:

讓浮點數一路維持字串表示。

以下是用新 API 進行精確計算的方式:

php
use BcMath\Number;

$item = Item::find(1);
$price = new Number($item->price);            // price: "99.99"
$quantity = new Number($item->quantity);      // quantity: "2"
$discountRate = new Number($item->discount);  // 10% 折扣
$taxRate = new Number($item->tax);            // 5% 稅金

// 計算變得自然、容易閱讀
$subtotal = $price * $quantity;
$discount = $subtotal * $discountRate;
$afterDiscount = $subtotal - $discount;
$tax = $afterDiscount * $taxRate;
$total = $afterDiscount + $tax;

var_dump($total);  // 自動轉換為字串

新 API 的幾個重點:

  • 直覺的物件導向介面
  • 支援標準數學運算子
  • 不可變(immutable)物件,確保值的安全
  • 實作 Stringable 介面

有一個行為差異需要特別留意:

scale(小數位數)的自動判定。與傳統 BCMath 函式不同,BcMath\Number 不理會全域的 bcmath.scale INI 設定,而是根據運算內容自動決定小數位數。(https://www.php.net/manual/en/class.bcmath-number.php

另外也要知道:

定義了固定小數位數的資料庫欄位,會自動對超出位數的值做四捨五入。例如欄位定義為 DECIMAL(7, 4),寫入 12.12345 時,實際儲存的會是 12.1235

與 Laravel 的優雅整合

我們可以用 Laravel 的 accessor 讓這一切更優雅:

php
use BcMath\Number;

class Item extends Model
{
    protected function quantity(): Attribute
    {
        return Attribute::make(
            get: fn (string $value) => new Number($value)
        );
    }

    protected function price(): Attribute
    {
        return Attribute::make(
            get: fn (string $value) => new Number($value)
        );
    }

    protected function discount(): Attribute
    {
        return Attribute::make(
            get: fn (string $value) => new Number($value)
        );
    }

    protected function tax(): Attribute
    {
        return Attribute::make(
            get: fn (string $value) => new Number($value)
        );
    }
}

或使用 自訂 cast

php
use BcMath\Number;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;

class AsDecimal implements CastsAttributes
{
    /**
     * Cast the given value.
     *
     * @param  array<string mixed>  $attributes
     */
    public function get(Model $model, string $key, mixed $value, array $attributes): Number
    {
        $value = blank($value) ? '0' : $value;

        return new Number($value);
    }

    /**
     * Prepare the given value for storage.
     *
     * @param  array<string mixed>  $attributes
     */
    public function set(Model $model, string $key, mixed $value, array $attributes): string
    {
        $number = $value instanceof Number ? $value : new Number(blank($value) ? '0' : $value);

        return (string) $number;
    }
}
php
class Item extends Model
{
    /**
     * Get the attributes that should be cast.
     *
     * @return array<string string>
     */
    protected function casts(): array
    {
        return [
            'quantity' => AsDecimal::class
            'price' => AsDecimal::class
            'discount' => AsDecimal::class
            'tax' => AsDecimal::class
        ];
    }
}

接著:

php
$item = Item::find(1);

$subtotal = $item->price * $item->quantity;
$discount = $subtotal * $item->discount;
$afterDiscount = $subtotal - $discount;
$tax = $afterDiscount * $item->tax;
$total = $afterDiscount + $tax;

結語

在開發醫療庫存系統時,精確的浮點數計算至關重要。無論是藥品單位的換算,還是成本的精確計算,一點點四捨五入誤差都可能造成嚴重的後果。而 PHP 8.4 的 BCMath Object API 加上 Laravel 優雅的 模型層,可以讓我們更從容的處理這些計算的方式。

這個整合帶來多重效益:

  • 醫療計算所需要的精確度
  • 物件導向語法帶來的可讀性
  • Laravel 優雅抽象帶來的可維護性
  • PHP 靜態型別帶來的型別安全

傳統的 BCMath 函式這些年來一直可靠好用,但這個新做法,實實在在地改善了每天的開發體驗。

相關文章

2026.01.21自建物流標籤列印系統:從 Loftware 到 GoDex 的轉移之路2025.11.20Laravel BCMath Cast:將精確計算整合進 Eloquent 模型2025.08.20重構(一):從單體應用到模組化