BUAA-2023软件工程结对编程博客作业
项目 | |
---|---|
这个作业属于哪个课程 | 2023北航敏捷软件工程 |
这个作业的要求在哪里 | 结对项目-最长英语单词链 |
我在这个课程的目标是 | 学习并实践软件工程开发的方法论。在把握整体流程和内容要素的基础上实践细节,培养开发技术、开发思维、团队协作等能力。 |
这个作业在哪个具体方面帮助我实现目标 | 在结对编程的过程中初步学会在压力下和一名队友进行合作开发、共同解决问题,培养合作开发的初步意识 |
Part0 准备
一、项目信息和地址
- 教学班级:周四下午班
- 项目地址:https://github.com/Enqurance/2023_Pair
二、PSP表格
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 60 | 120 |
· Estimate | · 估计这个任务需要多少时间 | 60 | 120 |
Development | 开发 | 1620 | 2180 |
· Analysis | · 需求分析 (包括学习新技术) | 150 | 300 |
· Design Spec | · 生成设计文档 | 90 | 60 |
· Design Review | · 设计复审 (和同事审核设计文档) | 90 | 120 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 30 | 120 |
· Design | · 具体设计 | 240 | 180 |
· Coding | · 具体编码 | 720 | 900 |
· Code Review | · 代码复审 | 120 | 200 |
· Test | · 测试(自我测试,修改代码,提交修改) | 180 | 300 |
Reporting | 报告 | 195 | 300 |
· Test Report | · 测试报告 | 90 | 100 |
· Size Measurement | · 计算工作量 | 60 | 100 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 45 | 100 |
合计 | 1875 | 2600 |
Part1 设计
三、接口设计
看教科书和其它资料中关于 Information Hiding,Interface Design,Loose Coupling 的章节,说明你们在结对编程中是如何利用这些方法对接口进行设计的。
Information Hiding
1、类的所有数据成员都是private,所有访问都是通过访问函数实现的
以核心计算类Core为例,该类中的所有数据变量均为私有的,只对外提供计算单词链的方法。Core类在被实例化后,所有的变量对外隐藏,外部根据输入参数的种类调用不同的Core对象的方法。
class Core {
private:
// 数据变量
int dp[MAX] = {0};
int lastWord[MAX] = {-1};
bool vis[MAX] = {false};
vector<string> longest_chain;
int longest_size = 0;
vector<vector<string>> all_chains;
int all_chains_size = 0;
// ...
// 方法...
public:
int genAllWordChain(vector<vector<string>> &result) {
// -n 参数的具体实现部分
}
// 其他方法
}
2、所有类与类之间都通过接口访问
我们为FileIO类和Core类都提供了交互的接口,类和类之间的数据交互全部由接口实现。以main.cpp调用FileIO类的函数处理文件读写为例。main.cpp调用与FIleIO之间的接口read_file(),接口中直接调用FileIO类的静态函数read_file(),从而实现了良好的封装。main主程序只知道调用了一个叫"read_file"的接口函数,并不关心该函数是谁实现的,具有良好的可移植性。
class FileIO {
public:
static FileIO& getInstance() {
return fileIO;
}
// 读文件,输出文件
int read_file(const string &filename) {
// 真正实现部分
}
private:
static FileIO fileIO;
FileIO() = default;
};
#ifdef __cplusplus
extern "C" {
#endif
__declspec(dllexport) int read_file(const string &filename) {
// 接口调用FileIO类的静态函数
return FileIO::getInstance().read_file(filename);
}
#ifdef __cplusplus
}
#endif
Interface Design
本次结对编程,我们和交换模块的小组互相定义了3个C++函数接口,相关的函数声明如下:
int gen_chains_all(const vector<string> &words, int len, vector<vector<string>> &result);
int gen_chain_word(const vector<string> &words, int len, vector<string> &result, char head, char tail, char reject, bool enable_loop);
int gen_chain_char(const vector<string> &words, int len, vector<string> &result, char head, char tail, char reject, bool enable_loop);
另外为了实现和 GUI 界面的接口和文件读写操作,我们还配套了其他的接口:
// 文件IO的接口
__declspec(dllexport) int read_file(const string &filename);
__declspec(dllexport) int output_screen(const vector<vector<string>> &all_chains);
__declspec(dllexport) int output_file(const vector<string> &longest_chain);
__declspec(dllexport) int get_words(vector<string> &words);
// GUI界面交互接口
JNIEXPORT jint JNICALL
Java_CoreAPI_genChainsAll(JNIEnv *env, jobject obj, jobjectArray jWords, jint len, jobjectArray jResult);
JNIEXPORT jint JNICALL
Java_CoreAPI_genChainWord(JNIEnv *env, jobject obj, jobjectArray jWords, jint len, jobjectArray jResult, jchar head, jchar tail, jchar reject, jboolean enable_loop);
JNIEXPORT jint JNICALL
Java_CoreAPI_genChainChar(JNIEnv *env, jobject obj, jobjectArray jWords, jint len, jobjectArray jResult, jchar head, jchar tail, jchar reject, jboolean enable_loop);
这些接口使程序有了良好的可移植性,在 main.cpp
和 GUI 程序调用时,完全不用管接口是如何实现的,这就意味着我可以更改 Core 类具体的实现函数,只要接口不变,程序就是良好的。
这一点大大提高了我们在后续 debug 的速度,方便快速定位。
Loose Coupling
本次 Wordlist
主程序,Lib
,Core
三个模块都是松耦合的,即每两个模块之间没有重复的部分;GUI
主程序由于是用 java 语言编写,在调用 Lib
模块时接口对接涉及到类型转化、调库等操作较为繁琐,而且 Lib
模块主要负责文件读写这类简单的操作,使用 java 语言更加方便快捷,因此 GUI
主程序只与 Core
模块进行松耦合。
可以用下图示意:
即 Wordlist
调用了 Library
和 Core
中的接口,GUI
调用了 Core
中的接口,而每个模块都只处理了自己那部分的事务,其他的功能通过接口调用实现。
四、计算模块接口的设计与实现过程
设计包括代码如何组织,比如会有几个类,几个函数,他们之间关系如何,关键函数是否需要画出流程图?说明你的算法的关键(不必列出源代码),以及独到之处。
核心计算类以及接口之间主要的函数调用关系如下:
其中 A->B
表示 A
方法调用了 B
方法。
计算模块接口设计
由于在实际开发前完整阅读了后续的作业要求,我们事先找到在阶段四合作的小组,确定了计算模块接口的设计,接口中的方法和官方要求的相同,见下图:
int gen_chains_all(const vector<string> &words, int len, vector<vector<string>> &result);
int gen_chain_word(const vector<string> &words, int len, vector<string> &result, char head, char tail, char reject, bool enable_loop);
int gen_chain_char(const vector<string> &words, int len, vector<string> &result, char head, char tail, char reject, bool enable_loop);
这些接口整合在Core.h的头文件中,但是并不在计算类Core中。在本地测试中,我们的单元测试函数 PerfTest
调用上述的接口,上述的接口又调用了核心计算类Core中相应的具体方法。
上述测试过程的UML流程如下:
接口方法的实现,实际上是根据命令行读入的参数信息先实例化一个Core类的对象core,再调用core相应的真正计算单词链的方法,根据返回的内容以及运行过程中core对象的某些属性(如超长结果)捕获异常并处理。以接口 gen_chain_char
为例说明:
- 对于接口
gen_chain_char
:- 我们用先使用参数实例化了一个Core类对象core,然后调用Core类中的
genMaxWordCountChain
方法进行单词链计算;
- 我们用先使用参数实例化了一个Core类对象core,然后调用Core类中的
- 对于
genMaxWordCountChain
:- Core 类的构造方法会根据
genMaxWordCountChain
传递进的参数对众多私有变量初始化,然后进行建图create_nodes
、检查环check_circle
操作; - 接着判断是否有环
loop_exist
。若没有环,则判断是否有必要进行重构图,若有必要,则执行Core类中的reBuildGraph
方法预处理,剔除不满足约束且必定在单词链头部的单词,然后调用快速算法dp_longest_chain
结合dp
和拓扑排序求解最长链;若有环,则运行dfs
算法,调用函数dfs_longest_chain
求解最长链。
- Core 类的构造方法会根据
- 对于异常:
- 主要处理结果过长、以及数据出现不被允许的环的两种情况。在接口
gen_chain_char
函数返回前,会判断程序执行中是否抛出了异常,若有则捕获异常并抛出,最后返回-1;
- 主要处理结果过长、以及数据出现不被允许的环的两种情况。在接口
具体算法设计
建图过程
首先,我们设计了一个节点类 Node
,记录了单词的一些信息,如首 s
尾 e
字母、单词本身 context
、连接的节点 toNodes
、点权 value
以及是否需要被删除 is_deleted
。对于每个不重复的单词,我们将其实例化为一个 Node
对象。对于两个单词 A
和 B
,若 A
的尾字母和 B
的首字母相同,连接一条从 A
到 B
的有向边,在 O(n^2)
的时间复杂度内创立一个有向图。由于单词链的长度和单词本身的信息相关,我们仅关心点权。当求解需求是字母最长时,点权 value
被设立为单词的长度,否则设为1。
gen_chains_all
解法是纯暴力搜索,完全使用 dfs
求解:
-
枚举所有的单词,然后以该单词为起点跑
dfs
算法dfs_all_chain
,在函数递归过程中需要传递一些必要的参数,如当前节点的下标id
,和当前单词链cur_chain
(这里存在优化点。 -
每次进入
dfs_all_chain
函数时会判断cur_chain
的长度,若可以形成一条单词链(譬如长度要大于2),则将此时cur_chain
的结果压入到all_chains
中,最终接口返回的是all_chains
储存的相关结果。 -
Core类另设置了一个
over_large
参数用于检测结果是否超长20000。当在dfs_all_chain
函数中检测到结果数组all_chains
的长度超过了20000,则算法会立即终止,并在接口中抛出异常,最后返回结果0。 -
算法的流程可以如下图表示:
gen_chain_word和gen_chain_char
这两个接口的实现过程基本一致,都是调用了Core类的 genMaxWordCountChain
方法。唯一的区别在于建图时的点权: gen_chain_word
接口将每个节点的点权为1,而 gen_chain_char
将点权设置为单词的长度。函数 genMaxWordCountChain
主要分为有环和无环的情况。在有环时采用 dfs
算法,无环时可以采用动态规划的思路求解。
无环,动态规划+拓扑排序算法 dp_longest_chain
:
-
先对需要用到的参数进行定义:
tmp_inDegree[MAX]
临时入度数组,dp[MAX]
数组记录以第i
个单词结尾的单词链的最大长度,last_word[MAX]
记录第i
个单词的前驱节点编号; -
参数初始化:
dp[MAX]
初始化为每个节点的点权,last_word[MAX]
为-1(即没有前驱); -
假设在没有
-h-t-j
三个参数的约束下,在拓扑排序的基础上状态转移。先将所有入度为0的节点加入到队列中,然后将队列的点依次弹出并判断:对于一个将要弹出的节点A
和它的后继B
,有如下状态转移方程,其中value
是B
的点权:- d p [ B ] = m a x ( d p [ B ] , d p [ A ] + B . v a l u e ) dp[B] = max(dp[B],\ dp[A] + B.value) dp[B]=max(dp[B], dp[A]+B.value)
-
当
dp[B]
的值被更新了,还要将last_word[B]
的值更新为A
,表示前驱被更新了。然后将B
对应的入度减一,当B
的入度为0,将B
也加入队列中。 -
最终统计答案时,遍历
dp[MAX]
,选取出满足单词链尾约束的最大的值,这就是最长链的长度。如果要输出最长链,只需根据这个最长链的尾节点,结合last_word
数组的值迭代取出。
上述步骤是假定了没有约束单词链的首尾字母,若不满足该条件,则需要利用拓扑排序对整个图进行重构:
- 进行拓扑排序,每次选择入度为0,且不满足首字母要求的的单词加入队列,并更新入度;
- 若单词
A
在队列拓扑排序中被弹出,则将其标记为“需要被删除的”,A.is_deleted = true
; - 初始化图的各种信息,利用剩下的节点的单词进行图重构。
这样重构后的图保证了每一个在拓扑排序中能作为开头的单词是符合条件的。
上述算法的最好时间复杂度为 O(m)
,最坏的时间复杂度为 O(n^2 + m)
,其中 n
为重构前图的点数,m
为图的边数。
该快速算法的流程图如下:
有环,暴力深度优先搜索:
- 检查是否有满足要求可以开头的单词,如果有,则调用
dfs_longest_chain
方法,深搜; - 当算法执行到单词
A
,遍历A
所有的后继B
,若B
满足结尾条件,则判断是否能更新最长链;同时为了避免在递归时参数拷贝占用内存消耗,可以利用引用传递,节省开销,这一步在后面优化有细讲; - 暴力
dfs
算法的流程与前面的几乎一样。
实现总结
- 最开始的时候我们能想到的就是对于全部的参数都用深搜暴力求解,但是这样的后果就是代码的性能不佳;
- 后来我们在写判环函数
check_circle
时发现了拓扑排序的算法能够和单词链前后节点相连的特性完美结合,可以非常良好地复用在求解最长链中。因此我们针对-w
和-c
两个参数设计了拓扑排序和动态规划相结合的快速算法,求解无环的部分。但是在构造单元测试样例的时候,我们发现了快速算法不能很好地处理-h-j
两个对首字母有要求的参数; - 接着,我们询问了往届的学长,发现他们采用重构图的方式进行解决——即反复删去那些必定在单词链开头且不满足首字母要求的节点,非常良好地解决了这个问题。如果对于单词链的开头有约束,则先重构图,使dp算法跑的单词链开头都满足要求。至此,无环情况的快速算法可以优雅地解决
-w-c-h-t-j
的需求; - 最后,对于有环和求解所有链的情况,我们暂时没有想到好的优化方法,只能采用暴力深搜的方法解决。其中可以优化的细节为,在
dfs
传递参数时选择引用传递,而不是值传递,这样可以大大减少递归调用时的拷贝过程。对于gen_chain_word
和gen_chain_char
两个函数,仅需要维护最长链;对于gen_chains_all
函数,由于有要求结果上限为20000个,因此在结果超长时,我们选择直接抛出异常并结束算法,并且返回0来节省算法的开销。
五、展示在所在开发环境下编译器编译通过无警告的截图
六、程序UML图
阅读有关 UML 的内容,画出 UML 图显示计算模块部分各个实体之间的关系(画一个图即可)
UML图中的“Main类”并不是一个真正的类,而是用来代表入口函数,不过这个入口函数也使用了Error类的对象,故而在图中标出。
Part2 开发与测试
七、计算模块接口部分的性能改进
记录在改进计算模块性能上所花费的时间,并展示你程序中消耗最大的函数,陈述你的性能改进策略。
我们使用gperftest进行性能分析。支持该工具的编译工具链需要部署在本地虚拟机上,并在CLion中更换工具链、重新构建目标项目才能使用。由于开发的时候使用了windows.h库,但Linux环境无法编译该库,故而创建了一系列Perf.h文件用于性能分析和测试。
1. 优化前总览
直观上来讲,程序的复杂度主要由图算法贡献,不过这也和数据规模有关。在数据规模很小且图并不复杂的情况下,IO时间通常占据了大部分程序执行时间;当测试数据规模逐渐增大,IO时间则会变为次要的。
下图给出的是-n参数下一个中等规模样例的性能分布,可见控制台IO和计算算法行时间都有比较大的性能开销。计算算法当中,除了dfs算法外,拷贝也花了genAllWordChain函数40%的开销。
随后是一个较为复杂的样例,在-w -r参属下执行:
该样例执行了10秒左右,且dfs函数占据了大部分开销(90.3%);另外,函数的解析和卸载也花费了大约4%的开销。由此可见,优化dfs算法对于程序整体性能的提升有较大的帮助。
最后是一个复杂样例,在-c -w下执行:
该样例执行了20秒左右,且dfs算法仍旧花费掉了大部分性能开销。仔细考察执行了的dfs函数,发现pushback操作、声明操作和dfs内部调用的下一个dfs各自占据了较大的开销。
2. 优化后
针对上述提到的拷贝所带来的开销,我们仔细分析了两个主要的算法(dp和dfs),发现dp处理结果的速度非常快,几百个单词的 -w-c
可以快速出结果,只有暴力的 dfs 算法开销较大。
针对 dfs,我们将原先的“值传递”修改为了“引用传递”,可以大大减少在递归时的函数参数传递拷贝构造的开销,这一改动效果是比较显著的,测试样例从18秒运行变为了1秒。
八、契约式设计、编程
阅读 Design by Contract,Code Contract 的内容,并描述这些做法的优缺点,说明你是如何把它们融入结对作业中的。
- Design by Contract:
- 设计契约(Design by Contract)是一种软件开发方法,旨在通过建立明确的接口约束来提高代码的正确性、可靠性和可维护性。它基于一组约定俗成的接口规范,这些规范定义了程序的输入、输出和状态的约束条件,类似于契约的条款。
- Code Contract:
- 代码契约(Code Contract)是微软公司提供的一种实现设计契约的工具,它可以帮助开发人员在代码中定义和验证各种契约条件。代码契约包括三个主要元素:前置条件(Precondition)、后置条件(Postcondition)和对象不变式(Invariant)。
- 前置条件定义了一个方法的输入参数应该满足的条件,例如参数不能为null,参数的值必须在某个范围内等。后置条件定义了方法执行完毕后所保证的结果,例如返回值不能为null,返回值必须满足某种约束等。对象不变式则是在整个对象的生命周期中保持不变的一些属性或条件,例如对象的某个属性不能为负数。
- 优点:
- 使用代码契约可以帮助开发人员更加准确地定义方法的行为,并提高代码的可读性、可维护性和可重用性。代码契约可以在开发时帮助发现潜在的问题,也可以在维护时避免引入新的问题。
在本次的作业中,我们没有明显地使用了“Code Contract”的方法开发,没有这个意识,以后可以尝试结合到开发过程中。
九、计算模块单元测试
计算模块部分单元测试展示。展示出项目部分单元测试代码,并说明测试的函数,构造测试数据的思路。并将单元测试得到的测试覆盖率截图,发表在博客中。要求总体覆盖率到 90% 以上,否则单元测试部分视作无效。
本次项目采用 gtest
进行测试,主要有队友负责。gtest
是谷歌的单元测试插件,可以测试行覆盖率、分值覆盖率等信息。我们针对Core模块进行了样例构造和单元测试。另外,由于 dll
文件不能被单元测试,故而也使用 Perf
系列文件进行单元测试。
1. 测试函数和测试用例说明
首先需要说明,由于编译器在编译时插入了一些分支代码以防止程序执行时出现“危险的行为”(包括引用空指针),使得程序出现了很多额外的分支。gtest
在汇编级别实现覆盖率分析,但它好像并不能识别这些原本不属于用户代码的分支语句,故而分支的覆盖率无法达到90%。我们尝试了很多方法,包括降低编译器优化级别、更改编译设置等,但都无济于事。因此,我们将会结合一些样例说明我们对关键分枝的覆盖情况。
一共设计了10个单元测试用例,能覆盖大部分行数和用户分支。下面给出一些单元测试用例函数作为说明。
第一个单元测试函数对于-n参数进行了测试:
/* 测试用例 */
woo oom moon noox
$$blue$$ nijun
/* 测试程序 */
TEST_F(CoreTest, Test_N) {
FileIO f = FileIO::getInstance();
string filename = "../test/Testfiles/input1.txt";
vector<string> words;
int size;
ASSERT_EQ(f.read_file(filename), 1);
ASSERT_EQ(size = f.get_words(words), 6);
ASSERT_EQ(comp_words(input1, words), 1); // 测试单词识别情况
/* 功能测试 */
Core core = *new Core(words, size);
vector<vector<string>> result;
//core.genAllWordChain(result);
ASSERT_EQ(core.genAllWordChain(result), 13); // 测试执行结果
ASSERT_EQ(f.output_screen(result), 1);
}
第二个单元测试函数进行了-w的组合参数测试:
/* 测试用例 */
Algebra
Apple
Zoo
Elephant
Under
Fox
Dog
Moon
Leaf
Trick
Pseudopseudohypoparathyroidism
bluez
todzzzzz
ted
/* 测试函数 */
// -w -h -t
TEST_F(CoreTest, Test_W_H_T) {
FileIO f = FileIO::getInstance();
string filename = "../test/Testfiles/input2.txt";
vector<string> words;
int size;
ASSERT_EQ(f.read_file(filename), 1);
ASSERT_EQ(size = f.get_words(words), 14);
ASSERT_EQ(comp_words(input2, words), 1);
/* 功能测试 */
Core core = *new Core(words, size, false, 'a', 'z', 0, false);
vector<string> result;
ASSERT_EQ(core.genMaxWordCountChain(result), 4);
ASSERT_EQ(f.output_file(result), 1);
}
第三个函数进行了-c的组合参数测试,包括了-r:
/* 测试用例 */
{"bbcc", "cdef", "fack", "kill", "cde" ,"eb"};
/* 测试函数 */
// -w -h -t -r
TEST_F(CoreTest, Test_W_H_R) {
FileIO f = FileIO::getInstance();
string filename = "../test/Testfiles/input3.txt";
vector<string> words;
int size;
ASSERT_EQ(f.read_file(filename), 1);
ASSERT_EQ(size = f.get_words(words), 6);
ASSERT_EQ(comp_words(input3, words), 1);
/* 功能测试 */
Core core = *new Core(words, size, true, 'b', 0, 0, false);
vector<string> result;
ASSERT_EQ(core.checkIllegalLoop(), false);
ASSERT_EQ(core.genMaxWordCountChain(result), 4);
ASSERT_EQ(f.output_file(result), 1);
}
第四个函数进行了错误测试,当执行-n参数时,如果单词链条超过20000返回0:
/* 测试用例过长,此处不给出 */
/* 测试函数 */
TEST_F(CoreTest, Test_TOO_MANY_CHAIN) {
FileIO f = FileIO::getInstance();
string filename = "../test/Testfiles/input6.txt";
vector<string> words;
int size;
ASSERT_EQ(f.read_file(filename), 1);
ASSERT_EQ(size = f.get_words(words), 250);
/* 功能测试 */
Core core = *new Core(words, size);
vector<vector<string>> result;
ASSERT_EQ(core.checkIllegalLoop(), false);
// ASSERT_EQ(core.genAllWordChain(result), 3);
ASSERT_EQ(core.genAllWordChain(result), 0);
ASSERT_EQ(f.output_screen(result), 1);
}
除了通过函数的返回值测试Core函数执行情况之外,单元测试中还测试了 FileIO
类的执行情况。单元测试样例则是设计过的,尽可能地覆盖比较多的分支。事实上,在 gtest
当中,如果用户分支语句被完全覆盖了,能够在左侧标明:
针对这一类可以被完全覆盖的分支,我们通过设计样例使其能够被完全覆盖,但另一类分支语句如:
if (tmp_inDegree[toNode_id] == 0 &&
((head != 0 && nodes[toNode_id]->get_s() != head) ||
(reject != 0 && nodes[toNode_id]->get_s() == reject))) {
q.push(nodes[toNode_id]);
}
实际上,reject 参数无效(即为0)时,不会出现有单词的第一个字符为0,故而这一类分支是无法通过构造样例测试的。我们则是通过设计样例,尽可能使得其他的分支被完全测试。
测试样例构造的思路主要有两点:
- 根据参数情况和测试目的进行构造。如构造有环的用例、构造会产生超长的用例等
- 根据分支的覆盖情况进行构造。如果有些分支无法覆盖,则尽可能设计复杂的情况覆盖这些分支。对于每一个分支,也要分析构造其测试样例的可能性
2. 测试结果展示
针对 PerfCore.h
、PerfFileIO.h
和 Node.h
进行单元测试,得到结果如下:
其中,Node.h
并没有分支语句,但是却有50%的分支覆盖率,这说明编译器插入分支的确对覆盖率的测试产生了影响。
3. 回归测试
在完成重要更新时(如更新函数组织逻辑、进行性能优化、引入新的库等),我们都中心进行单元测试,以保证程序在版本更新之后仍然有效。尽管很多问题都是在第一次测试中发现的,在回归测试中发现的错误不多,回归测试仍旧是有意义的。
十、计算模块部分异常处理说明
在博客中详细介绍每种异常的设计目标。每种异常都要选择一个单元测试样例发布在博客中,并指明错误对应的场景。
我们为本次项目一共设计了14个异常,对应的异常码和异常内容如下:
enum all_exception_state {
FILE_NOT_EXIST, // 文件不存在
FILE_ILLEGAL, // 文件不合法
FILE_LACK, // 缺少输入文件
FILE_MORE_THAN_ONE, // 输入文件多于一个
ARGS_UNIDENTIFIED, // 未定义的参数
ARGS_DUPLICATE, // 重复参数
ARG_N_CONFLICT, // -n参数冲突
VALUE_LACK, // -h-j-t的参数值缺失
VALUE_MORE_THAN_ONE, // -h-j-t的参数值多于一个字符
VALUE_ILLEGAL_ARGS, // -h-j-t的参数值不合法(不是字母)
BASIC_ARGS_CONFLICT, // 基础参数冲突-c-w-n
BASIC_ARGS_LACK, // 缺少基础参数-c-w-n
LOOP_ILLEGAL, // 不要求环,但是单词成环
RESULT_TOO_LARGE, // 结果长度超过20000
};
针对每种异常,我们都设计了一种单元测试样例,还是采用 googletest
套件进行开发:
// 测试套件示例
class CoreTest : public ::testing::Test {
protected:
void SetUp() override {
}
void TearDown() override {
}
};
int main(int argc, char **argv) { // 加载lib.dll库
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
以下异常测试的结果总览如下:
文件不存在 FILE_NOT_EXIST
异常输出结果:"Error: There is no TXT file in this path! Please make sure the path is correct!"
// no file
TEST_F(CoreTest, Test_NO_FILE) {
FileIO f = FileIO::getInstance();
string filename = "../test/Testfiles/input66.txt";
ASSERT_EQ(f.read_file(filename), -1);
}
文件非法(不是txt文件) FILE_ILLEGAL
异常输出结果:"Error: File illegal. Probably it's not a TXT file!"
// illegal file
TEST_F(CoreTest, Test_ILLEGAL_FILE) {
int ret1 = system("..\\bin\\Wordlist.exe -n ..\\test\\Testfiles\\MDFile.md");
ASSERT_EQ(ret1, 0);
}
缺少输入文件 FILE_LACK
异常输出结果:"Error: There is no input TXT included!"
TEST_F(CoreTest, Test_LACK) {
int ret1 = std::system("..\\bin\\Wordlist.exe -n -h a input.txt input1.txt");
ASSERT_EQ(ret1, 0);
}
输入文件多于一个 FILE_MORE_THAN_ONE
异常输出结果:"Error: There are more than one TXT included!"
TEST_F(CoreTest, Test_LACK) {
int ret1 = std::system("..\\bin\\Wordlist.exe -n input.txt input1.txt");
ASSERT_EQ(ret1, 0);
}
参数未定义 ARGS_UNIDENTIFIED
异常输出结果:"Error: Unidentified arg, please choose arg from (-n,-c,-w,-h,-t,-j)!"
TEST_F(CoreTest, Test_PARAMETER_ERROR) {
// 基本参数冲突 BASIC_ARGS_CONFLICT
int ret1 = std::system("..\\bin\\Wordlist.exe -c -w ..\\Testfiles\\input1.txt");
ASSERT_EQ(ret1, 0);
// 基本参数缺失 BASIC_ARGS_LACK
int ret2 = std::system("..\\bin\\Wordlist.exe ..\\Testfiles\\input1.txt");
ASSERT_EQ(ret2, 0);
// 参数缺少值 VALUE_LACK
int ret3 = std::system("..\\bin\\Wordlist.exe -w -h ..\\Testfiles\\input1.txt");
ASSERT_EQ(ret3, 0);
// 参数值多于一个 VALUE_MORE_THAN_ONE
int ret4 = std::system("..\\bin\\Wordlist.exe -w -t bb ..\\Testfiles\\input1.txt");
ASSERT_EQ(ret4, 0);
// 参数值非法(不是字母) VALUE_ILLEGAL_ARGS
int ret5 = std::system("..\\bin\\Wordlist.exe -w -j 8 ..\\Testfiles\\input1.txt");
ASSERT_EQ(ret5, 0);
// 参数N冲突 ARG_N_CONFLICT
int ret6 = std::system("..\\bin\\Wordlist.exe -n -h a ..\\Testfiles\\input1.txt");
ASSERT_EQ(ret6, 0);
// 参数未定义 ARGS_UNIDENTIFIED
int ret7 = std::system("..\\bin\\Wordlist.exe -n -o ..\\Testfiles\\input1.txt");
ASSERT_EQ(ret7, 0);
// 参数重复 ARGS_DUPLICATE
int ret8 = std::system("..\\bin\\Wordlist.exe -h a -h a ..\\Testfiles\\input1.txt");
ASSERT_EQ(ret8, 0);
}
参数重复 ARGS_DUPLICATE
异常输出结果:"Error: Duplicate arg!"
见上
参数N冲突 ARG_N_CONFLICT
异常输出结果:"Error: Arg -n cannot combine with other args!"
见上
参数缺少值 VALUE_LACK
异常输出结果:"Error: Lack value for arg (-h/-j/-t)!"
见上
参数值多于一个 VALUE_MORE_THAN_ONE
异常输出结果:"Error: More than one value for arg (-h/-j/-t)!"
见上
参数值非法(不是字母) VALUE_ILLEGAL_ARGS
异常输出结果:"Error: Illegal value for arg (-h/-j/-t), please enter character!"
见上
基本参数冲突 BASIC_ARGS_CONFLICT
异常输出结果:"Error: Arg(-n/-c/-w) conflict!"
见上
基本参数缺失 BASIC_ARGS_LACK
异常输出结果:"Error: Lack of basic arg(-n/-c/-w)!"
见上
存在非法环 LOOP_ILLEGAL
异常输出结果:"Error: There is a words loop included without permission!"
// illegal loop
TEST_F(CoreTest, Test_ILLEGAL_LOOP) {
FileIO f = FileIO::getInstance();
string filename = "../test/Testfiles/input3.txt";
vector<string> words;
int size;
ASSERT_EQ(f.read_file(filename), 1);
ASSERT_EQ(size = f.get_words(words), 6);
/* 开始测试 */
vector<vector<string>> result;
ASSERT_EQ(gen_chains_all(words, size, result), -1);
}
结果过长 RESULT_TOO_LARGE
异常输出结果:"Error: Too large for the results(length over 20000)!"
// too many chains
TEST_F(CoreTest, Test_TOO_MANY_CHAIN) {
FileIO f = FileIO::getInstance();
string filename = "../test/Testfiles/input6.txt";
vector<string> words;
int size;
ASSERT_EQ(f.read_file(filename), 1);
ASSERT_EQ(size = f.get_words(words), 250);
/* 功能测试 */
Core core = *new Core(words, size);
vector<vector<string>> result;
ASSERT_EQ(gen_chains_all(words, size, result), 0);
ASSERT_EQ(f.output_screen(result), 1);
}
十一、界面模块的详细设计过程
在博客中详细介绍界面模块是如何设计的,并写一些必要的代码说明解释实现过程。
GUI 基于 Java 的 Swing
包实现。Swing
主要通过监听器监听用户操作,从而实现反馈和交互。
GUI 界面绘制在一个继承了 JFrame
类的 MyFrame
实例上。JFrame
下使用 JPanel
进行布局。
public MyFrame() {
setTitle("Longest-Word-Chain");
setSize(800, 600);
setLocation(300, 200);
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
setResizable(false);
setFrameLayout();
}
随后,通过创建一系列的组件,并将这些组件安排在MyFrame合适的位置上进行排版布局。
/* 输出文本域设置 */
JLabel outputLabel = new JLabel("输出框");
outputLabel.setBounds(50, 0, 40, 20);
......
outputScrollPane.setBounds(50, 20, 400, 200);
panel2.add(outputScrollPane);
对于关键的按钮,可以增加监听器使其能够在被点击时执行反馈程序:
/* 文件选择器FileChooser */
JButton fileChooseButton = new JButton("选择文件");
fileChooseButton.setBounds(50, 260, 100, 30);
fileChooseButton.addActionListener(e -> {
JFileChooser fileChooser = new JFileChooser();
FileNameExtensionFilter filter = new FileNameExtensionFilter("Text Files", "txt");
......
}
});
panel1.add(fileChooseButton);
在事件监听器中调用 dll
模块提供的函数可以实现计算功能。具体的调用方法在下一个部分会有详细描述。
除此之外,GUI 界面通过事件监听实现了对不合法参数组合的屏蔽,这样许多违法的参数组合无法触发 Core 模块,在一定程度上屏蔽了这一部分错误。
十二、界面模块与计算模块的对接
详细地描述 UI 模块的设计与两个模块的对接,并在博客中截图实现的功能。
与计算模块对接
由于GUI界面使用 java 语言编写,而我们的主程序使用 C++ 语言,因此在GUI调用 core.dll
时涉及到 java 和 C++ 的交互问题。对此,我们查阅资料,使用了 java 语言提供的 jni.h
进行对接。
jni
简介:
JNI(Java Native Interface)是一种用于实现Java应用程序与本地代码(通常是C或C++)交互的技术。通过JNI,Java应用程序可以调用本地代码的函数,也可以将Java对象传递给本地代码,同时还可以从本地代码中获取Java对象的引用。
想要在 java 中调用 dll 中的函数,需要满足以下几点要求:
- java 中使用
native
参数标记方法为从本地代码中加载; - C++ 中对应的函数接口采用 C 的命名空间,具体做法为加上
extern "C"
标记; - 在 C++ 中的接口中使用 jni 中相应的导出符号
JNIEXPORT
标记; - 保证函数的参数与 java 中的方法的参数类型完全一致,再加上特定的 java 对象参数
JNIEnv *env
和jobject obj
; - 规范 C++ 和 java 中相应函数的命名。假设 java 中的方法是定义在
com.example
包,MyClass
类下,命名为myFunction()
,那么在 C++ 中对应的函数命名应为Java_com_example_MyClass_myFunction()
,否则 java 的虚拟机 JVM 将无法在 dll 中定位函数。
因此我们的三个主要函数接口 gen_chains_all
、gen_chain_word
、gen_chain_char
配套如下:
在 java 中:
import java.io.File;
public class CoreAPI {
static {
System.load(System.getProperty("user.dir") + File.separator + ".." + File.separator + "bin" + File.separator + "core.dll"); // 加载 DLL 文件
}
public static native int genChainsAll(String[] words, int len, String[][] result);
public static native int genChainWord(String[] words, int len, String[] result, char head, char tail, char reject, boolean enable_loop);
public static native int genChainChar(String[] words, int len, String[] result, char head, char tail, char reject, boolean enable_loop);
}
在 C++ 中:
#ifdef __cplusplus
extern "C" {
#endif
// 提供给GUI界面的接口
JNIEXPORT jint JNICALL
Java_CoreAPI_genChainsAll(JNIEnv *env, jobject obj, jobjectArray jWords, jint len, jobjectArray jResult) {
// 将java的数据类型转化成C++的
vector<string> words;
for (int i = 0; i < len; i++) {
jstring jWord = (jstring) env->GetObjectArrayElement(jWords, i);
const char *cWord = env->GetStringUTFChars(jWord, 0);
words.emplace_back(cWord);
env->ReleaseStringUTFChars(jWord, cWord);
}
vector<vector<string>> result;
// 执行C++的对应函数
Core core = *new Core(words, len);
if (core.checkIllegalLoop()) return (jint) -1;
int dllReturnCode = core.genAllWordChain(result);
// 将C++的数据类型转化成java的
int result_size = (int) result.size();
for (int i = 0; i < result_size; i++) {
int result_i_size = (int) result[i].size();
jobjectArray jRow = env->NewObjectArray(result_i_size, env->FindClass("java/lang/String"), NULL);
for (int j = 0; j < result_i_size; j++) {
env->SetObjectArrayElement(jRow, j, env->NewStringUTF(result[i][j].c_str()));
}
env->SetObjectArrayElement(jResult, i, jRow);
}
// 返回
return (jint) dllReturnCode;
}
JNIEXPORT jint JNICALL
Java_CoreAPI_genChainWord(JNIEnv *env, jobject obj, jobjectArray jWords, jint len, jobjectArray jResult, jchar head,
jchar tail, jchar reject, jboolean enable_loop) {
// Convert Java objects to C++ data types
std::vector<std::string> words;
for (int i = 0; i < len; i++) {
jstring jWord = (jstring) env->GetObjectArrayElement(jWords, i);
const char *cWord = env->GetStringUTFChars(jWord, 0);
words.emplace_back(cWord);
env->ReleaseStringUTFChars(jWord, cWord);
}
std::vector<std::string> result;
// Call your DLL function with the converted data types
Core core = *new Core(words, len, enable_loop, (char) head, (char) tail, (char) reject, false);
if (core.checkIllegalLoop()) return (jint) -1;
int dllReturnCode = gen_chain_word(words, len, result, (char) head, (char) tail, (char) reject, enable_loop);
// Convert the C++ data types to Java objects
for (int i = 0; i < (int )result.size(); i++) {
jstring jResultItem = env->NewStringUTF(result[i].c_str());
env->SetObjectArrayElement(jResult, i, jResultItem);
}
// Return the DLL return code
return (jint) dllReturnCode;
}
JNIEXPORT jint JNICALL
Java_CoreAPI_genChainChar(JNIEnv *env, jobject obj, jobjectArray jWords, jint len, jobjectArray jResult, jchar head,
jchar tail, jchar reject, jboolean enable_loop) {
// Convert Java objects to C++ data types
std::vector<std::string> words;
for (int i = 0; i < len; i++) {
jstring jWord = (jstring) env->GetObjectArrayElement(jWords, i);
const char *cWord = env->GetStringUTFChars(jWord, 0);
words.emplace_back(cWord);
env->ReleaseStringUTFChars(jWord, cWord);
}
std::vector<std::string> result;
// Call your DLL function with the converted data types
Core core = *new Core(words, len, enable_loop, (char) head, (char) tail, (char) reject, false);
if (core.checkIllegalLoop()) return (jint) -1;
int dllReturnCode = gen_chain_char(words, len, result, (char) head, (char) tail, (char) reject, enable_loop);
// Convert the C++ data types to Java objects
for (int i = 0; i < (int )result.size(); i++) {
jstring jResultItem = env->NewStringUTF(result[i].c_str());
env->SetObjectArrayElement(jResult, i, jResultItem);
}
// Return the DLL return code
return (jint) dllReturnCode;
}
#ifdef __cplusplus
}
#endif
IO 模块
由于我们的 GUI 程序是用 java 实现的,一开始并不知道怎么从 dll
中调用 C++ 的函数,因此我们在 GUI 程序内部配套了一个文件 IO 类专门处理文件读写。后来学习了相关知识,也实现了从核心操作库 core.dll
中调用接口函数。但最后我们还是没有选择将 GUI 界面与 lib.dll
做耦合,原因主要如下:
lib.dll
库仅负责文件的读写单词、文件输出操作,这些操作在 java 中非常方便地能实现;- 在 java 调用
dll
库的函数时,涉及到大量的 java 和 C++ 之间数据的转化,带来额外的开销; - java 与本地函数的交互配置较为繁琐,且我的队友使用的是苹果系统 macOS,无法实现
dll
调用。
Part3 总结
十三、描述结对的过程
提供两人在讨论的结对图像资料(比如 Live Share 的截图)。关于如何远程进行结对参见作业最后的注意事项。
2023.3.7 初步研讨
在图书馆的二楼公共空间进行第一次讨论。本次讨论较为具体,主要涉及到对题目要求的分析与解构、具体算法的确定、两人的分工以及下一阶段的时间安排。创立了github仓库,写入了节点类 Node
。
2023.3.8 初步开发
在主M303教室,第一次线下正式结对编程,深入读题,并进行初步开发,几乎完成了项目的雏形,具体实现了输入输出控制类FileIO、对命令行参数的读取、以及Core类核心算法(但是此时没有完成封装,只有一个main.cpp,包罗万象)。针对GUI界面,完成了基本框架的编写。
2023.3.9 项目雏形完成
在图书馆协作开发,针对 -w-c
参数无环情况进行优化,从 dfs
转化为 dp
算法,并完善了其它细节,main.cpp编译生成的 Wordlist.exe
文件测试可以运行。针对 GUI
程序,添加了选择框。
2023.3.10-3.11 开始阶段2
在老主楼二楼开水间开启阶段2的开发,反复尝试调配 dll
,利用 __declspec(dllexport)
导出函数接口,但均失败了。转而着手利用PerfTest构建单元测试。
2023.3.11-3.13 解决core.dll的封装
由于课程冲突,各自开发。反复查阅资料,发现 C++ 和 C 的函数命名空间不一致,要将接口采用 C 的命名空间,具体为利用 extern C
,结合 CMakelists
的配置,最终解决了封装为 core.dll
并在 main.cpp
中调用的问题,并在单元测试中发现了一些小bug并解决。扩展了单元测试。
2023.3.14 封装lib.dll,进行单元测试,添加异常类
利用封装 core.dll
的经验,进而封装好了 lib.dll
。并开始进行单元测试。同时添加了异常类 Error.h
,初步指定了几项异常。但并没有解决利用 dll
将异常类导出的问题,与导出接口函数有所区别。
2023.3.15-3.16 完善异常处理的设计
各自开发。扩展至现在的14个异常,并规范了相应的函数,在各个模块中实现了异常的处理。
2023.3.17 完成java对dll的调用,测试各个接口并解决bug
在宿舍结对编程,查阅资料实现了 GUI 界面(java实现)对 dll 函数的调用。随后针对各个接口设计单元测试样例,并在单元测试过程中发现了部分致命的 bug,随后修复。
2023.3.18 完成了单元测试,性能测试,开发GUI的IO
在宿舍结对编程,共同构造异常测试样例,完成了单元测试部分。进行了性能测试,针对高开销的部分函数进行了优化,效果显著。随后针对 GUI 开发了文件 IO 的接口,正在完善 GUI 程序。
2023.3.19 模块松耦合
与另一小组进行对接,互换了核心计算模块,但是在和 GUI 界面耦合时遇到了语言不同带来的交互问题。对接组使用的是 Python 语言,我们 GUI 使用的是 Java 语言,因此在对接时接口还需再封装一层。编写作业文档,并完成签入。
十四、关于结对编程的讨论
看教科书和其它参考书,网站中关于结对编程的章节,例如:http://www.cnblogs.com/xinz/archive/2011/08/07/2130332.html ,说明结对编程的优点和缺点。同时描述结对的每一个人的优点和缺点在哪里(要列出至少三个优点和一个缺点)。
结对编程评价:
-
优点:
-
显著加快查改bug速度:结对编程可以提高代码的质量,显著提高 debug 的速度。因为我和队友可以相互检查代码并发现潜在的问题。这可以减少程序中的错误和缺陷,提高软件的可靠性和稳定性。在结对过程中可以明显感觉到找 bug 和解决 bug 的速度会快很多。
-
新技术学习快:结对编程可以加快学习代码库的速度。我比较擅长写 C++,同组成员擅长 java。因此在写核心逻辑时主要我当“驾驶员”,在写 GUI 界面时由队友做“驾驶员”。通过观看他编写 GUI 界面的java代码,我可以快速学到很多东西,也能掌握基本的 java 图形化界面。
-
加强团队合作:结对编程可以增强团队合作,因为它可以鼓励队员之间相互交流和合作。这可以改善沟通和协作,并在开发过程中减少冲突。在合作的两星期里,我和队友待在一起的时间日益增多,尤其是最近一段时间临近 DDL 了,在相互的沟通交流中可以增进感情。
-
-
缺点:
- 成本较高:结对编程需要两个程序员一起工作,这可能会增加开发成本。此外,结对编程需要额外的硬件资源,如显示器和键盘,两个人盯着一块屏幕非常不好受,因此我们最后跑到了队友的宿舍开发,他有大屏幕。
- 较慢的速度:结对编程可能需要比独自开发代码更长的时间,因为两个程序员需要互相沟通和协作。比如比较简单的 IO 函数,我一个人可以快速解决,但是在结对编程中必须共同完成,从而导致速度变慢。
- 需要程序员之间的协调:结对编程需要队员之间良好的协调和合作,否则会导致合作效果不佳。如果两个程序员不能相互合作,那么结对编程可能会导致开发效率下降。
个人对结对编程的评价:
- 优点:
- 测试方面提升较大,debug 的速度明显提高;
- 显著提高沟通交流能力(增进感情♂
- 学习新技术快,可以互帮互学;
- 缺点:
- 时效性降低,开发效率下降;
- 队友的系统环境是 mac,环境不兼容的问题非常突出,导致必须装虚拟机;
队友对结对编程的评价:
- 两周的结对编程历程非常痛苦,且越到后面越痛苦。我和 zyl 同学有时线下一起开发,有时各自独立开发,并且基本上都是在自己的电脑上开发(事实上,两个人用一台电脑写代码是比较笨的做法,尤其是PC的普及使得每个人都有自己的使用习惯的情况下)。
- 由于我本学期课程很多,没有办法抽出很多时间和 zyl 同学线下沟通,但我们通过 WeChat 也相互探讨和学习了很多问题,并且使用 Github 实现了协作开发。我们基本实现了分工合作,只有少部分工作是相互交叉的。
- 由于我已经用了一年多的 MacBook,我在适应开发环境时遇到了不少问题,迫使我不得不重新在 Windows 笔记本上配置开发环境。这也导致我中间有些时候处于宕机状态。
- 总体来讲,我们本次结对编程的合作过程比较顺利,也增进了互相之间的了解。
人员 | 林子杰 | 周奕龙 |
---|---|---|
优点1 | 测试强大,捏造数据强,发现了好些bug | 算法能力强,完成了Core模块的主要内容 |
优点2 | 学习新技术快,仅用两天就完成了GUI的编写 | Debug超级快,并且很准确 |
优点3 | 学习积极主动性强,带着我一起敏捷 | 积极和别的小组沟通,完成了松耦合 |
缺点 | 使用的是mac系统,由于系统不兼容,一直在为项目配环境,耗费了部分时间 | 起床比较晚,有时候找不到人 |
Part4 附加-互换核心模块
基本信息
- 张启立 20373496
- 李震 20373443
交换过程总结
我们对张启立、李震小组的核心模块 libword_list.dll
都进行了使用命令行和GUI,以及测试程序进行耦合。
由于他们组提供给我们的接口是针对他们的 GUI 界面的,而他们这部分是用 Python 写的。因此在该部分接口中需要部分调整,比如在 C++ 中 load 进 dll
文件,以及接口的调整等。
正常导入之后,我们发现了他们的一些小bug,发现他们存在浅拷贝的错误。在交流后他们修改了该bug。