指针只不过是一个保存内存地址的变量。如果使用得当,指针会保存一个包含对象的有效内存地址,该地址与指针的类型兼容。像 C# 中的引用一样,特定执行环境中的所有指针都具有相同的大小,而不管指针指向的数据类型如何。例如,当一个程序为 32 位操作系统编译并运行时,指针通常为 4 字节(32 位)。
指针可以指向任何内存地址。您可以并且经常会拥有指向栈上的对象的指针。您还可以有指向静态对象、线程本地对象的指针,当然还有指向动态(即堆分配)对象的指针。当对指针只是一知半解的程序员想到它们时,通常是在动态对象的上下文中。
由于潜在的泄漏,您应该永远不要在智能指针之外分配动态内存。C++ 标准库提供了两个您应该考虑的智能指针:std::shared_ptr
和std::unique_ptr
。
通过将动态持续时间对象放入其中一个对象中,您可以保证当包含指向该内存的指针的std::
unique_ptr
或最后一个std::shared_ptr
超出范围时,该内存将使用正确版本的 delete ( delete
或delete[]
)正确释放,因此不会泄漏。这是上一章中的 RAII 模式。
当您使用智能指针正确执行 RAII 时,只会发生两件事:分配成功,因此当智能指针超出范围或分配失败时,内存将被正确释放,在这种情况下,没有分配内存,因此没有泄漏。实际上,最后一种情况在现代个人电脑和服务器上应该很少见,因为它们的内存很大,并且提供了虚拟内存。
如果你不使用智能指针,你只是在请求内存泄漏。用new
或new[]
分配内存和用delete
或delete[]
释放内存之间的任何异常都可能导致内存泄漏。如果你不小心,你可能会不小心使用了一个已经被删除的指针,但是没有设置为等于nullptr
。然后,您将访问内存中的某个随机位置,并将其视为有效指针。
在这种情况下,最好的情况是你的程序崩溃。如果没有,那么你正在以奇怪的、未知的方式破坏数据,并且可能将这些破坏保存到数据库中或者在网络上推送它们。你也可能打开了安全问题的大门。所以使用智能指针,让语言为你处理内存管理问题。
常量指针采用SomeClass* const someClass2 = &someClass1;.
的形式,换句话说,*
在const
之前。结果是指针本身不能指向其他任何东西,但是指针指向的数据仍然是可变的。这在大多数情况下不太可能非常有用。
指向常量的指针采用const SomeClass* someClass2 = &someClass1;
的形式。在这种情况下,*
排在const
之后。结果是指针可以指向其他东西,但是您不能修改它所指向的数据。这是一种声明参数的常见方式,您只需要检查这些参数,而不需要修改它们的数据。
指向常量的常量指针采用const SomeClass* const someClass2 = &someClass1;
形式。这里*
夹在两个const
关键词之间。结果是指针不能指向其他任何东西,并且您不能修改它所指向的数据。
常量正确性是指使用const
关键字修饰参数和功能,以便const
关键字的存在或不存在适当地传达任何潜在的副作用。您可以通过在函数参数声明后放置const
关键字来标记成员函数const
。
例如,int GetSomeInt(void) const;
声明了一个常量成员函数——一个不修改其所属对象数据的成员函数。编译器将强制执行这一保证。它还将确保当您将一个对象传递给一个将其作为常量的函数时,该函数不能调用该对象的任何非常量成员函数。
当你从一开始就开始做的时候,设计你的程序来坚持常量正确性是比较容易的。当您坚持 const-正确性时,使用多线程变得更加容易,因为您确切地知道哪些成员函数有副作用。跟踪与无效数据状态相关的 bug 也更容易。在项目中与您合作的其他人在调用某些成员函数时也会注意到类数据的潜在变化。
使用指针(包括智能指针)时,有三个操作符值得关注:*
、&
和->
。
间接运算符*
取消引用指针,这意味着您使用的是被指向的数据,而不是指针本身。在接下来的几个段落中,让我们假设p_someInt
是一个指向没有常量限定的整数的有效指针。
语句p_someInt = 5000000;
不会将值 5000000 赋给所指向的整数。相反,它会将指针设置为指向 32 位系统上的内存地址 5000000,0X004C4B40。内存地址 0X004C4B40 是什么?谁知道呢?可能是你的整数,但也有可能是别的东西。如果你幸运的话,这是一个无效的地址。下次你试图正确使用p_someInt
时,你的程序会崩溃。如果这是一个有效的数据地址,那么你可能会损坏数据。
语句*p_someInt = 5000000;
将值 5000000 赋给p_someInt
指向的整数。这是操作中的间接运算符;它采用p_someInt
并用一个代表指向的地址处的数据的 L 值替换它(我们将很快讨论 L 值)。
运算符的地址&
获取变量或函数的地址。这允许您创建指向本地对象的指针,您可以将其传递给需要指针的函数。您甚至不需要创建一个本地指针来实现这一点;您可以简单地使用您的局部变量,并在它前面加上 address-of 操作符作为参数,一切都将正常工作。
指向函数的指针类似于 C# 中的委托实例。给定这个函数声明:double GetValue(int idx);
这将是正确的函数指针:double (*SomeFunctionPtr)(int);
。
如果你的函数返回了一个指针,比如说:int* GetIntPtr(void);
那么这就是正确的函数指针:int* (*SomeIntPtrDelegate)(void);
。不要让双星号困扰你;只需记住*和函数指针名称周围的第一组括号,这样编译器就会正确地将其解释为函数指针,而不是函数声明。
->
成员访问运算符是当您有指向类实例的指针时,用来访问类成员的运算符。它是间接操作符和.
成员访问操作符的组合。所以p_someClassInstance->SetValue(10);
和(*p_someClassInstance).SetValue(10);
都在做同样的事情。
如果我们不至少简短地谈论 L 值和 R 值,那就不是 C++ 了。l 值之所以这么叫,是因为它们传统上出现在等号的左边。换句话说,它们是可以赋值的值——那些将在当前表达式的评估中幸存下来的值。最常见的 L 值类型是变量,但它也包括调用返回 L 值引用的函数的结果。
传统上,r 值出现在等式的右侧,或者更准确地说,它们是不能出现在左侧的值。它们是常数之类的东西,或者是评估一个方程的结果。例如,a + b,其中 a 和 b 可能是 L 值,但将它们相加的结果是 R 值,或者是返回 void 或 L 值引用以外的任何内容的函数的返回值。
引用就像非指针变量一样。一旦引用被初始化,它就不能引用另一个对象。您还必须在声明引用的地方初始化它。如果您的函数采用引用而不是对象,您就不会产生复制构造的成本。因为引用引用了对象,所以对它的更改就是对对象本身的更改。
就像指针一样,你也可以有一个常量引用。除非您需要修改对象,否则您应该使用 const 引用,因为它们提供编译器检查,以确保在您认为对象没有发生变化时不会发生变化。
有两种类型的引用:L 值引用和 R 值引用。L 值引用由类型名称后的&
标记(如SomeClass&
,而 R 值引用由类型名称后的&&
标记(如SomeClass&&)
)。在大多数情况下,他们的行为是一样的;主要区别在于 R 值引用对于移动语义是极其重要的。
下面的示例显示了指针和引用的用法,并在注释中进行了解释。
示例:指针示例\指针示例. cpp
#include <memory>
//// See the comment to the first use of assert() in _pmain below.
//#define NDEBUG 1
#include <cassert>
#include "../pchar.h"
using namespace std;
void SetValueToZero(int& value)
{
value = 0;
}
void SetValueToZero(int* value)
{
*value = 0;
}
int _pmain(int /*argc*/, _pchar* /*argv*/[])
{
int value = 0;
const int intArrCount = 20;
// Create a pointer to int.
int* p_intArr = new int[intArrCount];
// Create a const pointer to int.
int* const cp_intArr = p_intArr;
// These two statements are fine since we can modify the data that a
// const pointer points to.
// Set all elements to 5.
uninitialized_fill_n(cp_intArr, intArrCount, 5);
// Sets the first element to zero.
*cp_intArr = 0;
//// This statement is illegal because we cannot modify what a const
//// pointer points to.
//cp_intArr = nullptr;
// Create a pointer to const int.
const int* pc_intArr = nullptr;
// This is fine because we can modify what a pointer to const points
// to.
pc_intArr = p_intArr;
// Make sure we "use" pc_intArr.
value = *pc_intArr;
//// This statement is illegal since we cannot modify the data that a
//// pointer to const points to.
//*pc_intArr = 10;
const int* const cpc_intArr = p_intArr;
//// These two statements are illegal because we cannot modify
//// what a const pointer to const points to or the data it
//// points to.
//cpc_intArr = p_intArr;
//*cpc_intArr = 20;
// Make sure we "use" cpc_intArr.
value = *cpc_intArr;
*p_intArr = 6;
SetValueToZero(*p_intArr);
// From <cassert>, this macro will display a diagnostic message if the
// expression in parentheses evaluates to anything other than zero.
// Unlike the _ASSERTE macro, this will run during Release builds. To
// disable it, define NDEBUG before including the <cassert> header.
assert(*p_intArr == 0);
*p_intArr = 9;
int& r_first = *p_intArr;
SetValueToZero(r_first);
assert(*p_intArr == 0);
const int& cr_first = *p_intArr;
//// This statement is illegal because cr_first is a const reference,
//// but SetValueToZero does not take a const reference, only a
//// non-const reference, which makes sense considering it wants to
//// modify the value.
//SetValueToZero(cr_first);
value = cr_first;
// We can initialize a pointer using the address-of operator.
// Just be wary because local non-static variables become
// invalid when you exit their scope, so any pointers to them
// become invalid.
int* p_firstElement = &r_first;
*p_firstElement = 10;
SetValueToZero(*p_firstElement);
assert(*p_firstElement == 0);
// This will call the SetValueToZero(int*) overload because we
// are using the address-of operator to turn the reference into
// a pointer.
SetValueToZero(&r_first);
*p_intArr = 3;
SetValueToZero(&(*p_intArr));
assert(*p_firstElement == 0);
// Create a function pointer. Notice how we need to put the
// variable name in parentheses with a * before it.
void (*FunctionPtrToSVTZ)(int&) = nullptr;
// Set the function pointer to point to SetValueToZero. It picks
// the correct overload automatically.
FunctionPtrToSVTZ = &SetValueToZero;
*p_intArr = 20;
// Call the function pointed to by FunctionPtrToSVTZ, i.e.
// SetValueToZero(int&).
FunctionPtrToSVTZ(*p_intArr);
assert(*p_intArr == 0);
*p_intArr = 50;
// We can also call a function pointer like this. This is
// closer to what is actually happening behind the scenes;
// FunctionPtrToSVTZ is being de-referenced with the result
// being the function that is pointed to, which we then
// call using the value(s) specified in the second set of
// parentheses, i.e. *p_intArr here.
(*FunctionPtrToSVTZ)(*p_intArr);
assert(*p_intArr == 0);
// Make sure that we get value set to 0 so we can "use" it.
*p_intArr = 0;
value = *p_intArr;
// Delete the p_intArray using the delete[] operator since it is a
// dynamic p_intArray.
delete[] p_intArr;
p_intArr = nullptr;
return value;
}
我提到volatile
只是为了告诫不要使用它。像const
一样,可以声明一个变量volatile
。你甚至可以有一个const volatile
;这两者并不相互排斥。
volatile
是这样的:它可能并不意味着你认为它意味着什么。比如对多线程编程不好。volatile
的实际用例极其狭窄。有可能,如果你把volatile
限定词放在一个变量上,你正在做一些可怕的错误。
微软 C# 语言团队的成员埃里克·利伯特将volatile
的使用描述为,“表明你正在做一件彻头彻尾的疯狂的事情:你试图在两个不同的线程上读取和写入相同的值,而没有锁定到位。“他是对的,他的论点完美地延续到了 C++。
使用volatile
应该比使用goto
受到更多的质疑。我这样说是因为我能想到goto
的至少一个有效的通用用法:在完成一个非异常条件时打破一个深度嵌套的循环构造。volatile
相比之下,真正有用的只有你在写设备驱动或者写某种类型 ROM 芯片的代码。在这一点上,您确实应该完全熟悉国际标准化组织/国际电工委员会 C++ 编程语言标准本身,您的代码将在其中运行的执行环境的硬件规格,以及可能的国际标准化组织/国际电工委员会 C 语言标准。
| | 注意:您还应该熟悉目标硬件的汇编语言,这样您就可以查看生成的代码并确保编译器正在生成正确的代码(PDF) 供您使用
volatile
。 |
我一直忽略volatile
关键词的存在,并将在本书的剩余部分继续这样做。这是完全安全的,因为:
- 这是一种语言功能,除非你真的使用它,否则它不会发挥作用。
- 几乎每个人都可以安全地避免使用它。
关于volatile
的最后一个注意事项:它很可能产生的一个影响是较慢的代码。曾几何时,人们认为volatile
产生了和原子性一样的结果。它没有。当正确实现时,原子性保证了多个线程和多个处理器不能同时读写原子访问的内存块。其机制是锁、互斥、语义、栅栏、特殊处理器指令等等。volatile 唯一能做的就是强制 CPU 从内存中获取一个 volatile 变量,而不是使用它可能缓存在寄存器或栈中的任何值。是内存获取减慢了一切。