本文是《软件工程》第六章的详细中文解读,力求用最通俗易懂的语言讲清楚每个概念。

一、什么是软件架构?

想象你要建一栋楼。在开始砌砖之前,建筑师需要先画出整体蓝图:有几层、哪里是大厅、哪里是走廊、管道怎么走。软件架构做的事情是一样的——在写代码之前,先设计整个软件系统的"骨架"。
官方定义:软件架构设计关注的是"软件系统应该如何组织",以及"整体结构是什么样的"。它的输出是一个架构模型,描述系统由哪些主要部件(组件)构成,以及这些部件之间如何相互通信。

为什么架构很重要?

架构影响系统的四大关键属性:

属性 含义
性能 (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 视图模型

系统架构

逻辑视图
Logical View

过程视图
Process View

开发视图
Development View

物理视图
Physical View

用例/场景
Use Cases
+1


视图名称 关注点 主要受众
逻辑视图 系统中的关键对象/类 分析师、架构师
过程视图 运行时各进程如何交互 性能/可用性分析
开发视图 软件如何分解供团队开发 开发人员、项目经理
物理视图 软件组件如何分布在硬件上 系统工程师、运维

实际情况:在大多数项目中,开发一套完整的四视图文档成本太高,通常只开发那些对沟通和决策有用的视图。

四、架构模式 (Architectural Patterns)

架构模式就像"经过验证的最佳实践"——前人踩过坑、总结出来的好方案,你可以直接拿来用。
本章介绍四种最重要的架构模式:

4.1 模型-视图-控制器 (MVC)

核心思想:把"数据"、“显示”、"用户交互"三者分开。
生活类比:餐厅里,厨师(Model)负责做菜,服务员(Controller)负责接受点单并传达,菜单/菜品展示(View)负责让顾客看到。

操作鼠标键盘

更新

选择视图

状态变化通知

用户事件

查询状态

用户

Controller 控制器
处理用户交互

Model 模型
管理数据和业务逻辑

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)

核心思想:所有组件不直接互相通信,而是通过一个中央数据仓库来共享数据。
生活类比:公司里所有员工都把文件存到共享网盘,要用文件就去网盘取,不用互相发邮件传文件。

中央仓库
Project Repository

UML编辑器

设计翻译器

Java编辑器

Python编辑器

代码生成器

设计分析器

报告生成器


说明
适用场景 需要长期存储大量数据;数据驱动的系统(数据到位就触发操作)
优点 组件完全解耦;一个组件的修改自动对其他组件可见;数据统一管理
缺点 仓库是单点故障;分布式部署困难;所有通信都经过仓库效率低

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)

第4层_数据层

第3层_业务逻辑

第2层_用户通信

第1层_用户界面

Web浏览器

登录

角色检查

表单菜单管理

数据校验

安全管理

患者信息管理

数据导入导出

报告生成

事务管理

患者数据库

5.3 语言处理系统 (Language Processing Systems)

什么是语言处理系统:把一种语言翻译成另一种表示,并可能执行它。最典型的就是编译器
基本架构(图6.20)
源代码→翻译器抽象机器指令→解释器输出结果 \text{源代码} \xrightarrow{\text{翻译器}} \text{抽象机器指令} \xrightarrow{\text{解释器}} \text{输出结果} 源代码翻译器 抽象机器指令解释器 输出结果
编译器各组件详解(图6.21/6.22)

源代码

词法分析器
Lexical Analyzer
识别token

语法分析器
Syntax Analyzer
检查语法

语义分析器
Semantic Analyzer
检查语义

优化器
Optimizer

代码生成器
Code Generator

目标代码

符号表
Symbol Table

语法树
Syntax Tree

各组件职责

词法分析器  →  把源代码分解成 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、银行)
    ├── 信息系统(医院、图书馆)
    └── 语言处理系统(编译器)

关键结论

  • 架构决策比代码实现更难撤销,需要尽早做出
  • 不存在"万能架构",要根据具体非功能需求权衡选择
  • 不同的架构模式可以组合使用(如编译器 = 仓库 + 管道过滤器)

第七章:设计与实现 —— 从零理解

本文是《软件工程》第七章的详细中文解读,力求用最通俗易懂的语言讲清楚每个概念。

一、设计与实现是什么关系?

生活类比:盖房子时,建筑师画设计图(设计),工人按图施工(实现)。软件开发也一样——设计是决定"怎么做",实现是把设计变成真正可以运行的代码。
两者的关系:

需求(客户要什么)
       ↓
  软件设计(怎么做)
       ↓
  软件实现(把设计变成代码)

重要决策:在项目早期,需要决定"买还是自己做"。

买(配置现成软件)      →  便宜快速,但定制性差
自己做(从零开发)      →  完全定制,但贵且慢

二、面向对象设计的五个步骤

整个面向对象设计过程可以分为五步,像剥洋葱一样从外到内:

1. 理解系统上下文
和外部交互

2. 设计系统架构

3. 识别系统中的
主要对象

4. 开发设计模型

5. 规定接口

2.1 步骤一:理解系统上下文与交互

目标:搞清楚"这个系统和外部世界有什么关系"。

需要建立两种模型:

模型类型 是什么 解决什么问题
系统上下文模型 静态结构图,展示周围有哪些系统 这个系统和谁打交道?
交互模型(用例图) 动态图,展示系统如何被使用 系统具体做了什么?

气象站的系统上下文(图7.1)

                    ┌─────────────────┐
                    │  气象信息系统    │  (1个)
                    └────────┬────────┘
                             │
         ┌───────────────────┼──────────────────┐
         │                   │                  │
  ┌──────┴──────┐    ┌───────┴───────┐   ┌─────┴──────┐
  │  控制系统   │    │   卫星系统    │   │气象站1..N  │
  │  (1个)     │    │   (1个)      │   │(多个)      │
  └─────────────┘    └───────────────┘   └────────────┘

气象站的用例图(图7.2)

                          气象站系统
    ┌─────────────────────────────────────────────┐
    │                                             │
    │    (报告天气数据)    (报告状态)              │
气象 │                                             │
信息 ─── 报告天气  ──────────────────────────────  │
系统 │                                             │
    │    ── 报告状态 ─────────────────────────────  │
    │                                             │
    └─────────────────────────────────────────────┘
                    ↑
    ┌───────────────────────────────────────┐
    │  重启 / 关机 / 重新配置 / 省电模式 /   │
控制│          远程控制                      │
系统──────────────────────────────────────── │
    └───────────────────────────────────────┘

用例描述表格示例(图7.3,"报告天气"用例)

字段 内容
系统 气象站
用例 报告天气
参与者 气象信息系统、气象站
数据 发送温度/气压/风速/风向/降雨量的最大/最小/平均值
触发 气象信息系统通过卫星链路发起请求
响应 气象站发回汇总数据
备注 通常每小时报告一次,频率可调整

2.2 步骤二:设计系统架构

基于对外部交互的理解,设计系统的主要组件和它们的关系。
气象站高层架构(图7.4)

┌─────────────────────────────────────────────────────────┐
│                      气象站系统                          │
│                                                         │
│  ┌──────────┐  ┌──────────┐  ┌──────────────────────┐  │
│  │数据采集  │  │通信子系统│  │   配置管理子系统       │  │
│  │子系统    │  │          │  │                      │  │
│  └────┬─────┘  └────┬─────┘  └──────────────────────┘  │
│       │             │                                   │
│  ┌────┴─────────────┴──────────────────────┐            │
│  │              通信链路(广播总线)         │            │
│  └─────────────────────────────────────────┘            │
│                                                         │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐              │
│  │故障管理  │  │电源管理  │  │  仪器组  │              │
│  │子系统    │  │子系统    │  │          │              │
│  └──────────┘  └──────────┘  └──────────┘              │
└─────────────────────────────────────────────────────────┘

设计亮点:所有子系统通过广播总线通信,发送方不需要知道接收方是谁——这就是"监听者模式",方便扩展和修改。

2.3 步骤三:识别对象类

三种常见识别方法

方法1:语法分析法
  → 从系统自然语言描述中
  → 名词 = 对象/属性
  → 动词 = 操作/服务
方法2:应用领域分析法
  → 找领域中的有形实体(飞机、传感器)
  → 找角色(管理员)、事件(请求)
  → 找交互(会议)、组织单位(部门)
方法3:场景分析法
  → 逐个分析使用场景
  → 每个场景中识别所需的对象、属性、操作

气象站识别出的对象类(图7.6)

WeatherStation

+identifier

+reportWeather()

+reportStatus()

+powerSave(instruments)

+remoteControl(commands)

+reconfigure(commands)

+restart(instruments)

+shutdown(instruments)

WeatherData

+airTemperatures

+groundTemperatures

+windSpeeds

+windDirections

+pressures

+rainfall

+collect()

+summarize()

GroundThermometer

+gt_Ident

+temperature

+get()

+test()

Anemometer

+an_Ident

+windSpeed

+windDirection

+get()

+test()

Barometer

+bar_Ident

+pressure

+height

+get()

+test()

继承层次设计:注意到 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)

restart()

reconfigure()

powerSave()

shutdown()

reportWeather()

clock信号

remoteControl()

weather summary complete

transmission done

collection done

configuration done

reportStatus()

test complete

Shutdown

Running

Configuring

Summarizing

Collecting

Controlled

Transmitting

Testing

状态转换读法示例

  • 初始状态是 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)

notifies

observes

1

0..*

Subject

+Attach(Observer)

+Detach(Observer)

+Notify()

Observer

+Update()

ConcreteSubject

-subjectState

+GetState()

ConcreteObserver

-observerState

+Update()

角色说明

角色 职责 类比
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):

软件复用

抽象层
Abstraction Level
复用设计模式、架
构模式的思想

对象层
Object Level
直接
使用现成库中的类和方法

组件层
Component Level
复用整个功能框架如UI组件库

系统层
System Level
直接
使用现成的完整应用系统

复用的代价与收益

内容
好处 开发更快;风险更低;更可靠(已在其他项目中验证过)
成本 寻找合适组件需要时间;购买费用;适配和集成的工作量

原则:开始设计前先想"有没有现成的可以用",而不是一开始就从零做。

4.2 配置管理(Configuration Management)

问题背景:多人同时修改同一份代码,容易出现"我改了你的改动被覆盖"或"用了旧版本"的问题。
生活类比:多人合作写一本书,如果没有版本管理,A在第三章改了一个段落,B同时也在改第三章的同一个段落,最后合并时会乱套。
配置管理的四个核心活动(图7.14):

配置管理

版本管理
Version Management
跟踪每个组件的历史版本
防止互相覆盖

系统集成
System Integration
定义用哪些版本来构建
某个特定的系统版本

问题追踪
Problem Tracking
记录Bug报告
追踪修复进度

发布管理
Release Management
规划新版本功能
组织软件分发

常用工具

版本管理: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
GNU通用公共许可证
互惠型
你用了GPL的代码
你的项目也必须开源

LGPL
GNU宽松公共许可证
宽松互惠型
可以链接使用不开源
但修改了库本身要开源

BSD/MIT许可证
非互惠型
最宽松
可以用在商业闭源软件中
只需注明原作者

实际影响:如果你在做一个商业软件,要特别注意:

使用 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

发布测试
Release Testing

用户测试
User Testing

三、开发测试(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)

当多个类/对象组合成一个大组件,就需要测试它们之间的接口
四种接口类型:

接口类型

参数接口
Parameter Interface
通过参数传递数据

共享内存接口
Shared Memory
共用一块内存区域

过程调用接口
Procedural Interface
调用对方的方法

消息传递接口
Message Passing
发消息请求服务

三类接口错误:

错误类型 含义 例子
接口误用 调用时参数传错了 类型错、顺序错、数量错
接口误解 对被调用方的行为理解有误 以为二分查找自动排序,实则不会
时序错误 生产者和消费者速度不匹配 读到的数据还没被更新

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)

失败 FAIL
正常,代码还没写

通过 PASS

确定新功能需求

编写测试用例

运行测试

实现功能 + 重构代码

移动到下一个功能

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)

分为两类:

按实际使用频率
分配测试比例

超出设计上限
的极限测试

性能测试

操作剖面测试
Operational Profile

压力测试
Stress Testing

验证性能需求

发现极限行为
和隐藏缺陷

操作剖面:如果 90% 的实际操作是 A 类,5% 是 B 类,那么测试也要按这个比例分配,否则测的不是真实场景。
压力测试:设计上限是每秒处理300笔事务,那就逐渐加压到400、500……直到系统崩溃,看看:

  1. 系统是"优雅失败"(fail-soft,有序退化)还是直接崩溃?
  2. 在高负载下是否有只在此时才出现的隐藏 bug?

六、用户测试(User Testing)

用户参与测试,因为只有真实用户才了解自己的工作环境和使用习惯,开发者无法在测试环境中完全模拟。
三种用户测试类型:

Alpha 测试(Alpha Testing)
  │
  ├── 少数选定的用户
  ├── 与开发团队紧密合作
  └── 测试早期版本
Beta 测试(Beta Testing)
  │
  ├── 更大范围的用户群
  ├── 让用户在自己的环境中使用
  └── 发现各种环境下的兼容性问题
验收测试(Acceptance Testing)
  │
  ├── 客户用自己的数据测试
  ├── 正式决定是否接收/支付
  └── 有六个正式步骤(见下)

验收测试的六个步骤(图8.11)

通过

不通过

1. 定义验收标准
尽量在签合同前确定

2. 规划验收测试
确定资源、时间、预算

3. 设计验收测试用例
功能 + 非功能都要覆盖

4. 运行验收测试
在真实或模拟环境中执行

5. 协商测试结果
双方讨论问题的严重程度

6. 接受/拒绝系统

交付 + 付款

开发方修复
重新测试

注意:现实中验收测试结果往往是"有条件接受"——客户急于上线,愿意接受有问题的版本,但要求开发方承诺尽快修复。

七、全章总结

软件测试全景图
目的
├── 验证(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 提出的生命周期模型,把软件生命分成四个阶段:

软件开发
Development

软件演化
Evolution
大幅改变架构和功能

软件服务化
Servicing
只做小的必要改动

软件退役
Retirement
停止使用


阶段 特征 类比
演化期 大量修改架构和功能,用户提了很多需求都在实现 装修房子,大改
服务化期 只做小改动,公司在考虑如何替换这个系统 修修补补,凑合用
退役期 仅做最必要的改动,用户自行绕过问题 破房子,能住就住

为什么会从演化期进入服务化期?
随着不断修改,软件结构逐渐退化(degrade),每次修改都变得更贵、更难。当修改成本高到不划算时,就不再大改了。

三、演化过程(Evolution Processes)

演化的驱动力来自变更提案(Change Proposals),可以是:

变更来源
  ├── 之前需求文档里有但没实现的功能
  ├── 用户提出的新需求
  ├── Bug 报告
  └── 开发团队发现的改进点

变更分析与演化流程(图9.4)

变更请求
Change Requests

影响分析
Impact Analysis
评估改哪些组件、成本多少

发布规划
Release Planning
决定哪些变更放入下一版本

变更实现
Change Implementation

故障修复
Fault Repair

平台适配
Platform Adaptation

系统增强
System Enhancement

系统发布
System Release

紧急修复(Emergency Repair,图9.6)

有时必须立刻修复,来不及更新文档:

紧急变更的三种触发情形:
  1. 严重系统故障(影响正常运行或安全漏洞)
  2. 运行环境突然变化(如操作系统更新引发异常)
  3. 业务突然变化(新法规出台、竞争对手行动)
紧急修复流程:
  变更请求 → 直接分析源代码 → 修改源代码 → 发布修改后的系统
                                ↑
                   (跳过了需求和设计文档的更新!)
危险:代码、需求文档、设计文档三者逐渐不一致
     → 这也是敏捷方法主张"少写文档"的原因之一

敏捷方法在演化中的应用

敏捷方法(第三章)同样适用于演化阶段:

  • 用**用户故事(User Stories)**表达变更需求
  • Scrum 的 Backlog 帮助排列变更优先级
  • 自动化回归测试降低变更风险
    但也有麻烦
  • 敏捷团队交接给计划驱动团队 → 后者期望详细文档,而敏捷没有
  • 计划驱动团队交接给敏捷团队 → 代码结构乱,需要先重构才能用敏捷方式工作

四、遗留系统(Legacy Systems)

什么是遗留系统?

定义:使用过时语言和技术开发的老系统,虽然还在用,但维护越来越难。
生活类比:家里还在用一台 20 年前的老空调——它制冷,但耗电高、配件难找、修理师傅越来越少、和新的智能家居系统不兼容。
数据举例:全球商业系统中估计有超过 2000 亿行 COBOL 代码仍在运行(主要在金融行业),很多已经运行了几十年。

遗留系统的六大组成部分(图9.7)

遗留系统不只是软件,而是一个社会技术系统(Sociotechnical System)

受约束于

依赖

约束

运行于

使用

运行于

业务流程
Business Processes

应用软件
Application Software

业务规则
Business Policies and Rules

支撑软件
Support Software
OS、编译器、数据库

应用数据
Application Data

系统硬件
System Hardware

分层视角(图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)

定义:软件交付之后对其进行修改的一般过程。通常用于"维护"是由不同团队负责(非原开发团队)的情形。

三种维护类型

软件维护

故障修复
Fault Repair 24%
修复 Bug 和安全漏洞

环境适配
Environmental Adaptation 19%
适应新平台/硬件/OS

功能增加
Functionality Addition 58%
新需求/新功能

维护成本分布(图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 提出的生命周期模型,把软件生命分成四个阶段:

软件开发
Development

软件演化
Evolution
大幅改变架构和功能

软件服务化
Servicing
只做小的必要改动

软件退役
Retirement
停止使用


阶段 特征 类比
演化期 大量修改架构和功能,用户提了很多需求都在实现 装修房子,大改
服务化期 只做小改动,公司在考虑如何替换这个系统 修修补补,凑合用
退役期 仅做最必要的改动,用户自行绕过问题 破房子,能住就住

为什么会从演化期进入服务化期?
随着不断修改,软件结构逐渐退化(degrade),每次修改都变得更贵、更难。当修改成本高到不划算时,就不再大改了。

三、演化过程(Evolution Processes)

演化的驱动力来自变更提案(Change Proposals),可以是:

变更来源
  ├── 之前需求文档里有但没实现的功能
  ├── 用户提出的新需求
  ├── Bug 报告
  └── 开发团队发现的改进点

变更分析与演化流程(图9.4)

变更请求
Change Requests

影响分析
Impact Analysis
评估改哪些组件、成本多少

发布规划
Release Planning
决定哪些变更放入下一版本

变更实现
Change Implementation

故障修复
Fault Repair

平台适配
Platform Adaptation

系统增强
System Enhancement

系统发布
System Release

紧急修复(Emergency Repair,图9.6)

有时必须立刻修复,来不及更新文档:

紧急变更的三种触发情形:
  1. 严重系统故障(影响正常运行或安全漏洞)
  2. 运行环境突然变化(如操作系统更新引发异常)
  3. 业务突然变化(新法规出台、竞争对手行动)
紧急修复流程:
  变更请求 → 直接分析源代码 → 修改源代码 → 发布修改后的系统
                                ↑
                   (跳过了需求和设计文档的更新!)
危险:代码、需求文档、设计文档三者逐渐不一致
     → 这也是敏捷方法主张"少写文档"的原因之一

敏捷方法在演化中的应用

敏捷方法(第三章)同样适用于演化阶段:

  • 用**用户故事(User Stories)**表达变更需求
  • Scrum 的 Backlog 帮助排列变更优先级
  • 自动化回归测试降低变更风险
    但也有麻烦
  • 敏捷团队交接给计划驱动团队 → 后者期望详细文档,而敏捷没有
  • 计划驱动团队交接给敏捷团队 → 代码结构乱,需要先重构才能用敏捷方式工作

四、遗留系统(Legacy Systems)

什么是遗留系统?

定义:使用过时语言和技术开发的老系统,虽然还在用,但维护越来越难。
生活类比:家里还在用一台 20 年前的老空调——它制冷,但耗电高、配件难找、修理师傅越来越少、和新的智能家居系统不兼容。
数据举例:全球商业系统中估计有超过 2000 亿行 COBOL 代码仍在运行(主要在金融行业),很多已经运行了几十年。

遗留系统的六大组成部分(图9.7)

遗留系统不只是软件,而是一个社会技术系统(Sociotechnical System)

受约束于

依赖

约束

运行于

使用

运行于

业务流程
Business Processes

应用软件
Application Software

业务规则
Business Policies and Rules

支撑软件
Support Software
OS、编译器、数据库

应用数据
Application Data

系统硬件
System Hardware

分层视角(图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)

定义:软件交付之后对其进行修改的一般过程。通常用于"维护"是由不同团队负责(非原开发团队)的情形。

三种维护类型

软件维护

故障修复
Fault Repair 24%
修复 Bug 和安全漏洞

环境适配
Environmental Adaptation 19%
适应新平台/硬件/OS

功能增加
Functionality Addition 58%
新需求/新功能

维护成本分布(图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 定律:程序不变就会变得无用;程序一变结构就退化——这是软件演化的基本矛盾
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐