「软工结对编程」:最长英语单词链
项目 | 内容 |
---|---|
这个作业属于哪个课程 | 2023年北航敏捷软件工程社区 |
这个作业的要求在哪里 | 结对项目-最长英语单词链 |
我在这个课程的目标是 | 学习有关软件开发的方法论,熟悉基本的软件开发流程,通过“做中学”提高软件开发的能力 |
这个作业在哪个具体方面帮助我实现目标 | 体验结对编程,提高软件开发的能力 |
〇、 项目地址
- 教学班级:周四班
- 项目地址: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 意为“信息隐藏”,即只将需要对外提供服务的信息设计为函数接口,将核心内容,尤其是数据参数等信息隐藏起来不允许外部直接访问。这有点类似于面向对象思想中封装的概念。对于复杂的系统,通过封装,可以将整个系统分为互相有一定独立性的多个部分,也就是多个类。在类的内部,在设计时只需要考虑该类需要完成的任务,包括该类需要保存的数据和需要执行的逻辑,这也就是该类隐藏的信息;各类之间通过接口相互使用服务,设计时只需要考虑各类的协作,无需考虑类内部如何实现,接口就是外部间接获得该类隐藏的信息的途径。这既有利于简化逻辑,使程序更清晰;也更加安全,防止外部的非法访问。
对于我们的项目,我们也采用了 Information Hiding 的思路,首先是 core.dll 的设计,该模块只将三个计算 API 暴露在外,对于所有的具体的计算逻辑全都封装在了内部,且使用 private
进行保护,对于使用者,他们只能看到三个 API,无需考虑内部的具体实现。
除此之外,我们还将 Graph 封装起来,其内部保存了一个有向图和很多图上的信息,但是这个有向图从外部无法访问,其只提供了一些图论算法接口,我觉得这与信息隐藏的思想也是一致的。
1.2 Interface Design
Interface Disign 意为“接口设计”,我认为这包括两个方面。
一方面类似于信息隐藏,接口设计指将程序划分为多个部分后,使用事先约定的接口让各个模块相互连接,协同工作。这样做将接口与实现分离,提高了程序的可维护性和可扩展性。
在我们的程序中,我们通过接口使界面部分与计算部分解耦,结构清晰,也方便测试。如果需要添加功能只需约定新的 API 即可。
另一方面,我觉得接口设计也包括了如何设计科学高效的接口,通过了解相关知识和从本项目中获得的经验,我总结了以下一些设计思想或设计原则:
- 接口的职责应该尽量单一。在我们之前的设计中,
hasCircle
方法不仅返回图是否存在环,还负责拓扑排序获取拓扑序,结果在之后因为不需要判断是否存在环结构没有获取拓扑序导致错误,后来我们将拓扑排序单独提取出来,hasCircle
方法只返回一个结果。从中不难看出一个接口的功能应该简洁、明确。 - 接口要高内聚。对于一些外部不需要的接口,应该将他们使用
private
隔离起来。同时接口应该专注于单一任务,避免太过复杂的接口。
1.3 Loose Coupling
Loose Coupling 意为“松耦合”,我们理解松耦合既是信息隐藏和接口设计的目的,也是他们的必然结果。信息隐藏和接口设计的目的都是通过将整个任务分成很多部分来减少模块间交互的复杂逻辑,最终的结果也是所谓“高内聚低耦合”,复杂的逻辑隐藏在了模块内部,模块之间通过简洁明确的接口来交互,自然也就达到了“松耦合”的要求。
另外,松耦合也意味着减少点对点的交互,而是使用中间层来进行统筹,从而简化部分逻辑。
对于我们的项目,我们将高内聚的计算模块设计为一个 core.dll,同时加入中间层 core.cpp 处理环的情况并应用 Graph.cpp 中的不同算法,从而减少了界面模块与计算模块的耦合,同时也减少了 Graph 内部的点对点逻辑,使得 Graph 内部各个任务的计算更加独立。
2 Design by Contract,Code Contract
2.1 Design by Contract
契约式编程是一种基于契约的编程方法,其核心思想是通过明确的契约规定来保证代码的正确性和可靠性。在契约编程中,通过带有前置条件、后置条件和不变量的抽象数据类型的普通定义,开发人员需要清楚地定义代码的每个部分的预期行为,以及当它不符合预期行为时要采取的行动。
优点
- 提高代码质量:契约指定了代码的预期行为和约束,这可以在开发过程中自动化检查,以避免常见的错误。
- 提高可维护性:契约规定了代码的输入和输出,使得修改和维护代码更加容易和安全。
- 增强代码健壮性:契约规定在运行时检查代码的正确性,从而增强代码健壮性。
缺点
- 需要额外的代码:为每个部分编写规范需要额外的时间和精力。
- 代码性能可能会受到影响:由于需要执行额外的代码,契约式编程可能会增加代码的运行时间和内存占用。
我们在结对过程中事先确定了各个接口的规格,将其用专门的文档进行描述,开发时严格按照契约进行处理,同时在函数中使用断言等方式验证契约的规定的正确性,从而提高了代码的可靠性。
2.2 Code Contract
Code Contract 时微软开发的一款代码契约插件。改插件可以定义前置条件、后置条件、不变量等契约,来方便地进行契约化编程地开发,Code Contract 可以自动生成相关代码,在运行时检查契约是否得到了遵守。
在本次作业中,我们仅仅借鉴了其思想,并没有真正使用该插件。
3 PSP
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 120 | 120 |
· Estimate | · 估计这个任务需要多少时间 | 120 | 120 |
Development | 开发 | 1560 | 1770 |
· Analysis | · 需求分析 (包括学习新技术) | 180 | 120 |
· Design Spec | · 生成设计文档 | 120 | 150 |
· Design Review | · 设计复审 (和同事审核设计文档) | 90 | 60 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 30 | 30 |
· Design | · 具体设计 | 120 | 150 |
· Coding | · 具体编码 | 480 | 720 |
· Code Review | · 代码复审 | 240 | 300 |
· Test | · 测试(自我测试,修改代码,提交修改) | 300 | 240 |
Reporting | 报告 | 270 | 390 |
· Test Report | · 测试报告 | 60 | 60 |
· Size Measurement | · 计算工作量 | 30 | 30 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 180 | 300 |
Total | 合计 | 1950 | 2190 |
4 结对反思
4.1 结对编程优缺点
经过了两周的结对编程体验,我觉得结对编程有如下优点:
- 结对编程时,旁边有人在同时检查你的代码编写是否正确,当局者迷旁观者清,一些低级失误更容易被检查者发现,起到了代码复审的作用。
- 结对编程提升专注力,自己写代码容易走神开小差,但是两个人编程的时候必须跟上另一个人的思路,必须时时刻刻保持专注,也就提高了开发效率从。
- 结对编程促进了沟通,两个人知识背景不同,通过结对可以取长补短,双方分享自己的见解,提升互相的能力。
- 由于讨论的存在,每个人对项目的理解都更清晰,因为需要给对方解释清楚或者听对方解释。
但同时,我也觉得有以下缺点:
个人感觉结对编程适合比较成熟的项目,对于一些还需要研究的项目,两个人一起调研不如各自学习,或者一个人学会了给另一个人分享,我们在使用 OpenCppCoverage 的时候遇到了一点困难,两个人一起研究了很长时间,进度受到比较大的阻滞,个人感觉不如让一个人完全学会效率更高。
4.2 成员优缺点
温佳昊:
优点
- 对算法有一定了解
- 有一定性能优化的经验
- 搜寻资料的能力比较强
缺点
- 写代码缺少详细构思容易出低级错误
陈正昊:
优点
- 对C++比较熟悉
- 擅长编写设计文档
- 比较注意编码规范和代码风格
缺点
- 不太擅长算法设计
总的来讲,结对编程确实提高了编程的效率和我们对项目的理解,也提升了写出的代码的质量,还是很有意义的。