目录
- 1. 引用
- 1.1 创建引用变量
- 1.2 修改引用
- 1.3 const引用
- 1.4 指针的引用和引用的指针
- 1.5 结构化绑定和引用
- 1.6 引用数据成员
- 1.7 引用作为函数参数
- 1.8 const引用传递
- 1.9 值传递和引用传递
- 1.10 引用作为返回值
- 1.11 引用与指针之间的选择
- 2. const_cast
- 3. 异常
- 参考
1. 引用
专业的C++代码都广泛地使用了引用,引用是另一个变量的别名。对引用的所有修改都会更改其引用的变量的值,可以将其视为隐式指针,它省去了获取变量地址和解引用指针的麻烦。可以创建独立的引用变量,在类中使用引用数据成员,接受引用作为函数和方法的参数,并从函数和方法返回引用。
1.1 创建引用变量
引用变量必须在创建时被初始化,例如:
int x { 3 };
int& xRef { x };
给类型附加上一个&,则指示相应的变量是一个引用。它仍能像正常变量一样被使用,但是在幕后,它实际上是指向原始变量的指针。变量x和引用变量xRef指向同一个值,也就是说,xRef只是x的另一个名称。如果通过其中一个更改值,则也可以在另一个中看到更改。例如,以下代码通过xRef将x设置为10:
xRef = 10;
不允许在类定义之外声明一个引用而不对其进行初始化。
int& emptyRef; // does not compile!
警告:引用变量必须总是在创建时被初始化。
1.2 修改引用
引用始终指向它初始化时的那个变量,引用一旦创建便无法更改。对于刚开始使用C++的程序员来说,语法可能令人困惑。如果在声明引用时将变量赋值给引用,则引用指向该变量。但是,如果之后将变量赋值给引用,则引用所指变量的值会更改为赋值变量的值。原来的引用不会改为指向新的变量。示例如下:
int x { 3 }, y { 4 };
int& xRef { x };
xRef = y; // change value of x to 4. doesn't make xRef refer to y.
即使使用y的地址对xRef赋值,也无法规避此限制。
xRef = &y; // does not compile!
这句代码会编译失败,y的地址是一个指针,但是xRef被声明为一个对int的引用,而不是对指针的引用。
&emsp如果将引用赋值给引用会怎么办?这样会使第一个引用指向第二个引用所指的变量吗?你可能会想尝试以下代码:
int x { 3 }, z { 5 };
int& xRef { x };
int& zRef { z };
zRef = xRef; // assigns value, not references.
最后一行不会更改zRef,而是将z的值设置为3,因为xRef引用x,即3。
警告:一旦将引用初始化为引用特定变量,就无法将引用更改为引用另一个变量。只能更改引用所指向变量的值。
1.3 const引用
应用于引用的const通常比应用于指针的const容易,这有两个原因。首先,引用默认是const,因为你不能更改它们的指向。因此,不需要显式标记它们为const。其次,你无法创建对引用的引用,因此通常只有一个间接引用级别。获得多个间接级别的唯一方法是创建对指针的引用。
因此,当C++程序员提起const引用时,他们的意思是这样的:
int z;
const int& zRef { z };
zRef = 4; // does not compile!
通过将const应用于int&,可以阻止对zRef的赋值,如上所示。类似于指针,const int& zRef等价于int const& zRef。但是请注意,将zRef标记为const对z无效,仍然可以通过直接更改z的值而不是通过引用来更改z的值。
不能创建对未命名值的引用,例如整型字面量,除非该引用是const引用。在下面的示例中,unnamedRef1会编译失败,因为它是对非const的引用,却指向了一个常量。那意味着你可以更改常量5的值,这没有任何意义。unnamedRef2之所以有效,是因为它是const引用,因此不能编写例如unnamedRef2 = 7;
这样的代码。
int& unnamedRef1 { 5 }; // does not compile.
const int& unnamedRef2 { 5 }; // works as expected.
临时对象也是如此。不能为临时对象创建对非const的引用,但是const引用是可以的。例如,假设具有以下返回std::string对象的函数。
std::string getString() {return "Hello, world!";
}
可以为getString()的结果创建一个const引用,该引用将使临时std::string对象保持生命周期,直到该引用超出作用域。
std::string& string1 { getString() }; // does not compile.
const std::string& string2 { getString() }; // works as expected.
1.4 指针的引用和引用的指针
可以创建对任何类型的引用,包括指针类型。这是对指向int的指针的引用的示例:
int* intP { nullptr };
int*& ptrRef { intP };
ptrRef = new int;
*ptrRef = 5;
语法有点奇怪:你可能不习惯看到*和&彼此相邻。但是,语义很简单:ptrRef是对intP的引用,intP是对int的指针。修改ptrRef会更改intP。对指针的引用很少见,但有时可能有用。
取一个引用的地址与取该引用所指向的变量的地址得到的结果是相同,示例如下:
int x { 3 };
int& xRef { x };
int* xPtr { &xRef }; // address of a reference is pointer to value.
*xPtr = 100;
该代码通过取x的引用的地址来将xPtr设置为指向x。将100赋值给*xPtr会将x的值更改为100。由于类型不匹配,xPtr==xRef的比较是无法编译的,xPtr是指向int的指针,而xRef是对int的引用。比较xPtr==&xRef和xPtr==&x都可以正常编译。
最后,请注意,不能声明对引用的引用和对引用的指针。例如,int&&和int&*都是不允许的。
1.5 结构化绑定和引用
回顾结构化绑定,示例如下:
std::pair myPair { "hello", 5 };
auto [theString, theInt] { myPair }; // decompose using structured bindings.
引用和const变量也可以和结构化绑定一起使用,示例如下:
auto& [theString, theInt] { myPair }; // decompose into references-to-non-const.
const auto& [theString, theInt] { myPair }; // decompose into references-to-const.
1.6 引用数据成员
类的数据成员可以是引用。如前所述,引用不能不指向其他变量而存在,并且不可以更改引用指向的变量。因此,引用数据成员不能在类构造函数的函数体内部进行初始化,必须在所谓的构造函数初始化器中进行初始化。在语法方面,构造函数初始化器紧跟在构造函数声明之后,并以冒号开头。以下是一个展示构造函数初始化器的简单示例。
class MyClass {
public:MyClass(int& ref) : m_ref { ref } {/* body fo constructor */}
private:int& m_ref;
}
警告:引用必须始终在创建时被初始化。通常,引用是在声明时创建的,但是引用数据成员需要在类的构造函数初始化器中初始化。
1.7 引用作为函数参数
C++程序员通常不使用独立的引用变量或引用数据成员,引用的最常见用途是用于函数的参数。默认的参数传递语义是值传递:函数接收其参数的副本。修改这些参数后,原始实参保持不变。栈中变量的指针在C语言中使用,以允许函数修改其他栈帧中的变量。通过对指针的解引用,函数可以修改表示该变量的内存,即使该变量不在当前的栈帧中。这种方法的问题在于,它将指针复杂的语法带入了原本简单的任务。
相对于向函数传递指针,C++提供了一种更好的机制,称为引用传递,参数是引用而不是指针。以下是addOne()函数的两种实现,第一种对传入的变量没有影响,因为它是值传递的,因此该函数将接收传递给它的值的副本。第二种使用引用,因此改变了原始变量。
void addOne(int i) {i++; // has no real effect because this is a copy of the original.
}void addOne(int& i) {i++; // actually changes the original variable.
}
调用具有整型引用参数的addOne()函数的语法与调用具有整型参数的addOne()函数没有区别。
int myInt { 7 };
addOne(myInt);
注意:两个addOne()函数的实现之间存在微妙区别。使用值传递版本可以接收字面量而不出现任何问题,例如addOne(3)是合法的。然而,如果向引用传递的addOne()函数传递字面量,会导致编译错误。可使用下面一节的const引用传递解决该问题。
这是另一个引用派上用场的例子,这是一个简单的交换函数,用于交换两个int类型的值。
void swap(int& first, int& second) {int temp { first };first = second;second = temp;
}
可以向这样调用它:
int x { 5 }, y { 6 };
swap(x, y);
当使用实参x和y调用swap()时,形参first被初始化为对x的引用,second被初始化为对y的引用。当swap()修改first和second时,实际上更改的是x和y。
当你有一个指针但函数或方法只能接收引用时,就会产生一个常见的难题。在这种情况下,可以通过对指针解引用将其转换为引用。该操作提供了指针所指向的值,编译器随后使用该值初始化引用参数。例如,可以像这样调用swap():
int x { 5 }, y { 6 };
int *xp { &x }, *yp { &y };
swap(*xp, *yp);
最后,如果函数需要返回一个复制成本高昂的类的对象,函数接收一个对该类的非const引用的输出参数,此后进行修改,而非直接返回对象。开发人员认为这是防止从函数返回对象时创建副本从而导致性能损失的推荐方法。但是,即使在那时,编译器通常也足够聪明,可以避免任何冗余的复制。
警告:从函数返回对象的推荐方法是通过值返回,而不是使用一个输出参数。
1.8 const引用传递
const引用的参数的主要目的是效率。当将值传递给函数时,便会生成一个完整副本。传递引用时,实际上只是传递指向原始对象的指针,因此计算机无须生成副本。通过const引用传递,可以做到二者兼顾:不生成任何副本,且无法更改原始变量。当处理对象时,const引用变得非常重要,因为对象可能很大,并且对其进行复制可能会产生有害的副作用。下面的示例将展示如何将std::string作为const引用传递给函数:
void printString(const std::string& myString) {std::cout << myString << "\n";
}int main() {std::string someString { "Hello World" };printString(someString);printString("Hello World"); // passing literals works;
}
1.9 值传递和引用传递
当要修改参数并希望那些更改能够作用于传给函数的变量时,需要通过引用传递。但是,不应将引用传递的使用局限于那些情况。引用传递避免将实参复制到函数,从而提供了两个附加好处。
效率:复制大型的对象可能花费很长时间,引用传递只是将该对象的一个引用传给了函数。
支持:不是所有的类都允许值传递。
如果你想利用这些好处,但又不想修改原始对象,则应将参数标记为const,从而可以传递const引用。
注意:引用传递的这些好处意味着,应该只在对于简单的内置类型,例如int和double,且无须修改实参的时候使用值传递。如果需要将对象传递给函数,则更应该使用const引用传递而不是值传递。这样可以防止不必要的复制。如果函数需要修改对象,则通过非const的引用将其传递。
1.10 引用作为返回值
函数可以返回引用。当然,只有在函数终止后返回的引用所指向的变量继续存在的情况下,才可以使用此方法。
警告:切勿返回作用域为函数内部的局部变量的引用,例如在函数结束时将被销毁的自动分配的栈上变量。
返回引用的主要原因是,能够直接把返回值作为左值对其赋值。几个重载的运算符通常会返回引用,例如,运算符=,+=等。
1.11 引用与指针之间的选择
C++中的引用可能被认为是多余的:使用引用可以做的所有事情都可以使用指针完成。例如,可以这样编写前面出现的swap()函数。
void swap(int* first, int* second) {int temp { *first };*first = *second;*second = temp;
}
但是,此代码比使用引用的版本更杂乱。引用使程序简洁,更易于理解。它们也比指针安全,因为没有空引用,并且不需要显式解引用,因此不会遇到与指针相关的任何解引用错误。当然,这些关于引用更安全的争论只有在没有任何指针的情况下才有意义。例如,使用下面的函数,该函数接受对int的引用。
void refcall(int& t) {++t;
}
可以声明一个指针并将其初始化以指向内存中的某个随机位置。然后,可以解引用此指针,并将其作为引用参数传递给refcall(),如以下代码所示。这段代码可以成功编译,但是并不确定执行后会发生什么。例如,它可能导致程序崩溃。
int* ptr { (int*) 8 };
refcall(*ptr);
大多数时候,可以使用引用而不是指针。与指向对象的指针相同,对对象的引用也支持所谓的多态性。但是,在某些情况下,需要使用指针。一种情况是需要更改其指向的位置时。回顾一下,不能更改引用所指向的变量。例如,当分配动态内存时,需要将指向结果的指针存储在指针而不是引用中。需要指针的第二种情况是,指针是optional的,即当它可以为nullptr时。另一个用例是,如果想将多态类型存储在容器中。
很久以前,在遗留代码中,选择参数和返回类型中使用指针还是引用的一种方法是考虑内存的所有权。如果接收变量的代码为所有者,并因此负责释放与对象关联的内存,则它必须接收指向该对象的指针。如果接收该变量的代码不必释放内存,那么它接收一个引用。但是,现在应避免使用原始指针,而使用所谓的智能指针,这是转让所有权的推荐方法。
注意:尽量选择引用而不是指针,也就是说,只有在无法使用引用的情况下才选择使用指针。
考虑将一个整数数组分为两个数组的函数:分别存放奇数和偶数。该函数不知道源数组中的偶数或奇数个数,因此它应在检查源数组后为目标数组动态分配内存。它还应该返回两个新数组的大小。总共有4项要返回:指向两个新数组的指针以及两个新数组的大小。显然,必须使用引用传递。规范的C的写法如下所示:
void separateOddsAndEvens(const int arr[], size_t size, int** odds, size_t* numOdds, int** evens, size_t* numEvens) {// count the number of odds and evens.*numOdds = *numEvens = 0;for ( size_t i = 0; i < size; ++i ) {if ( arr[i] % 2 == 1 ) {++(*numOdds);} else {++(*numEvens);}}// allocate two new arrays of the appropriate size.*odds = new int[*numOdds];*evens = new int[*numEvens];// copy the odds and evens to the new arrays.size_t oddsPos = 0, evensPos = 0;for ( size_t i = 0; i < size; ++i ) {if ( arr[i] % 2 == 1 ) {(*odds)[oddsPos++] = arr[i];} else {(*evens)[evensPos++] = arr[i];}}
}
该函数的最后4个参数是”引用“参数。若要更改它们引用的值,separateOddsAndEvens()必须对它们解引用,这会导致函数体内的语法丑陋。此外,当调用separateOddsAndEvens()时,必须传递两个指针的地址,以便函数可以更改实际的指针,并传递两个size_t的地址,以便函数可以更改实际的size_t。还要注意,调用方要负责删除由separateOddsAndEvens()创建的两个数组。
int unSplit[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
int* oddNums { nullptr };
int* evenNums { nullptr };
size_t numOdds { 0 }, numEvens { 0 };separateOddsAndEvens(unSplit, std::size(unSplit), &oddNums, &numOdds, &evenNums, &numEvens);// use the arrays...delete[] oddNums;
oddNums = nullptr;
delete[] evenNums;
evenNums = nullptr;
如果此语法令你烦恼,则可以编写相同的函数,以获得真正的引用传递语义。
void separateOddsAndEvens(const int arr[], size_t size, int*& odds, size_t& numOdds, int*& evens, size_t& numEvens) {numOdds = numEvens = 0;for ( size_t i { 0 }; i < size; ++i ) {if ( arr[i] % 2 == 1 ) {++numOdds;} else {++numEvens;}}odds = new int[numOdds];evens = new int[numEvens];size_t oddsPos { 0 }, evensPos { 0 };for ( size_t i { 0 }; i < size; ++i ) {if ( arr[i] % 2 == 1 ) {odds[oddsPos++] = arr[i];} else {evens[evensPos++] = arr[i];}}
}
在这种情况下,参数odds和evens是对int的引用。separateOddsAndEvens()无须解引用就可以修改函数的实参int。相同的逻辑适用于numOdds和numEvens,它们是对size_t的引用。使用此版本的函数,不再需要传递指针或size_t的地址。引用参数会自动为你处理:
separateOddsAndEvens(unSplit, std::size(unSplit), oddNums, numOdds, evenNums, numEvens);
即使使用引用参数已经比使用指针干净得多,但建议避免使用动态分配的数组。例如,通过使用标准库容器vector,可将separateOddsAndEvens()函数重写为更安全、更短、更美观并且更具可读性,因为所有内存分配和释放都是自动发生的。
void separateOddsAndEvens(const std::vector<int>& arr, std::vector<int>& odds, std::vector<int>& evens) {for ( int i : arr ) {if ( i % 2 == 1 ) {odds.push_back(i);} else {evens.push_back(i);}}
}
这个版本可以被这样使用:
std::vector<int> vecUnSplit { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
std::vector<int> odds, evens;
separateOddsAndEvens(vecUnSplit, odds, evens);
请注意,你无须释放odds和evens容器,vector类负责此工作。此版本比使用指针或引用的版本更容易使用。
使用向量的版本已经比使用指针或引用的版本好得多,但是正如之前所建议的那样,应尽可能避免使用输出参数。如果一个函数需要返回一些东西,它应该直接返回而不是使用输出参数!如果object是局部变量、函数参数或临时值,return object格式的声明将会触发返回值优化RVO。此外,如果对象是局部变量,命名返回值优化NRVO将会生效。RVO和NRVO都是复制省略的形式,使从函数中返回对象非常高效。使用复制省略功能,编译器可以避免复制从函数返回的对象,这构成零复制值传递语义。
以下版本的separateOddsAndEvens()返回一个简单的包含两个vector的结构体,而不是接收两个输出向量作为参数。它也使用了C++20的指派初始化器。
struct OddsAndEvens {std::vector<int> odds, evens;
};OddsAndEvens separateOddsAndEvens(const std::vector<int>& arr) {std::vector<int> odds, evens;for ( int i : arr ) {if ( i % 2 == 1 ) {odds.push_back(i);} else {evens.push_back(i);}}return OddsAndEvens { .odds = odds,.evens = evens };
}
进行了这些更改之后,用于调用separateOddsAndEvens()的代码变得紧凑,且易于阅读和理解。
std::vector<int> vecUnSplit { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
auto oddsAndEvens { separateOddsAndEvens(vecUnSplit) };
// do something with oddsAndEvens.odds and oddsAndEvens.evens...
注意:不要使用输出参数,如果一个函数需要返回某些东西,直接按值返回即可。
2. const_cast
在C++中,每个变量都有特定的类型。在某些情况下,有可能将一种类型的变量转换为另一种类型的变量。为此,C++提供了5种类型的转换:const_cast()、static_cast()、reinterpret_cast()、dynamic_cast()和bit_cast()。
const_cast()是5种不同类型转换种最简单的,可以使用它为变量添加或取消const属性,这是5种类型转换中唯一可以消除const属性的转换。当然,从理论上来讲,不需要const转换。如果变量是const,则应保持const。但在实际中,有时会遇到这样的情况:一个函数指定接收const参数,然后这个参数将在接收非const参数的函数中使用,并且可以确保后者不会修改其非const参数。“正确”的解决方案是使const在程序中一直保持,但这并不总是可行的,尤其是在使用第三方库的情况下。因此,有时需要舍弃变量的const属性,但是只有在确定所调用的函数不会修改该对象时,才应这样做。否则,除了重构程序,别无选择。示例如下:
void ThirdPartyLibraryMethod(char* str);void f(const char* str) {ThirdPartyLibraryMethod(const_cast<char*>(str));
}
此外,标准库提供了一个名为std::as_const()的辅助方法,该方法定义在<utility>中,该方法接收一个引用参数,返回它的const引用版本。基本上,as_const(obj)等于const_cast<const T&>(obj),其中T是obj的类型。与使用const_cast相比,使用as_const()可以使代码更短,更易读。as_const()的基本用法如下:
std::string str { "C++" };
const std::string& constStr { std::as_const(str) };
3. 异常
C++是一种非常灵活的语言,但并不是非常安全。编译器允许编写改变随机内存地址或者尝试除以0的代码。异常就是试图增加一个安全等级的语言特性。
异常是一种预料之外的情形。例如,编写一个获取Web页面的函数,就有几件事情可能出错,包含页面的Internet主机可能被关闭,页面可能是空白的,或者连接可能会丢失。处理这种情况的一种方法是,从函数返回特定的值,如nullptr或其他错误代码。异常提供了处理该类问题的更好方法。
异常伴随着一些新术语。当某段代码检测到异常时,就会抛出一个异常,另一段代码会捕获这个异常并执行恰当的操作。下例给出了一个名为divideNumbers()的函数,如果调用者传递给分母的值为0,就会抛出一个异常。使用std::invalid_argument时需要<stdexcept>。
double divideNumbers(double numerator, double denominator) {if ( denominator == 0 ) {throw std::invalid_argument { "denominator can not be 0." }; }return numerator / denominator;
}
当执行throw行时,函数将立刻结束并不返回值。如果调用者将函数调用放到try/catch块中,就可以捕获异常并进行处理,如下面的代码所示。请记住,建议通过const引用捕获异常,例如下面示例中的const std::invalid_argument&。还要注意,所有标注库异常类都有一个名为what()的方法,该方法返回一个字符串,其中包含对该异常的简要说明。
try {std::cout << divideNumbers(2.5, 0.5) << "\n";std::cout << divideNumbers(2.3, 0) << "\n";std::cout << divideNumbers(4.5, 2.5) << "\n";
} catch ( const std::invalid_argument& exception ) {std::cout << std::format("Exception caught: {}\n", exception.what());
}
第一次调用divideNumbers()成功执行,结果会输出给用户。第二次调用会抛出一个异常,不会返回值,唯一的输出是捕获异常时输出的错误信息。第三次调用根本不会执行,因为第二次调用抛出了一个异常,导致程序跳转到catch块。前面代码块的输出:
5
Exception caught: denominator can not be 0.
C++的异常非常灵活,为正确使用异常,需要理解抛出异常时栈变量的行为,必须正确捕获并处理必要的异常。另外,如果需要在异常中包含有关错误的更多信息,则可以编写自己的异常类型。最后,C++编译器不会强迫你捕获所有可能发生的异常。如果你的代码从不捕获异常,但是引发了异常,则该程序将终止。
参考
[比] 马克·格雷戈勒著 程序喵大人 惠惠 墨梵 译 C++20高级编程(第五版)