网络一线牵,珍惜这段缘——软工结对开发作业实录

这篇博客是一次课程作业。

项目 内容
班级:北航2020春软件工程 006班(罗杰、任健 周五) 博客园班级博客
作业:设计程序求几何对象的交点集合,支持命令行和GUI 结对项目作业
个人课程目标 系统学习软件工程,训练软件开发能力
这个作业在哪个具体方面帮助我实现目标 体验结对编程;学习Windows下动态链接库的编译与使用
项目地址 GitHub: clone/https

本作业涉及到的小组及其成员:

学号 CnblogProfile GitHubRepo
17231145 * (结对伙伴) @FuturexGO *Repository
17373331 * (本文作者) @MisTariano
17231162 † (另一小组) @PX_L †Repository
17373321 † (另一小组) @youzn99

个人部分PSP与反思

PSP2.1 预估耗时(分钟) 实际耗时(分钟)
Planning 30(tot) 20(tot)
· Estimate 30 20
Development 860(tot) 1230 (tot)
· Analysis 90 200
· Design Spec 30 30
· Design Review 60 60
· Coding Standard 20 40
· Design 120 60
· Coding 360 720
· Code Review 60 60
· Test 120 60
Reporting 90(tot) 40(tot)
· Test Report 30 0
· Size Measurement 30 10
· Postmortem & Process Improvement Plan 30 30
In Total 980 1290

本次作业耗时超出预估,主要原因集中在对Windows下类库管理与编译流程的不熟悉上。在Linux上,一个.so共享库可以非常简单地被编译使用,加之Linux下编译器版本借助apt管理,环境变量往往比较清晰,因此借助cmake等管理工具很少出现编译上的问题。但是windows下的编译更为复杂,比如MinGW GNU及MSVC两条编译工具链间的异同、动态链接头.dll.a的使用、PATH内不同编译器间的冲突管理都是需要考虑的细节。在编译Qt GUI的时候我就由于系统内两套Qt类库(新安装的5.14与此前Anaconda自带的5.9)间的版本兼容问题迟迟不能运行打包好的可执行文件。

此外,由于我和同伴对松耦合的期望较高,在接口设计上我们花了比预估更久的时间来打磨一份足够好用的设计。总的来说,这次作业虽然时间超过了一开始的预估,但总归没有太多被浪费。

设计思想与方法

Information Hiding与Loose Coupling

好的设计总是高内聚而低耦合的。我们的设计思路正是遵循这样的原则展开:

  • 只暴露恰到好处的细节,隐藏用户不需要也不应该关心的内部数据结构、算法流程、底层操作,避免程序被以错误的(我们不希望的或未预期的)方式调用,达到高内聚。虽然很残忍,但我们必须假想用户是鲁莽而疯狂的——他有可能多次初始化一个重要的内存池,或用电视遥控器把火箭发动机关闭。
  • 将整个计算模块合理封装,对外暴露多个功能单一的无状态接口,实现接口与接口、接口与实现间的低耦合,当遇到新的接口格式/标准时可以方便地借助转接器等设计模式通过组合现有接口实现接口扩展/接口迁移。历史告诉我们,忠于开闭原则的人终将得到嘉赏。

这其实是一套老生常谈的东西,能把哭泣的小孩烦到闭嘴。然而一旦做到了这两点,我们就可以快速制定一套标准化的外层接口,并将程序接入——这个时候接口函数的声明与定义就可以完全分离了——我们就可以借助动态链接库提供代码定义,使程序可以被热更新。这就达成了作业要求的代码松耦合

Code Contract

契约式设计我们已经在大二的OO课程中有关JML的章节里初步接触过了,简单的说这是一种先建立接口抽象约束(契约),再依照契约实现接口并检验功能的开发流程。它十分类似于一种防御式编程、测试驱动开发及内联文档的结合体,对于瀑布式开发模型这种需求先得到完备设计再完成编码的工作流,契约可以贯穿开发始终,确保所有需求都被恰到好处地执行。然而对于更快速的迭代模式(比如课程关注的敏捷开发),编写契约将耗费大量精力而降低开发效率,与追求快速迭代适应需求、极小化文档的宗旨矛盾。另一方面,契约语言(如JML)的编写往往由需求提出方给出,这要求相关人员有一定的开发与专业基础。加之工具链不成熟、相关工具开发难度较大等因素,如今并没有得到广泛应用。

对于JML工具链及契约编程的简单介绍,我在大二时曾写过一篇相关博客,感兴趣可以移步查阅。

接口的具体设计与实现*

我们一开始按照自己的接口设计(私有接口,或customInterface)对核心模块进行了封装。

之后为了验证松耦合,我们与另一组的同学@PX_L共同设计了在各自接口标准之上的新标准(公有接口,StdInterface)。

这样我们都以新标准接口导出DLL可以方便地验证GUI、命令行、测试模块与核心模块的松耦合,之前各自已经完成的对customInterface的单元测试也无需重构,只需在customInterface基础上加壳即可。

我们设计的customInterfaceStdInterface的主要特点为:

  • 考虑到我们希望接口能具有较强的兼容性,我们使用C风格接口,不使用C++的面向对象、STL等特性。这样今后稍微改动就可以被C、C++、Python等各种语言调用。
  • 动态内存管理由接口内部实现,外部调用者无需直接操作指针。
  • 数据被良好封装,外部调用者无法更改和直接访问数据,也无法知道数据的地址。
  • 支持多例,即支持一个GUI程序中开多画布,每个画布数据独立。

用图可以阐释如下(为了理解方便,可能与代码有出入,如变量名等):

C风格 私有接口 customInterface:弱耦合

我们首先定制的接口,将计算类的基本操作进行了简单封装。代码请见src/interface.h:

#ifndef GEOMETRY_INTERFACE_H
#define GEOMETRY_INTERFACE_H

#include <unordered_set>
#include "Shapes.h"
#include "StdInterface.h"

// 数据管理实例类
struct gManager {
    std::vector<Geometry> *shapes;
    std::unordered_set<Point, hashCode_Point, equals_Point> *points;
    gPoint upperleft;
    gPoint lowerright;
};

// 初始一个数据管理实例
gManager *createManager();

// 关闭实例,释放其占用的内存资源
void closeManager(gManager *inst);

// 清空实例中当前缓存的所有形状和交点
void cleanManager(gManager *inst);

// 向实例中添加形状
ERROR_INFO addShape(gManager *inst, char objType, int x1, int y1, int x2, int y2,
                    gPoint *buf, int *posBuf);

// 根据文件输入向实例中批量添加形状
ERROR_INFO addShapesBatch(gManager *inst, FILE *inputFile, gPoint *buf, int *posBuf);

// 获得当前交点总数
int getIntersectionsCount(gManager *inst);

// 获得当前实例中交点综述
int getGeometricShapesCount(gManager *inst);

// 获得当前所有交点,写入buf为首地址的连续内存中
void getIntersections(gManager *inst, gPoint *buf);

// 获得当前所有图形,写入buf为首地址的连续内存中
void getGeometricShapes(gManager *inst, gShape *buf);

#endif //GEOMETRY_INTERFACE_H

注意到这套接口提供了一个数据管理器gManager,允许外部程序创建、持有计算中需要持久化的数据区并控制内存释放时机,这样所有的计算操作都不需要再维护全局缓存数据,这使所有接口都是无状态的,更易于解耦与使用。

这套封装已经已经足以覆盖对交点计算库的使用需求,但注意到两个问题:

  1. 由于追求泛用性使用C风格接口,以结构体的方式给出的gManager中类型为Geometry的数据是可以被直接访问到的,而这个类型实际上是std::variant<Line, Circle>(在src/Shapes.h中定义),用户的程序可以以我们不希望的方式添加对这个类型数据的操作,产生对这个类的耦合。一旦其他实现中没有提供这个类型,就不可能在不修改用户代码并重新编译的前提下将用户程序迁移至新的计算库实现。因此,这里并没有满足信息隐藏的原则,且难以实现松耦合。
  2. 由于接口中用到了Shapes.h定义的结构,Shapes.h被引入了用户项目,对用户不透明。因此用户不仅可以调用interface.h中声明的接口函数,还可以调用Shapes.h中声明的各种计算操作——这也是不满足信息隐藏原则的。实际上这还会导致Point.h的引用——用户程序在编译时必须同时包含这三个头文件,提供的动态库也必须完整实现这三个头文件中声明的函数,这使得程序借助松耦合取得的修改与扩展空间变得极小。

因此为了实现松耦合,我们在与另一组协商后设计了下述公有接口标准,对这些接口的引用关系与数据定义进行了集中整理。

C风格 公有接口 StdInterface:松耦合

我们的公有接口标准保留了C风格接口和无状态两个设计点,在单个头文件中给出了用户程序需要关注与使用的全部数据结构定义与函数声明。请见src/StdInterface.h

// 错误代码枚举
enum ERROR_CODE {
    SUCCESS,
    WRONG_FORMAT,
    VALUE_OUT_OF_RANGE,
    INVALID_LINE, INVALID_CIRCLE,
    LINE_OVERLAP, CIRCLE_OVERLAP,
};

// 运行状态与错误信息封装
struct ERROR_INFO {
    ERROR_CODE code = SUCCESS;
    int lineNoStartedWithZero = -1;
    char messages[50] = "";
};

// 形状结构
struct gShape {
    char type;
    int x1, y1, x2, y2;
};

// 交点结构
struct gPoint {
    double x;
    double y;
};

// 画布结构,类似私有接口中的gManager,为用户程序提供数据管理
struct gFigure {
    unsigned int figureId;
    gShape *shapes;  // only available after updateShapes()
    gPoint *points;  // only available after updatePoints()
    gPoint upperleft;
    gPoint lowerright;
};

// 创建画布
gFigure *addFigure();

// 释放画布资源
void deleteFigure(gFigure *fig);

// 清空画布中的形状与交点
void cleanFigure(gFigure *fig);

// 将形状添加至画布
ERROR_INFO addShapeToFigure(gFigure *fig, gShape obj);

// 将字符串desc中描述的形状添加至画布
ERROR_INFO addShapeToFigureString(gFigure *fig, const char *desc);

// 读取文件名为filename的文件并将其描述的图形批量导入画布
ERROR_INFO addShapesToFigureFile(gFigure *fig, const char *filename);

// 从标准输入流中读取形状并加入画布
ERROR_INFO addShapesToFigureStdin(gFigure *fig);

// 根据给定的形状索引移除形状
void removeShapeByIndex(gFigure *fig, unsigned int index);

// 获取交点总数
int getPointsCount(const gFigure *fig);

// 获取形状总数
int getShapesCount(const gFigure *fig);

// 将修改形状数据后新的交点数据同步到给定画布数据区points字段中
void updatePoints(gFigure *fig);

// 将修改形状数据后新的形状数据同步到给定画布数据区shapes字段中
void updateShapes(gFigure *fig);

不难发现这份接口涵盖的功能是上文中私有接口的超集。同时注意到在私有接口中存在的数据隐藏等问题,在这份接口定义中已经不存在了。

因此用户程序在编译时只需要包含这份头文件,就可以加载任何实现了这套接口的动态库并正常运行。

我们的松耦合实验便基于此接口完成。具体流程是:在完成标准制定后,参与的两组先协作完成这份头文件的编写,再分别将自己的私有接口组合以实现公有接口函数,最终分别编译动态库并进行互换实验。

核心模块内的设计——UML阐释

核心模块性能评估与改进*

性能评估与改进的工作主要由 @FuturexGO 完成,约用时3h。

由于我们继承了个人作业的思路,使用std::unordered_set<>进行去重,因此一个值得关注的问题是:

花在计算交点上的时间多,还是花在去重交点上的时间多?

如何分别优化两部分?

计算交点部分的优化

于是,在最初的版本,我们基于一个随机生成的样例对程序进行Profile,结果如下:

可以看出在该版本下,计算部分占用了30.6%的总时长,维护HashSet部分占用了13.2%+5.9%等时间,最后delete一个大数据结构占用了20.4%的时长。

对于我们的设计,一个通常的函数调用路径可以看成(为了介绍简便进行了再加工):

1. std::vector<Point> intersection(Shape1 &x, Shape2 &y);
<--> (std::visit 解析std::variant 和 Template Shape1&Shape2)
2. std::vector<Point> intersection(Line &x, Line &y);
<--> (进一步的逻辑)
3. bool checkPointLine(Line &l, Point &p);
   bool checkPointHalfline(Line &l, Point &p);
   bool checkPointSegment(Line &l, Point &p);
   bool checkHalflineOverlap(Line &x, Line &y);
   ...

展开函数调用树(此处未展示)我们发现,计算最密集的第三层(使用点积、叉积等计算几何方法检查点是否在给定范围上、检查是否重叠导致无穷多交点等)花费的时间并不占第一层的子树下的绝对多数\(<70\%\))。

于是我们想到,既然两个图形的交点至多为2个,其实并没有必要使用std::vector<>作为返回值。并且上述的一些被频繁调用的子逻辑函数可以内联。

进一步,我们的思路为:

  • 避免拷贝。使用引用或指针将容器传进参数里,作为“输出参数”。但是std::visit(visitor{}, arg1, arg2,...)所要求函数的参数必须都是std::variant类型,不支持其它类型,因此此处不适用。

  • 合理内联。上述的几个bool函数可以内联,以减少反复更改调用栈的开销。

  • 减少使用大规模的STL容器,用灵活的小STL替换。此处使用std::vector<>实在没有必要。并且在之后的更新中我们加入了判断两个图形相交是否合法(是否产生无穷多交点异常)的逻辑,因此需要将返回值除了交点外添加一个bool值。于是,我们使用了灵活的std::tuple<>将其重构成:

    typedef std::tuple<bool, int, Point, Point> point_container_t;
    point_container_t intersection(..., ...);
    

    其中第一个位置bool表示相交的合法性,第二个位置int表示有几个交点,第三第四个位置表示交点。如果只有一个交点,则第四个位置的变量无意义;如果没有交点,则两个Point对象均无意义。

于是改进后,我们对同一组数据进行Profile,得到结果:

可以看出,保持closeManager()函数没变,其占用率从之前的20.4%上升到了24.6%,说明整个程序主体部分的运行时间减少了\(1-20.4\%/24.6\%=17\%\),是一个很大的提升(我们管这个叫“delete测速法” 😃 )。

同时可以看出,现在计算部分的时间占用只是主体部分的1/3了,其中std::ordered_set::insert维护哈希表占的时间是计算部分的两倍

维护去重HashSet部分的优化

为了方便对比,让我们先去除closeManager()部分(delete了几个大容器):

可以看出,在某些数据上,维护集合的开销占到了70%!于是,我们在重写的hash函数和equals函数中加入计数,统计两个函数分别被调用的次数,发现:

对于最终unique点为28,527,681个的数据,hashCode()被调用了28,527,710次左右,这意味着重复的点只有0.0001%!对于这样的数据,hashset花了接近30秒去重了30个数据?并不是,我们发现std::unordered_map::rehash()方法占用了一定的时间,同时重载的equals()被调用了超过60,000,000次!

我们通过查找资料和猜想,产生了如下思路:

  • hashset/hashmap内部是由若干个buckets(桶)组成。每当数据占满了总bucket数的max_load_factor比率,就会进行rehash()操作进行扩容。
  • 在扩容中会重新排布数据点,可能发生迭代、冲突和判等。
  • 因此,既然我们已知总图形数,我们可以根据图形数估算总交点数,并提前rehash,开出空间留出适当的buckets数,使hashset在运行过程中尽可能少扩容。

因此我们在批量输入模式(如命令行调用、标准输入、GUI打开文件导入)下,一旦输入了图形数objCount,就进行如下操作:

points_set->rehash(int(objCount * objCount / 2.0 / 0.75) + 1);

该语句的策略是,假设objCount个图形会产生约C(objCount, 2)数量级的交点数。同时,我们希望能有75%左右的桶子占用率(这是个magic number,也有人使用70%,可能是经验值)。

经过上述优化,再次进行Profile的结果为:

img

可以看出,在计算部分没有变化的条件下,insert部分占用率从69.4%下降到了48.2%。事实上的总运行时间也缩短了5~10s。因此优化是绝对有效的。然而整个程序的主要时间开销还是花在了unordered_set上:

它确实是\(O(1)\)的。最终的总复杂度为\(O(n^2)\cdot O(1)=O(n^2)\)的。

但经常它比先加到顺序容器里、再排序、再去重的\(O(n^2\log n^2) = O(n^2\log n)\)还慢,慢得多

以下是具体的最终版本的性能实验数据(Apple Clang++,Release版本,文件读入标准输出,随机数据):

N 实际运行时间(s)
2500 0.90
5000 3.73
10000 15.65
20000 >60

核心模块单元测试*

单元测试工作由 @FuturexGO@MisTariano 共同贡献完成。

由于VS自有的单元测试并不通用,我们仍然选择了全平台支持、跨IDE支持的通用测试框架 GoogleTest,并将其集成到了各自prefer的IDE中。

由于本次作业中的基础数据结构有理数类被取消和重构,因此我们对Shapes.h和Point.h中涉及到的函数和数据结构分别重构了单元测试。之后,我们又对核心模块接口interface.h中的接口和异常分别构造了单元测试。四组单元测试取并集的总覆盖率如下图所示(interface.h未显示覆盖情况是因为其函数全部在对应的.cpp文件中,自己并没有单独的函数实现):

img

具体的测试代码可见 GitHubRepo/test。这里选取其中一个测试直线/射线/线段共线情况例子 进行解读:

待测试的函数为:

typedef std::tuple<bool, int, Point, Point> point_container_t;
point_container_t intersection(const Shape1 &a, const Shape2 &b);

返回的(待测试的)信息为:是否触发无穷交点异常、有几个交点、交点坐标。这里我们的侧重点是前两个返回信息的正确性,后面坐标的正确性由其他的测试来保证。

  • 首先将直线/射线/线段都看作是线段。这样两条共线线段的重叠情况有(两条线段的位置可互换是显然的,这里不再赘述):

    • “相离”。两条线段不共点。
    • ”相切“。两条线段共用一个点。
    • ”相交“。两条线段有无穷多个公共点。假设一条线长一条线段短,则还有这些情况:
      • 部分重叠;
      • 短“内含”于长,左对齐;
      • 短“内含”于长,右对齐;
      • 长短相同,二者完全重叠。

    因此体现在我们的代码中,我们对这些线段-线段的位置重合关系组合进行了生成:

    case_input_list_t getCases(const std::string &overlapType, const std::string &directionType) {
        // case 0:   --------    .........  (divide)
        // case 1:   --------........       (cat)
        // case 2:   ------.-.-.-......     (overlap)
        case_input_list_t cases;
        if (overlapType == "divide") {
          
            pushCaseByDirection(cases, directionType, 1, 1, 2, 2, 3, 3, 4, 4);
            ... // 不同参数数量级的其他 meta-case,如参数小至-1e5,如参数大至1e5等
        
        } else if (overlapType == "cat") {
          
            pushCaseByDirection(cases, directionType, 1, 1, 2, 2, 2, 2, 3, 3);
            ... // 每个case是上面meta-case的交换与变换
        
        } else { //if (overlapType == "overlap")
          
          	// normal case       -----.-.-.-.....
            pushCaseByDirection(cases, directionType, 1, 1, 3, 3, 2, 2, 4, 4);
            ...
            // left aligned      -.-.-.-.-.------
            pushCaseByDirection(cases, directionType, 1, 1, 3, 3, 1, 1, 4, 4);
            ...
            // right aligned     ------.-.-.-.-.-
            pushCaseByDirection(cases, directionType, 1, 1, 4, 4, 2, 2, 4, 4);
            ...
            // complete overlap  -.-.-.-.-.-.-.-.
            pushCaseByDirection(cases, directionType, 1, 1, 4, 4, 1, 1, 4, 4);
            ...
        }
        return cases;
    }
    

    其中每个pushCaseByDirection()又对下面的情况进行了组合考虑:

  • 接着把上面所说的线段看成向量。这样两个共线向量的方向组合情况有:

    • 同向,向x增加
    • 同向,向x减小
    • 异向,相向
    • 异向,向背

    因此对于上述调用的每次pushCaseByDirection(),我们都进行向量-向量方向关系级别的再组合:

    void pushCaseByDirection(case_input_list_t &container, const std::string &directionType,
                             int x1, int y1, int x2, int y2, int x3, int y3, int x4, int y4) {
        // - sub case 00: ------->    ........>  (right)
        // - sub case 01: <-------    <........  (left)
        // - sub case 02: ------->    <........  (face)
        // - sub case 03: <-------    ........>  (back)
        if (directionType == "right") {
            container.push_back(case_input_t{x1, y1, x2, y2, x3, y3, x4, y4});
        } else if (directionType == "left") {
            container.push_back(case_input_t{x2, y2, x1, y1, x4, y4, x3, y3});
        } else if (directionType == "face") {
            container.push_back(case_input_t{x1, y1, x2, y2, x4, y4, x3, y3});
        } else { // if (directionType == "back")
            container.push_back(case_input_t{x2, y2, x1, y1, x3, y3, x4, y4});
        }
    }
    
  • 最后将上面所说的向量扩展成直线/射线/线段的具体对象。这样每个对象都有三种情况,因此可以两两组合。我们利用了宏和辅助函数对GoogleTest的单元测试函数进行了封装,便于我们快速构造样例:

    // Macro functions
    #define TEST_LINE_OVERLAP_INTERSECTED(lineTypeA, lineTypeB, overlapType, directionType, expected, numIntersections) \
    TEST(ExceptionTest, lineTypeA##_##lineTypeB##_##overlapType##_##directionType) { \
        runCase((LineType::lineTypeA), (LineType::lineTypeB), #overlapType, #directionType, (expected), (numIntersections)); \
    }
    
    #define TEST_LINE_OVERLAP(lineTypeA, lineTypeB, overlapType, directionType, expected) \
    TEST_LINE_OVERLAP_INTERSECTED(lineTypeA, lineTypeB, overlapType, directionType, expected, 0)
    
    
    // case 0:   --------    .........  (divide)
    
    TEST_LINE_OVERLAP(LINE, LINE, divide, left, false)
    TEST_LINE_OVERLAP(LINE, LINE, divide, right, false)
    TEST_LINE_OVERLAP(LINE, LINE, divide, face, false)
    TEST_LINE_OVERLAP(LINE, LINE, divide, back, false)
    ... // (LINE, HALF, SEG) x (LINE, HALF, SEG) --> 6 combinations
    TEST_LINE_OVERLAP(SEGMENT_LINE, SEGMENT_LINE, divide, left, true)
    TEST_LINE_OVERLAP(SEGMENT_LINE, SEGMENT_LINE, divide, right, true)
    TEST_LINE_OVERLAP(SEGMENT_LINE, SEGMENT_LINE, divide, face, true)
    TEST_LINE_OVERLAP(SEGMENT_LINE, SEGMENT_LINE, divide, back, true)
    
      
    // case 1:   --------........       (cat)
    
    TEST_LINE_OVERLAP(LINE, LINE, cat, left, false)
    TEST_LINE_OVERLAP(LINE, LINE, cat, right, false)
    TEST_LINE_OVERLAP(LINE, LINE, cat, face, false)
    TEST_LINE_OVERLAP(LINE, LINE, cat, back, false)
    ... // (LINE, HALF, SEG) x (LINE, HALF, SEG) --> 6 combinations
    TEST_LINE_OVERLAP_INTERSECTED(SEGMENT_LINE, SEGMENT_LINE, cat, left, true, 1)
    TEST_LINE_OVERLAP_INTERSECTED(SEGMENT_LINE, SEGMENT_LINE, cat, right, true, 1)
    TEST_LINE_OVERLAP_INTERSECTED(SEGMENT_LINE, SEGMENT_LINE, cat, face, true, 1)
    TEST_LINE_OVERLAP_INTERSECTED(SEGMENT_LINE, SEGMENT_LINE, cat, back, true, 1)
      
    
    // case 2:   ------.-.-.-......     (overlap)
    
    TEST_LINE_OVERLAP(LINE, LINE, overlap, left, false)
    TEST_LINE_OVERLAP(LINE, LINE, overlap, right, false)
    TEST_LINE_OVERLAP(LINE, LINE, overlap, face, false)
    TEST_LINE_OVERLAP(LINE, LINE, overlap, back, false)
    ...
    

    其中TEST_LINE_OVERLAP的最后一位参数true/false表明该种情况是否合法。而TEST_LINE_OVERLAP_INTERSECTED的最后两个参数不但检查是否合法,还检查了是否相交、交点个数。

我们共生成并测试了149个单元测试例,涵盖了100%的代码行,并在每一次Git Commit前重新测试,以达到“回归测试”的效果。

核心模块代码质量分析

第一次运行分析时警告信息较多,主要问题是使用了不安全的函数和宽窄类型间隐式转换存在溢出风险:

(这里由于终端编码集设定问题显示乱码,但由于定位到代码后弹出的气泡框动态提示没有乱码因此不影响理解)

针对这些问题做了如下修改:

  • 使用带有失败标记及溢出检查的安全函数替换不安全的函数。这里需要说明的是,对于vs建议使用的fopen_s等函数,由于其不是标准C++实现,考虑到兼容性我们最终没有采用,通过显式添加编译头#define _CRT_SECURE_NO_WARNINGS关闭这些警告
  • 为计算过程添加显式类型转换。如在计算中将整形先转换为64位长整型再进行乘法运算。

全部修改后再次运行代码质量分析,不再报错误和警告:

核心模块异常处理*

异常处理部分由 @MisTariano 贡献设计与大部分代码, @FuturexGO 调整与重构。

为了使核心模块具有高度的兼容性,我们决定不使用异常类、try-catch等写法,而是采用了类似操作系统中的写法,将异常错误码和错误信息打包进结构体,在每个函数返回错误码,数据结构如下:

enum ERROR_CODE {
    SUCCESS,
    WRONG_FORMAT,
    VALUE_OUT_OF_RANGE,
    INVALID_LINE, INVALID_CIRCLE,
    LINE_OVERLAP, CIRCLE_OVERLAP,
};
struct ERROR_INFO {
    ERROR_CODE code = SUCCESS;
    int lineNoStartedWithZero = -1;
    char messages[50] = "";
};

其中每一类的错误具体设计如下(由于错误检测和抛出是公有标准接口的一部分,因此与另一组的 @PX_L 共同设计完成):

  • WRONG_FORMAT:输入不符合文法的情况,如缺失行、缺失字段、形状标识符不在C、L、R、S中、字段不是整数等。实现时,我们使用scanf/fscanf/sscanf("%s", word)每次取一个字符串token,再利用strtol尝试将其转换为整数。单元测试样例为:

    TEST(ExceptionTest, InvalidInput1) {
        gManager *mng = createManager();
        FILE *filein = fopen("../data/invalid_input1.txt", "r");
        EXPECT_EQ(addShapesBatch(mng, filein, nullptr, nullptr).code, ERROR_CODE::WRONG_FORMAT);
        EXPECT_EQ(getIntersectionsCount(mng), 3);
        closeManager(mng);
    }
    // invalid_input1.txt:
    5
    L 0 1 1 1
    L 1 0 1 1
    C 0 0 1
    LL 0 1 1 1
    L 0 -1 1 -1
    

    首先尝试从文件批量添加图形,应该得到WRONG_FORMAT的错误码。截止到发生错误的前一刻,成功添加的三个图形产生了三个交点。

  • VALUE_OUT_OF_RANGE:图形参数超过\((-100000, 100000)\)的限制(注意到圆半径非正不属于此异常)。测试例为:

    TEST(ExceptionTest, InvalidShape7) {
        gManager *mng = createManager();
        FILE *filein = fopen("../data/invalid_shape7.txt", "r");
        EXPECT_EQ(addShapesBatch(mng, filein, nullptr, nullptr).code, ERROR_CODE::VALUE_OUT_OF_RANGE);
        EXPECT_EQ(getIntersectionsCount(mng), 0);
        closeManager(mng);
    }
    // invalid_shape7.txt:
    5
    L 0 1 1 1
    L 1 0 10000000 1
    C 0 0 1
    L 0 1 1 1
    L 0 -1 1 -1
    

    首先尝试从文件批量添加图形,应该得到VALUE_OUT_OF_RANGE的错误码。截止到发生错误的前一刻,成功添加的三个图形产生了零个交点。

  • INVALID_LINE, INVALID_CIRCLE:不是一个线/圆图形,如直线两点重合、圆半径非正数。测试例为:

    TEST(ExceptionTest, InvalidShape3) {
        gManager *mng = createManager();
        FILE *filein = fopen("../data/invalid_shape3.txt", "r");
        EXPECT_EQ(addShapesBatch(mng, filein, nullptr, nullptr).code, ERROR_CODE::INVALID_CIRCLE);
        EXPECT_EQ(getIntersectionsCount(mng), 1);
        closeManager(mng);
    }
    // invalid_shape3.txt:
    5
    L 0 1 1 1
    L 1 0 0 1
    C 0 0 0
    L 0 1 1 1
    L 0 -1 1 -1
    
    TEST(ExceptionTest, InvalidShape4) {
        gManager *mng = createManager();
        FILE *filein = fopen("../data/invalid_shape4.txt", "r");
        EXPECT_EQ(addShapesBatch(mng, filein, nullptr, nullptr).code, ERROR_CODE::INVALID_LINE);
        EXPECT_EQ(getIntersectionsCount(mng), 0);
        closeManager(mng);
    }
    // invalid_shape4.txt:
    5
    L 0 1 1 1
    L 1 0 1 0
    C 0 0 1
    L 0 1 1 1
    L 0 -1 1 -1
    

    在第一例中,虽然第四个图形与第一个图形重合,但在此之前第三个图形圆已经非法,应该得到INVALID_CIRCLE。截止那时,成功添加的两个图形产生了一个交点。

    在第二例中,虽然第四个图形与第一个图形重合,但在此之前第二个图形直线已经非法,应该得到INVALID_LINe。截止那时,成功添加的一个图形产生了零个交点。

  • LINE_OVERLAP, CIRCLE_OVERLAP:线-线产生无穷多交点、圆-圆产生无穷多交点。测试例为:

    TEST(ExceptionTest, OverlapFromFile) {
        gManager *mng = createManager();
        FILE *filein = fopen("../data/overlap_batch.txt", "r");
        EXPECT_EQ(addShapesBatch(mng, filein, nullptr, nullptr).code, ERROR_CODE::LINE_OVERLAP);
        EXPECT_EQ(getIntersectionsCount(mng), 1);
        closeManager(mng);
    }
    // overlap_batch.txt:
    3
    L 0 0 1 1
    L 0 0 -1 1
    L 1 1 0 0
    

    在上例中,第三个图形与第一个图形重合,应该得到LINE_OVERLAP。截止那时,成功添加的两个图形产生了一个交点。圆-圆重合的例子较为显然,此处略。

GUI模块的具体设计*

由于追求跨平台能力,我们以Qt5为基础搭建GUI,借助QCustomPlot类库来绘制基本图形。支持的功能包括:

  • 批量导入图形:浏览并读取txt格式的文本文件,从文件中批量导入图形描述,并支持基本的文件格式错误处理。
  • 添加单个图形:在窗口右侧工作区输入参数并选择类型后,点击添加按钮可以将单个图形加入画布。
  • 画布选择并删除图形:在画布上单击图形使其被选中(颜色变蓝),再按下Delete键,即可将其从画布删去。若按住左Ctrl则可以复选多个图形,并批量删除。
  • 从列表选择并删除图形:在窗口右侧工作区的图形列表中选择一个或多个图形后,按下删除按钮,可以将选中的列表项对应的图形删去。
  • 交点求解:借助核心计算库实现的交点求解。所有画布上的图形间一旦产生交点会立即被以黑色实心点标出。
  • 鼠标滚轮缩放画布。用户使用鼠标滚轮缩放以调整视野大小。

以删除图形功能为例,我们重载了keyPressEvent以响应键盘事件delete键:

void MainWindow::keyPressEvent(QKeyEvent * event) {
    if(event->key() == Qt::Key_Delete){				   	// detected "delete" key
        auto items = ui->plot->selectedItems();		// get selected QCustomPlotItem(s)
        for(auto& item: items){
            auto name = item->objectName();
            ui->plot->removeItem(item);						// remove shape from GUI figure
            auto shape = shapes.find(name);
            if(shape != shapes.end()) {						// remove shape from GUI list
                shapeListModel.removeRow(shape->index.row());
            }
            shapes.remove(name);
        }
        cleanFigure(gfig);												// STDINTERFACE: recalculate points
        for(const auto &shapeItem: shapes.values()) {
            auto &shape = shapeItem.gshape;
            addShapeToFigure(gfig, {shape.type, shape.x1, shape.y1, shape.x2, shape.y2});
        }
        if(getShapesCount(gfig) == 0) {
            nextGraphId = 0;
        }
        replotPoints();														// replot points fetched from STDINTERFACE
        ui->plot->replot();
    }
}

检测到删除操作后,我们从QCP(QCustomPlot)框架中拿到待删除QCPItem,从GUI程序中维护的数据结构中查询到其属性,并在GUI的画布和列表中将其删除。之后,我们调用核心模块的接口以重新计算剩余形状的交点,最后在replotPoints()中取出这些交点拷贝到GUI的存储中,再进行重绘。

GUI与核心模块的对接*

在Qt中负责实现响应事件功能的是信号-槽机制。每个继承自QObject的对象sender都可以发射一个信号signal以表明其状态被改变。槽是普通的C++成员函数,作为函数指针member被关联,每个对象receiver都可以关联它的成员函数作为槽。

将信号-槽关联的函数原型为:

bool QObject::connect(const QObject *sender, const char *signal,
                      const QObject *receiver, const char *member);

因此为实现功能,我们定义了如下这些槽函数作为对基本信号(如点击按钮、点击鼠标、按下删除键)的响应:

private slots:
    void on_actionOpen_triggered();
    void on_shapeTypeComboBox_currentIndexChanged(int);
    void on_addShapeButton_clicked();
    void on_plot_mouseWheel(QWheelEvent*);
    void on_plot_mouseMove(QMouseEvent*);
    void on_plot_mousePress(QMouseEvent*);
    void on_deleteButton_clicked();

这些对信号的响应可以按UI的设计逻辑组合,再调用具体的行为函数,如画图、画点、重绘等:

QCPAbstractItem *drawCircle(const QString &id, int x, int y, int r);
QCPAbstractItem *drawHalfLine(const QString &id, int x1, int y1, int x2, int y2);
QCPAbstractItem *drawSegmentLine(const QString &id, int x1, int y1, int x2, int y2);
QCPAbstractItem *drawLine(const QString &id, int x1, int y1, int x2, int y2);
QString plotShape(char type, int x1, int y1, int x2, int y2);
void drawPoint(double x, double y);
void replotPoints();

在这些函数中,我们StdInterface.h中的接口函数被调用。

因此总结来看,接口函数被调用的路径是:

  1. 槽函数响应事件;
  2. 事件组合出的逻辑(类似状态机)调用执行特定功能的函数;
  3. 执行特定功能的函数可能调用接口函数以向核心模块或从核心模块同步数据更新。

同时,为了将QCP的对象QCPItem与核心模块内的几何图形建立联系,我们使用自定义数据结构和map将它们联系起来,实现索引的功能:

struct shape_item_t {
    gShape gshape;					// STDINTERFACE
    QCPAbstractItem *item;
    QPersistentModelIndex index;
};
QMap<QString, shape_item_t> shapes;

以导入文件为例,on_actionOpen_triggered()被按钮触发,决定导入文件。从QFileDialog得到文件名后,我们调用接口addShapesToFigureFile对核心模块内的数据进行更新,同时也维护GUI内部的数据和图形界面,保持核心模块内外同步:

void MainWindow::on_actionOpen_triggered() {
    ...
    ERROR_INFO err = addShapesToFigureFile(gfig, fname.toStdString().c_str());
    if(err.code == ERROR_CODE::SUCCESS){		// 成功计算交点, 无异常
        updateShapes(gfig);
        int nShape = getShapesCount(gfig);
        for(int i = 0; i<nShape; ++i) {
            auto &shape = gfig->shapes[i];	// 取出gShape, 一个个绘制图形
            QString id = plotShape(shape.type, shape.x1, shape.y1, shape.x2, shape.y2);
        }
        replotPoints();											// 取出gPoint, 批量绘制交点
    } else {
        cleanFigure(gfig);									// 发生异常, 取出异常提示信息串、异常行号等信息
        QMessageBox::warning(this, "批量导入失败", err.messages+QString("\n At line ")+QString::number(err.lineNoStartedWithZero));
    }
    ...
}

效果如下图所示:

松耦合展示

我们与 @PX_L 小组进行了松耦合实验。我们使用CMakeFile维护跨平台的工程结构与编译链接选项。因此我们的松耦合实验可以在多平台上进行:

  • 我们在MacOS上进行了命令行程序调用核心模块的实验,涉及到互换的动态链接库为libgCore.dylib
  • 我们在MS Windows上进行了GUI程序调用核心模块的实验,涉及到互换的动态链接库为libgCore.dlllibgCore.lib

命令行程序接入两个版本的核心模块

我们构造了三组样例进行测试,分别为:

  • same_ans.txt。两组的程序在它上的输出相同。
  • different_ans.txt。两组的程序在它上的输出由于精度问题处理不同,答案不相同,相差1。
  • exception.txt。两组的程序在它上都能检测异常,但异常提示信息不同。

测试的过程如下:

  • 两组各自编译出自己的动态链接库,分别命名为xwl_libgCore.**为dll、lib、dylib等)和lpx_libgCore.*
  • 撰写main.cpp,并引用接口声明文件 StdInterface.h。由于我们各自的customInterface不同,GUI和命令行程序的需求也不同,因此我们通过一次会议统一了标准接口,规则是兼容两组的功能需求,选取一个“最小公倍数”对现有代码进行增加,无需舍弃各自小组之前的设计
  • 使用链接命令,将名称为libgCore.*的库文件链接进main.cpp的目标可执行文件中
  • 分别在libgCore.*缺失、拷贝自xwl_libgCore.*、拷贝自lpx_libgCore.*的情况下运行可执行文件。过程中不重新编译,不修改代码

下面是测试结果:

期望相同输出:

期望不同输出:

期望检测相同异常,但抛出各自的自定义异常信息:

可以看出:

  • 可执行程序在缺失动态链接库的条件下无法运行。
  • 可执行程序在给定两个动态链接库中的任意一个都能正常运行。
  • 直接更换动态库能实现接入两组实现的不同后端的需求,并获得不同的输出和性能表现。

因此证明我们成功实现了计算模块gCore的松耦合。

在实验中,起初我们各自编码时认同的 StdInterface.h 有微小的差异,其中几个函数的签名不同:

// xwl, et al. 认同的接口
ERROR_INFO addShapeToFigureString(gFigure *fig, const char *desc);

// lpx, et al. 认同的接口
ERROR_INFO addShapeToFigureString(gFigure *fig, char *desc);

这样,编译生成动态链接库的过程是独立的,不会出现错误。然而在撰写main.cpp时应当#include "StdInterface.h",这样的头文件是唯一的,必须将标准统一才能成功运行,否则将会发现找不到所需的接口函数,因为上述两个函数的函数签名不同,本质上它们被编译器认为是两个不同的函数。

因此我们将头文件统一,修改了各自的接口的代码,之后便成功完成实验。

GUI程序接入两个版本的核心模块

类似上文中CLI环境下的测试,我们在GUI中也测试了使用不同dll的运行效果。需要注意的是,对于正样例而言,即使更换了实现GUI的行为差异也很难看出(交点之间过于密集时会难以分辨两种实现的差别),因此在这里我们主要测试了负例,即程序在添加图形遇到异常时观察GUI输出的错误信息。

  • case 1:尝试添加与现有形状存在重合的形状。此时我们已经添加了直线\(y=x\)并尝试再次添加

当可执行文件同级目录下提供我们实现的libgCore.dll时:

当可执行文件同级目录下提供lpx & yzn小组实现的libgCore.dll时:

  • case 2:当尝试打开存在非法形状类型描述的文件时

我们尝试打开这份测试文件:

5
L 0 1 1 1
L 1 0 1 1
C 0 0 1
? 0 1 1 1
L 0 -1 1 -1

注意到第五行(从0下标开始算的第4行)的形状类型是?,这是错误的,因此期望报错。

当可执行文件同级目录下提供我们实现的libgCore.dll时:

当可执行文件同级目录下提供lpx & yzn小组实现的libgCore.dll时:

  • case 3:当尝试打开不完整的文件时

我们尝试打开这份测试文件:

5
L 0 1 1 0
L 1 0 1 1

当可执行文件同级目录下提供我们实现的libgCore.dll时:

当可执行文件同级目录下提供lpx & yzn小组实现的libgCore.dll时:

  • 小结

可以看到对相同的异常情况,在加载不同dll运行时GUI程序给出了不同的报错信息——前后没有经过重新编译,而只是替换了dll文件而已。可见替换dll文件动态地更改了程序运行时的行为,起到了松耦合的效果。

结对过程*

我们主要使用腾讯会议实现结对编程的实时互动。腾讯会议的主要特点是可以实时共享屏幕同时通话,但单凭腾讯会议无法做到代码的管理。

img

因此我们使用GitHub进行源代码的管理。我们使用到的主要特色包括:

  • 分支 Branch。我们将工程分为若干个阶段,每开始构建一个阶段就新开出一个分支,完全debug、重构、测试后,才并入master分支。

    img

  • Pull Request。我们在将完成的分支合并到master时使用Pull Request进行逐行级别的代码复审和新增功能&潜在BUG记录。作者A主导完成某Branch后,作者A就发起一个Pull Request,并指定另一作者B为Reviewer对代码进行审查。

  • Releases&Tags。我们使用tags对commit进行分支无关的标记,进度统计更加明确,无需考虑分支的分离和合并;也让代码分享(如与其他人交流设计公有接口)更加简单。

反思结对编程

这是我第一次体验结对开发,是相当有趣的一次体验。在和xwl同学结对编程的过程中,无论是我写他看还是他看我写,都和个人开发、传统的两人分工开发的感觉完全不一样。首先是沟通不再有延后性,出现了任何问题我们可以第一时间进入讨论,并且不需要任一方花费额外时间去理解问题(因为两个人都看着代码,出现问题时双方都很清楚自己正在面对什么情况),并且一旦一方产生疑问另一方可以立即进行答复,这无疑提升了开发时的效率。此外,审阅者也可以及时发现编码者的错误,这比起事后对全部代码进行复审无疑是更高效的。

在设计接口、重构代码等需要大量讨论的工作中,这种即时性会最大程度展现。我们在制定公有接口时,采用的就是我写、xwl看的模式,我的设计有任何缺陷他可以立即对着代码指出(所谓趁热打铁),而我有一些难以简单描述的代码设计思路时也可以立即写出来伪代码向他展示,双方的沟通因此变得高效起来。

当然,有些工作受结对影响不大。比如查阅Qt的GUI文档时,实际上与自己一个人开发相似,因为检索的过程不需要太多讨论与沟通——如果结对开发时大部分时间都在做类似的工作,实际上效率反而会降低。所以我们后期的做法是分工学习与结对开发结合的形式,两人各自完成调研、检索、文档学习、仓库维护等工作,再结对解决比较关键的开发工作。个人认为这也混合的开发模式效果很好。

在本次开发中,我们依次完成了

  • 交点计算作业对射线、线段的扩展
  • 高性能、高精度的交点计算程序优化
  • 异常格式处理
  • 动态库封装与接口设计
  • 与另一组制定松耦合标准并实践松耦合
  • 一个基于交点库的Qt GUI

总的来说,我个人是非常满意的,能够在短短两周内完成如此多高质量的工作。这离不开队友xwl的帮助,他在代码优化、代码测试、接口设计、对外交流(?)等环节承担并完成了大量的工作,同时也攥写了本次课程博客的大部分章节。在这里表示诚挚的感谢!

同时也感谢和我们一起开展松耦合实验的两位同学lpx和yzn,他们不仅和我们合作完成了任务,还和我们保持积极的技术交流,为我们提供了很多设计思路。

最后,感谢课程组设计的这次作业。马上要进入团队开发了,希望接下来的学习继续如此酣畅淋漓。

posted @ 2020-03-24 02:07  MisTariano  阅读(516)  评论(6编辑  收藏  举报