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

42 阅读1小时+

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

原文:PHP 8 Objects, Patterns, and Practice

协议:CC BY-NC-SA 4.0

一、PHP:设计和管理

2004 年 7 月,PHP 5.0 发布。这个版本引入了一套激进的增强功能。也许其中第一个是对面向对象编程的彻底改进的支持。这激发了 PHP 社区对对象和设计的兴趣。事实上,这是一个过程的强化,这个过程始于第 4 版首次使 PHP 面向对象编程成为现实。

在这一章中,我看了用对象编码可以解决的一些需求。我非常简要地总结了模式演变和相关实践的一些方面。

我还概述了这本书涵盖的主题。我将查看以下内容:

  • 灾难的演变:一个项目变坏

  • 设计和 PHP:面向对象的设计技术如何在 PHP 社区扎根

  • 本书:对象、模式、实践

问题

问题是 PHP 太简单了。它诱惑你去尝试你的想法,并且用好的结果取悦你。您可以将大部分代码直接写入网页,因为 PHP 就是为了支持这一点而设计的。您将实用函数(如数据库访问代码)添加到可以包含在页面之间的文件中,不知不觉中,您就拥有了一个工作的 web 应用。

你正在走向毁灭。当然,你没有意识到这一点,因为你的网站看起来棒极了。它表现很好,你的客户很高兴,你的用户在花钱。

当你回到代码开始一个新的阶段时,麻烦就来了。现在你有了一个更大的团队,更多的用户和更大的预算。然而,在没有警告的情况下,事情开始出错。就好像你的项目中毒了。

你的新程序员正在努力理解对你来说是第二天性的代码,尽管可能有些曲折复杂。作为团队成员,她需要的时间比你预期的要长。

一个简单的改变,估计需要一天,当你发现你必须更新 20 个或更多的网页时,需要三天。

您的一位编码人员保存了他的文件版本,覆盖了您之前对同一代码所做的主要更改。三天后才发现丢失,此时您已经修改了自己的本地副本。整理这些乱七八糟的东西花了一天时间,耽误了第三个也在处理这个文件的开发人员。

由于应用的流行,您需要将代码转移到新的服务器上。该项目必须手工安装,并且您发现文件路径、数据库名称和密码被硬编码到许多源文件中。您在移动过程中停止工作,因为您不想覆盖迁移所需的配置更改。估计的两个小时变成了八个小时,因为有人在 Apache 模块 ModRewrite 中做了一些聪明的事情,应用现在需要这样才能正常运行。

你终于开始了第二阶段。这一天半一切正常。当您即将离开办公室时,第一份错误报告出现了。客户几分钟后打电话投诉。她的报告与第一份报告相似,但是稍微仔细一点就会发现,这是一个不同的错误导致了类似的行为。您还记得在该阶段开始时的简单变更,这使得在项目的其余部分中需要进行大量的修改。

您意识到并非所有需要的修改都已到位。这要么是因为它们从一开始就被忽略了,要么是因为这些文件在合并冲突中被覆盖了。您匆忙地进行必要的修改来修复错误。您太急于测试更改了,但是它们只是简单的复制和粘贴,那么会有什么问题呢?

第二天早上,您到达办公室,发现一个购物篮模块已经关闭了一整夜。您在最后一刻所做的更改省略了一个前导引号,导致代码不可用。当然,当你在睡觉的时候,其他时区的潜在顾客完全醒着,准备在你的店里花钱。您解决了问题,安抚了客户,并召集团队准备下一天的救火工作。

这个编码人员的日常故事可能看起来有点夸张,但是我已经看到所有这些事情一次又一次地发生。许多 PHP 项目开始时都很小,后来演变成了怪物。

因为表示层还包含应用逻辑,所以当数据库查询、身份验证检查、表单处理等等从一页复制到另一页时,复制就开始了。每当需要对这些代码块中的一个进行更改时,必须在发现代码的任何地方进行更改,否则错误肯定会随之而来。

缺少文档会使代码难以阅读,缺少测试会使不明显的错误在部署前无法被发现。客户业务不断变化的性质通常意味着代码会偏离其最初的目的,直到它执行根本不适合它的任务。因为这样的代码经常演变成一个沸腾的、混杂的块,很难(如果不是不可能的话)切换并重写它的一部分来适应新的目的。

如果你是一名自由职业的 PHP 顾问,这些都不是坏消息。评估和修复这样一个系统可以资助昂贵的浓缩咖啡饮料和 DVD 盒六个月或更长时间。然而,更严重的是,这类问题可能意味着一家企业的成败。

PHP 和其他语言

PHP 惊人的受欢迎程度意味着它的边界很早就经过了严格的测试。正如你将在下一章看到的,PHP 最初是作为一组管理个人主页的宏而诞生的。随着 PHP 3 的出现,以及更大程度上 PHP 4 的出现,这种语言迅速成为大型企业网站背后的成功力量。然而,在许多方面,PHP 的早期继承一直延续到脚本设计和项目管理中。在某些方面,PHP 保留了业余语言的不公平名声,最适合于演示任务。

大约在这个时候(大约在世纪之交),新的想法在其他编码社区流行开来。对面向对象设计的兴趣刺激了 Java 社区。由于 Java 是面向对象的语言,你可能认为这是多余的。当然,Java 提供了一种更容易使用的粒度,但是使用类和对象本身并不能决定一种特定的设计方法。

作为一种描述问题的方式,设计模式的概念以及其解决方案的本质在 20 世纪 70 年代首次被讨论。也许恰当地说,这个想法起源于建筑领域,而不是计算机科学,是在 Christopher Alexander 的一部开创性著作中:模式语言(牛津大学出版社,1977)。到 20 世纪 90 年代早期,面向对象的程序员使用同样的技术来命名和描述软件设计的问题。Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides(在本书中以他们亲切的昵称四人组*)撰写的关于设计模式的开创性著作Design Patterns:Elements of Reusable Object-Oriented Software(Addison-Wesley Professional,1995),今天仍然是不可或缺的。它所包含的模式是任何一个刚开始涉足这个领域的人所必需的第一步,这也是为什么本书中的大多数模式都是从它而来的。*

*Java 语言本身在其 API 中部署了许多核心模式,但直到 20 世纪 90 年代末,设计模式才渗透到编码社区的意识中。模式很快感染了主要街道书店的电脑部门,第一次火焰战争开始于邮件列表和论坛。

无论你认为模式是交流工艺知识的一种强有力的方式,还是主要是空话(根据这本书的标题,你可能会猜到我在这个问题上的立场),很难否认他们鼓励的对软件设计的重视本身是有益的。

相关的话题也越来越突出。肯特·贝克倡导的极限编程(XP)就是其中之一。XP 是一种鼓励灵活的、面向设计的、高度集中的计划和执行的项目方法。

XP 原则中突出的一点是坚持测试对项目的成功至关重要。测试应该自动化,经常运行,最好在目标代码编写之前就设计好。

XP 还规定项目应该被分解成小的(非常小的)迭代。代码和需求都应该被仔细检查。架构和设计应该是一个共享和持续的问题,导致代码的频繁修改。

如果说 XP 是设计运动中好战的一翼,那么我读过的关于编程的最好的书之一就很好地代表了这种温和的倾向:安德鲁·亨特和戴维·托马斯写的《实用程序员:从熟练工到大师》(Addison-Wesley Professional,1999)。

XP 被一些人认为是有点狂热,但是它是二十年来最高水平的面向对象实践的产物,它的原则被广泛地移植。特别是,被称为重构的代码修改被认为是模式的一个强大的附属品。重构自 20 世纪 80 年代以来一直在发展,但它是在马丁·福勒的重构目录中编纂的,重构:改进现有代码的设计 (Addison-Wesley Professional),该目录于 1999 年出版并定义了该领域。

随着 XP 和模式的兴起,测试也成了一个热门话题。强大的 JUnit 测试平台的发布进一步强调了自动化测试的重要性,JUnit 测试平台成为 Java 程序员武器库中的关键武器。由肯特·贝克和埃里希·伽马( http://junit.sourceforge.net/doc/testinfected/testing.htm )撰写的关于这个主题的一篇里程碑式的文章“测试感染:程序员喜欢编写测试”,对这个主题进行了出色的介绍,并且仍然具有巨大的影响力。

PHP 4 大约在这个时候发布,带来了效率的提高,更重要的是,增强了对对象的支持。这些增强使得完全面向对象的项目成为可能。程序员们欣然接受了这个特性,这让 Zend 的创始人 Zeev Suraski 和 Andi Gutmans 有些吃惊,他们加入拉斯马斯·勒德尔夫来管理 PHP 开发。正如你将在下一章看到的,PHP 的对象支持绝不是完美的。但是通过训练和小心使用语法,人们可以真正开始同时考虑对象和 PHP。

然而,像本章开头描述的设计灾难仍然很常见。设计文化还很遥远,在关于 PHP 的书籍中几乎不存在。然而,在网上,人们的兴趣很明显。Leon Atkinson 在 2001 年为 Zend 写了一篇关于 PHP 和模式的文章,Harry Fuecks 在 2002 年在 ??(现已不存在)创办了他的杂志。基于模式的框架项目,如 BinaryCloud,以及自动化测试和文档工具开始出现。

2003 年第一个 PHP 5 测试版的发布确保了 PHP 作为面向对象编程语言的未来。Zend Engine 2 提供了大大改进的对象支持。同样重要的是,它发出了一个信号,即对象和面向对象的设计现在是 PHP 项目的核心。

多年来,PHP 5 不断发展和改进,加入了一些重要的新特性,比如名称空间和闭包。在此期间,它获得了作为服务器端 web 编程最佳选择的声誉。

2015 年 12 月发布的 PHP 7 代表了这一趋势的延续。特别是,它提供了对参数和返回类型声明的支持——这两个特性是许多开发人员(以及本书以前的版本)多年来一直渴望的。还有许多其他特性和改进,包括匿名类、改进的内存使用和提高的速度。这些年来,从面向对象的程序员的角度来看,这种语言逐渐变得更健壮、更干净、更有趣。

在 2020 年 12 月,也就是 PHP 7 发布 5 年后,PHP 8 也将发布。虽然一些实现细节可能会发生变化(在撰写本书期间已经发生了一些变化),但在撰写本书时(2020 年 8 月),这些功能已经可用。我在这里详细介绍了其中的许多内容。它们包括对类型声明的改进、简化的属性赋值和许多其他新特性。标题添加的可能是对属性的支持(在其他语言中通常称为注释)。

关于这本书

这本书并不试图在面向对象设计领域开辟新的天地;在这方面,它岌岌可危地站在巨人的肩膀上。相反,我在 PHP 的上下文中研究了一些成熟的设计原则和一些关键模式(特别是那些在经典的四人组书籍设计模式中提到的)。最后,我超越了代码的严格限制,着眼于有助于确保项目成功的工具和技术。除了这个介绍和一个简短的结论,这本书分为三个主要部分:对象、模式和实践。

目标

我在第一部分开始时快速回顾了 PHP 和对象的历史,绘制了它们从 PHP 3 的事后想法到 PHP 5 的核心特性的转变。

即使你对对象知之甚少或者一无所知,你仍然可以成为一名有经验的成功的 PHP 程序员。出于这个原因,我从基本原则开始解释对象、类和继承。即使在这个早期阶段,我也看到了 PHP 5、PHP 7 和 PHP 8 引入的一些对象增强。

基础知识建立之后,我会更深入地研究我们的主题,研究 PHP 更高级的面向对象特性。我还专门用一章来介绍 PHP 提供的帮助您处理对象和类的工具。

然而,仅仅知道如何声明一个类并使用它来实例化一个对象是不够的。您必须首先为您的系统选择合适的参与者,并决定他们交互的最佳方式。这些选择比关于对象工具和语法的简单事实更难描述和学习。我以对 PHP 面向对象设计的介绍结束了第一部分。

模式

模式描述了软件设计中的一个问题,并提供了解决方案的核心。这里的“解决方案”不是指你可能在食谱中找到的那种剪切和粘贴代码(尽管食谱对程序员来说是很好的资源)。相反,设计模式描述了一种可以用来解决问题的方法。可能会给出一个示例实现,但它没有它用来说明的概念重要。

第二部分从定义设计模式和描述它们的结构开始。我也研究了它们流行背后的一些原因。

模式倾向于促进和遵循某些核心设计原则。理解这些可以帮助分析一个模式的动机,并且可以有效地应用于所有的编程。我讨论其中的一些原则。我还研究了统一建模语言(UML),这是一种独立于平台的描述类及其交互的方式。

虽然这本书不是一个模式目录,但是我研究了一些最著名和最有用的模式。我描述了每个模式解决的问题,分析了解决方案,并给出了一个 PHP 实现示例。

实践

如果管理不当,即使是完美平衡的架构也会失败。在第三部分中,我将介绍可用来帮助您创建确保项目成功的框架的工具。如果本书的其余部分是关于设计和编程的实践,那么第三部分是关于管理代码的实践。我所研究的工具可以形成一个项目的支持结构,有助于在错误发生时跟踪它们,促进程序员之间的协作,并提供安装的便利性和代码的清晰性。

我已经讨论了自动化测试的力量。我从介绍性的一章开始第三部分,概述了这个领域的问题和解决方案。

许多程序员对屈服于自己做所有事情的冲动感到内疚。Composer 和它的主存储库 Packagist 一起,提供了对数千个依赖项管理包的访问,这些包可以轻松地缝合到项目中。我研究了自己实现一个特性和部署一个 Composer 包之间的权衡。

当我在谈论 Composer 时,我会看看安装机制,它使包的部署像一个命令一样简单。

代码是关于协作的。这一事实可能是有益的。这也可能是一场彻底的噩梦。Git 是一个版本控制系统,它使许多程序员能够在同一个代码库上一起工作,而不会覆盖彼此的工作。它可以让您在开发的任何阶段抓取项目的快照,查看谁做了哪些更改,并将项目拆分为可合并的分支。Git 总有一天会拯救你的项目。

当人们和库合作时,他们通常会带来不同的惯例和风格。虽然这是健康的,但它也会破坏互操作性。像符合符合这样的词让我不寒而栗,但不可否认的是,互联网的创造力是由标准支撑的。通过遵守某些惯例,我们可以自由地在一个难以想象的大沙盒中玩耍。因此,在新的一章中,我探索 PHP 标准,它们如何帮助我们,以及我们应该如何以及为什么,是的,遵从

两个事实似乎是不可避免的。首先,bug 经常在代码的同一个区域重复出现,使得一些工作日就像是一场似曾相识的练习。第二,改进带来的破坏往往和它们修复的一样多,甚至更多。自动化测试可以解决这两个问题,为代码中的问题提供早期预警系统。我将介绍 PHPUnit,这是一个强大的 xUnit 测试平台实现,最初是为 Smalltalk 设计的,但现在已经移植到许多语言,特别是 Java。我特别关注 PHPUnit 的特性,以及测试的好处和一些成本。

申请很乱。他们可能需要将文件安装在非标准位置,或者想要设置数据库,或者需要修补服务器配置。简而言之,应用需要在安装过程中完成填充。Phing 是一个名为 Ant 的 Java 工具的忠实端口。Phing 和 Ant 解释一个构建文件,并按照您告诉他们的任何方式处理您的源文件。这通常意味着将它们从源目录复制到系统中的各个目标位置,但是,随着您的需求变得更加复杂,Phing 可以毫不费力地进行扩展来满足它们。

一些公司实施开发平台——但是在许多情况下,团队最终运行一系列不同的操作系统。承包商挥舞着笔记本电脑到达(你好,Paul Tregoing,第五版和当前版本的技术编辑),一些团队成员没完没了地宣传他们最喜欢的 Linux 发行版(那是我和我的 Fedora),许多人坚持要另一个性感的 PowerBook(在咖啡馆和会议室使用它根本不会让你看起来像是时髦的 Borg 大军中的另一个节点)。所有这些都可以不同程度地轻松运行灯堆栈。不过,理想情况下,开发人员应该在非常类似于最终产品系统的环境中运行他们的代码。我研究了一个应用,它使用了虚拟化技术,这样团队成员可以保留他们特有的开发平台,但在类似生产的系统上运行项目代码。

测试和构建都很好,但是您必须安装并运行您的测试,并且为了获得收益而继续这样做。如果您不自动化您的构建和测试,很容易变得自满和放任自流。我看到了一些被归入“持续集成”类别的工具和技术,它们将帮助您做到这一点。

第六版有什么新内容

PHP 是一门活的语言,正因为如此,它处于不断的回顾和发展之中。这个新版本,也已经过审查和彻底更新,以考虑到变化和新的机会。

我将介绍一些新特性,比如属性和对类型声明的许多增强。示例在适当的地方使用了 PHP 8 的特性,所以请注意,您经常需要针对 PHP 8 解释器运行代码——或者准备做一些降级工作。

摘要

这是一本关于面向对象设计和编程的书。它也是关于从协作到部署管理 PHP 代码库的工具。

这两个主题从不同但互补的角度论述了同一个问题。主要目标是构建实现其目标的系统,并很好地进行协作开发。

第二个目标在于软件系统的美观。作为程序员,我们建造有形状和动作的机器。我们在工作日投入了很多时间,在生活中投入了很多时间,写出了这些形状。我们希望我们构建的工具,无论是单独的类和对象,软件组件,还是最终产品,都能形成一个优雅的整体。版本控制、测试、文档和构建的过程不仅仅支持这个目标:它是我们想要实现的形状的一部分。就像我们想要干净和聪明的代码一样,我们想要一个为开发者和用户都设计好的代码库。共享、阅读和部署项目的机制应该和代码本身一样重要。*

二、PHP 和对象

对象并不总是 PHP 项目的关键部分。事实上,它们曾经被 PHP 的设计者描述为事后的想法。

事后想来,这一次已经被证明非常有弹性。在这一章中,我通过总结 PHP 面向对象特性的发展来介绍这本书对对象的覆盖。

我们将了解以下内容:

  • PHP/FI 2.0 : PHP,但不是我们所知道的。

  • PHP 3 :对象第一次出现。

  • PHP 4 :面向对象编程长大了。

  • PHP 5 :语言的核心对象。

  • PHP 7 :缩小差距。

  • PHP 8 :盘整继续。

PHP 对象的意外成功

有了 PHP 广泛的对象支持和如此多的面向对象的 PHP 库和应用,PHP 中对象的兴起似乎是一个自然而不可避免的过程的顶点。事实上,没有什么比这更偏离事实了。

一开始:PHP/FI

正如我们今天所知,PHP 的起源在于拉斯马斯·勒德尔夫使用 Perl 开发的两个工具。PHP 代表个人主页工具。FI 代表形式解释器。它们共同组成了向数据库发送 SQL 语句、处理表单和流程控制的宏。

这些工具用 C 语言重写,并以 PHP/FI 2.0 的名字组合在一起。这一阶段的语言看起来与我们今天认识的语法不同,但并没有那么不同。它支持变量、关联数组和函数。然而,天体甚至不在地平线上。

语法糖:PHP 3

事实上,甚至在 PHP 3 处于计划阶段时,对象就已经不在议程上了。PHP 3 的主要设计师是 Zeev Suraski 和 Andi Gutmans。PHP 3 完全重写了 PHP/FI 2.0,但是对象并没有被认为是新语法的必要部分。

根据 Zeev Suraski 的说法,对类的支持几乎是后来才添加的(准确地说是在 1997 年 8 月 27 日)。类和对象实际上只是定义和访问关联数组的另一种方式。

当然,方法和继承的增加使得类不仅仅是美化了的关联数组,但是对于如何处理类仍然有严格的限制。特别是,您不能访问父类的覆盖方法(如果您还不知道这意味着什么,请不要担心;后面我会解释)。我将在下一节中讨论的另一个缺点是 PHP 脚本中传递对象的方式不够理想。

这些物品在当时是一个边缘问题,这一点由于它们在官方文件中缺乏显著性而更加突出。手册中有一句话和一个代码示例是关于对象的。这个例子没有说明继承或属性。

PHP 4 和安静的革命

如果说 PHP 4 是这种语言的又一个突破性进展,那么大多数核心变化都发生在表面之下。Zend 引擎(它的名字来源于 Ze ev 和一个 nd i)是从零开始编写的,为语言提供动力。Zend 引擎是驱动 PHP 的主要组件之一。您可能想调用的任何 PHP 函数实际上都是高级扩展层的一部分。它们完成了它们被命名为的繁忙工作,比如与数据库 API 对话或为您处理字符串。在此之下,Zend 引擎管理内存,将控制权委托给其他组件,并将您每天使用的熟悉的 PHP 语法翻译成可运行的字节码。我们必须感谢 Zend 引擎提供了像类这样的核心语言特性。

从我们的 object ive 的角度来看,PHP 4 使得覆盖父方法并从子类访问它们成为可能的事实是一个主要的好处。

然而,一个主要的缺点仍然存在。将一个对象赋给一个变量,将它传递给一个函数,或者从一个方法返回它,都会产生一个副本。考虑这样一个任务:

$my_obj = new  User('bob');
$other  = $my_obj;

这导致存在两个用户对象,而不是对同一个用户对象的两个引用。在大多数面向对象的语言中,你会期望通过引用而不是通过值来赋值。这意味着您将传递并分配指向对象的句柄,而不是复制对象本身。默认的传值行为导致了许多不为人知的错误,因为程序员无意中修改了脚本中某个部分的对象,期望通过其他地方的引用看到这些更改。在本书中,你会看到很多例子,在这些例子中,我维护了对同一个对象的多个引用。

幸运的是,有一种方法可以强制按引用传递,但这意味着要记住使用笨拙的构造。

下面是如何通过引用进行分配:

$other =& $my_obj;
// $other and $my_obj point to same object

这将强制按引用传递:

function setSchool(& $school)
{
    // $school is now a reference to not a copy of passed object
}

此处通过引用返回:

function & getSchool()
{
    // returning a reference not a copy
    return  $this->school;
}

尽管这样做很好,但是很容易忘记添加&符号,这意味着 bug 很容易潜入面向对象的代码。这些特别难以追踪,因为它们很少导致任何报告的错误,只是看似合理但不完整的行为。

PHP 手册扩展了一般语法的覆盖范围,特别是对象,面向对象的编码开始成为主流。PHP 中的对象并不是没有争议的(毫无疑问,当时和现在一样),类似“我需要对象吗?”是邮件列表中常见的诱饵。事实上,Zend 网站上有很多鼓励面向对象编程的文章,还有一些警告性的文章。尽管存在传递引用问题和争议,但许多编码人员还是在代码中加入了与号字符。面向对象的 PHP 越来越受欢迎。齐夫·苏拉斯基在一篇为 DevX.com(www.devx.com/webdev/Article/10007/0/page/1)写的文章中写道:

PHP 历史上最大的转折之一是,尽管功能非常有限,尽管有许多问题和限制,PHP 中的面向对象编程仍然蓬勃发展,并成为越来越多的现成 PHP 应用最流行的范例。这种趋势出乎意料,让 PHP 陷入了一种不太理想的境地。很明显,对象的行为不像其他面向对象语言中的对象,而是像[关联]数组。

正如前一章所提到的,在网站和在线文章中,对面向对象设计的兴趣变得很明显。PHP 的官方软件库 PEAR 本身就采用了面向对象编程。事后看来,很容易认为 PHP 采用面向对象的支持是对不可避免的力量的不情愿的投降。重要的是要记住,虽然面向对象编程从 20 世纪 60 年代就已经存在,但它真正普及是在 90 年代中期。Java,这个伟大的普及程序,直到 1995 年才发布。作为过程语言 C 的超集,C++从 1979 年就出现了。经过长期的发展,它可以说在 20 世纪 90 年代实现了飞跃。Perl 5 于 1994 年发布,这是以前的过程化语言中的又一次革命,它使用户能够用对象来思考(尽管有些人认为 Perl 的面向对象支持也像是一种事后的想法)。对于一种小型的过程语言来说,PHP 开发其对象支持非常快,显示了对用户需求的真正响应。

拥抱变化:PHP 5

PHP 5 代表了对对象和面向对象编程的明确认可。这并不是说对象是使用 PHP 的唯一方式(顺便说一下,这本书也没有这么说)。然而,对象被认为是开发企业系统的强大而重要的手段,PHP 在其核心设计中完全支持它们。

可以说,PHP 5 增强的一个显著效果是更大的互联网公司采用了这种语言。都是雅虎!例如,脸书开始在他们的平台上广泛使用 PHP。在版本 5 中,PHP 成为互联网上开发和企业的标准语言之一。

对象已经从事后思考变成了语言驱动。也许最重要的变化是新的明显的按引用传递行为,它取代了对象复制的弊端。然而,这仅仅是开始。在本书中,尤其是在这一部分,我们将会遇到更多的增强,包括私有和受保护的方法和属性、static 关键字、名称空间、类型提示(现在称为类型声明)和异常。PHP 5 已经存在了很长一段时间(大约 12 年),重要的新特性也在不断发布。

Note

值得注意的是,严格来说 PHP 并没有随着 PHP 5 的引入而转向按引用传递,这一点也没有改变。相反,默认情况下,当一个对象被赋值、传递给一个方法或从一个方法返回时,该对象的标识符被复制。因此,除非您确定问题并使用&字符强制按引用传递,否则您仍然在执行复制操作。然而,实际上,这种复制和通过引用传递之间通常没有什么区别,因为您使用复制的标识符引用了与原始标识符相同的目标对象。

例如,PHP 5.3 引入了名称空间。这些允许您为类和函数创建一个命名的作用域,这样当您包含库和扩展系统时,就不太可能遇到重复的名称。它们还会将您从丑陋但必要的命名惯例中解救出来,例如:

class megaquiz_util_Conf
{

}

诸如此类的类名是防止包之间冲突的一种方法,但是它们会导致代码变得复杂。

我们还看到了对闭包、生成器、特征和后期静态绑定的支持。

PHP 7:缩小差距

程序员要求很高。对于许多设计模式的爱好者来说,PHP 仍然缺少两个关键特性。这些是标量类型声明和强制返回类型。在 PHP 5 中,可以强制传递给函数或方法的参数的类型,只要您只需要一个对象、一个数组或者后来的可调用代码。标量值(如整数、字符串和浮点数)根本无法实施。此外,如果你想声明一个方法或者一个函数的返回类型,你就完全没有运气了。

正如您将看到的,面向对象设计经常使用方法声明作为一种契约。该方法需要特定的输入,反过来,它承诺返回特定类型的数据。在许多情况下,PHP 5 程序员被迫依靠注释、约定和手工类型检查来维护这种契约。开发人员和评论员经常抱怨这一点。这是本书第四版中的一段引文:

仍然没有承诺提供对提示返回类型的支持。这将允许您在方法或函数的声明中声明它返回的对象类型。这将由 PHP 引擎强制执行。暗示的返回类型将进一步改善 PHP 对模式原则的支持(如“代码到接口,而不是实现”)。我希望有一天修订这本书,以涵盖这一特点!

我很高兴地告诉大家,这一天终于到来了!PHP 7 引入了标量类型声明(以前称为类型提示)和返回类型声明。更重要的是,PHP 7.4 通过引入类型化属性将类型安全性推进了一步。当然,所有这些都包含在这个版本中。

PHP 7 还提供了其他一些好处,包括匿名类和一些名称空间增强。

PHP 8:整合仍在继续

PHP 一直是一只伟大的喜鹊,从其他语言中借用闪亮的成熟特性。PHP 8 引入了许多新特性,包括属性,在其他语言中通常被称为注释。这些方便的标签可以用来提供关于系统中的类、方法、属性和常量的附加上下文信息。此外,PHP 8 继续扩展对类型声明的支持。在这方面特别有趣的是联合类型声明。这允许您声明属性或参数的类型应被约束为几种指定类型中的一种。您可以在利用 PHP 的类型灵活性的同时锁定您的类型。拥有你的蛋糕并吃掉它的定义!

倡导和不可知论:对象辩论

对象和面向对象的设计似乎激起了热情分水岭两边的激情。许多优秀的程序员多年来在不使用对象的情况下编写了优秀的代码,PHP 仍然是过程化 web 编程的优秀平台。

这本书自始至终自然地展示了面向对象的偏见,这种偏见反映了我受对象感染的观点。因为这本书对对象的颂扬,也是对面向对象设计的介绍,所以不可避免地强调面向对象。然而,本书并没有暗示对象是用 PHP 成功编码的唯一途径。

开发人员是否选择使用 PHP 作为面向对象语言曾经是一个偏好问题。在某种程度上,这仍然是正确的,人们可以使用函数和全局代码创建完全可以接受的工作系统。一些伟大的工具(比如 WordPress)在它们的底层架构中仍然是过程化的(尽管现在这些工具可能会大量使用对象)。然而,如果不使用和理解 PHP 对对象的支持,作为一名 PHP 程序员将变得越来越困难,尤其是因为您在项目中可能依赖的第三方库本身也可能是面向对象的。

尽管如此,当您阅读时,还是有必要记住著名的 Perl 格言,“有多种方法可以做到这一点。”对于较小的脚本来说尤其如此,在这种情况下,快速启动并运行一个工作示例比构建一个可以很好地扩展到更大系统的结构更重要(这种临时项目通常被称为“尖峰”)。

代码是一种灵活的媒介。诀窍是知道你的快速概念验证何时成为一个更大的开发的基础,并在你的代码的重量为你做出持久的设计决定之前停止。既然你已经决定在你的成长项目中采用面向设计的方法,我希望这本书能为你开始构建面向对象的架构提供帮助。

摘要

这个简短的章节将对象放在 PHP 语言的上下文中。PHP 的未来与面向对象设计紧密相关。在接下来的几章中,我将简要介绍 PHP 当前对对象特性的支持,并介绍一些设计问题。

三、对象基础

对象和类是本书的核心,自从十多年前引入 PHP 5 以来,它们也是 PHP 的核心。在这一章中,我通过研究 PHP 的核心面向对象特性,为更深入的对象和设计奠定了基础。如果你是面向对象编程的新手,你应该仔细阅读这一章。

本章将涵盖以下主题:

  • 类和对象:声明类和实例化对象

  • 构造器方法:自动设置你的对象

  • 原始类型和类类型:为什么类型很重要

  • 继承:为什么我们需要继承以及如何使用它

  • 可见性:简化你的对象接口,保护你的方法和属性不受干扰

类和对象

理解面向对象编程的第一个障碍是类和对象之间奇怪而奇妙的关系。对于许多人来说,正是这种关系代表了第一个启示的时刻,第一次面向对象的兴奋的闪光。所以我们不要忽略基本原则。

一等舱

类通常用对象来描述。这很有趣,因为对象通常是用类来描述的。这种循环会使面向对象编程的第一步变得困难。因为是类塑造了对象,所以我们应该从定义一个类开始。

简而言之,类是用于生成一个或多个对象的代码模板。您用关键字class和一个任意的类名来声明一个类。类名可以是数字和字母的任意组合,但不能以数字开头。它们也可以包含下划线字符。与类关联的代码必须用大括号括起来。在这里,我将这些元素结合起来构建一个类:

// listing 03.01

class ShopProduct
{
    // class body
}

示例中的ShopProduct类已经是一个合法的类,尽管它还不是非常有用。然而,我做了一件非常有意义的事情。我已经定义了一个类型;也就是说,我已经创建了一个可以在脚本中使用的数据类别。当你阅读这一章的时候,这种力量会变得更加清晰。

第一个(或两个)对象

如果类是生成对象的模板,那么对象就是根据类中定义的模板构造的数据。一个对象被称为它的类的一个实例。它是由类定义的类型。

我使用ShopProduct类作为生成ShopProduct对象的模型。为此,我需要new操作符。new运算符与类名结合使用,如下所示:

// listing 03.02
$product1 = new ShopProduct();
$product2 = new ShopProduct();

用类名作为唯一的操作数调用new操作符,并返回该类的一个实例;在我们的例子中,它生成一个ShopProduct对象。

我已经使用了ShopProduct类作为模板来生成两个ShopProduct对象。尽管它们在功能上是相同的(即,空的),$product1$product2是从单个类生成的相同类型的不同对象。

如果你仍然困惑,试试这个类比。把一个类想象成制造塑料鸭子的机器中的一个铸件。我们的目标是这台机器生产的鸭子。产生的东西的类型是由压制它的模具决定的。这些鸭子在各方面看起来都一样,但它们是不同的实体。换句话说,它们是同一类型的不同实例。这些鸭子甚至可能有自己的序列号来证明它们的身份。在 PHP 脚本中创建的每个对象都有自己唯一的标识符。(注意,标识符对于对象的生命周期是唯一的;也就是说,PHP 重用标识符,即使是在一个进程中。)我可以通过打印出$product1$product2对象来演示这一点:

// listing 03.03
var_dump($product1);
var_dump($product2);

执行这些函数会产生以下输出:

object(popp\ch03\batch01\ShopProduct)#235 (0) {

}

object(popp\ch03\batch01\ShopProduct)#234 (0) {

}

Note

在 PHP 的旧版本中(直到 5.1 版),可以直接打印一个对象。这将对象转换为包含对象 ID 的字符串。从 PHP 5.2 开始,这种语言不再支持这种魔力,任何将对象视为字符串的尝试都会导致错误,除非在对象的类中定义了名为__toString()的方法。我将在本章后面介绍方法,我将在第四章中介绍__toString()

通过将对象传递给var_dump(),我提取了有用的信息,包括在散列符号之后的每个对象的内部标识符。

为了让这些对象更有趣,我可以修改ShopProduct类来支持称为属性的特殊数据字段。

在类中设置属性

类可以定义称为属性的特殊变量。属性也称为成员变量,它保存的数据因对象而异。例如,对于ShopProduct对象,您可能希望操作标题和价格字段。

类中的属性看起来类似于标准变量,只是在声明属性时,必须在属性变量前面加上 visibility 关键字。这可以是publicprotectedprivate,它决定了代码中可以访问属性的位置。例如,公共属性可以在类外部访问,私有属性只能由类内部的代码访问。

我将在本章的后面回到这些关键词和可见性的问题。现在,我将使用关键字public声明一些属性:

// listing 03.04

class ShopProduct
{
    public $title = "default product";
    public $producerMainName = "main name";
    public $producerFirstName = "first name";
    public $price = 0;
}

如您所见,我设置了四个属性,并为每个属性分配了一个默认值。我从ShopProduct类实例化的任何对象现在将用默认数据预先填充。每个属性声明中的关键字public确保我可以从对象上下文外部访问属性。

您可以使用字符'->'(对象操作符)结合对象变量和属性名,逐个对象地访问属性变量,如下所示:

// listing 03.05
$product1 = new ShopProduct();
print $product1->title;

default product

因为属性被定义为public,您可以像读取它们一样给它们赋值,替换类中设置的任何默认值:

// listing 03.06
$product1 = new ShopProduct();
$product2 = new ShopProduct();
$product1->title = "My Antonia";
$product2->title = "Catch 22";

通过在ShopProduct类中声明和设置$title属性,我确保所有的ShopProduct对象在第一次创建时都有这个属性。这意味着基于这个假设,使用这个类的代码可以处理ShopProduct对象。因为我可以重置它,所以$title的值可能会因对象而异。

Note

使用类、函数或方法的代码通常被描述为类、函数或方法的客户端客户端代码。在接下来的章节中,你会经常看到这个术语。

事实上,PHP 并没有强迫我们在类中声明所有的属性。您可以向对象动态添加属性,如下所示:

// listing 03.07
$product1->arbitraryAddition = "treehouse";

然而,这种给对象分配属性的方法在面向对象编程中并不被认为是好的实践。

为什么动态设置属性是不好的做法?当你创建一个类时,你定义了一个类型。您告诉世界,您的类(以及从它实例化的任何对象)由一组特定的字段和函数组成。如果你的ShopProduct类定义了一个$title属性,那么任何使用ShopProduct对象的代码都可以在一个$title属性可用的假设下继续运行。但是,不能保证属性是动态设置的。

我的对象在这个阶段仍然是笨重的。当我需要处理一个对象的属性时,我必须在对象的外部进行。我伸手进去设置并获取属性信息。在多个对象上设置多个属性将很快变成一件苦差事:

// listing 03.08
$product1 = new ShopProduct();
$product1->title = "My Antonia";
$product1->producerMainName  = "Cather";
$product1->producerFirstName = "Willa";
$product1->price = 5.99;

我再次使用ShopProduct类,一个接一个地覆盖所有的默认属性值,直到我设置了所有的产品细节。既然我已经设置了一些数据,我也可以访问它:

// listing 03.09
print "author: {$product1->producerFirstName} "
    . "{$product1->producerMainName}\n";

这将输出以下内容:

author: Willa Cather

这种设置属性值的方法存在许多问题。因为 PHP 允许您动态设置属性,所以如果您拼错或忘记了属性名,您不会得到警告。例如,假设我想键入这一行:

// listing 03.10
$product1->producerFirstName = "Shirley";
$product1->producerMainName = "Jackson";

不幸的是,我错误地打成了这样:

// listing 03.11
$product1->producerFirstName = "Shirley";
$product1->producerSecondName = "Jackson";

就 PHP 引擎而言,这段代码完全合法,我不会被警告。但是,当我打印作者的名字时,我会得到意想不到的结果。

另一个问题是我的课堂太放松了。我没有被强迫去设定一个标题,一个价格,或者制片人的名字。客户端代码可以确保这些属性的存在,但是很可能经常会遇到默认值。理想情况下,我鼓励任何实例化ShopProduct对象的人设置有意义的属性值。

最后,我不得不千方百计去做一些我可能经常想做的事情。正如我们已经看到的,打印完整的作者姓名是一个令人厌倦的过程。

让对象代表我处理这种苦差事会很好。

所有这些问题都可以通过赋予ShopProduct对象自己的一组函数来解决,这些函数可以用来在对象上下文中操作属性数据。

使用方法

正如属性允许对象存储数据一样,方法允许对象执行任务。方法是在类中声明的特殊函数。正如您所料,方法声明类似于函数声明。function关键字位于方法名之前,后面是括号中的可选参数变量列表。方法体用大括号括起来:

// listing 03.12

public function myMethod($argument, $another)
{
    // ...
}

与函数不同,方法必须在类的主体中声明。它们还可以接受许多限定符,包括一个 visibility 关键字。像属性一样,方法可以被声明为publicprotectedprivate。通过声明一个方法public,您可以确保它可以从当前对象的外部被调用。如果在方法声明中省略了 visibility 关键字,该方法将被隐式声明为public。然而,为所有方法显式声明可见性被认为是一种好的做法(我将在本章后面回到方法修饰符)。

Note

在第十五章中,我将介绍代码中的最佳实践规则。编码风格标准 PSR-12 要求为所有方法声明可见性。

// listing 03.13

class ShopProduct
{
    public $title = "default product";
    public $producerMainName = "main name";
    public $producerFirstName = "first name";
    public $price = 0;

    public function getProducer()
    {
        return $this->producerFirstName . " "
            . $this->producerMainName;
    }
}

在大多数情况下,您将使用对象变量以及对象操作符->和方法名来调用方法。在方法调用中必须使用括号,就像调用函数一样(即使不向方法传递任何参数):

// listing 03.14

$product1 = new ShopProduct();
$product1->title = "My Antonia";
$product1->producerMainName = "Cather";
$product1->producerFirstName = "Willa";
$product1->price = 5.99;

print "author: {$product1->getProducer()}\n";

这将输出以下内容:

author: Willa Cather

我将getProducer()方法添加到ShopProduct类中。注意,我声明getProducer()是公共的,这意味着它可以从类外部调用。

我在这个方法的主体中引入了一个特性。$this伪变量是一个类引用一个对象实例的机制。如果你觉得这个概念难以接受,试着用短语“当前实例”代替$this请考虑以下陈述:

$this->producerFirstName

这转化为以下内容:

当前实例$producerFirstName 属性

因此,getProducer()方法组合并返回$producerFirstName$producerMainName属性,使我在每次需要引用完整的生产者名称时免于执行这项任务。

这让类提高了一点。尽管如此,我仍然受困于大量不必要的灵活性。我依靠客户端编码器来改变一个ShopProduct对象的默认值。这在两个方面存在问题。首先,正确初始化一个ShopProduct对象需要 5 行代码,没有一个编码人员会为此感谢你。第二,我无法确保在初始化ShopProduct对象时设置了任何属性。

我需要的是一个当一个对象从一个类实例化时自动调用的方法。

创建构造函数方法

创建对象时会调用构造函数方法。您可以使用它来进行设置,确保为重要的属性赋值,并完成任何必要的准备工作。

Note

在 PHP 5 之前的版本中,构造函数方法采用了封装它的类名。所以ShopProduct类将使用一个ShopProduct()方法作为它的构造函数。这在 PHP 7 中被否决,在 PHP 8 中不再有效。将你的构造方法命名为__construct()

注意,方法名以两个下划线字符开头。对于 PHP 类中的许多其他特殊方法,您会看到这种命名约定。在这里,我为ShopProduct类定义了一个构造函数:

Note

以这种方式开始的内置方法被称为魔法方法,因为它们在特定的环境中会被自动调用。你可以在 www.php.net/manual/en/language.oop5.magic.php 的 PHP 手册中读到更多关于它们的内容。虽然这样做并不违法,因为双下划线有如此特殊的含义,但在您自己的自定义方法中避免使用它们是一个好主意。

// listing 03.15
class ShopProduct
{
    public $title;
    public $producerMainName;
    public $producerFirstName;
    public $price = 0;

    public function __construct(
        $title,
        $firstName,
        $mainName,
        $price
    ) {
        $this->title = $title;
        $this->producerFirstName = $firstName;
        $this->producerMainName = $mainName;
        $this->price = $price;
    }

    public function getProducer()
    {
        return $this->producerFirstName . " "
            . $this->producerMainName;
    }
}

我再一次将功能收集到类中,节省了使用它的代码的工作量和重复。使用new操作符创建对象时,调用__construct()方法:

// listing 03.16
$product1 = new ShopProduct(
    "My Antonia",
    "Willa",
    "Cather", 5.99
);
print "author: {$product1->getProducer()}\n";

这会产生以下结果:

author: Willa Cather

提供的任何参数都传递给构造函数。所以在我的例子中,我将标题、名字、主要名称和产品价格传递给构造函数。构造函数方法使用伪变量$this给对象的每个属性赋值。

Note

一个ShopProduct对象现在更容易实例化,使用起来也更安全。实例化和设置在一条语句中完成。任何使用ShopProduct对象的代码都可以合理地确定它的所有属性都已初始化。

您可以不初始化属性而不出错。但是任何访问该属性的尝试都会导致致命错误。

构造函数属性提升

虽然我们已经使ShopProduct类更加安全,并且从客户的角度来看更加方便,但是我们也引入了相当多的样板文件。回顾一下这个类的现状。为了用四个属性实例化一个对象,我们总共需要三组对数据的引用。首先,我们声明属性,然后我们提供构造函数参数来保存数据,然后当我们将方法参数分配给属性时,我们将所有数据放在一起。PHP 8 提供了一个名为构造器 属性提升的特性,它提供了一个受欢迎的快捷方式。通过为您的构造函数参数包含一个 visibility 关键字,您可以将它们与同时分配给它们的属性声明结合起来。下面是ShopProduct的新版本:

// listing 03.17
class ShopProduct
{
    public function __construct(
        public $title,
        public $producerFirstName,
        public $producerMainName,
        public $price
    ) {
    }

    public function getProducer()
    {
        return $this->producerFirstName . " "
            . $this->producerMainName;
    }
}

构造函数方法签名中属性的声明和赋值都是隐式处理的。通过减少重复,这也减少了错误潜入代码的机会。通过使类更加紧凑,阅读源代码的人更容易关注逻辑。

Note

PHP 8 中引入了构造函数属性提升。如果您的项目仍然运行 PHP 7,那么您应该避免利用新语法。

可预测性是面向对象编程的一个重要方面。您应该设计您的类,以便对象的用户可以确定它们的特性。使对象安全的一种方法是使其属性中保存的数据类型可预测。例如,可以确保一个$name属性总是由字符数据组成。但是,如果属性数据是从类外部传入的,如何实现这一点呢?在下一节中,我将研究一种可以用来在方法声明中强制对象类型的机制。

默认参数和命名参数

随着时间的推移,方法参数列表会变得又长又笨拙。这使得处理一个类变得越来越困难,因为很难跟踪它的方法所需要的参数。通过在方法定义中提供默认值,我们可以让客户端编码人员的工作变得更容易。比方说,我们需要一个标题给我们的ShopProduct对象,但是接受空字符串值作为生产者名称,零值作为价格。对于ShopProduct,调用代码需要提供所有这些数据:

// listing 03.18
$product1 = new ShopProduct("Shop Catalogue", "", "", 0);

我们可以通过为参数提供默认值来简化这种实例化。在下一个例子中,我就是这样做的:

// listing 03.19
class ShopProduct
{
    public function __construct(
    public $title,
    public $producerFirstName = "",
    public $producerMainName = "",
    public $price = 0
    ) {
    }

    // ...
}

只有当调用代码在其调用中不提供值时,这些赋值才会被激活。现在,对构造函数的调用只需要指定一个值:标题。

// listing 03.20
$product1 = new ShopProduct("Shop Catalogue");

默认的参数值可以使使用方法更加方便,但是,通常情况下,它们也会导致意想不到的复杂化。如果我想提供一个价格,但仍然希望生产者名称回到它们的默认值,那么我的紧凑构造函数调用会发生什么情况呢?在 PHP 8 之前,我会被卡住。为了指定价格,我必须提供空的生产商名称。这让我们兜了一圈。我还需要弄清楚构造函数期望空的生产者名称值是什么类型的值。我应该传递空字符串吗?还是空值?我对缺省值的支持非但没有节省工作,反而很可能造成了混乱。

幸运的是,PHP 8 提供了命名参数。在我的方法调用中,我现在可以在我希望传递的值前面指定每个参数名。然后,PHP 将该值与方法签名中的正确参数相关联,而不考虑调用代码中的顺序。

// listing 03.21
$product1 = new ShopProduct(
    price: 0.7,
    title: "Shop Catalogue"
);

注意这里的语法:我告诉 PHP 我想通过首先指定参数名price,然后是冒号,然后是我想提供的值,将$price参数设置为0.7。因为我已经使用了命名参数,它们在调用中的顺序不再相关,我不再需要提供空的生产者名称值。

参数和类型

类型决定了在脚本中管理数据的方式。例如,您可以使用string类型来显示字符数据,并使用字符串函数来操作这些数据。数学表达式中使用整数,测试表达式中使用布尔,等等。这些类别被称为基本类型。然而,在更高的层次上,类定义了类型。因此,ShopProduct对象属于原始类型object,但它也属于ShopProduct类类型。在这一节中,我将研究这两种类型与类方法的关系。

方法和函数定义不一定要求参数应该是特定的类型。这既是祸也是福。参数可以是任何类型,这一事实为您提供了灵活性。您可以构建智能地响应不同数据类型的方法,根据不断变化的环境定制功能。当一个方法体期望一个参数保存一种类型,但却得到另一种类型时,这种灵活性也会导致代码中出现歧义。

原始类型

PHP 是一种松散类型的语言。这意味着没有必要声明变量来保存特定的数据类型。变量$number可以在相同的范围内保存值2和字符串"two"。在强类型语言中,如 C 或 Java,在给变量赋值之前,必须声明变量的类型,当然,该值必须是指定的类型。

这并不意味着 PHP 没有类型的概念。每个可以赋给变量的值都有一个类型。您可以使用 PHP 的一个类型检查函数来确定变量值的类型。表 3-1 列出了 PHP 中识别的原语类型及其对应的测试函数。每个函数接受一个变量或值,如果这个参数是相关类型的,则返回true

表 3-1

PHP 中的基本类型和检查函数

|

类型检查功能

|

类型

|

描述

| | --- | --- | --- | | is_bool() | 布尔代数学体系的 | 两个特殊值之一 true 或 false | | is_integer() | 整数 | 一个整数。is_int()is_long()的别名 | | is_float() | 浮动 | 浮点数(带小数点的数字)。is_double()的别名 | | is_string() | 线 | 字符数据 | | is_object() | 目标 | 一个物体 | | is_resource() | 资源 | 用于识别和使用外部资源(如数据库或文件)的句柄 | | is_array() | 排列 | 阵列 | | is_null() | 空 | 未分配的价值 |

当您使用方法和函数参数时,检查变量的类型可能特别重要。

基本类型:一个示例

您需要密切关注代码中的类型。这是你可能遇到的许多类型相关问题中的一个例子。

假设您正在从 XML 文件中提取配置设置。XML 元素告诉您的应用是否应该尝试将 IP 地址解析为域名,这是一个有用但相对昂贵的过程。

以下是一些示例 XML:

// listing 03.22
<settings>
    <resolvedomains>false</resolvedomains>
</settings>

字符串"false"由您的应用提取,并作为一个标志传递给一个名为outputAddresses()的方法,该方法显示 IP 地址数据。这里是outputAddresses():

// listing 03.23

class AddressManager
{
    private $addresses = ["209.131.36.159", "216.58.213.174"];

    public function outputAddresses($resolve)
    {
        foreach ($this->addresses as $address) {
            print $address;
            if ($resolve) {
                print " (" . gethostbyaddr($address) . ")";
            }
            print "\n";
        }
    }
}

当然,AddressManager类可以做一些改进。例如,将 IP 地址硬编码到一个类中不是很有用。然而,outputAddresses()方法循环遍历$addresses数组属性,打印每个元素。如果$resolve参数变量本身解析为true,该方法将输出域名和 IP 地址。

这里有一种结合使用settings XML 配置元素和AddressManager类的方法。看看你是否能发现它有什么缺陷:

// listing 03.24
$settings = simplexml_load_file(__DIR__ . "/resolve.xml");
$manager = new AddressManager();
$manager->outputAddresses((string)$settings->resolvedomains);

代码片段使用SimpleXML API 来获取resolvedomains元素的值。在这个例子中,我知道这个值是文本元素"false",我按照SimpleXML文档的建议将它转换成一个字符串。

这段代码不会像您预期的那样运行。在将字符串"false"传递给outputAddresses()方法时,我误解了该方法对参数的隐含假设。该方法需要一个布尔值(即truefalse)。事实上,字符串"false"将在测试中解析为true。这是因为 PHP 将在测试上下文中帮助您将一个非空字符串值转换为布尔值true。考虑以下代码:

if ("false") {
    // ...
}

它实际上相当于这样:

if (true) {
    // ...
}

有许多方法可以解决这个问题。

您可以使outputAddresses()方法更加宽容,这样它可以识别一个字符串,并应用一些基本规则将它转换成布尔等价形式:

// listing 03.25
public function outputAddresses($resolve)
{
    if (is_string($resolve)) {
        $resolve = (preg_match("/^(false|no|off)$/i", $resolve)) ? false : true;
    }
    // ...
}

然而,避免这种方法有很好的设计理由。一般来说,为一个方法或函数提供一个清晰严格的接口比提供一个模糊宽容的接口要好。模糊和宽容的函数和方法会导致混乱,从而滋生错误。

您可以采用另一种方法:让outputAddresses()方法保持原样,并包含一个注释,该注释包含明确的指令,即$resolve参数应该包含一个布尔值。这种方法本质上是告诉编码者阅读小字,否则后果自负:

// listing 03.26

/**
 * Outputs the list of addresses.
 * If $resolve is true then each address will be resolved
 * @param    $resolve    boolean    Resolve the address?
 */
public function outputAddresses($resolve)
{
    // ...
}

这是一种合理的方法,假设您的客户端编码人员是文档的勤奋读者(或者使用能够识别这种注释的聪明编辑器)。

最后,您可以让outputAddresses()严格控制它准备在$resolve参数中找到的数据类型。对于像 Boolean 这样的基本类型,在 PHP 7 发布之前只有一种方法可以做到这一点。您必须编写代码来检查传入的数据,并在数据与所需类型不匹配时采取某种措施:

// listing 03.27
public function outputAddresses($resolve)
{
    if (! is_bool($resolve)) {
        // do something drastic
    }
}

这种方法可以用来强制客户端代码在$resolve参数中提供正确的数据类型,或者发出警告。

Note

在下一节“类型声明:对象类型”中,我将描述一种更好的方法来约束传递给方法和函数的参数类型。

代表客户端转换字符串参数是友好的,但可能会带来其他问题。在提供转换机制时,您要猜测客户端的上下文和意图。另一方面,通过强制布尔数据类型,您让客户端决定是否将字符串映射到布尔值,并决定哪个单词应该映射到truefalse。与此同时,outputAddresses()方法专注于它被设计来执行的任务。这种强调在故意忽略更广泛的上下文的情况下执行特定任务的做法是面向对象编程中的一个重要原则,我将在整本书中经常提到它。

事实上,您处理参数类型的策略一方面取决于任何潜在错误的严重性,另一方面取决于灵活性的好处。PHP 根据上下文为您转换大多数原始值。例如,当在数学表达式中使用时,字符串中的数字被转换为整数或浮点等效值。因此您的代码可能会自然地原谅类型错误。

然而,总的来说,当涉及到对象和基本类型时,最好是在严格性方面出错。幸运的是,PHP 8 提供了比以前更多的工具来加强类型安全。

一些其他类型检查函数

我们已经看到了检查原始类型的变量处理函数。当我们检查变量的内容时,值得一提的是几个函数,它们超出了检查基本类型的范围,提供了关于变量中保存的数据的使用方式的更一般的信息。我在表 3-2 中列出了这些。

表 3-2

伪类型检查函数

|

功能

|

描述

| | --- | --- | | is_countable() | 可以传递给count()函数的数组或对象 | | is_iterable() | 可遍历的数据结构——也就是说,可以使用foreach进行循环 | | is_callable() | 可以调用的代码—通常是匿名函数或函数名 | | is_numeric() | int、long 或可以解析为数字的字符串 |

表 3-2 中描述的函数并不检查具体的类型,而是检查处理测试值的方式。例如,如果is_callable()为一个变量返回true,您知道您可以像对待一个函数或方法一样对待它并调用它。类似地,您可以循环通过一个通过了is_iterable()测试的值——即使它可能是一种特殊的对象而不是数组。

类型声明:对象类型

正如参数变量可以包含任何基本类型一样,默认情况下,它可以包含任何类型的对象。这种灵活性有其用途,但在方法定义的上下文中可能会出现问题。

想象一个设计用来处理ShopProduct对象的方法:

// listing 03.28
class ShopProductWriter
{
    public function write($shopProduct)
    {
        $str  = $shopProduct->title . ": "
            . $shopProduct->getProducer()
            . " (" . $shopProduct->price . ")\n";
        print $str;
    }
}

您可以像这样测试这个类:

// listing 03.29
$product1 = new ShopProduct("My Antonia", "Willa", "Cather", 5.99);
$writer = new ShopProductWriter();
$writer->write($product1);

这将输出以下内容:

My Antonia: Willa Cather (5.99)

ShopProductWriter类包含一个方法write()write()方法接受一个ShopProduct对象,并使用它的属性和方法来构造和打印一个摘要字符串。我使用了参数变量的名字$shopProduct,作为该方法需要一个ShopProduct对象的信号,但是我没有强制这样做。这意味着我可能会被传递一个意外的对象或原始类型,直到我开始尝试使用$shopProduct参数时才知道。到那时,我的代码可能已经假定它已经被传递了一个真正的ShopProduct对象。

Note

你可能想知道为什么我没有直接把write()方法添加到ShopProduct中。原因在于责任范围。ShopProduct类负责管理产品数据;ShopProductWriter负责写它。当你阅读本章时,你会开始明白为什么这种分工会有用。

为了解决这个问题,PHP 5 引入了类类型声明(当时称为类型提示)。要将类类型声明添加到方法参数中,只需在需要约束的方法参数前放置一个类名。所以我可以这样修改write()方法:

// listing 03.30
public function write(ShopProduct $shopProduct)
{
    // ...
}

现在,write()方法将只接受包含类型为ShopProduct的对象的$shopProduct参数。

下面是一个基础类:

// listing 03.31
class Wrong
{
}

下面是一个试图用一个Wrong对象调用write()的片段:

// listing 03.32
$writer = new ShopProductWriter();
$writer->write(new Wrong());

因为write()方法包含一个类类型声明,传递给它一个Wrong对象会导致致命错误。

TypeError: popp\ch03\batch08\ShopProductWriter::write(): Argument #1 ($shopProduct) must be of type
popp\ch03\batch04\ShopProduct, popp\ch03\batch08\Wrong given, called in /var/popp/src/ch03/batch08/Runner.php on ...

Note

在 TypeError 示例输出中,您可能已经注意到引用的类包含了许多附加信息。例如,Wrong类被引用为popp\ch03\batch08\ Wrong。这些是名称空间的例子,你会在第四章中遇到它们的细节。

这使我不必在处理之前测试参数的类型。这也使得方法签名对于客户端编码者来说更加清晰。她一眼就能看出write()方法的要求。她不必担心由类型错误引起的一些模糊错误,因为声明是严格执行的。

尽管这种自动类型检查是防止错误的好方法,但是理解类型声明是在运行时检查的还是很重要的。这意味着类声明只会在不需要的对象被传递给方法时报告错误。如果对write()的调用隐藏在一个只在圣诞节早上运行的条件子句中,如果你没有仔细检查你的代码,你可能会发现自己在这个假期工作。

类型声明:基本类型

在 PHP 7 发布之前,只能约束对象和一些其他类型(callable 和 array)。PHP 7 最终引入了标量类型声明。这允许您在参数列表中强制使用布尔、字符串、整数和浮点类型。

有了标量类型声明,我可以向ShopProduct类添加一些约束:

// listing 03.33
class ShopProduct
{
    public $title;
    public $producerMainName;
    public $producerFirstName;
    public $price = 0;

    public function __construct(
        string $title,
        string $firstName,
        string $mainName,
        float $price
    ) {
        $this->title = $title;
        $this->producerFirstName = $firstName;
        $this->producerMainName = $mainName;
        $this->price = $price;
    }

    // ...
}

有了这样的构造函数方法,我就可以确定$title$firstName$mainName参数将总是包含字符串数据,而$price将包含一个浮点数。我可以通过用错误的信息实例化ShopProduct来证明这一点:

// listing 03.34
// will fail
$product = new ShopProduct("title", "first", "main", []);

我试图实例化一个ShopProduct对象。我向构造函数传递了三个字符串,但是我在最后一关失败了,因为我传入了一个空数组而不是所需的 float。多亏了类型声明,PHP 不会让我得逞:

TypeError: popp\ch03\batch09\ShopProduct:: construct(): Argument #4 ($price) must be of type float, array given, called in...

默认情况下,在可能的情况下,PHP 会隐式地将参数转换为所需的类型。这是我们之前遇到的安全性和灵活性之间紧张关系的一个例子。例如,ShopProduct类的新实现将悄悄地为我们把一个字符串转换成一个浮点数。因此,该实例化不会失败:

// listing 03.35
$product = new ShopProduct("title", "first", "main", "4.22");

在幕后,字符串"4.22"变成了浮点4.22。到目前为止,很有用。但是回想一下我们在使用AddressManager类时遇到的问题。字符串"false"被悄悄地解析成布尔型true。默认情况下,如果我像这样在AddressManager::outputAddresses()方法中使用bool类型声明,这种情况仍然会发生:

// listing 03.36
public function outputAddresses(bool $resolve)
{
    // ...
}

现在考虑这样一个调用,它传递一个字符串:

// listing 03.37
$manager->outputAddresses("false");

由于隐式转换,它在功能上等同于传递布尔值true的函数。

您可以使标量类型声明变得严格,尽管只能在逐个文件的基础上进行。在这里,我打开严格类型声明并再次用字符串调用outputAddresses():

// listing 03.38
declare(strict_types=1);

        $manager->outputAddresses("false");

因为我声明了严格类型,所以这个调用导致抛出一个TypeError:

TypeError: popp\ch03\batch09\AddressManager::outputAddresses(): Argument #1 ($resolve) must be of type bool, string given, called in...

Note

strict_types声明适用于发出调用的文件,而不适用于实现函数或方法的文件。所以由客户机代码来执行严格性。

您可能需要将参数设置为可选的,但是如果提供了参数的话,还是要对其类型进行约束。您可以通过提供默认值来做到这一点:

// listing 03.39
class ConfReader
{

    public function getValues(array $default = [])
    {
        $values = [];

        // do something to get values

        // merge the provided defaults (it will always be an array)
        $values = array_merge($default, $values);
        return $values;
    }
}

mixed类型

PHP 8.0 中引入的mixed类型声明可能被视为一个语法糖的例子——也就是说,它本身并没有做太多事情。这之间没有功能的区别:

// listing 03.40
class Storage
{
    public function add(string $key, $value)
    {
        // do something with $key and $value
    }
}

还有这个:

// listing 03.41

class Storage
{
    public function add(string $key, mixed $value)
    {
        // do something with $key and $value
    }
}

在第二个版本中,我声明add()$value参数将接受mixed——换句话说,来自arrayboolcallableintfloatnullobjectresourcestring的任何类型。所以声明一个mixed $value等同于让$value在参数列表中没有类型声明。那么,为什么要为mixed声明费心呢?本质上,您是在声明参数有意地接受任何值。一个空的参数可能会接受任何值,或者因为代码作者懒惰而没有类型声明。消除怀疑和不确定性,因此它是有用的。

综上所述,在表 3-3 中,我列出了 PHP 支持的类型声明。

表 3-3

类型声明

|

类型声明

|

因为

|

描述

| | --- | --- | --- | | array | Five point one | 一个数组。可以默认为null或一个数组 | | int | Seven | 整数。可以默认为null或整数 | | float | Seven | 浮点数(带小数点的数字)。即使启用了严格模式,也将接受整数。可以默认为null,一个浮点数,或者一个整数 | | callable | Five point four | 可调用代码(如匿名函数)。可以默认为null | | bool | Seven | 一个布尔值。可以默认为null或布尔值 | | string | Five | 字符数据。可以默认为null或一个字符串 | | self | Five | 对包含类的引用 | | [a class type] | Five | 类或接口的类型。可以默认为null | | iterable | Seven point one | 可以用foreach遍历(不一定是数组,可以实现Traversable) | | 目标 | Seven point two | 一个物体 | | 混合的 | Eight | 值可以是任何类型的显式通知 |

联合类型

在包罗万象的mixed声明和相对严格的类型声明之间有一条鸿沟。如果需要将一个参数约束到两个、三个或更多的命名类型,该怎么办?在 PHP 8 之前,实现这一点的唯一方法是在方法体中测试类型。让我们带着新的需求回到Storage类。add()应该只接受一个字符串或一个布尔值作为它的$value方法。下面是一个在方法体中检查类型的实现:

// listing 03.42

class Storage
{
    public function add(string $key, $value)
    {
        if (! is_bool($value) && ! is_string($value)) {
            error_log("value must be string or Boolean - given: " . gettype($value));
            return false;
        }
        // do something with $key and $value
    }
}

Note

事实上,我们可能会抛出一个异常,而不是返回false。你可以在第四章中读到更多关于异常的内容。

虽然这种手工检查完成了工作,但它很难操作,也很难阅读。幸运的是,PHP 8 引入了一个新特性:联合类型,它允许您组合两个或多个由管道符号分隔的类型,以进行复合类型声明。

下面是我对Storage的重新实现:

// listing 03.43

class Storage
{
    public function add(string $key, string|bool $value)
    {
        // do something with $key and $value
    }
}

如果我现在试图将$value设置为除了 float 或 Boolean 之外的任何值,我将触发一个现在已经很熟悉的TypeError

如果我想让add()更宽容一点,我也可以使用联合类型来允许一个null值。

// listing 03.44

class Storage
{
    public function add(string $key, string|bool|null $value)
    {
        // do something with $key and $value
    }

}

联合类型声明与对象类型声明一样有效。此示例将接受类型为ShopProduct的对象或空值:

// listing 03.45
public function setShopProduct(ShopProduct|null $product)
{
    // do something with $product
}

因为许多方法接受或返回false作为可选值,PHP 8 在联合的上下文中支持false伪类型。因此,在这个例子中,我将接受一个ShopProduct对象或false:

// listing 03.46
    public function setShopProduct2(ShopProduct|false $product)
    {
        // do something with $product
    }

}

这比 union ShopProduct|bool更有用,因为在任何情况下我都不想接受true

Note

PHP 8 中增加了联合类型。

可空类型

当联合类型接受null作为两个选项之一时,您可以使用一个等价的参数。可空类型由前面带问号的类型声明组成。所以这个版本的Storage要么接受一个字符串,要么接受null:

// listing 03.47

class Storage
{
    public function add(string $key, ?string $value)
    {
        // do something with $key and $value
    }
}

当我描述类类型声明时,我暗示类型和类是同义的。然而,这两者之间有一个关键的区别。当你定义一个类的时候,你也定义了一个类型,但是一个类型可以描述整个类家族。将不同的类组合在一个类型下的机制称为继承。我将在下一节讨论继承。

返回类型声明

正如我们可以声明参数的类型一样,我们也可以使用返回类型声明来约束方法返回的类型。返回类型声明直接放在方法或函数的右括号之后,采用冒号后跟类型的形式。声明返回类型和声明参数类型时支持相同的类型集。所以这里我约束了getPlayLength()的返回类型:

// listing 03.48
public function getPlayLength(): int
{
    return $this->playLength;
}

如果调用此方法时未能返回整数值,PHP 将生成一个错误:

TypeError: popp\ch03\batch15\CdProduct::getPlayLength(): Return value must be of type int, none returned

因为返回值是以这种方式强制的,所以任何调用此方法的代码都可以放心地将其返回值视为整数。

返回类型声明支持可空类型和联合类型。让我们强制一个联合类型:

// listing 03.49
public function getPrice(): int|float
{
    return ($this->price - $this->discount);
}

从 PHP 8 开始,有一种类型是由返回类型声明而不是参数类型声明支持的。您可以声明一个方法永远不会返回一个带有void伪类型的值。因此,例如,因为setDiscount()方法被设计为设置而不是提供一个值,所以我在这里使用了一个void返回类型声明:

// listing 03.50
public function setDiscount(int|float $num): void
{
    $this->discount = $num;
}

继承

继承是从基类派生一个或多个类的方法。

从另一个类继承而来的类称为它的子类。这种关系经常用父母和孩子来描述。子类从父类派生并继承父类的特征。这些特征包括属性和方法。子类通常会向其父类(也称为超类)提供的功能添加新功能;由于这个原因,子类扩展了它的父类。

在深入研究继承的语法之前,我将检查它可以帮助您解决的问题。

继承问题

再看看ShopProduct类。目前,它非常通用。它可以处理各种产品:

// listing 03.51

$product1 = new ShopProduct("My Antonia", "Willa", "Cather", 5.99);
$product2 = new ShopProduct(
    "Exile on Coldharbour Lane", "The",
    "Alabama 3",
    10.99
);
print "author: " . $product1->getProducer() . "\n";
print "artist: " . $product2->getProducer() . "\n";

以下是输出结果:

author: Willa Cather
artist: The Alabama 3

将制作者的名字分成两部分对书籍和 CD 都很有效。我希望能够对“阿拉巴马 3”和“凯瑟”进行排序,而不是对“The”和“Willa”进行排序。懒惰是一种优秀的设计策略,所以现阶段不需要担心对不止一种产品使用ShopProduct

然而,如果我在我的例子中添加一些新的需求,事情会迅速变得更加复杂。例如,假设您需要表示特定于书籍和 CD 的数据。对于 CD,必须存储总播放时间;对于书籍,总页数。可能有许多其他的差异,但这将有助于说明这个问题。

我如何扩展我的例子来适应这些变化?两种选择立即呈现在眼前。首先,我可以将所有的数据放入ShopProduct类。其次,我可以将ShopProduct分成两个独立的类。

让我们检查第一种方法。这里,我将 CD 和书籍相关的数据合并到一个类中:

// listing 03.52
class ShopProduct
{
    public $numPages;
    public $playLength;
    public $title;
    public $producerMainName;
    public $producerFirstName;
    public $price;

    public function __construct(
        string $title,
        string $firstName,
        string $mainName,
        float $price,
        int $numPages = 0,
        int $playLength = 0
    ) {
        $this->title             = $title;
        $this->producerFirstName = $firstName;
        $this->producerMainName  = $mainName;
        $this->price             = $price;
        $this->numPages          = $numPages;
        $this->playLength        = $playLength;
    }

    public function getNumberOfPages(): int
    {
        return $this->numPages;
    }

    public function getPlayLength(): int
    {
        return $this->playLength;
    }

    public function getProducer(): string
    {
        return $this->producerFirstName . " "
            . $this->producerMainName;
    }
}

我已经提供了对$numPages$playLength属性的方法访问,以说明在这里起作用的不同力量。从此类实例化的对象将包含一个冗余方法,对于 CD,必须使用一个不必要的构造函数参数进行实例化:CD 将存储与书籍页面相关的信息和功能,而书籍将支持播放长度数据。这可能是你现在可以忍受的。但是,如果我添加更多的产品类型,每个类型都有自己的方法,然后为每个类型添加更多的方法,会发生什么呢?我们的类会变得越来越复杂,越来越难管理。

因此,将不属于同一个类的字段强制放入一个类会导致对象臃肿,具有冗余的属性和方法。

问题也不仅仅止于数据。我在功能性方面也遇到了困难。考虑一种总结产品的方法。销售部门要求在发票中使用清晰的汇总行。他们希望我包括 CD 的播放时间和书籍的页数,所以我将被迫为每种类型提供不同的实现。我可以尝试使用一个标志来跟踪对象的格式。

这里有一个例子:

// listing 03.53

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;
}

为了设置$type属性,我可以测试构造函数的$numPages参数。然而,ShopProduct类又一次变得比必要的更加复杂。随着我在我的格式中添加更多的差异,或者添加新的格式,这些功能上的差异将变得更加难以管理。也许我应该尝试用另一种方法来解决这个问题。

由于开始感觉像是两个类合二为一,我可以接受这一点,创建两种类型而不是一种。我可能会这样做:

// listing 03.54
class CdProduct
{
    public $playLength;
    public $title;
    public $producerMainName;
    public $producerFirstName;
    public $price;

    public function __construct(
        string $title,
        string $firstName,
        string $mainName,
        float $price,
        int $playLength
    ) {
        $this->title             = $title;
        $this->producerFirstName = $firstName;
        $this->producerMainName  = $mainName;
        $this->price             = $price;
        $this->playLength        = $playLength;
    }

    public function getPlayLength(): int
    {
        return $this->playLength;
    }

    public function getSummaryLine(): string
    {
        $base  = "{$this->title} ( {$this->producerMainName}, ";
        $base .= "{$this->producerFirstName} )";
        $base .= ": playing time - {$this->playLength}";
        return $base;
    }

    public function getProducer(): string
    {
        return $this->producerFirstName . " "
            . $this->producerMainName;
    }
}

// listing 03.55
class BookProduct
{
    public $numPages;
    public $title;
    public $producerMainName;
    public $producerFirstName;
    public $price;

    public function __construct(
        string $title,
        string $firstName,
        string $mainName,
        float $price,
        int $numPages
    ) {
        $this->title             = $title;
        $this->producerFirstName = $firstName;
        $this->producerMainName  = $mainName;
        $this->price             = $price;
        $this->numPages          = $numPages;
    }

    public function getNumberOfPages(): int
    {
        return $this->numPages;
    }

    public function getSummaryLine(): string
    {
        $base  = "{$this->title} ( {$this->producerMainName}, ";
        $base .= "{$this->producerFirstName} )";
        $base .= ": page count - {$this->numPages}";
        return $base;
    }

    public function getProducer(): string
    {
        return $this->producerFirstName . " "
            . $this->producerMainName;
    }
}

我已经解决了复杂性问题,但这是有代价的。我现在可以为每种格式创建一个getSummaryLine()方法,而不必测试标志。这两个类都不维护与其无关的字段或方法。

代价在于重复。每个类中的getProducerName()方法完全相同。每个构造函数都以相同的方式设置许多相同的属性。这是另一种你应该训练自己嗅出的难闻气味。

如果我需要getProducer()方法对每个类都有相同的行为,那么我对一个实现所做的任何更改都需要对另一个实现进行。如果不小心,这些类很快就会失去同步。

即使我有信心我可以保持复制,我的担忧并没有结束。我现在有两种类型而不是一种。

还记得ShopProductWriter课吗?它的write()方法被设计为使用单一类型:ShopProduct。我该如何修改它才能像以前一样工作?我可以从方法签名中删除类类型声明,但是我必须相信write()被传递了一个正确类型的对象。我可以在方法体中添加我自己的类型检查代码:

// listing 03.56
class ShopProductWriter
{
    public function write($shopProduct): void
    {
        if (
            ! ($shopProduct instanceof CdProduct) &&
            ! ($shopProduct instanceof BookProduct)
        ) {
            die("wrong type supplied");
        }
        $str  = "{$shopProduct->title}: "
            . $shopProduct->getProducer()
            . " ({$shopProduct->price})\n";
        print $str;
    }
}

注意例子中的instanceof操作符;如果左边操作数中的对象属于右边操作数所代表的类型,则instanceof解析为true

我又一次被迫增加了一层新的复杂性。我不仅要针对write()方法中的两种类型测试$shopProduct参数,还要相信每种类型都将继续支持与另一种类型相同的字段和方法。当我简单地要求单一类型时,一切都变得更加简洁,因为我可以使用类类型声明,因为我可以确信ShopProduct类支持特定的接口。

CD 和书籍这两个方面似乎不能很好地结合在一起,但又不能分开。我想把书籍和 CD 作为一个单一的类型,同时为每种格式提供一个单独的实现。我想在一个地方提供通用的功能以避免重复,但允许每种格式以不同的方式处理一些方法调用。我需要使用继承。

使用继承

构建继承树的第一步是找到基类中不匹配或者需要不同处理的元素。

我知道getPlayLength()getNumberOfPages()方法不属于一起。我也知道我需要为getSummaryLine()方法创建不同的实现。

让我们将这些差异作为两个派生类的基础:

// listing 03.57
class ShopProduct
{
    public $numPages;
    public $playLength;
    public $title;
    public $producerMainName;
    public $producerFirstName;
    public $price;

    public function __construct(
        string $title,
        string $firstName,
        string $mainName,
        float $price,
        int $numPages = 0,
        int $playLength = 0
    ) {
        $this->title             = $title;
        $this->producerFirstName = $firstName;
        $this->producerMainName  = $mainName;
        $this->price             = $price;
        $this->numPages          = $numPages;
        $this->playLength        = $playLength;
    }

    public function getProducer(): string
    {
        return $this->producerFirstName . " "
            . $this->producerMainName;
    }

    public function getSummaryLine(): string
    {
        $base  = "{$this->title} ( {$this->producerMainName}, ";
        $base .= "{$this->producerFirstName} )";
        return $base;
    }
}

// listing 03.58
class CdProduct extends ShopProduct
{
    public function getPlayLength(): int
    {
        return $this->playLength;
    }

    public function getSummaryLine(): string
    {
        $base  = "{$this->title} ( {$this->producerMainName}, ";
        $base .= "{$this->producerFirstName} )";
        $base .= ": playing time - {$this->playLength}";
        return $base;
    }
}

// listing 03.59
class BookProduct extends ShopProduct
{
    public function getNumberOfPages(): int
    {
        return $this->numPages;
    }

    public function getSummaryLine(): string
    {
        $base  = "{$this->title} ( {$this->producerMainName}, ";
        $base .= "{$this->producerFirstName} )";
        $base .= ": page count - {$this->numPages}"; return $base;
    }
}

要创建子类,必须在类声明中使用extends关键字。在这个例子中,我创建了两个新的类,BookProductCdProduct。两者都扩展了ShopProduct类。

因为派生类不定义构造函数,所以当它们被实例化时,父类的构造函数被自动调用。子类继承对所有父类的公共和受保护方法的访问(尽管不是私有方法或属性)。这意味着您可以在从CdProduct类实例化的对象上调用getProducer()方法,即使getProducer()是在ShopProduct类中定义的:

// listing 03.60
$product2 = new CdProduct(
    "Exile on Coldharbour Lane",
    "The",
    "Alabama 3",
    10.99,
    0,
    60.33
);
print "artist: {$product2->getProducer()}\n";

所以两个子类都继承了公共父类的行为。您可以将BookProduct对象视为ShopProduct对象。您可以将一个BookProductCdProduct对象传递给ShopProductWriter类的write()方法,所有这些都将按预期工作。

注意,CdProductBookProduct类都覆盖了getSummaryLine()方法,提供了它们自己的实现。派生类可以扩展但也可以改变其父类的功能。

这个方法的超类实现可能看起来是多余的,因为它被它的两个子类覆盖了。然而,它提供了新子类可能使用的基本功能。该方法的存在也为客户端代码提供了保证,即所有的ShopProduct对象都将提供一个getSummaryLine()方法。稍后,您将看到如何在根本不提供任何实现的情况下,在一个基类中做出这个承诺。每个子ShopProduct类继承其父类的属性。BookProductCdProduct都在各自版本的getSummaryLine()中访问$title属性。

起初,继承可能是一个很难理解的概念。通过定义一个扩展另一个类的类,可以确保从该类实例化的对象首先由子类的特征定义,然后由父类的特征定义。另一种思考方式是从搜索的角度。当我调用$product2->getProducer()时,在CdProduct类中找不到这样的方法,并且调用落入了ShopProduct中的默认实现。另一方面,当我调用$product2->getSummaryLine()时,在CdProduct中找到并调用getSummaryLine()方法。

属性访问也是如此。当我在BookProduct类的getSummaryLine()方法中访问$title时,在BookProduct类中找不到该属性。它是从父类ShopProduct中获得的。属性同样适用于两个子类,因此它属于超类。

然而,快速浏览一下ShopProduct构造函数,可以发现我仍然在管理基类中应该由其子类处理的数据。BookProduct类应该处理$numPages参数和属性,CdProduct类应该处理$playLength参数和属性。为了做到这一点,我将在每个子类中定义构造函数方法。

构造函数和继承

当你在子类中定义一个构造函数时,你就有责任将所有的参数传递给父类。如果您做不到这一点,您可能会得到一个部分构造的对象。

要调用父类中的方法,必须首先找到引用类本身的方法:句柄。PHP 为此提供了关键字parent

要在类而不是对象的上下文中引用一个方法,可以使用::而不是->:

parent::__construct()

Note

我将在第四章中更详细地介绍范围解析操作符(::)。

前面的代码片段意味着“调用父类的__construct()方法。”这里,我修改了我的例子,使每个类只处理适合它的数据:

// listing 03.61
class ShopProduct
{
    public $title;
    public $producerMainName;
    public $producerFirstName;
    public $price;

    public function __construct(
        $title,
        $firstName,
        $mainName,
        $price
    ) {
        $this->title             = $title;
        $this->producerFirstName = $firstName;
        $this->producerMainName  = $mainName;
        $this->price             = $price;
    }

    public function getProducer(): string
    {
        return $this->producerFirstName . " "
            . $this->producerMainName;
    }

    public function getSummaryLine(): string
    {
        $base  = "{$this->title} ( {$this->producerMainName}, ";
        $base .= "{$this->producerFirstName} )"; return $base;
    }
}

// listing 03.62
class BookProduct extends ShopProduct
{
    public $numPages;

    public function __construct(
        string $title,
        string $firstName,
        string $mainName,
        float $price,
        int $numPages
    ) {
        parent:: __construct(
            $title,
            $firstName,
            $mainName,
            $price
        );
        $this->numPages = $numPages;
    }

    public function getNumberOfPages(): int
    {
        return $this->numPages;
    }

    public function getSummaryLine(): string
    {
        $base  = "{$this->title} ( $this->producerMainName, ";
        $base .= "$this->producerFirstName )";
        $base .= ": page count - {$this->numPages}";
        return $base;
    }
}

// listing 03.63
class CdProduct extends ShopProduct
{
    public $playLength;

    public function __construct(
        string $title,
        string $firstName,
        string $mainName,
        float $price,
        int $playLength
    ) {
        parent:: __construct(
            $title,
            $firstName,
            $mainName,
            $price
        );
        $this->playLength = $playLength;
    }

    public function getPlayLength(): int
    {
        return $this->playLength;
    }

    public function getSummaryLine(): string
    {
        $base  = "{$this->title} ( {$this->producerMainName}, ";
        $base .= "{$this->producerFirstName} )";
        $base .= ": playing time - {$this->playLength}";
        return $base;
    }
}

每个子类在设置自己的属性之前都会调用其父类的构造函数。基类现在只知道自己的数据。子类通常是其父类的专门化。作为一个经验法则,你应该避免给家长类任何关于他们孩子的特殊知识。

Note

在 PHP 5 之前,构造函数采用封闭类的名字。新的统一构造函数使用名称__construct()。使用旧的语法,调用父构造函数会将您绑定到那个特定的类:parent::ShopProduct();。旧的构造函数语法在 PHP 7.0 中被弃用,在 PHP 8 中被完全删除。

调用被覆盖的方法

关键字parent可以用于任何覆盖父类中对应方法的方法。当您重写一个方法时,您可能不希望删除父方法的功能,而是希望扩展它。您可以通过在当前对象的上下文中调用父类的方法来实现这一点。如果您再次查看getSummaryLine()方法的实现,您会发现它们复制了大量代码。最好使用而不是复制已经在ShopProduct类中开发的功能:

// listing 03.64
// ShopProduct

    public function getSummaryLine(): string
    {
        $base  = "{$this->title} ( {$this->producerMainName}, ";
        $base .= "{$this->producerFirstName} )";
        return $base;
    }

// listing 03.65
// BookProduct

    public function getSummaryLine(): string
    {
        $base  = parent::getSummaryLine();
        $base .= ": page count - $this->numPages";
        return $base;
    }

我在ShopProduct基类中为getSummaryLine()方法设置了核心功能。

我没有在CdProductBookProduct子类中重现这一点,而是在继续向摘要字符串添加更多数据之前简单地调用父方法。

既然您已经看到了继承的基础,我将根据完整的图片重新检查属性和方法的可见性。

公共、私有和受保护:管理对类的访问

至此,我已经声明了所有的属性public。如果在属性声明中使用旧的var关键字,那么公共访问是方法和属性的默认设置。

Note

var在 PHP 5 中被弃用,将来可能会从语言中完全删除。

正如我们所见,类中的元素可以声明为publicprivateprotected:

  • 公共属性和方法可以从任何上下文中访问。

  • 私有方法或属性只能从封闭类内部访问。甚至子类都没有权限。

  • 受保护的方法或属性只能从封闭类或子类中访问。没有外部代码被授予访问权限。

那么这对我们有什么用呢?可见性关键字允许您仅公开客户端所需的类的那些方面。这为你的对象设置了一个清晰的界面。

通过防止客户端访问某些属性,访问控制还有助于防止代码中出现错误。例如,假设您希望允许ShopProduct对象支持折扣。您可以添加一个$discount属性和一个setDiscount()方法:

// listing 03.66

// ShopProduct class

    public $discount = 0;

//...

    public function setDiscount(int $num): void
    {
        $this->discount = $num;
    }

有了设置折扣的机制,您可以创建一个考虑已经应用的折扣的getPrice()方法:

// listing 03.67

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

在这一点上,你有一个问题。您只想公开调整后的价格,但是客户端可以轻松地绕过getPrice()方法并访问$price属性:

print "The price is {$product1->price}\n";

这将打印原始价格,而不是您希望显示的折扣调整价格。您可以通过将$price属性设为私有来立即停止这种情况。这将阻止直接访问,迫使客户端使用getPrice()方法。任何从ShopProduct类外部访问$price属性的尝试都将失败。就更广阔的世界而言,这种财产已不复存在。

将属性设置为private可能是一种过分热心的策略。子类不能访问private属性。想象一下,我们的业务规则规定只有书没有资格享受折扣。您可以覆盖getPrice()方法,使其返回$price属性,不应用折扣:

// listing 03.68
// BookProduct

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

由于私有的$price属性是在ShopProduct类中声明的,而不是在BookProduct类中声明的,所以在这里访问它的尝试将会失败。这个问题的解决方案是将$price变量声明为protected,从而授予对子类的访问权限。请记住,受保护的属性或方法不能从声明它的类层次结构外部访问。它只能从其原始类或原始类的子类中访问。

一般来说,宁可失之于隐私。首先将属性设为私有或受保护,仅在需要时放松限制。您的类中的许多(如果不是大多数)方法将是公共的,但是如果有疑问,请再次锁定它。为类中的其他方法提供本地功能的方法与类的用户无关。使其成为privateprotected

存取方法

即使当客户端程序员需要使用您的类持有的值时,拒绝对属性的直接访问,而是提供传递所需值的方法,通常也是一个好主意。这样的方法被称为访问器或获取器和设置器。

您已经看到了访问器方法带来的一个好处。您可以使用访问器根据具体情况过滤属性值,如getPrice()方法所示。

还可以使用 setter 方法来强制属性类型。类型声明可用于约束方法参数,但属性可以包含任何类型的数据。还记得使用ShopProduct对象输出列表数据的ShopProductWriter类吗?我可以进一步开发它,让它一次写任意数量的ShopProduct对象:

// listing 03.69
class ShopProductWriter
{
    public $products = [];

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

    public function write(): void
    {
        $str =  "";
        foreach ($this->products as $shopProduct) {
            $str .= "{$shopProduct->title}: ";
            $str .= $shopProduct->getProducer();
            $str .= " ({$shopProduct->getPrice()})\n";
        }
        print $str;
    }
}

ShopProductWriter类现在更有用了。它可以保存许多ShopProduct对象,并一次性为它们写入数据。不过,我必须相信我的客户编码人员会尊重这个类的意图。尽管我已经提供了一个addProduct()方法,但我并没有阻止程序员直接操作$products属性。有人不仅可以向$products数组属性添加错误类型的对象,甚至可以覆盖整个数组并用原始值替换它。我可以通过将$products属性私有来防止这种情况:

// listing 03.70

class ShopProductWriter
{
    private $products = [];

    //...

现在外部代码不可能破坏$products属性。所有访问都必须通过addProduct()方法,我在方法声明中使用的类类型声明确保只有ShopProduct对象可以添加到数组属性中。

类型化属性

因此,通过将方法签名中的类型声明与属性可见性声明相结合,可以控制类中的属性类型。下面是另一个例子:一个Point类,我在其中使用类型声明和属性可见性来管理属性类型:

// listing 03.71
class Point
{
    private $x = 0;
    private $y = 0;

    public function setVals(int $x, int $y)
    {
        $this->x = $x;
        $this->y = $y;
    }

    public function getX(): int
    {
        return $this->x;
    }

    public function getY(): int
    {
        return $this->y;
    }
}

因为$x$y属性是私有的,它们只能通过setVals()方法来设置——并且因为setVals()只接受整数值,所以你可以确保$x$y总是包含整数。

当然,因为这些属性被设置为private,所以访问它们的唯一方式是通过 getteraccessor 方法。

在引入类型化属性的 PHP 7.4 版本之前,我们一直坚持使用这种固定属性类型的方法。这允许我们为我们的属性声明类型。下面是利用这一点的Point版本:

// listing 03.72
class Point
{
    public int $x = 0;
    public int $y = 0;
}

我将属性$x$y设为公共属性,并使用类型声明来约束它们的类型。正因为如此,如果我愿意,我可以选择在不牺牲控制的情况下摆脱setVals()方法。我也不再需要getX()getY()方法。Point现在是一个非常简单的类,但是,即使它的两个属性都是公共的,它也向世界保证了它所保存的数据。

让我们尝试在其中一个属性上设置一个字符串:

// listing 03.73
$point = new Point();
$point->x = "a";

PHP 不会让我们得逞的:

TypeError: Cannot assign string to property popp\ch03\batch11\Point::$x of type int

Note

联合类型也可以在类型属性声明中使用。

商店产品类别

让我们通过修改ShopProduct类及其子类来锁定访问控制,并加入一些我们已经介绍过的其他特性来结束本章:

// listing 03.74
class ShopProduct
{
    private int|float $discount = 0;

    public function __construct(
        private string $title,
        private string $producerFirstName,
        private string $producerMainName,
        protected int|float $price
    ) {
    }

    public function getProducerFirstName(): string
    {
        return $this->producerFirstName;
    }

    public function getProducerMainName(): string
    {
        return $this->producerMainName;
    }

    public function setDiscount(int|float $num): void
    {
        $this->discount = $num;
    }

    public function getDiscount(): int
    {
        return $this->discount;
    }

    public function getTitle(): string
    {
        return $this->title;
    }

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

    public function getProducer(): string
    {
        return $this->producerFirstName . " "
            . $this->producerMainName;
    }

    public function getSummaryLine(): string
    {
        $base  = "{$this->title} ( {$this->producerMainName}, ";
        $base .= "{$this->producerFirstName} )";
        return $base;
    }
}

除了通过将属性的可见性设置为private(或者在$discount的情况下设置为protected来关闭对大多数属性的访问之外,我还重新引入了构造函数属性提升,这样我就可以将属性声明与构造函数签名结合起来。我还为$discount使用了属性类型声明——同时展示了 PHP 8 的新类型联合特性。我已经约束了$discount,这样它就可以被赋予一个int或者float值。这个约束可能看起来是多余的,因为$discount被声明为private,而setDiscount()方法中的类型声明——另一个联合——将强制执行相同的条件。然而,为你的属性声明类型是一个很好的实践,一部分是因为这是一种强制的内联文档,另一部分是因为它可以防止我们在进一步开发ShopProduct的过程中意外地反复无常。

// listing 03.75
class CdProduct extends ShopProduct
{
    public function __construct(
        string $title,
        string $firstName,
        string $mainName,
        int|float $price,
        private int $playLength
    ) {
        parent:: __construct(
            $title,
            $firstName,
            $mainName,
            $price
        );
    }

    public function getPlayLength(): int
    {
        return $this->playLength;
    }

    public function getSummaryLine(): string
    {
        $base  = "{$this->title} ( {$this->producerMainName}, ";
        $base .= "{$this->producerFirstName} )";
        $base .= ": playing time - {$this->playLength}";
        return $base;
    }
}

同样,我在构造函数的签名中使用了属性提升。这一次,仅仅是为了一个理由:$playLength。因为我将剩余的构造函数参数传递给父类,所以我没有为它们设置可见性。我在构造函数体中使用它们。

// listing 03.76
class BookProduct extends ShopProduct
{
    public function __construct(
        string $title,
        string $firstName,
        string $mainName,
        int|float $price,
        private int $numPages
    ) {
        parent:: construct(
            $title,
            $firstName,
            $mainName,
            $price
        );
    }

    public function getNumberOfPages(): int
    {
        return $this->numPages;
    }

    public function getSummaryLine(): string
    {
        $base  = parent::getSummaryLine();
        $base .= ": page count - $this->numPages";
        return $base;
    }

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

因此,在这个版本的ShopProduct家族中,所有属性要么是private要么是protected。我添加了一些访问器方法来使事情变得更完整。

摘要

这一章涵盖了很多内容,从一个空的实现到一个全功能的继承层次结构。你接受了一些设计问题,特别是关于类型和继承。您看到了 PHP 对可见性的支持,并探索了它的一些用途。在下一章,我将向你展示 PHP 更多的面向对象的特性。