PHP 语言让 WEB 端程序设计变得简单,这也是它能流行起来的缘由。但也是由于它的简单,PHP 也慢慢发展成一个相对复杂的语言,层出不穷的框架,各类语言特性和版本差别都时常让搞的咱们头大,不得不浪费大量时间去调试。这篇文章列出了十个最容易出错的地方,值得咱们去注意。php
foreach
循环后留下数组的引用还不清楚 PHP 中 foreach
遍历的工做原理?若是你在想遍历数组时操做数组中每一个元素,在 foreach
循环中使用引用会十分方便,例如html
$arr = array(1, 2, 3, 4); foreach ($arr as &$value) { $value = $value * 2; } // $arr 如今是 array(2, 4, 6, 8)
问题是,若是你不注意的话这会致使一些意想不到的负面做用。在上述例子,在代码执行完之后,$value
仍保留在做用域内,并保留着对数组最后一个元素的引用。以后与 $value
相关的操做会无心中修改数组中最后一个元素的值。mysql
你要记住 foreach
并不会产生一个块级做用域。所以,在上面例子中 $value
是一个全局引用变量。在 foreach
遍历中,每一次迭代都会造成一个对 $arr
下一个元素的引用。当遍历结束后, $value
会引用 $arr
的最后一个元素,并保留在做用域中laravel
这种行为会致使一些不易发现的,使人困惑的bug,如下是一个例子c++
$array = [1, 2, 3]; echo implode(',', $array), "\n"; foreach ($array as &$value) {} // 经过引用遍历 echo implode(',', $array), "\n"; foreach ($array as $value) {} // 经过赋值遍历 echo implode(',', $array), "\n";
以上代码会输出angularjs
1,2,3 1,2,3 1,2,2
你没有看错,最后一行的最后一个值是 2 ,而不是 3 ,为何?ajax
在完成第一个 foreach
遍历后, $array
并无改变,可是像上述解释的那样, $value
留下了一个对 $array
最后一个元素的危险的引用(由于 foreach
经过引用得到 $value
)sql
这致使当运行到第二个 foreach
,这个"奇怪的东西"发生了。当 $value
经过赋值得到, foreach
按顺序复制每一个 $array
的元素到 $value
时,第二个 foreach
里面的细节是这样的数据库
$array[0]
(也就是 1 )到 $value
($value
实际上是 $array
最后一个元素的引用,即 $array[2]
),因此 $array[2]
如今等于 1。因此 $array
如今包含 [1, 2, 1]$array[1]
(也就是 2 )到 $value
( $array[2]
的引用),因此 $array[2]
如今等于 2。因此 $array
如今包含 [1, 2, 2]$array[2]
(如今等于 2 ) 到 $value
( $array[2]
的引用),因此 $array[2]
如今等于 2 。因此 $array
如今包含 [1, 2, 2]为了在 foreach
中方便的使用引用而免遭这种麻烦,请在 foreach
执行完毕后 unset()
掉这个保留着引用的变量。例如json
$arr = array(1, 2, 3, 4); foreach ($arr as &$value) { $value = $value * 2; } unset($value); // $value 再也不引用 $arr[3]
isset()
的行为尽管名字叫 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']
存在而且值为 null
的时候, isset($data['keyShouldBeSet'])
也会返回 false
。因此上面的逻辑是不严谨的。
咱们来看另一个例子:
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']
,即:
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 }
考虑下面的代码片断:
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() 返回了一个 $values 数组的拷贝 // 因此`test`元素被添加到了这个拷贝中,而不是 $values 数组自己。 $config->getValues()['test'] = 'test'; // getValues() 又返回了另外一份 $values 数组的拷贝 // 且这份拷贝中并不包含一个`test`元素(这就是为何咱们会获得 「未定义索引」 消息)。 echo $config->getValues()['test'];
一个可能的修改方法是存储第一次经过 getValues()
返回的 $values
数组拷贝,而后后续操做都在那份拷贝上进行;例如:
$vals = $config->getValues(); $vals['test'] = 'test'; echo $vals['test'];
这段代码将会正常工做(例如,它将会输出test
而不会产生任何「未定义索引」消息),可是这个方法可能并不能知足你的需求。特别是上面的代码并不会修改原始的$values
数组。若是你想要修改原始的数组(例如添加一个test
元素),就须要修改getValues()
函数,让它返回一个$values
数组自身的引用。经过在函数名前面添加一个&
来讲明这个函数将返回一个引用;例如:
class Config { private $values = []; // 返回一个 $values 数组的引用 public function &getValues() { return $this->values; } } $config = new Config(); $config->getValues()['test'] = 'test'; echo $config->getValues()['test'];
这会输出期待的test
。
可是如今让事情更困惑一些,请考虑下面的代码片断:
class Config { private $values; // 使用数组对象而不是数组 public function __construct() { $this->values = new ArrayObject(); } public function getValues() { return $this->values; } } $config = new Config(); $config->getValues()['test'] = 'test'; echo $config->getValues()['test'];
若是你认为这段代码会致使与以前的数组
例子同样的「未定义索引」错误,那就错了。实际上,这段代码将会正常运行。缘由是,与数组不一样,PHP 永远会将对象按引用传递。(ArrayObject
是一个 SPL 对象,它彻底模仿数组的用法,可是倒是以对象来工做。)
像以上例子说明的,你应该以引用仍是拷贝来处理一般不是很明显就能看出来。所以,理解这些默认的行为(例如,变量和数组以值传递;对象以引用传递)而且仔细查看你将要调用的函数 API 文档,看看它是返回一个值,数组的拷贝,数组的引用或是对象的引用是必要的。
尽管如此,咱们要认识到应该尽可能避免返回一个数组或 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'); // 输出『testValue』
这个方法让调用者能够在不对私有的$values
数组自己进行公开访问的状况下设置或者获取数组中的任意值。
若是像这样的话,必定不难见到你的 PHP 没法正常工做。
$models = []; foreach ($inputValues as $inputValue) { $models[] = $valueRepository->findByValue($inputValue); }
这里也许没有真正的错误, 可是若是你跟随着代码的逻辑走下去, 你也许会发现这个看似无害的调用$valueRepository->findByValue()
最终执行了这样一种查询,例如:
$result = $connection->query("SELECT `x`,`y` FROM `values` WHERE `value`=" . $inputValue);
结果每轮循环都会产生一次对数据库的查询。 所以,假如你为这个循环提供了一个包含 1000 个值的数组,它会对资源产生 1000 单独的请求!若是这样的脚本在多个线程中被调用,他会有致使系统崩溃的潜在危险。
所以,相当重要的是,当你的代码要进行查询时,应该尽量的收集须要用到的值,而后在一个查询中获取全部结果。
一个咱们平时经常能见到查询效率低下的地方 (例如:在循环中)是使用一个数组中的值 (好比说不少的 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 的 mysql
扩展的时候,这也可能成为一个致使 libmysqlclient
出现『内存不足』(out of memory)的条件。
咱们在一个测试盒里演示一下,该测试盒的环境是:有限的内存(512MB RAM),MySQL,和 php-cli
。
咱们将像下面这样引导一个数据表:
// 链接 mysql $connection = new mysqli('localhost', 'username', 'password', 'database'); // 建立 400 个字段 $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); // 写入 2 百万行数据 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); }
OK,如今让咱们一块儿来看一下内存使用状况:
// 链接 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
Cool。 看来就内存使用而言,内部安全地管理了这个查询的内存。
为了更加明确这一点,咱们把限制提升一倍,使其达到 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"); }
当咱们把这个常见错误和上面的 常见错误 #4 结合起来考虑的时候, 就会意识到咱们的代码理想须要在二者间实现一个平衡。是让查询粒度化和重复化,仍是让单个查询巨大化。生活亦是如此,平衡不可或缺;哪个极端都很差,均可能会致使 PHP 没法正常运行。
从某种意义上说,这其实是PHP自己的一个问题,而不是你在调试 PHP 时遇到的问题,可是它从未获得妥善的解决。 PHP 6 的核心就是要作到支持 Unicode。可是随着 PHP 6 在 2010 年的暂停而搁置了。
这并不意味着开发者可以避免 正确处理 UTF-8 并避免作出全部字符串必须是『古老的 ASCII』的假设。 没有正确处理非 ASCII 字符串的代码会由于引入粗糙的 海森堡bug(heisenbugs) 而变得臭名昭著。当一个名字包含 『Schrödinger』的人注册到你的系统时,即便简单的 strlen($_POST['name'])
调用也会出现问题。
下面是一些能够避免出现这种问题的清单:
mb_*
函数代替老旧的字符串处理函数(须要先保证你的 PHP 构建版本开启了『多字节』(multibyte)扩展)。latin1
)。json_encode()
会转换非 ASCII 标识(好比: 『Schrödinger』会被转换成 『Schru00f6dinger』),可是 serialize()
不会 转换。Francisco Claria 在本博客上发表的 UTF-8 Primer for PHP and MySQL 是份宝贵的资源。
$_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 类型发送数据,这在接口中很是流行。这在 AngularJS $http
service 里是默认的发送数据的类型。)
在咱们举例子的服务端,咱们简单的打印一下 $_POST
数组:
// php var_dump($_POST);
奇怪的是,结果以下:
array(0) { }
为何?咱们的 JSON 串 {a: 'a', b: 'b'}
究竟发生了什么?
缘由在于 当内容类型为 application/x-www-form-urlencoded
或者 multipart/form-data
的时候 PHP 只会自动解析一个 POST 的有效内容。这里面有历史的缘由 --- 这两种内容类型是在 PHP 的 $_POST
实现前就已经在使用了的两个重要的类型。因此无论使用其余任何内容类型 (即便是那些如今很流行的,像 application/json
), PHP 也不会自动加载到 POST 的有效内容。
既然 $_POST
是一个超级全局变量,若是咱们重写 一次 (在咱们的脚本里尽量早的),被修改的值(包括 POST 的有效内容)将能够在咱们的代码里被引用。这很重要由于 $_POST
已经被 PHP 框架和几乎全部的自定义的脚本广泛使用来获取和传递请求数据。
因此,举个例子,当处理一个内容类型为 application/json
的 POST 有效内容的时候 ,咱们须要手动解析请求内容(decode 出 JSON 数据)而且覆盖 $_POST
变量,以下:
// php $_POST = json_decode(file_get_contents('php://input'), true);
而后当咱们打印 $_POST
数组的时候,咱们能够看到他正确的包含了 POST 的有效内容;以下:
array(2) { ["a"]=> string(1) "a" ["b"]=> string(1) "b" }
阅读下面的代码并思考会输出什么:
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"; }
尽管忽视代码标准并不直接致使须要去调试 PHP 代码,但这多是全部须要谈论的事情里最重要的一项。
在一个项目中忽视代码规范可以致使大量的问题。最乐观的预计,先后代码不一致(在此以前每一个开发者都在“作本身的事情”)。但最差的结果,PHP 代码不能运行或者很难(有时是不可能的)去顺利经过,这对于 调试代码、提高性能、维护项目来讲也是困难重重。而且这意味着下降大家团队的生产力,增长大量的额外(或者至少是本没必要要的)精力消耗。
幸运的是对于 PHP 开发者来讲,存在 PHP 编码标准建议(PSR),它由下面的五个标准组成:
PSR 起初是由市场上最大的组织平台维护者创造的。 Zend, Drupal, Symfony, Joomla 和 其余 为这些标准作出了贡献,并一直遵照它们。甚至,多年前试图成为一个标准的 PEAR ,如今也加入到 PSR 中来。
某种意义上,你的代码标准是什么几乎是不重要的,只要你遵循一个标准并坚持下去,但通常来说,跟随 PSR 是一个很不错的主意,除非你的项目上有其余让人难以抗拒的理由。愈来愈多的团队和项目正在听从 PSR 。在这一点上,大部分的 PHP 开发者达成了共识,所以使用 PSR 代码标准,有利于使新加入团队的开发者对你的代码标准感到更加的熟悉与温馨。
empty()
一些 PHP 开发者喜欢对几乎全部的事情使用 empty()
作布尔值检验。不过,在一些状况下,这会致使混乱。
首先,让咱们回到数组和 ArrayObject
实例(和数组相似)。考虑到他们的类似性,很容易假设它们的行为是相同的。然而,事实证实这是一个危险的假设。举例,在 PHP 5.0 中:
// PHP 5.0 或后续版本: $array = []; var_dump(empty($array)); // 输出 bool(true) $array = new ArrayObject(); var_dump(empty($array)); // 输出 bool(false) // 为何这两种方法不产生相同的输出呢?
更糟糕的是,PHP 5.0以前的结果多是不一样的:
// PHP 5.0 以前: $array = []; var_dump(empty($array)); // 输出 bool(false) $array = new ArrayObject(); var_dump(empty($array)); // 输出 bool(false)
这种方法上的不幸是十分广泛的。好比,在 Zend Framework 2 下的 Zend\Db\TableGateway
的 TableGateway::select()
结果中调用 current()
时返回数据的方式,正如文档所代表的那样。开发者很容易就会变成此类数据错误的受害者。
为了不这些问题的产生,更好的方法是使用 count()
去检验空数组结构:
// 注意这会在 PHP 的全部版本中发挥做用 (5.0 先后都是): $array = []; var_dump(count($array)); // 输出 int(0) $array = new ArrayObject(); var_dump(count($array)); // 输出 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); // 输出 string(4) "value" $magic = new Magic(); var_dump($magic->test); // 输出 string(4) "value"
到目前为止还好。
可是如今当咱们对其中的每个都调用 empty()
,让咱们看看会发生什么:
var_dump(empty($regular->test)); // 输出 bool(false) var_dump(empty($magic->test)); // 输出 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 在其20年的历史中,已经发生了显著的变化。花时间去熟悉语言自己的微妙之处是值得的,由于它有助于确保你编写的软件更具可扩展性,健壮和可维护性。
更多现代化 PHP 知识,请前往 Laravel / PHP 知识社区