跳转到内容

C++ 编程 - 第 4 章

来自 Wikibooks,开放世界中的开放书籍

高级特性

[编辑 | 编辑源代码]

模板是一种使代码更具可重用性的方法。简单示例包括创建可以存储任意数据类型的通用数据结构。模板对程序员非常有用,尤其是在与多种 继承运算符重载 相结合时。标准模板库 (STL) 在一个连接的模板框架内提供了许多有用的函数。

由于模板非常具有表现力,因此可以用于除通用编程以外的用途。其中一种用途称为 模板元编程,它是一种在编译时而不是运行时预先评估部分代码的方法。此处进一步讨论仅与模板作为一种通用编程方法相关。

到目前为止,您应该已经注意到,执行相同任务的函数往往看起来很相似。例如,如果您编写了一个打印整数的函数,则必须先声明该整数。这样,可以减少代码中出错的可能性,但是,为了处理您使用的所有不同数据类型而必须创建函数的不同版本,这有点烦人。例如,您可能希望该函数仅打印输入变量,无论该变量是什么类型。为每种可能的输入类型编写不同的函数(double, char *等...)将非常麻烦。这就是模板的用武之地。

模板解决了一些与宏相同的问题,在编译时生成“优化”代码,但受限于 C++ 的严格类型检查。

参数化类型,更广为人知的是模板,允许程序员创建一个可以处理许多不同类型的函数。无需考虑每种数据类型,您只需使用一个任意参数名称,编译器就会用您希望函数使用、操作等的不同数据类型替换它。

  • 模板在编译时使用源代码实例化。
  • 模板是类型安全的。
  • 模板允许用户定义的特化。
  • 模板允许非类型参数。
  • 模板使用“延迟结构约束”。
  • 模板支持混合。
模板语法

模板使用起来相当容易,只需查看语法即可

 template <class TYPEPARAMETER>

(或者,等效地,一些人更喜欢)

 template <typename TYPEPARAMETER>

函数模板

[编辑 | 编辑源代码]

有两种类型的模板。一个函数模板的行为类似于一个可以接受多种不同类型参数的函数。例如,标准模板库包含函数模板max(x, y)返回xy,以较大者为准。max()可以像这样定义

    template <typename TYPEPARAMETER>
    TYPEPARAMETER max(TYPEPARAMETER x, TYPEPARAMETER y)
    {
        if (x < y)
            return y;
        else
            return x;
    }

该模板可以像函数一样调用

    std::cout << max(3, 7);   // outputs 7

编译器通过检查参数来确定这是一个调用max(int, int)实例化函数的版本,其中类型TYPEPARAMETERint.

无论参数xy是整数、字符串还是任何其他类型,只要可以说“x < y”就适用。如果您已定义自己的数据类型,则可以使用运算符重载来定义<对您的类型的含义,从而允许您使用max()函数。虽然这在孤立的示例中可能看起来是一个小的优势,但在 STL 等综合库的背景下,它允许程序员获得新数据类型的广泛功能,只需为它定义几个运算符即可。仅仅定义<允许一种类型与标准sort(), stable_sort()binary_search()算法一起使用;数据结构,如set、堆和关联数组;等等。

作为反例,标准类型complex没有定义<运算符,因为 复数 没有严格的顺序。因此,如果xycomplex值,则max(x, y) 会导致编译错误。同样,其他依赖于<的模板不能应用于complex数据。不幸的是,编译器在历史上为这种类型的错误生成了比较晦涩难懂且无用的错误消息。确保某个对象符合 方法协议 可以缓解这个问题。

{TYPEPARAMETER}只是您要在函数中使用的任意TYPEPARAMETER名称。一些程序员更喜欢只使用T来代替TYPEPARAMETER.

假设您想创建一个能够处理多种数据类型的交换函数...看起来像这样

 template <class SOMETYPE> 
 void swap (SOMETYPE &x, SOMETYPE &y) 
 { 
   SOMETYPE temp = x; 
   x = y; 
   y = temp; 
 }

您在上面看到的函数看起来非常类似于任何其他交换函数,不同之处在于函数定义之前的模板 <class SOMETYPE> 行以及代码中的 SOMETYPE 实例。在您通常需要使用的数据类型名称或类的任何地方,您现在都用您在模板 <class SOMETYPE> 中使用的任意名称替换。例如,如果您使用 **SUPERDUPERTYPE** 而不是 **SOMETYPE**,代码将如下所示

 template <class SUPERDUPERTYPE> 
 void swap (SUPERDUPERTYPE &x, SUPERDUPERTYPE &y) 
 { 
   SUPERDUPERTYPE temp = x; 
   x = y; 
   y = temp; 
 }

如您所见,您可以使用您想要的任何标签作为模板 **TYPEPARAMETER**,只要它不是保留字。

类模板

[edit | edit source]

一个 *类模板* 将相同概念扩展到类。类模板通常用于创建泛型容器。例如,STL 有一个 链表 容器。要创建一个整数的链表,可以写list<int>. 字符串的链表表示为list<string>. 一种list有一组与之关联的标准函数,无论您在方括号中放入什么,这些函数都起作用。

如果您想拥有多个模板 **TYPEPARAMETER**,那么语法将是

 template <class SOMETYPE1, class SOMETYPE2, ...>
模板和类

假设您不想创建简单的模板化函数,而是想将模板用于类,以便该类可以处理多种数据类型。您可能已经注意到,某些类能够接受类型作为参数并根据该类型创建对象的变体(例如 STL 容器类层次结构的类)。这是因为它们被声明为模板,使用类似于下面呈现的语法

 template <class T> class Foo
 {
 public:
   Foo();
   void some_function();
   T some_other_function();

 private:
   int member_variable;
   T parametrized_variable;
 };

定义模板类的成员函数有点像定义函数模板,只是您使用范围解析运算符来指示这是模板类的成员函数。一个重要且非显而易见的细节是在类名之后使用包含参数化类型名的模板运算符。

以下示例通过定义来自上面示例类的函数来描述所需的语法。

 template <class T> Foo<T>::Foo()
 {
   member_variable = 0;
 }

 template <class T> void Foo<T>::some_function()
 {
   cout << "member_variable = " << member_variable << endl;
 }

 template <class T> T Foo<T>::some_other_function()
 {
   return parametrized_variable;
 }

如您可能已经注意到,如果您要声明一个返回参数化类型对象的函数,您只需要使用该参数的名称作为函数的返回类型。

注意
类模板可以用于避免继承中虚拟成员函数的开销。由于类类型在编译时已知,因此类模板不需要虚拟成员函数所需的虚拟指针表。这种区别还允许内联类模板的函数成员。

优点和缺点

[edit | edit source]

模板的一些用法,例如max()函数,以前由类似函数的 预处理器 填充。

// a max() macro
#define max(a,b)   ((a) < (b) ? (b) : (a))

宏和模板都在编译时扩展。宏总是内联扩展;模板也可以作为内联函数扩展,只要编译器认为合适。因此,类似函数的宏和函数模板都没有运行时开销。

然而,模板通常被认为比宏更适合这些用途。模板是类型安全的。模板避免了在大量使用类似函数的宏的代码中发现的一些常见错误。也许最重要的是,模板旨在应用于比宏更大的问题。类似函数的宏的定义必须放在代码的单行逻辑行上。

使用模板有三个主要缺点。首先,历史上许多编译器对模板的支持非常差,因此使用模板会使代码的可移植性降低。其次,几乎所有编译器在模板代码中检测到错误时都会产生令人困惑、无用的错误消息。这使得模板难以开发。第三,模板的每次使用都可能导致编译器生成额外的代码(模板的 *实例化*),因此不加选择地使用模板会导致 代码膨胀,从而导致可执行文件过大。

模板的另一个主要缺点是,无法替换类似 #define 的 max,它与不同类型或函数调用相同地起作用。模板已经取代了使用 #define 来创建复杂函数,但没有取代像 max(a,b) 这样的简单函数。有关尝试为 #define max 创建模板的完整讨论,请参阅 Scott Meyer 为 *C++ Report* 在 1995 年 1 月撰写的论文 "Min, Max and More"

使用模板的最大优势是,复杂的算法可以具有简单的接口,然后编译器使用该接口根据参数的类型选择正确的实现。例如,搜索算法可以利用被搜索容器的属性。这种技术贯穿整个 C++ 标准库。

链接问题

[edit | edit source]

在链接一个基于模板的程序时,该程序由分布在几个文件中的多个模块组成,经常会遇到一个令人费解的情况,即模块的目标代码无法链接,原因是“对(在...中插入模板成员函数名称)的未解析引用”。错误的函数实现就在那里,那么为什么它在目标代码中缺失了呢?让我们停下来思考一下,这怎么可能发生。

假设您创建了一个名为 Foo 的基于模板的类,并将它的声明放在文件 Util.hpp 中,以及另一个名为 Bar 的普通类

 template <class T> Foo
 {
 public: 
   Foo();
   T some_function();
   T some_other_function();
   T some_yet_other_function();
   T member;
 };

 class Bar
 {
   Bar();
   void do_something();
 };

现在,为了遵守所有艺术规则,您创建一个名为 Util.cc 的文件,将所有函数定义(模板或其他)都放在其中

 #include "Util.hpp"

 template <class T> T Foo<T>::some_function()
 {
  ...
 }

 template <class T> T Foo<T>::some_other_function()
 {
  ...
 }

 template <class T> T Foo<T>::some_yet_other_function()
 {
  ...
 }

最后

 void Bar::do_something()
 {
   Foo<int> my_foo;
   int x = my_foo.some_function();
   int y = my_foo.some_other_function();
 }

接下来,您编译模块,没有错误,您很高兴。但是,假设程序中还有另一个(主)模块,位于 MyProg.cc 中

 #include "Util.hpp"	// imports our utility classes' declarations, including the template

 int main()
 {
   Foo<int> main_foo;
   int z = main_foo.some_yet_other_function();
   return 0;
 }

这也干净地编译成目标代码。但是,当您尝试将这两个模块链接在一起时,您会收到一个错误,提示 MyProg.cc 中存在对 Foo<int>::some_yet_other function() 的未定义引用。您已正确定义模板成员函数,那么问题出在哪里?

如您所知,模板在编译时实例化。这有助于避免代码膨胀,代码膨胀将是为所有可能的类型生成所有可能的类和函数变体的结果。因此,当编译器处理 Util.cc 代码时,它看到 Foo 类的唯一变体是 Foo<int>,唯一需要的函数是

 int Foo<int>::some_function();
 int Foo<int>::some_other_function();

Util.cc 中的代码不需要 Foo 或其方法的任何其他变体存在,因此编译器只生成了这些代码。目标代码中没有 some_yet_other_function() 的实现,就像没有

 double Foo<double>::some_function();

 string Foo<string>::some_function();

MyProg.cc 代码编译没有错误,因为 Foo 使用的成员函数在 Util.hpp 头文件中正确声明,并且预期它将在链接时可用。但它不存在,因此出现了错误,如果您是模板新手并且开始在代码中寻找错误,就会带来很多麻烦,而您的代码实际上是完全正确的。

解决方案在一定程度上取决于编译器。对于 GNU 编译器,尝试使用 -frepo 标志进行实验,还可以阅读 “info gcc” 中与模板相关的部分(节点“模板实例化”:“模板在哪里?”)可能会有所启发。在 Borland 中,据说链接器选项中有一个选项可以激活“智能”模板,专门针对这类问题。

您还可以尝试另一种方法,称为显式实例化。您所做的是在包含模板的模块中创建一些虚拟代码,它创建模板类的所有变体并调用您知道在其他地方需要的其所有成员函数变体。显然,这需要您了解代码中需要哪些变体。在我们这个简单的例子中,这将如下所示

1. 将以下类声明添加到 Util.hpp

 class Instantiations
 {
 private:
   void Instantiate();
 };

2. 将以下成员函数定义添加到 Util.cc

 void Instantiations::Instantiate()
 {
   Foo<int> my_foo;
   my_foo.some_yet_other_function();
   // other explicit instantiations may follow
 }

当然,您永远不需要实际实例化 Instantiations 类,也不需要调用其任何方法。它们在代码中存在的事实会使编译器生成所需的模板的所有变体。现在目标代码将能够成功链接。

还有一种解决方案,虽然不是最优雅的,但仍然可行。只需将所有模板函数的定义代码移到 Util.hpp 头文件。这并不漂亮,因为头文件用于声明,而实现应该在其他地方定义,但它在这种情况下确实有效。在编译 MyProg.cc(以及包含 Util.hpp 的任何其他模块)代码时,编译器将生成所有需要的模板变体,因为定义已经可用。

模板元编程概述

[edit | edit source]

模板元编程 (TMP) 指的是使用 C++ 模板系统在代码中执行编译时计算。在大多数情况下,可以将其视为“用类型编程”——因为 TMP 主要使用的“值”是特定的 C++ 类型。使用类型作为计算的基本对象允许将类型推断规则的全部功能用于通用计算。

编译时编程

[edit | edit source]

预处理器允许在编译时执行某些计算,这意味着在代码编译完成后,决策已经做出,可以从编译的可执行文件中省略。以下是一个非常牵强的例子

#define myvar 17

#if myvar % 2
   cout << "Constant is odd" << endl;
#else
   cout << "Constant is even" << endl;
#endif

这种构造除了条件包含平台特定代码之外,没有太多应用。特别是没有办法迭代,因此不能用于通用计算。使用模板进行编译时编程的工作方式类似,但功能要强大得多,实际上它是 *图灵完备* 的。

特征类是简单形式的模板元编程的一个熟悉示例:给定类型的输入,它们将与该类型关联的属性计算为输出(例如,std::iterator_traits<> 接受迭代器类型作为输入,并计算属性,例如迭代器的 difference_type、value_type 等等)。

模板元编程的本质

[edit | edit source]

模板元编程更接近函数式编程,而不是普通的惯用 C++。 这是因为“变量”都是不可变的,因此需要使用递归而不是迭代来处理集合中的元素。 这对学习 TMP 的命令式程序员来说增加了另一层挑战:除了学习它的机制外,他们还必须学会用不同的方式思考。

模板元编程的局限性

[edit | edit source]

由于模板元编程是从模板系统的意外使用中发展而来,因此它经常很繁琐。 通常很难让维护人员清楚地了解代码的意图,因为所使用代码的自然含义与代码的用途截然不同。 处理此问题的最有效方法是依赖习惯用法;如果你想成为一名高效的模板元程序员,你必须学会识别常见的习惯用法。

它还挑战了旧编译器的能力;一般来说,2000 年左右或之后的编译器能够处理大多数实用的 TMP 代码。 即使编译器支持它,编译时间也可能非常长,并且在编译失败的情况下,错误消息通常难以理解。 有关模板实例化调试器,请参阅 TempLight

某些编码标准甚至可能禁止模板元编程,至少在 Boost 等第三方库之外是如此。

TMP 的历史

[edit | edit source]

从历史上看,TMP 是一种意外;在标准化 C++ 语言的过程中,人们发现它的模板系统碰巧是图灵完备的,即原则上能够计算任何可计算的东西。 这方面的第一个具体证明是由 Erwin Unruh 编写的程序,该程序计算了素数,尽管它实际上没有完成编译:素数列表是编译器在尝试编译代码时生成的错误消息的一部分。 [1] 从那时起,TMP 已经有了相当大的进步,现在已成为 C++ 中库构建者的实用工具,但其复杂性意味着它通常不适用于大多数应用程序或系统编程环境。

#include <iostream>

template <int p, int i>
class is_prime {
public:
	enum { prim = ( (p % i) && is_prime<p, i - 1>::prim ) }; 
};

template <int p>
class is_prime<p, 1> {
public:
	enum { prim = 1 };
};

template <int i>
class Prime_print {      // primary template for loop to print prime numbers
public:
	Prime_print<i - 1> a; 
	enum { prim = is_prime<i, i - 1>::prim };
	void f() {
		a.f();
		if (prim)
		{
			std::cout << "prime number:" << i << std::endl;
		}
	} 
};

template<>
class Prime_print<1> {   // full specialization to end the loop
public:
	enum { prim = 0 };
	void f() {}
};

#ifndef LAST 
#define LAST 18 
#endif

int main()
{
   Prime_print<LAST> a; 
   a.f(); 
}

构建块

[edit | edit source]

TMP 中的“变量”并不是真正的变量,因为它们的值不能改变,但你可以拥有命名的值,你可以像在普通编程中使用变量一样使用它们。 在使用类型编程时,命名值是类型定义

struct ValueHolder
{
   typedef int value;
};

您可以将其视为“存储”int 类型,以便可以在 value 名称下访问它。 整数值通常存储为枚举中的成员

struct ValueHolder
{
   enum { value = 2 };
};

这同样存储了值,以便可以在 value 名称下访问它。 这些示例中的任何一个本身都没有用,但它们构成了大多数其他 TMP 的基础,因此它们是需要意识到的重要模式。

函数

[edit | edit source]

函数将一个或多个输入参数映射到输出值。 TMP 与此相似的模板是模板类

template<int X, int Y>
struct Adder
{
   enum { result = X + Y };
};

这是一个将两个参数相加并将结果存储在 result enum 成员中的函数。 您可以在编译时使用类似 Adder<1, 2>::result 的内容调用它,这将在编译时扩展并与程序中的文字 3 完全相同。

分支

[edit | edit source]

条件分支可以通过编写模板类的两个备用特化来构造。 编译器将选择适合所提供类型的那个,然后可以访问实例化类中定义的值。 例如,考虑以下部分特化

template<typename X, typename Y>
struct SameType
{
   enum { result = 0 };
};

template<typename T>
struct SameType<T, T>
{
   enum { result = 1 };
};

这告诉我们它实例化的两个类型是否相同。 这似乎并不太有用,但它可以识别可能隐藏类型是否相同的类型定义,并且可以用于模板代码中的模板参数。 你可以像这样使用它

if (SameType<SomeThirdPartyType, int>::result)
{
   // ... Use some optimised code that can assume the type is an int
}
else
{
   // ... Use defensive code that doesn't make any assumptions about the type
}

上面的代码不太惯用:因为类型可以在编译时识别,所以 if() 块将始终具有一个微不足道的条件(它将始终解析为 if (1) { ... }if (0) { ... })。 然而,这确实说明了可以实现的那种事情。

递归

[edit | edit source]

由于在使用模板进行编程时没有可变变量可用,因此无法遍历一系列值。 可以在标准 C++ 中使用迭代来完成的任务必须用递归重新定义,即一个函数调用自身。 这通常采用模板类的形式,其输出值递归地引用自身,以及一个或多个特化,它们为防止无限递归提供固定值。 你可以将其视为上面描述的函数和条件分支思想的结合。

计算阶乘自然地以递归方式完成:,对于 。 在 TMP 中,这对应于一个类模板“阶乘”,其一般形式使用递归关系,其特化终止递归。

首先,一般(非特化)模板表示 factorial<n>::value 由下式给出n*factorial<n-1>::value:

template <unsigned n>
struct factorial
{
  enum { value = n * factorial<n-1>::value };
};

接下来,零的特化表示 factorial<0>::value 评估为 1

template <>
struct factorial<0>
{
  enum { value = 1 };
};

现在,一些在编译时“调用”阶乘模板的代码

 
int main() {
  // Because calculations are done at compile-time, they can be
  // used for things such as array sizes.
  int array[ factorial<7>::value ];
}

观察到 factorial<N>::value 成员是用 factorial<N> 模板表示的,但这不可能无限期地继续:每次评估时,它都会使用越来越小的(但非负)数字调用自身。 这最终必须达到零,此时特化开始,评估不会再递归。

示例:编译时“If”

[edit | edit source]

以下代码定义了一个名为“if_”的元函数;这是一个类模板,可用于根据编译时常量在两种类型之间进行选择,如下面的main中所示

template <bool Condition, typename TrueResult, typename FalseResult>
class if_;

template <typename TrueResult, typename FalseResult>
struct if_<true, TrueResult, FalseResult>
{
  typedef TrueResult result;
};

template <typename TrueResult, typename FalseResult>
struct if_<false, TrueResult, FalseResult>
{
  typedef FalseResult result;
};

int main()
{
  typename if_<true, int, void*>::result number(3);
  typename if_<false, int, void*>::result pointer(&number);

   typedef typename if_<(sizeof(void *) > sizeof(uint32_t)), uint64_t, uint32_t>::result
      integral_ptr_t;
	  
   integral_ptr_t converted_pointer = reinterpret_cast<integral_ptr_t>(pointer);
}

在第 18 行,我们使用真值对if_模板进行求值,因此使用的类型是提供的第一个值。因此整个表达式if_<true, int, void*>::result求值为int。类似地,在第 19 行,模板代码求值为void *。这些表达式与在源代码中将类型写为文字值的效果完全相同。

第 21 行是它开始变得巧妙的地方:我们定义了一种类型,它取决于与平台相关的sizeof表达式的值。在指针为 32 位或 64 位的平台上,这将选择正确的类型编译时没有任何修改,也无需预处理器宏。选择类型后,就可以像使用任何其他类型一样使用它。

注意
这段代码只是模板元编程功能的说明,并非旨在说明使用指针的良好跨平台实践。

为了比较,这个问题在 C90 中最好按如下方式解决

# include <stddef.h>
typedef size_t integral_ptr_t;
typedef int the_correct_size_was_chosen [sizeof (integral_ptr_t) >= sizeof (void *)? 1: -1];

碰巧的是,库定义的类型size_t应该是任何平台上这个问题的正确选择。为了确保这一点,第 3 行用作编译时检查,以查看所选类型是否实际上足够大;如果不是,数组类型the_correct_size_was_chosen将定义为负长度,导致编译时错误。在 C99 中,<stdint.h>可能会定义类型intptr_tuintptr_t

调试 TMP

[edit | edit source]

截至 2017 年,这无法以任何有意义的方式完成。通常,抛弃模板并重新开始比试图破译从模板元程序中的单个字节拼写错误产生的编译器输出的错综复杂的迷宫更容易。

考虑 C++ 标准化委员会秘书 Herb Sutter 的以下观察结果

   Herb: Boost.Lambda, is a marvel of engineering… and it worked very well if … if you spelled it exactly right the first time, and didn’t mind a 4-page error spew that told you almost nothing about what you did wrong if you spelled it a little wrong. …
   Charles: talk about giant error messages… you could have templates inside templates, you could have these error messages that make absolutely no sense at all.
   Herb: oh, they are baroque.

来源:https://ofekshilon.com/2012/09/01/meta-programming-is-still-evil/

"结构化" TMP 的约定

[edit | edit source]
Clipboard

待办事项
描述一些用于“结构化”TMP 的约定。

标准模板库 (STL)

[edit | edit source]

标准模板库 (STL) 是C++ 标准库的一部分,它提供算法、容器、迭代器和其他基本组件的集合,这些组件以模板、类和函数的形式实现,对于扩展 C++ 的功能和标准化至关重要。STL 的主要目标是通过强调性能和正确性来提供改进的实现标准化。

不用再担心您的数组是否需要容纳 257 条记录,也不用再担心字符串缓冲区溢出,您可以享受vectorstring,它们会自动扩展以包含更多记录或字符。例如,vector 就像数组一样,不同的是 vector 的大小可以扩展以容纳更多单元格,或者在需要更少单元格时缩小。必须牢记,STL 不会与 OOP 冲突,但本身不是面向对象的;尤其是它没有使用运行时多态性(即没有虚拟函数)。

STL 的真正力量不在于它的容器类,而在于它是一个框架,它通过迭代器结合了算法和数据结构,允许对高级算法进行通用实现,从而高效地处理各种形式的数据。举个简单的例子,同一个std::copy函数可用于将一个数组中的元素复制到另一个数组中,或者复制文件的字节,或者将“类似这样的文本”中的空格分隔的单词复制到std::vector<std::string>之类的容器中。

 // std::copy from array a to array b
 int a[10] = { 3,1,4,1,5,9,2,6,5,4 };
 int b[10];
 std::copy(&a[0], &a[9], b);

 // std::copy from input stream a to an arbitrary OutputIterator
 template <typename OutputIterator>
 void f(std::istream &a, OutputIterator destination) {
   std::copy(std::istreambuf_iterator<char>(a),
             std::istreambuf_iterator<char>(),
             destination);
 }

 // std::copy from a buffer containing text, inserting items in
 // order at the back of the container called words.
 std::istringstream buffer("text like this");
 std::vector<std::string> words;
 std::copy(std::istream_iterator<std::string>(buffer),
           std::istream_iterator<std::string>(),
           std::back_inserter(words));
 assert(words[0] == "text");
 assert(words[1] == "like");
 assert(words[2] == "this");

历史

[edit | edit source]
Alexander Stepanov
亚历山大·斯特潘诺夫

C++ 标准库包含 STL 的一部分(由SGI/惠普公司发布为软件库)。C++ 标准模板库的主要实现者是亚历山大·斯特潘诺夫

如今,我们称 STL 为被纳入 C++ 标准的内容。ISO C++ 不会指定头文件内容,并且允许在头文件中或在真正的库中实现 STL。

注意
亚历山大·斯特潘诺夫在一次采访中表示,他最初希望 STL 中的所有辅助函数都可见,但从政治上讲这是不可能的,尤其是堆函数。Bjarne 确实将 STL 中的组件数量减少了一半,以便将其纳入标准。

编译器已经包含一个作为 C++ 标准的一部分的实现(例如,MS Visual Studio 使用 Dinkum STL)。所有实现都必须符合标准关于功能和行为的要求,但程序在所有主要硬件实现、操作系统和编译器之间的一致性也取决于 STL 实现的可移植性。它们还可以提供扩展的功能或针对不同的设置进行优化。

STL 有许多不同的实现,它们都基于语言标准,但仍然彼此不同,对程序员来说是透明的,使代码库的专业化和快速演变成为可能。许多开源实现可用,可以用来参考。

STL 实现列表。

注意
将功能分隔成多个部分有很多好处,一些开发人员出于多种原因积极避免使用某些语言特性。C++ 允许程序员选择表达方式,控制开发范式,而不是受到更高抽象级别的限制。

容器

[edit | edit source]

我们将在本书的这一部分中讨论的容器是标准命名空间 (std::) 的一部分。它们都起源于 STL 的原始 SGI 实现。

注意
在选择容器时,您应该牢记它们的不同之处,这将帮助您生成更高效的代码。另请参阅本书的优化部分,了解在正确的容器中使用正确的数据

序列容器

[edit | edit source]
序列 - 比数组更易于使用

序列类似于 C 数组,但更易于使用。Vector 通常是学习的第一个序列。其他序列,列表和双端队列,与 vector 类似,但在某些特殊情况下更高效。(它们的行为在迭代器在容器更改时有效性的重要方面也存在差异;迭代器有效性是使用 C++ 中的容器时一个重要的但有点高级的概念。)

  • vector - "一个易于使用的数组"
  • list - 实际上是一个双向链表
  • deque - 双端队列(正确读作“deck”,常被误读为“dee-queue”)
vector
[edit | edit source]

vector 本身是一个类模板,它是一个序列容器,允许您轻松创建几乎任何数据类型的元素(每个实例一个类型)或使用它时程序中的对象。vector 类为您处理大多数内存管理。

由于向量包含连续的元素,因此它是替换旧的 C 样式数组的理想选择,在需要存储数据的情况下,并且在需要将动态数据存储为在程序执行期间大小会改变的数组的情况下(旧的 C 样式数组无法做到这一点)。 但是,与静态数组相比,向量会产生非常小的开销(取决于编译器的质量),并且不能通过初始化列表进行初始化。

注意

众所周知,由于 SECURE_SCL 标志,在使用 MSVC 编译器时,向量速度很慢,该标志默认情况下即使在优化构建中也会强制进行边界检查。

访问向量的成员或追加元素需要固定时间,无论向量有多大,而查找向量元素中的特定值或将元素插入向量需要的时间与它在向量中的位置成正比(取决于大小)。

注意

如果您创建了一个向量,可以使用连续的指针访问它的数据。

  std::vector<type> myvector(8);
  type * ptr = &myvector[0];
  ptr[0], ptr[7]; // access the first and last objects in myvector

此信息存在于 INCITS/ISO/IEC 14882-2003 中,但在 1998 年版本的 C++ 标准中没有得到适当的文档记录。
请注意,ptr[i] 比 myvector.at(i) 快,因为没有执行错误检查。 注意该指针的有效时间。 向量的连续性在与 C 代码交互时最常很重要。

您还应该记住,std::vector<T>::iterator 可能不是指针;使用迭代器是访问容器的最安全模式,但安全总是有性能成本。

示例
Clipboard

待办事项
是否应该将此拆分为 2 个示例,一个“旧的 C 样式数组”示例和一个“新的 C++ STL 向量”示例?


/*
David Cary 2009-03-04
quick demo for wikibooks
*/

#include <iostream>
#include <vector>
using namespace std;

vector<int> pick_vector_with_biggest_fifth_element(vector<int> left,vector<int> right)
{
    if(left[5] < right[5])
    {
        return( right );
    }
    // else
    return left ;
}

int* pick_array_with_biggest_fifth_element(int * left,int * right)
{
    if(left[5] < right[5])
    {
        return( right );
    }
    // else 
    return left ;
}

int vector_demo(void)
{
    cout << "vector demo" << endl;
    vector<int> left(7);
    vector<int> right(7);

    left[5] = 7;
    right[5] = 8;
    cout << left[5] << endl;
    cout << right[5] << endl;
    vector<int> biggest(pick_vector_with_biggest_fifth_element( left, right ) );
    cout << biggest[5] << endl;

    return 0;
}

int array_demo(void)
{
    cout << "array demo" << endl;
    int left[7];
    int right[7];

    left[5] = 7;
    right[5] = 8;
    cout << left[5] << endl;
    cout << right[5] << endl;
    int * biggest =
        pick_array_with_biggest_fifth_element( left, right );
    cout << biggest[5] << endl;

    return 0;
}

int main(void)
{
    vector_demo();
    array_demo();
}
成员函数

vector 类模拟了容器 概念,这意味着它具有begin()end()size()max_size()empty()swap() 方法。

注意
由于大多数向量(或双端队列)实现通常会为将来增长预留一些额外的内部存储。当内存资源成为一个因素时,在改变标准向量大小(或释放使用的内存)时,首选swap()方法。

  • 信息
    • vector::front - 返回对向量第一个元素的引用。
    • vector::back - 返回对向量最后一个元素的引用。
    • vector::size - 返回向量中的元素数量。
    • vector::empty - 如果向量没有元素,则返回 true。
  • 标准操作
    • vector::insert - 将元素插入向量(单个 & 范围),将后面的元素向上移动。效率低下。
    • vector::push_back - 将元素追加(插入)到向量的末尾,如果需要,为其分配内存。摊销 O(1) 时间。
    • vector::erase - 从向量中删除元素(单个 & 范围),将后面的元素向下移动。效率低下。
    • vector::pop_back - 删除向量的最后一个元素(可能减少容量 - 通常不会减少,但这取决于特定的 STL 实现)。摊销 O(1) 时间。
    • vector::clear - 删除所有元素。但是请注意,如果数据元素是指向动态创建的内存的指针(例如,使用了new 运算符),则内存不会被释放。
  • 分配/大小修改
    • vector::assign - 用于删除原始向量并将指定元素复制到一个空目标向量
    • vector::reserve - 更改向量的容量(分配更多内存),如果需要。在许多 STL 实现中,容量只能增长,永远不会减少。
    • vector::capacity - 返回向量的当前容量(已分配的内存)。
    • vector::resize - 更改向量的大小。
  • 迭代
    • vector::begin - 返回一个指向向量开始遍历的迭代器。
    • vector::end - 返回一个指向向量末尾的迭代器。
    • vector::at - 返回对向量中指定位置的数据元素的引用,具有边界检查。

注意

在处理容器时,重要的是要记住 capacity()、size() 和 empty() 的区别。

vector<int> v;
for (vector<int>::iterator it = v.begin(); it!=v.end(); ++it/* increment operand is used to move to next element*/) {
    cout << *it << endl;
}
vector::迭代器
[edit | edit source]

std::vector<T> 提供随机访问迭代器;与所有容器一样,迭代器的主要访问方式是通过 begin() 和 end() 成员函数。这些函数针对常量和非常量容器进行了重载,分别返回类型为 std::vector<T>::const_iterator 和 std::vector<T>::iterator 的迭代器。


Clipboard

待办事项
添加缺失的数据


向量示例
[edit | edit source]
 /* Vector sort example */
 #include <iostream>
 #include <vector>
 
 int main()
 {
         using namespace std;
  
         cout << "Sorting STL vector, \"the easier array\"... " << endl;
         cout << "Enter numbers, one per line.  Press ctrl-D to quit." << endl;
 
         vector<int> vec; 
         int tmp;
         while (cin>>tmp) {
                 vec.push_back(tmp);
         }
 
         cout << "Sorted: " << endl;
         sort(vec.begin(), vec.end());   
         int i = 0;
         for (i=0; i<vec.size(); i++) {
                 cout << vec[i] << endl;;
         }
 
         return 0;
 }

sort的调用实际上调用了函数模板的实例化std::sort,它将在由两个随机访问迭代器指定的任何半开范围上工作。

如果您想使上面的代码更“STLish”,您可以以以下方式编写此程序。

 #include <iostream>
 #include <vector>
 #include <algorithm>
 #include <iterator>
 
 int main()
 {
        using namespace std;
 
        cout << "Sorting STL vector, \"the easier array\"... " << endl;
        cout << "Enter numbers, one per line.  Press ctrl-D to quit." << endl;
 
        istream_iterator<int> first(cin);
        istream_iterator<int> last;
        vector<int> vec(first, last);
 
        sort(vec.begin(), vec.end());
 
        cout << "Sorted: " << endl;
 
        copy(vec.begin(), vec.end(), ostream_iterator<int>(cout, "\n"));
 
        return 0;
 }
链表
[edit | edit source]

STL 提供了一个名为list(标准命名空间(std::)的一部分)的类模板,它实现了一个非侵入式双向链表。链表可以在常数时间内在中间插入或删除元素,但没有随机访问。std::list 的一个有用特性是,只要该项保留在列表中,对插入到列表中的项目的引用、指针和迭代器就保持有效。

注意
考虑使用向量而不是列表来获得更好的缓存一致性,并避免“交换导致死亡”,请参见优化部分,了解有关使用正确的数据在正确的容器中的信息。

列表示例
[edit | edit source]
 /* List example - insertion in a list */
 #include <iostream>
 #include <algorithm>
 #include <iterator>
 #include <list>

 void print_list(std::list<int> const& a_filled_list)
 {
        using namespace std;

        ostream_iterator<int> out(cout, "  ");
        copy(a_filled_list.begin(), a_filled_list.end(), out);
 }

 int main()
 {
	 std::list<int> my_list;

	 my_list.push_back(1);
	 my_list.push_back(10);
	 print_list(my_list); //print : 1 10

	 std::cout << std::endl;

	 my_list.push_front(45);
	 print_list(my_list); //print : 45 1 10

	 return 0;
 }
Clipboard

待办事项
添加缺失的数据


关联容器(键和值)

[edit | edit source]

这种类型的容器使用键值指向容器中的每个元素,从而简化了程序员搜索容器。您不必逐个遍历数组或向量的元素来查找特定的元素,而只需查询 people["tero"] 即可。与向量和其他容器一样,关联容器可以扩展以容纳任意数量的元素。

映射和多映射
[edit | edit source]

mapmultimap 是关联容器,它们将键/值对管理为元素,如上所示。每个容器的元素将使用实际的键作为排序标准自动排序。这两者之间的区别在于映射不允许重复,而多映射允许重复。

  • map - 唯一键
  • multimap - 可以多次使用相同的键
  • set - 唯一键是值
  • multiset - 键是值,可以多次使用相同的键
  /* Map example - character distribution  */
  #include <iostream>
  #include <map>
  #include <string>
  #include <cctype>
 
  using namespace std;
 
  int main()
  {
          /* Character counts are stored in a map, so that 
           * character is the key.
           * Count of char a is chars['a']. */
          map<char, long> chars;
 
          cout << "chardist - Count character distributions" << endl;
          cout << "Type some text. Press ctrl-D to quit." << endl;
          char c;
          while (cin.get(c)) {
                  // Upper A and lower a are considered the same 
                  c=tolower(static_cast<unsigned char>(c));
                  chars[c]=chars[c]+1; // Could be written as ++chars[c];
          }
 
          cout << "Character distribution: " << endl;
            
          string alphabet("abcdefghijklmnopqrstuvwxyz");
          for (string::iterator letter_index=alphabet.begin(); letter_index != alphabet.end(); letter_index++) {
                  if (chars[*letter_index] != 0) {
                          cout << char(toupper(*letter_index))
                               << ":" << chars[*letter_index]
                               << "\t" << endl; 
                  }
          }
          return 0;
  }

容器适配器

[edit | edit source]
  • stack - 后进先出 (LIFO)
  • queue - 先进先出 (FIFO)
  • 优先队列

迭代器

[edit | edit source]

C++ 的迭代器是 STL 的基础之一。其他语言中也存在迭代器,但 C++ 使用了一种不同寻常的迭代器形式,具有优缺点。

在 C++ 中,迭代器是一个概念,而不是一个特定类型,它们是指针的泛化,是用于容器的抽象。迭代器根据遍历属性等属性进一步细分。

迭代器的基本思想是提供一种方法来遍历一些对象集合的概念。

一些(重叠的)迭代器类别是

  • 单一迭代器
  • 无效迭代器
  • 随机访问迭代器
  • 双向迭代器
  • 前向迭代器
  • 输入迭代器
  • 输出迭代器
  • 可变迭代器

一对迭代器[begin, end) 用于定义一个半开区间,其中包含从beginend 标识的元素,但不包括由end 标识的元素。作为特殊情况,对于任何有效的迭代器 x,半开区间[x, x) 为空。

注意
区间表示法可能有所不同,含义是表达区间限制的包含或排除。另一种常见的表示法是 [begin, end[(表示 begin 是区间的一部分,end 不是)。

C++ 中最原始的迭代器示例(可能是其语法的灵感来源)是内置指针,它们通常用于迭代数组中的元素。

迭代容器

[edit | edit source]

访问(但不修改)容器中的每个元素类型C<T>使用迭代器。

 for (
      typename C<T>::const_iterator iter = group.begin();
      iter != group.end();
      ++iter
     )
 {
     T const &element = *iter;
 
     // access element here
 }

请注意 typename 的使用。它告诉编译器 'const_iterator' 是一个类型,而不是一个静态成员变量。(它仅在模板代码中是必需的,事实上,在 C++98 中,它在普通的非模板代码中是无效的。这可能在下一版 C++ 标准中发生变化,以便上述 typename 始终允许。)

修改容器中的每个元素类型C<T>使用迭代器。

 for (
      typename C<T>::iterator iter = group.begin();
      iter != group.end();
      ++iter
     )
 {
     T &element = *iter;
 
     // modify element here
 }

在迭代容器时修改容器本身,一些容器(如 vector)需要谨慎,以确保迭代器不会失效,并最终指向一个无效的元素。例如,而不是

  for (i = v.begin(); i != v.end(); ++i) {
    ...
    if (erase_required) {
      v.erase(i);
    }
  }

  for (i = v.begin(); i != v.end(); ) {
    ...
    if (erase_required) {
        i = v.erase(i);
    } else {
        ++i;
    }
  }

erase()成员函数返回下一个有效的迭代器,或者end(),从而结束循环。请注意,++ierase()被调用时执行。

仿函数

[edit | edit source]

仿函数或函数对象,是一个具有 operator () 的对象。仿函数的重要性在于它们可以在许多可以使用 C++ 函数的上下文中使用,同时还能够维护状态信息。除了迭代器之外,仿函数是 STL 利用的最基本的概念之一。

STL 提供了许多预构建的仿函数类;例如,std::less 通常用于为需要确定两个对象中哪个对象在“前面”的算法指定默认比较函数。

 #include <vector>
 #include <algorithm>
 #include <iostream>

 // Define the Functor for AccumulateSquareValues
 template<typename T>
 struct AccumulateSquareValues
 {
     AccumulateSquareValues() : sumOfSquares()
     {
     }
     void operator()(const T& value)
     {
         sumOfSquares += value*value;
     }
     T Result() const
     {
         return sumOfSquares;
     }
     T sumOfSquares;
 };

 std::vector<int> intVec;
 intVec.reserve(10);
 for( int idx = 0; idx < 10; ++idx )
 {
     intVec.push_back(idx);
 }
 AccumulateSquareValues<int> sumOfSquare = std::for_each(intVec.begin(), 
                                                         intVec.end(), 
                                                         AccumulateSquareValues<int>() );
 std::cout << "The sum of squares for 1-10 is " << sumOfSquare.Result() << std::endl;

 // note: this problem can be solved in another, more clear way:
 // int sum_of_squares = std::inner_product(intVec.begin(), intVec.end(), intVec.begin(), 0);

算法

[edit | edit source]

STL 还提供了一些有用的算法,以模板函数的形式提供,这些函数在迭代器概念的帮助下,可以操作 STL 容器(或派生类)。

STL 算法不限于 STL 容器,例如

#include <algorithm>

  int array[10] = { 2,3,4,5,6,7,1,9,8,0 };

  int* begin = &array[0];
  int* end = &array[0] + 10;

  std::sort(begin, end);// the sort algorithm will work on a C style array
_if 后缀
_copy 后缀
  • 非修改算法
  • 修改算法
  • 移除算法
  • 变异算法
  • 排序算法
  • 排序范围算法
  • 数值算法

排列

[edit | edit source]
Clipboard

待办事项
完整


[edit | edit source]
stable_sort
[edit | edit source]
partial_sort
[edit | edit source]
最小值和最大值
[edit | edit source]

标准库提供函数模板 minmax,它们分别返回两个参数的最小值和最大值。每个函数都提供了一个重载,允许您自定义比较值的方式。

template<class T>
const T& min(const T& a, const T& b);

template<class T, class Compare>
const T& min(const T& a, const T& b, Compare c);

template<class T>
const T& max(const T& a, const T& b);

template<class T, class Compare>
const T& max(const T& a, const T& b, Compare c);

如何使用 Compare 类型参数的示例

 #include <iostream>
 #include <algorithm>
 #include <string>

 class Account
 {
	 private :
         std::string owner_name;
	 int credit;
	 int potential_credit_transfer;

	 public :
	 Account(){}
	 Account(std::string name, int initial_credit, int initial_credit_transfer) :
	 	 owner_name(name),
	         credit(initial_credit),
	         potential_credit_transfer(initial_credit_transfer)
	 {}

	 bool operator<(Account const& account) const { return credit < account.credit; }

	 int potential_credit() const { return credit + potential_credit_transfer; }

	 std::string const& owner() const { return owner_name; }
 };

 struct CompareAccountCredit
 {
	 bool operator()(Account const& account1, Account const& account2) const 
         { return account1 < account2; }
 };

 struct CompareAccountPotentialCredit
 {
	 bool operator()(Account const& account1, Account const& account2) const 
         { return account1.potential_credit() < account2.potential_credit(); }
 };

 int main()
 {
	 Account account1("Dennis Ritchie", 1000, 250), account2("Steeve Jobs", 500, 10000), 
         result_comparison;

	 result_comparison = std::min(account1, account2, CompareAccountCredit());
	 std::cout << "min credit of account is : " + result_comparison.owner() << std::endl;

	 result_comparison = std::min(account1, account2, CompareAccountPotentialCredit());
	 std::cout << "min potential credit of account is : " + result_comparison.owner() << std::endl;

	 return 0;
 }
Clipboard

待办事项
这需要一个使用 Compare 类型参数的示例

分配器

[edit | edit source]

分配器由标准 C++ 库(尤其是 STL)使用,以允许对内存分配策略进行参数化。

分配器的话题有点晦涩,大多数应用程序软件开发人员可以安全地忽略它。所有允许指定分配器的标准库构造都具有默认分配器,如果用户没有指定分配器,则使用该分配器。

如果一段代码的内存使用方式不寻常,以至于如果使用通用默认分配器会导致性能问题,则自定义分配器可能会有用。在其他情况下,默认分配器也不合适,例如在使用全局运算符 new 和 delete 的替代品的实现中使用标准容器时。

智能指针

[edit | edit source]

使用原始指针来存储分配的数据,然后在析构函数中清理它们,通常被认为是一个非常糟糕的想法,因为它容易出错。即使将分配的数据临时存储在原始指针中,然后在完成操作后将其删除,也应避免这样做。例如,如果您的代码抛出异常,则可能难以正确捕获异常并删除所有分配的对象。

智能指针可以通过使用编译器和语言语义来确保指针内容在指针本身超出作用域时自动释放,从而减轻这种麻烦。

#include <memory>
class A
{
public:
        virtual ~A() {}
	virtual char val() = 0;
};

class B : public A
{
public:
	virtual char val() { return 'B'; }
};

A* get_a_new_b()
{
	return new B();
}

bool some_func()
{
	bool rval = true;
	std::auto_ptr<A> a( get_a_new_b() );
	try {
		std::cout << a->val();
	} catch(...) {
		if( !a.get() ) {
			throw "Memory allocation failure!";
		}
		rval = false;
	}
	return rval;
}


Clipboard

待办事项
可以注意分配器使用的 rebind 模式是使用模板模板参数的替代方法。从历史上看,STL 的大部分开发是在 C++ 编译器支持模板模板参数之前进行的。有趣的是,现代模板元编程风格推广了一种类似于 rebind 的方法,而不是使用模板模板参数。


语义

[edit | edit source]

auto_ptr 具有严格所有权的语义,这意味着 auto_ptr 实例是负责对象生命周期的唯一实体。如果复制 auto_ptr,源将丢失引用。例如

#include <iostream>
#include <memory>
using namespace std;
 
int main(int argc, char **arv)
{
    int *i = new int;
    auto_ptr<int> x(i);
    auto_ptr<int> y;
    
    y = x;
    
    cout << x.get() << endl;
    cout << y.get() << endl;
}

此代码将为第一个 auto_ptr 对象打印一个 NULL 地址,为第二个对象打印一个非 NULL 地址,表明源对象在赋值 (=) 期间丢失了引用。示例中的原始指针 i 不应删除,因为它将由拥有引用的 auto_ptr 删除。事实上,new int 可以直接传递给 x,从而消除了对 i 的需求。

请注意,由 auto_ptr 指向的对象使用 operator delete 销毁;这意味着您应该只将 auto_ptr 用于使用 operator new 获得的指针。这排除了由 malloc()、calloc() 或 realloc()operator new[] 返回的指针。

异常处理

[edit | edit source]

异常处理 是一种旨在处理异常发生的构造,即改变程序执行正常流程的特殊情况。在设计编程任务(类甚至函数)时,无法始终假设应用程序/任务能够正常运行或完成(以预期结果退出)。它可能是该给定任务报告错误消息(返回错误代码)或仅退出不合适的情况。为了处理这些类型的案例,C++ 支持使用语言构造将错误处理和报告代码与普通代码分开,也就是说,可以处理这些异常(错误和异常)的构造,因此我们称这种为程序设计添加一致性的全局方法为异常处理

在检测到某些错误或异常情况的地方,据说异常被抛出。抛出将导致正常程序流程中止,成为引发异常。异常是程序化的,程序员指定抛出的条件。

已处理异常中,程序的执行将在指定代码块(称为catch 块)处恢复,该代码块在程序执行方面包含抛出点。catch 块可以(通常位于)位于与抛出点不同的函数/方法中。通过这种方式,C++ 支持非局部错误处理。除了改变程序流程之外,抛出异常还会将一个对象传递给 catch 块。此对象可以提供处理代码决定如何对异常做出反应所需的数据。

考虑以下关于trycatch 块组合的代码示例以供说明

void AFunction()
{
    // This function does not return normally, 
    // instead execution will resume at a catch block.
    // The thrown object is in this case of the type char const*,
    // i.e. it is a C-style string. More usually, exception
    // objects are of class type.
    throw "This is an exception!"; 
}

void AnotherFunction()
{
    // To catch exceptions, you first have to introduce
    // a try block via " try { ... } ". Then multiple catch
    // blocks can follow the try block.
    // " try { ... } catch(type 1) { ... } catch(type 2) { ... }"
    try 
    {
        AFunction();
       // Because the function throws an exception,
       // the rest of the code in this block will not
       // be executed
    }
    catch(char const* pch)  // This catch block 
                            // will react on exceptions
                            // of type char const*
    {
        // Execution will resume here.
        // You can handle the exception here.
    }
               // As can be seen
    catch(...) // The ellipsis indicates that this
               // block will catch exceptions of any type. 
    {
       // In this example, this block will not be executed,
       // because the preceding catch block is chosen to 
       // handle the exception.
    }
}

另一方面,未处理异常将导致函数终止,并且堆栈将被展开(堆栈分配的对象将调用析构函数),因为它正在寻找异常处理程序。如果没有找到,最终将导致程序终止。

从程序员的角度来看,引发异常是发出信号表明例程无法正常执行的一种有用方法。例如,当输入参数无效(例如,除法中的零分母)或它依赖的资源不可用(例如丢失的文件或硬盘错误)时。在没有异常的系统中,例程需要返回一些特殊的错误代码。但是,这有时会因半谓词问题而变得复杂,在半谓词问题中,例程的用户需要编写额外的代码来区分正常的返回值和错误的返回值。

由于很难编写异常安全的代码,因此只有在必要时才使用异常——当发生无法处理的错误时。不要将异常用于程序的正常流程。

此示例是错误的,它演示了要避免的内容

void sum(int iA, int iB)
{
    throw iA + iB;
}

int main()
{
    int iResult;

    try 
    {
        sum(2, 3);
    }
    catch(int iTmpResult)  
    {
        // Here the exception is used instead of a return value!
        // This is  wrong!
        iResult = iTmpResult;
    }

    return 0;
}

堆栈展开

[编辑 | 编辑源代码]

考虑以下代码

void g()
{ 
    throw std::exception();
}
 
void f()
{
    std::string str = "Hello"; // This string is newly allocated
    g();
}
 
int main()
{
    try
    {
        f();
    }
    catch(...) 
    { }
}

程序的流程

  • main() 调用 f()
  • f() 创建一个名为 str 的局部变量
  • str 构造函数分配一个内存块来保存字符串 "Hello"
  • f() 调用 g()
  • g() 抛出异常
  • f() 不会捕获异常。
由于异常没有被捕获,我们现在需要以干净的方式退出 f()
此时,所有在抛出之前局部变量的析构函数
被调用——这被称为“堆栈展开”。
  • str 的析构函数被调用,它释放了它占用的内存。
正如您所见,“堆栈展开”机制对于防止资源泄漏至关重要——如果没有它,str 将永远不会被销毁,它使用的内存将一直丢失到程序结束(甚至一直到下一次断电,或冷启动取决于操作系统的内存管理)。
  • main() 捕获异常
  • 程序继续。

“堆栈展开”保证在离开其作用域时会调用局部变量(堆栈变量)的析构函数。

抛出对象

[编辑 | 编辑源代码]

有几种方法可以抛出异常对象。

抛出指向对象的指针

void foo()
{
    throw new MyApplicationException();
}

void bar()
{
    try 
    {
        foo();
    }
    catch(MyApplicationException* e)
    {
        // Handle exception
    }
}

但现在,谁负责删除异常?处理程序?这使代码更难看。一定有更好的方法!

怎么样这个

void foo()
{
    throw MyApplicationException();
}

void bar()
{
    try 
    {
        foo();
    }
    catch(MyApplicationException e)
    {
        // Handle exception
    }
}

看起来不错!但现在,捕获异常的 catch 处理程序按值进行捕获,这意味着会调用复制构造函数。如果捕获的异常是由于内存不足而导致的 bad_alloc,这会导致程序崩溃。在这种情况下,看似安全的代码(假定可以处理内存分配问题)会导致程序因异常处理程序失败而崩溃。此外,按值捕获可能会导致复制由于对象切片而具有不同的行为。

正确的方法是

void foo()
{
    throw MyApplicationException();
}

void bar()
{
    try 
    {
        foo();
    }
    catch(MyApplicationException const& e)
    {
        // Handle exception
    }
}

此方法具有所有优点——编译器负责销毁对象,并且在捕获时不会进行复制!

结论是异常应该按值抛出,并按(通常为 const)引用捕获。

finally 关键字

[编辑 | 编辑源代码]

考虑代码片段

try
{
void x()
{
throw m();
}
}
catch n();
{
std:cout << "Exception caught\n";
}

当执行此代码时,程序将寻找异常的 catch 块,但它不存在。因此,它将崩溃,不会继续。
finally 关键字允许在崩溃之前执行一些最终代码。

finally
{
// residual code that will execute in any case
}

请注意,如果异常被捕获,则 try-catch 块后面的行将照常执行。因此,finally 块只会在没有匹配的 catch 块时起作用。

构造函数和析构函数

[编辑 | 编辑源代码]

当从构造函数中抛出异常时,该对象不被认为已实例化,因此其析构函数不会被调用。但是,相同主对象的已成功构造的基类和成员对象的析构函数将被调用。相同主对象的尚未构造的基类或成员对象的析构函数不会执行。示例

class A : public B, public C
{
public:
    D sD;
    E sE;
    A(void)
    :B(), C(), sD(), sE()
    {
    }
};

假设基类 C 的构造函数抛出异常。然后执行顺序为

  • B
  • C(抛出)
  • ~B

假设成员对象 sE 的构造函数抛出异常。然后执行顺序为

  • B
  • C
  • sD
  • sE(抛出)
  • ~sD
  • ~C
  • ~B

因此,如果执行了某些构造函数,那么可以依赖于相同主对象之前执行的所有其他构造函数都已成功。这使您可以使用已构造的成员或基对象作为相同主对象的后续成员或基对象的构造函数的参数。

使用new分配此对象时会发生什么?

  • 分配对象的内存
  • 对象的构造函数抛出异常
    • 由于异常,该对象没有被实例化
  • 删除对象占用的内存
  • 异常被传播,直到被捕获

从构造函数中抛出异常的主要目的是通知程序/用户对象创建和初始化没有正确完成。这是一种非常干净的方式来提供此重要信息,因为构造函数不返回包含一些错误代码的单独值(如初始化函数可能那样)。

相反,强烈建议不要在析构函数中抛出异常。重要的是要注意析构函数被调用的时间

  • 作为正常释放的一部分(退出作用域,删除)
  • 作为处理先前抛出异常的堆栈展开的一部分。

在前一种情况下,在析构函数中抛出异常可能只会导致由于对象分配错误而导致的内存泄漏。在后一种情况下,代码必须更聪明。如果异常是在由另一个异常引起的堆栈展开过程中抛出的,则无法选择首先处理哪个异常。这被解释为异常处理机制的失败,并导致程序调用 terminate 函数。

为了解决此问题,可以测试析构函数是否作为异常处理过程的一部分被调用。为此,应使用标准库函数 uncaught_exception,如果已抛出异常但尚未捕获,则该函数返回 true。在这种情况下执行的所有代码都不得抛出另一个异常。

需要这种仔细编码的情况非常罕见。如果代码以析构函数根本不抛出异常的方式编写,则调试起来会更安全、更轻松。

编写异常安全的代码

[编辑 | 编辑源代码]
异常安全性

如果代码中的运行时故障不会产生不良影响(例如内存泄漏、存储数据混乱或输出无效),则称该代码为异常安全。异常安全的代码必须满足即使发生异常也会对代码施加的不变式。异常安全性有多个级别

  1. 故障透明性,也称为不抛出保证:即使在出现异常情况的情况下,操作也能保证成功并满足所有要求。如果发生异常,它不会将异常进一步抛出。(最佳级别的异常安全性。)
  2. 提交或回滚语义,也称为强异常安全性无更改保证:操作可能会失败,但失败的操作保证不会产生副作用,因此所有数据都保留原始值。
  3. 基本异常安全性:失败操作的部分执行可能会导致副作用,但状态上的不变式将保留。即使数据现在与异常发生之前的值不同,任何存储的数据都将包含有效值。
  4. 最小异常安全性也称为无泄漏保证:失败操作的部分执行可能会存储无效数据,但不会导致崩溃,并且不会泄漏任何资源。
  5. 无异常安全性:不提供任何保证。(最差级别的异常安全性)

部分处理

[编辑 | 编辑源代码]

考虑以下情况

void g()
{
    throw "Exception";
}
  
void f()
{
    int* pI = new int(0);
    g();
    delete pI;
}

int main()
{
    f();
    return 0;
}

你能看到这段代码的问题吗?如果 `g()` 抛出异常,变量 `pI` 永远不会被删除,就会造成内存泄漏。

为了防止内存泄漏,`f()` 必须捕获异常并删除 `pI`。但是 `f()` 不能处理异常,因为它不知道如何处理!

那么解决方案是什么呢?`f()` 应该捕获异常,然后重新抛出异常。

void g()
{
    throw "Exception";
}
  
void f()
{
    int* pI = new int(0);

    try
    {
        g();
    }
    catch (...)
    {
        delete pI;
        throw; // This empty throw re-throws the exception we caught
               // An empty throw can only exist in a catch block
    }

    delete pI;
}

int main()
{
    f();
    return 0;
}

但是,有一个更好的方法:使用 **RAII** 类来避免使用异常处理。

如果你计划在你的代码中使用异常,你必须始终尝试以异常安全的方式编写代码。让我们来看看可能出现的一些问题。

考虑以下代码

void g()
{ 
    throw std::exception();
}
 
void f()
{
    int* pI = new int(2);

    *pI = 3;
    g();
    // Oops, if an exception is thrown, pI is never deleted
    // and we have a memory leak
    delete pI;
}
 
int main()
{
    try
    {
        f();
    } 
    catch(...) 
    { }

    return 0;
}

你能看到这段代码的问题吗?当抛出异常时,我们永远不会运行删除 `pI` 的那行代码!

解决方案是什么?之前我们看到了基于 `f()` 的捕获和重新抛出异常能力的解决方案。但是,使用“栈展开”机制有一个更简洁的解决方案。但是“栈展开”仅适用于对象的析构函数,那么我们如何使用它呢?

我们可以编写一个简单的包装类。

 // Note: This type of class is best implemented using templates, discussed in the next chapter.
 class IntDeleter {
 public:
    IntDeleter(int* piValue)
    {
        m_piValue = piValue;
    }
    
    ~IntDeleter() 
    {
        delete m_piValue;
    }
  
    // operator *, enables us to dereference the object and use it
    // like a regular pointer.
    int&  operator *() 
    {
        return *m_piValue;
    }

 private:
     int* m_piValue;
 };

`f()` 的新版本。

 void f()
 {
   IntDeleter pI(new int(2));

   *pI = 3;
   g();
   // No need to delete pI, this will be done in destruction.
   // This code is also exception safe.
 }

这里介绍的模式称为*保护*。保护在其他情况下非常有用,它也可以帮助我们使代码更加异常安全。保护模式类似于其他语言中的*finally* 块。

请注意,C++ 标准库提供了名为 `unique_ptr` 的模板保护。

异常层次结构

[编辑 | 编辑源代码]

你可以抛出一个对象(比如类或字符串)、一个指针(比如 `char*`)或一个基本类型(比如 `int`)作为异常。那么,你应该选择哪一个呢?你应该抛出对象,因为它们使程序员更容易处理异常。通常情况下,会创建一个异常类的类层次结构。

  • class MyApplicationException {};
    • class MathematicalException : public MyApplicationException {};
      • class DivisionByZeroException : public MathematicalException {};
    • class InvalidArgumentException : public MyApplicationException {};

一个例子

float divide(float fNumerator, float fDenominator)
{
    if (fDenominator == 0.0)
    {
        throw DivisionByZeroException();
    }

    return fNumerator/fDenominator;
}

enum MathOperators {DIVISION, PRODUCT};

float operate(int iAction, float fArgLeft, float fArgRight)
{ 
    if (iAction == DIVISION)
    {
        return divide(fArgLeft, fArgRight);
    }
    else if (iAction == PRODUCT))
    {
        // call the product function
        // ... 
    }

    // No match for the action! iAction is an invalid agument
    throw InvalidArgumentException(); 
}
 
int main(int iArgc, char* a_pchArgv[])
{
    try
    {
        operate(atoi(a_pchArgv[0]), atof(a_pchArgv[1]), atof(a_pchArgv[2]));
    } 
    catch(MathematicalException& )
    {
        // Handle Error
    }
    catch(MyApplicationException& )
    {
        // This will catch in InvalidArgumentException too.
        // Display help to the user, and explain about the arguments.
    }

    return 0;
}

注意
catch 块的顺序很重要。一个抛出的对象(比如 `InvalidArgumentException`)可以在其某个超类的 catch 块中被捕获。(例如,`catch (MyApplicationException& )` 也将捕获它)。这就是为什么将派生类的 catch 块放在其超类的 catch 块之前非常重要的原因。

异常规范

[编辑 | 编辑源代码]

注意
在新的 C++11 标准中,异常规范的使用已经被弃用。建议没有人使用它们。它们在这里包含是为了历史原因(并非所有人都使用 C++11)。

一个函数可以抛出的异常范围是该函数公共接口的重要组成部分。没有这些信息,你将不得不假设在调用任何函数时可能会发生任何异常,因此需要编写非常防御性的代码。了解可以抛出的异常列表,你可以简化代码,因为它不需要处理每种情况。

这些异常信息是公共接口的特定部分。类的用户不需要知道它的实现方式,但他们确实需要知道可以抛出的异常,就像他们需要知道成员函数的参数数量和类型一样。向库的客户端提供这些信息的一种方法是通过代码文档,但这需要非常仔细地手动更新。错误的异常信息比没有信息更糟糕,因为你最终可能会编写比你预期的异常安全性更低的代码。

C++ 提供了另一种记录异常接口的方法,即通过*异常规范*。异常规范由编译器解析,编译器提供一定程度的自动检查。异常规范可以应用于任何函数,看起来像这样。

double divide(double dNumerator, double dDenominator) throw (DivideByZeroException);

你可以使用空的异常规范指定函数不能抛出任何异常。

void safeFunction(int iFoo) throw();

异常规范的不足

[编辑 | 编辑源代码]

C++ 在编译时不会以编程方式强制执行异常规范。例如,以下代码是合法的。

void DubiousFunction(int iFoo) throw()
{
    if (iFoo < 0)
    {
        throw RangeException();
    }
}

C++ 不会在编译时检查异常规范,而是在运行时检查它们,这意味着你可能在测试时才意识到你的异常规范不准确,或者如果你运气不好,代码已经投入生产了才发现。

如果在运行时抛出一个异常,该异常会从一个函数中传播出去,而该函数在其异常规范中不允许该异常,那么该异常将不再传播,而是调用函数 `RangeException()`。函数 `RangeException()` 不会返回,但可以抛出另一个类型的异常,该异常可能会(也可能不会)满足异常规范并允许异常处理正常继续。如果仍然无法恢复情况,程序将被终止。

许多人认为在运行时尝试转换异常的行为比仅仅允许异常传播到可能处理它的调用者更糟糕。违反异常规范的事实并不意味着调用者*不能*处理这种情况,而只是意味着代码的作者没有预料到这种情况。通常,栈上会存在一个 `catch (...)` 块,它可以处理*任何*异常。

注意
一些编码标准要求不使用异常规范。在 C++ 语言标准 C++11(C++0x) 中,当前版本标准 (C++03) 中指定的异常规范的使用被弃用。异常规范的使用已被完全弃用,这意味着强烈建议没有人使用它们。

Noexcept 说明符

[编辑 | 编辑源代码]
Clipboard

待办事项
添加 C++11 noexcept 说明符,包括 `noexcept` 运算符。

运行时类型信息 (RTTI)

[编辑 | 编辑源代码]

RTTI 指的是系统能够报告对象的动态类型,并在运行时(而不是编译时)提供有关该类型的的信息,并且当一致地使用时,它可以成为一个强大的工具,简化程序员管理资源的工作。

考虑你已经了解的关于dynamic_cast 关键字的知识,假设我们有以下类层次结构。

class Interface
{
public:
        virtual void GenericOp() = 0;// pure virtual function
};

class SpecificClass : public Interface
{
public:
        virtual void GenericOp();
        virtual void SpecificOp();
};

假设我们还有一个类型为 `Interface*` 的指针,如下所示。

Interface* ptr_interface;

假设出现了一种情况,我们被迫假设但没有保证指针指向一个类型为 `SpecificClass` 的对象,并且我们想要调用该类的成员 `SpecificOp()`。为了动态转换为派生类型,我们可以使用dynamic_cast,如下所示。

SpecificClass* ptr_specific = dynamic_cast<SpecificClass*>(ptr_interface);
if( ptr_specific ){
    // our suspicions are confirmed -- it really was a SpecificClass
    ptr_specific->SpecificOp();
}else{
    // our suspicions were incorrect -- it is definitely not a SpecificClass.
    // The ptr_interface points to an instance of some other child class of the base InterfaceClass.
    ptr_interface->GenericOp();
};

使用dynamic_cast,程序将基类指针转换为派生类指针,并允许调用派生类成员。但是,要非常小心:如果你尝试转换的指针不是正确的类型,那么dynamic_cast 将返回一个空指针。

我们也可以对引用使用dynamic_cast

SpecificClass& ref_specific = dynamic_cast<SpecificClass&>(ref_interface);

它的工作原理几乎与指针相同。但是,如果要转换的对象的实际类型不正确,那么dynamic_cast 不会返回空(没有空引用这样的东西)。相反,它会抛出一个 `std::bad_cast` 异常。

语法
    typeid( object );

`typeid` 运算符用于在运行时确定对象的类。它返回对 `std::type_info` 对象的引用,该对象描述了“对象”,并且一直存在到程序结束。如果“对象”是一个解引用后的空指针,则该操作将抛出一个 `std::bad_typeid` 异常。

类 `std::bad_typeid` 的对象派生自 `std::exception`,并且由 `typeid` 等抛出。

注意
C++98 标准要求在编译单元中使用运算符 `typeid` 之前包含头文件<typeinfo>。否则,程序被认为是格式错误的。

在只需要类信息的情况下,使用typeid通常比dynamic_cast<class_type>更受欢迎,因为typeid在类型或非解除引用的值上应用时是一个恒定时间过程,而dynamic_cast必须在运行时遍历其参数的类派生网格。但是,您永远不应该依赖于确切的内容,例如由std::type_info::name()返回的内容,因为这是针对编译的实现特定的。

通常,仅在指向多态类类型(至少具有一个虚拟成员函数的类)的对象的指针或引用(即typeid(*ptr)typeid(ref))的解除引用上使用typeid才有用。这是因为这些是与运行时类型信息相关的唯一表达式。任何其他表达式的类型在编译时都是静态已知的。

示例
#include <iostream>
#include <typeinfo>  //for 'typeid' to work

class Person {
public:
   // ... Person members ...
   virtual ~Person() {}
};

class Employee : public Person {
   // ... Employee members ...
};

int main () {
   Person person;
   Employee employee;
   Person *ptr = &employee;
   // The string returned by typeid::name is implementation-defined
   std::cout << typeid(person).name() << std::endl;   // Person (statically known at compile-time)
   std::cout << typeid(employee).name() << std::endl; // Employee (statically known at compile-time)
   std::cout << typeid(ptr).name() << std::endl;      // Person * (statically known at compile-time)
   std::cout << typeid(*ptr).name() << std::endl;     // Employee (looked up dynamically at run-time
                                                      //           because it is the dereference of a
                                                      //           pointer to a polymorphic class)
}

输出(确切的输出因系统而异)

 Person
 Employee
 Person*
 Employee


在 RTTI 中,它在这种设置中使用

const std::type_info& info = typeid(object_expression);

有时我们需要知道对象的精确类型。typeid运算符返回对标准类std::type_info的引用,该类包含有关类型的信息。此类提供一些有用的成员,包括==!=运算符。最有趣的方法可能是

const char* std::type_info::name() const;

此成员函数返回指向包含对象类型名称的 C 风格字符串的指针。例如,使用我们前面示例中的类

const std::type_info &info = typeid(*ptr_interface);
std::cout << info.name() << std::endl;

此程序将打印类似于[1] SpecificClass的内容,因为这是指针ptr_interface的动态类型。

typeid实际上是一个运算符而不是一个函数,因为它也可以作用于类型

const std::type_info& info = typeid(type);

例如(有点循环)

const std::type_info& info = typeid(std::type_info);

将提供一个type_info对象,该对象描述type_info对象。后一种用法不是 RTTI,而是 CTTI(编译时类型识别)。

限制

[edit | edit source]

RTTI 有一些限制。首先,RTTI 只能用于多态类型。这意味着您的类必须至少具有一个虚拟函数,无论是直接的还是通过继承。其次,由于存储类型所需的额外信息,某些编译器需要一个特殊的开关来启用 RTTI。

请注意,对指针的引用在 RTTI 下不起作用

void example( int*& refptrTest )
{
        std::cout << "What type is *&refptrTest : " << typeid( refptrTest ).name() << std::endl;
}

将报告int*,因为typeid()不支持引用类型。

RTTI 的误用

[edit | edit source]

RTTI 应该在 C++ 程序中谨慎使用。这有几个原因。最重要的是,其他语言机制(如多态和模板)几乎总是优于 RTTI。与所有事物一样,存在例外,但关于 RTTI 的通常规则与goto语句或多或少相同。不要将其用作适当、更健壮设计的捷径。只有在有充分的理由这样做时才使用 RTTI,并且只有在知道自己在做什么的情况下才使用它。


  1. (由 std::type_info::name() 返回的确切字符串取决于编译器)。

章节摘要

[edit | edit source]
  1. 模板 Development stage: 80%
    1. 模板元编程 (TMP) Development stage: 60%
  2. 标准模板库 (STL) Development stage: 60%
  3. 智能指针 Development stage: 50%
  4. 异常处理 Development stage: 60%
  5. 运行时类型信息 (RTTI) Development stage: 60%

华夏公益教科书