清晰,优雅,但却是错的

最近国外关于go语言的讨论很多,其中有一个论题是关于go语言里采用的错误码的异常处理模式和Java里的try-catch的模式孰优孰劣的问题。今天的这篇文章就涉及到这两种模式的对比比较。

并不是因为你看不见错误的产生就意味着它的不存在。

下面这段代码来自《C# programming》这本书,摘自讲述异常处理的章节。

try {
  AccessDatabase accessDb = new AccessDatabase();
  accessDb.GenerateDatabase();
} catch (Exception e) {
  // Inspect caught exception
}

public void GenerateDatabase()
{
  CreatePhysicalDatabase();
  CreateTables();
  CreateIndexes();
}

你是否注意到了这中写法是多么的清晰和优雅。

清晰,优雅,但却是错的。

假设在执行CreateIndexes()这个方法时有异常抛出。GenerateDatabase()方法并不捕获它,异常就会抛回给调用者,在那里被捕获。

但当异常从GenerateDatabase()方法中抛出时,重要的信息就丢失了:数据库创建的状态。捕获到异常的代码并不知道这创建数据库的哪一步出的错。需要删除掉索引吗?需要删除表吗?需要删除物理数据库吗?它不知道。

如果是在执行CreateIndexes()时出了错,你就永远丢失了一个物理数据库文件和里面的表。(因为这些文件会存放在硬盘上,它们存在哪里并不确定。)

在一个异常抛出模式的编程语言里写出正确的代码给人的感觉会更难,相比之下,在一个错误码模式的编程语言里给人的感觉会容易些,因为前者中任何东西都可能抛出异常,你必须时刻准备捕捉它。而后者很显然,当你在接收到错误码时才去检查发生的错误。在异常抛出模式中,你必须意识到任何地方都可能出现异常。

换句话说,在错误码模式中,如果有人没有捕捉到错误的发生,很显然是他没有检查错误码。但在异常抛出模式中,你不能很直观的从代码中发现是否已经有人捕捉到了错误,因为能产生错误的地方并不明显。

思考下面的代码:

Guy AddNewGuy(string name)
{
 Guy guy = new Guy(name);
 AddToLeague(guy);
 guy.Team = ChooseRandomTeam();
 return guy;
}

这个函数创建了一个新Guy,把他加入到俱乐部里,然后给他随机分配一个组。 不能再简单的操作了。

请记住:任何一行代码都可能产生错误。

如果”new Guy(name)”抛出了异常,会怎样?
哦,很幸运,我们还没有开始做什么,没有损失。
如果”AddToLeague(guy)”抛出了异常,会怎样?
我们新创建的“guy”就会被遗弃,但GC会把他清理干净。
如果”guy.Team = ChooseRandomTeam()”抛出了异常,会怎样?
哦偶,我们有麻烦了。我们已经把这个guy加入了俱乐部。如果有人捕获到了异常,他会发现俱乐部里的这个人不属于任何组。如果有一段代码是来遍历俱乐部的所有会员,发现这个guy,哪个组的?这时就会得到一个NullReferenceException异常。因为他的组还没有初始化。

当你在写代码时,如果每行代码都会抛出异常,你是否明白每个异常都会产生什么样的后果?如果你想写出正确的代码,你就需要考虑到这些。

ok,如何修补这个问题?重新组织一下操作步骤。

Guy AddNewGuy(string name)
{
 Guy guy = new Guy(name);
 guy.Team = ChooseRandomTeam();
 AddToLeague(guy);
 return guy;
}

看起来是一个很微小的改动,但却对错误恢复产生巨大的影响。通过延后提交数据(把guy加入俱乐部),在构造这个guy过程中发生任何异常都不会产生任何的后续影响。会发生的事只是一个未构造完成的guy被丢弃了,最终会被GC清理掉。

通用设计原则:除非已经完备,不要提交数据。

当然,这个例子非常简单,因为在创建guy的步骤中没有其它的关联影响。如果在创建过程中出了什么问题,丢掉它就行了,让GC处理余下的事情。

在现实生活中,情况会麻烦的多。看看下面的代码:

Guy AddNewGuy(string name)
{
 Guy guy = new Guy(name);
 guy.Team = ChooseRandomTeam();
 guy.Team.Add(guy);
 AddToLeague(guy);
 return guy;
}

跟上面我们改正过的函数一样,只是有人认为,如果在组里保持一个成员列表的引用会更有效率些,于是,你需要把自己add到你想加入到组里。这样做又会产生什么样的后果?

[英文原文:Cleaner, more elegant, and wrong ]
分享这篇文章:

11 Responses to 清晰,优雅,但却是错的

  1. ammo says:

    我不认同本文的说法,数据库操作应当使用事务,在发生异常时回滚
    不会出现本文说的问题

  2. ekisstherain says:

    原子操作问题,肯定需要绑定操作的!!!

    • 丁凯 says:

      作者的事例跟数据库操作没什么关系,只是创建数据库而已,如果要使用基于异常的错误处理机制得写成如下形式:
      public void GenerateDatabase()
      {
      try {
      CreatePhysicalDatabase();
      CreateTables();
      CreateIndexes();
      } catch(Exception e) {
      if(e.isCreateIndexException()) {
      ……
      }
      if(e.isCreateTablesException()) {
      ……
      }

      }
      }

  3. ykkk says:

    文中的观点有点莫名其妙…
    catch异常却不去处理甚至不写入日志,本身就是一个极其严重的错误,当你catch一个异常的时候,你必须很清楚你写的cache语句的意义,那种catch异常却不做任何处理是只有初学者或者新手才会犯的错误。

    文中的观点是,提交最好在最后才做。这一点我认为完全是正确的。有时候的确没有办法使用事物处理,比如当你没有使用同一个数据库,或者根本没有使用数据库.这个时候想去模拟事物回滚就十分困难。把提交放到最后有助于你减少风险。

    但是但是!这个和try catch模式以及异常码模式哪个更合适关系不大把?try catch用好了也可以优雅而且没有错误。异常码写的时候也必须步步小心,否则一样会出问题。

    • af says:

      错误码模式最大的优点就是在最近的可能出错的地方进行检查,能够上下文无关。

      如果一个函数调用就用一次try/catch,那么跟错误码模式没区别。
      如果一大块代码用一个try/catch,那么需要集中处理不同函数的异常,就会出现不同函数的异常的隐形交叉。

  4. void says:

    异常模式很有用,不同意错误码模式。文中作者举得例子我不认同。用异常模式完全可以处理的了

  5. Voice says:

    于是,你需要把自己add到你想加入到组里。这样做又会产生什么样的后果? 这个不太理解作者想表达什么意思

  6. 3o,, says:

    看上去清晰和真正的清晰有差别,就和干净和看上去干净有差距一样。

    清晰,优雅没错,只是有可能是 “看上去清晰,优雅” 而已。

  7. 魏敬贤 says:

    异常机制的好处是不用每作一个处理就要验证一次错误码。错误码的好处是程序运行顺序是完全确定的,不会出现看着没问题而实际有问题的情况。

发表评论

邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据