软件工程学习:第六章:软件架构设计
本文是《软件工程》第六章的详细中文解读,力求用最通俗易懂的语言讲清楚每个概念。
一、什么是软件架构?
想象你要建一栋楼。在开始砌砖之前,建筑师需要先画出整体蓝图:有几层、哪里是大厅、哪里是走廊、管道怎么走。软件架构做的事情是一样的——在写代码之前,先设计整个软件系统的"骨架"。
官方定义:软件架构设计关注的是"软件系统应该如何组织",以及"整体结构是什么样的"。它的输出是一个架构模型,描述系统由哪些主要部件(组件)构成,以及这些部件之间如何相互通信。
为什么架构很重要?
架构影响系统的四大关键属性:
| 属性 | 含义 |
|---|---|
| 性能 (Performance) | 系统运行有多快 |
| 健壮性 (Robustness) | 系统出错时能否撑住 |
| 可分布性 (Distributability) | 能否部署在多台机器上 |
| 可维护性 (Maintainability) | 将来修改有多容易 |
重点结论:功能需求由各个组件实现,但非功能需求(性能、安全、可靠性等)主要由架构决定。
两个抽象层次
架构设计的两个层次
├── 小架构 (Architecture in the small)
│ └── 单个程序内部怎么分解成组件
│ 例:一个登录模块内部如何组织
│
└── 大架构 (Architecture in the large)
└── 整个企业级系统,跨多台计算机
例:银行系统包含多个子系统分布在多个服务器
二、架构设计要做哪些决策?
架构设计不是按步骤走的流水线,而是一系列需要回答的关键问题:
架构设计的关键问题(图6.2)
+--------------------------------------------------+
| 1. 有没有可以直接套用的通用架构模板? |
| 2. 系统结构的基本方式是什么? |
| 3. 系统如何分布在多个处理器/核心上? |
| 4. 可以使用哪些架构模式? |
| 5. 如何控制各组件的运行? |
| 6. 结构组件如何分解为子组件? |
| 7. 什么架构最能满足非功能需求? |
| 8. 架构应该如何文档化? |
+--------------------------------------------------+
非功能需求 vs 架构选择
下面是不同非功能需求对架构的具体影响:
| 非功能需求 | 推荐架构策略 |
|---|---|
| 性能 | 把关键操作集中在少数几个大组件里,部署在同一台机器上,减少网络通信 |
| 安全性 | 用分层架构,最重要的资产放在最内层,层层把关 |
| 安全可靠 (Safety) | 把安全相关的操作集中在一两个组件里,方便验证和紧急关闭 |
| 可用性 | 设计冗余组件,允许在不停机的情况下替换/更新组件 |
| 可维护性 | 用细粒度、自包含的小组件,数据生产者和消费者分离 |
注意冲突:性能要大组件,可维护性要小组件——两者冲突时需要在系统不同部分分别采用不同策略。
三、如何"看"一个架构?——架构视图
一个架构很复杂,一张图说不清楚。就像看一栋楼,你需要平面图、立面图、水电图……软件架构也需要多个视图 (View)。
Krutchen 的 4+1 视图模型
| 视图名称 | 关注点 | 主要受众 |
|---|---|---|
| 逻辑视图 | 系统中的关键对象/类 | 分析师、架构师 |
| 过程视图 | 运行时各进程如何交互 | 性能/可用性分析 |
| 开发视图 | 软件如何分解供团队开发 | 开发人员、项目经理 |
| 物理视图 | 软件组件如何分布在硬件上 | 系统工程师、运维 |
实际情况:在大多数项目中,开发一套完整的四视图文档成本太高,通常只开发那些对沟通和决策有用的视图。
四、架构模式 (Architectural Patterns)
架构模式就像"经过验证的最佳实践"——前人踩过坑、总结出来的好方案,你可以直接拿来用。
本章介绍四种最重要的架构模式:
4.1 模型-视图-控制器 (MVC)
核心思想:把"数据"、“显示”、"用户交互"三者分开。
生活类比:餐厅里,厨师(Model)负责做菜,服务员(Controller)负责接受点单并传达,菜单/菜品展示(View)负责让顾客看到。
Web应用中的MVC(图6.6):
浏览器
|
| HTTP 请求
v
Controller(控制器)
- 处理HTTP请求
- 应用特定逻辑
- 数据校验
| |
| | 更新请求
v v
View Model(业务逻辑 + 数据库)
- 动态生成页面
- 表单管理
| 说明 | |
|---|---|
| 适用场景 | 有多种方式查看/操作同一份数据;未来交互需求不确定 |
| 优点 | 数据和展示可以独立变化;同一数据可以有多种展示 |
| 缺点 | 数据模型简单时反而增加了复杂度 |
4.2 分层架构 (Layered Architecture)
核心思想:把系统功能按"层"组织,每一层只依赖它下面那一层提供的服务。就像一栋楼的楼层,只能走楼梯,不能跳层。
生活类比:操作系统就是分层的——应用程序 → 操作系统API → 驱动程序 → 硬件。
典型四层架构(图6.8)
+----------------------------------+
| 用户界面层 | ← 用户直接看到的
+----------------------------------+
| 用户界面管理 + 认证授权层 | ← 登录、权限检查
+----------------------------------+
| 核心业务逻辑/应用功能层 | ← 主要功能在这里
+----------------------------------+
| 系统支撑层(OS、数据库等) | ← 最底层基础设施
+----------------------------------+
iLearn 数字学习系统示例(图6.9):
第4层(顶层):浏览器用户界面
↑
第3层:应用服务
- 搜索 - 界面接入
↑
第2层:工具服务
- 邮件 - 视频会议 - 电子表格
- 虚拟学习环境 - 资源查找器
↑
第1层(底层):基础设施
- 配置服务 - 用户存储 - 日志监控
- 认证 - 组管理 - 应用存储
| 说明 | |
|---|---|
| 适用场景 | 在已有系统上增加新功能;需要多级安全控制;团队分工明确 |
| 优点 | 只要接口不变,整层可以替换;可在每层提供冗余提升可靠性 |
| 缺点 | 实际中层与层的边界很难划清;多层解析请求会影响性能 |
4.3 仓库架构 (Repository Architecture)
核心思想:所有组件不直接互相通信,而是通过一个中央数据仓库来共享数据。
生活类比:公司里所有员工都把文件存到共享网盘,要用文件就去网盘取,不用互相发邮件传文件。
| 说明 | |
|---|---|
| 适用场景 | 需要长期存储大量数据;数据驱动的系统(数据到位就触发操作) |
| 优点 | 组件完全解耦;一个组件的修改自动对其他组件可见;数据统一管理 |
| 缺点 | 仓库是单点故障;分布式部署困难;所有通信都经过仓库效率低 |
4.4 管道-过滤器架构 (Pipe and Filter)
核心思想:数据像水流一样,经过一系列"过滤器"(处理步骤),每个过滤器做一种变换,最终得到结果。
生活类比:工厂流水线——原材料 → 切割 → 打磨 → 喷漆 → 包装 → 成品。每道工序就是一个"过滤器",工序之间的传送带就是"管道"。
发票处理示例(图6.15):
[读取已发发票]
| 管道(数据流动)
v
[识别付款记录]
|
v
[查找逾期未付]
|
/ \
v v
[发收据] [发催款通知]
C++ 示意代码(展示管道过滤器的概念):
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
// 发票数据结构
struct Invoice {
int id;
std::string customer;
double amount;
bool paid; // 是否已付款
int daysOverdue; // 逾期天数
};
// 过滤器1:读取发票(模拟从数据库读取)
std::vector<Invoice> readInvoices() {
// 实际中应从数据库或文件读取
return {
{1, "Alice", 500.0, true, 0},
{2, "Bob", 300.0, false, 10},
{3, "Charlie",200.0, false, 0},
{4, "Dave", 400.0, false, 35},
};
}
// 过滤器2:识别已付款的发票(管道传入发票列表,输出已付款的)
std::vector<Invoice> filterPaid(const std::vector<Invoice>& invoices) {
std::vector<Invoice> paid;
for (const auto& inv : invoices) {
if (inv.paid) {
paid.push_back(inv);
}
}
return paid;
}
// 过滤器3:识别逾期未付(超过30天)的发票
std::vector<Invoice> filterOverdue(const std::vector<Invoice>& invoices) {
std::vector<Invoice> overdue;
for (const auto& inv : invoices) {
if (!inv.paid && inv.daysOverdue > 30) {
overdue.push_back(inv);
}
}
return overdue;
}
// 输出步骤:发送收据(对已付款发票)
void issueReceipts(const std::vector<Invoice>& paid) {
std::cout << "=== 发送收据 ===" << std::endl;
for (const auto& inv : paid) {
std::cout << "向 " << inv.customer
<< " 发送收据,金额:" << inv.amount << std::endl;
}
}
// 输出步骤:发送催款通知(对逾期发票)
void issueReminders(const std::vector<Invoice>& overdue) {
std::cout << "=== 发送催款通知 ===" << std::endl;
for (const auto& inv : overdue) {
std::cout << "向 " << inv.customer
<< " 发送催款,逾期 " << inv.daysOverdue
<< " 天,金额:" << inv.amount << std::endl;
}
}
int main() {
// 管道:数据流经每个过滤器
auto invoices = readInvoices(); // 过滤器1:读取
auto paid = filterPaid(invoices); // 过滤器2:筛选已付款
auto overdue = filterOverdue(invoices); // 过滤器3:筛选逾期
issueReceipts(paid); // 输出:发收据
issueReminders(overdue); // 输出:发催款
return 0;
}
| 说明 | |
|---|---|
| 适用场景 | 批处理数据系统;用户交互少的嵌入式系统;数据分阶段加工的业务流程 |
| 优点 | 直观易理解;过滤器可复用;易于添加新步骤;可串行或并行执行 |
| 缺点 | 各过滤器必须协商统一的数据格式;有解析/反解析开销;不适合复杂GUI交互 |
五、应用架构 (Application Architectures)
同一行业的软件往往有相似的"骨架"。应用架构就是针对某类应用的通用结构模板。
用处:
应用架构的5种用途
1. 作为设计起点 ────→ 不熟悉某类系统时,先套模板再特化
2. 作为设计检查清单 ──→ 比对你的设计和通用模板是否一致
3. 组织团队工作 ────→ 按架构分配模块给不同小组并行开发
4. 评估可复用组件 ──→ 对照架构找哪些组件可以重用
5. 统一沟通词汇 ────→ 大家用同一套术语讨论系统
5.1 事务处理系统 (Transaction Processing Systems)
什么是事务:一组操作,要么全部成功,要么全部失败,不允许"做了一半"。
经典例子——ATM取款:
事务={检查余额→扣减余额→吐钞} \text{事务} = \{\text{检查余额} \to \text{扣减余额} \to \text{吐钞}\} 事务={检查余额→扣减余额→吐钞}
这三步必须全部成功,才算一次完整事务。
概念结构(图6.16):
用户
|
v
I/O处理 ──→ 应用逻辑 ──→ 事务管理器 ──→ 数据库
(输入输出) (业务规则) (保证原子性) (持久存储)
ATM系统架构(图6.17,管道-过滤器风格):
输入阶段 处理阶段 输出阶段
+----------+ +----------+ +-----------+
|获取账户ID | → |查询账户 | → |打印凭条 |
|验证卡片 | |更新账户 | |退卡 |
|选择服务 | +----------+ |出钞 |
+----------+ ↕ +-----------+
ATM 数据库
5.2 信息系统 (Information Systems)
信息系统是最常见的事务处理系统,比如图书馆目录、医院患者记录、机票查询。
通用分层模型(图6.18):
+---------------------------+
| 用户界面 | ← 浏览器展示
+---------------------------+
| 用户通信层 | ← 处理所有输入输出
+---------------------------+
| 认证 & 授权层 | ← 登录/权限
+---------------------------+
| 信息检索与修改层 | ← 业务逻辑
+---------------------------+
| 事务管理层 | ← 保证数据一致性
+---------------------------+
| 数据库 | ← 持久存储
+---------------------------+
Mentcare 心理健康系统实例(图6.19):
5.3 语言处理系统 (Language Processing Systems)
什么是语言处理系统:把一种语言翻译成另一种表示,并可能执行它。最典型的就是编译器。
基本架构(图6.20):
源代码→翻译器抽象机器指令→解释器输出结果 \text{源代码} \xrightarrow{\text{翻译器}} \text{抽象机器指令} \xrightarrow{\text{解释器}} \text{输出结果} 源代码翻译器抽象机器指令解释器输出结果
编译器各组件详解(图6.21/6.22):
各组件职责:
词法分析器 → 把源代码分解成 token(词法单元)
例:"int x = 5;" → [int][x][=][5][;]
符号表 → 记录所有变量名、类名、函数名等信息
例:x → 类型:int, 作用域:全局
语法分析器 → 检查 token 序列是否符合语法规则
构建语法树
语法树 → 程序的内部树形表示
例:x = 5 表示为:赋值节点{左:x, 右:5}
语义分析器 → 检查语义是否正确
例:不能把字符串赋给整数变量
代码生成器 → 遍历语法树,生成目标机器代码
两种组织方式对比:
| 方式 | 结构 | 适用场景 |
|---|---|---|
| 管道-过滤器式 | 词法 → 语法 → 语义 → 生成,顺序执行 | 批处理编译(如编译一个文件) |
| 仓库式 | 所有工具共享符号表和语法树 | 集成开发环境(IDE),修改需即时反映 |
C++ 编译器词法分析器简化示例:
#include <iostream>
#include <string>
#include <vector>
// Token 类型枚举
enum class TokenType {
KEYWORD, // 关键字,如 int, if, while
IDENTIFIER, // 标识符,如变量名 x, myFunc
NUMBER, // 数字字面量,如 42
OPERATOR, // 运算符,如 =, +, -
SEMICOLON, // 分号 ;
UNKNOWN // 未知
};
// Token 结构体:每个词法单元
struct Token {
TokenType type;
std::string value;
};
// 辅助函数:判断是否是关键字
bool isKeyword(const std::string& s) {
return s == "int" || s == "if" || s == "while"
|| s == "return" || s == "float";
}
// 辅助函数:判断字符是否是字母或下划线
bool isAlpha(char c) {
return (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c == '_');
}
// 辅助函数:判断字符是否是数字
bool isDigit(char c) {
return c >= '0' && c <= '9';
}
// 词法分析器:把源代码字符串分解成 token 列表
// 这就是"管道-过滤器"中的第一个过滤器
std::vector<Token> lexicalAnalyze(const std::string& source) {
std::vector<Token> tokens;
size_t i = 0;
while (i < source.size()) {
char c = source[i];
// 跳过空白字符
if (c == ' ' || c == '\n' || c == '\t') {
i++;
continue;
}
// 识别标识符或关键字
if (isAlpha(c)) {
std::string word;
while (i < source.size() && (isAlpha(source[i]) || isDigit(source[i]))) {
word += source[i++];
}
// 判断是关键字还是普通标识符
tokens.push_back({
isKeyword(word) ? TokenType::KEYWORD : TokenType::IDENTIFIER,
word
});
continue;
}
// 识别数字
if (isDigit(c)) {
std::string num;
while (i < source.size() && isDigit(source[i])) {
num += source[i++];
}
tokens.push_back({TokenType::NUMBER, num});
continue;
}
// 识别分号
if (c == ';') {
tokens.push_back({TokenType::SEMICOLON, ";"});
i++;
continue;
}
// 识别运算符
if (c == '=' || c == '+' || c == '-' || c == '*' || c == '/') {
tokens.push_back({TokenType::OPERATOR, std::string(1, c)});
i++;
continue;
}
// 未知字符
tokens.push_back({TokenType::UNKNOWN, std::string(1, c)});
i++;
}
return tokens;
}
// 打印 token 类型名称
std::string typeName(TokenType t) {
switch (t) {
case TokenType::KEYWORD: return "KEYWORD";
case TokenType::IDENTIFIER: return "IDENTIFIER";
case TokenType::NUMBER: return "NUMBER";
case TokenType::OPERATOR: return "OPERATOR";
case TokenType::SEMICOLON: return "SEMICOLON";
default: return "UNKNOWN";
}
}
int main() {
// 测试源代码
std::string source = "int x = 42;";
std::cout << "源代码: " << source << std::endl;
std::cout << "词法分析结果:" << std::endl;
// 调用词法分析器(管道第一步)
auto tokens = lexicalAnalyze(source);
// 输出每个 token
for (const auto& tok : tokens) {
std::cout << " [" << typeName(tok.type) << "] "
<< tok.value << std::endl;
}
// 预期输出:
// [KEYWORD] int
// [IDENTIFIER] x
// [OPERATOR] =
// [NUMBER] 42
// [SEMICOLON] ;
return 0;
}
六、总结
本章核心知识图
软件架构设计
├── 是什么
│ ├── 系统的"骨架"蓝图
│ └── 影响性能/安全/可维护性等非功能属性
│
├── 设计决策
│ ├── 选择架构模式
│ ├── 分布方式
│ └── 文档化方式
│
├── 架构视图(4+1)
│ ├── 逻辑视图
│ ├── 过程视图
│ ├── 开发视图
│ └── 物理视图
│
├── 四大架构模式
│ ├── MVC ─────→ 分离数据/视图/控制
│ ├── 分层 ────→ 层层依赖,易替换
│ ├── 仓库 ────→ 中央数据共享
│ └── 管道过滤器→ 数据流水线加工
│
└── 典型应用架构
├── 事务处理系统(ATM、银行)
├── 信息系统(医院、图书馆)
└── 语言处理系统(编译器)
关键结论:
- 架构决策比代码实现更难撤销,需要尽早做出
- 不存在"万能架构",要根据具体非功能需求权衡选择
- 不同的架构模式可以组合使用(如编译器 = 仓库 + 管道过滤器)
第七章:设计与实现 —— 从零理解
本文是《软件工程》第七章的详细中文解读,力求用最通俗易懂的语言讲清楚每个概念。
一、设计与实现是什么关系?
生活类比:盖房子时,建筑师画设计图(设计),工人按图施工(实现)。软件开发也一样——设计是决定"怎么做",实现是把设计变成真正可以运行的代码。
两者的关系:
需求(客户要什么)
↓
软件设计(怎么做)
↓
软件实现(把设计变成代码)
重要决策:在项目早期,需要决定"买还是自己做"。
买(配置现成软件) → 便宜快速,但定制性差
自己做(从零开发) → 完全定制,但贵且慢
二、面向对象设计的五个步骤
整个面向对象设计过程可以分为五步,像剥洋葱一样从外到内:
2.1 步骤一:理解系统上下文与交互
目标:搞清楚"这个系统和外部世界有什么关系"。
需要建立两种模型:
| 模型类型 | 是什么 | 解决什么问题 |
|---|---|---|
| 系统上下文模型 | 静态结构图,展示周围有哪些系统 | 这个系统和谁打交道? |
| 交互模型(用例图) | 动态图,展示系统如何被使用 | 系统具体做了什么? |
气象站的系统上下文(图7.1):
┌─────────────────┐
│ 气象信息系统 │ (1个)
└────────┬────────┘
│
┌───────────────────┼──────────────────┐
│ │ │
┌──────┴──────┐ ┌───────┴───────┐ ┌─────┴──────┐
│ 控制系统 │ │ 卫星系统 │ │气象站1..N │
│ (1个) │ │ (1个) │ │(多个) │
└─────────────┘ └───────────────┘ └────────────┘
气象站的用例图(图7.2):
气象站系统
┌─────────────────────────────────────────────┐
│ │
│ (报告天气数据) (报告状态) │
气象 │ │
信息 ─── 报告天气 ────────────────────────────── │
系统 │ │
│ ── 报告状态 ───────────────────────────── │
│ │
└─────────────────────────────────────────────┘
↑
┌───────────────────────────────────────┐
│ 重启 / 关机 / 重新配置 / 省电模式 / │
控制│ 远程控制 │
系统──────────────────────────────────────── │
└───────────────────────────────────────┘
用例描述表格示例(图7.3,"报告天气"用例):
| 字段 | 内容 |
|---|---|
| 系统 | 气象站 |
| 用例 | 报告天气 |
| 参与者 | 气象信息系统、气象站 |
| 数据 | 发送温度/气压/风速/风向/降雨量的最大/最小/平均值 |
| 触发 | 气象信息系统通过卫星链路发起请求 |
| 响应 | 气象站发回汇总数据 |
| 备注 | 通常每小时报告一次,频率可调整 |
2.2 步骤二:设计系统架构
基于对外部交互的理解,设计系统的主要组件和它们的关系。
气象站高层架构(图7.4):
┌─────────────────────────────────────────────────────────┐
│ 气象站系统 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────────┐ │
│ │数据采集 │ │通信子系统│ │ 配置管理子系统 │ │
│ │子系统 │ │ │ │ │ │
│ └────┬─────┘ └────┬─────┘ └──────────────────────┘ │
│ │ │ │
│ ┌────┴─────────────┴──────────────────────┐ │
│ │ 通信链路(广播总线) │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │故障管理 │ │电源管理 │ │ 仪器组 │ │
│ │子系统 │ │子系统 │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────┘
设计亮点:所有子系统通过广播总线通信,发送方不需要知道接收方是谁——这就是"监听者模式",方便扩展和修改。
2.3 步骤三:识别对象类
三种常见识别方法:
方法1:语法分析法
→ 从系统自然语言描述中
→ 名词 = 对象/属性
→ 动词 = 操作/服务
方法2:应用领域分析法
→ 找领域中的有形实体(飞机、传感器)
→ 找角色(管理员)、事件(请求)
→ 找交互(会议)、组织单位(部门)
方法3:场景分析法
→ 逐个分析使用场景
→ 每个场景中识别所需的对象、属性、操作
气象站识别出的对象类(图7.6):
继承层次设计:注意到 GroundThermometer、Anemometer、Barometer 都有 get() 和 test() 操作,可以抽象出父类:
Instrument(抽象父类)
├── identifier
├── get()
└── test()
↑
┌─────────┼──────────┐
│ │ │
地面温度计 风速计 气压计
2.4 步骤四:开发设计模型
设计模型分两大类:
| 模型类型 | 说明 | UML 表示 |
|---|---|---|
| 静态模型 | 描述系统的固定结构 | 类图、子系统图 |
| 动态模型 | 描述运行时的行为 | 序列图、状态机图 |
序列图(动态模型)
序列图从上到下读,展示对象之间的消息传递顺序。
"报告天气"序列图(图7.7):
气象信息 :SatComms :WeatherStation :Commslink :WeatherData
系统 | | | |
| | | | |
|─request──►| | | |
|◄─ack─────| | | |
| |──reportWeather()─►| | |
| |◄─────ack──────── | | |
| | |──get(summary)─►| |
| | | |──summarize()─►|
| | | |◄─────────────|
| | |◄──send(report)─| |
| |◄─reply(report)───| | |
|◄─reply───| | | |
注:实心箭头 = 异步(不等回复继续执行)
方形箭头 = 同步(等待回复才继续)
状态机图(动态模型)
状态机图展示对象在不同"状态"之间如何切换。
气象站状态图(图7.8):
状态转换读法示例:
- 初始状态是 Shutdown(黑点箭头指向的)
- 收到
restart()消息 → 进入 Running 状态 - 在 Running 时收到
reportWeather()→ 进入 Summarizing → 进入 Transmitting → 回到 Running - 在 Running 时收到时钟信号 → 进入 Collecting(采集数据)→ 回到 Running
2.5 步骤五:接口规定
接口定义了对象对外"承诺提供"什么服务,隐藏内部实现细节。好比插座定义了"两孔/三孔"标准,你不需要知道里面的电路怎么接。
气象站的两个接口(图7.9):
«interface» «interface»
Reporting RemoteControl
───────────────── ─────────────────────────
weatherReport(WS-Ident) startInstrument(instrument)
statusReport(WS-Ident) stopInstrument(instrument)
collectData(instrument)
provideData(instrument)
好处:接口不变,内部实现可以随时修改,不影响调用者。
三、设计模式(Design Patterns)
什么是设计模式?
生活类比:厨师的"食谱"——描述的不是某一道具体的菜,而是"如何做某类菜的通用方法",比如"红烧类做法"可以用在红烧肉、红烧鱼上。
设计模式是"前人踩坑后总结出来的通用解决方案",由"四人帮(Gang of Four)"在1995年的书中系统整理。
设计模式的四个要素:
1. 名称(Name)
→ 一个有意义的简短名字,方便沟通
2. 问题描述(Problem)
→ 什么场景下可以用这个模式?
3. 解决方案(Solution)
→ 解决这个问题的设计模板(非具体代码)
4. 后果(Consequences)
→ 用了这个模式有什么优缺点?
观察者模式(Observer Pattern)详解
场景:同一份数据需要用多种方式展示,而且随时可能增加新的展示方式。
生活类比:股票价格(数据)同时在手机 App、电脑网页、大屏幕上显示。价格一变,所有显示都要自动更新。
UML 结构(图7.12):
角色说明:
| 角色 | 职责 | 类比 |
|---|---|---|
| Subject(主题) | 持有数据,管理观察者列表 | 股票交易所 |
| ConcreteSubject | 实际存储数据,状态变化时通知所有观察者 | 某只股票的实时价格 |
| Observer(观察者) | 定义更新接口 | "订阅者"这个角色的抽象 |
| ConcreteObserver | 具体的显示方式,收到通知后更新显示 | 手机App、网页、大屏幕 |
完整 C++ 实现(观察者模式):
#include <iostream>
#include <vector>
#include <string>
#include <algorithm> // for std::find, std::remove
// ============================================================
// 观察者模式完整实现
// 场景:股票价格变化,多个显示界面自动更新
// ============================================================
// 前向声明
class Subject;
// ============================================================
// Observer(观察者抽象基类):定义更新接口
// ============================================================
class Observer {
public:
virtual ~Observer() = default;
// 纯虚函数:所有具体观察者必须实现这个方法
// 当被观察对象状态改变时,调用此方法通知观察者
virtual void update(Subject* subject) = 0;
// 获取观察者名称(方便调试输出)
virtual std::string getName() const = 0;
};
// ============================================================
// Subject(主题抽象基类):管理观察者列表,发出通知
// ============================================================
class Subject {
private:
// 存储所有注册的观察者指针
std::vector<Observer*> observers;
public:
virtual ~Subject() = default;
// 注册(添加)一个观察者
void attach(Observer* observer) {
observers.push_back(observer);
}
// 注销(移除)一个观察者
void detach(Observer* observer) {
// 从列表中删除指定观察者
observers.erase(
std::remove(observers.begin(), observers.end(), observer),
observers.end()
);
}
// 通知所有观察者:调用每个观察者的 update()
void notify() {
for (Observer* obs : observers) {
obs->update(this); // 把自身传给观察者,让它取最新数据
}
}
};
// ============================================================
// ConcreteSubject(具体主题):股票价格数据
// ============================================================
class StockPrice : public Subject {
private:
std::string stockName; // 股票名称
double price; // 当前价格
public:
StockPrice(const std::string& name, double initialPrice)
: stockName(name), price(initialPrice) {}
// 修改价格,并自动通知所有观察者
void setPrice(double newPrice) {
price = newPrice;
std::cout << "\n[股票] " << stockName
<< " 价格变动为 " << price << " 元" << std::endl;
notify(); // 通知所有已注册的观察者
}
// 提供数据访问接口(观察者通过这个方法取数据)
double getPrice() const { return price; }
std::string getStockName() const { return stockName; }
};
// ============================================================
// ConcreteObserver1:手机 App 显示
// ============================================================
class MobileAppDisplay : public Observer {
private:
double lastKnownPrice; // 记录上次看到的价格
std::string appName;
public:
MobileAppDisplay(const std::string& name) : appName(name), lastKnownPrice(0) {}
// 收到通知后,更新显示
void update(Subject* subject) override {
// 将 Subject 向下转型为 StockPrice 以获取具体数据
StockPrice* stock = dynamic_cast<StockPrice*>(subject);
if (stock) {
lastKnownPrice = stock->getPrice();
std::cout << " [" << appName << "] 手机显示: "
<< stock->getStockName()
<< " = " << lastKnownPrice << " 元" << std::endl;
}
}
std::string getName() const override { return appName; }
};
// ============================================================
// ConcreteObserver2:网页图表显示
// ============================================================
class WebChartDisplay : public Observer {
private:
std::string chartName;
public:
WebChartDisplay(const std::string& name) : chartName(name) {}
void update(Subject* subject) override {
StockPrice* stock = dynamic_cast<StockPrice*>(subject);
if (stock) {
// 模拟绘制柱状图
int bars = static_cast<int>(stock->getPrice() / 10);
std::cout << " [" << chartName << "] 网页图表: ";
for (int i = 0; i < bars && i < 20; ++i) {
std::cout << "|";
}
std::cout << " " << stock->getPrice() << " 元" << std::endl;
}
}
std::string getName() const override { return chartName; }
};
// ============================================================
// ConcreteObserver3:预警系统(价格超过阈值时报警)
// ============================================================
class AlertSystem : public Observer {
private:
double threshold; // 报警阈值
public:
AlertSystem(double threshold) : threshold(threshold) {}
void update(Subject* subject) override {
StockPrice* stock = dynamic_cast<StockPrice*>(subject);
if (stock && stock->getPrice() > threshold) {
std::cout << " [警报] " << stock->getStockName()
<< " 价格超过阈值 " << threshold
<< " 元!当前: " << stock->getPrice() << " 元" << std::endl;
}
}
std::string getName() const override { return "预警系统"; }
};
// ============================================================
// 主函数:演示观察者模式
// ============================================================
int main() {
std::cout << "===== 观察者模式演示:股票价格监控 =====" << std::endl;
// 创建被观察对象(具体主题):腾讯股票,初始价格300元
StockPrice tencent("腾讯", 300.0);
// 创建三个观察者(不同的显示方式)
MobileAppDisplay app1("我的股票App");
MobileAppDisplay app2("另一个App");
WebChartDisplay chart("K线图");
AlertSystem alert(350.0); // 超过350元报警
// 注册观察者(订阅价格变化通知)
tencent.attach(&app1);
tencent.attach(&app2);
tencent.attach(&chart);
tencent.attach(&alert);
// 价格变化,所有观察者自动更新
tencent.setPrice(320.0); // 普通涨价
tencent.setPrice(355.0); // 超过阈值,触发报警
// 注销一个观察者(取消订阅)
std::cout << "\n[操作] 注销 '另一个App'" << std::endl;
tencent.detach(&app2);
tencent.setPrice(310.0); // 只有3个观察者收到通知了
return 0;
}
四种常见设计模式速查:
| 模式名 | 解决的问题 | 核心思想 |
|---|---|---|
| Observer(观察者) | 一个对象变化需通知多个对象 | 订阅-发布机制 |
| Facade(门面) | 多个复杂接口需要统一整理 | 提供一个简单的统一入口 |
| Iterator(迭代器) | 统一遍历各种集合 | 把遍历逻辑从集合中分离 |
| Decorator(装饰器) | 运行时动态扩展对象功能 | 用包装层叠加新功能 |
四、实现中的重要问题
4.1 软件复用(Reuse)
核心思想:站在巨人的肩膀上,尽量不重复造轮子。
软件复用的四个层次(图7.13):
复用的代价与收益:
| 内容 | |
|---|---|
| 好处 | 开发更快;风险更低;更可靠(已在其他项目中验证过) |
| 成本 | 寻找合适组件需要时间;购买费用;适配和集成的工作量 |
原则:开始设计前先想"有没有现成的可以用",而不是一开始就从零做。
4.2 配置管理(Configuration Management)
问题背景:多人同时修改同一份代码,容易出现"我改了你的改动被覆盖"或"用了旧版本"的问题。
生活类比:多人合作写一本书,如果没有版本管理,A在第三章改了一个段落,B同时也在改第三章的同一个段落,最后合并时会乱套。
配置管理的四个核心活动(图7.14):
常用工具:
版本管理:Git、Subversion(SVN)
构建系统:GNU Make、Maven
Bug追踪:Bugzilla、GitHub Issues
一站式平台:GitHub(集成了以上所有功能)
4.3 宿主-目标开发(Host-Target Development)
核心概念:写代码的机器(宿主/Host)和运行代码的机器(目标/Target)往往是不同的。
宿主(Host):开发平台
├── 你写代码的笔记本/台式机
├── 装有 IDE、编译器、调试工具
└── 例:Windows 上的 Visual Studio
目标(Target):执行平台
├── 代码最终运行的环境
├── 可能是嵌入式设备、服务器、手机
└── 例:工厂里的 ARM 控制器、云服务器
当宿主和目标不同时的挑战:
挑战1:测试困难
→ 不能直接在开发机上运行测试
→ 解决:使用模拟器(Simulator)
挑战2:依赖不同
→ 目标环境有特殊中间件或库
→ 解决:把代码传到目标机器上测试
挑战3:硬件特性
→ 嵌入式设备有功耗、尺寸、实时性约束
→ 解决:专门为目标硬件设计
开发平台需要的工具:
IDE(集成开发环境)
├── 编译器 + 语法高亮编辑器
├── 调试器(Debugger)
├── 图形编辑工具(如UML工具)
├── 测试工具(如JUnit)
├── 重构工具
└── 配置管理工具(如Git插件)
最著名的通用IDE:Eclipse(基于插件架构,可以支持Java、C/C++、嵌入式开发等各种语言和平台)
五、开源开发(Open-Source Development)
什么是开源?
开源:把软件的源代码公开,允许任何人查看、修改、参与开发。
起源:自由软件基金会(FSF)主张"代码应该对所有人可见和可修改"。
著名开源项目:
操作系统:Linux(最广泛使用的服务器OS)
Web服务器:Apache
数据库:MySQL
移动OS:Android
开发工具:Eclipse IDE
编程语言:Java(部分开源)
开源许可证的三种类型
许可证决定了"别人用了你的代码后,有什么义务":
实际影响:如果你在做一个商业软件,要特别注意:
使用 GPL 组件 → 你的整个软件也必须开源(可能违反商业目的)
使用 LGPL 组件 → 只要不修改库本身,可以用在闭源软件
使用 BSD/MIT 组件 → 最安全,可以自由用在闭源商业软件中
企业是否应该使用/参与开源?
使用开源的理由:
- 节省时间和成本
- 质量更高(大量用户帮助发现和修复 bug)
- 免费或低成本获取
自己开源的理由: - 降低开发成本(社区帮助开发)
- 建立用户社区
- 增加客户信任(如果公司倒闭,客户可以自己维护代码)
开源的注意事项: - 并非开源了就会有人参与,需要社区建设
- 有可能泄露商业知识
- 需要追踪所有使用的开源组件的许可证
六、全章总结
第七章知识图谱
面向对象设计(5步)
├── 1. 系统上下文(上下文图 + 用例图)
├── 2. 架构设计(子系统 + 通信方式)
├── 3. 识别对象类(语法/领域/场景分析)
├── 4. 设计模型
│ ├── 静态模型:类图、子系统图
│ └── 动态模型:序列图、状态机图
└── 5. 接口规定(interface 定义,隐藏实现)
设计模式
├── 定义:对通用问题的通用解决方案模板
├── 四要素:名称、问题、方案、后果
└── 常见模式:Observer、Facade、Iterator、Decorator
实现问题
├── 软件复用(4个层次:抽象/对象/组件/系统)
├── 配置管理(版本/集成/问题追踪/发布)
└── 宿主-目标开发(开发机 ≠ 运行机)
开源开发
├── 三种许可证:GPL(互惠)/ LGPL(宽松互惠)/ BSD(非互惠)
└── 商业使用注意:GPL 可能强制你开源自己的产品
核心结论:
- 设计和实现是交织进行的,不是严格先后顺序
- 设计模式是"复用设计知识",不是复用代码
- 能复用的尽量复用,但要搞清楚许可证
- 配置管理在多人协作中是必须的,不是可选的
第八章:软件测试 —— 从零理解
本文是《软件工程》第八章的详细中文解读,力求用最通俗易懂的语言讲清楚每个概念。
一、测试是什么?为什么要测试?
生活类比:你做了一道菜,在上桌前你要先尝一口——这就是"测试"。软件测试就是在软件正式交付给用户之前,用各种输入数据去"尝一尝",看看它的表现是否符合预期。
测试有两个核心目标:
测试的两大目标
目标1:验证(Validation)
→ 证明软件做了"该做的事"
→ 用正常的、预期的输入去跑,看输出是否正确
→ 问题:我们在做正确的产品吗?
目标2:发现缺陷(Defect Testing)
→ 找出软件在哪些情况下会出错
→ 故意用奇怪的、边界的、异常的输入去测
→ 问题:我们把产品做正确了吗?
最重要的一句话(Dijkstra,软件工程先驱):
“测试只能证明缺陷的存在,不能证明缺陷不存在。”
这句话的意思是:就算你做了一千个测试都通过了,也不能保证软件没有 bug。因为还有你没想到的测试用例。
验证 vs 确认
| 术语 | 英文 | 含义 | 问题 |
|---|---|---|---|
| 验证 | Verification | 检查软件是否符合规格说明 | 我们把产品做对了吗? |
| 确认 | Validation | 检查软件是否满足用户真实需求 | 我们做的是用户真正想要的吗? |
区别举例:规格说明书写的是"按钮是蓝色的",软件里按钮确实是蓝色的——这叫验证通过。但用户其实想要红色——这就是确认失败。
静态检查 vs 动态测试
除了运行程序来测试,还可以不运行程序,直接检查代码/文档,叫代码审查(Inspection)。
| 方式 | 是否运行程序 | 优点 | 局限 |
|---|---|---|---|
| 代码审查 | 不运行 | 可发现60%以上的错误;可审查未完成代码 | 无法发现运行时的交互问题、时序问题 |
| 软件测试 | 运行 | 能发现运行时才出现的问题 | 错误可能互相遮掩;无法穷举所有情况 |
二、测试的三个阶段
商业软件通常要经过三个大阶段的测试:
三、开发测试(Development Testing)
开发测试由开发团队自己负责,分为三个层次:
开发测试的三个层次
单元测试 (Unit Testing)
└── 测试单个方法、函数、类
目标:验证每个"零件"本身是好的
组件测试 (Component Testing)
└── 把多个单元组合成组件,测试组件接口
目标:验证"零件"组合在一起接口没问题
系统测试 (System Testing)
└── 把所有或大部分组件集成在一起测
目标:验证整个"机器"运转正常
3.1 单元测试(Unit Testing)
核心思想:一次只测一个"最小单位",比如一个函数或一个类的方法。
一个自动化单元测试由三部分构成:
自动化测试的三部曲
1. 设置(Setup)
→ 准备好测试数据、初始化对象
2. 调用(Call)
→ 调用要测试的方法/函数
3. 断言(Assertion)
→ 检查结果是否符合预期
→ 断言为真 = 测试通过
→ 断言为假 = 测试失败
Mock 对象:当被测对象依赖其他还没做好的模块(比如数据库),可以用"假的"Mock对象代替,模拟那个模块的行为,让测试可以独立进行。
C++ 单元测试示例(模拟简单的天气站测试):
#include <iostream>
#include <string>
#include <cassert> // 提供 assert 断言宏
#include <stdexcept> // 提供异常类
// ============================================================
// 被测对象:天气站类(WeatherStation)
// ============================================================
class WeatherStation {
private:
std::string identifier; // 天气站唯一标识符
bool running; // 是否正在运行
double temperature; // 当前温度
double humidity; // 当前湿度
public:
// 构造函数:初始化天气站
WeatherStation(const std::string& id)
: identifier(id), running(false), temperature(0.0), humidity(0.0) {}
// 启动仪器
void restart() {
running = true;
}
// 关闭仪器
void shutdown() {
if (!running) {
// 已经关闭的情况下再关闭,抛出异常
throw std::logic_error("天气站已经处于关闭状态");
}
running = false;
}
// 设置传感器数据(模拟数据采集)
void setData(double temp, double hum) {
temperature = temp;
humidity = hum;
}
// 生成天气报告
std::string reportWeather() const {
if (!running) {
return "ERROR: 天气站未运行";
}
return "温度:" + std::to_string(temperature)
+ " 湿度:" + std::to_string(humidity);
}
// 获取标识符
std::string getId() const { return identifier; }
// 获取运行状态
bool isRunning() const { return running; }
};
// ============================================================
// 简单的测试框架(模拟 JUnit 的行为)
// ============================================================
int testsPassed = 0; // 通过的测试数
int testsFailed = 0; // 失败的测试数
// 测试断言函数
void check(bool condition, const std::string& testName) {
if (condition) {
std::cout << "[PASS] " << testName << std::endl;
testsPassed++;
} else {
std::cout << "[FAIL] " << testName << std::endl;
testsFailed++;
}
}
// ============================================================
// 单元测试:测试 WeatherStation 类的各个方法
// ============================================================
// 测试1:标识符正确设置(Setup → Call → Assert)
void test_identifier_set_correctly() {
// Setup:创建天气站对象
WeatherStation ws("WS-001");
// Call + Assert:检查标识符是否正确
check(ws.getId() == "WS-001", "test_identifier_set_correctly");
}
// 测试2:初始状态应为未运行
void test_initial_state_not_running() {
WeatherStation ws("WS-002");
check(!ws.isRunning(), "test_initial_state_not_running");
}
// 测试3:restart 后状态变为运行中
void test_restart_sets_running() {
WeatherStation ws("WS-003");
ws.restart(); // 启动
check(ws.isRunning(), "test_restart_sets_running");
}
// 测试4:未运行时的报告应返回错误信息
void test_report_when_not_running_returns_error() {
WeatherStation ws("WS-004");
// 没有调用 restart(),直接请求报告
std::string report = ws.reportWeather();
check(report == "ERROR: 天气站未运行",
"test_report_when_not_running_returns_error");
}
// 测试5:运行中的报告应包含数据
void test_report_when_running_contains_data() {
WeatherStation ws("WS-005");
ws.restart(); // 启动
ws.setData(25.5, 60.0); // 设置传感器数据
std::string report = ws.reportWeather();
// 检查报告中包含温度信息
check(report.find("25.500000") != std::string::npos,
"test_report_when_running_contains_data");
}
// 测试6:状态转换序列 Shutdown → Running → Shutdown
void test_state_sequence_shutdown_running_shutdown() {
WeatherStation ws("WS-006");
// 初始:未运行
check(!ws.isRunning(), "sequence: 初始未运行");
// 启动
ws.restart();
check(ws.isRunning(), "sequence: restart后运行中");
// 关闭
ws.shutdown();
check(!ws.isRunning(), "sequence: shutdown后停止运行");
}
// 测试7:对已关闭的天气站再次关闭,应抛出异常
void test_shutdown_when_already_stopped_throws() {
WeatherStation ws("WS-007");
bool exceptionThrown = false;
try {
ws.shutdown(); // 还没 restart 就 shutdown
} catch (const std::logic_error&) {
exceptionThrown = true;
}
check(exceptionThrown, "test_shutdown_when_already_stopped_throws");
}
// ============================================================
// 主函数:运行所有测试
// ============================================================
int main() {
std::cout << "===== 天气站单元测试 =====" << std::endl;
// 运行所有测试用例
test_identifier_set_correctly();
test_initial_state_not_running();
test_restart_sets_running();
test_report_when_not_running_returns_error();
test_report_when_running_contains_data();
test_state_sequence_shutdown_running_shutdown();
test_shutdown_when_already_stopped_throws();
// 汇总报告
std::cout << "\n===== 测试结果汇总 =====" << std::endl;
std::cout << "通过: " << testsPassed << std::endl;
std::cout << "失败: " << testsFailed << std::endl;
return testsFailed == 0 ? 0 : 1; // 有失败则返回非零
}
3.2 选择测试用例的两大策略
策略一:等价划分测试(Equivalence Partition Testing)
核心思想:把所有可能的输入按"处理方式相同"分成若干组(等价类),每组只需选一个代表去测就够了。
生活类比:餐厅测试饮料温度,"0-10度冷饮"是一组,“60-80度热饮"是一组,只需各测一杯,不用把所有温度都测一遍。
关键规则:
好的测试用例=边界值+中间值 \text{好的测试用例} = \text{边界值} + \text{中间值} 好的测试用例=边界值+中间值
因为程序员往往只考虑"典型值”,边界值容易出 bug。
具体示例(原书图8.6):
规格说明:程序接受 4 到 8 个输入,每个输入是大于 10000 的五位整数。
输入值的等价划分:
有效区间
[10000 ─────────── 99999]
<10000| |>99999
(无效) | 有效(五位整数) |(无效)
↑ ↑ ↑
9999 50000 100000
(边界外) (中间值) (边界外)
10000 99999
(边界内) (边界内)
输入数量的等价划分:
有效区间
[4 ─────────── 8]
<4 | | >8
(无效)| 有效(4到8) |(无效)
↑ ↑ ↑
3 6 9
(边界外) (中间) (边界外)
4 8
(边界内) (边界内)
| 等价类 | 代表测试值 | 类型 |
|---|---|---|
| 有效五位整数 | 50000 | 有效中间值 |
| 有效五位整数下边界 | 10000 | 有效边界 |
| 有效五位整数上边界 | 99999 | 有效边界 |
| 小于10000(无效) | 9999 | 无效边界 |
| 大于99999(无效) | 100000 | 无效边界 |
| 输入数量4个(边界) | 4个输入 | 有效边界 |
| 输入数量8个(边界) | 8个输入 | 有效边界 |
| 输入数量3个(无效) | 3个输入 | 无效边界 |
| 输入数量9个(无效) | 9个输入 | 无效边界 |
测试命名:
- 只看规格说明设计测试 → 黑盒测试(Black-Box Testing)
- 还看代码内部逻辑设计测试 → 白盒测试(White-Box Testing)
策略二:基于指南的测试(Guideline-Based Testing)
根据经验总结的"常见出错点"来设计测试,比如测试序列/数组/列表时:
测试序列的经验指南:
1. 测试只有一个元素的序列(程序员常假设有多个元素)
2. 用不同长度的序列进行多组测试
3. 专门测试第一个、中间、最后一个元素的访问
4. 测试空序列(0个元素)
5. 测试超大序列(接近或超出最大容量)
3.3 组件测试(Component Testing)
当多个类/对象组合成一个大组件,就需要测试它们之间的接口。
四种接口类型:
三类接口错误:
| 错误类型 | 含义 | 例子 |
|---|---|---|
| 接口误用 | 调用时参数传错了 | 类型错、顺序错、数量错 |
| 接口误解 | 对被调用方的行为理解有误 | 以为二分查找自动排序,实则不会 |
| 时序错误 | 生产者和消费者速度不匹配 | 读到的数据还没被更新 |
3.4 系统测试(System Testing)
把所有或大部分组件整合在一起测试,关注组件之间的交互和涌现行为。
什么是涌现行为(Emergent Behavior):各个零件单独测都没问题,但组合在一起之后才出现的新特性或新问题。就像一堆零件分别测试都好,但组装成汽车后刹车和油门同时踩就可能出问题。
基于用例的系统测试(以天气站为例,图8.8):
请求报告的操作序列:
WeatherInfoSystem SatComms WeatherStation Commslink WeatherData
| | | | |
|--- request(report) ->| | | |
|<-- acknowledge ------| | | |
| |-- reportWeather -->| | |
| |<-- acknowledge ----| | |
| | |---- get(summary) ->| |
| | | |--- summarise() ->|
| | | |<-- summary ------|
| | |<--- send(report) --| |
| |<-- reply(report) --| | |
|<-- reply(report) ----| | | |
四、测试驱动开发(Test-Driven Development, TDD)
核心思想:先写测试,再写代码。不是等代码写完了再去测,而是在写代码之前就定义好"什么叫成功"。
TDD 的循环流程(图8.9):
TDD 的四大好处:
1. 代码覆盖(Code Coverage)
→ 每段代码都有对应测试,不会有"死代码"漏测
2. 回归测试(Regression Testing)
→ 改了代码之后,跑一遍所有旧测试,确保没引入新 bug
3. 简化调试(Simplified Debugging)
→ 测试失败 = 刚写的那段代码有问题,范围明确
4. 代码即文档(System Documentation)
→ 测试用例本身就在说明"这段代码该干什么"
C++ TDD 示例(测试一个字符串处理函数):
#include <iostream>
#include <string>
#include <cassert>
// ============================================================
// TDD 步骤演示:catWhiteSpace 函数
// 功能:把段落中连续的空白字符压缩成一个空格
// ============================================================
// 第一步:先写测试(此时函数还没实现)
// 第二步:运行 → 全部失败(正常!)
// 第三步:实现函数
// 第四步:再次运行 → 全部通过
// ============================================================
// 函数实现:把连续空白压缩成单个空格
// ============================================================
std::string catWhiteSpace(const std::string& paragraph) {
std::string result;
bool lastWasSpace = false; // 上一个字符是否是空白
for (char c : paragraph) {
bool isSpace = (c == ' ' || c == '\t' || c == '\n');
if (isSpace) {
if (!lastWasSpace) {
// 第一个空白字符,保留为一个空格
result += ' ';
}
// 后续连续空白,跳过(压缩)
lastWasSpace = true;
} else {
// 非空白字符,直接保留
result += c;
lastWasSpace = false;
}
}
return result;
}
// ============================================================
// 测试框架(简化版)
// ============================================================
int passed = 0, failed = 0;
void assertEqual(const std::string& expected,
const std::string& actual,
const std::string& testName) {
if (expected == actual) {
std::cout << "[PASS] " << testName << std::endl;
passed++;
} else {
std::cout << "[FAIL] " << testName << std::endl;
std::cout << " 期望: \"" << expected << "\"" << std::endl;
std::cout << " 实际: \"" << actual << "\"" << std::endl;
failed++;
}
}
// ============================================================
// TDD 测试用例(先写这些,再实现函数)
// ============================================================
// 等价类1:无空白的字符串,应该不变
void test_no_whitespace_unchanged() {
assertEqual("hello", catWhiteSpace("hello"),
"无空白字符串不变");
}
// 等价类2:单个空格,应该不变
void test_single_space_unchanged() {
assertEqual("a b", catWhiteSpace("a b"),
"单个空格不变");
}
// 等价类3:多个连续空格,应压缩为一个
void test_multiple_spaces_compressed() {
assertEqual("a b", catWhiteSpace("a b"),
"多个空格压缩为一个");
}
// 等价类4:制表符应被当作空白处理
void test_tabs_treated_as_whitespace() {
assertEqual("a b", catWhiteSpace("a\t\tb"),
"制表符压缩");
}
// 等价类5:混合空白(空格+制表符)压缩
void test_mixed_whitespace_compressed() {
assertEqual("a b", catWhiteSpace("a \t b"),
"混合空白压缩");
}
// 边界:空字符串
void test_empty_string() {
assertEqual("", catWhiteSpace(""),
"空字符串");
}
// 边界:只有一个字符
void test_single_character() {
assertEqual("x", catWhiteSpace("x"),
"单个字符");
}
// 边界:全是空格
void test_all_spaces() {
assertEqual(" ", catWhiteSpace(" "),
"全空格压缩为一个空格");
}
// 边界:字符串以空格开头
void test_leading_spaces() {
assertEqual(" abc", catWhiteSpace(" abc"),
"开头多空格压缩");
}
// 边界:字符串以空格结尾
void test_trailing_spaces() {
assertEqual("abc ", catWhiteSpace("abc "),
"结尾多空格压缩");
}
// ============================================================
// 主函数
// ============================================================
int main() {
std::cout << "===== catWhiteSpace TDD 测试 =====" << std::endl;
test_no_whitespace_unchanged();
test_single_space_unchanged();
test_multiple_spaces_compressed();
test_tabs_treated_as_whitespace();
test_mixed_whitespace_compressed();
test_empty_string();
test_single_character();
test_all_spaces();
test_leading_spaces();
test_trailing_spaces();
std::cout << "\n===== 结果 =====" << std::endl;
std::cout << "通过: " << passed << " 失败: " << failed << std::endl;
return failed == 0 ? 0 : 1;
}
五、发布测试(Release Testing)
发布测试是在软件正式发布给用户之前,由独立的测试团队(不是开发者)进行的测试。
两个关键区别:
开发测试 → 目的是找 bug(缺陷测试)
发布测试 → 目的是确认软件满足需求(验证测试)
发布测试是黑盒测试(也叫功能测试):测试者不看代码,只看输入和输出。
5.1 基于需求的测试(Requirements-Based Testing)
每个需求至少对应一个测试,证明这个需求被正确实现了。
示例(Mentcare 系统的药物过敏警告需求):
需求原文:
- 如果患者对某药物过敏,开处方时系统必须发出警告。
- 如果医生无视警告,必须说明原因。
对应测试用例:
测试1:无过敏记录的患者,开任意药不应触发警告
测试2:有过敏记录的患者,开过敏药应触发警告
测试3:有两种过敏药的患者,分别开两种药,应各触发一次警告
测试4:同时开两种过敏药,应同时显示两个警告
测试5:无视警告继续开处方,系统应要求填写忽略原因
5.2 场景测试(Scenario Testing)
核心思想:写一个"真实的故事",描述用户在现实中如何使用系统,然后用这个故事来测试。
示例故事(图8.10,George 护士的家访场景):
场景:护士 George 在使用 Mentcare 系统进行家访
步骤:
1. George 登录系统(测:认证功能)
2. 打印当天家访日程(测:日程管理)
3. 把患者记录下载到笔记本电脑(测:数据导出 + 加密)
4. 查看患者 Jim 的记录(测:记录查询 + 解密)
5. 查询药物副作用(测:药物数据库连接)
6. 在记录中写备注(测:记录修改)
7. 设置回诊提醒(测:提醒功能)
8. 系统自动重新加密 Jim 的记录(测:自动加密)
9. 回到诊所后上传所有记录(测:数据上传)
10. 系统生成需要回电的患者列表(测:自动生成列表)
场景测试的好处:一个场景同时测了多个功能的组合,还能发现单独测每个功能发现不了的交互问题。
5.3 性能测试(Performance Testing)
分为两类:
操作剖面:如果 90% 的实际操作是 A 类,5% 是 B 类,那么测试也要按这个比例分配,否则测的不是真实场景。
压力测试:设计上限是每秒处理300笔事务,那就逐渐加压到400、500……直到系统崩溃,看看:
- 系统是"优雅失败"(fail-soft,有序退化)还是直接崩溃?
- 在高负载下是否有只在此时才出现的隐藏 bug?
六、用户测试(User Testing)
用户参与测试,因为只有真实用户才了解自己的工作环境和使用习惯,开发者无法在测试环境中完全模拟。
三种用户测试类型:
Alpha 测试(Alpha Testing)
│
├── 少数选定的用户
├── 与开发团队紧密合作
└── 测试早期版本
Beta 测试(Beta Testing)
│
├── 更大范围的用户群
├── 让用户在自己的环境中使用
└── 发现各种环境下的兼容性问题
验收测试(Acceptance Testing)
│
├── 客户用自己的数据测试
├── 正式决定是否接收/支付
└── 有六个正式步骤(见下)
验收测试的六个步骤(图8.11)
注意:现实中验收测试结果往往是"有条件接受"——客户急于上线,愿意接受有问题的版本,但要求开发方承诺尽快修复。
七、全章总结
软件测试全景图
目的
├── 验证(Validation):做了正确的产品?
└── 缺陷测试(Defect Testing):产品做正确了?
测试阶段
├── 开发测试(Development Testing)
│ ├── 单元测试:测单个方法/类
│ │ ├── 等价划分:把输入分组,选代表值
│ │ └── 边界值:重点测边界
│ ├── 组件测试:测接口
│ │ ├── 参数接口
│ │ ├── 共享内存接口
│ │ ├── 过程调用接口
│ │ └── 消息传递接口
│ └── 系统测试:测整体交互
│
├── 发布测试(Release Testing) ← 独立团队,黑盒
│ ├── 基于需求:每个需求对应测试
│ ├── 场景测试:真实使用故事
│ └── 性能/压力测试
│
└── 用户测试(User Testing)
├── Alpha:少数用户 + 开发团队协作
├── Beta:大范围公开测试
└── 验收测试:客户正式决定是否接收
TDD(测试驱动开发)
└── 先写测试 → 写代码让测试通过 → 重构 → 循环
关键结论:
- 测试只能证明有 bug,不能证明没有 bug
- 不同阶段的测试有不同目的,不能互相替代
- 自动化测试大幅降低回归测试的成本
- 真实用户的测试不可替代,因为开发环境无法还原真实使用场景
第九章:软件演化 —— 从零理解
本文是《软件工程》第九章的详细中文解读,力求用最通俗易懂的语言讲清楚每个概念。
一、为什么软件需要"演化"?
生活类比:你买了一部手机,一直不更新系统,过两年发现新 App 装不上、银行 App 不支持、安全漏洞无人修——它还能用,但越来越"废"。软件也是一样。
软件必须持续变化的原因:
外部环境变化
├── 业务需求变了(公司业务模式调整)
├── 用户期望变了(用户希望更好用)
├── 硬件/平台变了(操作系统升级)
└── 竞争对手发布了新功能
内部原因
├── 运行中发现了 Bug
├── 性能需要优化
└── 非功能需求(安全性)需要改进
关键数据:历史数据显示,软件成本的 60% 到 90% 都是演化(维护)成本,而非初始开发成本。2006 年美国约 75% 的开发人员在做软件演化工作。
二、软件演化的生命周期模型
2.1 螺旋模型(图9.1)
软件工程不是"开发完就结束",而是一个持续的螺旋过程:
需求分析
|
┌─────┴─────┐
| |
设计 测试
| |
└─────┬─────┘
|
实现
|
发布版本1
|
┌─────┴──────────────────────┐
| 用户反馈/新需求/Bug报告 |
└─────┬──────────────────────┘
|
需求分析(再来一圈)
|
发布版本2
|
发布版本3 ...
现代变化:以前两三年发一个版本,现在互联网产品(App、网站)可能几周就发一个版本,因为竞争压力和用户反馈要求快速响应。
2.2 演化 vs 服务化(图9.2)
Rajlich 和 Bennett 提出的生命周期模型,把软件生命分成四个阶段:
| 阶段 | 特征 | 类比 |
|---|---|---|
| 演化期 | 大量修改架构和功能,用户提了很多需求都在实现 | 装修房子,大改 |
| 服务化期 | 只做小改动,公司在考虑如何替换这个系统 | 修修补补,凑合用 |
| 退役期 | 仅做最必要的改动,用户自行绕过问题 | 破房子,能住就住 |
为什么会从演化期进入服务化期?
随着不断修改,软件结构逐渐退化(degrade),每次修改都变得更贵、更难。当修改成本高到不划算时,就不再大改了。
三、演化过程(Evolution Processes)
演化的驱动力来自变更提案(Change Proposals),可以是:
变更来源
├── 之前需求文档里有但没实现的功能
├── 用户提出的新需求
├── Bug 报告
└── 开发团队发现的改进点
变更分析与演化流程(图9.4)
紧急修复(Emergency Repair,图9.6)
有时必须立刻修复,来不及更新文档:
紧急变更的三种触发情形:
1. 严重系统故障(影响正常运行或安全漏洞)
2. 运行环境突然变化(如操作系统更新引发异常)
3. 业务突然变化(新法规出台、竞争对手行动)
紧急修复流程:
变更请求 → 直接分析源代码 → 修改源代码 → 发布修改后的系统
↑
(跳过了需求和设计文档的更新!)
危险:代码、需求文档、设计文档三者逐渐不一致
→ 这也是敏捷方法主张"少写文档"的原因之一
敏捷方法在演化中的应用
敏捷方法(第三章)同样适用于演化阶段:
- 用**用户故事(User Stories)**表达变更需求
- Scrum 的 Backlog 帮助排列变更优先级
- 自动化回归测试降低变更风险
但也有麻烦: - 敏捷团队交接给计划驱动团队 → 后者期望详细文档,而敏捷没有
- 计划驱动团队交接给敏捷团队 → 代码结构乱,需要先重构才能用敏捷方式工作
四、遗留系统(Legacy Systems)
什么是遗留系统?
定义:使用过时语言和技术开发的老系统,虽然还在用,但维护越来越难。
生活类比:家里还在用一台 20 年前的老空调——它制冷,但耗电高、配件难找、修理师傅越来越少、和新的智能家居系统不兼容。
数据举例:全球商业系统中估计有超过 2000 亿行 COBOL 代码仍在运行(主要在金融行业),很多已经运行了几十年。
遗留系统的六大组成部分(图9.7)
遗留系统不只是软件,而是一个社会技术系统(Sociotechnical System):
分层视角(图9.8):
┌────────────────────────────────────┐
│ 业务流程层 │ ← 最上层:公司怎么运作
├────────────────────────────────────┤
│ 应用软件层 │ ← 业务逻辑代码
├────────────────────────────────────┤
│ 平台与基础设施软件层 │ ← OS、数据库、中间件
├────────────────────────────────────┤
│ 硬件层 │ ← 最底层:物理机器
└────────────────────────────────────┘
注意:改变某一层,往往会连锁影响上下层,并不是完全隔离的。
为什么不直接替换遗留系统?
替换有四大风险:
1. 没有完整的规格说明文档
→ 很难知道新系统要做什么才能完全替代旧系统
2. 业务流程和软件深度绑定
→ 流程是围绕旧软件设计的,换了软件流程也要跟着大改
3. 商业规则嵌入在代码里
→ 例如保险公司的风险评估规则,改错了可能引发重大损失
4. 新开发本身就有风险
→ 可能超期、超预算、达不到预期效果
真实案例:某大型企业有 150 多个遗留系统,决定用一套 ERP 替换全部。最终花了超过 1000 万英镑,只有部分上线,效果还不如被替代的旧系统。
遗留系统管理:四个战略选项(图9.9)
用业务价值和系统质量两个维度来决定对策:
系统质量(技术好坏)
高 ┃ ┃
┃ 继续正常维护 ┃ 必须保留,正常维护
高 ┃ [高质量,低业务价值] ┃ [高质量,高业务价值]
┃ 系统3,4,5 ┃ 系统6
┃─────────────────────┃─────────────────────
┃ 直接废弃 ┃ 必须重构或替换
低 ┃ [低质量,低业务价值] ┃ [低质量,高业务价值]
┃ 系统1,2,3 ┃ 系统9,10
┗━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━
低 高
业务价值(有多重要)
| 象限 | 策略 | 理由 |
|---|---|---|
| 低质量 + 低价值 | 废弃 | 维护贵,收益小,不值得 |
| 低质量 + 高价值 | 重构/替换 | 必须用但现在太贵维护,需要改善 |
| 高质量 + 低价值 | 继续正常维护 | 维护不贵,等到需要大改时再废弃 |
| 高质量 + 高价值 | 继续正常维护 | 好用又重要,不需要大动 |
如何评估业务价值?
问四个问题:
1. 使用频率:是否被频繁使用?(偶尔用但关键的也算高价值)
2. 支持的业务流程:支持的业务流程是否高效?是否已过时?
3. 系统可靠性:不可靠影响业务,则业务价值低
4. 系统输出:产出对业务有多重要?
如何评估系统(技术)质量?
从两方面评估:环境评估和应用评估。
环境评估因素(图9.10):
| 因素 | 关键问题 |
|---|---|
| 供应商稳定性 | 硬件/软件供应商还在吗?会继续维护吗? |
| 故障率 | 硬件/支撑软件经常崩溃吗? |
| 系统年龄 | 有多老了?越老越可能过时 |
| 性能 | 性能是否满足用户需求? |
| 维护支持需求 | 需要多少人工支持?成本高吗? |
| 维护成本 | 硬件维护费和软件许可证费用高吗? |
| 互操作性 | 和其他现代系统能对接吗? |
应用质量评估因素(图9.11):
| 因素 | 关键问题 |
|---|---|
| 可理解性 | 代码难理解吗?变量命名有意义吗? |
| 文档 | 文档完整、一致、最新吗? |
| 数据 | 有数据模型吗?数据重复、过时、不一致吗? |
| 性能 | 应用性能是否可接受? |
| 编程语言 | 还有人懂这门语言吗?能找到现代编译器吗? |
| 配置管理 | 版本是否受控管理? |
| 测试数据 | 有测试数据和回归测试记录吗? |
| 人员技能 | 有人会维护这个系统吗? |
五、软件维护(Software Maintenance)
定义:软件交付之后对其进行修改的一般过程。通常用于"维护"是由不同团队负责(非原开发团队)的情形。
三种维护类型
维护成本分布(图9.12):
功能增加/修改 ████████████████████████████ 58%
故障修复 ████████████ 24%
环境适配 ██████████ 19%
(数据来自30年间多项研究,分布基本稳定)
关键发现:修 Bug 只占 24%,大头(58%)是加新功能。
为什么维护比初始开发贵?
四个原因:
1. 新团队需要理解旧代码
→ 原开发团队已散,新人要花大量时间读懂旧代码
2. 开发团队无动力写可维护的代码
→ 维护合同是另一家公司签的,原开发团队偷懒没惩罚
3. 维护工作不受欢迎
→ 被视为"低技术含量工作",往往分给经验最少的人
→ 还要学过时的编程语言(如 COBOL)
4. 代码结构随时间退化
→ 每次修改都让结构更乱,越改越难改
Lehman 定律(软件演化动力学):
1. 程序必须持续变化才能保持有用 \text{1. 程序必须持续变化才能保持有用} 1. 程序必须持续变化才能保持有用
2. 随着程序演化,其结构逐渐退化 \text{2. 随着程序演化,其结构逐渐退化} 2. 随着程序演化,其结构逐渐退化
3. 每次发布的变化量大致恒定(与资源无关) \text{3. 每次发布的变化量大致恒定(与资源无关)} 3. 每次发布的变化量大致恒定(与资源无关)
4. 必须不断增加新功能来提高用户满意度 \text{4. 必须不断增加新功能来提高用户满意度} 4. 必须不断增加新功能来提高用户满意度
5.1 维护预测(Maintenance Prediction)
目标:提前预测哪里会变、会怎么变,从而提前设计好那些地方,降低未来成本。
预测变更请求数量时,要考虑:
1. 接口数量和复杂度(接口越多越容易需要改接口)
2. 易变需求的数量(基于政策/规程的需求最容易变)
3. 系统被多少业务流程使用(用的越多,提变更请求的越多)
过程度量指标(用于评估可维护性下降趋势):
| 指标 | 含义 | 趋势↑意味着 |
|---|---|---|
| 纠错性维护请求数 | Bug/故障报告增加 | 质量下降 |
| 影响分析平均时间 | 评估一个变更影响多少组件的时间 | 耦合度增加,可维护性下降 |
| 实现变更的平均时间 | 从分析完成到改好代码的时间 | 修改越来越难 |
| 未处理变更请求数 | 积压的待修改数量 | 跟不上变化节奏 |
5.2 软件再工程(Software Reengineering)
定义:对遗留系统进行重构和重新文档化,使其更容易理解和维护。不改变功能,只改善结构。
再工程 vs 替换的对比:
| 再工程 | 重新开发 | |
|---|---|---|
| 风险 | 低(在已有代码基础上改) | 高(全新开发可能失败) |
| 成本 | 低(案例:$12M vs $50M) | 高 |
| 结果 | 更好但不如全新系统 | 理论上最优 |
再工程流程(图9.14):
原始程序
│
├──[源代码翻译]────→ 转换为现代语言(如 COBOL→Java)
│
├──[逆向工程]──────→ 从代码中提取设计文档
│
├──[程序结构改善]──→ 改善控制流,更易读
│
├──[程序模块化]────→ 合并相关代码,去除冗余
│
└──[数据再工程]────→ 重新设计数据库结构,清理数据
│
▼
再工程后的程序(更好结构、更易维护)
再工程方法的成本谱(图9.15):
成本从低到高 →
[自动程序重构] [自动重构+手动改动] [重构+架构改变] [程序和数据重构]
低 高
↑ ↑
最便宜的选项 最贵的选项
(只是自动格式化) (连架构和数据都重做)
5.3 重构(Refactoring)
定义:对程序做小改进,在不改变功能的前提下,改善结构、降低复杂度、提高可读性。
类比:定期整理房间(重构)vs 等房间乱到极点再大扫除(再工程)。前者是预防性维护,后者是事后补救。
重构 vs 再工程的核心区别:
| 重构 | 再工程 | |
|---|---|---|
| 时机 | 贯穿整个开发/演化过程中随时做 | 系统已经维护多年、成本急剧上升后 |
| 规模 | 小改动,持续进行 | 大规模一次性改造 |
| 目的 | 预防结构退化 | 修复已经退化的结构 |
| 工具 | IDE 里的重构功能(如 Eclipse) | 专门的再工程工具链 |
五种常见"代码坏味道"(Bad Smells)及其解决方法:
坏味道1:重复代码(Duplicate Code)
现象:同样的代码出现在多个地方
解法:提取成一个独立的函数/方法,其他地方调用它
坏味道2:过长的方法(Long Methods)
现象:一个函数几百行
解法:拆分成多个短小的子函数
坏味道3:散乱的 switch/case 语句
现象:程序各处都有判断同一类型的 switch
解法:用面向对象的多态(polymorphism)替代
坏味道4:数据泥团(Data Clumping)
现象:多个地方都出现同一组字段/参数(如 city + zip + street)
解法:把这组数据封装成一个对象/类
坏味道5:过度泛化(Speculative Generality)
现象:开发者"以防万一"加了很多用不到的通用化代码
解法:删掉这些没用的复杂度
C++ 重构示例(展示"提取方法"和"消除重复"两种重构):
#include <iostream>
#include <string>
#include <vector>
#include <numeric> // std::accumulate
// ============================================================
// 重构前的代码:有两个"坏味道"
// 1. 重复代码:两个地方都有"计算平均值 + 打印报告"的逻辑
// 2. 过长方法:processStudents() 和 processEmployees() 太相似
// ============================================================
// 重构前版本
namespace Before {
struct Student {
std::string name;
double score;
};
struct Employee {
std::string name;
double salary;
};
// 问题:两个函数几乎完全相同,只是数据类型不同
// 这就是"重复代码"坏味道
void processStudents(const std::vector<Student>& students) {
// 计算总分(重复逻辑1)
double total = 0;
for (const auto& s : students) {
total += s.score;
}
double avg = total / students.size();
// 打印报告(重复逻辑2)
std::cout << "=== 学生成绩报告 ===" << std::endl;
for (const auto& s : students) {
std::cout << s.name << ": " << s.score << std::endl;
}
std::cout << "平均分: " << avg << std::endl;
}
void processEmployees(const std::vector<Employee>& employees) {
// 计算总工资(重复逻辑1,几乎一模一样)
double total = 0;
for (const auto& e : employees) {
total += e.salary;
}
double avg = total / employees.size();
// 打印报告(重复逻辑2,几乎一模一样)
std::cout << "=== 员工薪资报告 ===" << std::endl;
for (const auto& e : employees) {
std::cout << e.name << ": " << e.salary << std::endl;
}
std::cout << "平均值: " << avg << std::endl;
}
}
// ============================================================
// 重构后的代码:消除重复,提取通用逻辑
// 重构手法:
// 1. "提取方法":把计算平均值提取为独立函数
// 2. "使用模板":用 C++ 模板消除两个相似函数的重复
// ============================================================
namespace After {
// 提取的通用函数:计算一组数值的平均值
// 重构手法:Extract Method(提取方法)
double calculateAverage(const std::vector<double>& values) {
if (values.empty()) return 0.0;
// std::accumulate: 把 values 中所有元素加起来
double total = std::accumulate(values.begin(), values.end(), 0.0);
return total / values.size();
}
// 提取的通用函数:打印名称-数值对的列表
// 这样不管是学生/员工都能用同一个打印函数
void printReport(const std::string& title,
const std::vector<std::pair<std::string, double>>& items,
const std::string& valueLabel) {
std::cout << "=== " << title << " ===" << std::endl;
std::vector<double> values;
for (const auto& item : items) {
std::cout << item.first << ": " << item.second << std::endl;
values.push_back(item.second);
}
// 复用提取出来的计算平均值函数
std::cout << valueLabel << "平均值: " << calculateAverage(values) << std::endl;
}
struct Student {
std::string name;
double score;
};
struct Employee {
std::string name;
double salary;
};
// 重构后的学生处理函数:简洁,无重复逻辑
void processStudents(const std::vector<Student>& students) {
// 转换为通用格式
std::vector<std::pair<std::string, double>> items;
for (const auto& s : students) {
items.push_back({s.name, s.score});
}
// 调用通用打印函数
printReport("学生成绩报告", items, "成绩");
}
// 重构后的员工处理函数:同样简洁
void processEmployees(const std::vector<Employee>& employees) {
std::vector<std::pair<std::string, double>> items;
for (const auto& e : employees) {
items.push_back({e.name, e.salary});
}
printReport("员工薪资报告", items, "薪资");
}
}
// ============================================================
// 主函数:对比重构前后
// ============================================================
int main() {
// 测试数据
std::vector<Before::Student> bStudents = {
{"Alice", 92.5}, {"Bob", 78.0}, {"Charlie", 85.5}
};
std::vector<Before::Employee> bEmployees = {
{"Dave", 8000.0}, {"Eve", 9500.0}, {"Frank", 7200.0}
};
std::cout << "===== 重构前 =====" << std::endl;
Before::processStudents(bStudents);
Before::processEmployees(bEmployees);
std::cout << std::endl;
// 重构后使用相同数据
std::vector<After::Student> aStudents = {
{"Alice", 92.5}, {"Bob", 78.0}, {"Charlie", 85.5}
};
std::vector<After::Employee> aEmployees = {
{"Dave", 8000.0}, {"Eve", 9500.0}, {"Frank", 7200.0}
};
std::cout << "===== 重构后 =====" << std::endl;
After::processStudents(aStudents);
After::processEmployees(aEmployees);
// 结果完全相同,但重构后的代码:
// 1. calculateAverage 只在一处定义,修改时只改一处
// 2. printReport 可以被任何新的数据类型复用
// 3. 每个函数更短、更专注于单一职责
return 0;
}
六、全章总结
第九章知识图谱
软件演化
├── 为什么演化
│ ├── 业务变化 / 用户期望 / 平台变化
│ └── 成本事实:60-90% 的软件成本在演化上
│
├── 演化生命周期
│ ├── 螺旋模型:需求→设计→实现→测试→发布→循环
│ └── 四阶段:开发→演化→服务化→退役
│
├── 演化过程
│ ├── 正常流程:变更请求→影响分析→发布规划→实现→发布
│ └── 紧急修复:直接改代码(文档滞后,有风险)
│
├── 遗留系统
│ ├── 定义:老旧但仍在用的系统(含硬件/软件/流程/规则)
│ ├── 为何不替换:风险高、成本高、规格文档丢失
│ └── 管理策略:
│ ├── 废弃(低质量+低价值)
│ ├── 重构(低质量+高价值)
│ ├── 正常维护(高质量+低价值 或 高质量+高价值)
│ └── 替换(无法继续运行时)
│
└── 软件维护
├── 三类型:故障修复(24%) / 环境适配(19%) / 功能增加(58%)
├── 为什么贵:新团队理解成本 / 无激励写可维护代码 / 结构退化
├── 维护预测:预测变更量 / 可维护性 / 成本
├── 再工程:对已退化系统大规模重组(成本低于重新开发)
└── 重构:持续小改进,预防退化("代码坏味道"→修复)
核心结论:
- 软件不是"开发完就完了",演化贯穿整个生命周期
- 大多数成本在维护,而非初始开发
- 遗留系统的处置需要从业务价值和技术质量两个维度评估
- 重构是最经济的预防手段;再工程是代价较低的事后补救;替换是最后的手段
- Lehman 定律:程序不变就会变得无用;程序一变结构就退化——这是软件演化的基本矛盾
第九章:软件演化 —— 从零理解
本文是《软件工程》第九章的详细中文解读,力求用最通俗易懂的语言讲清楚每个概念。
一、为什么软件需要"演化"?
生活类比:你买了一部手机,一直不更新系统,过两年发现新 App 装不上、银行 App 不支持、安全漏洞无人修——它还能用,但越来越"废"。软件也是一样。
软件必须持续变化的原因:
外部环境变化
├── 业务需求变了(公司业务模式调整)
├── 用户期望变了(用户希望更好用)
├── 硬件/平台变了(操作系统升级)
└── 竞争对手发布了新功能
内部原因
├── 运行中发现了 Bug
├── 性能需要优化
└── 非功能需求(安全性)需要改进
关键数据:历史数据显示,软件成本的 60% 到 90% 都是演化(维护)成本,而非初始开发成本。2006 年美国约 75% 的开发人员在做软件演化工作。
二、软件演化的生命周期模型
2.1 螺旋模型(图9.1)
软件工程不是"开发完就结束",而是一个持续的螺旋过程:
需求分析
|
┌─────┴─────┐
| |
设计 测试
| |
└─────┬─────┘
|
实现
|
发布版本1
|
┌─────┴──────────────────────┐
| 用户反馈/新需求/Bug报告 |
└─────┬──────────────────────┘
|
需求分析(再来一圈)
|
发布版本2
|
发布版本3 ...
现代变化:以前两三年发一个版本,现在互联网产品(App、网站)可能几周就发一个版本,因为竞争压力和用户反馈要求快速响应。
2.2 演化 vs 服务化(图9.2)
Rajlich 和 Bennett 提出的生命周期模型,把软件生命分成四个阶段:
| 阶段 | 特征 | 类比 |
|---|---|---|
| 演化期 | 大量修改架构和功能,用户提了很多需求都在实现 | 装修房子,大改 |
| 服务化期 | 只做小改动,公司在考虑如何替换这个系统 | 修修补补,凑合用 |
| 退役期 | 仅做最必要的改动,用户自行绕过问题 | 破房子,能住就住 |
为什么会从演化期进入服务化期?
随着不断修改,软件结构逐渐退化(degrade),每次修改都变得更贵、更难。当修改成本高到不划算时,就不再大改了。
三、演化过程(Evolution Processes)
演化的驱动力来自变更提案(Change Proposals),可以是:
变更来源
├── 之前需求文档里有但没实现的功能
├── 用户提出的新需求
├── Bug 报告
└── 开发团队发现的改进点
变更分析与演化流程(图9.4)
紧急修复(Emergency Repair,图9.6)
有时必须立刻修复,来不及更新文档:
紧急变更的三种触发情形:
1. 严重系统故障(影响正常运行或安全漏洞)
2. 运行环境突然变化(如操作系统更新引发异常)
3. 业务突然变化(新法规出台、竞争对手行动)
紧急修复流程:
变更请求 → 直接分析源代码 → 修改源代码 → 发布修改后的系统
↑
(跳过了需求和设计文档的更新!)
危险:代码、需求文档、设计文档三者逐渐不一致
→ 这也是敏捷方法主张"少写文档"的原因之一
敏捷方法在演化中的应用
敏捷方法(第三章)同样适用于演化阶段:
- 用**用户故事(User Stories)**表达变更需求
- Scrum 的 Backlog 帮助排列变更优先级
- 自动化回归测试降低变更风险
但也有麻烦: - 敏捷团队交接给计划驱动团队 → 后者期望详细文档,而敏捷没有
- 计划驱动团队交接给敏捷团队 → 代码结构乱,需要先重构才能用敏捷方式工作
四、遗留系统(Legacy Systems)
什么是遗留系统?
定义:使用过时语言和技术开发的老系统,虽然还在用,但维护越来越难。
生活类比:家里还在用一台 20 年前的老空调——它制冷,但耗电高、配件难找、修理师傅越来越少、和新的智能家居系统不兼容。
数据举例:全球商业系统中估计有超过 2000 亿行 COBOL 代码仍在运行(主要在金融行业),很多已经运行了几十年。
遗留系统的六大组成部分(图9.7)
遗留系统不只是软件,而是一个社会技术系统(Sociotechnical System):
分层视角(图9.8):
┌────────────────────────────────────┐
│ 业务流程层 │ ← 最上层:公司怎么运作
├────────────────────────────────────┤
│ 应用软件层 │ ← 业务逻辑代码
├────────────────────────────────────┤
│ 平台与基础设施软件层 │ ← OS、数据库、中间件
├────────────────────────────────────┤
│ 硬件层 │ ← 最底层:物理机器
└────────────────────────────────────┘
注意:改变某一层,往往会连锁影响上下层,并不是完全隔离的。
为什么不直接替换遗留系统?
替换有四大风险:
1. 没有完整的规格说明文档
→ 很难知道新系统要做什么才能完全替代旧系统
2. 业务流程和软件深度绑定
→ 流程是围绕旧软件设计的,换了软件流程也要跟着大改
3. 商业规则嵌入在代码里
→ 例如保险公司的风险评估规则,改错了可能引发重大损失
4. 新开发本身就有风险
→ 可能超期、超预算、达不到预期效果
真实案例:某大型企业有 150 多个遗留系统,决定用一套 ERP 替换全部。最终花了超过 1000 万英镑,只有部分上线,效果还不如被替代的旧系统。
遗留系统管理:四个战略选项(图9.9)
用业务价值和系统质量两个维度来决定对策:
系统质量(技术好坏)
高 ┃ ┃
┃ 继续正常维护 ┃ 必须保留,正常维护
高 ┃ [高质量,低业务价值] ┃ [高质量,高业务价值]
┃ 系统3,4,5 ┃ 系统6
┃─────────────────────┃─────────────────────
┃ 直接废弃 ┃ 必须重构或替换
低 ┃ [低质量,低业务价值] ┃ [低质量,高业务价值]
┃ 系统1,2,3 ┃ 系统9,10
┗━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━
低 高
业务价值(有多重要)
| 象限 | 策略 | 理由 |
|---|---|---|
| 低质量 + 低价值 | 废弃 | 维护贵,收益小,不值得 |
| 低质量 + 高价值 | 重构/替换 | 必须用但现在太贵维护,需要改善 |
| 高质量 + 低价值 | 继续正常维护 | 维护不贵,等到需要大改时再废弃 |
| 高质量 + 高价值 | 继续正常维护 | 好用又重要,不需要大动 |
如何评估业务价值?
问四个问题:
1. 使用频率:是否被频繁使用?(偶尔用但关键的也算高价值)
2. 支持的业务流程:支持的业务流程是否高效?是否已过时?
3. 系统可靠性:不可靠影响业务,则业务价值低
4. 系统输出:产出对业务有多重要?
如何评估系统(技术)质量?
从两方面评估:环境评估和应用评估。
环境评估因素(图9.10):
| 因素 | 关键问题 |
|---|---|
| 供应商稳定性 | 硬件/软件供应商还在吗?会继续维护吗? |
| 故障率 | 硬件/支撑软件经常崩溃吗? |
| 系统年龄 | 有多老了?越老越可能过时 |
| 性能 | 性能是否满足用户需求? |
| 维护支持需求 | 需要多少人工支持?成本高吗? |
| 维护成本 | 硬件维护费和软件许可证费用高吗? |
| 互操作性 | 和其他现代系统能对接吗? |
应用质量评估因素(图9.11):
| 因素 | 关键问题 |
|---|---|
| 可理解性 | 代码难理解吗?变量命名有意义吗? |
| 文档 | 文档完整、一致、最新吗? |
| 数据 | 有数据模型吗?数据重复、过时、不一致吗? |
| 性能 | 应用性能是否可接受? |
| 编程语言 | 还有人懂这门语言吗?能找到现代编译器吗? |
| 配置管理 | 版本是否受控管理? |
| 测试数据 | 有测试数据和回归测试记录吗? |
| 人员技能 | 有人会维护这个系统吗? |
五、软件维护(Software Maintenance)
定义:软件交付之后对其进行修改的一般过程。通常用于"维护"是由不同团队负责(非原开发团队)的情形。
三种维护类型
维护成本分布(图9.12):
功能增加/修改 ████████████████████████████ 58%
故障修复 ████████████ 24%
环境适配 ██████████ 19%
(数据来自30年间多项研究,分布基本稳定)
关键发现:修 Bug 只占 24%,大头(58%)是加新功能。
为什么维护比初始开发贵?
四个原因:
1. 新团队需要理解旧代码
→ 原开发团队已散,新人要花大量时间读懂旧代码
2. 开发团队无动力写可维护的代码
→ 维护合同是另一家公司签的,原开发团队偷懒没惩罚
3. 维护工作不受欢迎
→ 被视为"低技术含量工作",往往分给经验最少的人
→ 还要学过时的编程语言(如 COBOL)
4. 代码结构随时间退化
→ 每次修改都让结构更乱,越改越难改
Lehman 定律(软件演化动力学):
1. 程序必须持续变化才能保持有用 \text{1. 程序必须持续变化才能保持有用} 1. 程序必须持续变化才能保持有用
2. 随着程序演化,其结构逐渐退化 \text{2. 随着程序演化,其结构逐渐退化} 2. 随着程序演化,其结构逐渐退化
3. 每次发布的变化量大致恒定(与资源无关) \text{3. 每次发布的变化量大致恒定(与资源无关)} 3. 每次发布的变化量大致恒定(与资源无关)
4. 必须不断增加新功能来提高用户满意度 \text{4. 必须不断增加新功能来提高用户满意度} 4. 必须不断增加新功能来提高用户满意度
5.1 维护预测(Maintenance Prediction)
目标:提前预测哪里会变、会怎么变,从而提前设计好那些地方,降低未来成本。
预测变更请求数量时,要考虑:
1. 接口数量和复杂度(接口越多越容易需要改接口)
2. 易变需求的数量(基于政策/规程的需求最容易变)
3. 系统被多少业务流程使用(用的越多,提变更请求的越多)
过程度量指标(用于评估可维护性下降趋势):
| 指标 | 含义 | 趋势↑意味着 |
|---|---|---|
| 纠错性维护请求数 | Bug/故障报告增加 | 质量下降 |
| 影响分析平均时间 | 评估一个变更影响多少组件的时间 | 耦合度增加,可维护性下降 |
| 实现变更的平均时间 | 从分析完成到改好代码的时间 | 修改越来越难 |
| 未处理变更请求数 | 积压的待修改数量 | 跟不上变化节奏 |
5.2 软件再工程(Software Reengineering)
定义:对遗留系统进行重构和重新文档化,使其更容易理解和维护。不改变功能,只改善结构。
再工程 vs 替换的对比:
| 再工程 | 重新开发 | |
|---|---|---|
| 风险 | 低(在已有代码基础上改) | 高(全新开发可能失败) |
| 成本 | 低(案例:$12M vs $50M) | 高 |
| 结果 | 更好但不如全新系统 | 理论上最优 |
再工程流程(图9.14):
原始程序
│
├──[源代码翻译]────→ 转换为现代语言(如 COBOL→Java)
│
├──[逆向工程]──────→ 从代码中提取设计文档
│
├──[程序结构改善]──→ 改善控制流,更易读
│
├──[程序模块化]────→ 合并相关代码,去除冗余
│
└──[数据再工程]────→ 重新设计数据库结构,清理数据
│
▼
再工程后的程序(更好结构、更易维护)
再工程方法的成本谱(图9.15):
成本从低到高 →
[自动程序重构] [自动重构+手动改动] [重构+架构改变] [程序和数据重构]
低 高
↑ ↑
最便宜的选项 最贵的选项
(只是自动格式化) (连架构和数据都重做)
5.3 重构(Refactoring)
定义:对程序做小改进,在不改变功能的前提下,改善结构、降低复杂度、提高可读性。
类比:定期整理房间(重构)vs 等房间乱到极点再大扫除(再工程)。前者是预防性维护,后者是事后补救。
重构 vs 再工程的核心区别:
| 重构 | 再工程 | |
|---|---|---|
| 时机 | 贯穿整个开发/演化过程中随时做 | 系统已经维护多年、成本急剧上升后 |
| 规模 | 小改动,持续进行 | 大规模一次性改造 |
| 目的 | 预防结构退化 | 修复已经退化的结构 |
| 工具 | IDE 里的重构功能(如 Eclipse) | 专门的再工程工具链 |
五种常见"代码坏味道"(Bad Smells)及其解决方法:
坏味道1:重复代码(Duplicate Code)
现象:同样的代码出现在多个地方
解法:提取成一个独立的函数/方法,其他地方调用它
坏味道2:过长的方法(Long Methods)
现象:一个函数几百行
解法:拆分成多个短小的子函数
坏味道3:散乱的 switch/case 语句
现象:程序各处都有判断同一类型的 switch
解法:用面向对象的多态(polymorphism)替代
坏味道4:数据泥团(Data Clumping)
现象:多个地方都出现同一组字段/参数(如 city + zip + street)
解法:把这组数据封装成一个对象/类
坏味道5:过度泛化(Speculative Generality)
现象:开发者"以防万一"加了很多用不到的通用化代码
解法:删掉这些没用的复杂度
C++ 重构示例(展示"提取方法"和"消除重复"两种重构):
#include <iostream>
#include <string>
#include <vector>
#include <numeric> // std::accumulate
// ============================================================
// 重构前的代码:有两个"坏味道"
// 1. 重复代码:两个地方都有"计算平均值 + 打印报告"的逻辑
// 2. 过长方法:processStudents() 和 processEmployees() 太相似
// ============================================================
// 重构前版本
namespace Before {
struct Student {
std::string name;
double score;
};
struct Employee {
std::string name;
double salary;
};
// 问题:两个函数几乎完全相同,只是数据类型不同
// 这就是"重复代码"坏味道
void processStudents(const std::vector<Student>& students) {
// 计算总分(重复逻辑1)
double total = 0;
for (const auto& s : students) {
total += s.score;
}
double avg = total / students.size();
// 打印报告(重复逻辑2)
std::cout << "=== 学生成绩报告 ===" << std::endl;
for (const auto& s : students) {
std::cout << s.name << ": " << s.score << std::endl;
}
std::cout << "平均分: " << avg << std::endl;
}
void processEmployees(const std::vector<Employee>& employees) {
// 计算总工资(重复逻辑1,几乎一模一样)
double total = 0;
for (const auto& e : employees) {
total += e.salary;
}
double avg = total / employees.size();
// 打印报告(重复逻辑2,几乎一模一样)
std::cout << "=== 员工薪资报告 ===" << std::endl;
for (const auto& e : employees) {
std::cout << e.name << ": " << e.salary << std::endl;
}
std::cout << "平均值: " << avg << std::endl;
}
}
// ============================================================
// 重构后的代码:消除重复,提取通用逻辑
// 重构手法:
// 1. "提取方法":把计算平均值提取为独立函数
// 2. "使用模板":用 C++ 模板消除两个相似函数的重复
// ============================================================
namespace After {
// 提取的通用函数:计算一组数值的平均值
// 重构手法:Extract Method(提取方法)
double calculateAverage(const std::vector<double>& values) {
if (values.empty()) return 0.0;
// std::accumulate: 把 values 中所有元素加起来
double total = std::accumulate(values.begin(), values.end(), 0.0);
return total / values.size();
}
// 提取的通用函数:打印名称-数值对的列表
// 这样不管是学生/员工都能用同一个打印函数
void printReport(const std::string& title,
const std::vector<std::pair<std::string, double>>& items,
const std::string& valueLabel) {
std::cout << "=== " << title << " ===" << std::endl;
std::vector<double> values;
for (const auto& item : items) {
std::cout << item.first << ": " << item.second << std::endl;
values.push_back(item.second);
}
// 复用提取出来的计算平均值函数
std::cout << valueLabel << "平均值: " << calculateAverage(values) << std::endl;
}
struct Student {
std::string name;
double score;
};
struct Employee {
std::string name;
double salary;
};
// 重构后的学生处理函数:简洁,无重复逻辑
void processStudents(const std::vector<Student>& students) {
// 转换为通用格式
std::vector<std::pair<std::string, double>> items;
for (const auto& s : students) {
items.push_back({s.name, s.score});
}
// 调用通用打印函数
printReport("学生成绩报告", items, "成绩");
}
// 重构后的员工处理函数:同样简洁
void processEmployees(const std::vector<Employee>& employees) {
std::vector<std::pair<std::string, double>> items;
for (const auto& e : employees) {
items.push_back({e.name, e.salary});
}
printReport("员工薪资报告", items, "薪资");
}
}
// ============================================================
// 主函数:对比重构前后
// ============================================================
int main() {
// 测试数据
std::vector<Before::Student> bStudents = {
{"Alice", 92.5}, {"Bob", 78.0}, {"Charlie", 85.5}
};
std::vector<Before::Employee> bEmployees = {
{"Dave", 8000.0}, {"Eve", 9500.0}, {"Frank", 7200.0}
};
std::cout << "===== 重构前 =====" << std::endl;
Before::processStudents(bStudents);
Before::processEmployees(bEmployees);
std::cout << std::endl;
// 重构后使用相同数据
std::vector<After::Student> aStudents = {
{"Alice", 92.5}, {"Bob", 78.0}, {"Charlie", 85.5}
};
std::vector<After::Employee> aEmployees = {
{"Dave", 8000.0}, {"Eve", 9500.0}, {"Frank", 7200.0}
};
std::cout << "===== 重构后 =====" << std::endl;
After::processStudents(aStudents);
After::processEmployees(aEmployees);
// 结果完全相同,但重构后的代码:
// 1. calculateAverage 只在一处定义,修改时只改一处
// 2. printReport 可以被任何新的数据类型复用
// 3. 每个函数更短、更专注于单一职责
return 0;
}
六、全章总结
第九章知识图谱
软件演化
├── 为什么演化
│ ├── 业务变化 / 用户期望 / 平台变化
│ └── 成本事实:60-90% 的软件成本在演化上
│
├── 演化生命周期
│ ├── 螺旋模型:需求→设计→实现→测试→发布→循环
│ └── 四阶段:开发→演化→服务化→退役
│
├── 演化过程
│ ├── 正常流程:变更请求→影响分析→发布规划→实现→发布
│ └── 紧急修复:直接改代码(文档滞后,有风险)
│
├── 遗留系统
│ ├── 定义:老旧但仍在用的系统(含硬件/软件/流程/规则)
│ ├── 为何不替换:风险高、成本高、规格文档丢失
│ └── 管理策略:
│ ├── 废弃(低质量+低价值)
│ ├── 重构(低质量+高价值)
│ ├── 正常维护(高质量+低价值 或 高质量+高价值)
│ └── 替换(无法继续运行时)
│
└── 软件维护
├── 三类型:故障修复(24%) / 环境适配(19%) / 功能增加(58%)
├── 为什么贵:新团队理解成本 / 无激励写可维护代码 / 结构退化
├── 维护预测:预测变更量 / 可维护性 / 成本
├── 再工程:对已退化系统大规模重组(成本低于重新开发)
└── 重构:持续小改进,预防退化("代码坏味道"→修复)
核心结论:
- 软件不是"开发完就完了",演化贯穿整个生命周期
- 大多数成本在维护,而非初始开发
- 遗留系统的处置需要从业务价值和技术质量两个维度评估
- 重构是最经济的预防手段;再工程是代价较低的事后补救;替换是最后的手段
- Lehman 定律:程序不变就会变得无用;程序一变结构就退化——这是软件演化的基本矛盾
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)