開發企業應用程式時,尤其是牽涉到金融交易、會計系統或庫存管理的場景,精確的數字計算往往沒有妥協的空間。一個小小的四捨五入誤差,可能累積成帳目上的顯著落差以及除錯的惡夢。
PHP 8.4 引進全新的 BCMath Object API 來處理任意精度的浮點數,來看看如何讓這類計算既精確又優雅。
寫過 PHP 一段時間的話,有很大的機率遇過這樣的浮點數精度問題:
$a = 0.1;
$b = 0.2;
var_dump($a + $b); // 輸出:0.30000000000000004這種誤差在金融計算裡是不被接受的。這不只是好不好看的問題 — 微小的誤差會持續累積,最終在應用程式裡變成真實世界的帳目落差。
基礎:資料庫結構
處理精確浮點數計算的第一步,從資料庫層開始。使用 DECIMAL 型別至關重要:
// 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 系統來處理浮點數:
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 都設定正確,實際計算時仍可能踩到精度問題:
// 來自資料庫的值
$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 在做算術運算時,會自動把字串轉成數字:
// 資料庫回傳的字串值
$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 擴充:
// 資料庫的值
$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"但當計算變得複雜,程式碼就越來越難讀、難維護:
// 複雜的訂單計算
$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 會被轉成 整數,造成精度流失:
$num = new Number(3.5); // 輸出:"3"
$num = new Number('4.1'); // 輸出:"4.1"這就是為什麼要把資料庫的欄位型別設定成 DECIMAL,並確保 PHP PDO 設定正確或做明確的 casting 的原因:
讓浮點數一路維持字串表示。
以下是用新 API 進行精確計算的方式:
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.scaleINI 設定,而是根據運算內容自動決定小數位數。(https://www.php.net/manual/en/class.bcmath-number.php)
另外也要知道:
定義了固定小數位數的資料庫欄位,會自動對超出位數的值做四捨五入。例如欄位定義為
DECIMAL(7, 4),寫入12.12345時,實際儲存的會是12.1235。
與 Laravel 的優雅整合
我們可以用 Laravel 的 accessor 讓這一切更優雅:
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:
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;
}
}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,
];
}
}接著:
$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 函式這些年來一直可靠好用,但這個新做法,實實在在地改善了每天的開發體驗。