——引用、const_cast和异常)
目录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_cast3. 异常参考1. 引用专业的C代码都广泛地使用了引用引用是另一个变量的别名。对引用的所有修改都会更改其引用的变量的值可以将其视为隐式指针它省去了获取变量地址和解引用指针的麻烦。可以创建独立的引用变量在类中使用引用数据成员接受引用作为函数和方法的参数并从函数和方法返回引用。1.1 创建引用变量引用变量必须在创建时被初始化例如intx{3};intxRef{x};给类型附加上一个则指示相应的变量是一个引用。它仍能像正常变量一样被使用但是在幕后它实际上是指向原始变量的指针。变量x和引用变量xRef指向同一个值也就是说xRef只是x的另一个名称。如果通过其中一个更改值则也可以在另一个中看到更改。例如以下代码通过xRef将x设置为10xRef 10;不允许在类定义之外声明一个引用而不对其进行初始化。int emptyRef; // does not compile!警告引用变量必须总是在创建时被初始化。1.2 修改引用引用始终指向它初始化时的那个变量引用一旦创建便无法更改。对于刚开始使用C的程序员来说语法可能令人困惑。如果在声明引用时将变量赋值给引用则引用指向该变量。但是如果之后将变量赋值给引用则引用所指变量的值会更改为赋值变量的值。原来的引用不会改为指向新的变量。示例如下intx{3},y{4};intxRef{x};xRefy;// change value of x to 4. doesnt make xRef refer to y.即使使用y的地址对xRef赋值也无法规避此限制。xRef y; // does not compile!这句代码会编译失败y的地址是一个指针但是xRef被声明为一个对int的引用而不是对指针的引用。如果将引用赋值给引用会怎么办这样会使第一个引用指向第二个引用所指的变量吗你可能会想尝试以下代码intx{3},z{5};intxRef{x};intzRef{z};zRefxRef;// assigns value, not references.最后一行不会更改zRef而是将z的值设置为3因为xRef引用x即3。警告一旦将引用初始化为引用特定变量就无法将引用更改为引用另一个变量。只能更改引用所指向变量的值。1.3 const引用应用于引用的const通常比应用于指针的const容易这有两个原因。首先引用默认是const因为你不能更改它们的指向。因此不需要显式标记它们为const。其次你无法创建对引用的引用因此通常只有一个间接引用级别。获得多个间接级别的唯一方法是创建对指针的引用。因此当C程序员提起const引用时他们的意思是这样的intz;constintzRef{z};zRef4;// 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;这样的代码。intunnamedRef1{5};// does not compile.constintunnamedRef2{5};// works as expected.临时对象也是如此。不能为临时对象创建对非const的引用但是const引用是可以的。例如假设具有以下返回std::string对象的函数。std::stringgetString(){returnHello, world!;}可以为getString()的结果创建一个const引用该引用将使临时std::string对象保持生命周期直到该引用超出作用域。std::stringstring1{getString()};// does not compile.conststd::stringstring2{getString()};// works as expected.1.4 指针的引用和引用的指针可以创建对任何类型的引用包括指针类型。这是对指向int的指针的引用的示例int*intP{nullptr};int*ptrRef{intP};ptrRefnewint;*ptrRef5;语法有点奇怪你可能不习惯看到*和彼此相邻。但是语义很简单ptrRef是对intP的引用intP是对int的指针。修改ptrRef会更改intP。对指针的引用很少见但有时可能有用。取一个引用的地址与取该引用所指向的变量的地址得到的结果是相同示例如下intx{3};intxRef{x};int*xPtr{xRef};// address of a reference is pointer to value.*xPtr100;该代码通过取x的引用的地址来将xPtr设置为指向x。将100赋值给*xPtr会将x的值更改为100。由于类型不匹配xPtrxRef的比较是无法编译的xPtr是指向int的指针而xRef是对int的引用。比较xPtrxRef和xPtrx都可以正常编译。最后请注意不能声明对引用的引用和对引用的指针。例如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.constauto[theString,theInt]{myPair};// decompose into references-to-const.1.6 引用数据成员类的数据成员可以是引用。如前所述引用不能不指向其他变量而存在并且不可以更改引用指向的变量。因此引用数据成员不能在类构造函数的函数体内部进行初始化必须在所谓的构造函数初始化器中进行初始化。在语法方面构造函数初始化器紧跟在构造函数声明之后并以冒号开头。以下是一个展示构造函数初始化器的简单示例。classMyClass{public:MyClass(intref):m_ref{ref}{/* body fo constructor */}private:intm_ref;}警告引用必须始终在创建时被初始化。通常引用是在声明时创建的但是引用数据成员需要在类的构造函数初始化器中初始化。1.7 引用作为函数参数C程序员通常不使用独立的引用变量或引用数据成员引用的最常见用途是用于函数的参数。默认的参数传递语义是值传递函数接收其参数的副本。修改这些参数后原始实参保持不变。栈中变量的指针在C语言中使用以允许函数修改其他栈帧中的变量。通过对指针的解引用函数可以修改表示该变量的内存即使该变量不在当前的栈帧中。这种方法的问题在于它将指针复杂的语法带入了原本简单的任务。相对于向函数传递指针C提供了一种更好的机制称为引用传递参数是引用而不是指针。以下是addOne()函数的两种实现第一种对传入的变量没有影响因为它是值传递的因此该函数将接收传递给它的值的副本。第二种使用引用因此改变了原始变量。voidaddOne(inti){i;// has no real effect because this is a copy of the original.}voidaddOne(inti){i;// actually changes the original variable.}调用具有整型引用参数的addOne()函数的语法与调用具有整型参数的addOne()函数没有区别。intmyInt{7};addOne(myInt);注意两个addOne()函数的实现之间存在微妙区别。使用值传递版本可以接收字面量而不出现任何问题例如addOne(3)是合法的。然而如果向引用传递的addOne()函数传递字面量会导致编译错误。可使用下面一节的const引用传递解决该问题。这是另一个引用派上用场的例子这是一个简单的交换函数用于交换两个int类型的值。voidswap(intfirst,intsecond){inttemp{first};firstsecond;secondtemp;}可以向这样调用它intx{5},y{6};swap(x,y);当使用实参x和y调用swap()时形参first被初始化为对x的引用second被初始化为对y的引用。当swap()修改first和second时实际上更改的是x和y。当你有一个指针但函数或方法只能接收引用时就会产生一个常见的难题。在这种情况下可以通过对指针解引用将其转换为引用。该操作提供了指针所指向的值编译器随后使用该值初始化引用参数。例如可以像这样调用swap()intx{5},y{6};int*xp{x},*yp{y};swap(*xp,*yp);最后如果函数需要返回一个复制成本高昂的类的对象函数接收一个对该类的非const引用的输出参数此后进行修改而非直接返回对象。开发人员认为这是防止从函数返回对象时创建副本从而导致性能损失的推荐方法。但是即使在那时编译器通常也足够聪明可以避免任何冗余的复制。警告从函数返回对象的推荐方法是通过值返回而不是使用一个输出参数。1.8 const引用传递const引用的参数的主要目的是效率。当将值传递给函数时便会生成一个完整副本。传递引用时实际上只是传递指向原始对象的指针因此计算机无须生成副本。通过const引用传递可以做到二者兼顾不生成任何副本且无法更改原始变量。当处理对象时const引用变得非常重要因为对象可能很大并且对其进行复制可能会产生有害的副作用。下面的示例将展示如何将std::string作为const引用传递给函数voidprintString(conststd::stringmyString){std::coutmyString\n;}intmain(){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()函数。voidswap(int*first,int*second){inttemp{*first};*first*second;*secondtemp;}但是此代码比使用引用的版本更杂乱。引用使程序简洁更易于理解。它们也比指针安全因为没有空引用并且不需要显式解引用因此不会遇到与指针相关的任何解引用错误。当然这些关于引用更安全的争论只有在没有任何指针的情况下才有意义。例如使用下面的函数该函数接受对int的引用。voidrefcall(intt){t;}可以声明一个指针并将其初始化以指向内存中的某个随机位置。然后可以解引用此指针并将其作为引用参数传递给refcall()如以下代码所示。这段代码可以成功编译但是并不确定执行后会发生什么。例如它可能导致程序崩溃。int*ptr{(int*)8};refcall(*ptr);大多数时候可以使用引用而不是指针。与指向对象的指针相同对对象的引用也支持所谓的多态性。但是在某些情况下需要使用指针。一种情况是需要更改其指向的位置时。回顾一下不能更改引用所指向的变量。例如当分配动态内存时需要将指向结果的指针存储在指针而不是引用中。需要指针的第二种情况是指针是optional的即当它可以为nullptr时。另一个用例是如果想将多态类型存储在容器中。很久以前在遗留代码中选择参数和返回类型中使用指针还是引用的一种方法是考虑内存的所有权。如果接收变量的代码为所有者并因此负责释放与对象关联的内存则它必须接收指向该对象的指针。如果接收该变量的代码不必释放内存那么它接收一个引用。但是现在应避免使用原始指针而使用所谓的智能指针这是转让所有权的推荐方法。注意尽量选择引用而不是指针也就是说只有在无法使用引用的情况下才选择使用指针。考虑将一个整数数组分为两个数组的函数分别存放奇数和偶数。该函数不知道源数组中的偶数或奇数个数因此它应在检查源数组后为目标数组动态分配内存。它还应该返回两个新数组的大小。总共有4项要返回指向两个新数组的指针以及两个新数组的大小。显然必须使用引用传递。规范的C的写法如下所示voidseparateOddsAndEvens(constintarr[],size_t size,int**odds,size_t*numOdds,int**evens,size_t*numEvens){// count the number of odds and evens.*numOdds*numEvens0;for(size_t i0;isize;i){if(arr[i]%21){(*numOdds);}else{(*numEvens);}}// allocate two new arrays of the appropriate size.*oddsnewint[*numOdds];*evensnewint[*numEvens];// copy the odds and evens to the new arrays.size_t oddsPos0,evensPos0;for(size_t i0;isize;i){if(arr[i]%21){(*odds)[oddsPos]arr[i];}else{(*evens)[evensPos]arr[i];}}}该函数的最后4个参数是”引用“参数。若要更改它们引用的值separateOddsAndEvens()必须对它们解引用这会导致函数体内的语法丑陋。此外当调用separateOddsAndEvens()时必须传递两个指针的地址以便函数可以更改实际的指针并传递两个size_t的地址以便函数可以更改实际的size_t。还要注意调用方要负责删除由separateOddsAndEvens()创建的两个数组。intunSplit[]{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;oddNumsnullptr;delete[]evenNums;evenNumsnullptr;如果此语法令你烦恼则可以编写相同的函数以获得真正的引用传递语义。voidseparateOddsAndEvens(constintarr[],size_t size,int*odds,size_tnumOdds,int*evens,size_tnumEvens){numOddsnumEvens0;for(size_t i{0};isize;i){if(arr[i]%21){numOdds;}else{numEvens;}}oddsnewint[numOdds];evensnewint[numEvens];size_t oddsPos{0},evensPos{0};for(size_t i{0};isize;i){if(arr[i]%21){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()函数重写为更安全、更短、更美观并且更具可读性因为所有内存分配和释放都是自动发生的。voidseparateOddsAndEvens(conststd::vectorintarr,std::vectorintodds,std::vectorintevens){for(inti:arr){if(i%21){odds.push_back(i);}else{evens.push_back(i);}}}这个版本可以被这样使用std::vectorintvecUnSplit{1,2,3,4,5,6,7,8,9,10};std::vectorintodds,evens;separateOddsAndEvens(vecUnSplit,odds,evens);请注意你无须释放odds和evens容器vector类负责此工作。此版本比使用指针或引用的版本更容易使用。使用向量的版本已经比使用指针或引用的版本好得多但是正如之前所建议的那样应尽可能避免使用输出参数。如果一个函数需要返回一些东西它应该直接返回而不是使用输出参数如果object是局部变量、函数参数或临时值return object格式的声明将会触发返回值优化RVO。此外如果对象是局部变量命名返回值优化NRVO将会生效。RVO和NRVO都是复制省略的形式使从函数中返回对象非常高效。使用复制省略功能编译器可以避免复制从函数返回的对象这构成零复制值传递语义。以下版本的separateOddsAndEvens()返回一个简单的包含两个vector的结构体而不是接收两个输出向量作为参数。它也使用了C20的指派初始化器。structOddsAndEvens{std::vectorintodds,evens;};OddsAndEvensseparateOddsAndEvens(conststd::vectorintarr){std::vectorintodds,evens;for(inti:arr){if(i%21){odds.push_back(i);}else{evens.push_back(i);}}returnOddsAndEvens{.oddsodds,.evensevens};}进行了这些更改之后用于调用separateOddsAndEvens()的代码变得紧凑且易于阅读和理解。std::vectorintvecUnSplit{1,2,3,4,5,6,7,8,9,10};autooddsAndEvens{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属性但是只有在确定所调用的函数不会修改该对象时才应这样做。否则除了重构程序别无选择。示例如下voidThirdPartyLibraryMethod(char*str);voidf(constchar*str){ThirdPartyLibraryMethod(const_castchar*(str));}此外标准库提供了一个名为std::as_const()的辅助方法该方法定义在utility中该方法接收一个引用参数返回它的const引用版本。基本上as_const(obj)等于const_castconst T(obj)其中T是obj的类型。与使用const_cast相比使用as_const()可以使代码更短更易读。as_const()的基本用法如下std::string str{C};conststd::stringconstStr{std::as_const(str)};3. 异常C是一种非常灵活的语言但并不是非常安全。编译器允许编写改变随机内存地址或者尝试除以0的代码。异常就是试图增加一个安全等级的语言特性。异常是一种预料之外的情形。例如编写一个获取Web页面的函数就有几件事情可能出错包含页面的Internet主机可能被关闭页面可能是空白的或者连接可能会丢失。处理这种情况的一种方法是从函数返回特定的值如nullptr或其他错误代码。异常提供了处理该类问题的更好方法。异常伴随着一些新术语。当某段代码检测到异常时就会抛出一个异常另一段代码会捕获这个异常并执行恰当的操作。下例给出了一个名为divideNumbers()的函数如果调用者传递给分母的值为0就会抛出一个异常。使用std::invalid_argument时需要stdexcept。doubledivideNumbers(doublenumerator,doubledenominator){if(denominator0){throwstd::invalid_argument{denominator can not be 0.};}returnnumerator/denominator;}当执行throw行时函数将立刻结束并不返回值。如果调用者将函数调用放到try/catch块中就可以捕获异常并进行处理如下面的代码所示。请记住建议通过const引用捕获异常例如下面示例中的const std::invalid_argument。还要注意所有标准库异常类都有一个名为what()的方法该方法返回一个字符串其中包含对该异常的简要说明。try{std::coutdivideNumbers(2.5,0.5)\n;std::coutdivideNumbers(2.3,0)\n;std::coutdivideNumbers(4.5,2.5)\n;}catch(conststd::invalid_argumentexception){std::coutstd::format(Exception caught: {}\n,exception.what());}第一次调用divideNumbers()成功执行结果会输出给用户。第二次调用会抛出一个异常不会返回值唯一的输出是捕获异常时输出的错误信息。第三次调用根本不会执行因为第二次调用抛出了一个异常导致程序跳转到catch块。前面代码块的输出5 Exception caught: denominator can not be 0.C的异常非常灵活为正确使用异常需要理解抛出异常时栈变量的行为必须正确捕获并处理必要的异常。另外如果需要在异常中包含有关错误的更多信息则可以编写自己的异常类型。最后C编译器不会强迫你捕获所有可能发生的异常。如果你的代码从不捕获异常但是引发了异常则该程序将终止。参考[比] 马克·格雷戈勒著 程序喵大人 惠惠 墨梵 译 C20高级编程(第五版)