在生产就绪项目中使用编程语言是学习语言本身的一个全新步骤。有时,本书中的简单例子可能会采取不同的方法,或者在现实世界的程序中面临许多困难。当理论遇到实践时,就是你学习语言的时候。C++ 也不例外。学习语法,解决一些书本上的问题,或者理解书本上有些简单的例子是不同的。当创建现实世界的应用时,我们面临不同范围的挑战,有时书籍缺乏理论来支持路上的实际问题。
在本章中,我们将尝试用 C++ 介绍实用编程的基础知识,这将帮助您更好地处理现实世界的应用。复杂的项目需要大量的思考和设计。有时候,程序员不得不完全重写项目,从头开始,只是因为他们在开发之初做出了错误的设计选择。本章试图阐明软件设计的过程。您将学习如何为项目构建更好的架构。
我们将在本章中讨论以下主题:
- 了解项目开发生命周期
- 设计模式及其应用
- 领域驱动设计
- 设计一个亚马逊克隆作为一个真实项目的例子
带有-std=c++ 2a
选项的 g++ 编译器用于编译本章中的示例。你可以在https://github.com/PacktPublishing/Expert-CPP找到本章使用的源文件。
无论何时处理问题,都应该仔细考虑需求分析的过程。项目开发中最大的错误之一是在没有彻底分析问题本身的情况下就开始编码。
想象一下,你的任务是创建一个计算器,一个允许用户对数字进行算术计算的简单工具。假设你神奇地按时完成了项目并发布了程序。现在,用户开始使用你的计算器,他们迟早会发现他们的计算结果没有超过整数的最大值。当他们抱怨这个问题时,你准备好用坚实的编码支持的论据来为自己(和你的创造)辩护,比如因为在计算中使用了int
数据类型。对你和你的程序员同事来说,这完全可以理解,但最终用户就是不能接受你的论点。他们想要一个工具,允许求和一些足够大的数字,否则,他们根本不会使用你的程序。你开始在你的计算器的下一个版本上工作,这一次,你使用 longs 甚至定制实现的大数字。当你突然意识到同样的用户抱怨没有找到对数或数字指数的功能时,你自豪地将你的程序发送给等待他们鼓掌的用户。这似乎令人望而生畏,因为可能会有越来越多的功能请求和越来越多的投诉。
虽然这个例子有点简单,但它完全涵盖了现实世界中通常发生的事情。甚至当你为你的程序实现了所有的功能,并且正在考虑休一个应得的长假时,用户也会开始抱怨程序中的错误。事实证明,有几种情况下,您的计算器会出现意外行为,并且不会给出或给出错误的结果。迟早,你会意识到,在向大众发布程序之前,真正需要的是适当的测试。
我们将讨论在实际项目中应该考虑的主题。无论何时开始新项目,都应该考虑以下步骤:
-
需求收集和分析
-
规范创建
-
设计和测试计划
-
编码
-
测试和稳定
-
发布和维护
前面的步骤并不是对每个项目都进行硬编码,尽管这可能被认为是每个软件开发团队为了成功发布产品而应该完成的最起码的工作。实际上,大多数步骤都被省略了,这是因为 IT 领域的每个人都最缺少的一件事——时间。但是,强烈建议遵循前面的步骤,因为从长远来看,最终会节省更多的时间。
这是创造稳定产品最关键的一步。程序员未能按时完成任务或在代码中留下大量 bug 的最常见原因之一是缺乏对项目的完整理解。
领域知识非常重要,在任何情况下都不应该被忽略。你可能会幸运地开发与你非常熟悉的事物相关的项目。然而,你应该考虑到并不是每个人都像你一样幸运(嗯,你可能也有那么不幸)。
想象一下,你正在做一个项目,自动分析和报告某个公司的股票交易。现在假设你对股票和股票交易一无所知。你不知道熊市或牛市,不知道交易交易的局限性,等等。你将如何成功完成这个项目?
即使你知道股票市场和交易,你可能不知道你的下一个大项目领域。如果你的任务是设计和实施(有或没有团队)一个控制你所在城市气象站的项目会怎么样?开始项目时,你打算先做什么?
您肯定应该从需求收集和分析开始。这只是一个过程,包括与客户沟通,并就项目提出许多问题。如果你不是和任何客户打交道,而是在一家产品公司工作,那么项目经理应该被视为客户。即使这个项目是你的主意,你是一个人在工作,你也应该把自己当成客户,尽管这听起来很荒谬,但还是要问自己很多问题(关于这个项目)。
让我们假设我们要征服电子商务,并希望发布一种最终能在自己的业务中击败市场鲨鱼的产品。受欢迎和成功的电子商务市场是亚马逊、易贝、阿里巴巴和其他一些公司。我们应该把这个问题表述为编写自己的亚马逊克隆。我们应该做什么来收集项目的需求?
首先,我们应该列出所有我们应该实现的特性,然后我们将确定优先级。例如,对于亚马逊克隆项目,我们可能会提出以下功能列表:
- 创建产品。
- 列出产品。
- 购买产品。
- 编辑产品详细信息。
- 移除产品。
- 按名称、价格范围和重量搜索产品。
- 偶尔通过电子邮件提醒用户产品的可用性。
应尽可能详细地描述特征;这将为开发人员(在这种情况下是你)解决问题。例如,创建产品应该由项目管理员或任何用户来完成。如果用户可以创建一个产品,它应该有限制,如果有的话。可能会有这样的情况,用户会错误地在我们的系统中创建数百个产品来增加他们唯一产品的可见性。
在与客户沟通时,应陈述、讨论并最终确定细节。如果项目中只有你一个人,你是项目的客户,沟通就是自己思考项目需求的过程。
当完成需求获取后,我们建议对每个特性进行优先级排序,并将它们分类为以下类别之一:
- 必须有
- 应该有
- 很高兴有
稍微思考一下并对前面的特性进行分类后,我们可以得出以下列表:
-
创建产品[必须有]。
-
列出产品[必须有]。
-
购买产品[必须拥有]。
-
编辑产品详细信息[应有]。
-
移除产品[必须有]。
-
按名称搜索产品[必须有]。
-
按价格范围[应有]搜索产品。
-
按重量搜索产品[很高兴拥有]。
-
偶尔通过电子邮件提醒用户产品的可用性[很高兴拥有]。
分类会让你对从哪里开始有一个基本的概念。程序员是贪婪的人;他们希望为他们的产品实现所有可能的特性。这肯定会失败。你应该先从最基本的特性开始——这就是为什么我们有几个很好拥有的特性。一些人开玩笑地坚持认为,应该将“很好拥有”的功能改名为“从来没有”的功能,因为在实践中,它们从来没有实现过。
不是每个人都喜欢创建规范。大多数程序员讨厌这一步,因为它不是编码,而是写作。
在收集项目需求之后,您应该创建一个文档,其中包括描述您的项目的每个细节。该规范有许多名称和类型。可以称之为项目需求文档 ( 珠三角)功能规范、开发规范等等。作为需求分析的结果,认真的程序员和团队会产生一个 PRD。这些严肃的人的下一步是创建功能规范以及开发规范等等。我们在一个名为规范创建的步骤中合并了所有的文档。
由您和您的团队决定是否需要前面提到的任何子文档。更好的方法是有产品的视觉表示,而不是文本文档。无论您的文档采取什么形式,它都应该仔细地表示您在需求收集步骤中所取得的成果。为了对此有一个基本的了解,让我们试着记录一些我们之前收集的特性(我们将我们的项目称为平台)
-
创建产品。具有管理员权限的平台用户可以创建产品。
-
平台必须允许创建具有定义权限的用户。此时,应该有两种类型的用户,即普通用户和管理员用户。
-
任何使用该平台的用户都必须能够看到可用产品的列表。
-
产品应该有图片、价格、名称、重量和描述。
-
为了购买产品,用户提供他们的卡的细节来兑现和产品装运的细节。
-
每个注册用户都应该提供送货地址、信用卡详细信息和电子邮件帐户。
清单可能会很长,实际上应该很长,因为清单越长,开发人员就越了解项目。
虽然我们坚持认为需求收集步骤是软件开发中最关键的,但是设计和测试计划也可以被认为是同样关键的步骤。如果你曾经没有先设计就开始了一个项目,你已经有了不可能的想法。尽管激励名言坚持认为没有什么是不可能的,但程序员确信至少有一件事是不可能的,那就是不先设计就成功完成一个项目。
设计的过程是最有趣的一步;它迫使我们思考,画画,再思考,理清一切,重新开始。项目的许多特征是在设计时发现的。要设计一个项目,你应该从头开始。首先,列出项目中涉及的所有实体和过程。对于亚马逊克隆示例,我们可以列出以下实体和流程:
- 用户
- 注册和授权
- 制品
- 处理
- 仓库(包含产品)
- 装运
这是一个高层次的设计——最终设计的起点。在本章中,我们将主要集中在项目的设计上。
在列出关键实体和过程之后,我们开始将它们分解成更详细的实体,这些实体稍后将被转换成类。项目的设计草图更好。只需绘制包含实体名称的矩形,如果它们以某种方式连接在一起或者是同一个过程的一部分,则用箭头将它们连接起来。如果有一个过程包含实体 A 或由实体 A 开始,并在实体 B 完成或导致实体 B,您可以从实体 A 到实体 B 开始一个箭头。无论绘图有多好,这都是更好地理解项目的必要步骤。例如,请看下图:
将实体和过程分解成类以及它们的相互通信是一门微妙的艺术,需要耐心和一致性。例如,让我们尝试为用户实体添加详细信息。如规范创建步骤中所述,注册用户应该提供递送地址、电子邮件地址和信用卡详细信息。让我们画一个代表用户的类图:
有趣的问题来了:我们应该如何处理实体中包含的复杂类型?例如,用户的传递地址是一个复杂的类型。这不可能只是string
,因为迟早我们可能需要根据用户的送货地址对他们进行分类,以做出最佳的发货。例如,如果用户的交货地址与包含所购产品的仓库地址位于不同的国家,则运输公司可能会让我们(或用户)损失一大笔钱。这是一个很好的场景,因为它引入了一个新问题,更新了我们对项目的理解。事实证明,我们应该处理用户订购的产品被分配到远离用户的特定仓库的情况。如果我们有许多仓库,我们应该选择离用户最近的包含所需产品的仓库。这些都是无法马上回答的问题,但这是设计项目的质量结果。否则,这些问题会在编码过程中出现,我们会比我们想象的更长时间地陷入其中。在任何已知的宇宙中,对该项目的初步估计都不会达到它的完成日期。
现在,您将如何在User
类中存储用户地址?简单的std::string
就可以了,如下例所示:
class User
{
public:
// code omitted for brevity
private:
std::string address_;
// code omitted for brevity
};
就其组成而言,地址是一个复杂的对象。地址可能由国家名称、国家代码、城市名称和街道名称组成,甚至可能包含纬度和经度。如果您需要找到离用户最近的仓库,后者非常好。制造更多的类型,让程序员的设计更加直观,这完全没问题。例如,以下结构可能非常适合表达用户的地址:
struct Address
{
std::string country;
std::string city;
std::string street;
float latitude{};
float longitude{};
};
现在,存储用户地址变得更加简单:
class User
{
// code omitted for brevity
Address address_;
};
我们将在本章后面回到这个例子。
设计项目的过程可能需要回到几个步骤来重申项目需求。在用前面的步骤阐明设计步骤之后,我们可以继续将项目分解成更小的组件。最好也创建交互图。
像下面这样的交互图将描述诸如由用户进行的交易到购买产品的操作:
测试计划也可以被认为是设计的一部分。它包括计划如何测试最终应用。例如,在此之前的步骤包括一个地址的概念,结果是,地址可以包含国家、城市等等。适当的测试应该包括检查是否可以在用户地址中成功设置国家值。虽然测试计划通常不被认为是程序员的任务,但是为项目做测试仍然是一个很好的实践。一个合适的测试计划会在设计项目时产生更多有用的信息。大多数输入数据处理和安全检查都是在测试计划中发现的。例如,在进行需求分析或编写功能规范时,对用户名或电子邮件地址设置严格的限制可能不是这样。测试计划关注这样的场景,并迫使开发人员负责数据检查。然而,大多数程序员都急于进入项目开发的下一步,即编码。
如前所述,编码不是项目开发的唯一部分。在编码之前,您应该通过利用规范中预测的所有需求来仔细设计您的项目。在项目开发的前几个步骤彻底完成后,编码会容易得多,效率也会更高。
一些团队实践测试驱动开发(TDD) ,这是产生更稳定的项目发布的一个很好的方法。TDD 的主要概念是在项目实施之前编写测试。这是程序员定义项目需求和回答开发过程中出现的进一步问题的好方法。
假设我们正在为User
类实现设置器。用户对象包含前面讨论的电子邮件字段,这意味着我们应该有一个set_email()
方法,如下面的代码片段所示:
class User
{
public:
// code omitted for brevity
void set_email(const std::string&);
private:
// code omitted for brevity
std::string email_;
};
TDD 方法建议在实现set_email()
方法本身之前,为set_email()
方法编写一个测试函数。假设我们有以下测试功能:
void test_set_email()
{
std::string valid_email = "[email protected]";
std::string invalid_email = "112%$";
User u;
u.set_email(valid_email);
u.set_email(invalid_email);
}
在前面的代码中,我们声明了两个string
变量,其中一个变量包含一个无效的电子邮件地址值。甚至在运行测试函数之前,我们就知道,在数据输入无效的情况下,set_email()
方法应该有所反应。常见的方法之一是抛出一个指示无效输入的异常。您也可以忽略set_email
实现中的无效输入,并返回一个表示操作成功的boolean
值。错误处理应该在项目中保持一致,并得到所有团队成员的同意。让我们考虑一下,我们将抛出一个异常,因此,当向方法传递一个无效值时,测试函数应该会得到一个异常。
前面的代码应该重写,如下所示:
void test_set_email()
{
std::string valid_email = "[email protected]";
std::string invalid_email = "112%$";
User u;
u.set_email(valid_email);
if (u.get_email() == valid_email) {
std::cout << "Success: valid email has been set successfully" << std::endl;
} else {
std::cout << "Fail: valid email has not been set" << std::endl;
}
try {
u.set_email(invalid_email);
std::cerr << "Fail: invalid email has not been rejected" << std::endl;
} catch (std::exception& e) {
std::cout << "Success: invalid email rejected" << std::endl;
}
}
测试功能似乎完成了。每当我们运行测试函数时,它都会输出set_email()
方法的当前状态。即使我们还没有实现set_email()
功能,相应的测试功能也是向其实现细节迈进了一大步。我们现在有了函数应该如何对有效和无效数据输入做出反应的基本概念。我们可以添加更多种类的数据,以确保set_email()
方法在其实现完成后将得到彻底测试。例如,我们可以用空字符串和长字符串来测试它。
以下是set_email()
方法的初步实现:
#include <regex>
#include <stdexcept>
void User::set_email(const std::string& email)
{
if (!std::regex_match(email, std::regex("(\\w+)(\\.|_)?(\\w*)@(\\w+)(\\.(\\w+))+")) {
throw std::invalid_argument("Invalid email");
}
this->email_ = email;
}
在方法的初始实现之后,我们应该再次运行我们的测试函数,以确保实现符合定义的测试用例。
Writing tests for your project is considered as a good coding practice. There are different types of tests, such as unit tests, regression tests, smoke tests, and so on. Developers should support unit test coverage for their projects.
编码的过程是项目开发生命周期中最混乱的步骤之一。很难估计一个类或其方法的实现需要多长时间,因为大多数问题和困难都是在编码过程中出现的。本章开头描述的项目开发生命周期的前几个步骤倾向于涵盖大多数这些问题,并简化编码过程。
项目完成后,应该进行适当的测试。通常,软件开发公司都有质量保证 ( QA )工程师对项目进行一丝不苟的测试。
在测试阶段验证的问题被转换成分配给程序员的相应任务来解决它们。问题可能会影响项目的发布,也可能被归类为次要问题。
程序员的基本任务不是立即修复问题,而是找到问题的根本原因。为了简单起见,我们来看看generate_username()
函数,它使用随机数结合电子邮件生成用户名:
std::string generate_username(const std::string& email)
{
int num = get_random_number();
std::string local_part = email.substr(0, email.find('@'));
return local_part + std::to_string(num);
}
generate_username()
函数调用get_random_number()
将返回值与电子邮件地址的本地部分相结合。本地部分是电子邮件地址中@
符号之前的部分。
质量保证工程师报告说,电子邮件本地部分的附加号码总是相同的。例如,对于电子邮件[email protected]
,生成的用户名为john42
,对于[email protected]
,生成的用户名为amanda42
。因此,下一次有电子邮件[email protected]
的用户试图在系统中注册时,生成的用户名amanda42
与已经存在的用户名冲突。对于测试人员来说,不知道项目的实现细节是完全可以的,所以他们将其报告为用户名生成功能中的一个问题。虽然您可能已经猜到真正的问题隐藏在get_random_number()
功能中,但在某些情况下,问题总是在没有找到根本原因的情况下得到解决。解决问题的错误方法可能会改变generate_username()
函数的实现。generate_random_number()
功能也可能用于其他功能,这反过来会使所有调用get_random_number()
的功能无法正常工作。虽然这个例子很简单,但深入思考并找到问题背后的真正原因是至关重要的。这种方法将在未来节省大量时间。
在通过修复所有关键和主要问题使项目稍微稳定之后,它就可以发布了。有时候,公司发布软件时会贴上“T0”测试版“T1”的标签,这样就为用户提供了一个借口,以防他们发现软件有问题。需要注意的是,很少有软件能够完美工作。发布之后,会出现更多的问题。因此,维护阶段就来了,这时开发人员正在进行修复和发布更新。
程序员有时开玩笑说,发布和维护是永远无法实现的步骤。然而,如果你花足够的时间设计这个项目,发布它的第一个版本不会花太多时间。正如我们在上一节中已经介绍的,设计从需求收集开始。之后,我们花时间定义实体,分解它们,分解成更小的组件,编码,测试,最后发布它。作为开发人员,我们对设计和编码阶段更感兴趣。正如已经提到的,一个好的设计选择对进一步的项目开发有很大的影响。现在让我们仔细看看整体设计过程。
如前所述,项目设计从列出一般实体开始,如设计电子商务平台时的用户、产品和仓库:
然后我们将每个实体分解成更小的组件。为了使事情更清楚,将每个实体视为一个单独的类。当把一个实体看作一个类时,它在分解方面更有意义。例如,我们将user
实体表示为一个类:
class User
{
public:
// constructors and assignment operators are omitted for code brevity
void set_name(const std::string& name);
std::string get_name() const;
void set_email(const std::string&);
std::string get_email() const;
// more setters and getters are omitted for code brevity
private:
std::string name_;
std::string email_;
Address address_;
int age;
};
User
类的类图如下:
然而,正如我们已经讨论过的那样,User
类的地址字段可能被表示为一个单独的类型(class
或struct
,这还不重要)。无论是数据聚合还是复杂类型,类图都会进行以下更改:
这些实体之间的关系将在设计过程中变得清晰。例如,地址本身不是一个实体,它是用户的一部分,也就是说,如果一个用户对象没有被实例化,它就不能有一个实例。然而,由于我们可能希望使用可重用代码,所以地址类型也可以用于仓库对象。也就是说用户和地址之间的关系是简单的聚合,而不是组合。
展望未来,我们可以在讨论支付选项时对用户类型提出更多要求。该平台的用户应该能够插入支付产品的选项。在决定我们将如何在User
类中表示支付选项之前,我们应该弄清楚那些选项到底是什么。让我们保持简单,假设支付选项包含信用卡号、持卡人姓名、到期日期和卡的安全代码。这听起来像是另一种数据聚合,所以让我们在一个结构中收集所有这些,如下所示:
struct PaymentOption
{
std::string number;
std::string holder_name;
std::chrono::year_month expiration_date;
int code;
};
注意前面结构中的std::chrono::year_month
;它代表特定年份的特定月份,在 C++ 20 中引入。大多数支付卡只携带卡到期的月份和年份,所以这个std::chrono::year_month
功能非常适合PaymentOption
。
所以,在设计User
类的过程中,我们想出了一个新的类型,PaymentOption
。一个用户可以有多个支付选项,所以User
和PaymentOption
的关系是一对多。现在让我们用这个新的聚合来更新我们的User
类图(尽管在这种情况下我们使用合成):
User
和PaymentOption
之间的依赖关系用下面的代码表示:
class User
{
public:
// code omitted for brevity
void add_payment_option(const PaymentOption& po) {
payment_options_.push_back(op);
}
std::vector get_payment_options() const {
return payment_options_;
}
private:
// code omitted for brevity
std::vector<PaymentOption> payment_options_;
};
我们应该注意,即使用户可能设置了多个支付选项,我们也应该将其中一个标记为主要选项。这很棘手,因为我们可以将所有选项存储在一个向量中,但现在我们必须将其中一个选项作为主要选项。
我们可以使用一对或tuple
(如果喜欢的话)将向量中的一个选项映射为boolean
值,指示它是否是主要的。下面的代码描述了前面介绍的User
类中元组的用法:
class User
{
public:
// code omitted for brevity
void add_payment_option(const PaymentOption& po, bool is_primary) {
payment_options_.push_back(std::make_tuple(po, is_primary));
}
std::vector<std::tuple<PaymentOption, boolean> > get_payment_options() const {
return payment_options_;
}
private:
// code omitted for brevity
std::vector<std::tuple<PaymentOption, boolean> > payment_options_;
};
我们可以通过以下方式利用类型别名来简化代码:
class User
{
public:
// code omitted for brevity
using PaymentOptionList = std::vector<std::tuple<PaymentOption, boolean> >;
// add_payment_option is omitted for brevity
PaymentOptionList get_payment_options() const {
return payment_options_;
}
private:
// code omitted for brevity
PaymentOptionList payment_options_;
};
以下是班级用户如何为用户检索主要支付选项:
User john = get_current_user(); // consider the function is implemented and works
auto payment_options = john.get_payment_options();
for (const auto& option : payment_options) {
auto [po, is_primary] = option;
if (is_primary) {
// use the po payment option
}
}
我们在for
循环中访问元组项时使用了结构化绑定。然而,在学习了关于数据结构和算法的章节之后,你现在意识到搜索主要支付选项是一个线性操作。每次我们需要检索主要支付选项时,遍历向量可能会被认为是一种糟糕的做法。
You might change the underlying data structure to make things run faster. For example, std::unordered_map
(that is, a hash table) sounds better. However, it doesn't make things faster just because it has constant-time access to its elements. In this scenario, we should map a boolean
value to the payment option. For all of the options except one, the boolean
value is the same falsy value. It will lead to collisions in the hash table, which will be handled by chaining values together mapped to the same hash value. The only benefit of using a hash table will be constant-time access to the primary payment option.
最后,我们得出最简单的解决方案,在类中单独存储主要支付选项。以下是我们应该如何重写User
类中支付选项的处理部分:
class User
{
public:
// code omitted for brevity
using PaymentOptionList = std::vector<PaymentOption>;
PaymentOption get_primary_payment_option() const {
return primary_payment_option_;
}
PaymentOptionList get_payment_options() const {
return payment_options_;
}
void add_payment_option(const PaymentOption& po, bool is_primary) {
if (is_primary) {
// moving current primary option to non-primaries
add_payment_option(primary_payment_option_, false);
primary_payment_option_ = po;
return;
}
payment_options_.push_back(po);
}
private:
// code omitted for brevity
PaymentOption primary_payment_option_;
PaymentOptionList payment_options_;
};
到目前为止,我们带您完成了定义支付选项存储方式的过程,只是为了展示伴随编码的设计过程。虽然我们已经为单一支付选项创建了许多版本,但这还不是最终版本。在支付选项向量中总是存在处理重复值的情况。每当您向用户添加付款选项作为主要选项,然后再添加另一个选项作为主要选项时,前一个选项会转到非主要列表。如果我们改变主意,再次添加旧的支付选项作为主要选项,它将不会从非主要列表中删除。
所以,总是有机会深入思考,避免潜在的问题。设计和编码齐头并进;然而,你不应该忘记 TDD。在大多数情况下,在编码之前编写测试将帮助您发现大量用例。
在你的项目设计中有很多原则和设计方法可以使用。保持设计简单总是更好的,然而,总的来说,有一些原则在几乎所有的项目中都是有用的。例如, SOLID 由五个原则组成,所有或部分原则对设计都是有用的。
SOLID 代表以下原则:
- 单一责任
- 开-关
- 利斯科夫替代
- 界面分离
- 依赖倒置
让我们用例子来讨论每个原理。
单一责任原则表述简单,即一个目标,一项任务。尽量减少你的对象的功能和它们的关系复杂性。让每个对象都有一个职责,即使把一个复杂的对象分解成更小更简单的组件并不总是容易的。单一责任是一个受环境限制的概念。它不是一个类中只有一个方法;它是关于让类或模块负责一件事。例如,我们之前设计的User
类有一个职责:存储用户信息。然而,我们在User
类中增加了支付选项,并强制其具有添加和删除支付选项的方法。我们还引入了一个主要的支付选项,这涉及到用户方法中的附加逻辑。我们可以朝两个方向前进。
第一个建议将User
类分解成两个独立的类。每个班级将有一个单一的责任。下面的类图描述了这个想法:
其中一个将只存储用户基本信息,下一个将为用户存储支付选项。我们给它们分别命名为UserInfo
和UserPaymentOptions
。有些人可能喜欢这个新设计,但我们会坚持旧设计。原因如下。虽然User
类包含用户信息和支付选项,但后者也代表一条信息。我们设置和获取支付选项的方式与设置和获取用户电子邮件的方式相同。所以,我们保持User
类不变,因为它已经满足了单一责任原则。当我们在User
类中添加支付功能时,这将打破平静。在这种情况下,User
类将存储用户信息并进行支付交易。就单一责任原则而言,这是不可接受的,因此,我们不会这样做。
单一责任原则也与职能有关。add_payment_option()
方法有两个职责。如果函数的第二个(默认)参数为真,它将添加一个新的主要支付选项。否则,它会将新的付款选项添加到非主要选项列表中。最好有一个单独的方法来添加主要的支付选项。这样,每种方法都有一个单独的职责。
开-闭原则规定一个类应该是开放的,可以扩展,但不可以修改。这意味着每当您需要新功能时,最好扩展基本功能,而不是修改它。比如我们设计的电子商务应用的Product
类。以下是Product
类的简单示意图:
每个Product
对象有三个属性:名称、价格、重量。现在,想象一下在设计了Product
类和整个电商平台之后,客户又有了新的需求。他们现在想购买数字产品,如电子书、电影和录音。除了产品的重量,一切都很好。现在可能有两种类型的产品——有形的和数字的——我们应该重新思考Product
用法的逻辑。我们可以在Product
中加入一个新的功能,如下面的代码所示:
class Product
{
public:
// code omitted for brevity
bool is_digital() const {
return weight_ == 0.0;
}
// code omitted for brevity
};
显然,我们修改了类——违背了开-闭原则。原则上说应该关闭类进行修改。应该可以延期。我们可以通过重新设计Product
类并使其成为所有产品的抽象基类来实现这一点。接下来,我们再创建两个继承Product
基类的类:PhysicalProduct
和DigitalProduct
。下面的类图描述了新的设计:
从上图中可以看到,我们从Product
类中移除了weight_
属性。现在我们又多了两个班级,PhysicalProduct
有一个weight_
属性,DigitalProduct
没有。相反,它有一个file_path_
属性。这种方法满足了开-闭原则,因为现在所有的类都可以扩展了。我们使用继承来扩展类,下面的原则与此密切相关。
利斯科夫替换原则是关于以正确的方式继承一个类型。简单地说,如果有一个函数接受某种类型的参数,那么同一个函数应该接受派生类型的参数。
The Liskov substitution principle is named after Barbara Liskov, a Turing Award winner and doctor of computer science.
一旦你理解了继承和利斯科夫替代原理,你就很难忘记它。让我们继续开发Product
类,并添加一个基于货币类型返回产品价格的新方法。我们可以用相同的货币单位存储价格,并提供一个将价格转换为指定货币的功能。下面是该方法的简单实现:
enum class Currency { USD, EUR, GBP }; // the list goes further
class Product
{
public:
// code omitted for brevity
double convert_price(Currency c) {
// convert to proper value
}
// code omitted for brevity
};
过了一段时间,该公司决定对所有数字产品纳入终身折扣。现在,每个数字产品都有 12%的折扣。短时间内,我们在DigitalProduct
类中添加了一个单独的函数,通过应用折扣返回转换后的价格。以下是它在DigitalProduct
中的外观:
class DigitalProduct : public Product
{
public:
// code omitted for brevity
double convert_price_with_discount(Currency c) {
// convert by applying a 12% discount
}
};
设计上的问题很明显。在DigitalProduct
实例上调用convert_price()
将不起作用。更糟糕的是,客户端代码不能调用它。相反,它应该叫convert_price_with_discount()
,因为所有的数字产品必须以 12%的折扣出售。该设计违背了利斯科夫替代原则。
我们不应该破坏类的层次结构,而应该记住多态的美。以下是更好的版本:
class Product
{
public:
// code omitted for brevity
virtual double convert_price(Currency c) {
// default implementation
}
// code omitted for brevity
};
class DigitalProduct : public Product
{
public:
// code omitted for brevity
double convert_price(Currency c) override {
// implementation applying a 12% discount
}
// code omitted for brevity
};
如你所见,我们不再需要convert_price_with_discount()
功能了。利斯科夫替代原则成立。然而,我们应该再次检查设计中的缺陷。让我们通过在基类中合并用于折扣计算的私有虚拟方法来使它变得更好。以下更新版本的Product
类包含一个名为calculate_discount()
的私有虚拟成员函数:
class Product
{
public:
// code omitted for brevity
virtual double convert_price(Currency c) {
auto final_price = apply_discount();
// convert the final_price based on the currency
}
private:
virtual double apply_discount() {
return getPrice(); // no discount by default
}
// code omitted for brevity
};
convert_price()
函数调用私有的apply_discount()
函数,该函数按原样返回价格。诀窍来了。我们在派生类中重写apply_discount()
函数,如下面的DigitalProduct
实现所示:
class DigitalProduct : public Product
{
public:
// code omitted for brevity
private:
double apply_discount() override {
return getPrice() * 0.12;
}
// code omitted for brevity
};
我们不能在类外调用私有函数,但是我们可以在派生类中重写它。前面的代码展示了覆盖私有虚函数的好处。我们修改实现,保持接口不变。如果派生类不需要为折扣计算提供自定义功能,则不会重写它。另一方面,DigitalProduct
需要在价格上打 12%的折扣后再转换。没有必要修改基类的公共接口。
You should consider rethinking the design of the Product
class. It seems even better to call apply_discount()
directly in getPrice()
, hence always returning the latest effective price. Though at some point you should force yourself to stop.
设计过程很有创意,有时并不令人满意。由于意想不到的新需求,重写所有代码并不罕见。我们使用原则和方法来最小化在实现新特性之后将会发生的重大变化。SOLID 的下一个原则是最佳实践之一,它将使您的设计更加灵活。
接口分离原则建议将复杂的接口分成更简单的接口。这种隔离允许类避免实现它们不使用的接口。
在我们的电子商务应用中,我们应该实现产品发货、替换和过期功能。产品的装运是将产品转移给买方。目前我们不关心装运细节。产品更换考虑在运输给买方后更换损坏或丢失的产品。最后,产品到期意味着扔掉那些在到期日之前没有售出的产品。
我们可以自由实现前面介绍的Product
类中的所有功能。然而,最终,我们会偶然发现一些无法运输的产品类型(例如,卖房子很少涉及运输给买家)。可能有不可替代的产品。例如,一幅原画即使丢失或损坏也不可能被替换。最后,数字产品永远不会过期。大多数情况下。
我们不应该强迫客户端代码实现它不需要的行为。对于客户端,我们指的是实现行为的类。以下示例是一种不良做法,与接口隔离原则相矛盾:
class IShippableReplaceableExpirable
{
public:
virtual void ship() = 0;
virtual void replace() = 0;
virtual void expire() = 0;
};
现在,Product
类实现了前面所示的接口。它必须为所有的方法提供一个实现。界面分离原理提出了以下模型:
class IShippable
{
public:
virtual void ship() = 0;
};
class IReplaceable
{
public:
virtual void replace() = 0;
};
class IExpirable
{
public:
virtual void expire() = 0;
};
现在,Product
类跳过实现任何接口。它的派生类从特定类型派生(实现)。下面的示例声明了几种类型的产品类,每一种都支持前面介绍的有限数量的行为。请注意,为了代码简洁,我们省略了类的主体:
class PhysicalProduct : public Product {};
// The book does not expire
class Book : public PhysicalProduct, public IShippable, public IReplaceable
{
};
// A house is not shipped, not replaced, but it can expire
// if the landlord decided to put it on sell till a specified date
class House : public PhysicalProduct, public IExpirable
{
};
class DigitalProduct : public Product {};
// An audio book is not shippable and it cannot expire.
// But we implement IReplaceable in case we send a wrong file to the user.
class AudioBook : public DigitalProduct, public IReplaceable
{
};
如果要将文件下载包装为发货,可以考虑对AudioBook
实现IShippable
。
最后,依赖反转声明对象不应该强耦合。它允许切换到一个替代依赖很容易。例如,当用户购买产品时,我们会发送一张关于购买的收据。从技术上讲,发送回执有几种方式,即通过邮件打印发送、通过电子邮件发送,或者在平台的用户账号页面显示回执。对于后者,我们通过电子邮件或应用向用户发送通知,通知他们收据已准备好可供查看。请看下面打印收据的界面:
class IReceiptSender
{
public:
virtual void send_receipt() = 0;
};
假设我们已经在Product
类中实现了purchase()
方法,并且在它完成时,我们发送收据。代码的以下部分处理收据的发送:
class Product
{
public:
// code omitted for brevity
void purchase(IReceiptSender* receipt_sender) {
// purchase logic omitted
// we send the receipt passing purchase information
receipt_sender->send(/* purchase-information */);
}
};
我们可以根据需要添加任意多的收据打印选项来扩展应用。下面的类实现了IReceiptSender
接口:
class MailReceiptSender : public IReceiptSender
{
public:
// code omitted for brevity
void send_receipt() override { /* ... */ }
};
另外两个类——EmailReceiptSender
和InAppReceiptSender
——都实现了IReceiptSender
。因此,要使用特定的收据,我们只需通过purchase()
方法向Product
注入依赖项,如下所示:
IReceiptSender* rs = new EmailReceiptSender();
// consider the get_purchasable_product() is implemented somewhere in the code
auto product = get_purchasable_product();
product.purchase(rs);
我们可以通过在User
类中实现一个方法,返回具体用户所需的收据发送选项,从而更进一步。这将使类更加解耦。
前面讨论的所有固体原理都是组成类的自然方式。坚持原则并不是强制性的,但是,如果你坚持原则,它会改善你的设计。
领域是计划的主题领域。我们正在讨论和设计一个以电子商务为主要概念,以其所有补充概念为领域的电子商务平台。我们建议您在项目中考虑领域驱动的设计。然而,这种方法并不是程序设计的灵丹妙药。
在设计项目时,考虑三层架构的以下三层是很方便的:
- 介绍会;展示会
- 业务逻辑
- 数据
三层架构适用于客户机-服务器软件,例如我们在本章中设计的软件。表示层向用户提供与产品、购买和运输相关的信息。它通过向客户端发布结果来与其他层进行通信。这是一个客户端可以直接访问的层,例如网络浏览器。
业务逻辑关心应用功能。例如,用户浏览由表示层提供的产品并决定购买其中一个。请求的处理是业务层的任务。在领域驱动的设计中,我们倾向于将领域级实体与其属性结合起来,以解决应用的复杂性。我们将用户视为User
类的实例,将产品视为Product
类的实例,以此类推。购买产品的用户被业务逻辑解释为创建Order
对象的User
对象,该对象又与Product
对象相关。然后将Order
对象绑定到与产品购买相关的Transaction
对象。相应的购买结果通过表示层来表示。
最后,数据层处理数据的存储和检索。从用户身份验证到产品购买,每个步骤都是从系统数据库中检索或记录的。
将应用分成几层可以处理一般的复杂性。更好的方法是编排具有单一职责的对象。领域驱动设计将实体与没有概念标识的对象区分开来。后者被称为价值对象。例如,用户不区分每个唯一的交易;他们只关心交易所代表的信息。另一方面,用户对象具有User
类(实体)形式的概念标识。
使用其他对象(或不使用)在对象上允许的操作是命名服务。服务实际上是一种不依赖于特定对象的操作。比如用set_name()
方法设置用户的名字,是一个不应该被认为是服务的操作。另一方面,用户购买产品是由服务封装的操作。
最后,领域驱动的设计集中集成了存储库和工厂模式。存储库模式负责检索和存储域对象的方法。工厂模式创建域对象。如果需要的话,使用这些模式允许我们交换可选的实现。现在让我们找出设计模式在电子商务平台中的力量。
设计模式是软件设计中常见问题的架构解决方案。需要注意的是,设计模式既不是方法,也不是算法。它们是架构构造,提供了一种组织类及其关系的方式,以在代码可维护性方面获得更好的结果。即使你以前没有使用过设计模式,你也很可能自己发明了一个。软件设计中往往会出现许多问题。例如,为现有库制作更好的界面是一种被称为门面的设计模式。设计模式有名字,以便程序员在对话或文档中使用它们。你应该很自然地用门面、工厂等与其他程序员聊天。
我们之前提到,领域驱动的设计结合了存储库和工厂模式。现在让我们来看看它们是什么,以及它们如何在我们的设计工作中发挥作用。
正如 Martin Fowler 最好地描述的那样,存储库模式“”使用类似于集合的接口来访问域对象,从而在域和数据映射层之间进行中介。
该模式提供了直接的数据操作方法,不需要直接使用数据库驱动程序。添加、更新、删除或选择数据自然适合应用领域。
方法之一是创建一个提供必要功能的通用存储库类。一个简单的界面如下所示:
class Entity; // new base class
template <typename T, typename = std::enable_if_t<std::is_base_of_v<Entity, T>>>
class Repository
{
public:
T get_by_id(int);
void insert(const T&);
void update(const T&);
void remove(const T&);
std::vector<T> get_all(std::function<bool(T)> condition);
};
我们在前面介绍了一个名为Entity
的新类。Repository
类处理实体,为了确保每个实体符合Entity
的相同界面,它将std::enable_if
和std::is_base_of_v
一起应用于模板参数。
std::is_base_of_v
is a short representation for std::is_base_of<>::value
. Also, std::enable_if_t
replaces std::enable_if<>::type
.
Entity
类就像下面的表示一样简单:
class Entity
{
public:
int get_id() const;
void set_id(int);
private:
int id_;
};
每个业务对象都是一个Entity
,因此,前面讨论的类应该更新为从Entity
继承。例如,User
类采用以下形式:
class User : public Entity
{
// code omitted for brevity
};
因此,我们可以通过以下方式使用存储库:
Repository<User> user_repo;
User fetched_user = user_repo.get_by_id(111);
前面的存储库模式是对这个主题的简单介绍,但是,您可以使它更加强大。它类似于门面模式。虽然使用外观模式的目的不是访问数据库,但最好还是用这个例子来解释。外观模式包装了一个或多个复杂的类,为客户端提供了一个简单的预定义接口来处理底层功能。
当程序员谈论工厂模式时,他们可能会混淆工厂方法和抽象工厂。这两种模式都是提供各种对象创建机制的创造性模式。我们来讨论工厂方法。它提供了在基类中创建对象的接口,并允许派生类修改将要创建的对象。
现在是处理物流的时候了,工厂的方法会在这方面帮助我们。当您开发一个提供产品发货的电子商务平台时,您应该考虑到并非所有用户都生活在您的仓库所在的同一区域。因此,从仓库向买方运输产品时,您应该选择合适的运输方式。自行车、无人机、卡车等等。感兴趣的问题是设计一个灵活的物流管理系统。
不同的交通工具需要不同的实现。然而,它们都符合一个接口。以下是Transport
接口及其派生的特定传输实现的类图:
上图中的每个具体类都提供了具体的交付实现。
假设我们设计了以下负责物流相关动作的Logistics
基类,包括选择合适的运输方式,如图所示:
前面应用的工厂方法允许灵活地添加新的运输类型以及新的物流方法。注意createTransport()
方法返回一个指向Transport
的指针。派生类重写方法,每个方法返回一个Transport
子类,因此提供了一个特定的传输模式。这是可能的,因为子类返回派生类型,否则,当重写基类方法时,我们不能返回不同的类型。
Logistics
中的createTransport()
如下图所示:
class Logistics
{
public:
Transport* getLogistics() = 0;
// other functions are omitted for brevity
};
Transport
类代表Drone
、Truck
和Ship
的基类。这意味着我们可以创建每个的一个实例,并使用Transport
指针引用它们,如图所示:
Transport* ship_transport = new Ship();
这奠定了工厂模式的基础,因为例如RoadLogistics
像这样覆盖getLogistics()
:
class RoadLogistics : public Logistics
{
public:
Truck* getLogistics() override {
return new Truck();
}
}
注意函数的返回类型,是Truck
而不是Transport
。之所以有效,是因为Truck
继承了Transport
。此外,请参见对象创建如何与对象本身解耦。创建新对象是通过工厂完成的,这与前面讨论的 SOLID 原则保持一致。
乍一看,利用设计模式会给设计带来额外的复杂性,这可能会令人困惑。然而,当实践设计模式时,你应该开发一个真正意义上的更好的设计,因为它们允许项目整体的灵活性和可扩展性。
软件开发需要细致的规划和设计。在本章中,我们了解到项目开发包括以下关键步骤:
- 需求收集和分析:这包括理解项目的领域,讨论和最终确定应该实现的特性。
- 规范创建:这包括记录需求和项目功能。
- 设计和测试计划:这指的是从较大的实体开始设计项目,向下分解每个实体为一个独立的类,与项目中的其他类相关。这一步还包括计划如何测试项目。
- 编码:这一步包括编写实现前面步骤中指定的项目的代码。
- 测试和稳定:这意味着对照预先计划的用例和场景检查项目,以发现问题并修复它们。
- 发布和维护:这是将我们带到项目发布和进一步维护的最后一步。
项目设计对程序员来说是一项复杂的任务。他们应该提前考虑,因为部分特性是在开发过程中引入的。
为了使设计灵活和健壮,我们讨论了导致更好的架构的原则和模式。我们已经学习了设计一个复杂的软件项目的过程。
避免糟糕设计决策的最好方法之一是遵循已经设计好的模式和实践。您应该考虑在未来的项目中使用 SOLID 原则和成熟的设计模式。
在下一章,我们将设计一个策略游戏。我们将熟悉更多的设计模式,并看到它们在游戏开发中的应用。
- TDD 有什么好处?
- UML 中交互图的目的是什么?
- 构图和聚合有什么区别?
- 你会如何描述利斯科夫替代原则?
- 假设给你上课
Animal
,上课Monkey
。后者描述了一种在树上跳跃的特殊动物。从一个Animal
类继承一个Monkey
类是否违背了开闭原则? - 将工厂方法应用于本章中讨论的
Product
类及其子类。
有关更多信息,请参考:
-
面向对象分析与设计与应用作者:Grady Booch,https://www . Amazon . com/面向对象-分析-设计-应用-第 3 期/dp/020189551X/
-
设计模式:可重用面向对象软件的元素作者:Erich Gamma 等人,https://www . Amazon . com/Design-Patterns-Elements-可重用-面向对象/dp/0201633612/
-
代码完成:软件构建实用手册作者:史蒂夫·麦康奈尔,https://www . Amazon . com/Code-Complete-实用-手册-构建/dp/0735619670/
-
领域驱动设计:解决软件核心的复杂性作者:Eric Evans,https://www . Amazon . com/领域驱动-设计-解决-复杂性-软件/dp/0321125215/