使用新的 PHP 8 运算符

PHP 8 引入了许多新的运算符。此外,PHP 8 还为这些运算符的使用引入了统一一致的方式。在本节中,我们将考察以下操作符:

  • 可变参数运算符

  • 空安全操作符

  • 连接运算符

  • 三元操作符

让我们先来讨论一下可变参数运算符。

使用可变参数运算符

variadics 运算符由普通 PHP 变量(或对象属性)前面的三个前导点(…​)组成。该运算符实际上从 PHP 5.6 开始就已经存在了。它也被称为以下内容:

  • 溅射(Splat)运算符

  • 散点(Scatter)运算符

  • 扩散(Spread)算子

在深入了解 PHP 8 对该运算符的改进之前,我们先来看看该运算符通常是如何使用的。

未知参数个数

变量运算符最常见的用途之一,是在定义一个参数数目未知的函数时使用。

在下面的代码示例中,multiVardump() 函数可以接受任意数量的变量。然后,它将 var_export() 的输出连接起来,并返回一个字符串:

// /repo/ch02/php7_variadic_params.php
function multiVardump(...$args) {
    $output = '';
    foreach ($args as $var)
        $output .= var_export($var, TRUE);
    return $output;
}
$a = new ArrayIterator(range('A','F'));
$b = function (string $val) { return str_rot13($val); };
$c = [1,2,3];
$d = 'TEST';
echo multiVardump($a, $b, $c);
echo multiVardump($d);

第一次调用函数时,我们提供了三个参数。第二次调用时,我们只提供了一个参数。由于我们使用了变量运算符,因此无需重写函数以适应更多或更少的参数。

有一个 func_get_args() PHP 函数可以将所有函数参数集合到一个数组中。不过,我们更倾向于使用 variadics 操作符,因为它必须在函数签名中说明,从而使程序开发者的意图更加明确。更多信息,请参见 https://php.net/func_get_args

清理剩余的参数

variadics 运算符的另一个用途是将剩余的参数真空化。通过这种技术,可以将必选参数与数量未知的可选参数混合起来。

在本例中,where() 函数产生一个 WHERE 子句,并被添加到结构化查询语言(SQL)的 SELECT 语句中。前两个参数是必须的:生成一个没有参数的 WHERE 子句是不合理的!点击此处查看代码:

// ch02/includes/php7_sql_lib.php
// other functions not shown
function where(stdClass $obj, $a, $b = '', $c = '',
        $d = '') {
    $obj->where[] = $a;
    $obj->where[] = $b;
    $obj->where[] = $c;
    $obj->where[] = $d;
}

使用此函数的调用代码可能如下所示:

// /repo/ch02/php7_variadics_sql.php
require_once __DIR__ . '/includes/php7_sql_lib.php';
$start = '2021-01-01';
$end = '2021-04-01';
$select = new stdClass();
from($select, 'events');
cols($select, ['id', 'event_key',
    'event_name', 'event_date']);
limit($select, 10);
where($select, 'event_date', '>=', "'$start'");
where($select, 'AND');
where($select, 'event_date', '<', "'$end'");
$sql = render($select);
// remaining code not shown

您可能已经注意到,由于参数数量有限,where() 必须被多次调用。这正是变量运算符的完美候选!下面是重写后的 where() 函数的样子:

// ch02/includes/php8_sql_lib.php
// other functions not shown
function where(stdClass $obj, ...$args) {
    $obj->where = (empty($obj->where))
        ? $args
        : array_merge($obj->where, $args);
}

由于 …​$args 总是以数组形式返回,为了确保对函数的其他调用不会丢失子句,我们需要执行 array_merge() 操作。下面是重写后的调用程序:

// /repo/ch02/php8_variadics_sql.php
require_once __DIR__ . '/includes/sql_lib2.php';
$start = '2021-01-01';
$end = '2021-04-01';
$select = new stdClass();
from($select, 'events');
cols($select, ['id', 'event_key',
    'event_name', 'event_date']);
limit($select, 10);
where($select, 'event_date', '>=', "'$start'",
    'AND', 'event_date', '<', "'$end'");
$sql = render($select);
// remaining code not shown

结果 SQL 语句如下所示:

SELECT id,event_key,event_name,event_date
FROM events
WHERE event_date >= '2021-01-01'
AND event_date <= '2021-04-01'
LIMIT 10

前面的输出显示,我们的 SQL 生成逻辑生成了一条有效语句。

使用可变参数运算符作为替代

到目前为止,这些对于有经验的 PHP 开发人员来说都不陌生。PHP 8 中的不同之处在于,可变参数运算符现在可以用于 widening 的情况。

为了正确描述如何使用可变参数运算符,我们需要简要地回到面向对象编程(OOP)。如果我们把刚才描述的 where() 函数改写成一个类方法,它可能看起来像这样:

// src/Php7/Sql/Where.php
namespace Php7\Sql;
class Where {
    public $where = [];
    public function where($a, $b = '', $c = '', $d = '') {
        $this->where[] = $a;
        $this->where[] = $b;
        $this->where[] = $c;
        $this->where[] = $d;
        return $this;
    }
// other code not shown
}

现在,假设我们有一个 Select 类,它扩展了 Where,但使用 可变参数操作符重新定义了方法签名。它可能是这样的:

// src/Php7/Sql/Select.php
namespace Php7\Sql;
class Select extends Where {
    public function where(...$args) {
        $this->where = (empty($obj->where))
            ? $args
            : array_merge($obj->where, $args);
    }
    // other code not shown
}

使用可变参数运算符是合理的,因为制定 WHERE 子句所提供的参数数量是未知的。下面是使用 OOP 重写的调用程序:

// /repo/ch02/php7_variadics_problem.php
require_once __DIR__ . '/../src/Server/Autoload/Loader.php'
$loader = new \Server\Autoload\Loader();
use Php7\Sql\Select;
$start = "'2021-01-01'";
$end = "'2021-04-01'";
$select = new Select();
$select->from($select, 'events')
    ->cols($select, ['id', 'event_key',
        'event_name', 'event_date'])
    ->limit($select, 10)
    ->where($select, 'event_date', '>=', "'$start'",
        'AND', 'event_date', '<=', "'$end'");
$sql = $select->render();
// other code not shown

但是,当您尝试在 PHP 7 下运行此示例时,会出现以下警告:

Warning: Declaration of Php7\Sql\Select::where(...$args) should be compatible with Php7\Sql\Where::where($a, $b = '', $c = '', $d = '') in /repo/src/Php7/Sql/Select.php on line 5

请注意,代码仍然可以运行;但是,PHP 7 并没有将可变参数运算符视为可行的替代品。下面是在 PHP 8 下运行的相同代码(使用 /repo/ch02/php8_variadics_no_problem.php):

image 2023 11 20 15 55 54 754
Figure 1. Figure 2.1 – Variadics operator acceptable in extending class

以下两个 PHP 文档参考资料解释了 PHP variadics 运算符背后的原因:

现在让我们来看看 nullsafe 运算符。

使用空安全运算符

nullsafe 运算符用于对象属性引用链。如果链中的某个属性不存在(换句话说,它被视为 NULL),操作符会安全地返回一个 NULL 值,而不会发出警告。

举例来说,假设我们有以下扩展标记语言(XML)文件:

<?xml version='1.0' standalone='yes'?>
<produce>
	<file>/repo/ch02/includes/produce.xml</file>
	<dept>
		<fruit>
			<apple>11</apple>
			<banana>22</banana>
			<cherry>33</cherry>
		</fruit>
		<vegetable>
			<artichoke>11</artichoke>
			<beans>22</beans>
			<cabbage>33</cabbage>
		</vegetable>
	</dept>
</produce>

以下是扫描 XML 文档并显示数量的代码片段:

// /repo/ch02/php7_nullsafe_xml.php
$xml = simplexml_load_file(__DIR__ .
    '/includes/produce.xml');
$produce = [
    'fruit' => ['apple','banana','cherry','pear'],
    'vegetable' => ['artichoke','beans','cabbage','squash']
];
$pattern = "%10s : %d\n";
foreach ($produce as $type => $items) {
    echo ucfirst($type) . ":\n";
    foreach ($items as $item) {
        $qty = getQuantity($xml, $type, $item);
        printf($pattern, $item, $qty);
    }
}

我们还需要定义一个 getQuantity() 函数,首先检查该属性是否为空,然后再进入下一级,如下所示:

function getQuantity(SimpleXMLElement $xml,
                     string $type, string $item {
    $qty = 0;
    if (!empty($xml->dept)) {
        if (!empty($xml->dept->$type)) {
            if (!empty($xml->dept->$type->$item)) {
                $qty = $xml->dept->$type->$item;
            }
        }
    }
    return $qty;
}

随着嵌套层级的加深,检查属性是否存在所需的函数也变得越来越复杂。这正是可以使用 nullsafe 操作符的地方。

请看同样的程序代码,但不需要 getQuantity() 函数,如下所示:

// /repo/ch02/php8_nullsafe_xml.php
$xml = simplexml_load_file(__DIR__ .
    '/includes/produce.xml');
$produce = [
    'fruit' => ['apple','banana','cherry','pear'],
    'vegetable' => ['artichoke','beans','cabbage','squash']
];
$pattern = "%10s : %d\n";
foreach ($produce as $type => $items) {
    echo ucfirst($type) . ":\n";
    foreach ($items as $item) {
        printf($pattern, $item,
            $xml?->dept?->$type?->$item);
    }
}

现在我们来看看 nullsafe 运算符的另一种用法。

使用 nullsafe 运算符使链条短路

nullsafe 操作符在一连串连接操作(包括对象属性引用、数组元素方法调用和静态引用)中使用时也很有用。

下面是一个返回匿名类的配置文件。它根据文件类型定义了不同的数据提取方法:

// ch02/includes/nullsafe_config.php
return new class() {
    const HEADERS = ['Name','Amt','Age','ISO','Company'];
    const PATTERN = "%20s | %16s | %3s | %3s | %s\n";
    public function json($fn) {
        $json = file_get_contents($fn);
        return json_decode($json, TRUE);
    }
    public function csv($fn) {
        $arr = [];
        $fh = new SplFileObject($fn, 'r');
        while ($node = $fh->fgetcsv()) $arr[] = $node;
        return $arr;
    }
    public function txt($fn) {
        $arr = [];
        $fh = new SplFileObject($fn, 'r');
        while ($node = $fh->fgets())
            $arr[] = explode("\t", $node);
        return $arr;
    }
    // all code not shown
};

该类还包括一个显示数据的方法,如以下代码片段所示:

public function display(array $data) {
    $total = 0;
    vprintf(self::PATTERN, self::HEADERS);
    foreach ($data as $row) {
        $total += $row[1];
        $row[1] = number_format($row[1], 0);
        $row[2] = (string) $row[2];
        vprintf(self::PATTERN, $row);
    }
    echo 'Combined Wealth: '
        . number_format($total, 0) . "\n";
}

在调用程序中,为了安全地执行 display() 方法,我们需要在执行回调之前添加 is_object() extra 安全检查和 method_exists(),如下代码片段所示:

// /repo/ch02/php7_nullsafe_short.php
$config = include __DIR__ .
    '/includes/nullsafe_config.php';
$allowed = ['csv' => 'csv','json' => 'json','txt' => 'txt'];
$format = $_GET['format'] ?? 'txt';
$ext = $allowed[$format] ?? 'txt';
$fn = __DIR__ . '/includes/nullsafe_data.' . $ext;
if (file_exists($fn)) {
    if (is_object($config)) {
        if (method_exists($config, 'display')) {
            if (method_exists($config, $ext)) {
                $config->display($config->$ext($fn));
            }
        }
    }
}

与前面的示例一样,nullsafe 操作符可用于确认 $config 作为对象是否存在。只需在第一个对象引用中使用 nullsafe 操作符,如果对象或方法不存在,操作符就会使整个链短路并返回 NULL

下面是使用 PHP 8 nullsafe 操作符重写的代码:

// /repo/ch02/php8_nullsafe_short.php
$config = include __DIR__ .
    '/includes/nullsafe_config.php';
$allowed = ['csv' => 'csv','json' => 'json', 'txt' => 'txt'];
$format = $_GET['format'] ?? $argv[1] ?? 'txt';
$ext = $allowed[$format] ?? 'txt';
$fn = __DIR__ . '/includes/nullsafe_data.' . $ext;
if (file_exists($fn)) {
    $config?->display($config->$ext($fn));
}

如果 $config 返回 NULL,整个操作链将被取消,不会产生警告或通知,返回值(如果有的话)也是 NULL。最终结果是,我们省去了编写三个额外的 if() 语句!

有关使用此运算符时其他注意事项的详细信息,请点击此处: https://wiki.php.net/rfc/nullsafe_operator

为了将格式参数传递给示例代码文件,您需要从浏览器运行代码,如下所示: http://localhost:8888/ch02/php7_nullsafe_short.php?format=json

接下来,我们看看连接运算符的变化。

连接运算符已被降级

尽管在 PHP 8 中连接运算符(例如句号(.))的精确用法没有改变,但它在 优先级顺序 中的相对位置发生了极其重要的变化。在 PHP 的早期版本中,连接运算符的优先级与低阶算术运算符加号 (+) 和减号 (-) 相等。接下来,让我们看看传统优先级顺序的一个潜在问题:违背直觉的结果。

// /repo/ch02/php7_ops_concat_1.php
$a = 11;
$b = 22;
echo "Sum: " . $a + $b;

如果只看代码,你会以为输出的内容大致是 "Sum:33"。但事实并非如此!请看看在 PHP 7.1 上运行时的输出结果:

root@php8_tips_php7 [ /repo/ch02 ]# php php7_ops_concat_1.php
PHP Warning: A non-numeric value encountered in /repo/ch02/
php7_ops_concat_1.php on line 5
PHP Stack trace:
PHP 1. {main}() /repo/ch02/php7_ops_concat_1.php:0
Warning: A non-numeric value encountered in /repo/ch02/php7_
ops_concat_1.php on line 5
Call Stack:
0.0001 345896 1. {main}()
22

此时,您可能想知道,因为代码从来不知道 11 + 22 的总和是 22,正如我们在前面的输出(最后一行)中看到的那样?

答案涉及优先顺序:从 PHP 7 开始,它始终是从左到右。因此,如果我们使用括号来使操作顺序更清晰,那么实际发生的情况如下:

echo ("Sum: " . $a) + $b;

11 与 "Sum: ",得到字符串 "Sum: 11"。然后将该字符串键入一个整数,得到 0 + 22 表达式,这就是我们的结果。

如果在 PHP 8 中运行同样的代码,请注意这里的区别:

root@php8_tips_php8 [ /repo/ch02 ]# php php8_ops_concat_1.php
Sum: 33

可以看到,算术运算符优先于连接运算符。使用括号后,代码在 PHP 8 中实际上就是这样处理的:

echo "Sum: " . ($a + $b);

最佳做法:使用括号可避免因依赖优先顺序而产生的复杂问题。有关降低连接运算符优先级的理由的更多信息,请访问: https://wiki.php.net/rfc/concatenation_precedence

现在我们把注意力转向三元算子。

使用嵌套三元运算符

三元运算符 对于 PHP 语言来说并不陌生。不过,在 PHP 8 中,三元运算符的解释方式有很大不同。这一变化与三元运算符传统的 左关联行为 有关。下面我们来看一个简单的例子来说明:

  1. 在这个示例中,假设我们使用 RecursiveDirectoryIterator 类和 RecursiveIteratorIterator 类来扫描目录结构。开始的代码可能是这样的:

    // /repo/ch02/php7_nested_ternary.php
    $path = realpath(__DIR__ . '/..');
    $searchPath = '/ch';
    $searchExt = 'php';
    $dirIter = new RecursiveDirectoryIterator($path);
    $itIter = new RecursiveIteratorIterator($dirIter);
  2. 然后,我们定义一个函数,用于匹配包含搜索路径 $searchPath 并以扩展名 $searchExt 结尾的文件,如下所示:

    function find_using_if($iter, $searchPath, $searchExt) {
        $matching = [];
        $non_match = [];
        $discard = [];
        foreach ($iter as $name => $obj) {
            if (!$obj->isFile()) {
                $discard[] = $name;
            } elseif (!strpos($name, $searchPath)) {
                $discard[] = $name;
            } elseif ($obj->getExtension() !== $searchExt) {
                $non_match[] = $name;
            } else {
                $matching[] = $name;
            }
        }
        show($matching, $non_match);
    }
  3. 不过,有些开发人员可能会倾向于使用嵌套的三元操作符来重构这个函数,而不是使用 if / elseif / else。下面是上一步中使用的相同代码:

    function find_using_tern($iter, $searchPath,
                             $searchExt){
        $matching = [];
        $non_match = [];
        $discard = [];
        foreach ($iter as $name => $obj) {
            $match = !$obj->isFile()
                ? $discard[] = $name
                : !strpos($name, $searchPath)
                    ? $discard[] = $name
                    : $obj->getExtension() !== $searchExt
                        ? $non_match[] = $name
                        : $matching[] = $name;
        }
        show($matching, $non_match);
    }

在 PHP 7 中,这两个函数的输出结果完全相同,如下图所示:

image 2023 11 20 16 27 57 293
Figure 2. Figure 2.2 – Nested ternary output using PHP 7

但在 PHP 8 中,嵌套的三元运算不再允许使用不带括号的三元运算。下面是运行相同代码块时的输出结果:

image 2023 11 20 16 28 49 523
Figure 3. Figure 2.3 – Nested ternary output using PHP 8

最佳做法:使用括号可避免嵌套三元运算的问题。有关三元操作嵌套差异的更多信息,请参阅本文: https://wiki.php.net/rfc/ternary_associativity

现在,您已经了解了新的 nullsafe 运算符。您还了解了现有的三个运算符—​变量运算符、连接运算符和三元运算符—​的功能是如何稍作修改的。现在,您可以很好地避免升级到 PHP 8 时的潜在危险。现在让我们来看看另一个新特性—​箭头函数。