本节书摘来自华章出版社《Hack与HHVM权威指南》一书中的第1章,第1.7节,作者 Owen Yamauchi,更多章节内容可以访问云栖社区“华章计算机”公众号查看。
1.7 类型提炼
假设你有个?string类型的值,而且准备把这个值传递给一个参数类型为string的函数。那么你怎么把一个类型(?string)转化为另外一个类型(string)呢?或者假设你有个object类型的值,它可能实现或没有实现Polarizable接口。同时,如果它实现了这个接口,你还希望调用这个object的方法polarize()。那么类型检查器如何才能知道polarize()调用是合法的?
在一个良好组织的代码中,实现一个值是一个类型同时又是另外一个类型的任务情况非常常见。这些看起来非常琐碎的事情是你必须拿来安抚类型检查器的关键所在。这是Hack能够在开发前期就捕获问题的关键。这也是Hack能够避免像调用一个不存在的方法、在不恰当的地方找到了一个空值,以及其他一些在PHP代码库开发调试中常见的恼人错误这些情况的原因。
你有三种类型检查器使用的方式对这些类型进行提炼转化,它们是:是否为空检查、类似is_integer()的内置类型查询函数,以及instanceof 。当这些语句在流程控制语句(比如循环语句、条件语句)中被使用时,类型推理引擎将会明确知晓:在不同的流程控制路径下,类型值也不同。
1.7.1 提炼nullable类型到non-nullable类型
null检查语句在从空值(nullable)的类型到非空值(non-nullable)类型的转变中经常用到。下面是个通过了类型检查器检查的示例。
function takes_string(string $str) {
// ...
}
function takes_nullable_string(?string $str) {
if ($str !== null) {
takes_string($str);
}
// ...
}```
在if语句段内部,类型检查器知道$str是个非空值,所以它能够传递给函takes_string() 。注意null检查应该使用恒等操作符===和!==,而不是普通的等于操作符 (==和!=),或者转化为一个boolean类型。如果你不用恒等符号,类型检查器会报告一个错误注6。内置的函数is_null()也是可以工作的。下面是三元表达式的例子:
function takes_nullable_string(?string $str) {
takes_string($str === null ? "(null)" : $str);
// ...
}`
当然你还可以使用如下的样式,即把一个分支流直接切断:
function processInfo(?string $info) {
if ($info === null) {
return;
}
takes_string($info);
}
类型检查器知道,对函数takes_string()的调用仅仅当$info变量不为空时才会被执行。因为如果它为空,if代码段将会进入,然后函数会直接返回。(如果return语句改成throw语句,效果是一样的。)
如下是个更大的例子,展示了一个更加复杂敏感的流程控制:
function fetch_from_cache(): ?string {
// ...
}
function do_expensive_computation(): string {
// ...
}
function get_data(): string {
$result = fetch_from_cache();
if ($result === null) {
$result = do_expensive_computation();
}
return $result;
}
在return语句这里,类型检查器知道$result是个非空的值,所以返回类型标注就被满足了。如果if代码段进入,那么一个非空的字符串将会分配给$result。如果if代码段没有进入,那么$result必须已经是非空的字符串。
最后,Hack有个特殊内置的函数叫做invariant(),你可以使用这个函数来向类型检查器陈述事实。这个函数使用两个参数,一个是boolean的表达式,另一个是字符串类型,主要用来向读者描述问题所在 :
function processInfo(?string $info) {
invariant($info !== null, "I know it's never null somehow");
takes_string($info);
}
在运行环境中,如果invariant()的第一个参数值被证明是false,那么一个不变异常(InvariantException)将会触发。类型检查器知道这并且推断出:在invariant()函数后面的调用中,$info不会是个空值。否则的话,一个异常早就被抛出了,而代码根本就不会执行到这个地方。
1.7.2 提炼mixed类型到原始类型
对每个原始类型来说,这里有内置的函数来检测一个变量是否为原始类型(比如is_integer()、is_string()、is_array())。类型检查器特别识别出这些函数,除了is_object()注7之外。这个知识点将在你检测mixed类型和泛型时用到。
你使用这些内置的函数来传递信息给类型检查器的方式,和你使用null检查的方式非常接近。类型检查器控制敏感的流程,你可以使用invariant()等。然而,这些内置函数携带的信息比“空或者非空”更复杂。所以这里我们对它们如何工作做更加细致的探讨。
首先,类型检查器不会记住任何负面的信息,例如“这个值不是个字符串类型”。请看如下示例:
function f(mixed $val) {
if (!is_string($val)) {
// 这里$val是个mixed类型,我们并不记得它不是一个字符串
} else {
// 这里$val是个字符串类型
}
}
在具体的实践中,这问题不大。如果我们知道一个值可能是任何类型而不是字符串的话,用途是非常小的。除非在将来能够对它进一步提炼。
其次,内置的类型查询功能是唯一的提炼类型到原始类型的办法。甚至对目前已知类型的值做身份对比也无法实现它:
function f(mixed $val) {
if ($val === 'some string') {
// 这里$val是个mixed类型
// 只有is_string可以告知类型检查器,它是个字符串
}
}
1.7.3 对象类型提炼
最终,类型检查器会明白使用instanceof来检查某个object是否为给定类或接口的实例。就像null检查和内置的类型查询一样,类型检查器明白条件语句和i
class ParentClass {
}
class ChildClass extends ParentClass {
public function doChildThings(): void {
// ...
}
}
function doThings(ParentClass $obj): void {
if ($obj instanceof ChildClass) {
$obj->doChildThings(); // OK
}
}
function unconditionallyDoThings(ParentClass $obj): void {
invariant($obj instanceof ChildClass, 'just trust me');
$obj->doChildThings(); // OK
}
这里将会有更多的细节,并不像null检查和内置类型查询功能,instanceof在处理类型方面能够以更复杂的方式重叠使用,但是类型检查器导航它们的能力的确有小的限制。
下面的例子将会展示这个限制,我们有个抽象类型的基类,同时有很多子类。这些子类中,有些实现了内置的接口Countable,但有些并没有实现:
abstract class BaseClass {
abstract public function twist(): void;
}
class CountableSubclass extends BaseClass implements Countable {
public function count(): int {
// ...
}
public function twist(): void {
// ...
}
}
class NonCountableSubclass extends BaseClass {
public function twist(): void {
// ...
}
}
然后我们有个拿BaseClass做参数类型的函数,如果传入的参数是Countable的话,我们就调用函数count(),然后调用一个在BaseClass里面声明的方法。这在面向对象的代码库中是非常常见的模式,不仅仅局限于Countable接口:
function twist_and_count(BaseClass $obj): void {
if ($obj instanceof Countable) {
echo 'Count: ' . $obj->count();
}
$obj->twist();
}
最后一行将会有个类型错误,这似乎是完全出乎意料的,所以让我们来了解一下这个问题的细节所在。
理解这个错误的关键在于,当类型检查器看到一个instanceof检查的时候,它所传递的信息将会是非常严格的和类型检查一致的信息,它不会考虑继承的层次结构、接口,或者任何其他的因素。它甚至有可能不满足相关的条件(比如BaseClass及其继承类们并没有实现Countable接口),但是类型检查器不这么认为。
在函数的开始部分,类型检查器由于类型标注的缘故,会认为$obj的类型是 BaseClass。然而在if代码段内,类型检查器会认为$obj是Countable类型的,而不是一个实现了Countable接口的BaseClass实例。它会忘记$obj在是Countable的同时,还是一个BaseClass。
然后我们进入if代码段后面的部分。这里,$obj的类型是个包含BaseClass或者Countable的未决的类型(详情请见1.6.2节的内容)。然后当类型检查器看到了代码 $obj->twist()的时候,它报告了一个错误,因为它认为有可能在此调用中$obj的值是非法的,比如它是Countable类型而不是BaseClass类型。当然你作为读者,知道这是不可能的。但是类型检查器不行。
解决这个问题的方法就是对于instanceof检查使用一个独立的本地变量。这就可以阻止类型检查器丢失$obj的类型信息,这才是这个问题的根源:
function twist_and_count(BaseClass $obj) {
$obj_countable = $obj;
if ($obj_countable instanceof Countable) {
echo 'Count: ' . $obj_countable->count();
}
$obj->twist();
}
在上面描述的所有情形中,在if语句或者invariant()函数调用中,必须是一个单独的类型查询。使用或逻辑操作符“||”的联合多类型查询是不被类型检查器支持的。正如下例所示,这将会有个类型错误:
class Parent {
}
class One extends Parent {
public function go(): void {}
}
class Two extends Parent {
public function go(): void {}
}
function f(Parent $obj): void {
if ($obj instanceof One || $obj instanceof Two) {
$obj->go(); // 错误
}
}
一个非常好的解决办法就是使用接口。创建一个接口然后声明一个go()方法,让One和Two来实现它,然后在函数f()中对这个接口进行检查。
1.7.4 属性值推理
目前为止,我们所有示例中的推理都是基于本地变量的。这很容易,因为类型检查器能够确信,它能够看到所有本地变量的读和写操作注8。所以它在对本地变量进行类型推理时,能够做出较强的担保。
对属性值的推理是更加困难的。困难的根源在于本地变量在它所在的函数外部无法修改,但是属性值可以。请分析下面的例子:
function increment_check_count(): void {
// ...
}
function check_for_valid_characters(string $name): void {
// ...
}
class C {
private ?string $name;
public function checkName(): void {
if ($this->name !== null) {
increment_check_count();
check_for_valid_characters($this->name);
}
}
}
这段代码并不能通过类型检查器,将会报告如下的错误:
/home/oyamauchi/test.php:16:34,44: Invalid argument (Typing[4110])
/home/oyamauchi/test.php:6:37,42: This is a string
/home/oyamauchi/test.php:11:11,17: It is incompatible with a nullable type
/home/oyamauchi/test.php:15:7,29: All the local information about the member
name has been invalidated during this call.
This is a limitation of the type-checker, use a local if that's the problem.
错误将会指向对函数check_for_valid_characters()的调用。错误信息给出了一个对错误的简单解释。在null检查后,类型检查器知道$this->name并不为空,然而,对函数increment_check_count()的调用执行迫使类型检查器忘记了$this->name不为空的结论,因为事实可能由于调用的结果而改变。
你作为一名程序员,可能知道$this->name的值不会因为调用increment_check_count()函数的结果而改变,但是类型检查器自己不能发现这点。正如我们看到的那样,推理是基于本地函数的。正如错误信息所说的一样,解决方法就是使用本地变量。复制属性值到一个本地变量,然后使用这个本地变量进行替换:
public function checkName(): void {
if ($this->name !== null) {
$local_name = $this->name;
Logger::log('checking name: ' . $local_name);
check_for_valid_characters($local_name);
}
}
你还可以在if代码段之外写本地变量的复制语句。然对本地变量进行null检查。无论如何,都必须让类型检查器确认$local_name没有被修改,然后它就能记住类型非空的推断了。