软件复杂;然而,当你设计你的代码时,无论是在开发代码的正常测试阶段,还是在发布 bug 报告时,在某个时候你都必须调试它。谨慎的做法是设计代码,使测试和调试尽可能简单。这意味着添加跟踪和报告代码,确定不变量以及前置和后置条件,以便您有一个测试代码的起点,并编写具有可理解和有意义的错误代码的函数。
C++ 和 C 标准库具有广泛的功能,允许您应用跟踪和报告功能,以便您可以测试代码是否以预期的方式处理数据。这些工具大多使用条件编译,因此报告只发生在调试版本中,但是如果您为跟踪提供有意义的消息,它们将成为代码文档的一部分。在报告代码的行为之前,您首先必须知道从中可以得到什么。
类不变量是条件,即对象状态,你知道它仍然是真的。在方法调用期间,对象状态将会改变,可能会使对象失效,但是一旦公共方法完成,对象状态必须保持一致。不能保证用户会以什么顺序调用类的方法,或者即使他们调用方法,所以无论用户调用什么方法,对象都必须是可用的。对象的不变方面适用于方法调用级别:在方法调用之间,对象必须一致且可用。
例如,假设您有一个表示日期的类:它保存 1 到 31 之间的日数字、1 到 12 之间的月数字和年数字。类的不变量是,无论你对日期类的对象做什么,它总是保持一个有效的日期。这意味着用户可以安全地使用日期类的对象。这也意味着类中的其他方法(比如,确定两个日期之间间隔多少天的方法,operator-
)可以假设日期对象中的值是有效的,因此这些方法不必检查它们所作用的数据的有效性。
但是,有效日期大于范围 1 到 31(天)和 1 到 12(月),因为不是每个月都有 31 天。所以,如果你有一个有效的日期,比如说 1997 年 4 月 5 日,并且你调用一个set_day
方法将日数设置为 31,由于 4 月 31 日不是一个有效的日期,所以违反了类不变条件。如果要更改日期对象中的值,唯一安全的方法是同时更改所有值:日、月和年,因为这是保持类不变性的唯一方法。
一种方法是在调试构建中定义一个私有方法,该方法测试类的不变量条件,并通过断言(见后面)确保不变量得到维护。您可以在可公开访问的方法离开之前调用这样的方法,以确保对象保持一致的状态。方法还应该定义前置条件和后置条件。前置条件是在调用方法之前您要求为真的条件,后置条件是在方法完成之后您保证为真的条件。对于类上的方法,类不变量是前置条件(因为在调用方法之前对象的状态应该是一致的),不变量也是后置条件(因为在方法完成之后对象的状态应该是一致的)。
还有一些先决条件是方法调用方的责任。先决条件是呼叫者确保的记录责任。例如,日期类将有一个前提条件,即日期在 1 到 31 之间。这简化了类代码,因为采用天数的方法可以假设传递的值永远不会超出范围(尽管,由于某些月份少于 31 天,值可能仍然无效)。同样,在调试版本中,您可以使用断言来检查这些先决条件是否为真,并且断言中的测试将在发布版本中被编译掉。在一个方法的末尾会有后置条件,也就是说,类不变量将被保持(并且对象的状态将是有效的),返回值将是有效的。
正如第 1 章、中所解释的,从 C++ 开始,当编译你的 C++ 程序时,有一个预编译步骤,将 C++ 源文件中包含的所有文件整理成一个文件,然后进行编译。预处理器还扩展宏,根据符号的值,包括一些代码并排除其他代码。
最简单的形式是,条件编译将代码用#ifdef
和#endif
括起来(也可以选择使用#else
,这样,只有定义了指定的符号,这些指令之间的代码才会被编译。
#ifdef TEST
cout << "TEST defined" << endl;
#else
cout << "TEST not defined" << endl;
#endif
保证只编译这些行中的一行,并且保证至少编译其中一行。如果符号TEST
被定义,那么第一行将被编译,就编译器而言,第二行不存在。如果符号TEST
未定义,则编译第二行。如果你想以相反的顺序输入这些行,你可以使用#ifndef
指令。通过条件编译提供的文本可以是 C++ 代码,也可以使用当前翻译单元中的其他符号用#define
定义,或者使用#undef
定义未定义的现有符号。
#ifdef
指令只是确定符号是否存在:它不测试它的值。#if
指令允许您测试表达式。您可以将符号设置为有值,并根据该值编译特定的代码。表达式必须是整数,因此单个#if
块可以使用#if
和多个#elif
指令以及(最多)一个#else
来测试多个值:
#if TEST < 0
cout << "negative" << endl;
#elif TEST > 0
cout << "positive" << endl;
#else
cout << "zero or undefined" << endl;
#endif
如果符号未定义,则#if
指令将符号视为具有值0
;如果您想区分这些情况,您可以使用defined
操作符来测试是否定义了符号。最多只能编译#if
/ #endif
块中的一个部分,如果某个值不匹配,则不会编译任何代码。表达式可以是宏,在这种情况下,宏将在测试条件之前展开。
定义符号有三种方法。第一种方式不受你的控制:编译器将定义一些符号(通常带有__
或_
前缀),这些符号为你提供关于编译器和编译过程的信息。这些符号中的一些将在后面的章节中描述。另外两种方法完全由您控制-您可以使用#define
在源文件(或头文件)中定义符号,或者使用/D
开关在命令行中定义它们:
cl /EHsc prog.cpp /DTEST=1
这将编译符号TEST
设置为值1
的源代码。
您通常会使用条件编译来提供不应在生产代码中使用的代码,例如,在调试模式或测试代码时使用的额外跟踪代码。例如,假设您有从数据库返回数据的库代码,但是您怀疑库函数中的 SQL 语句有错误,并且返回了太多值。在这里,您可以决定测试、添加代码来记录返回值的数量:
vector<int> data = get_data();
#if TRACE_LEVEL > 0
cout << "number of data items returned: " << data.size() << endl;
#endif
像这样的跟踪消息污染了您的用户界面,您将希望在生产代码中避免它们。然而,在调试中,它们在确定问题发生的位置方面是无价的。
你在调试模式下调用的任何代码,条件代码都应该是const
方法(这里是vector::size
),也就是说,它们不应该影响任何对象或应用数据的状态。您必须确保您的代码的逻辑在调试模式和发布模式下完全相同*。*
*# 使用实用程序
Pragmas 是特定于编译器的,通常关注目标文件中代码部分的技术细节。有几个 Visual C++ 实用程序在调试代码时很有用。
一般来说,您希望代码编译时尽可能少出现警告。Visual C++ 编译器的默认警告是/W1
,这意味着只列出最严重的警告。将该值增加到 2、3 或最高值 4 会逐渐增加编译期间给出的警告数量。使用/Wall
将给出四级警告和默认禁用的警告。即使对于最简单的代码,这最后一个选项也会产生充满警告的屏幕。当你有数百个警告时,有用的错误信息会隐藏在大量不重要的警告之间。因为 C++ 标准库很复杂,并且使用了一些几十年前的代码,所以编译器会警告您一些结构。为了防止这些警告污染生成的输出,选择性文件中的特定警告已被禁用。
如果您支持旧的库代码,您可能会发现代码编译时出现警告。您可能会尝试使用编译器/W
开关来降低警告级别,但这将抑制所有高于您启用的警告,并且它同样适用于您的代码,就像您可能包含在项目中的库代码一样。warning
实用程序给了你更多的灵活性。有两种方法可以调用它——您可以重置警告级别以覆盖编译器/W
开关,并且您可以更改特定警告的警告级别或完全禁用警告报告。
例如<iostream>
表头的顶部是一行:
#pragma warning(push,3)
这表示存储当前的警告级别,并且对于这个文件的其余部分(或者直到它被改变),将警告级别设置为 3。文件的底部是一行:
#pragma warning(pop)
这会将警告级别恢复到先前存储的级别。
您还可以更改一个或多个警告的报告方式。例如,在<istream>
的顶部是:
#pragma warning(disable: 4189)
该pragma
的第一部分是说明符disable
,表示禁止报告警告类型(在本例中为 4189)。如果选择,可以使用警告级别(1
、2
、3
或4
)作为说明符来更改警告的警告级别。这样做的一个用途是降低您正在处理的一段代码的警告级别,然后在代码之后将其返回到默认级别。例如:
#pragma warning(2: 4333)
unsigned shift8(unsigned char c)
{
return c >> 8;
}
#pragma warning(default: 4333)
该功能将一个字符右移 8 位,将产生 1 级警告 4333 ( 右移量过大,数据丢失)。这是一个问题,需要修复,但目前,您希望编译代码时没有来自该代码的警告,因此警告级别更改为 2 级。使用默认警告级别(/W1
)时,将不会显示警告。但是,如果您使用更敏感的警告级别(例如,/W2
)进行编译,则会报告此警告。警告级别的这种变化只是暂时的,因为最后一行将警告级别重置回默认值(即 1)。在这种情况下,警告级别会增加,这意味着您只会在编译器上看到更敏感的警告级别。您也可以降低警告级别,这意味着更有可能报告警告。您甚至可以将警告级别更改为error
,这样当代码中存在这种类型的警告时,代码就不会编译。
在测试和调试代码时,您不可避免地会遇到一些地方,在那里您可以看到潜在的问题,但是与您正在处理的问题相比,它的优先级较低。记下这个问题很重要,这样你就可以在以后解决这个问题。在 Visual C++ 中,有两种方法可以良性地做到这一点,还有两种方法会产生错误。
第一种方式是添加一个TODO:
注释,如下所示:
// TODO: potential data loss, review use of shift8 function
unsigned shift8(unsigned char c)
{
return c >> 8;
}
Visual Studio 编辑器有一个名为任务列表的工具窗口。这将列出项目中以预定任务之一开始的注释(默认值为TODO
、HACK
和UNDONE
)。
如果“任务列表”窗口不可见,请通过“视图”菜单启用它。Visual Studio 2015 中的默认设置是在 C++ 中启用任务。早期版本不是这样的,但是可以通过“工具”菜单、“选项”对话框,然后通过文本编辑器、C/C++、格式、视图,将“枚举注释任务”设置为“是”来启用它。任务标签列表可以在“选项”对话框的“环境”、“任务列表”项下找到。
任务列表列出了带有文件和行号的任务,您可以通过双击条目打开文件并找到注释。
第二种识别需要注意的代码的方法是message
pragma。顾名思义,这只是允许您在代码中放置信息性消息。当编译器遇到这个 pragma 时,它只是将消息放在输出流中。考虑以下代码:
#pragma message("review use of shift8 function")
unsigned shift8(unsigned char c)
{
return c >> 8;
}
如果用此代码和/W1
(默认)警告级别编译test.cpp
文件,输出将如下所示:
Microsoft (R) C/C++ Optimizing Compiler Version 19.00.24215.1 for x86
Copyright (C) Microsoft Corporation. All rights reserved.
test.cpp
review the use of shift8 function
test.cpp(8): warning C4333: '>>': right shift by too large amount, data loss
正如您所看到的,字符串被打印出来,就像编译器看到的那样,并且与警告消息相比,没有文件或行号的指示。有几种方法可以使用编译器符号来解决这个问题。
如果条件很重要,您将需要发出一个错误,一种方法是使用#error
指令。当编译器到达这个指令时,它会发出一个错误。这是一个严肃的动作,所以你只有在有其他选择的时候才会使用它。您很可能希望将其用于条件编译。典型的用途是只能用 C++ 编译器编译的代码:
#ifndef __cplusplus
#error C++ compiler required.
#endif
如果使用/Tc
开关将代码编译为 C,用该代码编译一个文件,那么__cplusplus
预处理器符号将不会被定义,并且将产生一个错误。
C++ 11 增加了一个新的指令static_assert
。这就像一个函数一样被调用(而调用以分号结束),但它不是一个函数,因为它只在编译时使用。此外,指令可以用在不使用函数调用的地方。该指令有两个参数:表达式和字符串。如果表达式是false
,那么字符串文字将在编译时与源文件和行号一起输出,并产生一个错误。在最简单的层次上,您可以使用它来发出一条消息:
#ifndef __cplusplus
static_assert(false, "Compile with /TP");
#endif
#include <iostream> // needs the C++ compiler
由于第一个参数是false
,指令会在编译时发出错误信息。同样的事情也可以通过#error
指令来实现。<type_traits>
库有各种谓词来测试类型的属性。例如,is_class
模板类有一个简单的模板参数,它是一个类型,如果类型是一个class
,那么static
成员value
被设置为true
。如果您有一个只应该为类实例化的模板化函数,您可以添加这个static_assert
:
#include <type_traits>
template <class T>
void func(T& value)
{
static_assert(std::is_class<T>::value, "T must be a class");
// other code
}
在编译时,编译器将尝试实例化该函数,并使用value
实例化该类型上的is_class
,以确定编译是否应该继续。例如,以下代码:
func(string("hello"));
func("hello");
第一行将正确编译,因为编译器将实例化一个函数func<string>,
,参数是一个class
。但是第二行不会编译,因为实例化的函数是func<const char*>
,const char*
不是class
。输出结果是:
Microsoft (R) C/C++ Optimizing Compiler Version 19.00.24215.1 for x86
Copyright (C) Microsoft Corporation. All rights reserved.
test.cpp
test.cpp(25): error C2338: T must be a class
test.cpp(39): note: see reference to function template instantiation
'void func<const char*>(T)' being compiled
with
[
T=const char *
]
static_assert
在线路 25 上,因此产生T must be a class
的误差。第 39 行是对func<const char*>
的第一次调用,给出了错误的上下文。
为了允许您使用调试器单步执行程序,您必须提供信息以允许调试器将机器代码与源代码相关联。至少,这意味着关闭所有优化,因为在试图优化代码时,C++ 编译器会重新排列代码。优化在默认情况下是关闭的(因此使用/Od
开关是多余的),但是很明显,为了能够调试一个进程并单步执行 C++ 代码,您需要移除所有的/O
优化开关。
由于 C++ 标准库使用 C 运行时,您将需要编译代码来使用后者的调试版本。您使用的开关取决于您是构建进程还是动态链接库 ( DLL ),以及您是静态链接 C 运行时还是通过 DLL 访问它。如果你正在编译一个进程,你使用/MDd
在一个 DLL 中获得 C 运行时的调试版本,如果你使用/MTd
你将获得静态链接 C 运行时的调试版本。如果你正在编写一个动态链接库,除了使用一个 C 运行时开关之外,你还必须使用/LDd
(默认为/MTd
)。这些开关将定义一个名为_DEBUG
的预处理器符号。
调试器需要知道调试器符号信息——变量的名称和类型,函数的名称以及与代码相关的行号。公认的方法是通过一个名为程序数据库的文件,扩展名为pdb
。使用其中一个/Z
开关生成一个pdb
文件:/Zi
或/ZI
开关将创建两个文件,一个文件的名称以VC
开头(例如VC140.pdb
),包含所有obj
文件的调试信息,另一个文件的名称包含过程调试。如果编译时没有链接(/c
),那么只创建第一个文件。默认情况下,Visual C++ 项目向导将使用/Od /MDd /ZI
进行调试版本。/ZI
开关是指以允许 Visual C++ 调试器执行Edit
和Continue
的格式创建程序数据库,也就是说,您可以更改一些代码并继续单步执行代码,而无需重新编译。当您编译发布版本时,向导将使用/O2 /MD /Zi
开关,这意味着代码针对速度进行了优化,但仍将创建程序数据库(没有Edit
和Continue
支持)。代码不需要程序数据库来运行(事实上,您不应该将它与您的代码一起分发),但是如果您有一个崩溃报告并且需要在调试器下运行发布构建代码,那么它是非常有用的。
这些/Z
编译器开关假定链接器与/debug
开关一起运行(如果编译器调用链接器,它将传递这个开关)。链接器将根据VC
程序数据库文件中的调试信息创建项目程序数据库。
这就提出了为什么发布构建文件需要程序数据库的问题。如果您在调试器下运行一个程序并查看调用堆栈,您通常会在操作系统文件中看到一长串堆栈帧。这些通常有相当无意义的名称,由 DLL 名称和一些数字和字符组成。可以为 Windows 安装这些符号(即pdb
文件),如果没有安装,可以指示 Visual C++ 调试器从网络上一台名为符号服务器的计算机上下载正在使用的库的符号。这些符号不是库的源代码,但它们确实给了您函数的名称和参数的类型,这为您提供了关于单步执行时调用堆栈状态的附加信息。
要访问代码中的跟踪、断言和报告工具,您必须启用调试运行时库,这是通过使用/MDd
、/MTd
或/LDd
编译器开关来完成的,这些开关将定义_DEBUG
预处理器符号。_DEBUG
预处理器符号支持许多功能,相反,不定义该符号将有助于优化您的代码。
#ifdef _DEBUG
cout << "debug build" << endl;
#else
cout << "release built" << endl;
#endif
C++ 编译器还将通过一些标准的预处理器符号来提供信息。其中大部分只对图书馆作者有用,但也有一些你可能想用。
ANSI 标准说__cplusplus
符号应该在编译器以 C++(而不是 C)的形式编译代码时定义,它还规定__FILE__
符号应该包含文件名,__LINE__
符号在你访问它的地方会有行号。__func__
符号将具有当前功能名称。这意味着您可以创建如下跟踪代码:
#ifdef _DEBUG
#define TRACE cout << __func__ << " (" << __LINE__ << ")" << endl;
#else
#define TRACE
#endif
如果该代码是为了调试而编译的(例如/MTd
),那么每当使用TRACE
时,cout
行将被内联;如果代码没有被编译用于调试,那么TRACE
将什么也不做。__func__
符号仅仅是函数名,它是不合格的,所以如果你在一个类方法中使用它,它将不会提供关于这个类的信息。
Visual C++ 还定义了特定于微软的符号。__FUNCSIG__
符号给出完整的签名,包括类名(和任何namespace
名称)、返回类型和参数。如果只是想要全限定名,那么可以使用__FUNCTION__
符号。在 Windows 头文件中你会经常看到的一个符号是_MSC_VER
。它有一个数字,是当前 C++ 编译器的版本,它与条件编译一起使用,因此较新的语言功能只能由支持它们的编译器编译。
Visual C++ 项目页面定义了名为$(ProjectDir)
和$(Configuration)
的构建宏。这些仅由 MSBuild 工具使用,因此它们在编译期间不会自动出现在源文件中,但是,如果您将预处理器符号设置为生成宏的值,则该值将在编译时通过该符号可用。系统环境变量也可以作为构建宏使用,因此可以使用它们来影响构建。例如,在 Windows 上,系统环境变量USERNAME
有当前登录用户的名称,因此您可以使用它来设置一个符号,然后在编译时访问它。
在 Visual C++ 项目页面中,可以在 C/C++ 预处理器项目页面上添加一个名为的预处理器定义:
DEVELOPER="$(USERNAME)"
然后,在代码中,您可以使用这个符号添加一行:
cout << "Compiled by " << DEVELOPER << endl;
如果您正在使用 make 文件,或者只是从命令行调用cl
,您可以添加一个开关来定义符号,如下所示:
/DDEVELOPER="$(USERNAME)"
这里转义双引号很重要,因为没有双引号,编译器会吃掉引号。
前面,您已经看到了如何使用#pragma message
和#error
指令将消息放入编译器的输出流中。在 Visual Studio 中编译代码时,编译器和链接器输出将出现在输出窗口中。如果消息的格式为:
path_to_source_file(line) message
其中path_to_source_file
是文件的完整路径,line
是出现message
的行号。然后,当您双击输出窗口中的这一行时,文件将被加载(如果还没有)并将插入点放在线上。
__FILE__
和__LINE__
符号为您提供了使#pragma message
和#error
指令更加有用所需的信息。输出__FILE__
很简单,因为它是一个字符串,C++ 将连接字符串文字:
#define AT_FILE(msg) __FILE__ " " msg
#pragma message(AT_FILE("this is a message"))
宏作为 pragma 的一部分被调用,以正确格式化消息;但是,您不能从宏中调用 pragma,因为#
有一个特殊的用途(一会儿就会有用)。这段代码的结果如下:
c:\Beginning_C++ Chapter_10test.cpp this is a message
通过宏输出__LINE__
需要更多的工作,因为它包含一个数字。这个问题在 C 语言中很常见,所以有一个使用两个宏和串线操作符#
的标准解决方案。
#define STRING2(x) #x
#define STRING(x) STRING2(x)
#define AT_FILE(msg) __FILE__ "(" STRING(__LINE__) ") " msg
STRING
宏用于将__LINE__
符号展开为一个数字,而STRING2
宏用于将该数字拉长。AT_FILE
宏以正确的格式格式化整个字符串。
诊断消息的有效使用是一个广泛的话题,因此本节将只向您介绍基本知识。当您设计代码时,您应该使编写诊断消息变得容易,例如,提供转储对象内容的机制,并提供对测试类不变量以及前置和后置条件的代码的访问。您还应该分析代码,以确保记录了适当的消息。例如,在循环中发出诊断消息通常会填满您的日志文件,从而难以读取日志文件中的其他消息。然而,某件事在一个循环中不断失败的事实本身可能是一个重要的诊断,就像执行失败行为的尝试次数一样,所以你可能需要记录下来。
对诊断消息使用cout
的好处是可以将这些消息与您的用户输出集成在一起,这样您就可以看到中间结果的最终效果。缺点是诊断消息与用户输出集成在一起,由于通常有大量的诊断消息,这些消息将完全淹没程序的用户输出。
C++ 有两个流对象,可以用来代替cout
。clog
和cerr
流对象将字符数据写入标准错误流(C 流指针stderr
),这通常会显示在控制台上,就像您正在使用cout
(输出到标准输出流,C 流指针stdout
)一样,但是您可以将其重定向到其他地方。clog
和cerr
的区别在于clog
使用缓冲输出,这可能比无缓冲的cerr
性能更好。但是,如果应用在没有刷新缓冲区的情况下意外停止,数据可能会丢失。
由于clog
和cerr
流对象在发布版本和调试版本中都可用,因此您应该只将它们用于您希望最终用户看到的消息。这使得它们不适用于跟踪消息(稍后将介绍)。相反,您应该将它们用于用户能够处理的诊断消息(可能是找不到文件或者进程没有执行操作的安全访问权限)。
ofstream file;
if (!file.open(argv[1], ios::out))
{
clog << "cannot open " << argv[1] << endl;
return 1;
}
这段代码分两步打开文件(而不是使用构造函数),如果文件无法打开,open
方法将返回false
。代码检查打开文件是否成功,如果失败,它将通过clog
对象告诉用户,然后从包含代码的任何函数返回,因为file
对象现在无效,不能使用。clog
对象被缓冲,但在这种情况下,我们想立即通知用户,这是由endl
操纵器执行的,它在流中插入一个换行符,然后刷新流。
默认情况下,clog
和cerr
流对象将输出到标准错误流,这意味着对于控制台应用,您可以通过重定向流来分离输出流和错误流。在命令行上,标准流可以通过使用值 0(代表stdin
)、1(代表stdout,
)和 2(代表stderr
)以及重定向操作符>
进行重定向。例如,应用app.exe
可以在main
功能中包含以下代码:
clog << "clog" << endl;
cerr << "cerrn";
cout << "cout" << endl;
cerr
对象没有被缓冲,所以你是否使用n
或endl
作为换行符是无关紧要的。当您在命令行上运行该命令时,您将看到如下内容:
C:\Beginning_C++ \Chapter_10>app
clog
cerr
cout
要将流重定向到文件,请将流句柄(1 代表stdout
,2 代表stderr
)重定向到文件;控制台将打开文件并将流写入文件:
C:\Beginning_C++ \Chapter_10>app 2>log.txt
cout
C:\Beginning_C++ \Chapter_10>type log.txt
clog
cerr
正如上一章所展示的,C++ 流对象是分层的,这样,根据流的类型,无论有无缓冲,向流中插入数据的调用都会将数据写入底层流对象。使用rdbuf
方法获取并替换该流缓冲区对象。如果希望应用将clog
对象重定向到文件,可以编写如下代码:
extern void run_code();
int main()
{
ofstream log_file;
if (log_file.open("log.txt")) clog.rdbuf(log_file.rdbuf());
run_code();
clog.flush();
log_file.close();
clog.rdbuf(nullptr);
return 0;
}
在这段代码中,应用代码将在run_code
函数中,其余代码设置clog
对象重定向到文件。
注意当run_code
函数返回时(应用已经完成),文件被显式关闭;这并不完全是因为ofstream
析构函数会关闭文件,在这种情况下,当main
函数返回时就会发生这种情况。最后一行很重要。标准流对象是在调用main
函数之前创建的,并且它们将在main
函数返回之后的某个时间被销毁,也就是说,在文件对象被销毁之后。为防止clog
对象访问被破坏的文件对象,调用rdbuf
方法传递nullptr
表示没有缓冲区。
通常,您会希望通过实时运行应用并输出跟踪消息来测试您的算法是否工作,从而测试您的代码。有时您会想要测试调用函数的顺序(例如,正确的分支发生在switch
语句或if
语句中),在其他情况下,您会想要测试中间值,以查看输入数据是否正确以及对该数据的计算是否正确。
跟踪消息会产生大量数据,因此将这些数据发送到控制台是不明智的。跟踪消息只在调试版本中生成是非常重要的。如果您在产品代码中留下跟踪消息,它可能会严重影响应用的性能(这将在后面解释)。此外,跟踪消息不太可能本地化,也不会检查它们是否包含可用于逆向工程算法的信息。发布版本中跟踪消息的最后一个问题是,您的客户会认为您向他们提供的代码没有经过完全测试。因此,当_DEBUG
符号被定义时,跟踪消息只在调试版本中生成是很重要的。
C Runtime 提供了一系列名称以_RPT
开头的宏,可以在定义_DEBUG
时用来跟踪消息。这些宏有char
和宽字符版本,也有只报告跟踪消息的版本,还有报告消息和消息位置(源文件和行号)的版本。最终,这些宏将调用一个名为_CrtDbgReport
的函数,该函数将使用其他地方确定的设置生成消息。
_RPTn
宏(其中n
是0
、1
、2
、3
、4
或5
)将采用一个格式字符串和 0 到 5 个参数,这些参数将在报告前放入字符串中。宏的第一个参数指示要报告的消息类型:_CRT_WARN
、_CRT_ERROR
或_CRT_ASSERT
。最后两个类别是相同的,指的是断言,这将在后面的章节中介绍。报表宏的第二个参数是一个格式字符串,后面是所需数量的参数。_RPTFn
宏的格式相同,但会报告源文件和行号以及格式化的消息。
默认操作是_CRT_WARN
消息不产生输出,_CRT_ERROR
和_CRT_ASSERT
消息将生成一个弹出窗口,允许您中止或调试应用。您可以通过调用_CrtSetReportMode
函数并提供类别和指示要采取的操作的值来更改对这些消息类别的响应。如果您使用_CRTDBG_MODE_DEBUG
,那么消息将被写入调试器输出窗口。如果您使用_CRTDBG_MODE_FILE
,那么消息将被写入一个文件,您可以打开该文件并将句柄传递给_CrtSetReportFile
功能。(也可以使用_CRTDBG_FILE_STDERR
或_CRTDBG_FILE_STDOUT
作为文件句柄,将消息发送到标准输出或错误输出。)如果您使用_CRTDBG_MODE_WNDW
作为报告模式,那么将使用中止/重试/忽略对话框显示消息。因为这将暂停当前的执行线程,所以它应该只用于断言消息(默认操作):
include <crtdbg.h>
extern void run_code();
int main()
{
_CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_DEBUG);
_RPTF0(_CRT_WARN, "Application startedn");
run_code();
_RPTF0(_CRT_WARN, "Application endedn");
return 0;
}
如果您没有在消息中提供n
,那么下一条消息将被附加到您的消息的末尾,并且在大多数情况下这不是您想要的(尽管您可以通过对_RPTn
宏的一系列调用来证明这一点,其中最后一条以n
结束)。
编译项目时会显示 Visual Studio 输出窗口(要在调试时显示该窗口,请选择“视图”菜单中的“输出”选项),顶部是一个标记为“显示输出来源”的组合框,通常设置为“生成”。如果将此设置为调试,您将看到调试会话期间生成的调试消息。这些将包括关于加载调试符号的消息和从_RPTn
宏重定向到输出窗口的消息。
如果您希望消息指向一个文件,那么您需要使用 Win32 CreateFile
函数打开该文件,并在调用_CrtSetReportFile
函数时使用该函数的句柄。为此,您需要包含 Windows 头文件:
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <crtdbg.h>
WIN32_LEAN_AND_MEAN
宏将减小包含的窗口文件的大小。
HANDLE file =
CreateFileA("log.txt", GENERIC_WRITE, 0, 0, CREATE_ALWAYS, 0, 0);
_CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_FILE);
_CrtSetReportFile(_CRT_WARN, file);
_RPTF0(_CRT_WARN, "Application startedn");
run_code();
_RPTF0(_CRT_WARN, "Application endedn");
CloseHandle(file);
该代码将警告消息导向文本文件log.txt
,该文件将在每次应用运行时被新建。
OutputDebugString
功能用于向调试器发送消息。该功能通过一个名为DBWIN_BUFFER
的共享内存部分来实现。共享内存意味着任何进程都可以访问该内存,因此 Windows 提供了两个事件对象,称为DBWIN_BUFFER_READY
和DBWIN_DATA_READY
,控制对该内存的读写访问。这些事件对象在进程之间共享,可以处于有信号或无信号状态。调试器将通过发送DBWIN_BUFFER_READY
事件来指示它不再使用共享内存,此时OutputDebugString
函数可以将数据写入共享内存。调试器将等待DBWIN_DATA_READY
事件,当它完成对存储器的写入并且可以安全地读取缓冲区时,OutputDebugString
函数将发出信号。写入内存部分的数据将是调用OutputDebugString
函数的进程的进程标识,后跟一个高达 4 KB 的数据字符串。
问题是,当您调用OutputDebugString
函数时,它将等待DBWIN_BUFFER_READY
事件,这意味着当您使用该函数时,您正在将应用的性能耦合到另一个进程的性能,该进程通常是调试器(但可能不是)。编写一个进程来访问DBWIN_BUFFER
共享内存部分并访问相关联的事件对象是非常容易的,因此您的生产代码可能会运行在有人运行此类应用的机器上。因此,使用条件编译非常重要,以便OutputDebugString
函数仅用于调试构建,这些代码永远不会发布给客户:
extern void run_code();
int main()
{
#ifdef _DEBUG
OutputDebugStringA("Application startedn");
#endif
run_code();
#ifdef _DEBUG
OutputDebugStringA("Application endedn");
#endif
return 0;
}
您需要包含windows.h
头文件来编译该代码。至于_RPT
的例子,你必须在调试器下运行这段代码才能看到输出,或者运行像 DebugView 这样的应用(可从微软的 Technet 网站获得)。
Windows 提供了DBWinMutex
互斥对象作为访问共享内存和事件对象的整体键。顾名思义,当您拥有互斥体的句柄时,您将拥有对资源的互斥访问权。问题是,进程不一定要有这个互斥体的句柄才能使用这些资源,因此你不能保证,如果你的应用认为它有独占访问权,它真的会有独占访问权。
断言检查条件是否为真。这个断言仅仅意味着:如果条件不成立,程序就不应该继续。明确声明不应该在发布代码中调用,因此必须使用条件编译。断言应该用于检查永远不会发生的情况:永远不会发生的事件。因为这些条件不会发生,所以在发布版本中不需要断言。
C 运行时提供通过<cassert>
头文件可用的assert
宏。除非定义了NDEBUG
符号,否则将调用宏和作为其唯一参数传递的表达式中调用的任何函数。也就是说,您不必定义_DEBUG
符号来使用断言,并且您应该已经采取了额外的措施来显式地防止assert
被调用。
值得重复一遍。即使没有定义_DEBUG
,也定义了assert
宏,因此可以在发布代码中调用断言。为了防止这种情况发生,您必须在发布版本中定义NDEBUG
符号。相反,您可以在调试版本中定义NDEBUG
符号,以便可以使用跟踪,但不必使用断言。
通常,您将在调试版本中使用断言来检查函数中的前置和后置条件是否满足,以及类不变条件是否满足。例如,您可能有一个二进制缓冲区,它在第十个字节位置有一个特殊值,因此编写了一个函数来提取该字节:
const int MAGIC=9;
char get_data(char *p, size_t size)
{
assert((p != nullptr));
assert((size >= MAGIC));
return p[MAGIC];
}
这里对assert
的调用是用来检查指针是否不是nullptr
以及缓冲区是否足够大。如果这些断言为真,则意味着通过指针访问第十个字节是安全的。
虽然在这段代码中并没有严格的必要,但是断言表达式在括号中给出。养成这样做的习惯是好的,因为assert
是一个宏,所以表达式中的逗号会被当作宏参数分隔符;括号对此进行了保护。
由于默认情况下assert
宏将在发布版本中定义,因此您必须通过在编译器命令行、make 文件中定义NDEBUG
来禁用它们,或者您可能希望显式使用条件编译:
#ifndef _DEBUG
#define NDEBUG
#endif
如果一个断言被调用并且它失败了,那么一个断言消息连同源文件和行号信息一起被打印在控制台上,然后这个过程以调用abort
结束。如果流程是用发布构建标准库构建的,那么流程abort
是简单的,但是,如果使用调试构建,那么用户将看到标准的中止/重试/忽略消息框,其中中止和忽略选项中止流程。重试选项将使用及时 ( 准时)调试将注册的调试器附加到进程。
相比之下,_ASSERT
和_ASSERTE
宏仅在_DEBUG
被定义时才被定义,因此这些宏在发布版本中不可用。当表达式为false
时,两个宏都接受一个表达式并生成一个断言消息。_ASSERT
宏的消息将包括源文件和行号,以及声明断言失败的消息。_ASSERTE
宏的消息类似,但包含失败的表达式。
_CrtSetReportMode(_CRT_ASSERT, _CRTDBG_MODE_FILE);
_CrtSetReportFile(_CRT_ASSERT, _CRTDBG_FILE_STDOUT);
int i = 99;
_ASSERTE((i > 100));
此代码设置报告模式,以便失败的断言将是控制台上打印的消息(而不是默认消息,即中止/重试/忽略对话框)。由于变量明显小于 100,断言将失败,因此进程将终止,控制台上将打印以下消息:
test.cpp(23) : Assertion failed: (i > 100)
“中止/重试/忽略”对话框为测试应用的人员提供了将调试器附加到进程的选项。如果您认为断言的失败令人发指,您可以通过调用_CrtDbgBreak
来强制调试器附加到进程。
int i = 99;
if (i <= 100) _CrtDbgBreak();
您不需要使用条件编译,因为在发布版本中_CrtDbgBreak
函数是一个非操作。在调试版本中,此代码将触发 JIT 调试,这为您提供了关闭应用或启动调试器的选项,如果您选择后者,将启动注册的 JIT 调试器。
main
功能是您应用的入口点。但是,这不是操作系统直接调用的,因为 C++ 会在调用main
之前执行初始化。这包括构建标准库全局对象(cin
、cout
、cerr
、clog,
和宽字符版本),并且为支持 C++ 库的 C 运行时库执行大量初始化。此外,还有您的代码创建的全局和静态对象。当main
函数返回时,必须调用全局和静态对象的析构函数,并在 C 运行时执行清理。
有几种方法可以故意停止一个过程。最简单的是从main
函数返回,但是这假设从您的代码想要完成这个过程的点有一个返回main
函数的简单路径。当然,进程终止必须是有序的,您应该避免编写代码,在代码中的任何地方停止进程都是正常的。但是,如果您遇到数据损坏且不可恢复的情况,并且任何其他操作都可能损坏更多数据,则除了终止应用之外,您可能别无选择。
<cstdlib>
头文件提供了对头文件的访问,允许您终止和处理应用终止的功能。当一个 C++ 程序正常关闭时,C++ 基础设施将调用在main
函数中创建的对象的析构函数(与它们的构造顺序相反)和static
对象的析构函数(可能是在main
函数以外的函数中创建的)。atexit
函数允许您注册在main
函数完成和static
对象析构函数被调用后将被调用的函数(没有参数和返回值)。您可以通过多次调用这个函数来注册多个函数,在终止时,这些函数将按照与注册相反的顺序被调用。在用atexit
函数注册的函数被调用后,任何全局对象的析构函数都将被调用。
还有一个叫做_onexit
的微软函数,也可以让你注册正常终止时要调用的函数。
exit
和_exit
函数执行进程的正常退出,也就是说,它们在关闭进程之前清理 C 运行时并刷新任何打开的文件。exit
函数通过调用任何注册的终止函数来做额外的工作;_exit
函数不调用这些终止函数,因此是快速退出。这些函数不会调用临时或自动对象的析构函数,所以如果使用堆栈对象来管理资源,在调用exit
之前,必须先显式调用析构函数代码。然而,静态和全局对象的析构函数将被调用。
quick_exit
函数导致正常关机,但不调用任何析构函数,也不刷新任何流,所以没有资源清理。用atexit
注册的函数不被调用,但是你可以通过用at_quick_exit
函数注册它们来注册终止函数被调用。调用这些终止函数后,quick_exit
函数调用关闭进程的_Exit
函数。
您也可以调用terminate
函数来关闭没有清理的进程。这个过程将调用一个已经在set_terminate
函数中注册的函数,然后调用abort
函数。如果程序中出现异常,并且没有被捕获,从而传播到main
函数,C++ 基础设施将调用terminate
函数。abort
功能是终止进程的最严重的机制。该函数将退出进程,而不调用对象的析构函数或执行任何其他清理。该函数发出SIGABORT
信号,因此可以向signal
函数注册一个函数,该函数将在进程终止前被调用。
有些函数被设计为执行一个动作并基于该动作返回值,例如,sqrt
将返回一个数字的平方根。其他函数执行更复杂的操作,并使用返回值来指示函数是否成功。对于这样的错误值没有通用的约定,所以如果一个函数返回一个简单的整数,就不能保证一个库使用的值与另一个库中的函数返回的值具有相同的含义。这意味着您必须仔细检查您使用的任何库代码的文档。
Windows 确实提供了常见的错误值,可以在winerror.h
头文件中找到,Windows 软件开发工具包 ( SDK )中的函数只在这个文件中返回值。如果您编写将在 Windows 应用中独占使用的库代码,请考虑使用此文件中的错误值,因为您可以使用 Win32 FormatMessage
函数来获取错误的描述,如下一节所述。
C 运行时库提供了一个名为errno
的全局变量(实际上它是一个你可以当作变量对待的宏)。c 函数将返回一个值来指示它们已经失败,您访问errno
值来确定错误是什么。<errno.h>
头文件定义了标准的 POSIX 错误值。errno
变量并不表示成功,它只表示错误,所以您应该只在某个函数表示有错误时才访问它。strerror
函数将返回一个 C 字符串,该字符串描述了作为参数传递的错误值;这些消息根据通过调用setlocale
函数设置的当前 C 语言环境进行本地化。
要在运行时获取 Win32 错误代码的描述,可以使用 Win32 FormatMessage
函数。这将获得系统消息或自定义消息的描述(在下一节中描述)。如果您想使用自定义消息,您必须加载绑定了消息资源的可执行文件(或动态链接库),并将HMODULE
句柄传递给FormatMessage
函数。如果你想得到系统消息的描述,你不需要加载一个模块,因为 Windows 会为你做这件事。例如,如果您调用 Win32 CreateFile
函数打开一个文件,但找不到该文件,该函数将返回一个值INVALID_HANDLE_VALUE,
,表示有错误。要获取错误的详细信息,您需要调用GetLastError
函数(该函数返回一个 32 位无符号值,有时称为DWORD
或HRESULT
)。然后,您可以将错误值传递给FormatMessage
:
HANDLE file = CreateFileA(
"does_not_exist", GENERIC_READ, 0, 0, OPEN_EXISTING, 0, 0);
if (INVALID_HANDLE_VALUE == file)
{
DWORD err = GetLastError();
char *str;
DWORD ret = FormatMessageA(
FORMAT_MESSAGE_FROM_SYSTEM|FORMAT_MESSAGE_ALLOCATE_BUFFER,
0, err, LANG_USER_DEFAULT, reinterpret_cast<LPSTR>(&str),
0, 0);
cout << "Error: "<< str << endl;
LocalFree(str);
}
else
{
CloseHandle(file);
}
该代码试图打开一个不存在的文件,并获得与失败相关的错误值(这将是ERROR_FILE_NOT_FOUND
的值)。然后代码调用FormatMessage
函数来获取描述错误的字符串。函数的第一个参数是一个标志,指示函数应该如何工作;在这种情况下,FORMAT_MESSAGE_FROM_SYSTEM
标志表示错误是系统错误,FORMAT_MESSAGE_ALLOCATE_BUFFER
标志表示函数应该使用 Win32 LocalAlloc
函数分配一个足够大的缓冲区来保存字符串。
If the error is a custom value that you have defined then you should use the FORMAT_MESSAGE_FROM_HMODULE
flag, open the file with LoadLibrary
and use the resulting HMODULE
as the parameter passed in through the second parameter.
第三个参数是错误信息编号(从GetLastError
开始),第四个参数是LANGID
,表示要使用的语言标识(在本例中LANG_USER_DEFAULT
获取当前登录用户的语言标识)。FormatMessage
函数将生成一个格式化的错误值,该字符串可能有替换参数。格式化的字符串在缓冲区中返回,您有两个选项:您可以分配一个字符缓冲区,并将指针作为第五个参数传入,将长度作为第六个参数传入,或者您可以请求函数使用LocalAlloc
函数分配一个缓冲区,如本例所示。要访问函数分配的缓冲区,需要通过第五个参数传递指针变量的地址。
请注意,第五个参数用于获取指向用户分配的缓冲区的指针,或者返回系统分配的缓冲区的地址,这就是为什么在这种情况下,指向指针的指针必须被强制转换。
有些格式字符串可能有参数,如果有,则通过第七个参数中的数组传递值(在这种情况下,不传递数组)。前面代码的结果是字符串:
Error: The system cannot find the file specified.
使用消息编译器、资源文件和FormatMessage
,您可以提供一种机制,从您的函数返回错误值,然后根据当前区域设置将这些值转换为本地化字符串。
前面的示例表明,您可以获取 Win32 错误的本地化字符串,但是您也可以创建自己的错误,并提供作为资源绑定到您的进程或库的本地化字符串。如果您打算向最终用户报告错误,您必须确保描述是本地化的。Windows 提供了一个名为消息编译器(mc.exe
)的工具,该工具将获取一个包含各种语言消息条目的文本文件,并将它们编译成可以绑定到模块的二进制资源。
例如:
LanguageNames = (British = 0x0409:MSG00409)
LanguageNames = (French = 0x040c:MSG0040C)
MessageId = 1
SymbolicName = IDS_GREETING
Language = English
Hello
.
Language = British
Good day
.
Language = French
Salut
.
这为同一消息定义了三个本地化字符串。这里的消息是简单的字符串,但是您可以使用运行时提供的占位符来定义消息格式。中性语言是美国英语,此外我们还为英国英语和法语定义了字符串。用于语言的名称在文件顶部的LanguageNames
行中定义。这些条目具有稍后将在文件中使用的名称、语言的代码页以及包含消息资源的二进制资源的名称。
MessageId
是FormatMessage
函数将使用的标识符,SymbolicName
是将在头文件中定义的预处理器符号,因此您可以在 C++ 代码中使用该消息,而不是数字。这个文件是通过传递给命令行工具mc.exe
来编译的,它将创建五个文件:一个带有符号定义的头文件,三个二进制源(MSG00001.bin
,默认为中性语言创建,以及MSG00409.bin
和MSG0040C.bin,
,因为LanguageNames
行而创建),以及一个资源编译器文件。
对于本例,资源编译器文件(扩展名为.rc
)将包含:
LANGUAGE 0xc,0x1
1 11 "MSG0040C.bin"
LANGUAGE 0x9,0x1
1 11 "MSG00001.bin"
LANGUAGE 0x9,0x1
1 11 "MSG00409.bin"
这是一个可以由 Windows SDK 资源编译器(rc.exe
)编译的标准资源文件,它会将消息资源编译成.res
文件,该文件可以绑定到可执行文件或 DLL。绑定了类型为11
的资源的进程或动态链接库可以被FormatMessage
函数用作描述性错误字符串的来源。
通常,您不会使用消息 ID 1,因为它不太可能是唯一的,并且您可能想要利用设施代码和严重性代码(有关设施代码的详细信息,请查看winerror.h
头文件)。此外,要指示消息不是 Windows,您可以在运行mc.exe
时使用/c
开关设置错误代码的客户位。这将意味着您的错误代码不会是像 1 这样的简单值,但这并不重要,因为您的代码将使用头文件中定义的符号。
顾名思义,例外是针对特殊情况的。它们不是正常情况。它们不是你想发生的情况,而是可能发生的情况。任何异常情况通常意味着您的数据将处于不一致的状态,因此使用异常意味着您需要从事务的角度来思考,也就是说,操作要么成功,要么对象的状态应该保持与尝试操作之前相同。当代码块中出现异常时,代码块中发生的一切都将无效。如果代码块是一个更宽的代码块的一部分(比如,一个函数是另一个函数的一系列函数调用),那么在那个代码块中的工作将是无效的。这意味着异常可能会传播到调用堆栈更高层的其他代码块,从而使依赖于成功操作的对象失效。在某个时候,异常情况将是可恢复的,因此您将希望防止异常进一步发展。
C++ 11 中不推荐使用异常规范,但是您可以在早期代码中看到它们。规范是通过应用于函数声明的throw
表达式给出可以从函数中抛出的异常。throw
规范可以是省略号,这意味着函数可以抛出异常,但类型没有指定。如果规范为空,则意味着函数不会抛出异常,这与在 C++ 11 中使用noexcept
说明符是一样的。
noexcept
说明符告诉编译器不需要异常处理,因此如果函数中出现异常,异常不会从函数中冒泡出来,并且terminate
函数将被立即调用。在这种情况下,不能保证自动对象的析构函数被调用。
在 C++ 中,异常情况是通过抛出异常对象而产生的。该异常对象可以是您喜欢的任何对象:对象、指针或内置类型,但是因为异常可能由其他人编写的代码处理,所以最好将用于表示异常的对象标准化。为此,标准库提供了exception
类,可以作为基类使用。
double reciprocal(double d)
{
if (d == 0)
{
// throw 0;
// throw "divide by zero";
// throw new exception("divide by zero");
throw exception("divide by zero");
}
return 1.0 / d;
}
这段代码测试参数,如果它为零,则抛出一个异常。给出了四个例子,它们都是有效的 C++,但只有最后一个版本是可以接受的,因为它使用了一个标准库类(或从标准库类派生的一个),并且遵循了按值抛出异常的惯例。
当抛出异常时,异常处理基础结构接管。执行将在当前代码块中停止,异常将向上传播到调用堆栈。当异常在代码块中传播时,所有自动对象都将被销毁,但是在代码黑堆中创建的对象不会被销毁。这是一个称为**堆栈展开的过程,**在异常移动到调用堆栈中它上面的堆栈帧之前,尽可能地清理每个堆栈帧。如果异常没有被捕获,它将传播到main
函数,此时将调用terminate
函数来处理异常(因此它将终止进程)。
您可以保护代码以处理传播的异常。代码由try
块保护,并由相关的catch
块捕获:
try
{
string s("this is an object");
vector<int> v = { 1, 0, -1};
reciprocal(v[0]);
reciprocal(v[1]);
reciprocal(v[2]);
}
catch(exception& e)
{
cout << e.what() << endl;
}
与 C++ 中的其他代码块不同,即使try
和catch
块包含单行代码,大括号也是强制性的。在前面的代码中,对reciprocal
函数的第二次调用将引发异常。该异常将停止该块中任何更多代码的执行,因此不会发生对reciprocal
函数的第三次调用。相反,异常会传播出代码块。try
块是在大括号之间定义的对象的范围,这意味着这些对象的析构函数将被调用(s
和v
)。然后,控制被传递给相关的catch
块,在这种情况下,只有一个处理程序。catch
块是try
块的独立块,因此您不能访问在try
块中定义的任何变量。这很有意义,因为当生成异常时,整个代码块都被污染了,所以您不能信任在该块中创建的任何对象。这段代码使用公认的约定,即通过引用捕获异常,因此捕获的是实际的异常对象,而不是副本。
惯例是:抛出我的值,被引用捕获。
标准库提供了一个名为uncaught_exception
的函数,如果已经抛出异常但尚未处理,该函数将返回true
。能够对此进行测试似乎有些奇怪,因为当异常发生时,除了异常基础结构之外,不会调用任何代码(例如catch
处理程序),您应该将异常代码放在那里。然而还有当抛出异常时调用的其他代码:在堆栈清理期间被销毁的自动对象的析构函数。uncaught_exception
函数应该在析构函数中使用,以确定对象是否由于异常而被销毁,而不是由于对象超出范围或被删除而被正常销毁。例如:
class test
{
string str;
public:
test() : str("") {}
test(const string& s) : str(s) {}
~test()
{
cout << boolalpha << str << " uncaught exception = "
<< uncaught_exception() << endl;
}
};
这个简单的对象指示它是否因为异常堆栈展开而被销毁。可以这样测试:
void f(bool b)
{
test t("auto f");
cout << (b ? "f throwing exception" : "f running fine")
<< endl;
if (b) throw exception("f failed");
}
int main()
{
test t1("auto main");
try
{
test t2("in try in main");
f(false);
f(true);
cout << "this will never be printed";
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
f
函数只有在用true
值调用时才会抛出异常。main
函数调用f
两次,一次使用false
值(因此在f
中不抛出异常),第二次使用true
。输出结果是:
f running fine
auto f uncaught exception = false
f throwing exception
auto f uncaught exception = true
in try in main uncaught exception = true
f failed
auto main uncaught exception = false
第一次f
被调用,test
对象被正常破坏,所以uncaught_exception
会返回false
。第二次f
调用的是函数中的test
对象在异常被捕捉之前就被破坏了,所以uncaught_exception
会返回true
。由于抛出异常,执行离开try
块,因此try
块中的test
对象被销毁,uncaught_exception
将返回true
。最后,当异常处理完毕,控制返回到catch
块后的代码时,main
函数中栈上创建的test
对象将在main
函数返回时被销毁,因此uncaught_exception
将返回false
。
exception
类是一个简单的 C 字符串容器:该字符串作为构造函数参数传递,并可通过what
访问器获得。标准库在<exception>
库中声明了异常类,并且鼓励您从中派生自己的异常类。标准库提供以下派生类;大多数在<stdexcept>
中定义。
| 级 | 投掷 |
| bad_alloc
| 当new
操作员无法分配内存时(在<new>
中) |
| bad_array_new_length
| 当new
运算符被要求创建一个长度无效的数组时(在<new>
中) |
| bad_cast
| 当dynamic_cast
到参考类型失败时(在<typeinfo>
中) |
| bad_exception
| 出现了意外情况(在<exception>
中) |
| bad_function_call
| 调用了一个空的function
对象(在<functional>
中) |
| bad_typeid
| 当typeid
的自变量为空时(在<typeinfo>
中) |
| bad_weak_ptr
| 当访问一个弱指针时,它指的是一个已经被破坏的对象(在<memory>
中) |
| domain_error
| 当试图在定义操作的域之外执行操作时 |
| invalid_argument
| 当参数使用了无效值时 |
| length_error
| 当试图超过为对象定义的长度时 |
| logic_error
| 当存在逻辑错误时,例如,类不变量或前置条件 |
| out_of_range
| 当试图访问对象定义范围之外的元素时 |
| overflow_error
| 当计算得出的值大于目标类型时 |
| range_error
| 当计算得出的值超出该类型的范围时 |
| runtime_error
| 当错误发生在代码范围之外时 |
| system_error
| 包装操作系统错误的基类(在<system_error>
中) |
| underflow_error
| 当计算导致下溢时 |
上表中提到的所有类都有一个接受const char*
或const string&
参数的构造函数,这与接受 C 字符串的exception
类不同(因此,如果描述是通过string
对象传递的,基类是使用c_str
方法构造的)。没有宽字符版本,所以如果你想从宽字符串构造一个异常描述,你必须转换它。此外,请注意,标准异常类只有一个构造函数参数,这可以通过继承的what
访问器获得。
关于异常可以保存的数据,没有绝对的规则。您可以从exception
中派生一个类,并用您想要提供给异常处理程序的任何值来构造它。
每个try
块可以有多个catch
块,这意味着您可以根据异常类型定制异常处理。catch
条款中的参数类型将按照其声明的顺序对照异常类型进行测试。异常将由与异常类型匹配的第一个处理程序处理,或者是一个基类。这突出了通过引用捕获异常对象的约定。如果您将 catch 作为基类对象,将会创建一个副本,对派生类对象进行切片。在许多情况下,代码将抛出从exception
类派生的类型的对象,因此这意味着exception
的捕获处理程序将捕获所有异常。
因为代码可以抛出任何对象,所以异常可能会传播出处理程序。C++ 允许你通过在catch
子句中使用省略号来捕捉一切。显然,您应该将catch
处理程序从派生最多的到派生最少的排序,并且(如果您使用它的话)在末尾使用省略号处理程序:
try
{
call_code();
}
catch(invalid_argument& iva)
{
cout << "invalid argument: " << e.what() << endl;
}
catch(exception& exc)
{
cout << typeid(exc).name() << ": " << e.what() << endl;
}
catch(...)
{
cout << "some other C++ exception" << endl;
}
如果受保护的代码没有抛出异常,则不执行catch
块。
当您的处理程序检查异常时,它可能决定不抑制异常;这被称为重新抛出异常。为此,您可以使用不带操作数的throw
语句(这仅在catch
处理程序中允许),该语句将重新引发被捕获的实际异常对象,而不是副本。
异常是基于线程的,因此很难将异常传播到另一个线程。exception_ptr
类(在<exception>
中)为任何类型的异常对象提供共享所有权语义。您可以通过调用make_exception_ptr
对象获得异常对象的共享副本,或者您甚至可以使用current_exception
获得正在catch
块中处理的异常的共享副本。两个函数都返回一个exception_ptr
对象。一个exception_ptr
对象可以保存任何类型的异常,而不仅仅是那些从exception
类派生的异常,所以从包装的异常中获取信息是特定于异常类型的。exception_ptr
对象对这些细节一无所知,因此您可以将其传递给想要使用共享异常(另一个线程)的上下文中的rethrow_exception
,然后捕获适当的异常对象。在下面的代码中,有两个线程正在运行。first_thread
功能在一个线程上运行,second_thread
功能在另一个线程上运行:
exception_ptr eptr = nullptr;
void first_thread()
{
try
{
call_code();
}
catch (...)
{
eptr = current_exception();
}
// some signalling mechanism ...
}
void second_thread()
{
// other code
// ... some signalling mechanism
if (eptr != nullptr)
{
try
{
rethrow_exception(eptr);
}
catch(my_exception& e)
{
// process this exception
}
eptr = nullptr;
}
// other code
}
前面的代码看起来像是在使用exception_ptr
作为指针。事实上,eptr
被创建为一个全局对象,nullptr
的赋值使用复制构造函数来创建一个空对象(其中包装的异常是nullptr
)。类似地,与nullptr
的比较实际上测试了包装异常。
这本书不是关于 C++ 线程的,所以我们不会详细讨论两个线程之间的信号传递。这段代码表明,一个异常的共享副本任何异常都可以存储在一个上下文中,然后在另一个上下文中重新抛出和处理。
您可以决定使用try
块保护整个函数,在这种情况下,您可以编写如下代码:
void test(double d)
{
try
{
cout << setw(10) << d << setw(10) << reciprocal(d) << endl;
}
catch (exception& e)
{
cout << "error: " << e.what() << endl;
}
}
这使用了前面定义的reciprocal
函数,如果参数为零,该函数将抛出exception
。另一种语法是:
void test(double d)
try
{
cout << setw(10) << d << setw(10) << reciprocal(d) << endl;
}
catch (exception& e)
{
cout << "error: " << e.what() << endl;
}
这看起来相当奇怪,因为函数原型后面紧跟着try... catch
块,并且没有外部的大括号集。功能体是try
块中的代码;当这段代码完成时,函数返回。如果函数返回值,它必须在try
块中执行。在大多数情况下,您会发现这种语法会降低代码的可读性,但是有一种情况可能会有用——对于构造函数中的初始值设定项列表。
class inverse
{
double recip;
public:
inverse() = delete;
inverse(double d) recip(reciprocal(d)) {}
double get_recip() const { return recip; }
};
在这段代码中,我们包装了一个double
值,它只是传递给构造函数的参数的倒数。通过调用初始化列表中的reciprocal
函数来初始化数据成员。因为这是在构造函数体之外,所以这里发生的异常将直接传递给调用构造函数的代码。如果您想做一些额外的处理,那么您可以在构造函数体内调用倒数函数:
inverse::inverse(double d)
{
try { recip = reciprocal(d); }
catch(exception& e) { cout << "invalid value " << d << endl; }
}
需要注意的是,异常将被自动重新抛出,因为构造函数中的任何异常都意味着对象无效。但是,这确实允许您在必要时进行一些额外的处理。此解决方案不适用于基对象构造函数中引发的异常,因为尽管您可以在派生构造函数体中调用基构造函数,但编译器会自动调用默认构造函数。如果你想让编译器调用默认构造函数以外的构造函数,你必须在初始化列表中调用它。在inverse
构造函数中提供异常代码的另一种语法是使用函数try
块:
inverse::inverse(double d)
try
: recip (reciprocal(d)) {}
catch(exception& e) { cout << "invalid value " << d << endl; }
这看起来有点混乱,但是构造器主体仍然在初始化列表之后,给recip
数据成员一个初始值。对reciprocal
的调用中的任何异常都将被捕获,并在处理后自动重新抛出。初始化列表可以包含对基类和任何数据成员的调用,所有这些都将受到try
块的保护。
<system_error>
库定义了一系列的类来封装系统错误。error_category
类提供了一种将数字错误值转换成本地化描述字符串的机制。通过<system_error>
中的generic_category
和system_category
功能可以获得两个对象,<ios>
有一个名为isostream_category
的功能;所有这些函数都返回一个error_category
对象。error_category
类有一个名为message
的方法,该方法返回您作为参数传递的错误号的字符串描述。从generic_category
函数返回的对象将返回 POSIX 错误的描述性字符串,因此您可以使用它来获取errno
值的描述。从system_category
函数返回的对象将通过使用FORMAT_MESSAGE_FROM_SYSTEM
作为标志参数的 Win32 FormatMessage
函数返回一个错误描述,因此这可用于获取string
对象中的窗口错误消息的描述性消息。
Note that message
has no extra parameters to pass in values for a Win32 error message that takes parameters. Consequently, in those situations you will get back a message that has formatting placeholders.
不管名称如何,isostream_category
对象本质上返回与generic_category
对象相同的描述。
system_error
例外是报告由其中一个error_category
对象描述的值的类。例如,这是先前用于FormatMessage
但使用system_error
重写的示例:
HANDLE file = CreateFileA(
"does_not_exist", GENERIC_READ, 0, 0, OPEN_EXISTING, 0, 0);
if (INVALID_HANDLE_VALUE == file)
{
throw system_error(GetLastError(), system_category());
}
else
{
CloseHandle(file);
}
这里使用的system_error
构造函数将错误值作为第一个参数(一个从 Win32 函数GetLastError
返回的ulong
)和一个system_category
对象,用于在调用system_error::what
方法时将错误值转换为描述性字符串。
一个catch
块可以在没有任何操作数的情况下通过调用throw
来重新引发当前异常,并且在调用堆栈中到达下一个try
块之前将会有堆栈展开。您也可以重新抛出嵌套在另一个异常中的当前异常*。这是通过调用<exception>
中的throw_with_nested
函数并传递新的异常来实现的。该函数调用current_exception
并将异常对象与参数一起包装在嵌套异常中,然后抛出。调用栈更高层的一个try
块可以捕获这个异常,但它只能访问外部异常;它不能直接访问内部异常。相反,可以通过调用rethrow_if_nested
来引发内部异常。例如,下面是打开文件的另一个版本的代码:*
void open(const char *filename)
{
try
{
ifstream file(filename);
file.exceptions(ios_base::failbit);
// code if the file exists
}
catch (exception& e)
{
throw_with_nested(
system_error(ENOENT, system_category(), filename));
}
}
该代码打开一个文件,如果该文件不存在,则设置一个状态位(您可以稍后通过调用rdstat
方法来测试这些位)。下一行表示抛出异常的类应该处理的状态位的值,在这种情况下提供ios_base::failbit
。如果构造函数未能打开文件,则该位将被设置,因此exceptions
方法将通过抛出异常来响应。在本例中,异常被捕获并包装到嵌套异常中。外部异常是一个system_error
异常,用一个错误值ENOENT
(这意味着文件不存在)和一个error_category
对象来解释它,传递文件的名称作为附加信息。
这个函数可以这样调用:
try
{
open("does_not_exist");
}
catch (exception& e)
{
cout << e.what() << endl;
}
这里捕获的异常是可以访问的,但是它只提供了关于外部对象的信息:
does_not_exist: The system cannot find the file specified.
该消息由system_error
对象使用传递给其构造器的附加信息和来自类别对象的描述来构造。要获取嵌套异常中的内部对象,您必须告诉系统通过调用rethrow_if_nested
来抛出内部异常。因此,不打印外部异常,而是调用如下函数:
void print_exception(exception& outer)
{
cout << outer.what() << endl;
try { rethrow_if_nested(outer); }
catch (exception& inner) { print_exception(inner); }
}
这将打印外部异常的描述,然后调用rethrow_if_nested,
,只有嵌套时才会抛出异常。如果是,它抛出内部异常,然后被捕获并递归调用print_exception
函数。结果是:
does_not_exist: The system cannot find the file specified.
ios_base::failbit set: iostream stream error
最后一行是调用ifstream::exception
方法时抛出的内部异常。
Windows 中的本机异常是结构化异常处理 ( SEH ),Visual C++ 有一个语言扩展允许您捕捉这些异常。重要的是要明白,它们与 C++ 异常不一样,后者被编译器认为是同步,也就是说,编译器知道一个方法是否可能(或者确切地说,不会)抛出一个 C++ 异常,并且它在分析代码时使用这个信息。C++ 异常也是通过类型捕获的。SEH 不是一个 C++ 概念,因此编译器将结构化异常视为异步,这意味着它将 SEH 保护块中的任何代码视为可能引发结构化异常,因此编译器无法执行优化。异常代码也会捕获 SEH 异常。
SEH 的语言扩展是微软 C/C++ 的扩展,也就是说,它们可以在 C 和 C++ 中使用,所以处理基础设施不知道对象析构函数。此外,当您捕获到 SEH 异常时,不会对堆栈或进程的任何其他部分的状态做出任何假设。
虽然大多数 Windows 函数会以适当的方式捕获内核生成的 SEH 异常,但有些函数会故意允许它们传播(例如,远程过程调用 ( RPC )函数,或用于内存管理的函数)。对于一些窗口函数,您可以明确请求使用 SEH 异常来处理错误。例如,HeapCreate
函数集将允许一个 Windows 应用创建一个私有堆,并且您可以传递HEAP_GENERATE_EXCEPTIONS
标志来指示在创建堆以及在私有堆中分配或重新分配内存时的错误将生成一个 SEH 异常。这是因为调用这些函数的开发人员可能认为故障非常严重,不可恢复,因此流程应该终止。由于 SEH 是一个如此严重的情况,你应该仔细审查是否是适当的(这并不是完全不可能的)做更多的报告细节的例外和终止该进程。
SEH 异常本质上是低级操作系统异常,但是熟悉语法很重要,因为它看起来类似于 C++ 异常。例如:
char* pPageBuffer;
unsigned long curPages = 0;
const unsigned long PAGESIZE = 4096;
const unsigned long PAGECOUNT = 10;
int main()
{
void* pReserved = VirtualAlloc(
nullptr, PAGECOUNT * PAGESIZE, MEM_RESERVE, PAGE_NOACCESS);
if (nullptr == pReserved)
{
cout << "allocation failed" << endl;
return 1;
}
char *pBuffer = static_cast<char*>(pReserved);
pPageBuffer = pBuffer;
for (int i = 0; i < PAGECOUNT * PAGESIZE; ++ i)
{
__try { pBuffer[i] = 'X'; } __except (exception_filter(GetExceptionCode())) { cout << "Exiting process.n"; ExitProcess(GetLastError()); }
}
VirtualFree(pReserved, 0, MEM_RELEASE);
return 0;
}
这里突出显示了 SEH 异常代码。这段代码使用了 Windows 的VirtualAlloc
功能来保留一定数量的内存页面。保留不会分配内存,该操作必须在一个名为的单独操作中执行,提交内存。Windows 将在名为页面的块中保留(并提交)内存,在大多数系统中,一个页面是 4096 字节,如这里所假设的。对VirtualAlloc
函数的调用表明它应该保留 10 页 4096 字节的内容,这些内容将在以后提交(和使用)。
VirtualAlloc
的第一个参数表示内存的位置,但是由于我们保留内存,这并不重要,所以nullptr
被传递。如果保留成功,那么指针返回到内存。for
循环只是一次向内存中写入一个字节的数据。突出显示的代码通过结构化异常处理来保护这种内存访问。受保护的块以__try
关键字开始。当一个 SEH 被提出,执行传递到__except
块。这与 C++ 异常中的catch
块非常不同。首先,__except
异常处理程序接收三个值中的一个来指示它应该如何表现。只有当这是EXCEPTION_EXECUTE_HANDLER
时,才会运行处理程序块中的代码(在该代码中,突然关闭进程)。如果该值为EXCEPTION_CONTINUE_SEARCH
,则异常未被识别,搜索将继续向上堆栈,,但不展开 C++ 堆栈。令人惊讶的值是EXCEPTION_CONTINUE_EXECUTION,
,因为这取消了异常,并且__try
块中的执行将继续。你不能用 C++ 异常来做这个。通常,SEH 代码将使用异常过滤函数来确定__except
处理程序需要什么动作。在这段代码中,这个过滤器被称为exception_filter,
,它被传递了通过调用 Windows 函数GetExceptionCode
获得的异常代码。这个语法很重要,因为这个函数只能在__except
上下文中调用。
循环第一次运行时,不会提交任何内存,因此写入内存的代码将引发异常:页面错误。执行将传递给异常处理程序并通过exception_filter
:
int exception_filter(unsigned int code)
{
if (code != EXCEPTION_ACCESS_VIOLATION)
{
cout << "Exception code = " << code << endl;
return EXCEPTION_EXECUTE_HANDLER;
}
if (curPage >= PAGECOUNT)
{
cout << "Exception: out of pages.n";
return EXCEPTION_EXECUTE_HANDLER;
}
if (VirtualAlloc(static_cast<void*>(pPageBuffer), PAGESIZE,
MEM_COMMIT, PAGE_READWRITE) == nullptr)
{
cout << "VirtualAlloc failed.n";
return EXCEPTION_EXECUTE_HANDLER;
}
curPage++ ;
pPageBuffer += PAGESIZE;
return EXCEPTION_CONTINUE_EXECUTION;
}
在 SEH 代码中,重要的是只处理您知道的异常,并且只有在您知道条件已经完全解决的情况下才使用异常。如果您访问尚未提交的 Windows 内存,操作系统会生成一个称为页面错误的异常。在这段代码中,测试异常代码,看它是否是页面错误,如果不是,过滤器返回,告诉异常处理程序在终止进程的异常处理程序块中运行代码。如果异常是页面错误,那么我们可以提交下一页。首先,有一个测试,看看页码是否在我们将使用的范围内(如果不在,则关闭该过程)。然后,通过再次调用VirtualAlloc
提交下一页,以识别要提交的页和该页中的字节数。如果函数成功,它将返回一个指向已提交页面的指针或一个空值。只有在提交页面成功的情况下,过滤器才会返回一个值EXCEPTION_CONTINUE_EXECUTION
,表示异常已经被处理,并且可以在异常被引发时继续执行。这段代码是使用VirtualAlloc
的标准方式,因为它意味着只有在需要的时候才会提交内存页面。
SEH 也有终止处理器的概念。当执行通过调用return
离开__try
代码块时,或者通过完成代码块中的所有代码,或者通过调用微软扩展__leave
指令,或者已经引发了 SEH,则调用标有__finally
的终止处理程序代码块。由于无论如何退出__try
块,终止处理程序总是被调用,因此可以将其用作释放资源的一种方式。但是,因为 SEH 不进行 C++ 堆栈展开(也不调用析构函数),这意味着您不能在具有 C++ 对象的函数中使用这些代码。事实上,编译器会拒绝编译具有 SEH 并创建了 C++ 对象的函数,无论是在函数堆栈上还是在堆上分配。(但是,您可以使用全局对象或在调用函数中分配并作为参数传入的对象。)构造__try
/ __finally
看起来很有用,但是受限于不能在创建 C++ 对象的代码中使用它的要求。
在这一点上,值得解释一下为什么用/EHsc
开关编译代码。简单的答案是,如果您不使用此开关,编译器将从标准库代码中发出警告,并且由于标准库使用异常,您必须使用/EHsc
开关。警告告诉你这样做,所以这就是你要做的。
长答案是/EH
开关有三个参数可以用来影响异常的处理方式。使用s
参数告诉编译器为同步异常提供基础结构,也就是说,可以在try
块中抛出并在catch
块中处理的 C++ 异常,以及调用自动 C++ 对象析构函数的堆栈展开异常。c
参数表明extern C
函数(也就是所有的 Windows SDK 函数)从不抛出 C++ 异常(因此编译器可以进行额外的优化)。因此,您可以使用/EHs
或/EHsc
编译标准库代码,但后者将生成更优化的代码。还有一个额外的参数,其中/EHa
表示代码将通过try
/ catch
块捕获同步和异步异常(SEH)。
*# 混合使用 C++ 和 SEH 异常处理
RaiseException
窗口函数将抛出一个 SEH 异常。第一个参数是异常代码,第二个参数表示处理完这个异常后流程是否可以继续(0
表示可以)。第三和第四个参数给出了关于异常的附加信息。第四个参数是指向带有这些附加参数的数组的指针,参数的数量在第三个参数中给出。
有了/EHa
,你可以这样写代码:
try
{
RaiseException(1, 0, 0, nullptr);
}
// legal code, but don't do it
catch(...)
{
cout << "SEH or C++ exception caught" << endl;
}
这段代码的问题在于它处理所有 SEH 异常。这是非常危险的,因为一些 SEH 异常可能表明进程状态被破坏,所以进程继续是危险的。C 运行时库提供了一个名为_set_se_translator
的函数,该函数提供了一种机制来指示哪些 SEH 异常由try
处理。这个函数由您用这个原型编写的函数传递一个指针:
void func(unsigned int, EXCEPTION_POINTERS*);
第一个参数是异常代码(将从GetExceptionCode
函数返回),第二个参数是从GetExceptionInformation
函数返回,并且具有与异常相关联的任何附加参数(例如,通过RaiseException
中的第三和第四个参数传递的参数)。您可以使用这些值抛出一个 C++ 异常来代替 SEH。如果您提供此功能:
void seh_to_cpp(unsigned int code, EXCEPTION_POINTERS*)
{
if (code == 1) throw exception("my error");
}
现在,您可以在处理 SEH 异常之前注册该函数:
_set_se_translator(seh_to_cpp);
try
{
RaiseException(1, 0, 0, nullptr);
}
catch(exception& e)
{
cout << e.what() << endl;
}
在这段代码中,RaiseException
函数使用值 1 提升一个自定义 SEH。这个翻译也许不是最有用的,但它说明了这一点。winnt.h
头文件定义了可以在窗口代码中引发的标准 SEH 异常的异常代码。更有用的翻译功能是:
double reciprocal(double d)
{
return 1.0 / d;
}
void seh_to_cpp(unsigned int code, EXCEPTION_POINTERS*)
{
if (STATUS_FLOAT_DIVIDE_BY_ZERO == code ||
STATUS_INTEGER_DIVIDE_BY_ZERO == code)
{
throw invalid_argument("divide by zero");
}
}
这允许您调用如下倒数函数:
_set_se_translator(seh_to_cpp);
try
{
reciprocal(0.0);
}
catch(invalid_argument& e)
{
cout << e.what() << endl;
}
一般来说,当您编写类时,您应该确保保护类的用户免受异常的影响。异常不是错误传播机制。如果类中的一个方法失败了,但是可以恢复(对象状态保持一致),那么您应该使用返回值(很可能是错误代码)来指示这一点。异常是针对异常情况的,这些情况使数据无效,并且在引发异常时,情况是不可恢复的。
当代码中出现异常时,您有三种选择。首先,您可以允许异常在调用堆栈中向上传播,并将处理异常的责任放在调用代码上。这意味着您调用代码时没有try
块的保护,即使代码被记录为能够抛出异常。在这种情况下,您必须确信异常对调用代码是有意义的。例如,如果您的类被记录为网络类,并使用临时文件来缓冲从网络接收的一些数据,则如果文件访问代码引发异常,则异常对象对于调用您的代码的代码没有意义,因为该客户端代码认为您的类是关于访问网络数据的,而不是文件数据。但是,如果网络代码抛出了一个错误,允许这些异常传播到调用代码可能是有意义的,尤其是当它们涉及到需要外部操作的错误时(例如,网络电缆被拔掉或存在安全问题)。
在这种情况下,你可以应用你的第二个选项,那就是用一个try
块保护可以抛出异常的代码,捕捉已知的异常,抛出一个更合适的异常,或许可以嵌套原来的异常,这样调用的代码就可以做更详细的分析。如果异常对您的调用代码有意义,您可以允许它传播出去,但是捕获原始异常允许您在重新抛出它之前采取额外的操作。
使用缓冲的网络数据示例,您可以确定,由于文件缓冲中存在错误,这意味着您无法读取更多的网络数据,因此您的异常处理代码应该以适当的方式关闭网络访问。错误发生在文件代码中,而不是网络代码中,因此突然关闭网络是不合理的,允许当前网络操作完成(但忽略数据)更有意义,这样就不会将错误传播回网络代码。
最后一个选项是用try
块保护所有代码,并捕获和消费异常,这样调用代码就可以在不抛出异常的情况下完成。有两种情况是合适的。首先,错误可能是可恢复的,因此在catch
条款中,您可以采取措施解决问题。在缓冲网络数据示例中,当打开临时文件时,如果您得到一个错误,即具有所请求名称的文件已经存在,您可以简单地使用另一个名称,然后重试。代码的用户不需要知道发生了这个问题(尽管跟踪这个错误可能是有意义的,这样您就可以在代码的测试阶段调查这个问题)。如果错误不可恢复,那么使对象的状态无效并返回错误代码可能更有意义。
您的代码应该利用 C++ 异常基础结构的行为,这保证了自动对象被销毁。因此,当您使用内存或其他适当的资源时,您应该尽可能地将它们包装在智能指针中,以便在抛出异常时,智能指针析构函数释放资源。使用资源获取是初始化(RAII)的类有vector
、string
、fstream,
和make_shared
函数,所以如果对象构造(或函数调用)成功,就意味着资源已经获取,可以通过这些对象使用资源。这些类也是资源释放销毁 ( RRD ),意思是对象销毁时释放资源。智能指针类unique_ptr
和shared_ptr
不是 RAII,因为它们只是包装资源,资源的分配由其他代码单独执行。但是,这些类是 RRD 类,所以可以确信,如果抛出异常,资源就会被释放。
异常处理可以提供三个级别的异常安全性。秤最安全的级别是无故障方法和功能。这是不抛出异常并且不允许异常传播的代码。这样的代码将保证类不变量得到维护,对象状态保持一致。没有失败的代码不是通过简单地捕获所有异常并使用它们来实现的,相反,您必须保护所有代码并捕获和处理所有异常,以确保对象保持一致的状态。
所有内置的 C++ 类型都是无故障的。您还可以保证所有的标准库类型都有不失败的析构函数,但是由于容器会在实例被销毁时调用包含的对象析构函数,这意味着您必须确保您写入容器的类型也有不失败的析构函数。
编写不失败类型可能涉及相当详细的代码,所以另一个选项是强保证。这样的代码会抛出异常,但是它们确保没有内存泄漏,并且当抛出异常时,对象将处于与调用方法时相同的状态。这本质上是一个事务性操作:要么修改对象,要么保持不变,就好像没有尝试执行该操作一样。在大多数情况下方法,这将提供一个异常安全的基本保证。在这种情况下,可以保证无论发生什么都不会泄漏内存,但是当抛出异常时,对象可能会处于不一致的状态,因此调用代码应该通过丢弃对象来处理异常。
文档很重要。如果对象方法标有throw
或noexcept
,那么你就知道它是无故障的。只有在文档中这样说的情况下,您才应该承担强有力的保证。否则,您可以假设对象将具有异常安全的基本保证,并且如果抛出异常,则对象无效。
当您编写 C++ 代码时,您应该始终用一只眼睛来观察代码的测试和调试。防止需要调试代码的理想方法是编写健壮、设计良好的代码。理想很难实现,所以最好编写易于诊断问题和调试的代码。C 运行时和 C++ 标准库提供了广泛的工具,使您能够跟踪和报告问题,并且通过错误代码处理和异常,您拥有丰富的工具集合来报告和处理函数故障。
读完这本书,你应该知道 C++ 语言和标准库提供了一种丰富、灵活和强大的代码编写方式。更重要的是,一旦你知道如何使用该语言及其库,使用 C++ 是一种乐趣。**