JML語言及工具
JML語言理論
JML語言利用前置條件、後置條件、不變式等限制文法,描述了Java程式的資料、方法、類的規格,是一種契約式程式設計的實作工具。
- 常用的JML語言特性
- \result:表示方法的傳回值。
- \old(expr):表示在方法執行前的值。一般将所關心的表達式取值整體括起來。
- \forall:全稱量詞修飾的布爾表達式,可聲明局部變量、覆寫變量的取值範圍,對目标條件進行驗證。
- \exists:存在量詞修飾的布爾表達式,類似\forall。
- \sum, \max, \min:對給定範圍的表達式進行運算。表達式的由聲明變量、取值範圍、表達式定義給出。
- <==>, <==, ==>:邏輯推理表達式,含義與數理邏輯中相同。
- \nothing, \everything:目前作用域下的變量範圍。
- 資料規格
- 不變式 invariant :在成員處于可見狀态下必須滿足的特性。其中可見狀态可了解為完整的穩定狀态。
- 修改限制 constraint :描述前序可見狀态 —> 目前可見狀态的變化限制。
- 和方法的後置條件一起對資料的變化作出規約。
- 資料規格可被子類繼承。
- 方法規格
- 前置條件 requires BoolExpr1 || BoolExpr2 || …;
- 後置條件 ensures BoolExpr1 || BoolExpr2 || …;
- 副作用影響要求 assignable modifiable
- 副作用影響判斷 \not_assigned(x,y,…) \not_modified(x,y,…),可應用于後置條件的判斷。
- pure方法:可以被JML引用,隻需撰寫後置條件。
- JML可以調用Java程式中的pure方法進行通路、判斷等操作。
- 方法的異常行為:normal_behavior, also, exceptional_behavior, signals () expr, signals_only;
- 方法規格需要考慮到的範圍:
- 修改參數
- 傳回值
- 修改成員變量
- 修改this的引用
- 方法規格的原則:
- 關注執行效果(需求決定)和造成的其他影響
- 無需關注實作方式本身
- 本質仍是資料限制(讓資料産生變化、類間的資料通信)
- requires語句需要覆寫所有可能的情況,包括exceptional_behavior和normal_behavior!
- 條件互斥,并集為全集。
-
類規格
在類内利用一些規格變量對類的資料結構維護進行抽象描述,同樣的與具體容器、對象等無關。(如pathList、pathIdList的雙數組例子)
- 規格變量和類中維護的資料有功能上的聯系,但沒有實作上的聯系。
- 從類的階層化上來看,子類繼承父類規格
- 子類可以重寫父類的方法規格
- 子類不能違反父類的規格,但是可以進一步收窄
JML工具鍊
- 使用OpenJML對實作的代碼進行:
- JML文法靜态檢查:給出JML語言上的文法錯誤,并不關心代碼
java -jar specs/openjml.jar -check SourceToCheck.java
- 程式代碼靜态檢查:給出程式中可能出現的潛在問題,并不關心JML語言
java -jar specs/openjml.jar -esc SourceToCheck.java
- 運作時檢查:生成一個新的.class檔案,其中包含了運作時檢查的assertion,在運作和單元測試的時候将發揮作用
java -jar specs/openjml.jar -rac SourceToCheck.java
- JML文法靜态檢查:給出JML語言上的文法錯誤,并不關心代碼
- 使用JMLUnitNG根據JML語言自動生成TestNG測試
- 基于JML生成測試檔案:
java -jar ./specs/jmlunitng.jar SourceToTest.java
- 利用OpenJML的rac,生成含有運作時檢查的特殊.class檔案并替換原檔案
java -jar ./specs/openjml.jar -d bin/ -rac SourceToTest.java
- 運作TestNG測試
java -cp ./specs/jmlunitng.jar:bin SourceToTest_JML_Test
- 基于JML生成測試檔案:
-
組合使用,效果為:(對讨論區中的Demo.java進行試運作)
确定自己的JML規格的文法正确性。
運作前給出部分可能的警告:
運作TestNG時針對性的進行測試:第三單元總結:JML規格定義下的程式設計、驗證與測試 第三單元總結:JML規格定義下的程式設計、驗證與測試 - 使用注意:
- 環境應為java-8
- 路徑内不能包含中文
- 在開展TestNg測試前要使用OpenJML的RAC(運作時assertion檢查)重新編譯待測程式位元組碼
- OpenJML不支援\forall int[] 和 \exists int[]
嘗試使用OpenJML的SMT Solver對簡單的類進行靜态驗證
首先,筆者嘗試使用OpenJML的靜态檢查("-esc")對Path.java進行驗證:
部分值得關注的規格代碼如下:
- 構造函數及其規格:
1 private /*@spec_public@*/ ArrayList<Integer> nodes; 2 private /*@spec_public@*/ HashSet<Integer> distinct; // keep a unique set 3 /*@ public normal_behavior 4 @ requires nodeList != null && nodeList.length != 0; 5 @ assignable \everything; 6 @ ensures (\forall int i; 0<=i && i<nodeList.length; nodeList[i] == nodes.get(i)); 7 @ ensures (\forall int i; 0<=i && i<nodeList.length; distinct.contains(nodeList[i])); 8 @ ensures (nodes.size() == nodeList.length); 9 @ also 10 @ public normal_behavior 11 @ requires nodeList == null || nodeList.length == 0; 12 @ assignable \everything; 13 @ ensures (nodes != null && nodes.size() == 0); 14 @ ensures (distinct != null && distinct.size() == 0); 15 @*/ 17 public Path(int... nodeList) { 18 if (nodeList == null || nodeList.length == 0) { 19 nodes = new ArrayList<Integer>(); 20 distinct = new HashSet<Integer>(); 21 } else { 22 nodes = new ArrayList<Integer>(nodeList.length); 23 distinct = new HashSet<Integer>(nodeList.length); 24 for (int x : nodeList) { 25 nodes.add(x); 26 distinct.add(x); 27 } 28 } 29 }
- containsNode()方法及其規格:
//@ ensures \result == distinct.contains(node); public /*@pure@*/ boolean containsNode(int node) { return distinct.contains(node); }
這兩個方法和規格隻是作為兩個例子,剩下的規格大緻按照指導書即可。(注意OpenJML不支援 \forall int[] 和 \exists int[] )
當運作靜态文法檢查時,沒有warning和error。
當運作靜态規格檢查時,出現如下的很多warning:
src/Path.java:17: 警告: The prover cannot establish an assertion (Postcondition: src/Path.java:11: 注: ) in method Path
public Path(int... nodeList) {
^
src/Path.java:11: 警告: Associated declaration: src/Path.java:17: 注:
@ ensures (\forall int i; 0 <= i && i < nodeList.length;
^
src/Path.java:17: 警告: The prover cannot establish an assertion (Postcondition: src/Path.java:13: 注: ) in method Path
public Path(int... nodeList) {
^
src/Path.java:13: 警告: Associated declaration: src/Path.java:17: 注:
@ ensures (\forall int i; 0 <= i && i < nodeList.length;
^
src/Path.java:17: 警告: The prover cannot establish an assertion (Postcondition: src/Path.java:15: 注: ) in method Path
public Path(int... nodeList) {
^
src/Path.java:15: 警告: Associated declaration: src/Path.java:17: 注:
@ ensures (nodes.size() == nodeList.length);
src/Path.java:93: 警告: The prover cannot establish an assertion (Postcondition: src/Path.java:91: 注: ) in method containsNode
return distinct.contains(node);
^
src/Path.java:91: 警告: Associated declaration: src/Path.java:93: 注:
//@ ensures \result == distinct.contains(node);
可以看到,OpenJML的ESC檢查認為我沒有滿足規格,甚至沒有滿足containsNode()方法中的return語句的規格(這個return和\result的對象是一模一樣的)。
而筆者對其他小的程式的驗證則沒有問題。
由此看來,OpenJML單獨作為一個驗證器去對類和方法進行靜态檢查,是不靠譜的。
其根本原因在于OpenJML所支援和識别的類型和類型的方法太少,其對基本資料類型的支援基本可用,但一旦涉及到了ArrayList等進階資料結構類,就表現地十分迷惑。
應該将其的runtime assertion checking(RAC)與JMLUnitNg一起使用,才能發揮其部分功能(為TestNg中的測試提供Assertion)。
使用JMLUnitNg自動生成測試用例并進行測試
首先對Path.java進行RAC注入下的TestNg測試
改寫JML和Java代碼後的,可用的MyPath.java源檔案如下Ubuntu PasteBin連結所示:
https://paste.ubuntu.com/p/zgsSCyRN5M/
首先進行如下生成操作:(可以寫作一個AutoGenerate.sh,之後直接運作 ./AutoGenerate.sh src/MyPath.java 即可)
java -jar ./specs/jmlunitng.jar -cp lib/oolib.jar:src "$@" # 生成TestNg和測試政策檔案
javac -cp ./specs/jmlunitng.jar:lib/oolib.jar -d bin/ src/*.java # 編譯位元組碼
java -jar ./specs/openjml.jar -d bin/ -cp lib/oolib.jar:src -rac "$@" # 将RAC的assertion注入到Path.class中
之後進行測試:(可以寫作一個AutoTest.sh,之後直接運作 ./AutoTest.sh MyPath 即可)
java -cp ./specs/jmlunitng.jar:lib/oolib.jar:bin "$@""_JML_Test" # 運作TestNg主檔案
得到如下結果:
可以看到,JMLUnitNg為TestNg生成了31個測試政策/用例。
首先測試Runtime Assertion Checking是否開啟,之後對各個方法進行測試(包括構造函數)。
對于參數為對象的方法,其生成的用例常常包括 NULL 和 空。
對于參數為int的方法,其生成的用例常常包括極值邊界資料和0。
一般地,JMLUnitNg生成的資料多在參數上和this上作出兩種變化,進行組合測試。
之後對PathContainer.java進行RAC注入下的TestNg測試
改寫JML和Java代碼後的,可用的MyPathContainer.java源檔案如下Ubuntu PasteBin連結所示:
https://paste.ubuntu.com/p/RTWW6SmrfB/
首先仍然進行如下生成操作:
./AutoGenerate.sh src/MyPathContainer.java
之後進行測試:
./AutoTest.sh MyPathContainer
得到如下結果:
可以看到,本套件隻會盲目的進行邊界值、特殊值、NULL、空的測試,最多對this進行某些構造(外界不可知),
但是并不能對其進行針對性的測試,如傳入有特定意義的Path對象。
中間會遇到一個“A catastrophic JML internal error occurred.”錯誤。
經筆者實驗,原因為OpenJML不支援如下的forEach + Iterator語言特性:
1 for (int node : path) { // using the Path iterator, which implements the Iterable<>
2 // ...
3 }
應該換成如下顯式的Iterator寫法:
1 Iterator<Integer> it = path.iterator();
2 while (it.hasNext()) {
3 int node = it.next(); // using the iterator EXPLICITLY !!
4 // ...
5 }
恕我直言,如果真的使用這些組合工具,那麼程式員在寫代碼的時候不能首先考慮其美觀性、整潔性、(程式和程式員的)高效性,
反而要時刻考慮自己寫的代碼能否被OpenJML了解,
那麼這肯定是違背了我們使用這些工具的初衷的!
由于從Path和PathContainer中看出其測試水準十分平凡,故筆者不再打算繼續對Graph類進行自動測試。
結論
經過筆者的測試,OpenJML + JMLUnitNG的實用工具組合——一點也不“實用”!
具體總結如下:
- 對複雜資料結構的支援差,對基本資料類型的支援好,對基本資料類型數組不支援量化表達式。
- 不支援對自定義類的自動智能構造,隻能盲目測試null、empty等,更不用說自動構造一些特殊的Path對象來進行測試。
- 對稍稍進階的Java語言特性不支援,寫代碼的時候居然還要考慮其“可跑性”。
是以,筆者認為:
JML語言是一個好的契約化程式設計的工具,但它絕不是導緻程式員花費額外時間去伺候、适應的理由。
JML語言(甚至混入一些自然語言進行描述)能夠顯著提升大型工程的正确性,進一步解放程式員和設計師等的工作,
但是其并不一定要用來真正的“跑起來”!
JML的重點是給人看的,而不是給機器看的。隻要程式員會看、會寫、會讀JML,會用它來給自己和産品帶來好處,這就夠了!
架構設計與疊代
第一次作業
第一次作業十分簡單,故沒有采取什麼特殊的設計。
由于對未來需求的不明朗,暫時沒有使用Trie樹手寫的專一性強的資料結構等維護序列,而是使用了雙向HashMap這一相容性強的結構來維護Path。
類圖如下:
第二次作業
第二次作業中,開始出現了圖的結構模型。
類似傳統算法競賽中的鄰接表結構存儲圖,筆者定義了若幹輔助類對圖結構進行管理:
- Pair類:統計無向邊使用,本質是一個無序對。即(Node1, Node2)和(Node2, Node1)視作相等。
- Edges類:類似鄰接表的資料結構,本質是 HashMap<Node, HashSet<Node>> 。即第一層HashMap為(有向)邊的起始點索引用,第二層儲存其所鄰接的所有結點。
- Matrix類:類似二維不定長數組的資料結構,本質是HashMap<Node, HashMap<Node, Integer>> 。用來維護最短路徑結果的dis[][]數組。
在第一次作業擴充上的類圖如下:
第三次作業
第三次作業中,出現了帶邊權的有向圖模型(拆點表示換乘後,圖成為了有向圖)。
在第二次作業的Matrix基礎上,筆者将Matrix進行改造,使其:
- 既能表示(u, v)間的dis最短路徑距離:(u, v, dis[u][v])
- 又能用于存儲有向圖及其邊權:(u, v, w)
達成了資料結構的複用(源于二者的本質都是點-點-資料關系)。
此外,将原本的Integer表示節點,更改為封裝一個Node(NodeId, PathId)類來進行管理:
- NodeId表示原先的結點号
- PathId表示由哪條線路拆點而來
- 拆點算法中,NodeId的公共源點為Node(NodeId, -1)(由于PathId滿足PathId>0恒成立)
- 拆點算法中,NodeId的公共彙點為Node(NodeId, 0) (由于PathId滿足PathId>0恒成立)
同時,将原先的無序對Pair擴充,變成有序對OrderedPair和無序對Pair,便于對原圖(拆點前)和最短路圖(拆點後)的邊進行統計管理。
将部分操作移出MyRailwaySystem類,分散、歸因到負責求最短路的ShortestPath類、負責求聯通性的Connectivity類、負責維護圖結構的GraphAction類中。
這樣設計的直接好處是程式的各個方法和類的複雜度都不高,隻有compareTo、查詢鄰接表等方法稍稍高,
但比起第一單元的多項式程式而言,複雜度控制有了長足的進步。
bug與測試
測試部分,由于本次作業屬于傳統的非時序輸入-非時許輸出問題,故可以使用對拍器+資料生成器進行對拍檢查。
而對于資料生成器的構造政策,由于本單元的正确性和算法效率要求并存,故采用以下的測試政策:
-
針對算法時間複雜度測試,構造完全包含公測和互測資料規模的資料進行極限測試。
如要求PATH_ADD + PATH_REMOVE總共不超過50條時,構造PATH_ADD 和 PATH_REMOVE 均達到50條的規模。
這樣測試才能保證自己的測試 完全覆寫了 正式測試的資料規模。
- 針對正确性測試,由于本單元作業中查詢的正确性至關重要,故将資料規模減小,同時增加查詢操作條數,加大随機查詢點對的覆寫率。
在自我線下檢查後,三次作業中均未出現公測和互測BUG。
規格和測試總結
- 撰寫JML語言時應當注意對參數的所有不同的pre-condition進行全覆寫,即所有的pre-con互斥,且并集應為全集。
- JML對post-condition的描述不必要考慮實作,應該使用最簡單、最本質的描述。
- 在實作功能後,首先利用單元測試架構,對規格中最大的normal_behavior進行幾個基礎功能測試。
- 之後,應當根據撰寫的規格,對每一個方法 分支針對性地 對應開展單元測試。
- 注意組合pre-con(既有參數的狀态、也有對象本身this的狀态)的情況
- 注意組合post-con的情況,確定每個分支都進行了至少一次測試
- 使用OpenJML可以提示JML的文法錯誤和可能的簡單錯誤(如溢出等),但不能指望其自動生成的測試資料和政策,還是應該手動根據規格構造樣例。