错误的 PHP 代码:PHP 开发人员最常犯的 10 个错误
已发表: 2022-03-11PHP 使得构建基于 Web 的系统变得相对容易,这也是它受欢迎的主要原因。 但是,尽管 PHP 易于使用,但它已经发展成为一种相当复杂的语言,它具有许多框架、细微差别和微妙之处,可能会咬住开发人员,导致数小时的头发拉扯调试。 本文重点介绍 PHP 开发人员需要注意的十个常见错误。
常见错误 #1:在foreach
循环之后留下悬空数组引用
不确定如何在 PHP 中使用 foreach 循环? 如果要对正在迭代的数组中的每个元素进行操作,在foreach
循环中使用引用会很有用。 例如:
$arr = array(1, 2, 3, 4); foreach ($arr as &$value) { $value = $value * 2; } // $arr is now array(2, 4, 6, 8)
问题是,如果你不小心,这也会产生一些不良的副作用和后果。 具体来说,在上面的示例中,代码执行后, $value
将保留在作用域内,并将持有对数组中最后一个元素的引用。 因此,涉及$value
的后续操作可能会无意中最终修改数组中的最后一个元素。
要记住的主要事情是foreach
不会创建范围。 因此,上面示例中的$value
是脚本顶级范围内的引用。 在每次迭代中foreach
将引用设置为指向$array
的下一个元素。 因此,在循环完成后, $value
仍然指向$array
的最后一个元素并保持在范围内。
这是可能导致的那种逃避和令人困惑的错误的示例:
$array = [1, 2, 3]; echo implode(',', $array), "\n"; foreach ($array as &$value) {} // by reference echo implode(',', $array), "\n"; foreach ($array as $value) {} // by value (ie, copy) echo implode(',', $array), "\n";
上面的代码将输出以下内容:
1,2,3 1,2,3 1,2,2
不,这不是一个错字。 最后一行的最后一个值确实是 2,而不是 3。
为什么?
在经过第一个foreach
循环后, $array
保持不变,但如上所述, $value
保留为对$array
中最后一个元素的悬空引用(因为foreach
循环通过引用访问$value
)。
结果,当我们执行第二个foreach
循环时,“奇怪的事情”似乎发生了。 具体来说,由于$value
现在是按值访问(即通过copy ),因此foreach
在循环的每个步骤中将每个连续的$array
元素复制到$value
中。 因此,以下是在第二个foreach
循环的每个步骤中发生的情况:
- 传递 1:将
$array[0]
(即“1”)复制到$value
(这是对$array[2]
的引用),所以$array[2]
现在等于 1。所以$array
现在包含 [1, 2, 1]。 - 传递 2:将
$array[1]
(即“2”)复制到$value
(这是对$array[2]
的引用),所以$array[2]
现在等于 2。所以$array
现在包含 [1, 2, 2]。 - 第 3 步:将
$array[2]
(现在等于“2”)复制到$value
(它是对$array[2]
的引用),所以$array[2]
仍然等于 2。所以$array
现在包含 [1 , 2, 2]。
为了在foreach
循环中仍然获得使用引用的好处而不冒此类问题的风险,请在foreach
循环之后立即在变量上调用unset()
以删除引用; 例如:
$arr = array(1, 2, 3, 4); foreach ($arr as &$value) { $value = $value * 2; } unset($value); // $value no longer references $arr[3]
常见错误 #2:误解isset()
行为
尽管有它的名字, isset()
不仅在项目不存在时返回 false,而且对于null
值也返回false
。
这种行为比最初可能出现的问题更多,并且是问题的常见来源。
考虑以下:
$data = fetchRecordFromStorage($storage, $identifier); if (!isset($data['keyShouldBeSet']) { // do something here if 'keyShouldBeSet' is not set }
这段代码的作者大概想检查keyShouldBeSet
是否设置在$data
中。 但是,正如所讨论的,如果$data['keyShouldBeSet']
已设置, isset($data['keyShouldBeSet'])
也将返回 false ,但设置为null
。 所以上面的逻辑是有缺陷的。
这是另一个例子:
if ($_POST['active']) { $postData = extractSomething($_POST); } // ... if (!isset($postData)) { echo 'post not active'; }
上面的代码假设如果$_POST['active']
返回true
,那么postData
必然会被设置,因此isset($postData)
将返回true
。 所以相反,上面的代码假设isset($postData)
返回false
的唯一方法是$_POST['active']
也返回false
。
不是。
如前所述,如果$postData
设置为null
, isset($postData)
也将返回false
。 因此,即使$_POST['active']
返回true
, isset($postData)
也可能返回false
。 再说一遍,上面的逻辑是有缺陷的。
顺便说一句,如果上面代码中的意图真的是再次检查$_POST['active']
是否返回 true,那么无论如何依赖isset()
都是一个糟糕的编码决定。 相反,最好重新检查$_POST['active']
; IE:
if ($_POST['active']) { $postData = extractSomething($_POST); } // ... if ($_POST['active']) { echo 'post not active'; }
但是,对于检查变量是否真的设置很重要的情况(即,区分未设置的变量和设置为null
的变量), array_key_exists()
方法更健壮解决方案。
例如,我们可以将上述两个示例中的第一个重写如下:
$data = fetchRecordFromStorage($storage, $identifier); if (! array_key_exists('keyShouldBeSet', $data)) { // do this if 'keyShouldBeSet' isn't set }
此外,通过结合array_key_exists()
和get_defined_vars()
,我们可以可靠地检查当前范围内的变量是否已设置:
if (array_key_exists('varShouldBeSet', get_defined_vars())) { // variable $varShouldBeSet exists in current scope }
常见错误 #3:对按引用返回与按值返回的混淆
考虑这个代码片段:
class Config { private $values = []; public function getValues() { return $this->values; } } $config = new Config(); $config->getValues()['test'] = 'test'; echo $config->getValues()['test'];
如果你运行上面的代码,你会得到以下信息:
PHP Notice: Undefined index: test in /path/to/my/script.php on line 21
怎么了?
问题是上面的代码混淆了按引用返回数组和按值返回数组。 除非您明确告诉 PHP 通过引用返回一个数组(即使用&
),否则 PHP 默认将“按值”返回该数组。 这意味着将返回数组的副本,因此被调用函数和调用者将不会访问数组的同一实例。
所以上面对getValues()
的调用返回了$values
数组的副本,而不是对它的引用。 考虑到这一点,让我们重新审视上面示例中的两个关键行:
// getValues() returns a COPY of the $values array, so this adds a 'test' element // to a COPY of the $values array, but not to the $values array itself. $config->getValues()['test'] = 'test'; // getValues() again returns ANOTHER COPY of the $values array, and THIS copy doesn't // contain a 'test' element (which is why we get the "undefined index" message). echo $config->getValues()['test'];
一种可能的解决方法是保存getValues()
返回的$values
数组的第一个副本,然后随后对该副本进行操作; 例如:
$vals = $config->getValues(); $vals['test'] = 'test'; echo $vals['test'];
该代码可以正常工作(即,它将输出test
而不生成任何“未定义索引”消息),但取决于您要完成的工作,这种方法可能合适,也可能不合适。 特别是,上面的代码不会修改原来的$values
数组。 因此,如果您确实希望您的修改(例如添加“测试”元素)影响原始数组,则需要修改getValues()
函数以返回对$values
数组本身的引用。 这是通过在函数名前添加&
来完成的,从而表明它应该返回一个引用; IE:
class Config { private $values = []; // return a REFERENCE to the actual $values array public function &getValues() { return $this->values; } } $config = new Config(); $config->getValues()['test'] = 'test'; echo $config->getValues()['test'];
正如预期的那样,它的输出将是test
。
但是为了让事情变得更加混乱,请考虑以下代码片段:
class Config { private $values; // using ArrayObject rather than array public function __construct() { $this->values = new ArrayObject(); } public function getValues() { return $this->values; } } $config = new Config(); $config->getValues()['test'] = 'test'; echo $config->getValues()['test'];
如果您猜测这会导致与我们之前的array
示例相同的“未定义索引”错误,那您就错了。 事实上,这段代码可以正常工作。 原因是,与数组不同, PHP 总是通过引用传递对象。 ( ArrayObject
是一个 SPL 对象,它完全模仿数组的使用,但作为一个对象工作。)
正如这些示例所示,在 PHP 中处理副本还是引用并不总是很明显。 因此,必须了解这些默认行为(即,变量和数组通过值传递;对象通过引用传递)并仔细检查您正在调用的函数的 API 文档,看看它是否返回一个值,a数组的副本、对数组的引用或对对象的引用。
综上所述,重要的是要注意返回对数组或ArrayObject
的引用的做法通常是应该避免的,因为它为调用者提供了修改实例私有数据的能力。 这与封装“正面交锋”。 相反,最好使用旧式的“getters”和“setters”,例如:
class Config { private $values = []; public function setValue($key, $value) { $this->values[$key] = $value; } public function getValue($key) { return $this->values[$key]; } } $config = new Config(); $config->setValue('testKey', 'testValue'); echo $config->getValue('testKey'); // echos 'testValue'
这种方法使调用者能够设置或获取数组中的任何值,而无需提供对其他私有$values
数组本身的公共访问。
常见错误 #4:循环执行查询
如果您的 PHP 不工作,遇到这样的事情并不少见:
$models = []; foreach ($inputValues as $inputValue) { $models[] = $valueRepository->findByValue($inputValue); }
虽然这里可能绝对没有错,但是如果您遵循代码中的逻辑,您可能会发现上面对$valueRepository->findByValue()
的看似无辜的调用最终会导致某种查询,例如:
$result = $connection->query("SELECT `x`,`y` FROM `values` WHERE `value`=" . $inputValue);
因此,上述循环的每次迭代都会导致对数据库的单独查询。 因此,例如,如果您向循环提供了一个包含 1,000 个值的数组,它将生成 1,000 个对资源的单独查询! 如果在多个线程中调用这样的脚本,它可能会使系统陷入停顿。
因此,识别代码何时进行查询至关重要,并尽可能收集值,然后运行一个查询以获取所有结果。
遇到查询效率低下(即,在循环中)的一个相当常见的例子是当一个表单被发布时带有一个值列表(例如,ID)。 然后,为了检索每个 ID 的完整记录数据,代码将遍历数组并对每个 ID 执行单独的 SQL 查询。 这通常看起来像这样:
$data = []; foreach ($ids as $id) { $result = $connection->query("SELECT `x`, `y` FROM `values` WHERE `id` = " . $id); $data[] = $result->fetch_row(); }
但同样的事情可以在单个SQL 查询中更有效地完成,如下所示:
$data = []; if (count($ids)) { $result = $connection->query("SELECT `x`, `y` FROM `values` WHERE `id` IN (" . implode(',', $ids)); while ($row = $result->fetch_row()) { $data[] = $row; } }
因此,识别代码何时直接或间接地进行查询至关重要。 只要有可能,就收集这些值,然后运行一个查询来获取所有结果。 然而,也必须谨慎行事,这导致我们的下一个常见的 PHP 错误......
常见错误#5:内存使用假象和低效
虽然一次获取多条记录肯定比对要获取的每一行运行单个查询更有效,但是当使用 PHP 的mysql
扩展时,这种方法可能会导致libmysqlclient
中出现“内存不足”的情况。
为了演示,让我们看一个资源有限(512MB RAM)、MySQL 和php-cli
的测试框。
我们将像这样引导一个数据库表:
// connect to mysql $connection = new mysqli('localhost', 'username', 'password', 'database'); // create table of 400 columns $query = 'CREATE TABLE `test`(`id` INT NOT NULL PRIMARY KEY AUTO_INCREMENT'; for ($col = 0; $col < 400; $col++) { $query .= ", `col$col` CHAR(10) NOT NULL"; } $query .= ');'; $connection->query($query); // write 2 million rows for ($row = 0; $row < 2000000; $row++) { $query = "INSERT INTO `test` VALUES ($row"; for ($col = 0; $col < 400; $col++) { $query .= ', ' . mt_rand(1000000000, 9999999999); } $query .= ')'; $connection->query($query); }
好的,现在让我们检查资源使用情况:

// connect to mysql $connection = new mysqli('localhost', 'username', 'password', 'database'); echo "Before: " . memory_get_peak_usage() . "\n"; $res = $connection->query('SELECT `x`,`y` FROM `test` LIMIT 1'); echo "Limit 1: " . memory_get_peak_usage() . "\n"; $res = $connection->query('SELECT `x`,`y` FROM `test` LIMIT 10000'); echo "Limit 10000: " . memory_get_peak_usage() . "\n";
输出:
Before: 224704 Limit 1: 224704 Limit 10000: 224704
凉爽的。 看起来查询在资源方面是在内部安全地管理的。
不过,可以肯定的是,让我们再提高一次限制并将其设置为 100,000。 哦哦。 当我们这样做时,我们得到:
PHP Warning: mysqli::query(): (HY000/2013): Lost connection to MySQL server during query in /root/test.php on line 11
发生了什么?
这里的问题是 PHP 的mysql
模块的工作方式。 它实际上只是libmysqlclient
的代理,它完成了肮脏的工作。 选择一部分数据时,它将直接进入内存。 由于此内存不是由 PHP 的管理器管理的,因此当我们超出查询限制时, memory_get_peak_usage()
不会显示资源利用率的任何增加。 这会导致像上面展示的那样的问题,我们被欺骗自满,认为我们的内存管理很好。 但实际上,我们的内存管理存在严重缺陷,我们可能会遇到如上所示的问题。
您至少可以通过使用mysqlnd
模块来避免上述假冒(尽管它本身不会提高您的内存利用率)。 mysqlnd
被编译为原生 PHP 扩展,它使用 PHP 的内存管理器。
因此,如果我们使用mysqlnd
而不是mysql
运行上述测试,我们会得到更真实的内存利用率图:
Before: 232048 Limit 1: 324952 Limit 10000: 32572912
顺便说一句,它甚至比这更糟糕。 根据 PHP 文档, mysql
使用两倍于mysqlnd
的资源来存储数据,因此使用mysql
的原始脚本确实使用了比这里显示的更多的内存(大约是两倍)。
为避免此类问题,请考虑限制查询的大小并使用迭代次数较少的循环; 例如:
$totalNumberToFetch = 10000; $portionSize = 100; for ($i = 0; $i <= ceil($totalNumberToFetch / $portionSize); $i++) { $limitFrom = $portionSize * $i; $res = $connection->query( "SELECT `x`,`y` FROM `test` LIMIT $limitFrom, $portionSize"); }
当我们考虑这个 PHP 错误和上面的错误 #4 时,我们意识到您的代码理想地需要在一方面让您的查询过于细化和重复,与让您的每一个个别查询太大。 就像生活中的大多数事情一样,需要平衡。 任何一个极端都不好,并且可能导致 PHP 无法正常工作。
常见错误 #6:忽略 Unicode/UTF-8 问题
从某种意义上说,这实际上是 PHP 本身的一个问题,而不是您在调试 PHP 时遇到的问题,但它从未得到充分解决。 PHP 6 的核心是要支持 Unicode,但是当 PHP 6 的开发在 2010 年暂停时,这被搁置了。
但这决不能免除开发人员正确处理 UTF-8 并避免错误假设所有字符串都必然是“普通的旧 ASCII”。 无法正确处理非 ASCII 字符串的代码因将粗糙的 heisenbugs 引入您的代码而臭名昭著。 如果像“薛定谔”这样姓氏的人试图注册到您的系统,即使是简单的strlen($_POST['name'])
调用也可能导致问题。
这是一个小清单,可以避免代码中出现此类问题:
- 如果您对 Unicode 和 UTF-8 了解不多,那么您至少应该学习基础知识。 这里有一本很棒的入门书。
- 确保始终使用
mb_*
函数而不是旧的字符串函数(确保“多字节”扩展名包含在您的 PHP 构建中)。 - 确保您的数据库和表设置为使用 Unicode(许多 MySQL 版本默认仍使用
latin1
)。 - 请记住,
json_encode()
转换非 ASCII 符号(例如,“Schrodinger”变为“Schr\u00f6dinger”)但serialize()
不会。 - 确保您的 PHP 代码文件也是 UTF-8 编码的,以避免在将字符串与硬编码或配置的字符串常量连接时发生冲突。
在这方面,一个特别有价值的资源是 Francisco Claria 在此博客上发表的 PHP 和 MySQL 的 UTF-8 Primer。
常见错误 #7:假设$_POST
将始终包含您的 POST 数据
尽管有它的名字, $_POST
POST 数组并不总是包含您的 POST 数据,而且很容易发现它是空的。 为了理解这一点,让我们看一个例子。 假设我们使用jQuery.ajax()
调用发出服务器请求,如下所示:
// js $.ajax({ url: 'http://my.site/some/path', method: 'post', data: JSON.stringify({a: 'a', b: 'b'}), contentType: 'application/json' });
(顺便提一下,注意这里的contentType: 'application/json'
。我们以 JSON 格式发送数据,这在 API 中非常流行。这是默认设置,例如,在 AngularJS $http
服务中发布。)
在我们示例的服务器端,我们简单地转储$_POST
数组:
// php var_dump($_POST);
令人惊讶的是,结果将是:
array(0) { }
为什么? 我们的 JSON 字符串{a: 'a', b: 'b'}
发生了什么?
答案是PHP 仅在其内容类型为application/x-www-form-urlencoded
或multipart/form-data
时才会自动解析 POST 有效负载。 其原因是历史性的——这两种内容类型基本上是几年前 PHP 的$_POST
实现时唯一使用的内容类型。 因此,对于任何其他内容类型(即使是当今非常流行的内容类型,例如application/json
),PHP 都不会自动加载 POST 有效负载。
由于$_POST
是一个超全局变量,如果我们覆盖它一次(最好是在脚本的早期),那么修改后的值(即,包括 POST 有效负载)将在我们的代码中被引用。 这很重要,因为$_POST
通常被 PHP 框架和几乎所有自定义脚本用于提取和转换请求数据。
因此,例如,在处理内容类型为application/json
的 POST 负载时,我们需要手动解析请求内容(即解码 JSON 数据)并覆盖$_POST
变量,如下所示:
// php $_POST = json_decode(file_get_contents('php://input'), true);
然后当我们转储$_POST
数组时,我们看到它正确地包含了 POST 有效负载; 例如:
array(2) { ["a"]=> string(1) "a" ["b"]=> string(1) "b" }
常见错误 #8:认为 PHP 支持字符数据类型
查看这段示例代码并尝试猜测它将打印什么:
for ($c = 'a'; $c <= 'z'; $c++) { echo $c . "\n"; }
如果您回答“a”到“z”,您可能会惊讶地发现自己错了。
是的,它会打印 'a' 到 'z',但它也会打印 'aa' 到 'yz'。 让我们看看为什么。
在 PHP 中没有char
数据类型; 只有string
可用。 考虑到这一点,在 PHP 中增加string
z
会产生aa
:
php> $c = 'z'; echo ++$c . "\n"; aa
然而,为了进一步混淆问题, aa
在字典上小于z
:
php> var_export((boolean)('aa' < 'z')) . "\n"; true
这就是为什么上面给出的示例代码会打印字母a
到z
,但也会打印aa
到yz
。 它在到达za
时停止,这是它遇到的第一个“大于” z
的值:
php> var_export((boolean)('za' < 'z')) . "\n"; false
在这种情况下,这是正确循环 PHP 中值 'a' 到 'z' 的一种方法:
for ($i = ord('a'); $i <= ord('z'); $i++) { echo chr($i) . "\n"; }
或者:
$letters = range('a', 'z'); for ($i = 0; $i < count($letters); $i++) { echo $letters[$i] . "\n"; }
常见错误 #9:忽略编码标准
尽管忽略编码标准不会直接导致需要调试 PHP 代码,但它仍然可能是这里要讨论的最重要的事情之一。
忽略编码标准可能会导致项目出现大量问题。 充其量,它会导致代码不一致(因为每个开发人员都在“做自己的事情”)。 但在最坏的情况下,它生成的 PHP 代码无法运行或难以导航(有时几乎是不可能的),这使得调试、增强和维护变得极其困难。 这意味着您的团队的生产力会降低,包括大量浪费(或至少是不必要的)努力。
对于 PHP 开发人员来说幸运的是,有 PHP 标准推荐 (PSR),它由以下五个标准组成:
- PSR-0:自动加载标准
- PSR-1:基本编码标准
- PSR-2:编码风格指南
- PSR-3:记录器接口
- PSR-4:自动装载机
PSR 最初是根据市场上最知名平台的维护者的意见创建的。 Zend、Drupal、Symfony、Joomla 和其他人为这些标准做出了贡献,现在正在遵循它们。 即使是在此之前多年试图成为标准的 PEAR,现在也参与了 PSR。
从某种意义上说,你的编码标准是什么几乎无关紧要,只要你同意一个标准并坚持下去,但是遵循 PSR 通常是一个好主意,除非你的项目有一些令人信服的理由不这样做. 越来越多的团队和项目符合 PSR。 在这一点上,Tt 绝对被大多数 PHP 开发人员认可为“标准”,因此使用它有助于确保新开发人员在加入您的团队时熟悉并熟悉您的编码标准。
常见错误 #10:滥用empty()
一些 PHP 开发人员喜欢使用empty()
对几乎所有内容进行布尔检查。 但是,在某些情况下,这可能会导致混淆。
首先,让我们回到数组和ArrayObject
实例(模仿数组)。 鉴于它们的相似性,很容易假设数组和ArrayObject
实例的行为相同。 然而,这被证明是一个危险的假设。 例如,在 PHP 5.0 中:
// PHP 5.0 or later: $array = []; var_dump(empty($array)); // outputs bool(true) $array = new ArrayObject(); var_dump(empty($array)); // outputs bool(false) // why don't these both produce the same output?
更糟糕的是,在 PHP 5.0 之前,结果会有所不同:
// Prior to PHP 5.0: $array = []; var_dump(empty($array)); // outputs bool(false) $array = new ArrayObject(); var_dump(empty($array)); // outputs bool(false)
不幸的是,这种方法很受欢迎。 例如,这是 Zend Framework 2 的Zend\Db\TableGateway
在对TableGateway::select()
结果调用current()
时返回数据的方式,正如文档所建议的那样。 使用此类数据,开发人员很容易成为此错误的受害者。
为了避免这些问题,检查空数组结构的更好方法是使用count()
:
// Note that this work in ALL versions of PHP (both pre and post 5.0): $array = []; var_dump(count($array)); // outputs int(0) $array = new ArrayObject(); var_dump(count($array)); // outputs int(0)
顺便说一句,由于 PHP 将0
转换为false
, count()
也可以在if ()
条件中用于检查空数组。 还值得注意的是,在 PHP 中, count()
是数组上的常数复杂度( O(1)
操作),这使得它更清楚地表明它是正确的选择。
另一个empty()
可能很危险的例子是将它与魔术类函数__get()
结合使用。 让我们定义两个类并在两者中都有一个test
属性。
首先让我们定义一个包含test
作为普通属性的Regular
类:
class Regular { public $test = 'value'; }
然后让我们定义一个Magic
类,它使用魔术__get()
运算符来访问它的test
属性:
class Magic { private $values = ['test' => 'value']; public function __get($key) { if (isset($this->values[$key])) { return $this->values[$key]; } } }
好的,现在让我们看看当我们尝试访问每个类的test
属性时会发生什么:
$regular = new Regular(); var_dump($regular->test); // outputs string(4) "value" $magic = new Magic(); var_dump($magic->test); // outputs string(4) "value"
到目前为止还好。
但是现在让我们看看当我们在每个上面调用empty()
时会发生什么:
var_dump(empty($regular->test)); // outputs bool(false) var_dump(empty($magic->test)); // outputs bool(true)
啊。 因此,如果我们依赖empty()
,我们可能会误以为$magic
的test
属性是空的,而实际上它被设置为'value'
。
不幸的是,如果一个类使用魔术__get()
函数来检索属性的值,则没有万无一失的方法来检查该属性值是否为空。 在类的范围之外,您实际上只能检查是否会返回null
值,这并不一定意味着未设置相应的键,因为它实际上可能已设置为null
。
相反,如果我们尝试引用Regular
类实例的不存在的属性,我们将收到类似于以下内容的通知:
Notice: Undefined property: Regular::$nonExistantTest in /path/to/test.php on line 10 Call Stack: 0.0012 234704 1. {main}() /path/to/test.php:0
所以这里的要点是应该小心使用empty()
方法,因为如果不小心,它可能会导致混淆——甚至可能误导——结果。
包起来
PHP 的易用性会使开发人员产生一种错误的舒适感,由于语言的一些细微差别和特性,他们很容易受到冗长的 PHP 调试的影响。 这可能会导致 PHP 无法正常工作以及出现此处描述的问题。
PHP 语言在其 20 年的历史中发生了显着的演变。 熟悉其微妙之处是一项值得努力的工作,因为它将有助于确保您生产的软件更具可扩展性、健壮性和可维护性。