「软工结对编程」:最长英语单词链

项目内容
这个作业属于哪个课程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'则表示用户没有使用该选项
};

外部可以调用getOptionsgetFilename两个函数来获取userOptionsfilename这两个变量的值。

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_MODESCREEN_INPUT_MODE, 默认为SCREEN_INPUT_MODE
    • taskType : 执行任务类型,可取N_TASKW_TASKC_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_argumentstd::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 测试框架,分为 genChainsAllTestgenChainWordTestgenChainCharTest 三部分,分别测试对应 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_allgen_chain_wordgen_chain_char这三个接口暴露给CLI和GUI,并且约束好了输入和输出。而内部的具体实现对CLI和GUI来说是透明的,它们也无需考虑。

1.2 Interface Design

当我们把模块内部的实现细节隐藏之后,还需要设计API接口,让调用者知道该如何使用模块的功能。我认为接口的设计应该遵循两个原则——

  • **首先是要方便易用。**API的设计应该简洁,其命名需要能够体现出其功能,而且也不应该有太多太复杂的参数,以方便调用者使用。
  • **其次是要科学高效。**每个API的职责应该尽量单一,内部实现应该高度内聚。如果多个功能耦合在一起,则当一个功能发生变化时,整个API的实现都要进行调整,其他功能势必会受到影响,这会极大降低程序的可复用性和可维护性。

在实现core模块时,我们遇到了这样一个问题——对于get_chain_wordget_chain_char两个接口,用户传入的数据可能隐含环,而对于有环和无环两种情况,我们需要用两个不同的算法进行解决。显然,在API中同时实现这两套算法是违背“单一职责原则”的。因此,我们在Core的下层又设计一个graph类,由graph类提供实现不同算法的接口,例如getChainWordWithCirclegetChainWordWithoutCircle等等。此外,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.1Personal Software Process Stages预估耗时(分钟)实际耗时(分钟)
Planning计划90120
· Estimate· 估计这个任务需要多少时间90120
Development开发14401770
· Analysis· 需求分析 (包括学习新技术)60120
· Design Spec· 生成设计文档120150
· Design Review· 设计复审 (和同事审核设计文档)3060
· Coding Standard· 代码规范 (为目前的开发制定合适的规范)3030
· Design· 具体设计120150
· Coding· 具体编码600720
· Code Review· 代码复审240300
· Test· 测试(自我测试,修改代码,提交修改)240240
Reporting报告330390
· Test Report· 测试报告6060
· Size Measurement· 计算工作量3030
· Postmortem & Process Improvement Plan· 事后总结, 并提出过程改进计划240300
Total合计18602190

4 结对反思

4.1 结对编程优缺点

经过了两周的结对编程体验,我觉得结对编程有如下优点:

  • 结对编程时,码字者写出的代码随时都会得到同伴的复审,可以提早发现很多因为粗心造成的bug
  • 结对编程时两个人不断沟通,可以互相学习,取长补短,提高各自的能力。
  • 结对编程提升专注力,自己写代码容易走神开小差,而当有人在旁边监督时,必须时时刻刻保持专注,也就提高了开发效率。

但同时,我认为结对编程有以下缺点:

  • 结对编程比较适合两个人都比较熟悉,或者其中一个人比较熟悉的项目。如果两个人都不知道该怎么写,那么一起研究不如各自独立调研,最后再一块交流调研的结果,这样效率会更高。
4.2 成员优缺点

温佳昊:

  • 优点
    • 有优秀的算法功底
    • 性能优化经验较多
    • 搜寻资料的能力比较强
  • 缺点
    • 写代码缺少详细构思容易出低级错误

陈正昊:

  • 优点
    • 对C++比较熟悉
    • 擅长编写设计文档
    • 比较注意编码规范和代码风格
  • 缺点
    • 不太擅长算法设计

总的来说,结对编程确实提高了项目开发和测试的效率,也让我们在对方身上学到了很多东西,可以说是收获满满啦!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值