PHP8-对象-模式和实践-二-

40 阅读33分钟

PHP8 对象、模式和实践(二)

原文:PHP 8 Objects, Patterns, and Practice

协议:CC BY-NC-SA 4.0

四、高级功能

您已经看到了类类型提示和访问控制如何让您对类的接口有更多的控制。在这一章中,我将深入研究 PHP 的面向对象特性。

本章将涵盖几个主题:

  • 静态方法和属性:通过类而不是对象来访问数据和功能

  • 抽象类和接口:分离设计和实现

  • 特征:在类层次结构之间共享实现

  • 错误处理:引入异常

  • 最终类和方法:限制继承

  • 拦截器方法:自动化委托

  • 析构函数方法:清理你的对象后

  • 克隆对象:制作对象副本

  • 将对象解析为字符串:创建汇总方法

  • 回调:用匿名函数和类给组件添加功能

静态方法和属性

前一章中的所有例子都与对象有关。我将类描述为产生对象的模板,将对象描述为类的活动实例——调用其方法并访问其属性的事物。我暗示,在面向对象编程中,真正的工作是由类的实例来完成的。毕竟,类仅仅是对象的模板。

其实没那么简单。您可以在类的上下文中访问方法和属性,而不是在对象的上下文中。这些方法和属性是“静态”的,必须通过使用关键字static来声明:

// listing 04.01
class StaticExample
{
    public static int $aNum = 0;
    public static function sayHello(): void
    {
        print "hello";
    }
}

静态方法是具有类范围的函数。它们本身不能访问类中的任何普通属性,因为这些属性属于一个对象;但是,他们可以访问静态属性。如果更改静态属性,该类的所有实例都能够访问新值。

因为您通过类而不是实例来访问静态元素,所以您不需要引用对象的变量。相反,您可以将类名与::结合使用,如下例所示:

// listing 04.02
print StaticExample::$aNum;
StaticExample::sayHello();

这种语法应该是上一章所熟悉的。我结合使用了::parent来访问一个被覆盖的方法。现在,和那时一样,我访问的是类而不是对象数据。类代码可以使用parent关键字来访问一个超类,而不使用它的类名。为了从同一个类中(而不是从子类中)访问一个静态方法或属性,我会使用self关键字。self对于类就像$this伪变量对于对象一样。因此,在StaticExample类之外,我使用类名来访问$aNum属性:

StaticExample::$aNum;

在一个类中,我可以使用self关键字:

// listing 04.03
class StaticExample2
{
    public static int $aNum = 0;
    public static function sayHello(): void
    {
        self::$aNum++;
        print "hello (" . self::$aNum . ")\n";
    }
}

Note

使用parent进行方法调用是您应该使用对非静态方法的静态引用的唯一情况。

除非您正在访问一个被覆盖的方法,否则您应该只使用>::来访问一个已经被显式声明为静态的方法或>属性。

然而,在文档中,您会经常看到用于引用方法或属性的静态语法。这并不意味着所讨论的项目一定是静态的,只是它属于某个类。例如,ShopProductWriter类的write()方法可能被称为ShopProductWriter::write(),即使write()方法不是静态的。当特定级别适当时,您会在这里看到这个语法。

根据定义,静态方法和属性是在类而不是对象上调用的。因此,它们通常被称为类变量和属性。这种面向类的结果是,您不能在静态方法中使用$this伪变量。

那么,为什么要使用静态方法或属性呢?静态元素有许多有用的特征。首先,它们在脚本中的任何地方都是可用的(假设您可以访问该类)。这意味着您可以访问功能,而不需要在对象之间传递类的实例,或者更糟糕的是,将实例存储在全局变量中。第二,静态属性对类的每个实例都可用,因此您可以设置对类型的所有成员都可用的值。最后,您不需要实例来访问静态属性或方法,这一事实可以使您不必为了获得简单的函数而实例化一个对象。

为了说明这一点,我将为ShopProduct类构建一个静态方法,该方法自动化了ShopProduct对象的实例化。使用 SQLite,我可能会像这样定义一个products表:

// listing 04.04
CREATE TABLE products (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    type TEXT,
    firstname TEXT,
    mainname TEXT,
    title TEXT,
    price float,
    numpages int,
    playlength int,
    discount int )

现在我想构建一个getInstance()方法,它接受一个行 ID 和PDO对象,用它们来获取一个数据库行,然后返回一个ShopProduct对象。我可以将这些方法添加到我在前一章创建的ShopProduct类中。你可能知道, PDO 代表 PHP 数据对象。PDO 类为不同的数据库应用提供了一个通用接口:

// listing 04.05

// ShopProduct class...

    private int $id = 0;
    // ...

    public function setID(int $id): void
    {
        $this->id = $id;
    }
    // ...

    public static function getInstance(int $id, \PDO $pdo): ShopProduct
    {
        $stmt = $pdo->prepare("select * from products where id=?");
        $result = $stmt->execute([$id]);
        $row = $stmt->fetch();
        if (empty($row)) {
            return null;
        }

        if ($row['type'] == "book") {
            $product = new BookProduct(
                $row['title'],
                $row['firstname'],
                $row['mainname'],
                (float) $row['price'],
                (int) $row['numpages']
            );
        } elseif ($row['type'] == "cd") {
            $product = new CdProduct(
                $row['title'],
                $row['firstname'],
                $row['mainname'],
                (float) $row['price'],
                (int) $row['playlength']
            );
        } else {
            $firstname = (is_null($row['firstname'])) ? "" : $row['firstname'];
            $product = new ShopProduct(
                $row['title'],
                $firstname,
                $row['mainname'],
                (float) $row['price']
            );
        }
        $product->setId((int) $row['id']);
        $product->setDiscount((int) $row['discount']);
        return $product;
    }

如您所见,getInstance()方法返回一个ShopProduct对象,并且基于一个类型标志,足够智能地计算出它应该实例化的精确专门化。为了保持示例简洁,我省略了任何错误处理。例如,在现实世界的版本中,我不会如此轻信假设所提供的PDO对象被初始化为与正确的数据库对话。事实上,我可能用一个保证这种行为的类来包装PDO。你可以在第十三章中读到更多关于面向对象编码和数据库的内容。

此方法在类上下文中比在对象上下文中更有用。它让您可以轻松地将数据库中的原始数据转换成一个对象,而不需要从一个ShopProduct对象开始。该方法不使用任何实例属性或方法,因此没有理由不将其声明为static。给定一个有效的PDO对象,我可以从应用的任何地方调用该方法:

// listing 04.06
$dsn = "sqlite:/tmp/products.sqlite3";
$pdo = new \PDO($dsn, null, null);
$pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION);
$obj = ShopProduct::getInstance(1, $pdo);

像这样的方法就像“工厂”一样,它们获取原材料(比如行数据或配置信息)并使用它们来生产对象。术语工厂适用于设计用来生成对象实例的代码。在以后的章节中,你会再次遇到工厂的例子。

当然,在某些方面,这个例子带来的问题和它解决的一样多。虽然我让系统中的任何地方都可以访问ShopProduct::getInstance()方法,而不需要ShopProduct实例,但是我还要求客户端代码提供一个 PDO 对象。在哪里可以找到这个?对于一个父类来说,对它的孩子有如此亲密的了解真的是好的做法吗?(提示:不,不是。)这类问题——在哪里获取关键对象和值,以及类之间应该了解多少——在面向对象编程中非常常见。我在第九章中研究了对象生成的各种方法。

常量属性

有些属性不应更改。生命、宇宙和一切事物的答案都是 42,而且你希望它保持这个状态。错误和状态标志通常会硬编码到您的类中。尽管它们应该是公开和静态可用的,但是客户端代码应该不能更改它们。

PHP 允许你在一个类中定义常量属性。像全局常量一样,类常量一旦设置就不能更改。常量属性是用关键字const声明的。常量不像常规属性那样以美元符号为前缀。按照惯例,它们通常只使用大写字符命名:

// listing 04.07
class ShopProduct
{
    public const AVAILABLE    = 0;
    public const OUT_OF_STOCK = 1;

常量属性只能包含原始值。不能将对象赋给常数。像静态属性一样,常量属性是通过类而不是实例来访问的。正如您定义一个不带美元符号的常量一样,当您引用一个常量时,也不需要前导符号:

// listing 04.08
print ShopProduct::AVAILABLE;

Note

PHP 7.1 引入了对常量可见性修饰符的支持。它们的工作方式与属性的可见性修改器完全相同。

一旦常量被声明,试图对其设置值将导致分析错误。

当您的属性需要在类的所有实例中可用时,以及当属性值需要固定不变时,您应该使用常量。

抽象类

抽象类不能被实例化。相反,它为任何可能扩展它的类定义(并且,可选地,部分实现)接口。

您用关键字abstract定义了一个抽象类。这里,我重新定义了我在前一章中创建的ShopProductWriter类,这次是一个抽象类:

// listing 04.09
abstract class ShopProductWriter
{
    protected array $products = [];

    public function addProduct(ShopProduct $shopProduct): void
    {
        $this->products[] = $shopProduct;
    }
}

您可以像往常一样创建方法和属性,但是任何以这种方式实例化抽象对象的尝试都会导致错误:

// listing 04.10
$writer = new ShopProductWriter();

您可以在以下输出中看到错误:

Error: Cannot instantiate abstract class
popp\ch04\batch03\ShopProductWriter

在大多数情况下,一个抽象类至少包含一个抽象方法。这些都是用关键字abstract声明的。抽象方法不能有实现。您以正常方式声明它,但是用分号而不是方法体结束声明。在这里,我给ShopProductWriter类添加了一个抽象的write()方法:

// listing 04.11
abstract class ShopProductWriter
{
    protected array $products = [];

    public function addProduct(ShopProduct $shopProduct): void
    {
        $this->products[] = $shopProduct;
    }

    abstract public function write(): void;
}

在创建一个抽象方法时,你要确保一个实现在所有具体的子类中都是可用的,但是你没有定义这个实现的细节。

假设我要创建一个从ShopProductWriter派生的类,它不实现write()方法,如下例所示:

// listing 04.12

class ErroredWriter extends ShopProductWriter
{
}

我会面临以下错误:

Fatal error: Class popp\ch04\batch03\ErroredWriter contains 1 abstract method and must therefore be declared abstract or implement the remaining methods (popp\ch04\batch03\ShopProductWriter::write) in...

因此,任何扩展抽象类的类都必须实现所有的抽象方法,或者将其本身声明为抽象的。一个扩展类不仅仅负责实现一个抽象方法。这样做时,它必须复制方法签名。这意味着实现方法的访问控制不能比抽象方法更严格。实现方法还应该需要与抽象方法相同数量的参数,以复制任何类类型声明。

下面是ShopProductWriter的两个实现——首先是XmlProductWriter:

// listing 04.13
class XmlProductWriter extends ShopProductWriter
{

    public function write(): void
    {
        $writer = new \XMLWriter();
        $writer->openMemory();
        $writer->startDocument('1.0', 'UTF-8');
        $writer->startElement("products");
        foreach ($this->products as $shopProduct) {
            $writer->startElement("product");
            $writer->writeAttribute("title", $shopProduct->getTitle());
            $writer->startElement("summary");
            $writer->text($shopProduct->getSummaryLine());
            $writer->endElement(); // summary
            $writer->endElement(); // product
        }
        $writer->endElement(); // products
        $writer->endDocument();
        print $writer->flush();
    }
}

这是更基本的TextProductWriter:

// listing 04.14
class TextProductWriter extends ShopProductWriter
{
    public function write(): void
    {
        $str = "PRODUCTS:\n";
        foreach ($this->products as $shopProduct) {
            $str .= $shopProduct->getSummaryLine() . "\n";
        }
        print $str;
    }
}

因此,我创建了两个类,每个类都有自己的write()方法实现。第一个输出 XML,第二个输出文本。一个需要一个ShopProductWriter对象的方法不知道它接收的是这两个类中的哪一个,但是可以肯定的是一个write()方法被实现了。请注意,在将$products视为数组之前,我不会测试它的类型。这是因为这个属性既被声明为数组,又在ShopProductWriter类中被初始化。

接口

虽然抽象类让你提供一些实现的方法,但是接口是纯粹的模板。接口只能定义功能;它永远无法实现它。用关键字interface声明一个接口。它可以包含属性和方法声明,但不能包含方法体。

这是一个界面:

// listing 04.15
interface Chargeable
{
    public function getPrice(): float;
}

如您所见,接口看起来非常像一个类。任何包含这个接口的类都必须实现它定义的所有方法,否则必须声明它是抽象的。

一个类可以在声明中使用关键字implements来实现一个接口。一旦你完成了这些,实现一个接口的过程就和扩展一个只包含抽象方法的抽象类是一样的。现在我将使ShopProduct类实现Chargeable:

// listing 04.16
class ShopProduct implements Chargeable
{
    // ...
    protected float $price;
    // ...

    public function getPrice(): float
    {
        return $this->price;
    }
    // ...
}

ShopProduct已经有了一个getPrice()方法,那么为什么实现Chargeable接口会有用呢?答案还是与类型有关。实现类采用它扩展的类的类型和它实现的接口。

这意味着CdProduct类属于以下类别:

CdProduct
ShopProduct
Chargeable

客户端代码可以利用这一点。知道一个对象的类型就是知道它的能力。考虑这种方法:

// listing 04.17
public function cdInfo(CdProduct $prod): int
{
    // we know we can call getPlayLength()
    $length = $prod->getPlayLength();
    // ...
}

该方法知道除了在ShopProduct类和Chargeable接口中定义的所有方法之外,$prod对象还有一个getPlayLength()方法。

传递了同一个对象,但是,具有更一般类型需求的方法——ShopProduct而不是CdProduct——只能知道提供的对象包含ShopProduct方法。

// listing 04.18
public function addProduct(ShopProduct $prod)
{
    // even if $prod is a CdProduct object
    // we don't *know* this -- so we can't
    // presume to use getPlayLength()
    // ...
}

如果没有进一步的测试,该方法将对getPlayLength()方法一无所知。

传递了同一个CdProduct对象,一个需要Chargeable对象的方法对ShopProductCdProduct类型一无所知:

// listing 04.19
public function addChargeableItem(Chargeable $item)
{
    // all we know about $item is that it
    // is a Chargeable object -- the fact that it
    // is also a CdProduct object is irrelevant.
    // We can only be sure of getPrice()
    //
    //...
}

这个方法只关心$item参数是否包含一个getPrice()方法。

因为任何类都可以实现一个接口(事实上,一个类可以实现任意数量的接口),所以接口有效地连接了原本不相关的类型。我可能会定义一个全新的类来实现Chargeable:

// listing 04.20
class Shipping implements Chargeable
{
    public function __construct(private float $price)
    {
    }

    public function getPrice(): float
    {
        return $this->price;
    }
}

我可以将一个Shipping对象传递给addChargeableItem()方法,就像我可以将一个ShopProduct对象传递给它一样。

对于使用Chargeable对象的客户端来说,重要的是它可以调用getPrice()方法。任何其他可用的方法都与其他类型相关联,无论是通过对象自己的类、超类还是另一个接口。这些与客户无关。

一个类既可以扩展一个超类,也可以实现任意数量的接口。extends子句应在implements子句之前:

// listing 04.21
class Consultancy extends TimedService implements Bookable, Chargeable
{
    // ...
}

注意,Consultancy类实现了不止一个接口。多个接口在逗号分隔的列表中跟在implements关键字后面。

PHP 只支持从单亲继承,所以extends关键字只能放在一个类名的前面。

特征

正如我们所看到的,接口帮助你管理这样一个事实,即像 Java 一样,PHP 不支持多重继承。换句话说,PHP 中的一个类只能扩展一个父类。然而,你可以让一个类承诺实现尽可能多的接口;对于它实现的每个接口,该类都采用相应的类型。

所以接口提供了没有实现的类型。但是,如果您希望跨继承层次结构共享一个实现,该怎么办呢?PHP 5.4 引入了特性,这些特性让你可以做到这一点。

trait 是一个类结构,它本身不能被实例化,但是可以被合并到类中。特征中定义的任何方法都可以作为使用它的任何类的一部分。特征改变了类的结构,但没有改变它的类型。将特征视为类的包含。

让我们来看看为什么一种特质可能是有用的。

特质要解决的问题

下面是带有一个calculateTax()方法的ShopProduct类的一个版本:

// listing 04.22

class ShopProduct
{
    private int $taxrate = 20;

// ...

    public function calculateTax(float $price): float
    {
        return (($this->taxrate / 100) * $price);
    }
}

calculateTax()方法接受一个$price参数,并根据私有的$taxrate财产计算销售税金额。

当然,子类可以访问calculateTax()。但是完全不同的类层次结构呢?想象一个名为UtilityService的类,它继承自另一个类Service。如果UtilityService需要使用相同的例程,我可能会发现自己完全复制了calculateTax()。服务如下:

// listing 04.23

abstract class Service
{
    // service oriented stuff
}

这里是UtilityService:

// listing 04.24

class UtilityService extends Service
{
    private int $taxrate = 20;

    public function calculateTax(float $price): float
    {
        return ( ( $this->taxrate / 100 ) * $price );
    }
}

因为UtilityServiceShopProduct不共享任何公共基类,所以它们不能轻易共享calculateTax()实现。因此,我们被迫将我们的实现从一个类复制粘贴到另一个类。

定义和使用特性

我将在本书中介绍的核心面向对象设计目标之一是消除重复。正如你将在第十一章看到的,这种重复的一个解决方案是将其分解成一个可重用的策略类。特质提供了另一种方法——也许不那么优雅,但肯定有效。

在这里,我声明了一个定义了calculateTax()方法的特征,然后我将它包含在ShopProductUtilityService中:

// listing 04.25
trait PriceUtilities
{
    private $taxrate = 20;

    public function calculateTax(float $price): float
    {
        return (($this->taxrate / 100) * $price);
    }

    // other utilities
}

我用关键字trait声明了PriceUtilities特征。特征的主体看起来非常类似于类的主体。它只是收集在大括号中的一组方法和属性。一旦我声明了它,我就可以从我的类中访问PriceUtilities特征。我用关键字use加上我希望合并的特征的名称来完成这个操作。所以在一个地方声明并实现了calculateTax()方法后,我继续将它合并到ShopProduct类中。

// listing 04.26
use popp\ch04\batch06_1\PriceUtilities;

class ShopProduct
{
    use PriceUtilities;
}

当然,我也将它添加到了UtilityService类中:

// listing 04.27
class UtilityService extends Service
{
    use PriceUtilities;
}

现在,当我调用这些类时,我知道它们共享PriceUtilities实现而没有重复。如果我在PriceUtilities中发现一个 bug,我可以在一个地方修复它。

// listing 04.28
$p = new ShopProduct();
print $p->calculateTax(100) . "\n";

$u = new UtilityService();
print $u->calculateTax(100) . "\n";

使用一个以上的特征

您可以在一个类中包含多个特征,方法是在关键字use后列出每个特征,用逗号分隔。在这个例子中,我定义并应用了一个新的特征,IdentityTrait,保留了我原来的PriceUtilities特征:

// listing 04.29
trait IdentityTrait
{
    public function generateId(): string
    {
        return uniqid();
    }
}

通过应用带有use关键字的PriceUtilitiesIdentityTrait,我使calculateTax()generateId()方法对ShopProduct类可用。这意味着该类同时提供了calculateTax()generateId()方法。

// listing 04.30
class ShopProduct
{
    use PriceUtilities;
    use IdentityTrait;
}

Note

IdentityTrait特征提供了generateId()方法。事实上,数据库经常为对象生成标识符,但是出于测试目的,您可能会切换到本地实现。你可以在第十三章中找到更多关于对象、数据库和唯一标识符的信息,这一章涵盖了身份映射模式。你可以在第十八章中了解更多关于测试和嘲讽的知识。

现在我可以在一个ShopProduct类上同时调用generateId()calculateTax()方法。

// listing 04.31
$p = new ShopProduct();
print $p->calculateTax(100) . "\n";
print $p->generateId() . "\n";

结合特征和界面

尽管特征很有用,但它们不会改变它们所应用的类的类型。因此,当您将IdentityTrait特征应用于多个类时,它们不会共享一个可以在方法签名中暗示的类型。

幸运的是,特征与接口配合得很好。我可以定义一个需要generateId()方法的接口,然后声明ShopProduct实现它:

// listing 04.32
interface IdentityObject
{
    public function generateId(): string;
}

如果我想让ShopProduct实现IdentityObject类型,我现在必须让它实现IdentityObject接口。

// listing 04.33
class ShopProduct implements IdentityObject
{
    use PriceUtilities;
    use IdentityTrait;
}

和以前一样,ShopProduct使用了IdentityTrait特征。然而,这个导入的方法generateId(),现在也实现了对IdentityObject接口的承诺。这意味着我们可以将ShopProduct对象传递给使用类型提示来要求IdentityObject实例的方法和函数,就像这样:

// listing 04.34
public static function storeIdentityObject(IdentityObject $idobj)
{
    // do something with the IdentityObject
}

管理方法名称与 insteadof 冲突

结合特质的能力是一个很好的特性,但是迟早会有冲突。例如,考虑一下如果我使用提供calculateTax()方法的两个特征会发生什么:

// listing 04.35
trait TaxTools
{
    public function calculateTax(float $price): float
    {
        return 222;
    }
}

因为我已经包含了两个包含calculateTax()方法的特征,PHP 无法确定哪个应该覆盖另一个。结果是一个致命的错误:

Fatal error: Trait method popp\ch04\batch06_3\TaxTools::calculateTax has not been applied as
popp\ch04\batch06_3\UtilityService::calculateTax, because of collision with
popp\ch04\batch06_3\PriceUtilities::calculateTax in...

为了解决这个问题,我可以使用insteadof关键字。以下是如何:

// listing 04.36
class UtilityService extends Service
{
    use PriceUtilities;
    use TaxTools {
        TaxTools::calculateTax insteadof PriceUtilities;
    }
}

为了对一个use语句应用进一步的指令,我必须首先添加一个主体。我用左大括号和右大括号来做这件事。在这个块中,我使用了insteadof操作符。这需要在左侧有一个完全限定的方法引用(即,标识特征和方法名称的方法引用,由范围解析操作符分隔)。在右边,insteadof需要特性的名称,它的等价方法应该被覆盖:

TaxTools::calculateTax insteadof PriceUtilities;

前面的代码片段意味着“使用TaxToolscalculateTax()方法,而不是PriceUtilities中的同名方法。”

所以当我运行这段代码时:

// listing 04.37
$u = new UtilityService();
print $u->calculateTax(100) . "\n";

我得到了我在TaxTools::calculateTax()中植入的虚拟输出:

222

别名覆盖特征方法

我们已经看到,您可以使用insteadof来消除方法之间的歧义。但是,如果您想访问被覆盖的方法,该怎么做呢?as操作符允许您给 trait 方法起别名。同样,as操作符需要对其左侧的方法进行完整引用。在运算符的右边,您应该输入别名的名称。例如,在这里,我用新名字basicTax()恢复了PriceUtilities特征的calculateTax()方法:

// listing 04.38
class UtilityService extends Service
{
    use PriceUtilities;
    use TaxTools {
        TaxTools::calculateTax insteadof PriceUtilities;
        PriceUtilities::calculateTax as basicTax;
    }
}

现在UtilityService类获得了两个方法:calculateTax()TaxTools版本和别名为basicTax()PriceUtilities版本。让我们运行这些方法:

// listing 04.39
$u = new UtilityService();
print $u->calculateTax(100) . "\n";
print $u->basicTax(100) . "\n";

这将产生以下输出:

222
20

所以PriceUtilities::calculateTax()作为UtilityService类的一部分以basicTax()的名字复活了。

Note

当一个方法名与特征冲突时,在use块中给其中一个方法名起别名是不够的。您必须首先使用insteadof操作符确定哪种方法取代了另一种方法。然后,您可以使用as操作符为被丢弃的方法重新指定一个新名称。

顺便提一下,在没有名称冲突的情况下,也可以使用方法名称别名。例如,您可能希望使用 trait 方法来实现在父类或接口中声明的抽象方法签名。

在 Traits 中使用静态方法

到目前为止,您看到的大多数示例都可以使用静态方法,因为它们不存储实例数据。将静态方法放在特征中并不复杂。这里,我更改了PriceUtilities::$taxrate属性和PriceUtilities::calculateTax()方法,使它们成为静态的:

// listing 04.40
trait PriceUtilities
{
    private static int $taxrate = 20;

    public static function calculateTax(float $price): float
    {
        return ((self::$taxrate / 100) * $price);
    }

    // other utilities
}

下面是UtilityService回到它的最小形式:

// listing 04.41
class UtilityService extends Service
{
    use PriceUtilities;
}

它所做的只是usePriceUtilities特性。然而,在调用calculateTax()方法时,有一个关键的区别:

// listing 04.42
print UtilityService::calculateTax(100) . "\n";

我现在必须在类上调用方法,而不是在对象上。如您所料,该脚本输出如下内容:

20

因此,静态方法在 traits 中声明,并通过主机类以正常方式访问。

访问主机类属性

您可能会认为静态方法真的是涉及特征的唯一方法。即使没有声明为静态的 trait 方法本质上也是静态的,对吗?嗯,错了,实际上你可以访问 host 类中的属性和方法:

// listing 04.43
trait PriceUtilities
{
    public function calculateTax(float $price): float
    {
        // is this good design?
        return (($this->taxrate / 100)  *  $price);
    }

    // other utilities
}

在前面的代码中,我修改了PriceUtilities特征,以便它访问其主机类中的属性。下面是一个主机——PriceUtilities——修改后声明属性:

// listing 04.44
class UtilityService extends Service
{
    use PriceUtilities;

    public $taxrate = 20;
}

如果你认为这是一个糟糕的设计,你是对的。这是一个极其糟糕的设计。虽然 trait 通过它的主机类访问数据集是有用的,但是并不要求UtilityService类实际提供一个$taxrate属性。请记住,特征应该可以跨许多不同的类使用。任何宿主类声明一个$taxrate的保证甚至可能性是什么?

另一方面,如果能够建立一个契约,从本质上说,“如果你使用这种特性,那么你必须为它提供某些资源”,那就太好了。

其实你完全可以达到这种效果。特征支持抽象方法。

在特征中定义抽象方法

您可以像在类中一样在特征中定义抽象方法。当一个类使用一个特征时,它就承担了实现它声明的任何抽象方法的义务。

Note

在 PHP 8 之前,traits 中定义的抽象方法的方法签名并不总是完全强制的。这意味着在某些情况下,实现类中的参数和返回类型可能与抽象方法声明中的不同。这个漏洞现在已经被堵住了。

有了这些知识,我可以重新实现我前面的例子,这样特征就可以强制任何使用它的类提供税率信息:

// listing 04.45
trait PriceUtilities
{
    public function calculateTax(float $price): float
    {
        // better design.. we know getTaxRate() is implemented
        return (($this->getTaxRate() / 100) * $price);
    }

    abstract public function getTaxRate(): float;
    // other utilities
}

通过在PriceUtilities特征中声明一个抽象的getTaxRate()方法,我强迫UtilityService类提供一个实现。

// listing 04.46
class UtilityService extends Service
{
    use PriceUtilities;

    public function getTaxRate(): float
    {
        return 20;
    }
}

由于 trait 中的抽象声明,如果我没有提供一个getTaxRate()方法,我就会得到一个致命错误。

更改对 Trait 方法的访问权限

当然,你可以声明一个特征方法publicprivateprotected。但是,您也可以从使用 trait 的类中更改这种访问。您已经看到了as操作符可以用来给方法名起别名。如果在这个操作符的右边使用访问修饰符,它将改变方法的访问级别,而不是它的名称。

例如,想象一下,您想在UtilityService中使用calculateTax(),但是不能让它用于实现代码。你可以这样改变use的陈述:

// listing 04.47
class UtilityService extends Service
{
    use PriceUtilities {
        PriceUtilities::calculateTax as private;
    }

    public function __construct(private float $price)
    {
    }

    public function getTaxRate(): float
    {
        return 20;
    }

    public function getFinalPrice(): float
    {
        return ($this->price + $this->calculateTax($this->price));
    }
}

我结合关键字private部署了as操作符,以便设置对calculateTax()的私有访问。这意味着我可以从getFinalPrice()访问该方法。这里有一个访问calculateTax()的外部尝试:

// listing 04.48
$u = new UtilityService(100);
print $u->calculateTax() . "\n";

不幸的是,这段代码会产生一个错误:

Error: Call to private method popp\ch04\batch06_9\UtilityService::calculateTax() from context ...

后期静态绑定:静态关键字

既然已经看到了抽象类、特征和接口,是时候简单地回到静态方法了。您看到了静态方法可以用作工厂,一种生成包含类的实例的方式。如果你像我一样是个懒惰的程序员,你可能会对这样一个例子中的重复感到恼火:

// listing 04.49
abstract class DomainObject
{

}

// listing 04.50
class User extends DomainObject
{
    public static function create(): User
    {
        return new User();
    }
}

// listing 04.51
class Document extends DomainObject
{
    public static function create(): Document
    {
        return new Document();
    }
}

我创建了一个名为DomainObject的超类。当然,在现实世界的项目中,这将包含其扩展类所共有的功能。然后我创建两个子类,UserDocument。我希望我的具体类有静态的create()方法。

Note

当构造函数已经执行了创建对象的工作时,我为什么还要使用静态工厂方法呢?在第十三章中,我将描述一种叫做身份图的模式。只有当具有相同区别特征的对象尚未被管理时,身份映射组件才生成和管理新对象。如果目标对象已经存在,则返回该对象。像create()这样的工厂方法是这类组件的好客户。

这段代码运行良好,但是有令人讨厌的重复。我不想为我创建的每个DomainObject子类创建这样的样板代码。相反,我将尝试将create()方法提升到超类:

// listing 04.52
abstract class DomainObject
{
    public static function create(): DomainObject
    {
        return new self();
    }
}

嗯,那辆看起来很整洁。我现在在一个地方有了公共代码,并且我使用了self作为对该类的引用。但是我对self关键字做了一个假设。事实上,它对类的作用与$this对对象的作用并不完全相同。self不指调用上下文;它指的是解决问题的背景。所以如果我运行前面的例子,我得到这个:

Error: Cannot instantiate abstract class
popp\ch04\batch06\DomainObject

所以self解析到DomainObject,定义create()的地方,而不是解析到Document,调用它的类。在 PHP 5.3 之前,这是一个严重的限制,导致了许多笨拙的解决方法。PHP 5.3 引入了一个叫做后期静态绑定的概念。这个特点最明显的体现就是关键词:staticstatic类似于self,只是它引用的是被调用的而不是包含类的*。在这种情况下,这意味着调用Document::create()会产生一个新的Document对象,而不是注定要实例化一个DomainObject对象。*

所以现在我可以在静态上下文中利用我的继承关系:

// listing 04.53
abstract class DomainObject
{

    public static function create(): DomainObject
    {
        return new static();
    }
}

// listing 04.54
class User extends DomainObject
{
}

// listing 04.55
class Document extends DomainObject
{
}

现在,如果我们在其中一个子类上调用create(),我们应该不会再导致错误——并获得一个与我们调用的相关的对象,而不是与包含create()的类相关的对象。

// listing 04.56
print_r(Document::create());

这是输出。

popp\ch04\batch07\Document Object
(
)

关键字static不仅可以用于实例化。与selfparent一样,static可以用作静态方法调用的标识符,即使是在非静态上下文中。假设我想在我的DomainObject课程中加入一个团队的概念。默认情况下,在我的新分类中,所有的类都属于“默认”类别,但是我希望能够为我的继承层次结构的一些分支覆盖这个类别:

// listing 04.57
abstract class DomainObject
{
    private string $group;

    public function __construct()
    {
        $this->group = static::getGroup();
    }

    public static function create(): DomainObject
    {
        return new static();
    }

    public static function getGroup(): string
    {
        return "default";
    }
}

// listing 04.58
class User extends DomainObject
{
}

// listing 04.59
class Document extends DomainObject
{
    public static function getGroup(): string
    {
        return "document";
    }
}

// listing 04.60
class SpreadSheet extends Document
{
}

// listing 04.61
print_r(User::create());
print_r(SpreadSheet::create());

我向DomainObject类引入了一个构造函数。它使用static关键字来调用一个静态方法:getGroup()DomainObject提供了默认的实现,但是Document覆盖了它。我还创建了一个新类SpreadSheet,它扩展了Document。以下是输出结果:

popp\ch04\batch07\User Object (
    [group:popp\ch04\batch07\DomainObject:private] => default
)
popp\ch04\batch07\SpreadSheet Object (
    [group:popp\ch04\batch07\DomainObject:private] => document
)

对于User类,不需要太多的聪明。DomainObject构造函数调用getGroup()并在本地找到它。不过,在SpreadSheet的情况下,搜索从被调用的类SpreadSheet本身开始。它没有提供实现,所以调用了Document类中的getGroup()方法。在 PHP 5.3 和后期静态绑定之前,我会一直使用self关键字,它只会在DomainObject类中寻找getGroup()

处理错误

事情出了差错。文件放错了位置,数据库服务器未初始化,URL 被更改,XML 文件被破坏,权限设置不当,以及超过了磁盘配额。这份名单越列越多。在预测每个问题的斗争中,一个简单的方法有时会被自己的错误处理代码压垮。

下面是一个简单的Conf类,它在 XML 配置文件中存储、检索和设置数据:

// listing 04.62
class Conf
{
    private \SimpleXMLElement $xml;
    private \SimpleXMLElement $lastmatch;

    public function __construct(private string $file)
    {
        $this->xml = simplexml_load_file($file);
    }

    public function write(): void
    {
        file_put_contents($this->file, $this->xml->asXML());
    }

    public function get(string $str): ?string
    {
        $matches = $this->xml->xpath("/conf/item[@name=\"$str\"]");
        if (count($matches)) {
            $this->lastmatch = $matches[0];
            return (string)$matches[0];
        }
        return null;
    }

    public function set(string $key, string $value): void
    {
        if (! is_null($this->get($key))) {
            $this->lastmatch[0] = $value;
            return;
        }
        $conf = $this->xml->conf;
        $this->xml->addChild('item', $value)->addAttribute('name', $key);
    }
}

Conf类使用SimpleXml扩展来访问名称/值对。以下是它的设计工作格式:

<?xml version="1.0" ?>
<conf>
    <item name="user">bob</item>
    <item name="pass">newpass</item>
    <item name="host">localhost</item>
</conf>

Conf类的构造函数接受一个文件路径,并将其传递给simplexml_load_file()。它将生成的SimpleXmlElement对象存储在一个名为$xml的属性中。get()方法使用 XPath 定位具有给定name属性的item元素,并返回其值。set()更改现有项目的值或创建一个新项目。最后,write()方法将新的配置数据保存回文件。

像许多示例代码一样,Conf类被高度简化了。特别是,它没有处理不存在或不可写文件的策略。它的前景也是乐观的。它假设 XML 文档是格式良好的,并且包含预期的元素。

测试这些错误条件相对来说是微不足道的,但是我仍然必须决定在它们出现时如何响应它们。一般有两种选择。

首先,我可以结束死刑。这很简单,但是很激烈。然后,我的卑微的类将负责带来一个围绕它崩溃的整个脚本。虽然像__construct()write()这样的方法可以很好地检测错误,但是它们没有决定如何处理错误的信息。

我可以返回某种错误标志,而不是在我的类中处理错误。这可以是布尔值或整数值,如0-1。有些类还会设置一个错误字符串或标志,这样客户端代码就可以在失败后请求更多信息。

许多 PEAR 包通过返回一个错误对象(一个PEAR_Error的实例)将这两种方法结合起来,该对象作为一个错误发生的通知,并包含错误消息。这种方法现在被否决了,但是很多类还没有升级,尤其是因为客户端代码经常依赖于旧的行为。

这里的问题是你污染了你的返回值。每次调用容易出错的方法时,您都必须依靠客户端编码器来测试返回类型。这可能有风险。不要相信任何人!

当您向调用代码返回一个错误值时,不能保证客户端会比您的方法更好地决定如何处理错误。如果是这样,那么问题又重新开始了。客户端方法必须确定如何响应错误条件,甚至可能实现不同的错误报告策略。

例外

PHP 5 引入了 PHP 异常,这是一种完全不同的处理错误情况的方式。这对于 PHP 来说是不同的。如果你有 Java 或 C++的经验,你会发现它们非常熟悉。异常解决了我在本节中提出的所有问题。

例外是从内置Exception类(或从派生类)实例化的特殊对象。

类型为Exception的对象被设计用来保存和报告错误信息。

Exception类构造函数接受两个可选参数,一个消息字符串和一个错误代码。该类提供了一些用于分析错误条件的有用方法。这些在表 4-1 中描述。

表 4-1

异常类的公共方法

|

方法

|

描述

| | --- | --- | | getMessage() | 获取传递给构造函数的消息字符串 | | getCode() | 获取传递给构造函数的代码整数 | | getFile() | 获取生成异常的文件 | | getLine() | 获取生成异常的行号 | | getPrevious() | 获取嵌套的异常对象 | | getTrace() | 获取跟踪导致异常的方法调用的多维数组,包括方法、类、文件和参数数据 | | getTraceAsString() | 获取由getTrace()返回的数据的字符串版本 | | __toString() | 在字符串上下文中使用Exception对象时自动调用。返回描述异常详细信息的字符串 |

Exception类对于提供错误通知和调试信息非常有用(在这方面,getTrace()getTraceAsString()方法特别有用)。其实和之前讨论的PEAR_Error级几乎一模一样。但是,异常不仅仅包含信息。

引发异常

throw关键字与Exception对象结合使用。它停止当前方法的执行,并将处理错误的责任传递回调用代码。这里,我修改了__construct()方法,使用了throw语句:

// listing 04.63
public function __construct(private string $file)
{
    if (! file_exists($file)) {
        throw new \Exception("file '{$file}' does not exist");
    }
    $this->xml = simplexml_load_file($file);
}

write()方法可以使用类似的构造:

// listing 04.64
public function write(): void
{
    if (! is_writeable($this->file)) {
        throw new \Exception("file '{$this->file}' is not writeable");
    }
    print "{$this->file} is apparently writeable\n";
    file_put_contents($this->file, $this->xml->asXML());
}

// listing 04.65
try {
    $conf = new Conf("/tmp/conf01.xml");
    //$conf = new Conf( "/root/unwriteable.xml" );
    //$conf = new Conf( "nonexistent/not_there.xml" );
    print "user: " . $conf->get('user') . "\n";
    print "host: " . $conf->get('host') . "\n";
    $conf->set("pass", "newpass");
    $conf->write();
} catch (\Exception $e) {
    // handle error in some way
}

如您所见,catch块表面上类似于一个方法声明。当抛出异常时,调用范围内的catch块被调用。Exception对象作为参数变量被自动传入。

正如在抛出异常时抛出方法中的执行被暂停一样,在try块中也是如此——控制直接传递给catch块。在那里,您可以执行任何可用的错误恢复任务。如果可以,避免依靠die声明。通过调用die,您使得测试更加困难,并且可能阻止系统中的其他代码执行必要的清理操作。如果无法从错误中恢复,您总是可以引发新的异常:

// listing 04.66
} catch (\Exception $e) {
    // handle error in some way
    // or
    throw new \Exception("Conf error: " . $e->getMessage());
}

或者,您也可以重新抛出已经给出的异常:

// listing 04.67
try {
    $conf = new Conf("nonexistent/not_there.xml");
} catch (\Exception $e) {
    // handle error...
    // or rethrow
    throw $e;
}

如果您在错误处理中不需要Exception对象本身,那么从 PHP 8 开始,您可以完全省略异常参数,只需指定类型:

// listing 04.68
try {
    $conf = new Conf("nonexistent/not_there.xml");
} catch (\Exception) {
    // handle error without using the Exception object
}

子类化异常

您可以创建扩展Exception类的类,就像您创建任何用户定义的类一样。有两个原因可以解释你为什么想这么做。首先,您可以扩展该类的功能。其次,派生类定义了一个新的类类型这一事实本身可以帮助错误处理。

事实上,您可以为一个try语句定义任意多的catch块。被调用的特定的catch块将取决于抛出的异常的类型和参数列表中的类类型提示。下面是一些扩展了Exception的简单类:

// listing 04.69
class XmlException extends \Exception
{
    public function construct(private \LibXmlError $error)
    {
        $shortfile = basename($error->file);
        $msg = "[{$shortfile}, line {$error->line}, col {$error->column}] {$error->message}";
        $this->error = $error;
        parent:: __construct($msg, $error->code);
    }

    public function getLibXmlError(): \LibXmlError
    {
        return $this->error;
    }
}

// listing 04.70
class FileException extends \Exception
{
}

// listing 04.71
class ConfException extends \Exception
{
}

SimpleXml遇到一个损坏的 XML 文件时,LibXmlError类在后台生成。它有$message$code属性,类似于Exception类。我利用了这种相似性,在XmlException类中使用了LibXmlError对象。FileExceptionConfException类只做子类Exception的事情。我现在可以在我的代码中使用这些类并修改construct()write():

// listing 04.72

// Conf class...

    public function __construct(private string $file)
    {
        if (! file_exists($file)) {
            throw new FileException("file '$file' does not exist");
        }
        $this->xml = simplexml_load_file($file, null, LIBXML_NOERROR);
        if (! is_object($this->xml)) {
            throw new XmlException(libxml_get_last_error());
        }
        $matches = $this->xml->xpath("/conf");
        if (! count($matches)) {
            throw new ConfException("could not find root element: conf");
        }
    }

    public function write(): void
    {
        if (! is_writeable($this->file)) {
            throw new FileException("file '{$this->file}' is not writeable");
        }
        file_put_contents($this->file, $this->xml->asXML());
    }

__construct()抛出一个XmlExceptionFileExceptionConfException,这取决于它遇到的错误类型。注意,我将选项标志LIBXML_NOERROR传递给simplexml_load_file()。这抑制了警告,让我可以在事后用我的XmlException类自由处理它们。如果我遇到一个格式错误的 XML 文件,我知道发生了错误,因为simplexml_load_file()不会返回一个对象。然后,我可以使用libxml_get_last_error()来访问错误。

如果$file属性指向一个不可写的实体,write()方法抛出一个FileException

因此,我已经确定__construct()可能抛出三种可能的异常之一。我该如何利用这一点呢?下面是实例化一个Conf对象的一些代码:

// listing 04.73
class Runner
{
    public static function init()
    {
        try {
            $conf = new Conf(__DIR__ . "/conf.broken.xml");
            print "user: " . $conf->get('user') . "\n";
            print "host: " . $conf->get('host') . "\n";
            $conf->set("pass", "newpass");
            $conf->write();
        } catch (FileException $e) {
            // permissions issue or non-existent file throw $e;
        } catch (XmlException $e) {
            // broken xml
        }  catch (ConfException $e) {
            // wrong kind of XML  file
        } catch (\Exception $e) {
            // backstop: should not be called
        }
    }
}

我为每个类类型提供了一个catch块。调用的块取决于抛出的异常类型。将执行第一个匹配的类型,所以记住将最通用的类型放在最后,将最专用的类型放在开始。例如,如果您将Exceptioncatch块放在XmlExceptionConfException的块之前,这两个块都不会被调用。这是因为这两个类都属于Exception类型,因此会匹配第一个测试。

如果配置文件有问题(如果文件不存在或不可写),则调用第一个catch块(FileException)。如果在解析 XML 文件时出现错误(例如,如果一个元素没有关闭),那么调用第二个块(XmlException)。如果一个有效的 XML 文件不包含预期的根conf元素,则调用第三个块(ConfException)。不应该到达最后一个块(Exception),因为我的方法只生成三个异常,这三个异常是显式处理的。拥有这样的“backstop”块通常是个好主意,以防在开发过程中向代码添加新的异常。

Note

如果您确实提供了一个“逆止”catch 块,您应该确保在大多数情况下确实对异常做了一些事情——静默失败会导致难以诊断的错误。

这些细粒度的catch块的好处是,它们允许您对不同的错误应用不同的恢复或失败机制。例如,您可能决定结束执行,记录错误并继续,或者显式地再次引发错误。

这里你可以玩的另一个技巧是抛出一个新的异常来包装当前的异常。这允许您声明错误并添加您自己的上下文信息,同时保留由您捕获的异常封装的数据。你可以在第十五章中读到更多关于这项技术的内容。

那么,如果客户端代码没有捕捉到异常,会发生什么呢?它被隐式地重新抛出,客户端自己的调用代码有机会捕获它。这个过程会一直继续,直到异常被捕获或者不再被抛出。此时,会出现致命错误。如果我没有捕捉到示例中的一个异常,将会发生以下情况:

PHP Fatal error: Uncaught exception 'FileException' with message

'file 'nonexistent/not_there.xml' does not exist' in ...

所以,当你抛出一个异常时,你就迫使客户端负责处理它。这不是放弃责任。当方法检测到错误,但没有上下文信息来智能地处理它时,应该抛出异常。我的例子中的write()方法知道写操作何时会失败,它知道失败的原因,但是不知道该怎么办。这是应该的。如果我让Conf类比现在更加知识化,它会失去焦点,变得不那么可重用。

用 finally 清除 try/catch 块后

异常影响代码流的方式可能会导致意想不到的问题。例如,在try块中产生异常后,可能不会执行清理代码或其他必要的内务处理。正如您所看到的,如果一个异常是在一个try块中生成的,那么流程会直接转移到相关的catch块。关闭数据库连接或文件句柄的代码可能不会被调用,状态信息可能不会被更新。

例如,想象一下,Runner::init()记录了它的行为。它记录初始化过程的开始、遇到的任何错误,然后记录初始化过程的结束。这里,我提供了这种日志记录的典型简化示例:

// listing 04.74
public static function init(): void
{
    try {
        $fh = fopen("/tmp/log.txt", "a"); fputs($fh, "start\n");
        $conf = new Conf(dirname( FILE ) . "/conf.broken.xml");
        print "user: " . $conf->get('user') . "\n";
        print "host: " . $conf->get('host') . "\n";
        $conf->set("pass", "newpass");
        $conf->write();
        fputs($fh, "end\n");
        fclose($fh);
    } catch (FileException $e) {
        // permissions issue or non-existent file
        fputs($fh, "file exception\n");
        throw $e;
    } catch (XmlException $e) {
        fputs($fh, "xml exception\n");
        // broken xml
    } catch (ConfException $e) {
        fputs($fh, "conf exception\n");
        // wrong kind of XML file
    } catch (\Exception $e) {
        fputs($fh, "general  exception\n");
        // backstop: should not be called
    }
}

我打开一个文件,log.txt;我给它写信;然后我调用我的配置代码。如果在这个过程中遇到异常,我会将这个事实记录在相关的catch块中。我通过写入日志并关闭其文件句柄来结束try块。

当然,如果遇到异常,这最后一步将永远不会到达。流程直接传递到相关的catch块,而try块的其余部分从不运行。以下是生成 XML 异常时的日志输出:

start
xml exception

如您所见,日志记录开始了,并且记录了文件异常,但是记录日志记录结束的那部分代码从未到达,因此日志没有更新。

您可能认为解决方案是将最后的日志记录步骤完全放在try / catch块之外。这不会可靠地工作。如果捕获到一个生成的异常,并且try块允许继续执行,那么流程将移动到try / catch构造之外。然而,catch块可能会再次抛出异常,或者完全结束脚本执行。

为了帮助程序员处理类似这样的问题,PHP 5.5 引入了一个新的关键字:finally。如果你熟悉 Java,你可能以前见过这个。尽管当抛出匹配异常时,catch块只是有条件地运行,但是无论try块中是否产生异常,finally块总是运行的。

我可以通过将我的日志写和代码移动到靠近一个finally块来解决这个问题:

// listing 04.75
public static function init2(): void
{
    $fh = fopen("/tmp/log.txt", "a");
    try {
        fputs($fh, "start\n");
        $conf = new Conf(dirname( FILE ) . "/conf.not-there.xml");
        print "user: " . $conf->get('user') . "\n";
        print "host: " . $conf->get('host') . "\n";
        $conf->set("pass", "newpass");
        $conf->write();
    } catch (FileException $e) {
        // permissions issue or non-existent file
        fputs($fh, "file exception\n");
        //throw $e;
    } catch (XmlException $e) {
        fputs($fh, "xml exception\n");
        // broken xml
    } catch (ConfException $e) {
        fputs($fh, "conf exception\n");
        // wrong kind of XML file
    } catch (Exception $e) {
        fputs($fh, "general exception\n");
        // backstop: should not be called
    }  finally  {
        fputs($fh, "end\n");
        fclose($fh);
    }
}

因为日志写入和fclose()调用被包装在一个finally块中,所以即使这些语句被运行,就像在一个FileException被捕获的情况下,异常被重新抛出。

以下是生成FileException时的日志文本:

start
file exception
end

Note

如果被调用的 catch 块再次抛出一个异常或返回值,那么就会运行一个finally块。但是,调用trycatch块中的die()exit()将结束脚本执行,并且finally块将不会运行。

最终类和方法

继承允许类层次结构中的巨大灵活性。您可以重写一个类或方法,以便客户端方法中的调用将获得完全不同的效果,这取决于它所传递的是哪个类实例。然而,有时一个类或方法应该保持固定不变。如果您已经为您的类或方法实现了明确的功能,并且您觉得覆盖它只会损害您工作的最终完美性,那么您可能需要final关键字。

停止继承。final 类不能被子类化。不太明显的是,final 方法不能被重写。

这里有一个final类:

// listing 04.76
final class Checkout
{
    // ...
}

这里尝试对Checkout类进行子类化:

// listing 04.77
class IllegalCheckout extends Checkout
{
    // ...
}

这会产生一个错误:

Fatal error: Class popp\ch04\batch13\IllegalCheckout may not inherit
from final class (popp\ch04\batch13\Checkout) in ...

通过在Checkout final 中声明一个方法,而不是在整个类中声明,我可以稍微放松一下。关键字final应该放在任何其他修饰语如protectedstatic之前,就像这样:

// listing 04.78
class Checkout
{
    final public function totalize(): void
    {
        // calculate bill
    }
}

我现在可以子类化Checkout,但是任何覆盖totalize()的尝试都会导致致命错误:

// listing 04.79
class IllegalCheckout extends Checkout
{
    final public function totalize(): void
    {
        // change bill calculation
    }
}

Fatal error: Cannot override final method popp\ch04\batch14\Checkout::totalize() in /var/popp/src/ch04/batch14/IllegalCheckout.php on line 9

好的面向对象代码倾向于强调定义良好的接口。然而,在接口背后,实现往往会有所不同。不同的类或类的组合符合公共接口,但在不同的环境中表现不同。通过将类或方法声明为 final,您限制了这种灵活性。有时候这是可取的,在本书的后面你会看到一些。然而,在宣布某件事是最终决定之前,你应该仔细考虑。真的没有覆盖有用的情况吗?当然,你可以随时改变你的想法,但是如果你发布一个库给其他人使用,这就不那么容易了。小心使用final

内部错误类

当异常第一次被引入时,尝试和捕捉的世界主要应用于用 PHP 编写的代码,而不是核心引擎。内部产生的错误保持着自己的逻辑。如果您想以与代码生成异常相同的方式管理核心错误,这可能会变得很麻烦。PHP 7 已经开始用Error类来解决这个问题。这实现了Throwable——与Exception类实现了相同的内置接口,因此可以用相同的方式对待它。这也意味着表 4-1 中描述的方法被采用。Error是针对单个错误类型的子类。下面是如何捕捉由eval语句生成的解析错误:

// listing 04.80
try {
    eval("illegal code");
} catch (\Error $e) {
    print get_class($e) . "\n";
    print $e->getMessage();
} catch (\Exception $e) {
    // do something with an Exception
}

以下是输出结果:

ParseError
syntax error, unexpected identifier "code"

因此,您可以通过指定Error超类或指定更具体的子类来匹配catch块中的某些类型的内部错误。表 4-2 显示了当前的Error子类。

表 4-2

PHP 7 引入的内置错误类

|

错误

|

描述

| | --- | --- | | ArgumentCountError | 当传递给用户定义的方法或函数的参数太少时抛出 | | ArithmeticError | 引发与数学相关的错误,尤其是与位算术相关的错误 | | AssertionError | 当assert()语言构造(用于调试)失败时抛出 | | CompileError | 当 PHP 代码格式错误,无法编译运行时抛出 | | DivisionByZeroError | 当试图将一个数除以零时抛出 | | ParseError | 当运行时尝试解析 PHP(例如,使用eval())失败时抛出 | | TypeError | 当将错误类型的参数传递给方法、方法返回错误类型的值或传递给方法的参数数量不正确时引发 |

使用拦截器

PHP 提供了内置的拦截器方法,可以拦截发送给未定义的方法和属性的消息。这也被称为重载,但是由于这个术语在 Java 和 C++中的意思完全不同,我认为更好的说法是拦截。

PHP 支持各种内置的拦截器或“神奇”的方法。像__construct()一样,当合适的条件被满足时,它们会被调用。表 4-3 描述了其中的一些方法。

表 4-3

拦截器方法

|

方法

|

描述

| | --- | --- | | __get($property) | 当访问未定义的属性时调用 | | __set($property, $value) | 将值赋给未定义的属性时调用 | | __isset($property) | 在未定义的属性上调用isset()时调用 | | __unset($property) | 在未定义的属性上调用unset()时调用 | | __call($method, $arg_array) | 当调用未定义的非静态方法时调用 | | __callStatic($method, $arg_array) | 当调用未定义的静态方法时调用 |

Note

你可以在 PHP 手册页阅读更多关于拦截器或魔法方法的内容: www.php.net/manual/en/language.oop5.magic.php

__get()__set()方法是为处理没有在类(或其父类)中声明的属性而设计的。

当客户端代码试图读取未声明的属性时,调用__get()。它是用包含客户端试图访问的属性名称的单个字符串参数自动调用的。从__get()方法返回的任何内容都将被发送回客户端,就好像目标属性以该值存在一样。这里有一个简单的例子:

// listing 04.81
class Person
{
    public function __get(string $property): mixed
    {
        $method = "get{$property}";
        if (method_exists($this, $method)) {
            return $this->$method();
        }

    }

    public function getName(): string
    {
        return "Bob";
    }

    public function getAge(): int
    {
        return 44;
    }
}

当客户端试图访问一个未定义的属性时,就会调用__get()方法。我已经实现了__get()来获取属性名并构造一个新字符串,在前面加上单词“get”。我将这个字符串传递给一个名为method_exists()的函数,它接受一个对象和一个方法名,并测试方法是否存在。如果这个方法确实存在,我就调用它并将它的返回值传递给客户机。假设客户请求一个$name属性:

// listing 04.82
$p = new Person();
print $p->name;

在这种情况下,在后台调用getName()方法:

Bob

如果方法不存在,我什么也不做。用户试图访问的属性将解析为 null。

__isset()方法的工作方式与__get()相似。它在客户端对未定义的属性调用isset()后被调用。下面是我如何扩展Person:

// listing 04.83
public function __isset(string $property): bool
{
    $method = "get{$property}";
    return (method_exists($this, $method));
}

现在,谨慎的用户可以在使用属性之前对其进行测试:

// listing 04.84
$p = new Person();
if (isset($p->name)) {
    print $p->name;
}

当客户端代码试图给一个未定义的属性赋值时,调用__set()方法。传递给它两个参数:属性的名称和客户端试图设置的值。然后,您可以决定如何处理这些参数。在这里,我进一步修改了Person类:

// listing 04.85
class Person
{
    private ?string $myname;
    private ?int $myage;

    public function __set(string $property, mixed $value): void
    {
        $method = "set{$property}";
        if (method_exists($this, $method)) {
            $this->$method($value);
        }
    }

    public function setName(?string $name): void
    {
        $this->myname = $name;
        if (! is_null($name)) {
            $this->myname = strtoupper($this->myname);
        }
    }

    public function setAge(?int $age): void
    {
        $this->myage = $age;
    }
}

在这个例子中,我使用“setter”方法,而不是“getter”如果用户试图给一个未定义的属性赋值,那么就用属性名和赋值调用__set()方法。我测试适当的方法是否存在,如果存在就调用它。这样,我就可以过滤赋值了。

Note

请记住,PHP 文档中的方法和属性经常以静态术语的形式出现,以便用它们的类来标识它们。所以你可能会谈到Person::$name属性,即使该属性没有被声明为static,并且实际上是通过一个对象来访问的。

因此,如果我创建一个Person对象,然后试图设置一个名为Person::$name的属性,就会调用__set()方法,因为这个类没有定义$name属性。向该方法传递字符串"name"和客户端分配的值。然后如何使用该值取决于__set()的实现。在这个例子中,我用属性参数结合字符串"set"构造了一个方法名。找到并适时调用setName()方法。这将转换传入的值并将其存储在不动产中:

// listing 04.86
$p = new Person();
$p->name = "bob";
// the $myname property becomes 'BOB'

如你所料,__unset()镜像__set()。当unset()在一个未定义的属性上被调用时,__unset()以属性的名称被调用。然后你可以用这些信息做你想做的事情。这个例子将null传递给一个方法,该方法使用了与__set()相同的技术进行解析:

// listing 04.87
public function __unset(string $property): void
{
    $method = "set{$property}";
    if (method_exists($this, $method)) {
        $this->$method(null);
    }
}

__call()方法可能是所有拦截器方法中最有用的。当客户端代码调用未定义的方法时,它被调用。用方法名和一个保存客户端传递的所有参数的数组调用__call()。从__call()方法返回的任何值都被返回给客户端,就像是被调用的方法返回的一样。

__call()方法对委托很有用。委托是一个对象将方法调用传递给第二个对象的机制。它类似于继承,因为子类将方法调用传递给它的父实现。通过继承,子对象和父对象之间的关系是固定的,因此在运行时切换接收对象的能力意味着委托比继承更灵活。一个例子可以稍微澄清一些事情。下面是一个简单的类,用于格式化来自Person类的信息:

// listing 04.88
class PersonWriter
{

    public function writeName(Person $p): void
    {
        print $p->getName() . "\n";
    }

    public function writeAge(Person $p): void
    {
        print $p->getAge() . "\n";
    }
}

当然,我可以对其进行子类化,以各种方式输出Person数据。下面是一个使用了PersonWriter对象和__call()方法的Person类的实现:

// listing 04.89
class Person
{

    public function __construct(private PersonWriter $writer)
    {
    }

    public function __call(string $method, array $args): mixed
    {
    if (method_exists($this->writer, $method)) {
        return $this->writer->$method($this);
    }
}

    public function getName(): string
    {
        return "Bob";
    }

    public function getAge(): int
    {
        return 44;
    }
}

这里的Person类需要一个PersonWriter对象作为构造函数参数,并将其存储在一个属性变量中。在__call()方法中,我使用提供的$method参数,在我存储的PersonWriter对象中测试同名的方法。如果我遇到这样的方法,我将方法调用委托给PersonWriter对象,将我的当前实例传递给它(在$this伪变量中)。考虑如果客户端调用Person会发生什么:

// listing 04.90
$person = new Person(new PersonWriter());
$person->writeName();

在这种情况下,调用__call()方法。我在我的PersonWriter对象中找到一个名为writeName()的方法并调用它。这样我就不用像这样手动调用委托方法了:

// listing 04.91
public function writeName(): void
{
    $this->writer->writeName($this);
}

使用拦截器方法,Person类神奇地获得了两个新方法。尽管自动委托可以节省大量的跑腿工作,但在清晰度方面可能会有成本。如果你过于依赖委托,那么你给世界呈现的是一个抵制反射(类方面的运行时检查)的动态接口,并且对客户端编码人员来说乍一看并不总是清晰的。这是因为控制委托类和它的目标之间的交互的逻辑可能是模糊的——隐藏在像__call()这样的方法中,而不是像类似的关系那样,通过继承关系或方法类型提示提前发出信号。拦截器方法有它们的位置,但是应该小心使用,依赖它们的类应该非常清楚地记录这个事实。

我将在本书的后面回到委派和反思的主题。

__get()__set()拦截器方法也可以用来管理复合属性。这对客户端程序员来说是一种便利。例如,想象一个管理门牌号和街道名称的Address类。最终,该对象数据将被写入数据库字段,因此将号码和街道分开是明智的。但是,如果门牌号和街道名称通常是不加区分地获得的,那么您可能想要帮助该类的用户。下面是一个管理复合属性的类,Address::$streetaddress:

// listing 04.92
class Address
{
    private string $number;
    private string $street;

    public function __construct(string $maybenumber, string $maybestreet = null)
    {
        if (is_null($maybestreet)) {
            $this->streetaddress = $maybenumber;
        } else {
            $this->number = $maybenumber;
            $this->street = $maybestreet;
        }
    }

    public function __set(string $property, mixed $value): void
    {
        if ($property === "streetaddress") {
            if (preg_match("/^(\d+.*?)[\s,]+(.+)$/", $value, $matches)) {
                $this->number = $matches[1];
                $this->street = $matches[2];
            } else {
                throw new \Exception("unable to parse street address: '{$value}'");
            }
        }
    }

    public function __get(string $property): mixed
    {
        if ($property === "streetaddress") {
            return $this->number . " " . $this->street;
        }
    }
}

当用户试图设置(不存在)Address::$streetaddress属性(通过类构造函数)时,拦截器方法__set()被调用。在那里,我测试了财产名称,streetaddress。在设置$number$street属性之前,我必须首先确保所提供的值可以被解析,然后继续提取字段。对于这个例子,我设置了简单的规则。如果地址以数字开头,并且在第二部分前面有空格或逗号,则可以解析该地址。多亏了反向引用,如果检查通过,我已经在$matches数组中有了我要找的数据,并且我给$number$street属性赋值。如果解析失败,我抛出一个异常。所以当一个字符串如441b Bakers Street被分配给Address::$streetaddress时,实际上是$number$street属性被填充。我可以用print_r()来证明这一点:

// listing 04.93
$address = new Address("441b Bakers Street"); print_r($address);

popp\ch04\batch16\Address Object
(
    [number:popp\ch04\batch16\Address:private] => 441b
    [street:popp\ch04\batch16\Address:private] => Bakers Street
)

当然,__get()方法要简单得多。每当访问Address::$streetaddress属性时,就会调用__get()。在这个拦截器的实现中,我测试了streetaddress,如果发现匹配,我将返回$number$street属性的串联。

Note

当客户端试图访问不可访问的方法或属性(即设置为privateprotected的方法或属性,因此对调用上下文隐藏)时,__get()__set()__call()也会被自动调用。

定义析构函数方法

您已经看到了在实例化一个对象时会自动调用__construct()方法。PHP 5 还引入了__destruct()方法。这在对象被垃圾收集之前被调用;也就是说,在它被从记忆中抹去之前。您可以使用此方法执行任何可能需要的最终清理。

想象一下,例如,一个类在排序时将自己保存到数据库中。我可以使用__destruct()方法来确保一个实例在被删除时保存它的数据:

// listing 04.94
class Person
{
    private int $id;

    public function __construct(protected string $name, private int $age)
    {
        $this->name = $name;
        $this->age  = $age;
    }

    public function setId(int $id): void
    {
        $this->id = $id;
    }

    public function __destruct()
    {
        if (! empty($this->id)) {
            // save Person data
            print "saving person\n";
        }
    }
}

每当您在一个对象上调用unset()函数时,或者当流程中不再存在对该对象的引用时,就会调用__destruct()方法。因此,如果我创建并销毁一个Person对象,您可以看到__destruct()方法开始发挥作用:

// listing 04.95
$person = new Person("bob", 44);
$person->setId(343);
unset($person);

以下是输出:

saving person

虽然像这样的把戏很有趣,但值得警惕。__call()__destruct()和他们的同事有时被称为神功。如果你读过奇幻小说,你就会知道,魔法并不总是一件好事。魔法是任意的,意想不到的。魔法扭曲了规则。魔法会产生隐性成本。

例如,在__destruct()的案例中,你可能最终会给客户带来不受欢迎的惊喜。考虑一下Person类——它在其__destruct()方法中执行数据库写操作。现在想象一个开发新手无所事事地测试Person类。他没有发现__destruct()方法,他开始实例化一组Person对象。将值传递给构造函数,他将 CEO 的秘密且有点猥亵的昵称分配给属性$name,然后将$age设置为 150。他运行了几次测试脚本,尝试不同的姓名和年龄组合。

第二天早上,他的经理让他去会议室解释为什么数据库里有侮辱性的数据。寓意呢?不要相信魔法。

使用 __clone()复制对象

在 PHP 4 中,复制一个对象只是简单地将一个变量赋值给另一个变量:

// listing 04.96
class CopyMe
{
}

// listing 04.97

$first = new CopyMe();
$second = $first;

// PHP 4: $second and $first are 2 distinct objects
// PHP 5 plus: $second and $first refer to one object

这种“简单的事情”是许多错误的来源,因为当变量被赋值、方法被调用和对象被返回时,对象副本被意外地产生。更糟糕的是,没有办法测试两个变量来确定它们是否指向同一个对象。等价测试会告诉你是否所有的字段都是相同的(==)或者两个变量都是对象(===),但是不会告诉你它们是否指向同一个对象。

在 PHP 中,似乎包含一个对象的变量实际上包含一个引用底层数据结构的标识符。当这样的变量被赋值或传递给一个方法时,它包含的标识符被复制。但是,每个副本继续指向同一个对象。这意味着,在我之前的例子中,$first$second包含指向同一个对象的标识符,而不是该对象的两个副本。虽然这通常是您在处理对象时想要的,但有时您需要获得对象的副本。

PHP 为此提供了关键字cloneclone对一个对象实例进行操作,产生一个按值复制:

// listing 04.98

$first = new CopyMe();
$second = clone $first;

// PHP 5 plus: $second and $first are 2 distinct objects

围绕对象复制的问题仅仅从这里开始。考虑我在上一节中实现的Person类。一个Person对象的默认副本将包含标识符($id属性),在一个完整的实现中,我将使用它来定位数据库中的正确行。如果我允许复制这个属性,客户端编码人员可能会以两个不同的对象表示同一个数据实体(数据库行)而告终,这可能不是她在复制时想要的。

幸运的是,当在对象上调用clone时,您可以控制复制什么。您可以通过实现一个名为__clone()的特殊方法来实现这一点(注意前面的两个下划线是魔术方法的特征)。当在一个对象上调用clone关键字时,会自动调用__clone()

当你实现__clone()时,理解方法运行的环境是很重要的。__clone()是在上运行的复制的对象而不是原来的。在这里,我将__clone()添加到Person类的另一个版本中:

// listing 04.99
class Person
{
    private int $id = 0;

    public function __construct(private string $name, private $age)
    {
    }

    public function setId(int $id): void
    {
        $this->id = $id;
    }

    public function __clone(): void
    {
        $this->id = 0;
    }
}

当在一个Person对象上调用clone时,产生一个新的浅拷贝,并且调用它的 __clone()方法。这意味着我在__clone()中做的任何事情都会覆盖我已经创建的默认副本。在这种情况下,我确保复制对象的$id属性设置为零:

// listing 04.100
$person = new Person("bob", 44);
$person->setId(343);
$person2 = clone $person;

浅层复制确保原始属性从旧对象复制到新对象。对象属性复制了它们的标识符,但没有复制它们的底层数据,这可能不是您在克隆对象时想要或期望的。假设我给了Person对象一个Account对象属性。这个对象有一个余额,我想把它复制到克隆的对象中。但是,我不想让两个Person对象都持有对同一个账户的引用:

// listing 04.101
class Account
{
    public function __construct(public float $balance)
    {
    }
}

// listing 04.102
class Person
{
    private int $id;

    public function __construct(private string $name, private int $age, public Account $account)
    {
    }

    public function setId(int $id): void
    {
        $this->id = $id;
    }

    public function __clone(): void
    {
        $this->id = 0;
    }
}

// listing 04.103
$person = new Person("bob", 44, new Account(200));
$person->setId(343);
$person2 = clone $person;

// give $person some money
$person->account->balance += 10;
// $person2 sees the credit too
print $person2->account->balance;

这将产生以下输出:

210

$person保存了对一个Account对象的引用,为了简洁起见,我将该对象保持为可公开访问的(如您所知,我通常会限制对一个属性的访问,如有必要,提供一个访问器方法)。当克隆被创建时,它保存了一个对$person引用的同一个Account对象的引用。我通过添加到$person对象的Account并通过$person2确认增加的平衡来演示这一点。

如果我不想在克隆操作后共享一个对象属性,那么由我来决定在__clone()方法中显式克隆它:

// listing 04.104
public function __clone(): void
{
    $this->id = 0;
    $this->account = clone $this->account;
}

为对象定义字符串值

PHP 5 引入的另一个受 Java 启发的特性是__toString()方法。在 PHP 5.2 之前,当您打印一个对象时,它会解析为如下所示的字符串:

// listing 04.105
class StringThing
{
}

// listing 04.106
$st = new StringThing();
print $st;

从 PHP 5.2 开始,这段代码会产生这样的错误:

Object of class popp\ch04\batch22\StringThing could not be converted to string ...

通过实现一个__toString()方法,您可以控制对象在字符串上下文中被访问时如何表示它们自己(或者显式地转换为字符串)。__toString()应该写成返回一个字符串值。当您的对象被传递给printecho时,该方法被自动调用,其返回值被替换。在这里,我给一个最小的Person类添加了一个__toString()版本:

// listing 04.107
class Person
{
    public function getName(): string
    {
        return "Bob";
    }

    public function getAge(): int
    {
        return 44;
    }

    public function __toString(): string
    {
        $desc  = $this->getName() . " (age ";
        $desc .= $this->getAge() . ")"; return $desc;
    }
}

现在,当我打印一个Person对象时,该对象将解析为:

// listing 04.108
$person = new Person();
print $person;

Bob (age 44)

对于日志记录和错误报告,以及主要任务是传递信息的类来说,__toString()方法特别有用。例如,Exception类在其__toString()方法中汇总异常数据。

从 PHP 8 开始,任何实现了__toString()方法的类都被隐式声明为实现了内置的Stringable接口。这意味着您可以使用联合类型声明来约束参数和属性。这里有一个例子:

// listing 04.109
public static function printThing(string|\Stringable $str): void
{
    print $str;
}

我们可以将一个字符串我们的Person对象传递给printThing()方法,它会很高兴地接受任何一个,并确信它可以以任何它选择的类似字符串的方式处理我们传递的任何内容。

回调、匿名函数和闭包

虽然严格来说,匿名函数不是面向对象的特性,但在这里提到它还是很有用的,因为在利用回调的面向对象应用中,您可能会遇到它们。

Note

一个回调是一个可执行代码块,它可以存储在一个变量中,或者传递给方法和函数供以后调用。

首先,这里有几个类:

// listing 04.110
class Product
{
    public function __construct(public string $name, public float $price)
    {
    }
}

// listing 04.111
class ProcessSale
{
    private array $callbacks;

    public function registerCallback(callable $callback): void
    {
        $this->callbacks[] = $callback;
    }

    public function sale(Product $product): void
    {
        print "{$product->name}: processing \n";
        foreach ($this->callbacks as $callback) {
            call_user_func($callback, $product);
        }
    }
}

这段代码是为运行我的各种回调而设计的。它由两类组成,ProductProcessSaleProduct只是简单的存储$name$price的属性。为了简洁起见,我公开了这些内容。请记住,在现实世界中,您可能希望使您的属性成为私有的或受保护的,并在必要时提供访问器方法。

ProcessSale由两种方法组成。第一个函数registerCallback()接受一个callable类型,并将其添加到$callbacks数组属性中。第二个方法,sale(),接受一个Product对象,输出一条关于它的消息,然后遍历$callbacks数组属性。

它将每个元素传递给call_user_func(),后者调用代码,传递给它一个对产品的引用。下面所有的例子都将适用于这个框架。

为什么回调有用?它们允许您在运行时将与组件核心任务不直接相关的功能插入到组件中。通过使组件具有回调感知能力,您可以让其他人在您还不知道的上下文中扩展您的代码。

例如,想象一下,ProcessSale的未来用户想要创建一个销售日志。如果用户有权访问该类,她可能会将日志代码直接添加到sale()方法中。然而,这并不总是一个好主意。如果她不是提供ProcessSale的包的维护者,那么她的修改会在下次包升级时被覆盖。即使她是组件的维护者,给sale()方法添加许多附带的任务将开始压倒它的核心职责,并潜在地使它在项目间不太可用。我将在下一节回到这些主题。

幸运的是,我让ProcessSale知道回调。在这里,我创建了一个模拟日志记录的回调:

// listing 04.112
$logger = function ($product) {
    print "    logging ({$product->name})\n";
};

$processor = new ProcessSale();
$processor->registerCallback($logger);

$processor->sale(new Product("shoes", 6));
print "\n";
$processor->sale(new Product("coffee", 6));

在这里,我创建了一个匿名函数。也就是说,我内联使用了function关键字,并且没有函数名。注意,因为这是一个内联语句,所以在代码块的末尾需要一个分号。我的匿名函数可以存储在变量中,并作为参数传递给函数和方法。这就是我所做的,将函数赋给$logger变量,并将其传递给ProcessSale::registerCallback()方法。最后,我创建了几个产品,并将它们传递给sale()方法。然后处理销售(实际上,打印一条关于产品的简单消息),并执行任何回调。下面是实际运行的代码:

shoes: processing
    logging (shoes)

coffee: processing
    logging (coffee)

PHP 7.4 引入了一种声明匿名函数的新方法。箭头函数在功能上与您已经遇到的匿名函数非常相似。然而,语法要简洁得多。它们不是由关键字function定义的,而是由fn定义的,然后是一个参数列表的括号,最后是一个箭头操作符(=>)后跟一个表达式,以此代替括号。这种紧凑的形式使得 arrow 函数在构建自定义排序等小回调时非常方便。在这里,我使用一个箭头函数将$logger匿名函数替换为一个完全等价的函数:

// listing 04.113

$logger = fn($product) => print "    logging ({$product->name})\n";

arrow 函数要简洁得多,但是,因为您只定义了一个表达式,所以它最适合用于相对简单的任务。

当然,回调不必匿名。您可以使用函数名,甚至对象引用和方法作为回调。在这里,我就是这样做的:

// listing 04.114
class Mailer
{
    public function doMail(Product $product): void
    {
        print "    mailing ({$product->name})\n";
    }
}

// listing 04.115
$processor = new ProcessSale();
$processor->registerCallback([new  Mailer(), "doMail"]);

$processor->sale(new Product("shoes", 6));
print "\n";
$processor->sale(new Product("coffee", 6));

我创建一个类:Mailer。它的单个方法doMail()接受一个Product对象并输出关于它的消息。当我调用registerCallback()时,我传递给它一个数组。第一个元素是一个Mailer对象,第二个是一个字符串,它匹配我想要调用的方法的名称。

记住registerCallback()使用类型声明来强制执行callable参数。PHP 足够聪明,能够识别出这种类型的数组是可调用的。数组形式的有效回调应该将对象作为第一个元素,将方法名作为第二个元素。我通过了这里的测试,这是我的输出:

shoes: processing
    mailing (shoes)

coffee: processing
    mailing (coffee)

您可以让一个方法返回一个匿名函数,就像这样:

// listing 04.116
class Totalizer
{
    public static function warnAmount(): callable
    {
        return function (Product $product) {
            if ($product->price > 5) {
                print "    reached high price: {$product->price}\n";
            }
        };
    }
}

// listing 04.117
$processor = new ProcessSale();
$processor->registerCallback(Totalizer::warnAmount());

$processor->sale(new Product("shoes", 6));
print "\n";
$processor->sale(new Product("coffee", 6));

除了使用warnAmount()方法作为匿名函数的工厂的便利之外,我在这里没有增加太多的兴趣。但是这个结构允许我做的不仅仅是生成一个匿名函数。它允许我利用闭包。匿名函数可以引用在匿名函数的父作用域中声明的变量。这是一个很难理解的概念。这就好像匿名函数继续记住它被创建的上下文。想象一下,我想让Totalizer::warnAmount()做两件事。首先,我希望它接受一个任意的目标金额。第二,我希望它能记录产品销售时的价格。当总数超过目标数量时,该函数将执行一个操作(在这种情况下,正如您可能已经猜到的,它将简单地编写一条消息)。

我可以用一个use子句让我的匿名函数跟踪更大范围内的变量:

// listing 04.118
class Totalizer2
{
    public static function warnAmount($amt): callable
    {
        $count = 0;
        return function ($product) use ($amt, &$count) {
            $count += $product->price;
            print "    count: $count\n";
            if ($count > $amt) {
                print "    high price reached: {$count}\n";
            }
        };
    }
}

// listing 04.119
$processor = new ProcessSale();
$processor->registerCallback(Totalizer2::warnAmount(8));

$processor->sale(new Product("shoes", 6));
print "\n";
$processor->sale(new Product("coffee", 6));

Totalizer2::warnAmount()返回的匿名函数在其use子句中指定了两个变量。第一个是$amt。这是warnAmount()接受的论点。第二个闭包变量是$count$countwarnAmount()的主体中声明,初始设置为零。注意,我在use子句中的$count变量前加了一个&符号。这意味着在匿名函数中将通过引用而不是通过值来访问变量。在匿名函数的主体中,我将$count增加产品的值,然后根据$amt测试新的总数。如果达到了目标值,我会输出一个通知。

下面是实际运行的代码:

shoes: processing
    count: 6

coffee:  processing
    count: 12
    high price reached: 12

这表明回调在调用之间跟踪$count$count$amt仍然与函数相关联,因为它们出现在声明的上下文中,并且在use子句中被指定。

箭头函数也生成闭包(像匿名函数一样,它们解析为内置Closure类的一个实例)。与需要与闭包变量显式关联的匿名函数不同,它们自动获得范围内所有变量的按值副本。这里有一个例子:

// listing 04.120
$markup = 3;
$counter = fn(Product $product) => print "($product->name) marked up price: " .
           ($product->price + $markup) . "\n";
$processor = new ProcessSale();
$processor->registerCallback($counter);

$processor->sale(new Product("shoes", 6));

print "\n";
$processor->sale(new Product("coffee", 6));

我能够在传递给ProcessSale::sale()的匿名函数中访问$markup。但是,因为函数只能通过值访问,所以我在函数中执行的任何操作都不会影响源变量。

PHP 7.1 引入了一种在对象上下文中管理闭包的新方法。Closure::fromCallable()方法允许你生成一个闭包,这个闭包赋予调用代码对对象的类和属性的访问权。下面是Totalizer系列的一个版本,它使用对象属性来实现与上一个示例相同的结果:

// listing 04.121

class Totalizer3
{
    private float $count = 0;
    private float $amt = 0;

    public function warnAmount(int $amt): callable
    {
        $this->amt = $amt;
        return \Closure::fromCallable([$this, "processPrice"]);
    }

    private function processPrice(Product $product): void
    {
        $this->count += $product->price;
        print "    count: {$this->count}\n";
        if ($this->count > $this->amt) {
            print "    high price reached: {$this->count}\n";
        }
    }
}

在这个例子中,warnAmount()方法不是静态的。这是因为,多亏了Closure::fromCallable(),我返回了一个对processPrice()方法的回调,该方法可以访问更大的对象。我设置了$amt属性并返回一个可调用的方法引用。当被调用时,processPrice()增加一个$count属性,并在达到$amt属性值时发出警告。如果processPrice()是一个公共方法,我可以简单地返回[$this, "processPrice"]。正如我们所看到的,PHP 足够聪明,可以计算出这样一个两元素数组应该解析为 callable。然而,我想使用Closure::fromCallable()有两个很好的理由。首先,我可以对私有或受保护的方法进行受控访问,而不必向整个世界公开它们——在控制访问的同时提供增强的功能。其次,我获得了性能提升,因为在确定返回值是否真正可调用时会有开销。

这里,我将Totalizer3与未更改的ProcessSale类一起使用:

// listing 04.122
$totalizer3 = new Totalizer3();
$processor = new ProcessSale();
$processor->registerCallback($totalizer3->warnAmount(8));

$processor->sale(new Product("shoes", 6));
print "\n";
$processor->sale(new Product("coffee", 6));

匿名类

PHP 7 引入了匿名类。当您需要从一个小类创建和派生一个实例时,当所讨论的父类简单且特定于本地上下文时,这些是有用的。

让我们回到我们的PersonWriter例子。这次我将从创建一个接口开始:

// listing 04.123
interface PersonWriter
{
    public function write(Person $person): void;
}

现在,这里有一个可以使用PersonWriter对象的Person类的版本:

// listing 04.124
class Person
{
    public function output(PersonWriter $writer): void
    {
        $writer->write($this);
    }

    public function getName(): string
    {
        return "Bob";
    }

    public function getAge(): int
    {
        return 44;
    }
}

output()方法接受一个PersonWriter实例,然后将当前类的一个实例传递给它的write()方法。通过这种方式,Person类很好地与编写器的实现隔离开来。

转到客户端代码,如果我们需要一个编写器来打印一个Person对象的名字和年龄值,我们可以继续用通常的方式创建一个类。但是这是一个如此简单的实现,我们同样可以创建一个类并同时将其传递给Person:

// listing 04.125
$person = new Person();
$person->output(
    new class implements PersonWriter {
        public function write(Person $person): void
        {
            print $person->getName() . " " . $person->getAge() . "\n";
        }
    }
);

如您所见,您可以用关键字new class声明一个匿名类。然后,在创建类块之前,您可以添加任何所需的extendsimplements子句。

匿名类不支持闭包。换句话说,在更大范围内声明的变量不能在类内访问。然而,您可以将值传递给匿名类的构造函数。让我们创建一个稍微复杂一点的PersonWriter:

// listing 04.126
$person = new Person();
$person->output(
    new class ("/tmp/persondump") implements PersonWriter {
        private $path;

        public function __construct(string $path)
        {
            $this->path = $path;
        }

        public function write(Person $person): void
        {
            file_put_contents($this->path, $person->getName() . " " . $person->getAge() . "\n");
        }
    }
);

我向构造函数传递了一个路径参数。这个值存储在$path属性中,最终由write()方法使用。

当然,如果您的匿名类的规模和复杂性开始增长,那么在类文件中创建一个命名类就变得更加明智了。如果您发现自己在不止一个地方复制匿名类,这一点尤其正确。

摘要

在这一章中,我们开始掌握 PHP 的高级面向对象特性。当你阅读这本书时,其中的一些会变得熟悉。特别是,我将经常回到抽象类、异常和静态方法。

在下一章中,我将从内置的对象特性后退一步,看看设计用来帮助你处理对象的类和函数。