跳至主要内容

PHP 5到8的主要特性更新

PHP 5 → 8:主要特性演进

按版本梳理 PHP 从 5.x 到 8.5 的主要语言特性。每个特性都配有可以直接运行的示例,以及它在真实环境里的输出。

5.x 到 8.3 的示例运行在 PHP 8.3.6 上,8.4 和 8.5 的示例运行在从官方源码编译的 PHP 8.5.0 上。输出没有经过修改,只是把文件路径缩短了。已经不存在于 8.x 的旧行为(比如 PHP 7 里 0 == "abc"true),会在文字里说明,而不是伪造输出。

总览

版本 年份 代表性特性
5.0 2004 全新的面向对象模型、异常处理
5.1 2005 PDO 统一数据库访问层
5.3 2009 命名空间、闭包、后期静态绑定
5.4 2012 Trait、短数组语法 []
5.5 2013 生成器 yield
5.6 2014 可变参数 ...、幂运算符 **
7.0 2015 Zend Engine 3(性能大幅提升)、标量类型声明、??<=>、可捕获的 Error
7.1 2016 可空类型、void、多异常捕获
7.4 2019 类型化属性、箭头函数、预加载
8.0 2020 JIT、命名参数、构造器属性提升、联合类型、match、Nullsafe、Attributes
8.1 2021 枚举、readonly 属性、Fibers
8.2 2022 readonly 类、DNF 类型
8.3 2023 类常量类型、#[\Override]json_validate()
8.4 2024 属性钩子、非对称可见性、延迟对象
8.5 2025 管道运算符 |>、URI 扩展、clone with、#[\NoDiscard]

7.2、7.3 等以修补和细节优化为主的版本没有单独列出。

PHP 5.x:现代 PHP 的地基

5.x 之前的 PHP 更像一门“能嵌进 HTML 的脚本语言”。这个系列补齐了工程化开发所需要的东西:像样的面向对象、统一的数据库层、命名空间和代码复用机制。

5.0 全新的面向对象模型

PHP 4 里对象在赋值时会被整体复制,也没有访问控制。5.0 换上了 Zend Engine 2,带来 public / protected / private、抽象类、接口、__construct / __destructclone,以及 try / catch 异常。对象变量里保存的是“句柄”,赋值只是多了一个指向同一个对象的引用。

oop-basics.php

<?php
interface Shape
{
    public function area();
}

abstract class Base implements Shape
{
    protected $name;                        // 访问控制:protected

    public function __construct($name)      // 统一的构造函数名
    {
        $this->name = $name;
    }

    public function describe()
    {
        return sprintf("%s 面积 = %.2f", $this->name, $this->area());
    }
}

class Circle extends Base
{
    private $r;

    public function __construct($r)
    {
        if ($r <= 0) {
            throw new InvalidArgumentException("半径必须大于 0");
        }
        parent::__construct('圆');
        $this->r = $r;
    }

    public function area()
    {
        return M_PI * $this->r * $this->r;
    }
}

$c = new Circle(2);
echo $c->describe(), "\n";

try {
    new Circle(-1);
} catch (InvalidArgumentException $e) {
    echo "捕获异常: ", $e->getMessage(), "\n";
}

// clone:对象默认按句柄传递,要复制必须显式 clone
$a = new Circle(1);
$b = $a;
$d = clone $a;
var_dump($a === $b, $a === $d);

输出:

$ php oop-basics.php
圆 面积 = 12.57
捕获异常: 半径必须大于 0
bool(true)
bool(false)

看输出的最后两行:$b = $a 只是复制了句柄,所以 $a === $btrue;想要真正的副本必须显式 clone,这时 $a === $dfalse

5.1 PDO:统一的数据库访问层

在此之前,每种数据库都有自己的一套函数(mysql_query()pg_query() ……),很多代码靠拼接字符串来构造 SQL。PDO 从 5.1 起随 PHP 一起发布,给所有数据库提供同一套接口,并把预处理语句变成了标准做法。

下面的示例用的是内存中的 SQLite,不需要安装任何数据库就能运行:

pdo.php

<?php
$pdo = new PDO('sqlite::memory:', null, null, array(
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
));

$pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)');

// 预处理语句:SQL 和数据分开传递
$insert = $pdo->prepare('INSERT INTO users (name, age) VALUES (:name, :age)');
$insert->execute(array('name' => 'Ann', 'age' => 31));
$insert->execute(array('name' => 'Bob', 'age' => 17));
// 恶意输入只会被当成一段普通文本存进去,不会被当成 SQL 执行
$insert->execute(array('name' => "x'); DROP TABLE users; --", 'age' => 40));

$query = $pdo->prepare('SELECT id, name FROM users WHERE age >= :min ORDER BY id');
$query->execute(array('min' => 18));
foreach ($query->fetchAll() as $row) {
    echo $row['id'], ': ', $row['name'], "\n";
}

echo '表还在,共 ', $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn(), " 行\n";

// 换数据库只需要改 DSN,其余代码不变,例如:
// new PDO('mysql:host=127.0.0.1;dbname=app;charset=utf8mb4', $user, $pass);
// new PDO('pgsql:host=127.0.0.1;dbname=app', $user, $pass);

输出:

$ php pdo.php
1: Ann
3: x'); DROP TABLE users; --
表还在,共 3 行

输出里那条看起来像 SQL 注入的名字,被当成普通文本存了进去,users 表完好无损。这就是预处理语句的意义:SQL 结构和数据是分开传输的。

顺带一提,PHP 7.0 彻底移除了老的 mysql_* 扩展,所以旧项目升级时,这里往往是第一个要改的地方。

5.3 命名空间、闭包与后期静态绑定

这是 5.x 里影响最大的版本之一。

  • 命名空间解决类名冲突,也是后来 Composer 自动加载和现代框架生态得以成立的前提。
  • 闭包(匿名函数)可以用 use 捕获外部变量,写回调不再需要先给函数起名字。
  • 后期静态绑定self 永远指“这段代码写在哪个类里”,static 则指“实际是谁在调用”。基类里写工厂方法、ActiveRecord 之类的场景几乎必用。

namespace-closure.php

<?php
namespace App\Model;

class Model
{
    public static function makeSelf()
    {
        return new self();                 // self:永远是"代码写在哪个类里"
    }

    public static function makeStatic()
    {
        return new static();               // static:后期静态绑定,是"实际被调用的类"
    }

    public static function table()
    {
        $parts = explode('\\', get_called_class());
        return strtolower(end($parts)) . 's';
    }
}

class User extends Model {}
class Order extends Model {}

echo get_class(User::makeSelf()), "\n";
echo get_class(User::makeStatic()), "\n";
echo User::table(), ' / ', Order::table(), "\n";

// 闭包:用 use 捕获外部变量
$rate = 1.09;
$addTax = function ($price) use ($rate) {
    return round($price * $rate, 2);
};
print_r(array_map($addTax, [100, 250, 80]));

// 按引用捕获
$count = 0;
$inc = function () use (&$count) {
    $count++;
};
$inc(); $inc(); $inc();
echo "count = $count\n";

输出:

$ php namespace-closure.php
App\Model\Model
App\Model\User
users / orders
Array
(
    [0] => 109
    [1] => 272.5
    [2] => 87.2
)
count = 3

前两行输出是 selfstatic 的区别:通过子类 User 调用时,new self() 得到的仍然是基类 Modelnew static() 得到的才是 User

5.4 Trait 与短数组语法

PHP 是单继承。Trait 允许把一组方法“横向”混入多个不相关的类,相当于由编译器代替你完成复制粘贴。5.4 还带来了短数组语法 [](此前只能写 array()),以及内置的开发用 Web 服务器 php -S localhost:8000

traits.php

<?php
trait Loggable
{
    private $logs = [];                      // 5.4 短数组语法

    public function log($msg)
    {
        $this->logs[] = $msg;
        return $this;
    }

    public function dump()
    {
        echo implode(' -> ', $this->logs), "\n";
    }
}

trait Timestamps
{
    public function touch()
    {
        return '更新于 2026-09-20';
    }
}

class OrderService
{
    use Loggable, Timestamps;                // 横向复用:不需要继承
}

$s = new OrderService();
$s->log('创建订单')->log('扣减库存')->log('发送通知')->dump();
echo $s->touch(), "\n";
print_r(class_uses($s));

输出:

$ php traits.php
创建订单 -> 扣减库存 -> 发送通知
更新于 2026-09-20
Array
(
    [Loggable] => Loggable
    [Timestamps] => Timestamps
)

5.5 生成器

函数里出现 yield 后,它就变成了一个可以暂停、恢复的迭代器。处理大文件、大结果集时,不需要先把所有数据装进数组,内存占用从“与数据量成正比”变成“基本恒定”。

generators.php

<?php
function readLines($n)
{
    for ($i = 1; $i <= $n; $i++) {
        echo "  [生成第 $i 项]\n";
        yield $i => "line-$i";
    }
}

echo "--- 惰性求值:生产与消费交替进行 ---\n";
foreach (readLines(3) as $k => $v) {
    echo "消费: $k => $v\n";
}

function xrange($a, $b)
{
    for ($i = $a; $i <= $b; $i++) {
        yield $i;
    }
}

echo "\n--- 内存对比:一百万个整数 ---\n";
$base = memory_get_usage();
$arr = range(1, 1000000);
printf("range()  数组占用: %.1f MB\n", (memory_get_usage() - $base) / 1048576);
unset($arr);

$base = memory_get_usage();
$peak = 0;
foreach (xrange(1, 1000000) as $n) {
    $peak = max($peak, memory_get_usage() - $base);
}
printf("生成器   占用    : %.3f MB\n", $peak / 1048576);

输出:

$ php generators.php
--- 惰性求值:生产与消费交替进行 ---
  [生成第 1 项]
消费: 1 => line-1
  [生成第 2 项]
消费: 2 => line-2
  [生成第 3 项]
消费: 3 => line-3

--- 内存对比:一百万个整数 ---
range()  数组占用: 16.0 MB
生成器   占用    : 0.001 MB

上半部分的输出显示了惰性求值:每消费一项,才会去生成下一项。下半部分是内存对比:一百万个整数放进数组需要约 16 MB(这是 PHP 8.3 的数字,PHP 5 下的差距更大),用生成器则几乎不占内存。

同一个版本里还有 finallypassword_hash() 和内置的 OPcache。

5.6 可变参数与参数展开

以前要靠 func_get_args() 读取可变参数,函数签名里根本看不出来。...$nums 直接写在参数列表里;调用时再用 ...$array 把数组展开成一个个参数。5.6 还引入了 ** 幂运算符。

variadic.php

<?php
function sum(...$nums)          // 收集可变参数(取代 func_get_args{
    return array_sum($nums);
}

echo sum(1, 2, 3, 4), "\n";

$args = [10, 20, 30];
echo sum(...$args), "\n";       // 参数展开

function connect($host, $port, $timeout)
{
    return "$host:$port (timeout=$timeout)";
}
echo connect(...['sg.example.com', 443, 5]), "\n";

echo 2 ** 10, "\n";             // 5.6 新增的幂运算符

输出:

$ php variadic.php
10
60
sg.example.com:443 (timeout=5)
1024

PHP 7.x:性能与类型系统的飞跃

7.0 是这些年最重要的一次升级:引擎底层重写带来了性能翻身,同时语言开始具备现代类型系统的雏形。

7.0 Zend Engine 3:不改代码就变快

7.0 重写了引擎的核心数据结构(更紧凑的变量和数组实现)。在官方基准测试中,常见的结果是比 5.6 快约一倍,内存占用也明显下降。这次升级对业务代码几乎没有要求,所以成了 PHP 生态迁移最快的一次。

这一节没有示例:要复现对比,需要同时装着 5.6 和 7.0,这里只转述官方基准的结论,并没有自己实测。

7.0 标量类型、返回类型与新运算符

现在可以给参数和返回值声明 intstringfloatbool。默认情况下 PHP 仍然会做隐式转换("1" 传给 int 参数会变成 1);在文件开头写上 declare(strict_types=1); 后,类型不匹配就直接抛出 TypeError。同一版本还新增了 ?? 空合并运算符、<=> 太空船运算符和匿名类。

scalar-types.php

<?php
declare(strict_types=1);

function add(int $a, int $b): int
{
    return $a + $b;
}

echo add(1, 2), "\n";

try {
    echo add("1", 2);                      // strict_types 下不再做隐式转换
} catch (TypeError $e) {
    echo "TypeError: ", strstr($e->getMessage(), ', called', true), "\n";
}

// ?? 空合并:等价于 isset($x) ? $x : 默认值
$user = ['name' => 'Alice'];
echo $user['nick'] ?? '匿名', "\n";
echo $user['name'] ?? '匿名', "\n";

// <=> 太空船:返回 -1 / 0 / 1,写排序回调特别方便
echo (1 <=> 2), ' ', (2 <=> 2), ' ', (3 <=> 2), "\n";

$people = [['n' => 'Bob', 'age' => 30], ['n' => 'Amy', 'age' => 25], ['n' => 'Cat', 'age' => 30]];
usort($people, function ($a, $b) {
    return [$b['age'], $a['n']] <=> [$a['age'], $b['n']];   // 年龄降序,同龄按姓名升序
});
echo implode(', ', array_map(function ($p) { return "{$p['n']}({$p['age']})"; }, $people)), "\n";

// 匿名类
$logger = new class {
    public function log(string $m): void
    {
        echo "[LOG] $m\n";
    }
};
$logger->log('匿名类可以直接实例化');

输出:

$ php scalar-types.php
3
TypeError: add(): Argument #1 ($a) must be of type int, string given
匿名
Alice
-1 0 1
Bob(30), Cat(30), Amy(25)
[LOG] 匿名类可以直接实例化
  • ?? 相当于“存在且不为 null 就用它,否则用默认值”,而且不会因为键不存在而产生 Notice。
  • <=> 返回 -101。示例里用它对数组做逐项比较,一行写出“年龄降序、同龄按姓名升序”的排序规则。

7.0 可捕获的 Error

在 PHP 5 里,调用不存在的函数、对 null 调用方法这类错误会直接让脚本终止,无法处理。7.0 把它们改成了可以捕获的 Error 异常,并让 ErrorException 共同实现 Throwable 接口:

Throwable
├── Exception                 原有的异常体系:RuntimeException、InvalidArgumentException ……
└── Error                     7.0 新增:引擎级错误
    ├── TypeError             类型不匹配(ArgumentCountError 是它的子类)
    ├── ArithmeticError
    │   └── DivisionByZeroError
    ├── ValueError            8.0 新增:参数值不合法
    └── UnhandledMatchError   8.0 新增:match 没有匹配项

throwable.php

<?php
// PHP 7 起,大部分致命错误变成可捕获的 Error,
// Error 与 Exception 都实现 Throwable 接口
function tryIt(callable $fn): void
{
    try {
        $fn();
    } catch (Throwable $t) {
        echo get_class($t), ': ', $t->getMessage(), "\n";
    }
}

tryIt(function () { undefined_function(); });
tryIt(function () { $x = null; $x->foo(); });
tryIt(function () { echo intdiv(1, 0); });
tryIt(function () { throw new RuntimeException('业务异常也照常捕获'); });

输出:

$ php throwable.php
Error: Call to undefined function undefined_function()
Error: Call to a member function foo() on null
DivisionByZeroError: Division by zero
RuntimeException: 业务异常也照常捕获

这对长驻进程(队列消费者、Swoole / RoadRunner 一类的服务)尤其重要:一个请求出错,可以被捕获、记录,而不会拖垮整个进程。

7.1 可空类型、void、多异常捕获与解构

?string 表示“字符串或 null”;void 声明函数不返回值;catch (A | B $e) 让多种异常共用同一段处理;list() 的简写 [] 也支持按键解构。

nullable-void.php

<?php
// 可空类型 ?T
function greet(?string $name): string
{
    return 'Hello, ' . ($name ?? 'guest');
}
echo greet('Ann'), "\n";
echo greet(null), "\n";

// void 返回类型
function logMsg(string $m): void
{
    echo "LOG: $m\n";
}
logMsg('无返回值');

// 多异常捕获
function risky(int $n)
{
    if ($n === 1) throw new InvalidArgumentException('参数错误');
    if ($n === 2) throw new RuntimeException('运行时错误');
    return 'ok';
}
foreach ([1, 2, 3] as $n) {
    try {
        echo risky($n), "\n";
    } catch (InvalidArgumentException | RuntimeException $e) {
        echo get_class($e), ' -> ', $e->getMessage(), "\n";
    }
}

// 短 list 语法 + 按键解构
['id' => $id, 'name' => $name] = ['id' => 7, 'name' => 'Bob'];
[$a, [$b, $c]] = [1, [2, 3]];
echo "$id/$name  $a-$b-$c\n";

// 遍历时直接解构
foreach ([['id' => 1, 'tag' => 'x'], ['id' => 2, 'tag' => 'y']] as ['id' => $i, 'tag' => $t]) {
    echo "$i:$t ";
}
echo "\n";

输出:

$ php nullable-void.php
Hello, Ann
Hello, guest
LOG: 无返回值
InvalidArgumentException -> 参数错误
RuntimeException -> 运行时错误
ok
7/Bob  1-2-3
1:x 2:y

7.4 类型化属性与箭头函数

类型化属性把类型声明从参数、返回值扩展到了类的属性,属性不再是“什么都能塞”的容器。箭头函数 fn($x) => ... 只能包含一个表达式,会自动按值捕获外层变量,写 array_map / array_filter 的回调时省掉了 function () use (...) { return ...; } 的一大串样板。

typed-props.php

<?php
class Point
{
    public int $x = 0;
    public ?string $label = null;
    public array $tags = [];
}

$p = new Point();
$p->x = 5;
$p->x = "7";                 // 非严格模式:数字字符串会被转成 int
var_dump($p->x);

try {
    $p->x = "abc";           // 无法转换 -> TypeError
} catch (TypeError $e) {
    echo $e->getMessage(), "\n";
}

// 箭头函数:自动按值捕获外层变量
$factor = 3;
$triple = fn($x) => $x * $factor;
print_r(array_map($triple, [1, 2, 3]));

$users = [['name' => 'Ann', 'age' => 17], ['name' => 'Bob', 'age' => 30]];
$adults = array_filter($users, fn($u) => $u['age'] >= 18);
echo implode(',', array_column($adults, 'name')), "\n";

输出:

$ php typed-props.php
int(7)
Cannot assign string to property Point::$x of type int
Array
(
    [0] => 3
    [1] => 6
    [2] => 9
)
Bob

注意第一次赋值 "7" 被悄悄转成了 int(7),这是非严格模式的默认行为;"abc" 无法转换,才会抛出 TypeError。如果需要更严格,同样可以用 declare(strict_types=1);

7.4 ??=、数组展开与预加载

7.4 里还有几个日常会用到的小改动:??=(“为空才赋值”)、数组里的展开运算符 ...、数字字面量的分隔符 1_000_000

null-assign-spread.php

<?php
// ??= 空合并赋值
$config = ['debug' => false];
$config['timeout'] ??= 30;
$config['timeout'] ??= 60;          // 已有值,不会覆盖
print_r($config);

// 数组展开
$a = [1, 2];
$b = [...$a, 3, ...[4, 5]];
echo implode(',', $b), "\n";

// 数字分隔符
$million = 1_000_000;
echo number_format($million), "\n";

输出:

$ php null-assign-spread.php
Array
(
    [debug] => 
    [timeout] => 30
)
1,2,3,4,5
1,000,000

预加载(Preloading)是一个部署层面的特性:PHP-FPM 启动时把框架核心类一次性编译进共享内存,之后每个请求直接使用,省去重复加载和编译的开销。它只需要配置,没有可展示的输出:

; php.ini
opcache.preload=/var/www/app/preload.php
opcache.preload_user=www-data
<?php
// preload.php:在服务启动时执行一次
require_once __DIR__ . '/vendor/autoload.php';

foreach (glob(__DIR__ . '/src/Core/*.php') as $file) {
    opcache_compile_file($file);
}

被预加载的文件在修改后需要重启 PHP-FPM 才会生效,所以通常只预加载稳定的核心代码。

PHP 8.0:又一次大版本升级

8.0 在语法上的改动集中在“写起来更短、读起来更清楚”,同时把很多长期存在的宽松行为收紧了。

8.0 JIT 编译器

JIT 把热点代码直接编译成机器码。它对纯计算型的任务(图像处理、数值运算、算法)提升很大,但一般的 Web 应用大部分时间花在数据库和网络 I/O 上,收益有限。启用方式是在 php.ini 里配置:

opcache.enable=1
opcache.jit_buffer_size=100M
opcache.jit=tracing

下面用一个纯计算的曼德博集合来对比。脚本会跑 3 次,取最快的一次:

mandelbrot.php

<?php
// CPU 密集型:曼德博集合(纯数值计算,几乎没有 I/O)
function mandelbrot(int $size): int
{
    $inside = 0;
    for ($y = 0; $y < $size; $y++) {
        for ($x = 0; $x < $size; $x++) {
            $cr = 2.0 * $x / $size - 1.5;
            $ci = 2.0 * $y / $size - 1.0;
            $zr = $zi = 0.0;
            $i = 0;
            while ($i < 100 && $zr * $zr + $zi * $zi < 4.0) {
                $t  = $zr * $zr - $zi * $zi + $cr;
                $zi = 2.0 * $zr * $zi + $ci;
                $zr = $t;
                $i++;
            }
            if ($i === 100) {
                $inside++;
            }
        }
    }
    return $inside;
}

// 跑 3 次,取最快的一次
$best = INF;
for ($round = 0; $round < 3; $round++) {
    $start = microtime(true);
    $n = mandelbrot(500);
    $best = min($best, microtime(true) - $start);
}
printf("集合内点数=%d,最快一次耗时 %.2f 秒\n", $n, $best);

输出:

$ php -d opcache.enable_cli=1 -d opcache.jit=off mandelbrot.php
集合内点数=96625,最快一次耗时 0.18 秒

输出:

$ php -d opcache.enable_cli=1 -d opcache.jit_buffer_size=64M -d opcache.jit=tracing mandelbrot.php
集合内点数=96625,最快一次耗时 0.04 秒

具体的耗时取决于机器,请把注意力放在两次结果的倍数上:这里快了 4 倍以上。这类几乎没有 I/O 的紧凑循环是 JIT 最擅长的场景;换成典型的“查库、渲染模板”的请求,差距会小得多。

8.0 命名参数、构造器属性提升与联合类型

  • 命名参数:调用时写出参数名,可以跳过中间的可选参数,也让 truefalse 这类含义不明的实参一目了然。
  • 构造器属性提升:在构造函数的参数前直接写可见性,声明属性和赋值一步完成,值对象和 DTO 的样板代码少了一大半。
  • 联合类型int|float|string,把“可以是几种类型之一”写进签名。
  • throw 表达式throw 现在可以出现在 ??、箭头函数等表达式的位置。

named-args.php

<?php
// 命名参数:跳过中间的可选参数,调用处一目了然
function createUser(string $name, int $age = 18, bool $admin = false, string $lang = 'zh')
{
    return sprintf("%s, %d岁, admin=%s, lang=%s", $name, $age, var_export($admin, true), $lang);
}

echo createUser('Tom', admin: true), "\n";
echo createUser(name: 'Amy', lang: 'en', age: 22), "\n";

// 构造器属性提升:声明 + 赋值一步完成
class User
{
    public function __construct(
        public string $name,
        private int $age = 0,
        protected array $roles = [],
    ) {}

    public function age(): int
    {
        return $this->age;
    }
}

$u = new User('Ann', 30, ['editor']);
print_r($u);
echo $u->name, ' / ', $u->age(), "\n";

// 联合类型
function format(int|float|string $v): string
{
    return gettype($v) . ':' . $v;
}
echo format(42), ' ', format(3.14), ' ', format('hi'), "\n";

// throw 变成表达式
$input = ['id' => 1];
try {
    $name = $input['name'] ?? throw new InvalidArgumentException('缺少 name');
} catch (InvalidArgumentException $e) {
    echo $e->getMessage(), "\n";
}

输出:

$ php named-args.php
Tom, 18岁, admin=true, lang=zh
Amy, 22岁, admin=false, lang=en
User Object
(
    [name] => Ann
    [age:User:private] => 30
    [roles:protected] => Array
        (
            [0] => editor
        )

)
Ann / 30
integer:42 double:3.14 string:hi
缺少 name

print_r 的输出可以看到,privateprotected 的构造器参数已经变成了类的属性,没有写任何一行 $this->x = $x;

8.0 match 表达式

match 可以看作更严格的 switch:它是表达式,有返回值;使用严格比较(===);不需要 break;没有任何分支匹配时会抛出 UnhandledMatchError,而不是静默略过。

match.php

<?php
$code = 404;

// match 是表达式,有返回值,使用严格比较(===),不需要 break
$msg = match ($code) {
    200, 201 => '成功',
    301, 302 => '重定向',
    404      => '未找到',
    500      => '服务器错误',
    default  => '未知',
};
echo "$code => $msg\n";

// match(true) 可以替代 if-elseif 链
$score = 85;
echo match (true) {
    $score >= 90 => 'A',
    $score >= 80 => 'B',
    $score >= 60 => 'C',
    default      => 'D',
}, "\n";

// 没有匹配项且没有 default:抛出 UnhandledMatchError(switch 会静默略过)
try {
    echo match (999) {
        1 => 'one',
    };
} catch (\UnhandledMatchError $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}

// 与 switch 的宽松比较对照
$v = "0";
switch ($v) {
    case 0:  echo "switch: 匹配 case 0(宽松 ==)\n"; break;
    default: echo "switch: default\n";
}
echo 'match : ', match ($v) { 0 => '匹配 0', default => '严格比较,"0" !== 0,走 default' }, "\n";

输出:

$ php match.php
404 => 未找到
B
UnhandledMatchError: Unhandled match case 999
switch: 匹配 case 0(宽松 ==)
match : 严格比较,"0" !== 0,走 default

最后两行展示了它和 switch 的关键差别:"0"0switch 的宽松比较下相等,在 match 的严格比较下不相等。

8.0 Nullsafe 运算符与 Attributes

Nullsafe 运算符 ?->:链条上任何一环是 null,整条表达式直接得到 null,不用再写一层层 if

Attributes(注解) #[...]:给类、方法、参数附加结构化的元数据,并且能通过反射读取。此前只能靠解析 docblock 注释,框架里的路由、校验、ORM 映射现在可以直接使用它。

nullsafe-attributes.php

<?php
class Address { public function __construct(public ?string $city = null) {} }
class Profile { public function __construct(public ?Address $address = null) {} }
class Account
{
    public function __construct(private ?Profile $profile = null) {}
    public function getProfile(): ?Profile { return $this->profile; }
}

$a1 = new Account(new Profile(new Address('Singapore')));
$a2 = new Account(new Profile());        // 没有地址
$a3 = null;                              // 没有账户

// Nullsafe:链上任何一环为 null,整条链直接返回 null
foreach ([$a1, $a2, $a3] as $acc) {
    echo $acc?->getProfile()?->address?->city ?? '(无)', "\n";
}

echo "\n--- Attributes(注解)---\n";
#[Attribute(Attribute::TARGET_METHOD)]
class Route
{
    public function __construct(public string $path, public string $method = 'GET') {}
}

class HomeController
{
    #[Route('/home')]
    public function index() {}

    #[Route('/users', method: 'POST')]
    public function store() {}
}

foreach ((new ReflectionClass(HomeController::class))->getMethods() as $m) {
    foreach ($m->getAttributes(Route::class) as $attr) {
        $r = $attr->newInstance();
        printf("%-4s %-8s -> %s::%s()\n", $r->method, $r->path, $m->class, $m->name);
    }
}

输出:

$ php nullsafe-attributes.php
Singapore
(无)
(无)

--- Attributes(注解)---
GET  /home    -> HomeController::index()
POST /users   -> HomeController::store()

8.0 更严格的类型与比较

8.0 修正了一批历史遗留的宽松行为,其中影响最大的是字符串与数字的比较:过去 0 == "abc" 会先把 "abc" 转成数字 0,结果为 true;现在只有当字符串本身是数字字符串时才按数字比较,否则把数字转成字符串再比较。另外,对非数字字符串做算术运算,以及给内部函数传错类型,都会抛出异常,而不是悄悄继续执行。

string-compare.php

<?php
// 新的字符串函数
var_dump(str_contains('Hello World', 'World'));
var_dump(str_starts_with('Hello World', 'Hello'));
var_dump(str_ends_with('Hello World', 'World'));

// 字符串与数字的比较规则变了(PHP 7 里下面这些结果是 true)
var_dump(0 == "abc");
var_dump(0 == "");
var_dump("abc" == 0);
var_dump(0 == "0");        // 数字字符串仍然按数字比较
var_dump(100 == "1e2");

// 对非数字字符串做算术运算:抛异常,而不是悄悄当成 0
try {
    echo "abc" * 2;
} catch (TypeError $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}

// 内部函数传入错误类型:抛 TypeError,而不是 Warning + 返回 null
try {
    strlen([]);
} catch (TypeError $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}

输出:

$ php string-compare.php
bool(true)
bool(true)
bool(true)
bool(false)
bool(false)
bool(false)
bool(true)
bool(true)
TypeError: Unsupported operand types: string * int
TypeError: strlen(): Argument #1 ($string) must be of type string, array given

在 PHP 7 里,0 == "abc"0 == """abc" == 0 这三个比较的结果都是 true"abc" * 2 会悄悄得到 0(7.1 起附带一条 Warning),strlen([]) 则是 Warning 加返回 null。老代码里如果依赖了这些行为,升级后会直接暴露出来。

同一批新增的 str_contains()str_starts_with()str_ends_with(),终于让“判断字符串包含/开头/结尾”不必再写 strpos(...) !== false

PHP 8.1 ~ 8.5:8.x 系列的持续增强

从 8.1 开始,每个版本都在补齐现代静态类型语言的能力:枚举、不可变对象、属性钩子……

8.1 枚举

枚举是一种受限的类:它的取值只能是预先声明的几个实例。它可以带“底层值”(stringint),可以有方法、常量,可以实现接口,也能直接作为类型约束。以前常见的做法是“一堆类常量加一段校验代码”,现在语言层面就保证了取值合法。

enum.php

<?php
enum Suit: string
{
    case Hearts = 'H';
    case Spades = 'S';
    case Clubs  = 'C';

    public function label(): string
    {
        return match ($this) {
            self::Hearts => '红桃',
            self::Spades => '黑桃',
            self::Clubs  => '梅花',
        };
    }

    public function color(): string
    {
        return $this === self::Hearts ? '红' : '黑';
    }

    const Wild = self::Spades;
}

echo Suit::Hearts->name, ' = ', Suit::Hearts->value, "\n";
echo Suit::from('S')->label(), "\n";                        // 值 -> 枚举,找不到会抛异常
echo Suit::tryFrom('X')?->label() ?? '(tryFrom 找不到,返回 null)', "\n";

try {
    Suit::from('X');
} catch (ValueError $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}

echo implode(', ', array_map(fn(Suit $s) => $s->label(), Suit::cases())), "\n";

function play(Suit $s): string          // 枚举可以直接作为类型约束
{
    return "打出 {$s->label()}({$s->color()})";
}
echo play(Suit::Clubs), "\n";
var_dump(Suit::Hearts instanceof Suit, Suit::Wild === Suit::Spades);

输出:

$ php enum.php
Hearts = H
黑桃
(tryFrom 找不到,返回 null)
ValueError: "X" is not a valid backing value for enum Suit
红桃, 黑桃, 梅花
打出 梅花(黑)
bool(true)
bool(true)
  • from() 找不到时抛出 ValueErrortryFrom() 则返回 null
  • cases() 返回所有枚举成员,适合用来生成下拉选项。
  • 枚举成员是单例,所以可以直接用 === 比较。

8.1 readonly 属性

readonly 属性只能初始化一次(并且只能在类的内部),之后任何修改都会抛出 Error。用它来写值对象(金额、坐标、配置)最合适:对象一旦创建,就不会在别处被意外改掉。

readonly.php

<?php
final class Money
{
    public function __construct(
        public readonly int $amount,
        public readonly string $currency,
    ) {}

    // 不可变对象:修改 = 返回一个新对象
    public function add(int $n): static
    {
        return new static($this->amount + $n, $this->currency);
    }
}

$m1 = new Money(100, 'SGD');
$m2 = $m1->add(50);
echo "{$m1->amount} {$m1->currency} -> {$m2->amount} {$m2->currency}\n";

try {
    $m1->amount = 999;
} catch (Error $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}

输出:

$ php readonly.php
100 SGD -> 150 SGD
Error: Cannot modify readonly property Money::$amount

8.1 Fibers

Fiber 是底层的协程原语:一段代码可以在中途“挂起”,把控制权交还给调用者,之后再从原来的位置继续。它本身不提供异步 I/O,而是给 Revolt、ReactPHP、Amp 这类事件循环库打下基础。日常业务代码很少直接使用它。

fiber.php

<?php
$fiber = new Fiber(function (string $name): string {
    echo "[$name] 开始\n";
    $x = Fiber::suspend('第 1 次暂停');       // 在这里挂起,把值交给外层
    echo "[$name] 收到: $x\n";
    $y = Fiber::suspend('第 2 次暂停');
    echo "[$name] 收到: $y\n";
    return "$name 完成";
});

$v = $fiber->start('任务A');
echo "主流程拿到: $v\n";
$v = $fiber->resume('数据1');
echo "主流程拿到: $v\n";
$fiber->resume('数据2');
echo "主流程拿到返回值: ", $fiber->getReturn(), "\n";
var_dump($fiber->isTerminated());

// 交替调度两个纤程(协程调度器的雏形)
echo "\n--- 简易调度 ---\n";
$tasks = [];
foreach (['A', 'B'] as $id) {
    $tasks[$id] = new Fiber(function () use ($id) {
        for ($i = 1; $i <= 2; $i++) {
            echo "任务{$id} 第{$i} 步\n";
            Fiber::suspend();
        }
    });
    $tasks[$id]->start();
}
while ($tasks) {
    foreach ($tasks as $id => $f) {
        $f->resume();
        if ($f->isTerminated()) unset($tasks[$id]);
    }
}

输出:

$ php fiber.php
[任务A] 开始
主流程拿到: 第 1 次暂停
[任务A] 收到: 数据1
主流程拿到: 第 2 次暂停
[任务A] 收到: 数据2
主流程拿到返回值: 任务A 完成
bool(true)

--- 简易调度 ---
任务A 第1 步
任务B 第1 步
任务A 第2 步
任务B 第2 步

前半部分展示了挂起与恢复时如何双向传值;后半部分用最简单的轮转调度让两个任务交替运行,这就是协程调度器的雏形。

8.1 其他:可调用语法、never 与 new in initializers

  • strlen(...)$obj->method(...) 这种 一等公民可调用语法,是 Closure::fromCallable() 的简写。
  • never 返回类型:函数不会正常返回(一定抛异常或 exit)。
  • new 可以出现在参数默认值里,依赖注入时可以直接给出默认实现。

callable-never.php

<?php
// 一等公民可调用语法:用 (...) 直接创建闭包
$len = strlen(...);
echo $len('hello'), "\n";

class Formatter
{
    public function upper(string $s): string { return strtoupper($s); }
    public static function wrap(string $s): string { return "[$s]"; }
}
$f = new Formatter();
$upper = $f->upper(...);
$wrap  = Formatter::wrap(...);
echo implode(' ', array_map($wrap, array_map($upper, ['a', 'b']))), "\n";

// never:函数永远不会正常返回
function fail(string $msg): never
{
    throw new RuntimeException($msg);
}
try {
    fail('走到这里就一定是异常');
} catch (RuntimeException $e) {
    echo $e->getMessage(), "\n";
}

// new 可以出现在参数默认值里
class Logger { public function __construct(public string $ch = 'app') {} }
class Service
{
    public function __construct(private Logger $logger = new Logger('default')) {}
    public function channel(): string { return $this->logger->ch; }
}
echo (new Service())->channel(), "\n";

// array_is_list
var_dump(array_is_list([1, 2, 3]), array_is_list([1 => 'a', 2 => 'b']));

输出:

$ php callable-never.php
5
[A] [B]
走到这里就一定是异常
default
bool(true)
bool(false)

8.2 readonly 类、DNF 类型与动态属性弃用

  • readonly:在类名前写一次 readonly,所有属性自动变成只读。
  • DNF 类型:允许把交集类型放进联合类型,写成 (A&B)|null 这样。
  • 动态属性被弃用:给没有声明过的属性赋值($obj->foo = 1)现在会触发 Deprecated,这是 8.x 升级中很常见的一条提示。

readonly-class.php

<?php
// readonly 类:所有属性自动变成 readonly
readonly class Point
{
    public function __construct(public int $x, public int $y) {}

    public function withX(int $x): static
    {
        return new static($x, $this->y);
    }
}

$p = new Point(1, 2);
$q = $p->withX(10);
echo "($p->x, $p->y) -> ($q->x, $q->y)\n";
try {
    $p->y = 5;
} catch (Error $e) {
    echo $e->getMessage(), "\n";
}

// DNF 类型:交集类型可以放进联合类型
interface HasId   { public function id(): int; }
interface HasName { public function name(): string; }

class Product implements HasId, HasName
{
    public function id(): int { return 7; }
    public function name(): string { return '键盘'; }
}

function describe((HasId&HasName)|null $e): string
{
    return $e === null ? '(空)' : "#{$e->id()} {$e->name()}";
}
echo describe(new Product()), ' | ', describe(null), "\n";

// 独立的 true / false / null 类型
function ok(): true { return true; }
var_dump(ok());

// 动态属性在 8.2 被弃用(8.3 仍会提示)
class Legacy {}
$o = new Legacy();
$o->foo = 1;
echo "foo = {$o->foo}\n";

输出:

$ php readonly-class.php
(1, 2) -> (10, 2)
Cannot modify readonly property Point::$y
#7 键盘 | (空)
bool(true)

Deprecated: Creation of dynamic property Legacy::$foo is deprecated in readonly-class.php on line 45
foo = 1

最后的 Deprecated 是在 8.2 起新增的提示:程序仍然能运行,但这种写法未来的版本会禁止。如果类确实需要动态属性,可以给它加上 #[\AllowDynamicProperties];更好的做法是把属性正式声明出来。

8.3 类常量类型、#[\Override] 与 json_validate()

  • 类常量可以声明类型const string NAME = ...;
  • #[\Override] 表示“这个方法必须覆盖父类或接口里的方法”。方法名拼错或父类改名之后,PHP 会在类加载阶段直接报致命错误,而不是悄悄变成一个新方法。
  • json_validate() 只判断字符串是不是合法 JSON,不用真的解码出一大棵数据。

typed-const.php

<?php
// 类常量可以声明类型
class App
{
    public const string NAME = 'demo';
    public const int MAX_RETRY = 3;
}
echo App::NAME, ' x', App::MAX_RETRY, "\n";

// 动态类常量读取:Foo::{$name}
$key = 'MAX_RETRY';
echo App::{$key}, "\n";

// json_validate:只校验,不用真的解码整个结构
var_dump(json_validate('{"name":"Ann","tags":[1,2,3]}'));
var_dump(json_validate('{name:"Ann"}'));
echo json_last_error_msg(), "\n";

输出:

$ php typed-const.php
demo x3
3
bool(true)
bool(false)
Syntax error

下面是 #[\Override] 拦截拼写错误的例子:helo() 在父类里并不存在。

override.php

<?php
class ParentClass
{
    public function hello(): string { return 'P'; }
}

class ChildClass extends ParentClass
{
    #[\Override]
    public function helo(): string   // 手滑写错了方法名
    {
        return 'C';
    }
}

echo (new ChildClass())->helo();

输出:

$ php override.php
Fatal error: ChildClass::helo() has #[\Override] attribute, but no matching parent method exists in override.php on line 10

8.4 属性钩子

属性钩子(Property Hooks)让属性自己带上 get / set 逻辑,不必再为了一点校验或计算,专门写一对 getter / setter 方法。有底层存储的属性用 set 做校验和规范化;没有底层存储的“虚拟属性”只写 get,读取时才计算。

property-hooks.php

<?php
class User
{
    public function __construct(
        public string $first,
        public string $last,
    ) {}

    // 虚拟属性:没有底层存储,读取时才计算
    public string $fullName {
        get => $this->first . ' ' . $this->last;
    }

    // set 钩子:赋值时校验 + 规范化
    public string $email {
        set(string $value) {
            if (!str_contains($value, '@')) {
                throw new InvalidArgumentException("邮箱格式不对: $value");
            }
            $this->email = strtolower($value);
        }
    }

    // 短语法:set => 表达式(参数名固定为 $value)
    public string $nickname = '' {
        set => trim($value);
    }
}

$u = new User('Ada', 'Lovelace');
echo $u->fullName, "\n";

$u->email = 'ADA@Example.COM';
echo $u->email, "\n";

$u->nickname = '   ada   ';
var_dump($u->nickname);

try {
    $u->email = 'oops';
} catch (InvalidArgumentException $e) {
    echo $e->getMessage(), "\n";
}

try {
    $u->fullName = 'X Y';          // 只有 get 钩子的属性不能被写入
} catch (Error $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}

输出:

$ php property-hooks.php
Ada Lovelace
ada@example.com
string(3) "ada"
邮箱格式不对: oops
Error: Property User::$fullName is read-only

8.4 非对称可见性

读和写可以有不同的可见性:public private(set) 表示“任何人可读,只有类内部可写”。以前要达到这个效果,得写 private 属性加一个 public getter,现在一个声明就行。

asymmetric.php

<?php
class Account
{
    public function __construct(
        public private(set) int $balance = 0,        // 任何人可读,只有类内部可写
        public protected(set) string $owner = 'n/a',   // 子类也可写
    ) {}

    public function deposit(int $amount): void
    {
        $this->balance += $amount;
    }
}

$a = new Account(100, 'Ann');
$a->deposit(50);
echo "余额: {$a->balance}, 户主: {$a->owner}\n";

try {
    $a->balance = 999999;           // 外部直接改:不允许
} catch (Error $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}

输出:

$ php asymmetric.php
余额: 150, 户主: Ann
Error: Cannot modify private(set) property Account::$balance from global scope

8.4 延迟对象

延迟对象(Lazy Objects)允许创建一个“占位”对象,直到第一次真正访问它的属性或方法时才执行构造逻辑。ORM 的懒加载、依赖注入容器里“用到才创建”的服务,以前要靠生成代理类实现,现在语言原生支持。

lazy.php

<?php
class HeavyService
{
    public string $name;

    public function __construct()
    {
        echo "  [HeavyService 构造:假装在连接数据库…]\n";
        $this->name = 'db-connection';
    }

    public function query(): string
    {
        return "来自 {$this->name} 的查询结果";
    }
}

$ref = new ReflectionClass(HeavyService::class);

// 创建"延迟幽灵对象":此刻不会执行构造函数
$svc = $ref->newLazyGhost(function (HeavyService $obj): void {
    $obj->__construct();
});

echo "对象已创建,尚未初始化: ";
var_dump($ref->isUninitializedLazyObject($svc));

echo "第一次访问 ->\n";
echo $svc->query(), "\n";

echo "现在已初始化: ";
var_dump(!$ref->isUninitializedLazyObject($svc));

输出:

$ php lazy.php
对象已创建,尚未初始化: bool(true)
第一次访问 ->
  [HeavyService 构造:假装在连接数据库…]
来自 db-connection 的查询结果
现在已初始化: bool(true)

输出里 HeavyService 的构造函数直到调用 query() 时才执行;在此之前对象已经存在,只是尚未初始化。

8.4 其他

  • new Foo()->method() 不必再给 new 表达式包一层括号。
  • 新增 array_find()array_find_key()array_any()array_all()
  • #[\Deprecated] 让你自己写的函数和方法也能触发标准的弃用提示。

array-find.php

<?php
// new 表达式后可以直接链式调用,不用再包一层括号
echo new DateTimeImmutable('2026-09-20')->format('Y-m-d (l)'), "\n";

// 数组查找函数:array_find / array_find_key / array_any / array_all
$nums = [3, 8, 12, 5];
var_dump(array_find($nums, fn($n) => $n > 5));
var_dump(array_find_key($nums, fn($n) => $n > 5));
var_dump(array_any($nums, fn($n) => $n % 2 === 0));
var_dump(array_all($nums, fn($n) => $n > 0));

// #[\Deprecated]:自己写的函数也能触发标准的弃用提示
#[\Deprecated('请改用 newApi()', since: '1.2')]
function oldApi(): string
{
    return 'old';
}
echo oldApi(), "\n";

输出:

$ php array-find.php
2026-09-20 (Sunday)
int(8)
int(1)
bool(true)
bool(true)

Deprecated: Function oldApi() is deprecated since 1.2, 请改用 newApi() in array-find.php on line 18
old

同时,8.4 起弃用了“类型加 = null”的隐式可空写法。升级到 8.4 之后,老代码里常会看到这条提示:

nullable-deprecated.php

<?php
// "类型 + 默认值 null" 的隐式可空写法,在 8.4 起被弃用
function greet(string $name = null): string
{
    return 'Hello, ' . ($name ?? 'guest');
}
echo greet(), "\n";

输出:

$ php nullable-deprecated.php
Deprecated: greet(): Implicitly marking parameter $name as nullable is deprecated, the explicit nullable type must be used instead in nullable-deprecated.php on line 3
Hello, guest

修复方法很简单:写成显式的 ?string $name = null

8.5 管道运算符

|> 把左边的值作为唯一参数传给右边的可调用对象,让“一连串函数依次处理同一个值”的代码可以从左到右读。右边可以是函数、strlen(...) 这种一等公民可调用语法,或者用括号包起来的箭头函数。

pipe.php

<?php
// 没有管道运算符:由内向外读,嵌套很深
$slug1 = str_replace(' ', '-', strtolower(trim('  Hello PHP World  ')));

// 有了 |>:从左到右,像流水线
$slug2 = '  Hello PHP World  '
    |> trim(...)
    |> strtolower(...)
    |> (fn(string $s) => str_replace(' ', '-', $s));

var_dump($slug1 === $slug2);
echo $slug2, "\n";

// 处理数组:过滤偶数再求和
$total = [1, 2, 3, 4, 5, 6]
    |> (fn(array $a) => array_filter($a, fn($n) => $n % 2 === 0))
    |> array_sum(...);
echo $total, "\n";

输出:

$ php pipe.php
bool(true)
hello-php-world
12

8.5 URI 扩展

内置的 URI 扩展按 RFC 3986 和 WHATWG 两套标准解析 URL,返回不可变对象;解析失败时抛出明确的异常,可以替代行为不够严谨的 parse_url()。下面的示例用的是 RFC 3986 版本 Uri\Rfc3986\Uri

uri.php

<?php
use Uri\Rfc3986\Uri;

$uri = new Uri('https://user@Example.COM:8080/docs/php/../guide?lang=zh&v=8.5#intro');

echo 'scheme  : ', $uri->getScheme(), "\n";
echo 'host    : ', $uri->getHost(), "\n";
echo 'port    : ', $uri->getPort(), "\n";
echo 'path    : ', $uri->getPath(), "\n";
echo 'query   : ', $uri->getQuery(), "\n";
echo 'fragment: ', $uri->getFragment(), "\n";

// getXxx() 返回规范化之后的值,getRawXxx() 返回原文
echo "\n";
echo 'host (规范化): ', $uri->getHost(), "\n";
echo 'host (原文)  : ', $uri->getRawHost(), "\n";
echo 'path (规范化): ', $uri->getPath(), "\n";
echo 'path (原文)  : ', $uri->getRawPath(), "\n";

// 不可变对象:with*() 返回新对象,原对象不变
echo "\n";
$next = $uri->withHost('example.org')->withPath('/new')->withQuery(null)->withFragment(null);
echo $next->toString(), "\n";
echo $uri->toString(), "\n";

// 非法 URI 会抛出明确的异常
echo "\n";
try {
    new Uri('https://exa mple.com');
} catch (\Uri\InvalidUriException $e) {
    echo get_class($e), ': ', $e->getMessage(), "\n";
}

输出:

$ php uri.php
scheme  : https
host    : example.com
port    : 8080
path    : /docs/guide
query   : lang=zh&v=8.5
fragment: intro

host (规范化): example.com
host (原文)  : Example.COM
path (规范化): /docs/guide
path (原文)  : /docs/php/../guide

https://user@example.org:8080/new
https://user@example.com:8080/docs/guide?lang=zh&v=8.5#intro

Uri\InvalidUriException: The specified URI is malformed

getXxx() 返回规范化之后的值(主机名转成小写、路径里的 .. 被折叠),getRawXxx() 返回原文。withXxx() 不会修改原对象,而是返回一个新对象,所以最后两行能同时看到修改前后的结果。传入非法字符时,会得到 Uri\InvalidUriException,而不是一个含糊的 false

8.5 clone with

clone 现在可以带第二个参数,一步完成“复制并修改某些属性”。它对 readonly 对象特别有用:以前写“with 方法”要把每个属性手动搬一遍,属性一多就很啰嗦。

clone-with.php

<?php
final class Money
{
    public function __construct(
        public readonly int $amount,
        public readonly string $currency,
    ) {}

    // 以前:要手写 new static(...) 并把每个属性搬一遍
    // 现在:clone 时直接覆盖想改的属性(连 readonly 也可以)
    public function withAmount(int $amount): static
    {
        return clone($this, ['amount' => $amount]);
    }
}

$a = new Money(100, 'SGD');
$b = $a->withAmount(250);
echo "{$a->amount} {$a->currency} / {$b->amount} {$b->currency}\n";

输出:

$ php clone-with.php
100 SGD / 250 SGD

8.5 array_first()、array_last() 与 #[\NoDiscard]

array_first()array_last() 取代了 reset() / end()$a[array_key_first($a)] 这类绕弯的写法,空数组时返回 null

#[\NoDiscard] 标记“返回值不应该被忽略”的函数,比如保存可能失败的操作;调用者忽略返回值时会得到 Warning。确实不需要结果时,可以用 8.5 新增的 (void) 转换明确表示“我是故意的”。

no-discard.php

<?php
// array_first / array_last:不用再 reset()/end() 或者 $a[array_key_first($a)]
$list = ['b' => 2, 'a' => 1, 'c' => 3];
echo array_first($list), ' ', array_last($list), "\n";
var_dump(array_first([]));

// #[\NoDiscard]:返回值不该被忽略的函数
#[\NoDiscard('保存可能失败,必须检查返回值')]
function save(array $data): bool
{
    return count($data) > 0;
}

save([]);                     // 忽略返回值 -> Warning
$ok = save(['x' => 1]);       // 使用了返回值 -> 没问题
(void) save([]);              // (void) 强制转换:明确表示"我故意不要结果"
echo "结束\n";

输出:

$ php no-discard.php
2 3
NULL

Warning: The return value of function save() should either be used or intentionally ignored by casting it as (void), 保存可能失败,必须检查返回值 in no-discard.php on line 14
结束

升级时最容易踩的坑

从 5.x 直接升到 8.x,大部分问题都出在“被移除的东西”和“被收紧的宽松行为”上。

变化 版本 处理办法
mysql_* 系列函数被移除 7.0 改用 PDO 或 mysqli
ereg* / split() 被移除 7.0 改用 preg_*
each()create_function() 被移除 8.0 改用 foreach 与匿名函数
$str{0} 花括号取字符被移除 8.0 改成 $str[0]
字符串与数字的比较规则改变 8.0 重新检查所有 ==,需要严格比较时用 ===
内部函数传错类型抛出 TypeError 8.0 修正调用处的类型,别依赖静默转换
动态属性被弃用 8.2 声明属性,或加 #[\AllowDynamicProperties]
"${var}" 字符串插值被弃用 8.2 改成 "{$var}""$var"
隐式可空参数被弃用 8.4 写成 ?T $x = null

一个实用的升级顺序是:先把错误报告调到 E_ALL,在目标版本上跑一遍测试,把所有 Deprecated 提示清掉,再去用新特性。Deprecated 是提前通知:被弃用的写法,在之后的版本里通常会变成真正的错误。

此博客中的热门博文

Elasticsearch 读写原理指南

### 1. 什么是 segment,里面装了什么? 在 Lucene(也是 Elasticsearch)里,索引被切分成若干 **segment(段)**,每个 segment 是一个完整的、只读的倒排索引单元。一个 segment 包含: * **倒排词典** —— 用 **FST(Finite‑State Transducer)** 以高度压缩的形式保存每个字段出现的所有 term 以及 term→ord 的映射。对应的磁盘文件是 `*.tim`(新版)或 `*.tis/*.tii`(旧版)。 * **倒排列表(postings)** —— 保存每个 term 出现的文档 ID、频次、位置信息等,文件名通常是 `*.doc`、`*.pos`、`*.pay`。 * **存储字段**(_source、store:true 的字段)—— 以二进制块的形式写入 `*.fdt` / `*.fdx`。 * **doc‑values、norms、向量** 等辅助结构,分别保存在 `*.dv`、`*.norm`、`*.tv` 等文件里。 * **deleted‑docs bitmap**(`*.del`),标记哪些文档已被删除或被更新。 所有这些文件在 segment **写入磁盘后即成为只读**,后续的查询只能读取,永远不会在原文件上进行增删改。 --- ### 2. 原始文档和 FST 为什么都在 segment 里? * **原始文档**:Elasticsearch 默认把完整的 JSON(_source)以及任何 `store:true` 的字段写入 segment 的 `*.fdt/*.fdx` 文件。每个 segment 保存自己的那部分文档,旧的 segment 在合并前仍然保留,直到合并后被删除。 * **FST**:每个字段的词典在每个 segment 中单独维护,采用 FST 进行前缀共享和字节压缩。这样即使同一个 term 在多个 segment 中出现,也会在每个 segment 里拥有独立的映射,查询时只需要在对应 segment 的 FST 中定位即可。 --- ### 3. 查询时到底是怎么遍历 segment 的? 1. **请求入口**      客户端的搜索请求先到达 **协调节点**,协调节点把请求 ...

LLM缓存详解

 可以把“大模型缓存”理解成: 把已经算过的结果(或中间结果)存下来,下次尽量复用 。但这里面其实分几层,不只是简单的“问题→答案”缓存。 1️⃣ 常见的几种缓存类型 (1)KV Cache(推理内部缓存) Transformer 在生成时,会把前面 token 的 Key/Value 向量 缓存下来。 本质:避免重复计算 attention 作用: 同一请求内部加速 特点: 👉 只对“同一上下文继续生成”有效 👉 不跨用户、不跨请求 这类缓存是你体感“流式输出越来越快”的原因之一。 (2)Prompt Cache(提示词缓存) 缓存的是: 相同(或高度相似)的 prompt → 对应的中间表示 / 输出 典型场景: 系统提示词(system prompt)很长 多轮对话里前文基本不变 👉 这里能省掉 前缀计算成本(prefill) (3)Embedding / 语义缓存(Semantic Cache) 这个才是你问题的关键 👇 不是按“字符串完全一致”,而是: 把问题转成向量 → 找“语义相似”的历史问题 → 直接复用答案 2️⃣ 为什么命中缓存成本低很多? 因为大模型推理成本主要在两块: (1)Prefill(吃 prompt) 复杂度 ~ O(n²) 很贵(尤其长 prompt) (2)Decode(逐 token 生成) 每个 token 都要算一遍模型 而缓存命中后: KV cache:不用重复 attention Prompt cache:不用重新 encode 语义缓存: 直接跳过模型推理 👉 相当于从: 几十~几百毫秒 + GPU算力 变成: 一次向量检索(毫秒级)+ 直接返回 所以成本差一个数量级是正常的。 3️⃣ “每个人问法不同,怎么命中缓存?” 这是核心难点,也是工程重点👇 ❌ 不能靠字符串匹配 比如: “今天天气怎么样” “今天外面热不热” 字符串完全不同 → 必须 miss ✅ 用语义相似度(Embedding) 流程一般是: 把问题转 embedding(向量) 在向量数据库里找 TopK 相似问题 如果相似度 > 阈值(比如 0.9) 直接返回缓存答案 一个简单示意 Q1: 北京天气怎么样 → embedding A Q2: 北京今天热吗 → embedding B cosine(A, B) ≈ 0.95...

事务的ACID是什么

 事务的 ACID 是数据库事务必须满足的四个基本性质,用来保证在并发和故障情况下数据的正确性与可靠性: A(Atomicity,原子性) 一个事务中的操作要么 全部成功 ,要么 全部失败回滚 ,不存在“只做了一半”的中间状态。 C(Consistency,一致性) 事务执行前后,数据库都必须处于 一致的合法状态 ,满足约束(如主键、外键、唯一性、业务规则等)。 I(Isolation,隔离性) 并发执行的多个事务之间 相互隔离 ,一个事务未提交的中间结果对其他事务不可见(具体强弱由隔离级别决定)。 D(Durability,持久性) 一旦事务提交成功,其结果会被 永久保存 ,即使系统崩溃也不会丢失(通常依赖 WAL/redo log 等机制)。 一句话记忆: 要么全做完、前后不破坏规则、互不干扰、做完不丢。