项目 | 内容 |
---|---|
这个作业属于哪个课程 | 2023年北航敏捷软件工程社区 |
这个作业的要求在哪里 | 结对项目-最长英语单词链 |
我在这个课程的目标是 | 学习有关软件开发的方法论,熟悉基本的软件开发流程,通过“做中学”提高软件开发的能力 |
这个作业在哪个具体方面帮助我实现目标 | 体验结对编程,提高软件开发的能力 |
〇、 项目地址
- 教学班级:周四班
- 项目地址:https://github.com/Hyggge/2023-SE-wordlist
一、项目设计
1 UML图
2 计算模块接口设计与实现
计算模块的接口设计如下:
int gen_chains_all(char* words[], int len, char* result[]);
int gen_chain_word(char* words[], int len, char* result[], char head, char tail, char except, bool enable_loop);
int gen_chain_char(char* words[], int len, char* result[], char head, char tail, char except, bool enable_loop);
gen_chains_all
函数用于生成所有的单词链,参数words
为输入的单词数组,参数len
为输入的单词数组的长度,参数result
为存放结果单词链的数组,返回值为结果单词链的数量,数组的每个元素为一个单词链,即最终输出的一行。gen_chain_word
函数用于计算最多单词数量的单词链,参数words
为输入的单词数组,参数len
为输入的单词数组的长度,参数result
为存放结果单词链的数组,参数head
为单词链的首字母,若不限制则为'\0'
,参数tail
为单词链的尾字母,若不限制则为'\0'
,参数except
为单词链中所有单词均不允许出现的首字母,若不限制则为\0
,参数enable_loop
为是否允许单词链中的单词形成环,返回值为结果单词链中单词的数量,数组的每个元素为一个单词。gen_chain_char
函数用于计算最多字母数量的单词链,其参数与返回值与gen_chain_word
函数相同。
我们的算法描述如下:
将 26 个字母看成点,将所有的单词看成从首字母到尾字母的边,单词的长度视为边权,那么我们就得到了一个 26 个点的有向图,那么题目中的参数可以这样理解:
-
-n
:即图上所有长度大于等于2的路径。 -
-w
:即图上经过边最多的路径。 -
-c
:即图上经过边的边权之和最大的路径。 -
-h
:即图上从指定点出发的路径。 -
-t
:即图上到达指定点的路径。 -
-j
:删去指定点的出边。 -
-r
:图允许出现长度大于等于 2 的环。
在我们的计算模块中只有一个类,即 Graph
类,它保存了所有边的信息,通过其成员函数进行所有的计算。
Graph
构造函数与可以被调用的函数及其功能如下:
Graph(char* words[], int len, char* result[], char except = '\0')
构造函数,用于建图。hasCircle()
函数,用于返回图中是否存在环。genChainsAll()
函数,用于计算所有的单词链。genChainWordWithCircle(char head, char tail)
函数,用于计算最多单词数量的单词链,允许单词链中的单词形成环。genChainWordWithoutCircle(char head, char tail)
函数,用于计算最多单词数量的单词链,不允许单词链中的单词形成环。genChainCharWithCircle(char head, char tail)
函数,用于计算最多字母数量的单词链,允许单词链中的单词形成环。genChainCharWithoutCircle(char head, char tail)
函数,用于计算最多字母数量的单词链,不允许单词链中的单词形成环。
其内部函数及其功能如下:
void addEdge(int u, int v, int wordId)
函数,用于添加一条边。void toposort()
函数,用于对点进行拓扑排序,同时判断是否存在环。void dfsChainsAll(int cur, bool allowSelfCircle)
函数,用于计算所有的单词链。void dfsChainWordWithCircle(int cur, char tail, std::vector<int>& curChain, std::vector<int>& maxChain, bool* visited)
函数,DFS 最多单词数量的单词链。void dfsChainCharWithCircle(int cur, char tail, std::vector<int>& curChain, std::vector<int>& maxChain, bool* visited, int curCharNum, int& maxCharNum)
函数,DFS 最多字母数量的单词链。
下面以 genChainWord
接口为例,函数调用关系如下(异常处理略):
其中 genChainWordWithCircle
调用 dfsChainWordWithCircle
使用 DFS 搜索最长链,genChainWordWithoutCircle
使用拓扑排序+DP 的方式求解最长链。
3 界面模块设计与实现
3.1 CLI
CLI模块主要由三个部分组成——参数解析器ArgParser,文件解析器FileParser以及控制器Controller。
ArgParser
ArgParser主要负责对用户输入的参数进行解析。ArgParser类的定义如下所示——
class ArgParser {
private:
UserOptions userOptions;
std::string filename;
public:
ArgParser(int argc, char* argv[]);
UserOptions getOptions();
std::string getFilename();
};
ArgParser类的构造函数需要接受两个参数——argc和argv,前者是参数的个数,后者是存储具体参数值的数组(其实就是main函数的参数)。然后,在ArgParser的构造函数中,我们直接对用户输入的内容进行解析,将“用户指定的文件名”存入变量filename
中, 将用户输入的所有有效的命令行选项(也就是-n,-w等等)封装到结构体变量userOptions
中。结构体的定义如下所示
struct UserOptions {
bool n, w, c, r; // 表示用户是否指定了-n, -w, -c, -r 这四个option
char h, t, j; // 存储在对应option下用户指定的字母,如果值为'\0'则表示用户没有使用该选项
};
外部可以调用getOptions
和getFilename
两个函数来获取userOptions
和filename
这两个变量的值。
FileParser
FileParser主要负责对用户指定的文件的内容进行解析。FileParser类的定义的如下所示——
class FileParser {
private:
char* words[WORDS_MAX_NUM] = {nullptr};
int wordsNum = 0;
public:
explicit FileParser(const std::string &filename);
~FileParser();
char** getWords();
int getWordsNum() const;
}
FileParser类的构造函数只需要一个参数——filename,也就是ArgParser解析出来的文件名/路径。在构造函数内部,我们使用fopen打开该文件,将文件包含的所有单词解析出来,存入成员变量words中。此外,我们还需要wordsNum来保存单词总数。外部可以通过调用getWords和getWordsNum获取这两个成员变量的值。
Controller
Controller主要负责调用core模块的API进行计算。Controller类的定义如下所示——
class Controller {
private:
UserOptions userOptions;
char** words;
int len;
char* result[WORDS_MAX_NUM] = {nullptr};
public:
Controller(UserOptions userOptions, char* words[], int len);
~Controller();
void run();
};
Controller构造函数的逻辑很简单,实际上就是将ArgParser解析出来的userOptions以及FileParser解析出来的words和len保存到对象的成员变量中,以便run函数使用。当run函数被调用时,它会跟根据userOptions的内容选择性地调用core模块的API进行计算,并把计算结果返回给用户。
交互逻辑
在main函数中,我们可以很容易看出这三个模块的交互逻辑和工作流程——
int main(int argc, char* argv[]) {
if (argc == 1) printUsage(); // 没有参数时,将命令行选项的用法打印到屏幕上
try {
// ArgParser
ArgParser argParser = ArgParser(argc, argv);
UserOptions userOptions = argParser.getOptions();
// FileParser
FileParser fileParser = FileParser(argParser.getFilename());
char** words = fileParser.getWords();
int len = fileParser.getWordsNum();
// Controller
Controller controller = Controller(userOptions, words, len);
controller.run();
} catch (std::exception &e) {
std::cerr << "\033[31m" << e.what() << "\033[0m" << std::endl;
return 1;
}
return 0;
}
此处有一个小细节——当用户没有指定任何参数时,我们需要将CLI的详细用法反馈给用户, 反馈信息如下所示
3.2 GUI
我们使用Qt作为GUI框架,最终效果如下
设计思路
- 首先使用Qt Designer进行界面设计
- 然后定义一些用于记录用户输入的全局变量,并且为界面组件的事件绑定槽函数。在槽函数中, 我们会根据用户的行为对全局变量进行修改。我们定义了下面几个全局变量——
inputMode
: 用户输入方式,可取FILE_INPUT_MODE
和SCREEN_INPUT_MODE
, 默认为SCREEN_INPUT_MODE
taskType
: 执行任务类型,可取N_TASK
,W_TASK
和C_TASK
, 默认为N_TASK
hOption
: 用户指定的单词链首字母,默认为'\0'
tOption
: 用户指定的单词链尾字母,默认为'\0'
jOption
: 用户指定的不允许出现的单词首字母,默认为'\0'
rOption
: 是否允许文本中隐含环,默认为false
- 最后编写“开始计算”按钮对应的函数,该函数会根据全局变量的值调用core模块的相关API。
用户操作流程
- 首先用户选择“文本输入方式”,两个选项中只能选择一个。
- 当用户选择“文件读取”时,输入框为
disabled
状态,文件选择按钮为enabled
状态。此时,用户需要点击“请选择文件”按钮打开文件选择对话框(dialog),对话框设置只能选择.txt
结尾的文件。 - 当用户选择“屏幕输入”时,输入框为
enabled
状态,文件选择按钮为disabled
状态。此时用户需要在右侧输入框中输入文本,后端不实时检查用户输入的内容。
- 当用户选择“文件读取”时,输入框为
- 然后用户可以选择执行的任务,三个选项中只能选择一个。
- 当用户选择“计算文本中所有单词链”,附加选项为
disabled
状态。 - 当用户选择其他两个选项时,附加选项为
enabled
状态。
- 当用户选择“计算文本中所有单词链”,附加选项为
- 如果用户选择-w和-c两个任务,则可以继续选择“附加选项”。
- 前三个附加选项(即-h, -t, -j)中,用户只能输入一个字符。
- 第四个选项(即-r)是一个下拉框,用户只能选择“是”或者“否”。
- 用户做完前三步后,需要点击“开始计算”按钮启动计算程序。
- 如果用户操作错误或者程序没有算出结果,则会弹出”错误提示消息框“,“导出结果”按钮为
disabled
状态。 - 如果用户操作合法并且程序算出了结果,则弹出”成功提示消息框“,右下方输出框会显示计算结果(和CLI程序正确执行的结果一致)。同时,“导出结果”按钮变为
enabled
状态
- 如果用户操作错误或者程序没有算出结果,则会弹出”错误提示消息框“,“导出结果”按钮为
- **最后,用户可以点击“导出结果”按钮将结果进行导出。**此时,程序会弹出对话框,用户可以选择文件保存的位置。
4 界面模块与计算模块对接
CLI 和 GUI 都使用 Core 的 API 直接与 Core 对接:
int gen_chains_all(char* words[], int len, char* result[]);
int gen_chain_word(char* words[], int len, char* result[], char head, char tail, char except, bool enable_loop);
int gen_chain_char(char* words[], int len, char* result[], char head, char tail, char except, bool enable_loop);
CLI
在 CLI 中,使用了 Controller 类,该类以用户参数和所有单词为输入,负责根据输入的参数检测参数相关的异常,另外调用对应的 API 进行计算并输出计算结果,该类具体负责与 Core 的对接,核心代码如下:
if (userOptions.n) {
resultLen = gen_chains_all(words, len, result);
} else if (userOptions.w) {
resultLen = gen_chain_word(words, len, result, userOptions.h, userOptions.t, userOptions.j, userOptions.r);
} else {
resultLen = gen_chain_char(words, len, result, userOptions.h, userOptions.t, userOptions.j, userOptions.r);
}
GUI
GUI 与计算 API 的对接在函数 Widget::work()
中,该函数负责分析用户输入并调用计算 API,其核心代码如下:
if (taskType == N_TASK) {
resultLen = gen_chains_all(words, wordsNum, result);
} else if (taskType == W_TASK) {
resultLen = gen_chain_word(words, wordsNum, result, hOption, tOption, jOption, rOption);
} else if (taskType == C_TASK) {
resultLen = gen_chain_char(words, wordsNum, result, hOption, tOption, jOption, rOption);
}
5 异常处理设计
我们在CLI、GUI及其Core模块中都进行了异常处理。CLI和GUI主要处理用户输入/操作时出现的异常,Core主要处理计算时出现的异常。
CLI异常处理
在CLI中,我们为每一种异常都单独定义了一个继承自std::exception的异常类,方便区别。这些异常分别在ArgParser,FileParser和Contorller中被抛出,最后在main函数中捕获并输出错误信息。我们定义了以下几种异常
- 参数不兼容异常:当用户同时使用-c, -n和-w,或者使用-n时同时使用-h, -t, -j, -r的时候,抛出该异常
- 缺少功能型参数异常:当-c, -n和-w这三个参数都没有被使用时,抛出该异常
- 参数使用异常:当用户使用-h, -t或者-r时没有接着指定字母,或者指定的是非字母字符的时候,抛出该异常
- 未知指令异常:当用户输入的内容无法被识别,比如输入了
Wordlist.exe buaa
时,抛出该异常 - 参数无法识别异常:当用户输入的参数无法被解析,比如输入了
Wordlist.exe -a
时,抛出该异常 - 文件打开异常:当用户指定的文件无法打开时,抛出该异常
- 无结果异常:当用户指定的任务没有解时,抛出该异常
- 无输入文件异常:当用户没有指定输入文件时,抛出该异常
class OptionIncompatibilityException : public std::exception {};
class MissingFunctionalOptionException : public std::exception {};
class OptionUsageException: public std::exception {};
class UnknownCommandException: public std::exception {};
class BadOptionException: public std::exception {};
class FileOpenException: public std::exception {};
class NoResultException: public std::exception {};
class NoInputFileException: public std::exception {};
GUI异常处理
在GUI中,当我们检测到用户的不合法操作时,随即调用QMessageBox类的API弹出消息框,将错误信息反馈给用户。由于在界面设计时已经通过硬编码指定了某些组件的操作规范,比如三个计算任务只能选择一个、附加选项输入框中只能输入一个字符等等,因此和CLI相比,GUI需要特殊检测的异常数量要少的多,只有以下三种:
- 用户在附加选项输入框中输入了非字母字符
- 用户指定的文件无法打开(包括输入文件和输出文件)
- 用户指定的任务没有解
Core异常处理
为了保证Core模块的可迁移性,我们没有在Core中定义新的异常类型,而是使用C++已经定义好的异常,例如std::invalid_argument
和std::logic_error
。
首先,我们需要对传入Core的API的参数进行合法性检查。以int gen_chain_word(char* words[], int len, char* result[], char head, char tail, char except, bool enable_loop)
为例,我们需要对每一个参数进行检查,检查逻辑和提示信息如下所示
if (words == nullptr) throw std::invalid_argument("Value of words can't be null");
if (len < 0) throw std::invalid_argument("Value of len can't be less than 0");
if (result == nullptr) throw std::invalid_argument("Value of result can't be null");
if (head != '\0' && !isalpha(head)) throw std::invalid_argument("Value of head must be a letter");
if (tail != '\0' && !isalpha(tail)) throw std::invalid_argument("Value of tail must be a letter");
if (except != '\0' && !isalpha(except)) throw std::invalid_argument("Value of except must be a letter");
其次,我们还需要处理三种在计算过程中可能出现的异常:
- ”输入文本出现单词环“的异常
- ”计算结果的长度大于20000“的异常
- “传入单词不为小写”的异常
需要注意的是,Core只是一个单独的模块(dll),最终都是要被CLI和GUI程序调用的。因此在Core中我们只是将异常抛了出去,至于如何捕获异常、如何将异常信息呈现给用户,就是由core的调用者决定的了。
6 模块互换
我们与另一个小组进行了模块互换——
-
吕元秋:20373273
-
龚悦 20373091
dev-combine分支地址:https://github.com/Hyggge/2023-SE-wordlist/tree/dev-combine
6.1 本组CLI和另一组的core对接
对接的问题主要出现在“何时为指针数组result的元素分配空间”上。我们小组是在core.dll内部为result数组中的各个指针分配空间,而另一个小组则是在core.dll外部分配空间。因此,为了能够完成对接,我们需要对CLI做出如下调整:
// 分配空间
static char field[10000][20000];
for (int i = 0; i < 10000; ++i) {
this->result[i] = field[i];
}
// 调用core的API
if (userOptions.n) {
resultLen = gen_chains_all(words, len, result);
} else if (userOptions.w) {
resultLen = gen_chain_word(words, len, result, userOptions.h, userOptions.t, userOptions.j, userOptions.r);
} else {
resultLen = gen_chain_char(words, len, result, userOptions.h, userOptions.t, userOptions.j, userOptions.r);
}
6.2 本组GUI和另一组的core对接
对接GUI时也遇到了同样的问题,做出的修改和上面类似。
// 分配空间
static char field[10000][20000];
char *result[10000] = {nullptr};
for (int i = 0; i < 10000; ++i) {
result[i] = field[i];
}
// 调用core的API
try {
startTime = clock();
if (taskType == N_TASK) {
resultLen = gen_chains_all(words, wordsNum, result);
} else if (taskType == W_TASK) {
resultLen = gen_chain_word(words, wordsNum, result, hOption, tOption, jOption, rOption);
} else if (taskType == C_TASK) {
resultLen = gen_chain_char(words, wordsNum, result, hOption, tOption, jOption, rOption);
}
endTime = clock();
} catch (std::exception &e) {
// ...
}
二、测试
1 编译器无警告截图
我们直接在命令行中对项目进行编译,Windows下的编译命令如下所示:
mkdir build
cd .\build
cmake -G "MinGW Makefiles" ..
mingw32-make.exe
可以看到编译器并没有发出警告——
2 计算模块单元测试
单元测试使用 google test 测试框架,分为 genChainsAllTest、genChainWordTest、genChainCharTest 三部分,分别测试对应 API。
在测试时,我们采用了 Pairwise 测试方法,考虑三种计算 API 、三种字符限制、是否允许环两两之间的作用,我们设计的单元测试如下:
API | -h | -t | -j | -r | 特点 |
---|---|---|---|---|---|
genChainsAll | 有/无自环 | ||||
genChainWord | √ | ||||
genChainWord | √ | ||||
genChainWord | √ | ||||
genChainWord | √ | √ | |||
genChainWord | √ | √ | |||
genChainWord | √ | √ | |||
genChainChar | |||||
genChainChar | √ | 有/无自环,是否以自环结尾 | |||
genChainChar | √ | ||||
genChainChar | √ | ||||
genChainChar | √ | √ | |||
genChainChar | √ | √ | |||
genChainChar | √ | √ |
部分单元测试代码举例如下:
TEST(genChainsAll, genChainsAllTestSelfCircle) {
const char* words[] = {"aa", "ab", "bb", "bc", "cc"};
const char* expected[] = {
"bc cc","ab bc","ab bc cc","bb bc","bb bc cc",
"ab bb","ab bb bc","ab bb bc cc","aa ab","aa ab bc",
"aa ab bc cc","aa ab bb","aa ab bb bc","aa ab bb bc cc"
};
int len = sizeof(words) / sizeof(words[0]);
int expectedLen = sizeof(expected) / sizeof(expected[0]);
char* result[100];
int resultLen = gen_chains_all(words, len, result);
ASSERT_EQ(resultLen, expectedLen);
std::sort(expected, expected + resultLen, my_cmp);
std::sort(result, result + resultLen, my_cmp);
for (int i = 0; i < expectedLen; ++i) {
ASSERT_STREQ(result[i], expected[i]);
}
}
TEST(genChainChar, genChainCharTestHeadCircle) {
const char* words[] = {"element", "heaven", "tot",
"tight", "teach", "talk"};
const char* expected[] = {"tot", "tight", "teach", "heaven",};
int wordsLen = sizeof(words) / sizeof(words[0]);
int expectedLen = sizeof(expected) / sizeof(expected[0]);
char* result[100];
int resultLen = gen_chain_char(words, wordsLen, result, 't', '\0', '\0', true);
ASSERT_EQ(resultLen, expectedLen);
for (int i = 0; i < expectedLen; ++i) {
ASSERT_STREQ(result[i], expected[i]);
}
}
TEST(genChainWord, genChainWordTestTailCircle) {
char* words[] = {"element", "heaven", "tot", "tight", "new",
"teach", "talk", "knight", "tough","not"};
// answer: element tot tight talk knight teach heaven new
// longest: element tot tight talk knight teach heaven not tough
const char* expected[] = {
"element", "tot", "tight", "talk",
"knight", "teach", "heaven", "new"};
int wordsLen = sizeof(words) / sizeof(words[0]);
int expectedLen = sizeof(expected) / sizeof(expected[0]);
char* result[100];
int resultLen = gen_chain_word(words, wordsLen, result, '\0', 'w', '\0', true);
ASSERT_EQ(resultLen, expectedLen);
for (int i = 0; i < expectedLen; ++i) {
ASSERT_STREQ(result[i], expected[i]);
}
}
3 异常处理单元测试
下面我们对Core模块“计算过程中可能出现的异常”进行测试——
-
”输入文本出现单词环“的异常测试
TEST(genChainsAll, genChainsAllTest4) { char* words[] = {"wa", "aba", "aca"}; int len = sizeof(words) / sizeof(words[0]); char* result[100]; try { gen_chains_all(words, len, result); FAIL(); } catch (std::logic_error& e) { ASSERT_STREQ(e.what(), "Circle detected"); } catch (...) { FAIL(); } }
-
“传入单词不为小写”的异常测试
TEST(genChainsAll, genChainsAllTest5) { char* words[] = {"wA", "aBa"}; int len = sizeof(words) / sizeof(words[0]); char* result[100]; try { gen_chains_all(words, len, result); FAIL(); } catch (std::invalid_argument& e) { ASSERT_STREQ(e.what(), "Word must be lower case in core"); } catch (...) { FAIL(); } }
-
”计算结果的长度大于20000“的异常测试
TEST(genChainsAll, genChainsAllTest6) { char* words[] = { "aa", "ab", "bb", "bc", "cc", "cd", "dd", "de", "ee", "ef", "ff", "fg", "gg", "gh", "hh", "hi", "ii", "ij", "jj", "jk", "kk", "kl", "ll", "lm", }; int len = sizeof(words) / sizeof(words[0]); char* result[20010]; try { gen_chains_all(words, len, result); FAIL(); } catch (std::logic_error& e) { ASSERT_STREQ(e.what(), "Length of result exceeds the upper limit(20000)"); } catch (...) { FAIL(); } }
4 测试覆盖率
单元测试覆盖率使用 OpenCppCoverage
工具计算,使用的命令如下:
OpenCppCoverage --sources=D:\SEProj\2023-SE-wordlist\core --excluded_line_regex "\s*else.*" --excluded_line_regex "\s*\}.*" -- D:\SEProj\2023-SE-wordlist\bin\UnitTest.exe
截图如下:
可以看到,我们的测试几乎可以覆盖了全部代码和分支,剩下个别分支只在结果超过 20000 时才会进入。
三、性能分析与优化
1 性能分析
我们采用下面的数据对Wordlist.exe进行性能分析,命令行参数为 -c -r。
# 完全图+自环
ba ab
ca ac cb bc
da ad db bd dc cd
ea ae eb be ec ce ed de
aaa aba aca ada aea
性能分析结果如下图所示。很明显dfs搜索占用了大部分的时间,因此我们需要对这部分代码进行优化。
2 性能优化策略
-
优先走自环。对于两种
dfs
我们的算法会优先走某个点的自环,将其走完后再尝试其它的边。在编写代码时,如果有自环还没走,就直接走该边,走完退出后直接break
退出循环,这样即可保证一定先走自环,自环走完后再走其他的边。 -
对于没有限制开头和结尾字母的情况,如果某个点入度>出度,那么它一定不可能是起始点,因为链的长度一定还能增加至前一个点,同理,如果某个点出度>入度,那么他一定不可能是终止点,因为链一定还能延申至下一个点。可以用这个方法减少 DFS 调用和检查。
四、结对过程
1 过程控制
我们采用notion进行任务管理和文档管理。
2 结对图片
五、总结与反思
1 Information Hiding,Interface Design,Loose Coupling
1.1 Information Hiding
Information Hiding意为信息隐藏,也就是说我们只把外部需要的信息暴露出去,而把那些外部不需要的或者不关心的信息隐藏。我们都知道,OOP有三大特性——封装、继承、多态,而封装的核心就是信息隐藏——我们在设计一个类中,通常会把一些外部可以调用的函数设为 public ,也就是暴露给使用者;而把类的数据和实现细节隐藏在类内部。我认为信息隐藏有以下两个优势:
- 首先,调用者在使用某个类时只希望知道两个东西——“WHAT”(这个类提供了什么功能)和“HOW”(我怎么使用这个类的功能)。而对于类内部是如何实现,调用者其实是不 care 的。信息隐藏可以把一些非常复杂的内部实现细节隐藏起来,只暴露给调用者几个简洁的接口,这样大大降低了不同模块之间的耦合和项目的复杂性。如果出现了新的需求,类可以只修改内部的实现方式,而保持接口不变,大大提高了项目的可维护性和可扩展性。
- 其次,“程序员之间是不可以相互信任的”。当你写好一个模块后,很难预料外部使用者会对该模块进行多么奇葩的操作。但是如果使用信息隐藏的方法,把内部实现细节和数据隐藏起来,则可以很轻松的防止用户对类进行各种不合理操作,既保护了数据隐私,也提高了安全性。
我们在设计core模块的时候也使用了信息隐藏的思想:该模块只把gen_chains_all
,gen_chain_word
,gen_chain_char
这三个接口暴露给CLI和GUI,并且约束好了输入和输出。而内部的具体实现对CLI和GUI来说是透明的,它们也无需考虑。
1.2 Interface Design
当我们把模块内部的实现细节隐藏之后,还需要设计API接口,让调用者知道该如何使用模块的功能。我认为接口的设计应该遵循两个原则——
- **首先是要方便易用。**API的设计应该简洁,其命名需要能够体现出其功能,而且也不应该有太多太复杂的参数,以方便调用者使用。
- **其次是要科学高效。**每个API的职责应该尽量单一,内部实现应该高度内聚。如果多个功能耦合在一起,则当一个功能发生变化时,整个API的实现都要进行调整,其他功能势必会受到影响,这会极大降低程序的可复用性和可维护性。
在实现core模块时,我们遇到了这样一个问题——对于get_chain_word
和get_chain_char
两个接口,用户传入的数据可能隐含环,而对于有环和无环两种情况,我们需要用两个不同的算法进行解决。显然,在API中同时实现这两套算法是违背“单一职责原则”的。因此,我们在Core的下层又设计一个graph类,由graph类提供实现不同算法的接口,例如getChainWordWithCircle
和getChainWordWithoutCircle
等等。此外,graph类还提供判断是否有环的接口hasCircle
,上层的core只需要很据hasCircle
的结果选择性调用不同算法对应的API即可。
1.3 Loose Coupling
Loose Coupling意为松耦合。模块间的耦合度是指模块之间的依赖关系,包括控制关系、调用关系、数据传递关系。模块间联系越多,其耦合性越强,同时表明其独立性越差。因此,在软件设计中,我们经常会根据“高内聚,低耦合”的标准划分模块,提高模块的独立性。实际上,上面所述的“Information Hiding”和“Interface Design”也是实现“高内聚低耦合”的方式。
在本次作业中,我们将计算部分的代码都封装到了core模块中,而CLI和GUI主要负责和用户的交互。它们之间的接口仅仅是core提供的三个API,符合松耦合的要求。
2 Design by Contract,Code Contract
2.1 Design by Contract
Design by Contract意为“契约式编程”,是一种设计软件的方法。它规定软件设计者应该为软件组件定义形式化的、精确的和可验证的接口规范,它扩展了带有前置条件、后置条件和不变量的抽象数据类型的普通定义。
在本次作业中,我们提前约束好了core中3个API和graph中5个API,以及这些API的前置条件、后置条件和不变量,然后再进行具体的设计和编程。同时,在开发CLI和GUI模块时,也严格按照契约对core的API进行调用。关于契约的具体内容,我们使用专门的文档进行描述,保证在开发时不会出现二义性。
2.2 Code Contract
Code Contract时微软开发的一款代码契约插件。该插件使用一种与语言无关的方式来表达契约,契约包括前置条件、后置条件和不变量。利用已经定义好的契约,Code Contract可以帮助我们完成运行时检查、静态契约验证和文档自动生成。在本次作业中,我们仅仅借鉴了其思想,并没有真正使用该插件。
3 PSP
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 90 | 120 |
· Estimate | · 估计这个任务需要多少时间 | 90 | 120 |
Development | 开发 | 1440 | 1770 |
· Analysis | · 需求分析 (包括学习新技术) | 60 | 120 |
· Design Spec | · 生成设计文档 | 120 | 150 |
· Design Review | · 设计复审 (和同事审核设计文档) | 30 | 60 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 30 | 30 |
· Design | · 具体设计 | 120 | 150 |
· Coding | · 具体编码 | 600 | 720 |
· Code Review | · 代码复审 | 240 | 300 |
· Test | · 测试(自我测试,修改代码,提交修改) | 240 | 240 |
Reporting | 报告 | 330 | 390 |
· Test Report | · 测试报告 | 60 | 60 |
· Size Measurement | · 计算工作量 | 30 | 30 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 240 | 300 |
Total | 合计 | 1860 | 2190 |
4 结对反思
4.1 结对编程优缺点
经过了两周的结对编程体验,我觉得结对编程有如下优点:
- 结对编程时,码字者写出的代码随时都会得到同伴的复审,可以提早发现很多因为粗心造成的bug
- 结对编程时两个人不断沟通,可以互相学习,取长补短,提高各自的能力。
- 结对编程提升专注力,自己写代码容易走神开小差,而当有人在旁边监督时,必须时时刻刻保持专注,也就提高了开发效率。
但同时,我认为结对编程有以下缺点:
- 结对编程比较适合两个人都比较熟悉,或者其中一个人比较熟悉的项目。如果两个人都不知道该怎么写,那么一起研究不如各自独立调研,最后再一块交流调研的结果,这样效率会更高。
4.2 成员优缺点
温佳昊:
- 优点
- 有优秀的算法功底
- 性能优化经验较多
- 搜寻资料的能力比较强
- 缺点
- 写代码缺少详细构思容易出低级错误
陈正昊:
- 优点
- 对C++比较熟悉
- 擅长编写设计文档
- 比较注意编码规范和代码风格
- 缺点
- 不太擅长算法设计
总的来说,结对编程确实提高了项目开发和测试的效率,也让我们在对方身上学到了很多东西,可以说是收获满满啦!