PHP 最受期待的功能之一就是泛型:能够拥有一个以另一个类型作为参数的类型。这是目前大多数编译型语言都具备的功能,但在像 PHP 这样的解释型语言中实现泛型,其中所有类型检查都必须在运行时完成,一直被证明是真正困难 ™,真正缓慢 ™,或者两者兼而有之。
但是,PHP 基金会开发团队的实验表明,我们可能能够用 20% 的工作获得 80% 的收益。这样够吗?
我们相信可以仅在接口和抽象类上实现泛型,这将提供泛型的大部分好处,但避免大多数陷阱。
特别是,接口和抽象类可以声明它们需要指定一个或多个类型:
interface Exporter<Thing> { ... }
然后实现/继承它们的类将被要求填入这些类型:
class WidgetExporter implements Exporter<Widget> { ... }
然后在 Exporter
中出现类型 Thing
的任何地方,它都会在 WidgetExporter
中变成 Widget
。
所有这些都在编译时完成,这使得它更容易、更快速,并且许多/大多数错误将在编译时被捕获。
运行时泛型,即你可以说 $fooExporter = new Exporter<Foo>()
,仍然不可能,但它们不会因为只走一部分路而变得更困难。
你会支持(并投票支持)如下所述的仅编译时泛型吗?
在 2023 年和 2024 年,基金会团队的 Arnaud Le Blanc 在 Nikita Popov 之前工作的基础上,对泛型进行了广泛的实验。该实验的完整结果可在该链接中获得,但简而言之,泛型的某些部分是可能的,甚至是直接的。然而,当边缘情况遇到问题时,它们是非常大的问题。
特别是:
Arnaud 和 Larry Garfield 之后进一步研究了引入"模块"系统,这将帮助 PHP 的编译器一次看到更多代码,从而能够更容易地进行类型推断,但遗憾的是,这也遇到了许多具有挑战性的边缘情况。
在 2025 年中期,基金会的 Gina Banyard 开始研究"关联类型",这是一个在少数语言中发现的与泛型相邻的功能,完全在编译时发生。它本质上允许类或接口指定继承者必须指定在某些情况下使用的类型。最初,它旨在作为 never 参数 RFC 的替代方案,为接口不需要指定特定类型但实现类需要的情况提供更好的解决方案。
初始计划看起来像这样:
interface ImporterExporter
{
type T;
public function import(string $input): T;
public function export(T $value): string;
}
class ThingImporter implements ImporterExporter
{
public function import(string $input): Thing { ... }
public function export(Thing $value): string { ... }
}
其中接口中对 T
的所有引用都需要在类中被相同的类型替换;它可以是任何类型,只要在所有情况下都是相同的。
然而,在与团队其他成员讨论后,很明显,如果我们只是改变拼写并眯着眼睛看,关联类型看起来很像泛型。或者更确切地说,是泛型的一部分,因为完整的泛型是一个非常复杂的功能,有许多不同的互补部分。
这引出了一个问题:我们能否只做其中的一些部分,并获得大部分好处?根据 Gina 的工作,答案是"可能!"
Gina 实现的,虽然仍需要一些完善和扩展,本质上是"手动单态化泛型",在编译时实现。"单态化"是泛型的一种方法(还有其他方法),其中编译器或引擎在使用该类型特定版本时"即时"创建类的类型特定版本。通过"手动",我们的意思是由开发者提前完成。
让我们看看这在实践中是什么样子的。
考虑我们之前的接口,但这样拼写:
interface ImporterExporter<T>
{
public function import(string $input): T;
public function export(T $value): string;
}
该接口要求任何实现它的类指定类型 T
应该是什么;它可以是任何类型,只要在两个地方都是相同的类型。
class ThingExporter implements ImporterExporter<Thing>
{
public function import(string $input): Thing { ... }
public function export(Thing $value): string { ... }
}
class WidgetExporter implements ImporterExporter<Widget>
{
public function import(string $input): Widget { ... }
public function export(Widget $value): string { ... }
}
这些都可以在编译时强制执行,那里更便宜,并由 opcache 缓存。
这本身还不是完整的泛型功能,但实际上是其中很大的一部分。还可以指定泛型类型必须符合其他某种类型,比如另一个接口。例如:
interface Repository<T: Entity>
{
public function save(T $entity): bool;
public function load(int $id): T;
}
class BlogPostRepository implements Repository<BlogPost>
{
// ...
}
这会起作用,但只有当 BlogPost
实现了 Entity
接口时。
虽然尚未实现,但 Gina 确信一些自然扩展也是可能的和直接的,如果投入足够的时间。它们可能可以包含在初始 RFC 中。
首先,允许抽象类也是泛型的。这将允许:
abstract class BaseRepository<T: Entity>
{
// ...
}
class BlogPostRepository extends BaseRepository<BlogPost> { ... }
特别值得注意的是,继承类实际上不需要做任何事情,除了指定类型。这就足够了。最终结果是我们可能会看到"空扩展类"的激增,它们只是指定一个类型并且没有自己的主体,作为其他语言中 $repo = new Repository<BlogPost>()
的替代。在 PHP 中,这将被拼写为:
class BlogPostRepository extends BaseRepository<BlogPost> { ... }
$repo = new BlogPostRepository();
(这就是我们谈到的"手动单态化"。)不理想,但仍然比 PHP 8.4 的现状强大得多。
其次,类型声明。已经可以对 BlogPostRepository
进行类型化,因为这只是我们一直知道的无聊的旧类。一个直接的扩展是允许这样:
class DataProcessor
{
public function __construct(private Repository<UserEntity> $repo) {}
}
也就是说,声明 $repo
必须实现 Repository
并指定一个本身是 UserEntity
(可以是类或另一个接口)子类的类型。在初始版本中,它可能不支持泛型作为复合类型的一部分(如 private Repository<UserEntity>|null $repo
),但这应该在稍后添加是可行的。
泛型最常见的用途之一是集合,无论是类型化数组还是对象。细节因语言而异,但已知为某种类型的集合非常有价值。之前的博客文章(上面链接)包括对 Derick Rethans 和 Larry Garfield 设计的集合的讨论,其中包括一个自定义的一次性语法,用于...本质上是这里描述的行为。为这种语法更新该设计将给我们三个接口,或者可能是基类:
abstract class Sequence<T>
{
private array $values = [];
public function append(T $new): static
{
$this->values[] = $new;
return $this;
}
public filter(callable $filter): static
{
// ...
}
}
abstract class Set<T>
{
// ...
}
abstract class Dict<K, V>
{
// ...
}
然后它们可以这样使用:
class Articles extends Sequence<Article> {}
class Library extends Set<Book> {}
class YearBooks extends Dict<int, Book> {}
如果需要,这些具体类可以在其中包含其他方法,但这是可选的。上面的内容足以拥有一个将整数映射到 Book
对象的集合,并具有语法级别的保证这些类型将保持。
上述设计可以在核心或用户空间中实现。将它们内置有明显的好处,但类型控制部分至少对用户空间代码也是可用的。
有一些更复杂的功能似乎是可行的,但需要足够的额外努力,它们几乎肯定不会在初始 RFC 中有意义。然而,它们可能在自己的未来 RFC 中,除非有任何意外。
在初始版本中,泛型类型将是不变的。BlogPostRepository
是 BaseRepository
的子类,但仅仅因为 FeaturedBlogPost
是 BlogPost
的子类并不意味着 BlogPostRepository
可以接受 FeaturedBlogPost
。挑战在于参数和返回类型的变异方向相反,并且因为泛型类型可能既作为参数类型(可以看作写上下文)又作为返回类型(可以看作读上下文)出现,它需要是不变的,类似于属性类型是不变的。(这是具有泛型的语言中的常见挑战。)
然而,Kotlin 和 C# 有一个我们应该能够为 PHP 借用的功能。如果泛型类型专门用于参数,它可以标记为 in
类型以表示它是逆变的。例如:
interface Saver<in Type>
{
public function save(Type $object): bool;
}
class BlogPostSaver<BlogPost>
{
public function save(BlogPost $object): bool { ... }
}
$bsaver = new BlogPostSaver();
$bsaver->save(new FeaturedBlogPost());
类似地,如果泛型类型专门用作返回类型,它可以标记为 out
:
interface Loader<out Type>
{
public function load(int $id): Type;
}
class BlogPostLoader<BlogPost>
{
public function load(int $id): BlogPost { ... }
}
$bloader = new BlogPostLoader();
$post = $bloader->load(5);
// $post 可能是 FeaturedBlogPost。
这里仍有许多细节需要弄清楚,这可能使它比预期的更复杂。这就是为什么我们还没有详细研究它,并将其留给未来的范围。
当然,PHP 有另一个类似类的构造,Traits。泛型如何与 Traits 交互仍不清楚。似乎可能以下内容最终可以工作:
trait Tools<T>
{
public function useful(T $param): int { ... }
}
class C
{
use Tools<Book>;
}
然而,这里有一些显著的挑战,主要围绕性能和避免代码重复。它们可能是可解决的,但足够复杂,不会在初始版本中。
到目前为止,我们只讨论了类和类似类的东西。函数呢?
至少在理论上,以下内容应该是可行的:
function compareThings<Thing>(Thing $thingOne, Thing $thingTwo) { ... };
compareThing<Widget>(new Widget(1), new Widget(2));
在每次调用时手动指定类型有点笨拙,但使其自动检测属于"类型推断"类别,如下所述。
实际上,这种模式可能没有大量的用例;主要是确保两个参数或一个参数和返回具有相同的未指定类型,这是相当小众的。对于方法会有什么影响也不清楚。这似乎是可能的,但可能不实用。
值得注意的是,上述计划中缺少的是,嗯,真正困难 ™ 的部分。目前,仍不清楚它们是否可能。
特别是,像 $blogRepo = new Repository<BlogPost>()
这样的语法仍然不在考虑范围内。挑战在于这里描述的部分方法可以将它需要的所有额外跟踪数据放在类上,并在编译时完成工作。支持使用 new
的即时声明将需要将额外的跟踪数据放在对象上,并在运行时完成所有工作。这要困难一个数量级。
很少有语言同时支持泛型类型和联合类型。PHP 已经有联合和交集类型一段时间了,并且大部分明智地使用了它们。然而,试图使类在复合类型上泛型会使整个事情呈指数级复杂。这是 Arnaud 在早期研究中遇到的领域之一。像以下代码可能永远不可能,至少如果我们关心性能的话:
class SimpleRepository implements Repository<BlogPost|User|Event>
{
// ...
}
class DataProcessor
{
public function __construct(private Repository<UserEntity|BlogPost> $repo) {}
}
实际上,这可能很好。甚至有用的情况很少。
然而,如上所述,对作为联合一部分的泛型进行类型化,如 public function setRepository(Repository<UserEntity>|null $repo) {}
,在后续 RFC 中可能是可能的。
类型推断是许多重度类型化语言的功能,其中编译器或引擎可以基于上下文"弄清楚"某物的类型应该是什么。作为一个简单的例子:
function add(int $x, int $y)
{
return $x + $y;
}
很容易看出该函数的返回类型是 int
,所以类型推断引擎会自动为你填入。
这对泛型非常有帮助,特别是如果运行时泛型变得可能。
class Car<Driver> {
public function __construct(private Driver $driver) {}
}
// 这个完整版本
new Car<StudentDriver>(new StudentDriver());
// 可以缩写为这个,引擎会弄清楚其余部分。
new Car(new StudentDriver());
Arnaud 去年的大量研究是关于类型推断的可行性,以使泛型代码更容易使用。不幸的是,这仍然在真正困难 ™ 的领域。另一方面,在运行时泛型(如在 new
中)变得可行之前,它也基本上无关紧要,而运行时泛型已经是真正困难 ™ 的。
所有上述内容仍然是完整 PHP 泛型实现的挑战。然而,重要的是,它们不会因为 Gina 对仅编译时部分的工作而变得更困难。不能保证它们永远可能,但采用我们能做的泛型部分不会使它们变得不那么可能。
这项工作仍然是实验性的。如上所述,还有一些额外的功能需要添加,以及数十个边缘情况和粗糙的角落需要整理。(如果你实现两个泛型接口会发生什么?匿名类会让任何事情变得更奇怪吗?等等。)将编译时泛型带到可投票状态还有很多工作要做。
当然,基金会希望尊重我们开发团队的时间、众多 RFC 审查者的时间,以及我们慷慨赞助商的钱包。基金会工作人员已经在泛型问题上投入了相当多的时间。在我们投入更多时间之前,我们想问社区(特别是 PHP Internals)...这值得吗?
像这里描述的部分泛型方法会是可接受的吗?即使可能无法一路走到完整的泛型,"仅编译时泛型"会是足够大的胜利来证明在其上花费更多时间吗?我们的团队认为是的,但 PHP 比我们的团队更大,所以我们想从更广泛的社区获得反馈。
你会支持(并投票支持)这里描述的仅编译时泛型吗?
我认为不值得。这个改动看起来相当大,投入得时间也巨大。收益却很小。因为现在又 PHPStan 等静态分析工具,也可以满足了。不如好好得搞异步 协程之类的功能。