嚴(yán)忠林
摘 ?要: 學(xué)習(xí)編程離不開大量的實踐訓(xùn)練,但批閱學(xué)生提交的代碼卻是一件相當(dāng)費神耗時的工作。Java教學(xué)大都圍繞其功能強(qiáng)大的標(biāo)準(zhǔn)類庫來組織安排,并通過相應(yīng)練習(xí)使學(xué)生熟練掌握。為了提高效率,設(shè)計了一個作業(yè)輔助批閱工具,它能對Java類文件進(jìn)行自動修改,在運(yùn)行時獲取關(guān)鍵類庫的使用信息,了解它們的調(diào)用頻次、先后次序、所用參數(shù)及返回值,可幫助理解程序邏輯,評判學(xué)生對教學(xué)內(nèi)容的掌握程度。
關(guān)鍵詞: Java類文件,ASM,代碼批閱,計算機(jī)輔助教學(xué)
中圖分類號:TP399 ? ? ? ? ?文獻(xiàn)標(biāo)識碼:A ? ? 文章編號:1006-8228(2020)01-53-04
Abstract: Coding is essential for learning a new programming language. However, for the instructors, it is very time-consuming and tedious to read over all of the code submitted by the students. The syllabus of Java programming consists of using its powerful standard libraries, accompanied with corresponding practical coding projects. A tool is designed to help the instructors to improve their code review efficiency. This tool is able to automatically modify the Java class files and return the usage of the class libraries, including their calling frequency, calling order, parameters and return values. This tool is helpful for understanding the logic of the code from students and judging their level of understanding.
Key words: Java class file; ASM; code review; CAI
0 引言
Java多年來一直是軟件開發(fā)的首選語言,也是計算機(jī)專業(yè)學(xué)習(xí)的重點,對相關(guān)知識和開發(fā)能力都有較高要求,因此需要安排充實的教學(xué)內(nèi)容,進(jìn)行有效的訓(xùn)練實踐。對于語法已入門的學(xué)生,教學(xué)通常是圍繞著各種類庫展開的。特別是JDK提供的標(biāo)準(zhǔn)類庫,功能豐富全面、使用方便高效。從輸入輸出到集合框架、多線程、網(wǎng)絡(luò)、數(shù)據(jù)庫,各種功能都是通過使用相應(yīng)對象,調(diào)用適當(dāng)方法來實現(xiàn)的。即使是JavaEE、云計算等進(jìn)階內(nèi)容,也大都?xì)w結(jié)為對特定類庫和框架的學(xué)習(xí)。這些內(nèi)容的教學(xué)都需要有針對性地安排大量實踐,而學(xué)生的掌握程度,也要通過批閱提交的作業(yè),查看相應(yīng)API的使用情況才能知曉。因此準(zhǔn)確全面、高效及時地處理學(xué)生作業(yè),就變得非常重要。
閱讀他人程序,從來就不輕松。那些真正能訓(xùn)練和體驗面向?qū)ο缶幊痰淖鳂I(yè),處理的問題和對應(yīng)的程序結(jié)構(gòu)都較復(fù)雜,代碼規(guī)模大,處理流程多變,運(yùn)行邏輯不是一眼能看清的。況且每個學(xué)生都有自己的編程風(fēng)格和習(xí)慣,審閱這些代碼相當(dāng)勞心費力,如班級的人數(shù)再多一點,就非常辛苦了。過去的應(yīng)對辦法,要么是不管代碼只看最后運(yùn)行結(jié)果,要么是僅抽選少數(shù)幾份作業(yè)批閱。它們都難以切實了解學(xué)生的學(xué)習(xí)情況,對教學(xué)效果的評估很容易產(chǎn)生偏差。而且如被學(xué)生察覺這種狀況,對師生間的信任、交流、配合也是有害的。
那么如何才能既省時省力,又全面準(zhǔn)確地檢查學(xué)生的作業(yè)情況呢?我們的建議是批閱時多關(guān)注作為教學(xué)重點的那些核心API的使用,因為作業(yè)的目的就是要通過訓(xùn)練來掌握它們,它們通常也是程序處理的關(guān)鍵步驟。當(dāng)然收集這些信息應(yīng)該極其方便,最好是全自動的,不用在文本中搜索查找。我們設(shè)計了一個工具達(dá)到此目的,它會在程序運(yùn)行中虛擬機(jī)執(zhí)行類裝載時對某些指令作適當(dāng)修改,讓它在完成相應(yīng)操作的同時也輸出必要信息。這樣程序一運(yùn)行,使用了哪些API、其順序頻度、具體數(shù)據(jù)等一目了然,從中不難推測它的設(shè)計思路,明確相關(guān)內(nèi)容的掌握程度。
1 類文件結(jié)構(gòu)和ASM工具
要修改Java虛擬機(jī)指令,必須對Java類文件結(jié)構(gòu)有所了解。它有統(tǒng)一緊湊的層次格式[1],簡化后的結(jié)構(gòu)如圖1所示,包含了類本身、全部的字段成員和方法成員信息,大量細(xì)節(jié)依靠相關(guān)屬性來描述,所有常數(shù)和文字信息都通過對常量池的下標(biāo)引用來表達(dá)。
我們要修改的代碼在相應(yīng)方法的Code屬性中,主要部分是由bytecode順序排列形成的字節(jié)數(shù)組,其中既有分支轉(zhuǎn)移及異常捕捉等復(fù)雜結(jié)構(gòu),又隱含了運(yùn)行中對局部變量和操作數(shù)棧的反復(fù)使用。它還包含多種屬性,有局部變量的相關(guān)信息、程序行與代碼的對應(yīng)關(guān)系、還有程序員相當(dāng)陌生的程序裝載時的代碼驗證支持。它們相互聯(lián)系對照,要直接在其中修改代碼,既困難又易錯。為此我們借用了一個使用廣泛、高效方便的開源工具ASM。
ASM是Java字節(jié)碼的分析、生成、變換工具[2],它對類內(nèi)成員的增刪改、對代碼內(nèi)各指令的查找變換都進(jìn)行了優(yōu)化,隱藏了對常量池的操作,允許引入指令跳轉(zhuǎn)、劃定區(qū)域所需要的地址標(biāo)號,能自動計算運(yùn)行時Frame的容量、代碼驗證用的StackMapTable等。針對類文件的復(fù)雜結(jié)構(gòu),ASM采用Visitor設(shè)計模式,提供了基于事件和基于對象的兩套API。前者簡單快捷,但必須按序、即時地處理類內(nèi)各元素。后者在內(nèi)存中將各元素組織成樹型結(jié)構(gòu),便于根據(jù)上下文自由修改。ASM提供下列核心部件[3]。
● ClassVisitor、FieldVisitor、MethodVisitor等抽象類:定義了處理類內(nèi)各種元素的visit方法簽名,用戶通過子類可實現(xiàn)對類內(nèi)部各種成員的過濾、變換、增補(bǔ)等操作。它們還可接受另一Visitor對象,組成處理鏈,實現(xiàn)對元素的后續(xù)處理。
● ClassNode、FieldNode、MethodNode:是以上各Visitor的子類,定義了記錄類內(nèi)部各種元素的數(shù)據(jù)結(jié)構(gòu)和對應(yīng)的visit方法,用于實現(xiàn)基于對象的處理方式,在變換修改后還能用accept方法發(fā)起后續(xù)處理。
● ClassReader:它有accept方法,能接受Visitor對象,并執(zhí)行對指定類文件內(nèi)部結(jié)構(gòu)的解析,進(jìn)而驅(qū)動該對象執(zhí)行相應(yīng)方法進(jìn)行處理。
● ClassWriter:是ClassVisitor的子類,執(zhí)行相應(yīng)方法能構(gòu)造出對應(yīng)類內(nèi)元素的二進(jìn)制表示,最后調(diào)用toByteArray方法,即可獲得完整的類文件。
使用ASM時要將Reader、自定義的Visitor和Writer串接在一起,有時甚至形成多路結(jié)構(gòu),共同完成較復(fù)雜的處理。圖3就是對作業(yè)代碼進(jìn)行修正變換所采用的結(jié)構(gòu),它會在代碼中搜尋指定的指令和結(jié)構(gòu),進(jìn)行必要的修改與添加。
2 類文件的修改變換
2.1 方法調(diào)用指令及變換方案
程序中使用類庫主要是通過調(diào)用它提供的方法。Java虛擬機(jī)有5種方法調(diào)用指令,INVOKEVIRTUAL和INVOKEINTERFACE分別調(diào)用類內(nèi)定義和接口定義的動態(tài)綁定方法;INVOKESPECIAL調(diào)用無需動態(tài)綁定的方法;它們都通過某個對象執(zhí)行,INVOKESTATIC調(diào)用不使用對象的類方法;INVOKEDYNAMIC目前除了lambda表達(dá)式外,Java程序中并不使用。這些指令內(nèi)都標(biāo)明了方法的名稱、類型及所在類在常量池中的索引,因此在代碼中查找和修改特定的調(diào)用指令是比較簡單的。
Java虛擬機(jī)執(zhí)行這些指令時都會在當(dāng)前線程的堆棧頂創(chuàng)建一個Frame作為運(yùn)行環(huán)境,其中包含局部變量區(qū)和操作數(shù)棧。方法調(diào)用指令前應(yīng)先有指令將所需參數(shù)按序壓入操作數(shù)棧,如是對象方法,首個參數(shù)一定是隱含的this引用。調(diào)用執(zhí)行時這些參數(shù)被轉(zhuǎn)錄至新的Frame,作為前幾個局部變量,在代碼中得到處理。調(diào)用完成后將撤消該Frame,如有返回結(jié)果,它會出現(xiàn)在原Frame的操作數(shù)棧頂。
要在使用指定的API時獲得信息,就要找到對應(yīng)調(diào)用指令加以修改。比如對于正則表達(dá)式,通常關(guān)心其模式字符串的使用是否正確,這就要考察Pattern類的compile方法。最簡單的辦法是將該調(diào)用指令替換為對名為_compile的一個新方法的調(diào)用,由它再去調(diào)用原來的Pattern.compile,但額外會輸出需要的信息,比如作為參數(shù)的模式字符串等。新方法保持與原方法的參數(shù)及返回完全一致,這樣調(diào)用指令前后無需再作其他修改,只要在代碼中引入新的_compile方法就可以了。使用這種修改調(diào)用指令的方法就可方便地獲取所有要關(guān)注的API的使用信息。
除了主動調(diào)用類庫的方法外,應(yīng)用程序可能還要提供某些接口或抽象類規(guī)定的實現(xiàn)代碼,比如Comparator接口就需要完成compare方法,它是被調(diào)用的。有時候了解它們的執(zhí)行,考察其參數(shù)和返回結(jié)果,對理解整個程序也很有意義。為此可使用另一種替換方法,假如要了解compare方法的被調(diào)用情況,可將類文件中的原實現(xiàn)代碼改名為_compare,另生成一個替代的compare方法,它會調(diào)用被改名的_compare來實現(xiàn)功能,但還能輸出需要的信息,這樣就能知道比較操作的時機(jī)、次數(shù)、每次比較的值和結(jié)果等。
2.2 標(biāo)注變換點
采用這種方案,必須事先聲明關(guān)注哪些方法,怎么輸出信息,為此要按圖2的樣例代碼所示定義CheckItems類。它通過一些自定義的標(biāo)注指出關(guān)注的方法,@Item1和@Item2分別針對上述主動調(diào)用和被動調(diào)用的情形。它們都要寫出完整的方法名,后面再緊跟處理方法,其中除了執(zhí)行原本的調(diào)用外,還說明怎么輸出信息。這些引入的代碼會按前述方案進(jìn)行處理,有些要改名,會用無沖突的名字代替。但這些方法的參數(shù)和返回類型必須和原方法完全一致,由于其中所有的方法調(diào)用都改換成了類方法調(diào)用,所以如果原來是對象方法,其隱含的this引用也要作為第一個參數(shù)顯式寫出,如圖2所示。
CheckItems的父類是Check,它為處理和保存調(diào)用信息提供了一些通用方法。
● 打開并管理一輸出流,提供print()和println()等方法,用來記錄需要的信息。
● 提供fline()方法,返回調(diào)用點所在的源文件名和行號,可幫助區(qū)分同一方法在不同地點的調(diào)用,也有利于源文件閱讀時的查找。
● 如果參數(shù)或返回值是基本類型或字符串,可直接輸出,其意義很明晰。但對引用型變量t,則應(yīng)使用obj(t)方法,它返回一個由類型和序號構(gòu)成的、較容易理解的名稱,如Thread1、Pattern3等,還保證同一引用一定獲得同一名稱,有助于在考察流程時確定各對象前后的同一性。
● 如果對象t是自定義類型,還可用val(t)獲得其內(nèi)部各字段的名字和值,比如對學(xué)生定義的日期類型,可能會輸出{year:2019,month:1,day:1},這樣可以切實了解代碼運(yùn)行中的數(shù)據(jù)細(xì)節(jié)及其變化,對分析、理解程序是非常必要的。
如果要考察特定對象的生成情況,也可在其構(gòu)造方法的調(diào)用指令中,進(jìn)行類似地修改替換,獲得信息輸出。但構(gòu)造方法在類文件中被命名為
在CheckItems類中還可根據(jù)需要引入一些狀態(tài)變量來控制信息輸出。比如對循環(huán)中的操作,有時只要查看前幾次調(diào)用或最后的調(diào)用,有時只需統(tǒng)計調(diào)用次數(shù),通過引入變量就能實現(xiàn)這些控制。圖2的樣例代碼就統(tǒng)計了compare方法的調(diào)用次數(shù),并僅對前三次輸出。由此需引入對這些狀態(tài)變量進(jìn)行初始化和終極處理的begin和end方法,會在每份作業(yè)批閱的開始和結(jié)尾處執(zhí)行。
對多線程相關(guān)作業(yè),常常還關(guān)心用synchronized指示的對象鎖的狀態(tài)。該保留字在語法上有兩種用法:置于方法定義前或組成一個代碼塊,對應(yīng)地在類文件中也有兩種處置[4]:在對應(yīng)方法的標(biāo)記中標(biāo)志;用MONITORENTER/MONITOREXIT指令劃出加鎖區(qū)間,虛擬機(jī)運(yùn)行時會相應(yīng)的進(jìn)行加/解鎖操作。批閱作業(yè)時如希望了解這些情況,可在CheckItems類中加@LockInfo標(biāo)注,代碼處理時就會在合適位置插入指令,執(zhí)行時就可獲得加/解鎖的時機(jī)、運(yùn)行的線程、被鎖的對象等信息。
批閱學(xué)生作業(yè)當(dāng)然很希望了解他們的設(shè)計,有哪幾個類、每個類的內(nèi)部結(jié)構(gòu)等。雖然可以查閱源代碼,但要打開不同的文件再翻閱查找,還是比較費事。為此可在CheckItems類中加@MemberInfo標(biāo)注,將會在處理類文件時先輸出每個類的梗概信息,包括它們的繼承/實現(xiàn)關(guān)系、所有字段和方法成員的名字、類型、訪問控制等內(nèi)容。這樣就大概了解了其設(shè)計結(jié)構(gòu),對理解后續(xù)運(yùn)行時輸出的信息也有幫助。
2.3 代碼變換器結(jié)構(gòu)
有了CheckItems文件,就明確了需要考察的方法調(diào)用以及對應(yīng)的信息輸出方式,現(xiàn)在只需有一個代碼變換器,將這些內(nèi)容“織入”到學(xué)生提交的作業(yè)中,代碼一經(jīng)執(zhí)行,那些關(guān)鍵步驟就被記錄下來。通過查看這些信息,就能了解學(xué)生的學(xué)習(xí)情況。
代碼變換器利用ASM提供的各種工具類構(gòu)建,結(jié)構(gòu)如圖3所示。其中Reader2讀入CheckItems,將其中內(nèi)容存于Node對象中,并向Visitor1指出需要進(jìn)行變換的方法。Reader1能讀入作業(yè)中的各個class文件,解析后交Visitor1處理,Visitor1如發(fā)現(xiàn)匹配的方法,將按約定修改名稱、指令, 再送至Writer,同時將需要補(bǔ)充的內(nèi)容告訴Visitor2。Visitor2從Node中取得相應(yīng)元素,作必要修改后也送至Writer。最后由Writer綜合這些輸入,構(gòu)造出新的、能輸出需要信息、完整可運(yùn)行的二進(jìn)制類文件。
實際批閱作業(yè)時將全班的代碼組織在一個子目錄中,每人是獨立的包或jar文件,控制程序?qū)⒅饌€處理它們。對每份作業(yè),生成一個自定義的類裝載器,負(fù)責(zé)裝載學(xué)生作業(yè)代碼并利用上述變換器進(jìn)行必要轉(zhuǎn)換。在調(diào)用main()啟動運(yùn)行后,其輸出的信息將送至指定文件。為避免處理過程脫離控制,變換器還將攔截Runtime和System 類的exit或halt調(diào)用,將其轉(zhuǎn)換為能被控制程序捕捉的異常。代碼變換器還會把代碼中所有的標(biāo)準(zhǔn)輸出也轉(zhuǎn)向至同一指定文件,這樣該程序的設(shè)計結(jié)構(gòu)、運(yùn)行過程中的關(guān)鍵信息和運(yùn)行結(jié)果都被放置在一起,理解、評判學(xué)生作業(yè)情況就很方便了。
3 結(jié)束語
有了這一作業(yè)批閱輔助工具,可以大大提高工作效率。對于稍有規(guī)模的程序,要理解其思路,少不了要來回參照,分層拆剖。如果學(xué)生人數(shù)較多,對教師的心神精力是一個考驗。而使用本工具,因為每一關(guān)鍵步驟都有提示,比照源代碼,其運(yùn)行邏輯通常立即就可以抓住。
批改作業(yè)時只閱讀程序而不運(yùn)行,細(xì)節(jié)錯誤很難發(fā)現(xiàn)。只看運(yùn)行最終結(jié)果,雖能發(fā)現(xiàn)有錯,但要找到問題所在也很困難。而使用本工具,只要對輸出的信息有合適安排,那么定位錯誤位置,找出癥結(jié)所在就會容易很多。
當(dāng)然,在此基礎(chǔ)上還可以進(jìn)一步開展工作,比如對處理過程、運(yùn)行結(jié)果添加自動評分功能,對代碼實現(xiàn)簡單的數(shù)據(jù)流、控制流分析以更清晰顯現(xiàn)程序邏輯等,這都有待于今后繼續(xù)努力。
參考文獻(xiàn)(References):
[1] Tim Lindholm, Frank Yellin. The Java Virtual Machine Specification Java SE 11 Edition[M] Addison-Wesley Professional,2018.8
[2] Eric Bruneton. ASM 4.0 A Java bytecode engineering library[EB/OL].http://www.ow2.org/
[3] objectweb.org. asm 7.0 API[EB/OL]. http://www.ow2.org/
[4] James Gosling, Bill Joy. The Java LanguageSpecificationJava SE 11 Edition [M] Addison-Wesley Professional 2018.8