结对项目 - 最长单词链
结对项目 - 最长单词链
项目 | 内容 |
---|---|
本作业属于北航软件工程课程 | 博客园班级链接 |
作业要求请点击链接查看 | 作业要求 |
我在这门课程的目标是 | 成为一个具有一定经验的软件开发人员 |
这个作业在哪个具体方面帮助我实现目标 | 通过结对项目,锻炼极限编程的能力 |
一、GitHub项目地址
Release版程序的项目仓库地址见此
二、PSP表格与预估开发时间
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | --- | --- |
· Estimate | · 估计这个任务需要多少时间 | 1350 | ??? |
Development | 开发 | --- | --- |
· Analysis | · 需求分析 | 30 | ??? |
· Design Spec | · 生成设计文档 | 60 | ??? |
· Design Review | · 设计复审(和同事审核设计文档) | 30 | ??? |
· Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 30 | ??? |
· Design | · 具体设计 | 120 | ??? |
· Coding | · 具体编码 | 480 | ??? |
· Code Review | · 代码复审 | 120 | ??? |
· Test | · 测试(自我测试,修改代码,提交修改) | 240 | ??? |
Reporting | 报告 | --- | --- |
· Test Report | · 测试报告 | 60 | ??? |
· Size Measurement | · 计算工作量 | 60 | ??? |
· Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 120 | ??? |
Total | 合计 | 1350 | ??? |
三、教科书与设计
- Information Hiding,即信息隐藏,与面向对象编程中的封装性有异曲同工之妙。具备封装性得面向对象编程隐藏了某一方法的具体运行步骤,取而代之的是通过消息传递机制发送消息,在面向对象编程中就是调用一个类的方法。我们在本次作业中将程序的核心计算模块Core分离出来,为它配置了几个API,外界调用API传入特定的参数获得期望的结果,但不会知道内部的运行细节,因而也避免了对程序的运行状态造成破坏。通过这样的手段,我们实现了代码的信息隐藏。
- Interface Design,即接口设计,是任何程序设计过程的重中之重。接口设计这个词顾名思义,是将一个程序抽象成具有一个或几个特定功能的模块,外界需要与程序进行信息交换时,只能通过这几个接口进行。在本次作业中,我们参照作业要求设计了
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)
这两个接口,以及List<string> GenerateChain(bool outputToFile = false)
这个专为C#程序调用的接口。这些接口使用简单方便、意义一目了然,我们认为这是一个很优雅的接口设计。 - Loose Coupling,即松耦合,意味着程序的不同模块之间耦合度很低,可以单独剥离出来使用。在本次作业中,我们将核心计算模块Core.dll单独分离出来,供GUI调用,同时Core自己本身也可以通过命令行调用,实现了整体的松耦合。
四、计算模块接口的设计与实现过程
-
本次作业是实现一个最长单词链计算程序,根据我们的分析,这个程序本质上是一个算法问题,没有必要把各种元素抽象成对象,元素之间也不需要维护自己的状态,可以说只是一个输入=>输出的纯函数。因此,我们没有采用严格的面向对象编程方法(先把元素抽象成对象,再设计每个对象的属性、方法和接口,最后把类整合到一起),而是只写了一个类,在类里面设计一些纯函数,用面向过程的方法编写的程序。事实证明,面向过程的编程范式并没有给我们带来麻烦,反而减少了面向对象方法所带来的一些性能开销,也让整体的结构变得简洁易懂。在实际的工程开发项目中,这样的小程序只会作为一个模块出现,为这样简单的需求制定一个庞大的面向对象设计,在如此紧张的工期之下,我们认为是不划算的。
-
我们选择了更为现代、IDE支持更友好、开发工具更为丰富的C#语言进行编码。
-
以下是一份所有函数的详情列表
函数名 返回值 参数列表 说明 ConvertWordArrayToList List<string> char*[] words, int len 工具函数,为了将C风格的char*[]转换为C#风格的List ConvertWordListToArray int IReadOnlyList<string> wordList, char*[] words 工具函数,为了将C#风格的List转换为C风格的char*[] GenerateChain int int mode, char*[] words, int len, char*[] result, char head, char tail, bool enable_loop 将作业要求中的两个接口合二为一后的包装函数 gen_chain_word int char*[] words, int len, char*[] result, char head, char tail, bool enable_loop 作业要求中的接口之一 gen_chain_char int char*[] words, int len, char*[] result, char head, char tail, bool enable_loop 作业要求中的接口之二 CheckValid void char head, char tail 检查-h和-t选项是否后接一个英文字母作为参数 GenerateChain List<string> bool outputToFile = false 为C#程序提供的核心计算模块调用接口 Core None args 作为独立程序运行时可以接收命令行参数的构造函数 Core None string input = "", int mode = 0, char head = '\0', char tail = '\0', bool enableLoop = false, string outputFilePath = @"solution.txt", bool inputIsFile = true 作为类库时可以接收程序参数的构造函数 ParseCommandLineArguments void IReadOnlyList<string> args 解析命令行参数 ExceptWithCause void ProgramException exception 用于抛出自定义异常 ReadContentFromFile string None 用于将文本文件读入到内存中 ReadContentFromFile string string filePath 用于将文本文件读入到内存中 DivideWord List<string> string content 将字符串按作业要求切分为单词列表 FindLongestChain List<string> List<string> words, int mode, char last = '\0', List<string> current = null 核心算法函数,用于寻找最长单词链 OutputChain void IEnumerable<string> chain 用于将计算出来的最长单词链输出到文件 -
函数之间的关系大致如下所示:
get_chain_word
和get_chain_char
这两个函数是供C/C++调用而提供的接口,由于其功能高度相似,因此使用GenerateChain(int mode, char*[] words, int len, char*[] result, char head, char tail, bool enable_loop)
这个函数作为其底层函数,功能上的区别由mode参数进行区分。- 三个重载的
GenerateChain
方法并无本质区别,只是传入的参数不同。它们都先读取用户输入的内容(这里可能会用到ReadContentFromFile
函数把文件中的单词列表加载到内存中),然后调用DivideWord
方法将单词列表切分成一个单词List,随后使用FindLongestChain
方法计算出最长单词链。根据选项的不同,可能还会调用OutputChain
方法将单词链输出到指定文件之中。 Core
构造函数有两个重载,一个是通过命令行调用时使用,一个是作为类库时使用ParseCommandLineArguments
这个方法当且仅当核心模块通过命令行使用时才会被调用。它的作用是解析命令行选项,并将相关的参数存入类中。ExceptWithCause
负责程序抛出异常之后的处理。一般情况下,它会将异常再次向外抛出。ConvertWordArrayToList
和ConvertWordListToArray
这两个函数是工具函数,为了适配C/C++风格的接口而诞生。它们的作用是List<string>和char*[]的互相转换。
-
算法的关键函数是FindLongestChain,在本次作业中我们采用了BFS搜索算法,BFS已经在前序课程中多次使用,因此在博客中我省略了流程图和算法关键的说明。算法的独到之处、创新点和计算关键将在第六节中详细阐述。
五、UML
六、计算模块接口部分的性能改进
-
在设计与编写计算模块性能接口的时候,我们总共花了约3小时的时间。
-
最开始,我们采用了完全的暴力搜索算法,无论是否打开了-r选项,都会用BFS搜索算法从头到尾算出所有的单词链,然后选取其中最长的一个返回。对于-r选项来说,这样的算法无可厚非,因为隐含单词环的最长单词链问题等价于有向有环图的最长路径问题,是一个NPC问题,无法在多项式内求解,只能采用暴力搜索的方法。但是,如果程序参数不含-r选项,再像有-r选项那样,从头到尾寻找出所有的单词链,再判断是否有单词环,在性能上就损失太大了。
-
因此最后我们在
FindLongestChain
方法内部加入了对于-r选项的判断,如果-r选项没有打开,则一旦检测到单词环就立刻从函数中返回,避免不必要的、过大的计算开销。 -
我们原本计划使用一些特殊的数据结构(如将一个单词抽象成首字母、尾字母和长度的三元组),对性能做进一步的优化。但做项目的时间有限、工期紧张,因而没能如计划完成。如果采用数据结构优化,可以将原本n*n!复杂度的算法优化至n!,但都属于指数复杂度,在单词量较大的时候显现不出区别。
-
性能分析工具给出的性能结果如下图所示
CPU资源消耗最大的函数无疑是FindLongestChain
这个用于计算最长单词链的函数。
七、契约式设计
- Design by Contract,即契约式设计,是一种设计计算机软件的方法。这种方法要求软件设计这为软件组建定义正式的、精确的并且可验证的接口,这样,为传统的抽象数据类型又增加了先验条件、后验条件和不变式。
- 如果在面向对象程序设计中一个类的函数提供了某种功能,那么它要:
- 期望所有调用它的客户每跑一都保证一定的进入条件,这就是函数的先验条件:客户的义务和供应商的权利,这样它就不用去处理不满足先验条件的情况。
- 保证退出时给出特定的属性,这就是函数的后验条件:供应商的义务,显然也是客户的权利。
- 在进入时假定、并在退出时保持一些特定的属性:不变条件。
- 契约式设计既有优点也有缺点:
- 在多人合作的大型项目中,任何人都不会有能力完整掌控全局的所有代码。因此,就需要将整个程序分成许多个子模块,每个模块由不同的开发人员负责。因此,在软件项目的一开始,就需要为每一个模块定义其与其它模块的接口,以及这些接口应当具有的性质,即先验条件、后验条件和不变条件。不这样的话,模块之间就无从配合,整个软件也会成为一团无法成型的稀泥。只有当各个模块的接口都定义良好,软件才能以分模块开发的方式继续构建。
- 但是,在小型的、只有两三个人的项目中,契约式设计就变成了一种累赘。小团队追求的是小步快跑的敏捷开发模式,通常一个项目只有一两个星期的时间,这个时候如果先花上几天时间去设计接口,显然是不划算的做法。此外,由于小团队通常使用更为便捷的脚本语言进行开发,接口往往难以设计得到,当编码进行到一定时间之后如果突然发现接口无法满足需求,再做改动的话就会耗费大量的时间。
- 在真实的软件工程项目中,大型项目往往倾向于契约式设计,小型项目往往选择避开契约式设计。
- 在本次作业中,我们将核心计算模块的接口
GenerateChain
做成了唯一对外暴露的接口,并进行了完善的单元测试,可以满足事先规定的先验条件、后验条件和不变条件。之后,GUI模块和计算模块就可以同步并行开发,这个接口的契约保证了最后GUI和计算模块的顺利对接。通过这样的方式,我们把契约式设计用在了这次结对项目之中。
八、计算模块单元测试展示
-
我们的部分单元测试代码如下所示:
[TestMethod()] public unsafe void gen_chain_wordTest() { TestGenChain(_wordList1, _wordChain1WithR, enableLoop: true); TestGenChain(_wordList2, _wordChain2); TestGenChain(_wordList2, _wordChain2WithHe, head: 'e'); TestGenChain(_wordList2, _wordChain2WithTt, tail: 't'); } [TestMethod()] public void gen_chain_charTest() { TestGenChain(_wordList2, _wordChain2WithC, mode: 1); } private unsafe void TestGenChain(List<string> wordList, List<string> expectedChain, int mode = 0, char head = '\0', char tail = '\0', bool enableLoop = false) { var resultArray = CreateStringArray(wordList.Count, 100); var wordListArray = ConvertToArray(wordList); var len = 0; switch (mode) { case 0: len = Core.gen_chain_word(wordListArray, wordListArray.Length, resultArray, head, tail, enableLoop); break; case 1: len = Core.gen_chain_char(wordListArray, wordListArray.Length, resultArray, head, tail, enableLoop); break; default: Assert.Fail(); break; } var result = ConvertToList(resultArray, len); CollectionAssert.AreEqual(expectedChain, result); } private static unsafe char*[] CreateStringArray(int length = 100, int wordLength = 100) { var array = new char*[length]; for (var i = 0; i < length; i++) { var word = new char[wordLength]; fixed (char* wordPointer = &word[0]) { array[i] = wordPointer; } } return array; } private static unsafe List<string> ConvertToList(char*[] words, int len) { var wordList = new List<string>(); for (var i = 0; i < len; i++) { wordList.Add(new string(words[i])); } return wordList; } private static unsafe char*[] ConvertToArray(IReadOnlyList<string> words) { var wordList = new char*[words.Count]; for (var i = 0; i < words.Count; i++) { var word = new char[100]; fixed (char* wordPointer = &word[0]) { int j; for (j = 0; j < words[i].Length; j++) { word[j] = words[i][j]; } word[j] = '\0'; wordList[i] = wordPointer; } } return wordList; } [TestMethod()] public void ParseCommandLineArgumentsTest() { TestWrongArgs("-w"); TestWrongArgs("-c"); TestWrongArgs("-h"); TestWrongArgs("-t"); TestWrongArgs("-"); TestWrongArgs("-h 1"); TestWrongArgs("-t 233"); TestCorrectArgs("-w input.txt"); TestCorrectArgs("-c input.txt"); TestCorrectArgs("-w input.txt -h a"); TestCorrectArgs("-w input.txt -t b"); TestWrongArgs("abcdefg"); TestWrongArgs("-w input.txt -h 9"); TestWrongArgs("-w input.txt -t 0"); TestWrongArgs("-c input.txt -h 7 -t 1"); TestWrongArgs("-w input.txt -h 123"); TestWrongArgs("-c input.txt -t 321"); TestWrongArgs("-h a"); TestWrongArgs("-w input.txt -w input.txt"); TestWrongArgs("-w input.txt -c input.txt"); TestWrongArgs("-c input.txt -c input.txt"); TestWrongArgs("-c input.txt -w input.txt"); TestWrongArgs("-w input.txt -h a -h c"); TestWrongArgs("-w input.txt -t a -t c"); TestWrongArgs("-w input.txt -r -r"); TestCorrectArgs("-w input.txt -r"); } private static void TestWrongArgs(string arguments) { var args = System.Text.RegularExpressions.Regex.Split(arguments, @"\s+"); try { var core = new Core(args); Assert.Fail(); } catch (ProgramException) { } } private static void TestCorrectArgs(string arguments) { var args = System.Text.RegularExpressions.Regex.Split(arguments, @"\s+"); try { var core = new Core(args); } catch (Exception) { Assert.Fail(); } }
-
我们的单元测试主要对四个函数进行测试,分别是
gen_chain_word
、gen_chain_char
、GenerateChain
和ParseCommandLineArguments
。这四个函数是计算模块的核心函数,也是最容易出现Bug的函数。 -
我们构造测试数据的思路是:
- 对于三个计算单词链的函数,构造两个类型为```List<string>的字符串列表,第一个列表是输入的单词,第二个列表是预期生成的单词链,然后将第一个列表传入待测函数,将返回的列表与预期单词链进行比对。
- 对于解析命令行参数的函数,我们通过
TestCorrectArgs
和TestWrongArgs
这两个函数对正确和错误的参数进行测试。如上文中的单元测试代码展示的那样,我们构造了各种各样的输入参数,对ParseCommandLineArgs
的每一个语句都进行了测试。
-
单元测试覆盖率的截图如下:
由上图可见,WordChain模块的单元测试的整体覆盖率达到了93%,符合作业中的要求。需要注意的是,由于ReSharper统计语句覆盖率的计算方式有问题,会把抛出异常后的下一条空语句也加入统计范围,而程序在抛出异常后便不可能在继续执行,因此那几条空语句本不该出现在覆盖率统计之中。排除因统计软件造成的影响之后,通过查看详细的逐语句单元测试报告,可以看到我们的核心计算模块WordChain的单元测试覆盖率达到了接近100%的水平。
九、异常处理
-
针对本次作业,我们设计了以下几种异常,它们的设计目标和对应的错误场景标注在异常名字的下方:
-
public class ProgramException : ApplicationException
最长单词链程序的异常基类,由程序本身逻辑引发的异常全部继承自
ProgramException
-
public class ModeNotProvidedException : ProgramException
没有指定-w或-c时引发的异常
-
public class ArgumentErrorException : ProgramException
参数错误时引发的异常,如-h或-t后面不是英文字母、-w和-c同时出现等等
-
public class FileException : ProgramException
由于文件读写错误造成的异常基类
-
public class InputFileException : FileException
找不到输入文件引发的异常
-
public class FileNotReadableException : FileException
没有权限读文件引发的异常
-
public class FileNotWritableException : FileException
没有权限写文件引发的异常
-
public class WordRingException : ProgramException
单词环存在而没有提供-r参数引发的异常
-
-
能够覆盖这些异常的单元测试样例前文中已给出,这里再次复制粘贴一遍:
[TestMethod()] public void ParseCommandLineArgumentsTest() { TestWrongArgs("-w"); TestWrongArgs("-c"); TestWrongArgs("-h"); TestWrongArgs("-t"); TestWrongArgs("-"); TestWrongArgs("-h 1"); TestWrongArgs("-t 233"); TestCorrectArgs("-w input.txt"); TestCorrectArgs("-c input.txt"); TestCorrectArgs("-w input.txt -h a"); TestCorrectArgs("-w input.txt -t b"); TestWrongArgs("abcdefg"); TestWrongArgs("-w input.txt -h 9"); TestWrongArgs("-w input.txt -t 0"); TestWrongArgs("-c input.txt -h 7 -t 1"); TestWrongArgs("-w input.txt -h 123"); TestWrongArgs("-c input.txt -t 321"); TestWrongArgs("-h a"); TestWrongArgs("-w input.txt -w input.txt"); TestWrongArgs("-w input.txt -c input.txt"); TestWrongArgs("-c input.txt -c input.txt"); TestWrongArgs("-c input.txt -w input.txt"); TestWrongArgs("-w input.txt -h a -h c"); TestWrongArgs("-w input.txt -t a -t c"); TestWrongArgs("-w input.txt -r -r"); TestCorrectArgs("-w input.txt -r"); } private static void TestWrongArgs(string arguments) { var args = System.Text.RegularExpressions.Regex.Split(arguments, @"\s+"); try { var core = new Core(args); Assert.Fail(); } catch (ProgramException) { } } private static void TestCorrectArgs(string arguments) { var args = System.Text.RegularExpressions.Regex.Split(arguments, @"\s+"); try { var core = new Core(args); } catch (Exception) { Assert.Fail(); } }
十、界面模块的设计过程
-
在本次结对编程作业中,我们选取了C#作为开发语言,而C#受到微软的支持,对GUI有天然的适应性。我们选取了WinForm作为GUI的图形界面框架,用少量的代码便完成了GUI的功能。
-
我们的设计目标是:
- 支持两种导入单词文本的方式:导入单词文本文件,或直接在界面上输入单词并提交;
- 提供可供用户交互的按钮;
- 实现
-w -c -h -t -r
这五个参数的功能; - 对于异常情况,弹窗给用户提示;
- 将结果直接输出到界面上,或者导出到指定文件。
-
WinForm在Visual Studio开发环境下,可以直接使用拖动的方式进行界面的设计与构建。我们将选项做成CheckBox的形式,导入文件做成OpenFileDialog的形式,将异常情况提示做成弹窗的形式,将-h和-t参数做成下拉列表选择形式。
-
GUI的实现过程由于采用了Visual Studio作为开发环境,因此界面的搭建过程都是可视化的,下面的截图清晰地展示了这一点。这也是我们选用C#作为开发语言的重要原因。
-
不过,有时也会需要写一些代码,以完成按钮触发动作的功能。例如,在实现”点击按钮选择单词文件“的过程中,我们便用到了下面这段代码:
private void select_file_Click(object sender, EventArgs e) { OpenFileDialog dlg = new OpenFileDialog(); if (dlg.ShowDialog() == DialogResult.OK) inputText.Text = dlg.FileName; }
-
总体而言,由于C#对GUI的天然优秀支持,GUI的设计还是比较顺利的。
十一、界面模块与计算模块的对接
-
前文提到,由于我们的界面模块和GUI模块都采用了C#语言进行开发,因此在GUI调用计算模块接口时,就不必采取C/C++风格的带有字符指针数组参数的接口,而是可以使用更为简捷易用的List<string>类型进行对接。
-
在本次作业中,我们为计算模块设计了
GenerateChain
这个C#风格接口,专门用于和GUI对接。GUI在获取到用户选择的参数后,将参数通过Core
构造函数传递给核心计算模块,然后调用GenerateChain
方法即可计算出最长单词链。 -
对接的过程十分简单,仅需两行代码即可完成。这也展现了我们此次作业设计的优越性。
-
最终,带有GUI的程序整体实现的功能截图如下:
十二、结对过程与照片
- 结对照片如下所示:
十三、结对编程与结对组员的优点与缺点
- 结对编程的优点与缺点
- 优点
- 结对编程让两个人所写的代码不断地处于”复审“的过程,避免牛仔式的编程
- 结对编程的过程是一个互相督促的过程,由于督促的压力,程序员得以更认真地工作
- 结对编程避免了“我的代码”还是“他的代码”的问题,使得代码的责任不再属于某个人,而是属于两个人,进而属于整个团队,这样能够帮助建立集体拥有代码的意识
- 缺点
- 处于探索阶段的项目如果采用结对编程的方式,就会导致研究无法深入、钻研无法继续
- 优点
- 结对组员的优点与缺点
- 我(16061125 周雨飞)
- 优点
- 开发效率高
- 擅长快速学习和使用新的技术
- 代码风格优秀、工程意识较强
- 缺点
- 写代码时不够仔细,有时候会出现一些小Bug
- 优点
- 他(16061145 周国杰)
- 优点
- 技术能力强、算法功底好
- 擅长深入钻研程序的性能部分
- 思想睿智,适合担任团队领导者
- 缺点
- 喜欢装弱
- 优点
- 我(16061125 周雨飞)
十四、PSP表格与实际开发时间
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | --- | --- |
· Estimate | · 估计这个任务需要多少时间 | 1350 | 1440 |
Development | 开发 | --- | --- |
· Analysis | · 需求分析 | 30 | 30 |
· Design Spec | · 生成设计文档 | 60 | 30 |
· Design Review | · 设计复审(和同事审核设计文档) | 30 | 30 |
· Coding Standard | · 代码规范(为目前的开发制定合适的规范) | 30 | 30 |
· Design | · 具体设计 | 120 | 150 |
· Coding | · 具体编码 | 480 | 600 |
· Code Review | · 代码复审 | 120 | 180 |
· Test | · 测试(自我测试,修改代码,提交修改) | 240 | 300 |
Reporting | 报告 | --- | --- |
· Test Report | · 测试报告 | 60 | 15 |
· Size Measurement | · 计算工作量 | 60 | 15 |
· Postmortem & Process Improvement Plan | · 事后总结,并提出过程改进计划 | 120 | 60 |
Total | 合计 | 1350 | 1440 |