项目 | 内容 |
---|---|
这个作业属于哪个课程 | 2023 年北航软件工程 |
这个作业的要求在哪里 | 结对项目-最长英语单词链 |
教学班级 | 周四下午班 |
项目地址 | 项目地址 |
我在这个课程的目标是 | 了解软件工程的方法论、获得软件项目开发的实践经验、构建一个具有我的气息的艺术品 |
这个作业在哪个具体方面帮助我实现目标 | 对于结对编程有了初步认识,对于很多工程理念有了一些的体会 |
P S P 2.1 \tt{PSP2.1} PSP2.1 | P e r s o n a l S o f t w a r e P r o c e s s S t a g e s \tt{Personal\space Software\space Process\space Stages} Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 60 | |
Estimate | 估计这个任务需要多少时间 | 60 | |
Development | 开发 | 1680 | |
Analysis | 需求分析 (包括学习新技术) | 240 | |
Design Spec | 生成设计文档 | 60 | |
Design Review | 设计复审 (和同事审核设计文档) | 30 | |
Coding Standard | 代码规范 (为目前的开发制定合适的规范) | 30 | |
Design | 具体设计 | 120 | |
Coding | 具体编码 | 720 | |
Code Review | 代码复审 | 300 | |
Test | 测试(自我测试,修改代码,提交修改) | 180 | |
Reporting | 报告 | 390 | |
Test Report | 测试报告 | 120 | |
Size Measurement | 计算工作量 | 30 | |
Postmortem & Process Improvement Plan | 事后总结, 并提出过程改进计划 | 240 | |
合计 | 2130 |
“信息隐藏“一般和“封装”概念相似,它指的是像模块外隐藏模块内部的实现信息,只提供稳定的接口,这样当模块内部实现细节发生改变的时候,并不会影响使用这个模块的其他模块,同样的,其他模块的修改并不会破坏此模块的功能和结构。
这个概念我们当做了设计接口的基本准则,分为以下两点:
这道题的数学模型是一个有向无环有权图上的算法,我们考虑到“图”的实现其实并不需要向上层暴露,所以可以完全掩盖图的信息。同时,为了让接口足够稳定,我们不仅考虑了我们组对于接口的使用,我们还考虑了与我们交换组对于接口的使用,最后通过协商确定了接口。
Interface design 主要面向用户,强调给用户一个方便使用的接口。本次作业中我们设计了 GUI 图形界面,方便用户更直观的使用程序。
松耦合是紧耦合的对立面。实现松耦合可以降低组件之间的关联性,将其相互影响降到最低。对于松耦合和紧耦合的对比,有如下表:
可以看到松耦合的一个重要特点就是在原本需要紧密通信的两个双方,引入一个新的抽象层,这样原本需要“同步,点对点,不具有独立性”的通信就会变成“异步,通过媒介,具有独立性”的通信。这种设计思路出现在计算机网络,web 应用开发等多种设计中。
在我们的设计中,我们并没有由 main
直接调用 parse
和 core
模块,而是在 main
和 core
之间加设了一层 control
层,用于沟通 main
和 core
,这样增加了程序的可移植性(在 GUI 和交换模块的时候更加方便)。
但是不可否认,松耦合会增加开发时的工作量,并不是一味的松耦合就可以将设计推到极致。在我们的开发中,尝试将不同需求的 API 采用完全不同的函数实现(尽管有一些是相似的),目的是为了在细微处调整不同算法的优化,但是实践证明,这大大增加了开发的工作量,而且收益很小。
对于这道题,我们采用的模型是图模型,我们建立了一个具有 26 个节点的图,分别用 int
表示,0 ~ 25
节点分别对应字母 a ~ z
。单词是图上的边,单词的首字母和尾字母分别指示了这个边的起点和终点,比如说 banana
这个单词的起点就是 b
,终点是 a
,单词的长度是这条边的权,也就是如下图的模型(局部)
那么寻找“单词链”的过程就类似于寻找路径的问题。
需要注意的是,这是一个复杂图,即可能存在两个或者两个以上的边的起点和终点相同,比如说 banana
和 ba
就会造成这种现象,同样的,这个图中有可能存在自环的,比如说 aba
。
文档中提出的多种需求,可以被分为两类,一个是求解所有的路径,一个是求解最长的路径。
对于求解所有的路径,唯一的方法是进行 DFS,因为求解所有的路径是一种枚举,DFS 也是一种枚举策略。
在进行 DFS 的时候,需要利用一个 visit
数组,这个数组用来避免重复路径的出现,考虑到这个图是一个复杂图,所以没有办法像比较常见的做法,记录一个对于点的 visit
数组(因为可以重复遍历一个点),根据题目要求,我们应当记录一个对于边的 visit
数组。按照这种逻辑,此时的复杂度与边的个数相关,经过不严谨的推断,如果边的个数为 n n n ,那么此时的算法复杂度大致是 O ( n ! ) O(n!) O(n!) 。
这种 DFS 的思路,无论是对于成环的还是不成环的,都是适用的,因为 visit
记录的对象是以边为单位的。
对于求解最长的路径,最简单的思路是首先枚举所有的路径,然后挑出最长的一条路径来,此时的算法复杂度是与第一个问题保持一致的。但是考虑到第二个问题的输入数据的规模是要大于第一个问题的,所以用这种无脑 DFS 的方式是没有办法满足所有的要求的。所以可以对这个算法进行一定程度的优化。这个问题可以被描述为“在图上寻找最长路径”的问题。
首先对于无环的情况,是可以先对图上的点进行拓扑排序,然后按照拓扑序进行动态规划,计算最长路径,这种方法是建立在“最长路径的子路径同样是最长子路径”的基础上的,是符合动态规划特征的。为了输出答案,我们只需要再维护一个一维的数组用于还原方案即可。此时的时间复杂度是 O ( n ) O(n) O(n) 。
对于有环的情况,会发现没有办法使用“拓扑排序 + 动态规划”的思路,这是因为拓扑排序要求图是无环的。针对这种情况,我们可以考虑首先利用 tarjan
算法求解出 scc
,然后对于“顶层图”,也就是每个节点都是一个 scc
的图,进行“拓扑 + 动态规划”的思路,而对于 scc
内部,只能再次使用 DFS 算法。
可是当一个 scc
过大的时候,采用 DFS 依然会导致时间复杂度飙升,这里可以考虑记忆化搜索,利用一个 int128
去记录路径中已经出现的边,并且再记录一个搜索的起点,这两项会确定唯一一个“最长路径”,然后将其记录下来,即可完成记忆化搜索,如下图所示:
map<pair<int, pair<long, long>>, int> remember;
但是在我们实现的时候,并没有实现完整的这个算法,这是因为这种有环图中的路径还原我们之前采用的同样是 DFS,我们的数据结构在进行这种记忆化搜索的还原时,支持性不太强,最终由于时间原因,没有能够实现。
对于题目中对于“指定头结点,尾节点”的限制行为,可以通过在正常的算法开始之前限定起始节点或者在算法运行结束之后挑选终止节点的方法解决。对于禁止某个头结点的行为
由上面的分析可以看出,计算核心的数据结构应当采用图结构,所以我们建立了一个类 Graph
去作为数据结构的主体,其内部主要维护了一个邻接链表来记录图结构,之所以采用这种形式,是因为在其上实现的算法经常有查看某个特定定点的所有出边的操作。
Graph
上实现了 tarjan(), topoSort(), deleteEdge(), compressGraph()
等多种与图联系比较紧密的算法。
关于边的结构,实现了一个 Edge
类,用于记录边的起点和终点,权。同时为了方便 C 风格的算法实现(我个人感觉很多算法描述起来,确实是用 C 风格而不是 python 风格要更加自然一些),我们引入了 index
这个 int
量,表示对于 Edge
的唯一标识。
在介绍了面向对象的部分后,介绍我们面向过程的部分,我们的算法主体是面向过程的,一共分为三个 API,表示三种需求
/**
* 统计所有的单词链
* @param words 单词数组
* @param wordsLen 单词数组大小
* @param result 结果数组,每一个元素是一个单词链
* @return 单词链的个数
*/
int countChains(char *words[], int wordsLen, char *result[]);
/**
* 以单词形式统计最长的单词链
* @param words 单词数组
* @param wordsLen 单词数组大小
* @param result 结果数组
* @param head 指定首字母,如果指定,则范围为 'a' ~ 'z',若没有指定则为 0
* @param tail 指定尾字母,如果指定,则范围为 'a' ~ 'z',若没有指定则为 0
* @param ban 指定禁用的首字母,如果指定,则范围为 'a' ~ 'z',若没有指定则为 0
* @param allowLoop 是否允许环
* @return 最长的单词链的长度
*/
int getLongestWordChain(char *words[], int wordsLen, char *result[], char head, char tail, char ban, bool allowLoop);
/**
* 以字符形式统计最长的单词链
* @param words 单词数组
* @param wordsLen 单词数组大小
* @param result 结果数组
* @param head 指定首字母,如果指定,则范围为 'a' ~ 'z',若没有指定则为 0
* @param tail 指定尾字母,如果指定,则范围为 'a' ~ 'z',若没有指定则为 0
* @param ban 指定禁用的首字母,如果指定,则范围为 'a' ~ 'z',若没有指定则为 0
* @param allowLoop 是否允许环
* @return 最长的单词链的长度
*/
int getLongestCharChain(char *words[], int wordsLen, char *result[], char head, char tail, char ban, bool allowLoop);
函数调用关系如下:
对于 countChains
:
对于 getLongestWordChain()
对于 getLongestCharChain()
可以看到 getLongestWordChain()
和 getLongestCharChain()
的调用关系基本一致,这是因为在设计的时候为了优化进行的代码冗余性提升。
为了在 clion
中显示警告信息,我们需要在 CMakeList.txt
中这样调整信息:
set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wextra")
然后运行时就会将警告信息显示在 clion
的控制台中,最终完全没有警告的截图如下:
总的 UML 如图所示
对于局部图,有如下所示
MyException
Edge
Graph
我们利用 gperf
进行性能分析,并利用 pprof
进行可视化转换。
在对 -n
功能进行性能分析的时候,我们发现了一个现象:
明明只是简单的保存发现的链,居然会占如此多的性能,所以我们研究了这个函数,注意到他实际上进行了两遍复制(在生成一条单词链的时候进行了一遍,在将生成单词链复制到 result
中又进行了一遍),所以我们修改了这个函数,将其改为只需要复制一遍,如下所示:
void saveChain(int &chainCount, char *result[], vector<char *> &chain) {
int resultLen = 0;
for (const auto &word: chain) {
resultLen += (int) strlen(word) + 1;
}
char *resultChain = (char *) malloc(resultLen);
int i = 0;
for (const auto &word: chain) {
int len = (int) strlen(word);
for (int j = 0; j < len; j++) {
resultChain[i++] = word[j];
}
resultChain[i++] = ' ';
}
resultChain[resultLen - 1] = '\0';
result[chainCount++] = resultChain;
}
修改后的性能图,发现此方法性能有一个明显提高:
性能消耗最大的函数是这个
void WordChain::Loop::dfsSccDistance(Graph *scc, int start, int cur, int step,
bool edgeVisit[], int vertexVisit[], int sccDistance[][MAX_VERTEX]) {
vertexVisit[cur]++;
// 遍历当前节点的每一条非自环边
for (auto &edge: scc->getVertexEdges()[cur]) {
int target = edge->getTarget();
if (!edgeVisit[edge->getIndex()]) {
edgeVisit[edge->getIndex()] = true;
// 这里记录自环情况,如果已经发生过一次自环计算了,那么就不能发生第二次了
int targetWeight = (vertexVisit[target] > 0) ? 0 : scc->getVertexWeight(target);
sccDistance[start][target] = std::max(sccDistance[start][target], step + 1 + targetWeight);
dfsSccDistance(scc, start, target, step + 1 + targetWeight, edgeVisit, vertexVisit, sccDistance);
edgeVisit[edge->getIndex()] = false;
}
}
vertexVisit[cur]--;
}
考虑到 scc 内的 DFS 是不相关的,所以考虑对于这个部分进行并行,openMp
是一个多线程工具,我们如果想要使用它,需要首先修改 CMakeList.txt
文件
find_package(OpenMP REQUIRED)
# 在链接之前先判断是否已经搜索到openmp
if(OpenMP_FOUND)
target_link_libraries(${PROJECT_NAME} OpenMP::OpenMP_CXX)
else()
message(FATAL_ERROR "openmp not found!")
endif()
然后在需要并行的代码里,利用编译指令来开启并行,如下所示:
#pragma omp parallel for schedule(dynamic)
for (int j = 0; j < ALPHA_SIZE; j++) {
// 如果该节点属于某个颜色
if (rawGraph->getSccIndex()[j] == i) {
bool *edgeVisit = new bool[MAX_EDGE];
memset(edgeVisit, 0, sizeof(bool) * MAX_EDGE);
int vertexVisit[MAX_VERTEX] = {0};
// 进行 dfs
dfsSccDistance(rawGraph->getSccs()[i], j, j, 0, edgeVisit, vertexVisit, sccDistance);
delete[] edgeVisit;
}
}
对于边界区域,也同样可以利用 critical
指出,来保证正确性:
#pragma omp critical
{
sccDistance[start][target] = std::max(sccDistance[start][target], step + edgeWeight + targetWeight);
}
最终效果确实实现了并行,我们可以在算法进行过程中打印输出语句来检验,如下所示,可以发现语句呈现乱序状态,正是由于线程切换造成的:
同时从桌面的性能检测工具可以看出,并行化的程序确实极致压榨了 CPU 的性能(成功让我的电脑在 30 分钟内电量归零):
我们进行这个优化是希望可以处理一个 scc 中有多条边的时候,可以更快的进行处理,在理论上可以优化 12 倍(因为我的电脑是 12 核的),但是我们发现,即使是这样,我们也没有办法跑通一个较为复杂的 scc 图,这是因为这种优化其实是一种 O ( 1 ) O(1) O(1) 形式的优化,并没有办法大幅度提高效率。所以非常遗憾。
但是看到自己将上个学期“并行计算”中学到的知识用在了软工里,还是非常开心的!
契约式设计的主要目的是希望程序员能够在设计程序时明确地规定一个模块单元(具体到面向对象,就是一个类的实例)在调用某个操作前后应当属于何种状态。
其实我们之前在操作系统课程里是接触过契约编程的,在需要填空的代码段前,有的会用注释标注 preCondition, postConditon
之类的字样,大致可以用下图解释:
我们对于图中黄色的部分并不陌生,无论是面向过程的还是面向对象的,只要是大型的编程,大多都是以“模块化”的形式进行的,只不过契约编程要求我们在编程前明确的指出“前置条件,副作用,后置条件”等信息,来辅助模块之间的对接。在理论上看,利用这种“编程的契约”,可以大大地降低模块间对接过程中可能会发生的失误,同时还能降低因为模块化导致的效率冗余现象。
但是非常的不幸,就如同现实社会中,拟定契约是一件非常痛苦的事情,以契约的思想去设计和实现项目,同样是一件非常痛苦的事情,我们在开始的时候尝试使用契约编程的思想,希望厘清每个模块间的界限,但是事实就是,我们也不确定有多少个模块,当然这个并不能怪别人,是我们的算法水平并不支持我们对于项目的整体架构做出完整的预测,做到胸有成竹。而且拟定契约应该还需要以某种文本载体的形式存在,如果只是口头契约,那么就如同现实中的“口头借条”一样难以发挥效用,这也是我们在实践中吸取的教训。
在实践中,我们出现了一些因为契约不清楚,而导致的实现失误,比如说在处理重复单词的时候,我们没有确定清楚这个功能的位置,毕竟它有可能在读入单词后立刻处理,也可以在进行计算前处理,所以在这里出现了一点点小的失误。但是在其他地方还是很正常的。就我个人感觉,契约编程更适合与分工开发,毕竟书写不同模块的人需要依靠接口对接,显然指明接口的诸多特性,有助于更好的分工,但是考虑到“结对编程”其实是“一个人的开发”,所以过于书面化的“契约编程”,我个人觉得是有一些画蛇添足的。这个想法我觉得也是和“结对编程是敏捷编程的一种方法”的表述有一些贴合,毕竟“契约”听上去就不像一个可以让开发敏捷起来的事情。
单元测试采用了google的gtest测试框架,主要由手工样例测试构成,力求测试到所有参数和所有异常情况,保证代码覆盖率,以getLongestCharChain
接口为例
void testgetLongestCharChain(char *words[], int len, char *ans[], int ans_len, char head, char tail,
char ban, bool allowLoop) {
char **result = (char **) malloc(10000);
int out_len = 0;
ASSERT_TRUE(ans_len == out_len);
std::sort(result, result + out_len, [](char *
p, char *q) { return strcmp(p, q) < 0; });
std::sort(ans, ans + ans_len, [](char *
p, char *q) { return strcmp(p, q) < 0; });
//
for (int i = 0; i < ans_len; i++) {
if (result != nullptr) ASSERT_TRUE(strcmp(ans[i], result[i]) == 0);
}
}
对应的测试样例如下
TEST(getLongestCharChain, singleWordNoLoop) {
char *words[] = {"aaa"};
char *ans[] = {};
testgetLongestCharChain(words, 1, ans, 0, 'a', 'a', 0, false);
}
TEST(getLongestCharChain, singleWord) {
char *words[] = {"aaa"};
char *ans[] = {};
testgetLongestCharChain(words, 1, ans, 0, 'a', 'a', 0, true);
}
TEST(getLongestCharChain, rparam) {
char *words[] = {"element", "heaven", "table", "teach", "talk"};
char *ans[] = {"table", "element", "teach", "heaven"};
testgetLongestCharChain(words, 5, ans, 4, 0, 0, 0, true);
}
TEST(getLongestCharChain, cparam) {
char *words[] = {"element", "heaven", "teach", "talk"};
char *ans[] = {"element", "teach", "heaven"};
testgetLongestCharChain(words, 4, ans, 3, 0, 0, 0, false);
}
TEST(getLongestChariChain, hparam) {
char *words[] = {"algebra", "apple", "zoo", "elephant", "under", "fox", "dog", "moon", "leaf", "trick",
"pseudopseudohypoparathyroidism"};
char *ans[] = {"pseudopseudohypoparathyroidism", "moon"};
testgetLongestCharChain(words, 11, ans, 2, 'p', 'n', 0, false);
}
TEST(getLongestCharChain, hparamtParamLoop) {
char *words[] = {"algebra", "apple", "zoo", "elephant", "under", "fox", "dog", "moon", "leaf", "trick",
"pseudopseudohypoparathyroidism"};
char *ans[] = {"pseudopseudohypoparathyroidism", "moon"};
testgetLongestCharChain(words, 11, ans, 2, 'p', 'n', 0, true);
}
TEST(getLongestCharChain, hpramTparamLoopNew) {
char *words[] = {"algebra", "apple", "zoo", "elephant", "under", "fox", "dog", "moon", "leaf", "trick",
"pseudopseudohypoparathyroidism"};
char *ans[] = {"pseudopseudohypoparathyroidism", "moon"};
testgetLongestCharChain(words, 11, ans, 2, 'p', 'n', 0, false);
}
单元测试覆盖率:
core.cpp和graph.cpp两个计算核心的文件代码行数覆盖率和分支覆盖率都在90%以上
我们采用了 CPP 的异常处理机制进行异常处理。我们定义了自己的异常,这个异常继承自 std::runtime_error
同时实现了自己的构造器方法,并且重写了 what()
方法用于打印异常信息,其实现如下
class MyException : std::runtime_error {
public:
ErrorType errorType;
explicit MyException(ErrorType errorType);
const char *what() const noexcept override;
};
MyException::MyException(ErrorType errorType) :
runtime_error(errorMap.find(errorType)->second), errorType(errorType)
{
}
const char *MyException::what() const noexcept
{
return errorMap.find(errorType)->second.c_str();
}
我们定义了枚举变量 ErrorType
来实现异常的多样性,具体如下所示
enum ErrorType {
FILE_NOT_FIND = 1,
MULTI_FILE_PATH,
PARAMETER_NOT_EXISTS,
NO_FILE_PATH,
NO_CHAR_ERROR,
CHAR_FORM_ERROR,
ALLOC_MEMORY_ERROR,
MULTI_WORK_ERROR,
NO_WORK_ERROR,
FIRST_CHAR_DUPLICATE,
ENABLE_LOOP_DUPLICATE,
N_WORK_WITH_OTHER_PARAMETER,
WORD_NOT_AVAILABLE,
HAVE_LOOP,
TOO_MANY_CHAINS
};
const static std::unordered_map<ErrorType, std::string> errorMap = {
{MULTI_FILE_PATH, "指定了多个文件路径,请仅指定单一路径!"},
{PARAMETER_NOT_EXISTS, "参数不存在,请重新输入!"},
{MULTI_WORK_ERROR, "指定了多个任务,请仅指定一个任务!"},
{NO_CHAR_ERROR, "指定首尾字母时忘记字母参数!"},
{CHAR_FORM_ERROR, "指定字母时格式不正确!只允许指定大小写字母!"},
{FIRST_CHAR_DUPLICATE, "重复指定首字母!"},
{ENABLE_LOOP_DUPLICATE, "重复指定有环参数!"},
{NO_FILE_PATH, "参数中不存在文件路径!"},
{N_WORK_WITH_OTHER_PARAMETER, "-n 参数不支持和其他参数共同使用!"},
{NO_WORK_ERROR, "未指定任务,请指定一个任务!"},
{WORD_NOT_AVAILABLE, "不存在符合要求的单词链"},
{FILE_NOT_FIND, "输入文件没有找到"},
{HAVE_LOOP, "无环图中有环"},
{TOO_MANY_CHAINS, "单词链过多"}
};
最后我们在 control
方法中对于抛出的异常进行捕获,并将 what()
中的信息打印到标准错误流中:
try {
//....
}
catch (MyException &e) {
cerr << e.what();
}
指在输入命令行命令的时候,输入了多个输入文件,就会抛出这个异常,如
./wordList input1.txt input2.txt
输入的参数不是题目给定的,就会抛出这个异常,比如说
./wordList input.txt -f
尽管这个程序有多个参数,但是有些参数是冲突的,比如说同时指定求解单词链的个数和最长单词链,这时就会抛出这个异常,比如说:
./wordList input.txt -n -w
在指定 -h, -t, -j
参数的时候,没有指定对应的字符,这时就会抛出这个异常,比如说:
./wordList input.txt -n -j
在指定 -h, -t, -j
参数的时候,指定的字符不是字母,这时就会抛出这个异常,比如说:
./wordList input.txt -n -j 0
在指定 -h, -t, -j
参数的时候,指定的字符存在多个,这时就会抛出这个异常,比如说:
./wordList input.txt -n -j a -j a
在重复指定 -r
参数后,就会抛出这个异常
./wordList -r input.txt -r
当没有指定输入文件的时候,会抛出这个异常
./wordList -r
-n
参数不支持和其他参数共同使用,如果同时使用,就会抛出这个异常:
./wordList -r input.txt -n
没有指定任务的时候,会抛出这个异常
./wordList input.txt
不存在符合要求的单词链时,会抛出这个异常
输入命令:
./wordList input.txt -j a -n
input.txt
中的内容为
ab
bc
cd
输入文件没有找到,即不存在指定的输入文件
./wordList notExisitInput.txt -w
无环图中有环。
输入命令:
./wordList input.txt -n
input.txt
中的内容为
ab
ba
GUI采用Qt编写,Qt是一种cpp框架,所以没有接口转换的问题,使用的IDE是Qt Creator,非常方便,界面设计只需要用鼠标点,查询事件可以通过跳转槽完成。
GUI会在用户界面收集所有参数,然后通过core.h引入函数签名调用core中的接口完成查询任务,通过在跳转槽中捕获异常可以在gui得到关于异常的提示
查询最长单词链:
查询最长字母链:
查询所有单词链:
文件导出功能:
异常提示:
时间提示:
我们选择在新主楼进行结对,结对的时间是 3 月的 6,7,8,9,10,11,12,15 日。基本上只要上完课了就会去进行结对。以下是我们结对的图片:
我们基本上就按照《构建之法》中提到的结对编程的方法,一个人敲代码,一个人领航,然后基本上过两个小时交换一次,大致一天可以交换两到三次。
驾驶员负责具体的代码书写,驾驶员负责查阅相关资料和指导实现。
这个项目被我们分割成了 6 个部分:
指令解析是我们进行的第一个模块,因为第一次结对很尴尬,所以我们立刻就开始了。就一个人写,一个人看着,所幸这个地方并不太难。领航员时刻盯着说明文档,避免出现没有考虑到的错误,然后驾驶员专注于按照领航员的说法进行正确的解析和对于异常情况的处理。
在文件读入模块时,领航员考虑利用休息时间整理出一套完整的实现算法,这是因为单词的读入要实现“分词”和“去重”,同时要考虑文件读入的性能。所以领航员实现了一套基于状态机的算法,可以简洁优雅地完成“分词”功能,但是发现驾驶员在心中也已经有了自己的打算,更加侧重于文件读写优化,加上敲代码的过程不是听领航员讲课的过程,所以驾驶员在没有完全弄清楚领航员的设计前,就基本已经完成了代码。这个现象我们会在下一节讨论的。
在计算核心部分,我们明白任务的算法成分很高,就约定休息一天进行调研,然后在进行结对编程,但是我本人突然发现在一天之中没法很好的完成调研,因为我很紧张,很害怕和结对队友沟通的时候让他发现我有一些地方不懂,然后就很焦虑,而且遇到不会的地方,总是想要不然跳过吧,反正他说不定也会看的。然后开始结对的时候发现他看的算法的效率有些低,所以有改成实现我调研的算法,但是此时我俩都并不透彻的了解,所以硬着头皮开始实践,因为我对于算法比较熟悉,所以由我来担任驾驶员来编写代码,由另一位同学担任领航员,但是此时的领航员就只有代码复核的工作了,就没有办法在算法实现层面指导驾驶员了,同时驾驶员需要写更多的注释来保证领航员的复核工作的顺利进行。这一天是体验最差的一天。可能是由于这一天的较差体验,我们两个人又都熬夜肝了算法实现,然后在第二天的时候终于实现了比较好的配合,驾驶员可以按照领航员的指示实现代码,同时领航员还可以紧紧地复核代码,可以说是配合地非常默契了。但是因为算法确实极为复杂(对我们两个算法能力不是那么强的人来说),所以这个算法我们足足实现了三天,在后期的时候虽然配合默契,但是频繁的结对,对于自由生活的向往得不到实现(简直就像结了婚一样(bushi)),后期还是有一点小烦躁的。
在 CLI 输出部分,因为在计算模块调试的时候,基本上也需要利用输出功能进行调试,所以我们只稍微将模块解耦了一下,就完成了这个部分。
对于 GUI 部分,因为我的电脑是 manjaro,不知道为啥按照 Qt 的时候总会报异常,所以我们最终是在我的队友的电脑上实现的,这个时候我就更像是不了解原理的废物了,所以由我进行代码审核,而队友担任驾驶员。
在测试部分,我们一起构造了样例,并且进行了比较流畅的结对编程,体验比较好。
我个人觉得结对编程的体验还是非常正面的,基本上在教材中描述的效果,都是已经达到了的,比如说
auto
的就不显式地指明类型,该用引用的就用引用,该区分成不同文件的就拆成不同文件。可以说我们的源码质量有了很高的提升。但是不可否认的,结对编程也是有一定缺点的:
总结一下,相比于往届学长博客对于结对编程的态度,我的态度要更加肯定一些,只是感觉因为课时安排的问题,非常遗憾没有完全体会到结对编程的威力。另外我突然想到,《Ruby 元编程》的行文思路就是一个经验丰富的工程师带着一个比较年轻的工程师去进行 Ruby 的开发,也可以说是一种“结对编程”了,而且他们充分发挥了教材中“帮带”的好处(这种好处在我们的实践中是没有的),这是我非常遗憾没有体会的。
我个人觉得最大的原因是选题,我们的选题是一个非常难的算法题目和工程题目,其算法的难度高到甚至我现在也没有办法实现它的完全体,而其工程特性包括了它需要结合单元测试测试,发布,模块解耦,GUI 开发,协作,API 设计等诸多特性,这些特性个人光是学习,就已经很困难了,更不要说还要应用新的工程形式——结对编程了。反观《Ruby 元编程》中的示例,年轻工程师虽然对于 Ruby 的特性不太熟悉,但是他对于要开发的项目是很熟悉的,无论是数据库管理还是 web 框架,他都是清楚要干啥的,他只是不清楚如何利用 Ruby 的特性进行优化,加上这种应用的算法浓度很小,所以两人合作得才很圆满。老工程师可以介绍 Ruby 的语言特性和技术,而新工程师的奇思妙想也会突破老工程师的思维定式,我觉得这种“结对编程”才是更加合理的行为。我个人觉得其实可以选择一个更加简单熟悉的题目,比如说 OO 第一单元的表达式计算,而工程特性则可以从 web 开发入手,这样大家对于选题才会更加熟悉,不会出现“完成不了”的恐惧感,进而可以游刃有余地体会结对编程的魅力。
qs 优点:
qs 缺点:
shj 优点:
shj 缺点:
P S P 2.1 \tt{PSP2.1} PSP2.1 | P e r s o n a l S o f t w a r e P r o c e s s S t a g e s \tt{Personal\space Software\space Process\space Stages} Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 60 | 60 |
Estimate | 估计这个任务需要多少时间 | 60 | 60 |
Development | 开发 | 1680 | 1560 |
Analysis | 需求分析 (包括学习新技术) | 240 | 120 |
Design Spec | 生成设计文档 | 60 | 60 |
Design Review | 设计复审 (和同事审核设计文档) | 30 | 30 |
Coding Standard | 代码规范 (为目前的开发制定合适的规范) | 30 | 30 |
Design | 具体设计 | 120 | 60 |
Coding | 具体编码 | 720 | 840 |
Code Review | 代码复审 | 300 | 300 |
Test | 测试(自我测试,修改代码,提交修改) | 180 | 120 |
Reporting | 报告 | 390 | 570 |
Test Report | 测试报告 | 120 | 60 |
Size Measurement | 计算工作量 | 30 | 30 |
Postmortem & Process Improvement Plan | 事后总结, 并提出过程改进计划 | 240 | 480 |
合计 | 2130 | 2190 |
可以看到具体的变化主要是三点,在设计方面的时间减少了,在实现方面的时间增加了,在总结方面的时间增加了。这其实和结对编程的形式有关,两个人去做设计总比一个人做设计要难一些,效率低一些,所以我们在设计方面就没有很成熟就开始实际编码了,这就进一步导致了我们的编码实现时间变长了,结对编程没有发挥出应有的效果来。
所以我才会花更多的时间去写事后总结,去调研真正的结对编程,所以报告的时间增加了。
与我们交换模块的小组为 cjj 和 wxz,他们的学号分别是 20373021 和 20373020。
因为在做设计的时候,我们就和互换小组商量过 core
接口问题和讨论过异常的形式,所以说我们的接口基本上完全一致,在互换过程中没有出现太大的纰漏,只有在我们的 core
加上他们的 GUI 测试异常的时候,发现异常无法捕获,没有办法确定到底是哪里的 bug,最后我们用
catch(...)
的形式完成了捕获。
以下是效果图:
通过单元测试
我们的 GUI 加他们的 core
他们的 GUI 加我们的 core