C++ 引用:使用和安全性的清晰说明。






4.68/5 (18投票s)
关于何时使用 C++ 引用以及如何知道它们是安全的,有一些清晰的标准。
引言
几年前,我写了一篇关于 C++ 引用的文章,当时我仍然对何时使用它们以及它们何时安全感到有些困惑。那篇文章风格有些新闻报道式,不幸的是,它引发了一场关于 C++ 引用的身份、含义和编译解释的熟悉的“争论”。
从那时起,我花了一些精力来弄清这个问题,以下是我的结论。
我不想再次点燃这场争论——我认为我在这里写的内容对双方都有说服力。
关于使用 C++ 引用的标准的干巴巴陈述
当测试其有效性没有任何好处时,请使用引用而不是指针。这可能是
- 因为您有充分的理由知道它将始终有效,因此测试它将浪费时间(C++ 引用的主要和最预期的用途)。
- 或者,因为测试指针是否为非零值并不能可靠地验证其有效性。
除了提供一些语法上的便利之外,使用 C++ 引用而不是指针可以使情况更清晰。在第一种情况下,它防止程序员进行不必要的有效性测试;在第二种情况下,它防止程序员被欺骗而进行不可靠的有效性测试。
我认为这是一个选择 C++ 引用而不是指针的可靠设计标准,并且它在最常见的应用中得到了说明,其中第一个应用,即复制构造函数,实际上需要使用 C++ 引用,并且有效地定义了它的语法和文法。
复制构造函数
复制构造函数不能接受按值传递的参数,因为按值传递正是它所定义的。
class CClass { CClass(CClass c) //cannot be allowed to compile //because the compiler would enter infinite recursion { //Initialisation code } };
它可以被写成接收指针参数,但那样它将不再被编译器识别为复制构造函数,并且它所有隐式的应用(创建临时副本等)都将不起作用。
class CClass { CClass(CClass* pC) { //Initialisation code } }; CClass Function() { CClass c; return c; } //does not use the constructor defined above to make the temporary copy returned
C++ 引用是提供连贯的复制构造函数表达式所必需的。
class CClass { CClass(CClass const & c) { //Initialisation code } }; CClass Function1() { CClass c; return c; } //uses the copy constructor defined above
引用参数 CClass& const c
指定要匹配和接收的类型是 CClass
对象,但它是一个引用(是的,一个底层指针)被实际传入。由于传入参数的类型是 CClass
值,因此没有必要具有允许您测试和将地址设为零的指针语义。您在构造函数中使用它,就像使用值一样,因为它就是值。
const
修饰符很重要。它声明传入的引用将不会用于修改它所指向的内容。这告诉编译器可以传递 const 对象。没有它,编译器将不会将 const 对象与此复制构造函数匹配,而是使用自己的默认值。您不会轻易知道这一点,因为代码可以正常编译。
乍一看,如果您希望引用参数不区分 const 性,则必须为其提供 const
修饰符,这似乎是矛盾的;只有当您希望它拒绝 const 对象时,才省略 const
修饰符。当然,实际上发生的是,如果没有 const
修饰符,您就表明有意修改被引用的对象,而 const 对象不允许这样做。
将参数传递给函数
我们可以在 C++ 引用和指针之间选择的最常见情况是传递给函数和方法的参数类型。使用 C++ 引用的坚实理由是,当您传递在调用作用域中声明的值或包含它的更广阔的作用域中的值时。
CClass c; Function1(c); void Function1(CClass& c) { c.DoSomething(); }
在这种情况下,作用域确保传递进来的 C++ 引用在函数或方法的整个执行过程中始终有效。在函数或方法中测试其有效性没有价值,并且可以通过点运算符将其视为静态变量,因为它们是静态变量,但在更广阔的作用域中声明。从低级角度看,它们非常稳固地位于堆栈的更下方。
您也可以使用指针来实现相同的功能……
CClass c; Function1(&c); void Function1(CClass* pC) { pC->DoSomething(); }
……但调用和函数定义都更丑陋,而且没有任何好处。您知道变量 c
是有效的,所以没有必要暗示它可能无效。
当然,您可以使用引用,但将其视为隐藏的指针,但从官方语言定义中采纳所建议的观点很有用,即将引用传递给函数可以使该函数直接访问在更广阔作用域中已声明的变量。更广阔作用域中的变量始终存在,而引用为您提供了一个名称,您可以据此引用它。
结构上安全的 C++ 引用
到目前为止所描述的引用的用法是使用引用的例子,因为引用的变量的有效性是有保证的,并且测试它没有意义。它们是完全安全的,也就是说,它们由作用域保证在结构上是安全的。现在有趣的是,如果 C++ 引用被设计成禁止以下操作……
• 从指针转换
CClass* pC=NULL; CClass& c= *(pC); //c is invalid object
• 从函数返回
CClass& Function3() { CClass c; return c; //c is destroyed on destruction and a reference to where it used to be is returned }
• 初始化中涉及的指针解引用
CClass& c=Parent.pChild->Object.c; //Object is not guaranteed to always exist
……那么 C++ 引用将保证在所有使用中始终是安全的。这是因为 C++ 引用本质上是 const 的。其声明必须包括初始化,并且此后不能更改。这意味着您不能声明它,然后在较窄的作用域中稍后将其分配给某个东西。您只能将其初始化为已在同一作用域或更广阔的作用域中存在且因此保证与引用本身一样存在的对象。
访问动态集合元素——并非总是安全
回到 C++ 的实际世界,上述操作并未被禁止。它们不仅被允许,而且被用于非常重要的用途。当然,您可以在很大程度上自己禁止使用 C++ 引用来确保其安全。我稍后会详细介绍这一点。但首先,让我们看看允许这些危险操作的一些好处,并充分意识到其代价是我们失去了所有 C++ 引用始终有效的全面保证。
使用普通静态数组,我们使用 []
运算符来读取和修改数组元素或执行它们的成员函数……
CClass a[8]; a[0].intval=5; a[0].DoSomething();
……但在很多时候,我们使用动态集合而不是数组,并且我们发现使用相同的语法非常方便。
vector<CClass> v; v.resize(8); v[0].intval=5; v[0].DoSomething();
动态集合通过重载 []
运算符来实现这一点。现在,如果 []
运算符按值返回 CClass
对象,那么它将返回一个副本,并且将 5 赋值给 intval
和调用 DoSomething()
将在临时副本上执行,然后将其丢弃——这不是我们真正想要的。因此,它返回对数组中 CClass
对象的 C++ 引用。此外,为了提供该 C++ 引用,它必须将其持有的指向动态创建的对象的指针转换为 C++ 引用,或者在初始化 C++ 引用时使用指针解引用。无论哪种方式,这都是对上述禁止操作的彻底违背,而这些禁止操作本可以使 C++ 引用在结构上保持安全。
现在,对于直接使用 []
运算符对集合的未命名元素执行操作,这是完全安全的。返回的 C++ 引用是临时的、未命名的、不可见的,并且只在执行单个操作期间存在,在此期间,被引用的元素不会发生任何可能使其失效的事情。问题出现在您决定做一些巧妙的事情以避免对同一元素进行重复解引用时。
您可以制作一个副本,对其进行操作,然后将其复制回来,但两个副本会增加执行开销。
CClass c=v[0]; c.intval=5; c.DoSomething(); v[0]=c;
一个非常有效的解决方案是声明一个对返回的元素命名的引用并对其进行操作。
CClass& c=v[0] c.intval=5; c.DoSomething();
没有进行复制,也不需要复制回来,您直接操作了数组元素本身。这效率极高,而且赏心悦目。上面的例子也是完全安全的,但是如果在使用该引用的过程中集合受到任何干扰……
CClass& c=v[0] c.intval=5; v.resize(8); //may cause v[0] to be moved in memory c.DoSomething(); //c may now represent an invalid object
……那么我们可能会发现自己遇到了一个无效的 C++ 引用,这非常糟糕,因为我们喜欢认为它们是安全的,甚至赋予它们静态声明变量的语法。
您也可以使用指针……
CClass* pC=&v[0] pC->intval=5; v.resize(8); //may cause v[0] to be moved in memory if(pC) //passes test because pC still points at where the element was //it is non NULL pC->DoSomething(); // pC may now point at invalid memory
……但您为丑陋的语法获得的所有好处只是一个虚假的“安全感”,因为测试是无用的。当数组重新排列自身时,它不会通知您的指针 pC
将其自身设置为 NULL。
无可避免的事实是,一旦您使用从函数或方法返回的 C++ 引用(包括 []
运算符)初始化了一个命名的 C++ 引用,您就离开了结构安全的舒适区,必须采取自己知情的措施来确保安全。一种解决方案是使用花括号将集合元素的命名引用保持在非常严格的作用域内,并避免在它们生命周期内发生任何集合操作的干扰。
{ CClass& c=v[0]; //take a reference to the element c.intval=5; //work on it c.DoSomething(); }//close scope before touching the array v.resize(8); //array operation, may move elements { CClass& c=v[0]; //take a reference to the element again c.DoSomething(); //work on it c.intval=5; //safe as long as DoSomething() didn't disturb the array //watch out for this gotcha! }
在展示了这种高效但可以说是“不受保护”的 C++ 引用使用方式之后,我应该指出它引入的另一个危险。
这是漂亮的
CClass& c=v[0] c.intval=5; c.DoSomething();
但只要在输入时犯一个错误
CClass c=v[0] c.intval=5; c.DoSomething();
它将编译并运行,但不会做您想做的事情。它将修改一个副本,然后将其丢弃。小心!
对动态集合元素的长期安全引用
如果您确实想引用动态集合的元素,并在集合可能发生变化的情况下持有它,并希望在它失效时自动将其归零,那么 C++ 引用和原始指针都无法做到,您需要使用引用计数的智能指针。
在标准库中,只要将数组元素声明为 std:shared_ptr<T>
,就可以安全地引用它们。
vector<std::shared_ptr<CClass> > v; v.resize(8); v[0]=new CClass; v[1]=new CClass; std::shared_ptr<CClass> spC0= v[0]; //shared ownership reference std::weak_ptr<CClass> wpC1= v[1]; //observing reference
这有一些缺点。vector
的元素容易受到共享所有权的影响,因此您会失去重置元素将删除它的保证。使用 shared_ptr
持有长期引用就是这样做,它会保持对象存活。对于观察性引用,您需要使用 std::weak_ptr
,并接受每次想要解引用它时都必须将其转换为 std::shared_ptr
。
如果您不跨线程共享您的对象,并且您的设计根本上是单所有权,那么这些是严重且不必要的缺点。另一种选择是利用我最近在 Code Project 上发布的一个智能指针系统。
vector<std::owner_ptr<CClass, ElementType> > v; //array of exclusive owners //that will survive STL collections. v.resize(8); v[0]=new CClass; v[1]=new CClass; ref_ptr<CClass> rC0= v[0]; // observing reference supporting direct dereference with the -> operator //sharing ownership is expressly prohibited
或者,如果您想要一个值数组而不是指针数组
vector<super_gives_ref_ptr<CClass > > v; //array of values, super classed to provide ref_ptr_to_this() method. v.resize(8); ref_ptr<CClass> rC0= v[0].ref_ptr_to_this(); // observing reference supporting direct dereference with the -> operator //sharing ownership is expressly prohibited
这些智能指针在所有时候都是安全的,因为它们要么有效,要么测试为零。
其他不安全的 C++ 引用用法
我们可以从明显的“自寻死路”的例子开始。
CClass* pC=NULL; CClass& c= *(pC); //c is invalid object CClass& Function3() { CClass c; return c; //c is destroyed on destruction and a reference to where it used to be is returned }
以及一个不太明显的例子……
CClass& c=Parent.pChild->Object.c; //Object is not guaranteed to always exist
……初始化中的 -> 表明可能涉及动态创建,因此该引用可能会失效。
最常见的危险可能发生在当您有函数和方法接受引用参数作为参数,而您需要传递一个由指针引用的对象时。
void Function1(CClass& c) { c.DoSomething(); } CClass* pC=GetObject(); Function1(*pC);
如果 pC
最终为 NULL,则 Function1 将收到一个无效的引用。
所以为了防止这种情况发生,我们可以先添加一个测试,只有当指针非零时才调用函数。
CClass* pC=GetObject(); if(pC) Function1(*pC);
好的,但请记住,当指针指向的对象被销毁时,指针并不总是会被重置,所以在一般情况下,非空测试并不可靠。您确实需要仔细查看指针的来源以及您真正需要做什么来测试其有效性。
进一步的结构安全的 C++ 引用用法
有一种非常常见的情况,即 C++ 引用绝对是正确的选择,那就是作为对父对象的反向引用。请注意,与父母和孩子的人类类比不同,我们谈论的是一种关系,在这种关系中,孩子只有在父母存活的情况下才能存在,因此通常孩子将是父类的成员,或者将由父类的一个成员智能指针动态创建和持有。也就是说,父母的存活时间保证比孩子长。通常程序员使用原始指针……
class CChild { CParent* m_pParent; }; Class CParent { CChild m_Child; public: CParent() { m_Child. m_pParent=this; } };
或者,如果孩子是动态创建的
class CChild { CParent* m_pParent; public: CChild(CParent* pParent) { m_pParent= pParent; } }; Class CParent { owner_ptr<CChild> m_apChild; public: CParent() { m_apChild = NULL; } Void CreateChild() { m_apChild = new CChild(this); } };
……但我们知道父母总是在那里,所以永远不需要检查反向指针是否有效,也没有理由将其暴露于被清零的危险之中。有时智能指针被用作反向指针,这可能会导致灾难……
std::shared_ptr<CParent> m_spParent: //provokes cyclic references causing memory leaks
……或者毫无意义……
std::weak_ptr<CParent> m_wpParent:
正确的解决方案是使用 C++ 引用,但是如何初始化作为类成员的 C++ 引用的知识并不广泛,并且需要理解初始化列表。问题在于,引用必须在它存在时立即初始化为指向一个有效对象,而在构造函数体中初始化它已经太晚了。此时,所有成员都已创建并准备好使用。幸运的是,C++ 允许您在构造函数体外部定义一个初始化列表,您可以在其中为任何将在创建时分配的成员指定初始值,并且在这里我们可以初始化任何引用成员。
class CChild { CParent& Parent; public: CChild(CParent& _Parent) : Parent(_Parent) //initialiser list { } };
请注意,我将反向引用命名为 Parent
而不是 m_Parent
或 m_rParent
。这是因为它实际上不是一个成员,也不需要被视为引用。它是对父对象的直接引用,因此应该被视为仅仅是父对象。
对于动态创建的孩子,父代码看起来会像这样……
Class CParent { owner_ptr<CChild> m_apChild; public: CParent() { m_apChild = NULL; } Void CreateChild() { m_apChild = new CChild(this); } };
……但如果孩子是父类的一个成员,我们也需要使用父类的初始化列表来初始化它。
Class CParent { CChild m_Child; public: CParent() : m_Child(*this); { } };
父类中的两个同级子成员也可以初始化为相互引用。
class CChild { CChild & OtherChild; public: CChild(CChild & _ OtherChild) : OtherChild (_OtherChild) { } }; Class CParent { CChild m_Child1; CChild m_Child2; public: CParent() : m_Child1(m_Child2), m_Child2(m_Child1); { } };
还有其他 C++ 引用的安全用法。虽然它们可能看起来微不足道,但它们可以作为编译时开关很有用。
对同一作用域内的某个对象的引用……
CClass C; CClass& c=C;
……乍一看这似乎毫无意义,因为任何可以引用引用 c
的地方,您也可以引用原始变量 C
,但如果我们有……
CClass C; CClass C1; CClass& c=C; // loads of code working on c which refers to C
然后,如果我们想使用 C1
而不是 C
,我们只需更改一行代码。
CClass& c=C1; // loads of code working on c which refers to C1
也有一些情况下,C++ 引用可以由函数或方法返回并且是完全安全的。
当它是对全局变量的引用时;这可能是一个函数或方法,它根据各种条件选择要返回的全局变量来工作……
CClass g_C; CClass g_C1; CClass& GetAppropriateClassObjectToWorkWith() { if(Condition) return g_C; else return g_C1; }
……无论哪种情况,它都将返回一个对全局变量的引用,这将是完全安全的。
对于类的私有非静态方法返回对成员变量的引用也是安全的,因为它只能被赋给一个生命周期仅限于调用方法内的局部变量……在此期间,类对象及其所有成员都必须仍然存在。
CParent { private: CChild m_Child1; CChild m_Child2; CChild & GetAppropriateChildObjectToWorkWith() { if(Condition) return Child1; else return Child2; } public: void DoSomething() { CChild& Child= GetAppropriateChildObjectToWorkWith(); //Work with Child. } };
您还可以有公共方法返回类成员的引用,但这将允许您初始化一个可能在类对象生命周期之外的引用。您不再拥有结构安全性,并且将不得不诉诸自己知情的措施来确保安全。
C++ 引用的使用能否优化编译代码?
对于当前编译器来说,简短的答案是否定的。例如,在将引用传递给函数的一般情况下,需要存储一个底层指针,因为在编译函数时不知道调用上下文,并且它可能具有几个不同的调用上下文。然而,有许多函数和方法,特别是类的私有方法,总是以相同的调用上下文调用。未来的编译器可以检测到这一点,而不是为每次调用存储传递进来的底层指针;它可以简单地将一个偏移量硬编码到堆栈中,引用变量总是在那里找到,从而消除了每次调用时传递底层指针的需要。
当然,如果您用指针表达同样的事情,那么您就是在明确要求编译器为它们创建存储空间,除非它能够轻易地发现您的请求完全冗余,否则它就会这样做。您对编译器真正想要的了解越具体……
void func1(A& a) //I want 'a' to refer to the existing variable of type A referenced in the call. void func2(A* pA) //I want a new variable A* pA which is a copy of the variable of type A* //referenced in the call which may or may not hold the address of a variable of type A.
……您就越有可能以最高效的方式获得它。更重要的是,您和其他程序员就越有可能理解您的真实意图。