前言
"给函数参数补默认值"这件事,在 PHP 7.4 之前有一堆写法,每种都有各自的坑:
<?php // 写法一:isset 三连,参数一多就变成一屏 if (!isset($options['timeout'])) { $options['timeout'] = 5; } // 写法二:三元运算符,但没考虑到"键不存在"会发 Notice $timeout = isset($options['timeout']) ? $options['timeout'] : 5; // 写法三:短三元,0 和 '' 会被当成"没传" $timeout = $options['timeout'] ?: 5; // 写法四:?? 已经不错了,但要写两遍变量名 $options['timeout'] = $options['timeout'] ?? 5;??=(null 合并赋值运算符,null coalescing assignment operator)是PHP 7.4 引入的,它把这四种写法统一成了最短的一种:
<?php $options['timeout'] ??= 5;它解决的问题不是"少敲几个字符",而是把"只在缺失时赋值"这个语义固定下来,并且不再产生 Notice。本文讲清它的确切语义(尤其是和?:、??的区别)、惰性求值这个容易被忽略的特性,以及在函数参数、配置合并、多层默认值这三类场景里怎么用。
一、??=的确切语义
一句话:当左边"未定义"或"值恰好是null"时,把右边的值赋给它;否则什么都不做。
它等价于下面这行,但左边只求值一次:
<?php $a = $a ?? $default; // 等价写法 $a ??= $default; // 更短,且左边只计算一次"左边只求值一次"在左边是复杂表达式时很关键。下面这种写法会调用两次getOptions():
<?php $this->getOptions()['timeout'] = $this->getOptions()['timeout'] ?? 5;而??=不会重复调用。什么时候会"什么都不做",是理解它的核心,请看对照表:
$a的初始状态 | $a = $a ?? 'd' | $a ??= 'd' | $a = $a ?: 'd' |
|---|---|---|---|
| 未定义 | 'd'(无 Notice) | 'd'(无 Notice) | 'd',但会发Notice: Undefined variable |
null | 'd' | 'd' | 'd' |
0 | 0 | 0 | 'd' |
0.0 | 0.0 | 0.0 | 'd' |
'' | '' | '' | 'd' |
'0' | '0' | '0' | 'd' |
false | false | false | 'd' |
[] | [] | [] | 'd' |
'x' | 'x' | 'x' | 'x' |
三个结论:
??=只认null和"未定义"。0、''、false、[]都是"有效值",不会被覆盖。这正是参数默认值场景最需要的行为——用户明确传了0(比如"重试次数 0 次")时,默认值不该把它顶掉。?:用的是"真值判断",上表最后六行全部被覆盖。拿它处理默认值是经典 bug 来源。??=和??都不会因为变量未定义而发 Notice,而?:和if (!$a)会。注意这个 Notice 在 PHP 7.4 下是E_NOTICE,PHP 8.0 起升级成了E_WARNING,升级后日志会明显变吵。
返回值:整个表达式等于"赋进去的值"
??=是一个赋值表达式,所以它有值:
<?php $a = null; $b = ($a ??= 5); var_dump($a, $b); // int(5) int(5)这个特性在链式赋值里偶尔有用,但不要拿它当"是否缺失"的判断,原因见后面的坑点。
二、惰性求值:右边不是每次都算
??=的右边只在真的需要赋值时才求值。这一点和?:一样,但在实际代码里经常被忽略:
<?php $logger ??= $this->buildLogger(); // buildLogger() 只在 $logger 为 null 时被调用这条规则的价值在于:可以把"开销大的默认值构造"直接写在右边,不用担心每次都执行。下面的实战代码里会用一个计数器把这个行为验证出来。
三、它能用在哪里
| 目标 | 写法 | 说明 |
|---|---|---|
| 普通变量 | $a ??= 1; | 变量未定义也不会报错 |
| 数组元素 | $arr['k'] ??= 1; | 数组本身必须存在 |
| 多维数组 | $c['a']['b'] ??= 1; | 中间层不存在时会自动创建 |
| 对象属性 | $obj->prop ??= 1; | $obj必须存在;注意与魔术方法的交互 |
| 静态属性 | self::$cache ??= []; | 类内直接写$cache ??= []会变成局部变量 |
| 字符串偏移 | $s[0] ??= 'x'; | 不常见,但语法允许 |
不能用在不可写的目标上。函数调用的返回值、(new Foo)->bar之后再去写、以及各种临时表达式,都只能读不能写,??=会直接报错。这也是"左边只求值一次"的另一个好处:它本来就不该被用在临时表达式上。
四、实战:三种典型场景
下面这份代码可以在 PHP 7.4 上直接运行,覆盖了参数默认值、惰性默认值、配置级联三类场景。
<?php declare(strict_types=1); /** * ??= 在参数与配置默认值里的典型用法 * 最低版本:PHP 7.4(null 合并赋值运算符是 7.4 引入的) */ /* ---------- 场景一:函数参数默认值 ---------- */ function paginate(array $rows, ?int $perPage = null, ?int $page = null): array { // 没传、或者显式传了 null,都按默认值处理 $perPage ??= 20; $page ??= 1; if ($perPage < 1 || $page < 1) { throw new InvalidArgumentException('分页参数必须是正整数'); } $offset = ($page - 1) * $perPage; return array_slice($rows, $offset, $perPage); } /* ---------- 场景二:惰性默认值 ---------- */ function makeLogger(?string $path = null, ?array $options = null): string { $buildCalls = 0; // 右侧的闭包只在 $path 为 null 时才执行 $defaultPath = static function () use (&$buildCalls): string { $buildCalls++; return '/var/log/app.log'; }; $path ??= $defaultPath(); $options ??= ['level' => 'info', 'rotate' => true]; return sprintf('path=%s calls=%d options=%d', $path, $buildCalls, count($options)); } /* ---------- 场景三:配置级联 ---------- */ function buildConfig(array $userConfig): array { $config = $userConfig; // 中间层不存在时会被自动创建 $config['db']['host'] ??= '127.0.0.1'; $config['db']['port'] ??= 3306; $config['cache']['driver'] ??= 'file'; $config['cache']['ttl'] ??= 300; return $config; } /* ---------- 场景四:按默认值合并两个来源 ---------- */ function withDefaults(array $options): array { $defaults = [ 'timeout' => 5, 'retries' => 3, 'verify' => true, 'baseUri' => 'https://api.example.com', ]; // 只给"没传"的键补默认值,用户传的 0 / '' / false 都会被保留 foreach ($defaults as $key => $value) { $options[$key] ??= $value; } return $options; } // ---------------- 演示 ---------------- print_r(paginate(range(1, 10), 3, 2)); echo makeLogger('/tmp/custom.log'), PHP_EOL; echo makeLogger(null), PHP_EOL; // cache.ttl 显式传了 0,不会被 300 顶掉 print_r(buildConfig(['cache' => ['ttl' => 0]])); // timeout 传 0 保留,retries 传 null 被补成 3 print_r(withDefaults(['timeout' => 0, 'retries' => null])); // 值语义验证:0 不是 null,不会被覆盖 $retry = 0; $retry ??= 5; var_dump($retry); // int(0) // 未定义的变量用 ??= 不会产生任何警告 unset($undefined); $undefined ??= 'default'; var_dump($undefined); // string(7) "default"关键输出:
Array ( [0] => 4 [1] => 5 [2] => 6 ) path=/tmp/custom.log calls=0 options=2 path=/var/log/app.log calls=1 options=2 Array ( [cache] => Array ( [ttl] => 0 [driver] => file ) [db] => Array ( [host] => 127.0.0.1 [port] => 3306 ) ) Array ( [timeout] => 0 [retries] => 3 [verify] => 1 [baseUri] => https://api.example.com ) int(0) string(7) "default"两处值得留意:path=/tmp/custom.log calls=0说明传了值时默认值构造函数一次都没跑;[ttl] => 0说明显式传的0被完整保留了——如果那行写的是?:,这里会变成300。
常见坑点
1. 无法区分"没传"和"显式传 null"
❌ 错误写法:
<?php function createUser(string $name, ?string $nickname = null): array { // 调用方传 null 想表达"清空昵称",这里却把它补成了默认值 $nickname ??= $name; return ['name' => $name, 'nickname' => $nickname]; }✅ 正确写法:只有当"缺失"和"null"需要区分时,用array_key_exists()/func_num_args()显式判断:
<?php function createUser(string $name, ?string $nickname = null): array { // func_num_args() 告诉你调用方到底传了几个参数 if (func_num_args() < 2) { $nickname = $name; } return ['name' => $name, 'nickname' => $nickname]; }这是??=的语义边界:它把"未定义"和"null"当成同一件事,这一点在绝大多数默认值场景下是对的,但遇到"null 有特殊含义"的接口就要换手段。
2. 用?:代替??=
❌ 错误写法:
<?php $options['retries'] = $options['retries'] ?: 3; // 用户传 0(表示不重试)→ 变成 3;传 '' → 变成 3;传 false → 变成 3✅ 正确写法:
<?php $options['retries'] ??= 3; // 只有没传或传 null 时才补 3这个 bug 在"用户明确传了 0 或空字符串"时才暴露,测试用例里如果只测了正常值,会一直潜伏到线上。
3. 以为右边的表达式每次都会执行
❌ 错误写法:
<?php $stats = ['default_used' => 0]; $config ??= (function () use (&$stats) { $stats['default_used']++; // 指望它统计"用了多少次默认值" return ['mode' => 'prod']; })(); echo $stats['default_used']; // 永远是 0✅ 正确写法:把统计放在赋值语句外面,或者接受它只在赋值时执行一次的事实:
<?php if ($config === null) { $stats['default_used']++; $config = ['mode' => 'prod']; }惰性求值是特性,不是 bug,但它会让"我明明每次都调用这个函数"的假设失效。
4. 对$_GET/$_POST用了??=就以为数据安全了
❌ 错误写法:
<?php $_GET['page'] ??= 1; $page = $_GET['page']; $sql = "SELECT * FROM articles LIMIT {$page}"; // 用户传的是字符串或者数组✅ 正确写法:??=只负责"补默认值",不负责类型校验。补完还要显式转换和校验:
<?php $raw = $_GET['page'] ?? null; $page = filter_var($raw, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); if ($page === false) { $page = 1; }尤其注意:如果请求是?page[]=1,$_GET['page']是数组,??=不会拦它,直接拼进 SQL 或者当成数字用都会出问题。
5. 魔术方法类里没实现__isset()
❌ 错误写法:
<?php class Settings { private array $data = []; public function __get(string $name) { return $this->data[$name] ?? null; } // 只实现了 __get(),没有 __isset() } $settings = new Settings(); $settings->theme ??= 'dark'; // 每次都重新赋值,即使之前设过✅ 正确写法:同时实现__isset():
<?php class Settings { private array $data = []; public function __get(string $name) { return $this->data[$name] ?? null; } public function __isset(string $name): bool { return isset($this->data[$name]); } public function __set(string $name, $value): void { $this->data[$name] = $value; } }??=判断"是否已设置"时问的是__isset()。类里只写了__get()时,__isset()不存在,判断结果永远是"未设置",于是每次执行??=都会覆盖掉之前设的值——症状是"配置项设了又变回去",而且完全看不出原因。
6. 多维数组的自动创建带来意外结构
❌ 错误写法:
<?php // 键名来自用户输入时,会自动创建出任意深度的嵌套结构 foreach ($_POST as $key => $value) { $config[$key] ??= $value; // 单层还行 $config['user'][$key] ??= $value; // 会悄悄创建 $config['user'] }✅ 正确写法:先校验键名,再赋值。??=的"自动创建中间层"很方便,但方便的方向是"帮你补齐",它不会拒绝任何结构:
<?php $allowed = ['timeout', 'retries', 'verify']; $key = (string) $key; if (!in_array($key, $allowed, true)) { throw new InvalidArgumentException("未知配置项: {$key}"); } $config[$key] ??= $value;7. 拿??=的返回值当"是否缺失"的判断
❌ 错误写法:
<?php if ($options['timeout'] ??= 5) { // 作者以为:进了这个分支说明"没传 timeout" // 实际是:只要 $options['timeout'] 为假值(0、''、false)就会进不来 }✅ 正确写法:需要知道"是否缺失"就显式判断,不要读赋值表达式的结果:
<?php $usedDefault = !isset($options['timeout']); $options['timeout'] ??= 5; if ($usedDefault) { // 这里才是"真的用了默认值" }8. 部署环境的 PHP 低于 7.4
❌ 错误写法:本地用 8.x 开发,在??=上写得很顺手,部署到一台还跑着 PHP 7.3 的机器上,直接得到Parse error: syntax error, unexpected '??='——整个文件都加载不了,页面直接 500,而且错误信息指向的那一行可能完全无辜(PHP 在解析阶段就把整个文件拒了)。
✅ 正确写法:动手前确认版本,并且知道??=的边界:
| 写法 | 引入版本 |
|---|---|
??(null 合并) | PHP 7.0 |
??=(null 合并赋值) | PHP 7.4 |
箭头函数fn() | PHP 7.4 |
| 类型化属性 | PHP 7.4 |
| 构造器属性提升 | PHP 8.0 |
str_contains()/str_starts_with() | PHP 8.0 |
总结
| 需求 | 写法 | 注意事项 |
|---|---|---|
| 变量/数组元素补默认值 | $x ??= $default; | 只对"未定义"和null生效 |
保留用户传的0/''/false | $x ??= $default; | 用?:会把这些值顶掉 |
| 默认值构造开销大 | $x ??= build(); | 惰性求值,只在需要时执行 |
| 区分"没传"与"传了 null" | func_num_args()/array_key_exists() | ??=不区分这两者 |
| 多层配置 | $c['db']['host'] ??= '...'; | 中间层会被自动创建 |
| 判断是否用了默认值 | 先isset()记下结果,再??= | 不要读??=的返回值 |
??=的价值在于它把"默认值"这件事的语义压缩到了一个符号里:只在缺失时生效,且不影响任何"看起来像空值"的合法输入。用它的同时记住那条边界——它眼里的"缺失"只有两种情况,未定义和null;一旦业务上需要把null当成有意义的值,就得换回array_key_exists()这类显式判断。