结对编程——一路忘川

结对编程——一路忘川

一、项目信息

项目 内容
这个作业属于哪个课程 2023 年北航软件工程
这个作业的要求在哪里 结对项目-最长英语单词链
教学班级 周四下午班
项目地址 项目地址
我在这个课程的目标是 了解软件工程的方法论、获得软件项目开发的实践经验、构建一个具有我的气息的艺术品
这个作业在哪个具体方面帮助我实现目标 对于结对编程有了初步认识,对于很多工程理念有了一些的体会

二、PSP 估计

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

三、理论实践

3.1 Information Hiding

“信息隐藏“一般和“封装”概念相似,它指的是像模块外隐藏模块内部的实现信息,只提供稳定的接口,这样当模块内部实现细节发生改变的时候,并不会影响使用这个模块的其他模块,同样的,其他模块的修改并不会破坏此模块的功能和结构。

这个概念我们当做了设计接口的基本准则,分为以下两点:

  • 接口的设计应当稳定。
  • 接口不应当保留过多的实现细节。

这道题的数学模型是一个有向无环有权图上的算法,我们考虑到“图”的实现其实并不需要向上层暴露,所以可以完全掩盖图的信息。同时,为了让接口足够稳定,我们不仅考虑了我们组对于接口的使用,我们还考虑了与我们交换组对于接口的使用,最后通过协商确定了接口。

3.2 Interface Design

Interface design 主要面向用户,强调给用户一个方便使用的接口。本次作业中我们设计了 GUI 图形界面,方便用户更直观的使用程序。

结对编程——一路忘川_第1张图片

3.3 Loose Coupling

松耦合是紧耦合的对立面。实现松耦合可以降低组件之间的关联性,将其相互影响降到最低。对于松耦合和紧耦合的对比,有如下表:

结对编程——一路忘川_第2张图片

可以看到松耦合的一个重要特点就是在原本需要紧密通信的两个双方,引入一个新的抽象层,这样原本需要“同步,点对点,不具有独立性”的通信就会变成“异步,通过媒介,具有独立性”的通信。这种设计思路出现在计算机网络,web 应用开发等多种设计中。

在我们的设计中,我们并没有由 main 直接调用 parsecore 模块,而是在 maincore 之间加设了一层 control 层,用于沟通 maincore,这样增加了程序的可移植性(在 GUI 和交换模块的时候更加方便)。

但是不可否认,松耦合会增加开发时的工作量,并不是一味的松耦合就可以将设计推到极致。在我们的开发中,尝试将不同需求的 API 采用完全不同的函数实现(尽管有一些是相似的),目的是为了在细微处调整不同算法的优化,但是实践证明,这大大增加了开发的工作量,而且收益很小。


四、计算模块设计实现

4.1 算法模型

对于这道题,我们采用的模型是图模型,我们建立了一个具有 26 个节点的图,分别用 int 表示,0 ~ 25 节点分别对应字母 a ~ z 。单词是图上的边,单词的首字母和尾字母分别指示了这个边的起点和终点,比如说 banana 这个单词的起点就是 b,终点是 a,单词的长度是这条边的权,也就是如下图的模型(局部)

结对编程——一路忘川_第3张图片

那么寻找“单词链”的过程就类似于寻找路径的问题。

需要注意的是,这是一个复杂图,即可能存在两个或者两个以上的边的起点和终点相同,比如说 bananaba 就会造成这种现象,同样的,这个图中有可能存在自环的,比如说 aba

4.2 问题分析

文档中提出的多种需求,可以被分为两类,一个是求解所有的路径,一个是求解最长的路径。

对于求解所有的路径,唯一的方法是进行 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,我们的数据结构在进行这种记忆化搜索的还原时,支持性不太强,最终由于时间原因,没有能够实现。

对于题目中对于“指定头结点,尾节点”的限制行为,可以通过在正常的算法开始之前限定起始节点或者在算法运行结束之后挑选终止节点的方法解决。对于禁止某个头结点的行为

4.3 具体实现

由上面的分析可以看出,计算核心的数据结构应当采用图结构,所以我们建立了一个类 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

结对编程——一路忘川_第4张图片

对于 getLongestWordChain()

结对编程——一路忘川_第5张图片

对于 getLongestCharChain()

结对编程——一路忘川_第6张图片

可以看到 getLongestWordChain()getLongestCharChain() 的调用关系基本一致,这是因为在设计的时候为了优化进行的代码冗余性提升。


五、无警告编译

为了在 clion 中显示警告信息,我们需要在 CMakeList.txt 中这样调整信息:

set (CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -Wextra")

然后运行时就会将警告信息显示在 clion 的控制台中,最终完全没有警告的截图如下:

结对编程——一路忘川_第7张图片


六、UML 图

总的 UML 如图所示

结对编程——一路忘川_第8张图片

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YnZoLgCS-1679189505021)(file:///home/thysrael/learn/sem6/SE/WordChain/src/html/dir_56ea320f36428fc096e6ea8f45d8dae0_dep.png)]

对于局部图,有如下所示

MyException

结对编程——一路忘川_第9张图片

Edge

结对编程——一路忘川_第10张图片

Graph

结对编程——一路忘川_第11张图片


七、性能改进

我们利用 gperf 进行性能分析,并利用 pprof 进行可视化转换。

7.1 存储单词链

在对 -n 功能进行性能分析的时候,我们发现了一个现象:

结对编程——一路忘川_第12张图片

明明只是简单的保存发现的链,居然会占如此多的性能,所以我们研究了这个函数,注意到他实际上进行了两遍复制(在生成一条单词链的时候进行了一遍,在将生成单词链复制到 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;
}

修改后的性能图,发现此方法性能有一个明显提高:

结对编程——一路忘川_第13张图片

7.2 OpenMp 并行

性能消耗最大的函数是这个

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);
}

最终效果确实实现了并行,我们可以在算法进行过程中打印输出语句来检验,如下所示,可以发现语句呈现乱序状态,正是由于线程切换造成的:

结对编程——一路忘川_第14张图片

同时从桌面的性能检测工具可以看出,并行化的程序确实极致压榨了 CPU 的性能(成功让我的电脑在 30 分钟内电量归零):

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4VWx4p1i-1679189505023)(结对编程/mp2.jpg)]

我们进行这个优化是希望可以处理一个 scc 中有多条边的时候,可以更快的进行处理,在理论上可以优化 12 倍(因为我的电脑是 12 核的),但是我们发现,即使是这样,我们也没有办法跑通一个较为复杂的 scc 图,这是因为这种优化其实是一种 O ( 1 ) O(1) O(1) 形式的优化,并没有办法大幅度提高效率。所以非常遗憾。

但是看到自己将上个学期“并行计算”中学到的知识用在了软工里,还是非常开心的!


八、契约编程

契约式设计的主要目的是希望程序员能够在设计程序时明确地规定一个模块单元(具体到面向对象,就是一个类的实例)在调用某个操作前后应当属于何种状态。

其实我们之前在操作系统课程里是接触过契约编程的,在需要填空的代码段前,有的会用注释标注 preCondition, postConditon 之类的字样,大致可以用下图解释:

结对编程——一路忘川_第15张图片

我们对于图中黄色的部分并不陌生,无论是面向过程的还是面向对象的,只要是大型的编程,大多都是以“模块化”的形式进行的,只不过契约编程要求我们在编程前明确的指出“前置条件,副作用,后置条件”等信息,来辅助模块之间的对接。在理论上看,利用这种“编程的契约”,可以大大地降低模块间对接过程中可能会发生的失误,同时还能降低因为模块化导致的效率冗余现象。

但是非常的不幸,就如同现实社会中,拟定契约是一件非常痛苦的事情,以契约的思想去设计和实现项目,同样是一件非常痛苦的事情,我们在开始的时候尝试使用契约编程的思想,希望厘清每个模块间的界限,但是事实就是,我们也不确定有多少个模块,当然这个并不能怪别人,是我们的算法水平并不支持我们对于项目的整体架构做出完整的预测,做到胸有成竹。而且拟定契约应该还需要以某种文本载体的形式存在,如果只是口头契约,那么就如同现实中的“口头借条”一样难以发挥效用,这也是我们在实践中吸取的教训。

在实践中,我们出现了一些因为契约不清楚,而导致的实现失误,比如说在处理重复单词的时候,我们没有确定清楚这个功能的位置,毕竟它有可能在读入单词后立刻处理,也可以在进行计算前处理,所以在这里出现了一点点小的失误。但是在其他地方还是很正常的。就我个人感觉,契约编程更适合与分工开发,毕竟书写不同模块的人需要依靠接口对接,显然指明接口的诸多特性,有助于更好的分工,但是考虑到“结对编程”其实是“一个人的开发”,所以过于书面化的“契约编程”,我个人觉得是有一些画蛇添足的。这个想法我觉得也是和“结对编程是敏捷编程的一种方法”的表述有一些贴合,毕竟“契约”听上去就不像一个可以让开发敏捷起来的事情。


九、单元测试

单元测试采用了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);
}

单元测试覆盖率:

结对编程——一路忘川_第16张图片

core.cpp和graph.cpp两个计算核心的文件代码行数覆盖率和分支覆盖率都在90%以上


十、异常处理

10.1 异常处理框架

我们采用了 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();
}

10.2 异常种类

10.2.1 MULTI_FILE_PATH

指在输入命令行命令的时候,输入了多个输入文件,就会抛出这个异常,如

./wordList input1.txt input2.txt 

10.2.2 PARAMETER_NOT_EXISTS

输入的参数不是题目给定的,就会抛出这个异常,比如说

./wordList input.txt -f

10.2.3 MULTI_WORK_ERROR

尽管这个程序有多个参数,但是有些参数是冲突的,比如说同时指定求解单词链的个数和最长单词链,这时就会抛出这个异常,比如说:

./wordList input.txt -n -w

10.2.4 NO_CHAR_ERROR

在指定 -h, -t, -j 参数的时候,没有指定对应的字符,这时就会抛出这个异常,比如说:

./wordList input.txt -n -j

10.2.5 CHAR_FORM_ERROR

在指定 -h, -t, -j 参数的时候,指定的字符不是字母,这时就会抛出这个异常,比如说:

./wordList input.txt -n -j 0

10.2.6 FIRST_CHAR_DUPLICATE

在指定 -h, -t, -j 参数的时候,指定的字符存在多个,这时就会抛出这个异常,比如说:

./wordList input.txt -n -j a -j a

10.2.7 ENABLE_LOOP_DUPLICATE

在重复指定 -r 参数后,就会抛出这个异常

./wordList -r input.txt -r

10.2.8 NO_FILE_PATH

当没有指定输入文件的时候,会抛出这个异常

./wordList -r

10.2.9 N_WORK_WITH_OTHER_PARAMETER

-n 参数不支持和其他参数共同使用,如果同时使用,就会抛出这个异常:

./wordList -r input.txt -n

10.2.10 NO_WORK_ERROR

没有指定任务的时候,会抛出这个异常

./wordList input.txt

10.2.11 WORD_NOT_AVAILABLE

不存在符合要求的单词链时,会抛出这个异常

输入命令:

./wordList input.txt -j a -n

input.txt 中的内容为

ab
bc
cd

10.2.12 FILE_NOT_FIND

输入文件没有找到,即不存在指定的输入文件

./wordList notExisitInput.txt -w

10.2.13 HAVE_LOOP

无环图中有环。

输入命令:

./wordList input.txt -n

input.txt 中的内容为

ab
ba

十一、界面模块设计

GUI采用Qt编写,Qt是一种cpp框架,所以没有接口转换的问题,使用的IDE是Qt Creator,非常方便,界面设计只需要用鼠标点,查询事件可以通过跳转槽完成。


十二、界面模块与计算模块对接

GUI会在用户界面收集所有参数,然后通过core.h引入函数签名调用core中的接口完成查询任务,通过在跳转槽中捕获异常可以在gui得到关于异常的提示

查询最长单词链:

结对编程——一路忘川_第17张图片

查询最长字母链:

结对编程——一路忘川_第18张图片

查询所有单词链:

结对编程——一路忘川_第19张图片

文件导出功能:

结对编程——一路忘川_第20张图片

异常提示:

结对编程——一路忘川_第21张图片

时间提示:

结对编程——一路忘川_第22张图片


十三、结对过程

13.1 时间地点

我们选择在新主楼进行结对,结对的时间是 3 月的 6,7,8,9,10,11,12,15 日。基本上只要上完课了就会去进行结对。以下是我们结对的图片:
结对编程——一路忘川_第23张图片

13.2 结对形式

我们基本上就按照《构建之法》中提到的结对编程的方法,一个人敲代码,一个人领航,然后基本上过两个小时交换一次,大致一天可以交换两到三次。

驾驶员负责具体的代码书写,驾驶员负责查阅相关资料和指导实现。

13.3 结对过程

这个项目被我们分割成了 6 个部分:

  • 指令解析,
  • 文件读入,
  • 计算核心
  • 输出
  • GUI
  • 测试

指令解析是我们进行的第一个模块,因为第一次结对很尴尬,所以我们立刻就开始了。就一个人写,一个人看着,所幸这个地方并不太难。领航员时刻盯着说明文档,避免出现没有考虑到的错误,然后驾驶员专注于按照领航员的说法进行正确的解析和对于异常情况的处理。

在文件读入模块时,领航员考虑利用休息时间整理出一套完整的实现算法,这是因为单词的读入要实现“分词”和“去重”,同时要考虑文件读入的性能。所以领航员实现了一套基于状态机的算法,可以简洁优雅地完成“分词”功能,但是发现驾驶员在心中也已经有了自己的打算,更加侧重于文件读写优化,加上敲代码的过程不是听领航员讲课的过程,所以驾驶员在没有完全弄清楚领航员的设计前,就基本已经完成了代码。这个现象我们会在下一节讨论的。

在计算核心部分,我们明白任务的算法成分很高,就约定休息一天进行调研,然后在进行结对编程,但是我本人突然发现在一天之中没法很好的完成调研,因为我很紧张,很害怕和结对队友沟通的时候让他发现我有一些地方不懂,然后就很焦虑,而且遇到不会的地方,总是想要不然跳过吧,反正他说不定也会看的。然后开始结对的时候发现他看的算法的效率有些低,所以有改成实现我调研的算法,但是此时我俩都并不透彻的了解,所以硬着头皮开始实践,因为我对于算法比较熟悉,所以由我来担任驾驶员来编写代码,由另一位同学担任领航员,但是此时的领航员就只有代码复核的工作了,就没有办法在算法实现层面指导驾驶员了,同时驾驶员需要写更多的注释来保证领航员的复核工作的顺利进行。这一天是体验最差的一天。可能是由于这一天的较差体验,我们两个人又都熬夜肝了算法实现,然后在第二天的时候终于实现了比较好的配合,驾驶员可以按照领航员的指示实现代码,同时领航员还可以紧紧地复核代码,可以说是配合地非常默契了。但是因为算法确实极为复杂(对我们两个算法能力不是那么强的人来说),所以这个算法我们足足实现了三天,在后期的时候虽然配合默契,但是频繁的结对,对于自由生活的向往得不到实现(简直就像结了婚一样(bushi)),后期还是有一点小烦躁的。

在 CLI 输出部分,因为在计算模块调试的时候,基本上也需要利用输出功能进行调试,所以我们只稍微将模块解耦了一下,就完成了这个部分。

对于 GUI 部分,因为我的电脑是 manjaro,不知道为啥按照 Qt 的时候总会报异常,所以我们最终是在我的队友的电脑上实现的,这个时候我就更像是不了解原理的废物了,所以由我进行代码审核,而队友担任驾驶员。

在测试部分,我们一起构造了样例,并且进行了比较流畅的结对编程,体验比较好。


十四、结对编程分析

14.1 过程评价

我个人觉得结对编程的体验还是非常正面的,基本上在教材中描述的效果,都是已经达到了的,比如说

  • 有一个人看着就不敢乱写代码了,这个确实是的,我们写代码的时候确实要更加规范,不开全局数组的就不开,该用 auto 的就不显式地指明类型,该用引用的就用引用,该区分成不同文件的就拆成不同文件。可以说我们的源码质量有了很高的提升。
  • 优势互补:这也是同样的,比如我一开始就没有写过 CPP 的大型项目,但是有了我的队友在身边,一旦出现没有我不懂的语法知识,我可以立刻请教它,如果我想尝试新的语法特性,我也可以立刻问他应该如何应用。相应的,我的队友如果有一些关于语言特性方面的问题,也可以问我这个“云 CPP 大师”。
  • 互相督促:确实是这样的,因为两个人敲代码的时候确实没法摸鱼,我自己敲代码敲到开心处的时候,总喜欢打开知乎闲逛一会儿平复一下心情,感觉有了队友的督促,确实效率高了不少。
  • 代码正确性更高:确实,因为领航员时时的代码复审和驾驶员必须经常性的解释自己的代码是什么意思,所以我们经常写着写着代码就发现自己的错误了,测试的时候一遍过的概率要更加高一些。

但是不可否认的,结对编程也是有一定缺点的:

  • 个人问题转换层了合作问题:我觉得这个是最难受的,就是很多时候,这个东西就是我自己一个人想就好了,我可以吃饭的时候想,也可以睡觉前想,也可以想不想就不想,想拖多久拖多久,但是一个合作问题,我会着急,会匆匆略过,会担心合作的问题,这其实是非常不好的。最不好的一点就是没有大量的时间去做设计。
  • 新形式造成的陌生感:这也是标题“一路忘川”的来源,就是我们基本上就是“做中学”的模式,对于一个不大的项目,就写着写着就写完了,很多形式都没有来得及充分实践,拿赛车手距离,这个项目不过只有 1 km,却需要配备一个领航员,一个驾驶员,一脚油门就到了的事情,搞得这么专业,反而弄得形式大于实际效果了。尤其是我们一开始虽然看了教材,但是真的很难一下子在实践过程中完全贯彻这个形式,比如说在上文提到的文件读入模块,因为比较简单,所以驾驶员上来就给写了,领航员一点话都插不上,而到了后面的算法,又过于陡峭,很难让领航员发挥算法设计的指导价值。
  • 时间问题:真的是没有时间,我们两个人是 CO 的助教,彼此互相了解而且都是十分肝的,基本上没有课的时候都会聚在一起,但是真的有时间的时候不多,而且聚在一起真的很累。

总结一下,相比于往届学长博客对于结对编程的态度,我的态度要更加肯定一些,只是感觉因为课时安排的问题,非常遗憾没有完全体会到结对编程的威力。另外我突然想到,《Ruby 元编程》的行文思路就是一个经验丰富的工程师带着一个比较年轻的工程师去进行 Ruby 的开发,也可以说是一种“结对编程”了,而且他们充分发挥了教材中“帮带”的好处(这种好处在我们的实践中是没有的),这是我非常遗憾没有体会的。

我个人觉得最大的原因是选题,我们的选题是一个非常难的算法题目和工程题目,其算法的难度高到甚至我现在也没有办法实现它的完全体,而其工程特性包括了它需要结合单元测试测试,发布,模块解耦,GUI 开发,协作,API 设计等诸多特性,这些特性个人光是学习,就已经很困难了,更不要说还要应用新的工程形式——结对编程了。反观《Ruby 元编程》中的示例,年轻工程师虽然对于 Ruby 的特性不太熟悉,但是他对于要开发的项目是很熟悉的,无论是数据库管理还是 web 框架,他都是清楚要干啥的,他只是不清楚如何利用 Ruby 的特性进行优化,加上这种应用的算法浓度很小,所以两人合作得才很圆满。老工程师可以介绍 Ruby 的语言特性和技术,而新工程师的奇思妙想也会突破老工程师的思维定式,我觉得这种“结对编程”才是更加合理的行为。我个人觉得其实可以选择一个更加简单熟悉的题目,比如说 OO 第一单元的表达式计算,而工程特性则可以从 web 开发入手,这样大家对于选题才会更加熟悉,不会出现“完成不了”的恐惧感,进而可以游刃有余地体会结对编程的魅力。

14.2 成员评价

qs 优点:

  • 十分的肝。
  • 会一些 CPP 底层原理。
  • 懂一些算法(但是不全懂)、工程(但是不全懂)和设计(但是没有贯彻)。

qs 缺点:

  • 十分焦虑,担心项目完成不了,有些悲观。
  • 对于 CPP 不熟悉。
  • 强迫症,而且有一定的控制欲。

shj 优点:

  • 十分了解 CPP。
  • 工程能力强。
  • 心态十分的稳定。
  • 执行力强。

shj 缺点:

  • 比较随和,不太反对 qs 的意见。

十五、PSP 实际

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(...)

的形式完成了捕获。

以下是效果图:

通过单元测试

结对编程——一路忘川_第24张图片

我们的 GUI 加他们的 core

结对编程——一路忘川_第25张图片

他们的 GUI 加我们的 core

结对编程——一路忘川_第26张图片

你可能感兴趣的:(结对编程,软件工程)