跳到主要内容

指针和引用

指针

进程中的数据都有其存储的地址1。地址也是数据。存放地址所用的变量类型叫指针变量,简称指针。

补充

指针变量的大小在不同环境下有差异。在 32 位机上,地址用 32 位二进制整数表示,因此一个指针的大小为 4 字节。而 64 位机上,地址用 64 位二进制整数表示,因此一个指针的大小就变成了 8 字节。

地址只是一个刻度一般的数据,为了针对不同类型的数据,「指针变量」也有不同的类型,比如,可以有 int 类型的指针变量,其中存储的地址(即指针变量存储的数值)对应一块大小为 32 位的空间的起始地址;有 char 类型的指针变量,其中存储的地址对应一块 8 位的空间的起始地址。

“类型” 的概念只存在于编译前,编译器会识别出各种数据类型,然后生成目标文件,obj里面就包含了每条语句的汇编实现,所以pnVar++或者pcVar++,在生成的PE文件.text节里面早已写死。

如下自定义结构体 Birthday 类型的指针变量,对应着一块 96 bit 的空间:

struct Birthday {
int year;
int mouth;
int day;
};
备注
  • sizeof 对数组,得到整个数组所占空间大小。
  • sizeof 对指针,得到指针本身所占空间大小。

声明和使用

指针声明:

char* p; //指向字符的指针
char * p = &v[3]; // p指向v的第4个元素
char x = *p; //*p是p指向的对象

指针的使用:

int main() {
Birthday me{1999, 3, 14}, lover{1874, 3, 14};
Birthday* me_ptr = &me;
(*me_ptr) = lover; // me: {1874, 3, 14}
(*me_ptr).year = 1999; // me: {1999, 3, 14}
me_ptr->year = 1874; // me: {1874, 3, 14}
};

偏移

指针变量也可以 和整数 进行加减操作。对于 int 型指针,每加 1(递增 1),其指向的地址偏移 32 位(即 4 个字节);若加 2,则指向的地址偏移 2 × 32 = 64 位。同理,对于 char 型指针,每次递增,其指向的地址偏移 8 位(即 1 个字节)。

void talk(){
int y = 1;
cout << sizeof y << endl;
int * x = &y;
cout << x << endl;
(x)++;
cout << x << endl;
}
output
4
0xbf999ffe24
0xbf999ffe28

偏移访问数组

int main() {
int a[3] = {1, 2, 3};
int* p = a; // p 指向 a[0]
*p = 4; // a: [4, 2, 3]
p = p + 1; // p 指向 a[1]
*p = 5; // a: [4, 5, 3]
p++; // p 指向 a[2]
*p = 6; // a: [4, 5, 6]
};

我们常用 [] 运算符来访问数组中某一指定偏移量处的元素。比如 a[3] 或者 p[4]。这种写法和对指针进行运算后再引用是等价的,即 p[4] 和 *(p + 4) 是等价的两种写法。

nullptr

补充

在 C++11 之前,C++ 和 C 一样使用 NULL 宏表示空指针常量,C++ 中 NULL 的实现一般如下:

// C++11 前

#define NULL 0

空指针和整数 0 的混用在 C++ 中会导致许多问题,比如:

int f(int x);
int f(int* p);

在调用 f(NULL) 时,实际调用的函数的类型是 int(int) 而不是 int(int *).

比起在 C++ 中,因为有两个定义,在 C 语言中 NULL 造成的问题更为严重:如果在一个传递可变参数的函数中,函数编写者想要接受一个指针,但是函数调用者传递了一个定义为整型的 NULL,则会造成未定义行为,因在函数内使用传入的可变参数时,要进行类型转换,而从整型到指针类型的转换是未定义行为。

C++ 规定 nullptr 可以隐式转换为任何指针类型,这种转换结果是该类型的空指针值。

nullptr 的类型为 std::nullptr_t, 称作空指针类型,可能的实现如下:

namespace std {
typedef decltype(nullptr) nullptr_t;
};

另外,C++11 起 NULL 宏的实现也被修改为了:

// C++11 起
#define NULL nullptr

当确实没有对象可以指向或者需要表示没有对象可用的概念时,我们赋予指针值nullptr(空指针),所有指针类型都共享同一个nullptr.

double* pd = nullptr;
Link<Record>* lst = nullptr;
int x = nullptr; //wrong!! nullptr is a pointer not a integer

接收一个指针实参时,检查一下它是否指向某个东西时通常是一种明智的做法.

统计x在p中出现的次数,假定p指向一个以0结尾的字符数组
int count(const char* p, char x){
if(p == nullptr) return 0;
int count = 0;
for (;*p!= 0;++p)
if(*p == x) ++count;
return count;
}
//或者
int count_2(const char* p,char x){
if(p == nullptr) return 0;
while(*p){
if(*p==x) ++count;
++p;
}
return count;
}

进阶使用

使用指针,使得程序编写者可以操作程序运行时中各处的数据,而不必局限于作用域。

在 C/C++ 中,调用函数(过程)时使用的参数,均以拷贝的形式传入子过程中(引用除外,会在后续介绍)。默认情况下,函数仅能通过返回值,将结果返回到调用处。但是,如果某个函数希望修改其外部的数据,或者某个结构体/类的数据量较为庞大、不宜进行拷贝,这时,则可以通过向其传入外部数据的地址,便得以在其中访问甚至修改外部数据。

动态实例化

除此之外,程序编写时往往会涉及到动态内存分配,即,程序会在运行时,向操作系统动态地申请或归还存放数据所需的内存。当程序通过调用操作系统接口申请内存时,操作系统将返回程序所申请空间的地址。要使用这块空间,我们需要将这块空间的地址存储在指针变量中。

在 C++ 中,我们使用 new 运算符来获取一块内存,使用 delete 运算符释放某指针所指向的空间。

int* p = new int(1234);
/* ... */
delete p;

上面的语句使用 new 运算符向操作系统申请了一块 int 大小的空间,将其中的值初始化为 1234,并声明了一个 int 型的指针 p 指向这块空间。

同理,也可以使用 new 开辟新的对象:

class A {
int a;

public:
A(int a_) : a(a_) {}
};

int main() {
A* p = new A(1234);
delete p;
};

如上,「new 表达式」将尝试开辟一块对应大小的空间,并尝试在这块空间上构造这一对象,并返回这一空间的地址。

struct ThreeInt {
int a;
int b;
int c;
};

int main() {
ThreeInt* p = new ThreeInt{1, 2, 3};
/* ... */
delete p;
}

{} 运算符可以用来初始化没有构造函数的结构。除此之外,使用 {} 运算符可以使得变量的初始化形式变得统一。

需要注意,当使用 new 申请的内存不再使用时,需要使用 delete 释放这块空间。不能对一块内存释放两次或以上。而对空指针 nullptr 使用 delete 操作是合法的。

动态创建数组

也可以使用 new[] 运算符创建数组,这时 new[] 运算符会返回数组的首地址,也就是数组第一个元素的地址,我们可以用对应类型的指针存储这个地址。释放时,则需要使用 delete[] 运算符。

size_t element_cnt = 5;
int *p = new int[element_cnt];
delete[] p;

数组中元素的存储是连续的,即 p + 1 指向的是 p 的后继元素。

函数指针

简单地说,要调用一个函数,需要知晓该函数的参数类型、个数以及返回值类型,这些也统一称作接口类型。

可以通过函数指针调用函数。有时候,若干个函数的接口类型是相同的,使用函数指针可以根据程序的运行 动态地 选择需要调用的函数。换句话说,可以在不修改一个函数的情况下,仅通过修改向其传入的参数(函数指针),使得该函数的行为发生变化。

#include <iostream>

int (*binary_int_op)(int, int);

int foo1(int a, int b) { return a * b + b; }

int foo2(int a, int b) { return (a + b) * b; }

int main() {
int choice;
std::cin >> choice;
if (choice == 1) {
binary_int_op = foo1;
} else {
binary_int_op = foo2;
}

int m, n;
std::cin >> m >> n;
std::cout << binary_int_op(m, n);
};

函数指针

普通函数指针定义

int (*pfi)()

假设有如下类

class Screen{
public:
int height() { return _height; }
int width() { return _width; }
};

现在这样赋值

pfi = &Screen::height;
//非法赋值,类型违例

因为指向成员函数指针包含三个方面:1)参数表(个数、类型) 2)返回类型 3) 所属类类型 而普通的指向函数指针只包含两个方面

正确的指向成员函数指针定义:

int (Screen::*pmf)();
pmf = &Screen:height;

为避免复杂的指针类型,使用typedef

typedef int (Screen *PTR_TYPE)();
PTR_TYPE pmf = &Screen::height;

指向类成员的指针的使用:

int (Screen::*pmf)() = &Screen::height;
Screen myScreen, *bufScreen;
//直接调用成员函数
if(myScreen.height() == bufScreen->height())
//通过成员函数指针的等价调用
if((myScreen.*pmf)() == (bufScreen->*pmf)())

同样道理,指向数据成员指针:

int Screen::* ps_screen = &_height

总结:与普通指针比较,加上classname::*

static成员的指针

static成员的指针: 我们知道,静态类成员属于该类的全局对象和函数,它们的指针是普通指针(请记住static成员函数没用this指针)

class classname
{
public:
static void rai(double incr);
static double interest() { };
//
private:
static double _interestRate;
string _owner;
};
//ok
double *pd = &classname::_interestRate;
//error
double classname::*pd = &classname::_interestRate;
//ok
double (*pfunc)() = &classname::interest;

引用

左值引用 常规引用,一般表示对象的身份。

右值引用 右值引用就是必须绑定到右值(一个临时对象、将要销毁的对象)的引用,一般表示对象的值。

右值引用可实现转移语义(Move Sementics)和精确传递(Perfect Forwarding),它的主要目的有两个方面:

消除两个对象交互时不必要的对象拷贝,节省运算存储资源,提高效率。

能够更简洁明确地定义泛型函数。

引用折叠 X& &、X& &&、X&& & 可折叠成 X&

X&& && 可折叠成 X&&

C++ 中引入了引用的概念,相对于指针来说,更易用,也更安全。详情可以参见 C++:引用 以及 C 与 C++ 的区别:指针与引用。

引用(Reference)是 C++语言相对于 C语言的又一个扩充,类似于指针,只是在声明的时候用&取代了*。

引用可以看做是被引用对象的一个别名,在声明引用时,必须同时对其进行初始化。引用的声明方法如下:

类型标识符 &引用名 = 被引用对象;
int a = 10;
int &b = a;
cout << a << " " << b << endl;
cout << &a << " " << &b << endl;
output
10 10
0018FDB4 0018FDB4

从这段程序中我们可以看出,变量 a 和变量 b 都是指向同一地址的,也即变量 b 是变量 a 的另一个名字,也可以理解为 0018FDB4 空间拥有两个名字:a 和 b。

由于引用和原始变量都是指向同一地址的,因此通过引用也可以修改原始变量中所存储的变量值,如例 2 所示。

常引用(不可变引用)

如果我们不希望通过引用来改变原始变量的值时,我们可以按照如下的方式声明引用:

const 类型标识符 & 引用名 = 被引用的变量名;

int a = 10;
const int &b = a;
b = 20; //compile error
a = 20;

函数引用参数

如果我们在声明或定义函数的时候将函数的形参指定为引用,则在调用该函数时会将实参直接传递给形参,而不是将实参的拷贝传递给形参。如此一来,如果在函数体中修改了该参数,则实参的值也会被修改。这跟函数的普通传值调用还是有区别的。

函数引用返回值

在 C++ 中,非 void 型函数需要返回一个返回值,类似函数形参,我们同样可以将函数的返回值声明为引用。普通的函数返回值是通过传值返回,即将关键字 return 后面紧接的表达式运算结果或变量拷贝到一个临时存储空间中,然后函数调用者从临时存储空间中取到函数返回值。

当我们将函数返回值声明为引用的形式时,中间没有经过拷贝给临时空间,再从临时存储空间中拷贝出来的这么一个过程。这就是普通的传值返回和引用返回的区别。

函数返回值的作用域
int & valplus(int &a)
{
a = a + 5;
return a;
}; //在返回时可以存在

int & valplus(int a)
{
int b = a+5;
return b; //获取不到返回值
};

mapping to hardware

不同对象引用相同的共享值:

int x = 2;
int y = 3;
int* p = &x;
int* q = &y; //现在p!=q且*p!=*q;
p = q; // p变成了&y,现在p==q,因此 *p == *q;

这段代码的下效果:

引用和指针都是引用/指向一个对象,在内存中都表示为一个机器地址. 但是使用他们的语言规则是不同的.给一个引用复制不会改变它引用了什么,二是给他引用的对象赋值.

int x = 2;
int y = 3;
int &r = x;
int &r2 = y;
r = r2;//从r2读取值,写入r中:x变为3

这段代码的效果:

初始化

初始化和赋值不同,一般而言,正确执行赋值之后,被赋值对象必须有一个值,而另一方面,初始化的任务是讲一段未初始化的内存变成一个合法的对象. 对几乎所有的类型来说,读写一个未初始化的变量的结果都是未定义的. 对内置类型来说, 这个问题对引用来说更为明显.

int x = 7;
int& r {x}; //将r绑定到x(r引用x)
r = 7; // 给r引用的对象赋值
int& r2; // 错误:未初始化的引用
r2 = 99; // 给r2引用的对象赋值

幸运的是,我们不能使用一个未初始化的引用. 如果可以的话,r2=99就会将99赋予某个未指定的内存位置. 这最终可能导致糟糕的结果或者程序奔溃.

你可以使用一个=初始化一个引用,但不要被这种形式迷惑.

int& r = x;

这仍然是一个初始化操作,将r绑定到x,而不是任何形式的值拷贝.

还有一种情况,我们不想改变实参,又希望避免参数拷贝的代价,此时应该使用const引用.

double sort(const vector<double>&);

函数接收const引用类型的参数是非常普遍的.

Footnotes

  1. 这个地址指的是虚拟内存地址. 程序每次的运行过程中,变量在物理内存中的存储位置不尽相同。不过,我们仍能够在编程时,通过一定的语句,来取得数据在内存中的地址。虚拟地址一般而言是相同的. 通过地址变换机构获取物理内存地址. 具体内容详见操作系统.

Loading Comments...