结对编程小结与收获

BitTigerio 2018-04-20

和很多人一样,这次编程是痛苦的……是崩溃的……
这篇博文中1. 2. 3.和我们此前提交的结对编程项目总结十分相似,不想看的话从4.开始看起就可以了。

1. 初始设计

2. 遇到的问题

3. 最终实现

4. 个人感悟

  • 4.1. 关于编码
  • 4.2. 关于结对
  • 4.3. 关于对接
  • 4.4. 关于重构

5. 总结

1. 初始设计:

一开始,我们错误地理解了题意,认为要实现两个独立的功能:对用户提供的表达式进行求值,和按照设置随机生成表达式。

对于表达式求值模块,我们打算用最传统的方法,用运算符栈和运算数栈两个栈进行计算。这部分由我的队友实现。

对于生成表达式模块,考虑到题目要求较高的灵活性,我们打算使用二叉树表示算式,通过递归实现算式的生成、求值及翻译成字符串。
在这个部分,我们最初打算划分成许多个模块。从上而下,首先是Generator模块,实现与用户的交互、二叉树的生成;然后是Setting类,考虑到各种设置信息很多,用一个类负责保存各种设置信息和解析xml;其次是Node,即二叉树结点;然后是Num类,用来将三种类型,即整数、小数、分数,封装在一个对象里,从而上层可以直接调用;最后实现分数类Fraction,封装了各种运算。
这部分由我实现。

2. 遇到的问题:

我们遇到的第一个问题在于整体结构太过于繁琐。Generator, Setting, Node, Num, Fraction,五个模块、四层封装,太过于复杂;况且Num这一层次只是判断一下操作数类型,似乎比较鸡肋。经过讨论之后,我们去掉了Num这一层次,直接在Node中保存操作数信息。

我们遇到的第二个问题在于,突然从助教那里得知整数除法应该整除。我们原本考虑丢弃不能整除的式子重新生成,后来发现这样速度太慢。
最后,我们决定放弃绝对的随机性,而选择配凑。我们考虑了好几种配凑方式,最后决定对于整数的除法,通过给定一个整数值、配凑生成表达式的方式,生成被除数与除数的算式。
事实上,我们也考虑过整体更改为配凑方式,但配凑方式逻辑较复杂,而且难以处理乘方,所以我们只打算对整数除法进行配凑。不过到了后来,我们惊喜地发现乘方的幂次也可以配凑。

我们遇到的第三个问题在于对于dll的使用非常不熟悉。我们为此研究了很久,才终于攻克这一关。
首先,我们对于dll的概念非常不了解,为了了解这些基础概念就花了很久。
然后,非常尴尬地,我们原本打算导出Generator类,但是用dll导出类非常复杂。有两种可选的方案,一是将Generator及其属性的类全部导出,这样一来会暴露自己的内部实现,违反了封装原理;另外一个是导出抽象类,但是这个概念我们两人都不太熟悉,而且这样会增加一层调用,加大代码复杂度。
在我们和ui进行了进一步沟通之后,我们了解到ui其实只需要函数;再加上我们编码中也出现了不知道如何让Setting对象成为全局对象这一问题,我们决定直接使用全局变量与c函数接口。
最后,我们生成dll后不知如何运行。查找了很多资料之后,我们才学会通过另一个项目,即testDll项目,调用自己生成的dll。

我们遇到的第四个问题在于,我们发现用std::string作为接口似乎有一定问题。通过查找资料,我们查到不适宜用stl作为dll接口,而最好用纯c。因此我们提供了一个char数组的接口,本想删除std::string接口。不过考虑到似乎很多ui都使用c++,我们提供了两个接口。

另外,我们还陆陆续续解决了一系列除零、模零、随机数范围有问题之类的bug,并满足了某组ui的提供文件接口的需求。

3. 最终实现:

Fraction模块:和最初设想一样,封装分数的运算与显示功能。

Node模块:二叉树结点类。有操作符结点与操作数结点两种,操作数结点又分整数、小数、分数三种。有递归计算、递归解析为表达式和判断两个表达式是否等效功能。
对于判断表达式是否等效,思路如下:首先判断根节点是否相等,然后如果两子树非空,递归分别判断两子树是否相等。假如两子树不等,但根节点为+或*,则交换两子树,分别递归判断是否相等(即,加法与乘法可交换)。

Setting模块:保存各个变量值,通过几个函数进行设置,有输入检查,如果输入不合法就丢弃输入、采用默认值。本来我们打算用xml或json,但后来发现参数不多,而且ui组大多数没有使用xml / json,因此我们也没有提供相应接口。

Generate模块:调用Node与Setting,按照不同的设置生成表达式。
有两种生成方式:

  1. generate_tree,递归随机生成操作符、操作数,从而递归生成二叉树。
  2. generate_int_node(int val),通过给定的值配凑生成一个表达式,但只能生成整数表达式。
    这个模块提供了std::string接口、char数组接口与文件接口。

4. 个人感悟:

4.1. 关于编码:

虽然我们成功地实现了所有功能,和UI对接的时候也假装信心满满、我们做出来的东西天下第一牛逼,但其实至少我心里非常清楚其实有相当多的缺陷。几个耿耿于怀的问题如下:

首先,我们写了过多的重复逻辑
一开始,我们提供了std::string的接口,我们内部所有的字符串操作也是用std::string完成的。比如将分数转换成字符串,我们采用的方法便是对分数重载<<操作符,用std::stringstream转换为字符串。
然而,后来考虑到std::string有兼容性问题,我们增加了char数组的接口。当时考虑到需要防止越界访问,又想要增加效率,于是就用snprintf重写了一份;因此,对分数重载的<<操作符也用不了了,我们只能又写了一个toString函数。
现在想来,其实直接调用std::string的接口,比较一下size,如果没有超出size的话strcpy进去即可;就算一定要重写一个函数,也完全可以直接用stringstream的getline等函数。真的不知道自己当时脑子里在想什么。也许就是贪图那一点点效率吧,不过也是后来才意识到,这个作业中效率其实没有那么重要。
因此,最后的结果就是同样的代码逻辑被重复了好几遍,整个代码结构也变得相当混乱。到最后,虽然我已经检查了好几遍了,但我还是不太确定重复代码中逻辑是不是一致的、会不会有我注意不到的地方有不一致的地方,这样相当缺乏可读性和可维护性。引以为戒吧。

这也同样让我联想到《代码大全2》中说的,代码的各种优点有时候是负相关的,应该针对需求的特性去编程。这次为了增加一丁点点效率,我犯了重复代码逻辑的错误,从而降低了可读性和可维护性。同样,这次我也纠结了很久如何生成【绝对随机】的算式,却保证整数能够整除,后来才意识到绝对随机不是很必要。这说明,明确需求是很重要的!不能按自己的想法瞎写!

其次,这次我们的测试做得不够到位。
一来是因为一开始写代码图快,有点懒得写测试集,自己人工测试也懒得把各种情况一一测试,所以不少问题是被UI调用后发现的……(在此隆重感谢康鑫同学)。之后注意了加强了测试力度,问题也少了许多。
二来,是因为generate模块实在不知道如何测试……因为这个模块是随机生成式子,我们没法给出预期值,如果另外写一段解析表达式、判断表达式是否满足要求的代码感觉也不太现实,这段代码本身就可能会有很多bug……所以最后我们还是人工测试的。这里要感谢队友,他测试起来比我耐心多了=w=

最后,还是dll没有导出类的问题。虽然用dll导出类会有很多麻烦、这次大家很多人都是直接导出的函数,但是之后考虑还是觉得导出类更合理。因为我们建树时有动态申请内存,如果导出类可以在析构函数中清理内存,而现在这样导出函数只能在api文档中不停强调要调用clear函数记得调用clear函数啊一定要调用clear函数。
关于导出类的困难上面提过了,这次我们畏难求快没有尝试抽象类,但我打算之后试试。

4.2. 关于结对:

必须要相当愧疚地说,开始工作前我和队友其实对结对的了解都不太深刻,觉得就各写各的、有问题交流一下,最后合并一下就可以了。我们都觉得一个人写一个人看是一个相当形式化没什么卵用的东西。不知道队友是怎么想的,反正我最后觉得这个想法是很错误的,我们这次因为结对合作的问题导致完全没发挥结对应有的作用。

首先,我和队友在编码风格上不太一样,而且也没有事先约定。我偏向c++的编码风格,而且会非常夸张地空格、换行、分多文件……而我的队友比较偏c,代码写得比较紧凑。所以我们一开始的合作花了很多力气纠结风格的不一致,而非好好工作。
这给我的感悟是,很多东西一定要事先约定好。确实存在的矛盾就是确实存在的,不可能写着写着就消失,如果一开始不约定好肯定会浪费后面的时间。

第二个问题在于,我们一开始错误地理解了题意,我的队友一开始写的工作全都是无用功……(为什么我们两个都非常默契地同样地理解错了题意呢,果然是队友么)等到我们终于意识到题意理解错了的时候,我们两人都完成了各自要写的基本功能了,所以队友只能来理解我的代码,在我的基础上继续工作。
然而,一来我们基本没有一个人写、一个人看,二来我们风格相差太大,而且c与(半吊子)c++之间有着巨大的鸿沟,所以到后面队友也一直没有完全理解我的代码,导致我们合作非常尴尬。
理解错题意是真的没办法,所以这里需要吸取的经验主要在于怎样能够快速止损。
首先,一个人写一个人看还是有必要的,真的是有必要的。我因为自己编码习惯不太良好,会经常性地乱写、写到一般想起什么就去写别的之类的,所以不太想让别人看我写,自己因为懒也不想看别人写。但是后来才意识到,恰恰是习惯不好才需要别人看着写,才能纠正一些坏习惯。另外,看别人写代码的过程才能更好地理解代码的逻辑,也方便找出可能的错误、在现有代码基础上继续工作,而研究一个已经完成的代码真的非常痛苦。(我看过,真的让我绝望)假如我们从一开始就一个人看一个人写,后面的工作会容易很多。
其次,风格问题真的要从一开始就约定。(这是第二遍提了,这真的是血的教训)我之后问过队友,看代码的障碍究竟在哪里,才意识到他花了很多时间在处理我们二人风格上的不一致,而非理解我的代码逻辑。

最后,我们在沟通上也有很多问题,很多时候我也想当然地以为队友能够理解我的代码、而没有给出很多的帮助(我大概高估了自己代码的可读性吧),导致中途有一段时间我们相互拖累。以前一直以为沟通只是套话官话,现在才明白真的很重要。

总结而言,我们在一开始没有做好功课,对结对编程理解很肤浅、以为只是形式化的东西而已,所以根本没有从结对中获得太多的好处,整体体验和个人作业没什么差别。
没法写什么积极的收获了,但消极的收获也算是收获吧……

4.3. 关于对接:

关于对接,我从一开始就很迷茫,不太理解到底如何对接;在群里看到dll后,我就立马查了很多资料,花了好一段时间改成了dll,然后就非常热心地向团体项目的做UI的队友兜售我的半成品Core,因为我真的非常害怕对接出错,想早点对接。
对接过程整体而言是比较顺的,但是还是有几个问题:

首先,我发布出去的dll没有经过足够多的测试,我经常只是试了试能够工作、某几种情况没问题就随便发出去了。这种敷衍以后应该避免=w=

第二,我dll更新太多,给一开始跟我对接的UI组带来了很大的困扰……其实我现在也不太明白更新应该是怎样的频率,确实时不时就有些新发现的bug需要修复,但是太快更新确实不合适。还是说这说明我们发布dll太早了?希望有人能够指教。

第三,我一开始没太关注UI组的需求,具体表现在某组UI强烈要求我使用文件接口但我觉得这样不好就坚持用std::string接口=w=后来还是增加了文件接口,而且意识到这样不太好。应该尽量满足UI需求。

对接还有一个要点就是要及时回应UI反映的问题,不过这个我自我感觉挺好的……基本上UI发的消息只要我在线就秒回,就算身边没电脑也立马用手机去github上面查代码有没有出错,以至于最后几天我一看到有UI的人跟我发消息就紧张……做乙方真累啊。

4.4. 关于重构:

我们的初始规划、中间过程和最后架构上面提到了,可以看到我又重构了……上次做个人作业我就非常崩溃,被自己重构到想哭了,当时几乎要发誓坚决不重构。然而这一次还是重构了。

就非常崩溃,不知道为什么一开始的架构总是错的。至今为止的编程,有时候架构多了、整体臃肿而冗余,有时候架构少了、各个功能过度耦合。有的功能可以合并有的功能应该分开。道理自然都知道,但是怎么判断是多了还是少了还是正好?怎么判断什么功能应该合并什么应该分开?No idea.

之后群里也讨论这个问题,看到某位大佬说重构是很正常的事情,新手很难一开始就选择正确的架构的。豁然开朗,也感到了安慰,看来不是我的问题,实践是检验真理的第一标准,很多东西要写了才知道有问题。架构很大程度上不是一个理性的东西,而是一个感性的东西,需要很多经验积累,慢慢养成一种直觉。(所以说以后还是要重构……)

具体到这次来说,我学到的经验和上次差不多,不要盲目地增加封装的层数。

5. 总结:

总之虽然这次写得很糟心但还是有很多收获……
谢谢看到这里!

相关推荐