https://github.com/supplient/longest_word_chainc++
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | ||
· Estimate | · 估计这个任务须要多少时间 | 45 | 30 |
Development | 开发 | ||
· Analysi | · 需求分析 (包括学习新技术) | 120 | 180 |
· Design Spec | · 生成设计文档 | 45 | 0 |
· Design Review | · 设计复审 (和同事审核设计文档) | 45 | 0 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 30 | 45 |
· Design | · 具体设计 | 120 | 60 |
· Coding | · 具体编码 | 480 | 450 |
· Code Review | · 代码复审 | 120 | 180 |
· Test | · 测试(自我测试,修改代码,提交修改) | 360 | 450 |
Reporting | 报告 | ||
· Test Report | · 测试报告 | 60 | 60 |
· Size Measurement | · 计算工做量 | 30 | 30 |
· Postmortem & Process Improvement Plan | · 过后总结, 并提出过程改进计划 | 30 | 0 |
合计 | 1485 | 1485 |
cmdUI中的CoreSetting类的接口设计遵循了数据封装的思想,解析后的结果不对外直接开放,仅能经过const函数进行查询。它的接口设计简单朴素,符合cmdUI对它的IO需求,不须要额外添加适配器进行转换。并且由于遵循了松散耦合的思想,因此不须要非得要cmdUI才能调用CoreSetting,这使得能够为之单独构造单元测试。git
UIUtility整个模块的存在遵循了松散耦合的思想。由于UI控制部分中存在和UI形式关联不多的部分,因此单独将这部分代码抽离出来做为一个独立的模块,方便不一样种类的UI共通调用。在这之中,StreamReader是其主体类,它的接口设计很是朴素,若是将read和getReadLen看作一个调用过程的话,它能够被看作是一个无反作用的算法类。同时,咱们为了使得调用UIUtility的过程的复杂度看起来类似,咱们提供了一个封装类FileReader做为接口来简化对文件的调用时的复杂度。程序员
在Core模块中,咱们将无状态的Core类做为其惟一的导出类。这也就意味着对于其余模块而言,Core模块的接口有且仅有一个Core类。并且由于Core类无状态,因此无反作用,这也就意味着容许多个模块并行调用Core模块,符合计算模块的设计思想,不过在本次做业中这属于多此一举就是了。Core模块内部咱们使用ChainSolver做为实现类,它的全部内部计算数据都不对外开放,返回的也是新构造且不做管理的新内存,这确保了Core类的方法中能够简单地构造ChainSolver类,而后委托运算,而不须要作额外的适配工做。github
Core模块自己遵循了松散耦合的思想,正如上点所说,它容许多个模块并行调用,这点的前提是它能够被多个模块调用。因此Core模块的实现是彻底独立,甚至于接口自己都是C化,使用char*[],而非string或者CString,这使得它与UI模块实现解耦,使得不一样种UI模块调用它的复杂度都比较接近。算法
咱们认为此次结对编程的题目能够抽象为有向有环图求最长路径,目前咱们使用的算法仍是深度遍历穷举所用可能的路径,该图有26个点对应26个英文字母,每一个边表明输入的单词,例如输入"hello"能够化为从点'h'到'o'的边。编程
首先根据五项需求-w-r-h-t-c, 其中单独的-w是能够约化到-r的,而单独的-r是不能约化到-w,对于-h-t只是对路径首尾进行检测约束,对于-c只是将图的边加权。因此算法应该针对-r设计。深度遍历的过程将从一个点开始,例如'h',若是该点有指向点的边,则递归访问下一点,下一点的递归完成后才会访问'h'的其余边的指向。后端
边和点的struct分别是:数组
struct Edge { std::string word; int code; int weight; int next; };
边Edge的word存储输入的原始字符串;code是该边的识别编码;weight是该边权重,weight默认为1,在-c时为字符串长度;next是字符串末尾字符ascii码-'a'的值,方便将点的编码都归为0-25。数据结构
struct WordMap { std::vector<Edge> toLast; };
点WordMap只包含一个边的vector。多线程
核心计算包括两个类Core和ChainSolver,Core负责标准输入,全部外部类和函数只调用Core,Core再调用ChainSolver的get_max_chain函数。
ChainSolver类有三个函数公有函数get_max_chain、私有函数CreateMap、私有函数Recursion,get_max_chain调用CreateMap来建立图,再调用Recursion递归DFS建立过的图。
接收如下参数:
char *input[], int num, char *result[], char head, char tail, bool isGetMaxChar, bool enable_loop
以上参数分别是输入的单词数组,输入的单词量,须要输出结果的result,head为-h的头部要求,tail为-t的尾部要求,isGetMaxChar是-c的边权重修改要求,enable_loop是-r的要求。
这个部分须要考虑重复输入的单词并将其抛弃,map<string,int>类型的inputWord存储的是已录入的单词。
if (inputWord.find(s) != inputWord.end()) { return 0; } inputWord.insert(std::pair<std::string, int>(s, code));
单词编码部分使用最普通的线性编码0-100,最开始想到过APHash编码可是对于做业里面的小规模的输入太过拖沓。
使用的是递归法DFS,对传入节点进行路径穷举遍历,进入过的点和边都会被记载在isUsedPoint和isUsedEdge中,递归返回后再将记载记录释放。优化部分会在第六节描述。
咱们对DFS穷举法的主要优化是自环剪枝,很明显在求-r最长路径的过程当中自环是一定须要走的边,不须要再递归断定了,因此在Recursion进入节点的时候先将该节点全部未走过的自环边加入路径中。
//ensure the edge that wait to push is not used before. if (isUsedEdge[iter.code]) continue;//'continue' will jump this edge. //push every self-circle edge. path.push_back(iter.word); if (iter.next == point) { len+=iter.weight; continue; } ... ... ... //pop every self-circle edge. for (auto iter : map[point].toLast) { if (iter.next == point) { isUsedEdge[iter.code] = false; path.pop_back(); } else { break; } }
输入为55个彻底随机的单词的状况下,VS性能分析结果以下:
从图中能够看到ChainSolver::get_max_chain函数占用CPU资源总计约30%, 占整个后端main调用的四分之三,递归遍历毫无疑问是整个项目里面的性能瓶颈。
优势:
缺点:
个人见解是DbC应该适度、局部地被使用,过分追求彻底的DbC是不可取的。适度的DbC在此次的结对编程中咱们也使用了。
固然,这也是由于咱们此次的做业的复杂度并不高,再加上由于结对编程的关系,因此沟通时间不少不少,因此如此简单形式的约定就可行了。更高复杂度的工程中,我以为可能局部的,例如相似于Core模块的算法类,中可能会须要严格的DbC来确保需求描述的准确性。
Core模块的单元测试覆盖率:
对Core模块进行单元测试的过程是:构造测试样例=>传入进Core模块=>检查返回的单词链的长度与内容是否正确。
其中,咱们将后两步独立出来,封装进了testutil的两个函数中。
两个函数分别是:
void testRight(char* words[], int words_len, char* res[], int res_len, bool is_max_char, char head, char tail, bool enable_loop); void testRightMulti(char* words[], int words_len, vector<char**> res, vector<int> res_len, bool is_max_char, char head, char tail, bool enable_loop);
两个函数的区别在于testRightMulti容许符合要求的最长单词链有多条,Core模块只要返回其中一条即为正解。
值得关注的是enable_loop参数。
当enable_loop为假时,会按索引序比较正解与Core返回的解;
当enable_loop为真时,只会进行无序比较。
做为展现的一个简单的测试样例:
TEST_METHOD(simple) { char* words[] = { "hello", "world", "ofk", "kw" }; char* res[] = { "hello", "ofk", "kw", "world" }; testRight(words, 4, res, 4); }
咱们构造测试样例的方式有如下四种:
a. 分析做业要求,找出边界条件,构造边界样例。
b. 根据算法的搜索方式的性质,构造针对性样例。
c. 使用简单算法自动生成大样例,构造压力样例。
d. 针对可能被抛出的异常,构造异常样例。
咱们首先细读做业文档,从中划出能够得出边界条件的语句,而后针对该语句构造边界样例。这一过程在demand_analyze.md的前半段被体现。摘取其中一段:
在完成算法编写后,针对算法中可能存在的分支、迭代,构造针对性样例试图去覆盖,检验是否如预期那样执行。这一过程在demand_analyze.md的后半段被体现。摘取其中一段:
咱们设计了一种简单的算法来自动生成大的测试样例。咱们称这种算法为PyramidGenerator。
算法的基本思路是构造一个最多只有26个非根节点的树,且该树具备最长深度的节点有且仅有一个。
如图构造树,每个节点表明一个字母,每条边表示以起点为首字母、以终点为尾字母的一族单词。由此,每条边上均可以生成无数单词。
而若是咱们控制root-t-e-o这条路径上的每条边都只生成一个单词,例如:tme-ego,那么该路径的单词连起来就是惟一最长单词链。
经过使用PyramidGenerator,咱们能够生成任意大小的无环测试样例。
咱们简单地针对Core执行中会抛出的异常构造对应的样例,当该样例被输入后,尝试捕获异常。
计算部分一处异常处理,当没有启用-r功能却检查到有环图时将抛出异常w_c_h_t_ChainLoop,如下代码的断定条件是该边的终点已被走过,同时没有开启-r,同时不是自环。
if (isUsedPoint[iter.next] && !isEnableLoop && point != iter.next) { throw w_c_h_t_ChainLoop; }
另外也须要考虑对Core的单元测试异常抛出,其实如下异常在程序做为总体运行时不会抛出:
传入的尾部要求是否合理:
if (tail_input != 0 && (tail_input < 'a' || tail_input > 'z')) { throw para_tail_error; }
传入的头部要求是否合理:
if (head_input != 0 && (head_input <'a' || head_input > 'z')) { throw para_head_error; }
传入的enable_loop是否合法:
try { isEnableLoop = enable_loop; } catch(...){ throw para_loop_error; }
传入的input数组里面是否是的确有num个值:
for (i = 0; i < num; i++) { try { CreateMap(input[i], isGetMaxChar); } catch (...) { throw para_input_error; } }
传入的res是否能接受结果:
try { for (auto iter : maxPath) { char *new_str = new char[iter.length() + 2]; // std::cout << iter << " "; for (unsigned int j = 0; j < iter.length(); j++) new_str[j] = iter[j]; new_str[iter.length()] = '\0'; result[i] = new_str; // TODO release such memory i++; } } catch (...) { throw para_res_error; }
传入input输入字符串是否包括非法字符(在建立图时统一抛出异常):
int ChainSolver::CreateMap(char *c_s, bool isGetMaxChar) { try { ... ... ... } catch (...) { throw create_map_error; } return 0;
界面分为三个组成部分:
a. UIUtility: UI共通的代码的集合
b. cmdUI: 基于命令行实现的UI
c. MFCUI: 基于MFC实现的GUI
由于不管是命令行实现仍是MFC实现,只要是UI界面就会有部分共通的逻辑须要处理。
因此咱们将这部分逻辑抽离出来,编译成UIUtility.dll。
实际被抽离出来的逻辑为对文本输入进行分词,获得英语单词数组的部分。咱们将其称为StreamReader,它接受一个输入流,输出对应的单词数组。
为了方便调用,咱们也提供了一个做为StreamReader的封装的FileReader,它接受一个文件名,并将该文件的输入流做为StreamReader的输入。
StreamReader的具体实现是从流中逐字符读取,自身维护一个临时单词与单词数组。
这部分实现了以命令行参数为用户输入的用户界面。
对命令行参数进行解析的工做被抽离出来在CoreSetting中被实现。CoreSetting会检验参数的合法性,并解析它的含义。
cmdUI的主程序就可使用CoreSetting解析出来的结果作进一步的操做。
以后主程序简单地调用Core模块,而后将结果输出至文件即结束程序。
这部分实现了基于MFC实现的GUI。
按照做业要求,GUI实现须要
咱们的设计中,整个界面分为4块:
输入
经过两个单选按钮来控制输入方式。对于直接输入就直接用一个文本框接收输入,对于文件输入就提供一个地址输入栏和文件打开按钮来让用户给出文件地址。选项
选项分为三组。对于-w, -c这组,使用单选按钮保证用户只能够选取其中一个按钮。对于-h, -t,使用勾选框来控制是否添加,同时给出一个只接受一个英文字符的文本框来接受指定字母做为参数。对于-r,简单地使用一个勾选框来表示是否启用该参数。运行
运行包括程序的执行与程序的关闭两部分。也就是Run和Cancel两个按钮。当用户点击Run时,程序将会读取输入和选项中的数据,并检查数据合法性,以后委托给Core执行,最后将返回的结果填充进输出部分。当Cancel被点击时,简单地结束程序的运行。输出
输出分为两部分。第一部分是打印显示,当Run被点击,而且数据被顺利处理之后,返回的结果会直接打印到一个文字框中。第二部分是导出,相似于输入部分的地址选择,咱们也提供了接口供用户选择输出文件,并提供Export按钮来执行导出功能。
大部分代码都是简单显然的。值得一提的是选项中的-h, -t所使用的文本框的实现。咱们的作法是,每当文本框内容被改变,检查文本长度,若大于1,则截断前面的,只留下最后一个字符。而后检查该字符是否为英文字母(大小写无所谓,自动转化成小写字母),若非英文字母则忽略。
UI模块的设计已在10中详细描述,此处不重复。
咱们将Core模块做为一个独立的dll抽离出了代码。咱们准备了一个导出类Core来做为Core模块总体对外的接口,UI模块能够经过简单地调用这个Core类的静态函数来调用Core模块。
在UI模块调用dll模块时,咱们采起的方案是隐式调用。为了支持这样的作法,咱们在UI模块的附加依赖项中加入了Core.lib,而且将Core.dll和UI模块的可执行文件放在了同一个文件夹中。
关于具体函数接口,咱们只是简单地按照做业的要求实现了Core的接口。接口函数内部会创建ChainSolver类来执行真正的计算过程。固然,这对UI模块而言是透明的。
对于cmdUI,它能解析命令行参数并输出结果至solution.txt中。
例如,命令行参数以下:
运行后的控制台显示:
同时,BIN目录下产生输出文件solution.txt:
而若是参数不正确,例以下图的非法参数-k:
则会提示错误,并终止程序:
对于MFCUI,它能提供一组控件来让用户输入,并提供一组控件来让用户获得输出。
例如,执行后用户界面以下:
用户能够经过这部分来选择输入模式,上面的是直接输入文本内容,下面的是选择文本文件做为输入:
能够经过这部分来控制选项:
能够经过这部分来查看输出并选择文件来导出:
例如,以下做为输入:
点击Run后就会获得以下输出:
点击Open选择导出文件:
再点击Export执行导出,若是成功就会有以下提示:
若是选项不正确,例以下图的明明勾选了-h,却没有给出对应的首字母:
就会给出报错提示:
咱们两人的结对一直牢牢围绕邹欣老师《构建之法》一书的要求:
在结对编程模式下,一对程序员肩并肩、平等
地、互补地进行开发工做。他们并排坐在一台电
脑前,面对同一个显示器,使用同一个键盘、同
一个鼠标一块儿工做。他们一块儿分析,一块儿设计,
一块儿写测试用例,一块儿编码,一块儿作单元测试,
一块儿作集成测试,一块儿写文档等。
咱们两人在这次结对编程以前就已熟识,此次结对编程的任务也必然是无缝衔接般地完成。在结对的过程当中咱们大部分地代码都是坐在一块儿完成的,一块儿分析一块儿设计。固然第一次尝试这样的编程方式也会带来不少疑惑,好比编程一方不免会出现一些没必要要的并且较为复杂的构想,好比在计算核心的部分是否须要将字符串进行哈希编码,此时须要另外一方加入思考博弈,这样的过程颇有趣不过也很花时间。
总的来讲,咱们以为结对编程是个高强度、注重思惟碰撞的编程方式,和传统那种“各自码各自的代码再push”的方式,结对编程的过程当中咱们感觉到了实时性的交流和code review,这是一种很是敏捷的软件开发方式。
结对编程的优势:
结对编程的缺点:
搭档的优势:
搭档的缺点:
咱们和马振亚&马浩翔组交换了模块。
名字 | 学号 |
---|---|
马振亚 | 16061109 |
马浩翔 | 16061097 |
由于咱们两组都将Core模块封装成了dll,因此交换过程很是简单便捷。他们将他们Core的dll, lib, h文件给咱们,而后咱们调整了一下编译选项、调用关系就能够方便地使用他们的代码。
衔接过程最大的不顺利是由于咱们仿照了做业示例中的那样将Core类的全部方法都声明为静态方法,而他们组选择的是将接口声明为Core类的成员函数。因此咱们不得不修改咱们的代码去符合他们组的接口要求。
另外一个不顺利是由于他们组是直接在Core类中实现了算法,因此他们的Core.h中包含了一些运行须要的库函数的头文件。而在咱们的机子上,他们所须要的stdc++.h头文件并不存在,因此咱们不得不删去这句,再包含vector和map来知足编译需求。
衔接完成后在测试过程当中也发现了不一样。主要是需求理解的不一样,对于他们的Core模块而言,空输入、空输出都是会抛出异常的、不合法的,但对于咱们的程序而言,这些都是合法的。