软件工程第2次作业 | 结对项目-最长单词链
项目 | 内容 |
---|---|
本次作业所属课程 | 北航2019软件工程 |
本次作业要求 | 要求详情 |
我在本课程的目标 | 提升工程化思想与能力 |
本次作业的帮助 | 初次体验结对编程,了解开发流程 |
1、Github项目地址
https://github.com/PaParaZz1/longest-word-chain
2、PSP表格
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 20 | 15 |
· Estimate | · 估计这个任务需要多少时间 | 20 | 15 |
Development | 开发 | 2050 | 2955 |
· Analysis | · 需求分析 (包括学习新技术) | 240 | 360 |
· Design Spec | · 生成设计文档 | 25 | 20 |
· Design Review | · 设计复审 (和同事审核设计文档) | 15 | 15 |
··Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 10 | 10 |
· Design | · 具体设计 | 60 | 90 |
· Coding | · 具体编码 | 1440 | 1800 |
· Code Review | · 代码复审 | 20 | 180 |
· Test | · 测试(自我测试,修改代码,提交修改) | 240 | 480 |
Reporting | 报告 | 80 | 110 |
· Test Report | · 测试报告 | 60 | 90 |
· Size Measurement | · 计算工作量 | 10 | 10 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 10 | 10 |
合计 | 2150 | 3080 |
3、Information Hiding, Interface Design, Loose Coupling
- 在接口设计中,我们首要在结构在进行设计,要求各个方法之间降低依赖,针对不同的参数选择,选择不同的算法,调用不同的函数,而非通过接口进入一个大函数再进行判断。这样的设计方法条理更加清晰,并且在对方法增删修改时更加方便,拿本次工程为例即主计算模块仅仅暴露出接口,无论是控制台程序或是用户交互界面,都可以通过输入调用指定方法进行运算,以保证“finishing the task at hand without drawing unnecessary attention to itself.”。隐藏了计算的内部逻辑,增强了计算模块的可移植性和适用性,确保“each of its components has, or makes use of, little or no knowledge of the definitions of other separate components”。
4、计算模块接口的设计与实现过程
我们在设计计算模块时使用了自定义的对外接口,接口形式如下
c++
se_errcode Calculate(const string& input_text, string& output_text, LongestWordChainType& longest_type, const char& head, const char& tail, bool enable_circle, WordChainError& handle_error);
对于该接口的说明如下:
- 我们将整个运算逻辑抽象在一个接口内,不同的“最长”计算方式使用枚举类型
LongestWordChainType
进行描述,head和tail表示单词链的首尾字母,enable_circle表示是否允许输入中含有环,这样所以的需求选项都可以被集中在一个接口中,代码的复用性非常高,且很简洁。 - 我们将输入输出抽象为string,具体的IO逻辑可能涉及读取文件,与gui协同,输入输出格式等问题,这一部分相对于计算逻辑十分独立,且较容易发生需求上的变化,故独立在计算模块之外,给计算模块的一律整理成string形式,并用非字母符号来分割单词,完成了计算和IO的解耦,同时string对象不需要手动维护
char*
数组,增加了程序的鲁棒性。 - 对于异常处理,一般c++编程中是不使用异常的,因为其会对运行效率带来巨大的影响,一旦抛出异常整个程序的运行时间将大大增加,所以在程序逻辑上,我们使用自定义的错误码返回值来完成逻辑上的处理,同时建立专门的资源和错误信息管理对象handle_error,负责管理资源和错误信息记录
关于计算逻辑的实现,我们认为这里主要的两部分是数据结构和算法,接下来我们会对这两方面分开进行说明。
数据结构方面:
- 我们将该问题抽象为一个有向图,该图中的结点是26个字母,一个单词便可以表示为从首字母到尾字母的一条边,由问题的特性我们知道,这张图中是有自圈(例如awa),多重边(例如awwb, awb),简单的使用邻接矩阵,邻接表是很难应对这种数据的。我们抽象了新的“边”元素,使用
WordMapElement
类去描述它,对于首字母和尾字母相同的边,存储在这一个元素之中,并按照单词含有的字母数量降序进行排列存储。而首字母,尾字母根据问题去创建结点,使用特殊的“边”元素,构建出我们所用的数据结构。 - 在具体实现上,我们使用
unordered_map<char, unordered_map<char, WordMapElement> >
这样的c++容器结构去存储,避免了定长数组带来的维护性差,可扩展性差的问题,同时又具有直接根据key-value来访问元素的方便性。 - 在搜索时,我们需要存储当前搜到的最长单词链的相关信息,我们仿照上文中类似的模式建立类似的数据结构。
算法方面:
- 由于该问题本身是一个NP-Complete问题,所以会有很多的剪枝优化和启发式搜索算法,那么从设计框架角度,我们需要一个可扩展性很高的搜索框架,而不是把算法耦合在整体逻辑中,所以,我们设计一个通用的搜索接口
SearchInterface
,定义了公共的接口方法Search
和LookUp
,任何搜索算法只要继承接口类重写这两个方法即可,其内部的算法优化逻辑将封装在方法内部,与外部的逻辑无关,这样可以很方便的添加优化算法,同时保证架构设计的完整性。
另一独特之处:
- 在作业文档给出的接口中,对于单词数最长和字母数最长,分别设计了两个接口,但是我们认为本质上来讲,这只是两种不同的“长度”度量而已,从实现来说只有计算长度时,每条边对应的长度不同这一点差异,所以我们的设计将其统一在一起,如果未来有新的需求,比如说“其中含字母a的个数最多”,只需要添加新的长度计算方式即可,而不用改动整体逻辑。
5、UML图
- 使用visual studio自带的插件生成的uml图如下:
6、计算模块接口部分的性能改进
- 计算模块的性能上,经过visual studio 2017自带的性能探查器分析,我们得到了如下的效能分析结果:
- 可以发现,效能瓶颈主要存在于ChainSearch方法中,new一个对象时分配内存进行初始化,占用了大量cpu资源。另外在Search和LookUp方法中大量的搜索等操作也是效能瓶颈之一。
- 同样的,在CheckCircle方法中,变量的声明也占用了大量cpu资源。说明为了提升计算模块的性能,我们需要在这几个方面加以改进。
- 需要注意的是,上述效能分析使用的数据规模并不大,所以我们猜测随着数据规模的增大,DFS方法的cpu使用率应该会显著增加,所以我们又进行了大数据规模(约9000词)的测试,结果如下:
- 可以发现,当数据规模增加,暴力DFS的时间成本和cpu使用率大幅增加,由此我们可以得到结论,在数据规模小的时候,需要减少变量声明初始化以及内存分配的相关代码。而当数据规模很大时,则需要从算法出发减小复杂度,提升性能。
7、Design by Contract, Code Contract
Design by contract (DbC), also known as contract programming, programming by contract and design-by-contract programming, is an approach for designing software. It prescribes that software designers should define formal, precise and verifiable interface specifications for software components, which extend the ordinary definition of abstract data types with preconditions, postconditions and invariants. These specifications are referred to as "contracts", in accordance with a conceptual metaphor with the conditions and obligations of business contracts.
引用自wikipedia
- 所谓Design by Contract, 简单来说就是在结对编程的过程中,我们需要同伴间制定一个规范,主要针对类以及类的方法,防止在执行代码前后发生我们不期待的结果,其中就要求我们明确执行前对传入参数的要求,执行中什么是不能改变的量,执行后期待返回什么结果,这样做的优点是对于代码的编写者和使调用者提出要求,编写者需要保证代码运行过程中能够输出正确的结果,调用者需要保证提供正确无误的参数,双方都有必须履行的义务,也有使用的权利,这样就保证了双方代码的质量,提高了软件工程的效率和质量。缺点在于这无形间提高了对编码素质的要求,增加了编写时间,并且契约式编程对程序语言有一定的要求,在我们的项目中,明确了函数接口的时候,确定了哪些量是不能变的,那些量是需要返回的,并且在单元测试中使用Assert断言进行了验证。
8、计算模块部分单元测试展示
- 计算模块部分的单元测试,我们的测试思路是从基本类开始测试,然后测试基本类的方法,接着测试使用到这个类的方法,由小到大以保证单元测试的正确性。
- 处理I/O的函数之一ExtractWord(),测试数据构造思路在于构造出由不同的字符分割的单词,包括没有单词的情况,将分离结果与预设结果逐个对拍。
TEST_METHOD(Test_ExtractWord)
{
//TEST ExtractWord
WordChainError error1;
string input_text1 = "_this is a!@#$test of0extract!word...... ";
vector<string> input_buffer1;
vector<string> result1 = { "this","is","a","test","of","extract","word" };
ExtractWord(input_text1, input_buffer1,error1);
Assert::AreEqual(result1.size(), input_buffer1.size());
for (int i = 0; i < result1.size(); i++) {
Assert::AreEqual(result1[i], input_buffer1[i]);
}
WordChainError error2;
string input_text2 = "_this___is___another======test of extract[][][]word. ";
vector<string> input_buffer2;
vector<string> result2 = { "this","is","another","test","of","extract","word" };
ExtractWord(input_text2, input_buffer2,error2);
Assert::AreEqual(result2.size(), input_buffer2.size());
for (int i = 0; i < result2.size(); i++) {
Assert::AreEqual(result2[i], input_buffer2[i]);
}
WordChainError error3;
string input_text3 = "_[][][]....";
vector<string> input_buffer3;
vector<string> result3 = { };
ExtractWord(input_text3, input_buffer3,error3);
Assert::AreEqual(result3.size(), input_buffer3.size());
for (int i = 0; i < result3.size(); i++) {
Assert::AreEqual(result3[i], input_buffer3[i]);
}
}
- Word类的基本单元测试,主要验证其构建方法和其内部方法的正确性,构造数据包括单个字母的情况和多个字母的情况。
TEST_METHOD(Test_Class_Word)
{
//TEST Class_Word
Word test1 = Word("a");
Assert::AreEqual(test1.GetHead(), 'a');
Assert::AreEqual(test1.GetTail(), 'a');
Assert::AreEqual(test1.GetWord(), string("a"));
Assert::AreEqual(test1.GetKey(), string("aa"));
Word test2 = Word("phycho");
Assert::AreEqual(test2.GetHead(), 'p');
Assert::AreEqual(test2.GetTail(), 'o');
Assert::AreEqual(test2.GetWord(), string("phycho"));
Assert::AreEqual(test2.GetKey(), string("po"));
}
- DistanceElement类内部方法的单元测试,构造数据中验证对其操作先后顺序的影响是否满足需求,以及基本方法的正确性验证。
TEST_METHOD(Test_Class_DistanceElement_Method)
{
//TEST Class_DistanceElement_Method: SetDistance/GetDistance/SetWordChain/CopyWordBuffer/ToString
LongestWordChainType type1 = letter_longest;
DistanceElement testElement1 = DistanceElement(type1);
Assert::AreEqual(testElement1.GetDistance(), 0);
vector<string> input1 = { "a","test","of","it" };
vector<string> output1;
testElement1.SetWordChain(input1);
testElement1.CopyWordBuffer(output1);
for (int i = 0; i < input1.size(); i++) {
Assert::AreEqual(output1[i], input1[i]);
}
testElement1.SetDistance(6);
Assert::AreEqual(testElement1.GetDistance(), 6);
Assert::AreEqual(testElement1.ToString(), string("a-test-of-it"));
LongestWordChainType type2 = word_longest;
DistanceElement testElement2 = DistanceElement(type2);
Assert::AreEqual(testElement2.GetDistance(), 0);
vector<string> input2 = { "another","test","of","it" };
vector<string> output2;
testElement2.SetWordChain(input2);
testElement2.CopyWordBuffer(output2);
for (int i = 0; i < input2.size(); i++) {
Assert::AreEqual(output2[i], input2[i]);
}
testElement2.SetDistance(2);
Assert::AreEqual(testElement2.GetDistance(), 2);
Assert::AreEqual(testElement2.ToString(), string("another-test-of-it"));
}
- 将某个方法内部的方法全部验证过后,就可以对调用其的方法进行单元测试,下面展示的是计算模块的整体调用,返回值为计算结果,数据构造上考虑到了是否有环,是否有头尾字母的要求等,将计算方法返回结果与正确结果比对。
TEST_METHOD(Test_Calculate)
{
//Test Calculate: include CalculateLongestChain/ChainSearch
WordChainError error;
string input_text ="Algebra))Apple 123Zoo Elephant Under Fox_Dog-Moon Leaf`;;Trick Pseudopseudohypoparathyroidism";
string output_text1 = "";
LongestWordChainType type1 = word_longest;
Calculate(input_text, output_text1, type1, NO_ASSIGN_HEAD, NO_ASSIGN_TAIL, false,error);
string result1 = "algebra\napple\nelephant\ntrick\n";
Assert::AreEqual(result1, output_text1);
string output_text2 = "";
LongestWordChainType type2 = letter_longest;
Calculate(input_text, output_text2, type2, NO_ASSIGN_HEAD, NO_ASSIGN_TAIL, false,error);
string result2 = "pseudopseudohypoparathyroidism\nmoon\n";
Assert::AreEqual(result2, output_text2);
string input_text_ring = "Algebra))Apple aaaaa 123Zoo Elephant Under Fox_Dog-Moon Leaf`;;Trick Pseudopseudohypoparathyroidism";
string output_text3 = "";
string result3 = "algebra\naaaaa\napple\nelephant\ntrick\n";
LongestWordChainType type3 = word_longest;
Calculate(input_text_ring, output_text3, type3, NO_ASSIGN_HEAD, NO_ASSIGN_TAIL, true, error);
Assert::AreEqual(result3, output_text3);
}
- 有关覆盖率,我们时间紧张导致没能够完全弄明白coveragec插件的使用方法,但是我们的测试是从基本类开始一步一步往外测试,每一种情况都有相应的测试用例,我们有信心自己达到了90%以上的覆盖率,如果作业有需求,我们可以再去重新测试,请相信本次只是对于插件的不熟悉,绝对没有忽视单元测试的重要性。
9、计算模块部分异常处理说明
-
异常设计上,我们设计了7种异常
- 重复单词异常
- 环异常
- 输入文件异常
- 输出文件异常
- 命令行参数异常
- 计算模式异常
- 无结果异常
-
其中重复单词异常会在命令行输出,但是不会影响程序的进行,计算模式异常和命令行参数异常均为在对命令行进行解析时发生的异常,我们并没有单独为其写一个方法,所以难以在单元测试中验证,仅会在命令行输出错误信息。
-
其余四种异常均在单元测试中进行了验证。
-
输入文件异常,对应找不到输入文件的场景等:
std::ifstream in("notexist.txt");
std::stringstream buffer1;
WordChainError error3;
if (!in.is_open()) {
char buffer1[MAX_BUFFER_SIZE];
sprintf(buffer1, "Error Type: can't open input file\n");
string error_content(buffer1);
int error_code = SE_ERROR_OPENING_INPUT_FILE;
error3.AppendInfo(error_code, error_content);
}
string errortext3 = error3.ToString();
Assert::AreEqual(errortext3, string("Error Type: can't open input file\nError Content: Error Type: can't open input file\n"));
- 输出文件异常,对应输出文件被意外关闭的场景等:
std::stringstream buffer2;
std::ofstream out("close.txt");
WordChainError error4;
out.close();
if (!out.is_open()) {
char buffer2[MAX_BUFFER_SIZE];
sprintf(buffer2, "Error Type: can't open output file\n");
string error_content(buffer2);
int error_code = SE_ERROR_OPENING_OUTPUT_FILE;
error4.AppendInfo(error_code, error_content);
}
string errortext4 = error4.ToString();
Assert::AreEqual(errortext4, string("Error Type: can't open output file\nError Content: Error Type: can't open output file\n"));
- 环异常,对应未选择-r选项但是输入单词可成环的场景:
WordChainError error1;
string input_text1 = "Algebra))Apple aaaaa 123Zoo Elephant Under Fox_Dog-Moon Leaf`;;Trick Pseudopseudohypoparathyroidism";
string output_text1 = "";
string errortext1;
LongestWordChainType type1 = word_longest;
Calculate(input_text1, output_text1, type1, NO_ASSIGN_HEAD, NO_ASSIGN_TAIL,false, error1);
errortext1 = error1.ToString();
Assert::AreEqual(errortext1,string("Error Type: input has circle but not enable circle\nError Content: Error Type: input has circle but not enable circle\n"));
- 无结果异常,对应根据选择的计算模式和头尾字母要求,没有对应结果的场景:
WordChainError error2;
string input_text2 = "Algebra Zoo";
string output_text2 = "";
string errortext2;
LongestWordChainType type2 = word_longest;
Calculate(input_text2, output_text2, type2, 'i', NO_ASSIGN_TAIL, false, error2);
errortext2 = error2.ToString();
Assert::AreEqual(errortext2, string("Error Type: no available word chain\nError Content: no available word chain for head(i) and tail(0)\n"));
10、界面模块的详细设计过程
-
界面模块我们使用了Qt的库进行了设计。编码上仍然是c++语言,ui设计上使用了Qt Creator进行设计。
-
首先进行界面组件需求分析:
- 两种导入文本的方式
- 交互式按钮,分别是五个功能选项
- 异常情况界面提示
- 正确结果界面显示
- 导出结果,保存到文件
- 使用说明
-
以上的需求可以大概表明我们的用户界面需要至少五个参数选择的交互按钮,两个界面,其中一个负责写入文本,一个负责显示正确结果和错误信息。另外需要四个按钮,分别对应导入文本,运行程序,导出结果和显示使用说明。
-
明确了以上需求之后,我们在Qt Creator中设计了大概的用户界面(macOS下):
-
其中使用radiobutton选择两种计算方式,checkbox选择是否允许单词环,下拉框选择是否有开头和结尾字母的要求,这些设计都是为了方便用户的使用。
-
以下为部分用户界面的代码:
//按钮触发事件(引入文件以及显示帮助信息)
void MainWindow::on_pushButton_import_clicked()
{
QString fileName=QFileDialog::getOpenFileName(this,tr("Choose File"),"",tr("text(*.txt)"));
QFile file(fileName);
if(!file.open(QFile::ReadOnly|QFile::Text)){
QString errMsg="error when import file";
ui->outputArea->setText(errMsg);
return;
}
QTextStream in(&file);
ui->inputArea->clear();
ui->inputArea->setText(in.readAll());
}
void MainWindow::on_pushButton_help_clicked()
{
dialog = new Dialog(this);
dialog->setModal(false);
QString helpMsg="test help";
dialog->ui->textBrowser->setPlainText(helpMsg);
dialog->show();
}
11、界面模块与计算模块的对接
- 模块对接方面,主要是通过接口函数(作业要求中的Core)进行计算,其中各个参数的值是通过界面模块的控件传入的,例如radiobutton控制的值为-w选项或-c选项,checkbox传入单词环的布尔值,combobox传入是否有-h,-t选项以及对应的字符,输入框传入文本或者从文件读入的内容,界面模块如下(window下)。
- 对接的过程主要体现在运行程序按钮上,我们为其绑定了事件调用core的对应函数,即Calculate(content, output,type,head,tail,ring),代码如下:
void MainWindow::on_pushButton_run_clicked()
{
int para=ui->radioButton_w->isChecked()?1:2;
bool ring=ui->checkBox_loop->isChecked();
string content = ui->inputArea->toPlainText().toStdString();
char head, tail;
if (ui->comboBox_h->currentIndex() == 0) {
head = '\0';
}
else {
head = 'a' + ui->comboBox_h->currentIndex() - 1;
}
if (ui->comboBox_t->currentIndex() == 0) {
tail = '\0';
}
else {
tail = 'a' + ui->comboBox_t->currentIndex() - 1;
}
if(content.size()==0){
QString errMsg="empty input!";
ui->outputArea->setPlainText(errMsg);
}
else{
//call corresponding function
string output;
LongestWordChainType type;
se_errcode code;
QString s = "fin";
WordChainError error;
if (para == 1) {
type = word_longest;
code=Calculate(content, output,type,head,tail,ring,error);
}
else {
type = letter_longest;
code=Calculate(content, output, type, head, tail, ring,error);
}
if (code == SE_OK) {
QString result = QString::fromStdString(output);
ui->outputArea->setPlainText(result);
}
else {
string result = error.ToString();
QString error= QString::fromStdString(result);
ui->outputArea->setPlainText(error);
}
}
//cout<<"onclick_run"<<endl;
}
功能运行结果如下:
- 导入文件:
- 参数选择1:
- 运行结果1:
- 参数选择2及运行结果:
- 导出结果:
- 错误提示:
12、结对的过程
13、结对编程以及个人的优点和缺点
-
结对编程的优点在于首先在代码质量上,取决于水平较高的那一位,并且两个人一起工作,可以提高各自的效率和工作能力,实力较差的那一方还可以从另一方学习到新的知识和技能,明确编码规范,以提升自己的工程化思想。分工合作同时也能够让每个人专注于自己的职责。并且在编码过程中,两个人会产生更多的思维碰撞和交流,有利于项目的推进和维护。结对编程的缺点在于由于代码是两个人各自写的,所以在风格和方法上会有差异,每个人的理解上也会存在偏差,需要及时的交流。另外倘若其中一方不能保证足够的合作时间,无疑会拖慢项目的进度。
-
结对过程中我同伴的优点是:1、代码十分规范,风格良好,使我学到了很多。2、在项目开始的时候很快的明确了数据类型和接口定义,为项目推进打好了基础。3、编写代码效率很高,并且还能及时解答我不了解或不熟悉的代码问题。缺点由于同伴平常很忙,是在一起编程的时间比较少。我的优点在于:1、由于新建工程,单元测试,gui方面都是我主要负责,碰到了很多配环境,引入文件的问题,遇到困难能够快速的查询解决方法并且解决。2、按时完成要求的开发任务和测试任务。3、努力学习了命名规范和代码风格,争取统一。缺点在于编码能力不强,对c++语言还是不熟悉,需要很认真的阅读代码和查询资料才能看明白每个函数的用处。
14、界面模块,测试模块和核心模块的松耦合
- 这个部分我们与 申化文 16231247和肖萌威 16061030两位同学进行了交换,非常可惜的是,由于我们是自行设计的接口,GUI也调用的是自己的函数,未能在最后封装后修改完全部代码。我们原本设计的接口接受的输入是整个字符串,包括了单词的分割处理。然而Core.dll中的输入是已经分割完成的单词数组,输出也是单词数组。而我们的输出是一个字符串,修改的过程无疑给我们增加了很大难度,没能够按时完成。但是我们的Core.dll成功在他人的GUI跑通,这也给我们一个提醒,要今早完成要求工作,才有足够的时间测试和调试。
15、自我反思
- 由于自己的问题没能够完成耦合的,非常对不起我的搭档,在接下来的工程中,我会对自己要求更加严格,多与团队成员进行沟通,明确代码规范。非常感谢本次结对作业,伙伴对我的包容和指教!