断言与异常(Assertion Vs Exception)
在日常编程实践中,断言与异常的界限不是很明显,这也使得它们常常没有被正确的使用。我也在不断的与这个模糊的怪兽搏斗,仅写此文和大家分享一下我的个人看法。我想我们还可以从很多角度来区别断言和异常的使用场景,欢迎大家的意见和建议。
异常的使用场景:用于捕获外部的可能错误
断言的使用场景:用于捕获内部的不可能错误
我们可以先仔细分析一下我们在.net中已经存在的异常。
- System.IO.FileLoadException
- SqlException
- IOException
- ServerException
首先,我们先不将它们看成异常,因为我们现在还没有在异常和断言之间划清界限,我们先将它们看成错误。
当我们在编码的第一现场考虑到可能会出现文件加载的错误或者服务器错误后,我们的第一直觉是这不是我们代码的问题,这是我们代码之外的问题。
例如下面这段代码
public void WriteSnapShot(string fileName, IEnumerable<DbItem> items) { string format = "{0}\t{1}\t{2}\t{3}\t{4}\t{5}"; using (FileStream fs = new FileStream(fileName, FileMode.Create)) { using (StreamWriter sw = new StreamWriter(fs, Encoding.Unicode)) { ... foreach (var item in items) { sw.WriteLine(string.Format(format, new object[]{ item.dealMan, item.version, item.priority, item.bugStatus, item.bugNum, item.description})); } sw.Flush(); } } }
上面的代码在写入文件,很显然会导致IOException。稍微有经验的程序员都会考虑到IO上可能出问题,那我们应该如何处理这个问题呢?在这个上下文中,我们别无它法,只能让这个错误继续往上抛,通知上面一层的调用者,有一个错误发生了,至于上一层调用者会如何处理,不是这个函数要考虑的问题。但在这个函数中,要记得一点,将当前函数中所占用的资源释放了。因此,当我们不能控制的外部错误出现时,我们可以将其作为异常往上抛,这时,我们该使用异常。
现在再来看看断言,我们还是以下面的一段代码为例子。
public Entities.SimpleBugInfo GetSimpleBugInfo(string bugNum) { var selector = DependencyFactory.Resolve<ISelector>(); var list = selector.Return<Entities.SimpleBugInfo>( reader => new Entities.SimpleBugInfo { bugNum = reader["bugNum"].ToString(), dealMan = reader["dealMan"].ToString(), description = reader["description"].ToString(), size = Convert.ToInt32(reader["size"]), fired = Convert.ToInt32(reader["fired"]), }, "select * from bugInfo", new WhereClause(bugNum, "bugNum")); Trace.Assert(list != null); if (list.Count == 0) return null; else return list[0]; }
当我贴出这段代码时,心情有些坎坷,因为我本人在这里也纠结了很久,这也是我一直没有将断言和异常划清界线的原因之一。
首先我们来回顾一下之前定义的断言使用场景:内部不可能发生的错误。
selector.Return这段代码是不是内部代码?如果我们能够修改Return中的代码,说明它是内部代码;反之,说明它是外部代码。对于内部代码,我们可以用断言来保护其逻辑的不变性,当断言被触发时,我们就可以确信是内部代码的错误,我们应该立即修复。
再纠结一下,假设Return是外部代码,我们没有办法去修改它。那么上面的代码可以有两种写法(如果你有更多的想法,请赐教)。
第一种,直接抛出异常。
If(list == null) { throw new NullReferenceException(); }
第二种,调整代码。
if(list == null || list.Count == 0) { return null; } else { return list[0]; }
当然,还有一种就是什么也不做,让代码执行下去直至系统为你抛出空引用错误。但这种做法违背了防卸性编程的原则,我们总是应行尽早或离错误的发生地最近的地方处理错误,避免错误数据流向系统的其它地方,产生更加严重的错误。
总结
对异常或断言的使用取决于你要防卸的是一个内部错误还是外部错误以及你认为它是一个内部错误或外部错误。如果你决定防卸一个内部错误,那请果断使用断言,反之,请使用异常。
异常处理
异常处理对于流程的控制,就像抛出与捕获一样分为两个方面:
如果错误(或某种情况)发生,是否允许程序的控制流继续执行下去(异常的抛出)
如果当前有异常发生,当前的代码是否有机会让程序的控制流进入到一个合理的状态(异常的捕获)
我认为可以用以上两条,作为判断异常处理的准绳。其实大家现在应该可以发现,这个所谓的准绳的着重点在于异常对于流程的影响,而不再是在什么情况下才使用异常。
对于流程控制,最直接的莫过于下面这段代码
try { foreach (var lockGroup in lockGroups) { ... foreach (var newlock in lockGroup.ToArray()) { ... if (diningBlocks.Exists(n => testLockRange.IsOverlapped(n.StartTime, n.EndTime))) { status = LockStatus.InResourceBlock; throw new LockException(); } var diningAvail = availabilities.Find(n => n.Time == newlock.StartTime.TimeOfDay); if (diningAvail == null) { status = LockStatus.Failed; throw new LockException(); } ... if (newLockQuantity > diningAvail.MaxAvail && !canOverrideLock.AllowOverBook) { status = LockStatus.Override; throw new LockException(); } else if (newLockQuantity + reservedQuantity + currentLockedAvail > diningAvail.MaxAvail && !canOverrideLock.AllowOverBook) { status = LockStatus.Override; throw new LockException(); } ... } } } catch (LockException) { return new DiningLock[] { }; }
在上面的代码中,有两层for循环,当最内层出现某种情况时,要求停止整个for循环的执行,显然用两个break是不行的,还得加入一个辅助变量。
但是,如果用异常,这个处理就简单多了。可以直接在最内层的抛出异常,在最外层(或是流程控制需要的地方)捕获异常。
在上面的代码中,异常处理起到了流程控制的作用,而不仅仅传递错误信息,对代码的简化做出了贡献。