编程

PHP 中的单引号 vs. 双引号

260 2024-11-12 02:24:00

最近,我又听说 PHP 社区中有人仍然在谈论单引号和双引号,他们说使用单引号只是一种微优化,但如果你习惯了一直使用单引号,你会节省大量的 CPU 周期!

为什么会这样呢?

PHP 实现了字符串插值,在其中它搜索字符串中变量的使用情况,并将其替换为所用变量的值:

$juice = "apple";
echo "They drank some $juice juice.";
// 输出: They drank some apple juice.

此功能仅限于双引号和 heredoc 中的字符串。使用单引号(或 nowdoc)会产生不同的结果:

$juice = "apple";
echo 'They drank some $juice juice.';
// 输出: They drank some $juice juice.

请看:PHP 不会在单引号字符串中搜索变量。因此,我们可以开始在所有地方使用单引号。因此人们开始建议做这样的修改…

- $juice = "apple";
+ $juice = 'apple';

.. 因为它会更快,每次执行该代码都会节省大量 CPU 周期,因为 PHP 不会在单引号字符串中查找变量(无论如何,这些字符串在示例中都是不存在的),结案。

真的结案了吗?

很明显,使用单引号和双引号是有区别的,但要理解发生了什么,我们需要更深入地挖掘。

尽管 PHP 是一种解释性语言,但它使用了一个编译步骤,在这个步骤中,某些部分一起作用,以获得虚拟机可以实际执行的东西,即操作码。那么,我们如何从 PHP 源代码转换为操作码呢?

Lexer 词法分析器

Lexer 扫描源代码文件并将其分解为 token。关于这是什么意思的一个简单示例可以在 token_get_all() 函数文档中找到。一个只有 <?php echo ""; 的 PHP 源代码转换成这些 token:

T_OPEN_TAG (<?php )
T_ECHO (echo)
T_WHITESPACE ( )
T_CONSTANT_ENCAPSED_STRING ("")

我们可以在这个 3v4l.org 代码片段中看到它的实际应用。

解析器

解析器获取这些 token 并从中生成抽象语法树(AST)。当以 JSON 表示时,上述示例的 AST 表示如下:

{
  "data": [
    {
      "nodeType": "Stmt_Echo",
      "attributes": {
        "startLine": 1,
        "startTokenPos": 1,
        "startFilePos": 6,
        "endLine": 1,
        "endTokenPos": 4,
        "endFilePos": 13
      },
      "exprs": [
        {
          "nodeType": "Scalar_String",
          "attributes": {
            "startLine": 1,
            "startTokenPos": 3,
            "startFilePos": 11,
            "endLine": 1,
            "endTokenPos": 3,
            "endFilePos": 12,
            "kind": 2,
            "rawValue": "\"\""
          },
          "value": ""
        }
      ]
    }
  ]
}

如果你也想玩这个,看看其他代码的 AST 是什么样子的,我发现 Ryan Chandler 的 https://phpast.com/https://php-ast-viewer.com/ 都展示了给定 PHP 代码的 AST。

编译器

编译器获取 AST 并创建操作码(Opcode)。操作码是虚拟机执行的东西,如果你设置并启用了该设置,它也是将存储在 OPcache 中的东西(我强烈推荐)。

要查看操作码,我们有多个选项(可能更多,但我确实知道这三个):

  1. 使用 vulcan logic dumper 扩展。它也被加入到 3v4l.org
  2. 使用 phpdbg -p script.php 转储操作码
  3. 或者 opcache.opt_debug_level INI 的 OPcache 设置使之打印出操作码
    • 0x10000 在优化前输出操作码
    • 0x20000 在优化后输出操作码
$ echo '<?php echo "";' > foo.php
$ php -dopcache.opt_debug_level=0x10000 foo.php
$_main:
...
0000 ECHO string("")
0001 RETURN int(1)

Hypothesis

回到使用单引号与双引号时节省 CPU 周期的最初想法,我认为我们都同意,只有当 PHP 在运行时为每个请求计算这些字符串时,这才是正确的。

运行时会发生什么?

那么,让我们看看 PHP 为这两个不同版本创建了哪些操作码。

双引号:

<?php echo "apple";
0000 ECHO string("apple")
0001 RETURN int(1)

vs. 单引号:

<?php echo 'apple';
0000 ECHO string("apple")
0001 RETURN int(1)

等等,奇怪的事情发生了。这看起来一模一样!我的微观优化去了哪里?

也许,只是也许 ECHO 操作码处理程序(handler)的实现解析给定的字符串,尽管没有标记或其他东西告诉它这样做…hmm🤔

让我们尝试一种不同的方法,看看 lexer 对这两种情况做了什么:

双引号:

T_OPEN_TAG (<?php )
T_ECHO (echo)
T_WHITESPACE ( )
T_CONSTANT_ENCAPSED_STRING ("")

vs. 单引号:

T_OPEN_TAG (<?php )
T_ECHO (echo)
T_WHITESPACE ( )
T_CONSTANT_ENCAPSED_STRING ('')

token 仍在区分双引号和单引号,但检查 AST 将为两种情况下给出了相同的结果——唯一的区别是 Scalar_String 节点属性中的 rawValue,它仍然有单引号/双引号,但在这两种情况中 value 都使用了双引号。

New Hypothesis

难道,字符串插值实际上是在编译时完成的吗?

让我们来看一个稍微“复杂”一点的例子:

<?php
$juice="apple";
echo "juice: $juice";

给文件的 Token 为:

T_OPEN_TAG (<?php)
T_VARIABLE ($juice)
T_CONSTANT_ENCAPSED_STRING ("apple")
T_WHITESPACE ()
T_ECHO (echo)
T_WHITESPACE ( )
T_ENCAPSED_AND_WHITESPACE (juice: )
T_VARIABLE ($juice)

看看最后这两个 TOKEN!字符串插值在 lexer 词法分析器中处理,因此是编译时的事情,与运行时无关。

为了完整起见,让我们看看由此生成的操作码(优化后,使用 0x20000):

0000 ASSIGN CV0($juice) string("apple")
0001 T2 = FAST_CONCAT string("juice: ") CV0($juice)
0002 ECHO T2
0003 RETURN int(1)

这与简单的 <?php echo ""; 示例中的操作码不同,不过这不是重点因为此处我们所做的也不一样。

那么:我应该使用连接还是插值?

让我们来看看这三个不同的版本:

<?php
$juice = "apple";
echo "juice: $juice $juice";
echo "juice: ", $juice, " ", $juice;
echo "juice: ".$juice." ".$juice;
  • 第一个版本使用字符串插值
  • 第二个使用逗号分隔(据我所知,只适用于 echo,不适用于分配变量或其他任何场景)
  • 第三个情况使用字符串连接

第一个操作码将字符串 "apple" 赋值给变量 $juice

0000 ASSIGN CV0($juice) string("apple")

第一版(字符串插值)使用 rope 作为底层数据结构,该结构经过优化,可以尽可能少地进行字符串复制。

0001 T2 = ROPE_INIT 4 string("juice: ")
0002 T2 = ROPE_ADD 1 T2 CV0($juice)
0003 T2 = ROPE_ADD 2 T2 string(" ")
0004 T1 = ROPE_END 3 T2 CV0($juice)
0005 ECHO T1

第二个版本内存最为高效,因为它不创建中间字符串表示。相反,它会多次调用 ECHO,从 I/O 的角度来看,这是一个阻塞调用,因此取决于你 的用例,这可能是一个缺点。

0006 ECHO string("juice: ")
0007 ECHO CV0($juice)
0008 ECHO string(" ")
0009 ECHO CV0($juice)

第三个版本使用 CONCAT/FAST_CONCAT 来创建中间字符串表示层,因此可能会比 rope 版本进行更多的内存复制和/或使用更多的内存。

0010 T1 = CONCAT string("juice: ") CV0($juice)
0011 T2 = FAST_CONCAT T1 string(" ")
0012 T1 = CONCAT T2 CV0($juice)
0013 ECHO T1

那么…此处怎么做是正确的,为什么是字符串插值?

字符串插值在 echo "juice: $juice"; 的情况下使用 FAST_CONCAT;或者在 echo "juice: $juice $juice"; 的情况下高度优化的ROPE_* 操作码,但最重要的是,它清楚地传达了意图,到目前为止,在我使用过的任何 PHP 应用中,这些都不是瓶颈,所以这些都不重要。

简言之

字符串插值是编译时的事情。当然,如果没有 OPcache,Lexer 词法分析器将不得不在每次请求时检查双引号字符串中使用的变量(即使没有变量)同时也会增加 CPU 周期,但老实说:问题不在于双引号字符串,而在于没有使用 OPcache!

然而,有一个警告:PHP 4(我相信甚至包括 5.0 甚至 5.1,我不知道)在运行时都会进行字符串插值,所以使用这些版本…嗯,我想如果有人真的还在使用 PHP 5,与上述情况相同:问题不在于双引号字符串,而在于使用过时的 PHP 版本。

最终建议

更新到最新的 PHP 版本,启用 OPcache!

 

PHP