香港科技大学软件工程笔记-全-
香港科技大学软件工程笔记(全)
001:软件开发是复杂的 🚀
在本节课中,我们将学习软件工程的基本概念,理解为什么开发大型软件系统是一个复杂的过程,并初步了解应对这种复杂性的技术。
概述
软件开发之所以复杂,是因为大型软件系统通常包含数百万行代码,例如波音787飞机拥有1400万行代码,而Windows 10则拥有1.5亿行代码。面对如此庞大的系统,我们无法采用自底向上的方式逐行、逐功能地实现,而必须在开始编码之前进行大量的规划。
软件复杂性的来源
上一节我们提到了代码量的庞大,本节中我们来看看软件复杂性的具体来源。
应用领域的复杂性
软件复杂性可能源于应用领域本身。待解决的问题可能非常复杂,而开发者通常并非该领域的专家。例如,如果你被要求为医院开发一个跟踪病人信息的系统,你虽然是编程专家,但并非医疗领域的专家。因此,在开发系统之前,你可能需要先学习相关的应用领域知识。
利益相关者沟通的复杂性
复杂性也可能来自利益相关者之间的沟通。不同的利益相关者可能使用不同的词汇进行交流。例如,你的客户可能要求你构建一个“存储数字”的系统。这时,你就需要与客户澄清,他们是想存储整数、实数还是电话号码。此外,利益相关者可能拥有不同的背景知识,而人类语言本身也存在固有的模糊性,这些都增加了沟通的复杂性。
项目管理与开发的复杂性
大型软件开发项目的管理本身就很复杂。因此,我们必须将项目分解成多个部分,然后再重新组装。这需要协调许多不同的部分和人员。同时,编码本身以及创建有用的软件也是一个复杂的过程。
复杂性导致的问题
理解了复杂性的来源后,我们来看看它可能引发哪些具体问题。
软件质量问题
软件复杂性可能导致软件质量问题。例如,你可能使软件系统变得不可靠。接下来,我们将通过一个关于Ariane 5火箭的视频来了解一个小错误如何导致整个火箭爆炸。我们将在后续讨论测试时详细分析这个错误。


(视频内容描述:Ariane 5火箭发射37秒后,机载计算机判定其偏离航线90度,并自动调整了轨迹,而此时火箭正以音速飞行,最终导致爆炸。)
你也可能使系统变得不安全。例如,伦敦救护车系统在1992年两次瘫痪,并因未能提供救护服务而导致人员死亡。


复杂性还可能导致项目被废弃。例如,伦敦证券交易所花费五年时间开发的系统,最终因不可靠而不得不被放弃。
此外,你可能使系统变得不灵活,难以更改和维护。
软件开发问题
软件复杂性也可能导致软件开发本身的问题。例如,你的项目可能超时、超预算,或者无法满足用户需求。实际开发出可用代码的速度可能低于预期,这意味着项目进度落后。对于大型软件系统,有数据显示:
以下是大型软件项目常见问题的统计数据:
- 17% 的项目会面临公司声誉威胁(例如,在互联网上出现负面评价)。
- 45% 的项目会超出预算。
- 7% 的项目会超出预定时间。
- 56% 的项目交付的价值低于预期。
总结

本节课中,我们一起学习了软件工程的重要性。我们认识到开发大型软件系统是一个极其复杂的过程,这种复杂性来源于应用领域知识、利益相关者沟通、项目管理与编码本身。这种复杂性可能导致严重的软件质量问题(如不可靠、不安全、被废弃)和开发问题(如超时、超预算、交付价值低)。理解这些复杂性及其后果,是学习如何应用软件工程方法和技术来有效管理开发过程的第一步。
002:应对复杂性 🧩

在本节课中,我们将要学习如何应对软件开发中的复杂性。复杂性是大型软件项目面临的主要挑战之一,有效的管理策略对于项目的成功至关重要。

明确设计目标 🎯


为了应对复杂性,我们首先必须在项目开始时选择合适的设计目标。


例如,如果你要构建一个在线交易处理系统,你必须确保系统高效且可靠。但请注意,由于项目的时间和预算限制,通常不可能同时实现所有目标。此外,一些设计目标之间可能存在冲突,因此你无法同时实现所有设计目标。


为了选择合适的设计目标,你需要在项目开始时清晰地理解客户的需求。如果客户告诉你,他们希望系统高效且可靠,那么这些就必须成为你为项目选择的设计目标。

接着,你需要为特定项目确定设计目标的优先级,并围绕这些目标进行开发。你必须在项目开始时,选择两到四个最合适的设计目标。



拥有清晰的设计目标将降低系统设计的复杂性,因为你确切地知道软件系统需要什么。最终,当你设计系统时,事情会变得容易得多。

应用模块化与增量开发 🧱

为了应对复杂性,我们可以做的另一件事是应用模块化和增量开发。
因为人类的理解能力是有限的,当我们构建大型软件系统时,我们可以应用一种称为“分而治之”的方法,将大型软件系统分解成许多更小的部分。



我们不是一次性处理整个大型软件系统,而是逐个构建这些更小的部分,即更小的模块。模块的定义是系统中可以单独考虑的部分。请注意,这些模块会相互交互,因为一个模块可能会使用其他模块的某些功能或操作。


以下是模块化开发的核心概念:
- 模块:一个可以独立设计和实现的系统单元。
- 接口:模块对外提供的、供其他模块调用的方法集合。
利用信息隐藏 🔒
应对复杂性的另一个方法是使用信息隐藏。当我们尝试访问一个特定模块时,我们只被允许通过接口来访问或与该模块交互。



通过使用接口,我们可以同时实现抽象和封装。

那么,抽象和封装的定义是什么?
- 抽象意味着隐藏细节。当我们尝试使用一个模块时,我们实际上不必理解模块内部的内容,即模块内的源代码和实现。我们只需要理解如何使用接口即可。
- 封装意味着如果我们想要维护或修改一个特定模块,那么修改将仅限于这个模块内部,我们不需要触及其他模块。

抽象的好处在于,只需理解接口,而不必理解模块内的实际实现,就可以使用一个模块。
这将降低理解系统的复杂性,因为你只需要理解如何使用接口,而不是理解模块内的内容,即源代码和实现。




接口也提供了封装。如果你想更改一个模块,你只需要更改那个特定的模块,而不会影响系统的其余部分。这将降低维护系统的复杂性,因为当你维护系统、想要更改某些东西时,你只需要处理一件事,而不是其他所有事。
模块化与增量开发的优势 📈

现在,通过应用模块化和增量开发(即将大型软件系统分解成小块,然后一次处理一小块),并为这些模块(小块)使用接口,可以实现团队开发中更高的生产力。因为一个团队将只负责一小部分工作,并且由于人们专注于一小部分而不是整个大型软件系统,最终我们可能在系统开发中获得更少的错误。




此外,还能带来更易维护的软件、更可重用的软件,并使得软件开发过程更具可预测性。


这将降低开发该系统所需成本和时间的估算复杂性。

培训软件工程师 👨💻

应对复杂性的另一种方法是为软件工程师提供培训。

那么工程师是做什么的?工程师应用科学知识(例如数学、物理)来为一些技术问题开发解决方案。
工程师必须设计材料,在考虑一些限制条件(例如项目的时间和预算)的同时,选择合适的材料、结构和系统。




那么,作为一名软件工程师,我们必须做什么?作为软件工程师,我们必须做两件事:
- 小规模编程:即编码。
- 大规模编程:即软件工程。除了编码,在软件工程中你还需要做很多事情。

以下是软件工程师在“大规模编程”中的主要职责:
- 与用户沟通应用需求,与客户和用户交流。
- 将模糊的需求转化为更精确的表述。
- 在不同抽象层次上构建系统模型,以便在与客户、用户沟通时,可以使用这些模型与项目中的不同利益相关者交流。
- 使用和应用多种软件开发流程(我们将在后续课程中讨论不同的开发流程)。
- 选择合适的架构设计,并进行设计权衡。
- 团队协作。



这将降低构建系统的复杂性。


总结 📝


本节课中,我们一起学习了应对软件复杂性的几种核心策略。首先,明确并优先处理设计目标是项目成功的基石。其次,通过模块化和增量开发,我们可以将庞大问题分解为可管理的小块。再者,利用信息隐藏(通过接口实现抽象和封装)能显著降低理解和维护系统的难度。最后,认识到软件工程师的角色远不止编码,还包括沟通、建模、流程选择和团队协作等“大规模编程”活动,并通过培训提升这些能力,是有效管理复杂性的关键。掌握这些方法,将帮助我们在面对复杂软件项目时更有条理和信心。
003:什么是软件工程 🧑💻


在本节课中,我们将探讨软件工程的核心定义,理解它与单纯编程的区别,并了解构建大型软件系统所涉及的复杂性和系统性活动。

概述
软件工程远不止是编写代码。它是一门涉及系统性、有纪律的开发过程的学科,旨在构建高质量、可维护的软件系统,以解决现实世界中的问题。我们将通过分析大型软件项目的规模、团队协作的重要性以及软件工程与计算机科学的区别,来深入理解其内涵。
软件工程的规模与复杂性
上一节我们介绍了软件工程的基本概念,本节中我们来看看大型软件系统的实际规模。
一架波音747飞机包含的代码行数比其物理零件(包括螺母和螺栓)还要多。具体来说,一架波音747拥有400万行代码。而如今大多数游戏的代码量约为600万行。这些都是极其庞大的软件系统,无法一次只考虑一行代码或一个类。
为了理解其工作量,我们可以做一个简单的计算:
- 假设一名软件工程师平均每分钟能写一行源代码。
- 每小时60分钟,每周工作40小时,则每周可写
1 * 60 * 40 = 2400行代码。 - 每年按50个工作周计算,则每位工程师每年可产出
2400 * 50 = 120,000行代码。
因此,如果一个项目估计有200万行代码,那么至少需要 2,000,000 / 120,000 ≈ 17 名软件工程师才能完成。这让我们感受到,这类项目需要大量规划,并且必须在团队中协作完成。
此外,请相信,在软件行业,平均每分钟写一行代码实际上已经是相当高的效率了。这主要是因为,软件工程师的工作远不止编码。
软件工程的影响与沟通
理解了软件项目的庞大规模后,我们来看看软件工程决策可能带来的实际影响。
以亚马逊网站的一次小型软件升级为例。这次升级并不顺利,导致了90分钟的网站宕机。据估计,这给亚马逊造成了280万美元的收入损失。这些损失是永久性的,因为客户在当时的需求未能被满足。
这个案例说明,系统可能并非安全关键,但却是财务关键的。该团队需要更好地理解开发关键系统的流程,以及如何在不导致系统瘫痪的情况下进行升级。亚马逊本可以从一个定义更清晰的软件开发流程中受益。
作为一名软件工程师,你需要回答很多问题。核心问题之一是“需要写多少代码?”,但还有很多其他问题:
- 如何帮助客户?
- 解决客户问题需要什么?
- 用户将如何与系统交互?
- 将使用什么操作系统、语言和硬件?
- 整体软件系统结构是怎样的?不同组件如何交互?
- 如何组织团队以提高效率?
- 我们能否按时完成项目(例如赶上假日购物季)?
贯穿这些问题的共同趋势是,你需要与很多人沟通才能实现目标。你需要与提出系统需求的客户、系统的最终用户、领域专家(如银行家、安全专家、医学科学家)、其他工程学科的工程师,以及项目中的其他工程师密切互动。
另一个重要的潜在主题是沟通。软件工程是一项高度社交化的活动。你不是坐在角落里独自写代码,而是始终在与他人合作。这是一个非常以人为本的领域。
但这并不意味着软件工程师不亲自动手使用最新技术和方法编写程序。我们同样以此为生。同样重要的是,软件工程不仅仅是项目管理和沟通,但也不仅仅是编码。


软件工程 vs. 计算机科学
现在,你可能会发现软件工程并不完全等同于计算机科学。虽然两者都试图用代码解决现实世界的问题,但它们的处理方式截然不同。
我们可以用一个比喻来理解:如果一座桥梁倒塌,是科学家的错还是工程师的错?答案更偏向于工程师。

以下是两者的核心区别:
- 科学家建造事物以学习新知识;工程师学习知识以设计和建造高质量的产品。
- 科学家希望取得科学突破;工程师希望避免工程失败。
- 计算机科学家希望理解算法和计算理论的基础;软件工程师希望学习和设计构建高质量软件系统的最佳实践原则。
- 计算机科学家想知道基本技术如何工作以及如何改进它;软件工程师想知道技术的特性,以便将最合适的技术设计到他们的软件系统中。
软件工程的定义与核心活动
那么,究竟什么是软件工程?查阅维基百科或在线资料,你会得到不同的定义。综合来看,软件工程包含以下关键特征:
- 有纪律的开发伦理:这意味着我们试图以有纪律的方式进行开发,而不是像编程作业那样随意打开源代码文件就开始输入。
- 追求质量:我们努力在最终产品中实现一定的质量。
- 解决现实用户问题:旨在某个应用领域解决真实的用户问题。
- 团队协作:因为必须在团队中工作。
- 多版本演进:软件项目不是一次性的。因此我们需要软件配置管理来跟踪我们开发的不同软件版本。
基于这些特征,软件工程涉及以下几项核心活动:
- 建模活动:我们必须捕获所有用户需求,然后构建模型。需要构建两种模型:
- 需求模型:用于捕获来自用户或客户的所有需求。
- 解决方案模型:用于实际实现的模型。
然后,我们需要将需求模型与解决方案模型进行匹配。
- 问题解决活动:我们试图在存在变化的情况下寻找最合适的解决方案。因此它不是简单的算法过程,而是系统性的。
- 知识获取活动:请注意,知识获取不是线性过程。你在开发项目时学习,有时可能因为学到了错误的东西而需要“忘记”,在最坏的情况下,甚至可能需要因为完全错误而重新开始。
- ** rationale(决策依据)管理活动**:我们的假设和解决方案可能因缺陷或技术变化而时常改变,我们可能需要不时重新审视之前的决策。因此,记录项目中的所有内容非常重要,以便记住我们为何做出某些决定或选择。项目文档帮助我们记住所有在项目中做出的决策和选择。
应对复杂性的工程方法
为了应对软件开发的复杂性,我们需要采取以下工程方法:
首先,我们必须选择最合适的设计目标,通常在项目开始时确定两到三个核心目标。
其次,我们需要利用模块化和增量开发技术。这意味着我们将大型软件系统分解成许多较小的部分,并尝试一次处理一小部分。
最后,我们必须使用有效的软件工程技术。
从技术角度来看,在一个软件工程项目中,我们必须做两件事:
- 软件工程(宏观编程):这意味着我们必须构建模型、记录系统,并跟踪项目中产生的所有需求和解决方案。
- 编码(微观编程):当然,我们也必须编写代码、构建系统。
请注意,虽然编码是重要的软件开发活动,但软件工程同样至关重要,因为它有助于降低项目的复杂性,并最终帮助实现更高质量和更可维护的软件系统。
总结

本节课中,我们一起学习了软件工程的核心内涵。我们了解到,软件工程是一门系统性的、有纪律的学科,专注于通过团队协作、有效沟通和严谨流程来构建高质量、可维护的大型软件系统。它不仅仅是编写代码(微观编程),更包括需求分析、系统建模、决策管理和文档化等宏观编程活动。理解软件工程与计算机科学的区别,以及掌握应对复杂性的模块化与增量开发方法,是成为一名合格软件工程师的重要基础。
004:UML与类图基础 🎯
在本节课中,我们将学习如何使用统一建模语言(UML)来构建模型,以捕获应用领域内的所有数据需求。具体来说,我们将重点学习如何绘制类图,以记录或跟踪软件系统中所有重要的对象。
概述 📋
UML是一种通用的可视化建模语言,用于表示系统。它结合了面向对象建模技术的最佳实践,并且与软件开发方法论或过程无关。UML是建模系统的行业标准面向对象建模语言,非常适合面向对象编程,但也可用于非面向对象系统。
UML的基本思想是使用一组对象来表示系统,这些对象是软件系统中存在的重要对象的集合。
为什么需要建模?🤔
让我们通过一个例子来理解建模的重要性。客户对项目的描述、项目负责人的理解、分析师的设计、程序员的实现、业务顾问的说明以及最终交付给客户的产品,这些环节之间可能存在巨大的理解偏差。问题的核心在于沟通不畅,因为不同的利益相关者可能在脑海中有着不同的想法。
因此,在项目初期构建一个模型至关重要。通过模型进行沟通,可以确保所有利益相关者对项目有统一的理解。
再以建造飞机为例。在真正制造飞机之前,我们会先构建一个飞机模型。因为飞机本身非常复杂,通过模型我们可以抽象掉一些复杂的细节,构建一个更简单但仍能体现飞机关键特征的表示。这个模型用于与项目中的不同人员进行沟通。
对于软件开发而言,建模允许我们关注“大局”,即软件系统中所有重要的对象,这被称为“大规模编程”。通过关注大局,我们能更好地处理软件开发的复杂性,因为源代码级别的实现本身可能非常复杂。建模的结果是能更好地理解项目需求,获得更清晰的设计,以及更易于维护的软件系统。
为什么选择面向对象建模?🎯
使用对象来建模事物是很自然的。例如,在应用领域中,我们有组织、人员和汽车。人员为不同的组织工作,并且拥有不同的汽车。很自然地将应用领域中的这些事物映射为模型中的不同对象。
在模型中,我们有“组织”、“人员”和“汽车”等类,并通过关系将它们连接起来。例如,人员为组织工作,人员拥有汽车。我们使用关联来连接它们。

这种表示方式减少了应用领域与模型之间的语义差距,因为它自然地反映了人们对现实世界的思考方式。因此,本节课我们将学习如何使用类图来捕获应用领域中重要对象的集合。


面向对象建模的不同层次 🏗️
在面向对象建模中,我们可以构建不同层次的模型:
- 需求模型:用于捕获软件系统中的所有需求。在这个模型中,我们试图识别所有需要跟踪的重要事物、对象或概念。
- 解决方案模型:通过分析和设计,我们构建一个用于实现的解决方案模型。这个模型中的对象将最终在软件系统中实现。
- 实现级别:最终,我们将解决方案模型实现为一组源代码或代码对象。

相同的核心概念可以应用于所有层次。在本课程中,我们将重点关注两个模型:用于捕获数据需求的需求模型,以及用于指导实现的解决方案模型。本节课我们专注于需求模型。
请记住,需求模型与实现无关。它只是根据应用领域,捕获其中存在的重要对象,帮助我们理解软件系统需要处理哪些事物。
UML的结构与图表类型 🧩
UML包含不同的构建块,例如模型中的重要事物、不同关系以及用于建模系统的各种图表。此外,它还有一些通用机制,如提供额外的文本描述、注释以及扩展机制。
我们可以从不同视角构建模型,例如用例视图、逻辑视图、实现视图、进程视图或部署视图。
在本课程中,我们将主要介绍三种UML图表:
- 类图:用于捕获软件系统中的所有数据需求,以对象及其关系的集合形式呈现。
- 用例图:用于捕获系统提供的所有功能。
- 状态机图:用于捕获对象可能具有的所有状态。
本节课,我们将深入探讨类图的基本组件。

类图的基本组件 🔧
上一节我们介绍了UML的概览和建模的重要性,本节中我们来看看绘制类图所需的核心组件。
以下是绘制类图时需要理解的基本元素:
-
类
- 类是对象的蓝图,描述了具有相同属性、操作、关系和语义的一组对象。
- 在UML中,类用一个矩形表示,通常分为三栏:类名、属性和操作。
- 例如,一个
Person类可能具有name(属性)和workFor(操作或关联)。
-
关联
- 关联描述了类之间的结构关系,表明类的对象之间以某种方式连接。
- 用连接两个类的实线表示。
- 关联可以具有多重性(例如,1, *, 0..1),以说明一个类的多少个对象可以与另一个类的一个对象相关联。
- 例如,
Person类和Company类之间可以存在“worksFor”关联。

-
关联类
- 有时,关联本身可能具有需要记录的属性或操作。这时可以使用关联类。
- 关联类通过虚线连接到关联线上。
- 例如,
Person和Project之间的“Assignment”关联可能具有startDate和role属性,这些属性可以放在名为Assignment的关联类中。
-
泛化
- 泛化是一种特殊/一般关系,其中子类(特殊类)继承父类(一般类)的结构和行为。
- 用带空心箭头的实线表示,箭头指向父类。
- 例如,
Student类和Teacher类可以泛化自Person类。
-
约束
- 除了图形元素,我们还可以在类图上应用额外的文本约束,以指定更精确的规则。
- 约束通常写在花括号
{}内。 - 例如,可以为
BankAccount类的balance属性添加约束{balance >= 0}。
总结 📝
本节课中,我们一起学习了UML建模的基础知识。我们了解了UML是一种用于系统建模的可视化语言,它通过抽象帮助我们关注软件系统中的重要对象,从而改善沟通、处理复杂性并更好地理解需求。
我们重点探讨了类图,它是用于捕获数据需求的核心UML图表。我们学习了类图的基本组件,包括类、关联、关联类、泛化以及如何应用约束。记住,在本阶段构建的需求模型旨在描述应用领域中存在什么,而非如何实现它。

掌握这些基础组件是进行有效面向对象建模的第一步,它将为后续学习更复杂的建模概念和图表打下坚实的基础。
005:UML类图
在本节课中,我们将学习如何使用UML类图来为软件系统建模。我们将重点介绍类、关联、关联类以及泛化等核心概念,并以一个银行系统为例进行说明。
软件工程:P5.1:类的概念与表示


上一节我们介绍了UML类图的基本作用。本节中,我们来看看UML类图中最核心的元素——类。
假设我们要为一个银行构建软件系统。我们知道,银行内部有多个不同的银行账户。例如,有F的账户、Sam的账户、EVS的账户等。每个账户都有一些共同的属性,比如账号、余额。同时,每个账户也支持一些共同的操作,例如查询余额、存款、取款或支付利息。


为了避免在应用领域中反复指代这些具体的账户,我们将进行分类。我们将所有这些账户实例归为一组,形成一个名为“Account”的类。在模型中,我们只需说系统中有“Account”类即可。

所以,类描述了一组具有共同语义、属性、操作和关系的对象的集合。例如,所有账户都有相同的属性和操作,因此它们被归类为同一个“Account”类。



类是一个分类器,而具体的对象是它的实例。类就像一个创建对象的工厂,我们可以用它来创建银行应用领域中的不同账户。
一个好的类应该只捕获应用领域中的一个重要抽象,不应将多个不同的事物混杂在一个过于通用的类中。类的命名应使用应用领域的恰当词汇,使其含义明确、可追溯,并且易于阅读。

软件工程:P5.2:类的属性与操作

在了解了类的基本概念后,现在我们来详细看看如何定义类的内部结构,即属性和操作。
在类中,我们必须定义该类的所有属性。定义属性时需要提供名称和类型,这是UML建模的强制要求。此外,还可以指定可见性、初始值、多重性和可变性等,但这些在UML模型中是可选的。
例如,在“Account”类中,属性“accountNumber”的类型是整数(Integer),属性“balance”的类型是金额(Money)。


类中另一个需要指定的部分是操作。操作描述了可以应用于该类对象的函数或转换。例如,“存款”就是一个操作。
对于每个操作,我们需要指定其签名,包括:
- 名称(如
deposit) - 参数(如
amount: Money) - 返回类型
同时,还需要指定操作的可见性:
- public (+):对所有其他对象可用。
- private (-):仅对该对象本身可用。
- protected (#):对该对象本身及其所有子类可用(涉及继承时)。
- package (~):在同一个包内可用。



一个操作的具体实现被称为方法。同一个操作可能有多种不同的实现方式,因此一个操作可以对应多个方法。




软件工程:P5.3:使用类建模的意义
我们已经学习了如何定义类及其属性和操作。那么,为什么我们要使用类来为系统建模呢?
首先,使用类和对象来表示系统非常自然,尤其对于面向对象系统。通过将系统表示为一组对象,并将其抽象为一组类,有助于我们理解系统中哪些是重要的事物,并以此为基础来规定系统。

其次,选择合适的类是一个重要的设计决策,它能促进开发过程中的模块化。模块化意味着我们将一个大型软件系统分解成小块,然后可以逐个处理这些小模块。

如果某个模块过于通用或庞大,我们在构建系统时就会失去模块化的优势。因此,设计良好、职责单一的类是构建模块化、可维护软件系统的关键。


总结

本节课中,我们一起学习了UML类图的核心——类。我们了解了类是对具有共同特征对象的抽象,是创建实例的工厂。我们详细探讨了如何定义类的属性(名称、类型等)和操作(签名、可见性等),并区分了操作与方法的概念。最后,我们理解了使用类进行建模如何帮助理解系统、促进模块化设计,并强调了设计良好、职责单一的类的重要性。
软件工程:P6:类图中的关联与聚合关系 📊





在本节课中,我们将学习类图(Class Diagram)中的核心概念——关联(Association)与聚合(Aggregation)。我们将了解对象之间如何连接,如何通过关联来抽象化这些连接,以及如何通过多重性(Multiplicity)和角色(Role)来精确描述这些关系。最后,我们会探讨一种特殊的关联——聚合与组合(Composition),并学习如何正确使用它们。




关联(Association)的概念



在类图中,对象或类并非孤立存在。它们以某种方式与其他对象或类相互连接。例如,一个客户(Customer)可能持有一个账户(Account),而这个账户又关联于某家银行(Bank)。系统需要跟踪所有这些存在于实例之间的连接(Links)。


为了简化表示,我们不会在类图中画出每一个具体的连接。相反,我们会进行分类(Classification),将一组具有相同性质的连接抽象为一个关联。例如,将所有“客户持有账户”的连接抽象为 Customer 类和 Account 类之间的一个关联。同样,将所有“账户属于银行”的连接抽象为 Account 类和 Bank 类之间的另一个关联。这样,我们就通过抽象隐藏了细节,使模型更清晰。

关联描述了一组具有相同含义的连接。在概念上,关联本质上是双向的(Bidirectional)。例如,账户被客户持有,同时客户持有账户,这描述了同一关系的两个方向。

在某些情况下,我们可能会用箭头(Arrowhead)来显示关联的可导航性(Navigability),表示一个对象依赖于另一个对象。在需求模型(Requirement Model)中,关联通常不带箭头,因为大多数情况下关系是双向的。而在解决方案模型(Solution Model)中,我们可能会使用箭头来表示一个类在实现上依赖于另一个类。




关联的类型与命名规则


以下是关联的不同类型:


- 一元关联(Unary Association):一个类与自身相关联。例如,一个人(Person)可以管理(Manages)另一个人,或者一个人与另一个人结婚(Married to)。这种关联也被称为自反关联(Reflexive Association)。
- 二元关联(Binary Association):两个不同的类之间的关联。这是实践中最常见的关联类型。
- 多元关联(n-ary Association):三个或更多类之间的关联。例如,一个关联同时涉及
Student、Course和Instructor三个类。





注意:高阶(三元及以上)关联非常罕见,除非绝对必要,否则不应在图中使用。因为它们会使图表难以阅读和理解,不利于与项目干系人沟通。在本课程中,我们主要使用一元和二元关联。

关于类图和关联的命名,有一个重要规则:类名和关联名的集合必须是唯一的。在创建名称时,请确保它们没有歧义。


多重性(Multiplicity)


关联需要指定多重性,它限制了一个类的对象与另一个类的对象相关联的数量。


多重性的表示格式为 下限..上限。例如:
0..*:表示“零到任意多个”(*代表“任何”)。这表示参与是可选的(Optional),且数量无上限。1..1:表示“恰好一个”。这表示参与是强制的(Mandatory)。1..5:表示“至少一个,最多五个”。

多重性是一种应用领域约束(Application Domain Constraint),你需要从领域专家那里获取这些信息。例如,从银行业务领域可知:一家银行(Bank)可以拥有多个账户(0..*),但一个账户必须且只能属于一家银行(1..1)。



为了简化图表,有两种特殊的基数表示法:
1可以替代1..1,表示“恰好一个”。*可以替代0..*,表示“零到任意多个”。

关于多重性的重要考量:在类图中指定的约束必须始终被满足。因此,你需要考虑系统所有可能的状态。例如,规定“一门课程(Course)必须有至少10名学生(Student)”可能不成立,因为在选课开始前,课程可能还没有学生。此时,多重性应放宽为 0..45,而将“至少10人”作为一条业务规则(例如,写在注释里),说明在选课截止后必须满足此条件。








角色(Role)



在关联的每一端,我们可以指定角色名,以阐明类在该关联中扮演的具体身份。



对于二元关联,角色名通常比较明显(例如,“雇主(Employer)”和“雇员(Employee)”),因此有时可以省略。



但对于一元关联,必须提供角色名。因为关联两端都是同一个类,没有角色名就无法区分关系。例如,在“管理(Manages)”这个一元关联中,必须在两端分别标明“管理者(Boss)”和“被管理者(Worker)”,这样在存储数据时才能明确每条连接的具体含义。






聚合与组合(Aggregation & Composition)

聚合与组合是两种特殊的关联,用于表示“整体-部分(Whole-Part)”关系,即一个对象是另一个对象的一部分。


它们通过菱形(Diamond)符号来表示,菱形连接在代表“整体”的类一端。
聚合(Aggregation) 使用空心菱形 ◇ 表示。它意味着:
- 部分可以独立于整体而存在。
- 多重性在整体一端通常是
0..1(可选参与)。例如,一台计算机(Computer)包含多个硬盘(Disk),但硬盘可以被移除并在另一台计算机上使用。销毁计算机时,硬盘不一定被销毁。

组合(Composition) 使用实心菱形 ◆ 表示。它意味着:
- 部分的生命周期依赖于整体,不能独立存在。
- 多重性在整体一端通常是
1..1(强制参与)。例如,一个房间(Room)是建筑物(Building)的一部分。如果建筑物被销毁,其中的房间也会被销毁。房间不能脱离建筑物独立存在。





如何选择聚合或组合? 一个实用的判断方法是:思考当“整体”被销毁时,“部分”是否应该一同被销毁。如果是,则使用组合(实心菱形);如果否,则使用聚合(空心菱形)或普通关联。

重要提示:不要简单地将所有“拥有(has-a)”关系都表示为聚合或组合。只有当确实存在“部分是整体的一部分”这种物理或逻辑上的紧密构成关系时,才使用菱形符号。例如,“客户拥有银行账户”是一种关联关系,但账户并不是客户物理上的一部分,因此不应使用聚合/组合。使用聚合/组合是一种设计决策,它为模型增加了更多的语义信息,但如果不确定,使用普通关联也是完全可以接受的。



总结


本节课我们一起学习了类图中关于对象连接的核心知识。我们首先了解了关联如何抽象化对象之间的连接,并讨论了其双向性。接着,我们探讨了关联的不同类型(一元、二元、多元)和命名规则。







然后,我们深入学习了多重性的概念,它用于精确量化关联关系,并知道这是来自应用领域的约束。我们还介绍了角色名,尤其在一元关联中的必要性。
最后,我们学习了两种特殊的“整体-部分”关联:聚合(空心菱形,部分可独立存在)和组合(实心菱形,部分依赖于整体)。关键是要谨慎使用它们,确保真正表示构成关系。







掌握这些概念,将帮助你绘制出更精确、更具表达力的类图,从而更好地进行软件系统的分析和设计。
007:关联类 📚
在本节课中,我们将学习UML类图中一个重要的附加组件——关联类。我们将通过一个学生选课系统的例子,探讨如何正确地为关联关系建模属性。

概述
在绘制UML类图时,有时关联关系本身会拥有属性。例如,在学生选课系统中,“学生”和“课程”之间的“选课”关联,可能需要记录“成绩”这一属性。本节课我们将探讨如何正确地为这种关联属性建模。
问题引入:如何存储成绩?
假设我们需要构建一个系统来追踪学生的选课信息。我们有一组学生和一组课程,需要记录哪位学生选修了哪门课程。例如,Mac选修了COM3111、COM3021和COM3311。
现在,如果我们还想记录一个属性,比如“成绩”,那么应该在哪里存储成绩呢?以下是几种可能的方案。
方案一:将成绩作为学生类的属性 ❌
第一种方案是将成绩作为Student类的一个属性。例如,对于一个学生,我们存储他/她每门课的成绩。
代码示例:
class Student {
String name;
String grade_COM3111; // 为COM3111课程的成绩
String grade_COM3021; // 为COM3021课程的成绩
// ... 其他课程成绩
}

问题:
这种方法不可行,因为我们无法预知每个学生需要多少个成绩属性。课程数量是动态的,为每门课都定义一个属性不切实际。

方案二:将成绩作为学生的多值属性 ❌
第二种方案是使用一个多值属性(如数组或列表)来存储成绩。
代码示例:
class Student {
String name;
List<String> grades; // 存储所有成绩的列表
}
问题:
这种方法同样不可行。虽然我们可以存储多个成绩,但我们无法知道列表中的每个成绩具体对应哪一门课程。信息失去了关联性。



方案三:使用关联类
上一节我们探讨了两种不可行的方案。本节中,我们来看看一种更合适的建模方法——关联类。
当关联关系本身拥有属性时,我们可以使用关联类来为这些属性建模。关联类通过一条虚线连接到关联线上,它包含了该关联所特有的属性。
在我们的例子中,“学生”和“课程”之间的“选课”关联,其属性“成绩”就应该放在关联类中。

UML表示:
[Student] ----- (enrolls in) ----- [Course]
|
|
[Enrollment]
- grade: String

含义:
Student和Course之间存在一个名为enrolls in的关联。Enrollment是一个关联类,它拥有属性grade,用于记录某位学生在某门课程上取得的成绩。


关联类的限制
然而,关联类模型也存在一个重要的限制。让我们通过一个具体场景来理解。
假设学生Kenneth上学期选修了COM3111,但不及格(成绩为F)。这学期他重修了同一门课,并取得了A+的成绩。
问题:
在关联类模型中,Student和Course的同一对实例之间只能存在一条关联链接。这意味着我们无法同时记录Kenneth两次选修COM3111的历史信息。系统只能保留最近一次(A+)或之前一次(F)的记录,无法两者兼顾。
限制总结:
- 一旦一对对象实例(如Kenneth和COM3111)通过关联类建立了关系,它们之间就不能再建立第二条链接。
- 这导致无法用简单的关联类模型来记录学生多次选修同一门课程的历史成绩。


解决方案:引入“课程开设”类
为了克服上述限制,我们需要一个更精细的模型。核心思想是:学生不是直接选修“课程”,而是选修该课程在特定学期的“开设实例”。
我们引入一个新的类——Offering(课程开设)。它代表一门课程在某个特定学期(如“2019年秋季COM3111”)的具体开设。
更新后的UML模型:
[Student] ----- (takes) ----- [Offering] ----- (is an offering of) ----- [Course]
|
[Enrollment]
- grade: String


模型解释:
Course(课程)类保持不变。- 新增
Offering(课程开设)类,它与Course是“属于”关系(is an offering of)。一门课程可以有多个开设实例(如不同学期)。 Student与Offering之间建立“选修”关联(takes)。Student和Offering之间的关联,其属性“成绩”依然由关联类Enrollment来记录。

优势:
现在,Kenneth可以分别与“COM3111-2019秋季”和“COM3111-2020春季”这两个不同的Offering实例建立关联,并分别记录成绩F和A+。这样就完整保存了历史信息,并且符合UML的建模规则。


总结
本节课我们一起学习了UML类图中的关联类。
- 我们首先明确了问题:当关联关系本身具有属性(如选课成绩)时,需要特殊处理。
- 我们分析了两种简单但不可行的方案:将属性放在关联的某一端类中。
- 我们引入了关联类作为标准解决方案,用于建模关联的属性。
- 我们发现了关联类的一个关键限制:同一对对象实例之间只能存在一个关联链接,这导致无法记录重复关系(如重修课程)的历史。
- 最后,我们通过引入中间实体(如
Offering类)来细化模型,从而解决了上述限制,能够完整记录所有历史信息。



理解关联类及其适用场景,对于构建准确、灵活的软件系统模型至关重要。
008:泛化与继承 🧬





在本节课中,我们将要学习面向对象建模中的一个核心概念——泛化,也就是我们常说的继承。我们将了解它的定义、作用、表示方法以及相关的约束规则。


概述

泛化是描述同类事物之间关系的一种方式。它允许我们通过创建更通用的父类(超类)和更具体的子类来组织类,从而减少冗余,提高模型的可重用性和可维护性。
什么是泛化?


泛化是同类事物之间的一种关系。我们可以从一个类出发,将其特化为多个更具体的子类。


例如,我们可能有一个“账户”类,然后将其特化为不同类型的子类,如“支票账户”和“储蓄账户”。这些子类都属于“账户”这一大类。


泛化的方向:自上而下与自下而上
泛化可以从两个方向进行:自上而下的特化和自下而上的泛化。


自上而下的特化

自上而下的特化是指从一个通用的类开始,根据某些特征(区分器)将其划分为更具体的子类。

以下是一个银行系统账户的例子:






在这个例子中,我们有一个通用的“账户”类。通过一个名为“账户类型”的区分器,我们可以将其特化为“支票账户”和“储蓄账户”。在类图中,我们使用一个空心三角形来表示这种泛化关系。

- 支票账户:拥有一个额外的属性“服务费”。
- 储蓄账户:拥有一个额外的属性“利率”和一个额外的操作“支付利息”。
自下而上的泛化

自下而上的泛化是指当我们发现两个或多个类非常相似时,可以提取它们的共同特征,创建一个更通用的父类。
例如,我们发现“银行账户”和“信用卡账户”具有相似的属性(如账号、金额)和操作(如查询余额)。我们可以将它们泛化,创建一个通用的“账户”父类。




在这个例子中,“账户”类包含了公共的属性和操作(如账号、金额、余额查询)。而“银行账户”和“信用卡账户”作为子类,除了继承这些公共部分外,还可以拥有自己特有的属性和操作。
继承的优势
继承的主要好处在于它能够提取并集中定义公共的属性、关系和操作。
- 减少冗余:公共部分只需在父类中定义一次,所有子类自动继承。
- 提高可重用性:公共逻辑被封装在父类中,可以被多个子类复用。
- 简化修改:如果需要修改公共逻辑,只需修改父类即可。如果只需修改某个特定子类的行为,也只需修改该子类,不会影响其他类。


方法重写


子类可以重新定义从父类继承来的方法,这称为方法重写。
例如,在“账户”父类中可能有一个“取款”操作。在“支票账户”子类中,我们可以重新定义一个“取款”操作。当一个支票账户对象执行取款时,它将使用子类中定义的这个新版本,而不是父类中的版本。这就是重写。
单继承与多级继承
我们目前讨论的是单继承,即一个子类只有一个直接的父类。我们还可以有多级继承。
例如,我们可以有“账户” -> “银行账户” -> “支票与储蓄综合账户”这样的多级继承链。
需要注意的是,虽然可以使用多级继承,但为了保持模型的简洁和易读性,建议将继承的层级限制在两到三层以内。过多的层级会使类图变得复杂难懂。



处理特殊情况:约束的放宽

有时,子类可能对继承自父类的关联关系有更严格的约束。例如:
- 研究生(PG Student)可以选修0到3门课程。
- 本科生(UG Student)可以选修1到5门课程。
在父类“学生”中,我们需要将约束放宽为0到5门课程,以涵盖所有情况。为了不丢失子类的特定约束信息,我们可以使用注释来补充说明。
抽象类

在泛化中,我们可以定义抽象类。在类图中,抽象类的名称用斜体表示。

抽象类是一个没有直接实例的类。它的存在主要是为了建模目的,用于定义公共接口和行为,而具体的对象只能由其子类创建。
例如,“账户”可能被定义为抽象类。系统中没有纯粹的“账户”对象,只有“支票账户”或“储蓄账户”对象,但它们都属于“账户”这个抽象概念。

泛化的约束:覆盖范围


我们可以为泛化关系指定覆盖范围约束,主要分为两类:
1. 互斥性覆盖
描述子类之间实例是否可以重叠。
- 互斥:一个实例不能同时属于多个子类。例如,一个“账户”不能既是“个人账户”又是“企业账户”。
- 重叠:一个实例可以同时属于多个子类。例如,一个“运动员”可以同时是“网球运动员”和“足球运动员”。


2. 完整性覆盖
描述父类的所有实例是否都必须在已定义的子类中。
- 完全覆盖:父类的每一个实例都必须是某个已定义子类的实例。例如,一所大学里只有“本科生”和“研究生”,没有其他类型的学生。
- 不完全覆盖:父类可能存在不属于任何已定义子类的实例。例如,“树木”被分为松树、橡树、枫树,但世界上还存在其他未在此列出的树种。

这些约束通常以 {disjoint, complete} 等形式标注在泛化关系的三角形旁边。
综合示例分析
让我们分析一个支付学费的例子。支付方式可能有“现金”、“信用卡”、“借记卡”。

- 互斥性:应该是重叠的,因为你可以用一部分现金加一部分信用卡来支付学费。
- 完整性:应该是不完全的,因为还存在其他支付方式,如网上银行、支票等。
因此,这个泛化关系的覆盖范围约束应标注为 {overlapping, incomplete}。
总结

本节课我们一起学习了面向对象建模中的泛化(继承) 概念。我们了解了:
- 泛化是组织同类事物的有效方式,分为自上而下特化和自下而上泛化。
- 继承的核心优势在于减少冗余、提高可重用性和简化修改。
- 子类可以通过方法重写来修改或扩展继承自父类的行为。
- 可以使用抽象类来定义公共接口而不创建实例。
- 通过覆盖范围约束(互斥性、完整性)可以精确描述子类之间的关系。
合理使用泛化能够使我们的软件模型更加清晰、灵活和易于维护。
009:类图总结 📊

在本节课中,我们将学习统一建模语言类图的总结内容,重点了解如何在类图中指定额外的约束条件,并回顾类图的核心构成元素。
在类图中指定约束



上一节我们介绍了类图的基本元素,本节中我们来看看如何为这些元素添加约束条件。
在UML类图中,可以指定一些额外的约束。例如,对于“余额”这个属性,我们可以添加一个约束:余额必须大于或等于100,000。

指定约束有两种常见方式。以下是具体方法:

- 可以将约束直接写在相关操作旁边。例如,在检查余额的操作旁注明:
balance >= 100,000。 - 也可以使用一个单独的注释框来提供额外信息。例如,为“优选储蓄账户”添加一个注释,说明其余额必须大于或等于800,000。

约束本质上是一个可以被判定为真或假的陈述。在上述例子中,余额要么满足“大于或等于100,000”的条件,要么不满足。



一旦在设计中应用了约束,在系统实现时就必须强制遵守它。



其他类型的约束
除了数值范围,类图中还可以定义其他类型的约束。以下是几种常见的约束类型:
- 排序:例如,学生等待课程名额的列表可能需要遵循“先进先出”的顺序。
- 子集:例如,一个委员会有“成员”,同时其中一人是“主席”。那么“主席”就是“成员”的一个子集。
- 异或:这意味着只能选择多条关联路径中的一条,而不能同时选择多条。例如,一个账户要么由个人持有,要么由公司持有,但不能同时由两者持有。这表示关联路径是互斥的。


本讲内容回顾
现在,让我们总结一下本讲及上一讲所涵盖的核心内容。
我们学习了如何定义类以及类之间的关联关系。


具体来说,我们覆盖了以下知识点:
- 如何指定聚合和组合关系。
- 如何定义泛化关系。
- 如何为泛化关系提供约束条件。


我们定义了所有这些元素及其约束。这些就是我们在最近两讲中学习的内容。

类图的应用场景
最后,我们来看看类图的两个主要应用场景。
1. 为应用领域建模数据需求
类图可用于为特定应用领域内的数据需求建模。例如,在一个电商应用领域中,存在不同的商品、订单、发票、账户和VIP客户等实体。我们可以用类图来捕获所有这些需要存储在系统中的数据需求。

2. 为程序结构建模


类图也可以用来为程序本身的结构建模。


例如,在一个程序中,可能有不同的客户端类、事件处理器类和GUI处理器类。我们可以使用类图来描述这些类以及它们之间的关系。我们将在后续关于程序建模的课程中详细讨论这一点。



正如上一讲提到的,程序中的某些类可能依赖于其他类,这时我们可以使用依赖关系来表示。这也将在后续课程中详细探讨。



本节课中我们一起学习了如何在UML类图中定义和使用约束条件,回顾了类图的核心构成元素,并了解了类图在建模应用领域数据需求和程序结构两方面的应用。掌握这些知识,将帮助你更准确地使用类图进行软件设计与分析。
010:系统需求捕获入门 🎯
在本节课中,我们将要学习系统需求捕获的基础知识。需求捕获是软件开发过程中的首要活动,它决定了项目的方向和最终成果。我们将探讨需求的定义、重要性、挑战,并学习如何使用UML类图来捕获系统的数据需求。
什么是需求?📝
上一节我们提到了软件开发中的五个核心活动。本节中,我们来看看其中第一个活动——需求捕获。

在软件工程中,我们将需求定义为:系统必须具备的功能特性,或是为了获得客户认可而必须满足的约束条件。需求的范围很广,可以是从高层次的抽象陈述(例如问题陈述或系统约束),到使用公式或图表等形式表达的详细数学规范。
需求主要分为两类:
- 用户需求:主要为客户编写,使用自然语言描述,可能辅以图表来说明系统提供的服务和操作约束。
- 系统需求:一份结构化文档,详细描述系统的功能、服务和操作约束。它定义了应该实现什么,通常被视为客户与开发者之间合同的一部分,面向客户和开发者等不同利益相关者。

需求捕获的一个主要目的是明确最终软件系统的行为。在这个过程中,我们需要理解待解决的问题,并以客户和用户都能理解并认可的方式,明确系统所需的特性和约束。
请注意:在需求捕获阶段,我们旨在明确问题,而非提供解决方案或设计。我们只是尝试捕获并记录需求。

需求捕获的结果将有助于项目规划,因为一旦你确切知道项目中需要完成什么,就能更好地制定计划。
为什么需求捕获既重要又困难?🤔
上一节我们定义了需求,本节中我们来探讨为什么需要正式地捕获和记录需求。
原因在于人脑的记忆并不可靠。你可能认为在项目初期记得所有细节,但在开发或实施过程中,往往会忘记许多重要的内容。因此,我们需要通过文档来捕获和记录所有需求,以减少错误。
许多软件项目的失败正是源于需求不完整或缺乏用户参与。如果在项目初期未能做好需求捕获,这将成为软件开发失败或出现问题的主要原因。下图展示了在项目不同阶段修复缺陷的成本对比,你可以看到,早期修复缺陷的成本较低,而后期修复则非常昂贵。


正因如此,我们希望能在项目一开始就正确捕获所有需求,以避免在项目后期修复与需求相关的缺陷,从而降低成本。良好的需求捕获有助于降低软件开发成本。
那么,为什么需求捕获如此困难呢?有时甚至连客户自己也不完全清楚他们想要什么。因此,作为软件工程师,一项重要任务就是将客户模糊的需求转化为更精确、可用于开发的内容。

需求捕获通常需要来自不同背景的多个利益相关者协作。挑战在于如何弥合不同利益相关者之间的知识鸿沟。
需求捕获的主要活动 🛠️

了解了需求捕获的挑战后,本节中我们来看看在捕获需求时需要执行哪些主要活动。
作为软件工程师,你需要完成以下几件事:
- 学习应用领域知识,并从中发现所有需求。
- 将客户模糊的想法转化为更精确的表述。
- 选择合适的形式来表述需求(在本课程中,我们将使用类图和用例图)。
- 有时甚至需要教育客户或用户。
对于系统需求捕获,我们需要构建不同的模型:
- 构建领域模型以捕获所有数据需求。
- 构建用例模型以捕获所有功能需求。
- 捕获所有非功能性需求。
- 最终验证所有系统需求。


在本讲座中,我们重点介绍领域建模。
使用类图进行领域建模 📊
上一节我们概述了需求捕获的活动,本节中我们聚焦于如何使用类图进行领域建模,以捕获数据需求。
我们将使用类图来捕获所有数据需求。在这个阶段,我们只专注于捕获需求,而忽略实现细节。

具体做法是:给定一个问题陈述,我们尝试从中提取所有需求,然后绘制一个类图来表示在软件系统中需要跟踪记录的所有重要事物。这就是我们所说的数据需求——即必须在软件系统中跟踪记录的所有重要信息。
以下是捕获需求时需要执行的活动:
- 理解应用领域。
- 识别用户需求。
- 确定开发系统的潜在风险。
- 通过领域模型、用例模型和非功能性需求来捕获系统需求。
- 确保捕获的系统需求是正确且完整的。
在本课程中,我们专注于捕获系统需求。在项目初期制定一份系统需求规格说明非常重要。
系统需求规格说明 文档记录了系统需求,是关于软件系统需要做什么的正式声明。它应该包括用户需求的定义,以及使用不同模型(如类图、用例图)对系统需求的规范说明。
请注意:SRS不是设计文档。我们使用的模型并非用于实现,我们只是指定系统应该做什么,而不考虑如何实际实现这些功能。
尽管许多敏捷方法可能认为编写详细的SRS是浪费时间,因为需求可能变化很快。然而,即使你使用敏捷方法(例如Scrum),你仍然需要通过产品待办事项列表等形式来捕获需求。因此,学习如何从应用领域捕获所有需求仍然很重要。
编写SRS有不同的方式:
- 使用自然语言(如问题陈述)。
- 使用模板的结构化自然语言。
- 使用图形化符号(如UML图)。
- 使用设计描述语言(如伪代码)。
- 使用数学规范。
在本课程中,我们专注于绘制图表(图形化符号)以及使用模板来编写SRS。
总结 📚

本节课中,我们一起学习了系统需求捕获的基础知识。我们明确了需求的定义及其在软件开发过程中的核心作用,理解了需求捕获为何既至关重要又充满挑战。我们还介绍了需求捕获的主要活动,并重点学习了如何使用UML类图进行领域建模,以捕获系统的数据需求。记住,良好的开端是成功的一半,在项目初期投入精力做好需求捕获,将为后续的开发工作奠定坚实的基础,并有效降低项目风险和成本。
011:领域建模-评估类 🧱

在本节课中,我们将学习领域建模的核心步骤,特别是如何从问题陈述中识别和评估类。我们将了解如何提取潜在类、关联和属性,并学习过滤无关信息以构建一个清晰、准确的类图。

上一节我们介绍了领域建模的基本概念,本节中我们来看看如何具体地从问题陈述中识别和评估类。

识别潜在类与关联
领域建模的目标是使用类图捕获数据需求,并追踪软件系统中需要记录的所有重要对象。
类图旨在捕获最重要的类及其关联。这些类和关联可以从客户提供的问题陈述中提炼出来。


请注意,类可以是业务对象、现实世界对象、概念,甚至是事件。我们使用类图来描述它们。
类通常以名词或名词短语的形式出现。因此,看到问题陈述后,你应做的第一件事是提取其中所有的名词和名词短语。它们是类图中的潜在类。
关联通常以动词或动词短语的形式出现。同样,你需要提取问题陈述中所有的动词和动词短语,它们是类图中潜在的关联。


提取所有名词和动词后,应将其转换为单数形式和主动语态,以便于阅读。
评估类的相关性
我们只识别相关的类和关联。如果不相关,可以忽略它们。我们只追踪那些需要在系统中持久化或记录的对象。
将用户需求分解为类和关联取决于判断、经验和问题的性质。在领域建模中,通常没有唯一正确的答案。这意味着对于同一个问题陈述,可能没有唯一正确的模型。

可能存在多个正确答案。选择哪一种模型将只是基于你个人判断和经验的设计选择。这就是为什么通常没有唯一正确的分解方式。
识别属性

识别出所有类和关联后,接下来需要识别属性。属性通常以名词后接介词短语的形式出现,例如“学生的密码”、“学生的地址”等。它们也可能是带有枚举值的形容词,例如“四年制博士学位”。

你只需要识别与应用程序域直接相关的属性。
请注意,大多数属性可能无法直接从问题陈述中获得。在这种情况下,你可能需要与领域专家交流,他们能告诉你软件系统中具体需要哪些属性。
过滤无关类的规则
以下是用于忽略无关类的一些规则:



- 无关类:如果某些类与领域模型无关,则消除它们。
- 模糊类:如果某些类定义模糊,可以忽略它们,或使其更具体。例如,问题陈述中出现“在系统中我们尝试存储信息”,这里的“信息”比较模糊。
- 冗余类:如果某些类含义冗余,则保留描述性最强的一个。例如,“班级”和“课程”可能指代同一事物,则只保留一个。
- 实为属性的类:如果某些类名实际上是属性,则将其设为属性而非类。
- 实为关联角色的类:如果某些类名描述的是一个角色,则将其设为关联下的角色名,而非独立的类。
- 操作或功能:如果某些类描述的是操作、动作或功能,则消除它们。
- 实现构造:如果某些类描述的是实现构造,则消除它们。例如,问题陈述中的“系统”一词,它是你要构建的系统本身,不应出现在类图中。

本节课中,我们一起学习了如何从问题陈述中系统地识别和评估类、关联与属性。我们掌握了提取关键信息的方法,并了解了过滤无关类的具体规则。记住,领域建模是一个需要结合领域知识和设计判断的过程,其目标是为软件系统构建一个清晰、准确的数据结构蓝图。
软件工程:P12:领域建模 - 评估关联与属性 🧩
在本节课中,我们将学习如何评估和优化领域模型中的关联与属性,确保模型既简洁又准确地反映问题域。我们将重点讨论如何识别并移除不相关的元素,以及如何恰当地处理冗余信息。
上一节我们介绍了领域建模的基本概念,本节中我们来看看如何具体评估模型中的关联关系。

首先,存在一些规则可以帮助我们消除不相关的关联。
以下是评估关联时需要检查的几种情况:
- 与领域无关的关联:如果某些关联与当前要建模的业务领域无关,则应将其消除。
- 描述操作的关联:如果关联描述的是动态的操作行为,而非静态的结构关系,也应将其消除。
- 三元关联:应考虑将三元关联分解为多个二元关联。正如之前提到的,我们尽量避免使用三元关联,因为它会使图表难以阅读。
- 派生关联:如果通过一条路径(例如
A -> C -> B)可以获得与直接路径(A -> B)相同的信息,那么可以消除其中一条路径。但在移除路径前必须非常谨慎。



为了理解如何谨慎处理冗余路径,让我们研究一个练习中的例子。

假设我们有以下类图,该系统旨在跟踪不同账户的交易记录,而账户归属于不同的客户。我们可以看到图中存在一个循环。那么,我们能否移除其中一条链路,比如 makes 这个关联呢?
[客户] ---holds---> [账户]
^ |
| |
makes isAgainst
| |
| v
[交易] <---------------/



现在让我们更详细地研究这个例子。假设这是软件系统中存储的实际信息:交易 T1 是客户 C1 和 C2 之间账户 A1 和 A2 的一次转账。
如果我们只保留 isAgainst(交易针对账户)和 holds(客户持有账户)这两个关联,能否判断出是哪个客户发起了这笔交易?我们无法判断交易 T1 是由 C1 还是 C2 发起的,因为仅凭现有信息无法确定这一点。


因此,我们需要 makes(客户发起交易)这个关联来明确告知我们是谁发起或开始了这笔交易。有了这个关联,我们就可以确定,例如 T1 是由客户 C1 发起的。


所以,在这个特殊案例中,我们不能移除这个看似冗余的路径,因为两条路径具有不同的含义。一条路径(通过账户)表示“交易针对哪些账户,以及这些账户属于哪些客户”;另一条路径(直接关联)表示“谁实际发起了该交易”。这就是我们必须同时保留两条路径的原因。
此外,如果某些关联描述的是实现层面的构造,也可能需要消除它们。


处理完关联后,我们接下来看看如何评估属性。
对于属性,需要仔细检查它们是否与类图中的某个类或对象紧密相关,因为一个类应该保持简单和内聚。



以下是评估属性时需要遵循的准则:
- 无关属性:如果某些属性与类不相关,则应消除。
- 应提升为类的属性:如果某些属性本身具有独立的行为或复杂结构,应将其提升为单独的类。
- 应放入关联类的属性:如果某些属性描述的是两个类之间关系的特征,而非任一类的特征,则应创建一个关联类,并将这些属性放入其中。
- 对象标识符(ID):所有用于标识对象实例的 ID 都应被消除。对象标识符不应包含在领域模型中,因为它们并非对象的真实属性,仅仅是人工编号。例如,在第一个练习中,我们有 ID 作为属性,必须移除它们。为了确保类图简洁易读,我们只保留必要的属性,并忽略所有 ID。

最后,为了完成领域模型的构建,我们需要为每个类指定所有属性,并为每个属性指定其名称、类型和多重性。对于每个关联,我们需要为其命名;如果是单向关联,还需要提供角色名,可能还包括多重性,并在需要时使用关联类。

本节课中我们一起学习了如何评估领域模型中的关联与属性,掌握了消除无关元素、处理冗余信息以及规范化属性定义的方法,这些步骤对于构建一个清晰、准确且有用的领域模型至关重要。
013:用例建模 - 参与者 👤
在本节课中,我们将学习如何构建用例模型,以捕获软件系统的所有功能需求。我们将重点介绍用例建模中的第一个核心概念——参与者。

用例建模旨在从用户的角度捕获系统行为,并与领域模型协同进行增量式开发。这有助于捕获数据和功能需求,规划开发迭代,并最终验证系统。请注意,用例驱动着开发工作,我们基于用例模型中捕获的所有功能来开发软件系统。
什么是参与者?🎭
上一节我们介绍了用例模型的目的,本节中我们来看看构成用例图的首要元素——参与者。

一个参与者代表了与系统直接交互的外部事物。在用例图中,我们使用以下符号来表示一个参与者:
[小人图标] 或 <<actor>> 类名
参与者可以是一个人,也可以是另一个系统。它为系统提供输入或从系统接收输出,并指定了用户在系统中扮演的角色。需要注意的是,一个用户可能扮演多个角色(多个参与者),而多个用户也可能扮演同一个角色。
参与者通常是发现系统用例(功能)的来源。在UML中,参与者是一种特定类型的类(stereotype of a UML class)。一个软件系统中可能有许多用户,我们将相似的用户归类为用例图中的一个参与者。
如何识别参与者?🔍
识别参与者是构建用例模型的第一步。以下是识别参与者时可以问自己的一些问题:

- 谁或什么将使用这个系统?
- 他们在交互中扮演什么角色?
- 谁从系统获取信息,或谁向系统提供信息?
- 有哪些其他系统会与本系统交互?
- 谁负责安装、启动、关闭或维护系统?
请注意,同一个实体(例如“学生”)可能同时作为领域模型中的一个类和用例模型中的一个参与者。在领域模型中,“学生”类表示系统内部需要管理的数据;而在用例模型中,“学生”参与者则表示使用系统功能的外部用户。
重要提示:输入/输出设备(如键盘、鼠标、打印机)永远不是参与者。它们是系统实现交互的媒介,而非扮演角色的用户或外部系统。


为参与者编写描述 📝

对于识别出的每个参与者,我们需要简要描述其在与系统交互时所扮演的角色。这有助于明确参与者的边界和职责。
实践示例:大学课程注册系统 🏫
让我们通过一个例子来实践。假设我们要为“ASU大学”构建一个课程注册系统。我们得到了以下问题描述:

“ASU大学需要一个系统,允许学生在线注册课程。教师可以提交他们计划教授的课程。系统需要与大学的计费系统交互,以在学生注册课程后更新其账单信息。”
根据这个描述,我们可以识别出以下参与者:
- 学生:在大学注册课程的人。
- 教师:大学教学人员的一部分,负责提交课程信息。
- 计费系统:负责为学生计费的外部系统。
以下是这些参与者的简要描述:
- 学生:在ASU大学注册或退选课程的个人用户。
- 教师:负责提交和更新其教学课程信息的大学教职员工。
- 计费系统:一个外部软件系统,当学生注册课程后,本系统需与之通信以更新学生的财务账单。
在用例图中,我们用一个大方框(系统边界)来代表“ASU课程注册系统”,并将识别出的参与者画在方框外部。系统内部的功能(用例)我们将在后续课程中学习。



本节课中,我们一起学习了用例建模的基础——参与者。我们了解了参与者是代表与系统交互的外部用户或系统的角色,学习了通过提问来识别参与者的方法,并掌握了为参与者编写简要描述的技巧。我们还通过一个大学课程注册系统的例子进行了实践。记住,参与者是发现系统功能的起点,清晰地定义参与者是构建准确用例模型的关键第一步。在下一节中,我们将深入探讨如何定义系统内部的用例。
014:用例图与用例详解
在本节课中,我们将学习统一建模语言中用例图的核心组成部分——用例。我们将了解用例的定义、表示方法、与场景的关系,以及如何识别和描述用例。
用例的定义与表示
上一节我们介绍了用例图中的参与者,本节中我们来看看用例本身。
用例是系统功能的特定使用方式,它代表了系统提供的一个具体功能。在UML中,我们使用椭圆形来表示一个用例。
用例描述了参与者与系统之间发生的交互,以及从参与者视角来看系统必须完成的任务。我们将其描述为一个完整的事件和动作序列。用例总是由参与者发起。在绘制用例图时,我们通常只考虑正常的事件序列,而忽略替代或异常情况。这些异常和替代事件可以包含在用例规约中,但不出现在用例图中。
用例与场景
在用例建模中,场景是从单个参与者的视角,对系统单次使用的具体、聚焦且非正式的描述。它是一次执行用例的实际尝试。例如,一个名为“Kenne”的参与者使用系统来完成某项任务。
请注意,用例建模有两种视角:
- 自上而下:从更通用的用例开始,然后为每个单独的用户细化为更精确的场景。
- 自下而上:从个体用户如何与系统交互的具体案例开始,然后将它们抽象为更通用的用例。
在实际操作中,我们通常会结合使用这两种视角来找出所有用例。从UML类的角度看,用例是一个构造型,而场景是它的一个实例。来自单个用户的更详细故事就是一个实例,我们将所有实例归类为一个单一的用例。
如何识别用例与场景
为了识别软件系统的用例和场景,你需要思考以下问题:

以下是识别用例和场景时需要考虑的关键问题:
- 参与者希望利用系统执行哪些任务?
- 参与者需要访问系统中的哪些信息?
- 参与者需要将哪些外部变化通知给系统?
- 系统需要将哪些事件通知给参与者?
- 系统将如何得到支持和维护?
如何描述用例
对于每个识别出的用例,你需要进行清晰的描述。
以下是描述用例时必须包含的要素:
- 用例名称:从参与者的视角出发,使用主动语态的现在时动词短语,以便于阅读。
- 目的描述:说明用例的目的和功能概要。
- 领域词汇:在描述中使用问题领域的专业词汇。

本节课中我们一起学习了用例图的核心元素——用例。我们明确了用例是系统提供的功能,用椭圆形表示,并由参与者发起。我们区分了用例(通用功能类)与场景(具体执行实例)的关系,并介绍了识别用例的方法以及描述用例的基本要素。掌握这些是进行有效需求分析和系统设计的基础。
软件工程:P15:用例建模示例 🎯
在本节课中,我们将通过一个具体的示例,学习如何从需求描述中识别功能,并将这些功能整合成完整、可读的用例图。我们将遵循从原始语句分析到最终模型构建的完整流程。

现在,让我们回到ASU的用例模型。我们将通过逐句研究需求描述,尝试从中识别出所有功能。
从第一句话中,我们得到的第一个功能是:学生将请求课程目录。请注意,课程目录只是课程注册系统上可用课程列表。因此,我们将此功能细化为:学生将在系统上浏览课程。
同时,有人需要为学生准备课程目录。因此,我们得到另一个功能:有人将在课程注册系统上准备课程。
从第二句话中,我们得到的功能是:有人将在系统上准备课程。这与我们从第一句话中获得的功能相同。

在第一句话中,我们可以看到学生可以选择课程。
从第四句话中,我们可以看到学生可以在系统上指明两个备选选择。因此,我们可以说学生选择备选课程。但请注意,这些备选选择仍然是课程。因此,我们将其细化为:学生将在系统上选择两个备选课程。

接着,有人将取消课程。在下一句话中,我们没有获得新功能。再下一句话中,我们看到有人将取消课程,这与之前捕获的功能相同,因此此语句没有新功能。
在这句话中,我们得到一个功能:楼宇系统将从系统接收楼宇信息。
从这句话中,我们可以得到的另一个功能是:教师将选择要教授的课程,并且教师将请求注册名单。
在下一句话中,我们将看到学生将更改他们的日程。最终,日程只是一组课程。因此,我们将其细化为:学生将更改课程。

在这句话中,我们将得到学生可以添加课程以及退选课程的功能。
现在,从需求描述中我们可以看到,有人将在系统上准备课程,也有人将取消课程。因此,您可能需要与领域专家沟通,专家会告诉您,管理员或注册员将在系统上维护所有课程信息。这样,我们就为这个特定的应用领域发现了另一个参与者:注册员。以上是我们从需求描述中捕获的所有功能。
现在,我们完成了吗?我们是否只需简单地绘制一个包含所有这些功能的用例图?我会说“不”。因为如果用例图中包含太多功能,最终会导致图中出现很多椭圆,使图表难以阅读。问题出在哪里?
请注意,当我们尝试构建一个用例时,一个好的用例通常代表一个完整的主要功能,即一个从开始到结束的完整故事。因此,它必须是一个从头到尾的完整故事。请这样思考:浏览课程、选择课程、添加课程、退选课程、更改课程,这些是好的用例吗?不,它们不是好的用例,因为它们只是故事的一部分,不是一个完整的故事。我们必须使其更完整。

学生选择课程、添加课程、浏览课程、退选课程、更改课程,他们是在做什么?他们是在尝试注册课程。因此,我们必须将所有这些功能分组,使其更完整。我们将简化为一个单一的用例:学生将在系统上注册课程。通过使用一个完整的故事,而不是五个部分故事,您的用例图将更具可读性、更简洁、也更容易理解。

因此,只要可能,尽量使您的用例尽可能完整。这就是为什么我们必须遵循这个规则:拥有更长、更广泛的用例,优于拥有更小或部分的用例。我们需要真实且完整的用例,而不是一个用例的几个子用例,不是拼图故事,我们需要一个更完整的故事。
现在,让我们尝试将功能分组,使它们更完整。
以下是我们可以分组的功能:
- 准备课程和取消课程可以归为一组,因为注册员准备课程和取消课程,他们实际上是在系统上维护课程信息。
- 学生注册课程。请注意,选择课程和更改课程的功能已经被添加课程和退选课程的功能所覆盖。因此,我们甚至可以删除它们,因为它们已被涵盖。
- 教师将选择要教授的课程,并且教师将请求注册名单。这些都是系统提供的功能。

最终,这是我们为ASU应用领域绘制的用例图:注册员将维护课程信息;教师将选择要教授的课程和请求注册名单;学生将注册课程。
现在,我们这里有一条连线,而不是箭头。箭头意味着学生将启动这个用例,这个用例是由学生发起的。但我们有一条介于“注册课程”用例和“楼宇系统”之间的连线,因为在“注册课程”用例中,最终您将接收楼宇信息。


因此,我们这里有一条连线,意味着该用例将向楼宇系统传递一些信息。这个用例不是由楼宇系统启动的。它是由学生启动的,因为我们有一个从学生指向用例的箭头。我们有一条介于楼宇系统和注册课程用例之间的连线,因为楼宇系统将从这个用例接收一些信息。这条连线表示楼宇系统和该用例之间将存在一些通信。请注意,我们没有箭头,这意味着该用例不是由楼宇系统启动的,实际上是由学生启动的。
对于您提出的每个用例,您都必须制定一个用例规约。我们将在下一讲中详细讨论如何编写用例规约。
请注意,在用例建模中,您也可以使用泛化。您可以在参与者上执行泛化,也可以在用例上执行泛化。例如,员工可以泛化为两种类型:职员或经理。或者我们也可以泛化用例,例如,“用户验证”可以泛化为两种类型:“检查密码”和“扫描指纹”。
但是,在您的项目中,请尽量不要使用用例泛化。没有必要这样做,因为即使不使用用例泛化,您仍然可以得到正确答案,而且它经常被误用。因此,我建议大家在项目中尽量避免对用例使用泛化。当然,您仍然可以在参与者上使用泛化。


这就是本节课我想涵盖的所有内容。

总结
本节课中,我们一起学习了用例建模的具体实践。我们从逐句分析需求开始,识别出离散的功能点,然后通过功能分组和故事完整性的原则,将这些点整合成代表完整业务流程的用例。我们明确了好的用例应是完整的用户目标,并最终构建了包含参与者、用例及它们之间关系的用例图。记住,保持用例的完整性和图表的简洁性是创建有效用例模型的关键。
016:用例规范 📝
在本节课中,我们将学习如何编写用例规范,以详细描述一个用例内部的事件流程。用例规范是需求分析阶段的关键文档,它帮助我们清晰地定义系统与用户之间的交互。
概述

上一节我们介绍了用例图,它从宏观上描述了系统的功能范围。本节中,我们来看看如何为用例图中的每一个用例编写详细的规范。一个完整的用例规范包含多个部分,以下是其主要组成部分。
用例规范的组成部分
一个用例规范必须包含以下核心要素:

- 用例名称:清晰标识该用例。
- 简要描述:用一两句话概括用例的目的。
- 参与者:列出与该用例交互的所有角色。
- 前置条件:执行该用例前,系统和参与者必须满足的状态。
- 事件流:描述执行用例时,参与者和系统之间交互的详细步骤。
- 后置条件:用例成功结束后,系统所处的状态。
- 可选/异常流:描述正常流程之外的变体或错误处理流程。
- 非功能性需求:与该用例相关的性能、安全等要求(如有)。
前置条件与后置条件详解
前置条件
前置条件是一个关于状态的声明,它说明了在执行用例之前,系统和/或参与者必须处于何种状态。它只在该用例的执行依赖于某些特定条件时才需要写明。
- 目的:使各个用例的描述尽可能相互独立。
- 写法:只声明“什么必须为真”,而不描述“如何使其为真”。
- 性质:前置条件是执行用例的必要条件,但非充分条件。用例的启动总是需要参与者首先在系统中执行某个动作。
示例:在一个银行ATM系统的“取款”用例中,一个可能的前置条件是:用户账户余额 >= 100元。这意味着在执行取款操作前,必须满足此条件。
后置条件
后置条件是一个关于状态的声明,它说明了在用例结束时,系统处于何种状态。它只在用例结束时的系统状态对参与者或其他用例重要时才需要写明。
- 目的:确保读者和利益相关者清楚了解执行用例后的结果。该结果状态可能成为其他用例的前置条件。
- 写法:清晰定义用例成功完成后的系统状态。

示例:继续使用ATM“取款”的例子,一个可能的后续条件是:取款后,用户账户余额 >= 0元。这定义了用例成功执行后的结果状态。
事件流:基本流与可选流
事件流是对完成用例所需的一系列动作的精确且易于理解的描述。它规定了在用例执行过程中,系统和参与者应该做什么。
在用例规范中,我们首先描述基本流(即正常情况下会发生的流程),然后根据需要添加可选流(即变体或异常行为)。
描述基本流时,可以使用类似伪代码的结构,例如:

- 条件语句:
if (条件) then (执行动作) - 循环语句:
for (条件) repeat (执行动作)或while (条件) do (执行动作)
用例规范示例
以下是一个“选课授课”用例的规范示例,该系统是ASECU课程注册系统的一部分。
用例名称: 选课授课

简要描述: 本用例描述教授如何为一个尚未开始的学期选择要教授的课程。

参与者: 教师
前置条件:
- 教师已成功登录系统。
- 目标学期尚未开始。
基本事件流:
- 用例开始于教师参与者选择在系统中选课授课。
- 系统显示选课授课界面。
- 教师指明他希望授课的学期和年份。
- 当教师选择创建授课活动时,系统将检索并显示给定学期的可用课程信息(前提是修改截止日期尚未过等)。
- ...(后续步骤)
后置条件:
- 教师成功为指定学期选择了要教授的课程。
- 课程选择信息已保存在系统中。
可选流:
- A. 无可用课程:如果在步骤4中,系统发现没有可用课程,则通知教师并结束用例。
- B. 截止日期已过:如果修改截止日期已过,系统阻止选择并显示错误信息。

(注:示例中省略了部分细节以保持简洁,实际文档应更完整。)
总结

本节课中,我们一起学习了如何编写用例规范。我们了解到,一个规范的用例描述需要包含名称、描述、参与者、前置后置条件以及详细的事件流(包括基本流和可选流)。通过编写用例规范,我们可以将用例图中抽象的功能点转化为具体、可验证的系统行为描述,为后续的系统设计和开发奠定坚实的基础。
017:扩展点与备选流 📝
在本节课中,我们将学习如何在用例规格说明中定义和使用扩展点,以及如何利用扩展点来组织备选流。扩展点是描述可选或异常行为的关键机制,有助于清晰地构建复杂的用例流程。
扩展点的定义与作用
我们可以在用例规格说明中指定扩展点。

扩展点是一个命名的位置,相当于事件流中的一个标签,用于标记可以插入额外行为的地方。通常,我们讨论的是异常或备选行为。
扩展点主要有三种类型,我们可以将它们添加到用例规格说明中。第一种称为单点扩展点;第二种是发生在一组不同离散位置的扩展点;最后一种是区域扩展点。
现在,让我们通过示例来了解这三种不同类型的扩展点。
三种扩展点类型详解
以下是三种扩展点的具体说明。
- 单点扩展点:这种标签在用例规格说明中只出现一次,因此称为单点扩展点。
- 离散位置扩展点:这种标签在用例规格说明中出现多次,例如在“创建活动”和“修改活动”中都有出现。因为它出现在多个离散位置,所以称为离散位置扩展点。
- 区域扩展点:我们定义一个区域,例如从“开始修改日程”到“确认修改日程”。这个区域被指定为一个区域扩展点,任何在此区域内发生的特殊行为都可以引用这个区域扩展点。
需要注意的是,扩展点主要用于在用例规格说明中定义备选流,即可选或异常行为。
备选流的概念与类型


那么,什么是备选流?在备选流中,我们尝试定义一个描述可选或异常行为的事件流。它允许将不常用的功能增量式地添加到基本流中。

备选流应编号为A1、A2,直至An。
我们可以在用例规格说明中指定三种备选流。
- 特定备选流:在用例事件流的某个特定命名点开始。
- 限定备选流:只能在用例的两个扩展点之间发生。
- 通用备选流:可以在用例内的任何点开始。
备选流的格式与退出点
备选流的第一行有以下形式。

对于特定备选流,我们这样表述:在用例规格说明中的某个扩展点(某个位置),当某事发生时,我们将做某事。或者,在某个特定扩展点,如果某事发生,我们做某事。这就是我们在特定位置定义特定备选流的方式。请注意,特定备选流依赖于用例规格说明中发生的某些事件或条件。
限定备选流将发生在两个扩展点之间的任何位置。
通用备选流将发生在事件流中的任何时间。
请注意,备选流的最后一行将指定该流的退出点。你必须明确说明在退出备选流后,参与者从何处恢复事件流。
对于可选行为,通常我们在原始扩展点恢复。对于异常行为,通常我们在另一个扩展点恢复。对于异常行为,可能是原始扩展点、另一个扩展点,甚至用例可能结束。对于异常行为,我们必须在备选流中明确指定如何退出该流。
备选流示例分析
这是一个异常流的例子。
例如,在“选择活动”这个标签位置,如果讲师输入了错误的学期或年份,系统将通知讲师学期或年份无效。然后,我们将在“输入学期”处恢复事件流。这意味着我们必须重新输入学期和年份,直到输入正确为止。
这是一个可选流的例子。

在“开始创建日程”这个扩展点,如果日程已存在,则显示一个弹出窗口,通知讲师日程已存在。然后,事件流将在另一个扩展点“选择活动”处恢复,依此类推。
你可以在课程页面下载的示例用例规格说明中找到更多关于备选流的例子。
总结

本节课中,我们一起学习了用例规格说明中的扩展点与备选流。我们了解了扩展点的三种类型(单点、离散位置、区域)及其作用,掌握了三种备选流(特定、限定、通用)的定义方式、书写格式以及关键的退出点指定方法。通过清晰的扩展点和结构化的备选流,我们可以更有效地描述复杂的系统行为,使用例规格说明更具条理性和可维护性。
018:子流程与用例详述规范 📝
在本节课中,我们将学习如何在用例规格说明中创建和使用子流程,以提升文档的可读性。同时,我们也将探讨如何确定用例描述的详细程度,并理解保持用例独立性的重要性。
子流程的概念与作用
上一节我们介绍了用例规格说明的基本结构,本节中我们来看看如何通过子流程来优化其组织。
你可以在用例规格说明中创建子流程。子流程是用例规格说明内部具有明确目的且不可再分的事件片段。由于该事件片段具有明确目的,我们可以将其提取出来,放入一个子流程中。通过使用子流程,我们可以提高用例规格说明的可读性。

子流程的使用规范

以下是关于子流程编号和命名的具体规范:
- 子流程应编号为 S1、S2……Sn。
- 你必须为子流程命名。请注意,子流程并非独立的用例,它只是用例中有意义且目的明确的一部分。我们将该片段内的事件提取出来,放入子流程中。
子流程应用示例
为了更直观地理解,让我们看一个具体的例子。在第二种风格的用例规格说明(简要式)中,我们可以看到子流程的应用。







现在你可以看到,在事件流中我们有两个子流程:一个叫“创建日程”,另一个叫“修改日程”。“创建日程”内的事件流将被放置在“创建日程”子流程下,这些是我们为创建日程所做的事情。同样,“修改日程”的事件片段则放在“修改日程”子流程下。
这样一来,基本流变得更加易于阅读,因为它更简洁。如果你想了解“创建日程”和“修改日程”的细节,可以随时进入相应的子流程查看其内部的事件流。
用例描述的详细程度
在编写用例规格说明时,一个常见的问题是:需要描述多少细节才足够?






经验法则是:细节应足够让所有利益相关者理解用例内发生的事情。基本流应明确无误地描述所需的行为。你应该不断问自己“这意味着什么”,直到所有歧义都被消除。

用例的独立性原则

请注意,用例之间不直接相互通信。我们假设用例旨在独立于其他用例。这是因为我们希望用例规格说明易于阅读和理解。如果某个用例以某种方式与另一个用例相关,那么当我在阅读其中一个用例规格说明时,可能稍后需要参考另一个。如果另一个用例又依赖于其他用例,我就需要阅读更多。为了简化并确保所有利益相关者都能轻松理解我们的用例规格说明,我们假设每个用例都旨在独立于其他用例。


因此,当你构思用例时,尽量不要将系统行为分解成过小的用例。例如,“选择产品”、“输入订单和配送信息”、“输入支付信息”、“确认订单”,这些都太小了。它们只是故事的一部分,而非完整的故事。由于它们只是故事的一部分,这些小型用例很可能相互依赖。
为了避免这种情况,我们必须使其成为一个更完整的故事。因此,我们需要将它们组合在一起,最终可能只有一个更大但更完整的用例,例如“下订单”。当你构思用例时,必须思考这些小用例是否真的彼此独立,并思考你是否会只做其中一件事而不做其他事。如果你无论如何都需要一起做其他事情,那么为什么不将它们组合成一个更大的用例呢?


本节课中我们一起学习了子流程的创建与使用规范,了解了通过子流程可以提升用例文档的可读性。我们还掌握了确定用例描述详细程度的经验法则,并深刻理解了保持用例独立性的重要原则,这有助于我们构建出清晰、完整且易于理解的用例规格说明。
019:非功能性需求与系统需求验证 🎯

在本节课中,我们将学习如何捕获非功能性需求,以及如何验证系统需求。首先,我们将探讨非功能性需求的具体内容。

什么是非功能性需求?
非功能性需求本质上是对系统用例施加的约束。这意味着我们试图对系统的某些功能施加额外的限制条件。
这些约束可能涉及多个方面,以下是其主要类别:

- 设计质量:例如可靠性、可移植性、可维护性或文档要求(需要何种文档,面向谁)。
- 硬件:例如实现平台、内存大小、存储容量等。
- 实现:例如标准、编程语言以及错误处理。
- 接口:例如与用户界面或外部系统相关的事项。
- 管理:例如系统备份、安装或维护。
- 性能:例如速度、吞吐量、响应时间或准确性。
- 物理环境:例如异常条件或分布式操作(如果我们讨论的是分布式系统)。
- 安全:例如系统安全、数据安全或物理访问控制。

非功能性需求实例分析
现在,让我们通过一个具体例子来学习如何识别非功能性需求。我们将分析一款名为“SetWatch”的卫星手表的原始需求陈述。
以下是该需求陈述的逐句分析:

- 陈述1:“任何知道如何读取数字手表并理解所显示的缩写国际时间的用户,都应该能够使用SetWatch。”
- 需求类型:接口与可用性。因为它对用户界面和易用性提出了要求。
- 陈述2:“SetWatch不应出现任何需要重置手表的软件故障。”
- 需求类型:设计质量(可靠性)。它要求系统必须高度可靠。
- 陈述3:“SetWatch应能通过USB接口接受其板载处理器的升级。”
- 需求类型:设计质量(可支持性)。它要求系统支持通过USB进行升级。
- 陈述4:“SetWatch应在GPS信号中断期结束后的五分钟内显示正确时间。”
- 需求类型:性能(响应时间)。它对系统恢复并显示正确时间的速度提出了要求。
- 陈述5:“SetWatch应在五年内将时间测量精度保持在百分之一秒内。”
- 需求类型:性能(准确性)。它对系统的长期计时精度提出了严格要求。
- 陈述6:“SetWatch应能在全部24个时区正确显示时间。”
- 需求类型:性能。它要求系统功能覆盖全球所有时区。
- 陈述7:“所有与SetWatch相关的软件都将使用Java语言编写。”
- 需求类型:实现。它指定了系统开发所使用的编程语言:
Java。
- 需求类型:实现。它指定了系统开发所使用的编程语言:
- 陈述8:“SetWatch应符合Webify Watch API 2.0定义的物理、电气及软件接口规范。”
- 需求类型:接口。它规定了系统必须遵循的外部接口标准。
如何指定非功能性需求
在明确了非功能性需求的类别和分析方法后,我们来看看如何正式地指定它们。

非功能性需求可以指定在我们已定义的用例之上,也可以指定在整个系统之上。需要注意的是,部分非功能性需求会以管理用例的形式实现。
例如,与安全、系统启动、系统关闭或系统备份相关的需求,可以指定为管理用例。管理用例是专门处理非功能性需求(如安全性、系统操作或维护)的用例。

因此,我们可以通过编写管理用例来详细描述这些非功能性需求。
总结

本节课中,我们一起学习了非功能性需求的核心概念。我们了解到非功能性需求是对系统功能的约束,涵盖了质量、硬件、性能、安全等多个维度。我们通过分析“SetWatch”的实例,练习了如何从需求陈述中识别和分类不同的非功能性需求。最后,我们知道了非功能性需求可以通过补充规格说明或管理用例来具体指定。理解并准确捕获非功能性需求,对于构建一个高质量、符合期望的软件系统至关重要。
020:验证系统需求 🔍
在本节课中,我们将要学习如何验证系统需求。在通过领域模型捕获了所有数据需求、通过用例模型捕获了所有功能需求以及所有非功能需求之后,我们必须对我们已捕获的所有需求进行验证。

上一节我们介绍了如何捕获需求,本节中我们来看看如何确保这些需求的质量。
需求验证的目标 ✅
所有记录在系统需求规格说明书中的需求,都必须持续地与客户或用户进行验证,以确保它们满足以下关键特性:
- 完整性:意味着我们已经捕获了所有必要的需求。
- 一致性:意味着需求之间没有自相矛盾。
- 清晰性:意味着所有利益相关者都能理解这些需求。
- 正确性:意味着需求准确地反映了用户的真实意图。
- 现实性:意味着需求在技术上是可行的。

验收测试是验证系统实现是否满足需求的主要手段,我们将在讨论测试时详细讲解。在验收测试中,我们会在客户或用户面前验证所有需求。
需求验证示例与解决方案 🛠️
以下是几种常见需求问题的具体例子及其解决方案。

不完整的需求
问题描述:智能手表规格说明没有指定当用户站在GPS精度限制内的时间区边界时的行为。这意味着当用户站在两个时区的交界处时,手表上的时间不应在两个时区之间反复切换。
解决方案:需要增加一条功能需求:“手表显示的时区变化频率不应高于每五分钟一次。” 这样就能确保时间不会频繁跳动。

不一致的需求
问题描述:第一条需求说“手表软件不应有缺陷且无需升级”,而第二条需求却说“手表软件应能通过USB接口轻松升级”。这两条需求相互冲突。

解决方案:必须修订其中一条冲突的需求,使其保持一致。例如,可以修改第一条需求,或明确第二条需求为“在必要时,软件应能通过USB接口进行升级”。
不清晰的需求

问题描述:智能手表规格说明提到了“时区”,但没有明确是否需要处理夏令时。
解决方案:需要细化和澄清该需求。可以增加一条需求:“手表应能处理夏令时调整。”


不正确的需求
问题描述:需求说明“手表仅支持24个时区”。但现实中全球有超过24个时区。

解决方案:必须修正该需求,将其改为“支持所有时区”。
需求规格说明书的核心构成 📄


一个完整的系统需求规格说明书应包含以下核心部分:

- 领域模型:用于捕获应用程序的所有数据需求。这通常是一个类图,用于表示软件系统中持久存在的重要对象及其关系。
- 用例模型:用于捕获应用程序的功能需求。这包括一个用例图,用于展示软件系统提供的所有功能,以及用用例规格说明来描述每个用例的事件流。
- 非功能需求:描述对用例或整个系统的附加约束。
- 已验证的需求:经过上述完整性、一致性、清晰性、正确性和现实性验证的所有需求。
需求在开发流程中的演进 🔄
在捕获并验证了初始需求之后,开发流程并未结束。在后续的迭代或阶段中,我们需要:

- 细化需求:为上述的领域模型、用例模型等工件指定更多细节。
- 创建测试模型:制定一组测试用例,用于后续验证。
- 进行分析与设计:在分析模型中创建匹配的用例实现,并在设计模型中创建更详细的用例实现。分析模型和设计模型将直接用于指导系统实现。


正是通过这种方式,用例驱动着后续的迭代和开发阶段,确保软件开发始终围绕用户需求展开。
总结 📝

本节课中我们一起学习了系统需求验证的重要性与方法。我们了解到,一个合格的需求必须具备完整性、一致性、清晰性、正确性和现实性。我们通过具体示例分析了不完整、不一致、不清晰和不正确的需求问题及其修正方法。最后,我们回顾了需求规格说明书的核心构成,并了解了已验证的需求如何驱动后续的分析、设计与实现工作,从而确保最终交付的软件产品能够真正满足用户的需要。
021:软件开发入门 🚀
在本节课中,我们将对软件开发进行概述。你将了解软件在实践中的规划、管理和开发方式,并理解在构建软件系统时使用迭代和增量式开发流程的重要性。最后,你将认识到一个优秀的软件开发流程应包含哪些重要组成部分。

软件开发项目的本质
上一节我们介绍了课程目标,本节中我们来看看软件开发项目本身的特点。

软件开发项目在很大程度上是无形的。这意味着很难可视化项目中的所有细节。这与制造汽车不同,你可以制作汽车模型,并从模型中可视化汽车的细节,并且大规模生产既容易又便宜。
对于软件工程项目,产品通常以源代码的形式存在。源代码易于复制和批量生产。

开发过程是劳动密集型的。这意味着你需要的主要资源将是程序员,他们负责构建源代码。
任何人都可以轻松地物理创建和修改它。任何懂编程的人都可以在项目完成后修改产品(源代码)。

它不会因使用而磨损。这与汽车不同,汽车在使用过程中会磨损。但源代码在使用时不会磨损。
软件的类型
在软件工程中,你可能需要为客户构建不同类型的软件。以下是几种主要类型:
- 通用软件:例如 Windows 10、Microsoft Office 或可以从 App Store 或 Google Play 下载的应用程序。基本上,所有需求都来自市场调研。
- 定制软件:为特定应用领域(例如餐厅)构建的软件系统。所有需求都来自客户需求。开发工作量很高,因为必须在构建系统之前捕获所有需求。使用副本数量较少,因为可能只有一家餐厅使用该软件系统。
- 嵌入式软件系统:例如 DVD 播放器中的嵌入式系统。开发工作量较低,因为从 DVD 播放器本身你已经知道了所有需求。使用副本数量很高,因为所有 DVD 播放器都将使用相同的系统。
此外,软件还可以按处理方式分类:
- 数据处理系统:负责组织和存储业务数据。
- 实时处理系统:实时控制设备和流程。
- 技术系统:不包含应用领域相关知识、工作流程和过程。
- 社会技术系统:需要先了解应用领域,然后才能开发软件系统。
软件工程主要侧重于技术系统(即通用软件或嵌入式系统)的开发。但在本课程中,我们仍将重点关注定制软件,因为许多定制软件之所以失败,是因为无法满足用户需求,或者无法在项目一开始就正确捕获所有需求。因此,本课程的一个重要主题是如何从某个应用领域捕获需求。
软件开发项目的类型
作为一名软件工程师,你可能需要处理不同类型的软件开发项目。
- 绿地项目:意味着你必须从零开始。
- 演进式项目:你获得一个现有系统,然后基于该系统修复缺陷、适应新技术、添加新功能或提高其可维护性。这是最常见的类型。
- 基于框架的项目:你获得一个框架,然后必须根据特定需求调整该框架。
信不信由你,当你以后在行业工作时,你会发现大多数项目都是演进式项目。

软件开发生命周期
一个软件项目在开始开发后,其开发生命周期包括持续地维护,直到项目终止。
在每个生命周期内,包含不同的阶段:
- 定义:确定在该生命周期内需要做什么,即为客户构建什么。
- 可行性研究:双重检查计划要做的事情是否可行。如果可行,则继续;如果不可行,则停止并告知客户。
- 设计:提出所有设计细节。
- 构建:构建系统。
- 部署:最终部署系统。

生命周期概念为软件开发提供了结构,这些是你在软件开发中必须做的事情。各个阶段为管理和控制提供了基础,你必须为生命周期中的不同阶段定义所有里程碑和交付成果。

软件开发的四个重要组成部分
在一个软件开发项目中,有四个重要的组成部分:
- 需求:必须从应用领域为项目捕获所有需求。
- 人员:在项目中需要与不同的人员合作,例如不同的利益相关者,包括客户、用户和软件工程师。
- 流程:在开发中使用不同的流程,即项目中必须执行的一系列活动。
- 制品:最终获得一系列制品作为产品,例如在软件系统中使用的不同图表、模型、源代码,甚至用户手册。
软件开发流程是一个模板,需要考虑不同的情况,例如团队中不同的人员、开发中将使用的不同流程以及产品中将包含的不同制品等。

项目规划与管理
作为一名软件工程师或项目经理,首要任务是定义项目的范围、目标和约束。
- 范围:意味着在项目中必须做什么或不做什么。必须准确定义项目的需求。
- 目标:希望在项目中实现什么。
- 约束:通常指预算和时间。
在项目计划中,必须定义问题并设定设计目标。在第一讲中我们提到,在项目一开始就必须选择合适的设计目标。然后,基于设计目标来设计系统。

接着,需要分析需求,从应用领域捕获所有需求。
然后准备一些图表,例如:
- 类图:捕获应用领域中所有重要的数据对象。
- 用例图:捕获软件系统的所有功能。这将为你提供系统内所有需求的概览。
最后,估算交付产品所需的时间和精力,通常也以预算、时间和进度来表示。
本节课总结

在本节课中,我们一起学习了软件开发的基础概览。我们探讨了软件开发项目的无形和劳动密集型本质,认识了通用软件、定制软件和嵌入式软件等不同类型。我们了解了从绿地项目到演进式项目等不同的项目类型,并梳理了软件开发生命周期的各个阶段。最重要的是,我们明确了软件开发中需求、人员、流程和制品这四个核心组成部分,以及项目初期定义范围、目标和约束的重要性。这些概念为理解后续更具体的软件开发流程奠定了坚实的基础。
022:项目风险管理 🛡️
在本节课中,我们将学习项目管理中的一个核心环节:风险管理。作为项目经理,识别并应对项目中可能出现的所有风险至关重要。我们将了解风险的定义、分类以及一套系统的应对策略。
风险的定义与分类
上一节我们介绍了项目经理的职责,本节中我们来看看什么是项目风险。风险的定义很简单:项目中任何可能出错的事情。
风险可能来源于多个方面:
以下是风险的主要类别:
- 技术风险:与技术实现相关。
- 人员风险:与团队成员相关。
- 组织风险:与公司或团队结构相关。
- 工具风险:与开发工具和环境相关。
- 需求风险:与项目需求相关。
- 估算风险:与时间、预算的估算相关。错误的估算会导致项目延期或超支,这本身就是一种重大风险。
风险的应对策略
认识了风险的类型后,我们需要知道如何应对它们。处理风险主要有四种策略:
以下是四种核心的风险应对策略:
- 规避:尝试阻止风险发生。
- 限制:当风险无法避免时,尽量限制其影响范围。
- 缓解:设计任务来探测风险是否会发生,或降低其发生的可能性。
- 监控:对风险进行密切监控。

请注意,如果你不主动管理风险,风险就会主动攻击你。因此,优秀的项目经理应在项目初期就识别所有潜在风险。

风险评估与规划
仅仅识别风险是不够的,我们还需要对它们进行评估和排序。这并非易事,因为你需要为每个潜在风险评估其发生概率和影响程度。
- 发生概率:指风险发生的可能性,例如“此风险有80%的概率会发生”。
- 影响程度:指风险的性质、可能造成的后果、影响范围以及持续时间。
此外,你还需要为这些评估提供依据,即评估的准确性和置信度。最后,根据风险对项目的潜在影响进行优先级排序。
这是一个风险规划的例子:


- 风险:高人员流动率。
- 概率:70%。
- 影响:项目工期延长15%,总成本增加12%。
成本效益分析与帕累托法则
确定了风险及其影响后,项目经理需要思考采取哪些步骤来缓解风险,并进行成本效益分析。
这意味着你需要权衡:应对风险所付出的成本,是否高于风险发生所造成的后果成本。如果后果微不足道,修复成本极低,有时选择让风险发生后再修复可能是更经济的选择。
由于项目中潜在风险很多,我们需要应用帕累托法则(80/20法则)。我们不应同等地监控所有风险,而应聚焦于那20%最关键、最重要的风险,并对它们进行密切监控。

课堂项目的风险思考
让我们思考一个有趣的问题:在课程项目中,最大的风险是什么?
以下是几个可能的选项:
- 上课时睡着?
- 不知道如何使用开发工具?
- 不清楚项目的具体需求?
- 不知道如何团队协作?
- 没有足够的时间完成项目?

答案可能是“以上所有”,因为它们都可能对项目成功构成关键威胁。这正说明了在项目开始时全面识别风险的重要性。
正如孙子兵法所言:“知己知彼,百战不殆。”了解你的项目,了解潜在的风险,你就能从容应对各种挑战。

总结

本节课中我们一起学习了项目风险管理。我们明确了风险是“任何可能出错的事”,并将其分为技术、人员、估算等类别。我们掌握了规避、限制、缓解、监控四种应对策略,并学会了通过评估概率和影响来规划风险。最后,我们理解了通过成本效益分析和应用帕累托法则(聚焦关键的20%),可以更高效地管理风险,确保项目顺利进行。
023:项目规划
在本节课中,我们将学习如何制定一个全面的项目计划。项目计划是软件项目管理的核心,它定义了项目的组织结构、任务活动、进度安排、估算方法以及开发流程。一个清晰的项目计划是项目成功的关键。
项目组织结构
在项目计划中,必须明确项目的组织结构。这包括指定团队中的角色、职责以及所需的人员数量。
为了提高项目成功的可能性,一个好主意是吸纳一位对类似系统有经验的团队成员。这里的“类似系统”指的是与你将要构建的项目相似的系统。
团队组织结构应该是模块化的,以限制沟通和交互的复杂性。因为如果团队人数过多,沟通会变得复杂。必须为团队中的每个成员分配明确的职责。
每个团队应负责系统一个或多个部分的设计与实现。这再次体现了“分而治之”的原则。我们将大型软件系统分解成更小的部分,然后每个团队负责系统的一个或多个小部分。
此外,必须为每个部分以及整个系统指定一位负责人。这样,如果项目中任何环节出现问题,你都可以找到相应的负责人进行沟通。
成功的关键在于在项目中实现适当程度的沟通。

任务与活动
在项目计划中,还必须明确项目内的任务和活动。
任务的定义是:为一个角色分配的、定义明确的工作。
活动的定义是:一组相关的任务。
有些项目是计划驱动型的。这意味着任务、活动及其时间表在项目一开始就进行了详细的规划。

有些项目则是敏捷型的。这意味着任务、活动及其时间表会随着项目的进展而增量式地规划。具体采用哪种方法高度依赖于项目本身。我们将在下一讲中介绍不同的软件开发流程,其中一些是计划驱动的,另一些是敏捷的。
项目进度安排
同样在项目计划中,我们必须制定一个项目进度表。它规定了项目中的所有任务、任务的顺序、每项任务的时间估算、资源分配、里程碑以及项目内的可交付成果。
通常,我们需要在项目中跟踪三个层次的进度表:
- 主进度表:为管理层和团队沟通提供整个项目的概览。
- 宏观进度表:用于日常项目管理。
- 微观进度表:用于团队内部管理。

在本课程中,我们将介绍用于管理进度的燃尽图和燃起图。
项目估算
你必须在项目计划的一开始就进行所有估算,例如:项目规模、需要投入的工作量、工期、生产率以及开发成本等。

估算是基于经验、历史数据甚至数学模型进行的。为了获得正确或接近正确的估算,你需要一些有类似项目经验的人来告诉你项目中可能使用的数字。

如前所述,估算本身带有固有风险。通过在项目一开始就明确项目范围,可以降低这种风险。这样你就能清楚地知道在项目开始时必须做什么,以及不必做什么。
此外,使用过去项目(尤其是类似项目)的历史数据。例如,你知道你的项目需要这么多行代码和这么多人,那么你就可以借鉴过去的项目进行估算。同时,运用“分而治之”的方法,尝试将大型软件系统分解成更小的部分,先对每个小部分进行估算,然后再将它们汇总。
软件开发流程

软件项目中另一个重要的“P”是流程。
流程的定义是:将用户需求转化为软件产品所需的一整套活动(有时称为工作流)及其顺序。
一个流程规定了项目中的所有主要活动,并有一套指导原则来解释每个活动的目标。流程会使用一些资源,并且可能由一些子流程组成。
每个流程活动都有入口和出口标准,这样我们就知道活动何时开始、何时结束。
流程活动按顺序组织,以便我们可以一个接一个地执行。
约束和控制可能适用于流程活动。我们可能会在某个特定活动上花费时间,或者安排特定人员去完成项目中的某个活动。
那么,流程为何重要?因为它有助于简化项目管理。通过确切知道项目中必须执行哪些活动,可以帮助你更好地管理项目。
它也允许劳动分工。因为确切知道项目中必须做什么,就可以将这些活动分配给团队中的不同成员。同时,它能促进团队合作、个人工作以及沟通。


它还允许专业知识的重用和再分配。这意味着,如果我们在一个项目中使用了某个流程并且成功了,那么我们就可以将相同的流程应用到另一个类似的项目中。
此外,流程有助于培训,能提高生产率和促进更好的开发。流程有助于在软件开发活动中强加一致性和结构。

总结

本节课我们一起学习了项目规划的多个核心组成部分。我们明确了在项目计划中定义组织结构、任务活动、进度安排和估算方法的重要性。同时,我们介绍了软件开发流程的概念及其对项目管理的关键作用,例如促进分工、提高一致性等。下一讲,我们将深入探讨不同的软件开发流程,包括计划驱动型和敏捷型方法。
024:软件开发流程 🛠️
在本节课中,我们将探讨可用于软件开发的不同流程。我们将介绍瀑布模型、边做边改、原型法、螺旋模型、阶段发布、敏捷开发以及统一过程等模型。无论为项目选择哪种流程,最终都会包含以下几个阶段:获取需求、分析与设计、实现系统以及最终测试。不同的软件开发流程主要区别在于这些阶段如何组合、强调和执行。学习完这些流程后,最重要的是理解各自的优缺点,以便在特定场景下选择最合适的开发过程。
瀑布模型 🌊
上一节我们概述了软件开发的基本阶段,本节首先来看瀑布模型。在瀑布模型中,我们首先获取所有需求。然后基于这些需求进行系统分析与设计,并创建用于实现的模型。接着,我们进行系统实现。实现完成后,执行测试以确保软件系统没有缺陷。最后,进行系统维护。
我们称之为瀑布模型,是因为流程总是向下进行:获取需求后进行分析设计,然后实现、测试、维护。虽然理论上可以回溯(例如,发现设计因需求错误而不正确时),但回溯的代价很高。因此,我们通常希望流程一直向下,避免回溯。
正因如此,在完成特定阶段(如分析与设计)后,必须进行评审,仔细检查设计是否正确,然后才能进入下一步,因为回溯成本高昂。该模型仅适用于需求被充分理解且不太可能变更的情况。因此,这是一种更规范的模型,但在工业界并不常用,因为软件系统的需求很可能发生变化,很难在项目初期就捕获并冻结所有需求。
以下是瀑布模型的优缺点:
优点:
- 流程规范、有纪律性。
- 使开发过程可预测且易于监控,因为阶段间顺序推进并尽量避免回溯。
- 强制执行文档标准,并要求在进入下一阶段前批准文档。
- 与其他工程流程模型(不限于软件工程)契合良好。
缺点:
- 假设线性顺序开发是可行的,即尽量避免回溯。
- 过于僵化,假设每个阶段的结果在进入下一阶段前都可以被“冻结”。
- 不同阶段常使用不同的语言和符号。
- 几乎不提供或很少提供用户反馈的机会,这是高风险来源,因为它假设项目中的所有内容都不会改变。

迭代与增量式开发流程 🔄
上一节我们讨论了线性的瀑布模型,本节开始介绍一些迭代和增量式的软件开发流程。
边做边改模型
第一个是“边做边改”模型。这通常是我们在完成编程作业时使用的方法。在编程作业中,你会得到一组需求,然后基于需求进行构建。如果正确,就继续;如果不正确,则进行测试,发现软件有误后,返回去修复实现。这就是所谓的“边做边改”:编码、测试、发现问题、修复。
如果使用此流程,很可能会有很多变更,因为一旦出错就必须修复。代码结构通常会变得混乱,因为需要在源代码中加入许多变通方案才能使其工作。它不适合大型系统,通常仅用于编程作业。使用此流程会使过程不可预测、难以控制,很可能导致预算超支、进度延误,并且无法满足期望,因为你无法确切知道需要修复多少次才能得到正确的系统。
因此,这是我们应避免的流程。
原型法 🧪

接下来要讨论的流程是原型法。在原型法中,我们尝试快速收集一些需求,然后进行快速设计。接着,我们构建一个原型,并让客户参与评估该原型。请注意,原型不是整个系统,也不是完整系统。例如,如果我们正在构建一个Web应用程序,可能只构建应用程序的用户界面,以便向客户展示UI,从而获取关于系统的进一步反馈。
从客户那里收集反馈后,我们必须改进原型,并可能根据客户反馈重新设计系统,然后构建另一个原型,直到获得最终产品。本质上,它类似于“边做边改”,但通过原型法,我们引入了客户评估,并强制执行了一定的纪律性。
当需求模糊不清时,原型法非常有用。当你不确切知道需求是什么时,可以快速进行一些设计,构建一个小型原型,然后询问客户这个原型是否满足他们的需求。如果满足,则进一步改进原型;如果不满足,则必须改进设计并构建新的原型。因此,当需求模糊时,它非常有用,因为你可以利用原型从客户那里收集更多需求。它允许探索所需的功能,并且通常具有创新性。
以下是原型法的优缺点:
优点:
- 允许快速探索需求。
- 允许在进一步改进系统之前获得用户反馈和批准。
- 允许探索不同的解决方案。
缺点:
- 并非一个完整的软件开发流程。
- 过程不可见,使得进度难以衡量,因为你不知道在获得最终系统之前需要改进原型多少次。
- 文档通常很少或完全缺失。
- 最终产品可能不是完整的系统,因为我们讨论的是构建原型,而不是最终系统。

螺旋模型 🌀
下一个要介绍的流程是螺旋模型。螺旋模型的特点是引入了另一个阶段:风险分析。

我们要做的是:进行一些规划,然后进行风险分析,以预见项目中所有可能的风险。在收集所有风险后,决定是否继续推进。如果决定继续,则实现系统,之后让客户参与评估,然后再次规划系统并重新进行风险分析,如此循环。因此我们称之为螺旋模型,因为在获得最终系统之前必须进行多轮循环,希望经过多次循环后最终能趋近于一个完整的系统。
螺旋模型的优点如下:

优点:
- 风险评估有助于减少开发问题,因为你在实际实现系统之前就试图预见所有风险。
- 规划和客户评估阶段有助于产品更好地满足客户期望,因为在进入下一轮循环之前,你总会征求客户反馈。
- 迭代和增量的规划、工程和评估有助于项目管理。
缺点:
- 需要风险评估专家来识别项目中的所有风险。
- 在获得最终系统之前,需要对各个阶段进行更详细的阐述。
- 它更适合内部开发而非合同开发,因为如果是内部系统,你确切知道需求是什么,也更容易预见所有风险。
阶段发布流程 📦

接下来介绍的流程是阶段发布流程。由于项目中很可能发生变更,因此我们必须为此做好计划。在阶段发布中,我们尝试一部分一部分地发布系统。通过不时发布系统的不同构建版本,然后进一步改进构建版本一,得到构建版本二,构建版本二完成后,再发布给用户,依此类推。因此,发布版本是并行开发和使用的。
请注意,开发可以是增量式的,即从系统的一部分开始,然后构建另一部分,直到获得所有功能。也可以是迭代式的,即尝试构建完整系统,但使系统变得越来越复杂。许多组织同时使用迭代和增量方法的组合。

以下是阶段发布流程的优缺点:
优点:
- 降低了项目失败的风险,因为每次只专注于系统的一部分。
- 促进了系统模块化。
- 允许频繁发布。
- 允许将适当的专业知识应用于系统的特定部分。
- 允许早期培训和用户反馈。

缺点:
- 系统各部分需要相对较小。
- 可能难以识别所有部分所需的公共设施。我们假设各部分相互独立,但实际上很可能存在一些共享或所有部分都需要的公共设施,因此需要识别出这部分,因为它是关键部分,将被所有其他部分共享。
总结 📝

本节课我们一起学习了多种软件开发流程。我们从线性的瀑布模型开始,了解了其规范性和适用场景。接着探讨了迭代和增量式开发,包括应避免的“边做边改”模型、适用于模糊需求的原型法、强调风险管理的螺旋模型,以及通过分阶段发布来管理变更和风险的阶段发布流程。理解这些流程的优缺点至关重要,它能帮助我们在实际项目中,根据需求明确度、变更可能性、风险水平等因素,选择最合适的开发方法,从而提高软件项目的成功率。
025:敏捷开发 🚀
在本节课中,我们将要学习敏捷开发方法。我们将介绍敏捷的核心思想,并详细探讨三种具体的敏捷过程:极限编程、持续集成和Scrum。通过学习,你将理解敏捷开发如何通过强调个体协作、可工作软件和快速响应变化来提升软件开发效率。
敏捷宣言与核心思想
上一节我们介绍了传统的软件开发模型。本节中我们来看看一种更灵活的方法——敏捷开发。
敏捷开发的核心思想体现在“敏捷宣言”中。它强调我们应更关注左侧项目的价值,而相对减少对右侧项目的关注。
例如,我们更关注:
- 个体和互动 而非 流程和工具
- 可工作的软件 而非 详尽的文档
- 客户合作 而非 合同谈判
- 响应变化 而非 遵循计划
这意味着,相比于花费大量时间制定文档或计划,我们更侧重于实际的软件实现。这就是我们在谈论敏捷过程时所关注的焦点。
但这并不意味着右侧的项目没有价值,只是我们的关注重点有所不同。
常见的敏捷实践
在深入具体过程之前,我们先了解一些被多种敏捷方法采用的通用实践。以下是几种常见的敏捷实践:

- 计划扑克:一种用于估算项目所需资源、人力、时间和进度的协作式估算技术。
- 结对编程:两名开发者共同在一台计算机上工作,一人编写代码(驾驶员),另一人实时审查每一行代码(领航员)。公式可以表示为:
代码质量 ≈ 驾驶员实现 + 领航员审查。这样做是为了通过他人的双重检查来避免错误。 - 测试驱动开发:在编写实现代码之前,先编写该功能的测试用例。其流程可概括为:红(测试失败) -> 绿(测试通过) -> 重构(优化代码)。
极限编程
首先,我们将要介绍的是极限编程。这是一种将敏捷价值观贯彻到具体实践中的开发方法。
在极限编程中,流程通常如下:
- 需求收集与分析:开发者确定系统所需的功能。
- 估算:为每个功能提供时间和成本估算。
- 迭代规划:客户选择在特定开发迭代中要包含的功能。
- 任务分解:开发者将每个迭代分解为多个任务。
- 任务分配:不同的人和团队负责不同的任务。
- 测试先行:针对每个任务,开发者先设计测试用例(测试驱动开发)。
- 结对实现:使用结对编程的方式实现任务。
- 集成:最后,将所有任务集成在一起,形成一个可用的产品。
持续集成
接下来,我们探讨持续集成。这是一种强调频繁集成代码的敏捷软件开发实践。
持续集成是指开发者每天多次将代码集成到共享仓库中。这意味着开发者完成一部分工作后,会立即将代码集成到主分支。
我们为什么要这样做?因为只集成一小部分代码到仓库时,我们为集成和测试所需付出的努力是最小的。因此,我们希望快速执行集成和测试,以便立即发现任何错误或集成问题。
以下是使用持续集成的优势:
- 早期集成与测试:允许开发者尽早集成和测试代码。
- 快速发现缺陷:快速发现并暴露可能破坏构建的错误。
- 减少集成冲突:通过持续集成,减少大规模集成时的冲突。
- 可视化进度:帮助开发者检查开发进度,因为所有工作都体现在共享仓库中。
- 自动化构建与测试:使用CI服务器上的脚本自动完成构建和测试。

其架构通常如下:
- 开发者将更改提交到版本控制仓库。
- CI服务器从版本控制仓库拉取最新代码。
- CI服务器尝试构建和测试最新版本。
- 如果最新版本存在任何问题,会将反馈发送给开发者。
需要注意的是,这需要一个CI服务器来定期编译代码并向开发者报告结果,同时需要在服务器上准备所有脚本,以自动完成构建和测试。
Scrum框架
现在,我们来看看在业界非常流行的Scrum框架。Scrum是一个敏捷软件开发过程,它主要规定了开发软件产品应该做什么,但没有规定具体的软件工程实践,团队需要自行决定如何工作。
在Scrum中,需求以产品待办事项列表的形式捕获,这是一个软件系统所需功能的列表。产品负责人(客户)为这些条目设定优先级。
软件产品通过一系列称为“冲刺”的迭代来开发。团队自组织地决定交付产品的最佳方式。
核心概念与流程
以下是Scrum中的核心概念:
- 产品待办列表:包含系统所有所需功能的列表。
- 冲刺:一个时间固定的迭代周期,通常为2到4周。团队专注于完成该冲刺内选定的功能。
- 冲刺规划:从产品待办列表中选取一部分功能,放入一个冲刺中。
- 不变的需求:在一个冲刺期间,需求不允许变更。
角色、会议与工件
Scrum定义了特定的角色、会议和工件来支持开发:
- 角色:
- 产品负责人:代表客户或关键利益相关者,负责定义和排序产品需求,根据需要调整每次迭代的需求和优先级,决定发布日期和内容,并验收工作成果。
- Scrum主管:通常是项目经理,负责推行Scrum价值观和实践,确保团队功能完整、高效生产,促进各角色和职能之间的紧密合作,并移除进度障碍,保护团队免受外部干扰。
- 会议:
- 冲刺规划会:决定在接下来的冲刺中要开发哪些功能。
- 每日站会:一个简短的会议,团队成员通常回答三个问题:昨天做了什么?今天计划做什么?有什么障碍吗?这有助于跟踪开发进度。
- 工件:
- 产品待办列表:记录所有需求的文档。
- 冲刺待办列表:从产品待办列表中选出、计划在当前冲刺中完成的功能子集。
- 燃尽图:用于跟踪开发进度的图表,显示剩余工作量随时间减少的趋势。

燃尽图
燃尽图是Scrum中至关重要的进度跟踪工具。它按日测量给定冲刺或发布中剩余的工作量。
在图表中,你可以看到剩余工作量每天上下波动,但总体趋势是向零下降。通过燃尽图,团队可以快速计算图表斜率,即“燃尽速度”,也就是每天的平均生产率。这使得团队能够预测冲刺或发布的预计完成日期,并与计划进行对比,从而早期判断项目是否在正轨上。
敏捷方法的优缺点
最后,我们来总结一下敏捷方法的优点和缺点。
优点:
- 适应变化:开发能适应不断变化的需求。
- 即时反馈:客户或用户可提供即时反馈。
- 快速上市:能够实现快速推向市场。
- 缺陷更少:最终产品缺陷更少,因为每个冲刺内的缺陷都应在进入下一冲刺前被清理。
缺点:
- 要求高参与度:需要用户积极参与和紧密协作。
- 文档可能不足:通常缺乏详尽的文档。
- 可能存在范围蔓延:随着用户不断提出新需求,项目范围可能不断扩大。
- 站会负担:每日站会可能对团队造成负担。

本节课中我们一起学习了敏捷开发的核心思想,并深入了解了三种具体的敏捷实践:极限编程、持续集成和Scrum。我们看到了敏捷如何通过迭代、协作和快速反馈来应对软件开发的复杂性。理解这些框架的优缺点,能帮助你在实际项目中做出更合适的选择。
软件工程:P26:统一过程 🧩
在本节课中,我们将学习如何将之前介绍的各种软件开发过程整合到一个通用框架中,即统一过程。我们将了解其核心活动、阶段划分,以及它如何融合其他过程的优点。
上一节我们介绍了多种软件开发过程模型。本节中,我们来看看如何根据项目需求选择合适的过程。

如果你需要形式化和纪律性,可以选择瀑布模型或螺旋模型。
如果你需要关注点分离和模块化,可以选择瀑布模型、螺旋模型或分阶段发布模型。
如果你需要抽象性和通用性,可以选择瀑布模型或螺旋模型。
如果你需要一个能预见变化的过程,可以选择螺旋模型、分阶段发布模型或敏捷模型。
如果你希望进行增量式开发,可以选择原型模型、螺旋模型、分阶段发布模型或敏捷模型。
如果你希望在开发中进行风险评估,可以选择螺旋模型。
基于以上选择标准,我们引入一个通用框架。这就是所谓的统一过程。它是一个可用于软件开发的通用框架。无论你最终选择哪种具体过程进行开发,基本上都需要执行以下核心活动。
以下是软件开发必须执行的核心活动:
- 捕获需求
- 进行分析
- 完成设计
- 实现系统
- 测试系统
在项目内部,还需要执行软件质量保证以确保最终软件的质量,并进行项目管理。
此外,项目内部会经历不同的阶段:
- 初始阶段:收集所有需求,并再次确认需求是否可行。如果可行则继续,否则可能终止项目。
- 细化阶段:制定用于实现的设计方案。
- 构建阶段:实现系统。
- 移交阶段:部署并维护系统。
这些就是在一个软件项目中通常需要完成的工作。

因此,我们说统一过程从先前各种过程中选取了最佳实践,为软件开发提供了一个通用的过程框架。它定义了一组在软件开发中必须执行的活动,并定义了一个可以在项目中遵循的模型。

请注意,统一过程中的每次迭代都会产出一个可工作的产品,而每个增量都会建立一个系统基线。

在接下来的课程中,我们将反复回到统一过程的这张示意图。因为我们将详细讨论不同的活动,例如分析、设计、实现和测试。
总结

本节课中我们一起学习了:
- 一个软件开发过程需要同时考虑管理和工程问题。
- 一个软件开发过程需要考虑组织、项目和人员等特性。
- 统一过程融合了先前各种软件开发过程的最佳实践。
- 它提供了一个通用框架,可用于讨论软件开发活动。
027:防御性编程 🛡️
在本节课中,我们将学习软件实现阶段的核心概念,重点是如何在编码时避免错误。我们将探讨防御性编程、代码审查和重构等实践方法,以保护并提升代码质量。


实现概述
上一节我们介绍了实现阶段的目标。本节中,我们来看看实现的具体活动和关注点。
实现主要关注构建,即将设计转化为可执行的代码。实现工作流以模块和子系统为单位来构建系统。
- 模块 是系统中可物理替换的部分,它封装了实现,遵循并提供一组接口。例如:源代码、二进制文件、脚本或可执行文件。
- 子系统 将模块组织成更易管理的部分。
在实现过程中,我们需要为每个类生成源代码,选择并编写合适的算法来实现方法,将类分配给不同的模块,并通过编译和链接将模块与子系统集成为可执行文件。此外,还需要进行版本控制,制定集成计划,并在分布式系统中将可执行模块部署到不同的处理节点上。
防御性编程

上一节我们介绍了实现的基本流程。本节中,我们来看看如何通过防御性编程来避免编码错误。
防御性编程的核心原则是:时刻保护自己,不信任任何人(包括用户),并双重检查所有来自外部源的数据值。

以下是防御性编程的关键实践:
- 检查所有外部输入:必须双重检查来自用户的所有输入(如通过图形界面、命令行等),然后再将其传入系统内部类。
- 使用屏障类:通过屏障类在数据进入内部类之前进行清理,确保内部类使用的所有数据都是干净且可信的。
- 善用断言:断言是插入程序中的逻辑公式,用于在运行时检查特定条件是否成立。
断言的使用
断言是防御性编程的重要工具。它们用于检查程序执行过程中的前置条件和后置条件。
- 前置条件:在代码执行前插入的断言。
- 后置条件:在代码执行后插入的断言。
我们可以通过两种推理方式来建立断言:
-
正向推理:已知代码执行前的条件(前置条件),推导执行后必须成立的条件(后置条件)。
# 示例:已知 x 是偶数 # 执行以下代码后... x = 5 y = 2 * x # 后置条件:y 是偶数 (因为 y = 10) -
反向推理:已知代码执行后的目标(后置条件),推导执行前必须满足的条件(前置条件)。这种方法在测试和调试中尤其有用,因为它能帮助你理解为了产生特定结果或错误,输入需要满足什么条件。
断言常用于检查以下容易出错的地方:
- 输入/输出参数
- 文件或流是否已打开/关闭
- 只读输入变量的值
- 指针是否为空
- 传入例程的数组或容器是否至少包含 X 个元素
- 表是否已初始化,或容器是否为空
使用断言的指导原则:
- 用断言来文档化和验证前置条件与后置条件。
- 用断言检查那些“绝不应该发生”的情况。
- 避免在断言中放入可执行代码。

以下是一个 C# 中断言的示例,用于检查分母不为零:
Debug.Assert(denominator != 0, "Denominator cannot be zero.");
总结

本节课中,我们一起学习了软件实现阶段的主要活动,并重点掌握了防御性编程的理念与实践。我们了解到,通过不信任外部输入、使用屏障类清理数据以及善用断言来检查关键条件,可以有效地在编码阶段避免许多常见错误,从而构建出更健壮、可靠的软件系统。
软件工程:P28:代码审查 🔍

在本节课中,我们将要学习一种在编码过程中避免错误的重要实践:代码审查。这是一种通过他人检查代码来提升程序质量和稳定性的协作方法。
什么是代码审查?🤔
上一节我们介绍了结对编程,代码审查可以看作是结对编程的离线版本。这意味着在你完成部分程序后,需要向另一位开发者展示和解释你的源代码。在代码审查中,我们尝试评审其他开发者编写的代码。这是行业中非常普遍的实践,但其目的并非挑刺,而是通过协作让程序变得更好、更稳定。
自愿进行审查非常重要,否则你可能会被迫去审查其他开发者写得很糟糕的代码。其动机在于,通过让另一位开发者双重检查你的源代码,我们可以尽早发现大多数缺陷和错误。同时,这也能确保系统中的每一段代码都被不止一个人看过,迫使代码提交者在将内容加入程序前三思,并允许初级程序员向更有经验的开发者学习。
责任由代码提交者和审查者共同承担。再次强调,目的不是绩效评估,而是一种旨在让程序更好、更稳定的协作。
审查什么?谁参与?在哪里进行?📋



现在,我们来看看代码审查的具体实施细节。以下是关于审查内容、参与者和地点的要点:

- 审查内容:可以审查文档、一个连贯的模块或一段已完成的独立代码。
- 参与者:可以是另一位开发者,也可以是一个开发者小组。
- 地点:通常在你自己的电脑前进行,你需要向他人演示你的源代码。
审查时关注什么?🎯
了解了基本框架后,我们来看看在执行代码审查时应该关注哪些方面。以下是一些常见的关注点:
- 易错代码:指那些你反复出错的地方,我们可以重点审查这些部分。
- 先前发现的程序缺陷类型。
- 与安全相关的问题。
- 基于清单,即根据某些编码标准进行检查。
如何执行审查?🗣️

最后,我们来探讨如何具体执行一次代码审查。基本上,你需要以演示和陈述的形式向他人展示你的源代码。


你必须展示你的代码,并逐步讲解代码的执行逻辑,向另一位开发者解释你的程序中发生了什么。因此,我说这本质上是一种演示和陈述。

总结 📝

本节课中,我们一起学习了代码审查。我们了解到,代码审查是一种通过开发者间相互检查代码来早期发现错误、提升代码质量和团队知识的协作实践。它审查的内容包括代码、文档和模块,需要提交者进行演示和讲解,其核心精神是协作改进,而非个人评估。掌握并实践有效的代码审查,是成为一名优秀软件工程师的重要一环。
029:重构 🛠️
在本节课中,我们将要学习软件工程中的一个重要实践——重构。重构是在不改变软件外部行为的前提下,改善其内部结构的过程。我们将探讨为何需要进行重构、重构的不同层次以及如何在实际项目中应用重构。
概述



重构旨在提升代码质量,使其更易于理解、维护和扩展。即使代码当前功能正常,其结构也可能随着时间推移而恶化。通过重构,我们可以确保代码不仅能够正确执行功能,还能适应未来的变化,并且对其他开发者友好。

为何需要重构?🤔
代码会随着时间推移而腐化。当程序员向程序中逐步添加新功能时,可能会引入临时的解决方案和变通方法。久而久之,代码的结构就会变得混乱和难以维护。
因此,为了改善程序的整体结构,我们必须定期进行重构。

低层次重构

低层次重构主要关注代码的微观层面,例如重命名和代码组织。以下是常见的低层次重构操作:
- 重命名操作和变量:使用有意义的名称代替模糊的名称(如
function1,x,y)。例如,将常量x = 240重命名为MAX_ENROLLMENT = 240。 - 提取方法:将一段代码提取成一个独立的方法,以提高可读性和复用性。
- 提取模块或操作:将通用的功能提取到独立的模块或操作中。
- 修改操作签名:优化方法或函数的参数列表,使其更清晰。
- 拆分操作:将一个庞大的操作拆分成几个更小、内聚性更强的操作。
- 组织相关语句:将语义上相关的代码语句在物理位置上放在一起。
许多集成开发环境(IDE)都支持上述类型的自动化重构。



高层次重构
上一节我们介绍了低层次重构,本节中我们来看看更重要的高层次重构。高层次重构关注改善程序的宏观架构,通常需要手动进行,且对提升代码可维护性至关重要。
以下是高层次重构的关键实践:
- 替换晦涩的语言习惯:使用更安全、更清晰的替代方案。例如,避免将整个
if语句写在一行内,即使语法允许。 - 使用注释澄清:对随着时间演变或含义不清的代码段添加清晰的注释。
- 应用设计模式:根据特定的设计模式来重构代码结构(我们将在后续课程中介绍设计模式)。



重构实战:图书馆系统示例 📚
现在,让我们通过一个图书馆管理系统的例子来看如何应用重构。假设我们有一个 Main 类,它包含了几乎所有系统功能,这形成了一个典型的“上帝类”(God Class)。
“上帝类”是指一个试图完成系统中所有事情的庞大类,它会导致代码难以阅读和维护。
为了改善结构,我们可以按以下步骤进行重构:

- 识别并分类相关属性和操作:从类图中找出功能上相关的操作。例如,所有与“物品”(Item)相关的操作(如借出、归还、添加、删除、查找物品)应该归为一类;所有与“目录”(Catalog)相关的操作(如打印、排序、搜索、列出目录)应该归为另一类。
- 为功能集合找到“自然归属”并迁移:将识别出的操作迁移到它们逻辑上所属的类中。将与
Item相关的操作移到Item类,将与Catalog相关的操作移到Catalog类。 - 移除瞬态关联:用类型化的属性和操作参数替换不必要的直接关联。例如,
Main类通过Catalog类来访问Item,因此可以移除Main类与Item类之间的直接关联。
经过以上重构,我们得到了一个更清晰、更易于理解的类图,其中职责分配明确,结构得到了显著改善。
何时以及如何进行重构?⏰
假设你需要向一个设计不佳但当前运行正常的代码中添加新功能,正确的做法是:
- 假设你有足够的时间把事情做对。
- 编写单元测试来验证现有代码的正确性。
- 重构代码,改善其结构。
- 添加新的功能。
你可能会争论说项目时间有限,没空重构。许多开发者和项目经理也不喜欢重构,因为他们认为这是在浪费时间,且可能引入风险。



然而,进行重构至关重要,因为:
- 结构良好的代码更有利于快速开发。
- 完成重构能提升开发者的士气,因为大家都更愿意在“干净的房子”(结构良好的程序)里工作。

我们应该将重构作为开发过程的一部分持续进行(开发 -> 重构 -> 开发 -> 重构)。在项目后期进行大规模重构会非常困难,因为系统功能已经很多,改动一处可能影响其他部分。
总结

本节课中我们一起学习了重构的概念与实践。我们了解到,重构是通过改善代码内部结构来提升软件可维护性、可读性和可扩展性的关键活动。我们区分了低层次重构(如重命名、提取方法)和高层次重构(如应用设计模式、改善架构),并通过一个图书馆系统的例子演示了重构的实际步骤。最后,我们强调了将重构作为持续开发过程一部分的重要性,以确保代码质量随着项目进展而不断提升。
030:调试与配置管理 🐛
在本节课中,我们将要学习软件实现过程中的两个重要环节:调试与配置管理。调试是发现并修复程序中错误的过程,而配置管理则涉及对软件变更的系统性控制。我们将首先聚焦于调试,探讨如何高效地定位和修复程序中的缺陷。
概述
调试是软件开发中耗时且令人头疼的环节。为了减少调试工作,我们可以采取多种策略:首先,通过防御性编程和充分测试来尽量避免引入错误;其次,当错误确实出现时,需要系统性地进行调试以定位问题根源。请注意,测试(寻找问题是否存在)与调试(定位问题并找出原因)是两个不同的概念。
“Bug”(程序错误)一词的起源,可以追溯到1947年9月9日,当时在哈佛马克二型计算机的一个继电器中发现了一只飞蛾,导致系统故障。这成为了第一个被记录的计算机“Bug”。


防御Bug的策略
既然调试如此痛苦,我们应优先考虑如何防御Bug。主要策略有以下几种,按优先级从高到低排列:
- 通过设计使错误不可能发生。
- 尽量不引入缺陷。
- 使用断言等方法使错误立即可见。
- 进行调试。


上一节我们概述了防御策略,接下来我们详细看看前两种策略。
1. 通过设计使错误不可能发生
选择特定的编程语言、协议或数据结构,可以从根源上杜绝某类错误。
- 使用Java:可以避免内存覆写错误。
- 使用TCP协议:保证数据包按序到达,避免乱序问题。
- 使用Java的
BigInteger:避免整数溢出。 - 制定团队规范:例如,禁止使用递归,可以避免递归相关的错误;使用不可变数据结构,可以保证行为的一致性。


关键点:一旦选择了某种策略,就必须在整个项目中严格遵守相应的规范。

2. 尽量不引入缺陷

核心思想是“第一次就做对”。这要求我们在编码前充分思考。
- 先思考,后编码:不要盲目复制代码。
- 警惕“简单错误”:如果程序中存在大量容易发现的错误,通常也意味着存在难以发现的深层错误。
- 不要依赖编译器:编译器只能发现语法错误,无法发现语义或逻辑错误。
- 追求简单性:这是应对复杂性的关键。在学期初我们提到,模块化是管理大型软件系统复杂性的核心方法。


通过模块化,我们将程序分解为多个易于理解的小块,每次只处理一块。同时,应使用具有明确定义接口的抽象数据类型,并辅以防御性编程。
当Bug出现时:调试策略
尽管我们尽力预防,Bug仍会出现。本节中,我们来看看当Bug出现时,如何进行有效的调试。

利用模块化定位Bug

模块化的优势在调试时得以体现。目标是将Bug定位到程序的一小部分。
- “二分法”定位:
- 从完整程序开始,逐步移除模块,直到Bug消失。最后被移除的模块很可能包含Bug。
- 或者,从空程序开始,逐步添加模块,直到Bug出现。最后添加的模块很可能包含Bug。
- 利用模块化推理:逐步跟踪程序执行,查看中间结果,理解程序内部状态。






- 二分搜索加速:对于一个大型程序,可以将其分成两半。先测试其中一半是否存在错误。如果错误存在于这一半,则继续对这一半进行二分;否则,检查另一半。这能快速缩小问题范围。




充分利用断言
断言是防御性编程和调试的利器。它用于在代码中检查必须满足的条件,一旦条件为假,程序会立即报错。
考虑以下在数组A中查找值K的代码:

示例1(有问题):
int i = 0;
while (true) {
if (A[i] == K) {
break;
}
i++;
}
问题:如果K不在数组中,循环将永不终止(无限循环)。
示例2(改进):
int i = 0;
while (i < A.size()) {
if (A[i] == K) {
break;
}
i++;
}
改进:循环总会终止。
新问题:如果后续代码依赖“K一定在数组中”这个条件,而K实际上不在,则可能引发其他错误。
示例3(使用断言):
int i = 0;
while (i < A.size()) {
if (A[i] == K) {
break;
}
i++;
}
assert (i < A.size()) : “K was not found in array A”;
优势:通过断言,我们明确检查并声明了“K必须在数组中”这一条件。如果条件不满足,程序会立即停止并给出错误信息,便于快速定位问题。


断言的作用:记录并检查程序中的不变量,使错误在发生时立刻显现。


调试思维与常见错误
调试时需要有正确的思维方式。Bug往往不在你认为的地方。
- 自我提问:问自己“Bug可能在哪里?”,更要问“Bug不可能在哪里?”,并解释原因。然后忽略那些不可能的地方。
- 检查低级错误:
- 参数顺序传反。
- 标识符拼写错误。
- 区分对象相等性:
a == b:检查两个对象引用是否指向同一个内存地址。a.equals(b):检查两个对象的内容是否相等。
- 变量未初始化。
- 深拷贝与浅拷贝:是复制了对象地址,还是复制了对象内容?
- 修复后重新编译:修复Bug后,记得重新编译所有相关文件,再链接生成可执行文件。



在代码中插入检查点
在程序中插入大量检查(如前置条件检查、一致性检查、针对特定Bug的检查),目标是跟踪程序执行,并在尽可能接近Bug的地方让程序停止。

一个重要问题:生产代码中应该保留断言吗?
答案是:视情况而定。
- 保留:如果你希望程序在发生任何错误时立即停止,避免产生更严重的后果(如数据损坏),则应保留。
- 移除:如果错误后果不严重,且你希望程序继续运行(例如某些游戏场景),则可以考虑移除断言以提高性能。


回归测试
每当发现并修复一个Bug后,必须将能重现该Bug的输入保存下来,作为系统的回归测试用例,并重新运行所有测试。
为什么要做回归测试?
- 确保修复不引入新问题:我们常常在修复新Bug时,不小心重新引入了旧的Bug。
- 积累测试用例集:帮助构建一个日益完善的测试套件。
- 防止Bug复发:出现过的Bug,未来很可能再次出现。


因此,应尽可能频繁地运行回归测试,并自动化这一过程。同时,保持测试集的简洁,避免不必要的测试。
在工业界,代码缺陷率大约为每1000行代码10个Bug。那些无法立即定位的Bug,通常会在集成测试时被发现,或者由用户报告。
收到用户报告后的标准流程:
- 澄清问题症状。
- 找到并理解问题根源。
- 创建测试用例。
- 修复Bug。
- 运行回归测试。

调试陷入困境时
当调试变得异常困难时,可以尝试以下方法:

- 重新审视所有假设:操作系统变了吗?硬盘空间足够吗?
- 调试代码,而非注释:注释可能已经过时。
- 开始写文档:通过梳理和记录系统流程,你可能会对系统有新的认识,从而发现Bug。
- 寻求帮助:向队友或更有经验的程序员请教。
- 暂时离开:休息一下,清空大脑。通常,在精神饱满的清晨解决问题,比在深夜苦苦挣扎更有效。



总结



本节课中,我们一起学习了软件调试的核心思想与实用技巧。我们首先了解了防御Bug的几种策略,优先级最高的是通过设计和规范避免错误。当Bug出现时,我们应利用模块化设计快速定位问题,并善用断言等工具。调试需要系统性的思维,要注意常见陷阱。修复Bug后,务必进行回归测试。最后,当调试遇到瓶颈时,不妨换个角度思考或寻求帮助。掌握这些方法,将能显著提升你解决软件问题的效率。
031:配置管理 📦
在本节课中,我们将要学习软件工程中的一个重要实践——配置管理。配置管理旨在系统地管理、控制和监控软件生命周期中所有产物的变更,确保项目的有序进行和系统的稳定性。
概述
配置管理意味着我们尝试管理、控制并监控对生命周期产物的变更。这里的“产物”不仅指源代码,还包括文档、规格说明甚至用户手册。我们试图跟踪这些项目上的所有变化,这服务于变更管理。
什么是配置管理?🔍

配置管理主要包含以下几个核心活动:
- 变更管理:跟踪并评估由开发人员或客户提出的软件变更建议。
- 版本管理:跟踪并管理系统组件的多个版本。
- 系统构建:从代码库中的组件和数据库中构建可执行的系统。
- 发布管理:准备并跟踪发布给客户的系统版本。
在配置管理中,我们试图保留所有的变更记录。变更管理确保系统演进是一个受控的过程,我们会优先处理最紧急且最具成本效益的变更。
那么,我们想要控制哪些变更呢?它可能包括计划、文档、规格说明、流程、程序源代码、手册甚至数据。在一个项目中,我们希望捕获并跟踪所有这些项目上的变更。

配置项与软件库 📚
我们定义一个配置项为需要控制其变更的产物。
为了支持变更管理,我们需要一个软件库。软件库提供了存储、标记、识别版本以及跟踪配置项状态的设施。
以下是一个软件库的示例结构:

- 开发者工作区:供单个开发者用于日常开发。
- 主目录:包含相对稳定的版本,供其他开发者在此基础上工作。
- 软件仓库:包含非常稳定的系统版本,用于发布给用户。


基线 📍
一个基线是软件开发中的一个时间点或阶段,在此之后任何变更都必须经过正式流程。这意味着我们在某个特定时间点“冻结”系统。当我们实现了一些重要功能且系统趋于稳定时,我们就可以在某个时间点冻结系统,并声明我们已经达到了一个基线。
再次强调,我们讨论的不仅仅是源代码。在一个基线中,可能包含文档、源代码、一组测试用例甚至用户手册。基线定义了在特定时间点的一个具体系统。


一个配置项要成为基线的一部分,必须首先通过一系列正式的评审和测试,并且通常是在一个项目里程碑处。这意味着我们必须进行测试以确保系统是稳定的。在实现了重要功能并达到项目里程碑后,我们就可以冻结系统,声明我们有了一个基线。


然后,我们可以从软件库中检出配置项。任何修改后的配置项在替换原始项之前,必须再次经过正式的评审流程。这也正是需要版本管理的原因——我们必须跟踪软件库中配置项的不同版本。
变更流程 🔄
当项目中发生变更时(例如客户要求修改某些内容),在实施变更之前需要完成以下步骤:


以下是变更流程的步骤:
- 提交变更请求:由用户或开发人员提交。
- 评估变更:变更控制机构评估变更的成本和影响,并决定是否批准,如果批准则发布变更指令。通常由管理人员决定变更是否可行。
- 检出与修改:检出配置项,进行修改,并在软件质量保证(例如,经过测试团队测试)后重新检入。
- 版本控制:对修改后的项进行版本控制。
- 发布与审计:使配置项可供使用(例如,用于升级或发布),并应进行审计和状态报告。

审计确保变更已被正确实施,通常由软件质量保证团队或测试团队执行。状态报告机制则让所有相关方了解变更的状态,并允许管理层或用户确定谁在何时、为何做出了何种变更。
版本管理 📈
我们必须执行版本管理来跟踪软件库中特定配置项的所有版本。版本管理确保配置项的完整性和一致性。
以下是版本管理中需要跟踪的内容:

- 主线:源代码版本的一个序列,后续版本由早期版本衍生而来。
- 分支:如果进行并行开发,则会产生不同的分支。
- 变体:如果存在旨在共存的、针对不同环境的配置(例如,用于Windows和Linux的Oracle软件系统),则会产生变体。
下图是一个描述项目变更历史的演化图示例:


从图中可以看到不同的版本。当我们从一个版本到另一个版本时,有一条主线。如果进行并行开发,会产生不同的分支,最终这些分支会合并回主线。通过这个图,我们可以直观地看到配置项的演化过程。
本章总结 📝
在本节课中,我们一起学习了配置管理的核心概念。
首先,我们介绍了配置管理的定义及其包含的四大活动:变更管理、版本管理、系统构建和发布管理。
接着,我们探讨了配置项和软件库的概念,理解了基线在控制项目稳定性中的关键作用。
然后,我们详细分析了正式的变更控制流程,从请求提交到审计报告的完整步骤。
最后,我们学习了版本管理,包括如何通过主线、分支和变体来跟踪和管理软件的不同版本。

配置管理是确保软件项目在持续变更中保持可控、可追溯和高质量的重要基石。掌握这些实践,能帮助团队更高效地协作并交付可靠的软件产品。
032:测试基础
在本节课中,我们将要学习软件测试的基础知识。测试是软件开发中至关重要的环节,旨在发现软件系统中的缺陷和错误。我们将探讨测试的目的、基本测试用例类型、测试用例的生成方法,以及基于组件和系统的测试原则与策略。

上一节我们介绍了测试的总体目标,本节中我们来看看为什么测试如此重要。
为什么需要测试?


一些开发者可能认为测试主要在实现后进行,但事实并非如此。在测试驱动开发中,我们必须在实际编码之前就考虑所有可能的测试用例和测试计划。因此,测试可能在实现之前或实现过程中进行,而不仅仅是在实现之后。
以下是程序员可能不愿进行测试的几个常见原因:
- 希望快速完成项目,认为测试会拖慢进度。
- 对自己的代码能力过于自信,不怀疑代码的完美性。
- 认为测试是能力不足的程序员才需要做的事情。
- 相信自己的代码能正常工作,或盲目信任队友的代码。
然而,如果不进行充分测试,微小的错误也可能摧毁整个系统。


案例一:阿丽亚娜5型火箭
火箭在发射37秒后爆炸,原因是系统试图将一个64位浮点数转换为16位有符号整数时发生异常,程序崩溃导致火箭爆炸,总损失超过10亿美元。
案例二:温哥华证券交易所系统
1992年,新系统启动时将指数基准设为1000。由于更新值时采用了截断而非四舍五入,22个月后指数跌至520,而正确的计算值应为1098点。软件测试不足在美国每年造成高达600亿美元的损失。
如果你的软件值得编写,那么它也值得被测试,以确保其正确性。

测试的定义与目标
在测试中,我们试图发现软件系统中预期行为与实际观察到的行为之间的差异。测试主要涉及两大任务:

- 验证:确保我们根据需求文档构建了“正确的产品”。
- 确认:确保我们“正确地构建了产品”,即检查实现质量,保证所有功能正确运行且没有缺陷。
大多数测试工作都集中在确认上,即发现软件系统中的错误。我们的目标是设计一套测试用例,能够系统地发现软件缺陷。
重要原则:测试无法证明软件没有错误。对于一个非平凡的系统,由于存在太多可能的输入和状态,进行穷尽测试是不现实的。测试是一项“破坏性”活动,我们试图让软件失败。同时,软件工程师通常难以有效地测试自己编写的软件,因为人们往往对自己的错误视而不见。

测试计划与策略
既然无法进行穷尽测试,我们如何制定测试计划?关键在于找到一小组能在最短时间内发现最多缺陷的测试用例。这是因为测试通常在实现后进行,而项目有严格的截止日期。
我们的目标是:用最少的时间和精力,找到最有可能发现缺陷的测试用例。

一个测试计划需要明确:
- 测试策略:执行哪些测试、如何执行、需要达到的代码覆盖率以及预期的通过标准。
- 测试进度:何时运行测试。
- 资源估算:测试所需的人力或系统资源。

核心问题是:如何找到一组输入,它小到可以快速完成,但又大到足以验证和确认系统?
测试用例生成:等价类划分
为了解决上述问题,我们采用的主要方法是等价类划分。其思想是将所有可能的输入值划分为若干个集合(等价类),使得同一集合中的值在软件中具有相似的行为。然后,我们从每个集合中选取一个代表值进行测试。
如何定义“相似行为”?有两种主要方法:
1. 执行等价
根据程序执行路径是否相同来划分输入。例如,对于一个函数:
if x < 0:
return -x
else:
return x
我们可以将输入划分为两个集合:{x | x < 0} 和 {x | x >= 0}。然后从每个集合选取一个值(如-3和3)进行测试。

2. 子域划分
根据输入是否会导致正确或错误的结果来划分。考虑一个有缺陷的代码,其条件误写为 if x <= -2。此时:
- 正确子域:
{x | x < -2 或 x >= 0}(返回正确结果)。 - 错误子域:
{x | -2 <= x < 0}(返回错误结果)。
如果我们仅使用执行等价选取的测试值(-3和3),两者都落在正确子域,将无法发现这个错误。而如果我们从错误子域(如-1)和正确子域(如-3)各选一个值,就能有效地发现缺陷。
因此,一个理想的测试集应包含来自不同子域(尤其是可能触发错误的子域)的代表值,并且这个集合应尽可能小。


为了进行有效的划分,我们需要结合程序依赖信息(如源代码)和程序独立信息(如文档、算法、数据结构等)。不同的启发式方法针对不同类型的错误,因此在实践中,我们通常组合使用多种方法。
总结


本节课中我们一起学习了软件测试的基础。我们明确了测试的目的是发现软件缺陷,并理解了验证与确认的区别。我们认识到穷尽测试的不现实性,因此测试策略的核心是通过等价类划分等方法,精心挑选一小部分具有高缺陷发现率的测试用例。我们介绍了基于执行路径的“执行等价”和基于输出正确性的“子域划分”两种生成测试用例的基本方法。记住,充分的测试是保证软件质量、避免灾难性后果的关键步骤。
软件工程:P33:设计测试

在本节课中,我们将学习软件测试设计中的核心概念,特别是如何运用启发式方法寻找子域来设计测试用例。我们将探讨白盒测试、黑盒测试和回归测试,并重点介绍白盒测试中的基本路径测试方法。
测试用例与基本步骤
测试用例是测试系统的一种具体方式。它规定了测试内容、测试条件、测试方法以及预期结果等。

运行一个测试用例的基本步骤如下:
- 选择用于测试的输入数据或软件配置。
- 定义预期输出。
- 使用输入运行程序或方法。
- 记录实际结果。
- 将实际结果与预期结果进行比较,判断测试是否通过。
如果实际结果与预期一致,则测试通过;否则,测试失败。

软件测试的三种类型

在软件系统上,我们可以执行三种主要类型的测试。
白盒测试
白盒测试也称为“小规模测试”。其目标是基于数据或控制结构来验证组件的内部逻辑。测试用例的设计依赖于对组件内部工作原理(即源代码)的了解。因此,进行白盒测试需要源代码。

黑盒测试



黑盒测试也称为“大规模测试”。其目标是基于输入和输出来验证组件的功能。测试用例的设计仅依赖于组件规定的功能,无需查看源代码。
回归测试


回归测试在我们讨论缺陷修复时已经涉及。其核心思想是,每当我们发现一个缺陷并找到能复现该缺陷的输入时,就将此输入保存为一个新的测试用例。因为一旦我们犯过一个错误,未来很可能再次犯同样的错误。因此,将能暴露缺陷的输入纳入回归测试集是一个好习惯。回归测试既可以使用白盒测试用例,也可以使用黑盒测试用例。


白盒测试的启发式方法
上一节我们介绍了测试的基本类型,本节我们将重点深入白盒测试。白盒测试的目标是寻找子域,以确保我们执行了程序中的所有独立路径、所有逻辑语句、所有循环以及检查了所有内部数据结构。

首先,我们将介绍基本路径测试,它专注于寻找能覆盖程序中所有独立路径的子域。
为何需要覆盖独立路径?


你可能会问,为什么我们需要费心研究程序中的独立路径?例如,如果我们只是执行程序中的每一条语句,这对于发现错误是否足够?
让我们来看一个例子。
if (A && B) {
// 执行语句块1
} else {
// 执行语句块2(可能包含错误,如除数为零)
}

如果我们让条件 A && B 为真,那么我们将执行程序中的所有语句(即语句块1)。但这足够吗?答案是否定的。

考虑另一种情况:如果我们让条件 A && B 为假,程序将执行 else 分支(语句块2)。如果该分支中存在错误(例如“除数为零”),那么仅测试真分支将无法发现这个错误。
因此,仅仅测试程序中的每一条语句是不够的,我们必须确保测试到条件语句的所有可能分支(即所有独立路径)。

总结

本节课我们一起学习了软件测试设计的基础。我们明确了测试用例的定义和运行步骤,区分了白盒测试、黑盒测试和回归测试三种类型及其适用场景。我们重点探讨了白盒测试中基本路径测试的重要性,并通过实例理解了为什么需要覆盖程序中的所有独立路径,而不仅仅是执行所有语句。掌握这些概念是设计有效测试用例、提升软件质量的关键第一步。
软件工程:P34:基本路径测试 🧪
在本节课中,我们将要学习一种名为基本路径测试的白盒测试技术。其核心目标是确保程序中的所有独立路径至少被执行一次。我们将通过绘制程序流图、计算圈复杂度,并基于此定义一组基本执行路径来指导测试用例的设计。

绘制程序流图 📊


上一节我们介绍了基本路径测试的目标,本节中我们来看看如何绘制程序流图。程序流图是一种用于表示程序控制流的图形化工具,它使用不同的符号来代表不同类型的语句。


以下是绘制流图时使用的符号:



- 顺序执行:使用一个节点表示,语句按顺序执行。
- 条件语句(if-else):使用一个谓词节点(菱形)表示,根据条件真假分支。
- 多分支语句(case/switch):使用一个谓词节点表示,有多个分支。
- do-while循环:先执行循环体,然后检查条件,条件为真则继续循环。
- do-until循环:先执行循环体,直到条件为真时退出循环。



现在,让我们为一个示例程序绘制流图。

首先,我们从语句1开始,这是一个do-while循环。如果条件为真,则继续执行语句2和语句3。语句3是一个条件语句。
如果语句3的条件为真,则执行语句4和5。如果为假,则执行语句6和7。



语句7是另一个条件语句。如果为真,则执行语句8;如果为假,则执行语句9和10。


最后,在完成if语句后,我们将它们合并到节点11。接着,合并语句4和5的节点。然后,为do-while循环添加另一个语句。最后,我们返回到do-while循环并再次检查条件。如果条件为真,则再次执行语句2、3等;如果为假,则结束程序并转到语句14。




获得流图后,下一步是通过合并那些顺序执行的语句来简化流图。


在这个例子中,我们可以看到无论何种情况,语句2和3总是一起执行,因此可以将它们合并为一个节点。同样,语句4和5、语句6和7、语句9和10、以及语句12和13也总是一起执行,因此可以分别将它们合并为单个节点。

以下是合并顺序语句为单个节点的一些规则:
- 从一个简单节点开始。简单节点是指只有一个出箭头的节点。
- 持续合并,直到遇到一个谓词节点(有多个出箭头的节点),此时将该谓词节点也包含进组中。
- 或者,持续合并,直到遇到一个共享节点(被多条路径共享的节点),此时不将该共享节点包含进组中。

例如,对于语句4和5,我们从简单节点4开始,合并节点5,然后停止,因为下一个节点是共享节点。对于语句9和10,我们从简单节点9开始,合并直到遇到共享节点11,然后停止,不包含节点11。

简化后,我们得到简化版的流图。同时,不要忘记映射表,因为它会告诉我们简化图中的每个节点对应原始程序中的哪些语句。例如,节点2对应原始程序的语句2和3。



计算圈复杂度 🔢
上一节我们得到了简化流图,本节中我们来看看如何计算圈复杂度。圈复杂度(记为 V(G) )是对源代码逻辑复杂性的量化度量。如果源代码逻辑复杂,圈复杂度就会更高,因为程序中存在许多独立路径。圈复杂度为需要测试的路径数量提供了一个上限。

计算圈复杂度有几种方法:

- 数区域法:计算流图中封闭区域的数量(包括外部区域)。
- 在示例流图中,我们可以数出有4个区域,因此 V(G) = 4。
- 边节点公式法:使用公式 V(G) = E - N + 2,其中E是边数,N是节点数。
- 在示例流图中,E=11,N=9,代入公式得 V(G) = 11 - 9 + 2 = 4。
- 谓词节点法:使用公式 V(G) = P + 1,其中P是谓词节点(有多个出箭头的节点)的数量。
- 在示例流图中,节点1、2、4是谓词节点,P=3,因此 V(G) = 3 + 1 = 4。



对于这个流图,圈复杂度 V(G) = 4。这意味着该流图中独立路径数量的上限是4。


确定基本路径集 🧭

上一节我们计算了圈复杂度,本节中我们基于它来确定一组基本线性独立路径。
独立路径的定义是:该路径引入了至少一组新的处理语句或一个新的条件。换句话说,它必须遍历流图中至少一条之前未被任何已定义路径遍历过的边。

基本集是流图中一组线性独立的路径集合。需要注意的是,基本集不是唯一的。同一段代码可能有多组不同的基本集。从基本集导出的测试用例能保证在测试期间每个语句至少被执行一次。

让我们看一个简单的例子。假设一个流图的圈复杂度是3。那么基本集中独立路径的数量最多为3,但实际可能少于3,因为圈复杂度只是一个上限。

在这个例子中,我们可能找到两组不同的基本集:
- 第一组包含路径A和路径B。
- 第二组包含路径A和路径C。
在进行测试时,我们可以选择使用第一组或第二组。
回到我们之前的复杂示例,由于圈复杂度是4,这意味着基本集中最多有4条独立路径。



以下是该流图中可能的独立路径:
- 1, 2, 3, 4, 5, 12, 13, 14
- 1, 2, 3, 6, 7, 8, 11, 12, 13, 14
- 1, 2, 3, 6, 7, 9, 10, 11, 12, 13, 14
- 1, 14
请注意,路径“1, 14”可能并不独立于其他路径,因为它包含的边可能已被其他路径覆盖。然而,在测试中仍然建议包含它。因为语句1是一个do-while循环,这条路径测试的是数组为空、没有记录的情况,此时程序应立即退出。这是一个重要的边界情况。

准备测试用例与总结 ✅


上一节我们确定了基本路径集,最后一步是根据这些路径准备测试用例,以强制程序执行基本集中的每条路径。
准备测试用例时需注意:
- 基本集引用的是程序中的语句编号。
- 需要确保覆盖所有谓词节点(即所有条件语句)。
- 基本路径测试应尽可能应用于所有关键组件。不必对程序中每个简单过程都进行,因为绘制流图耗时。应重点关注那些逻辑复杂、包含许多条件语句和循环的关键过程。


需要强调的是,基本路径测试并不测试代码中所有可能的路径组合,而是保证流图中的每条独立路径至少被执行一次。
本节课中我们一起学习了基本路径测试的完整流程:
- 绘制程序流图,并使用规则进行简化。
- 计算圈复杂度 V(G),它定义了独立路径数量的上限。
- 确定一组基本线性独立路径。
- 设计测试用例来执行这些路径,确保语句覆盖和重要的边界情况(如空数组)得到测试。


这种方法为测试复杂的逻辑代码提供了一种系统化的、可量化的手段。
035:白盒测试 🧪
在本节课中,我们将学习软件测试的三种类型:白盒测试、黑盒测试和灰盒测试。我们将从白盒测试开始,重点讲解条件测试、循环测试和数据流测试这三种具体方法。
上一节我们介绍了基本路径测试。本节中,我们来看看白盒测试中的其他几种重要测试方法。
条件测试
条件测试关注程序中的条件语句。之所以要专门测试条件语句,是因为这些地方很容易出错。条件测试的目标是执行组件中每个简单逻辑条件的真值和假值。



我们关注程序中的条件语句,例如简单条件、复合条件、关系表达式或布尔表达式。测试重点应放在这些容易因布尔运算符、布尔变量、关系运算符或算术表达式错误、缺失或多余而出错的地方。
如果程序中存在复合语句,例如 if (A && B),那么我们应该测试复合语句中的每一个简单条件。请注意,基本路径测试已经覆盖了这些情况。

现在,让我们看一个例子。


假设程序中有一个复合语句。例如,语句6是一个复合语句:if (A && B),则执行语句7,否则执行其他语句。


现在,我们想为这个程序推导出流图。我们需要将复合语句拆分为两个简单条件:6A(对应条件A)和6B(对应条件B)。
执行语句6时,首先检查条件6A。如果6A为真,则继续检查条件6B。如果6B也为真,那么整个复合语句为真,我们将执行语句7。


如果6A为假,则直接执行else语句。


如果6A为真但6B为假,复合语句为假,同样会进入else语句。

因此,如果程序中有复合条件,最终会在流图中形成一个三角形结构。我们可以使用基本路径测试来检查流图中的所有简单条件。复合语句中的所有简单条件都会被基本路径测试覆盖,因为我们把每个简单条件分开处理。
我们还会进行域测试。例如,程序中有一个条件 E1 > E2,比如 x > 4。我们需要用三组输入值来测试这个条件语句:使 E1 > E2 的值(如 x=6)、使 E1 = E2 的值(如 x=4)和使 E1 < E2 的值(如 x=2)。

循环测试
循环测试关注测试循环在其边界内外的执行情况。在循环中,你需要进行迭代。
以下是需要测试的循环情况:
- 循环执行0次或1次。
- 循环执行2次。
- 循环执行m次(m小于最大迭代次数n)。
- 循环执行n-1次、n次和n+1次。

这样做的目的是确保循环在最小迭代次数、最大迭代次数以及范围内的正常迭代次数下都能正常工作。
如果存在嵌套循环,我们需要进行以下操作:
- 首先,对最内层循环进行简单循环测试,同时将外层循环的迭代次数保持在最小值(例如0次或1次)。这样做的目的是专注于测试内层循环,同时让外层循环保持简单。
- 然后,由内向外进行测试。接下来,让内层循环以简单方式运行(0次或1次),然后对外层循环应用简单循环测试。
- 继续这个过程,直到所有循环都被测试完毕。

需要注意的是,测试工作量会随着循环嵌套层数的增加呈几何级数增长。
如果存在串联循环(即一个循环后面跟着另一个循环),且两个循环彼此独立,则可以分别对每个循环应用简单循环测试。如果它们相互依赖,则可以应用嵌套循环测试的方法,即测试一个循环时,将另一个循环的迭代次数保持在最小值。
如果程序中存在非结构化循环,我们不应该在程序中使用它们。如果存在,应该重构代码以改进源代码的结构。

数据流测试
白盒测试的另一种类型是数据流测试。在数据流测试中,我们试图确保变量在代码执行的某些点上具有正确的值。
什么是定义-使用链?例如,对于一个变量x,其定义-使用链是从定义x的地方开始,到使用或消费x的地方为止。


在数据流测试中,我们关注定义x之后又使用x的路径。程序中的每一条定义-使用链必须至少被覆盖一次。

对于每个变量,沿着从变量定义处到变量使用处的语句路径进行测试。
这些测试可以与基本路径测试结合进行。

总结

本节课中,我们一起学习了白盒测试中的三种具体方法:条件测试、循环测试和数据流测试。条件测试专注于验证程序中的条件语句;循环测试确保循环在各种迭代次数下正确运行;数据流测试则通过追踪变量的定义和使用路径来发现潜在错误。这些方法共同帮助我们更有效地发现程序中的缺陷。
软件工程:P36:黑盒测试 🧪

在本节课中,我们将要学习黑盒测试。这是一种在不查看源代码的情况下,通过向系统输入数据并检查其输出来验证系统功能的方法。我们将重点介绍等价类划分、边界值分析、线程测试和基于状态的测试等核心概念。

上一节我们介绍了黑盒测试的基本概念,本节中我们来看看如何进行等价类划分。
黑盒测试时,我们没有系统的源代码,只有可执行文件。我们的做法是向可执行文件输入一些数据,生成输出,然后核对输出是否与预期一致。之所以称为“黑盒”,是因为我们无法看到源代码,源代码被放在一个“黑盒子”里。

黑盒测试旨在发现系统中的错误,例如:功能错误或缺失、接口不兼容、数据结构或外部数据库错误、性能错误、初始化或终止错误。
为了达到合理的测试效果,一个黑盒测试用例应覆盖一系列输入或输出值,而不仅仅是单个值。这意味着它应能揭示一类错误的存在与否,而非仅针对单一错误。
我们将重点介绍等价类划分。该方法通过按类型对输入和输出进行分组来创建子域,以实现对一类错误的测试覆盖。我们尝试将组件的输入或输出数据划分为等价的数据分区。这些等价分区通常源自需求规格说明。然后,我们设计测试用例,目标是至少覆盖每个等价分区一次。
以下是划分等价类的启发式方法:尝试将所有输入划分为不同的子域,基于有效和无效的输入或输出。执行测试时,我们需要从每个子域中选择典型值。

- 如果输入是一个范围:例如,学生ID的有效范围是
0到9999。那么有效子域是[0, 9999]。无效子域是小于0(如负数)和大于9999(如10000)的值。 - 如果输入是一个特定值:例如,有效值是
100。那么有效子域就是{100}。任何小于100或大于100的值都属于无效子域。 - 如果输入是一组相关值:例如,星期几(周一至周日)。那么有效子域是
{Monday, Tuesday, ..., Sunday}。任何不在此集合中的值(如"Funday")都属于无效子域。 - 如果输入是布尔值:那么有效子域是
{true, false}。任何非布尔值(如"yes")都属于无效子域。
除了测试有效和无效子域,在进行黑盒测试时,我们还应该测试边界情况。这是因为错误更容易发生在子域的边界处,而非中心区域。


以下是进行边界测试的原因:你可能会犯“差一错误”、忘记处理空容器或空对象、出现算术溢出错误,或者程序未能处理对象的别名问题等。位于主域边界的小子域有更高的概率发现此类错误。因此,我们不仅要测试主域内的典型值,还应测试边界情况。

对于测试,我们需要选择主域边界上的值。

- 示例:绝对值函数:边界情况是
0。除了测试0,我们还应该考虑边界附近的值,例如-1和1。
以下是针对不同输入类型的边界测试指南:
- 值范围:例如,学生ID范围
[0, 9999]。- 下界:测试
-1,0,1。 - 上界:测试
9998,9999,10000。
- 下界:测试
- 离散值集合:测试最小值、最大值以及紧邻最小值和最大值的值。
- 值集合:如果可能,测试集合中的所有值。例如,星期几的集合很小,应测试所有可能输入。如果输入集合很大,可以测试部分有效输入,并测试一个不在集合中的值(如非星期几的值)。
- 布尔值:测试两个布尔值
true和false,以及一个非布尔值。 - 输出值:也应应用上述指南,在最小和最大预期输出值处创建测试。
- 数据结构边界:例如,数组索引范围
[1, 10]。应测试索引0,1,2以及9,10,11。我们关注最小索引、最大索引以及紧邻它们的索引。

让我们再次以学生选课系统(ASU)为例。其中一个可能的输入是学生ID。

假设学生ID的有效范围是 1000000 到 9999999。那么,我们如何根据不同的情况(边界情况、典型情况、特殊情况)设计测试用例呢?

- 边界情况:测试最小值
1000000、最大值9999999,以及紧邻它们的值999999和10000000。 - 典型值:测试一个有效且存在于数据库中的学生ID,例如
5000000。测试一个在有效范围内但数据库中不存在的学生ID,例如5000001。 - 范围外值:测试一个低于范围的值,例如
500000。测试一个高于范围的值,例如50000000。 - 特殊情况:测试学生输入非数字值的情况,例如
"5647ABC"。应测试此特殊值,观察系统如何处理。
对于每个测试用例,你需要为其命名,描述测试设置、输入值,以及如何验证测试是否通过。你需要为黑盒测试准备的所有输入都这样做。

在黑盒测试中,我们还可以进行线程测试。

线程测试是一种基于事件的方法,测试基于触发系统动作的事件。这对于面向对象系统尤为重要,因为在一个OO系统中,一个对象可能调用另一个对象来为其执行操作。线程测试通常在类经过单元测试并集成到子系统之后使用。
但由于通常存在过多的输入或输出组合,可能无法完成所有线程测试。因此,我们只关注最常执行的线程。你可以从用例规格说明中的基本事件流中提取所有重要的线程。

这是一个线程测试的示例:假设在你的系统中,对象1是学生,对象2是课程,对象5是大学。学生对象1可能调用课程对象2(因为想注册某门课),然后课程对象2可能不得不调用大学对象5来执行某些操作。因此,在进行线程测试时,我们需要测试从对象1到对象2再到对象5的这个线程。
线程测试的类型可能包括:
- 单线程:例如,对象3 -> 对象2 -> 对象4。
- 多线程:例如,对象1 -> 对象2,然后同时到对象4和对象5。
- 多输入线程:例如,多个输入同时进入对象1。

在进行黑盒测试时,我们也可以关注基于状态的测试。因为有些程序可以同时处于多种不同的状态。例如,在使用微软画图时,你可以将画笔设置为不同的颜色。进行测试时,我们应该将对象置于不同的状态(例如,将画笔设置为不同颜色),然后再执行操作(如在白板上画图)。

请注意,基于状态的测试侧重于比较类的最终状态与预期状态。系统中特定对象的所有状态都可以通过状态机图推导出来(我们将在后续课程中讨论如何绘制状态机图)。然后,我们应该为每个状态转换推导出一组有代表性的激励(输入),并在应用激励后检查类的属性或链接,以确定是否达到了特定状态。
我们可以基于对象的属性或与其他类的链接来确定对象的状态。
- 基于属性:例如,可以根据“班级人数”这个属性来确定一个班级是否已满。如果班级人数是
50(假设满员),那么我们就知道班级对象处于“已满”状态。 - 基于链接:例如,我们有一组电影。如果一部电影在系统中与某个客户对象链接,那么我们就知道这部电影已被该客户“租借”,处于“不可用”状态。我们可以通过检查电影到客户的链接来确定电影的状态(可用或不可用)。
在应用测试激励之前,我们必须使用激励或链接将类置于所需的状态。

本节课中我们一起学习了黑盒测试的核心技术。我们了解了如何通过等价类划分来系统性地设计测试用例,如何通过边界值分析来发现常见的“差一”等错误,以及如何针对面向对象系统的特点进行线程测试和基于状态的测试。掌握这些方法,可以帮助我们在不了解内部实现的情况下,有效地验证软件的外部行为是否符合预期。
037:回归测试 🔄

在本节课中,我们将要学习软件测试中的最后一种类型——回归测试。我们将回顾其核心概念,并理解为什么它在软件开发过程中至关重要。


概述

回归测试是一种旨在确保软件在修改后,原有功能依然正常工作的测试方法。其核心思想是:曾经出现的错误,未来可能再次出现。通过将已修复的错误转化为测试用例,我们可以持续防范这些错误的回归。

回归测试的核心流程

上一节我们介绍了调试的概念,本节中我们来看看如何将调试过程与回归测试结合起来。回归测试的流程紧密围绕错误的发现与修复展开。


以下是回归测试的标准步骤:
- 发现并复现错误:当在软件中发现一个错误(Bug)时,首要任务是找到能够稳定复现该错误的输入条件。
- 保存测试信息:记录下用于复现错误的输入数据,以及该输入对应的正确预期输出。
- 创建测试用例:将上述输入和预期输出整合,形成一个正式的测试用例。
- 修复错误:对代码进行修改,以消除该错误。
- 验证修复:运行新创建的测试用例,验证在修复后,程序对于该输入能产生预期的正确输出。


回归测试的重要性

执行回归测试主要有两个关键好处,它们都源于同一个核心理念。

以下是回归测试的主要价值:


- 积累高质量的测试套件:每一个由真实错误转化而来的测试用例,都是针对程序薄弱环节的高价值测试。这能帮助团队构建一个强大且有针对性的测试集合。
- 防范错误复发:在未来的代码修改、功能添加或重构过程中,可能会无意间重新引入已修复过的错误。回归测试用例就像一个安全网,能够及时捕获这类“倒退”(Regression),确保软件质量不会因新的变更而下降。
总结


本节课中我们一起学习了回归测试。我们了解到,回归测试的本质是将已修复的错误转化为自动化的测试用例。其核心目的是防止相同的错误在未来再次发生,并通过不断积累由真实问题驱动的测试用例,来提升整体测试套件的有效性。记住这个简单而强大的公式:Bug -> 测试用例 -> 持续防护。
038:执行测试 🧪
在本节课中,我们将学习如何执行软件测试。我们将涵盖测试的实现、不同类型测试(如单元测试、集成测试和系统测试)的执行方法,以及如何评估测试结果。
实现测试
上一节我们介绍了测试的基本概念,本节中我们来看看如何实现测试。我们希望尽可能自动化测试过程,因为运行测试用例可能非常繁琐且耗时。这主要是由于需要考虑大量可能的输入值和系统状态。
测试组件是一个程序,它自动化一个或多个测试过程或其部分。这意味着我们可以使用另一个程序来执行测试用例。
以下是可用于帮助我们编写测试组件的工具:
- 记录用户执行操作时测试用例动作的工具。
- 参数化脚本,可接受各种输入值。
- 电子表格和数据库应用程序,用于存储所需的输入数据以及每个测试的结果。
执行测试
了解了如何实现测试后,现在我们来探讨如何执行测试。执行测试时,我们必须首先根据特定的软件配置和测试配置来设置系统,然后向系统输入数据,获取结果,最后评估并将结果与预期结果进行比较。


如果结果相同,则没有错误,测试完成。如果结果不同,则表明存在错误,我们必须调试系统。调试后,需要再次执行相同的测试并重新评估结果。最后,我们需要使用电子表格或数据库应用程序来跟踪错误率数据,即记录哪些测试通过,哪些测试失败。
测试策略
在上一讲中,我们讨论了可以在软件系统上执行的不同类型的测试,例如黑盒测试、白盒测试和回归测试。测试策略将指定在哪个时间点采用哪种测试技术(例如白盒、黑盒或回归测试)。

在制定测试策略时,我们必须明确以下内容:

以下是制定测试策略时需要明确的事项:
- 测试组件所需执行的确切步骤。
- 计划并执行这些步骤的时间点。
- 执行这些步骤所需的时间、精力和资源。
测试计划(即制定所有测试策略)之所以困难,是因为调试部分存在时间不确定性。我们通常不知道调试需要多少时间。测试通常在实现之后、截止日期之前进行,这意味着测试时间有限。因此,在有限的时间内,我们必须在灵活性与计划管理之间取得平衡。测试常常在截止日期压力较大时进行,因为我们在发布产品前进行测试。我们应该使进度可衡量,并在出现问题时尽快识别。
测试层次:由内而外
当我们开发系统时,我们采用由外而内的方法。这意味着我们先进行需求捕获,然后进行分析和设计,最后实现系统。

但是,当我们执行测试时,我们采用由内而外的方法。我们首先从单元测试开始,即专注于源代码。然后,我们将所有类集成在一起,进行集成测试。一旦我们拥有了整个系统,就进行系统测试。最后,我们进行验收测试,在客户面前演示所有功能。

以下是各测试层次的特点:
- 单元测试:专注于系统源代码,因此大多数测试用例将是白盒测试用例,辅以一些黑盒测试用例。
- 集成测试:使用白盒和黑盒测试用例的组合。
- 系统测试:专注于整个系统而不查看源代码,因此大多数测试用例是黑盒测试用例。
- 验收测试:再次涉及黑盒测试,我们尝试在客户面前演示功能,但不涉及源代码,因此将使用黑盒测试用例。

测试执行者
那么,应该由谁来执行测试呢?对于单元测试、集成测试和系统测试,将由开发人员执行。但对于验收测试,我们在客户面前进行,因此将由用户执行。

以下是不同测试的执行者:
- 单元测试:由软件工程师执行。
- 集成测试:由软件工程师或测试团队执行。
- 系统测试:由测试团队执行。
- 验收测试:由用户或客户尝试使用系统。
总结

本节课中,我们一起学习了如何执行软件测试。我们探讨了测试的自动化实现,详细说明了单元测试、集成测试、系统测试和验收测试的执行流程与策略,并明确了不同测试类型的执行者和侧重点。理解这些内容有助于我们更系统、高效地规划和执行测试活动。
039:单元、集成与系统测试 🧪

在本节课中,我们将学习软件测试的三个关键层次:单元测试、集成测试和系统测试。我们将了解每种测试的目的、方法以及在实际项目中如何应用它们。
单元测试 🔬
上一节我们介绍了测试的基本概念,本节中我们来看看最基础的测试类型——单元测试。单元测试针对的是软件中的最小可测试单元,通常是一个类或一个函数。
当我们进行单元测试时,我们有一个待测试的组件(通常是一个类或程序的一部分)。我们准备许多输入数据,并将其输入到待测试的组件中。测试用例的类型可以涉及接口、独立路径、边界条件、局部数据结构或错误处理路径等。我们强调白盒测试技术,因为此时我们讨论的是源代码。
但请注意,当我们测试一个类或系统的一部分时,系统的其他部分可能尚未准备就绪。那么,我们如何在没有其他部分的情况下进行测试呢?
因此,在进行测试时,我们需要所谓的驱动模块和桩模块来完成测试。
- 驱动模块帮助我们输入用于测试的数据,并将其输入到待测试组件内的函数中。例如,如果组件中有一个求绝对值的函数,那么驱动模块将帮助我将输入数据输入该函数,然后检查输出是否正确。
- 桩模块是被待测试组件调用的组件。这个组件可能会调用另一个类来执行某些操作。例如,该组件调用另一个类来获取一些学生信息。那么,为了完成测试,我不调用实际提供学生信息的类,而是使用一个虚拟类。这个虚拟类可能总是返回相同的学生信息给该组件,这样我就可以在没有学生类的情况下完成测试。因此,桩模块就是一个虚拟类,它将返回一些必要的信息,以便组件完成测试。
在面向对象测试中,我们应该对什么进行单元测试呢?一个单元测试至少应该针对一个类。我们必须测试软件系统中的每一个类。

然而,我们知道系统中的对象可以处于不同的状态,因为一个对象可能有多种状态,这会使测试变得困难。这就是我们在上一讲中讨论过的内容:在测试之前,你必须将每个类置于每一个可能的状态中。
那么,如何处理继承和多态呢?这意味着当我们有超类和子类时,如果一个子类重写了已经测试过的超类的方法,那么需要测试什么?是只测试被重写的方法,还是测试所有的方法?
如何处理封装呢?封装意味着我们试图进行信息隐藏,试图隐藏对象内部的内容。这在面向对象编程中非常常见。为了进行测试,我们可能需要提供一个仅供测试使用的方法,该方法将报告对象的所有状态。我们将使用该方法来获取该特定对象内部的实际内容。
通常,当我们实现一个栈时,通常只有两个方法:push 和 pop。push 可以将某些内容压入栈,pop 可以从栈中取出内容。但为了进行测试,我们可能还需要一个名为 peek 的方法,以便查看对象(或栈)内部的实际内容。如果你查看一个空栈,应该看不到任何内容。如果你将某些内容压入栈然后查看,你应该看到刚刚插入栈的元素。如果你弹出某些内容然后查看,那么你应该看到有内容从栈中移除,等等。我们需要这个额外的 peek 方法,以便准确查看对象内部的内容。
集成测试 🔗
上一节我们学习了如何测试单个组件,本节中我们来看看当多个组件组合在一起时会发生什么,这就是集成测试。
有些人可能会问,如果所有组件在单元测试后都能正常工作,为什么还要进行更多测试?为什么需要进行集成测试?原因是交互类错误无法通过单元测试发现。例如,接口误用、接口误解或时序错误,这些都无法通过单元测试发现。因此,我们必须进行集成测试,以确保没有交互错误。

但是,当我们进行集成测试时,我们不会一次性完成所有集成。这意味着我们不会将所有类集成在一起,然后得到整个系统,再简单地测试整个系统。因为如果我们测试整个系统,那将与系统测试相同,而不是集成测试。因此,要进行集成测试,我们必须增量式地进行。
增量式意味着我们将类一个一个地集成到系统中,然后观察会发生什么。
对于集成测试,我们可以采用自顶向下的方法。通常,顶层系统将是用户界面组件。使用这种自顶向下的方法,我们首先测试顶层。

以下是集成测试的几种主要方法:
自顶向下方法

使用这种方法,我们首先测试顶层子系统,然后向下测试较低层的子系统。通常,顶层子系统是用户界面组件。因此,使用这种方法,我们将首先测试用户界面。
我们使用桩模块来测试顶层子系统。然后,我们将用实际的子系统逐个替换这些桩模块。每当集成一个新的子系统时,会重新运行先前测试的一个子集,例如进行回归测试。
自顶向下方法的好处是,它首先测试用户界面组件。如果用户界面组件在软件系统中至关重要,那么使用自顶向下方法是好的,因为你首先测试了关键组件。
但缺点是,在测试后期才能进行重要的低层处理。我们在执行测试时需要编写许多桩模块。通常,较低层的子系统是控制器,它们控制软件系统的行为。使用自顶向下方法意味着我们将在测试后期才测试它们。
自底向上方法
另一种我们可以使用的方法是自底向上方法。对于自底向上,我们首先测试底层子系统,然后使用驱动模块测试这些底层子系统。一旦我们完成了特定子系统的测试,就可以将该子系统与系统的顶层部分集成。
自底向上方法的好处是,我们将首先测试控制器。通常,控制器在软件系统中至关重要,因此首先测试它们是个好主意。缺点是,我们将在测试后期才测试用户界面组件。

现在我们可以看到,如果使用自顶向下,我们将首先测试用户界面;如果使用自底向上,我们将首先测试控制器。
三明治方法
还有一种方法叫做三明治方法,这意味着我们将同时使用桩模块测试顶层部分,并使用驱动模块测试底层部分,因此我们可以并行进行。
在测试完系统的顶层和底层部分后,我们可以将它们集成在一起。这就是我们所说的三明治方法。
三明治方法的好处是,我们现在可以并行地对用户界面和控制器进行测试。但缺点是,我们必须编写许多桩模块和驱动模块。三明治方法非常高效,因为我们可以并行执行测试,并且可以缩短总测试时间。

请注意,当我们执行测试时,关键子系统应尽可能早地进行测试。如果用户界面是关键,那么我们首先测试它们;如果控制器是关键,那么我们首先测试它们,等等。
此外,那些涉及多个软件需求或具有高级别控制(即具有较高的圈复杂度)的子系统,以及那些复杂或容易出错的子系统,或者具有特定性能要求(意味着需要满足该特定子系统的某些非功能性需求)的子系统,都应尽可能早地进行测试。

请注意,对关键子系统需要进行回归测试。为什么?因为子系统很重要,如果我们之前犯了错误,我们会尽量避免在同一子系统上再次犯同样的错误。
系统测试 🖥️
上一节我们讨论了如何将组件集成在一起测试,本节中我们来看看如何将软件作为一个完整的系统进行测试,这就是系统测试。

在系统测试中,我们将系统作为一个整体进行测试,以确保系统在集成后能正常运行。
以下是在进行系统测试时可能想要执行的一些特定类型的测试:
- 功能测试:验证系统需求规格说明中指定的所有功能。
- 性能测试:验证你在非功能性需求中指定的设计目标。
- 引导测试:选择一组最终用户来试用系统。
- 验收测试:让客户或用户使用系统,然后验证可用性,并根据系统需求规格说明验证所有功能和非功能需求。
- 安装测试:在实际使用环境中验证可用性,并验证功能和非功能需求。
在本讲中,我们重点关注性能测试、引导测试和验收测试。
性能测试

在性能测试中,我们讨论的是非功能性需求,例如:
- 压力测试:你的系统能否同时处理许多并发请求。
- 容量测试:你的系统能否处理大量数据、高复杂度算法或高磁盘碎片。
- 安全测试:验证安全保护机制。
- 时序测试:验证系统能否满足某些时间约束。
- 恢复测试:你必须验证系统在不同方式下被迫失败时能否恢复。
引导测试

你也可以进行引导测试,这意味着尝试邀请一些最终用户来试用系统。
我们可以执行两种类型的引导测试:
- Alpha测试:你将用户邀请到开发地点。在Alpha测试中,你邀请一些用户到开发地点,然后在开发地点进行测试。因此我们说测试是在受控环境中进行的,在开发地点内。
- Beta测试:你将软件发布给最终用户,最终用户将在他们自己的机器上试用该软件。因此我们说,对于Beta测试,测试是在客户现场、在客户的机器上进行的。在游戏中,我们非常普遍地使用Beta测试。我们将游戏发布给玩家试用,如果他们发现游戏中的任何错误,可以向开发人员报告,开发人员可以修复这些错误。
验收测试
在验收测试中,我们试图向客户证明系统的某个功能或约束是完全可操作的。这意味着我们试图在客户面前演示某些东西。
以下是在验收测试中可以做的事情:
- 功能验证:系统是否提供所需的功能。
- 接口验证:例如,接口是否执行设计功能并遵循所需的设计标准。
- 信息内容验证:例如,系统是否会在系统上正确存储所有数据。
- 性能验证:系统是否满足特定的性能标准,等等。

当你设计某个测试时,你需要以简洁、精确且可测试的方式编写需求,具体做法是:
- 将相关的需求分组在一起。
- 删除任何无法测试的需求。
- 通过查看用例、查看领域模型以及查看系统需求规格说明中的非功能性需求,添加从用户那里收集的任何额外需求。
然后,对于每个需求,你必须想出一个评估场景,该场景将向客户证明你已经实现了相应的需求。
请注意,由于大多数评估场景都依赖于用户界面,因此在用户界面确定之前,它们无法完成或测试。

总结 📝
在本节课中,我们一起学习了软件测试的三个核心层次。
- 单元测试聚焦于测试最小的代码单元(如类或函数),需要使用驱动模块和桩模块来模拟未完成的部分,并需考虑面向对象特性(如状态、继承、封装)带来的测试挑战。
- 集成测试旨在发现组件间交互时产生的错误,主要采用增量式方法,包括自顶向下、自底向上和三明治策略,关键是要尽早测试系统中的关键部分。
- 系统测试将软件视为一个整体进行验证,包括功能测试、性能测试、引导测试(Alpha/Beta测试)和最终的验收测试,以确保系统满足所有指定的需求和标准。

理解并正确应用这些测试层次,对于构建高质量、可靠的软件系统至关重要。
040:验收测试示例 📋
在本节课中,我们将学习如何为一个具体的需求陈述编写验收测试。我们将以大学课程注册系统为例,详细讲解从需求提取到编写可测试的验收标准,再到评估测试结果的完整流程。

从需求到可测试的陈述
上一节我们介绍了验收测试的基本概念,本节中我们来看看如何将模糊的需求转化为可测试的陈述。
首先,我们有一个关于大学课程注册系统的需求陈述。我们需要从中提取所有需求。
以下是需求陈述中的部分内容:
- 学生可以请求包含本学期课程安排的课程目录。
- 学生决定选修哪些课程。
- ……

接下来,我们必须以可测试的方式重述所有需求。测试的重点是系统能做什么,而不是用户能做什么。

因此,我们需要将需求重述为对系统行为的描述。例如:
- 原需求:学生可以请求包含本学期课程安排的课程目录。
- 可测试的陈述:系统必须能够生成包含本学期课程安排的课程目录。
对于“学生决定选修哪些课程”这条需求,由于我们测试的是系统而非学生,因此该陈述不可测试,应被删除。
我们需要对所有剩余的需求执行相同的操作,将它们全部转化为可测试的陈述。

请注意,进行测试时必须包含所有已识别的需求。可能还有其他从用户或系统需求规格说明中收集到的需求,务必记得将它们也纳入测试范围。


编写验收测试
在将需求转化为可测试的陈述后,我们就可以开始编写具体的验收测试了。
编写验收测试时,确保使用关键词 “演示 (demonstrate)”。因为验收测试需要在客户面前实际演示系统的功能。

以下是为课程注册系统编写的一些验收测试示例:
- 演示系统能够生成包含本学期课程安排的课程目录。
- 演示课程注册功能,最多可选择四门主修课程。
- 演示课程注册功能,最多可选择两门备选课程。
- ……

这些测试必须具有可操作性,即能为客户设计出具体的测试用例,让客户能够实际操作系统以验证所有需求是否得到满足。

评估测试结果

编写并执行测试后,我们需要评估测试结果。测试工程师需要通过以下步骤进行评估:
- 将测试结果与测试计划中设定的目标进行比较。
- 准备度量标准以确定软件的当前质量。

那么,我们如何知道何时可以停止测试并发布软件呢?可以考虑以下标准:
- 任务完成度与覆盖率:例如,如果通过了99%的测试任务,我们可以认为系统是可靠的。
- 基于错误率的可靠性:如果错误率很低,说明系统中错误不多,系统相对稳定。

在项目中,我们需要持续追踪错误率。如果实际故障率高于预期,我们可以采取以下措施:
- 执行额外的测试以发现更多缺陷。
- 重新评估测试标准是否定得过高或过于严苛。
- 交付系统中可接受的部分,并继续修订和测试不可接受的部分,稍后作为增量更新交付。

测试优先级策略
请注意,所有测试任务都很重要。但如果时间确实有限,应遵循以下优先级策略:
以下是时间不足时应遵循的测试优先级:
- 测试系统能力比测试组件更重要:例如,对整个系统进行黑盒测试比单元测试更重要。
- 测试旧功能比测试新功能更重要:在旧版本中正常工作的功能,在新版本中应依然正常工作。
- 测试典型情况比测试边界情况更重要。
有效测试的要点总结

本节课中我们一起学习了验收测试的编写与评估。为了有效进行测试,需要牢记以下几点:
- 良好的计划:明确知道要测试什么。
- 正确的技术:在正确的情况下使用正确的测试技术(如本课程介绍的白盒测试、黑盒测试和回归测试)。
- 尽早且频繁地测试:测试通常在实现后进行,而此时时间压力较大,因此尽早开始测试是个好主意。
- 利用自动化工具:例如,使用 JUnit 来帮助系统、全面地运行所有单元测试。
- 设定清晰的停止标准:不可能等待一个完美的系统。需要事先决定通过多少测试才算足够,例如通过99%的测试后,即可考虑向最终用户发布系统。
041:系统设计与分析简介 🏗️
在本节课中,我们将学习系统分析与设计。这意味着,在通过软件需求规格说明书(SRS)捕获所有需求之后,我们如何将这些需求转化为一个可以实际用于实现(编码)的模型。
本节课结束后,你将理解系统分析与设计在软件开发中的目的和重要性。你将了解系统分析与设计期间发生的主要活动。你还会知道如何在项目初期实现设计目标并处理实现环境。最后,你将了解什么是架构模式和设计模式,以及何时使用它们。
系统分析与设计的目的
上一节我们介绍了课程目标,本节中我们来看看系统分析与设计的核心目的。

当我们谈论分析与设计时,我们试图创建一个可以实际用于实现的模型。因此,分析与设计发生在构建(即实现系统)之前。
在系统分析与设计中,我们尝试将用例模型构建成一种健壮且可维护的形式,使其适应实现环境,并为实现做好准备。这意味着我们需要提出一个合理且稳定的架构,以及一个用于实现的蓝图。

构建系统的两个阶段
我们通过两个阶段,逐步将系统构建为由具有明确定义关系的基本部分组成的结构。
第一阶段,我们尝试构建一个系统骨架,其中的各个部分组合在一起,形成一个稳定的基础。这个骨架由软件架构提供。

第二阶段,我们尝试让系统生长,各个部分围绕骨架聚合,产生更精细的系统结构。这种生长是通过分析与设计工作流完成的。
当我们创建一个可用于实现的模型时,可能还需要考虑来自解决方案领域的技术方案。
为什么需要分析与设计?
一些同学可能会问,在捕获所有需求后,为什么不直接开始实现?
原因是,当我们构建用例模型时,用例模型中的信息不足以直接用于实现。这意味着在进入实现之前,我们必须完善规格说明,以消除任何歧义和冗余。
同时,我们需要使用开发者的语言(即编程语言)更精确地描述需求。我们可以描述所有的类,并尝试使用开发者的语言来创建模型。此外,我们还需要对需求进行结构化,以便于理解和维护。
因此,在进入实现之前,我们必须收集所有这些重要的信息。我们可能还需要考虑一些之前忽略的问题,例如非功能性需求和实现环境。现在,我们必须在进入实现之前,创建一个能够解决所有这些问题的模型。这意味着我们需要一个能让我们在开始实现之前,对需求有更详细和精确理解的模型。
处理实现环境的例子
以下是一个如何处理实现环境的例子。

假设我们必须构建一个系统,并且该系统需要在不同的操作系统上兼容。
那么,我们可能需要使用一个桥接类,即文件管理器,来处理不同操作系统上的文件访问。这就是我们所说的桥接设计模式。我们将在后续的一节课中详细介绍桥接设计模式。
因此,当我们尝试为实现创建一个模型时,我们还必须在设计和模型中考虑实现环境。我们通常需要定义许多额外的类来处理实现环境,以便于维护。例如,如果我们需要支持另一个操作系统,那么我们只需修改文件管理器,而无需修改整个系统。这就是使用桥接设计模式来处理涉及多个操作系统的实现环境的好处。

本节课中,我们一起学习了系统分析与设计的基本概念。我们了解到,分析与设计是将需求转化为可实施模型的关键桥梁,它发生在编码之前,目的是消除歧义、适应环境并构建稳定的系统架构。我们还通过一个例子,初步认识了设计模式(如桥接模式)在解决特定实现环境问题中的作用。
042:架构设计与分析 🏗️
在本节课中,我们将学习软件架构设计与分析的核心概念。我们将探讨如何将大型软件系统分解为子系统,理解子系统之间的连接方式,并介绍几种常见的架构模式。这些知识对于构建可维护、高性能且安全的软件系统至关重要。
子系统与架构设计

在架构分析与设计中,我们专注于理解系统应如何以子系统的形式进行组织。我们尝试设计系统的整体结构,识别主要的结构组件以及它们之间的关系,这些关系通过子系统来定义。
我们关注如何将子系统组织在一起以形成整个系统。一个子系统封装其内容,并通过接口向其他子系统提供服务。子系统还提供信息隐藏,这是我们之前讨论过的概念。由于我们处理的是大型软件系统,因此必须采用“分而治之”的策略,将大型系统拆分为许多较小的子系统,然后一次处理一个较小的子系统。
每个子系统通过其接口提供服务。这意味着你无需知道如何实现该子系统,只需知道如何使用其接口来调用该子系统。
子系统连接:封闭层与开放层


上一节我们介绍了子系统的概念,本节中我们来看看如何将子系统连接起来形成整个系统。通常,非功能性需求高度依赖于系统架构,即我们如何安排子系统以形成整个系统。架构分析与设计是管理复杂性的重要工具,因为我们无需再思考整个大型系统,只需设计和组织子系统即可。
当我们将子系统连接起来时,连接方式可以是封闭层或开放层。
- 封闭层:每一层只能依赖于紧邻其下的那一层。
- 开放层:每一层可以依赖于其下的任何层。


以下是两种连接方式的示例:
- 封闭层示例:一个绿色子系统连接到紧邻其下的另一个子系统。
- 开放层示例:一个子系统依赖于一个并非紧邻其下的子系统。
在实践中,一个系统通常包含3到5个子系统层。同一层内的分区将子系统组织起来以提供不同的服务,这导致了层内的点对点服务。分区和分层共同产生了系统层次结构。
通常,顶层子系统与用户界面相关,而内层则是控制软件系统行为的更关键子系统,我们称之为控制器。

非功能性需求与架构选择
基于我们在软件需求规格说明书中捕获的所有非功能性需求,我们可以设计出用于构建系统的层次结构。
以下是针对不同非功能性需求的架构设计考量:
- 性能:可以使用少量子系统来定位关键功能。当执行系统功能时,只需引用少量子系统。
- 安全性:可以使用分层架构,将最关键的子系统置于最内层,并采用封闭层设计。这样,要访问关键内容,必须经过多层才能到达最内层。
- 安全性(Safety):可以将一个子系统或其易受攻击的子系统定位安全相关功能。
- 可用性:可以包含冗余子系统,以便在不停止系统的情况下进行替换或更新。
- 可维护性:应使用小型且自包含的子系统,这些子系统易于更改。这样,当需要修改特定功能时,只需改动一个子系统,而不会影响其他子系统。
架构模式介绍

我们也可以使用一些现有的架构模式来设计软件系统。架构模式规定了如何组织和连接一组软件组件以形成整个系统。它是系统组织的蓝图,定义了系统的软件架构,包括如何将系统分解为不同子系统、全局控制流、错误处理策略、模块间组件通信协议等。

请注意,大多数大型软件系统在不同部分会同时使用多种架构模式。

以下是几种常见的架构模式:
多层架构模式
这是我们已涵盖的模式。我们将子系统置于不同层中,并使用封闭层或开放层方式连接它们。
仓库架构模式
在仓库架构模式中,有许多客户端,这些客户端依赖于一个仓库进行数据管理。如果客户端想要访问某些数据,必须通过这个集中式的仓库进行。这是数据库管理系统使用的典型架构模式。

该模式的缺点是,仓库可能成为性能和可修改性的瓶颈,因为许多客户端都依赖于此仓库来检索和获取数据,仓库与子系统之间存在高度耦合。
客户端-服务器模式

在客户端-服务器模式中,我们有许多客户端和一个服务器,服务器之下有一个数据库服务器。这是构建Web应用程序的典型模式,因为Web应用程序可能有许多客户端(例如使用浏览器)从服务器获取内容,而服务器则在数据库服务器中跟踪所有信息。
它可以是三层架构(客户端、服务器、数据库服务器),也可以是P2P架构(客户端可以直接相互通信以获取信息)。
代理模式
在代理模式中,有一个客户端试图远程访问某些内容。我们必须在客户端上实现一个代理,这样当尝试远程获取内容时,必须通过此代理进行访问。然后有一个代理器来处理客户端与远程对象之间的通信,远程对象存储客户端想要访问的信息。
该模式的好处是,当尝试远程访问时,请求由代理处理,通信由代理器处理。因此,客户端远程访问就像本地访问一样,因为所有事情都由代理和代理器自动处理。

我们可以使用代理设计模式来实现代理架构。
事务处理模式
在此模式中,有一个子系统处理所有事务。这些事务被输入到一个事务分发器中,该分发器最终将这些事务分发给不同的处理器进行处理。

使用此模式的应用示例是数据库引擎,因为在数据库管理系统中存在许多事务,DBMS利用此模式将事务分发给不同的处理器进行处理。
管道-过滤器模式
在此模式中,数据流通过一系列过滤器传递。例如,如果你有一系列图像需要处理,可以将第一张图像输入第一个子系统(如转换为灰度图),处理完成后,将其传递给第二个子系统(如进行抗锯齿处理)。此时,第一个子系统空闲,可以开始处理第二张图像。这样,不同的子系统可以同时处理不同的图像,这就是流水线处理。
该模式的好处是每个过滤器或子系统可以并发地处理数据。使用管道-过滤器模式的应用示例是Unix Shell。
模型-视图-控制器模式

MVC是一种非常流行的模式,用于开发涉及用户界面、数据和控制器系统。模型指处理系统数据的子系统,视图指处理用户界面的子系统,控制器指控制软件系统行为的子系统。
我们将大型软件系统拆分为这三种类型的子系统。这样做的好处是,例如,如果你想修改用户界面,只需修改视图子系统,而无需触碰其他两个子系统;如果你想修改数据,只需修改模型子系统。MVC模型试图将用户界面层与系统的其他部分分离。


我们可以使用观察者设计模式来分离模型和视图。
总结

本节课中,我们一起学习了软件架构设计与分析。我们理解了如何通过子系统来组织系统,区分了封闭层与开放层的连接方式,并探讨了如何根据不同的非功能性需求(如性能、安全、可维护性)来选择合适的架构策略。最后,我们介绍了几种关键的架构模式,包括多层、仓库、客户端-服务器、代理、事务处理、管道-过滤器和MVC模式,这些模式为构建复杂软件系统提供了经过验证的蓝图。掌握这些概念对于设计出结构清晰、易于维护且满足非功能性需求的软件至关重要。
043:用例分析
在本节课中,我们将要学习用例分析的核心概念——分析类。分析类是对最终系统实现中一个或多个类的抽象。通过识别所有分析类,我们可以预估最终实现所需的类数量。在进入实现阶段前,先确定分析类是一个好方法,因为这能让我们大致了解需要哪些类来满足所有功能需求。因此,本节内容将聚焦于功能需求。

请注意,用于分析类的类描述是概念性的,而非面向实现的。这是因为我们正在进行抽象,试图跳过细节。具体表现为:属性类型是概念性的,而非编程语言类型;行为通过职责的文本描述来定义;关系也是概念性的,而非面向实现。
分析类属于以下三种原型之一:边界类、实体类或控制类。

边界类
上一节我们介绍了分析类的概念,本节中我们来看看第一种类型——边界类。
边界类使用以下符号表示:<<boundary>>。它用于建模系统与其参与者之间的交互,代表实现特定功能所需的用户界面元素或设备的抽象。边界类与系统外部的参与者以及系统内部的对象进行交互。其描述应保持在相当高的概念层面,例如,对于某个功能,我们“需要一些用户界面”,而不是描述界面中的每一个按钮、菜单项。我们再次通过抽象来跳过细节,以处理设计的复杂性。



在设计中引入边界类,是为了封装和隔离系统接口的变更。如果我们想修改某个功能的用户界面,只需修改对应的边界类即可。

以下是识别边界类的方法:
通常从用例描述中寻找,从参与者出发,尝试识别向系统输入数据所需的表单和窗口,或识别系统用于响应的通知和消息。请注意,我们不建模界面的视觉方面,即不指定界面中的每一个按钮、图标或菜单。我们始终使用用户术语来描述界面。
最初,我们会为用例图中的每个“参与者-用例”对识别一个边界类实例。对于人类参与者,它代表参与者与系统交互的用户界面窗口;对于外部系统参与者,它代表与外部系统的通信接口。
现在,让我们看一个ASU课程注册系统的用例模型示例,重点关注教授可以在系统上执行的功能。
从用例图可知,我们需要一些教授用户界面,供教授选择要教授的课程以及请求注册信息。这意味着我们需要一个边界类,例如 ProfessorUI,供教授在系统上操作。

请注意,初始的边界对象之后可能会被聚合,或成为其他边界对象的聚合部分。例如,我们可能有 SelectCourseToTeachUI,它是 ProfessorUI 的一部分;还有 RequestEnrollmentUI,也是 ProfessorUI 的一部分。而在 SelectCourseToTeachUI 内部,可能还有更进一步的用户界面,例如 CreateScheduleUI、ViewScheduleUI 和 ModifyScheduleUI,它们是 SelectCourseToTeachUI 的一部分。


实体类
了解了边界类后,我们来看第二种分析类——实体类。

实体类用于建模系统中长期存在且通常持久保存的信息。我们关注在软件系统内持久存在的数据。从我们为软件系统构建的领域模型中识别实体类并不困难,我们再次聚焦于软件系统内持久存在的数据。
一个实体对象很可能从数据库获取数据。引入实体类是为了封装和隔离其所代表信息的变更。如果我们想修改系统中的某些信息,只需修改实体类。
以下是识别实体类的方法:
我们将检查用例的场景,并确定执行该用例需要哪些领域模型类。因此,我们必须回到领域模型,识别与特定用例场景相关的对象。

例如,对于“创建课表”场景,我们可以看到需要获取学期、年份、课程信息以及教学安排,这些都是持久保存在软件系统中的内容。因此,我们需要为“创建课表”场景准备三个实体类:Course、CourseOffering 和 Professor。在这个场景中,我们同样需要 ProfessorUI 这个边界类。

控制类

接下来,我们探讨最后一种分析类——控制类。
控制类用于建模协调、排序、事务和控制行为。这意味着控制类将控制软件系统的行为。请注意,控制类实例通常在应用领域中没有直接对应物。
控制对象提供了将其他类在一个用例实现中联系在一起的“粘合剂”。我们使用控制类在一个用例场景中将不同的类组织在一起。一个控制类实例用于表示与特定用例或业务逻辑相关的控制。通过使用控制类,我们可以封装和隔离对控制或业务逻辑的变更。这意味着,如果我们想改变程序行为的逻辑,只需修改控制类。

以下是识别控制类的方法:
最初,我们为每个“参与者-用例”对分配一个控制对象,该对象负责用例内部的事件流。如果一个用例内有复杂的行为,那么我们可能需要多个控制类。也有可能将多个控制对象组合在一起,或者消除一些不太重要的控制对象。
请注意,一个控制对象最多只应与一个参与者关联。这是因为系统需求的变更通常由参与者发起,并且通过将控制对象与至少一个参与者关联,我们可以将所需的变更隔离到仅该特定参与者所需的功能上。因此,为简化起见,我们将控制对象与最多一个参与者绑定。
现在,对于“选择要教授的课程”这个功能,我们可以看到需要 ProfessorUI,还需要一个 SelectCourseToTeachManager 来控制该用例内的行为,以及该特定用例所需的数据对象(实体类)。
连接分析对象

现在你可能会问,我们如何将边界、控制和实体对象连接在一起?以下是在一个用例内连接不同对象时可以使用的规则:

- 参与者只能与边界对象交互。
- 边界对象只能与控制对象交互。一个用户界面不可能直接与另一个用户界面交互;如果需要一个用户界面与另一个交互,必须通过控制对象完成。
- 实体对象只能与控制对象交互。例如,一个数据对象不能直接与另一个数据对象交互,必须通过控制器完成。
- 控制对象可以与边界对象、实体对象或另一个控制对象交互。
这些是我们可以用来连接分析对象的规则。现在你可以看到,教授将使用 ProfessorUI,而 ProfessorUI 连接到控制器 SelectCourseToTeachManager,以修改该用例内的数据。
为何进行三类划分




我们为什么要把一个用例划分为这三种类型的类呢?因为对于每个功能,我们都需要一些用户界面、一些控制器以及一些数据。这就是我们将用例划分为这三种类型的原因。
这样做的目标是实现变更的局部化,并尝试构建一个稳定的系统。因为,假设我们想修改用户界面,那么只需修改边界类,而无需触及其他两类;或者如果我们想修改某个功能的行为,也只需修改控制器,而无需再次触及其他两类。
但在现实中,我们可能需要对功能放置的位置做出许多判断。我们将在后续课程中讨论不同的设计模式,以帮助指导这些决策。
总结与关联

本节课中我们一起学习了用例分析中的三类分析类:边界类、实体类和控制类。

现在,对于“选择要教授的课程”这个用例,我们将拥有这些分析类。它们可以追溯回我们用例模型中的这个用例。这种追溯关系有助于未来的系统维护。例如,如果我们想修改或维护“选择要教授的课程”这个特定功能,我们总是可以追溯回我们的用例文档,然后获取有关该特定功能的更多细节。
软件工程:04:类设计 🧱

在本节课中,我们将要学习软件设计中的一个核心环节——类设计。我们将探讨如何从分析模型过渡到具体的设计类,并理解如何构建高内聚、低耦合的类结构,这是构建健壮、可维护软件系统的关键。
上一节我们讨论了从分析到设计的过渡,本节中我们来看看如何具体地进行类设计。
在类设计中,我们需要为最终的实现确定所有要使用的类。这包括使用解决方案域中的类来实现问题域中的类。具体来说,我们需要处理三种主要类型的设计类:
- 边界类:与用户界面技术相关。
- 实体类:负责数据管理技术。
- 控制类:控制软件系统的行为。

当我们尝试确定控制类时,可能需要处理与分布、性能或事务相关的问题。例如,是否需要在每个节点都有一个独立的设计类?为了提升性能,是否需要将控制类与边界类合并?我们的系统是否需要事务管理?
确定了用于实现的基本类之后,我们还需要进行一系列细化工作。
首先,需要选择可复用的组件,例如判断是否有现成的库或设计模式可供复用。其次,必须完成每个类的详细规格说明。以下是需要为每个类补充的详细信息:
- 属性
- 关联关系
- 操作(方法)
- 类型
- 可见性(如public, private)
- 尽可能添加的约束条件
此外,还需要识别主动类。主动类的定义是拥有自身控制线程的类,它们通常是控制类或边界类。

接下来,需要重构设计模型,例如调整关联关系,或通过继承来提高代码复用性。同时,也要优化设计模型。例如,当类图中存在循环依赖时,可能需要重新审视设计;或者将多个类合并为一个类;亦或是缓存或延迟某些计算。
请注意,这些设计类是使用编程语言的语法来描述的,就像你在面向对象编程课程中看到的类一样。
当我们构建出所有类之后,还必须确保这些类具有高内聚和低耦合的特性。
那么,什么是内聚和耦合呢?内聚衡量的是一个类需要处理多少件功能上不同的事情。当一个类只做一件事时,它的内聚性最高。这意味着每个类应该只负责一个单一的职责,而不应将多个类的功能合并成一个庞大的“上帝类”。上帝类负责系统内的许多事情,是我们在软件系统中应该避免的,因为它难以维护和更新。


耦合衡量的是一个类与其他类之间依赖关系的数量和类型。也就是说,一个类依赖于多少个其他类。当一个类与其他类的依赖关系最小化时,它的耦合度最低。在面向对象编程中,我们追求低耦合,因为如果一个类依赖于许多其他类,那么这个类将变得难以维护和更新。因此,我们希望类之间的依赖尽可能少。



本节课中我们一起学习了类设计的基本流程与核心原则。我们了解了如何从分析模型导出边界类、实体类和控制类,并学习了如何通过补充属性、操作等细节来完成类的规格说明。更重要的是,我们掌握了构建优秀类设计的两个关键指标:高内聚(一个类只做一件事)和低耦合(最小化类间依赖)。遵循这些原则,有助于我们创建出更清晰、更易维护的软件系统。
045:状态机图 📊
在本节课中,我们将学习如何绘制状态机图,以捕捉软件系统中对象所有重要的状态和转换。状态机图是描述单个对象在其生命周期内行为的有力工具。
概述
状态机图用于描述软件系统中单个对象的行为。它展示了对象在其生命周期内(从创建到销毁)可能经历的所有状态,以及触发状态之间转换的事件。通过绘制状态机图,我们可以清晰地理解对象的动态行为。
状态机图的基本概念
上一节我们介绍了状态机图的目的,本节中我们来看看它的基本构成元素。

状态机图是一个有向图,它使用节点表示对象的状态,使用边表示状态之间的转换。它展示了对象可以发送和接收的所有消息,以及对象在其生命周期内可能进入的所有状态。

以下是状态机图的核心组成部分:
- 状态:对象在满足某些条件、执行某些活动或等待某个事件时所处的状况。状态具有持续时间。
- 转换:从一个状态(源状态)改变到另一个状态(目标状态)的过程。转换是瞬时且不可中断的。
- 事件:在某个时间点发生的、可能触发状态转换的事情。
- 初始状态:对象被创建后进入的第一个状态。
- 最终状态:对象被销毁前所处的状态。

如何绘制状态机图

现在,我们来看一个具体的例子,学习如何绘制一个状态机图。

这是一个银行账户的状态机图示例。对于银行软件系统中的账户,我们定义了以下几个状态:In Credit(余额为正)、Overdrawn(透支)、Frozen(冻结)和 Closed(关闭)。

我们还需要指定状态之间的所有转换。例如:
- 当余额变为负数时,对象从
In Credit状态转换到Overdrawn状态。 - 如果账户透支状态持续三个月,则转换到
Frozen状态。 - 在
In Credit状态时,如果调用closeAccount操作,则转换到Closed状态。 - 账户关闭五年后,转换到最终状态。




状态机图中还有两个特殊状态:
- 初始状态:用一个实心圆点表示。当通过构造函数创建对象时,对象进入此状态,随后立即进入第一个实际状态(如
In Credit)。 - 最终状态:用一个圆圈包围的实心圆点表示。当对象被销毁或删除时,进入此状态。如果状态机图没有最终状态,可能表示循环行为。
状态的详细描述
上一节我们看到了状态的基本表示,本节中我们来深入了解状态的内部细节。

状态可以用一个矩形表示。我们可以为状态命名(推荐做法,便于理解),也可以使用匿名状态。状态的特征可以通过对象的属性值(例如,银行账户的 balance 属性决定是 In Credit 还是 Overdrawn)或与其他对象的链接关系来确定。
一个更详细的状态机图可以在状态内部声明可执行的活动。例如,在 In Credit 状态下,可以执行 deposit(存款)或 withdraw(取款)操作;在 Overdrawn 状态下,也可以执行这些操作,并且当余额低于透支限额时,可以执行 notifyManager(通知经理)活动。

转换的定义与事件类型

我们了解了状态,现在来看看驱动状态变化的转换和事件。
转换的定义是从一个源状态到目标状态的变化。源状态和目标状态可以是同一个,这意味着对象可以回到原状态。转换的触发需要三个要素:
- 事件触发器:发生了什么事件。
- 守卫条件:必须满足什么条件。
- 效果列表:转换触发时要自动执行的操作。

事件是在某个瞬时时间点发生的事情。在状态机图中,主要有三种事件类型:
- 调用事件:调用对象类中定义的某个操作。例如:
deposit(amount),withdraw(amount)。 - 变更事件:当某个条件变为真时发生。例如:
when (balance < 0)。 - 时间事件:在某个时间点或经过一段时间后发生。例如:
after (3 months)。
事件被逐个处理。如果一个事件没有触发任何转换,它将被忽略。同时,一次只能触发一个转换,这是为了保证状态机图的确定性,即一个对象在任一时刻只能处于一个明确的状态。
状态内的行为与活动
最后,我们来探讨对象在某个状态内部可以执行哪些行为。

对象离开一个状态有两种方式:自动的(完成状态内所有活动后自动转换)或非自动的(由外部事件触发转换)。
在状态内部,可以定义多种行为:
- 无行为:停留在该状态,直到有事件发生。
- 执行活动:需要花费时间完成,并且可以被中断的操作。
- 进入活动:在进入该状态时立即执行的动作。
- 退出活动:在离开该状态之前执行的动作。

例如,在一个“输入密码”的状态中:
- 进入活动可能是:
set echo to ‘*’(将回显设置为星号),clear password(清空密码)。 - 退出活动可能是:
set echo to normal(将回显恢复为正常)。 - 状态内活动可能是:
do: key press(处理按键输入),do: clear password(处理清空密码操作)。


总结

本节课中,我们一起学习了状态机图的核心知识。我们了解到状态机图是描述单个对象生命周期行为的强大工具,它由状态、转换、事件等核心元素构成。我们学习了如何识别和绘制状态,包括初始状态和最终状态;理解了转换的触发机制(事件、守卫条件、效果);区分了不同类型的事件(调用、变更、时间事件);并探讨了状态内部可以定义的活动(进入、退出、执行活动)。掌握状态机图有助于我们精确地建模和设计软件系统中对象的动态行为。
046:状态机图示例 🚀
在本节课中,我们将学习如何为一个具体的类绘制状态机图。我们将通过分析一个大学课程注册系统中的“课程班次”类,来实践状态机图的构建方法,并探讨复合状态机图的概念。

上一节我们介绍了绘制状态机图所需的所有基本组件。本节中,我们来看看如何应用这些知识解决一个实际问题。

构建状态机图的步骤
要为一个类构建状态机图,你需要问自己以下几个问题:
- 这个类可能处于哪些状态?
- 是什么决定了类所处的状态?
- 每个状态会对哪些事件做出响应?
- 当事件发生时,会发生什么?


以下是针对“课程班次”类构建状态机图的具体过程。


确定状态与初始设置

首先,我们需要为这个状态机图确定一个初始状态和一个最终状态。接着,思考“课程班次”类可能具有的状态。


对于一个特定的课程班次,它可能处于以下几种状态:
- 可用:此时班次尚有名额。
- 已满:此时班次已无空余座位。
- 已开课:在注册期结束后,如果学生人数足够,课程将开课。
- 已取消:如果注册期结束后学生人数不足,课程将被取消。

当我们创建一个班次时,会从初始状态进入“可用”状态,并需要将注册学生数初始化为0,因为最初班次里没有任何学生。
定义状态转移

根据问题描述,当班次中有40名学生时,班次状态将变为“已满”。因此,我们可以从“可用”状态转移到“已满”状态。这个转移由一个变更事件触发,条件是 enrolled == 40。
当课程班次已满时,也可能因为学生退课而回到“可用”状态。这发生在调用 dropStudent() 操作时,该操作会注销学生。


当课程处于“可用”状态时,如果注册期结束且班内学生数大于等于10,课程将进入“已开课”状态。反之,如果注册期结束且学生数小于10,课程将进入“已取消”状态。最后,在取消班次后,需要销毁该班次对象,进入最终状态。
丰富状态内的行为

如果我们确切知道类中包含哪些操作,就可以在状态机图的状态内部添加更多信息。
例如,在“可用”状态下,我们可以执行 addStudent() 或 dropStudent() 操作。添加学生时需要注册该学生,退课则需要注销该学生。
同样,在“已取消”状态下,需要执行一系列操作:
- 注销所有学生。
- 删除课程安排。
- 删除班次本身。
复合状态机图
状态机图还可以是复合的,即一个状态机图嵌套在另一个状态机图中。这分为两种类型:顺序复合状态机图和并发复合状态机图。

顺序复合状态机图
在顺序复合状态机图中,一个对象在嵌套的多个状态机图中,同一时间只处于其中一个状态。图标表示这是一个复合状态,其内部包含另一个状态机图。外部的状态称为超状态,内部的状态称为子状态。
转移可以从超状态边界进入复合状态机图的初始子状态,也可以从某个子状态直接退出复合状态机图,进入超状态中的另一个状态。
例如,在一个表示“前进”的超状态中,可能包含“一档”、“二档”、“三档”等子状态。无论当前处于哪个档位,当触发“停车”事件时,都会从复合状态的边界转移回“一档”。这让我们能够用状态机图捕捉更复杂的行为。

并发复合状态机图
在并发复合状态机图中,一个对象在状态机图的每个并发区域中同时各处于一个状态。图标同样表示这是一个复合状态。

例如,在一个表示“课程未完成”的状态中,学生可能需要同时完成多项任务才能进入“通过”状态:完成所有实验、完成学期项目、并通过考试。状态机图内部分为不同的区域来分别表示这些并发任务。
同步与分叉
转移可以有多个源状态或目标状态,这代表了控制流的分叉或同步。在图中通常用一条粗横线(同步条)来表示。
- 分叉:一个转移指向同步条,同步条再指向多个目标状态(如A1和B1)。这表示控制流将同时进入A1和B1状态。
- 汇合:多个源状态(如A2和B2)的转移指向同一个同步条,再汇合成一个转移。这表示必须同时等待A2和B2状态都完成后,才能进行下一步(如进入“清理”状态)。
如果没有同步条,状态机图是确定性的,意味着在某一时刻只会选择多条路径中的一条,而不会同时执行。同步机制使得同时进入多个并发区域成为可能。
何时使用状态机图
你可能会问,是否需要为软件系统中的每个类都绘制状态机图?答案是否定的。一个软件系统包含大量类,为每个类绘制状态机图将耗费大量时间。

因此,没有必要为程序中的每个类都制作状态机图。我们通常只为那些具有显著动态行为的类绘制状态机图。如果对象的行为比较复杂,绘制状态机图有助于澄清其行为逻辑。

本节课中我们一起学习了如何为一个具体的类构建详细的状态机图。我们通过“课程班次”的例子,实践了从识别状态、定义转移,到丰富状态内部行为的全过程。此外,我们还探讨了顺序复合与并发复合状态机图的概念,以及如何使用同步条来表示控制流的分叉与汇合。最后,我们明确了状态机图的应用场景,即主要用于描述具有复杂动态行为的类。
软件工程:P47:设计模式入门 🧩
在本节课中,我们将要学习设计模式。设计模式是软件工程中用于解决常见问题的可复用方案。理解设计模式能帮助我们构建更健壮、更易维护的软件系统。


什么是设计模式?
上一节我们明确了本节课的主题。本节中,我们来看看设计模式的具体定义。
一个设计模式是针对软件设计中反复出现的问题的、通用的、可复用的解决方案。
它代表了在特定上下文环境下开发软件系统时,对某一常见问题的解决方案。当我们谈论设计模式时,我们谈论的是特定上下文中的问题与解决方案对。
设计模式对于描述如何以及为何解决非功能性需求特别有用。这意味着我们可以利用设计模式来应对一些非功能性需求,例如可扩展性、可维护性等。
但请注意,设计模式仅仅是一个描述或模板,它指导你如何解决一个能在多种情境下出现的问题,但它不是一个可以直接转换成源代码的完整设计。也就是说,你可以遵循这个模板来解决问题,但不能直接复用其代码。

如何描述设计模式?
了解了设计模式的定义后,我们来看看如何描述一个设计模式。
当我们描述一个设计模式时,通常描述的是类与对象之间的关系和交互,而不会指定最终涉及的具体应用类或对象。我们通常使用类图来描述一个设计模式。
设计模式广泛使用了继承和委托。继承涉及超类型和子类型的关系。委托则是指让其他类为某个类完成特定任务。


为何要学习设计模式?

既然设计模式不是现成的代码,我们为何要学习它呢?本节将探讨学习设计模式的价值。
如果我们能够复用一些成功的软件设计,这将有助于新程序员通过实例学习,并表现得更加像专家。有一个著名的模式目录,它记录了在特定上下文中非常有用的设计模式。
类比:如何成为国际象棋大师?

为了更好地理解学习设计模式的过程,我们可以将其与成为国际象棋大师的过程进行类比。
要成为国际象棋大师:
- 学习基本规则和物理要求:例如,棋子的名称、合法走法等。
- 学习原则:例如,特定棋子的相对价值、中心格子的战略价值等。
- 研究大师的对局:必须研究其他大师的棋局。这就像玩电子游戏,要成为专家,你必须研究其他高手是如何玩的。这些对局中包含必须被理解、记忆并反复正确应用的模式。
如何成为软件设计大师?

将上述类比应用到软件设计领域,我们可以勾勒出成为软件设计大师的路径。
要成为软件设计大师:
- 学习基本规则:例如,算法、数据结构、编程语言等。
- 学习原则:例如,结构化编程、模块化编程、面向对象编程等。
- 研究大师的设计:必须研究其他专家、大师的设计方案。同样,这其中存在着成百上千个可供学习的模式。
总结

本节课中,我们一起学习了设计模式的核心概念。我们了解到设计模式是解决软件设计中常见问题的可复用模板,它通过描述类与对象的关系来提供解决方案,而非提供具体代码。学习设计模式,如同学习象棋或任何复杂技能,需要从基础规则、核心原则入手,并最终通过研究和复用大师们总结出的成功模式来提升自己的设计能力。掌握设计模式是迈向软件设计专家的重要一步。
048:策略模式 🦆

在本节课中,我们将通过一个鸭子模拟游戏的例子,学习一种重要的设计模式——策略模式。我们将探讨如何通过分离变化的部分来设计更灵活、更易维护的系统。

游戏示例:鸭子模拟
现在,让我们从一个例子开始,这个例子是鸭子模拟游戏。



在这个游戏中,你需要模拟不同鸭子的行为。


在游戏内部,我们打算使用继承,因为我们有鸭子类,并且游戏中有不同类型的鸭子。例如,绿头鸭和红头鸭。
鸭子是一个抽象类。所有鸭子都能叫和游泳,因此我们将在超类中实现这些操作。

因为每种鸭子看起来都不同,所以我们将在子类中实现 display 方法。


现在,我们为一次重大创新做准备:鸭子可以飞了。

于是,我们必须在超类中添加另一个 fly 操作。

然后,我们将在超类中实现这个 fly 操作。但随后出现了问题,因为后来我们需要添加另一种鸭子类型:橡皮鸭。


我们知道橡皮鸭不能飞。如果我们在超类中实现 fly 操作,它将被所有子类使用。


但现在,由于橡皮鸭不会呱呱叫,我们必须重写 quack 操作,使其发出吱吱声而不是呱呱叫。同时,因为橡皮鸭不能飞。

我们还必须重写 fly 方法,使其什么都不做,因为橡皮鸭不能飞。
但后来,我们计划再包含另一种鸭子类型:木头诱饵鸭。我们知道木头诱饵鸭不会叫也不会飞。
所以我们必须重写 quack 和 fly 方法,让它们什么都不做。最初,Joe 认为为了复用的目的使用继承会很棒,但最终结果并不理想,因为我们有不同类型的鸭子,它们有不同的行为,如果仅仅使用继承来实现鸭子和不同类型的鸭子,系统会变得难以维护。



继承的缺点
现在,思考一下使用继承来提供行为的缺点。
以下是可能的缺点列表:
- 代码在子类间重复:不,因为我们只需要在超类中定义一次(例如
fly),它就会被所有子类使用,所以我们不必在子类间重复代码。 - 运行时行为改变困难:是的,因为在游戏过程中,如果我们想改变行为(例如让橡皮鸭呱呱叫而不是吱吱叫),会很困难,因为我们在子类中硬编码了行为。
- 我们可以让鸭子跳舞:这是真的。我们可以在超类中包含另一个名为
dance的操作。 - 如何了解所有鸭子的行为:是的,因为在我们的例子中,为了知道橡皮鸭不能呱呱叫(而是吱吱叫)以及橡皮鸭不能飞,我们必须实际进入子类查看源代码,并仔细检查那些被我们在子类中定义的操作所覆盖的方法,因此很难了解所有鸭子的行为。
- 鸭子不能同时飞和叫:鸭子可以同时飞和叫。我们可以简单地同时调用这两个操作。
- 更改可能无意中影响其他鸭子:是的,因为如果你在超类中更改某些内容,它会影响所有子类。


接口的局限性
如果我们使用接口来实现鸭子的行为会怎样?
我们有一个名为 Flyable 的接口,还有一个名为 Quackable 的接口,它们有抽象操作 fly 和 quack。这意味着我们将在实际实现类中实现这些操作。

这是一个好的设计吗?不是,因为绿头鸭和红头鸭的 fly 操作完全相同。这意味着我们将在 MallardDuck 和 RedheadDuck 的实现中有重复的代码。所以这不是一个好主意。

原因是我们将会有重复的代码。
设计原则:分离变化的部分

那么,如何想出一个更好的设计呢?我们将利用一个设计原则:识别出应用中变化的部分,并将它们与保持不变的部分分离。
我们要做的是,将变化的部分封装起来,这样它就不会影响代码的其余部分。如果我们利用这个设计原则,最终会达到什么效果?最终,代码更改带来的意外后果会更少,系统的灵活性也会更高。

现在,让我们回到鸭子模拟游戏。实际变化的部分是飞行行为和叫声行为。所以我们要把它们抽离出来,放到别的地方。现在,Duck 类仍然是所有鸭子的超类,但我们将把飞行行为和叫声行为抽离出来,放到别处。

现在,飞行和叫声行为各自拥有自己的一组类,各种行为实现将存在于这些飞行行为和叫声行为类中。

设计原则:面向接口编程

我们可以在鸭子模拟游戏中利用的另一个设计原则是:面向接口编程,而不是面向实现编程。通过这种方式,鸭子类不需要知道它们行为的任何实现细节。
那么,“面向接口编程,而不是面向实现编程”是什么意思呢?让我们看一个例子。

假设我们有超类 Animal,然后有具体的实现,例如 Dog 和 Cat。那么,面向一个典型的实现编程意味着我们将 d 初始化为一只 Dog,然后让这只狗叫。这意味着我们将变量 d 硬编码为 Dog 类型,这不灵活,因为以后如果我们想把 d 改成 Cat,就不可能了。
所以,更好的方式是面向接口或超类编程。这意味着我们将创建一个 Animal 类型的变量 a,然后将 a 初始化为一只 Dog,然后调用 a 的 makeSound 方法。这是一种更好的定义动物的方式,因为在游戏中,我们以后可以将 a 更改为另一种类型,例如 Cat。
与其在代码中硬编码子类型,我们应该在运行时分配具体的实现对象。这意味着我们可以创建一个 Animal 变量,然后使用一个操作来创建具体的动物。例如,如果我们想要动物是狗,那么这个操作将返回一只狗;如果我们想要动物是猫,那么这个操作将返回一只猫,然后将猫赋值给变量 a,最终我们可以调用动物的 makeSound 方法。
通过这样做,我们获得了更大的灵活性,因为我们可以在游戏期间创建任何类型的动物,并且我们可以根据创建的动物类型调用 makeSound 操作来发出相应的声音。
现在,我们将对鸭子的行为做同样的事情。

定义行为接口
现在,我们将有一个名为 FlyBehavior 的接口,然后我们将在其子类中有不同的飞行行为实现。例如,FlyWithWings 或 FlyNoWay。FlyWithWings 是为有翅膀的类型实现的飞行行为。FlyNoWay 是为那些不能飞的鸭子(例如橡皮鸭或木头诱饵鸭)实现的。

我们还将有一个 QuackBehavior 接口,然后我们将在其子类中实现具体的叫声行为,例如 Quack、Squeak 或 Mute。

现在,通过使用这种设计,我们可以为不同类型的鸭子复用飞行和叫声行为,并且我们也可以轻松地向这两个接口添加新的行为。
因此,现在鸭子模拟游戏既具有复用的好处,同时又易于维护。



因为如果我们想要维护叫声行为和飞行行为,我们只需要修改 FlyBehavior 接口和 QuackBehavior 接口。

新设计的优势

现在,使用新设计,如果你需要在鸭子模拟游戏中添加火箭动力飞行,会怎么做?这会很容易,对吧?


在 FlyBehavior 下,我们将有另一个实现,称为 FlyRocketPowered。


你能想到一个可能想使用叫声行为但不是鸭子的类吗?例如,在你的游戏中,你可能有一个鸭子呼叫器试图假装成鸭子,所以这个鸭子呼叫器对象可能会在游戏中使用叫声行为。
现在,我们还需要添加两个变量来跟踪每只鸭子的飞行行为和叫声行为,这些变量被声明为行为接口类型。

然后,我们将实现一些进一步的操作来执行叫和飞。



组合行为
现在,每只鸭子都有一个引用,指向实现了飞行行为接口和叫声行为接口的某个对象。当你尝试执行飞行和尝试执行叫时,你将调用这两个操作,并利用飞行行为来执行飞行,同时利用指定的叫声行为来执行叫。
例如,对于绿头鸭,叫声行为将是 Quack,飞行行为将是 FlyWithWings。
橡皮鸭呢?对于橡皮鸭,叫声行为应该是 Squeak,飞行行为应该是 FlyNoWay,因为橡皮鸭不能飞。


这就是整个图景。现在每只鸭子都将有一些飞行行为和叫声行为,我们将在子类中定义具体的叫声行为和飞行行为。例如,绿头鸭的叫声行为,我们将使用 Quack 这个具体实现;飞行行为,我们将使用 FlyWithWings 这个具体实现。


动态设置行为
为了让游戏更加灵活,我们将实现这两个操作:setFlyBehavior 和 setQuackBehavior,这样我们就可以在游戏运行时动态地改变飞行行为和叫声行为。


这就是整个图景。在超类 Duck 下,我们必须声明飞行行为和叫声行为,然后我们有不同类型的鸭子。接着,我们使用接口来定义飞行行为和叫声行为,在每个接口下,我们将有飞行行为的具体实现和叫声行为的具体实现。
现在你可以看到,飞行行为被封装在 FlyBehavior 这个接口中,叫声行为被封装在 QuackBehavior 这个接口中。然后在超类下,我们有不同类型的鸭子。所以我们说这里存在“是一个”的关系(超类和子类)。
在接口下,我们有不同的具体实现。在每只鸭子中,你可以看到每只鸭子都将有一些飞行行为和叫声行为,所以我们说每只鸭子“有一个”飞行行为和“有一个”叫声行为。
策略模式
现在,将每个接口下的一组行为视为一个算法家族。所以我们有不同的飞行算法和不同的叫声算法。这些行为或算法是可以互换的。同时,客户端(例如鸭子)将使用封装好的飞行和叫声算法家族。
封装意味着你不必确切知道我们如何实现飞行行为和叫声行为,你只需要在游戏中使用该行为即可。
这被称为策略模式,因为我们使用不同的策略或不同的算法来做某事。

这是我们用于策略模式的类图。在策略模式中,我们有一个客户端(例如 Duck),然后每只鸭子都有一个引用(这个箭头表示“有一个”引用)指向鸭子可以用于飞行和叫的不同策略。然后,飞行和叫的策略是鸭子的一部分,或者说在鸭子内部,我们必须声明飞行行为和叫声行为。
现在我们说,这个客户端有一个指向策略对象的引用。这个策略类将为所有支持的算法声明一个公共接口,所以我们将在策略的不同实现下有不同的算法。然后这些是不同算法的具体实现。
这是我们在策略模式中利用的另一个设计原则:我们倾向于使用“有一个”关系,而不是继承。


“有一个”优于“是一个”
所以继承意味着我们有超类和子类。

但“有一个”关系意味着每个客户端,它们将有不同的策略来做某事(例如飞行和叫)。所以在我们的类图中,我们倾向于这种水平线,即客户端将有不同的策略来做某事,而不是继承。
在鸭子模拟游戏中,我们也倾向于“有一个”关系,因为每只鸭子将有不同的飞行行为和不同的叫声行为。




总结
在本节课中,我们一起学习了策略模式。我们从一个鸭子模拟游戏的例子开始,分析了单纯使用继承和接口带来的问题,如代码重复、维护困难、缺乏灵活性等。
接着,我们引入了两个关键的设计原则:
- 识别应用中变化的部分,并将其封装起来。
- 面向接口编程,而不是面向实现编程。
通过应用这些原则,我们将鸭子的飞行行为和叫声行为抽离出来,定义为独立的策略接口(FlyBehavior, QuackBehavior),并为其提供多种具体实现。鸭子类(客户端)通过“有一个”的关系组合这些行为,而不是通过继承硬编码行为。这种方式使得行为可以独立变化、轻松替换和复用,极大地提高了系统的灵活性和可维护性。


最后,我们看到了策略模式的正式类图,并理解了其核心思想:定义算法家族,分别封装,让它们可以互相替换,使得算法的变化独立于使用算法的客户端。
049:观察者模式 👀
概述
在本节课中,我们将要学习一种名为“观察者模式”的行为型设计模式。我们将了解它的核心思想、工作原理、类图结构以及其实现时可能遇到的复杂性。通过一个电子表格应用的例子,我们将清晰地理解观察者模式如何管理对象间的一对多依赖关系。
设计模式概述

如果你想在软件工程项目中学习更多可用的不同设计模式,那么《设计模式》这本书是设计模式的圣经。
在这本设计模式圣经中,你可以学习到不同类型的设计模式,例如创建型模式、结构型模式、行为型模式或并发模式等。

当我们描述一个设计模式时,通常使用类图来描述,但除了类图,我们还必须指定所有这些内容。

例如,设计模式的名称、试图解决的问题、上下文、关注点、解决方案等,这些都是为特定设计模式必须指定的内容。
到目前为止,我们已经介绍了策略模式,它是一种行为型设计模式。接下来我们将要介绍的是观察者模式。
观察者模式的核心思想
观察者模式背后的思想很简单。让我们来看一个例子。

考虑一个电子表格应用程序。在这个应用程序下,你可能有一组数据,以及多个图表。你需要基于同一组数据绘制不同的图表。
但在观察者模式中,我们不称它们为数据和图表,我们称它们为一个单一的主题和多个观察者,这就是我们所说的观察者模式。
观察者模式是这样工作的。假设我们有一个单一的主题和多个观察者对象。
这些观察者对象订阅了这个主题,而对象D没有订阅这个主题。
我们假设,例如,主题可能管理一些数据。在这些观察者中,它们可能是一个电子表格应用中的图表。
一旦数据被更新,主题就会通知所有订阅了它的观察者去更新图表。
然后,所有的观察者对象都会收到关于更新的通知,并相应地更新图表。

现在,对象D没有订阅这个主题。之后,它可能会订阅这个主题。
然后,它将成为这个主题的观察者对象集合中的一员。之后,当我们更新这个主题时,所有的观察者对象都会相应地更新。
你也可以在任何时候取消订阅一个特定的观察者对象。一旦取消订阅,它将不再获得更新。

在观察者模式中,我们讨论的是一对多的依赖关系,因为我们有一个主题和多个观察者。一旦主题被更新,它将通知观察者对象更新自身。所以主题持有状态。
而这些是依赖对象。它们将收到关于更新的通知。
观察者模式的类图结构
这是我们用于观察者模式的类图。在观察者模式下,我们有主题以及主题的具体实现。
主题持有对观察者的引用,在观察者接口下,我们有观察者的不同具体实现。具体观察者持有对具体主题的引用。
现在,这个类定义了主题接口,然后每个主题可能有多个观察者。在所有观察者中,都有在这个观察者接口下的具体实现。
具体主题是主题接口下的一个实现。具体观察者是观察者接口下的实现。

每个观察者都向一个具体主题注册,以接收通知和更新。
松耦合的设计原则
在观察者模式中,我们说我们有一对多的依赖关系,但我们仍然说主题和观察者是松耦合的,这是为什么?
仅仅是因为主题通过接口了解观察者。我们可以在任何时候在观察者接口下添加新的观察者。对主题或观察者的更改不会相互影响。

总的来说,我们说主题和观察者通过接口相互了解。

这就是为什么我们实现了这个设计原则。我们努力在交互的对象之间实现松耦合的设计。
这很好,因为松耦合的设计最小化了对象之间的相互依赖。

实现上的复杂性

当我们描述观察者模式时,它相当简单,我们只是使用这个类图,并说我们有一个主题,然后主题将拥有多个观察者。
但现在当我们实现观察者模式时,它可能相当复杂。
因为观察者模式必须支持广播通信,以便主题更新所有观察者。我们还需要处理事件通知协议,例如,主题应该宣布哪些事件,是否每个事件都应该通知给每个观察者,主题是否应该定义不同种类的事件并允许观察者有选择地订阅,等等。
这就是为什么我们说,当我们描述观察者模式时有点简单,我们在类图中只是有一些主题和多个观察者。但当我们实现观察者模式时,它实际上可能相当复杂。
总结

本节课中,我们一起学习了观察者模式。我们了解了它是一种用于管理对象间一对多依赖关系的行为型设计模式,其核心是主题与观察者通过接口进行松耦合的交互。我们通过类图分析了其结构,并认识到虽然其概念描述起来相对直接,但在实际实现中需要考虑广播通信和事件协议等复杂因素。掌握观察者模式有助于构建灵活、可维护的软件系统。
050:中介者模式 🧩


在本节课中,我们将介绍更多的设计模式。我们将继续探讨中介者模式。
上一节我们介绍了其他设计模式,本节中我们来看看中介者模式。

接下来要介绍的模式被称为中介者模式。

对于中介者模式,我们将使用以下示例。假设我们要为一个智能家居构建一个软件系统,在这个系统中会有不同的设备,例如闹钟、日历、洒水器和咖啡机。这是你可以在智能家居中实现的功能,例如,当闹钟响起时,它会通知咖啡机开始煮咖啡。洒水器会根据日历、温度和雷达等不同条件来决定是否开启。
那么,这种设计有什么问题呢?假设我们允许各个设备(同事类)直接相互通信。问题在于,每个设备都必须维护一套用于执行某些操作的规则。例如,闹钟需要检查日历、检查洒水器,并通知另一个设备(如咖啡机)开始煮咖啡。而咖啡机又需要维护另一套规则,例如检查日历、检查闹钟等。因此,在这种设计下,每个设备都必须维护一套规则。之后,当我们试图维护所有这些规则时会变得困难,因为我们无法确切知道规则存在于何处。例如,“开始煮咖啡”这个事件,我们不知道它存在于闹钟、日历还是洒水器中。因此,维护和跟踪房屋内的所有规则变得困难。
这就是为什么我们不采用这种设计,而是选择基于中介者模式的另一种设计。

这就是中介者模式。现在,我们不再让各个设备维护自己的规则,而是将所有规则集中在中介者中管理。这些设备不再直接相互交互,它们与中介者交互。然后,当中介者内部发生某些事件时,它会通知某些设备执行相应的操作。

你可以将这个中介者视为一个集中控制器,用于控制智能家居内的行为,并将所有规则集中在这个中介者中管理。

这是中介者模式的类图。我们有一个中介者接口,以及该接口的具体实现。我们还有不同的同事类(设备),以及同事类的具体实现。同事类持有对中介者接口的引用,而具体的中介者则持有对具体同事类的引用。

以下是核心概念的结构:
// 中介者接口
interface Mediator {
void notify(Colleague sender, String event);
}
// 同事类基类
abstract class Colleague {
protected Mediator mediator;
public Colleague(Mediator m) {
this.mediator = m;
}
}
// 具体同事类示例
class Alarm extends Colleague {
// ... 具体实现
public void ring() {
mediator.notify(this, "alarmRang");
}
}
// 具体中介者
class SmartHomeMediator implements Mediator {
private Alarm alarm;
private CoffeePot coffeePot;
// ... 其他同事类引用
@Override
public void notify(Colleague sender, String event) {
// 集中处理所有交互逻辑
if (sender instanceof Alarm && event.equals("alarmRang")) {
coffeePot.startBrewing();
}
// ... 其他规则
}
}
中介者模式通常用于协调相关的图形用户界面组件。例如,当你在用户界面中选择一个特定选项时,可以启用界面中的某些部分。我们使用一个集中的中介者来控制用户界面的行为。


中介者模式的优点如下:
以下是中介者模式的主要优点:
- 解耦同事类:使用中介者模式后,同事类不再直接相互通信。
- 集中控制:我们将所有规则集中在一个中介者中管理。如果你想维护规则,只需进入中介者类进行更新。
- 简化协议:因为同事类无需直接相互通信。
- 限制子类化:当逻辑或规则需要扩展时,我们通常只需要扩展中介者类。
但中介者模式也有缺点:
以下是中介者模式的主要缺点:
- 中介者对象可能变得复杂:因为你将所有规则集中在一个控制器中,所以实现控制器本身可能会变得复杂。


本节课中我们一起学习了中介者模式。我们了解了其定义、通过智能家居示例分析了直接交互设计的问题、学习了中介者模式如何通过引入一个集中协调者来解耦对象间的复杂交互、查看了其标准类图结构,并总结了该模式的优缺点。中介者模式的核心思想是将多对多的复杂通信转化为一对多的集中管理,从而提高系统的可维护性和灵活性,但需注意避免中介者自身变得过于庞大复杂。
软件工程:P51:代理模式 🛡️

在本节课中,我们将要学习代理模式。代理模式是一种结构型设计模式,它允许我们延迟创建和初始化一个高成本对象,直到真正需要使用时才进行。
上一节我们介绍了其他设计模式,本节中我们来看看代理模式。代理模式旨在解决一个问题:我们希望推迟创建和初始化一个对象的全部成本,直到我们实际需要使用它时。
例如,假设一个网页中包含多媒体内容。我们不希望在打开网页时就立即加载并显示所有内容,而是等到用户需要查看时才去下载和加载。这就是“延迟创建和初始化对象的全部成本”的含义。在文档编辑器中,我们可能包含图片,但只在用户需要查看时才真正加载这些图片,从而保证打开文档的速度。

我们在这里所做的是尝试按需创建昂贵的对象。


以下是代理模式的一个应用示例。在文档编辑器中,我们有一个图像代理。这个图像代理负责访问存储在硬盘上的实际图像文件。
这可能是文档编辑器的一种设计。我们有一个图形接口。当你尝试获取图像时,必须通过图像代理来获取。然后,图像代理会帮助你从硬盘加载图像。
以下是如何实现图像代理的逻辑:
- 如果我们确实需要绘制图像,那么就从硬盘加载图像并绘制它。
- 但还有另一种选择,我们称之为“获取扩展”。我们只获取图像的尺寸,然后在文档中创建一个空框。如果图像尚未加载,我们只返回其尺寸(宽度和高度),以便在文档中创建一个占位空框。否则,我们返回实际的图像对象,以便在文档中显示真实图像。
这就是让文档编辑器通过代理来访问特定图像的含义。

这是代理模式使用的类图。我们有一个客户端(例如文档编辑器),一个主题接口。当我们尝试访问真实主题时,必须通过一个代理类。除非确实必要,否则我们不会真正获取真实主题。代理类实现了主题接口,最终的真实主题则存储在硬盘上。

本节课中我们一起学习了代理模式。代理模式的核心思想是通过一个代理对象来控制对另一个对象的访问,常用于实现延迟加载、访问控制、日志记录等功能。其关键结构是客户端通过代理接口与代理对象交互,而代理对象在必要时才创建或调用真实的对象。
软件工程:P52:桥接模式与单例模式

在本节课中,我们将要学习两种设计模式:桥接模式和单例模式。桥接模式用于将抽象部分与实现部分分离,使它们可以独立变化。单例模式则确保一个类只有一个实例,并提供一个全局访问点。这两种模式在软件设计中都非常实用。
桥接模式
上一节我们介绍了适配器模式,本节中我们来看看桥接模式。桥接模式的核心思想是将抽象与实现解耦,使它们可以独立地扩展。
假设我们有一个场景:需要处理不同类型的窗口(如图标窗口、应用窗口)在不同的操作系统(如Windows、Mac)上显示。如果为每种窗口类型和操作系统的组合都创建一个具体的实现类,会导致类的数量急剧膨胀,难以维护。

这就是桥接模式要解决的问题。它通过引入一个“实现”接口,将窗口的抽象类型(如IconWindow)与其具体的平台实现(如WindowsImpl)分离开来。

以下是桥接模式的结构:
- 抽象部分:定义抽象接口(如
Window),并持有一个对实现部分接口的引用。 - 扩充抽象:扩展抽象接口,定义不同的具体抽象(如
IconWindow,ApplicationWindow)。 - 实现部分:定义实现类的接口(如
WindowImpl),提供底层操作。 - 具体实现:实现实现部分接口,提供平台特定的代码(如
WindowsImpl,MacImpl)。

通过这种设计,当我们需要在Windows系统上绘制一个图标窗口时,IconWindow会调用其持有的WindowImpl引用,而这个引用指向的是WindowsImpl的具体实例。这样,窗口类型和操作系统实现就被“桥接”在了一起,彼此可以独立变化。
桥接模式的关键在于分离。它分离了抽象的层级(窗口类型)和实现的层级(操作系统),使得增加新的窗口类型或支持新的操作系统都变得更加容易,无需修改另一方。
单例模式
了解了用于处理多维度变化的桥接模式后,我们来看一个目标完全相反的模式——单例模式。单例模式确保一个类只有一个实例,并提供一个全局访问点。
这个模式极其简单,但应用广泛。它的核心目的是控制实例数量。

为什么我们需要确保只有一个实例呢?以下是几个常见的例子:
- 线程池
- 缓存系统
- 对话框
- 日志记录器
- 数据库连接池
在这些场景中,多个实例可能导致资源冲突、状态不一致或资源浪费。单例模式通过封装其创建过程,确保任何地方访问到的都是同一个对象。



单例模式的类图非常简单。其核心实现通常包含以下部分:
- 私有静态实例变量:用于保存类的唯一实例。
- 私有构造函数:防止外部通过
new关键字创建实例。 - 公共静态访问方法:提供获取该唯一实例的全局访问点。
一个典型的代码实现如下:
public class Singleton {
// 1. 私有静态变量,持有唯一实例
private static Singleton instance;
// 2. 私有构造函数,防止外部实例化
private Singleton() {}
// 3. 公共静态方法,提供全局访问点
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

使用时,我们通过Singleton.getInstance()来获取这个唯一的实例,而不是直接创建它。
总结
本节课中我们一起学习了两种重要的设计模式。
- 桥接模式:通过将抽象(如窗口类型)与实现(如操作系统)分离,使它们可以独立扩展。它使用组合关系(抽象持有实现的引用)来替代继承,解决了多维度变化导致的类爆炸问题。
- 单例模式:确保一个类只有一个实例,并提供一个访问它的全局节点。它常用于管理共享资源或控制全局状态,实现关键在于私有化构造函数并提供静态的实例获取方法。

理解这两种模式有助于我们在设计软件时,更好地管理对象的创建、结构和生命周期。
053:工厂模式 🏭

在本节课中,我们将要学习本课程涵盖的最后一个设计模式——工厂模式。顾名思义,工厂模式用于“制造”或“创建”对象。我们将通过一个披萨店的软件系统示例,来理解工厂模式如何帮助我们遵循重要的设计原则,构建更灵活、更易于维护的代码。
从问题出发:为何需要工厂模式?
上一节我们介绍了设计模式的基本概念,本节中我们来看看工厂模式要解决的具体问题。

假设我们使用继承体系,有一个超类型(Supertype)和多个子类型(Subtype)。在初始化对象时,我们必须指定具体的子类型。
例如,我们有一个 Duck 超类,以及 MallardDuck、DecoyDuck、RubberDuck 等子类。在不同的场景下,我们需要创建不同的鸭子对象:

if (场景 == “野餐”) {
duck = new MallardDuck();
} else if (场景 == “狩猎”) {
duck = new DecoyDuck();
} else if (场景 == “洗澡”) {
duck = new RubberDuck();
}

这种代码存在一个问题:它对修改不封闭。如果未来增加了新的场景或新的鸭子类型,就必须修改这段代码,添加新的 if-else 分支。这违反了开闭原则。

核心设计原则 ⚙️
在深入工厂模式之前,我们需要理解两个支撑该模式的核心设计原则。

开闭原则 (Open-Closed Principle):软件实体(类、模块、函数等)应该对扩展开放,但对修改关闭。这意味着,当需要添加新功能时,应通过扩展而非修改现有代码来实现。
封装变化原则 (Encapsulate What Varies):识别出应用中会变化的部分,并将其与保持不变的部分分离。将变化的部分封装起来,以后可以独立修改或扩展,而不会影响其他部分。

在披萨店的例子中,创建哪种具体披萨对象的逻辑是会变化的(可能增加或减少披萨种类),而制作披萨的步骤(准备、烘烤、切割、装盒)是相对稳定的。工厂模式正是将“创建对象”这个变化点封装起来。

披萨店示例:初始设计 🍕
让我们通过一个具体的披萨店订单系统来理解问题。



在 PizzaStore 类中,有一个 orderPizza 方法,其流程如下:

- 根据订单类型创建披萨对象。
- 准备披萨 (
prepare)。 - 烘烤披萨 (
bake)。 - 切割披萨 (
cut)。 - 装盒披萨 (
box)。 - 返回披萨。
初始的代码中,创建披萨对象的逻辑直接写在 orderPizza 方法里:

Pizza pizza;
if (type.equals(“cheese”)) {
pizza = new CheesePizza();
} else if (type.equals(“greek”)) {
pizza = new GreekPizza();
} else if (type.equals(“pepperoni”)) {
pizza = new PepperoniPizza();
}
// ... 后续制作步骤
这个设计的问题与之前的鸭子例子相同:如果菜单需要变动(例如下架希腊披萨,或新增海鲜披萨),我们必须打开并修改 PizzaStore 类的源代码。这违反了开闭原则。
引入工厂模式 🏗️


为了解决上述问题,我们将创建对象的职责抽取出来,封装到一个单独的类中,这个类就是“工厂”。

我们创建一个 SimplePizzaFactory 类,它专门负责根据类型创建披萨对象:
public class SimplePizzaFactory {
public Pizza createPizza(String type) {
Pizza pizza = null;
if (type.equals(“cheese”)) {
pizza = new CheesePizza();
} else if (type.equals(“greek”)) {
pizza = new GreekPizza();
} else if (type.equals(“pepperoni”)) {
pizza = new PepperoniPizza();
}
return pizza;
}
}
现在,PizzaStore 类持有一个 SimplePizzaFactory 的引用。当需要创建披萨时,它委托给工厂对象:
public class PizzaStore {
SimplePizzaFactory factory;
public PizzaStore(SimplePizzaFactory factory) {
this.factory = factory;
}
public Pizza orderPizza(String type) {
Pizza pizza;
// 委托工厂创建对象
pizza = factory.createPizza(type);
pizza.prepare();
pizza.bake();
pizza.cut();
pizza.box();
return pizza;
}
}

通过这种改造,披萨类型的变化被隔离在 SimplePizzaFactory 类中。未来修改菜单时,我们只需改动工厂类,而 PizzaStore 的核心订单流程保持不变,符合开闭原则。
工厂模式的类图 📐
以下是简单工厂模式(Simple Factory)的典型类图结构:

<<Creator>> <<Product>>
SimplePizzaFactory Pizza
+createPizza(type) +prepare()
+bake()
| +cut()
| uses +box()
|
| --------------------------
| | | |
| CheesePizza GreekPizza PepperoniPizza
| (Concrete Products)
- 产品 (Product):
Pizza接口或抽象类,定义了产品的通用操作(prepare,bake等)。 - 具体产品 (Concrete Product):
CheesePizza、GreekPizza等,实现了Pizza接口。 - 创建者/工厂 (Creator):
SimplePizzaFactory,负责创建具体产品的对象。它包含一个如createPizza的工厂方法。

这个结构清晰地分离了“对象的创建”与“对象的使用”。
何时使用及优缺点 ⚖️
在学习了工厂模式的结构后,我们来探讨其适用场景和利弊。
何时使用设计模式(包括工厂模式)?
以下是适用设计模式的一些情况:
- 针对反复出现的、具有变化性的问题寻求解决方案。
- 解决方案需要多个步骤协作,而非简单的线性指令。
- 开发者更关注成熟解决方案的存在性,而非从头推导。
使用设计模式(工厂模式)的优点:
- 促进大规模设计复用:提供了经过验证的解决方案模板。
- 捕获专家知识:模式中蕴含了设计权衡和最佳实践,使新手也能写出专家级代码。
- 改善开发沟通:模式名称(如“工厂模式”)成为团队共享的词汇,提升了沟通效率。
- 助力面向对象设计:大多数模式使用类图描述,自然地引导开发者进行良好的面向对象设计。
使用设计模式(工厂模式)的缺点:
- 非直接代码复用:模式是设计蓝图,而非可直接拷贝粘贴的代码库。
- 看似简单,实现复杂:类图描述简洁,但实际实现时可能需要处理许多细节,变得复杂。
- 可能导致“模式超载”:团队可能花费过多时间寻找“完美”的模式,而忽略了简单直接的解决方案。
- 验证依赖经验:模式是否正确应用,更多地依赖同行评审和经验判断,而非自动化测试。
- 集成过程人力密集:将模式成功整合到开发流程中,需要专家的指导和团队的深入学习。
总结 📝
本节课中我们一起学习了工厂模式。我们从一段违反开闭原则的代码开始,分析了直接实例化对象带来的维护性问题。随后,我们引入了开闭原则和封装变化原则作为理论指导。
通过披萨店的案例,我们逐步演示了如何将变化的部分——对象创建逻辑——抽取出来,封装到一个独立的 SimplePizzaFactory 类中。这样,PizzaStore 类就只依赖于 Pizza 抽象和工厂接口,从而对具体的披萨类型变化关闭。

最后,我们讨论了工厂模式的类图结构,并分析了设计模式通用的适用场景、优点与缺点。记住,工厂模式的核心价值在于将对象的创建与使用分离,从而提高代码的灵活性、可维护性和可扩展性。
054:反模式 🚫

在本节课中,我们将要学习软件工程中的“反模式”。反模式是那些看似有效、实则有害的常见解决方案。了解它们能帮助我们避免在开发软件时犯下类似的错误。
上一节我们介绍了优秀的设计模式,本节中我们来看看它们的反面——反模式。
什么是反模式?
设计模式是专家总结出的优秀解决方案,是开发者应当遵循的典范。反模式则是糟糕的解决方案,是开发者应当避免的实践。

有人可能会问,为何要研究这些糟糕的方案?原因在于,作为一名优秀的程序员,你应当能够在构建软件系统时,识别并避免采用这些有害的解决方案。
反模式有多种类型,例如:
- 开发相关的反模式
- 面向对象的反模式
- 组织管理的反模式
- 特定领域的反模式
与设计模式类似,反模式也有其权威的参考著作。
软件中的反模式

在社会中,存在一些不应做的事情,例如犯罪、恐怖主义等,我们称之为社会反模式。同样,在软件项目中,也存在一些不应采取的实践,即软件反模式,例如“面条式代码”和“上帝类”。本课程将重点介绍这两种。
面条式代码 🍝

面条式代码是指结构混乱、难以扩展或修改的代码。其结构是非结构化的。
而我们希望软件系统是结构化良好的代码,这样的系统才易于维护和修改。
面条式代码的症状
以下是面条式代码的一些典型症状:
- 快速演示代码:为了快速完成编程作业或演示而编写的、只求能运行的代码,最终可能演变成面条式代码。
- 孤独的程序员:不与其他开发者交流的程序员所编写的代码。
- 文档过时或匮乏:在开发过程中缺乏或没有更新文档。
- 50%的维护时间用于重新理解系统:维护系统时,需要花费大量时间阅读和理解源代码。
- 程序员犹豫不决:当被要求修改系统时,宁愿重写整个系统也不愿修改现有代码。
- 系统无法复用:代码难以在其他地方重用。
- 包含大量变通方案:代码中充斥着许多临时性的、非标准的解决方案。
面向对象中的面条式代码症状
在面向对象编程中,面条式代码可能表现为:
- 具有许多参数的方法:
public void processData(int a, String b, double c, Object d, ...) - 可疑的类或全局变量:类的职责不清晰,或过度使用全局变量。
- 对象间存在意外且难以预见的关系:类之间的耦合度过高且混乱。
- 过程式的方法:在面向对象系统中编写了大量过程式的代码。
- 丧失了面向对象的优势:代码没有体现出封装、继承、多态等优点。

核心问题在于,如果修改一个软件系统需要花费极长时间去重新理解源代码,那么它就是面条式代码,此时开发者宁愿重写整个系统。
上帝类 👑
另一个常见的反模式是“上帝类”,我们在讨论重构时也曾提及。
上帝类的症状

上帝类通常具有以下症状:
- 单个类拥有过多属性和操作:这意味着一个类承担了过多的职责。
- 违反了面向对象设计的内聚性原则:内聚性原则要求一个类只应负责一件事,而不是许多事。
- 维护起来如同噩梦:由于一个类中包含了太多职责和操作,修改这个上帝类将变得异常困难。
上帝类的典型成因
以下是导致上帝类出现的常见原因:

- 需求规格说明不当
- 缺乏面向对象的架构设计
- 缺乏任何架构设计
- 缺乏架构执行的监督
- 干预有限:将所有功能都塞进一个类中。
上帝类的后果
上帝类会带来一系列不良后果:
- 丧失了面向对象的优势
- 代码过于复杂,难以复用或测试
- 产生不必要的代码
- 开发成本高昂,因为存在一个庞大的类
- 最终不得不重构代码

系统分析与设计内容回顾 📋
在最近的几讲中,我们讨论了系统分析与设计的核心内容。
首先,我们探讨了架构设计,即如何将各个子系统组织起来形成完整的系统。
接着,我们学习了如何识别分析类,将系统划分为边界类、控制类和实体类。
此外,我们还介绍了如何确定用于实现的所有类。在这个过程中,可以利用设计模式来处理一些反复出现的设计需求或非功能性需求。

总结
本节课我们一起学习了如何为软件系统建立分析与设计模型。在这个模型中,你需要明确以下几点:
- 如何组织子系统以构建整个系统。
- 所有的分析类,包括边界类、实体类和控制类。
- 所有用于实现的类,这些类应包含实际的源代码。
同时,我们重点学习了两种需要避免的反模式:面条式代码和上帝类。识别并避免这些反模式,对于构建可维护、可扩展的高质量软件至关重要。

以上就是系统分析与设计部分希望涵盖的全部内容。
055:实现软件质量 🎯
在本节课中,我们将探讨软件质量保证。这意味着,为了确保最终能为软件项目交付一个高质量的产品,我们需要做哪些事情。
概述

软件质量保证贯穿整个项目周期,而不仅仅是测试。虽然充分的测试很重要,但我们需要在整个项目中持续进行质量保证活动。

软件质量保证概述
上一节我们介绍了课程目标,本节中我们来看看软件质量保证的具体定义。
软件质量保证是指专业人员在整个开发周期中,应用一系列程序、技术和工具,以确保产品达到或超过预设标准的过程。
为了确保最终产品的质量,我们需要进行以下核心活动:
- 质量保证:定义整个组织必须遵循的标准,这些标准是产出高质量软件的基础。
- 质量规划:为特定的软件产品选择和裁剪标准。不同项目可能适用不同的标准集合。
- 质量控制:确保在整个项目中,所有标准都得到遵循。持续的质量改进是总体目标。
早期缺陷修复的重要性

在质量规划中,我们确定了标准。本节中我们来看看遵循这些标准的一个关键好处:及早发现缺陷。
我们之前见过这个图表。所有人都应该知道,如果在项目初期发现并修复缺陷,成本会很低;如果在项目后期修复,成本将非常高昂。
因此,软件质量保证活动在项目初期就能带来回报,因为它有助于及早发现缺陷,从而以较低成本进行修复。


实现软件质量的要素
了解了早期修复的好处后,我们来看看实现软件质量需要哪些具体要素。
在学期初我们提到,一个项目有四个重要部分:项目、人员、过程和产品。为了实现软件质量,我们需要:
- 良好的过程:需要一套完善的流程,让组织内所有人能遵循以开发软件系统,并需要对过程进行度量和反馈。
- 优秀的人员:需要对组织内所有人员进行教育和培训。
- 有效的项目管理:需要在项目初期制定计划,并在整个开发过程中进行监控。
- 充分的测试:需要执行足够的测试,以确保最终产品没有缺陷。
质量保证的具体活动

上一节我们列出了实现质量的要素,本节中我们详细看看质量保证的具体活动。
为了实现软件质量,我们需要进行以下活动:
- 设定质量属性:确立软件产品必须满足的一系列质量属性。这些通常是我们希望在项目初期达成的设计目标。
- 度量质量属性:必须能够度量这些质量属性,以确定产品符合设计目标的程度。
- 跟踪质量属性:在整个项目过程中跟踪质量属性的数值变化。
- 利用质量信息改进:利用已开发软件的质量信息,来改进未来软件产品的质量。例如,如果在某个项目中使用的标准和度量取得了成功,未来可以考虑在另一个项目中复用。
总结

本节课中,我们一起学习了软件质量保证的核心概念。我们了解到,质量保证是一个贯穿项目始终的过程,包括质量保证、质量规划和质量控制。我们认识到及早发现和修复缺陷的重要性,以及实现高质量软件需要关注过程、人员、项目和测试等多个方面。最后,我们明确了设定、度量、跟踪质量属性并利用其进行持续改进是质量保证的关键活动。
软件工程:P56:软件质量保证活动 📋

在本节课中,我们将要学习软件质量保证活动,即为了达成软件质量目标而必须执行的一系列工作。我们将重点探讨如何制定标准与度量指标。

为了确保软件质量,我们必须执行一系列活动。这些活动包括制定标准、定义度量指标、使用合适的工具、进行评审、实施配置管理以及充分的测试。本节课,我们将聚焦于如何为软件质量保证制定标准和度量指标。
什么是标准?
上一节我们介绍了质量保证活动,本节中我们来看看其中的核心要素之一:标准。

标准是一种规范或要求,它建立了统一的工程或技术准则、方法、流程和实践。对于软件工程而言,重要的标准包括产品标准和过程标准。
以下是两类标准的定义:
- 产品标准:关注产出什么。它定义了所有产品工件(如需求文档、设计图、代码)应具备的特性,以确保其质量。其核心是产品本身。
- 公式/代码示例:例如,代码标准可能规定
函数长度 <= 50行或必须进行异常处理。
- 公式/代码示例:例如,代码标准可能规定
- 过程标准:关注如何产出。它定义了应如何执行软件过程(如开发、测试、部署流程)以确保软件质量。其核心是遵循的流程。
为什么标准很重要?

理解了标准的定义后,我们来看看它在质量保证中扮演的关键角色。
标准之所以重要,原因如下:
- 它们记录了最佳或最合适的实践,帮助我们避免犯错。
- 它们为实施质量控制提供了框架,确保项目内能正确遵循最佳实践。
- 它们有助于保证项目工作的连续性。这意味着当我们启动新项目时,可以减少学习成本,可以复用之前项目的一些标准。
因此,组织通常需要一本标准手册来记录所有标准,并要求所有员工遵循。对于每个具体项目,我们需要决定哪些标准可以忽略、直接使用、修改或新建,即为每个项目量身定制一套标准。
什么是度量指标?
在制定了标准之后,我们需要一种方法来衡量是否达到了这些标准,这就是度量指标的作用。

度量指标是与软件产品、过程或相关工件相关的任何类型的测量。
以下是度量指标重要性的原因:
- 它们可用于控制开发过程。例如,帮助我们精确了解项目需要投入多少工作量、时间等资源。
- 它们可用于预测相关的产品质量。例如,我们可以使用圈复杂度来预测软件系统的可维护性。
公式/代码示例:圈复杂度的简化计算公式为 M = E - N + 2P,其中E是控制流图中边的数量,N是节点数,P是连通分量数。圈复杂度高通常意味着逻辑复杂,系统更难维护。
这意味着我们不能仅凭猜测,而必须借助度量指标来量化软件系统的质量属性。

本节课中,我们一起学习了软件质量保证的两项核心活动:制定标准和定义度量指标。标准(包括产品标准和过程标准)为我们提供了统一的行动准则和最佳实践框架;而度量指标则为我们提供了量化的工具,用于控制过程和预测质量。它们是实现系统化、可衡量软件质量保证的基础。
057:实现产品质量 🎯

在本节课中,我们将学习如何在软件开发过程中实现和评估产品质量。我们将探讨如何设定设计目标,以及如何通过可测量的内部属性来评估这些抽象的外部质量属性。
设定设计目标 🎯
要实现产品质量,我们必须在项目初期设定一系列与项目相关的设计目标。请记住,设计目标是我们希望系统具备的质量属性。

例如,这些属性包括可理解性、可维护性、可靠性等。
通常,我们只能在系统完成后评估它是否具备这些属性。因此,当我们执行验收任务时,才能测试是否达到了项目初期设定的设计目标。
但是,如果我们想在系统开发过程中评估是否正在实现某个设计目标,该如何操作呢?
内部属性与外部属性 📊
为了在开发过程中评估设计目标,我们必须使用所谓的内部属性来尝试评估和估算外部属性。
以下是一些外部属性与内部属性之间可能的关系示例。请注意,外部属性(如可维护性、可靠性)通常比较模糊且难以直接测量。

因此,我们尝试将它们映射到可测量的内部属性上。
- 可维护性:可以与参数数量、圈复杂度、代码行数和错误信息数量相关联。因为当参数更多、圈复杂度更高、代码行数更多、错误信息也更多时,软件系统将更难以维护。
- 可用性:可以与错误信息数量和用户手册长度相关联。我们知道,当错误信息更多、用户手册更长时,系统将更难以使用。

然而,这里的问题是,很难形式化地定义并验证内部属性与设计目标之间的关系。此外,用于计算度量的定量软件信息必须被收集、校准和解释。
评估设计组件的可维护性 🔧
对于一个设计组件,你可能希望实现的一个设计目标是可维护性。
根据经验,我们知道可维护性应该与设计组件的复杂性相关。这意味着当组件很复杂时,系统将难以维护。

而复杂性本身又与一系列质量属性相关,例如内聚性、耦合性、可理解性、适应性等。
但其中一些属性(如可理解性)也无法直接测量。那么,为了测量设计组件的复杂性和可维护性,我们能做什么呢?


使用结构扇入与扇出 📈
我们可以使用的一种方法是结构扇入和扇出。
- 扇入:指一个组件(例如一个类)被多少个其他组件调用。公式上可以表示为:
扇入 = 调用该组件的其他组件数量。高扇入通常意味着高耦合,这是我们应该避免的。 - 扇出:指一个组件调用了多少个其他组件。公式上可以表示为:
扇出 = 该组件调用的其他组件数量。高扇出意味着调用组件具有较高的复杂性,因为它需要协调许多其他类来完成功能。
我们也可以使用信息扇入和扇出,这需要考虑传递的参数数量加上对共享数据结构的访问次数。
计算组件复杂度 🧮
我们可以使用以下公式来计算组件的复杂度:

复杂度 = (组件长度) * (扇入) * (扇出)
然后对这个结果取平方。这个度量方法已在Unix系统中得到验证,是预测实现所需工作量的一个非常有用的指标。
这意味着,如果使用此公式计算出的复杂度很高,那么该组件将非常难以实现,我们需要投入更多精力来实现它。

其他质量评估指标 📐
我们还可以考虑其他指标:
- 设计结构质量指数:遵循IE标准,考虑子系统和数据库的属性来计算DSQI。如果该指数接近1,则表明设计良好;如果DSQI较低,则可能需要进一步的设计工作和评审。
- 软件成熟度指数:考虑产品生命周期中的变更,使用SMI来计算其稳定性。如果SMI接近1,则系统相对稳定;如果指数太低(例如接近0),则表明设计可能存在问题,需要改进。


对于一个实现组件,一些关键的设计目标是可靠性和易于实现。
测量可靠性与实现难度 🛡️
以下是测量软件系统可靠性和难度的一些方法:
- Halstead软件科学:基于组件体积、组件难度和预期错误数。如果V、D、E值高,则组件难以实现。
- McCabe圈复杂度度量:基于代码的圈复杂度。如果圈复杂度高,则组件复杂且难以实现。
- 代码行数:如果代码行数很多,则系统或组件将难以实现。
- 标识符长度:如果有很多长度很长的标识符,则该组件可能难以理解和实现。
- 条件嵌套深度:如果存在多层循环嵌套,则组件的复杂度会很高,意味着难以实现。
我们应该制定一些标准来避免出现复杂的组件,并高亮显示有问题的组件。


确保正确性与追踪缺陷 ✅

最后,我们必须证明程序和规格说明是正确的。这意味着我们必须从逻辑上证明所有需求都已正确转化为程序。
同时,我们必须追踪所有软件缺陷。我们需要对缺陷进行分类并确定其原因。在这里,我们可以应用80/20法则,因为80%的缺陷可以追溯到20%的原因。这意味着如果我们修复了这20%的原因,就能解决80%的缺陷。

通过结合以上两种方法,我们可以确保最终能够实现高度可靠的软件。
总结 📝

本节课中,我们一起学习了如何通过设定明确的设计目标来指导产品质量的实现。我们探讨了如何将难以直接测量的外部质量属性(如可维护性、可靠性)映射到可量化的内部属性(如扇入/扇出、圈复杂度、代码行数)上进行评估。我们还介绍了几种具体的度量方法和公式,用于计算组件复杂度、评估设计结构质量和软件稳定性。最后,我们强调了通过形式化验证和系统化缺陷追踪来确保软件最终高度可靠的重要性。掌握这些方法,有助于我们在开发过程中持续监控和改进产品质量。
058:实现项目、流程与人员质量 🎯
在本节课中,我们将学习如何实现软件项目、开发流程以及人员三个层面的质量。我们将探讨通过评审、配置管理、过程改进和人员能力提升等具体方法来保障软件质量。


实现项目质量 🏗️

上一节我们讨论了质量的重要性,本节中我们来看看如何实现项目质量。实现项目质量的一个核心方法是定期执行评审。

以下是实现项目质量的关键方法:

- 执行定期评审:我们不仅指代码评审,还包括对需求、分析和设计的评审。这些早期阶段通常引入了50%至60%的缺陷。通过执行正式的技术评审,可以揭示其中75%的缺陷。
- 核心公式:
早期且频繁的评审 => 早期发现缺陷
- 核心公式:
- 实施软件配置管理:因为项目中必然会发生变更。我们需要通过软件配置管理来管理、控制和监控项目中的所有变更。
实现流程质量 🔄
在讨论了项目质量后,我们接下来探讨如何实现流程质量。你可能会问,流程在软件开发中有多重要?

软件开发有一些独特因素,它们会影响产品质量,且与所用流程无关:
- 软件是设计出来的,而非制造出来的。
- 软件开发是创造性的,而非机械性的。
- 外部因素可能影响软件质量。
- 个人技能和经验对项目质量有显著影响。
因此,有时人员和技术比流程更重要。拥有优秀的人员和技术,项目成功的可能性也很大。此外,无论使用何种流程,资源不足(如时间或预算)总会影响产品质量。
一个详细的软件开发流程通常不可直接移植,因为它高度依赖于特定组织。例如,即使谷歌使用敏捷开发,其他公司照搬同一流程也未必成功。
有一个名为 SEI CMM 的指南,旨在帮助软件组织评估和改进其软件开发流程。它分为五个等级:

- 初始级:无管理。
- 可重复级:具备基本管理,例如跟踪项目时间和预算。
- 已定义级:为项目定义了所有流程,并提供培训。
- 已管理级:除了管理,还在整个项目中进行度量。
- 优化级:在管理和度量的基础上,增加反馈机制以持续改进。

实现人员质量 👥
上一节我们介绍了流程质量模型,本节中我们来看看如何评估和提升人员质量。有一个名为 PCMM 的指南,旨在评估和改进组织内人员的知识和技能。
PCMM也分为五个等级:

- 初始级:不提供任何培训。
- 可管理级:组织提供基本培训,例如针对特定编程语言的培训。
- 已定义级:尝试定制工作实践,并有计划定位和发展所需人才。
- 已预测级:关键词是注重团队建设。
- 优化级:注重提升团队和个人技能,采用最佳实践。
当你日后在工业界工作时,应确保为达到3级或以上的组织工作,因为这意味着它们会为你提供培训,使你能在不同的项目中学习成长。
总结与关键要点 📝
本节课中我们一起学习了实现软件质量的多维度方法。以下是为实现质量需要做的事情总结:
- 组织应有质量方针:记录软件质量保证程序。
- 项目应有质量计划:明确项目最重要的质量属性及评估方法。
- 组织应有定义明确的标准手册:确保所有员工遵循。
- 建立机制和流程:监控对组织所有质量要求的符合性。
- 定期执行评审:确保不犯错误。
- 持续监控所有度量指标:用于突出软件中可能存在质量问题的异常部分。

请注意,高质量的软件不会凭空出现。软件质量保证需要融入到软件开发流程中。开发高质量软件需要管理、标准、度量以及承诺。同时要认识到,测试是质量保证的重要组成部分,但并非获得高质量软件产品的全部,因为测试通常在项目后期进行。本节课我们重点讨论了如何在项目早期发现缺陷的各种方法。

本节课中,我们一起学习了通过评审、配置管理、流程改进(SEI CMM)和人员能力发展(PCMM)来实现项目、流程和人员三个层面的质量,并总结了构建全面质量保证体系的关键要素。
059:项目管理 🎯
在本节课中,我们将探讨项目管理。这意味着,作为一名优秀的项目经理,你需要完成哪些工作。


概述


项目管理贯穿整个软件项目周期。薄弱的项目管理通常是导致软件项目失败的关键原因。因此,做好项目管理至关重要。然而,项目管理并非易事,因为项目初期就需要制定一份包含项目所有信息的开发计划,并且你必须在信息不完整的情况下完成这份计划。
学习目标

完成本讲后,你将能够:
- 了解软件项目经理的主要任务。
- 理解在所有软件项目中制定项目计划的必要性。
- 理解软件项目中人员配备和进度安排的一些要求。
- 了解估算软件开发规模和成本的一些技术。
- 理解项目跟踪与控制的重要性。

项目失败的警示


在深入探讨如何管理项目之前,我们先通过一个短片来了解项目失败的一些常见原因。
我需要管理一个更“性感”的项目来推动我的职业生涯。
它只需要听起来不错,并且在我找到更好的工作之前别失败就行。
一个用于打击恐怖分子的纳米技术干细胞项目怎么样?好吧。
这是我的项目所需的最低预算。用一半的钱你能做什么?失败。
你什么时候可以开始?我想我已经开始了。我会向供应商询问大概价格,看看这个想法是否可行。
在我们的变更控制委员会批准项目之前,你不能与供应商交谈。
但这需要一份成本效益分析,而没有供应商的大概价格我无法完成分析。
那就凭你的最佳猜测吧。
所以我应该编造一个数字,以便获得批准去打一个电话,询问这个数字本来应该是多少,对吗?
但首先,你需要获得我的批准才能进行成本效益分析。你会批准吗?我得先看看数字。
根据要求,我撰写了商业计划书,以显示第三年实现盈利。
关键的营收假设是,一辆装甲车会撞穿那面墙并洒出里面的东西。
另外,别站在预计彗星会撞击出石油的位置。看起来不错。
你不会读我的技术报告,所以我在这张复杂的幻灯片里总结了它。
如果你盯着它看足够久,要么会产生理解了它的错觉,要么会因为不好意思承认自己不懂而保持沉默。
你有什么问题会暴露你的无知吗?
我有个问题,那个三角形的东西对那个管子生气了吗?
不,不,不,不。这个设计在现实世界中永远行不通。
这个设计已经在现实世界中广泛使用了。
如果你需要时间来编造更多无知的批评,我可以稍后再来。我不知道如何设计电源。所以我在一块木头上钉了颗钉子。
我明天开始休假,所以我把文件给你,以防你需要做修改。
让我们庆祝一下,这一切很快就搞定了。Wally,你的项目完成了吗?我下周完成。
你已经连续七周都说“下周”了。这次我凭什么相信你?
因为前六次。那么,你的项目进展如何?它是一堆失败的烂摊子。就像15只醉醺醺的猴子在玩拼图。
你的项目进展如何?很好。嗯。我们的Beta测试结果出来了。我们的用户界面引发了普遍的沮丧和自残行为。
显然,为了公共利益,我们需要推迟发布。你什么时候变成共产主义者了?你未能达到我们CEO设定的目标。你是指那个不可能的目标,那个不明智的目标,还是那个你没告诉我的目标?
我弄明白生活的问题出在哪了。是其他人。


上一节我们看到了项目失败的多种可能性。本节中,我们将开始学习如何通过有效的项目管理来避免这些失败。


项目管理概述


项目管理贯穿整个项目周期。如前所述,薄弱的项目管理通常是软件项目的致命伤。如果项目管理不善,最终可能导致项目失败。
然而,项目管理并不容易。因为在项目初期,我们就需要制定一份开发计划。这份计划需要包含项目的所有信息,而你必须在知识不完整的情况下制定它。这意味着,在项目甚至还未启动时,你就需要进行各种估算。
例如,在开始实施系统之前,你就必须估算项目需要多少人手、软件系统将有多少行代码,以及项目需要多少资金或预算等等。
此外,你通常还需要在时间、资金和技能等资源有限的情况下规划项目。因此,在项目计划中,你必须决定许多开发事项。
以下是需要在项目计划中决定的关键事项:
- 需要哪些功能。
- 需要完成哪些任务。
- 是购买功能还是自行构建功能。
- 需要投入多少工作量。
- 需要哪些资源。
请注意,制定开发计划是一个持续的过程。这意味着在开发项目的同时,我们也在进行规划。换句话说,我们需要“规划工作”并“执行计划”同时进行。
总结

本节课中,我们一起学习了项目管理的基础。我们首先通过一个短片了解了项目失败的常见原因,然后探讨了项目管理的核心概念,即在整个项目周期中进行持续规划的重要性。我们认识到,项目经理需要在信息不完整和资源有限的条件下,对功能、任务、资源、成本等进行早期估算和决策,并随着项目进展不断调整计划,以确保项目成功。
060:软件开发计划 📋
在本节课中,我们将要学习如何制定一份完整的软件开发计划。软件开发计划是项目管理的核心文件,它定义了项目的范围、管理方式以及如何交付成果。我们将逐一探讨计划中必须包含的关键组成部分。
项目范围与定义


首先,我们来讨论项目计划中应包含的内容。在软件开发计划中,我们必须记录开发工作的范围以及如何管理项目。
范围指的是在项目中应该完成什么,以及不应该完成什么。请注意,软件开发计划定义了整个项目。制定软件开发计划是一个具有不完整数据的约束优化问题。
之所以说数据不完整,是因为我们尚未开始项目工作。为了制定计划,我们需要来自不同人员的输入:
- 我们需要开发经理来组织项目、任务、预算等。
- 我们还需要有类似项目经验的人员,他们可以指导我们项目可用的设计,并帮助评估项目的技术可行性。
- 此外,我们还需要专家用户或领域专家,以便通过他们从应用领域获取需求。

项目交付成果

在项目计划中,我们还必须明确项目的交付成果。
因此,在计划中,我们需要具体说明应向谁、在何时交付什么。交付成果可以分为以下几类:
- 客户产品:例如可执行代码、用户手册等。
- 过程制品:例如系统需求、分析设计规格说明、对象设计文件或源代码。
- 内部交付物:例如源代码库、Makefile 等。
- 服务:例如,我们可能需要为客户提供培训、安装服务、定制服务等。
你必须在计划中明确指定项目的所有交付成果。交付成果将影响人员配置和团队组织。例如,如果你有许多交付成果,那么显然需要更多团队成员来参与项目。


开发环境


同样,在项目计划中,你必须明确开发环境。
在软件开发计划中,我们必须指定项目中可以使用的硬件和软件开发工具。请注意,开发工具不等于开发过程。开发工具只是我们为了成功完成项目而将在项目中使用的工具。我们仍然需要一些良好的流程,以便组织中的每个人都能遵循这些流程来开发项目。
工具只有在使已充分理解的流程更高效时才有效。当我们选择开发工具时,必须进行评估,例如其对生命周期的支持以及采用新工具对成本和进度的风险。如果我们使用一些新工具,则可能需要在购买工具上花费额外的资金,并且可能需要为开发人员提供额外的培训,以教会他们如何使用这些工具。

工作分解结构
我们还必须在开发计划中明确工作分解结构。
在项目计划中,我们必须使用分而治之的方法将项目分解为任务和子任务。这通常以树形结构表示,类似这样:
项目
├── 任务 A
│ ├── 子任务 A1
│ └── 子任务 A2
└── 任务 B
├── 子任务 B1
└── 子任务 B2


因此,在项目中,你必须明确需要执行哪些任务,而一个任务中可能包含进一步的子任务等。
你必须识别完成项目所需的所有活动和任务,并估计每个叶子节点(最底层任务)所需的资源,然后汇总起来以获得整个项目的估算。此外,你可以使用这个工作分解结构来跟踪项目的预算和进度。
工作分解结构使得每个任务能够:
- 易于规划。
- 易于分配给个人和团队。
- 易于跟踪,以监控进度并了解谁在负责哪个任务。
- 易于预算,以便跟踪成本。

工作分解结构应具有适当的粒度。如果粒度太小,则难以跟踪;如果粒度太大,则难以控制成本和衡量进度,因为太笼统。

人员配置与组织

接着,在项目计划中,你还必须明确人员配置和组织。
你必须定义项目组织,明确项目中的角色和职责、每个角色的人员数量以及团队构成。项目中应至少有一名成员具有类似系统的经验,该成员可以帮助你估算项目规模,并帮助你设计软件系统。
请注意,团队组织应模块化,以限制沟通和交互的复杂性。如果一个团队中有太多人,最终你将花费大量时间在沟通上。此外,应为每个团队成员分配明确的职责。应组建团队,使其负责一个或多个子系统的设计和实现。应为每个子系统以及整个系统确定一名负责人,这样如果某个特定子系统出现问题,你总是可以与该负责人沟通。成功的关键在于达到适当的沟通水平。
项目进度安排
在你的项目计划中,你还必须制定项目进度表。


在项目进度表中,你必须定义任务、任务排序、每个任务的估算、资源分配、里程碑、交付成果,以及关键路径(如果我们讨论PERT图)。通常,需要维护三个级别的进度表:
- 主进度表:整个项目的概览。
- 宏观进度表:用于日常项目管理。
- 微观进度表:用于团队管理。
在本课程中,我们将讨论如何使用甘特图、PERT图和燃尽图进行进度安排。我们已经讨论过在Scrum中如何使用燃尽图管理进度。现在,我们将讨论如何绘制甘特图和PERT图。
这是一个甘特图的例子。在甘特图中,你必须指定项目中需要执行的所有任务,并估算每个任务所需的时间。从甘特图中,我们可以看到整个项目的总体进度。

我们也可以使用PERT图来指定进度。在PERT图中,我们仍然需要识别项目中将要执行的所有任务,为每个任务分配一个ID,估算每个任务的持续时间,并指明任务间的顺序关系。一旦我们有了所有这些信息,就可以将其转换为PERT图。

PERT图是以关键路径网络形式呈现的项目任务的图形化表示。这是一个例子:假设我们从节点1开始,然后执行任务A,接着从节点1到节点2。完成任务B后,我们从节点2到节点3。然后我们将并行执行任务C和D,一旦完成任务C,我们前往节点4;一旦完成任务D,我们前往节点5。接着我们将并行执行任务E和F,之后执行任务G和任务H,然后完成项目。请注意,我们还需要那些虚线任务(虚活动)来将并行任务合并回主流。然后,我们可以使用关键路径来识别项目持续时间。关键路径是图中红色的路径,我们将其定义为从起始节点到结束节点的最长路径。由于我们讨论的是最长路径,所以我们选择路径D而不是路径C(因为它更长),选择路径F而不是路径E(因为它更长)。我们讨论的是从起点到终点的最长路径,通过这条路径,你可以估算整个项目的持续时间。这就是为什么我们说总体进度取决于关键路径。
项目估算
在你的项目计划中,你还必须提出所有的估算。
我们必须提供项目的估算,例如项目规模、我们需要在项目上投入的工作量或项目持续时间等。请注意,估算是基于经验、历史数据、模型和勇气的。这就是为什么为了降低估算风险,我们需要一些有类似系统经验的人来告诉我们,例如项目的规模、项目的持续时间、项目需要多少人等。

因此,这里我说风险可以通过以下方式降低:尝试提前确定项目范围,然后使用过去项目的度量数据或一些有经验的人员,并在估算之前使用分而治之的方法将所有任务分解为更小的任务。
我们可以为软件系统收集许多不同类型的度量数据。在本课程中,我们将讨论生产力度量,特别是规模导向度量和功能导向度量。
如果我们讨论规模导向度量,我们讨论的是项目的规模。我们可以根据项目的代码行数来估算项目规模。我们可以根据过去项目的数据或有类似项目经验的人员来得出这个估算。一旦我们可以用千行代码来估算项目规模,我们就可以使用这个KLoC来估算,例如项目的生产力、成本、质量和文档。这种方法的问题是什么?请注意,所有这些估算都是基于项目的代码行数。但请注意,不同的人可能为同一个程序编写不同数量的代码,这就是问题所在。因此,很难得出一个单一的数字,因为不同的人有不同的编程风格。


功能导向度量基于项目的软件属性,例如系统中的用户输入数量、系统输出数量、查询数量、系统中的文件数量以及系统中的外部接口数量等。

一旦我们有了这些数字,我们将把它们代入这个方程:FP = 总计数值 * [0.65 + 0.01 * Σ(Fi)]。然后我们乘以一些加权因子,将它们相加,最终得到总计数值。得到总计数值后,我们将其代入这个方程以获得功能导向度量。有些人可能会问这个Fi是什么。Fi实际上来自一项调查。你必须完成调查并计算平均Fi。例如,你必须回答这些问题:系统是否需要可靠的备份和恢复?如果没有影响,你说是0;如果是必需的,你说是5,等等。然后你必须回答所有这些问题,为每个问题给出评分,最终计算出平均评分,然后将其代入方程,以获得功能导向度量。一旦你得到了这个FP,你就可以估算项目的生产力、成本、质量和文档。现在你可以看到,我们试图根据软件系统提供的功能来得出所有估算。
其他估算方法与风险管理
我们还可以使用其他一些方法来得出估算。一种是我们所说的德尔菲技术。我们不只是得到一个单一的数字,而是从三个不同的人那里得到三个数字,然后对这三个数字取平均值。

我们也可以使用PERT估算,这意味着我们需要请每位专家提供一个数值范围,通常是乐观值、最可能值和悲观值。然后,我们将根据这个数值范围计算一些期望值和标准差。请注意,标准差是进度和预算风险的度量,这意味着如果标准差较高,那么项目中的风险就会更高。为什么?因为如果估算的偏差较大,就意味着项目中的风险更高。如果你以前学过统计学,你就会知道期望值落在±1个标准差范围内的概率是68%。
请注意,让有经验的开发人员进行估算是至关重要的,因为他们之前有类似项目的经验。此外,估算应始终以多种方式进行,并且结果应进行交叉检查。不完整和不精确的需求会阻碍准确的成本估算,仅仅因为你不知道确切的需求是什么,就很难进行估算。
在项目计划中,你还必须明确我们需要为项目收集哪些度量数据。你必须确定收集哪些度量数据以及如何收集它们。通常,如果我们讨论项目管理,度量数据通常与规模相关,例如用例数量、类的数量或项目的代码行数。我们需要将计划的规模与当前的规模进行比较,以确定项目的进度和稳定性。
在项目计划中,我们必须预见并列出项目中可能发生的所有风险。这就是为什么我在这里说,你必须预见项目中可能出问题的地方,然后估计其发生的可能性及其影响。同时,制定具有成本效益的应急计划,即如果风险发生,你必须采取哪些行动。做好这一点是优秀经理的重要品质。此外,风险规划与预防性管理相关,因此我们试图预见将要发生的事情,然后努力避免风险发生。
时间分段预算
我们还必须在项目计划中制定时间分段预算。

在时间分段预算中,我们试图具体说明项目预算计划在何时支出,以及在每个支出水平上预期完成什么。人力成本很可能是主要成本,因为如果我们谈论的是一个软件项目,我们主要需要程序员来开发项目。但当然,还有其他成本,例如培训所需的成本、购买软件许可证、硬件组件等所需的成本。最好保留额外的10%到15%作为额外储备金,以防项目出现问题,你仍然有一些额外的预算可以用于项目。确保跟踪支出,即定期比较计划支出与实际支出,以及计划完成情况与实际完成情况。
总结

本节课中,我们一起学习了如何制定一份全面的软件开发计划。我们涵盖了计划的核心组成部分:定义项目范围、明确交付成果、配置开发环境、创建工作分解结构、规划人员组织、制定项目进度表、进行项目估算、管理风险以及制定时间分段预算。理解并掌握这些内容,是确保软件项目能够有序、高效推进并最终成功交付的关键。
061:项目跟踪与控制 🎯
在本节课中,我们将学习软件项目管理的核心环节——项目跟踪与控制。我们将探讨为何需要持续监控项目,以及如何通过有效的实践来确保项目按预算和进度进行。
概述
软件项目进度可能瞬息万变,因此需要对项目活动进行持续、一致且无干扰的监控。监控的主要目的是确保项目符合预算和进度计划。由于项目过程中必然会发生变更,因此应用软件配置管理(SCM)至关重要。
项目监控的必要性

软件项目需要持续监控,因为其进度可能逐日变化。监控的主要目的是确保项目符合预算和进度计划。

关于人员与变更的要点


一个常见的误区是向进度落后的项目增加人力。这通常会使项目更加延误,因为需要额外的时间来培训新成员。因此,向落后项目增加人力往往会使其更加落后。


项目内部发生变更是必然的,这就是为什么应用软件配置管理(SCM) 非常重要。

常规项目活动


以下是项目团队必须定期执行的活动:

- 召开项目状态会议:例如每日站会、周会或月会。
- 进行项目评审:定期进行,并评估每次评审的结果。
- 检查里程碑:确认是否按计划完成。
- 比较预算:定期将实际支出与计划预算进行比较。
- 进行非正式沟通:与项目成员进行非正式交流,以获得对进展的主观评估。
优秀项目经理的实践


现在,让我们通过一段视频来了解一位优秀项目经理需要做哪些事情。视频来自Ash Long咨询公司的区域高级副总裁Matthew Paul,他总结了成功交付IT项目的六个最佳实践。



以下是确保IT项目成功的六个关键实践:
- 明确项目范围:确保项目涉及的所有人都了解范围。范围应形成文档,以便在项目后续遇到问题时提供参考。明确哪些内容不在项目范围内同样重要。
- 定义需求:在开始前,清晰定义业务需求和技术需求至关重要。没有明确定义的需求,项目成功的机会渺茫。
- 保持现实:业务方总希望项目尽快交付并包含所有功能。作为项目经理,从第一次会议开始就设定正确的期望是必要的。
- 预留缓冲空间:务必在项目时间线中预留额外时间,在预算中预留额外资源。项目总会遇到不可预见的情况,这些缓冲空间能帮助你仍在预算内按时交付项目。
- 设定明确的目标和KPI:在项目时间线内设定定义明确的目标和关键绩效指标。如果我们能达成目标和KPI,项目成功的几率就会更大。
- 为变更做好准备:我们知道随着项目推进,变更必然会发生。项目团队必须制定完善的变更管理流程,并确保每个人都完全清楚当变更发生时如何处理。


项目偏离轨道时的应对策略

如果你发现IT项目偏离了轨道,以下五个技巧可以帮助你重回正轨:



- 停止相互指责,协作寻找解决方案:重点不在于我们如何陷入这个问题,而在于如何解决它。
- 回归基础:重新审视项目范围,回顾需求,与利益相关者重新沟通,使项目重回正轨。
- 开放且频繁地沟通:在交付IT项目时,沟通永远不嫌多。
- 确保拥有正确的资源:不仅核心项目团队需要,最好还能在外部准备一些额外资源,以便在需要让项目重回正轨时能够随时介入。
- 进行自我评估:作为IT项目经理,要不断问自己:我们是否有足够的资源来交付这个项目?我们是否有足够的资金?如果其中任何一个问题的答案是否定的,不要害怕:第一,将此情况告知业务方;第二,采取必要措施以确保拥有交付项目所需的正确资金和资源。

总结

本节课中,我们一起学习了项目跟踪与控制的核心概念。我们了解到持续监控对于确保项目按预算和进度进行至关重要,也明白了为何向落后项目简单增加人力可能适得其反。通过视频案例,我们掌握了成功管理IT项目的六个最佳实践,以及在项目偏离轨道时的五个补救技巧。

最后,请记住:作为一名优秀的项目经理,你应该管理流程,而不是被流程所管理。

本课程到此结束。感谢学习。

浙公网安备 33010602011771号