一、第四单元的架构设计
第一次UML作业
第一次作业比较简单,仅包含类图的解析。正确理解UML元素的含义,以及每种UMLElement
的各个属性的所指向的东西,就能比较容易地完成此次作业。
这里我构建了两个类:ClassRelations
以及InterfaceRelations
用来存储类和接口所包含的属性、方法等类图的基本信息,同时还要保存父类(父接口)、实现的接口,用来递归查找。是一种比较容易想到的实现方式。
类图如下:
为了加快对ClassName
的查找和判断,使用了一个邻接表来存储同名的类,方便查找相应的类。
private HashMap<String, ArrayList<UmlClass>> classNameMap = new HashMap<>();
private void checkClassName(String className)
throws ClassNotFoundException, ClassDuplicatedException {
if (!classNameMap.containsKey(className)) {
throw new ClassNotFoundException(className);
} else if (classNameMap.get(className).size() > 1) {
throw new ClassDuplicatedException(className);
}
}
第二次UML作业
第二次作业在第一次作业的基础上,增加了对UML状态图和UML时序图的解析。此外,还增加了三个模型有效性检查的规则,分别是:不含重名的成员、不能循环继承、不能重复实现接口。
与第一次作业相比,整体的架构变化不大,总的思想仍然是把每一种UML元素放到对应的UML图中,根据需求对这些数据加以维护。整体稍加改动后直接继承上次的作业。
本次实现的MyUmlGeneralInteraction
直接继承了上次的MyUmlInteraction
。对于状态图和时序图,与上次作业类似的,我创建了两个类StateMachineRelations
和SequenceRelations
用来保存与状态图和时序图相关的信息。
类图如下:
对于模型有效性的检验
R001:针对下面给定的模型元素容器,不能含有重名的成员(UML002)
与检测类名重复类似的,在
ClassRelations
类中,添加属性和关联对端的时候,对同名的属性和关联对端进行计数,就可以获得找到重名的成员。R002:不能有循环继承(UML008)
R003:任何一个类或接口不能重复继承另外一个接口(UML009)
这两个规则都是和继承或接口的实现相关的,所以放在一起处理。继承/实现关系构成了一个有向图,循环继承和多次实现接口的优先图,具有如下特点:
- 循环继承的充要条件是:有向图中存在环(节点到节点自身存在一条路径)
- 重复继承的充要条件是:有向图中,一个节点到另一个节点存在不止一条路径
所以,将边权定义为优先图节点之间的路径数目,那么也就能同时判断是否存在循环继承和重复继承
对于节点
i
到节点j
的路径数目,对于中间节点k
,存在如下的关系:route(i, j) = route(i, j) + route(i, k) * route(k, j)
(貌似是动态规划的状态转移?有没有算法大佬可以讲一下,没学过不是很懂。顺便期待下学期算法有一个好的收获)
使用
UmlClassOrInterface
接口对类和接口的关系统一建模,具体的代码如下:private HashMap<UmlClassOrInterface, HashMap<UmlClassOrInterface, Integer>> extensionGraph = new HashMap<>(); //包含继承关系和接口实现关系 public static <T> void floyd(HashMap<T, HashMap<T, Integer>> graph) { for (T k : graph.keySet()) { for (T i : graph.keySet()) { if (i.equals(k) || !graph.get(i).containsKey(k)) { continue; } int ik = graph.get(i).get(k); for (T j : graph.keySet()) { if (k.equals(j) || !graph.get(k).containsKey(j)) { continue; } int kj = graph.get(k).get(j); if (!graph.get(i).containsKey(j)) { // 更新路径数 graph.get(i).put(j, ik * kj); } else { int newCnt = graph.get(i).get(j) + ik * kj; graph.get(i).put(j, newCnt); } } } } } protected Set<UmlClassOrInterface> check008() { Set<UmlClassOrInterface> set = new HashSet<>(); for (UmlClassOrInterface i : extensionGraph.keySet()) { if (extensionGraph.get(i).containsKey(i)) { set.add(i); } } return set; } protected Set<UmlClassOrInterface> check009() { Set<UmlClassOrInterface> set = new HashSet<>(); for (UmlClassOrInterface i : extensionGraph.keySet()) { for (UmlClassOrInterface j : extensionGraph.get(i).keySet()) { if (extensionGraph.get(i).get(j) > 1) { set.add(i); } } } return set; }
二、四个单元中架构设计及OO方法理解的演进
架构设计是面向对象这门课的重中之重。由于我们的课程是按照单元推荐,每个单元的每一次作业都是在前一次的基础上,进行增量任务,所以一个好的架构可以让后续的作业更加容易完成。
第一单元:多项式求导
前两次的作业因为需求还比较简单,还算是有一个比较看得过去的架构。把多项式分解为单项式,再把单项式分解成幂函数和三角函数,一个多项式的求导问题就被分而治之。就是降低耦合,抽象出各个不同的类,将它们独立出来,再把每个对象组合在一起来统一处理。
但是随着第三次作业的发布,这个架构设计的弊端就显现出来了。扩展性较差,导致第三次作业加上嵌套求导之后,几乎推倒重写。还是说明前两次作业对面向对象机制的理解不够深刻,以及对Java语言掌握不够熟练(不会使用接口泛型等特性)
第二单元:多线程电梯
多线程电梯调度主要就是要理解清楚生产者-消费者模型以及发布-订阅模型这些课上讲到的重要的多线程编程的设计模型。多线程最重要的问题就是线程的同步与互斥、线程间通信的问题。
第二单元多线程电梯调度主要运用的就是“生产者-消费者模型”,构建一个两级的关系,通过共享队列在线程间传递信息。输入线程与调度器线程通过共享队列交互,调度器线程再与电梯线程通过共享队列交互。这个架构设计延续了三次作业,主要的变化都是调度器内部和电梯内部的调度算法。
这个单元,课上老师也介绍了一些设计模式,比如单例模式、工厂模式等等。虽然实际写代码的时候没有特别注意使用这些设计模式,但是这些学习设计模式的思想,也是在知道我们怎么去设计类,怎么去设计接口,使得程序具有良好的扩展性和鲁棒性。当然,最重要的还是理解了多线程编程的方式和要点。
第三单元:JML规格设计
Java Modeling Language 是用于Java语言建模的语言,是一种契约化的设计思想。这个单元主要是围绕着图论展开的,由于有JML规格作为提示,整体比较顺畅。作业难度依次递增,从路径到地铁图层层递进。将不同的需求(最短路径,最少票价,最少换乘)分别用不同类的建模完成,降低耦合。由于都是图,所以把通用的图的算法单独提出来放到一个类,作为静态方法来调用(其实这样是有点危险的,一旦这里出错,所有地方都出错了,呜呜呜我就错在这了)
JML为程序的开发设计提供了一个统一的规范规格,虽然估计JML实际应用不是很多,JML描述的规格,对方法、类等程序单元进行了严格的约束,这些正确详实的规格,相较于自然语言,能更加规范地描述需求,减少歧义,保证开发的速度与质量。这种”先设计规格再实现“的约定对于大型工程的协同开发有很多的好处。
第四单元:UML图解析器
这一单元主要的任务是解析UML图中的各种元素。理清楚UML各个元素的各个字段的意义以及之间的关系以后,推进的就比较顺利。
根据UML图的组织形式,可以很容易地联想到仿照UML图地组织形式来构建每个类,按照类图、顺序图、状态图分别构建模型和存储相应数据。把实现的各部分划分成不同的责任单元,建立各个类来分别负责完成各自的任务。与第三单元有些类似。
三、四个单元中测试理解与实践的演进
第一单元:多项式求导
学习了讨论区编写测试脚本的方法,我采用如下的方式来发现别人的Bug:
- 把所有人的代码编译打包为.jar文件
- 编写测试数据(人工或自动生成),保存在
testData.txt
文件中 - 编写bash脚本,在Windows平台使用
Git bash
执行 - 编写Python脚本
sympy
用来计算标准答案,以及互测屋所有输出的答案 - 将第三步和第四步的数据输出保存到
log.txt
文件中,人工比对
使用到的工具包括:Python Sympy, Git bash, VS code等。
第一单元由于没有官方包的介入,输入输出都是自己处理,所以WRONG FORMAT是测试的重中之重。
第二单元:多线程电梯
多线程编程比较特殊,由于多线程并行具有不确定性,且不同的调度算法会造成不同的输出结果,所以不存在唯一的正确答案,评测机实现起来也有点复杂。所以我自测阶段,没有进行大量数据的测试。
互测阶段,主要是读代码,看有没有出现一下几种问题:
- 线程安全问题(共享资源有没有上锁)
- 轮询,容易造成CPU超时
- 调度算法,容易造成运行总时间超时
第三单元:JML规格设计
第三单元使用了很重要的一个测试工具:JUnit。
单元测试是一个强有力的测试工具。相比大量数据的黑盒测试,JUnit单元测试可以在更快速的找到代码漏洞,花费更少的时间,达到很好的验证正确性的效果。JUnit还有一个有点是有测试覆盖率的指标,这是随机数据的测试所不能比拟的。
除了使用JUnit进行测试,还构造了一个比较强的随机数据生成器来进行测试,与同学的输出进行比较。(还是晚了,发现Bug的时候已经截至了)
第四单元:UML图解析器
第四单元用StarUML花了几个比较特殊的图,比如重复继承、循环继承相关,以及带多的环的状态图等特殊情况,来测试代码的正确性,并重点测试了几个用到了深度优先搜索算法的指令。期末考试也比较忙,测试频次不是很多。
测试先行是我印象最深的。
无论是什么工程,写代码、连电路、焊板子这些,最重要的都是进行全面的测试。测试一定要和编写代码同步进行,或者先于写代码完成。第三单元最后一次作业惨痛的教训让我记住了“测试先行”这个道理,不要等到最后再匆匆忙忙测试然后提交,一个隐蔽的错误造成的可能就是满盘皆输。
四、总结自己的课程收获
- 优秀的IDE是事半功倍的前提:IDEA真的很好用
- 能较为熟练地使用Git、GitLab、UML等现代工具
- 比较熟练的掌握了一门面向对象的编程语言:Java,以及良好的代码风格
- 了解并掌握了多线程并发编程的编程方式和技巧
- 收获了面向对象的程序设计思想、原则
- 基本的软件测试意识、能力、方法技巧,
以及如何做一个狼人 - 软件架构设计意识和一些常用的设计模式,比如工厂模式、单例模式等
- 抗压能力和加班能力,
没有周末,是为以后996做准备 - 迭代开发的能力,
以及作为一个成熟地乙方应该要安心接受甲方(助教组和老师)的需求变动
一个学期的OO课程终于结束啦。就像爬山一样,现在到了山顶终于可以喘口气了。每周的作业走在催逼着自己不断前进,虽然一个学期基本没过过一个舒服完整的周末,但回过头来看,这一万多行代码,每个单元博客的总结记录,看得到这一路上的进步和收获。虽然有做的不尽人意的地方,但还是蛮有成就感的。
至少最后表彰总结课上没有空手回去?
五、对面向对象课程的建议
开个测试分享区,可以分享测试机或者测试数据
第二个是希望互测能有些改变,这个我在第一单元的博客中也写到过。
这三次的互测后,我相对现在的互测制度提一点小小的建议:
- 仍然划分A, B, C三档,但是分组把这三组混合编组,例如8人间可以{2A, 3B, 3C},7人间{2A, 3B, 2C}
- 找到某个等级作业的Bug得对应等级得分,而与自己的作业等级无关
- 这样做得好处是:每个人都可以看到不同水平的代码,给C组和B组的同学更多学习的机会;避免了高段位“大眼瞪小眼”的尴尬,还有低段位“菜鸡互啄”的无趣;找到高段位的Bug更有成就感和分数奖励
- 缺点是:规则较为复杂,实现起来可能比较麻烦,而且不一定每个人都接受这种制度
有同学说:“经过这三次互测,我的bash脚本和Python 写的比原来好多了。”
当然,这些都只是我个人的想法,抛砖引玉,还请助教组学长学姐和老师们能研究出更好的制度,回归互测的本质。
或者简单一些,把互测的人数减少到5至6人可能会合适些,这样可以多读代码,以免一些同学互测阶段直接放弃。
研讨课参与程度不太高,感觉“研讨”的氛围不是很明显。
感觉第三单元可以提到第一单元来。特别是多项式求导作为第一单元,而且最后一次作业,确实有点困难,不如先从JML开始,逐步熟悉Java语言和面向对象的建模方式。