引用
引用变量是一个别名,也就是说,它是某个已存在变量的另一个名字。一旦把引用初始化为某个变量,就可以使用该引用名称或变量名称来指向变量。
C++ 引用 vs 指针
引用很容易与指针混淆,它们之间有三个主要的不同:
- 不存在空引用。引用必须连接到一块合法的内存。
- 一旦引用被初始化为一个对象,就不能被指向到另一个对象。指针可以在任何时候指向到另一个对象。
- 引用必须在创建时被初始化。指针可以在任何时间被初始化。
引用的好处之一就是在函数调用时在内存中不会生成副本。
引用总结
- 在引用的使用中,单纯给某个变量取个别名是毫无意义的,引用的目的主要用于在函数参数传递中,解决大块数据或对象的传递效率和空间不如意的问题。
- 用引用传递函数的参数,能保证参数传递中不产生副本,提高传递的效率,且通过const的使用,保证了引用传递的安全性。
- 引用与指针的区别是,指针通过某个指针变量指向一个对象后,对它所指向的变量间接操作。程序中使用指针,程序的可读性差;而引用本身就是目标变量的别名,对引用的操作就是对目标变量的操作。
- 使用引用的时机。流操作符<<和>>、赋值操作符=的返回值、拷贝构造函数的参数、赋值操作符=的参数、其它情况都推荐使用引用。
- 声明引用时,必须同时对其进行初始化。
- 引用声明完毕后,相当于目标变量名有两个名称,即该目标原名称和引用名,且不能再把该引用名作为其他变量名的别名。ra=1; 等价于 a=1;
- 声明一个引用,不是新定义了一个变量,它只表示该引用名是目标变量名的一个别名,它本身不是一种数据类型,因此引用本身不占存储单元,系统也不给引用分配存储单元。故:对引用求地址,就是对目标变量求地址。&ra与&a相等。
- 不能建立数组的引用。因为数组是一个由若干个元素所成的集合,所以无法建立一个数组的别名。
- 不能建立引用的引用,不能建立指向引用的指针。因为引用不是一种数据类型!!所以没有引用的引用,没有引用的指针。
引用就是某一变量(目标)的一个别名,对引用的操作与对变量直接操作完全一样。
引用的声明方法:类型标识符&引用名=目标变量名;
引用应用
引用作为参数
引用的一个重要作用就是作为函数的参数。以前的C语言中函数参数传递是值传递,如果有大块数据作为参数传递的时候,采用的方案往往是指针,因为这样可以避免将整块数据全部压栈,可以提高程序的效率。但是现在(C++中)又增加了一种同样有效率的选择(在某些特殊情况下又是必须的选择),就是引用。
递引用给函数与传递指针的效果是一样的。这时,被调函数的形参就成为原来主调函数中的实参变量或对象的一个别名来使用,所以在被调函数中对形参变量的操作就是对其相应的目标对象(在主调函数中)的操作。
使用引用传递函数的参数,在内存中并没有产生实参的副本,它是直接对实参操作;而使用一般变量传递函数的参数,当发生函数调用时,需要给形参分配存储单元,形参变量是实参变量的副本;如果传递的是对象,还将调用拷贝构造函数。因此,当参数传递的数据较大时,用引用比用一般变量传递参数的效率和所占空间都好。
使用指针作为函数的参数虽然也能达到与使用引用的效果,但是,在被调函数中同样要给形参分配存储单元,且需要重复使用”*指针变量名”的形式进行运算,这很容易产生错误且程序的阅读性较差;另一方面,在主调函数的调用点处,必须用变量的地址作为实参。而引用更容易使用,更清晰。
常引用
常引用声明方式:const 类型标识符 &引用名=目标变量名;
用这种方式声明的引用,不能通过引用对目标变量的值进行修改,从而使引用的目标成为const,达到了引用的安全性。
引用作为返回值
要以引用返回函数值,则函数定义时要按以下格式:
类型标识符 &函数名(形参列表及类型说明)
{函数体}
说明:
(1)以引用返回函数值,定义函数时需要在函数名前加&
(2)用引用返回一个函数值的最大好处是,在内存中不产生被返回值的副本。
引用作为返回值,必须遵守以下规则:
(1)不能返回局部变量的引用。这条可以参照Effective C++[1]的Item 31。主要原因是局部变量会在函数返回后被销毁,因此被返回的引用就成为了”无所指”的引用,程序会进入未知状态。
(2)不能返回函数内部new分配的内存的引用。这条可以参照Effective C++[1]的Item 31。虽然不存在局部变量的被动销毁问题,可对于这种情况(返回函数内部new分配内存的引用),又面临其它尴尬局面。例如,被函数返回的引用只是作为一个临时变量出现,而没有被赋予一个实际的变量,那么这个引用所指向的空间(由new分配)就无法释放,造成memory leak(内存泄露)。
(3)可以返回类成员的引用,但最好是const。这条原则可以参照Effective C++[1]的Item 30。主要原因是当对象的属性是与某种业务规则(business rule)相关联的时候,其赋值常常与某些其它属性或者对象的状态有关,因此有必要将赋值操作封装在一个业务规则当中。如果其它对象可以获得该属性的非常量引用(或指针),那么对该属性的单纯赋值就会破坏业务规则的完整性。
(4)引用与一些操作符的重载:流操作符<<和>>,这两个操作符常常希望被连续使用,例如:cout << “hello” << endl; 因此这两个操作符的返回值应该是一个仍然支持这两个操作符的流引用。可选的其它方案包括:返回一个流对象和返回一个流对象指针。但是对于返回一个流对象,程序必须重新(拷贝)构造一个新的流对象,也就是说,连续的两个<<操作符实际上是针对不同对象的!这无法让人接受。对于返回一个流指针则不能连续使用<<操作符。因此,返回一个流对象引用是惟一选择。这个唯一选择很关键,它说明了引用的重要性以及无可替代性,也许这就是C++语言中引入引用这个概念的原因吧。 赋值操作符=。这个操作符象流操作符一样,是可以连续使用的,例如:x = j = 10;或者(x=10)=100;赋值操作符的返回值必须是一个左值,以便可以被继续赋值。因此引用成了这个操作符的惟一返回值选择。
(5)在另外的一些操作符中,却千万不能返回引用:+-*/ 四则运算符。它们不能返回引用,Effective C++[1]的Item23详细的讨论了这个问题。主要原因是这四个操作符没有side effect,因此,它们必须构造一个对象作为返回值,可选的方案包括:返回一个对象、返回一个局部变量的引用,返回一个new分配的对象的引用、返回一个静态对象引用。根据前面提到的引用作为返回值的三个规则,第2、3两个方案都被否决了。静态对象的引用又因为((a+b) == (c+d))会永远为true而导致错误。所以可选的只剩下返回一个对象了。
引用和多态
引用是除指针外另一个可以产生多态效果的手段。这意味着,一个基类的引用可以指向它的派生类实例。
【例7】:
class A;
class B:public A{……};
B b;
A &Ref = b; // 用派生类对象初始化基类对象的引用
Ref 只能用来访问派生类对象中从基类继承下来的成员,是基类引用指向派生类。如果A类中定义有虚函数,并且在B类中重写了这个虚函数,就可以通过Ref产生多态效果。
顶层const与底层const
2.4.3.Top-Level const
As we’ve seen, a pointer is an object that can point to a different object. As a result,we can talk independently about whether a pointer is const and whether the objects to which it can point are const. We use the term top-level const to indicate that the pointer itself is a const. When a pointer can point to a const object, we refer to that const as a low-level const.
指针本身是一个对象,因为,指针实际对应着内存单元的一段存储空间,然而,指针所指向的也是一个数据对象,因此,指针是一个常量与指针所指向的是一个常量是两个完全不同的概念, 顶层 const 表示的是 指针本身是一个常量, 底层 const 表示的是 指针所指的对象是一个常量,更一般情况下, 顶层 const 可以表示任意对象是一个常量,这对于算术类型、类、指针等任何数据类型都是成立的, 底层 const 则与指针和引用等复合类型的基本类型部分有关 ,比较特殊的是,指针既可以是顶层 const 也可以是底层 const ,这一点与其他类型区别明显。
顶层和底层的翻译很容易让人误解为就只有两层,实际上当然是不是的。首先我们假设有这样的代码:
1 | template<typename T> using Const = const T; |
然后
1 | const int *** const shit = nullptr; |
要怎么看呢?很简单,不要用const和*,用Const和Ptr来表达,马上明白:
1 | Const<Ptr<Ptr<Ptr<Const<int>>>>> shit = nullptr; |
从右向左读,星号读作pointer,没多一层加一个to,然后最前面加上declare就行。比如对const int * const shit;,可以读作:declare shit as const pointer to pointer to pointer to const int。
constexpr
constexpr
说明符声明可以在编译时求得函数或变量的值。然后这些变量和函数(若给定了合适的函数实参)即可用于仅允许编译时常量表达式之处。用于对象或非静态成员函数 (C++14 前)声明的 constexpr 说明符蕴含 const。用于函数声明的 constexpr 说明符或 static 成员变量 (C++17 起)蕴含 inline。若函数或函数模板的任何声明拥有 constexpr
说明符,则每个声明必须都含有该说明符。
constexpr 变量必须满足下列要求:
- 其类型必须是字面类型 (LiteralType) 。
- 它必须被立即初始化
- 其初始化的全表达式,包括所有隐式转换、构造函数调用等,都必须是常量表达式
constexpr 函数必须满足下列要求:
- 它必须非虚
显示强制转换
static_cast
用法:static_cast < type-id > ( expression ),
该运算符把expression转换为type-id类型,但没有运行时类型检查来保证转换的安全性,它主要有如下几种用法:
- 用于基本数据类型之间的转换,如把int转换为char,把int转换成enum,但这种转换的安全性需要开发者自己保证(这可以理解为保证数据的精度,即程序员能不能保证自己想要的程序安全),如在把int转换为char时,如果char没有足够的比特位来存放int的值(int>127或int<-127时),那么static_cast所做的只是简单的截断,及简单地把int的低8位复制到char的8位中,并直接抛弃高位。
- 把空指针转换成目标类型的空指针
- 把任何类型的表达式类型转换成void类型
- 用于类层次结构中父类和子类之间指针和引用的转换。
对于以上第(4)点,存在两种形式的转换,即上行转换(子类到父类)和下行转换(父类到子类)。对于static_cast,上行转换时安全的,而下行转换时不安全的,为什么呢?因为static_cast的转换时粗暴的,它仅根据类型转换语句中提供的信息(尖括号中的类型)来进行转换,这种转换方式对于上行转换,由于子类总是包含父类的所有数据成员和函数成员,因此从子类转换到父类的指针对象可以没有任何顾虑的访问其(指父类)的成员。而对于下行转换为什么不安全,是因为static_cast只是在编译时进行类型坚持,没有运行时的类型检查,具体原理在dynamic_cast中说明。
一句话概括:
仅当类型之间可隐式转换时(除类层次间的下行转换以外),static_cast的转换才是合法的,否则将产生错误。(基类指针或引用转换成子类指针或引用为下行转换)
类层次间的下行转换不能通过隐式转换完成,但是可以通过static_cast完成,但是由于没有动态类型检查,所以是不安全的。。
1 | double d=3.14; |
const_cast
只用使用const_cast才能将const性质转换掉。在这种情况下,试图使用其他三种形式的强制转换都会导致编译时的错误。类似地,除了添加或者删除const特性,用const_cast符来执行其他任何类型转换,都会引起编译错误。
1 | const double val=3.14; |
在《C++ primer》(第五版)中是这样介绍const_cast的:
const_cast只能改变运算对象的底层const
1 | const char *pc; |
对于将常量对象转换成非常量对象的行为,我们一般称其为“去掉const性质(cast away the const)”。一旦我们去掉了某个对象的const性质,编译器就不再阻止我们对该对象进行写操作了。如果对象本身不是一个常量,使用强制类型转换获得写权限是合法的行为。然而如果对象是一个常量,再使用const_cast执行写操作就会产生未定义的后果。
只有const_cast能改变表达式的常量属性,使用其他形式的命名强制类型转换改变表达式的常量属性都将引发编译器错误。同样的,也不能用const_cast改变表达式的 类型:
1 | const char* cp; |
reinterpret_cast
从语法上看,这个操作符仅用于指针类型的转换(返回值是指针)。它用来将一个类型指针转换为另一个类型指针,它只需在编译时重新解释指针的类型。这个操作符基本不考虑转换类型之间是否是相关的。
1 | int *ip=NULL; |
注意:
滥用 reinterpret_cast 运算符可能很容易带来风险。 除非所需转换本身是低级别的,否则应使用其他强制转换运算符之一。
在《C++ Primer(中文 第五版 )》指出reinterpret_cast很危险,不建议使用。
dynamic_cast
该运算符把expression转换成type类型的对象。type必须是类型的指针、类的引用或者void*。type和expression的形式要对应,如果type是指针类型,那么expression也必须是一个指针,如果type是一个引用,那么expression也必须是一个引用。
static_cast只在编译时进行类型检查,与其他强制类型转换不同,dynamic_cast涉及运行时类型检查。dynamic_cast运行时类型检查需要运行时类型信息,而这个信息存储在类的虚函数表中,只有定义了虚函数的类才有虚函数表,故对没有虚函数表的类使用会导致dynamic_cast编译错误。
所以dynamic_cast主要用于类层次结构中父类和子类之间指针和引用的转换,由于具有运行时类型检查,因此可以保证下行转换的安全性,何为安全性?即转换成功就返回转换后的正确类型指针,如果转换失败,则返回NULL(如果是转换到引用类型的dynamic_cast失败,则抛出bad_cast类型的异常),之所以说static_cast在下行转换时不安全,是因为即使转换失败,它也不返回NULL。
另外,若绑定到引用或指针的对象类型不是目标类型,则dynamic_cast会失败(这点下面细说)。若转换到指针的失败,dynamic_cast的结果是0值,若转换到引用类型的失败,则抛出一个bad_cast类型的异常。
dynamic_cast主要符主要用于类层次间的上行转换和下行转换。
- 在类层次间上行转换时,dynamic_cast和static_cast的效果一样。因为在公有继承方式(保护继承、私有继承,不能隐式转换)下,派生类的对象/对象指针/对象引用可以赋值给基类的对象/对象指针/对象引用(发生隐式转换),反过来则不行。
- 若发生下行转换是安全的,也就是,如果基类指针或者引用的确指向一个派生类对象,这个运算符会传回转型过的指针,若不安全,则会传回空指针。
针对下行转换,换句话说:向下转换的成功与否还与将要转换的类型有关,即要转换的指针指向的对象的实际类型与转换以后的对象类型一定要相同,否则转换失败。
1 | class Base |
- 若调用函数func的实参p指向一个Derived类型的对象,即则pd1和pd2是一样的,并且对这两个指针执行 Derived类的任何操作都是安全的,语句1和2都是输出1、2;
1
2Base *p=new Derived;
func(p); - 若p指向的是一个Base类型的对象,即那么pd1指向Base对象的地址,对它进行Derived类型的操作将是不安全的(如访问d),输出d的值时,将会是一个垃圾值;而pd2将是一个空指针,对空指针进行操作,将会发生异常。
1
2Base *p=new Base;
func(p);
异常处理(try catch)
C++异常机制概述
异常是程序在执行期间产生的问题。C++ 异常是指在程序运行时发生的特殊情况,比如尝试除以零的操作。
异常提供了一种转移程序控制权的方式。C++ 异常处理涉及到三个关键字:try、catch、throw。
- throw: 当问题出现时,程序会抛出一个异常。这是通过使用 throw 关键字来完成的。
- catch: 在您想要处理问题的地方,通过异常处理程序捕获异常。catch 关键字用于捕获异常。
- try: try 块中的代码标识将被激活的特定异常。它后面通常跟着一个或多个 catch 块。
如果有一个块抛出一个异常,捕获异常的方法会使用 try 和 catch 关键字。try 块中放置可能抛出异常的代码,try 块中的代码被称为保护代码。使用 try/catch 语句的语法如下所示:
1 | try |
异常事件发生时,程序使用throw关键字抛出异常表达式,抛出点称为异常出现点,由操作系统为程序设置当前异常对象,然后执行程序的当前异常处理代码块,在包含了异常出现点的最内层的try块,依次匹配catch语句中的异常对象(只进行类型匹配,catch参数有时在catch语句中并不会使用到)。若匹配成功,则执行catch块内的异常处理语句,然后接着执行try…catch…块之后的代码。如果在当前的try…catch…块内找不到匹配该异常对象的catch语句,则由更外层的try…catch…块来处理该异常;如果当前函数内所有的try…catch…块都不能匹配该异常,则递归回退到调用栈的上一层去处理该异常。如果一直退到主函数main()都不能处理该异常,则调用系统函数terminate()终止程序。
执行throw语句时,throw表达式将作为对象被复制构造为一个新的对象,称为异常对象。异常对象放在内存的特殊位置,该位置既不是栈也不是堆,在window上是放在线程信息块TIB中。这个构造出来的新对象与本级的try所对应的catch语句进行类型匹配,类型匹配的原则在下面介绍。
异常对象
异常对象是一种特殊的对象,编译器依据异常抛出表达式复制构造异常对象,这要求抛出异常表达式不能是一个不完全类型(一个类型在声明之后定义之前为一个不完全类型。不完全类型意味着该类型没有完整的数据与操作描述),而且可以进行复制构造,这就要求异常抛出表达式的复制构造函数(或移动构造函数)、析构函数不能是私有的。
异常对象不同于函数的局部对象,局部对象在函数调用结束后就被自动销毁,而异常对象将驻留在所有可能被激活的catch语句都能访问到的内存空间中,也即上文所说的TIB。当异常对象与catch语句成功匹配上后,在该catch语句的结束处被自动析构。
在函数中返回局部变量的引用或指针几乎肯定会造成错误,同样的道理,在throw语句中抛出局部变量的指针或引用也几乎是错误的行为。如果指针所指向的变量在执行catch语句时已经被销毁,对指针进行解引用将发生意想不到的后果。
throw出一个表达式时,该表达式的静态编译类型将决定异常对象的类型。所以当throw出的是基类指针的解引用,而该指针所指向的实际对象是派生类对象,此时将发生派生类对象切割。
除了抛出用户自定义的类型外,C++标准库定义了一组类,用户报告标准库函数遇到的问题。这些标准库异常类只定义了几种运算,包括创建或拷贝异常类型对象,以及为异常类型的对象赋值。
C++ 提供了一系列标准的异常,定义在 ** 中,我们可以在程序中使用这些标准的异常。它们是以父子类层次结构组织起来的,如下所示:
标准异常类 | 描述 | 头文件 |
---|---|---|
exception | 最通用的异常类,只报告异常的发生而不提供任何额外的信息 | exception |
runtime_error | 只有在运行时才能检测出的错误 | stdexcept |
rang_error | 运行时错误:产生了超出有意义值域范围的结果 | stdexcept |
overflow_error | 运行时错误:计算上溢 | stdexcept |
underflow_error | 运行时错误:计算下溢 | stdexcept |
logic_error | 程序逻辑错误 | stdexcept |
domain_error | 逻辑错误:参数对应的结果值不存在 | stdexcept |
invalid_argument | 逻辑错误:无效参数 | stdexcept |
length_error | 逻辑错误:试图创建一个超出该类型最大长度的对象 | stdexcept |
out_of_range | 逻辑错误:使用一个超出有效范围的值 | stdexcept |
bad_alloc | 内存动态分配错误 | new |
bad_cast | dynamic_cast类型转换出错 | type_info |
bad_exception | 这在处理 C++ 程序中无法预期的异常时非常有用。 | exception |
bad_typeid | 该异常可以通过 typeid 抛出。 | typeinfo |
catch 关键字
catch语句匹配被抛出的异常对象。如果catch语句的参数是引用类型,则该参数可直接作用于异常对象,即参数的改变也会改变异常对象,而且在catch中重新抛出异常时会继续传递这种改变。如果catch参数是传值的,则复制构函数将依据异常对象来构造catch参数对象。在该catch语句结束的时候,先析构catch参数对象,然后再析构异常对象。
在进行异常对象的匹配时,编译器不会做任何的隐式类型转换或类型提升。除了以下几种情况外,异常对象的类型必须与catch语句的声明类型完全匹配:
- 允许从非常量到常量的类型转换。
- 允许派生类到基类的类型转换。
- 数组被转换成指向数组(元素)类型的指针。
- 函数被转换成指向函数类型的指针。
寻找catch语句的过程中,匹配上的未必是类型完全匹配那项,而在是最靠前的第一个匹配上的catch语句(我称它为最先匹配原则)。所以,派生类的处理代码catch语句应该放在基类的处理catch语句之前,否则先匹配上的总是参数类型为基类的catch语句,而能够精确匹配的catch语句却不能够被匹配上。
在catch块中,如果在当前函数内无法解决异常,可以继续向外层抛出异常,让外层catch异常处理块接着处理。此时可以使用不带表达式的throw语句将捕获的异常重新抛出:
1 | catch(type x) |
被重新抛出的异常对象为保存在TIB中的那个异常对象,与catch的参数对象没有关系,若catch参数对象是引用类型,可能在catch语句内已经对异常对象进行了修改,那么重新抛出的是修改后的异常对象;若catch参数对象是非引用类型,则重新抛出的异常对象并没有受到修改。
使用catch(…){}可以捕获所有类型的异常,根据最先匹配原则,catch(…){}应该放在所有catch语句的最后面,否则无法让其他可以精确匹配的catch语句得到匹配。通常在catch(…){}语句中执行当前可以做的处理,然后再重新抛出异常。注意,catch中重新抛出的异常只能被外层的catch语句捕获。
栈展开、RAII
其实栈展开已经在前面说过,就是从异常抛出点一路向外层函数寻找匹配的catch语句的过程,寻找结束于某个匹配的catch语句或标准库函数terminate。这里重点要说的是栈展开过程中对局部变量的销毁问题。我们知道,在函数调用结束时,函数的局部变量会被系统自动销毁,类似的,throw可能会导致调用链上的语句块提前退出,此时,语句块中的局部变量将按照构成生成顺序的逆序,依次调用析构函数进行对象的销毁。例如下面这个例子:
1 | //一个没有任何意义的类 |
程序将输出:
定义变量a时调用了默认构造函数,使用a初始化异常变量时调用了复制构造函数,使用异常变量复制构造catch参数对象时同样调用了复制构造函数。三个构造对应三个析构,也即try语句块中局部变量a自动被析构了。然而,如果a是在自由存储区上分配的内存时:
1 | int main() |
程序运行结果:
同样的三次构造,却只调用了两次的析构函数!说明a的内存在发生异常时并没有被释放掉,发生了内存泄漏。
RAII机制有助于解决这个问题,RAII(Resource acquisition is initialization,资源获取即初始化)。它的思想是以对象管理资源。为了更为方便、鲁棒地释放已获取的资源,避免资源死锁,一个办法是把资源数据用对象封装起来。程序发生异常,执行栈展开时,封装了资源的对象会被自动调用其析构函数以释放资源。C++中的智能指针便符合RAII。关于这个问题详细可以看《Effective C++》条款13.
异常机制与构造函数
异常机制的一个合理的使用是在构造函数中。构造函数没有返回值,所以应该使用异常机制来报告发生的问题。更重要的是,构造函数抛出异常表明构造函数还没有执行完,其对应的析构函数不会自动被调用,因此析构函数应该先析构所有所有已初始化的基对象,成员对象,再抛出异常。
C++类构造函数初始化列表的异常机制,称为function-try block。一般形式为:
1 | myClass::myClass(type1 pa1) |
异常机制与析构函数
C++不禁止析构函数向外界抛出异常,但析构函数被期望不向外界函数抛出异常。析构函数中向函数外抛出异常,将直接调用terminator()系统函数终止程序。如果一个析构函数内部抛出了异常,就应该在析构函数的内部捕获并处理该异常,不能让异常被抛出析构函数之外。可以如此处理:
- 若析构函数抛出异常,调用std::abort()来终止程序。
- 在析构函数中catch捕获异常并作处理。
关于具体细节,有兴趣可以看《Effective C++》条款08:别让异常逃离析构函数。
noexcept修饰符与noexcept操作符
noexcept修饰符是C++11新提供的异常说明符,用于声明一个函数不会抛出异常。编译器能够针对不抛出异常的函数进行优化,另一个显而易见的好处是你明确了某个函数不会抛出异常,别人调用你的函数时就知道不用针对这个函数进行异常捕获。在C++98中关于异常处理的程序中你可能会看到这样的代码:
1 | void func() throw(int ,double ) {...} |
这是throw作为函数异常说明,前者表示func()这个函数可能会抛出int或double类型的异常,后者表示func()函数不会抛出异常。事实上前者很少被使用,在C++11这种做法已经被摒弃,而后者则被C++11的noexcept异常声明所代替:
1 | void func() noexcept {...} |
在C++11中,编译器并不会在编译期检查函数的noexcept声明,因此,被声明为noexcept的函数若携带异常抛出语句还是可以通过编译的。在函数运行时若抛出了异常,编译器可以选择直接调用terminate()函数来终结程序的运行,因此,noexcept的一个作用是阻止异常的传播,提高安全性.
上面一点提到了,我们不能让异常逃出析构函数,因为那将导致程序的不明确行为或直接终止程序。实际上出于安全的考虑,C++11标准中让类的析构函数默认也是noexcept的。 同样是为了安全性的考虑,经常被析构函数用于释放资源的delete函数,C++11也默认将其设置为noexcept。
noexcept也可以接受一个常量表达式作为参数,例如:
1 | void func() noexcept(常量表达式); |
常量表达式的结果会被转换成bool类型,noexcept(true)表示函数不会抛出异常,noexcept(false)则表示函数有可能会抛出异常。故若你想更改析构函数默认的noexcept声明,可以显式地加上noexcept(false)声明,但这并不会带给你什么好处。
定义新的异常
以通过继承和重载 exception 类来定义新的异常。下面的实例演示了如何使用 std::exception 类来实现自己的异常:
1 |
|
异常处理的性能分析
异常处理机制的主要环节是运行期类型检查。当抛出一个异常时,必须确定异常是不是从try块中抛出。异常处理机制为了完善异常和它的处理器之间的匹配,需要存储每个异常对象的类型信息以及catch语句的额外信息。由于异常对象可以是任何类型(如用户自定义类型),并且也可以是多态的,获取其动态类型必须要使用运行时类型检查(RTTI),此外还需要运行期代码信息和关于每个函数的结构。
当异常抛出点所在函数无法解决异常时,异常对象沿着调用链被传递出去,程序的控制权也发生了转移。转移的过程中为了将异常对象的信息携带到程序执行处(如对异常对象的复制构造或者catch参数的析构),在时间和空间上都要付出一定的代价,本身也有不安全性,特别是异常对象是个复杂的类的时候。
异常处理技术在不同平台以及编译器下的实现方式都不同,但都会给程序增加额外的负担,当异常处理被关闭时,额外的数据结构、查找表、一些附加的代码都不会被生成,正是因为如此,对于明确不抛出异常的函数,我们需要使用noexcept进行声明。
函数基础
自动对象
默认情况下,局部变量的生命期局限于所在函数的每次执行期间。只有当定义它的函数被调用时才存在的对象称为自动对象。自动对象在每次调用函数时创建和撤销。该类型局部变量存储在栈上,在动态存储区。
局部变量所对应的自动对象在函数控制经过变量定义语句时创建。如果在定义时提供了初始化,那么每次创建对象时,对象都会被赋予指定的初值。对于未初始化的内置类型局部变量,其初值不确定。当函数调用时结束,自动对象就会被撤销。
形参也是自动对象,其所占存储空间在函数调用时被创建,在函数结束时撤销。
局部静态对象
静态局部变量的意义:
- 分配空间在全局数据栈上
- 作用域只局限于当前的函数范围内(局部)
- 生命周期为整个程序,不会随着当前的函数结束而结束
- 首次初始化时赋值生效,以后的初始化赋值自动跳过
含有可变形参的函数
initializer_list
initializer_list是C++11提供的新类型,定义在同名头文件中。
用于表示某种特定类型的值的数组,和vector一样,initializer_list也是一种模板类型。
1 | template< class T > |
它提供的操作如下:
1 | initializer_list<T> lst; |
需要注意的是,initializer_list对象中的元素永远是常量值,我们无法改变initializer_list对象中元素的值。并且,拷贝或赋值一个initializer_list对象不会拷贝列表中的元素,其实只是引用而已,原始列表和副本共享元素。
和使用vector一样,我们也可以使用迭代器访问initializer_list里的元素
如果想向initializer_list形参中传递一个值的序列,则必须把序列放在一对花括号内:
1 | //expected和actual是string对象 |
而现在c++11添加了initializer_list后,我们可以这样初始化std::vector v = { 1, 2, 3, 4 };
,并且,C++11允许构造函数和其他函数把初始化列表当做参数。
省略符形参
省略符形参函数定义如下:
1 | //整数求和 |
省略符形参 可以接受不同的参数类型,但是使用起来相对复杂:
va_list 在头文件
va_arg(ap,int) 方法用来获取实参列表中的实参,“int”是实参的类型。它从首个实参依次获取,将获取到的结果作为返回值返回。
va_start(ap,x) 就是用来设置首个实参,表示取参数的时候从x的下一个参数开始。(不取x)
va_end(ap) 表示用完ap,即要释放内存。
在获取参数列表的时候,va_list并不能判断实参的总个数,所以需要设置结束参数。
在上例中,判断取得的参数是否为0,如果为0,表示参数结尾:while (var = va_arg(ap, int)) 。
所以在使用函数时,需要以0作为结束参数:
1 | //省略符形参不需要用{},但是要以0做结尾: |
返回类型
返回列表
C++11规定,函数可以返回花括号包围的值的列表
如果函数返回的是内置类型,则花括号包围的列表最多包含一个值,而且该值所占空间不应该大于目标类型的空间
如果函数返回的是类类型,由类本身定义初始值如何使用
1 | vector<string> func() |
尾置返回类型
尾置返回类型是在C++11标准中新增的语法,可以用于任何函数定义中,旨在方便复杂函数的定义。尾置返回类型跟在形参列表后面并以一个->符号开头。为了表示函数真正的返回类型跟在形参列表之后,需要在本应该出现返回类型的地方放置一个auto关键字。
1 | //声明一个返回指向数组的指针的函数 |
使用decltype声明函数返回值类型
有时候我们知道函数的返回值是什么类型,就可以使用decltype来简化函数声明
例如:下面的arrPtr函数返回一个数组指针
1 | int odd[] = { 1,3,5,7,9 }; |
函数指针
函数指针介绍
函数指针指向某种特定类型,函数的类型由其参数及返回类型共同决定,与函数名无关。举例如下:
1 | int add(int nLeft,int nRight);//函数定义 |
该函数类型为int(int,int),要想声明一个指向该类函数的指针,只需用指针替换函数名即可:
1 | int (*pf)(int,int);//未初始化 |
则pf可指向int(int,int)类型的函数。pf前面有*,说明pf是指针,右侧是形参列表,表示pf指向的是函数,左侧为int,说明pf指向的函数返回值为int。则pf可指向int(int,int)类型的函数。而add类型为int(int,int),则pf可指向add函数。
标准C函数指针
函数指针定义
普通函数指针定义int (*pf)(int,int);
使用typedef定义函数指针类型
1 | typedef int (*PF)(int,int); |
函数指针的普通使用
1 | pf = add; |
注意:add类型必须与pf可指向的函数类型完全匹配
函数指针作为形参
1 | //第二个形参为函数类型,会自动转换为指向此类函数的指针 |
形参中有函数指针的函数调用,以fuc为例:
1 | pf = add;//pf是函数指针 |
返回指向函数的指针
使用typedef定义的函数指针类型作为返回参数
1 | PF fuc2(int);//PF为函数指针类型 |
直接定义函数指针作为返回参数
1 | int (*fuc2(int))(int,int);//显示定义 |
说明:按照有内向外的顺序阅读此声明语句。fuc2有形参列表,则fuc2是一个函数,其形参为fuc2(int),fuc2前面有*,所以fuc2返回一个指针,指针本身也包含形参列表(int,int),因此指针指向函数,该函数的返回值为int.
总结:fuc2是一个函数,形参为(int),返回一个指向int(int,int)的函数指针。
C++函数指针
定义
由于C++完全兼容C,则C中可用的函数指针用法皆可用于C++
C++其他函数(指针)定义方式及使用
typedef与decltype组合定义函数类型
1 | typedef decltype(add) add2; |
decltype返回函数类型,add2是与add相同类型的函数,不同的是add2是类型,而非具体函数。
使用方法:
1 | add2* pf;//pf指向add类型的函数指针,未初始化 |
typedef与decltype组合定义函数指针类型
1 | typedef decltype(add)* PF2;//PF2与1.1PF意义相同 |
使用推断类型关键字auto定义函数类型和函数指针
1 | auto pf = add;//pf可认为是add的别名(个人理解) |
函数指针使用
函数指针形参
1 | typedef decltype(add) add2; |
说明:不论形参声明的是函数类型:void fuc2 (add2 add);还是函数指针类型void fuc2 (PF2 add);都可作为函数指针形参声明,在参数传入时,若传入函数名,则将其自动转换为函数指针。
返回指向函数的指针
使用auto关键字
1 | auto fuc2(int)-> int(*)(int,int) //fuc2返回函数指针为int(*)(int,int) |
使用decltype关键字
1 | decltype(add)* fuc2(int)//明确知道返回哪个函数,可用decltype关键字推断其函数类型, |
成员函数指针
普通成员函数指针使用
1 | class A//定义类A |
继承中的函数指针使用
1 | class A |