版权声明
请尊重原创作品。转载请保持文章完整性,并以超链接形式注明原始作者“tingsking18”和主站点地址,方便其他朋友提问和指正。
关于C++中异常的争论何其多也,但往往是一些不合事实的误解。异常曾经是一个难以用好的语言特性,幸运的是,随着C++社区经验的积累,今天我们已经有足够的知识轻松编写异常安全的代码了,而且编写异常安全的代码一般也不会对性能造成影响。
使用异常还是返回错误码?这是个争论不休的话题。大家一定听说过这样的说法:只有在真正异常的时候,才使用异常。那什么是“真正异常的时候”?在回答这个问题以前,让我们先看一看程序设计中的不变式原理。
对象就是属性聚合加方法,如何判定一个对象的属性聚合是不是处于逻辑上正确的状态呢?这可以通过一系列的断言,最后下一个结论说:这个对象的属性聚合逻辑上是正确的或者是有问题的。这些断言就是衡量对象属性聚合对错的不变式。
我们通常在函数调用中,实施不变式的检查。不变式分为三类:前条件,后条件和不变式。前条件是指在函数调用之前,必须满足的逻辑条件,后条件是函数调用后必须满足的逻辑条件,不变式则是整个函数执行中都必须满足的条件。在我们的讨论中,不变式既是前条件又是后条件。前条件是必须满足的,如果不满足,那就是程序逻辑错误,后条件则不一定。现在,我们可以用不变式来严格定义异常状况了:满足前条件,但是无法满足后条件,即为异常状况。当且仅当发生异常状况时,才抛出异常。
关于何时抛出异常的回答中,并不排斥返回值报告错误,而且这两者是正交的。然而,从我们经验上来说,完全可以在这两者中加以选择,这又是为什么呢?事实上,当我们做出这种选择时,必然意味着接口语意的改变,在不改变接口的情况下,其实是无法选择的(试试看,用返回值处理构造函数中的错误)。通过不变式区别出正常和异常状况,还可以更好地提炼接口。
对于异常安全的评定,可分为三个级别:基本保证、强保证和不会失败。
基本保证:确保出现异常时程序(对象)处于未知但有效的状态。所谓有效,即对象的不变式检查全部通过。
强保证:确保操作的事务性,要么成功,程序处于目标状态,要么不发生改变。
不会失败:对于大多数函数来说,这是很难保证的。对于C++程序,至少析构函数、释放函数和swap函数要确保不会失败,这是编写异常安全代码的基础。
首先从异常情况下资源管理的问题开始.很多人可能都这么干过:
Type* obj = new Type;
try{ do_something...}
catch(...){ delete obj; throw;}
不要这么做!这么做只会使你的代码看上去混乱,而且会降低效率,这也是一直以来异常名声不大好的原因之一. 请借助于RAII技术来完成这样的工作:
auto_ptr<Type> obj_ptr(new Type);
do_something...
这样的代码简洁、安全而且无损于效率。当你不关心或是无法处理异常时,请不要试图捕获它。并非使用try...catch才能编写异常安全的代码,大部分异常安全的代码都不需要try...catch。我承认,现实世界并非总是如上述的例子那样简单,但是这个例子确实可以代表很多异常安全代码的做法。在这个例子中,boost::scoped_ptr是auto_ptr一个更适合的替代品。
现在来考虑这样一个构造函数:
Type() : m_a(new TypeA), m_b(new TypeB){}
假设成员变量m_a和m_b是原始的指针类型,并且和Type内的申明顺序一致。这样的代码是不安全的,它存在资源泄漏问题,构造函数的失败回滚机制无法应对这样的问题。如果new TypeB抛出异常,new TypeA返回的资源是得不到释放机会的.曾经,很多人用这样的方法避免异常:
Type() : m_a(NULL), m_b(NULL){
auto_ptr<TypeA> tmp_a(new TypeA);
auto_ptr<TypeB> tmp_b(new TypeB);
m_a = tmp_a.release();
m_b = tmp_b.release();
}
当然,这样的方法确实是能够实现异常安全的代码的,而且其中实现思想将是非常重要的,在如何实现强保证的异常安全代码中会采用这种思想.然而这种做法不够彻底,至少析构函数还是要手动完成的。我们仍然可以借助RAII技术,把这件事做得更为彻底:shared_ptr<TypeA> m_a; shared_ptr<TypeB> m_b;这样,我们就可以轻而易举地写出异常安全的代码:
Type() : m_a(new TypeA), m_b(new TypeB){}
如果你觉得shared_ptr的性能不能满足要求,可以编写一个接口类似scoped_ptr的智能指针类,在析构函数中释放资源即可。如果类设计成不可复制的,也可以直接用scoped_ptr。强烈建议不要把auto_ptr作为数据成员使用,scoped_ptr虽然名字不大好,但是至少很安全而且不会导致混乱。
RAII技术并不仅仅用于上述例子中,所有必须成对出现的操作都可以通过这一技术完成而不必try...catch.下面的代码也是常见的:
a_lock.lock();
try{ ...} catch(...) {a_lock.unlock();throw;}
a_lock.unlock();
可以这样解决,先提供一个成对操作的辅助类:
struct scoped_lock{
explicit scoped_lock(Lock& lock) : m_l(lock){m_l.lock();}
~scoped_lock(){m_l.unlock();}
private:
Lock& m_l;
};
然后,代码只需这样写:
scoped_lock guard(a_lock);
do_something...
清晰而优雅!继续考察这个例子,假设我们并不需要成对操作, 显然,修改scoped_lock构造函数即可解决问题。然而,往往方法名称和参数也不是那么固定的,怎么办?可以借助这样一个辅助类:
template<typename FEnd, typename FBegin>
struct pair_guard{
pair_guard(FEnd fe, FBegin fb) : m_fe(fe) {if (fb) fb();}
~pair_guard(){m_fe();}
private:
FEnd m_fe;
...//禁止复制
};
typedef pair_guard<function<void () > , function<void()> > simple_pair_guard;
好了,借助boost库,我们可以这样来编写代码了:
simple_pair_guard guard(bind(&Lock::unlock, a_lock), bind(&Lock::lock, a_lock) );
do_something...
我承认,这样的代码不如前面的简洁和容易理解,但是它更灵活,无论函数名称是什么,都可以拿来结对。我们可以加强对bind的运用,结合占位符和reference_wrapper,就可以处理函数参数、动态绑定变量。所有我们在catch内外的相同工作,交给pair_guard去完成即可。
考察前面的几个例子,也许你已经发现了,所谓异常安全的代码,竟然就是如何避免try...catch的代码,这和直觉似乎是违背的。有些时候,事情就是如此违背直觉。异常是无处不在的,当你不需要关心异常或者无法处理异常的时候,就应该避免捕获异常。除非你打算捕获所有异常,否则,请务必把未处理的异常再次抛出。try...catch的方式固然能够写出异常安全的代码,但是那样的代码无论是清晰性和效率都是难以忍受的,而这正是很多人抨击C++异常的理由。在C++的世界,就应该按照C++的法则来行事。
如果按照上述的原则行事,能够实现基本保证了吗?诚恳地说,基础设施有了,但技巧上还不够,让我们继续分析不够的部分。
对于一个方法常规的执行过程,我们在方法内部可能需要多次修改对象状态,在方法执行的中途,对象是可能处于非法状态的(非法状态 != 未知状态),如果此时发生异常,对象将变得无效。利用前述的手段,在pair_guard的析构中修复对象是可行的,但缺乏效率,代码将变得复杂。最好的办法是......是避免这么作,这么说有点不厚道,但并非毫无道理。当对象处于非法状态时,意味着此时此刻对象不能安全重入、不能共享。现实一点的做法是:
a.每一次修改对象,都确保对象处于合法状态
b.或者当对象处于非法状态时,所有操作决不会失败。
在接下来的强保证的讨论中细述如何做到这两点。
强保证是事务性的,这个事务性和数据库的事务性有区别,也有共通性。实现强保证的原则做法是:在可能失败的过程中计算出对象的目标状态,但是不修改对象,在决不失败的过程中,把对象替换到目标状态。考察一个不安全的字符串赋值方法:
string& operator=(const string& rsh){
if (this != &rsh){
myalloc locked_pool(m_data);
locked_pool.deallocate(m_data);
if (rsh.empty())
m_data = NULL;
else{
m_data = locked_pool.allocate(rsh.size() + 1);
never_failed_copy(m_data, rsh.m_data, rsh.size() + 1);
}
}
return *this;
}
locked_pool是为了锁定内存页。为了讨论的简单起见,我们假设只有locked_pool构造函数和allocate是可能抛出异常的,那么这段代码连基本保证也没有做到。若allocate失败,则m_data取值将是非法的。参考上面的b条目,我们可以这样修改代码:
myalloc locked_pool(m_data);
locked_pool.deallocate(m_data); //进入非法状态
m_data = NULL; //立刻再次回到合法状态,且不会失败
if(!rsh.empty()){
m_data = locked_pool.allocate(rsh.size() + 1);
never_failed_memcopy(m_data, rsh.m_data, rsh.size() + 1);
}
现在,如果locked_pool失败,对象不发生改变。如果allocate失败,对象是一个空字符串,这既不是初始状态,也不是我们预期的目标状态,但它是一个合法状态。我们阐明了实现基本保证所需要的技巧部分,结合前述的基础设施(RAII的运用),完全可以实现基本保证了...哦,其实还是有一点疏漏,不过,那就留到最后吧。
继续,让上面的代码实现强保证:
myalloc locked_pool(m_data);
char* tmp = NULL;
if(!rsh.empty()){
tmp = locked_pool.allocate(rsh.size() + 1);
never_failed_memcopy(tmp, rsh.m_data, rsh.size() + 1); //先生成目标状态
}
swap(tmp, m_data); //对象安全进入目标状态
m_alloc.deallocate(tmp); //释放原有资源
强保证的代码多使用了一个局部变量tmp,先计算出目标状态放在tmp中,然后在安全进入目标状态,这个过程我们并没有损失什么东西(代码清晰性,性能等等)。看上去,实现强保证并不比基本保证困难多少,一般而言,也确实如此。不过,别太自信,举一种典型的很难实现强保证的例子,对于区间操作的强保证:
for (itr = range.begin(); itr != range.end(); ++itr){
itr->do_something();
}
如果某个do_something失败了,range将处于什么状态?这段代码仍然做到了基本保证,但不是强保证的,根据实现强保证的基本原则,我们可以这么做:
tmp = range;
for (itr = tmp.begin(); itr != tmp.end(); ++itr){
itr->do_something();
}
swap(tmp, range);
似乎很简单啊!呵呵,这样的做法并非不可取,只是有时候行不通。因为我们额外付出了性能的代价,而且,这个代价可能很大。无论如何,我们阐述了实现强保证的方法,怎么取舍则由您决定了。
接下来讨论最后一种异常安全保证:不会失败。
通常,我们并不需要这么强的安全保证,但是我们至少必须保证三类过程不会失败:析构函数,释放类函数,swap。析构和释放函数不会失败,这是RAII技术有效的基石,swap不会失败,是为了“在决不失败的过程中,把对象替换到目标状态”。我们前面的所有讨论都是建立在这三类过程不会失败的基础上的,在这里,弥补了上面的那个疏漏。
一般而言,语言内部类型的赋值、取地址等运算是不会发生异常的,上述三类过程逻辑上也是不会发生异常的。内部运算中,除法运算可能抛出异常。但是地址访问错通常是一种错误,而不是异常,我们本应该在前条件检查中就发现的这一点的。所有不会发生异常操作的简单累加,仍然不会导致异常。
好了,现在我们可以总结一下编写异常安全代码的几条准则了:
1.只在应该使用异常的地方抛出异常
2.如果不知道如何处理异常,请不要捕获(截留)异常。
3.充分使用RAII,旁路异常。
4.努力实现强保证,至少实现基本保证。
5.确保析构函数、释放类函数和swap不会失败。
另外,还有一些语言细节问题,因为和这个主题有关也一并列出:
1.不要这样抛出异常:throw new exception;这将导致内存泄漏。
2.自定义类型,应该捕获异常的引用类型:catch(exception& e)或catch(const exception& e)。
3.不要使用异常规范,即使是空异常规范。编译器并不保证只抛出异常规范允许的异常,更多内容请参考相关书籍。
分享到:
相关推荐
在某些情况下,开发者可能需要将已有的C++代码转换为Java代码,以便在Java平台上运行或利用Java的生态系统。 标题“C++代码转Java工具”暗示了一个软件或服务的存在,它的功能是自动化C++源代码到Java源代码的转换...
通过深入理解和实践这些C和C++代码精粹,开发者能够编写出更加高效、可读性强的代码,为项目贡献坚实的基础。无论是初学者还是经验丰富的开发者,都应该不断学习和探索这些优秀实践,不断提升自己的编程水平。
- `try-catch`语句:学习如何编写异常安全的代码。 9. **STL(标准模板库)** - 容器(如vector、list、set、map等):掌握各种容器的使用和操作。 - 迭代器:理解迭代器的工作原理,以及如何遍历和修改容器中的...
该项目的核心是一段用C++编写的源代码,它能够让开发者深入理解2D游戏设计与编程。游戏的源代码MC.cpp文件,包含了游戏的核心逻辑,例如游戏循环、用户输入处理、游戏状态更新、渲染逻辑等。在C++中,游戏循环是游戏...
总之,这个C++编写的通讯录源代码提供了学习和理解面向对象编程、文件操作、数据结构选择以及异常处理等核心C++概念的机会。通过研究和分析这个程序,开发者不仅可以提升C++技能,还能深入理解软件工程中的设计决策...
开发者可以直接在IDE中编写、调试C++代码,大大提高了开发效率。 5. **C++11及以上版本支持** Android NDK支持C++11及以上版本,这使得开发者可以利用C++的新特性,如智能指针、lambda表达式和右值引用等,提高...
条款8:编写异常安全的代码——之一 32 条款9:编写异常安全的代码——之二 37 条款10:编写异常安全的代码——之三 40 条款11:编写异常安全的代码——之四 47 条款12:编写异常安全的代码——之五 50 条款13...
在C++编程中,异常处理是...总之,理解和熟练掌握C++的异常处理机制,以及在VS环境下如何结合使用SEH,对于编写健壮、容错性好的程序至关重要。通过不断实践和学习,你将能够更好地应对各种程序运行时可能出现的问题。
7. 错误处理和异常安全:大型软件需要考虑各种错误情况,C++的异常处理机制可以帮助我们处理运行时错误。 【压缩包子文件的文件名称列表】中,"批清理.bat"可能是一个批处理脚本,用于自动化清理或构建过程。"tsk_r...
5. **异常处理**:学习如何使用try、catch和throw关键字来捕获和处理程序运行时可能出现的错误,这是编写健壮代码的重要部分。 6. **标准库的使用**:C++标准库提供了大量的功能,如I/O流(iostream)、容器(如...
5. 模板(Template)和泛型编程:C++的模板功能允许我们编写能够处理多种数据类型的函数和类,从而实现泛型编程。在代码中,你会看到如何定义和使用模板,包括函数模板、类模板以及模板特化。 6. 异常处理...
规范可能要求在特定情况下使用异常,如资源分配失败,或者要求异常安全的代码设计。 5. **模板和泛型编程**:规范可能指导如何有效使用模板,以实现代码复用和类型安全,同时避免过度使用导致的编译器复杂度增加。 ...
遵循一定的命名规则、使用空格和缩进、添加注释都是编写高质量C++代码的关键。 以上就是对标题和描述中涉及的C++程序设计基础知识点的详解,这些知识是每个C++程序员应该掌握的基础。通过学习和实践这些内容,...
- **异常处理**:C#中的异常在C++中可能表现为错误代码或返回值,需要妥善处理。 - **内存管理**:托管代码和非托管代码对内存的管理方式不同,需要避免内存泄漏和悬挂指针。 4. **示例代码**: 在C#中,一个...
通过阅读《C++经典代码大全》,读者不仅可以提高编程技能,还能学习到如何写出更安全、更高效的C++代码,这对于在软件开发领域建立坚实基础和提升职业素养非常有益。这本书对于初学者来说是一个很好的起点,对于经验...
NDK(Native Development Kit)是Google提供的工具集,用于在Android上编写C/C++代码。 2. **创建JNI接口**:在Java类中声明native方法,这些方法将在C++代码中实现。例如: ```java public class MyActivity ...
《经典C和C++代码精粹》是一本专注于C和C++语言编程的教材,通过详细讲解两种语言的基础和深入内容,旨在为读者提供一套全面的学习资料。书中涵盖了两种语言的预备知识、主要概念、数据抽象、运算符重载、具体的数据...
7. **异常处理**:在编写这样的程序时,需要考虑到可能出现的各种错误情况,如文件不存在、网络连接失败等,所以异常处理(try-catch)机制是必须的。 8. **安全编程**:尽管我们讨论的是一个不道德的例子,但了解...
通过本教程,学习者将深入理解C++的基本语法、类和对象、继承、多态、模板等关键概念,以及如何编写高效、安全的C++代码。 SMC,全称State Machine Compiler,是一种用于创建状态机的工具,尤其适用于构建复杂、可...