BUAA-2023软件工程结对编程博客作业
项目 | |
---|---|
这个作业属于哪个课程 | 2023北航敏捷软件工程 |
这个作业的要求在哪里 | 结对项目-最长英语单词链 |
我在这个课程的目标是 | 学习并实践软件工程开发的方法论。在把握整体流程和内容要素的基础上实践细节,培养开发技术、开发思维、团队协作等能力。 |
这个作业在哪个具体方面帮助我实现目标 | 在结对编程的过程中初步学会在压力下和一名队友进行合作开发、共同解决问题,培养合作开发的初步意识 |
一、项目信息和地址
- 教学班级:周四下午班
- 项目地址:https://github.com/Enqurance/2023_Pair
二、PSP表格
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 60 | 120 |
· Estimate | · 估计这个任务需要多少时间 | 60 | 120 |
Development | 开发 | 1500 | 2060 |
· Analysis | · 需求分析 (包括学习新技术) | 120 | 300 |
· Design Spec | · 生成设计文档 | 60 | 60 |
· Design Review | · 设计复审 (和同事审核设计文档) | 60 | 120 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 60 | 120 |
· Design | · 具体设计 | 300 | 180 |
· Coding | · 具体编码 | 600 | 900 |
· Code Review | · 代码复审 | 100 | 200 |
· Test | · 测试(自我测试,修改代码,提交修改) | 200 | 300 |
Reporting | 报告 | 300 | 300 |
· Test Report | · 测试报告 | 100 | 100 |
· Size Measurement | · 计算工作量 | 100 | 100 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 100 | 100 |
合计 | 1860 | 2480 |
三、信息隐藏和接口设计
看教科书和其它资料中关于 Information Hiding,Interface Design,Loose Coupling 的章节,说明你们在结对编程中是如何利用这些方法对接口进行设计的。
信息隐藏是指在设计软件时,将实现细节隐藏在组件内部,对外仅仅暴露接口。这样可以降低组件之间的依赖性,从而提高代码的可维护性和可重用性。
接口设计是指在软件开发中,定义功能模块之间交互的接口。在设计接口的时候,需要考虑参数类型、返回值类型等问题。良好的接口设计能够提高组件之间的耦合度,从而降低维护和修改的成本。
在本次结对项目中,我们计划采用C++语言的面向对象的特点对功能模块实现封装,并且对每一个功能模块都采用单例模式的设计模式。我们计划将功能模块分为四个部分:
- 程序入口
- 计算模块Core(单例)
- IO模块FileIO(单例)
- 错误处理模块Error(单例)
每一个模块都各司其职,并且具备生产接口所需参数的能力。利用面向对象的特性,能够比较好地践行信息隐藏和接口设计这两个软件开发思维方法。
1.信息隐藏
我们利用面向对象编程的特点,通过将关键信息设置为private成员实现了信息隐藏;同时,我们也将一些没有必要向外界暴露的函数进行了隐藏。
class Core {
private:
// 数据变量
int dp[MAX] = {0};
int lastWord[MAX] = {-1};
bool vis[MAX] = {false};
vector<string> longest_chain;
// ...
// 方法...
public:
int genAllWordChain(vector<vector<string>> &result) {
// -n 参数的具体实现部分
}
// 其他方法
}
在设计接口的时候,我们对外仅仅暴露必要的功能性接口(public方法),对于其调用的子函数,都在类中设置为私有,这可以让用户只关注于当前接口的输入和输出,并且避免接口变得臃肿。
2.接口设计
在我们的设计中,类和类之间的交互通过接口实。单例类仅对定义其的文件可见,例如FileIO中使用了FileIO单例,但对外仅仅暴露read_file接口。这样的设计使得模块间的相互调用变得方便简洁。
经过商讨,我们预先确定了交换模块的小组,并且定义了实现-n、-w、-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);
接口仅面向参数功能,而不负责额外的工作。
四、计算模块的接口设计与实现过程
设计包括代码如何组织,比如会有几个类,几个函数,他们之间关系如何,关键函数是否需要画出流程图?说明你的算法的关键(不必列出源代码),以及独到之处。
计算模块接口设计
由于在实际开发前完整阅读了后续的作业要求,我们事先找到在阶段四合作的小组,确定了计算模块接口的设计,接口中的方法和官方要求的相同,见下图:
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
:- 我们用先使用参数words, len, result, head, tail, reject, enable_loop参数实例化了一个Core类对象core,然后调用Core类中的
genMaxWordCountChain
方法进行单词链计算;
- 我们用先使用参数words, len, result, head, tail, reject, enable_loop参数实例化了一个Core类对象core,然后调用Core类中的
- 对于
Core
类:- 构造方法会根据
genMaxWordCountChain
传递进的参数对众多私有变量初始化,然后进行建图create_nodes
、检查环check_circle
操作; - 接着
genMaxWordCountChain
判断是否有环。若没有环,则判断是否有必要进行重构图,若有必要,则执行Core类中的reBuildGraph
方法预处理,剔除不满足约束且必定在单词链头部的单词,然后调用快速算法dp_longest_chain
结合dp
和拓扑排序求解最长链;若有环,则在图上跑dfs
算法,调用函数dfs_longest_chain
求解最长链。
- 构造方法会根据
- 对于异常:
- 主要处理结果过长、以及数据出现不被允许的环的两种情况。在接口
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
算法的流程与前面的几乎一样。
核心计算类以及接口之间主要的函数调用关系如下:
其中 A->B
表示 A
方法调用了 B
方法。
实现总结:
- 最开始的时候我们能想到的就是对于全部的参数都用深搜暴力求解,但是这样的后果就是代码的性能不佳,同伴在单元测试随便捏的几个数据就能超时;
- 后来我们在写判环函数
check_circle
时发现了拓扑排序的算法能够和单词链前后节点相连的特性完美结合,可以非常良好地复用在求解最长链中。因此我们针对-w
和-c
两个参数设计了拓扑排序和动态规划相结合的快速算法,求解不要求环的部分。但是在构造单元测试样例的时候,我们发现了快速算法不能很好地处理-h-j
两个对首字母有要求的参数; - 接着,我们询问了往届的学长,发现他们采用重构图的方式进行解决——即反复删去那些必定在单词链开头且不满足首字母要求的节点,非常良好地解决了这个问题。至此,无环情况的快速算法可以优雅地解决
-w-c-h-t-j
的需求; - 最后,对于有环和求解所有链的情况,我们暂时没有想到好的优化方法,只能采用暴力深搜的方法解决。其中可以优化的细节为,在
dfs
传递参数时选择引用传递,而不是值传递,这样可以大大减少递归调用时的拷贝过程。对于gen_chain_word
和gen_chain_char
两个函数,仅需要维护最长链;对于gen_chains_all
函数,由于有要求结果上限为20000个,因此在结果超长时,我们选择直接抛出异常并结束算法,并且返回0来节省算法的开销。
五、所在开发环境下编译器编译通过无警告的截图
六、UML类图
阅读有关 UML 的内容,画出 UML 图显示计算模块部分各个实体之间的关系(画一个图即可)
UML图中的“Main类”并不是一个真正的类,而是用来代表入口函数,不过这个入口函数也使用了Error类的对象,故而在图中标出。
七、性能分析与性能改进
记录在改进计算模块性能上所花费的时间,并展示你程序中消耗最大的函数,陈述你的性能改进策略。
我们使用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,Code Contract 的内容,并描述这些做法的优缺点,说明你是如何把它们融入结对作业中的。
契约式编程
契约式设计是一种软件开发方法,它的核心思想是将程序组件之间的关系形式化为合同,并使用前置条件、后置条件和不变式来确保程序的正确性和可靠性。理想情况下,这种开发方法有助于提高代码可读性、提高代码质量,并且有便于扩展和调试。在我的理解当中,这种设计理念旨在用形式化方法约束软件开发。
代码契约则是微软引入的一种静态验证技术,它通过在代码中添加前置条件、后置条件和对象不变式来描述代码组件之间的约束关系,从而提高代码的可靠性、可维护性和可读性。Code Contract也希望通过规定一系列的契约条件,使得开发人员编写的代码具有更好的可读性、可测试性和更高的质量
契约式编程在理想情况下有很多好处:
-
有助于帮助开发人员理清设计思路
-
在大型项目中,可以有效减少少数模块发生错误导致的高成本
-
平衡了用户和开发者之间的关系,双方都要在遵守模块契约的前提下设计或使用模块
-
契约设计和编程可以让接口有更好的可读性和可维护性
当然,契约式编程也有不少缺点:
- 花费精力。程序员和用户学习、了解契约都需要花费精力,执行契约也要花费精力
- 增加开发成本。如果契约模块很小而项目规模较大,或者是项目本身规模就很小,契约编程会提高开发成本
- 额外的执行开销。契约需要花费额外的代码开销
本次的作业中,我们并没有从形式化的层面进行严格的契约设计,但我们也尽可能地规范了类和接口的设计,使之在语义上和在功能上都能够体现“约定”。例如在设计核心计算类Core时,我们确定在Core实例化前没有图,保证在实例化后图已经构建,而且在各个函数运行过程中保证图的相关信息不被修改,且每一步函数调用都保证能够符合参数约束等。事实上,课程组给出的设计文档和我们的开发过程也在某种程度上形成了“契约”。
九、单元测试说明
计算模块部分单元测试展示。展示出项目部分单元测试代码,并说明测试的函数,构造测试数据的思路。并将单元测试得到的测试覆盖率截图,发表在博客中。要求总体覆盖率到 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
};
文件不存在 FILE_NOT_EXIST
异常输出结果:"Error: There is no TXT file in this path! Please make sure the path is correct!"
文件非法(不是txt文件) FILE_ILLEGAL
异常输出结果:"Error: File illegal. Probably it's not a TXT file!"
缺少输入文件 FILE_LACK
异常输出结果:"Error: There is no input TXT included!"
输入文件多于一个 FILE_MORE_THAN_ONE
异常输出结果:"Error: There are more than one TXT included!"
参数未定义 ARGS_UNIDENTIFIED
异常输出结果:"Error: Unidentified arg, please choose arg from (-n,-c,-w,-h,-t,-j)!"
参数重复 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!"
结果过长 RESULT_TOO_LARGE
异常输出结果:"Error: Too large for the results(length over 20000)!"
十一、界面模块的详细设计过程
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();
}
/* 通过setBounds方法实现Panel在Frame上的布局 */
private void setFrameLayout() {
......
JPanel panel1 = new JPanel();
panel1.setLayout(null);
panel1.setBounds(0, 0, 500, 300);
add(panel1);
JPanel panel2 = new JPanel();
panel2.setLayout(null);
panel2.setBounds(0, 300, 500, 300);
add(panel2);
JPanel panel3 = new JPanel();
panel3.setLayout(null);
panel3.setLocation(500, 0);
add(panel3);
......
}
随后,通过创建一系列的组件,并将这些组件安排在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模块,在一定程度上屏蔽了这一部分错误。
对于程序执行时的问题,GUI模块也可以通过捕获返回值并依此在输出框中返回正确的结果。
2.成品展示
GUI界面的成品展示如下:
可以看到,用户可以在输入框输入内容,并且可以选择txt文件,GUI会将txt文件的内容捕获到输入框;附加参数在默认情况下是被屏蔽不可选择的;保存文件指定用户必须保存以txt为后缀的文件。
更详细的使用内容在guibin下的Readme文件中。
十二、界面模块与计算模块的对接
详细地描述 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;
}
}
IO 模块
由于我们的 GUI 程序是用 java 实现的,一开始并不知道怎么从 dll
中调用 C++ 的函数,因此我们在 GUI 程序内部配套了一个文件 IO 类专门处理文件读写。后来学习了相关知识,也实现了从核心操作库 core.dll
中调用接口函数。但最后我们还是没有选择将 GUI 界面与 lib.dll
做耦合,原因主要如下:
lib.dll
库仅负责文件的读写单词、文件输出操作,这些操作在 java 中非常方便地能实现;- 在 java 调用
dll
库的函数时,涉及到大量的 java 和 C++ 之间数据的转化,带来额外的开销; - java 与本地函数的交互配置较为繁琐,且我的队友使用的是苹果系统 macOS,无法实现
dll
调用。
附加-互换核心模块
我们对张启立、李震小组的核心模块 libword_list.dll
都进行了使用命令行和GUI,以及测试程序进行耦合。
由于他们组提供给我们的接口是针对他们的 GUI 界面的,而他们这部分是用 Python 写的。因此在该部分接口中需要部分调整,比如在 C++ 中 load 进 dll
文件,以及接口的调整等。
正常导入之后,我们发现了他们的一些小bug,发现他们存在浅拷贝的错误。在交流后他们修改了该bug。
与测试模块耦合
与GUI界面模块耦合
十三、结对历程
提供两人在讨论的结对图像资料(比如 Live Share 的截图)。关于如何远程进行结对参见作业最后的注意事项。
两周的结对编程历程非常痛苦,且越到后面越痛苦。
我和zyl同学有时线下一起开发,有时各自独立开发,并且基本上都是在自己的电脑上开发(事实上,两个人用一台电脑写代码是比较笨的做法,尤其是PC的普及使得每个人都有自己的使用习惯的情况下)。
由于我本学期课程很多,没有办法抽出很多时间和zyl同学线下沟通,但我们通过WeChat也相互探讨和学习了很多问题,并且使用Github实现了协作开发。我们基本实现了分工合作,只有少部分工作是相互交叉的。
由于我已经用了一年多的MacBook,我在适应开发环境时遇到了不少问题,迫使我不得不重新在Windows笔记本上配置开发环境。这也导致我中间有些时候处于宕机状态。
下面通过时间线梳理我们的结对历程。
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)
导出函数接口,但均失败了。同时配置gtest,准备进行单元测试。
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个异常,并规范了相应的函数,在各个模块中实现了异常的处理。开始配置Perf虚拟环境和工具链,进行性能测试
2023.3.17 完成java对dll的调用,测试各个接口并解决bug
在宿舍结对编程,查阅资料实现了 GUI 界面(java实现)对 dll 函数的调用,并且完成了GUI的编码。随后针对各个接口设计单元测试样例,并在单元测试过程中发现了部分致命的 bug,随后修复。
2023.3.18 完成了单元测试,性能测试,开发GUI的IO
在宿舍结对编程,共同构造异常测试样例,完成了单元测试部分。进行了性能测试,针对高开销的部分函数进行了优化,效果显著。随后针对 GUI 开发了文件 IO 的接口,正在完善 GUI 程序。
2023.3.19 模块松耦合
与另一小组进行对接,互换了核心计算模块,但是在和 GUI 界面耦合时遇到了语言不同带来的交互问题。对接组使用的是 Python 语言,我们 GUI 使用的是 Java 语言,因此在对接时接口还需再封装一层。编写作业文档,并完成签入。
十四、评价结对编程
在经历了两周高强的开发后,我发现结对编程的确能够在某些方面帮助我们开发:
- 允许更有效的交流和讨论。在线下结对过程中,我们发现交流和沟通的效率比在线上软件中更高
- 提高设计水平和代码质量。二人合作时,能够产生更好的点子、编出质量更高的代码。同时可以一人编码,一人测试,及时发现错误并解决
- 共享经验和方法。虽然我们有的模块是独立开发的,但是结对让我们能够互相了解对方开发的一些细节,从而能够更多地注意开发中的细节
但是结对编程这一理念本身也有缺陷:
- 对时间要求很高。结对的理念是两人一同参与编码,而实际上我们的课表差异比较大,想找出两人都有空的时间比较困难
- 成本较高。结对编程需要两个程序员一起工作,这可能会增加开发成本。另外,为了适应两人不同的开发环境,我也被迫做了很多调整。
除了对结对编程的方法论进行评价外,我还想对本项结对编程作业进行评价(吐槽):
-
为什么不用跨平台能力更好的语言?Java、Python都具有更好的跨平台能力,不论是从评测的角度来讲还是从开发的角度来讲,都会更方便。C++不仅在Windows和Linux、MacOS平台编译出的可执行程序不同,连库都不一样!真是麻烦死了
-
文档的要求不明确。比如:
-
图形化界面是附加内容,怎么出现在任务列表里呢,好像变成必做项目了
-
Core模块功能不统一。前面将Core模块定义为一个仅计算的模块,但后文又有描述
在
Core
模块中实现抛出异常的功能,并撰写测试用例:传进去一个错误的参数或给出一个错误的单词文本,期望能捕获这个异常。如果没有,测试就报错。事实上,Core模块仅负责计算,那么识别参数和抛出参数组合异常的功能不应该由Core负责。
-
GUI开发环境环境。事实上,GUI的开发没有限制语言,但是助教又告知我们测试平台可能存在不兼容的情况
-
我很能理解助教想要帮助同学们强化对软件开发流程的认识,也很能够体会助教的工作量。但是我仍旧希望课程组想办法提升同学们的开发体验,不断优化课程内容。
林子杰 | 周奕龙 | |
---|---|---|
优点1 | 测试强大,捏造数据强,发现了好些bug | 算法能力强,完成了Core模块的主要内容 |
优点2 | 学习新技术快,仅用两天就完成了GUI的编写 | Debug超级快,并且很准确 |
优点3 | 学习积极主动性强,带着我一起敏捷 | 积极和别的小组沟通,完成了松耦合 |
缺点 | 使用的是mac系统,由于系统不兼容,一直在为项目配环境,耗费了部分时间 | 起床比较晚,有时候找不到人 |