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

29 阅读1小时+

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

原文:PHP 8 Objects, Patterns, and Practice

协议:CC BY-NC-SA 4.0

五、对象工具

正如我们所见,PHP 通过类和方法等语言结构支持面向对象编程。该语言还通过旨在帮助您处理对象的函数和类提供了更广泛的支持。

在这一章中,我们将会看到一些可以用来组织、测试和操作对象和类的工具和技术。

本章将涵盖以下工具和技术:

  • 名称空间(Namespaces):将你的代码组织成独立的类似包的部分

  • 包含路径:为您的库代码设置中央可访问位置

  • 类和对象函数:测试对象、类、属性和方法的函数

  • 反射 API:一套强大的内置类,在运行时提供对类信息前所未有的访问

  • 属性 : PHP 对注释的实现——一种机制,通过这种机制,类、方法、属性和参数可以使用源代码中的标签用丰富的信息来增强

PHP 和包

包是一组相关的类和函数,通常以某种方式组合在一起。包可以用来将系统的各个部分相互分离。一些编程语言正式识别包,并为它们提供不同的名称空间。PHP 本身没有包的概念,但是从 PHP 5.3 开始,它引入了名称空间。我将在下一节讨论这个特性。我还将看看将类组织成类似包的结构的老方法。

PHP 包和名称空间

尽管 PHP 本质上并不支持包的概念,但开发人员传统上使用命名方案和文件系统将他们的代码组织成类似包的结构。

直到 PHP 5.3,开发人员被迫在共享的上下文中命名他们的文件。换句话说,如果您命名了一个类ShoppingBasket,它将立即在您的系统中可用。这导致了两个主要问题。首先,也是最具破坏性的,是命名冲突的可能性。你可能认为这不太可能。毕竟,你所要做的就是记住给所有的类起一个唯一的名字,对吗?问题是,我们越来越依赖库代码。这当然是件好事,因为它促进了代码重用。但是假设您的项目是这样的:

// listing 05.01
require_once __DIR__ . "/../useful/Outputter.php";

class Outputter
{
    // output data
}

现在假设您在useful/Outputter.php合并了包含的文件:

// listing 05.02
class Outputter
{
    //
}

你能猜到会发生什么,对吗?出现这种情况:

PHP Fatal error: Cannot declare class Outputter because the name is
already in use in /var/popp/src/ch05/batch01/useful/Outputter.php on
line 4

在引入名称空间之前,有一个解决这个问题的传统方法。答案是在类名前面加上包名,这样就保证了类名的唯一性:

// listing 05.03

// my/Outputer.php

require_once __DIR __ . "/../useful/Outputter.php";

class my_Outputter
{
    // output data
}

// listing 05.04

// useful/Outputter.php

class useful_Outputter
{
    //
}

这里的问题是,随着项目越来越复杂,类名变得越来越长。这不是一个很大的问题,但是它导致了代码可读性的问题,并且使得在工作时更难记住类名。许多累积的编码时间都浪费在了打字错误上。

如果您维护的是遗留代码,您可能仍然会看到遵循这种约定的代码。因此,在本章的后面,我将简单地回到处理包的老方法。

名称空间拯救世界

PHP 5.3 引入了名称空间。本质上,名称空间是一个桶,您可以在其中放置您的类、函数和变量。在命名空间中,您可以无限制地访问这些项。从外部,您必须导入或引用该命名空间,以便访问它包含的项。

迷茫?举个例子应该会有帮助。在这里,我使用名称空间重写了前面的示例:

// listing 05.05
namespace my;

require_once __DIR__ . "/../useful/Outputter.php";

class Outputter
{
    // output data
}

// listing 05.06

namespace useful;

class Outputter
{
    //
}

注意关键字namespace。如您所料,这个关键字建立了一个名称空间。如果使用这个特性,那么命名空间声明必须是其文件中的第一条语句。我创建了两个名称空间:myuseful。不过,通常情况下,您会希望有更深的名称空间。您将从一个组织或项目标识符开始。然后,您需要通过产品包来进一步验证这一点。PHP 允许您声明嵌套的名称空间。为此,您只需使用反斜杠字符来划分每个级别:

// listing 05.07
namespace popp\ch05\batch04\util;

class Debug
{
    public static function helloWorld(): void
    {
        print "hello from Debug\n";
    }
}

您通常会使用与产品或组织相关的名称来定义存储库。我可能会使用我的一个域:getinstance.com,例如。因为域名对其所有者来说是唯一的,所以这是 Java 开发人员通常用于他们的包名的一个技巧。它们颠倒域名,从最普通的到最特殊的。或者,我可以使用我为本书中的代码示例选择的名称空间:popp,作为书名。一旦我确定了我的存储库,我可能会继续定义包。在这种情况下,我使用的是章节,然后是编号的批次。这允许我将示例组组织到离散的桶中。所以在这一章的这一点上,我在popp\ch05\batch04。最后,我可以进一步按类别组织代码。我和util一起去了。

那么我该如何调用这个方法呢?事实上,这取决于你从哪里打电话。如果从命名空间内调用方法,可以直接调用方法:

// listing 05.08
Debug::helloWorld();

这就是所谓的非限定名。因为我已经在popp\ch05\batch04\util名称空间中,所以我不需要在类名前添加任何类型的路径。如果我从命名空间上下文之外访问该类,我可以这样做:

// listing 05.09
\popp\ch05\batch04\Debug::helloworld();

我会从下面的代码中得到什么输出?

// listing 05.10
namespace main;

    popp\ch05\batch04\Debug::helloworld();

那是一个棘手的问题。事实上,这是我的输出:

PHP Fatal error: Class 'popp\ch05\batch04\Debug' not found in...

这是因为我在这里使用了相对名称空间。PHP 在名称空间main下寻找popp\ ch05\batch04\util,但没有找到。正如您可以通过使用分隔符创建绝对 URL 和文件路径一样,您也可以创建名称空间。此版本的示例修复了以前的错误:

// listing 05.11
namespace main;

    \popp\ch05\batch04\Debug::helloworld();

这个反斜杠告诉 PHP 从根开始搜索,而不是从当前名称空间开始。

但是名称空间不是应该帮助您减少输入吗?当然,Debug类声明更短,但是这些调用就像旧的命名约定一样冗长。您可以使用use关键字来解决这个问题。这允许您在当前名称空间内为其他名称空间起别名。这里有一个例子:

// listing 05.12
namespace main;

use popp\ch05\batch04\util;

    util\Debug::helloWorld();

popp\ch05\batch04\util名称空间被导入并隐式别名化为util。请注意,我没有以反斜杠字符开头。use的参数是从根空间搜索的,而不是从当前的名称空间。如果我根本不想引用名称空间,我可以导入Debug类本身:

// listing 05.13
namespace main;

use popp\ch05\batch04\util\Debug;

    Debug::helloWorld();

这是最常用的约定。但是,如果在调用名称空间中已经有了一个Debug类,会发生什么呢?这里有这样一个类:

// listing 05.14
namespace popp\ch05\batch04;

class Debug
{
    public static function helloWorld(): void
    {
        print "hello from popp\\ch05\\batch04\\Debug\n";
    }
}

这里有一些来自popp\ch05\batch04名称空间的调用代码,它引用了两个Debug类:

// listing 05.15
namespace popp\ch05\batch04;

use popp\ch05\batch04\util\Debug;
use popp\ch05\batch04\Debug;

Debug::helloWorld();

如您所料,这会导致致命错误:

PHP Fatal error: Cannot use popp\ch05\batch04\Debug as Debug because the name is already in use in...

因此,我似乎又回到了原点,回到了类名冲突。幸运的是,这个问题有一个答案。我可以明确我的别名:

// listing 05.16
namespace popp\ch05\batch04;

use popp\ch05\batch04\util\Debug;
use popp\ch05\batch04\Debug as CoreDebug;

CoreDebug::helloWorld();

通过将as子句用于use,我能够将Debug别名改为coreDebug

如果你在一个命名空间中编写代码,并且你想访问一个驻留在根(非命名空间)空间中的类、特征或接口(例如,PHP 的核心类,如ExceptionErrorClosure),你可以简单地在名字前面加一个反斜杠。这里有一个在根空间中声明的类:

// listing 05.17
class TreeLister
{
    public static function helloWorld(): void
    {
        print "hello from root namespace\n";
    }
}

这里有一些命名空间代码:

// listing 05.18
namespace popp\ch05\batch04\util;

class TreeLister
{
    public static function helloWorld(): void
    {
        print "hello from " . __NAMESPACE__ . "\n";
    }
}

// listing 05.19
namespace popp\ch05\batch04;

use popp\ch05\batch04\util\TreeLister;

        TreeLister::helloWorld();  // access local
        \TreeLister::helloWorld(); // access from root

命名空间代码声明了它自己的TreeLister类。客户端代码使用本地版本,用一个use语句指定完整路径。用单个反斜杠限定的名称访问根名称空间中类似命名的类。

这是上一个片段的输出:

hello from popp\ch05\batch04\util
hello from root namespace

这个输出值得展示,因为它演示了__NAMESPACE__常量的操作。这将输出当前的名称空间,这在调试中很有用。

您可以使用已经看到的语法在同一个文件中声明多个名称空间。您还可以使用一种将大括号与 namespace 关键字结合使用的替代语法:

// listing 05.20
namespace com\getinstance\util {

    class Debug
    {
        public static function helloWorld(): void
        {
            print "hello from Debug\n";
        }
    }
}

namespace other {

    \com\getinstance\util\Debug::helloWorld();
}

如果您必须在同一个文件中组合多个名称空间,那么这是推荐的做法。然而,通常认为在每个文件的基础上定义名称空间是最佳实践。

Note

不能在同一个文件中同时使用大括号和 line 命名空间语法。你必须选择一个并坚持到底。

使用文件系统模拟包

无论您使用哪个版本的 PHP,您都应该使用文件系统来组织类,文件系统提供了一种包结构。例如,您可以创建utilbusiness目录,并包含带有require_once()语句的类文件,如下所示:

// listing 05.21
require_once('business/Customer.php');
require_once('util/WebTools.php');

你也可以使用include_once()达到同样的效果。include()require()语句之间的唯一区别在于它们对错误的处理。当您遇到错误时,使用require()调用的文件将使您的整个过程停止。通过调用include()遇到的同样的错误只会产生一个警告并结束包含文件的执行,让调用代码继续。这使得require()require_once()成为包含库文件的安全选择,而include()include_once()对于模板化之类的操作非常有用。

Note

require()require_once()其实是语句,不是函数。这意味着您可以在使用括号时省略它们。就我个人而言,我更喜欢使用括号,但如果你也这样做,请做好被急于解释你的错误的学究们烦透的准备。

图 5-1 从 Nautilus 文件管理器的角度显示了utilbusiness包。

img/314621_6_En_5_Fig1_HTML.jpg

图 5-1

使用文件系统组织的 PHP 包

Note

require_once()接受文件的路径,并将其包含在当前脚本中。只有在目标尚未包含在其他地方的情况下,该语句才会包含目标。这种一次性方法在访问库代码时特别有用,因为它可以防止意外重定义类和函数。当使用像require()include()这样的语句,同一个文件被脚本的不同部分包含在一个进程中时,就会发生这种情况。

习惯上优先使用require()require_once()而不是类似的include()include_once()功能。这是因为在使用require()函数访问的文件中遇到致命错误会导致整个脚本停止运行。在使用include()函数访问的文件中遇到的相同错误将导致包含文件的执行停止,但只会在调用脚本中生成一个警告。前者,更激烈的行为,更安全。

require()相比,使用require_once()会产生开销。如果你需要从你的系统中挤出最后一毫秒,你可以考虑使用require()来代替。通常情况下,这是效率和便利之间的权衡。

就 PHP 而言,这种结构并没有什么特别之处。您只是将库脚本放在不同的目录中。它确实有助于组织的整洁,并且可以与名称空间或命名约定并行使用。

命名梨的方式

在引入名称空间之前,开发人员被迫求助于约定来避免类名冲突。正如我们所看到的,其中最常见的是 PEAR 开发人员维护的假命名空间。

Note

PEAR 代表 PHP 扩展和应用库。它是官方维护的增加 PHP 功能的包和工具的档案。核心 PEAR 包包含在 PHP 发行版中,其他包可以使用简单的命令行工具添加。您可以在 http://pear.php.net浏览梨包。

PEAR 使用文件系统来定义它的包,正如我所描述的。在引入名称空间之前,每个类都是根据其包路径命名的,每个目录名用下划线字符分隔。

例如,PEAR 包含一个名为 XML 的包,其中有一个 RPC 子包。RPC 包包含一个名为Server.php的文件。如你所料,Server.php中定义的类并不叫做Server。如果没有名称空间,迟早会与 PEAR 项目或用户代码中的另一个Server类发生冲突。相反,这个类被命名为XML_RPC_Server。这种方法产生了不吸引人的类名。然而,它确实使代码易于阅读,因为类名总是描述它自己的上下文。

包括路径

当你组织你的组件时,有两个观点你应该记住。我已经介绍了第一种,文件和目录放在文件系统中。但是您也应该考虑组件相互访问的方式。到目前为止,我在本节中已经谈到了包含路径的问题。

当包含一个文件时,可以使用当前工作目录的相对路径或文件系统上的绝对路径来引用它。

Note

尽管理解包含路径的工作方式和要求文件所涉及的问题很重要,但是记住许多现代系统不再依赖类级别的 require 语句也很重要。相反,它们使用自动加载和名称空间的组合。稍后我将介绍自动加载,然后在第 15 和 16 章中更详细地查看实用的自动加载建议和工具。

到目前为止,您所看到的示例偶尔会指定需求文件和必需文件之间的固定关系:

// listing 05.22
require_once __DIR__ . "/../useful/Outputter.php";

这工作得很好,除了它硬编码了文件之间的关系。在调用类的包含目录旁边必须总是有一个useful目录。

也许最糟糕的方法是曲折的相对路径:

// listing 05.23
require_once('../../projectlib/business/User.php');

这是有问题的,因为这里指定的路径不是相对于包含这个require_once语句的文件,而是相对于配置的调用上下文(通常,但不总是,当前工作目录)。像这样的路径会导致混乱(根据我的经验,这几乎总是一个迹象,表明一个系统在其他方面也需要相当大的改进)。

当然,您可以使用绝对路径:

// listing 05.24
require_once('/home/john/projectlib/business/User.php');

这将适用于单个实例——但它很脆弱。通过如此详细地指定路径,可以将库文件冻结在特定的上下文中。每当你在一个新的服务器上安装项目时,所有的require语句都需要改变以适应一个新的文件路径。这使得库很难重新定位,如果不制作副本,在项目间共享库也不切实际。在这两种情况下,您都失去了在所有附加目录中打包的想法。是business套餐,还是projectlib/business套餐?

如果您必须在代码中手动包含文件,最简洁的方法是将调用代码从库中分离出来。您已经看到了这样的结构:

// listing 05.25
require_once('business/User.php');

在前面使用这种路径的例子中,我们隐含地假设了一个相对路径。换句话说,business/User.php在功能上与./business/User.php相同。但是,如果前面的 require 语句可以在系统上的任何目录下工作,那会怎么样呢?您可以使用包含路径来实现这一点。这是 PHP 在试图获取文件时搜索的目录列表。你可以通过修改include_path指令来增加这个列表。include_path通常在 PHP 的中央配置文件php.ini中设置。它定义了一个目录列表,在类 Unix 系统中用冒号分隔,在 Windows 系统中用分号分隔:

include_path = ".:/usr/local/lib/php-libraries"

如果您正在使用 Apache,您也可以在服务器应用的配置文件(通常称为httpd.conf)或每个目录的 Apache 配置文件(通常称为.htaccess)中设置include_path,语法如下:

php_value include_path value .:/usr/local/lib/php-libraries

Note

文件在一些托管公司提供的网络空间中特别有用,这些公司提供非常有限的对服务器环境的访问。

当您使用一个文件系统函数,如fopen()require()时,其非绝对路径相对于当前工作目录不存在,包含路径中的目录会自动搜索,从列表中的第一个目录开始(对于fopen(),您必须在其参数列表中包含一个标志来启用该特性)。当遇到目标文件时,搜索结束,文件函数完成它的任务。

因此,通过将包目录放在包含目录中,您只需要在您的require()语句中引用包和文件。

您可能需要向include_path添加一个目录,这样您就可以维护自己的库目录。要做到这一点,您可以编辑php.ini文件(记住,对于 PHP 服务器模块,您需要重启服务器以使更改生效)。

如果您没有使用php.ini文件所需的权限,您可以使用set_include_path()函数在脚本中设置包含路径。set_include_path()接受一个包含路径(正如它将出现在php.ini中)并仅改变当前进程的include_path设置。php.ini文件可能已经为include_path定义了一个有用的值,所以与其覆盖它,不如使用get_include_path()函数访问它,并添加您自己的目录。以下是将目录添加到当前包含路径的方法:

set_include_path(get_include_path() . PATH_SEPARATOR . "/home/john/phplib/");

PATH_SEPARATOR常量将在 Unix 系统上解析为冒号,在 Windows 平台上解析为分号。因此,出于可移植性的原因,使用它被认为是最佳实践。

自动加载

尽管将require_once与 include 路径结合使用很简洁,但是许多开发人员在高级别上完全摒弃了 require 语句,转而依赖于 autoload。

Note

本书以前的版本讨论了一个名为__autoload()的内置函数,它提供了本节讨论的功能的一个更粗糙的版本。从 PHP 7.2.0 开始,这个函数被弃用,并在 PHP 8 中被删除。

为此,您应该组织您的类,使每个类都位于自己的文件中。每个类文件应该与它包含的类名有固定的关系,所以您可以在名为ShopProduct.php的文件中定义一个ShopProduct类,其目录对应于该类的名称空间的元素。

PHP 5 引入了自动加载功能来帮助自动包含类文件。默认支持非常基本,但仍然有用。可以通过不带参数调用名为spl_autoload_register()的函数来启用它。然后,如果以这种方式激活了自动加载功能,当您试图实例化一个未知的类时,PHP 将调用一个名为spl_autoload()的内置函数。这将使用提供的类名(转换成小写)在您的包含路径中搜索名为<classname>.php<classname>.inc(其中<classname>是未知类名)的文件。

这里有一个简单的例子:

// listing 05.26
spl_autoload_register();
$writer = new Writer();

假设我还没有包含一个包含Writer对象的文件,这个实例化看起来注定会失败。然而,因为我已经设置了自动加载,PHP 将试图包含一个名为writer.phpwriter.inc的文件,然后将再次尝试实例化。如果这些文件中的一个存在,并且包含一个名为Writer的类,那么一切都会好的。

此默认行为支持名称空间,用目录名替换每个包:

// listing 05.27
spl_autoload_register();
$writer = new util\Writer();

前面的代码将在名为util的目录中找到名为writer.php(注意小写名称)的文件。

如果我碰巧根据大小写来命名我的类文件怎么办?也就是说,如果我保留大写字母来命名它们呢?如果我将Writer类放在一个名为Writer.php的文件中,那么默认实现将无法找到它。

幸运的是,我可以注册自己的自定义函数来处理不同的约定集。为了利用这一点,我必须将一个自定义函数的引用传递给spl_autoload_register()。我的自动加载函数需要一个参数。然后,如果 PHP 引擎试图实例化一个未知的类,它将调用这个函数,将未知的类名作为字符串传递给它。由 autoload 函数定义一个策略来定位并包含丢失的类文件。一旦调用了 autoload 函数,PHP 将再次尝试实例化该类。

下面是一个简单的自动加载函数,以及一个要加载的类:

// listing 05.28
class Blah
{
    public function wave(): void
    {
        print "saying hi from root";
    }
}

// listing 05.29
$basic = function (string $classname) {
    $file = __DIR__ . "/" . "{$classname}.php";
    if (file_exists($file)) {
        require_once($file);
    }
};

\spl_autoload_register($basic);

$blah = new Blah();
$blah->wave();

最初未能实例化Blah,PHP 引擎将看到我已经用spl_autoload_register()函数注册了一个自动加载函数,并向它传递字符串"Blah"。我的实现只是试图包含文件Blah.php。当然,只有当文件与声明 autoload 函数的文件在同一个目录中时,这才会起作用。在现实世界的例子中,我必须将包含路径配置与我的自动加载逻辑结合起来(这正是 Composer 的自动加载实现所做的)。

如果我想提供老学校的支持,我可能会自动化 PEAR 包包括:

// listing 05.30

class util_Blah
{
    public function wave(): void
    {
        print "saying hi from underscore file";
    }
}

// listing 05.31

$underscores = function (string $classname) {
    $path = str_replace('_', DIRECTORY_SEPARATOR, $classname);
    $path = __DIR__ . "/$path";
    if (file_exists("{$path}.php")) {
        require_once("{$path}.php");
    }
};

\spl_autoload_register($underscores);

$blah = new util_Blah();
$blah->wave();

如您所见,autoload 函数匹配所提供的$classname中的下划线,并用DIRECTORY_SEPARATOR字符(Unix 系统上的/)替换每一个下划线。我试图包含类文件(util/Blah.php)。如果类文件存在,并且它包含的类已被正确命名,则对象应该被实例化而不会出现错误。当然,这确实需要程序员遵守一个命名约定,禁止在类名中使用下划线字符,除非是在它分割包的地方。

名称空间呢?我们已经看到默认的自动加载功能支持名称空间。但是如果我们覆盖默认设置,那么就由我们来提供名称空间支持。这只是匹配和替换反斜杠字符的问题:

// listing 05.32
namespace util;

class LocalPath
{

    public function wave(): void
    {
        print "hello from " . get_class();
    }
}

// listing 05.33
$namespaces = function (string $path) {
    if (preg_match('/\\\\/', $path)) {
        $path = str_replace('\\', DIRECTORY_SEPARATOR, $path);
    }
    if (file_exists("{$path}.php")) {
        require_once("{$path}.php");
    }
};

\spl_autoload_register($namespaces);
$obj = new util\LocalPath();
$obj->wave();

传递给 autoload 函数的值总是被规范化为完全限定的名称,没有前导反斜杠,因此在实例化时不需要担心别名或相对名称空间。

请注意,这个解决方案绝不是完美的。file_exists()函数没有考虑包含路径,所以它不能准确反映require_once运行良好的所有情况。对此有各种解决方案。您可以使用自己的路径感知版本的file_exists(),或者尝试在 try 子句中要求该文件(在本例中捕捉Error,而不是Exception)。然而幸运的是,PHP 提供了stream_resolve_include_path()函数。这将返回一个表示给定路径的绝对文件名的字符串,或者,对于我们的目的很重要的是,如果在包含路径中找不到该文件,则返回false

// listing 05.34
$namespaces = function (string $path) {
    if (preg_match('/\\\\/', $path)) {
        $path = str_replace('\\', DIRECTORY_SEPARATOR, $path);
    }

    if (\stream_resolve_include_path("{$path}.php") !== false) {
        require_once("{$path}.php");
    }
};

\spl_autoload_register($namespaces);
$obj = new util\LocalPath();
$obj->wave();

如果我想同时支持梨形类名名称空间该怎么办?我可以将我的自动加载实现合并到一个单独的自定义函数中。或者,我可以利用spl_autoload_register()堆栈其自动加载函数的事实:

// listing 05.35
$underscores = function (string $classname) {
    $path = str_replace('_', DIRECTORY_SEPARATOR, $classname);
    $path = __DIR__ . "/$path";
    if (\stream_resolve_include_path("{$path}.php") !== false) {
        require_once("{$path}.php");
    }
};

$namespaces = function (string $path) {
    if (preg_match('/\\\\/',  $path)) {
        $path = str_replace('\\', DIRECTORY_SEPARATOR, $path);
    }
    if (\stream_resolve_include_path("{$path}.php") !== false) {
        require_once("{$path}.php");
    }
};

\spl_autoload_register($namespaces);
\spl_autoload_register($underscores);

$blah = new util_Blah();
$blah->wave();

$obj = new util\LocalPath();
$obj->wave();

当遇到未知的类时,PHP 引擎将依次调用 autoload 函数(根据它们注册的顺序),当可以实例化或所有选项都用尽时停止。

这种堆叠显然是有开销的,那么 PHP 为什么支持它呢?在实际项目中,您可能会将名称空间和下划线策略组合成一个函数。但是,大型系统和第三方库中的组件可能需要注册自己的自动加载机制。堆叠允许系统的多个部分独立注册自动加载策略,而不会相互覆盖。事实上,一个只需要一个自动加载机制的库可以将它的自定义自动加载函数(或者任何类型的可调用函数,比如匿名函数)的名称传递给spl_autoload_unregister()来清理它自己!

类和对象函数

PHP 为测试类和对象提供了一组强大的函数。这为什么有用?毕竟,您可能编写了脚本中使用的大多数类。

事实上,您在运行时并不总是知道您正在使用的类。例如,您可能已经设计了一个透明地使用第三方附加类的系统。在这种情况下,通常会实例化一个只有类名的对象。PHP 允许你使用字符串动态地引用类,就像这样:

// listing 05.36
namespace tasks;

class Task
{
    public function doSpeak()
    {
        print "hello\n";
    }
}

// listing 05.37
$classname = "Task";
require_once("tasks/{$classname}.php");
$classname = "tasks\\$classname";
$myObj = new $classname();
$myObj->doSpeak();

这个脚本可能从一个配置文件或者通过比较一个 web 请求和一个目录的内容来获取我分配给$classname的字符串。然后,可以使用该字符串加载一个类文件并实例化一个对象。注意,我在这个片段中构造了一个名称空间限定。

通常,当您希望系统能够运行用户创建的插件时,您会这样做。在实际项目中做任何有风险的事情之前,您必须检查该类是否存在,它是否有您期望的方法,等等。

Note

即使采取了安全措施,您也应该对动态安装第三方插件代码保持高度警惕。永远不要自动运行用户上传的代码。这样安装的任何插件通常会以与您的核心代码相同的权限执行,因此恶意插件作者可能会对您的系统造成严重破坏。

这并不是说插件不是一个好主意。允许第三方开发者增强核心系统可以提供很大的灵活性。为了确保更高的安全性,您可以支持插件目录,但是要求代码文件由系统管理员直接安装,或者从受密码保护的管理环境中安装。管理员要么在安装前亲自检查插件代码,要么从一个声誉良好的存储库中寻找插件。这是流行的博客平台 WordPress 处理插件的方式。

一些类函数已经被更强大的反射 API 所取代,我将在这一章的后面讨论。然而,它们的简单性和易用性使它们成为某些情况下的首选。

寻找课程

class_exists()函数接受一个表示要检查的类的字符串,如果该类存在,则返回一个布尔值true,否则返回false

使用这个函数,我可以使前面的片段更安全一点:

// listing 05.38
$base = __DIR__;
$classname = "Task";
$path = "{$base}/tasks/{$classname}.php";
if (! file_exists($path)) {
    throw new \Exception("No such file as {$path}");
}
require_once($path);
$qclassname = "tasks\\$classname";
if (! class_exists($qclassname)) {
    throw new Exception("No such class as $qclassname");
}
$myObj = new $qclassname();
$myObj->doSpeak();

当然,你不能确定这个类不需要构造函数参数。为了达到这种安全水平,你必须求助于反射 API,这将在本章后面介绍。然而,class_exists()确实允许您在使用它之前检查该类是否存在。

Note

请记住,如前所述,您应该始终警惕外部来源提供的任何数据。在以任何方式使用它之前对它进行测试和处理。对于文件路径,您应该转义或删除点和目录分隔符,以防止不道德的用户更改目录和包含意外的文件。然而,当我描述构建易于扩展的系统的方法时,这些技术通常涵盖部署的所有者(具有隐含的写权限),而不是她的外部用户。

您还可以使用get_declared_classes()函数获得脚本进程中定义的所有类的数组:

// listing 05.39
print_r(get_declared_classes());

这将列出用户定义的和内置的类。记住,它只返回函数调用时声明的类。您可以稍后运行require()require_once(),从而增加脚本中的类数量。

了解一个对象或类

如您所知,您可以使用类类型提示来约束方法参数的对象类型。即使有了这个工具,你也不能总是确定一个对象的类型。

有许多基本工具可以用来检查对象的类型。首先可以用get_class()函数检查一个对象的类。它接受任何对象作为参数,并以字符串形式返回其类名:

// listing 05.40
$product = self::getProduct();
if (get_class($product) === 'popp\ch05\batch05\CdProduct') {
    print "\$product is a CdProduct object\n";
}

在这个片段中,我从getProduct()函数中获取了某个东西。为了绝对确定它是一个CdProduct对象,我使用了get_class()方法。

Note

我在第三章中讲述了CdProductBookProduct类。

下面是getProduct()函数:

// listing 05.41
public static function getProduct()
{
    return new CdProduct(
        "Exile on Coldharbour Lane",
        "The",
        "Alabama 3",
        10.99,
        60.33
    );
}

getProduct()只是实例化并返回一个CdProduct对象。我将在本节中充分利用这个功能。

get_class()函数是一个非常特殊的工具。你经常想要一个类类型的更一般的确认。您可能想知道一个对象属于ShopProduct家族,但是您并不关心它的实际类是BookProduct还是CdProduct。为此,PHP 提供了instanceof运算符。

Note

PHP 4 不支持instanceof。相反,它提供了is_a()函数,该函数在 PHP 5.0 中被弃用,但在 PHP 5.3 中被恢复了。

instanceof操作符使用两个操作数,要测试的对象在关键字的左边,类或接口名在右边。如果对象是给定类型的实例,则解析为true:

// listing 05.42
$product = self::getProduct();
if ($product instanceof \popp\ch05\batch05\CdProduct) {
    print "\$product is an instance of CdProduct\n";
}

获取对类的完全限定字符串引用

名称空间已经清除了面向对象 PHP 的许多丑陋之处。我们不再需要忍受长得离谱的类名,或者冒着命名冲突的风险(遗留代码除外)。另一方面,对于别名和相对名称空间引用,解析一些类路径以使它们是完全限定的可能是一件麻烦的事情。

以下是一些难以解析的类名示例:

// listing 05.43
namespace mypackage;

use util as u;
use util\db\Querier as q;

class Local
{
}

// Resolve these:

// Aliased namespace
//  u\Writer;

// Aliased class
//  q;

// Class referenced in local context
//  Local

弄清楚这些类引用是如何解析的并不太难,但是编写代码来捕捉每一种可能性会很痛苦。例如,给定u\Writer,自动解析器需要知道uutil的别名,它本身不是一个名称空间。有益的是,PHP 5.5 引入了ClassName::class语法。换句话说,给定一个类引用,您可以附加一个范围解析操作符和class关键字来获得完全限定的类名:

// listing 05.44
print u\Writer::class . "\n";
print q::class . "\n";
print Local::class . "\n";

前面的代码片段输出如下:

util\Writer
util\db\Querier
mypackage\Local

从 PHP 8 开始,你也可以在一个对象上调用::class。例如,给定一个ShopProduct的实例,我可以得到完整的类名,如下所示:

// listing 05.45
$bookp = new BookProduct(
    "Catch 22",
    "Joseph",
    "Heller",
    11.99,
    300
);
print $bookp::class;

运行此输出

popp\ch04\batch02\BookProduct

请注意,这种方便的语法并没有提供新的功能——您已经遇到了实现相同结果的get_class()函数。

学习方法

您可以使用get_class_methods()函数获得一个类中所有方法的列表。这需要一个类名,并返回一个包含该类中所有方法名称的数组:

// listing 05.46
print_r(get_class_methods('\\popp\\ch04\\batch02\\BookProduct'));

假设BookProduct类存在,您可能会看到如下内容:

Array
(
    [0] => __construct
    [1] => getNumberOfPages
    [2] => getSummaryLine
    [3] => getPrice
    [4] => setID
    [5] => getProducerFirstName
    [6] => getProducerMainName
    [7] => setDiscount
    [8] => getDiscount
    [9] => getTitle
    [10] => getProducer
    [11] => getInstance
)

在示例中,我将包含类名的字符串传递给get_class_methods(),并用print_r()函数转储返回的数组。我也可以将一个对象传递给get_class_methods(),得到同样的结果。只有公共方法的名称才会包含在返回的列表中。

如您所见,您可以将方法名存储在字符串变量中,并与对象一起动态调用它,如下所示:

// listing 05.47
$product = self::getProduct();
$method = "getTitle";   // define a method name
print $product->$method(); // invoke the method

当然,这可能是危险的。如果方法不存在会怎么样?正如您所料,您的脚本将会失败并出现错误。您已经遇到了一种测试方法是否存在的方式:

// listing 05.48
if (in_array($method, get_class_methods($product))) {
    print $product->$method(); // invoke the method
}

在调用之前,我检查方法名是否存在于由get_class_methods()返回的数组中。

PHP 为此提供了更专门的工具。您可以用两个函数在一定程度上检查方法名:is_callable()method_exists()is_callable()是两种功能中较为复杂的一种。它接受一个表示函数名的字符串变量作为它的第一个参数,如果该函数存在并且可以被调用,则返回true。要对一个方法应用相同的测试,您应该向它传递一个数组来代替函数名。数组必须包含一个对象或类名作为其第一个元素,要检查的方法名作为其第二个元素。如果该方法存在于类中,该函数将返回 true:

// listing 05.49
if (is_callable([$product, $method])) {
    print $product->$method(); // invoke the method
}

可选地接受第二个参数,一个布尔值。如果将此设置为true,函数将只检查给定方法或函数名的语法,而不检查其实际存在。它还接受可选的第三个参数,该参数应该是一个变量。如果提供,这将用您提供的可调用函数的字符串表示形式填充。

这里,我用可选第三个参数调用is_callable(),然后输出:

// listing 05.50
if (is_callable([$product, $method], false, $callableName)) {
    print $callableName;
}

这是我的输出:

popp\ch05\batch05\CdProduct::getTitle

这种功能对于文档或日志记录来说可能很方便。

method_exists()函数需要一个对象(或类名)和一个方法名,如果给定的方法存在于对象的类中,则返回true:

// listing 05.51
if (method_exists($product, $method)) {
    print $product->$method(); // invoke the method
}

Caution

记住,一个方法的存在并不意味着它是可调用的。method_exists()privateprotected方法以及public方法返回true

了解属性

正如您可以查询类的方法一样,您也可以查询它的字段。get_class_vars()函数需要一个类名,并返回一个关联数组。返回的数组包含字段名作为其键,包含字段值作为其值。让我们对CdProduct对象进行测试。为了便于说明,我们向类添加了一个公共属性,CdProduct::$coverUrl:

// listing 05.52
print_r(get_class_vars('\\popp\\ch05\\batch05\\CdProduct'));

仅显示公共属性:

Array (
    [coverUrl] => cover url
)

了解继承

类函数也允许我们绘制继承关系。我们可以找到一个类的父类,比如用get_parent_class()。这个函数需要一个对象或类名,如果有超类的话,它返回超类的名称。如果不存在这样的类——也就是说,如果我们测试的类没有父类——那么函数返回false

// listing 05.53
print  get_parent_class('\\popp\\ch04\\batch02\\BookProduct');

如您所料,这会产生父类:ShopProduct

我们还可以使用is_subclass_of()函数测试一个类是否是另一个类的后代。这需要一个子对象(或类名)和父类名。如果第二个参数是第一个参数的超类,函数返回true:

// listing 05.54
$product = self::getBookProduct(); // acquire an object

if (is_subclass_of($product, '\\popp\\ch04\\batch02\\ShopProduct')) {
    print "BookProduct is a subclass of ShopProduct\n";
}

将只告诉你类继承关系。它不会告诉你一个类实现了一个接口。为此,您应该使用instanceof操作符。或者,您可以使用 SPL(标准 PHP 库)的一部分函数。class_implements()接受类名或对象引用,并返回接口名数组:

// listing 05.55
if (in_array('someInterface', class_implements($product))) {
    print "BookProduct is an interface of someInterface\n";
}

方法调用

您已经遇到了一个例子,在这个例子中,我使用了一个字符串来动态调用一个方法:

// listing 05.56
$product = self::getProduct();
$method = "getTitle";   // define a method name
print $product->$method(); // invoke the method

PHP 也提供了call_user_func()方法来达到同样的目的。call_user_func()可以调用任何种类的可调用函数(如函数名或匿名函数)。在这里,我通过传递字符串中的函数名来调用函数:

$returnVal = call_user_func("myFunction");

为了调用一个方法,我可以传递一个数组。此的第一个元素应该是对象,第二个元素应该是要调用的方法的名称:

$returnVal = call_user_func([$myObj, "methodName"]);

传递给call_user_func()的任何进一步的参数将被视为目标函数或方法的参数,并以相同的顺序传递,如下所示:

// listing 05.57
$product = self::getBookProduct(); // Acquire a BookProduct object
call_user_func([$product, 'setDiscount'], 20);

当然,这个动态调用相当于:

$product->setDiscount(20);

call_user_func()方法不会极大地改变你的生活,因为你同样可以直接用一个字符串代替方法名,就像这样:

// listing 05.58
$method = "setDiscount";
$product->$method(20);

然而,更令人印象深刻的是相关的call_user_func_array()函数。就选择目标方法或功能而言,其操作方式与call_user_func()相同。不过,最重要的是,它接受目标方法所需的任何参数作为数组。

Note

注意——使用call_user_func()传递给函数或方法的参数不是通过引用传递的。

那么这为什么有用呢?有时,您会得到数组形式的参数。除非你事先知道你要处理的论点的数量,否则很难把它们传递下去。在第四章中,我看到了可以用来创建委托类的拦截器方法。下面是一个简单的__call()方法的例子:

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

如您所见,当客户端代码调用未定义的方法时,会调用__call()方法。在这个例子中,我在一个名为$thirdpartyShop的属性中维护一个对象。如果我在存储对象中找到一个与$method参数匹配的方法,我就调用它。我愉快地假设目标方法不需要任何参数,这就是我的问题的开始。当我编写__call()方法时,我无法判断每次调用时$args数组会有多大。如果我将$args直接传递给委托方法,我将传递一个数组参数,而不是它可能期望的单独参数。call_user_func_array()完美解决问题:

// listing 05.60
public function __call(string $method, array $args): mixed
{
    if (method_exists($this->thirdpartyShop, $method)) {
        return call_user_func_array(
            [
                $this->thirdpartyShop,
                $method
            ],
            $args
        );
    }
}

反射 API

PHP 的反射 API 对于 PHP 就像java.lang.reflect包对于 Java 一样。它由用于分析属性、方法和类的内置类组成。它在某些方面类似于现有的对象函数,比如get_class_vars(),但是更加灵活,并且提供了更多的细节。它还被设计成能与 PHP 的面向对象特性(如访问控制、接口和抽象类)一起工作,而更老、更有限的类函数则不能。

入门指南

反射 API 不仅仅可以用来检查类。例如,ReflectionFunction类提供了关于给定函数的信息,而ReflectionExtension提供了关于编译到语言中的扩展的信息。表 5-1 列出了 API 中的一些类。

表 5-1。

反射 API 中的关键类

|

|

描述

| | --- | --- | | Reflection | 提供一个静态的export()方法来总结类信息 | | ReflectionAttribute | 关于类、属性、常数或参数的上下文信息 | | ReflectionClass | 课程信息和工具 | | ReflectionClassConstant | 关于常数的信息 | | ReflectionException | 错误类 | | ReflectionExtension | PHP 扩展信息 | | ReflectionFunction | 功能信息和工具 | | ReflectionGenerator | 关于发电机的信息 | | ReflectionMethod | 类方法信息和工具 | | ReflectionNamedType | 关于函数或方法的返回类型的信息(联合返回类型用ReflectionUnionType描述) | | ReflectionObject | 对象信息和工具(继承自ReflectionClass | | ReflectionParameter | 方法参数信息 | | ReflectionProperty | 类别属性信息 | | ReflectionType | 关于函数或方法的返回类型的信息 | | ReflectionUnionType | 联合类型声明的ReflectionType对象集合 | | ReflectionZendExtension | PHP Zend 扩展信息 |

在它们之间,反射 API 中的类提供了对脚本中对象、函数和扩展信息的前所未有的运行时访问。

反射 API 的强大功能意味着你应该优先使用它而不是类和对象函数。你很快就会发现它作为测试类的工具是不可或缺的。例如,您可能想要生成类图或文档,或者您可能想要将对象信息保存到数据库,检查对象的访问器(getter 和 setter)方法以提取字段名。根据命名方案构建一个调用模块类中的方法的框架是反射的另一种用途。

是时候卷起袖子了

您已经遇到了一些用于检查类属性的函数。这些是有用的,但往往是有限的。这里有一个能够胜任这项工作的工具。ReflectionClass提供揭示给定类的每个方面的信息的方法,无论它是用户定义的还是内部的。ReflectionClass的构造函数接受一个类或接口名(或一个对象实例)作为它唯一的参数:

// listing 05.61
$prodclass = new \ReflectionClass(CdProduct::class);
print $prodclass;

一旦创建了一个ReflectionClass对象,您就可以立即转储关于该类的各种信息,只需在字符串上下文中访问它。下面是我为ShopProduct打印我的ReflectionClass实例时生成的输出的节略摘录:

Class [ <user> class popp\ch04\batch02\CdProduct extends popp\ch04\batch02\
ShopProduct ] {
  @@ /var/popp/src/ch04/batch02/CdProduct.php 6-37

  - Constants [2] {
    Constant [ public int AVAILABLE ] { 0 }
    Constant [ public int OUT_OF_STOCK ] { 1 }
}

  - Static properties [0] {
}

  - Static methods [1] {
    Method [ <user, inherits popp\ch04\batch02\ShopProduct> static public method getInstance ] {
      @@ /var/popp/src/ch04/batch02/ShopProduct.php 93 - 130

      - Parameters [2] {
        Parameter #0 [ <required> int $id ]
        Parameter #1 [ <required> PDO $pdo ]
      }
      - Return [ popp\ch04\batch02\ShopProduct ]
    }
  }

  - Properties [3] {
    Property [ private $playLength = 0 ]
    Property [ public $status = NULL ]
    Property [ protected int|float $price ]
  }
...

Note

实用方法Reflection::export()曾经是转储ReflectionClass信息的标准方式。这在 PHP 7.4 中被否决,在 PHP 8.0 中被完全删除

如您所见,ReflectionClass提供了对一个类信息的卓越访问。字符串输出提供了关于CdProduct几乎每个方面的概要信息,包括属性和方法的访问控制状态、每个方法所需的参数以及每个方法在脚本文档中的位置。与更成熟的调试功能相比。var_dump()函数是一个用于汇总数据的通用工具。在提取摘要之前,您必须实例化一个对象,即使这样,它也不能提供类似于ReflectionClass所提供的细节:

// listing 05.62
$cd = new CdProduct("cd1", "bob", "bobbleson", 4, 50);
var_dump($cd);

以下是输出结果:

object(popp\ch04\batch02\CdProduct)#15 (8) {
  ["playLength":"popp\ch04\batch02\CdProduct":private]=>
  int(50)
  ["status"]=>
  NULL
  ["title":"popp\ch04\batch02\ShopProduct":private]=>
  string(3) "cd1"
  ["producerMainName":"popp\ch04\batch02\ShopProduct":private]=>
  string(9) "bobbleson"
  ["producerFirstName":"popp\ch04\batch02\ShopProduct":private]=>
  string(3) "bob"
  ["price":protected]=>
  float(4)
  ["discount":"popp\ch04\batch02\ShopProduct":private]=>
  int(0)
  ["id":"popp\ch04\batch02\ShopProduct":private]=>
  int(0)
}

var_dump()和它的表亲print_r()是在脚本中公开数据的非常方便的工具。对于类和函数,反射 API 将事情带到了一个全新的水平。

检查一节课

一个ReflectionClass实例的原始转储可以为调试提供大量有用的信息,但是我们可以以更专业的方式使用 API。让我们直接使用Reflection类。

您已经看到了如何实例化一个ReflectionClass对象:

// listing 05.63
$prodclass = new \ReflectionClass(CdProduct::class);

接下来,我将使用ReflectionClass对象来研究脚本中的CdProduct。是什么样的课?可以创建实例吗?这里有一个函数来回答这些问题:

// listing 05.64
// class ClassInfo

public static function getData(\ReflectionClass $class): string
{
    $details = "";
    $name = $class->getName();

    $details .= ($class->isUserDefined())  ? "$name is user defined\n"     : "" ;
    $details .= ($class->isInternal())     ? "$name is built-in\n"         : "" ;
    $details .= ($class->isInterface())    ? "$name is interface\n"        : "" ;
    $details  .=  ($class->isAbstract())   ? "$name is an abstract class\n" : "" ;
    $details .= ($class->isFinal())        ? "$name is a final class\n"     : "" ;
    $details .= ($class->isInstantiable()) ? "$name can be instantiated\n"  : "$name can not be instantiated\n" ;
    $details .= ($class->isCloneable())    ? "$name can be cloned\n"        : "$name can not be cloned\n" ;
   return $details;
}

// listing 05.65
$prodclass = new \ReflectionClass(CdProduct::class);
print ClassInfo::getData($prodclass);

我创建了一个ReflectionClass对象,通过将CdProduct类名传递给ReflectionClass的构造函数,将它赋给一个名为$prodclass的变量。然后将$prodclass传递给一个名为ClassInfo::classData()的方法,该方法演示了一些可用于查询类的方法。

这些方法应该是不言自明的,但下面是对其中一些方法的简要描述:

  • ReflectionClass::getName()返回被检查的类的名称。

  • 如果该类已经在 PHP 代码中声明,则ReflectionClass::isUserDefined()方法返回true,如果该类是内置的,则ReflectionClass::isInternal()返回true

  • 你可以用ReflectionClass::isAbstract()测试一个类是否抽象,用ReflectionClass::isInterface()测试它是否是一个接口。

  • 如果你想得到一个类的实例,你可以用ReflectionClass::isInstantiable()来测试它的可行性。

  • 您可以用ReflectionClass::isCloneable() method来检查一个类是否是可克隆的。

  • 您甚至可以检查用户定义的类的源代码。ReflectionClass对象提供对其类的文件名以及文件中该类的开始和结束行的访问。

这里有一个快速的方法,它使用ReflectionClass来访问类的源代码:

// listing 05.66
class ReflectionUtil
{
    public static function getClassSource(\ReflectionClass $class): string
    {
        $path  = $class->getFileName();
        $lines = @file($path);
        $from  = $class->getStartLine();
        $to    = $class->getEndLine();
        $len   = $to - $from + 1;
        return implode(array_slice($lines, $from - 1, $len));
    }
}

// listing 05.67
print ReflectionUtil::getClassSource(
    new \ReflectionClass(CdProduct::class)
);

ReflectionUtil是一个简单的类,只有一个静态方法ReflectionUtil::getClassSource()。该方法将一个ReflectionClass对象作为唯一的参数,并返回被引用类的源代码。ReflectionClass::getFileName()提供类文件的路径作为绝对路径,所以代码应该能够直接打开它。file()获取文件中所有行的数组。ReflectionClass::getStartLine()提供该类的起始行;ReflectionClass::getEndLine()找到最后一行。从那以后,只需使用array_slice()提取感兴趣的行。

为了保持简洁,这段代码省略了错误处理(通过在对file()的调用前放置字符@)。在现实世界的应用中,您需要检查参数和结果代码。

检查方法

正如ReflectionClass用于检查类一样,ReflectionMethod对象检查方法。

你可以从ReflectionClass::getMethods()中得到一个ReflectionMethod对象的数组。或者,如果您需要使用一个特定的方法,ReflectionClass::getMethod()接受一个方法名并返回相关的ReflectionMethod对象。

您也可以直接实例化ReflectionMethod,传递给它一个类/方法字符串、类名和方法名,或者一个对象和方法名。

这些变化看起来可能是这样的:

// listing 05.68
$cd = new CdProduct("cd1", "bob", "bobbleson", 4, 50);
$classname = CdProduct::class;

$rmethod1 = new \ReflectionMethod("{$classname}:: construct");// class/method string
$rmethod2 = new \ReflectionMethod($classname, " construct");// class name and method name
$rmethod3 = new \ReflectionMethod($cd, " construct");// object and method name

这里,我们使用ReflectionClass::getMethods()来测试ReflectionMethod类:

// listing 05.69
$prodclass = new \ReflectionClass(CdProduct::class);
$methods = $prodclass->getMethods();

foreach ($methods as $method) {
    print ClassInfo::methodData($method);
    print "\n----\n";
}

// listing 05.70

// class ClassInfo

public static function methodData(\ReflectionMethod $method): string
{
    $details = "";
    $name = $method->getName();

    $details .= ($method->isUserDefined())    ? "$name is user defined\n"      : ""  ;
    $details .= ($method->isInternal())       ? "$name is built-in\n" : "" ;
    $details .= ($method->isAbstract())       ? "$name is an abstract class\n"      : "" ;
    $details .= ($method->isPublic())         ? "$name is public\n" : "" ;
    $details .= ($method->isProtected())      ? "$name is protected\n"      : "" ;
    $details .= ($method->isPrivate())        ? "$name is private\n" : "" ;
    $details .= ($method->isStatic())         ? "$name is static\n"  : "" ;
    $details .= ($method->isFinal())          ? "$name is final\n"   : "" ;
    $details .= ($method->isConstructor())    ? "$name is the constructor\n"      : "" ;
    $details .= ($method->returnsReference()) ? "$name returns a reference (as opposed to a value)\n"      : "" ;

    return $details;
}

代码使用ReflectionClass::getMethods()来获得一个由ReflectionMethod对象组成的数组,然后遍历该数组,将每个对象传递给methodData()

methodData()中使用的方法名称反映了它们的意图:代码检查方法是用户定义的、内置的、抽象的、公共的、受保护的、静态的还是最终的。您还可以检查该方法是否是其类的构造函数,以及它是否返回引用。

有一个警告:如果被测试的方法只是返回一个对象,那么ReflectionMethod::returnsReference()不会返回true,即使在 PHP 5 中对象是通过引用传递和赋值的。相反,ReflectionMethod::returnsReference()仅在所讨论的方法被显式声明为返回引用时才返回 true(通过在方法名前面放置一个&字符)。

如您所料,您可以使用类似于之前使用ReflectionClass的技术来访问方法的源代码:

// listing 05.71

// class ReflectionUtil
public static function getMethodSource(\ReflectionMethod $method): string
{
    $path  = $method->getFileName();
    $lines = @file($path);
    $from  = $method->getStartLine();
    $to    = $method->getEndLine();
    $len   = $to - $from + 1;
    return implode(array_slice($lines, $from - 1, $len));
}

// listing 05.72
$class = new \ReflectionClass(CdProduct::class);
$method = $class->getMethod('getSummaryLine');
print ReflectionUtil::getMethodSource($method);

因为ReflectionMethod为我们提供了getFileName()getStartLine()getEndLine()方法,所以提取方法的源代码很简单。

检查方法参数

既然方法签名可以约束对象参数的类型,那么检查方法签名中声明的参数的能力就变得非常有用。反射 API 提供了ReflectionParameter类就是为了这个目的。要得到一个ReflectionParameter对象,你需要一个ReflectionMethod对象的帮助。ReflectionMethod::getParameters()方法返回一个ReflectionParameter对象的数组。

也可以用通常的方法直接实例化一个ReflectionParameter对象。ReflectionParameter 的构造函数需要一个callable参数和一个表示参数编号的整数(索引为零)或一个表示参数名称的字符串。

所以,这四个实例化是等价的。每个都为CdProduct类的构造函数的第二个参数建立了一个ReflectionParameter对象。

// listing 05.73
$classname = CdProduct::class;

$rparam1 = new \ReflectionParameter([$classname, "__construct"], 1);
$rparam2 = new \ReflectionParameter([$classname, "__construct"], "firstName");

$cd = new CdProduct("cd1", "bob", "bobbleson", 4, 50);
$rparam3 = new \ReflectionParameter([$cd, "__construct"], 1);
$rparam4 = new \ReflectionParameter([$cd, "__construct"], "firstName");

ReflectionParameter可以告诉您参数的名称以及变量是否通过引用传递(即,在方法声明中前面有一个&符号)。它还可以告诉您参数提示所需的类,以及该方法是否接受参数的空值。

下面是一些ReflectionParameter的方法:

// listing 05.74
$class = new \ReflectionClass(CdProduct::class);

$method = $class->getMethod("__construct");
$params = $method->getParameters();

foreach ($params as $param) {
    print ClassInfo::argData($param) . "\n";
}

// listing 05.75

// class ClassInfo
public static function argData(\ReflectionParameter $arg): string
{
    $details = "";
    $declaringclass = $arg->getDeclaringClass();
    $name = $arg->getName();

    $position = $arg->getPosition();
    $details .= "\$$name has position $position\n";
    if ($arg->hasType()) {
        $type = $arg->getType();
        $typenames = [];
        if ($type instanceof \ReflectionUnionType) {
            $types = $type->getTypes();
            foreach ($types as $utype) {
                     $typenames[] = $utype->getName();
            }
        } else {
            $typenames[] = $type->getName();
        }
        $typename = implode("|",  $typenames);
        $details .= "\$$name should be type {$typename}\n";
    }

    if ($arg->isPassedByReference()) {
        $details .= "\${$name} is passed by reference\n";
    }

    if ($arg->isDefaultValueAvailable()) {
        $def = $arg->getDefaultValue();
        $details .= "\${$name} has default: $def\n";
    }
    if ($arg->allowsNull()) {
        $details .= "\${$name} can be null\n";
    }

    return $details;
}

使用ReflectionClass::getMethod()方法,代码获得一个ReflectionMethod对象。然后它使用ReflectionMethod::getParameters()来获得一个ReflectionParameter对象的数组。argData()函数使用传递给它的ReflectionParameter对象来获取关于参数的信息。

首先,它用ReflectionParameter::getName()获取参数的变量名。如果指定了类型,则ReflectionParameter::getType()方法返回一个ReflectionType对象,如果指定的类型是联合类型,则返回一个ReflectionUnionType类。无论从哪一个返回,都将构造一个表示所需类型的字符串。然后,代码检查参数是否是对isPassedByReference();的引用,最后,它查找默认值的可用性,然后将其添加到返回字符串中。

使用反射 API

有了反射 API 的基础知识,现在就可以让 API 工作了。

假设您正在创建一个动态调用Module对象的类。也就是说,它可以接受由第三方编写的插件,这些插件可以嵌入到应用中,而不需要任何硬编码。为了实现这一点,您可以在Module接口或抽象基类中定义一个execute()方法,强制所有子类定义一个实现。您可以允许系统用户在外部 XML 配置文件中列出Module类。在对每个对象调用execute()之前,您的系统可以使用这些信息来聚集一些Module对象。

然而,如果每个Module需要不同的信息来完成它的工作,会发生什么呢?在这种情况下,XML 文件可以为每个Module提供属性键和值,每个Module的创建者可以为每个属性名提供 setter 方法。有了这个基础,就要靠代码来确保为正确的属性名调用正确的 setter 方法。

这里有一些关于Module接口和几个实现类的基础:

// listing 05.76
class Person
{
    public $name;

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

// listing 05.77
interface Module
{
    public function execute(): void;
}

// listing 05.78
class FtpModule implements Module
{
    public function setHost(string $host): void
    {
        print "FtpModule::setHost(): $host\n";
    }

    public function setUser(string|int $user): void
    {
        print "FtpModule::setUser(): $user\n";
    }

    public function execute(): void
    {
        // do things
    }
}

// listing 05.79
class PersonModule implements Module
{
    public function setPerson(Person $person): void
    {
        print "PersonModule::setPerson(): {$person->name}\n";
    }

    public function execute(): void
    {
        // do things
    }
}

这里,PersonModuleFtpModule都提供了execute()方法的空实现。每个类还实现 setter 方法,这些方法除了报告它们被调用之外什么也不做。系统规定了所有 setter 方法必须有一个参数的约定:或者是一个字符串,或者是一个可以用一个字符串参数实例化的对象。PersonModule::setPerson()方法需要一个Person对象,所以我在例子中包含了一个Person类。

为了使用PersonModuleFtpModule,下一步是创建一个ModuleRunner类。它将使用由模块名索引的多维数组来表示 XML 文件中提供的配置信息。下面是代码:

// listing 05.80
class ModuleRunner

{
    private array $configData = [
        PersonModule::class => ['person' => 'bob'],
        FtpModule::class    => [
            'host' => 'example.com',
            'user' => 'anon'
        ]
    ];

    private array $modules = [];

    // ...
}

ModuleRunner::$configData属性包含对两个Module类的引用。对于每个模块元素,代码维护一个包含一组属性的子数组。ModuleRunnerinit()方法负责创建正确的Module对象,如下所示:

// listing 05.81

// class ModuleRunner
public function init(): void
{
    $interface = new \ReflectionClass(Module::class);
    foreach ($this->configData as $modulename => $params) {
        $module_class = new \ReflectionClass($modulename);
        if (! $module_class->isSubclassOf($interface)) {
            throw new Exception("unknown module type: $modulename");
        }
        $module = $module_class->newInstance();
        foreach ($module_class->getMethods() as $method) {
            $this->handleMethod($module, $method, $params);
            // we cover handleMethod() in a future listing!
        }
        array_push($this->modules, $module);
    }
}

// listing 05.82
$test = new ModuleRunner();
$test->init();

init()方法遍历ModuleRunner::$configData数组,对于每个模块元素,它试图创建一个ReflectionClass对象。当用一个不存在的类名调用ReflectionClass的构造函数时会产生一个异常,所以在现实环境中,我会在这里包含更多的错误处理。我使用ReflectionClass::isSubclassOf()方法来确保模块类属于Module类型。

在调用每个Moduleexecute()方法之前,必须创建一个实例。这就是ReflectionClass::newInstance()的目的。该方法接受任意数量的参数,并将其传递给相关类的构造函数方法。如果一切正常,它将返回该类的一个实例(对于生产代码,一定要谨慎编码:在创建实例之前,检查每个Module对象的构造函数方法是否不需要参数)。

ReflectionClass::getMethods()返回该类可用的所有ReflectionMethod对象的数组。对于数组中的每个元素,代码都会调用ModuleRunner::handleMethod()方法。然后传递给它一个Module实例、ReflectionMethod对象和一个与Module关联的属性数组。handleMethod()验证并调用Module对象的 setter 方法:

// listing 05.83

// class ModuleRunner
public function handleMethod(Module $module, \ReflectionMethod $method, array $params):
bool
{
    $name = $method->getName();
    $args = $method->getParameters();

    if (count($args) != 1 || substr($name, 0, 3) != "set") {
        return  false;
    }

    $property = strtolower(substr($name, 3));

    if (! isset($params[$property])) {
        return false;
    }

    if (! $args[0]->hasType()) {
        $method->invoke($module, $params[$property]);
        return true;
    }

    $arg_type = $args[0]->getType();

    if (! ($arg_type instanceof \ReflectionUnionType) && class_exists($arg_type->getName())) {
        $method->invoke(
            $module,
            (new \ReflectionClass($arg_type->getName()))->newInstance($params[$property])
        );
    } else {
        $method->invoke($module, $params[$property]);
    }
    return true;
}

handleMethod()首先检查该方法是否是有效的 setter。在代码中,一个有效的 setter 方法必须被命名为setXXXX(),并且必须声明一个——并且只能声明一个——参数。

假设参数检查通过,然后代码从方法名中提取一个属性名,方法是从方法名的开头删除set,并将结果子串转换成小写字符。该字符串用于测试$params数组参数。该数组包含用户提供的属性,这些属性将与Module对象相关联。如果$params数组不包含属性,代码放弃并返回false

如果从模块方法中提取的属性名与$params数组中的一个元素匹配,我可以继续调用正确的 setter 方法。为此,代码必须检查 setter 方法的第一个(也是唯一一个)必需参数的类型。如果参数有一个类型声明(ReflectionParameter::hasType()),并且指定的类型解析为一个类,那么我们知道该方法需要一个对象。否则,我们假设它需要一个原语。

为了调用 setter 方法,我需要新的反射 API 方法。ReflectionMethod::invoke()需要一个对象(或静态方法的null)和任意数量的方法参数来传递给它所代表的方法。ReflectionMethod::invoke()如果提供的对象与其方法不匹配,抛出异常。我以两种方式之一调用这个方法。如果 setter 方法不需要对象参数,我用用户提供的属性字符串调用ReflectionMethod::invoke()。如果这个方法需要一个对象(我可以通过使用类型名的class_exists来测试),我使用属性字符串来实例化一个正确类型的对象。然后将它传递给 setter。

该示例假定所需的对象可以用其构造函数的单个字符串参数进行实例化。当然,最好在调用ReflectionClass::newInstance()之前检查一下这个。

ModuleRunner::init()方法完成它的过程时,对象已经有了一个Module对象的存储库,所有的都以数据为基础。现在可以给这个类一个方法来遍历Module对象,对每个对象调用execute()

属性

许多语言都提供了一种机制,通过这种机制,代码可以使用源文件中的特殊标记。这些通常被称为注释。尽管在 PHP 8 之前,PHP 包中已经有了一些 userland 实现(特别是,例如,Doctrine 数据库库和 Symfony 路由组件),但是在语言层面上还没有对这个特性的支持。随着属性的引入,这种情况有所改变。

本质上,属性是一个特殊的标记,它允许您向类、方法、属性、参数或常数添加附加信息。通过反射,系统可以获得这些信息。

那么你能用注释做什么呢?通常,一个方法可能会提供更多关于其预期使用方式的信息。例如,客户端代码可能会扫描一个类来发现应该自动运行的方法。随着我们的进展,我将提到其他用例。

让我们声明并访问一个注释:

// listing 05.84
namespace popp\ch05\batch09;

#[info]
class Person
{
}

因此,用一个由#[]包围的字符串标记来声明一个注释。在这种情况下,我选择了#[info]。在许多代码示例中,我排除了名称空间声明,因为代码在声明的名称空间或main中同样运行良好。不过,在这种情况下,值得注意的是名称空间。我将回到这一点。

现在要访问注释:

// listing 05.85
$rpers = new \ReflectionClass(Person::class);
$attrs = $rpers->getAttributes();
foreach ($attrs as $attr) {
    print $attr->getName() . "\n";
}

我实例化了一个ReflectionClass对象,这样我就可以检查Person。然后我调用了getAttributes()方法。这将返回一个由ReflectionAttribute对象组成的数组。ReflectionAttribute::getName()返回我声明的属性的名称。

以下是输出:

popp\ch05\batch09\info

因此,在我的输出中,注释是命名空间的。名称的popp\ch05\batch09部分是隐含的。我可以根据引用类的规则和别名来引用注释。因此在popp\ch05\batch09名称空间中声明[#info]等同于在其他地方声明[#\popp\ch05\batch09\info]。事实上,正如您将看到的,您甚至可以声明一个可以为您引用的任何属性实例化的类。

注释可以应用于 PHP 的各个方面。表 5-2 列出了可以注释的特性以及相应的反射类。

表 5-2。

适合注释的 PHP 特性

|

特征

|

获得物ˌ获得

| | --- | --- | | 类 | ReflectionClass::getAttributes() | | 财产 | ReflectionProperty::getAttributes() | | 功能/方法 | ReflectionFunction::getAttributes() | | 常数 | ReflectionConstant::getAttributes() |

下面是一个应用于方法的属性示例:

// listing 05.86
#[moreinfo]
public function setName(string $name): void
{
    $this->name = $name;
}

现在访问它。您应该会发现这个过程非常熟悉:

// listing 05.87
$rpers = new \ReflectionClass(Person::class);
$rmeth = $rpers->getMethod("setName");
$attrs = $rmeth->getAttributes();
foreach ($attrs as $attr) {
    print $attr->getName() . "\n";
}

输出现在应该也很熟悉了。我们显示了到moreinfo的完全命名空间路径。

popp\ch05\batch09\moreinfo

到目前为止,您已经看到了一些有用的东西。我们可以包含一个属性作为某种标志。例如,Debug属性可以与只在开发过程中调用的方法相关联。然而,属性还有更多。我们可以定义一个类型,并通过参数提供进一步的信息。这开启了新的可能性。在路由库中,我可能会断言一个方法应该映射到的 URL 端点。在事件系统中,属性可能表示一个类或方法应该与一个特定的事件相关联。

在这个例子中,我定义了一个包含两个参数的属性:

// listing 05.88
#[ApiInfo("The 3 digit company identifier", "A five character department tag")]
public function setInfo(int $companyid, string $department): void
{
    $this->companyid = $companyid;
    $this->department = $department;
}

一旦我获得了一个ReflectionAttribute对象,我就可以使用getArguments()方法访问参数。

// listing 05.89
$rpers = new \ReflectionClass(Person::class);
$rmeth = $rpers->getMethod("setInfo");
$attrs = $rmeth->getAttributes();
foreach ($attrs as $attr) {
    print $attr->getName() . "\n";
    foreach ($attr->getArguments() as $arg) {
        print "  - $arg\n";
    }
}

以下是输出:

popp\ch05\batch09\ApiInfo
  - The 3 digit company identifier
  - A five character department tag

正如我提到的,您可以显式地将一个属性映射到一个类。下面是一个简单的ApiInfo类:

// listing 05.90
namespace popp\ch05\batch09;

use Attribute;
#[Attribute]

class ApiInfo
{
    public function __construct(public string $compinfo, public string $depinfo)
    {
    }
}

为了正确地在属性和我的类之间建立关联,我必须记住use Attribute并将内置的[#Attribute]应用于类。

在实例化时,关联属性的任何参数都会自动传递给相应类的构造函数。在这种情况下,我只是将数据分配给相应的属性。在现实世界的应用中,我可能会执行一些额外的处理或提供相关的功能来证明类的声明是正确的。

理解属性类不会自动调用是很重要的。我们必须通过ReflectionAttribute::newInstance()做到这一点。在这里,我修改了我的客户机代码,使之适用于新的类:

// listing 05.91
$rpers = new \ReflectionClass(Person::class);
$rmeth = $rpers->getMethod("setInfo");
$attrs = $rmeth->getAttributes();
foreach ($attrs  as  $attr)  {
    print $attr->getName() . "\n";
    $attrobj = $attr->newInstance();
    print "  - " . $attrobj->compinfo . "\n";
    print "  - " . $attrobj->depinfo . "\n";
}

虽然我是通过ApiInfo对象访问属性数据,但是这里的效果是一样的。我调用ReflectionAttribute::newInstance(),然后访问填充的属性。

等等,虽然!最后一个例子有一个深刻的、潜在的致命缺陷。一个方法可以添加多个属性。因此,我们不能确定分配给setInfo()方法的每个属性都是ApiInfo的实例。那些对ApiInfo::$compinfoApiInfo::$depinfo的属性访问对于任何不属于ApiInfo类型的属性必定会失败。

幸运的是,我们可以对getAttributes()应用过滤器:

// listing 05.92
$rpers = new \ReflectionClass(Person::class);
$rmeth = $rpers->getMethod("setInfo");
$attrs = $rmeth->getAttributes(ApiInfo::class);

现在,将只返回与ApiInfo::class完全匹配的内容——使得代码的其余部分变得安全。我们可以像这样进一步放松一下:

// listing 05.93
$rpers = new \ReflectionClass(Person::class);
$rmeth = $rpers->getMethod("setInfo");
$attrs = $rmeth->getAttributes(ApiInfo::class, \ReflectionAttribute::IS_INSTANCEOF);

通过将第二个参数ReflectionAttribute::IS_INSTANCEOF传递给ReflectionAttribute::getAttributes(),我放松了过滤器以匹配指定的类以及任何扩展或实现的子类或接口。

表 5-3 列出了我们遇到的 ReflectionAttribute 的方法。

表 5-3。

一些反射属性方法

|

方法

|

描述

| | --- | --- | | getName() | 返回属性的完整命名空间类型 | | getArguments() | 返回与被引用属性相关联的所有参数的数组 | | newInstance() | 实例化并返回属性类的实例,将任何参数传递给构造函数 |

Note

在第九章中,我通过一个更加复杂的属性用法的例子来工作。

摘要

在这一章中,我介绍了一些你可以用来管理你的库和类的技术和工具。我研究了 PHP 的名称空间特性。您已经看到,我们可以将包含路径、名称空间、自动加载和文件系统结合起来,为类提供灵活的组织。

我们还研究了 PHP 的对象和类函数,然后用强大的反射 API 将事情推进到下一个级别。我们使用Reflection类构建了一个简单的例子,展示了Reflection必须提供的一个潜在用途。最后,我们将Reflection类与属性结合起来:这是 PHP 8 的一个主要新特性。

六、对象和设计

既然我们已经详细了解了 PHP 对象支持的机制,我们将从细节上退一步,考虑如何最好地使用我们遇到的工具。在这一章中,我将向你介绍一些关于对象和设计的问题。我还将研究 UML,一种用于描述面向对象系统的强大的图形语言。

本章将涵盖以下主题:

  • 设计基础知识:我所说的设计是什么,面向对象的设计与过程代码有什么不同

  • 类范围:如何决定一个类包含什么

  • 封装:将实现和数据隐藏在类的接口后面

  • 多态性:使用一个公共超类型来允许在运行时透明地替换专门化的子类型

  • UML :使用图来描述面向对象的架构

定义代码设计

代码设计的一个意义涉及系统的定义:确定系统的需求、范围和目标。系统需要做什么?它需要为谁做这件事?系统的输出是什么?它们满足陈述的需求吗?在较低的层面上,设计可以被理解为定义系统参与者并组织他们之间关系的过程。本章关注的是第二种意义:类和对象的定义和配置。

那么什么是参与者呢?面向对象的系统是由类组成的。在你的系统中决定这些玩家的性质是很重要的。类部分是由方法组成的;所以在定义你的类时,你必须决定哪些方法属于同一个类。但是,正如您将看到的,类通常在继承关系中组合在一起,以符合公共接口。在设计系统时,这些接口或类型应该是您的第一选择。

您还可以为您的类定义其他关系。您可以创建由其他类型组成的类或管理其他类型实例列表的类。您可以设计简单使用其他对象的类。这种组合或使用关系的可能性是内置在您的类中的(例如,通过在方法签名中使用类型声明),但是实际的对象关系发生在运行时,这可以为您的设计增加灵活性。你将在本章中看到如何建立这些关系的模型,我们将在整本书中进一步探讨它们。

作为设计过程的一部分,您必须决定一个操作何时应该属于一个类型,何时应该属于该类型使用的另一个类。无论你走到哪里,你都会面临选择和决定,这些选择和决定可能会让你变得清晰和优雅,也可能会让你陷入妥协的泥沼。

在这一章中,我将探讨一些可能影响其中一些选择的问题。

面向对象和过程编程

面向对象的设计与更传统的过程化代码有什么不同?很容易说主要的区别是面向对象的代码中有对象。这既不真实也没用。在 PHP 中,你经常会发现使用对象的程序代码。您还可能遇到包含程序代码片段的类。类的存在并不能保证面向对象的设计,即使在 Java 这样的语言中也是如此,它迫使你在一个类中做大多数事情。

面向对象和过程化代码的一个核心区别在于责任的分配方式。过程代码采取一系列连续的命令和函数调用的形式。控制代码倾向于负责处理不同的情况。这种自上而下的控制会导致项目中重复和依赖的发展。面向对象的代码试图通过将处理任务的责任从客户端代码转移到系统中的对象来最小化这些依赖性。

在这一节中,我将设置一个简单的问题,然后从面向对象和过程代码两个方面来分析它,以说明这些要点。我的项目是建立一个快速的工具来读取和写入配置文件。为了保持对代码结构的关注,我将在这些例子中省略实现细节。

我将从解决这个问题的程序方法开始。首先,我将以这种格式读写文本:

key:value

为此,我只需要两个函数:

// listing 06.01
function readParams(string $source): array
{
    $params = [];
    // read text parameters from $source
    return $params;
}

function writeParams(array $params, string $source): void
{
    // write text parameters to $source
}

readParams函数需要源文件的名称。它试图打开它并读取每一行,寻找键/值对。它构建了一个关联数组。最后,它将数组返回给控制代码。writeParams()接受关联数组和源文件的路径。它遍历关联数组,将每个键/值对写入文件。下面是一些使用这些函数的客户端代码:

// listing 06.02
$file = "/tmp/params.txt";
$params = [
    "key1" => "val1",
    "key2" => "val2",
    "key3" => "val3",
];
writeParams($params, $file);
$output = readParams($file);
print_r($output);

这段代码相对紧凑,应该易于维护。调用writeParams()函数来创建param.txt,并向其写入如下内容:

key1:val1
key2:val2
key3:val3

readParams()函数解析相同的格式。

在许多项目中,范围不断扩大和发展。让我们通过引入一个新的需求来掩饰这一点。代码现在还必须处理如下所示的 XML 结构:

<params>
    <param>
        <key>my key</key>
        <val>my val</val>
    </param>
</params>

如果参数文件以.xml结尾,则应以 XML 模式读取参数文件。尽管这并不难适应,但它可能会使我的代码更难维护。现阶段我真的有两个选择。我可以检查控制代码中的文件扩展名,或者在我的读写函数中进行测试。在这里,我倾向于后一种方法:

// listing 06.03
function readParams(string $source): array
{
    $params = [];
    if (preg_match("/\.xml$/i", $source)) {
        // read XML parameters from $source
    } else {
        // read text parameters from $source
    }
    return $params;
}

function writeParams(array $params, string $source): void
{
    if (preg_match("/\.xml$/i", $source)) {
        // write XML parameters to $source
    } else {
        // write text parameters to $source
    }
}

Note

说明性代码总是包含一个困难的平衡动作。它需要足够清楚地表明自己的观点,这通常意味着为了表面上的目的而牺牲错误检查和适用性。换句话说,这里的例子实际上是为了说明设计和复制的问题,而不是解析和写入文件数据的最佳方式。为此,我省略了与当前问题无关的实现。

如您所见,我不得不在每个函数中使用 XML 扩展测试。这种重复可能会给我们带来问题。如果我被要求包含另一种参数格式,我需要记住保持readParams()writeParams()函数相互一致。

现在我将用一些简单的类来解决同样的问题。首先,我创建一个抽象基类,它将定义类型的接口:

// listing 06.04
abstract class ParamHandler
{
    protected array $params = [];

    public function __construct(protected string $source)
    {
    }

    public function addParam(string $key, string $val): void
    {
        $this->params[$key] = $val;
    }

    public function getAllParams(): array
    {
        return $this->params;
    }

    public static function getInstance(string $filename): ParamHandler
    {
        if (preg_match("/\.xml$/i",  $filename))  {
            return new XmlParamHandler($filename);
        }
        return new TextParamHandler($filename);
    }

    abstract public function write(): void;
    abstract public function read(): void;
}

我定义了addParam()方法,允许用户向受保护的$params属性和getAllParams()添加参数,以提供对数组副本的访问。

我还创建了一个静态的getInstance()方法来测试文件扩展名,并根据结果返回一个特定的子类。关键的是,我定义了两个抽象方法,read()write(),确保任何子类都支持这个接口。

Note

将用于生成子对象的静态方法放在父类中很方便。然而,这样的设计决策有其自身的后果。ParamHandler类型现在基本上仅限于处理这个中央条件语句中的具体类。如果需要处理另一种格式,会发生什么?当然,如果你是ParamHandler的维护者,你可以随时修改getInstance()的方法。然而,如果您是一名客户端编码人员,更改这个库类可能不那么容易(事实上,更改它并不难,但是您面临的前景是,每次重新安装提供它的包时,都必须重新应用您的补丁)。我将在第九章中讨论物体创建的问题。

现在,我将定义子类,再次省略实现的细节以保持示例的简洁:

// listing 06.05
class XmlParamHandler extends ParamHandler
{

    public function write(): void
    {
        // write XML
        // using $this->params
    }

    public function read(): void
    {
        // read XML
        // and populate $this->params
    }
}

// listing 06.06
class TextParamHandler extends ParamHandler
{

    public function write(): void
    {
        // write text
        // using $this->params
    }

    public function read(): void
    {
        // read text
        // and populate $this->params
    }
}

这些类只是提供了write()read()方法的实现。每个类将根据适当的格式写作和阅读。

根据文件扩展名,客户端代码将完全透明地写入文本和 XML 格式:

// listing 06.07
$test = ParamHandler::getInstance(__DIR__ . "/params.xml");
$test->addParam("key1", "val1");
$test->addParam("key2", "val2");
$test->addParam("key3", "val3");
$test->write(); // writing in XML format

我们也可以从任一文件格式中读取:

// listing 06.08
$test = ParamHandler::getInstance(__DIR__ . "/params.txt");
$test->read(); // reading in text format
$params = $test->getAllParams();
print_r($params);

那么,我们能从这两种方法中学到什么呢?

责任

过程示例中的控制代码负责决定格式——不是一次,而是两次。当然,条件代码被整理成函数,但这只是掩盖了单个流程的事实,即在流程进行时做出决策。对readParams()writeParams()的调用发生在不同的上下文中,所以我们被迫在每个函数中重复文件扩展名测试(或者对这个测试执行不同的操作)。

在面向对象版本中,关于文件格式的选择是在静态getInstance()方法中进行的,该方法只测试一次文件扩展名,提供正确的子类。客户端代码不负责实现。它使用所提供的对象,而不知道或对它所属的特定子类不感兴趣。它只知道它正在处理一个ParamHandler对象,并且它将支持write()read()。过程代码忙于细节,而面向对象的代码只处理接口,不关心实现的细节。因为实现的责任在于对象,而不在于客户机代码,所以很容易透明地支持新格式。

内聚力

内聚性是最接近的过程相互关联的程度。理想情况下,您应该创建共享明确职责的组件。如果您的代码广泛传播相关的例程,您会发现它们更难维护,因为您必须四处搜寻以进行更改。

我们的ParamHandler类将相关的过程收集到一个公共的上下文中。处理 XML 的方法共享一个上下文,在这个上下文中,它们可以共享数据,并且如果需要的话,对一个方法的更改可以很容易地反映在另一个方法中(例如,如果您需要更改 XML 元素名称)。因此可以说ParamHandler类具有很高的内聚性。

另一方面,过程示例将相关的过程分开。使用 XML 的代码分布在不同的函数中。

耦合

当系统代码的离散部分彼此紧密结合在一起,以至于一个部分的变化必然导致其他部分的变化时,紧耦合就发生了。紧密耦合绝不是过程代码所独有的,尽管这种代码的顺序性质使它容易出现问题。

您可以在程序示例中看到这种耦合。writeParams()readParams()函数对文件扩展名运行相同的测试,以确定它们应该如何处理数据。您对其中一个进行的任何逻辑更改都必须在另一个中实现。例如,如果您要添加一种新的格式,您必须使这些函数相互一致,以便它们都以相同的方式实现新的文件扩展名测试。随着您添加新的与参数相关的函数,这个问题只会变得更糟。

面向对象的示例将各个子类彼此分离,并与客户端代码分离。如果需要添加新的参数格式,可以简单地创建一个新的子类,修改静态getInstance()方法中的一个测试。

正交性

具有严格定义的职责并且独立于更广泛的系统的组件的杀手级组合有时被称为正交性。安德鲁·亨特和戴维·托马斯在他们的书《实用主义程序员 20 周年纪念版》中讨论了这个问题。

有人认为,正交性促进了重用,因为组件可以插入新系统,而不需要任何特殊的配置。这些组成部分将有明确的输入和输出,独立于任何更广泛的背景。正交代码使更改更容易,因为更改实现的影响将局限于被更改的组件。最后,正交码更安全。bug 的影响范围应该是有限的。高度相互依赖的代码中的错误很容易在更广泛的系统中引起连锁反应。

在类上下文中,松散耦合和高内聚并不是自动的。毕竟,我们可以将整个过程性的例子嵌入到一个被误导的类中。那么,我们如何在代码中实现这种平衡呢?我通常从考虑应该存在于我的系统中的类开始。

选择你的课程

定义你的类的边界是非常困难的,特别是当它们随着你构建的任何系统而发展的时候。

当你对真实世界建模时,这看起来很简单。面向对象的系统通常以真实事物的软件表示为特色——有大量的PersonInvoiceShop类。这似乎表明定义一个类就是在你的系统中找到事物,然后通过方法给它们代理。这是一个不错的起点,但确实有其危险性。如果你把一个类看作一个名词,一个任意数量的动词的主语,那么你可能会发现它膨胀了,因为正在进行的开发和需求变化要求它做越来越多的事情。

让我们考虑一下我们在第三章中创建的ShopProduct示例。我们的系统存在是为了向客户提供产品,所以定义一个ShopProduct类是一个显而易见的选择。但这是我们需要做的唯一决定吗?我们提供了诸如getTitle()getPrice()的方法来访问产品数据。当我们被要求提供一种输出发票和交货通知汇总信息的机制时,定义一个write()方法似乎是有意义的。当客户要求我们提供不同格式的产品摘要时,我们再次查看我们的类。除了write()方法之外,我们适时地创建了writeXML()writeHTML()方法。或者我们给write()添加条件代码,根据一个选项标志输出不同的格式。

不管怎样,这里的问题是ShopProduct类现在试图做的太多了。它正在努力管理展示策略和产品数据。

你应该如何考虑定义类?最好的方法是认为一个类有一个主要的责任,并且尽可能地使这个责任单一和集中。把责任用语言表达出来。有人说过,你应该能够用 25 个或更少的单词来描述一个类的责任,很少使用“和”或“或”这样的词如果你的句子太长或陷入子句中,可能是时候考虑按照你描述的一些职责定义新的类了。

所以,ShopProduct类负责管理产品数据。如果我们增加了不同格式的写作方法,我们就开始增加一个新的责任领域:产品展示。正如你在第三章中看到的,我们实际上根据这些不同的职责定义了两种类型。ShopProduct类型仍然负责产品数据,而ShopProductWriter类型负责显示产品信息。各个子类细化了这些职责。

Note

很少有设计规则是完全不灵活的。例如,您有时会看到在一个不相关的类中保存对象数据的代码。虽然这似乎违反了一个类应该有一个单独职责的规则,但它可能是功能存在的最方便的地方,因为一个方法必须拥有对实例字段的完全访问权。使用本地持久化方法还可以避免我们创建一个并行的持久化类层次结构来镜像我们的可保存类,从而引入不可避免的耦合。我们将在第十二章中讨论对象持久化的其他策略。避免宗教式的遵守设计规则;他们不能代替你分析面前的问题。努力保持对规则背后的推理的关注,并强调这一点胜过规则本身。

多态性

多态性,或者说类切换,是面向对象系统的一个常见特征。你已经在这本书里遇到过几次了。

多态是在一个公共接口后面维护多个实现。这听起来很复杂,但实际上你现在应该很熟悉了。代码中大量条件语句的出现通常表明了对多态性的需求。

当我在第三章中第一次创建ShopProduct类时,我尝试了一个单独的类,它管理书籍和 CD 的功能,以及通用产品。为了提供汇总信息,我依赖一个条件语句:

// listing 06.09
public function getSummaryLine(): string
{
    $base = "{$this->title} ( {$this->producerMainName}, ";
    $base .= "{$this->producerFirstName} )";
    if ($this->type == 'book') {
        $base .= ": page count - {$this->numPages}";
    } elseif ($this->type == 'cd') {
        $base .= ": playing time - {$this->playLength}";
    }
    return $base;
}

这些陈述暗示了两个子类的形状:CdProductBookProduct

出于同样的原因,我的过程参数示例中的条件语句包含了我最终实现的面向对象结构的种子。我在脚本的两个部分重复了相同的条件:

// listing 06.10
function readParams(string $source): array
{
    $params = [];
    if (preg_match("/\.xml$/i", $source)) {
        // read XML parameters from $source
    } else {
        // read text parameters from $source
    }
    return $params;
}

function writeParams(array $params, string $source): void
{
    if (preg_match("/\.xml$/i", $source)) {
        // write XML parameters to $source
    } else {
        // write text parameters to $source
    }
}

每个子句都暗示了我最终产生的一个子类:XmlParamHandlerTextParamHandler。这些扩展了抽象基类ParamHandlerwrite()read()的方法:

// listing 06.11
// could return XmlParamHandler or TextParamHandler
$test = ParamHandler::getInstance($file);

$test->read(); // could be XmlParamHandler::read() or TextParamHandler::read()
$test->addParam("newkey1", "newval1");
$test->write(); // could be XmlParamHandler::write() or TextParamHandler::write()

值得注意的是,多态并不排斥条件句。像ParamHandler::getInstance()这样的方法通常会根据switchif语句来决定返回哪些对象。但是,这些倾向于将条件代码集中到一个地方。

正如您所看到的,PHP 强制执行由抽象类定义的接口。这是有帮助的,因为我们可以确定一个具体的子类将支持与那些由抽象父类定义的方法签名完全相同的方法签名。这包括类型声明和访问控制。因此,客户端代码可以互换地对待一个公共超类的所有子类(只要它只依赖于父类中定义的功能)。

包装

封装仅仅意味着对客户端隐藏数据和功能。再说一次,它是一个关键的面向对象的概念。

在最简单的层面上,通过声明属性privateprotected来封装数据。通过对客户端代码隐藏属性,可以强制实施接口并防止对象数据的意外损坏。

多态说明了另一种封装。通过将不同的实现放在一个公共接口后面,您可以对客户端隐藏这些底层策略。这意味着在这个接口后面所做的任何更改对更广泛的系统都是透明的。您可以添加新类或更改类中的代码,而不会导致错误。重要的是接口,而不是接口下的工作机制。这些机制保持得越独立,变更或修复在您的项目中产生连锁反应的机会就越小。

在某些方面,封装是面向对象编程的关键。你的目标应该是使每个部分尽可能独立于其他部分。类和方法应该接收尽可能多的信息来执行分配给它们的任务,这些任务应该限制在一定的范围内,并清楚地标识出来。

privateprotectedpublic关键字的引入使得封装变得更加容易。然而,封装也是一种精神状态。PHP 4 没有为隐藏数据提供正式的支持。隐私必须使用文档和命名约定来表示。例如,下划线是表示私有属性的常用方式:

var $_touchezpas;

当然,代码必须被仔细检查,因为隐私没有被严格执行。不过有趣的是,错误很少发生,因为代码的结构和风格非常清楚地表明了哪些属性不需要处理。

出于同样的原因,即使在 PHP 5 到来之后,我们也可以打破规则,通过使用instanceof操作符来发现我们在类切换上下文中使用的对象的确切子类型:

// listing 06.12
public function workWithProducts(ShopProduct $prod)
{
    if ($prod instanceof CdProduct) {
        // do cd thing
    } elseif ($prod instanceof BookProduct) {
        // do book thing
    }
}

你可能有一个很好的理由这样做,但是,一般来说,它带有一点不确定的气味。通过查询示例中的特定子类型,我建立了一个依赖关系。虽然子类型的细节被多态隐藏了,但是完全改变ShopProduct继承层次结构而没有不良影响是可能的。这段代码结束了这一切。现在,如果我需要合理化CdProductBookProduct类,我可能会在workWithProducts()方法中产生意想不到的副作用。

从这个例子中可以吸取两个教训。首先,封装有助于创建正交码。第二,封装的可实施程度无关紧要。封装是一种技术,应该被类和它们的客户同等地遵守。

忘记怎么做了

如果你像我一样,提到一个问题会让你的思维加速,寻找可能提供解决方案的机制。您可能会选择能够解决某个问题的函数,重新使用巧妙的正则表达式,并跟踪 Composer 包。您可能在一个旧项目中有一些可粘贴的代码,做一些类似的事情。在设计阶段,你可以把所有这些都放在一边一段时间。清空你头脑中的程序和机制。

只考虑系统的关键参与者:它需要的类型和它们的接口。当然,你对过程的了解会影响你的思维。打开文件的类需要路径,数据库代码需要管理表名和密码,等等。但是,让代码中的结构和关系引导你。您会发现,在定义良好的接口背后,实现很容易到位。然后,如果需要,您可以灵活地切换、改进或扩展实现,而不会影响更广泛的系统。

为了强调接口,考虑抽象基类或接口,而不是具体的孩子。例如,在我获取参数的代码中,接口是设计中最重要的方面。我想要一个读写名称/值对的类型。对于类型来说,重要的是这种责任,而不是实际的持久性介质或存储和检索数据的方式。我围绕抽象的ParamHandler类来设计系统,并且只在稍后添加实际读写参数的具体策略。这样,我从一开始就将多态和封装构建到我的系统中。该结构有助于类别切换。

当然,话虽如此,我从一开始就知道会有文本和 XML 实现ParamHandler,毫无疑问这影响了我的界面。在设计界面时,总会有一些心理杂耍要做。

Design Patterns:Elements of Reusable Object-Oriented Software(Addison-Wesley Professional,1995 年)中,四人组用一句话总结了这个原则:“编程到一个接口,而不是一个实现。”这是一个很好的补充到你的编码手册。

四个路标

很少有人在设计阶段就完全正确。随着需求的变化,或者随着我们对正在解决的问题的本质有了更深的理解,我们大多数人都会修改我们的代码。

当你修改你的代码时,它很容易脱离你的控制。这里增加一个方法,那里增加一个新类,渐渐地你的系统开始衰退。正如您已经看到的,您的代码可以指出改进的方向。代码中的这些指针有时被称为代码味道——也就是说,代码中的特性可能会建议特定的修复,或者至少会让你重新审视你的设计。在这一节中,我将已经提出的一些要点提炼为四个迹象,您应该在编码时注意这些迹象。

代码复制

重复是代码中最大的弊端之一。如果你在编写一个例程时有一种奇怪的似曾相识的感觉,很可能你有问题。

看看你系统中重复的例子。也许他们属于彼此。复制通常意味着紧密耦合。如果你改变了某个套路的一些基本内容,类似的套路需要修改吗?如果是这样的话,他们很可能属于同一个类。

知道得太多的阶层

从一个方法到另一个方法传递参数可能很痛苦。为什么不简单地通过使用全局变量来减少痛苦呢?有了全球,每个人都可以得到数据。

全局变量有它们的位置,但是它们确实需要以某种程度的怀疑来看待。顺便说一句,这是相当高的怀疑程度。通过使用一个全局变量,或者通过给一个类任何种类的关于它的更广领域的知识,你把它锚定到它的上下文中,使它不那么可重用和依赖于超出它控制的代码。请记住,您希望解耦您的类和例程,而不是创建相互依赖。尝试限制一个类对其上下文的了解。我将在本书的后面部分探讨一些策略。

百事通

你的类是否试图同时做太多事情?如果是的话,看看你能否列出这个类的职责。你会发现其中的一个会成为一个好的课程的基础。

如果创建子类,保持一个过分热心的类不变会导致特殊的问题。您在子类中扩展了哪个职责?如果您需要一个子类来承担多个责任,您会怎么做?你可能会有太多的子类或者过度依赖条件代码。

条件语句

在你的项目中,你会有充分的理由使用ifswitch语句。不过,有时这种结构可能是对多态性的一种呼唤。

如果您发现您在一个类中频繁地测试某些条件,特别是如果您发现这些测试在不止一个方法中被镜像,这可能是您的一个类应该是两个或更多的迹象。看看条件代码的结构是否暗示了可以在类中表达的职责。新的类应该实现一个共享的抽象基类。很有可能你必须解决如何将正确的类传递给客户端代码。我将在第九章介绍一些创建对象的模式。

UML

到目前为止,在本书中,我让代码自己说话,我用简短的例子来说明继承和多态等概念。这很有用,因为 PHP 在这里是一种通用的语言:如果你已经读到这里,它是我们共有的语言。然而,随着我们的例子越来越大,越来越复杂,仅仅使用代码来说明广泛的设计变得有些荒谬。很难在几行代码中看到一个概述。

UML 代表统一建模语言。首字母正确地用于定冠词。这不仅仅是一种统一建模语言,也是 ?? 的统一建模语言。

也许这种权威的语气来自于语言形成的环境。根据 Martin Fowler ( UML 精华,Addison-Wesley Professional,1999)的说法,UML 只是在面向对象设计社区的精英们之间经过多年的知识和官僚争论后才成为一个标准。

这场斗争的结果是一个强大的描述面向对象系统的图形语法。在这一节中,我们将仅仅触及表面,但是你将很快发现一点点 UML(对不起,一点点 UML)就能走很长的路。

特别是类图可以描述结构和模式,这样它们的意义就显而易见了。这种明亮的清晰度在代码片段和要点中通常很难找到。

类图

虽然类图只是 UML 的一个方面,但是它们可能是最普遍的。因为它们对于描述面向对象的关系特别有用,所以我将在本书中主要使用它们。

代表类别

如你所料,类是类图的主要组成部分。一个类由一个命名的盒子表示(见图 6-1 )。

img/314621_6_En_6_Fig1_HTML.png

图 6-1

头等

该类分为三个部分,名称显示在第一个部分。当我们只给出类名的信息时,这些分割线是可选的。在设计类图时,我们可能会发现图 6-1 的详细程度对于某些类来说已经足够了。我们没有义务在类图中表示每个字段和方法,甚至每个类。

抽象类要么用斜体表示类名(见图 6-2 ),要么在类名前加上{abstract}(见图 6-3 )。第一种方法是两种方法中比较常见的,但第二种方法在你做笔记时更有用。

img/314621_6_En_6_Fig3_HTML.png

图 6-3

使用约束定义的抽象类

img/314621_6_En_6_Fig2_HTML.png

图 6-2

抽象类

Note

{abstract}语法是约束的一个例子。约束在类图中用来描述特定元素的使用方式。大括号之间的文本没有特殊的结构;它应该简单地提供适用于该元素的任何条件的简短说明。

接口的定义方式与类相同,只是它们必须包含一个原型(即 UML 的扩展),如图 6-4 所示。

img/314621_6_En_6_Fig4_HTML.png

图 6-4

一个界面

属性

概括地说,属性描述了一个类的特性。属性列在类名正下方的部分(见图 6-5 )。

img/314621_6_En_6_Fig5_HTML.png

图 6-5

一个属性

让我们仔细看看示例中的属性。初始符号表示属性的可见性或访问控制级别。表 6-1 显示了三种可用的符号。

表 6-1

可见度符号

|

标志

|

能见度

|

说明

| | --- | --- | --- | | + | 公众 | 适用于所有代码 | | - | 私人的 | 仅适用于当前类 | | # | 保护 | 仅适用于当前类别及其子类 |

可见性符号后跟属性的名称。在这种情况下,我描述的是ShopProduct::$price属性。冒号用于将属性名与其类型分开(或者,可以在末尾提供一个默认值,用等号分隔)。

同样,你只需要为了清晰起见,尽可能多地包括细节。

操作

操作描述方法;或者,更确切地说,它们描述了可以对一个类的实例进行的调用。图 6-6 显示了ShopProduct类中的两个操作。

img/314621_6_En_6_Fig6_HTML.png

图 6-6

操作

如您所见,操作使用与属性相似的语法。可见性符号位于方法名之前。参数列表用括号括起来。该方法的返回类型(如果有)用冒号分隔。参数用逗号分隔,遵循属性语法,属性名称和类型用冒号分隔。

如您所料,这种语法相对灵活。您可以省略可见性标志和返回类型。参数通常只由它们的类型表示,因为参数名通常并不重要。

描述继承和实现

UML 将继承关系描述为一般化。这种关系用一条从子类别到其父类别的线来表示。这条线带有一个空的闭合箭头。

图 6-7 显示了ShopProduct类与其子类之间的关系。

img/314621_6_En_6_Fig7_HTML.png

图 6-7

描述继承

UML 描述了接口和实现它的类之间的关系。因此,如果ShopProduct类要实现Chargeable接口,我们可以将其添加到我们的类图中,如图 6-8 所示。

img/314621_6_En_6_Fig8_HTML.png

图 6-8

描述接口实现

联合

继承只是面向对象系统中许多关系中的一种。当一个类属性被声明为包含对另一个类的一个(或多个)实例的引用时,就会发生关联。

在图 6-9 中,我们建模了两个类,并在它们之间创建了一个关联。

img/314621_6_En_6_Fig9_HTML.png

图 6-9

类协会

在这个阶段,我们对这种关系的性质是模糊的。我们只指定了一个Teacher对象将引用一个或多个Pupil对象,反之亦然。这种关系可能是也可能不是互惠的。

你可以用箭头来描述关联的方向。如果Teacher类有一个Pupil类的实例,而不是相反,那么你应该把你的关联做成一个从Teacher指向Pupil类的箭头。这种关联称为单向关联,如图 6-10 所示。

img/314621_6_En_6_Fig10_HTML.png

图 6-10

单向联系

如果每个类都有对另一个类的引用,你可以用一个双向箭头来描述双向关系,如图 6-11 所示。

img/314621_6_En_6_Fig11_HTML.png

图 6-11

双向联系

您还可以指定一个类被关联中的另一个类引用的实例的数量(这也称为关联的“基数”)。您可以通过在每个类别旁边放置一个数字或范围来实现这一点。您也可以使用星号(*)代表任何数字。在图 6-12 中,可以有一个Teacher对象,也可以有零个或多个Pupil对象。

img/314621_6_En_6_Fig12_HTML.png

图 6-12

定义关联的多重性

在图 6-13 中,关联中可以有一个Teacher对象和五到十个Pupil对象。

img/314621_6_En_6_Fig13_HTML.png

图 6-13

定义关联的多重性

聚集和组成

聚合和组合类似于关联。所有这些都描述了这样一种情况:一个类拥有对另一个类的一个或多个实例的永久引用。但是,通过聚合和组合,被引用的实例形成了引用对象的固有部分。

在聚合的情况下,包含的对象是容器的核心部分,但是它们也可以同时被其他对象包含。聚合关系由一条以空心菱形开始的线表示。

在图 6-14 中,我定义了两类:SchoolClassPupilSchoolClass班蕴Pupil

img/314621_6_En_6_Fig14_HTML.png

图 6-14

聚合

小学生组成一个类,但是同一个Pupil对象可以同时被不同的SchoolClass实例引用。如果我要解散一个学校类,我不一定会删除该学生,他可能会参加其他类。

组成代表了比这更强的关系。在合成中,被包含的对象只能由其容器引用。当容器被删除时,它应该被删除。组合关系的描述方式与聚合关系相同,只是菱形应被填充(见图 6-15 )。

img/314621_6_En_6_Fig15_HTML.png

图 6-15

作文

一个Person类维护一个对SocialSecurityData对象的引用。包含的实例只能属于包含的Person对象。

描述用途

在 UML 中,使用关系被描述为依赖关系。它是本节讨论的关系中最短暂的,因为它没有描述类之间的永久链接。

使用的类可以作为参数传递,也可以作为方法调用的结果获取。

图 6-16 中的Report类使用了一个ShopProductWriter对象。使用关系由连接两者的虚线和空心箭头表示。然而,它并不像一个ShopProductWriter对象维护一个ShopProduct对象数组那样将这个引用作为一个属性来维护。

img/314621_6_En_6_Fig16_HTML.png

图 6-16

依赖关系

使用笔记

类图可以捕捉系统的结构,但是它们没有提供过程感。图 6-16 告诉我们系统中的类。从图 6-16 ,你知道一个Report对象使用一个ShopProductWriter,但是你不知道这个的机制。在图 6-17 中,我用一个注释来说明一些事情。

img/314621_6_En_6_Fig17_HTML.png

图 6-17

使用注释来阐明依赖关系

如你所见,一张纸币由一个带有折叠角的盒子组成。它通常包含伪代码的碎片。

这阐明了图 6-16;您现在可以看到,Report对象使用了一个ShopProductWriter来输出产品数据。这不是一个启示,但是使用关系并不总是那么明显。在某些情况下,即使是一张便条也不能提供足够的信息。幸运的是,您可以对系统中对象的交互以及类的结构进行建模。

序列图

序列图是基于对象的,而不是基于类的。它用于一步一步地对系统中的过程进行建模。

让我们构建一个简单的图表,对一个Report对象写入产品数据的方式进行建模。序列图从左至右展示了系统的参与者(见图 6-18 )。

img/314621_6_En_6_Fig18_HTML.png

图 6-18

序列图中的对象

我已经用类名单独标记了我的对象。如果在我的图中,同一个类有多个独立工作的实例,我将使用格式label:class(例如product1:ShopProduct)包含一个对象名。

您从上到下展示了您正在建模的流程的生命周期,如图 6-19 所示。

img/314621_6_En_6_Fig19_HTML.png

图 6-19

序列图中的对象生命线

垂直虚线表示系统中对象的寿命。生命线后面的大方框代表过程的焦点。如果您从上到下阅读图 6-19 ,您可以看到该过程如何在系统中的对象之间移动。如果不显示对象之间传递的消息,这很难阅读。我在图 6-20 中添加了这些。

img/314621_6_En_6_Fig20_HTML.png

图 6-20

完整的序列图

箭头表示从一个对象发送到另一个对象的消息。返回值通常是隐式的(尽管它们可以用虚线表示,从被调用的对象传递到消息发起者)。每条消息都使用相关的方法调用进行标记。虽然有一些语法,但是你可以非常灵活地使用你的标签。方括号表示一种情况:

[okToPrint]
write()

这个代码片段意味着只有满足正确的条件时,才应该进行write()调用。星号用于表示重复;可选地,进一步的澄清可以放在方括号中:

*[for each ShopProduct]
write()

可以从上到下解读图 6-20 。首先,Report对象从一个ProductStore对象获取一个ShopProduct对象的列表。它将这些传递给一个ShopProductWriter对象,该对象存储对它们的引用(尽管我们只能从图中推断出这一点)。ShopProductWriter对象为它引用的每个ShopProduct对象调用ShopProduct::getSummaryLine(),将结果添加到它的输出中。

正如你所看到的,序列图可以模拟流程,冻结动态交互的片段,并以惊人的清晰度呈现出来。

Note

看图 6-16 和 6-20 。注意类图是如何说明多态性的,显示了从ShopProductWriterShopProduct派生的类。现在请注意,当我们对对象间的通信进行建模时,这个细节是如何变得透明的。在可能的情况下,我们希望对象使用最通用的类型,这样我们就可以隐藏实现的细节。

摘要

在这一章中,我超越了面向对象编程的具体细节,着眼于一些关键的设计问题。我研究了封装、松散耦合和内聚等特性,这些特性是灵活且可重用的面向对象系统的基本方面。我接着看了 UML,为本书后面的模式工作打下了基础。