项目 | 内容 |
---|---|
这个做业属于哪一个课程 | 2019春季计算机学院软件工程(罗杰)(北京航空航天大学) |
这个做业的要求在哪里 | 结对项目-最长单词链 |
我在这个课程的目标是 | 学习软件工程方法与相关工具,提高本身的工程能力,锻炼本身与他人协同开发以及开发较大项目的能力 |
这个做业在哪一个具体方面帮助我实现目标 | 学习并尝试实践告终对编程,锻炼了代码尤为测试方面的基础 |
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | ||
· Estimate · | 估计这个任务须要多少时间 | 30 | 60 |
Development | 开发 | ||
· Analysis | · 需求分析 (包括学习新技术) | 300 | 400 |
· Design Spec | · 生成设计文档 | 60 | 40 |
· Design Review | · 设计复审 (和同事审核设计文档) | 120 | 90 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 15 | 10 |
· Design | · 具体设计 | 180 | 180 |
· Coding | · 具体编码 | 600 | 720 |
· Code Review | · 代码复审 | 250 | 240 |
· Test | · 测试(自我测试,修改代码,提交修改) | 200 | 300 |
Reporting | 报告 | ||
· Test Report | · 测试报告 | 200 | 150 |
· Size Measurement | · 计算工做量 | 60 | 30 |
· Postmortem & Process Improvement Plan | · 过后总结, 并提出过程改进计划 | 30 | 30 |
合计 | 2045 | 2250 |
信息隐藏我认为便是封装的概念,在咱们实现过程当中,咱们将界面模块(Base)和计算模块(Core)中的操做均封装为接口,而且Core中的core和graph两个类也分别进行封装,从而外部仅能调用Core模块的接口,而Base暴露的也仅仅是开始运行的方法。c++
接口设计咱们严格参照了做业的要求,即Core模块只暴露两个函数int gen_chain_word(char* words[], int len, char* result[], char head, char tail, bool enable_loop)
和int gen_chain_char(char* words[], int len, char* result[], char head, char tail, bool enable_loop)
。其中须要注意的是二者的返回值均是单词链的单词个数(做业中的“单词链长度”)。git
咱们从开始编程时便有意地为松耦合作准备,具体操做即是开始时将Base和Core分开,而且Base也仅仅调用以上约定的两个函数。在将Core封装为dll后Base的代码几乎能够直接应用,而且最终仅须要更换Core.dll文件即可切换计算模块。github
计算模块主要由两个类实现,分别是core和graph。其中core主要负责参数的读入以及动态规划算法的实现,而graph主要负责图的创建以及图论方面算法的实现,例如拓扑排序和DFS主要在graph中完成。算法
core类中,因为暴露在外的两个接口gen_chain_word
和gen_chain_char
仅仅是在初始化图的时候有所不一样(word函数将图的边权值所有初始化为1,而char函数按单词长度初始化权值),所以最终咱们选择使用一个公共的函数做为内部的实现,经过一个参数区分调用的是哪个接口。编程
算法流程图:
具体实现中,首先在单词进入Core前就要对words数组进行检查,若其中包含非法字符则报错,不然将全部字母转为小写再进入具体算法。以后进入delete_repeat_words
将重复的单词删掉,由于单词链中不容许出现重复单词。windows
接下来进入main_func
,这里须要按照单词列表和输入的模式创建图,并初始化好各个数据结构。以后对图进行拓扑排序,如有环且没有指定-r则报错,不然按照图中所示分别选择两中不一样的算法进行计算。数组
在计算结束后须要检查答案是不是一个词,由于动态规划仅能找到最长的边链条,但并不能排除存在单个词很是长的状况,所以若是答案符合这种形式仍须要删掉这个词并从新计算。网络
算法的独到之处:数据结构
这一部分中咱们花费了约3-4小时。咱们主要针对有环状况的算法进行改进。因为在一个有向有环图中寻找最长的路径是一个NP问题,从算法自己的角度来看不管如何都逃不开NP这个坑,所以咱们使用了普通的DFS来进行。咱们优化的地方在于将算法中访存的消耗尽量下降。起初咱们使用了vector做为路径存储的数据结构,在通过一次性能分析后咱们发现递归部分中退栈操做不少,所以咱们将vector改成一维数组,将原先的pop_back变成栈顶指针的--操做,从而下降了时间消耗。函数
下图为有环状况下消耗CPU最多的函数,即DFS的主函数
咱们使用一个9000余词的文件对于无环状况进行了测试,结果发现算法在无环状况下表现良好,gen_chain_word/char中消耗最多的部分其实是接口中进行malloc的部分。
契约式设计的主要思路是设计接口时提供接口的先验条件、后验条件和不变式,这让我想起了OO课上学习的JSF。
契约式设计的优势有:
契约式设计的缺点是上手较难,一开始不容易严格按照要求实现,而且部分逻辑较为复杂的函数使用契约时也比较麻烦。
在咱们实现的过程当中,咱们对函数运行先后的状态事先进行了约定,从而能顺利地将不一样的函数串接起来造成完整的程序。但咱们并无在实际实现中添加断言等来进行严格的约束。
单元测试范例
单元测试部分咱们分别对无环和有环以及各类异常状况进行了测试,共构造了19个单元测试。
构造测试的思路大致上是尽量覆盖各个分支。因为char和word两个接口在具体运行时仅仅是初始化权值不一样,所以测试中咱们更注重于各类特殊状况的测试,如开头和结尾的自环等。
单元测试的分支覆盖率为97%,其中包含了各类异常测试的覆盖。
计算模块因为仅仅将两个函数接口暴露给用户,从而用户在调用函数时能够存在各类不合法的输入。主要的不合法输入包括如下几点
针对以上的错误咱们分别构造了以下测试
void invalid_char_test() //不合法字符 { char* words[] = { "a12345", "avdc", "fewt" }; char* results[100]; int len = gen_chain_word(words, 3, results, 0, 0, true); } void empty_string() //空串 { char* words[] = { "", "avdc", "fewt" }; char* results[100]; int len = gen_chain_word(words, 3, results, 0, 0, false); } void not_enough_words() //单词不够(一个词不成链) { char* words[] = { "a" }; char* results[100]; int len = gen_chain_word(words, 1, results, 0, 0, true); } void has_loop() //(存在环) { char* words[] = { "aba", "aca", "fewt" }; char* results[100]; int len = gen_chain_word(words, 3, results, 0, 0, false); } void invalid_head() /(非法头部) { char* words[] = { "aba", "avdc", "fewt" }; char* results[100]; int len = gen_chain_word(words, 3, results, 1, 0, false); } void invalid_tail() //(非法尾部) { char* words[] = { "aa", "avdc", "fewt" }; char* results[100]; int len = gen_chain_word(words, 3, results, 0, 1, false); } }
而且使用诸如如下格式的接口进行单元测试
TEST_METHOD(WordsException9) { Assert::ExpectException<std::invalid_argument>([&] {exception_test::invalid_head(); }); try { exception_test::invalid_head(); } catch (const std::exception& e) { Assert::AreEqual("Core: invalid head or tail", e.what()); } }
(在参阅了一些同窗的博客后我感受这种单元测试的写法有点别扭,实际上仅仅是为了调用ExceptException接口而搞得如此复杂……)
界面主要分为两大部分:命令行参数读取解析及文件读取。
在命令行参数解析部分,因为目前已有不少的开源库可供使用,本着不重复造轮子的原则咱们使用了一个较为轻量的头文件库cxxopts来处理命令行参数。
Github地址:https://github.com/jarro2783/cxxopts/ 简单的使用介绍: 引入头文件库: #include <cxxopts.hpp> 建立一个cxxopts的Option实例,输入参数是程序名和程序的描述 cxxopts::Options options("MyProgram", "One line description of MyProgram"); 使用add_options方法添加参数,其中括号内第一个参数为短参数和长参数名,第二个参数是描述,可选的第三个参数是选项后输入的实参。 options.add_options() ("d,debug", "Enable debugging") ("f,file", "File name", cxxopts::value<std::string>()) ; 使用parse方法对输入的参数进行分析,其中这里输入的两个参数为main函数读取的argc和argv。 auto result = options.parse(argc, argv); 使用 `result.count("option")` 获取名为“option”的参数出现的次数,并使用: result["option"].as<type>() 来获取其值。注意到若是option不存在会抛出异常。
(以上内容摘自项目Github主页)
这个库能够自动将各个参数的值读出并对不合法的状况抛出异常。但因为其涉及的不合法状况较为朴素,我对一些相对复杂的不合法输入进行了处理,例如同时输入-w和-c、或输入了两个-w的状况。
最终我将命令行参数读取和分析封装在base类的parse_arguments函数中,函数经过参数将读取到的值返回。
在文件读取部分,我设计的思路是按字符从文件头开始向后扫描,并在出现特殊字符的位置断开,从而将文件中的合法单词分隔出来。
注意到在读文件过程当中我对单词的长度也作了约束,如读到长度过长的单词则会抛出异常。最终将以上单词保存在base类成员中的数组内便可。
界面模块我也写了一些单元测试,主要测试以上的两个函数。其中大部分测试均针对命令行参数的解析。例如:
dll模块对接方面我使用了Base模块显式调用Dll的方法(即仅借助dll文件,不借助lib文件)。经过windows.h中的LoadLibrary和GetProcAddress实现。最终将调用的过程与开头读文件、解析参数等过程结合,造成完整的运行程序。
最终只要将Core.dll文件放置在可执行文件同一目录下,即可以运行程序计算结果。
咱们与1610106一、16061118组互换了Core.dll进行测试。
咱们的dll文件能够在对方的界面模块下运行
但对方的Core模块并不能被咱们的base模块调用。询问得知对方的Core以C#实现,当我使用dumpbin查看其中导出的函数时并不能查看到任何信息。
在上网查阅了一些资料后我了解到C++调用C#须要将dll转换为C#的类组件,貌似并无相似于c++这样直接调用的方法。
我和个人结对队友因为宿舍距离很远,每次去咖啡厅又很贵,最终选择了使用Teamviewer+视频的方式进行结对合做。好在使用Teamviewer时网络很流畅,而且对方也能够在个人电脑上操做,甚至比两我的在线下结对还要方便一些。这也让我对结对编程有了更深的认识,结对不必定必须两我的坐在一块儿才能完成,只要沟通渠道方便、代码均可以看到,就能方便地进行结对编程。固然因为咱们开始的晚了一些,中间某些时候也不得不采用了并行的方式。
结对编程相比于我的开发和双人并行开发而言,好处是实时的复审能够避免不少小的问题。我在和队友结对编程的过程当中,发生的一些笔误或者算法方面的疏忽均可以被及时地发现并改正。而且结对编程在讨论中进行,对于编程过程当中模块间的约束也更加清晰。结对编程的缺点是假如两我的时间常常错开(假若有12小时时差),或两人的时间安排有差,则很难进行。
成员 | 优势 | 缺点 |
---|---|---|
周博闻 | 1.可以较快解决各类工程方面的问题 2.可以想到一些易被忽略的点 3. 快速上手新知识并加以应用 | 有些地方不够细致,致使浪费不少时间找小bug |
庹东成 | 1.算法能力很强 2.思路清晰,遇到新的问题能找出不少办法 3.结对中效率高,对队友很友善 | 命名及代码格式有些不太规范 |