Java 編程語言的一個特色是存儲自動管理,它把程序員從很容易出錯的釋放使用後的內存的工作中解放出來。盡管如此,許多程序還是得處理資源問題,例如文件和數據庫連接,這些都必須在使用之後明確地釋放掉。跟手工管理存儲一樣,程序員在手工管理資源時也會犯很多錯誤。其中一個就是本周專欄的主題 ― Split Cleaner錯誤模式。
分開還是不分開
在管理諸如文件和數據庫連接這樣的資源時,您必須在使用完資源後把它釋放掉。當然,對代碼的任何指定的執行,您希望一次獲得資源,然後一次將其釋放。要做到這點,您可以采用兩種方式:
您可以在同一個方法中獲得並釋放資源。用這種方式,可以保證資源每獲得一次,也釋放一次。
您可以跟蹤代碼的每一個可能的執行路徑,並確保在每一個實例中資源最後都被釋放掉了。
第二種方式可能會出問題。因為您的代碼庫不可避免地要變大,另一個不熟悉您代碼的程序員或許會添加一條沒把資源釋放掉的執行路徑,其後果當然是資源洩漏。
Split Cleaner 錯誤模式
我把符合這種模式的錯誤稱為 split cleaner,是因為釋放資源的代碼是沿各種可能的執行路徑分開的。因為沿各條路徑的釋放代碼很可能都是一樣的,所以大多數 split cleaner 也是 rogue tile的例子。(Rogue tile 是我對一種錯誤的稱呼,這種錯誤的起因是:起初用拷貝和粘貼的方式編寫代碼,但後來在做了一些更改後卻忘了適當地修改代碼的所有副本。如想更多了解 rogue tile,請參閱我的論文“ 錯誤模式:介紹。”)
例如,假設您正用 JDBC 處理一張員工姓名表。您希望執行的許多操作中包括遍歷這張表並對其中包含的數據進行計算。您要完成的最後一個操作可能是打印出所有員工的名字,如下所示:
清單 1. 遍歷一個員工表的代碼
import java.sql.*;
public class Example {
public static void main(String[] args) {
String url = "your database url";
try {
Connection con = DriverManager.getConnection(url);
new TablePrinter(con, "Names").walk();
}
catch (SQLException e) {
throw new RuntimeException(e.toString());
}
}
}
abstract class TableWalker {
Connection con;
String tableName;
public TableWalker(Connection _con, String _tableName) {
this.con = _con;
this.tableName = _tableName;
}
public void walk() throws SQLException {
String queryString =("SELECT * FROM " + tableName);
Statement stmt = con.createStatement();
ResultSet rs = stmt.executeQuery(queryString);
while (rs.next()) {
execute(rs);
}
con.close();
}
public abstract void execute(ResultSet rs) throws SQLException;
}
class TablePrinter extends TableWalker {
public TablePrinter(Connection _con, String _tableName) {
super(_con, _tableName);
}
public void execute(ResultSet rs) throws SQLException {
String s = rs.getString("FIRST_NAME");
System.out.println(s);
}
}
先說點題外話。請注意,我已把用來遍歷表的代碼抽出來放到了抽象類 Walker 中,以使新的子類可以很容易地遍歷表中的行。雖然試圖預測程序被擴展的各種方式並為其編寫代碼通常是浪費時間,但還是讓我們假設在此例中 絕對地,毫無疑問地,無論如何對代碼只做這一類的擴展。(事實上,我可以保證在本文結束前,只會有這樣一種擴展)。
症狀
現在,請注意數據庫連接被傳遞到了 TableWalker 的構造函數中。一旦完成對表的遍歷,它就將關閉連接。
所以,在這個例子中,我們采用第二種策略來清除連接。我們已經嘗試過沿著每一條執行路徑分別關閉連接。
讓我們假設在我們的系統環境中,在一次遍歷數據後關閉連接是有意義的(例如,也許這段代碼會被從命令提示符中調用)。即使在那種情況下,我們也不能捕捉到每一條可能的執行路徑 — 如果拋出了 SQLException ,程序可能會在關閉連接前異常終止。
因為 SQLException 在成熟代碼中很少見,所以這個錯誤可能在很長一段時間內都不會(可能在原開發人員已經轉行後也不會)表現出什麼症狀。自然地,這使得在症狀 真的表現出來時,診斷起來就更加困難。
但是如果擴展了代碼,就會有一些方式使症狀的出現變得快得多。
例如,我們假設在原始代碼寫好後,發現存檔的許多電話號碼明顯是過時的。於是管理人員決定把所有員工的電話號碼都替換為 411。為完成這個更新,新寫一個 TableWalker 如下:
清單 2. 更新過時數據的 walker 代碼
class TableFixer extends TableWalker {
public TableFixer(Connection _con, String _tableName) {
super(_con, _tableName);
}
public void execute(ResultSet rs) throws SQLException {
String s = rs.getString("FIRST_NAME");
String updateString = "UPDATE " + tableName +
"SET PHONE_NUMBER = " + "411" +
"WHERE FIRST_NAME LIKE '" + s + "'";
Statement stmt = con.createStatement();
stmt.executeUpdate(updateString);
}
}
因為 TableFixer 也繼承 TableWalker ,所以在這個類的一個實例上調用 walk 將關閉與該數據庫的連接,就象 TablePrinter 一樣。如果一個程序試圖用同一個連接生成兩個 walker 的實例,將發現第一個 walker 一完成遍歷後連接就被關閉了。
編程新手很容易犯這樣的錯誤,特別是如果不變量 ― 就是說只能構造一個 walker ― 沒有文檔或未測試過的話。
治療及預防措施
當您發現有一條執行路徑中沒有包含適當的清除代碼時,您可能會上當,只是簡單地把它添加到那條路徑中。
例如,您可以把 walk 方法的程序正文包到一個 try 程序塊中,並加入一條 finally 子句以 確保關閉了連接。但這樣一個解決方案卻不是個好辦法。
我們的 TableWalker 完全不必擔心關閉連接的問題。即使每個 TableWalker 都 確實設法關閉了連接,我們也會陷入到第二種方式中,這種方式可以讓這類錯誤模式自動現身 ― 當我們運行多個 walker 時,就會有 太多walker 試圖關閉連接。
更糟的是,如果我們兩次調用 con.close() (一次在 try 塊中,一次在 catch 塊中,而不是在 finally 語句中簡單地單獨調用),我們就會把 rogue tile 引入到代碼中。
如果代碼中添加了很多這種 rogue tile,那麼要成功地重新組織代碼將變得很困難。即使在測試期間,其中一些 rogue tile 可能處理的是基本上不會出現的執行路徑。
一個好得多的解決方案是重新組織代碼,用第一種方式來管理這些資源:把獲得和釋放資源的代碼放到同一個方法中。
Andrew Hunt 和 Dave Thomas 在他們的一本優秀書籍 The Pragmatic Programmer 中用一個成語 ― “有始有終”來提倡這種思想。每個方法都要負責把它獲得的資源釋放掉。在我們的示例中,就是把對 con.close() 的調用移到類 Example 的 main 方法中,如下所示:
清單 3. 通過重新組織代碼使資源的獲得和釋放發生在同一個方法內
public class Example {
public static void main(String[] args) {
String url = "your database url";
// con must be declared and initialized here, so as to be in
// the scope of both the try and finally clauses.
Connection con = null;
try {
con = DriverManager.getConnection(url, "Fernanda", "J8");
new TablePrinter(con, "Names").walk();
}
catch (SQLException e) {
throw new RuntimeException(e.toString());
}
finally {
try {
con.close();
}
catch (Exception e) {
throw new RuntimeException(e.toString());
}
}
}
}
這裡,對 con.close() 的調用是在創建連接的相同 try 塊的 finally 子句中,避開了沒調用它的任何可能的執行路徑。
總結
我們把本周的錯誤模式總結如下:
模式:Split Cleaner
症狀:程序沒能正確地管理資源,表現為洩漏或過早地釋放了它們。
起因:程序的一些執行路徑沒有做到它們應該做的工作:釋放資源 正好一次。
治療和預防措施:把負責釋放資源的代碼移到獲得資源的同一方法中。
不再贅述。