(对对碰)软工结对作业
最长单词链问题
1、项目github链接
2、计划(梦想)中的PSP时间分配和实际(现实)的PSP时间分配
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
Planning | 计划 | 60 | 80 |
· Estimate | · 估计这个任务需要多少时间 | 14天 | 12天 |
Development | 开发 | 9天 | 7天 |
· Analysis | · 需求分析 (包括学习新技术) | 0.5天 | 1天 |
· Design Spec | · 生成设计文档 | 120 | 150 |
· Design Review | · 设计复审 (和同事审核设计文档) | 60 | 60 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 30 | 50 |
· Design | · 具体设计 | 1天 | 1天 |
· Coding | · 具体编码 | 5天 | 5天 |
· Code Review | · 代码复审 | 1.5天 | 1天 |
· Test | · 测试(自我测试,修改代码,提交修改) | 1.5天 | 1天 |
Reporting | 报告 | 1天 | 1天 |
· Test Report | · 测试报告 | 2小时 | 4小时 |
· Size Measurement | · 计算工作量 | 1小时 | 0.5小时 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 0.5天 | 0.5天 |
合计 | 10天 | 12天 |
3、看教科书和其它资料中关于Information Hiding, Interface Design, Loose Coupling的章节,说明你们在结对编程中是如何利用这些方法对接口进行设计的
-
信息隐藏
我们的设计在多个层面做了很好的封装,很好的保证了各个模块间的信息隐藏性,将搜索算法逻辑封装在具体实现内部,向外给出统一的公告接口,将计算逻辑封装在calculate这一个接口函数中。 -
接口设计
接口设计上首先是一个全局对外接口calculate,我们详细分析了实际需求的本质,设计了一个通用接口来满足各种各样的需求,在数据结构封装上我们提供了数据访问和修改专用的接口,规范数据结构的使用,防止出现隐藏bug。没有使用任何全局变量,只使用了一些全局宏来输出log进行debug。对于可能出现的各类优化算法,我们设计了公共的接口类,规范了统一的算法接口。 -
松耦合
在计算逻辑和IO之间松耦合,计算逻辑和具体的IO方式无关,设置了规范的数据类型对象和错误信息提示对象,很好的完成了松耦合。
4、计算模块接口的设计与实现过程
我们在设计计算模块时使用了自定义的对外接口,接口形式如下
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图
6、计算模块接口部分的性能改进
- 计算模块的性能上,经过visual studio 2017自带的性能探查器分析,我们得到了如下的效能分析结果:
- 可以发现,效能瓶颈主要存在于ChainSearch方法中,new一个对象时分配内存进行初始化,占用了大量cpu资源。另外在Search和LookUp方法中大量的搜索等操作也是效能瓶颈之一。
- 同样的,在CheckCircle方法中,变量的声明也占用了大量cpu资源。说明为了提升计算模块的性能,我们需要在这几个方面加以改进。
- 需要注意的是,上述效能分析使用的数据规模并不大,所以我们猜测随着数据规模的增大,DFS方法的cpu使用率应该会显著增加,所以我们又进行了大数据规模(约9000词)的测试,结果如下:
- 可以发现,当数据规模增加,暴力DFS的时间成本和cpu使用率大幅增加,由此我们可以得到结论,在数据规模小的时候,需要减少变量声明初始化以及内存分配的相关代码。而当数据规模很大时,则需要从算法出发减小复杂度,提升性能。
- 此外, 从算法角度来说,可以使用更好的剪枝策略,比如只使用入度为0的点和环上的点进行搜索,或者采用启发式搜索策略
7、看Design by Contract, Code Contract的内容,描述这些做法的优缺点, 说明你是如何把它们融入结对作业中的
契约编程 Design by Contract
这种编程方式的特点是严格规定前置条件,后置条件,不变项,比如大二oo课程中就有类似的训练(JSF),这样做的好处是严格限制了输入输出条件,函数可能产生的副作用,从而可以很好的规范程序接口,同时,基于这种规定,可以更好地进行单元测试,覆盖到每一个函数和方法的具体细节,使得程序的正确性得到了更大的保证, 但是与之相对的,就需要开发者付出大量的时间和精力,做非常详尽的测试,对于工期很紧的项目可能无法实际操作。我认为在时间中,可以选择部分绝对不能出现问题的核心模块,使用这种方式进行开发,对于比较边缘的模块,并不需要做这么多,从而让开发兼顾开发效率和正确性。
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);
}
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、结对编程的优缺点
优点:
- 可以两个人交替负责开发和设计,能够有很多讨论问题的机会
- 开局自带code reviewer,对于代码质量有非常大的提升
- 可以互相给对方的code写测试,这种开发和测试并行的方式效率很高
缺点 - 需要两个人有一定的公共技术栈,否则相差太远很多思考方式或者编码习惯上的问题会阻碍效率
- 有可能会产生矛盾,甚至是1+1<1
14、界面模块,测试模块和核心模块的松耦合(附加题)
- 这个部分我们与 申化文 16231247和肖萌威 16061030两位同学交换了GUI模块和计算模块,但是由于是最后一天晚上才交换,之前没有约定公共的接口格式,导致在输入输出数据转化时需要额外的时间开销,我们原本设计的接口接受的输入是整个字符串,包括了单词的分割处理。然而对方的处理方式是输入已经分割完成的单词数组,输出也是单词数组,最终我们未能在截止时间前完成调用对方GUI的工作。但是我们的计算模块成功在他人的GUI跑通,这说明我们的计算模块是兼容性比较高的。这次经历告诉我们:一定要尽早约定公共接口设计,同时更要尽早完成工作不要压线赶DDL。