
写在前面|0.1 + 0.2 != 0.3,这个浮点精度问题每个 PHP 开发者都被坑过。电商金额算错一分,对账的时候就是灾难。
TypePHP 的类型系统里藏着一个很少被提起的杀手锏:内置 BigInt、Decimal、BigFloat 三种高精度数值类型,底层是 mpdecimal 大数库。这篇用一个完整的金额计算案例,把 TypePHP 的类型系统从头到尾过一遍。
先看完整代码。场景:计算订单总额,单价、折扣、税费全程不丢精度。
<?php
// order.php
declare(strict_types=1);
use decimal_types; // 本文件所有浮点字面量自动成为 Decimal
function main(): void
{
$price = 19.9; // Decimal,不是 float!
$qty = 3;
$discount = 0.85; // Decimal
$total = $price * $qty * $discount;
$tax = $total * 0.06;
echo "小计: " . $total->toString() . "\n";
echo "税费: " . $tax->toString() . "\n";
echo "应付: " . ($total + $tax)->toString() . "\n";
}
编译运行:
tpc order.php -O2 -o order
./order
预期输出:
小计: 50.745
税费: 3.0447
应付: 53.7897
关键点在 use decimal_types 这一行:文件里所有浮点字面量自动成为 Decimal 类型,十进制运算,天生没有二进制浮点误差。换成原生 PHP 跑一下对比,你会看到熟悉的 53.78970000000001 之类的尾巴。
同理还有 use bigint_types——所有整数字面量变成任意精度整数,超大 ID、雪花算法溢出这类问题直接消失。
讲完案例看全貌。TypePHP 编译器内部维护了一套 C++ 存储类型(php::*),挑重点列:
C++ 类型 | 对应 PHP | 说明 |
|---|---|---|
php::Var | mixed | 动态类型,zval 存储,默认状态 |
php::Int / php::Float / php::Bool | int/float/bool | 原生类型,需 use native_types |
php::Str / php::Array | string/array | 原生字符串/数组 |
php::Object | object | 通用对象,无具体类信息 |
php::Stream | resource | 流资源,TypePHP 专有 |
php::BigInt / php::Decimal / php::BigFloat | — | 高精度三件套 |
php::StdVector / php::StdMap 等 | — | C++ 标准库容器 |
注意一个反直觉的点:不声明任何东西时,所有变量默认都是 php::Var。
也就是说 TypePHP 默认行为和原生 PHP 一样动态,性能优化全部靠你主动声明换取。这和 Rust 那种默认严格的路线完全相反,是刻意的设计选择——先保证现有代码能编译,再让你渐进式收紧类型。
PHP 类型声明到 AOT 类型的映射大部分很直白(int → php::Int,string → php::Str),但有三个声明会退化为动态类型,文档里专门标了:
null 类型声明 → php::Varcallable → php::Var(编译器无法追踪闭包和回调)iterable → php::Var热点函数上用了这三种声明,等于白开 native_types。实操检查一下:
<?php
use native_types;
// 反例:callable 参数让一切退回动态
function apply(callable $fn, int $x): int {
return $fn($x); // 动态调用,无优化
}
// 正例:类型确定的直接调用
function square(int $x): int {
return $x * $x;
}
TypePHP 在编译期对每个表达式做类型推断,规则很机械,记住几条就够:
字面量自动识别有个彩蛋:超过 19 位的整数字面量自动识别为 BigInt,超过 16 位有效数字的浮点自动识别为 Decimal:
$id = 12345678901234567890; // 19 位,自动 BigInt
$pi = 0.1234567890123456; // 16 位有效数字,自动 Decimal
二元运算按优先级提升:BigFloat > Decimal > BigInt > Float > Int,任一操作数命中高级类型,结果就提升。但有个例外:两个 Int 相除还是 Int(整数除法),除不尽时退化为 Var。
最严格的一条:Float 变量禁止隐式转 Decimal(会报错),必须走字符串构造。这是防止你先在 float 里丢了精度、再假装高精度。文档给的正规写法:
$a = std::bigInt("12345678901234567890"); // 字符串构造,推荐
$b = std::decimal("0.01");
$c = std::bigFloat("1.2345e100");
TypePHP 给类型转换专门设计了一套关键词方法 to*,比 PHP 的强制转换强在两点:能链式调用、编译器能精确推断返回类型。
declare(strict_types=1);
use native_types;
function convert_demo(mixed $input): void
{
$i = $input->toInt(); // 等价于 (int) $input
$f = $input->toFloat();
$s = $input->toString();
$b = $input->toBool();
$a = $input->toArray();
}
其中 toArray() 有个贴心设计:如果对象类里定义了 toArray() 方法,优先调用它;没有才退回到"属性表转数组"。给 DTO 类加个 toArray() 就能控制序列化输出:
class User {
public int $id;
public string $name;
public function toArray(): array {
return ['uid' => $this->id, 'display_name' => $this->name];
}
}
$user = new User(1, 'admin');
$arr = $user->toArray(); // ['uid' => 1, 'display_name' => 'admin']
这是类型系统里最实用的技巧。从数组取元素、调用返回 mixed 的函数后,编译器丢了类型信息,后续方法调用只能动态分发。用 toObject() 把类型"接"回来:
$user = $array['user']->toObject(User::class);
echo $user->greet(); // 编译器重新认识它,生成 Native Call
Stream 资源同理,管道、socket 从数组里取出来先接类型再链式操作:
$sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0);
$client = $sockets[0]->toStream();
$client->write("hello");
热点循环里从数组取对象做计算,加不加这一行性能差很多——这是第二篇说的"Native Call 条件"的补救手段。
最后讲个反向操作。编译器按第一次赋值推断类型,之后赋不同类型会报错。需要"这个变量就是什么都可能装"的时候,用 any() 主动放弃类型跟踪:
function main(): void
{
$rand = random_int(0, 10000);
if ($rand % 2) {
$o = any(new Foo1());
} else {
$o = any(new Foo2()); // 不加 any() 这里会报类型冲突
}
if (method_exists($o, 'run')) {
$o->run(); // 动态分发
}
}
any() 只影响编译期推断,不产生运行时检查开销——但降级为 Var 之后,这个变量也告别了 Native Call 优化。性能和灵活性的交易,又一次明码标价。
use decimal_types,超大整数用 use bigint_types,字面量自动变高精度toObject()/toStream() 把数组里捞出来的值接回强类型,是性能的关键细节any(),但要知道代价下一篇讲编译期注解:NotNull、NotEmpty、Validate 这些怎么把空指针和非法参数扼杀在编译阶段,同样带完整案例。