271
社区成员




面向对象课程的第三单元将我们带入了基于规格的软件构造世界,核心围绕Java建模语言(JML)展开。本单元作业通过模拟一个社交网络系统,让我们在实践中学习和理解规格化设计、图模型的构建与维护、以及各类测试方法。本文将结合三次作业的实践,对本单元的测试过程、大模型辅助、架构设计、性能优化、Junit测试以及个人学习体会进行总结和分析。
在本单元中,有效的测试是保证代码正确性和鲁棒性的关键。我主要结合了以下几种测试方法:
单元测试 (Unit Testing):
Person
类的 addTag
, isLinked
,Network
类的 addPerson
, addRelation
,以及各个异常类的构造和 print
方法等进行单独测试。例如,测试向 Person
添加一个已存在的 Tag
是否会按预期抛出 EqualTagIdException
。功能测试 (Functional Testing):
ap
, ar
, qts
, sm
, dce
等)设计测试用例。例如,构造一系列 ap
和 ar
指令构建一个小型网络,然后用 qts
指令验证三元组数量是否计算正确;或者发送不同类型的消息,验证接收者的状态(社交值、金钱、消息列表)是否按规格更新。集成测试 (Integration Testing):
addMessage
后调用 sendMessage
,涉及 Network
, Person
, Message
(及其子类), Tag
(如果是群组消息) 等多个类的交互。需要验证消息是否正确传递、状态是否正确更新(如发送者扣钱、接收者加钱、社交值更新、表情热度增加等)。压力测试 (Stress Testing):
Person
,数万条关系),然后执行大量查询指令(如 qci
, qsp
, qts
)或修改指令(如 mr
, dce
),观察程序运行时间和内存消耗,检查是否有超时 (TLE) 或内存溢出 (MLE) 的情况。回归测试 (Regression Testing):
构造有效的测试数据是测试成功的关键。我的策略主要包括:
边界条件数据:
Tag
内人数达到1000上限)。等价类划分数据:
addRelation
时,id1和id2是否存在、是否相同、是否已关联。sendMessage
,消息类型(普通、红包、表情、转发)、发送目标(个人、标签)、发送者与接收者/标签的关系(是否存在、是否连接)。异常情况数据:
EqualPersonIdException
),查询不存在路径的两个Person (PathNotFoundException
),删除没有权限的公众号文章 (DeleteArticlePermissionDeniedException
)。ErrorCount
)是否正确更新。特殊结构数据:
isCircle
, queryShortestPath
。queryTripleSum
和 queryCoupleSum
。组合指令数据:
随机大数据与手动构造数据结合:
deleteColdEmoji
指令,需要构造不同热度的表情、包含这些表情的消息,以及普通消息,然后设置不同的 limit
值进行测试。参照JML规格构造:
requires
子句来构造触发正常行为和异常行为的输入。ensures
子句来确定预期的输出和状态变化,从而验证结果。在本单元中,虽然我未直接全程使用大模型辅助完成JML规格撰写和代码实现,但结合以往经验和对大模型能力的理解,我认为可以从以下方面引导大模型完成复杂任务:
场景一:从自然语言描述生成初步JML
addRelation(int id1, int id2, int value)
方法生成JML规格。该方法在两个已存在的、未关联的用户间添加一条值为 value
的双向关系。如果用户id1或id2不存在,抛出 PersonIdNotFoundException
。如果id1和id2已存在关系,抛出 EqualRelationException
。确保更新双方的熟人列表和关系值。”requires
, assignable
, ensures
, signals
子句。但需要人工审查其完整性(如是否覆盖所有路径)和准确性(如 \old
的使用、副作用的精确描述)。场景二:解释和优化现有JML
assignable
子句是否可以更精确:public normal_behavior requires containsPerson(id1) && containsPerson(id2) && !getPerson(id1).isLinked(getPerson(id2)); assignable persons[*]; ...
”场景一:根据JML生成代码骨架或完整实现
Network
类中的 queryTripleSum
方法生成Java实现。规格:ensures \result == (\sum int i; 0 <= i && i < persons.length; (\sum int j; i < j && j < persons.length; (\sum int k; j < k && k < persons.length && getPerson(persons[i].getId()).isLinked(getPerson(persons[j].getId())) && getPerson(persons[j].getId()).isLinked(getPerson(persons[k].getId())) && getPerson(persons[k].getId()).isLinked(getPerson(persons[i].getId())); 1)));
。考虑性能,维护一个成员变量 tripleSum
并在 addRelation
和 modifyRelation
(删除关系时) 中更新它。”tripleSum
),需要明确提示。生成的代码仍需仔细测试。场景二:代码逻辑检查与调试辅助
sendMessage
方法在处理群发红包消息时,金额分配似乎有误。这是JML规格,这是我的代码。当群组大小为0时,发送者不应扣钱,但现在好像扣了。请帮我分析问题。”大模型在简单、模式化的JML生成和基于规格的代码实现方面能提供不错的辅助,但对于复杂逻辑、性能优化和深层语义理解,仍需开发者主导,并将其作为高效的助手而非完全依赖的决策者。
本单元的架构设计遵循了典型的面向接口编程和分层思想。
com.oocourse.spec3.main
): 由课程组提供,包含 NetworkInterface
, PersonInterface
, MessageInterface
, TagInterface
, OfficialAccountInterface
等。这些接口通过JML严格定义了各个组件的行为和契约。Network.java
, Person.java
等): 我们需要实现的具体类。Network
类是核心,作为整个社交网络的容器和管理器,负责处理大部分指令,维护 Person
, OfficialAccount
, Message
, Emoji
等对象的集合,并协调它们之间的交互。Person
类表示网络中的用户节点,包含其属性(id, name, age, money, socialValue)、关系(acquaintance, value)、持有的标签(tags)、接收的消息和文章。Message
类及其子类(EmojiMessage
, RedEnvelopeMessage
, ForwardMessage
)封装了消息的属性和特定行为逻辑。Tag
类表示用户标签,内部维护一组 Person
。OfficialAccount
类表示公众号,维护所有者、关注者、文章、贡献者等信息。Runner
类 (课程组提供) 负责解析输入指令,并通过反射调用我们实现的 Network
中的方法。com.oocourse.spec3.exceptions
):自定义的各种异常,部分异常类内部通过 ErrorCount
实现了简单的异常统计。这种设计将规格与实现分离,使得我们可以专注于实现细节,而不必担心破坏高层调用逻辑,同时也为后续的扩展和维护提供了便利。
社交网络本身就是一个图,其中 Person
是节点,Person
之间的关系是边。
节点 (Person
) 存储:
Network
类中,我使用 private final Map<Integer, PersonInterface> persons;
(初始化为 HashMap
) 来存储所有的 Person
对象。Key是 personId
,Value是 Person
对象。这保证了通过ID可以O(1)平均时间复杂度快速查找用户。边 (关系) 存储与维护:
Person
对象内部维护自己的熟人列表和对应的关系值。Person
类中使用:private final Map<Integer, PersonInterface> acquaintance;
(HashMap) 存储熟人对象。private final Map<Integer, Integer> value;
(HashMap) 存储与对应熟人的关系值。addRelation(id1, id2, value)
:Network
的 persons
中获取 person1
和 person2
对象。person1
的 acquaintance
和 value
中添加 person2
及关系值。person2
的 acquaintance
和 value
中添加 person1
及关系值。tripleSum
: 这是关键。在添加关系时,需要检查新形成的边 (person1, person2)
是否与 person1
的其他邻居 px
和 person2
的其他邻居 py
形成新的三元组。具体策略是遍历 person1
的所有邻居 neighbor1
(除去 person2
),检查 neighbor1
是否也是 person2
的邻居。如果是,则 tripleSum++
。或者,遍历较少邻居的一方,以优化检查。我的代码中采取了后者:// Network.java addRelation
// ...
Map<Integer, PersonInterface> acq1 = ((Person) person1).getAcquaintanceMap();
Map<Integer, PersonInterface> acq2 = ((Person) person2).getAcquaintanceMap();
boolean condition = acq1.size() < acq2.size();
Map<Integer, PersonInterface> acq = condition ? acq1 : acq2;
int id = condition ? id2 : id1; // The other person in the new relation
PersonInterface person = condition ? person2 : person1; // The other person
for (PersonInterface neighbor : acq.values()) {
if (neighbor.getId() != id && neighbor.isLinked(person)) { tripleSum++; }
}
modifyRelation(id1, id2, value)
:person1
和 person2
。newValue = oldValue + value
。newValue > 0
:更新双方 value
map 中的值。newValue <= 0
:删除关系。acquaintance
和 value
map 中移除对方。tripleSum
: 遍历 person1
的所有邻居 neighbor1
(在删除前,不包括 person2
),如果 neighbor1
也是 person2
的邻居,则 tripleSum--
。// Network.java modifyRelation (when removing relation)
Map<Integer, PersonInterface> acq1 = ((Person) person1).getAcquaintanceMap();
Map<Integer, PersonInterface> acq2 = ((Person) person2).getAcquaintanceMap();
for (PersonInterface neighbor : acq1.values()) {
if (neighbor.getId() != id2 && acq2.containsKey(neighbor.getId())) { tripleSum--; }
}
// ... unlink logic ...
Collection<TagInterface> pt1 = ((Person) person1).getAllTags();
for (TagInterface tag : pt1) { if (tag.hasPerson(person2)) { tag.delPerson(person2); } }
// similar for pt2
图算法实现:
isCircle(id1, id2)
(连通性查询) 和 queryShortestPath(id1, id2)
(最短路径查询): 均采用广度优先搜索 (BFS) 算法。isCircle
:找到目标节点即返回 true
。queryShortestPath
:在BFS过程中记录每个节点到起点的距离,找到目标节点时其距离即为最短路径长度(边权为1)。Queue
(如 ArrayDeque
) 辅助BFS,使用 Set
(如 HashSet
) 或 Map
记录已访问节点和距离。这种通过在 Person
对象中存储邻接信息,并在 Network
层面进行协调和特定属性(如 tripleSum
)维护的策略,在查找和局部修改方面有较好的效率。对于全局图属性的查询,则依赖经典的图算法。
本单元作业对性能有较高要求,尤其是在处理大规模数据和复杂查询时。
queryTripleSum
(QTS):
Network
类中维护一个成员变量 private int tripleSum;
。addRelation(p1, p2, value)
时,遍历 p1
(或 p2
,取邻居少的一方) 的所有已有邻居 px
,如果 px
也与 p2
(或 p1
) 相连,则 (p1, p2, px)
构成一个新的三元组,tripleSum++
。modifyRelation(p1, p2, value)
导致关系值 <=0
而删除关系时,同样遍历 p1
(或 p2
) 的邻居 px
,如果 px
也与 p2
(或 p1
) 相连,则 (p1, p2, px)
这个三元组被破坏,tripleSum--
。addRelation
和 modifyRelation
(删除时) 的复杂度会增加O(degree),但在社交网络稀疏图的场景下通常优于O(N^3)的查询。queryCoupleSum
(QCS):
queryBestAcquaintance(id)
(QBA)。如果QBA每次都遍历某人的所有熟人来查找value最大且id最小者,其复杂度为O(degree)。那么QCS的复杂度可能是O(N*degree),最坏情况下O(N^2)。Person
类中缓存最佳熟人信息。private int bestAcquaintanceId;
和 private int bestAcquaintanceValue;
(以及一个标记 hasAcquaintances
)。link(person, val)
(内部方法,对应 addRelation
) 或 modifyValue(person, newValue)
(内部方法,对应 modifyRelation
) 或 unlink(person)
(内部方法,对应关系删除) 时,动态更新这两个缓存值。Person
的所有熟人来找到新的最佳熟人 (recomputeBestAcquaintance)。queryBestAcquaintance
变为O(1)。QCS的复杂度显著降低,接近O(N),除非频繁发生导致最佳熟人失效的删除操作。Tag.getAgeVar()
和 Tag.getAgeMean()
:
Tag
内所有 Person
计算年龄总和及平方和,则复杂度为O(TagSize)。Tag
类中维护 private long sumAge;
和 private long sumAgeSquare;
。Tag.addPerson(person)
时,sumAge += person.getAge(); sumAgeSquare += person.getAge() * person.getAge();
。Tag.delPerson(person)
时,相应减去其年龄和年龄的平方。getAgeMean()
和 getAgeVar()
变为O(1)。deleteColdEmoji(limit)
(DCE):
limit
的表情,并删除 messages
列表中所有引用了这些被删除表情的 EmojiMessage
。如果实现不当,可能导致多次遍历或低效的删除。emojiHeatMap
,找出所有热度低于 limit
的表情ID,存入一个临时集合 emojisToDelete
。emojisToDelete
,从 emojiHeatMap
中移除这些表情。messages
集合。对每个消息,如果它是 EmojiMessageInterface
类型,并且其 emojiId
存在于 emojisToDelete
中,则通过迭代器的 remove()
方法将其从 messages
中删除。规格与实现分离是面向对象设计中的核心原则之一,JML在本单元中正是体现了这一点。
NetworkInterface
中的JML。Network.java
。分离带来的好处:
queryTripleSum
的实现从O(N^3)优化到O(1)查询(配合增量更新),对调用者是透明的。在本单元中,我们严格按照JML规格来实现功能。这意味着,即使我们的具体实现(如使用 HashMap
还是 TreeMap
,或者算法的具体步骤)有所不同,只要最终行为符合JML的描述,就被认为是正确的。这种分离使得我们可以专注于算法效率和数据结构的优化,而不必担心功能上的偏差(只要实现严格遵守规格)。
本单元我们学习并实践了Junit测试。利用JML规格信息可以极大地提升Junit测试设计的针对性和有效性。
JML规格为Junit测试提供了直接的指导:
针对 requires
子句 (前置条件):
requires
子句的输入数据,验证方法是否能正常执行并产生 ensures
所描述的结果。requires
子句的输入数据。如果JML中对应的 signals
子句指明了应抛出的异常,则使用 assertThrows(ExpectedException.class, () -> { methodCall; });
来验证异常是否正确抛出。addRelation
,测试当 !containsPerson(id1)
时是否抛出 PersonIdNotFoundException
。getPerson(id1).isLinked(getPerson(id2))
(即关系已存在) 时是否抛出 EqualRelationException
。针对 ensures
子句 (后置条件):
\result
: 对于有返回值的方法,检查返回值是否与 \result
表达式的预期一致。assignable
/ modifiable
):\old(expression)
来记录方法执行前的状态。assignable
子句中列出的变量/对象状态是否按 ensures
描述的那样发生了变化。addPerson
后,验证 persons
数组确实包含了新添加的 person
,并且其长度增加了1。\not_assigned
/ \not_modified
implied): 检查 assignable
子句未列出的重要状态是否保持不变,防止意外副作用。针对 signals
(异常抛出) 和 signals_only
(仅抛出指定异常) 子句:
assertThrows
来验证在特定条件下是否抛出了预期的异常。NetworkTest.java
中,虽然没有直接用 assertThrows
,但其测试 deleteColdEmoji
时,很多方法(如 addMessage
)在内部对异常进行了捕获和打印,这间接反映了对异常路径的考虑。一个更标准的Junit做法是直接断言异常。针对 invariant
(不变量):
setUp
和 tearDown
或每个测试方法的开始和结束时检查类不变量是否保持。例如,persons.length == emojiHeatList.length
。构造复杂场景:
ensures
子句,构造需要多步操作才能达成的状态,然后进行测试。例如 deleteColdEmoji
的JML规格非常复杂,NetworkTest.java
中的 testEmojisAndMessagesMixed
方法就试图覆盖其多种交互情况。Junit结合JML规格进行测试,对于检验代码实现与规格的一致性非常有效:
assertEquals
, assertTrue
, assertThrows
)可以精确地验证方法的输出、状态变化和异常行为是否符合规格。NetworkTest.java
):NetworkTest.java
主要测试了 deleteColdEmoji
方法。它通过 setUp
创建了初始网络状态。checkEmojiListProperties
和 checkMessageListProperties
方法实际上是在验证 deleteColdEmoji
JML中关于 emojiIdList
, emojiHeatList
和 messages
状态变化的 ensures
子句。ensures emojiIdList.length == (\num_of int i; 0 <= i && i < \old(emojiIdList.length); \old(emojiHeatList[i] >= limit));
这一条,测试代码通过比较新旧列表长度和内容来验证。ensures (\forall int i; 0 <= i && i < \old(messages.length); (\old(messages[i]) instanceof EmojiMessageInterface && containsEmojiId(\old(((EmojiMessageInterface)messages[i]).getEmojiId())) ==> \not_assigned(\old(messages[i])) && (\exists int j; 0 <= j && j < messages.length; messages[j].equals(\old(messages[i])))));
这部分被 checkMessageListProperties
尝试验证,确保应保留的消息确实被保留且未被错误修改。局限性:
Junit测试无法穷尽所有可能的输入和执行路径,因此不能完全证明代码100%符合规格(这是形式化验证工具的目标)。测试的有效性高度依赖于测试用例的设计质量和覆盖广度。但它仍然是发现不一致性的强大实用工具。
第三单元是关于JML规格和基于规格开发的单元,对我而言充满了挑战与收获:
JML的学习曲线: 初次接触JML,其语法和各类子句(requires
, ensures
, assignable
, invariant
, \old
, \forall
, \exists
, \sum
等)的学习需要投入较多时间和精力。理解JML的精确语义,并能用它准确描述方法行为,是一大难点。但一旦掌握,它就成为理解和设计代码的有力工具。
规格与实现的分离思想深化: 理论上知道这个原则,但本单元通过JML强制我们首先思考“做什么”(规格),然后才是“怎么做”(实现),深刻体会到了这一思想的好处。它使得代码结构更清晰,也更容易进行模块化测试和维护。
图论算法的应用: 社交网络天然是图结构,本单元涉及的 isCircle
(连通性判断)、queryShortestPath
(最短路径)、queryTripleSum
(三元闭包计数) 等,都是图论中的经典问题。这促使我复习和实践了BFS等算法,并思考如何在工程项目中应用它们。
性能优化的重要性: 简单的遵循JML字面意思去实现某些查询(如QTS)会导致严重的性能问题。这迫使我们思考如何通过缓存、增量计算等方式优化实现,同时又不违反规格。这让我理解到,好的实现不仅要正确,还要高效。
测试驱动的开发意识: JML规格为编写测试用例(尤其是单元测试)提供了非常好的指引。先理解规格,再构思测试数据,然后编写代码,最后用测试验证。这种流程能更早地发现问题,提高代码质量。Junit的使用也让测试过程更加系统化和自动化。
细节决定成败: JML规格非常严格,任何一个条件的疏忽或误解都可能导致实现错误。例如,assignable
子句的范围,\old
关键字的使用时机,各种边界条件的处理,都需要非常仔细。多次因为对JML细节理解不到位而导致bug。
代码的健壮性: 大量的自定义异常类和 signals
子句,强调了程序在遇到非法输入或状态时,应能优雅地处理并给出明确反馈,而不是直接崩溃。
总的来说,第三单元虽然难度较大,但对于提升抽象思维能力、严谨设计能力和测试能力都有很大帮助。它让我们从关注代码的“形”转向关注代码的“神”——即代码所应遵循的行为契约。这种基于规格的开发方法,是构建复杂、可靠软件系统的重要基石。