toobo / matching
PHP的模式匹配
Requires
- php: >=7
Requires (Dev)
- phpunit/phpunit: 6.2.*
This package is auto-updated.
Last update: 2024-08-29 04:37:10 UTC
README
对于曾经玩过函数式编程语言的开发者来说,PHP中非常缺乏的一个特性就是 “模式匹配”。
我不会尝试解释模式匹配是什么。
我只是想说,这个库尝试在PHP中提供一些受到其启发的功能。
这是什么呢?
简而言之,这个库提供了一个函数,它接受一组回调(PHP中的任何callable
),每个回调有不同的签名,然后返回另一个回调,该回调可以接受任意参数执行。
返回的回调作为原始给定回调集的代理,并只执行其中一个。要执行的回调通过尝试将传递给代理回调的参数与原始给定回调的签名之间的最佳匹配来选择。
通过示例进行说明
use Toobo\Matching\Matcher; $callback = Matcher::for( function(string $name) { return "Hi, my name is $name."; }, function(int $age) { return "I am $age years old."; } ); $callback('Giuseppe'); // "Hi, my name is Giuseppe." $callback(35); // "I am 35 years old."
在上面的代码片段中,在传递给Matcher::for()
的回调中,参数类型与传递给$callback
的值匹配的回调会被执行。
参数类型不是使回调“匹配”的唯一因素,另一个因素是接受参数的数量。
例如
$callback = Matcher::for( function(string $name, int $age) { return "Hi, my name is $name and I am $age years old."; }, function(int $age, string $name) { return "Hi, my name is $name and I am $age years old."; }, function(int $children) { return $children === 1 ? 'I have 1 child.' : "I have $children children."; } ); $callback('Giuseppe', 35); // "Hi, my name is Giuseppe and I am 35 years old." $callback(35, 'Giuseppe'); // "Hi, my name is Giuseppe and I am 35 years old." $callback(1); // "I have 1 child."
在上面的例子中,当传递两个参数给$callback
时,执行函数的选择是基于接收到的参数的类型。
当传递单个参数时,唯一可能匹配的回调是第3个,它实际上匹配,因为传递的值的类型(整数)与回调类型声明匹配。
处理无匹配的情况
如果没有回调匹配会发生什么?
简单来说,会抛出一个类型为Toobo\Matching\Exception\NotMatched
的异常(它扩展了Toobo\Matching\Exception\Exception
,该异常又扩展了\TypeError
)。
然而,Matcher::for()
返回的$callback
实际上是一个Toobo\Matching\Matcher
的实例,它除了有一个使它成为callable
的__invoke()
方法外,还有一个failWith()
方法,可以用来提供一个额外的回调,如果传递给Matcher::for()
的回调中没有匹配的,这个回调会被执行
$callback = Matcher::for( function(string $name) { return "Hi, my name is $name."; } )->failWith(function(...$args) { return "Sorry, I don't know what you mean."; }); $callback('Giuseppe'); // "Hi, my name is Giuseppe." $callback(true); // "Sorry, I don't know what you mean."
这意味着failWith()
可以防止抛出异常。
注意,failWith()
接收传递给$callback
的所有参数,就像传递给Matcher::for()
的任何回调一样。
处理无类型声明和“特异性”
在PHP中,类型声明是完全可选的。当给定的回调参数没有类型声明时,它们将充当“通配符”匹配任何值。
$callback = Matcher::for( function(string $name, int $age) { return "I'm $name and I'm $age years old." } function($anything, int $age) { return "I'm $age years old."; } ); $callback('Giuseppe', 35); // "I'm Giuseppe and I'm 35 years old." $callback(true, 35); // "I'm 35 years old."
特异性
由于没有类型声明的参数,可能存在多个签名不同的回调匹配。
为了决定哪个应该被执行,该库计算一个“特异性”值。
特异性等于实际匹配的类型声明数量。
与具有最高特异性值匹配的回调会被执行。
$callback = Matcher::for( function($foo, $bar) { return "First" } function($foo, int $bar) { return "Second"; } ); $callback('a', 'b'); // "First" $callback('a', 1); // "Second"
在上面的例子中,两个给定的回调都要求两个参数,但第一个回调只能在第二个参数不是整数时匹配。
事实上,当第二个参数是整数时,第二个回调将匹配特异性值为1
,而第一个回调将匹配特异性值为零,因为它没有类型声明。
请注意,特异性不是基于回调签名中的类型声明计算的,而是基于传递给生成的回调的实际值计算的。
当涉及到可变参数或默认参数时,这是相关的。
例如
$callback = Matcher::for( function(int ...$numbers) { } function(string $foo, int $bar = 0) { } );
在上面的代码片段中,第一个回调可能与一个 变量特定性 匹配,其值等于或大于 0
:实际上,它的特定性将等于传递给 $callback
的参数数量(假设它们都是整数,否则回调不匹配)。
第二个回调可能匹配特定性为 1
的特定性(当以单个字符串参数调用 $callback
时),或者匹配特定性为 2
的特定性(当传递两个参数,第一个是字符串,第二个是整数时)。
处理默认值和“权重”
传递给 Matcher::for()
的回调可能包含一些具有默认值的参数。
这将被考虑在内以进行匹配。例如
$callback = Matcher::for( function(string $name) { return "Hi, my name is $name."; } function(string $name, int $age, array $children = []) { $msg = "I'm $name ($age)"; $count = count($children); if ($count === 1) { $msg .= ' I have 1 child, their name is ' . reset($children) . '.'; } elseif($count > 1) { $msg .= "I have $count children: " . implode(', ', $children) . '.'; } return $msg; } ); $callback('Giuseppe'); // "Hi, my name is Giuseppe." $callback('Giuseppe', 35); // "I'm Giuseppe (35)." $callback('Giuseppe', 35, ['Sofia']); // "I'm Giuseppe (35). I have 1 child, their name is Sofia."
第二个回调在传递 2 个或 3 个参数给它时匹配,因为它的第三个参数是可选的。
权重
考虑默认值的一个影响是,具有不同签名的更多回调可以与相同的特定性匹配。
例如
$callback = Matcher::for( function(string $name, int $age = -1) { $msg = "I'm $name"; return $age > 0 ? "$msg ($age)." : "$msg." } function(string $name) { return "Hi, my name is $name."; } ); $callback('Giuseppe'); // "Hi, my name is Giuseppe."
在上面的代码片段中,$callback
被一个字符串参数调用,并且两个给定的回调都匹配。
并且两者都与特定性 1
匹配,因为有一个参数,并且两个回调都有对该参数的类型声明。
然而,正在执行的回调是第二个,其签名仅包含一个参数。
原因是当有更多回调与相同的特定性匹配时,会计算一个 “权重” 值来决定哪个将被执行。
权重是计算为 接收到的值的总数,减去实际接收到的值与声明的回调参数数量之差的模。
以下示例可能可以澄清。
在上面的最后一个代码片段中,第一个回调与权重 0
匹配
// $weight = {n. of total args} - abs( {n. of total args} - {n. of params in signature} ) $weight = 1 - abs(1 - 2); // 0
而第二个回调与权重 1
匹配
$weight = 1 - abs(1 - 1); // 1
因此,由于权重较高,第二个回调被执行。
值得重复的是:权重 用于在多个回调与相同的 特定性 匹配时决定哪个回调将被执行;如果多个回调匹配,并且其中一个与更高的特定性匹配,则无论 权重 如何,它都会被执行。
处理可变参数
处理可变参数的方式基于 最小惊讶 原则。
首先,如果一个可变参数有类型声明,它将像预期的那样工作。
此外,在 PHP 中,可变参数始终是可选的,因此它们被认为是可选的。
由于最小惊讶原则,在内部为可变参数保留了一种特殊的处理方式:当回调有可变参数时,它们的权重从 0
开始计算,而不是从总参数数量开始。
以下示例应阐明原因。给定
$callback = Matcher::for( function(...$args) { } function($a = 'x', $b = 'y', $c = 'z') { } ); $callback('foo', 'bar');
不知道权重计算的内部,人们自然会假设匹配的回调是第二个。
然而,如果不应用可变参数的特殊权重计算,两个回调都会有一个权重 1
(因为接收到的参数和声明的参数之间的差的模在两种情况下都是 1
),因此执行的功能将不可预测。
感谢对可变参数应用“特殊”的权重计算,具有可变参数的功能与权重 -1
匹配,并且具有更高权重的另一个回调按预期执行。
然而,当可变参数也包含类型声明时,它们的特定性会随着传递的参数数量增加而增加,从而增加了匹配的机会。
例如
$callback = Matcher::for( function(int...$numbers) { return 'These numbers matched: ' . implode(', ', $numbers); } function(int $age) { return "I'm $age years old."; }, function($a, int $b, $c) { return implode(', ', [$a, $b, $c]); } ); $callback(1, 2, 3); // "These numbers matched: 1, 2, 3" $callback(35); // "I'm 35 years old."
在第一种情况(传递三个整数时),第一个和第三个回调匹配,然而第一个(可变参数)回调“获胜”,因为它与特定性的3
匹配(它接收了满足可变参数类型声明的3
个参数),而第三个回调与特定性的1
匹配。
在第二种情况(传递单个整数时),第一个和第二个回调匹配,但后者被执行,因为这两个回调的特定性都等于1
,但可变参数回调的匹配权重为零,而执行回调的匹配权重为1
。
“捕获所有”的可变参数回调
可能不会立即清楚的是,这种类型的回调
$callback = Matcher::for( function(...$args) { // something here } );
将始终匹配。
因为没有类型声明,所以它接受任何类型的参数,并且可变参数参数被验证为接受任意数量的参数。
然而,这种回调的特定性始终是零,因为没有类型声明,并且权重将等于或小于零(传递的参数越多,权重越低)。
具有如此低的特定性和权重,这种类型的回调实际上是一种“后备”机制,总是会在没有更合适的匹配时匹配。
这也意味着,这样的回调可以用来代替failWith()
来处理没有其他匹配的情况(而且如果使用这样的回调,最终的failWith()
回调永远不会被调用),但有一个区别:传递给failWith()
的回调,与传递给Matcher::for()
的回调不同,不能绑定到对象。
有关回调绑定的更多信息见下文。
回调绑定
尽管本README中的所有示例都使用匿名函数以提高可读性,但Matcher::for()
接受PHP中任何可调用的东西。
然而,在内部,它始终存储给定回调的闭包版本,该版本是通过\ReflectionFunction::getClosure()
(或\ReflectionMethod::getClosure()
)获得的。
始终存储闭包允许将给定的回调绑定到任意对象,从而开辟了有趣的可能性。
要绑定一个对象,请调用方法Matcher::bindTo()
,并传递用作给定回调的$newthis
的对象。
例如
$matcher = Matcher::for( function(string $param) { return $this->offsetExists($param) ? $this[$param] : null; }, function(int $param) { $values = array_values($this->getArrayCopy()); return array_key_exists($param, $values) ? $values[$param] : null; }, function(string ...$params) { return array_map(function($param) { return $this->offsetExists($param) ? $this[$param] : null; }, $params); } ); $matcher = $matcher->bindTo(new \ArrayObject(['foo' => 'Foo!', 'bar' => 'Bar!'])); $matcher('foo'); // "Foo!" $matcher(1); // "Bar!" $matcher('foo', 'meh', 'bar'); // ["Foo!", null, "Bar!"]
即使匹配器有许多回调,回调绑定也是相当高效的,因为它只为匹配的回调执行。
实际上,当调用Matcher::bindTo()
时,实际上并没有发生任何事情:传递的对象被存储,将在匹配回调被计算时用于绑定匹配的回调。
这也意味着可以在同一个匹配器实例上高效地调用Matcher::bindTo()
,并使用不同的对象。
不可绑定的回调
某些回调不能绑定到对象。例如,声明为static
的闭包或普通函数。
这不是问题。当调用Matcher::bindTo()
时,如果匹配的回调不可绑定,则不执行任何操作。考虑到不可绑定的回调不能包含任何$this
引用,这并不重要。
一个近乎现实世界的使用示例
在某些语言中,例如Java,一个对象可能有多个构造函数,每个构造函数的签名不同。当对象被实例化时,根据传递给构造函数的参数选择要使用的构造函数,按类型匹配,就像这个库所做的那样。
通过在构造函数中使用条件语句(增加循环复杂度并降低可读性)以及放弃类型安全的构造函数参数,可以在PHP中获得这种对象构造的灵活性。
借助这个库,可以在PHP中模仿Java的多构造函数功能,同时保持类型安全的参数,而不使用条件语句。
例如
namespace Example; use Toobo\Matching\Matcher; class Person { private static $factory; private static function buildFactory(): Matcher { self:$factory or self:$factory = Matcher::for( function(string $firstname, string $lastname, int $age, string $email = '') { $this->fullname = "$firstname $lastname"; $this->age = $age; $this->email = $email; }, function(string $fullname, int $age, string $email = '') { $this->fullname = $fullname; $this->age = $age; $this->email = $email; }, function(int $age, string $fullname, string $email = '') { $this->age = $age; $this->fullname = $fullname; $this->email = $email; } ); return self:$factory; } public function __construct(...$args) { self::buildFactory()->bindTo($this)(...$args); } public function introduce(): string { $out = "My name is $this->fullname and I am $this->age years old."; if ( $this->email ) { $out .= " My email address is '$this->email'."; } return $out; } }
灵活构造器的实际应用
(new Person('Giuseppe', 'Mazzapica', 35)) ->introduce(); // My name is Giuseppe Mazzapica and I am 35 years old. (new Person('Giuseppe Mazzapica', 35)) ->introduce(); // My name is Giuseppe Mazzapica and I am 35 years old. (new Person(35, 'Giuseppe Mazzapica')) ->introduce(); // My name is Giuseppe Mazzapica and I am 35 years old. (new Person(35, 'Giuseppe Mazzapica', 'gm@example.com')) ->introduce(); // My name is Giuseppe Mazzapica and I am 35 years old. My email address is 'gm@example.com'.
所以我们有灵活性和类型安全:使用与内部回调类型不匹配的内容调用Person
构造函数将抛出异常(这个异常扩展了TypeError
,与PHP在类型声明不匹配时抛出的异常相同)。
好的,不错,但是我应该在生产环境中使用这个吗?
我对代码即将投入生产非常自信,测试覆盖率接近100%。
然而,为了完成其工作,匹配使用了大量的反射和闭包生成,这些操作在PHP中不是最快的。
然而,坦白说,到目前为止,我完全没有进行性能分析。
为什么会有这个东西存在呢?
主要是为了好玩(我的乐趣)。为什么不呢?
需求
- PHP 7+
- 使用Composer进行安装
安装
通过Composer,在packagist.org上安装toobo/matching
。
许可证
匹配是开源的,并按照MIT许可证发布。有关更多信息,请参阅LICENSE文件。