结对项目-最长英语单词链
项目 | 内容 |
---|---|
这个作业属于哪个课程 | 2023 年北航软件工程 |
这个作业的要求在哪里 | 结对项目-最长英语单词链 |
我在这个课程的目标是 | 了解并体验软件工程,实现从「程序」到「软件」的进展。 |
这个作业在哪个具体方面帮助我实现目标 | 体验结对编程,初步实践工程化开发。 |
1. GitHub 项目地址
- 教学班级:周四下午班
- 项目地址:github仓库
2. PSP 表格 —— 预估
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | ||
· Estimate | · 估计这个任务需要多少时间 | 30 | |
Development | 开发 | ||
· Analysis | · 需求分析 (包括学习新技术) | 300 | |
· Design Spec | · 生成设计文档 | 30 | |
· Design Review | · 设计复审 (和同事审核设计文档) | 20 | |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 20 | |
· Design | · 具体设计 | 100 | |
· Coding | · 具体编码 | 1000 | |
· Code Review | · 代码复审 | 100 | |
· Test | · 测试 (自我测试,修改代码,提交修改) | 500 | |
Reporting | 报告 | ||
· Test Report | · 测试报告 | 100 | |
· Size Measurement | · 计算工作量 | 20 | |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 60 | |
合计 | 2170 |
3. 接口设计相关思想
Information Hiding (信息隐藏)
计算机科学中,信息隐藏是将敏感的或外部无需访问的信息封装在自身内部,使得外部不可见此类信息。
我们的设计中,设计到算法的代码被封装到core中,成为计算模块。计算模块通过向外暴露如下三个接口与 CLI、GUI 进行交互,实现了计算模块内部信息的隐藏:
int gen_chains_all(char *words[], int len, char *result[], void *(*)(size_t));
int gen_chain_word(char *words[], int len, char *result[], char head, char tail, char jail, bool enable_loop, void *(*)(size_t));
int gen_chain_char(char *words[], int len, char *result[], char head, char tail, char jail, bool enable_loop, void *(*)(size_t));
对于计算部分的异常,由计算部分产生,通过 c++ 的异常机制抛给调用方处理。CLI GUI不用负责检查计算部分的异常。
Interface Design (接口设计原则)
接口设计决定了模块之间沟通的效率和效果。良好的接口设计应该可以让外部通过接口,简明快速地了解到接收的请求和返回的结果,而不关心实现的细节。
接口设计中的基本原则包括明确接口职责和职责单一性原则。一个良好的接口一定要明确接口中每一个参数,调用者在调用时不应该产生歧义,并且调用时能快速得到结果。接口中包含的参数应该是最简单的,不应该把多个任务放在同一个接口中。
在我们的设计中,上面三个接口依次对应-n
、-w
、-c
三个命令行参数。-h
、-t
、-j
、-r
四个命令行参数分别对应到每个接口中的参数head
、tail
、jail
、enable_loop
四个参数。实现不同任务使用了不同的计算接口。
Loose Coupling (松耦合)
松耦合是指模块之间联系较少,对于其中一个模块的修改对其他模块的影响应该比较少,模块之间仅用提供的接口进行联系。松耦合的重要意义在于需求的变化,这样修改的部分会尽可能减少。
在我们的设计中,由于做到了良好的信息隐藏和接口设计,计算模块、GUI、CLI 均为松耦合,因而可以很方便地和其它组的相应模块进行互换,也方便与其他组进行对拍。
计算模块和交互模块一个交互要求较高的是对结果保存区域的内存管理。保存结果的区域如果由调用方分配,由调用方释放,则调用方需要提前估计被调用方返回的结果长度,不够灵活。如果由被调用方申请,若调用方与被调用方使用不同的内存管理库,则会导致分配与释放的不匹配问题。
在我们的实现中,采用了被调用方使用调用方的内存管理库进行分配,由调用方负责释放的方案。具体到接口定义上, 我们接口的最后一个参数是一个函数指针,使用这个参数将调用方的内存分配器传递给被调用方。
void *out_malloc(size_t)
借助这种实现方式,保证了调用方(CLI)与被调用方 (Core)在 C库 方面是解耦的,可以使用 GCC编译 CLI ,使用 MSVC 编译 Core,也可以反过来使用 GCC 编译 CLI ,使用 MSVC 编译 Core ,虽然两者使用了不同的C库。
在与其他组交换模块时,由于提前约定好了接口的行为,且实现耦合度不高,没有遇到太多问题。
4. 计算模块接口的设计与实现过程
代码组织与接口设计
计算模块被整体封装到compute_context_t
类中,对外暴露3个接口如
int gen_chains_all(char *words[], int len, char *result[], void *(*)(size_t));
int gen_chain_word(char *words[], int len, char *result[], char head, char tail, char jail, bool enable_loop, void *(*)(size_t));
int gen_chain_char(char *words[], int len, char *result[], char head, char tail, char jail, bool enable_loop, void *(*)(size_t));
- 这三个接口分别负责
n
、w
、c
三个命令行参数。 h
、t
、j
、r
四个命令行参数分别对应到每个接口中的参数head
、tail
、jail
、enable_loop
四个参数(由于n
和其他命令函参数不兼容,因此只有后两个接口有该参数)。words
为传入的单词,是一个字符指针数组,每个字符指针指向一个单词。我们约定,传入的单词是分割正确、统一小写、经过去重的。len
为传入的个数。result
为返回的结果,是一个字符指针数组。对于gen_chains_all
,每个字符指针指向一个单词链;对于gen_chain_word
、gen_chain_char
,每个字符指针指向一个单词;- 此外,为了防止内存泄露问题,我们在接口中也传入了
out_malloc
函数。我们约定,若接口抛出异常或返回值为0,则该函数不会被调用;否则,该函数恰好会被调用一次,该次调用会分配result所需的全部空间。为了避免内存泄漏,接口的调用者在获取了result的结果后,会负责释放计算模块malloc的空间。
算法
首先进行预处理工作,完成单词分割、统一小写、去重的工作;注意单独一个字母也算单词。
接着建立一个 26 个节点的有向图,每个节点代表一个字母。对于每个单词,设其首字母和尾字母分别为 h 和 t,添加一条有向边 h→t。该有向图的每个长度不小于 2 的简单路径 (同一条边至多经过一次) 均对应一个单词链。
我们定义从图中任意一个节点出发,根据有向边的方向无法回到原节点的图就叫做有向无环图(DAG,Direct Acyclic Graph)。在此基础上,我们定义 myDAG,为每个点至多有一个自环的,且去掉所有自环后为 DAG 的图。
对于七种命令行参数,大致可以将其分为如下三种类型:
n
,仅能单独使用,求出所有的单词链。返回值为单词链的个数,返回的result中每个char指针为一个单词链,单词之间用空格分隔;每个有向图必须是 myDAG。w
,计算最长单词链,每条边边权为 1,可以搭配如下4种参数使用h a
,要求对应单词链的起点必须是字母a
。t b
,要求对应单词链的终点必须是字母b
。j c
,在处理输入单词时将以c
开头的单词过滤。r
,若没有该参数则有向图必须是 myDAG。
c
,计算最长单词链,每条边边权为单词长度,同样可以搭配上述4种参数使用。
算法的具体的执行流程如下
- 调用
void word_preprocessing()
,将以char*输入的word转为string格式并排序。此外,在这个过程中,会过滤掉jail
禁止使用的单词。 - 调用
void get_SCC()
,利用 Tarjan 算法求出有向图的每个强连通分量。 - 如果要求有向图是 myDAG,会调用
int check_loop()
,先判断是否有一个点存在多于一个的自环,再判断是否存在一个强连通分量有多于两个点。 - 根据要求类型的不同调用不同函数
-
若要求输出所有的单词链,则调用
int get_all(char* result[])
,该函数通过枚举起点,DFS 找到所有单词链,若单词链数量超过 20000,则会返回一个负数作为异常值;否则,返回值为一个非负值,代表单词链个数;result中每个指针指向一个单词链的首地址 -
若有向图是 myDAG,则调用
int get_longest_chain_on_DAG(char* result[], char head, char tail, bool weighted)
。该函数的本质是在 DAG 上 dp 求最长路。设f_i 是以节点i为起点的最长路长度,对于一条边起点为 i、终点为 j、权值为 w 的边,通过逆序枚举拓扑序 (已经通过 Tarjan 算法求出),转移方程为:f_i = max (f_i, f_j+w)但该函数具体实现仍有许多改动的细节,列举如下:
- 如果限定了终点 i,对于所有 j≠i,f_j 初始化为 -inf,f_i初始化为 0;否则均初始化为 0。
- 计算最大值的同时,需要记录方案。
- 如果
enable_self_loop
为真,算完每个点 f_i 后要将其自环的权值加到上面,同样需要改动方案的输出。(今年由于没有-m,所以这一条恒真) - 因为最终要求路径长度不短于 2,最终统计答案时需要枚举第一条边 (i→j),且如果限制了起点需要保证其合法性,接着将这条边的权值 +f_j作为答案,此外如果枚举的第一条边是自环还要进行特判(如何特判)。
-
若有向图不要求为 myDAG,则调用
int get_longest_chain(char* result[], char head, char tail, bool weighted)
。该函数的本质是状压 DP 求解一般有向图的最长链。众所周知,一般有向图的最长链是一个 NP 问题,为保证正确性,我们没有采取近似算法。具体来说,设 f_{S,i} 是已使用的边集为 S,所在点为 i,以此为起始状态能经过的最长路长度,对于一条边起点为 i、终点为 j、权值为 w 的边 e,转移为:f_{S,i}=max(f_{S \cup e,j}+w,f_{S,i})。具体实现上,因为最多只有 100 条边,可以使用__int128
存储 S,进行记忆化搜索。
-
独到之处
考虑了内存泄露问题,考虑了对于 C库 的解耦,不要求调用方与被调用方使用相同的 C库。
5. 编译通过无警告的截图
6. UML 图
7. 计算模块接口部分的性能改进
使用一个较大的有环数据和-w参数测试,并使用 WSL 下的perf工具进行 profiling ,得到如下结果。
时间主要花费在 dfs_state
,该函数在记忆化搜索过程中,会遍历所有潜在最优解状态。
算法优化
dfs和在myDAG图上的算法是线性的,不是性能瓶颈,没有优化的必要。下面说明在处理非myDAG图时引入的优化
- 优化搜索顺序
- 如果当前点存在自环,优先走完全部自环
- 如果存在重边,优先走当前没走过的边中,权值最大的那条
- S 中仅保留和 i 属于同一个强连通分量的边
- 由于不同强连通分量间的边在DP转移时独立,为了减少状态数,只需记录每个强连通分量内的边的状态
当然,上述优化在极端情况下都会失效,例如在完全有向图中,上述优化无法带来任何改进。但在一般随机出来的图中,上述优化有较大提升。
工程优化
进一步检查Profiling的结果
可以发现递归调用的 dfs_state 函数中时间占用较多的是在 std::map 容器上进行的查找操作。如果可以加速 map 容器的速度,就可以比较明显的提高我们的整体性能。
std::map 查找的时间复杂度已经是 O(log(n)), 并没有优化空间,只可以从实现的角度进行优化。std::map 是一个有序的容器,但在我们的算法中,并不需要这个有序性,因此我们使用std::unordered_map 替换了 std::map 容器。
在完成替换后,使用我们编写的对拍器比较了 std::map 的版本和 std::unordered_map 的版本,在100个随机产生的测试点上,进行了性能对比,结果如下图:
总执行时间减少了 29% ,实现了一个有效的性能改进。
通过进一步的网络检索,了解到一个开源的快速 unorder_map 平替, https://github.com/greg7mdp/parallel-hashmap 项目。
使用 parallel-hashmap 实现的 hashmap 替换 unorder_map ,再次在 450 个随机测试点上进行测试,得到结果如图:
可以看到仅替换一个map的实现就又获得了三倍的性能改进,证明了通过 profiling 寻找热点函数,着重优化热点函数的思路是完全正确的。
使用 6 个节点的完全图,在经过上面的优化后,可以在 12s 左右完成计算
8. 契约相关思想
Design by Contract (契约式设计)
契约式设计强调三个基本概念:前置条件、后置条件、不变式。契约式设计要求模块在运行(调用)前满足前置条件,在运行之后结果满足后置条件,并且运行的结果中满足不变式所要求某些变量的不变。每个模块需要定义正式的,精确的并且可验证的接口。
- 优点
- 便于形式化验证
- 约束严谨,保证程序的正确性
- 促进模块式开发,便于解耦
- 缺点
- 时间成本高,可能出现编写契约的时间远远多于代码实现和测试时间的谬误
- 上手门槛高,对于一个快速而短期的结对项目来说并不合适
基于以上原因,在本次结对编程中,我们并没有采用严格的契约式编程模式。不过,我们采用了类似的思想,来提高软件工程的质量。在计算模块和CLI/GUI交互时,我们遵循如下契约:
- 传入计算模块的单词是分割正确、统一小写、经过去重的
- 结果
result
所需的空间在计算模块内部分配,由调用者释放
Code Contract
Code Contract 是微软为 .NET 提供的一个契约式编程插件。本次结对编程中,仅使用cpp进行开发,并没有使用 .NET,因此没用使用该插件。
9. 计算模块部分单元测试展示
测试框架
单元测试部分,针对计算部分的三个接口函数进行。使用了 Google Test 测试框架,免去了机械性的重复编写比对代码的过程,只需要专注编写测试点即可。
测试点分为两类,分为手工构造的特殊样例以及随机生成的随机样例。手工构造样例注重一些边界情况,和异常的处理,保证分支的覆盖率,而随机生成通过多次连续重复调用接口函数,检查接口在多次调用情况下的正确性(如 GUI 的使用)。如果接口函数的实现中,不小心隐含了不期望的状态改变,则可能在多次重复调用的测试下出现问题。
对于手工编写的测试点,构造样例时需要给出输入的字符串,输入的参数,以及分别针对三个接口函数的期望返回值。借助编译器预处理器的include功能,可以将测试点单独编写在独立的文件中,在单元测试文件中,多次include进来,保证样例编写一次,测试进行多次的复用。
对于随机生成的测试点,设计了多个数据生成器,分别进行完全随机的,和偏向产生更多环的数据生成,对核心进行充分验证。每个测试点会通过一个单独编写的高复杂度暴力搜索算法结果产生标答,将标答与待测结果进行比对产生结果。
无论是手工测试的结果,还是随机测试的结果,在待测核心完成计算后,都会对输出结果的合法性进行检查。检查过程中,会检查输出的单词是否符合链的定义,输出单词是否是输入过的单词,同一个链中,是否重复输出了相同的单词,遍历所有单词链时,是否重复输出了相同的单词链。
内存泄漏检查
为了检查程序是否出现内存泄漏,借助了强大的 Valgrind memcheck 工具。使用一组由五个点构成的完全图作为输入,分别在不同的参数下检查命令行程序是否存在内存 泄漏。
结果如下图:
在每个模式下,不同的异常下,都没有出现内存的泄漏。
数据构造思路
手工样例
长为5的环
std::string words_str[] = {"ab", "bc", "cd", "de", "ea"};
int std_cnt_ans = -1; // 异常时为 -1
int std_word_ans = 5;
int std_char_ans = 5;
char head = '\0';
char tail = '\0';
char jail = '\0';
bool ring = true;
一个环+一条链
std::string words_str[] = {"ab", "bc", "ad", "de", "efa"};
int std_cnt_ans = -1;
int std_word_ans = 5;
int std_char_ans = 5;
char head = '\0';
char tail = '\0';
char jail = '\0';
bool ring = true;
一个自环
std::string words_str[] = {"aa", "ab"};
int std_cnt_ans = 1;
int std_word_ans = 2;
int std_char_ans = 2;
char head = '\0';
char tail = '\0';
char jail = '\0';
bool ring = false;
两个自环
std::string words_str[] = {"aa", "aaa"};
int std_cnt_ans = -1; // > 20000
int std_word_ans = -1;
int std_char_ans = -1;
char head = '\0';
char tail = '\0';
char jail = '\0';
bool ring = false;
有连通分量有至少2个点
std::string words_str[] = {"ab", "ba"};
int std_cnt_ans = -1;
int std_word_ans = -1;
int std_char_ans = -1;
char head = '\0';
char tail = '\0';
char jail = '\0';
bool ring = false;
仅一条边
std::string words_str[] = {"aa"};
int std_cnt_ans = 0;
int std_word_ans = 0;
int std_char_ans = 0;
char head = '\0';
char tail = '\0';
char jail = '\0';
bool ring = false;
超过20000
std::string words_str[] = {"ab", "abb", "bc", "bcc", "cd", "cdd", "de", "dee", "ef", "eff", "fg", "fgg", "fggg", "fgggg", "fgggg", "gh", "ghh", "ghhh", "ghhhh", "ghhhhh", "hi", "hii", "hiiii", "hiiiii", "ij", "ijj", "ijjj", "ijjjj", "ijjjjj"};
int std_cnt_ans = -1; // > 20000
int std_word_ans = 9;
int std_char_ans = 9;
char head = '\0';
char tail = '\0';
char jail = '\0';
bool ring = false;
- h
std::string words_str[] = {"ab", "bc"};
int std_cnt_ans = 1;
int std_word_ans = 0;
int std_char_ans = 0;
char head = 'b';
char tail = '\0';
char jail = '\0';
bool ring = false;
- t
std::string words_str[] = {"ab", "bc"};
int std_cnt_ans = 1;
int std_word_ans = 0;
int std_char_ans = 0;
char head = '\0';
char tail = 'b';
char jail = '\0';
bool ring = false;
- j
std::string words_str[] = {"ab", "bc"};
int std_cnt_ans = 1;
int std_word_ans = 0;
int std_char_ans = 0;
char head = '\0';
char tail = 'b';
char jail = '\0';
bool ring = false;
随机生成样例
参数说明:
- n: 有向图中点的个数
- DAG: 是否限定必须是有向无环图
- word_cnt: 有向图中边的个数
- Seed: 随机数种子
- is_complete: 是否按完全图的顺序构造边
unsigned int rnd() {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
return seed;
}
char **generator(int n, bool DAG, int word_cnt, unsigned int Seed, bool is_complete) {
set<string> dic;
dic.clear();
seed = Seed ^ n ^ word_cnt;
char **words = (char **) malloc(word_cnt * sizeof(char *));
words[0] = (char *) malloc(word_cnt * 34);
char *alloca_space = words[0];
if (!is_complete) {
for (int i = 0; i < word_cnt; i++) {
while (true) {
int len = (int) ((rnd() % 30) + 3);
words[i] = alloca_space;
alloca_space += len + 1;
if (words[i] != nullptr) {
words[i][0] = (char) ('a' + rnd() % n);
words[i][1] = (char) (i % n + 'a');
for (int j = 2; j < len; j++) words[i][j] = (char) (rnd() % n + 'a');
if (DAG && words[i][0] >= words[i][len - 1]) {
if (words[i][0] == words[i][len - 1]) {
if (words[i][0] == n - 1 + 'a') words[i][0]--;
else words[i][len - 1]++;
} else std::swap(words[i][0], words[i][len - 1]);
}
words[i][len] = 0;
}
string word = words[i];
if (dic.find(word) == dic.end()) {
dic.insert(word);
break;
} else {
alloca_space -= len + 1;
}
}
}
} else {
int tot = 0;
while (true) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
while (true) {
int len = (int) (rnd() % 30) + 3;
words[tot] = alloca_space;
alloca_space += len + 1;
if (words[tot] != nullptr) {
words[tot][0] = (char) (i + 'a');
words[tot][len - 1] = (char) (j + 'a');
for (int k = 1; k < len; k++) words[tot][k] = (char) (rnd() % n + 'a');
words[tot][len] = 0;
}
string word = words[tot];
if (dic.find(word) == dic.end()) {
if (--word_cnt == 0) {
return words;
}
++tot;
break;
} else {
alloca_space -= len + 1;
}
}
}
}
}
}
return words;
}
朴素状压DP
int status[1 << 20][20];
int dp(char *words[], int len, char head, char tail, bool weighted) {
int words_len[20];
for (int i = 0; i < len; i++) {
words_len[i] = (int) strlen(words[i]);
}
for (int i = 0; i < (1 << len); i++) {
for (int j = 0; j < len; j++) {
status[i][j] = (int) -1e9;
}
}
for (int i = 0; i < len; i++) {
if (!head || words[i][0] == head) {
status[1ll << i][i] = weighted ? words_len[i] : 1;
}
}
for (int i = 0; i < (1 << len); i++) {
for (int j = 0; j < len; j++) {
if (i & (1 << j)) {
for (int k = 0; k < len; k++) {
if (!(i & (1 << k))) {
if (words[j][words_len[j] - 1] == words[k][0]) {
status[i | (1ll << k)][k] = std::max(status[i | (1ll << k)][k],
status[i][j] +
(weighted ? words_len[k] : 1));
}
}
}
}
}
}
int ans = 0;
for (int state = 0; state < (1 << len); state++) {
for (int j = 0; j < len; j++) {
if (state & (1 << j)) {
if (state == (1 << j)) continue;
if (!tail || words[j][words_len[j] - 1] == tail) ans = std::max(ans, status[state][j]);
}
}
}
return ans;
}
SPJ
void std_check_gen_max(std::string words_str[], int word_cnt,
int (*fut)(char *[], int, char *[], char, char, char, bool, void *(*)(size_t)),
char head, char tail, char jail, bool ring, int max) {
const char *words[word_cnt];
char **result = (char **) malloc(sizeof(char *) * 65536);
result[0] = new char[128 * 1024 * 1024];
for (int i = 0; i < word_cnt; i++) {
words[i] = words_str[i].c_str();
}
int ret = fut(const_cast<char **>(words), word_cnt, result, head, tail, jail, ring, malloc);
EXPECT_EQ(ret, max);
word_set_t appeared;
word_set_t dictionary = build_dictionary(word_cnt, const_cast<char **>(words));
std::stringstream str_builder;
for (int i = 0; i < ret; i++) {
str_builder << result[i];
if (i != ret - 1) {
str_builder << ' ';
}
}
int err_cnt = check_validation(dictionary, appeared, str_builder.str().c_str());
EXPECT_EQ(err_cnt, 0);
if (ret != 0) {
free(result[0]);
}
free(result);
}
覆盖率
使用 gcov 工具,以及 gcovr 可视化工具,对计算核心的代码覆盖率进行了检查。在单元测试中行覆盖率和分支覆盖率均高于 90% 。
10. 计算模块异常处理说明
异常主要分为三类,分别是CLI、文件系统、运行时异常。
- CLI异常
- 输入文件
- 未指定输入文件
- 指定多个输入文件
- 输入文件不是txt格式
- 未定义参数
- 功能性参数(-n -w -c)
- 未指定功能性参数
- 指定了多个功能性参数
- 附加型参数(-h -t -j -r)
- 参数格式不合法/缺少字母参数(如 -h 1, -h aaa, -h )
- 附加型参数重复
- 参数不兼容(-n模式下不能出现附加型参数)
- 输入文件
- 文件系统异常
- 输入文件不存在/无法打开
- 输入文件过大
- 运行时异常
- 无 -r 时有单词环
- 指针数组result的长度超过20000
CLI 相关异常
输入文件
未指定输入文件
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Error: No input file found!", e.what()));
return;
}
Assert::Fail();
}
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe -n"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Error: No input file found!", e.what()));
return;
}
Assert::Fail();
}
指定了多个输入文件
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe -n input.txt input.txt"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Error: Only support one input file!", e.what()));
return;
}
Assert::Fail();
}
输入文件不是txt格式
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe -n input.md"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Error: \"input.md\" is neither a text file nor an argument!", e.what()));
return;
}
Assert::Fail();
}
未定义参数
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe -k"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Error: \"-k\" is neither a text file nor an argument!", e.what()));
return;
}
Assert::Fail();
}
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe -k input.txt"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Error: \"-k\" is neither a text file nor an argument!", e.what()));
return;
}
Assert::Fail();
}
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe -k a input.txt"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Error: \"-k\" is neither a text file nor an argument!", e.what()));
return;
}
Assert::Fail();
}
功能性参数
未指定功能性参数
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe input.txt"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Error: No specified mode selected, use -n/-w/-c!
", e.what()));
return;
}
Assert::Fail();
}
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe -h a input.txt"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Error: No specified mode selected, use -n/-w/-c!
", e.what()));
return;
}
Assert::Fail();
}
指定了多个功能性参数
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe -n -w input.txt"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Error: Multiple mode selected, only support one -n/-w/-c argument!", e.what()));
return;
}
Assert::Fail();
}
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe -n -n input.txt"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Error: Multiple mode selected, only support one -n/-w/-c argument!", e.what()));
return;
}
Assert::Fail();
}
附加型参数
参数格式不合法/缺少字母参数
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe -w -h hh input.txt"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Error: Need letter after -h!", e.what()));
return;
}
Assert::Fail();
}
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe -w -h input.txt"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Error: Need letter after -h!", e.what()));
return;
}
Assert::Fail();
}
参数重复
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe -w -h a -h b input.txt"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Error: Repeated args, do not repeatedly use -h!", e.what()));
return;
}
Assert::Fail();
}
在 -n 模式下出现附加型参数
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe -n -h a input.txt"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Error: -n mode doesn't support -h/-t/-j/-r options!", e.what()));
return;
}
Assert::Fail();
}
文件系统异常
输入文件不存在/无法打开
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe -n not_exist.txt"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Error: Cannot find input file: \"not_exist.txt\"!", e.what()));
return;
}
Assert::Fail();
}
输入文件过大
准备一个2237743kb
大小的文件large.txt
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe -n large.txt"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Error: Input filesize 2237743kb is too large. Support up to 1048576kb!", e.what()));
return;
}
Assert::Fail();
}
运行时异常
无 -r 参数时有单词环
input.txt
a
aa
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe -n input.txt"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Word ring detected, at least two self ring on one node!", e.what()));
return;
}
Assert::Fail();
}
input.txt
ab
ba
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe -n input.txt"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Word ring detected, at least one scc has more than two nodes!", e.what()));
return;
}
Assert::Fail();
}
指针数组result的长度超过20000
input.txt
{"ab", "abb", "bc", "bcc", "cd", "cdd", "de", "dee", "ef", "eff", "fg", "fgg", "fggg", "fgggg", "fgggg", "gh", "ghh", "ghhh", "ghhhh", "ghhhhh", "hi", "hii", "hiiii", "hiiiii", "ij", "ijj", "ijjj", "ijjjj", "ijjjjj"};
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe -n input.txt"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Too many word chains!", e.what()));
return;
}
Assert::Fail();
}
有 -r 参数时,输入词数超过128
input.txt
"aa","ba","ca","da","ea","fa","ga","ha","ia","ja","ka","la",
"ab","bb","cb","db","eb","fb","gb","hb","ib","jb","kb","lb",
"ac","bc","cc","dc","ec","fc","gc","hc","ic","jc","kc","lc",
"ad","bd","cd","dd","ed","fd","gd","hd","id","jd","kd","ld",
"ae","be","ce","de","ee","fe","ge","he","ie","je","ke","le",
"af","bf","cf","df","ef","ff","gf","hf","if","jf","kf","lf",
"ag","bg","cg","dg","eg","fg","gg","hg","ig","jg","kg","lg",
"ah","bh","ch","dh","eh","fh","gh","hh","ih","jh","kh","lh",
"ai","bi","ci","di","ei","fi","gi","hi","ii","ji","ki","li",
"aj","bj","cj","dj","ej","fj","gj","hj","ij","jj","kj","lj",
"ak","bk","ck","dk","ek","fk","gk","hk","ik","jk","kk","lk",
"al","bl","cl","dl","el","fl","gl","hl","il","jl","kl","ll"
TEST_METHOD(missing_arguments){
try{
const char* args[] = {"WordList.exe -w -r input.txt"};
main_serve(1, args);
}catch(invalid_argument const &e){
Assert::AreEqual(0, strcmp("Too many words, support up to 128 words!", e.what()));
return;
}
Assert::Fail();
}
11. 界面模块设计
GUI 设计
我们的 GUI 使用 Qt5 构建,工程使用 CMake 进行管理,位于项目 GUI 目录下。
gui 的窗口如图所示
界面的左侧是输入部分,提供一个文本框接受输入单词,也可以选择从文件导入。
提供相关框体控制计算模式。右侧是一个 Status 指示器和一个结果框,Status 指示器可以指示当前计算状态。
界面左侧有一个下拉框用于模式选择,模式选择提供了三种支持的计算模式。当模式为遍历所有单词链时,其他的输入选项如 Head Tail Jail 以及 允许环 参数都是不允许使用的,被设置为不可用
当模式为 最多单词 或者 最多字母 寻找最长单词链时,其他输入选项可用。
右侧的结果框可以对结果进行展示,也可以高亮反馈计算核心部分的异常
右侧上方还有一个状态指示器,可以指示当前计算的三种状态 (Ready Pending Calculating),后面跟随着上次计算完成的时间。
左侧有一个导出文件的选项,当状态为 Ready 时,可以导出文件。当状态不为 Ready 时,导出文件按钮会变灰无法按下。
GUI 异步优化
由于对于有环情况的计算,在输入复杂的情况下复杂度比较高,速度比较慢。如果使用主进程完成计算,那主进程会被长时间占用,无法响应其他输入而导致界面卡死。
为了解决这个问题,我们借助Qt内部的多线程和信号-槽机制,实现了计算和渲染逻辑的分离。
在 Compute 按钮被按下时,主进程会检查计算进程的状态,若计算进程空闲则立即触发计算,若计算进程忙,则将计算任务加入等待序列中,等待当前计算完成再开始下一次计算(不强制杀死计算进程避免内存泄漏)。
// ... Compute 按钮案件处理函数节选 (gui/main_ui.cpp)
if (calculating && !pending) {
ui->status_label->setText("Status: <font color = #FF5809 >Pending...</font>");
pending = true;
} else if (!calculating) {
ui->status_label->setText("Status: <font color = #FF2400 >Calculating...</font>");
calculating = true;
pending = false;
// 开启新的计算线程
emit start_computation();
}
// ...
当计算进程计算完成,会通过信号机制通知主进程,主进程完成状态的更新和 UI 重绘。
// 计算线程完成的信号处理函数 (gui/main_ui.cpp)
void main_ui::computation_finish() {
if (pending) {
ui->status_label->setText("Status: <font color = #FF2400 >Calculating...</font>");
calculating = true;
pending = false;
// 开启新的计算线程
emit start_computation();
} else {
calculating = false;
ui->status_label->setText("Status: <font color = #4896FA >Ready</font>");
// 更新UI
ui->text_output->setText(QString::fromStdString(pending_cal->ans));
}
}
体现在 GUI 上,GUI 右侧的状态指示器会指示上一个计算请求处于哪一个状态
12. 界面模块对接
接口设计
GUI 部分的接口复用了 CLI 部分的接口,借此和计算部分保持了一个低的耦合度。
int gen_chains_all(char *words[], int len, char *result[], void *(*)(size_t));
int gen_chain_word(char *words[], int len, char *result[], char head, char tail, char jail, bool enable_loop, void *(*)(size_t));
int gen_chain_char(char *words[], int len, char *result[], char head, char tail, char jail, bool enable_loop, void *(*)(size_t));
接口需要以 C 函数的形式导出并存储在 core.dll 的动态链接库中,在 GUI 启动时会动态加载这个库。
// 接口函数指针定义
typedef int (*max_cnt_f)(char *[], int, char *[], void *(*)(size_t));
typedef int (*max_fut_f)(char *[], int, char *[], char, char, char, bool,
void *(*)(size_t));
// ...
// 加载dll
HINSTANCE core_lib;
core_lib = LoadLibrary("core.dll");
// 加载失败时弹窗报错
if (core_lib == nullptr) {
QMessageBox::critical(nullptr, "严重错误", "不能正确加载core.dll, 请检查运行环境",
QMessageBox::Escape, QMessageBox::Escape);
return -1;
}
// 加载计算函数
gen_chains_all = (max_cnt_f) GetProcAddress(core_lib, "gen_chains_all");
gen_chain_word = (max_fut_f) GetProcAddress(core_lib, "gen_chain_word");
gen_chain_char = (max_fut_f) GetProcAddress(core_lib, "gen_chain_char");
如果无法加载 core.dll ,GUI 会弹窗报错
13. 结对过程
我们在主楼302教室/图书馆一楼咖啡厅进行结对,照片如下
14. 结对编程总结
结对编程优缺点分析
优点
- 某些问题遇到瓶颈时可以共同讨论,拓宽思路,避免一个人陷入误区。
- 自带 code review,更容易在代码编写的过程中发现 bug,降低测试难度。
缺点
- 如果两人之前并不熟悉,需要花时间磨合。
- 如果两人技能树差异较大,则不如各自分工效率高。
两人优缺点分析
王小鸽
优点
- 对算法较为熟悉
- 对STL使用较为熟悉
- 对数据构造较为熟悉
缺点
- 对cpp工程规范不太熟悉
- 学习新技术栈较慢
王哲
优点
- 对工具使用较为熟悉
- 对性能分析,工程优化较为熟悉
- 学习新技术栈的能力较强
缺点
- 不擅长算法
- 比较拖延
15. PSP 表格 —— 实际
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | ||
· Estimate | · 估计这个任务需要多少时间 | 30 | 20 |
Development | 开发 | ||
· Analysis | · 需求分析 (包括学习新技术) | 300 | 360 |
· Design Spec | · 生成设计文档 | 30 | 30 |
· Design Review | · 设计复审 (和同事审核设计文档) | 20 | 10 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 20 | 10 |
· Design | · 具体设计 | 100 | 60 |
· Coding | · 具体编码 | 1000 | 1200 |
· Code Review | · 代码复审 | 100 | 60 |
· Test | · 测试 (自我测试,修改代码,提交修改) | 500 | 600 |
Reporting | 报告 | ||
· Test Report | · 测试报告 | 100 | 120 |
· Size Measurement | · 计算工作量 | 20 | 30 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 60 | 120 |
合计 | 2170 | 2460 |
16. 附加题:互换模块
我们于另外一组交换了模块,另一组成员:
- 陈楚岩:20373743
- 何天然:20373944
由于在事前约定了相同的接口,交换的过程比较顺利,没有遇到特别多的问题。
交换结果位于 dev-combine 分支( 项目链接 ) ,交换后的二进制全部保存在combine目录下面