诊断Java代码::SplitCleaner错误模式

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

症状:程序没能正确地管理资源,表现为泄漏或过早地释放了它们。

起因:程序的一些执行路径没有做到它们应该做的工作:释放资源 正好一次。

治疗和预防措施:把负责释放资源的代码移到获得资源的同一方法中。

不再赘述。

总结成功的经验能够让人越来越聪明,

诊断Java代码::SplitCleaner错误模式

相关文章:

你感兴趣的文章:

标签云: