C++ 第七章 指针、数组与引用 - 7.7 引用
作者:互联网
第七章 指针、数组与引用
7.7 引用
通过使用指针,我们就能以很低的代价在一个范围内传递大量数据,与直接拷贝所有数据不同,我们只需要传递指向这些数据的指针的值就行了。指针的类型决定了我们能对指针所指的对象进行哪些操作。使用指针与使用对象名存在以下差别:
- 语法形式不同,*p和p->m分别取代了obj和obj.m。
- 同一个指针在不同时刻可以指向不同对象。
- 使用指针要比直接使用对象更小心:指针的值可能是nullptr,也可能指向一个我们并不想要的对象。
这些差别有时候很烦人。例如,程序员常常受困于到底该用f(&x)还是f(x)。更糟糕的是,程序员必须花费大量精力去管理变化多端的指针变量,而且还得时时防范指针取值为nullptr的情况。此外,当我们重载运算符时(比如+),肯定希望写成x+y的形式而不是&x+&y。解决这些问题的语言机制是使用引用(reference)。和指针类似,引用作为对象的别名存放的也是对象的机器地址。与指针相比,引用不会带来额外的开销。引用与指针的区别主要包括:
- 访问引用与访问对象本身从语法形式上看是一样的。
- 引用所引的永远是一开始初始化的那个对象。
- 不存在“空引用”,我们可以认为引用一定对应着某个对象(见7.7.4节)。
引用实际上是对象的别名。引用最重要的用途是作为函数的实参或返回值,此外,它也被用于重载运算符(第18章)。例如:
template<class T>
class vector{
T* elem;
//...
public:
T& operator[](int i){return elem[i];} //返回元素的引用
const T& operator[](int i) const {return elem[i];} //返回常量元素的引用
void push_back(const T& a); //通过引用传入待添加的元素
//...
};
void f(const vector<double>& v)
{
double d1 = v[1]; //把v.operator[](1)所引的double的值拷给d1
v[2] = 7; //把7赋给v.operator[](2)所引的double
v.push_back(d1); //给push_back()传入d1的引用
}
以引用的形式给函数传递实参的思路非常经典,它的历史和高级程序设计语言一样悠久(Fortran语言最早的版本就用到了这种思想)。
为了体现左值/右值以及const/非const的区别,存在三种形式的引用:
- 左值引用(lvalue reference):引用那些我们希望改变值的对象。
- const引用(const reference):引用那些我们不希望改变值的对象(比如常量)。
- 右值引用(rvalue reference):所引对象的值在我们使用之后就无须保留了(比如临时变量)。
这三种形式统称为引用,其中前面两种形式都是左值引用。
7.7.1 左值引用
在类型名字中,符号X&的意思是“X的引用”;它常用于表示左值的引用,因此称为左值引用。例如:
void f()
{
int var = 1;
int& r{var}; //r和var对应同一个int
int x = r; //x的值变为1
r = 2; //var的值变为2
}
为了确保引用对应某个对象(即把它绑定到某个对象),我们必须初始化引用。例如:
int var = 1;
int& r1{var}; //OK:初始化r1
int& r2; //错误:缺少初始化量
extern int& r3; //OK:r3在别处初始化
初始化引用和给引用赋值是完全不同的操作。除了形式上的区别外,事实上没有专门针对引用的运算符。例如:
void g()
{
int var = 0;
int& rr{var};
++rr; //var的值加1
int* pp = &rr; //pp指向var
}
在这段代码中,++rr的含义并不是递增引用rr,相反它的作用是给rr所引的int(即var)加1。因此,引用本身的值一旦经过初始化就不能再改变了;它永远都指向一开始指定的对象。我们可以使用&rr得到一个指向rr所引对象的指针。但是我们既不能令某个指针指向引用,也不能定义引用的数组。从这个意义上来说,引用不是对象。
显然,引用的实现方式应该类似于常量指针,每次使用引用实际上是对该指针执行解引用操作。绝大多数情况下像这样理解引用是没问题的,不过程序员必须谨记:引用不是对象,而指针是一种对象。例如:
有时候编译器能对引用进行优化,使得在运行时无须任何对象表示该引用。
当初始值是左值时(你能获取地址的对象,见6.4节),引用的初始化过程没什么特殊之处。提供给“普通”T&的初始值必须是T类型的左值。
const T&的初始值不一定非得是左值,甚至可以不是T类型的。此时:
(1)首先,如果必要的话先执行目标为T的隐式类型转换(见10.5节)
(2)然后,所得的值置于一个T类型的临时变量中。
(3)最后,把这个临时变量作为初始值。
考虑如下的情况:
double& dr = 1; //错误:此处需要左值
const double& cdr{1}; //OK
后一条语句的初始化过程可以理解为:
double temp = double{1}; //首先用给定的值创建一个临时变量
const double& cdr{temp}; //然后用这个临时变量作为cdr的初始值
用于存放引用初始值的临时变量的生命周期从它创建之处开始,到它的引用作用域结束为止。
普通变量的引用和常量的引用必须区分开来。为变量引入一个临时量充满了风险,当我们为该变量赋值时,实际上是在为一个转瞬即逝的临时量赋值。常量的引用则不存在这一问题,函数的实参经常定义成常量的引用(见18.2.4节)。
我们常用引用作为函数的实参类型,这样函数就能修改传入其中的对象的值了。例如:
void increment(int& aa)
{
++aa;
}
void f()
{
int x = 1;
increment(x); //x = 2
}
实参传递在本质上与初始化过程非常相似。因此当调用函数increment时,实参aa变成了另一个名字x。从代码的可读性角度出发,尽量避免让函数更改它的实参值,我们可以让函数显式地返回一个值来达到同样的目的:
int next(int p){return p+1;}
void g()
{
int x = 1;
increment(x); //x = 2
x = next(x); //x = 3
}
函数increment(x)从形式上看不出x的值已经被改变,相反 x==next(x)可以。因此,除非函数名字能明显地表达修改实参的意思,否则不要轻易使用“普通”引用。
引用还能作为函数的返回值类型,此时,该函数既能作为赋值运算符的左侧运算对象,也能作为赋值运算符的右侧运算对象。一个典型的示例是如下所示的Map:
template<class K, class V>
class Map{
public:
V& operator[](const K& v);
pair<K, V>* begin(){return &elem[0];}
pair<K, V>* end(){return &elem[0]+elem.size();}
private:
vector<pair<K, V>>elem; //{key, value}对
};
实际标准库map(见4.4.3节和31.4.3节)所用的数据结构通常是一颗红黑树。但是为了避开琐碎的细节,我在这里使用最简单的线性搜索实现Map:
template<class K, class V>
V& Map<K, V>::operator[](const K& k)
{
for(auto& x : elem)
if(k == x.first)
return x.second;
elem.push_back({k, V{}}); //在末尾添加一对(见4.4.2节)
return elem.back().second; //返回新元素的默认值
}
我在传递键值实参K的时候使用了引用,因为键值的类型可能太大以至于不便拷贝。类似地,返回结果也设为引用类型,因为拷贝函数返回的值也可能代价过于昂贵。之所以把k的类型设为const引用是因为我不希望函数修改它的值,而且这样做还允许我给函数传入一个字面值常量或者临时对象。因为Map的用户几乎肯定会使用和更改找到的值,所以函数的返回值应该是一个非const引用。例如:
int main()
{
Map<string.int> buf;
for(string s; cin>>s;) ++buf[s];
for(const auto& x : buf)
cout << x.first << “:” << x.second << ‘\n’;
}
每次执行程序,输入循环负责从标准输入流cin读入单词到字符串s中(见4.3.2节),同时更新该单词对应的计数值。最后,输入的每个单词以及它们各自出现的次数以表格形式输出出来。假设输入是:
aa bb bb aa aa bb aa aa
则程序的运行结果将是:
aa : 5
bb : 3
因为我们的Map像标准库map一样定义了begin()和end(),所以可以使用范围for循环遍历其元素。
7.7.2 右值引用
C++之所以设计了几种不同形式的引用,是为了支持对象的不同用法:
- 非const左值引用所引的对象可以由用户写入内容。
- const左值引用所引的对象从用户的角度来看是不可修改的。
- 右值引用对应一个临时对象,用户可以修改这个对象(通常确实会修改它),并且认定这个对象以后不会被用到了。
我们最好事先判断引用所引的是否是临时对象,如果是的话,我们就能用比较廉价的移动操作代替昂贵的拷贝操作了(见3.3.2节,17.1节和17.5.2节)。对于像string和list这样的对象来说,它们本身所含的信息量可能非常庞大,但是用于指向这些信息的描述符(比如引用)可能非常小。此时,如果我们确认以后不会再用到该信息,则执行廉价的移动操作是最好的选择。一个典型的例子是,编译器清楚地知道函数返回的局部变量的值不会再被用到了(见3.3.2节)。
右值引用可以绑定到右值,但是不能绑定到左值。从这一点上来说,右值引用与左值引用正好相反。例如:
string var{“Cambridge”};
string f();
string& r1{var}; //左值引用:r1绑定到var(左值)上
string& r2{f()}; //左值引用,错误:f()是右值
string& r3{“Princeton”}; //左值引用,错误:不允许绑定到临时变量
string&& rr1{f()}; //右值引用,正确:rr1绑定到一个右值(临时变量)
string&& rr2{var}; //右值引用,错误:var是左值
string&& rr3{“Oxford”}; //rr3引用的是一个临时变量,它的内容是“Oxford”
const string cr1&{“Harvard”}; //OK:创建一个临时变量,然后把它绑定到cr1
声明符&&表示“右值引用”。我们不使用const右值引用,因为右值引用的大多数用法都是建立在能够修改所引对象的基础上的。const左值引用和右值引用都能绑定右值,但是它们的目标完全不同:
- 右值引用实现了一种“破坏性读取”,某些数据本来需要被拷贝,使用右值引用可以优化其性能。
- const左值引用的作用是保护参数内容不被修改。
右值引用所引对象的使用方式与左值引用所引的对象以及普通变量没什么区别,例如:
string f(string&& s)
{
if(s.size())
s[0] = toupper(s[0]);
return s;
}
有时,程序员明确知道某一对象不再有用了,但是编译器并不知道这一点,例如:
template<class T>
swap(T& a, T& b) //“旧式的swap函数”
{
T tmp{a}; //此时,我们拥有了两份a
a = b; //此时,我们拥有了两份b
b = tmp; //此时,我们拥有了两份tmp(即a)
}
如果T是string和vector等拷贝操作非常昂贵的类型,则上面这个swap()函数会非常昂贵。注意一个事实:其实我们根本没打算拷贝什么东西,我们想要的只是在a、b和tmp间移动数据而已。我们可以告诉编译器我们的初衷:
template<class T>
void swap(T& a, T& b) //“(几乎)完美的swap函数”
{
T tmp{static_cast<T&&>(a)}; //初始化的同时对a写操作
a = static_cast<T&&>(b); //赋值的同时对b写操作
b = static_cast<T&&>(tmp); //赋值的同时对tmp写操作
}
static_cast<T&&>(x)的结果值是T&&类型的右值引用,引用的对象是x。现在我们可以把右值引用的优化操作用在x上了。当类型T含有移动构造函数(见3.3.2节和17.5.2节)或者移动赋值运算符时,上述优化操作将发挥作用。以vector为例:
template<class T> class vector{
//...
vector(const vector& r); //拷贝构造函数(拷贝r的表示)
vector(vector&& r); // 移动构造函数(“窃取”r的表示)
};
vector<string> s;
vector<string> s2{s}; //s是左值,使用拷贝构造函数
vector<string> s3{s + “tail”}; //s+“tail”是右值,使用移动构造函数
在swap()函数中使用static_cast显得有点繁琐,程序员有时还可能拼错,因此标准库提供了一个名为move()的函数:move(x)等价于static_cast<X&&>(x),其中x的类型是X。通过使用move(),我们就能让swap()的形式变得清晰简洁:
template<class T>
void swap(T& a, T& b) //“(几乎)完美的swap函数”
{
T tmp{move(a)}; //从a中移出值
a = move(b); //从b中移出值
b = move(tmp); //从tmp中移出值
}
与最初的swap()相比,最新版本无须执行任何拷贝操作,它使用移动操作完成所需的功能。
因为move(x)实际上并不真的移动x(它只是为x创建了一个右值引用),所以其实给它起名rval()的话更贴切,不过move()的名字已经使用了太长时间,程序员已经习惯了。
我之所以认为这个swap()是“几乎完美的”,是因为它只能交换左值。例如:
void f(vector<int>& v)
{
swap(v, vector<int>{1,2,3}); //用1,2,3替换v的元素
//...
}
有的时候人们确实需要用一组有序默认值替换容器的当前内容,但是上面的swap()函数做不到。一种解决方案是再增加两个重载函数:
template<class T> void swap(T&& a, T& b);
template<class T> void swap(T& a, T&& b);
最后一个版本可以满足我们的要求。标准库为string和vector等类型(见31.3.3节)提供了shrink_to_fit()和clear(),以使得swap()可以处理右值参数:
void f(string& s, vector<int>& v)
{
s.shrink_to_fit(); //令s.capacity() == s.size()
swap(s, string{s}); //令s.capacity() == s.size()
v.clear(); //清空v
swap(v.vector<int>{}); //清空v
v = {}; //清空v
}
右值引用还可用于实参转发(见23.5.2.1节和35.5.1节)。
所有标准库容器都提供了移动构造函数和移动赋值运算符(见31.3.2节)。它们用于插入新元素的操作,比如insert()和push_back(),都提供了接受右值引用的版本。
7.7.3 引用的引用
如果你让引用指向某类型的引用,那么你得到的还是该类型的引用,而非特殊的引用的引用类型。但你得到的到底是哪种引用呢,左值引用还是右值引用?考虑如下情况:
using rr_i = int&&;
using lr_i = int&;
using rr_rr_i = rr_i&&; // “int&& &&”的类型是int&&
using lr_rr_i = rr_i&; // “int&& &”的类型是int&
using rr_lr_i = lr_i&&; // “int& &&”的类型是int&
using lr_lr_i = lr_i&; // “int& &”的类型是int&
总之,永远是左值引用优先。这种规定合情合理:不管我们怎么做都无法改变左值引用绑定左值的事实。有时候,我们把这种现象称为引用合并(reference collapse)。
C++不允许下面的语法形式:
int && & r = i;
引用的引用只能作为别名(见3.4.5节和6.5节)的结果或者模板类型的参数(见23.5.2.1节)。
7.7.4 指针与引用
指针和引用是两种无须拷贝就能在别处使用对象的机制。它们的图形化表示如下所示:
指针和引用各有优势,也都存在不足之处。
如果你需要更换所指的对象,应该使用指针。你可以用=、+=、-=、++和–改变指针变量的值(见11.1.4节)。例如:
void fp(char* p)
{
while(*p)
cout << ++*p;
}
void fr(char& r)
{
while(r)
cout << ++r; //哎哟:增加的是所引用的char的值,而非引用本身
//很可能是个死循环!
}
void fr2(char& r)
{
char* p = &r; //得到一个指向所引用对象的指针
while(*p)
cout << ++*p;
}
反之,如果你想让某个名字永远对应同一个对象,应该使用引用。例如:
template<class T> class Proxy{ //Proxy引用初始化它的那个对象
T& m;
public:
Proxy(T& mm) : m{mm}{}
//...
};
template<class T> class Handle{ //Handle 引用当前对象
T* m;
public:
Proxy(T& mm) : m{mm}{}
void rebind(T* mm){ m = mm;}
//...
};
如果你想自定义(重载)一个运算符(见18.1节),使之用于指向对象的某物,应该使用引用。例如:
Matrix operator+(const Matrix&, const Matrix&); //OK
Matrix operator-(const Matrix*, const Matrix*); //错误:不是用户自定义类型参数
Matrix y, z;
//...
Matrix x = y+z; //OK
Matrix x2 = &y - &z; //难看且存在错误
C++不允许重新定义指针等内置类型的运算符含义(见18.2.3节)。
如果你想让一个集合中的元素指向对象,应该使用指针:
int x, y;
string& a1[] = {x, y}; //错误:引用的数组
string* a2[] = {&x, &y}; //OK
vector<string&> s1 = {x, y}; //错误:引用的向量
vector<string*> s2 = {&x, &y}; //OK
除非C++对于某些情况做出了明确的规定,我们不得不照做;其他大多数时候,程序员有权在指针和引用中进行选择,这个过程有点像艺术创作:需要点儿智慧,也需要点儿美感。理论上,我们应该尽量减少错误的风险,并且增加代码的可读性。
如果你需要表示“值空缺”,则应该使用指针。指针提供了nullptr作为“空指针”,但是并没有“空引用”与之对应。例如:
void fp(X* p)
{
if(p == nullptr){
//指针的值为空
}
else{
//使用*p
}
}
void fr(X& r) //常规形式
{
//假定r合法,然后使用它
}
当确实需要的时候,也可以为特定的类型构造一个“空引用”。例如:
void fr2(X& r)
{
if(&r == &nullptr){ //或者是r == nullptr
//引用为空
}
else{
//使用r
}
}
显然,你需要让nullX有良好的定义。但不管怎么说,这种用法并不符合语言习惯,我不建议程序员使用。默认情况下,程序员可以认定他所使用的引用是有效的。除非有人故意创建一个无效的引用,否则这种情况很难遇到。例如:
char* ident(char* p){return p;}
char& r{*ident(nullptr)}; //无效代码
这是无效的C++代码。即使你的编程环境暂时没有发现,也最好不要这样写。
标签:const,右值,int,左值,C++,引用,指针,7.7 来源: https://blog.csdn.net/qq_40660998/article/details/121852910