PHP 的古怪特性
摘要
一位开发者在使用了五年后反思 PHP 的古怪之处,重点介绍了其数组实现和类型系统的奇特之处。
暂无内容
查看缓存全文
缓存时间: 2026/05/23 18:33
# PHP 的奇特之处
来源:https://flowtwo.io/post/php's-oddities
过去五年里,我一直在工作中使用 PHP 编程。我所在组织的整个后端都是用 PHP 编写的——这是公司 2007 年成立之初做出的决定。在入职之前,我从没想过自己会使用这门语言,但人生总会带你去往各种意想不到的方向。
尽管 PHP 是一门成熟且常用的语言(https://www.statista.com/statistics/793628/worldwide-developer-survey-most-used-languages/?srsltid=AfmBOor-eez-HJNOJUfxxPpj02oq2XE4LrHgQpOuoAZPwmYvIDOm9uiE),但它在业界名声不佳。不过这大多是基于过时的认知,不了解 PHP 如今的能力。新版本在特性上已赶上大多数其他语言;到目前,它已经是一门相当通用的多用途语言了。当然不再*仅仅*用于服务 HTML,像最初设计的那样。
我不再在那家公司工作了,所以回顾这些年使用 PHP 的经历,有些东西我总觉得很奇怪。
不仅仅是奇怪,它的一些语言特性真的很不直观,而且容易引发 bug。这些来自个人经验和之前工作中许多令人头疼的经历。在这篇文章中,我将解释两个最令人头疼的问题——简而言之:
1. 数组既奇怪又过载
2. 类型系统笨重
## 数组并非真正的数组
PHP 的标准库基本上只有一种数据结构:`array`。这是有意为之;它被设计成一种通用、灵活的数据结构,可以覆盖多种用例。从技术上讲,它是一个*有序的键值字典*,而不是传统意义上的数组(https://en.wikipedia.org/wiki/Array_(data_structure)?wprov=sfti1)。
不幸的是,灵活性带来了复杂性。如果你想在一个分配的内存块中创建一组固定大小的对象,你实际上做不到。PHP 假装支持它们,但这种假象会在意想不到的地方破灭。
假设我有一堆水果。PHP 允许我定义一个水果“数组”,我可以对它做常规的数组操作。
``
$fruits = ["apples", "oranges", "limes"];
// 你可以数它们
count($fruits) // 3
// 你可以访问它们
$fruits[0] // "apples"
// 你可以打印它们
print_r($fruits);
/*
Array
(
[0] => "apples"
[1] => "oranges"
[2] => "limes"
)
*/
``
一切看起来都正常,但当你对这个“简单”数组执行修改操作时,麻烦就来了;它会暴露出自己是一个键值存储。
当你使用 PHP 内置函数进行标准数组操作(如排序或过滤)时,它会同时操作数组的键和值。如果它就地修改数组或通过返回值修改,键的顺序很可能会变得不一致。
``
// 这会搞乱你的数组
$filteredFruits = array_filter($fruits, fn ($name) => str_contains($name, "limes"));
print_r($filteredFruits);
/*
Array
(
[2] => limes
)
*/
// 获取第一个元素不再有效
print($filteredFruits[0]);
/*
PHP Warning: Undefined array key 0
*/
// 删除元素也会搞乱数组
unset($fruits[0]);
print_r($fruits);
/*
Array
(
[1] => or anges
[2] => limes
)
*/
``
limes-arrays.png*为什么我不能拥有所有这些索引???*
让这些数组恢复到自然索引状态的唯一方法是使用 `array_values()` 函数。你只能知道这个,否则就会产生微妙的 bug。
``
$filteredFruitsFixed = array_values($filteredFruits);
print_r($filteredFruitsFixed);
/*
Array
(
[0] => limes
)
*/
$fruitsFixed = array_values($fruits);
print_r($fruitsFixed);
/*
Array
(
[0] => oranges
[1] => limes
)
*/
``
**让我感到奇怪的是,PHP 不支持简单的对象集合。当你 99% 的时间只是想用序数索引时,却不得不管理这些随意的数字键,这很烦人。感觉就像是一个泄漏的抽象。**
## 类属性类型令人困惑
在 PHP5 中,语言增加了原生类型系统。随着时间的推移逐渐扩展,到 PHP7 时,你可以为类的属性定义类型。尽管 PHP 是一门脚本语言,类型声明有助于在测试期间捕获 bug,甚至在开发中使用像 *PHPStan* 这样的静态分析工具也是如此。
但 PHP 的类型系统有一些怪癖,因为它是在现有的动态类型语言基础上构建的。规则必须在行为*之后*设计。对于类属性,有一个隐藏的“未初始化”状态,如果不够小心就会冒出来。
让我们定义一个具有三个 `string` 属性的 `Book` 类:
``
class Book {
public $title;
public string $author;
public ?string $publisher;
}
``
这里我展示了声明字符串属性类型的所有方式:
1. 不声明
2. 它是字符串
3. 它是可空字符串
在 PHP7 之前,所有类属性都是 (1):无类型。由于类型系统是可选的,它必须与“遗留”行为共存,这会产生奇怪的后果。例如,你认为实例化 `Book` 对象后,这三个属性的值会是什么?
``
$b = new Book();
print_r($b);
/*
Book Object(
[title] =>
)
*/
``
这是陷阱题!只有无类型的 `$title` 属性会有一个值,而且这个值是 `NULL`。这看起来没问题,大致符合我对语言使用 `NULL` 值的预期。但另外两个属性不会值,因为它们不存在,或者说它们可能存在但尚未初始化。
**这个例子暴露了属性可能处于的“未初始化”状态,这与 `NULL` 不同。这种区别在你尝试对这些属性进行 null 检查时令人沮丧地显现出来:**
``
print("title is null: " . is_null($b->title));
/*
title is null: 1
*/
print("author is null: " . is_null($b->author));
/*
PHP Fatal error: Uncaught Error: Typed property Book::author must not be accessed before initialization...
*/
print("publisher is null: " . is_null($b->publisher));
/*
PHP Fatal error: Uncaught Error: Typed property Book::publisher must not be accessed before initialization...
*/
``
不是警告——如果你尝试访问一个未初始化的属性,就会发生致命错误。这种情况在你尝试将数据反序列化为 PHP 对象时经常出现。如果某个字段的数据不存在,你可能根本就不会初始化该属性。
null-book.png*啊对,NULL……这是谁说的来着?*
这种对属性定义宽松的行为使得围绕它们编写代码更加困难。尤其是考虑到任何对象都可以动态添加属性:
``
$b->foo = "bar";
print("foo: " . $b->foo);
/*
foo: bar
*/
``
**所以我觉得类属性类型系统在帮助你理解给定对象由什么组成方面作用不大,而且在某些方面反而变得更*不清晰*,因为它引入了这种新的未初始化状态。** 作为开发者,很难编写防御性代码,因为你永远不确定针对所有这些情况该做哪些检查:`is_null()`、`isset()`、`property_exists()`、`empty()`……哪些函数涵盖哪些状态并不明显。
我认为未初始化这个状态根本不需要存在。对于可空类型化的属性,只需默认设为 `null`,就像无类型属性那样。对于非空类型,要求它们被定义为构造函数参数(https://www.php.net/manual/en/language.oop5.decon.php#:~:text=Constructor%20Promotion%20%C2%B6)或者在声明时提供默认值。`readonly` 属性已经有类似的要求,所以 PHP 执行引擎强制执行它当然是可行的。
但这里可能有一些我遗漏的细微差别或历史原因。如果你知道,请在评论中告诉我。
## 结论
尽管这篇文章中我批评了很多,但我仍然认为 PHP 遭到的大量仇恨是不应得的。像任何语言一样,它有其怪癖和权衡,但你可以用 PHP 完成其他语言能完成的任何任务。你对一门语言了解得越多,就越能更好地组织代码,使其“顺应”语言的特性,编写更地道的代码。
我*确实*喜欢 PHP 的地方:
1. 它是脚本语言,所以开发摩擦很小。修改文件后立即生效。
2. Laravel(https://laravel.com/)是一个可靠的 Web 框架,具有大量可扩展功能。它固执己见,确实倾向于“自动魔法”的框架风格,但设计得很好,所以你不会介意。
3. 所有的美元符号 $ 都能提醒你最终这一切是为了什么 🤑
感谢阅读!
相似文章
关于C数组类型语义的讨论
本文解释了C数组类型的令人困惑的行为,包括它们退化为指针、sizeof和函数参数等例外情况,并将其与函数类型进行比较,提出了一种数组和指针严格分离的心理模型。
Phel v0.36.0 – 运行于 PHP 之上的 Lisp,现支持数值塔(Numeric Tower)与一等公民 Var
Phel v0.36.0 已发布,为这款受 Lisp 启发的、编译为 PHP 的函数式编程语言引入了数值塔和一等公民 Var。
为什么多年来 Ruby 依然让人有家的感觉
作者回顾了使用 Ruby 的 15 年经历,称赞了其隐藏特性,如 refinements、delegation 以及新的 ZJIT JIT 编译器,并指出 Ruby 搭配 ZJIT 正在缩小与 Go 和 Rust 等更快语言的性能差距。
C语言中在C++中仍然无法工作的构造——以及一些已发生变化的构造
一篇更新经典调查的博文,关于C语言中在C++中无法工作的构造,涵盖了C++20和C23标准中影响兼容性的变化。
过去一年,我是如何改变编程方式的?你呢?
一位程序员回顾了过去一年其开发工作流程的演变,从使用基于LLM的IDE自动补全转向采用CLI编码代理和plan.md文件,并对传统IDE的必要性提出了质疑。