OO第三单元作业——JML规格的理解与使用
OO第三单元作业——JML规格的理解与使用
在第三单元作业中,我们实现了社交网络查询的基本功能。我们需要根据课程组下发的官方JML代码,来确定社交网络中需要实现的功能,而后设计自己的方法来实现。一般来说,一个方法的JML代码的行数要远大于其实现代码的行数,因此,我们可能具有这样的体会:一个方法的JML规格写得十分复杂,有二三十行,但是真正看明白后,实现起来只需要不到十行代码。这就是JML规格的目的所在:使程序员在阅读完JML规格后,可以完全明晰方法的设计目标(虽然设计方式并不是统一要求的,可以自由发挥),因此有必要对方法的前提条件、执行结果进行详尽解释,消除任何可能存在的二义性。对于我们来说,需要具备快速,精准理解JML代码的能力。下面,我将首先回顾JML规格中的一些关键字的使用方法。
一、本单元作业设计架构
以第十一次作业为例,官方下发的JML规格中包含NetWork、Group、Person、Message以及三个Message子类的JML代码,其中前三个类无疑是这个单元设计的核心类。NetWork类在程序运行时只会生成一个实例,也就是我们要模拟的社交网络图。每个NetWork类中管理着多个Person、Group类的实例。Person毫无疑问,是社交网络中的节点;而Group是一些节点的集合。若两个人互相认识,则在对应的两个节点之间加一条边,边上的权值由用户输入决定。以上是社交网络图实现的基本规则,下面我们分门别类,介绍一些隐藏规则。
Person类
首先,每个Person对象需要记录id、name、age、money、socialValue这五个属性,他们在Person对象创建时被初始化:其中id、name、age三个属性由用户输入决定,而money和socialValue被初始化为0。Person类中为这五个属性设计了get方法,又为money和socialValue设计了add方法(在原有值的基础上自增一个新值,可以为负数),它们记录了Person类的全部基本属性。
在一个无向图中,节点需要记录它的邻接节点以及邻接边的权值,但重要的是要将邻接节点和邻接边对应起来。我使用HashMap数据结构来实现上述目标:以邻接节点为key值(Person类),以这个邻接节点所在邻接边的权值为value值(Integer型),这样使得对边权值的查询操作方便了许多——只需找到一个节点,而后使用HashMap类自带的get方法,就可以得到该节点和另一个节点的邻接边的权值。若这两个节点之间没有邻接边,就返回0。
每个Person对象还需要记录它所接收的消息集合。这个“消息集合”指的是一个队列,最早接收到的消息在队尾,最晚接收到的消息在队首,而在接收消息时,将新接收的消息置于队首——Deque接口类型有这个功能,于是我使用LinkedDeque类型容器来实现Person接收的消息队列。在查询某个Person接收到的最近4条消息时,返回双端队列的队首4个元素即可。
Group类
Group类对象使用HashSet数据结构来实现其成员(Person)的集合。因此,加之groupId,Group类需要实现的必要属性只有两个。但是事实并非如此,为了实现查询上的快捷性,我们需要另外设计三个属性:
1、ageSum:记录所有成员年龄的和。
2、agePowSum:记录所有成员年龄的平方和。
3、valueSum:那些两个节点都在该组内的边的权值之和的二倍。
这三个属性是为如下问题而设计的:组内成员的平均年龄、组内成员的年龄方差、组内所有节点对间的边的权值之和。面对这三个查询,若我们每次都重新遍历组内所有成员节点,必然造成算法复杂度超标。因此我们必须设计以上三个属性、在组内成员变动时更改它们,而在查询时只需对这三个属性进行简单的计算即可得到结果。
具体来说,在组内有新成员(假设年龄为r)加入时,需要执行以下操作:一、更新组内成员年龄和(ageSum+=r);二、更新组内成员年龄平方和(agePowSum+=r*r);三、遍历所有组内的老成员,将该成员与新增成员之间边(若存在)的权值的2倍加到valueSum中。在小组去除某成员(假设年龄为r)时,需要:一、更新组内成员年龄和(ageSum-=r);二、更新组内成员年龄平方和(agePowSum-=r*r);三、遍历组内剩余成员,将该组员与移出成员之间的边(若存在)的权值的2倍从valueSum中减去。注意,若增加边时发现这条边的两个顶点在同一个组中,也需要更新valueSum(加上这条边的权值的两倍)。因此,对于每个Person对象,还需要设计一个HashSet数据结构,记录所有其所在的Group。
在查询组内成员平均年龄时,使用ageSum除以组内成员数量(n)得到结果(ageMean);在查询组内成员年龄的方差时,有\result == (agePowSum - 2 * ageMean * ageSum + ageMean * ageMean) / n;在查询组内所有节点对的边的权值之和时,直接返回valueSum即可。
Block类
Block也是一个节点集合,它所包含的节点在整个社交网络图中构成一个极大连通分量。设计这个类是为了计算最短路径(迪杰斯特拉算法)和最小生成树——因为这两个算法都是在节点所在极大连通分量内执行的,实时记录社交网络图中的极大连通分量可以减少查询时的操作复杂度。在对图的所有操作中,有两种会改变极大连通分量的分布情况,一是增加新节点、二是增加边(本单元作业中没有减少边的操作)。因此,Block类需要有两种构造方法。当社交网络图中新增一个节点时,这个节点单独构成了一个极大联通分量,对应地,第一种构造方法接收一个Person类对象,将其作为Block类内的唯一成员。当社交网络图中新增一条边,而这条边所连接的两个节点原本处于不同的极大连通分量中时,这两个极大联通分量需要合并为一个新的极大连通分量,对应地,第二种构造方法接收两个不同的Block对象,将这两个Block对象所包含的所有成员纳入本实例的成员集合中。同时,对于每个Person类对象,需要实时记录其所在的极大连通分量(Block类),即Person类和Block类存在关联(互引用)关系,其关联形式为1(Block)对多(Person)。
本次作业中,isCircle指令查询两个节点是否在图中互相可达,即它们是否处于同一极大连通分量中。blockSum指令查询图中的所有节点最多可以被分为多少个互相不可达的节点集合,即图中存在多少个极大连通分量。引入了Block类的设计后,这两个指令的复杂度为O(1)。、
在最小生成树的计算中,一个Block类需要记录其最小生成树计算值。对于不同的初始节点,计算得到的最小生成树的结果相同。因此,当多次查询同一极大连通分量的最小生成树时,即使查询的初始节点不同,也只需查询一次即可。只有当极大联通分量发生改变,或者整个社交网络图关于“该极大联通分量包含的节点集合”的生成子图中有新增边时,最小生成树才会重新计算。
最短路径使用迪杰斯特拉算法。由于计算出的最短路径值与源节点和目的节点相关,若要记录的话,需要记录每一对源节点和目的节点组合的最短路径值,而当极大连通分量改变或者极大联通分量内有新增边时,这些记录就作废了。因此,对于最短路径查询,在记录源节点和目的节点所在极大连通分量的基础上,无需做额外记录,在每次查询时均使用迪杰斯特拉算法计算最短路径即可。
Network类
Network类在程序的每次运行中只拥有一个实例。它使用HashSet结构管理着社交网络图中的所有Person,Group,Block和Message对象。对于query_group_sum,query_block_sum,query_people_sum这样的指令,直接输出HashSet结构的大小即可。而对于其它几乎所有指令,比如addToGroup,delFromGroup等,需要根据用户输入的id来寻找相应的Person、Group或Message对象,若没有找到对应的对象,还要抛出异常。
Message类
在Network中维护了一个HashSet容器,用于存储所有待发送消息。addMessage用于将一个消息加入到待发送消息集合中,而sendMessage才是真正的发送消息操作,用于发送一个在待发送消息集合中的消息。若待发送消息集合中不存在对应id的消息,要抛出异常。
Message类具有三个子类:EmojiMessage类、RedEnvelopeMessage类和NoticeMessage类,这三个子类具有不同的功能。
EmojiMessage类涉及到表情包id和表情包热度的问题,因此,我设计了一个HashMap类来实现这个功能:表情包id(Integer型)作为key值,表情包热度(Integer型)作为value值。使用到不存在的表情包id时,抛出异常;否则相应表情包的热度增加一。还有增加表情包指令(新增表情包的热度为0)和删除冷门表情包指令(删除热度低于一定阈值的表情包),它们都是对表情包集合的常规操作。
RedEnvelopeMessage类涉及到Person对象的money属性。消息的类型为0时,表示私发消息,即Message.person1将此消息单独发送给Message.person2,此时person1将一定的money转给person2;消息的类型为1时,表示群发消息,即Message.person1将此消息发送在Message.group中,此时person1将一定的money均分给组内的其他人。另外,对于私发消息,要更新person2的接收消息队列,将接收到的消息加到双端队列的首部。
NoticeMessage没有其它功能。Message类及其三个子类几乎没有自由设计的空间,其方法全部为构造方法和查询方法。
具有计数功能异常类
第三单元第十一次作业有十个异常类。这些异常可以再被分为两类:单计数类和双计数类。单计数类负责计数八个异常:包含所有的idNotFound异常和equalId异常,它们是由一个id引发的异常;双计数类负责计数两个异常:包含relationNotFound异常和equalRelation异常,它们是由两个id共同引发的异常。在这两个计数类中,用HashMap记录每个id引发异常的次数。另外,为了能够实时更新此类异常发生的总次数,异常类需要以静态属性的方式关联对应的计数类。
二、单元测试
这一单元我没有写数据生成程序,仅仅使用同学提供的测试样例进行对拍。由于对拍次数不多,而且样例强度没有保障,因此在强测过程中成绩十分不理想(三次强测分别是70、80、10分)。在完成第11次作业后,我使用指导书中提供的样例和第9、10次作业的强测、互测样例来测试我的代码,均未检测出问题。但实际上,我在第11次作业的新增指令的实现部分有一个很隐蔽的bug,在指导书样例、中测、对拍过程中均未发现。仅仅由于这一个错误,我在第11次作业的强测部分挂了8个点。
说实话,我也曾尝试编写测试程序和数据生成程序。在第二单元中,我的数据生成程序和测试程序保证了我在那三次作业中,强测部分的测试点全部通过。但是,第二单元的测试模式是:用户输入所期望的目标,我的程序输出为了达到这个目标所需要的步骤,在测试程序中,我只要检测这些步骤是否能正确达到目标即可。但是在第三单元中,无法按照此类思路编写测试程序,因此测试环节只能通过和同学对拍完成。
最近由于视力疲惫,我没有写数据生成程序,而只是手搓了几个简单的测试样例,来检查我的代码在基本功能上是否有误。而后与同学对拍,片面地检查较复杂的功能。我没有对全部功能进行完整的检查,也没有考虑程序运行时间问题——这导致我在每次强测中都有因超时而挂掉的测试点。
总之,由于测试工作没有花足功夫,我付出了惨痛的代价。
三、bug分析
这三次作业出现了比较多的bug。我在下面将进行一一分析。
第九次作业
在“通过id来寻找指定的Person对象”这一过程中,如果id是有效的,则返回对应的Person实例;否则返回null。在isCircle方法中,需要首先判断传入的两个id是否有效,只有有效时才进一步判断它们是否在同一极大连通分量中,否则抛出personIdNotFoundException异常。而我忘记判断id的有效性,当通过id的到的Person引用为null时,我仍然通过该引用获取它所在的极大连通分量,导致程序出现NullPointerException。
第十次作业
经过助教的指点,我才意识到组内成员年龄和、年龄平方和,成员间关系总和等属性不能在每次查询时都重新计算,而应在组内成员关系发生变动时更新这些值,在查询时仅仅输出结果。这样使得这类方法的复杂度由O(n)下降为O(1)。
我在最小生成树的算法内部存在细节性问题,为了把问题说清楚,我先复述一边prim算法的流程:把Block内的所有成员分为两个集合,分别叫做已加入(最小生成树)节点集合和未加入(最小生成树)节点集合。每次选择一条边,这条边是满足“一个顶点在已加入节点集合中,另一个顶点在未加入节点集合中”的权值最小的边,将其在“未加入节点集合”中的顶点加入到“已加入节点集合”中,并记录这条边的权值。多次这样操作后,所有节点都被加入到“已加入节点集合”中,这时,所有选择过的边的权值之和即为最小生成树的查询结果。
在选择边时,我们应该遍历每个“已加入节点集合”中的节点。对于每个节点、考虑它们的所有在“未加入节点集合”中的邻接节点,选择权值最小的边。而我的做法是:对“已加入节点集合”和“未加入节点集合”进行双重遍历。对于比较大的极大联通分量,这么做是很费时间的。这就像是我要找一个美国朋友:正确的做法是列出所有我的朋友,看看他们中哪个是美国人;而我之前的做法是列出所有美国人,看看哪个是我们朋友。
第十一次作业
第十一次作业中,我犯了一个粗心性问题:对JML规格理解错误。具体是什么错误我已经忘了。
不过,我还因为超时而挂掉了一个测试点。经查看,这个测试点中频繁使用查询最短路径指令,而其查询的对象是同一个极大连通分量。因此,我采取邻接矩阵法进行了记录,使得运行时间由30秒减少为7秒。但是这种方法治标不治本,算法的复杂度没有下降。
四、功能扩展
假设出现了几种不同的Person
- Advertiser:持续向外发送产品广告
- Producer:产品生产商,通过Advertiser来销售产品
- Customer:消费者,会关注广告并选择和自己偏好匹配的产品来购买 -- 所谓购买,就是直接通过Advertiser给相应Producer发一个购买消息
- Person:吃瓜群众,不发广告,不买东西,不卖东西
如此Network可以支持市场营销,并能查询某种商品的销售额和销售路径等 请讨论如何对Network扩展,给出相关接口方法,并选择3个核心业务功能的接口方法撰写JML规格(借鉴所总结的JML规格模式)
我们需要新增Producer、Advertiser和Customer三个接口。另外,还需要增加一个产品类,并增加生产完成消息、广告、订单消息这三个消息类的子类。我对扩展功能的研究不是很清楚,下面展示的只是初步设计的规格。
public interface Producer extends Person { public instance model int[] productId; //目前能够生产的产品id public instance model int[] productNum; //每种产品的库存
public instance model Advertiser[] aders; //销售员 Producer(int id, String name, int age); //构造函数
public void addProductType(Product product); //增加一种生产产品
public void produce(Product product, int num); //生产一定数量的产品 public void sendProduct(Product product, int num,Person person); //向person发送num个product类型的产品
public void sendMessage(); //向销售员发送消息
public void addAdvertiser(Advertiser ader); //增加一个销售员 }
public interface Advertiser extends Person {
public instance model int[] productId; //正在推销的产品种类
public instance model Producer[] producers; //生产商
public instance model Customer[] customers; //顾客
public void recieveProductionFinishMessage(ProductionFinishMessage message); //(从生产商处)接收生产完成消息
publiv void recievePurchaseMessage(PurchaseMessage message); //(从顾客处)接收订单消息
public void sendAdvertisementMessage(); //向顾客发送广告
public void addProducer(Producer producer); //增加合作的生产商
public void addCustomer(Customer customer); //增加客户
public void delCustomer(Customer customer); //失去客户
}
public interface Customer extends Person {
public instance model int[] interest; //感兴趣的产品种类
public instance model Advertiser[] aders; //关注的推销员
public void addAdvertiser(Advertiser ader); //增加关注的推销员
public void delAdvertiser(Advertiser ader); //对某一个推销员取消关注
public void recieveMessage(AdvertisementMessage message); //接收推销员发送的广告
public void sendPurchaseMessage(Product product, int num); //订购产品
} // 产品类 public interface Product { public instance model int id; public instance model String name; public instance model int func;
public int getId();
public int getFunc();
}
下面展示Producer类的produce方法、sendProduct方法、和addProductType方法的JML规格。
/*@ public normal_behaviour
@ requires (\exists int i; 0 <= i && i < productId.length; productId[i] == product.getId());
@ assignable productNum;
@ ensures (\exists int i; 0 <= i && i < productId.length; productId[i] == product.getId() &&
@ productNum[i] == \old(productNum[i]) + num);
@ also
@ public exceptional_behaviour
@ requires (\forall int i; 0 <= i && i < productId.length; productId[i] != product.getId());
@ assignable \nothing;
@ signals ProductTypeNotFoundException
@*/
public void produce(Product product, int num) throws ProductTypeNotFoundException;
/*@ public normal_behaviour
@ requires (\exists int i; 0 <= i && i < productId.length; productId[i] == product.getId() &&
@ productNum[i] >= num);
@ assignable productNum;
@ ensures (\exists int i; 0 <= i && i < productId.length; productId[i] == product.getId() &&
@ productNum[i] == \old(productNum[i]) - num);
@ also
@ public exceptional_behaviour
@ signals (ProductTypeNotFoundException e) !(\exists int i; 0 <= i && i <= productId.length;
@ productId[i] != product.getId());
@ signals (NotEnoughProductException e) (\exists int i; 0 <= i && i < productId.length; productId[i] == product.getId()
@ && num > productNum[i]);
@*/
public void sendProduct(Product product, int num) throws ProductTypeNotFoundException, NotEnoughProductException;
@ public normal_behaviour
@ requires (\forall int i; 0 <= i && i < productId.length; productId[i] != product.getId());
@ assignable productId, productNum;
@ ensures (\forall int i; 0 <= i && i < \old(productId.length);
@ (\exists int j; 0 <= j && j < productId.length; productId[j] == \old(productId[i]) &&
@ productNum[j] == \old(productNum[i])));
@ ensures (exists int i; 0 <= i && i < productId.length; productId[i] == product.getId() && productNum[i] == 0);
@ ensures productId.length == \old(productId.length) + 1;
@ ensures productNum.length == \old(productNum.length) + 1;
@ also
@ public exceptional_bahivour
@ requires (\exists int i; 0 <= i && i < productId.length; productId[i] == product.getId());
@ assignable \nothing;
@ signals EqualProductTypeException
@*/
public void addProductType(Product product) throws EqualProductTypeException;
五、体会与感想
这个单元对我不是很友善,我由于测试工作做得不好,导致强测得分很低。究其根本,在于我对于算法复杂度的考虑太少。拿到一个JML规格设计,我将其“一句一句”地翻译为java代码。这样做是十分低效的。我们在拿到JML规格后,应该先完整地浏览一遍,明确要实现什么功能,而后设计出自己的架构,以最优的方式来实现需要的功能。