问题
我们需要解决下面的三个问题:
- 如何改变vector对象的大小(改变元素数量)?
- 如何捕获并报告越界的vector元素访问?
- 如何用参数指定vector元素的类型?
在内存管理的最底层,所有的对象都是固定大小并且不存在类型的概念。本章中介绍的语言特性和编程技术能令我们实现可变类型对象的容器,还能实现元素数目的改变。这能带给我们最根本的灵活性和便利性。
改变大小
假如我们定义了
vector<double>v(n); //v.size()==n
则可通过三种方法改变v的大小:
v.resize(10); //v现在有10个元素
v.push_back(7); //在v的末尾增加一个值为7的元素
//v.size()递增1
v=v2; //赋值为另一个vector:v现在变为v2的一个副本
///v.size()现在等于v2.size()
标准库vector提供了更多可以改变自身大小的操作,如erase()和insert(),但在本章中我们只考虑如何为vector实现上述三种操作。
表示方式
实际上,所有vector实现都会记录元素数目和为“未来扩展”预留的“空闲空间”量。例如:
class vector{
int sz; //元素数目
double* elem; //首元素地址
int space; //元素数加上用于新元素的(“当前分配的”)“空闲空间”/“槽位数”
public:
//...
};
由于我们从0开始为元素计数,因此sz(元素数量)指向最后一个元素之后的位置,而space指向最后一个已分配单元之后的位置。当最初构造一个vector对象时,space==sz:即没有“空闲时间”。我们不会分配额外的空间,直到我们开始改变元素数目为止。一般而言,space==sz,因此没有额外的内存开销,除非我们使用push_back()。
默认构造函数(创建一个空vector)将整数成员设置为0,将指针成员设置为nullptr:
vector::vector()
:sz{0},elem{nullptr},space{0} {}
默认构造函数不在空闲空间中分配内存,只占用最小的存储空间。
reserve和capacity
用于改变大小(即改变元素数量)的最基本的操作是vector::reserve()。这一操作用来为新元素增加内存空间:
void vector::reserve(int newalloc) //(设置space)
{
if(newalloc<=space) return; //永远不会减少分配的空间
double* p=new double[newalloc]; //分配新空间
for(int i=0;i<sz;++i) p[i]=elem[i]; //拷贝现有元素
delete[]elem; //释放旧空间
elem=p;
space=newalloc;
}
注意,我们并不对预留空间中的元素进行初始化。毕竟,我们只是预留空间以备将来使用,使用这些空间是push_back()和resize()的工作。
用户可能关心vector对象中闲置空间的大小,因此我们(与标准库vector类似)提供了一个函数以获得这一信息:
int vector::capacity()const { return space; }
即,对于一个名为v的vector,v.capacity()-v.size()表示在不重新分配空间的前提下,我们用push_back()能够向v添加的元素数量。
resize
我们只需处理以下几种情况:
- 新的大小大于已分配的空间
- 新的大小大于当前大小,但小于或等于已分配空间
- 新的大小等于当前大小
- 新的大小小于当前大小
下面的代码展示了resize()的实现:
void vector::resize(int newsize)
//令vector有newsize个元素
//用默认值初始化每个新元素
{
reserve(newsize);
for(int i=sz;i<newsize;++i) elem[i]=0; //初始化新元素
sz=newsize;
}
我们用reserve()处理困难的内存空间管理问题。代码中的循环及初始化新的元素(如果有的话)。在本例中,我们没有显式地处理每一种情况,但可以验证:在上述代码中,每一种情况均被正确地处理了。
push_back
void vector::push_back(double d)
{
if(space==0)
reserve(8); //从8个元素开始
else if(sz==space)
reserve(2*space); //获取更多空间
elem[sz]=d; //将d添加到末尾
++sz; //增加大小(sz为元素数)
}
换句话说,如果没有空闲空间,我们将分配的空间大小加倍。
赋值
当赋值v1=v2完成后,向量v1应该是向量v2的一个副本。显然,我们需要拷贝元素,那么空闲空间怎么处理?我们是否“拷贝”尾部的“空闲空间”?答案是否定的:新的vector将会获得元素的副本,但我们完全不了解新vector将被如何使用,因此我们无须为尾部的空闲空间操心。
最简单的实现包括如下操作:
- 为副本分配存储空间
- 拷贝元素
- 释放原有已分配的空间
- 将sz、elem、space设置为新值。
如下所示:
vector& vector::operator=(const vector& a)
{
if(this==&a) return *this; //自赋值,什么也不需要做
if(a.sz<=space){ //空间足够,无须分配新空间
for(int i=0;i<a.sz;++i) elem[i]=a.elem[i]; //拷贝元素
sz=a.sz;
return *this;
}
double* p=new double[a.sz]; //分配新空间
for(int i=0;i<a.sz;++i) p[i]=a.elem[i]; //拷贝元素
delete[] elem; //释放旧空间
space=sz=a.sz; //设置新大小
elem=p; //设置新元素指针
return *this; //返回自引用
}
我们首先检测自引用(如v=v);在这种情况下,我们不需要做任何事。这一检测在逻辑上看是多余的,但有时会带来明显的性能优化。这里展示了this指针的一种常见用途,检测参数a与调用成员函数(本例中是operator=())的对象是否是同一对象。
模板
本质上是,模板是一种机制,它令程序员能够使用类型作为类或函数的参数。当随后我们提供具体类型作为参数时,编译器会为之生成特定的类或函数。
类型作为模板参数
在C++中引入类型参数T的语法为template<typename T>前缀,其含义是“对所有类型T”。例如:
//接近实用类型的类型T的vector:
template<typename T>
class vector{ //读作“对所有类型T”
int sz; //大小
T* elem; //指向元素的指针
int space; //大小+空闲空间
public:
vector():sz{0},elem{nullptr},space{0} {}
explicit vector(int s):sz{s},elem{new T[s]},space{s}
{
for(int i=0;i<sz;++i) elem[i]=0; //元素被初始化
}
vector(const vector&); //拷贝构造函数
vector& operator=(const vector&); //拷贝赋值
vector(vector&&); //移动构造函数
vector& operator=(vector&&); //移动赋值
~vector(){delete[] elem;} //析构函数
T& operator[](int n){return elem[n];} //访问:返回引用
const T& operator[](int n) const{return elem[n];}
int size() const{return sz;} //当前大小
int capacity() const{return space;}
void resize(int newsize); //增长
void push_back(const T&d);
void reserve(int newalloc);
};
当我们使用模板时,可以认为编译器是按照如下方式生成类的:用实际类型(模板实参)取代模板参数。我们有时候称类模板为类型生成器,称由一个类模板按给定模板实参生成类型(类)的过程为特例化或模板实例化。例如,vector<char>和vector<Poly_line*>被称为vector的特例化版本。对于简单的模板,如我们的vector,实例化是一个相当简单的过程。但对于更通用、更复杂的模板,实例化是一个相当复杂的过程。幸运的是,模板实例化的复杂性是编译器设计者而不是模板使用者需要解决的问题。模板实例化(生成模板特例化版本)只占用程序的编译时间或链接时间,而不会占用程序运行时间。
当你需要一个函数处理给定对象和实参类型时,编译器会根据模板为你生成一个函数。你可以在模板中使用template<class T>代替template<typename T>,两者完全相同。
泛型编程
泛型编程的定义是编写能够正确处理以参数形式呈现的各种类型的代码,只要这些参数类型满足特定的语法和语义要求。当参数化是一个类时,我们将得到一个类模板,通常也称为参数化类型或者参数化类。当参数化的是一个函数时,我们将得到一个函数模板,通常也称为参数化函数,有时也称为算法。因此,泛型编程有时称为“面向算法的程序设计”,设计重点在于算法而非算法所使用的数据类型。这种依赖于显示模板参数的泛型编程通常被称为参数化多态。
相反,从类层次与虚函数获得的多态被称为即时多态,而这种编程风格被称为面向对象编程。
之所以两类编程都被称为多态,是因为每种类型都依赖于程序员通过一个单一接口表示一个概念的多个版本。多态表示很多不同的类型可以通过一个公共接口操纵它们。面向对象编程比泛型编程更自由,但普通的泛型编程更为规则,更容易理解,能被更好地执行(因此用“即时”和“参数化”进行区分)。
两种编程可总结如下:
- 泛型编程:由模板支撑,依赖编译时解析。
- 面向对象编程:由类层次和虚函数支撑,依赖运行时解析。
将这两种类型的编程相结合是可行的,也是有用的。那么,人们用模板来做什么?答案是,为了无与伦比的灵活性和性能,我们应该:
- 在对性能要求高的场合使用模板(例如,数值计算和强实时)
- 在需要灵活组合不同类型信息的场合中使用模板(如C++标准库)
概念
模板的“内在”(定义)与其接口(声明)不能很好地分离。当编译使用模板的代码时,编译器会“探查”模板内部以及模板实参。它这样做是为了获得生成优化代码所需的信息。为了获得信息,当今的编译器都要求在使用模板的对方必须能得到模板的完整定义。这包括调用的所有成员函数和所有模板函数。因此,模板的编写者会将模板定义放在头文件中。这并不是C++标准所要求的。建议这样处理自己的模板:对于会在多个编译单元中使用的模板,将其定义放在一个头文件中。
在编写模板时可以首先设计一个针对特定类型的类并测试它。这个类设计好后,将特定类型替换为模板参数,并用不同的模板实参测试它。
一个模板实参应满足的要求集合称为概念。一个模板实参必须满足模板对它的要求,即概念。例如,vector要求其元素能拷贝和移动、可获取地址且能默认构造(如果需要的话)。换句话说,元素必须满足一组要求——我们可以称之为Element。在C++14中,我们可以显式陈述概念:
template<typename T> //对所有类型T
requires Element<T>() //对所有满足Element的类型T
class vector{
//...
};
这说明一个概念实际上是一个类型谓词,即一个编译时求值的(constexpr)函数,若类型实参(本例中的T)具有概念所要求的属性(本例中的Element),此函数返回true,否则返回false。我们可以使用如下简写语法:
template<Element T> //对所有令Element<T>()为true的类型T
class vector{
//...
};
如果没有支持概念的C++14编译器,我们可以通过命名和注释来说明要求:
template<typename Elem> //要求Element<Elem>()
class vector{
//...
};
每个容器类型和迭代器类型T都有一个值类型(用Value_type<T>表示),对应元素类型。此Value_type<T>通常实现为类模板的成员类型T::value_type。
容器和继承
人们总是尝试一种不可行的面向对象编程和泛型编程的组合方式:试图将派生类对象的容器作为基类对象的容器使用。这样类对象将会发生发生“切片”现象。我们只需记住,对于任意模板C,“D是B”并不意味着“C<D>是C< B >”。
整数作为模板参数
本质上,任何类别的实参都是有用的,但在此处我们只考虑类型和整数作为参数。下面我们讨论一个例子,这是整型值作为模板实参的最常见的用途——一个容器所包含的元素数在编译时就已确定:
template<typename T,int N>struct array{
T elem[N]; //在成员数组中保存元素
//依赖于默认构造函数、析构函数和赋值操作
T& operator[](int n); //访问:返回引用
const T& operator[](int n)const;
T* data(){return elem;} //转换为T*
const T* data()const{return elem;}
int size()const{return N;}
}
我们可以像下面这样使用array:
array<int,256>gb;
array<double,6>ad={0.0,1.1,2.2,3.3,4.4,5.5};
const int max=1024;
void some_fct(int n)
{
array<char,max>loc;
array<char,n>oops; //错误:编译器不知道n的值
//...
array<char,max>loc2=loc; //创建副本作为备份
//...
loc=loc2; //恢复数据
//...
}
array功能与vector相比更加有限,为什么人们还要使用array?一种答案是“效率”。我们在编译时就已经知道array的大小,因此编译器可以(为全局变量如gb)分配静态内存和(为局部变量如loc)分配栈内存而不是在自由存储空间中分配内存。当我们进行范围检查时,可直接与常量(大小参数N)进行比较。在不允许使用自由空间的例如嵌入式系统程序或安全攸关的程序中array比vector更具有优势,因为不会违反临界限制(不使用自由空间)。
而array较数组也更具有优势。前面提到数组的各种问题,例如数组不知道自身的大小,可以很容易地转换为指针,不能正确拷贝等,但array不存在这些问题,例如:
double* p=ad; //错误:不能隐式转换为指针
double* q=ad.data(); //正确:显式转换
template<typename C>void printout(const C&c) //函数模板
{
for(int i=0;i<c.size();++i)cout<<c[i]<<endl;
}
与vector类似,我们可以对array调用printout():
printout(ad); //用array调用
vector<int>vi;
//...
printout(vi); //用vector调用
这是一个将泛型编程应用于数据访问的简单例子。这段代码能够正确运行的原因在于array和vector的接口(size()和下标操作)是相同的。
模板实参推断
对于一个类模板,当你创建某个特定类的对象时,需要指定模板实参。例如:
array<char,1024>buf; //对buf,T是char且N是1024
array<double,10>b2; //对b2,T是double且N是10
对于函数模板,编译器通常能够根据函数实参推断出模板参数。例如:
template<class T,int N>void fill(array<T,N>&b,const T&val)
{
for(int i=0;i<N;++i)b[i]=val;
}
void f()
{
fill(buf,'x'); //对fill(),T是char且N是1024
//因为这是buff所具有的参数
fill(b2,0.0); //对fill(),T是double且N是10
//因为这是b2所具有的参数
}
在技术上,fill(buf,'x')是fill<char,1024>(buf,'x')的简写,fill(b2,0.0)是fill<double,10>(b2,0.0)的简写.幸运的是,我们通常并不需要编写这么具体的代码。编译器能够为我们做这些事。
泛化vector
现在,我们考虑如下问题:
- 如果类型X没有默认值,我们应如何处理vector<X>?
- 当元素使用完毕时,我们如何确保它们被销毁了?
为了处理没有默认值的类型,我们可以设置一个用户选项,以便在我们需要一个“默认值”时能够指定使用什么值:
template<typename T>void vector::resize(int newsize,T def=T());
即除非用户制定了其他值,否则使用T()作为默认值。例如:
vector<double>v1;
v1.resize(100); //添加100个double()(即0.0)
v1.resize(200,0.0); //添加100个0.0——指定0.0是冗余的
v1.resize(300,1.0); //添加100个1.0
struct No_default{
No_default(int); //No_default唯一的构造
//...
};
vector<No_default>v2(10); //错误:试图创建10个No_default()
vector<No_default>v3;
v3.resize(100,No_default(2)); //添加100个No_default(2)的副本
v3.resize(200); //错误:试图添加100个No_default()
析构函数的问题要更难以解决。基本上,我们需要处理非常尴尬的情况:数据结构同时包含已初始化数据和未初始化数据。我们需要寻找一种获得并管理未初始化内存空间的方法。幸运的是,标准库为我们提供了allocator类,该类能够提供未初始化内存。下面代码给出了allocator的一个稍微简化的版本:
template<typename T>class allocator{
public:
//...
T* allocate(int n); //为n个类型为T的对象分配空间
void deallocate(T* p,int n); //释放从p开始的n个类型为T的对象
void construct(T* p,const T& v); //在地址p构造一个值为v的T类型对象
void destroy(T* p); //销毁p中的T
};
本节内容表明,我们能用这四个基本操作实现:
- 分配能够容纳一个T类型对象的未初始化的内存空间
- 在未初始化空间中构造T类型对象
- 销毁一个T类型对象,并将其内存重置为未初始化状态
- 释放能够容纳一个T类型对象的未初始化内存空间
allocator正是我们实现vector<T>::reserve()需要使用的工具。我们先向vector传递一个分配器参数:
template<typename T,typename A=allocator<T>>class vector{
A alloc; //用alloc管理元素内存
//...
};
除了提供一个allocator并默认使用标准的分配器而不是使用new之外,一切与之前的版本完全相同。作为vector的实现者以及试图理解基本问题并学习基本技术的学习者,我们必须明白vector是如何处理未初始化内存并为其用户构造合适的对象的。唯一影响到的代码是vector中直接处理内存的成员函数,例如vector<T>::reserve():
template<typename T,typename A>
void vector<T,A>::reserve(int newalloc)
{
if(newalloc<=space) return; //从不减少分配的空间
T* p=alloc.allocate(newalloc); //分配新空间
for(int i=0;i<sz;++i)alloc.construct(&p[i],elem[i]); //拷贝
for(int i=0;i<sz;++i)alloc.destroy(&elem[i]); //销毁(多了一步)
alloc.deallocate(elem,space); //释放旧空间
elem=p;
space=newalloc;
}
我们在未初始化空间中构造副本来移动元素,然后销毁原有的元素。我们不能使用赋值,因为对于string这样的类型,赋值操作会假设目标空间已被初始化。
实现了reserve()之后,vector<T,A>::push_back()就容易实现了:
template<typename T,typename A>
void vector<T,A>::push_back(const T& val)
{
if(space==0)reserve(8); //从8个元素的空间开始
else if(sz==space)reserve(2*space); //获取更多空间
alloc.construct(&elem[sz],val); //将val添加到末尾
++sz; //增加大小
}
类似地,vector<T,A>::resize()也不难实现:
template<typename T,typename A>
void vector<T,A>::resize(int newsize,T val=T())
{
reserve(newsize);
for(int i=sz;i<newsize;++i)alloc.construct(&elem[i],val); //构造
for(int i=sz;i<newsize;++i)alloc.destroy(&elem[i]); //销毁
sz=newsize;
}
注意,由于有些类型没有默认构造函数,因此我们再次提供了用户选项,可指定一个作为新元素的初始值。
本例中另一个新内容是当我们缩小一个vector时析构“剩余元素”,我们可以将析构函数看作将一个有类型对象转换为“裸内存”的操作。
范围检查和异常
回顾目前的vector版本,我们会发现它没有对数据访问进行范围检查。operator[]的实现十分简单:
template<typename T,typename A>T& vector<T,A>::operator[](int n)
{
return elem[n];
}
最简单的方式是增加一个名为at()的操作,可实现带范围检查的元素访问:
struct out_of_range{/*...*/}; //此类用来报告越界访问错误
template<typename T,typename A=allocator<T>>class vector{
//...
T& at(int n); //带范围检查的访问
const T& at(int n)const; //带范围检查的访问
T& operator[](int n); //不带检查的访问
const T& operator[](int n)const; //不带检查的访问
//...
}
template<typename T,typename A>T& vector<T,A>::at(int n)
{
if(n<0||sz<=n) throw out_of_range();
return elem[n];
}
template<typename T,typename A>T& vector<T,A>::operator[](int n)
//照旧
{
return elem[n];
}
有了at(),我们可以编写如下代码:
void print_some(vector<int>&v)
{
int i=-1;
while(cin>>i&&i!=-1)
try{
cout<<"v["<<i<<"]=="<<v.at(i)<<endl;
}
catch(out_of_range){
cout<<"bad index:"<<i<<endl;
}
}
这段代码中,我们通过at()进行带范围检查的数据访问,并且捕获out_of_range以避免非法的数据访问。
一般做法是,当我们确定元素索引有效时,用下标操作[]进行数据访问;而当元素索引可能造成越界时,应使用at()。
旁白:设计上的考虑
标准库vector在operator[]()也中没有进行范围检查,而是在at()中提供了范围检查。主要有以下四个方面因素:
- 兼容性:在C++具有异常机制之前,人们就已经在使用不带范围检查的下标操作了。
- 效率:你可以在一个不进行范围检查但性能更优的运算符基础上实现一个进行范围检查的运算符,但你不能在一个进行范围检查的运算符基础上实现性能更优的运算符。
- 约束:在一些环境中,异常是不可接受的。
- 检查的可选性:C++标准并没有规定你不能对vector进行范围检查,所以如果你希望进行检查,可选择能够进行范围检查的实现。
坦白:使用宏
大多数标准库vector实现不对下标运算符([])进行范围检查,但在at()中提供范围检查。我们在此处选择了“选项4”:vector的实现不必对[]进行范围检查,但这么做也是允许的,因此我们的代码处理了这种情况。
struct Rang_error:out_of_range{ //增强的vector越界错误报告
int index;
Rang_error(int i):out_of_range("Range error"),index(i){}
};
template<typename T>struct Vector:public std::vector<T>{
using size_type=typename std::vector<T>::size_type;
using vector<T>::vector; //使用vector<T>的构造函数
T& operator[](size_type i) //不是return at(i);
{
if(i<0||this->size()<=i)throw Rang_error(i);
return std::vector<T>::operator[](i);
}
const T& operator[](size_type i)const
{
if(i<0||this->size()<=i)throw Rang_error(i);
return std::vector<T>::operator[](i);
}
};
第一个using为std::vector的size_type引入了一个便利的别名;第二个using将vector的所有构造函数都引入了Vector。
在std_lib_facilities.h中,我们采用了一种糟糕的花招(宏替换),重新定义vector使之代表Vector:
//令人讨厌的宏替换花招,来实现带范围检查的vector:
#define vector Vector
这意味着每当你写下vector时,编译器看到的都会是Vector。
资源和异常
编程的一个基本原则是,如果我们获取了资源,那么我们还必须负责——直接或间接地——将其归还给负责管理这些资源的系统。资源的例子包括:
- 内存
- 锁
- 句柄
- 线程句柄
- 套接字
- 窗口
本质上,资源可以被视为这样一类东西:资源的使用者必须向系统中的“资源管理者”归还(释放)资源,并由“资源管理者”负责资源的回收。对于vector这样负责释放一个资源的对象,我们称之为资源的所有者或句柄。
潜在的资源管理问题
我们必须小心处理表面上无害的指针赋值操作,如
int* p=new int[s]; //获取内存
当程序运行到delete语句时,p可能已不再指向我们所分配的内存资源:
void suspicious(int s,int x)
{
int* p=new int[s]; //获取内存
//...
if(x)p=q; //令p指向另一个对象
//...
delete[] p; //释放内存
}
上述例子中的if(x)使得我们不能够确定p的取值是否已经改变。程序也可能永远都不能到达delete语句:
void suspicious(int s,int x)
{
int* p=new int[s]; //获取内存
//...
if(x) return;
//...
delete[] p; //释放内存
}
程序不能到达delete语句的原因也许是程序抛出了一个异常:
void suspicious(int s,int x)
{
int* p=new int[s]; //获取内存
vector<int>v;
//...
if(x) p[x]=v.at(x);
//...
delete[] p; //释放内存
}
我们最关心最后一种情况。程序员很有可能会认为这是一个异常处理问题而不是一个资源管理问题,会写出如下代码:
void suspicious(int s,int x) //混乱的代码
{
int* p=new int[s]; //获取内存
vector<int>v;
//...
try{
if(x) p[x]=v.at(x);
//...
}catch(...){ //捕获所有异常
delete[] p; //释放内存
throw; //重抛出异常
}
//...
delete[] p; //释放内存
}
上述解决方法会带来一些额外的代码并造成资源释放代码的重复(delete[] p;)。换句话说,这一解决方法有些丑;更糟糕的是,它不能很好地推广。考虑下面获取更多资源的例子:
void suspicious(vector<int>& v,int s)
{
int* p=new int[s];
vector<int>v1;
//...
int* q=new int[s];
vector<int>v2;
//...
delete[] p;
delete[] q;
}
注意,如果new操作不能够分配所需内存,它将抛出标准库异常bad_alloc。对于这个例子,try...catch技术也可以用于解决内存泄漏问题,但在代码中会包含多个try语句块,这将造成代码的重复冗杂。
资源获取即初始化
幸运的是,我们可以不必在代码中添加复杂的try...catch语句就能有效处理潜在的资源泄漏问题。例如:
void f(vector<int>& v,int s)
{
vector<int>p(s);
vector<int>q(s);
//...
}
这一实现就好多了。资源(在这里是自由存储区中的内存空间)由构造函数获取,而由对应的析构函数释放。这一解决具有一般性;它能用于所有资源类型:通过对象的构造函数获取资源,并通过对应的析构函数释放资源。通过这一方法能够有效处理的资源包括:数据库锁、套接字和I/O缓冲区。这一技术被称为——资源获取即初始化,简写为RAII。
当程序的执行序列超出了被完全构造的对象或子对象的作用域时,这些对象的析构函数将自动被调用。对于一个对象而言,当其构造函数执行完毕时,它被认为构造完成。对象的构造函数和析构函数会根据实际需要被调用。
特别地,当我们需要在某个作用域内使用可变大小的存储空间时,我们应使用vector而不是显式使用new和delete。
保证
当不能只在单一的作用域(及其子作用域)内使用vector对象时,我们应该怎么做呢?例如:
vector<int>* make_vec() //创建一个填满的vector
{
vector<int>* p=new vector<int>; //我们在自由存储空间分配vector
//...向vector填充数据;这可能抛出异常...
return p;
}
我们可以通过try语句块处理异常的抛出:
vector<int>* make_vec()
{
vector<int>* p=new vector<int>;
try{
//向vector填充数据;这可能抛出异常...
return p;
}
catch(...){
delete p; //进行局部清理
throw; //重抛出异常,允许调用者处理这种情况:
//make_vec()无法完成要求它的工作
}
}
make_vec()函数展示了错误处理的一个十分通用的形式:函数总是试图完成它的工作,而如果它不能完成工作,则它应释放所有的局部资源(在这里是自由存储区中分配的vector对象)并通过抛出异常的方式报告其工作的失败。
- 基本保证:代码try...catch的目的是保证make_vec()要么成功,要么在不造成资源泄漏的前提下抛出异常。这通常称为基本保证。如果程序中的某段代码需要能够从异常throw中恢复,那么该段代码就需要提供基本保证。
- 强保证:如果一个函数除了提供基本保证,还具有如下特征——在该函数的任务失败后,所有可观测值(所有不属于该函数的值)仍能与其在该函数被调用前的值一致,那么我们称该函数提供强保证。强保证是一种理性情况:函数要么成功完成了所有的任务,要么除了抛出异常之外什么也不做。
- 无抛出保证:C++提供的所有内建工具本质上能够提供无抛出保证:它们不会抛出异常。为了避免异常的抛出,我们应该避免使用throw、new以及引用类型的dynamic_cast。
基本保证和强保证对于检验程序的正确性是十分有用的。为了能够根据这些理想情况编写高性能的代码,RAII是必不可少的。
unique_ptr
make_vec()提供了基本保证,尽管如此,try...catch这部分代码仍然是丑陋的。解决办法很明显:我们必须以某种方式使用RAII:也就是说,我们需要提供一个对象以容纳vector<int>对象,以便得当异常发生时它能够销毁vector对象。因此,在<memory>中标准库提供了unique_ptr:
vector<int>* make_vec() //创建一个填满的vector
{
unique_ptr<vector<int>>p{new vector<int>}; //在自由存储空间分配
//...向vector填充数据:这可能抛出异常...
return p.release();
}
unique_ptr是一种能存储指针的对象。我们用new返回的对象立即初始化unique_ptr对象。与指针一样,我们可以对unique_ptr使用->和*
运算符(例如p->at(2)或(* p).at(2)),因此我们可以将unique_ptr看作一种指针。但是,unique_ptr拥有所指向的对象:当销毁unique_ptr时,它会delete所指向的对象。这意味着如果在向vector<int>填充数据时抛出了异常,或者我们过早从返回make_vec,vector<int>会被恰当地销毁。p.release()会从p中提取出所保存的(指向vector<int>的)指针,从而我们可以返回它,它还使得p保存的指针变为nullptr,从而销毁p(return所做的事情)不会销毁任何对象。使用unique_ptr令我们可以反复做我们建议的事情——用怀疑的目光看待显式的try块;就像make_vec()那样,大多数try块可以被“资源获取即初始化”技术的某种变体所替代。
以上版本已经很好,唯一的问题是它还返回一个指针,从而调用者还是必须记得delete这个指针。返回一个unique_ptr可以解决此问题:
unique_ptr<vector<int>> make_vec() //创建一个填满的vector
{
unique_ptr<vector<int>>p{new vector<int>}; //在自由存储空间分配
//...向vector填充数据:这可能抛出异常...
return p;
}
unique_ptr非常像普通指针,但它有一项重要限制:你不能将一个unique_ptr赋予另一个unique_ptr从而让它们指向相同的对象。此限制是必须的,否则会引起混淆:哪个unique_ptr拥有所指向的对象?谁负责delete它?例如:
void no_good()
{
unique_ptr<X>p{new X};
unique_ptr<X>q{p};
//...
}//此时p和q都delete X
如果你需要一种既确保释放内存又能被拷贝的“智能”指针,可以使用shared_ptr。但是,那是一种更重量级的解决方案,需要一个使用计数来确保最后一个拷贝销毁时能销毁指向的对象。
unique_ptr有一个有趣的性质:与普通指针相比没有额外开销。
以移动方式返回结果
有一种常用的返回大量信息的技术:将信息放在自由存储空间中,然后返回指向它的指针。但这种技术也是高复杂性的来源以及内存管理错误的主要来源:对于从函数返回的指向自由存储空间的指针,谁delete它?当异常发生时,我们能否确保指向自由空间中对象的指针被正确delete?除非我们采用了系统的指针管理(或使用unique_ptr和shared_ptr这样的“智能”指针)。
幸运的是,当我们向vector添加移动操作时,就解决了vector的上述问题:使用移动构造函数将元素的所有权从函数移除。例如:
vector<int> make_vec() //创建一个填满的vector
{
vector<int>res;
//...向vector填充数据:这可能抛出异常
return res; //移动构造函数高效地转移了所有权
}
make_vec()的这个(最终)版本最为简单。移动方法可推广到所有容器以及所有其他资源句柄。例如,fstream使用这种技术跟踪文件句柄。移动方法既简单又通用。使用资源句柄简化了代码并消除了主要错误来源。与直接使用指针的方案相比,没有任何运行时开销,即便有的话,也非常小且容易预测。
vector类的RAII
如何保证我们已经发现了所有需要保护的指针?如何保证我们已经释放了所有指向不应在作用域末尾销毁的对象的指针?考虑之前设计的reserve():
template<typename T,typename A>
void vector<T,A>::reserve(int newalloc)
{
if(newalloc<=space) return; //从不减少分配的空间
T* p=alloc.allocate(newalloc); //分配新空间
for(int i=0;i<sz;++i)alloc.construct(&p[i],elem[i]); //拷贝
for(int i=0;i<sz;++i)alloc.destroy(&elem[i]); //销毁
alloc.deallocate(elem,space); //释放旧空间
elem=p;
space=newalloc;
}
对已有元素的拷贝操作alloc.construct(&p[i],elem[i])可能会抛出异常。属于前面介绍的指针易错的情况。我们可以采用unique_ptr解决方案。一个更好的解决方案是,将“vector所用内存”认为是一种资源;也就是说,我们可以定义一个vector_base类以代表我们一直使用的基本概念。vector_base的代码如下:
template<typename T,typename A>
struct vector_base{
A alloc;
T* elem;
int sz;
int space;
vector_base(const A& a,int n)
:alloc{a},elem{alloc.allocate(n)},sz{n},space{n}{}
~vector_base(){alloc.deallocate(elem,space);}
};
注意,vector_base处理的是内存而不是(带类型的)对象。我们的vector实现可以将它用于存储所需元素类型的对象。本质上,vector是vector_base的一个便携的接口:
template<typename T,typename A=allocator<T>>
class vector:private vector_base<T,A>{
public:
//...
};
我们可以按如下更简单也更正确的方式重新实现reserve():
template<typename T,typename A>
void vector<T,A>::reserve(int newalloc)
{
if(newalloc<=this->space)return; //从不减少分配的内存
vector_base<T,A> b(this->alloc,newalloc); //分配新空间
uninitialized_copy(b.elem,&b.elem[this->sz],this->elem); //拷贝(参数分别为:新地址起始地址,新地址目标末尾地址,旧元素地址)
for(int i=0;i<this->sz;++i)
this->alloc.destroy(&this->elem[i]); //释放旧空间
swap<vector_base<T,A>>(*this,b); //交换表示
}
我们使用标准库函数uninitialized_copy来构造b中元素的副本,因为它能正确处理元素拷贝构造函数中抛出的异常。当我们退出reserve()函数时,原有内存空间将被vector_base的析构函数自动释放——如果拷贝操作成功的话。如果退出是因拷贝操作抛出异常而造成的,新分配的空间将被释放。
swap()函数是一个标准库算法,它能交换两个对象的值。我们使用swap<vector_base<T,A>>(*
this,b)而不是更简单的swap(*
this,b),这是因为* this和b是两种不同的类型(分别是vector和vector_base),因此我们必须显式指出想要使用swap的哪个特例化版本。
类似地,当我们从派生类vector<T,A>的一个成员来引用基类vector_base<T,A>的成员时,例如vector<T,A>::reserve(),必须显式使用this->。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。