PHP 7.4中新增的null合并赋值运算符怎么简化函数参数判断
发布时间:2026/10/1 18:49:18来源:尧图网络
前言给函数参数补默认值这件事在 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无 Noticed无 Noticed但会发Notice: Undefined variablenullddd000d0.00.00.0dd000dfalsefalsefalsed[][][]dxxxx三个结论??只认null和未定义。0、、false、[]都是有效值不会被覆盖。这正是参数默认值场景最需要的行为——用户明确传了0比如重试次数 0 次时默认值不该把它顶掉。?:用的是真值判断上表最后六行全部被覆盖。拿它处理默认值是经典 bug 来源。??和??都不会因为变量未定义而发 Notice而?:和if (!$a)会。注意这个 Notice 在 PHP 7.4 下是E_NOTICEPHP 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_types1); /** * ?? 在参数与配置默认值里的典型用法 * 最低版本PHP 7.4null 合并赋值运算符是 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 calls0 options2 path/var/log/app.log calls1 options2 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 calls0说明传了值时默认值构造函数一次都没跑[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.0str_contains()/str_starts_with()PHP 8.0总结需求写法注意事项变量/数组元素补默认值$x ?? $default;只对未定义和null生效保留用户传的0//false$x ?? $default;用?:会把这些值顶掉默认值构造开销大$x ?? build();惰性求值只在需要时执行区分没传与传了 nullfunc_num_args()/array_key_exists()??不区分这两者多层配置$c[db][host] ?? ...;中间层会被自动创建判断是否用了默认值先isset()记下结果再??不要读??的返回值??的价值在于它把默认值这件事的语义压缩到了一个符号里只在缺失时生效且不影响任何看起来像空值的合法输入。用它的同时记住那条边界——它眼里的缺失只有两种情况未定义和null一旦业务上需要把null当成有意义的值就得换回array_key_exists()这类显式判断。
网站建设高端定制企业官网