【C++总结提炼0x02】内存管理(一):原始指针


前言

一直以来,C++都继承着C语言对于指针和数组的支持,在早期的C++中,我们
可能会这样定义一个指针或者一个数组。

int n;
int *p = &n;
int digits[10] = {5,5,5,1,2,3,4,5,6,7};

但是在现代C++中,我们更多时候会倾向于避免使用这些“原始指针或者数组”。
取而代之的是使用更新的智能指针(比如:unique_ptr),和容器类(比如:vector,list)。使用它们有很多显而易见的好处:

  • 有更多,功能更全面的接口
  • 能够更清晰的表达开发者的意图
  • 它们的析构函数能自动释放资源

当然,这并不意味“原始指针”已经被完全抛弃了。
当我们使用一些基于“原始指针”的库或者框架,当我们去阅读使用“原始指针”的代码时,我们还得和原始指针打交道。

所以,本篇文章就从“原始指针”说起,包含如下内容:

指向对象的指针

指针和地址

内存可能是计算机中最重要的东西,当你新建一个变量,运行一个程序,都在和内存打交道,而指针则是我们操纵和管理内存的工具。很多人会把指针想的很复杂,但实际上指针只是一个数(无论什么类型的指针),它储存一个内存地址,仅此而已。

更细致的说法是:

  • 指针是一个对象,它保存着另一个对象的地址
  • 每个指针有一个类型,它代表着所指向对象的类型:
int *pi;                  // a "pointer to int"
unsigned long *pul;       // a "pointer to unsigned long"

所以当我有任意一个对象 x,并且我想获取这个对象的地址时,我会使用 &x 来获取 x 的地址。换句话说,如果 x 的类型是 T,则&x的类型是 “指向T的指针”:

int i;
unsigned long ul;           // object T

int *pi = &i;
unsigned long *pul = &ul;   // pointer to T

在了解了以上两点后,我们很容易想到的是,既然指针只是一个数,那么指针的值可以修改吗? 事实上,在大部分情况下是可以的,指针可以在其生命周期的不同阶段指向不同对象 至于一些例外情况,本文稍后会做介绍。
在这里插入图片描述
例如,上图中的一个指针P,我们有时希望它指向对象a,有时希望它指向对象b,那么我可以这样修改P的指向:

int a = 1, b = 2;
int *p = &a;

p = &b;

空指针

还有一种指针,它不指向任何东西,被我们称做空指针。

int *p1 = NULL;       // traditional C
int *p2 = 0;          // traditional C++
int *p3 = nullptr;    // modern C++

上述的p1,p2,p3都是空指针,只是不同时期的语法不同,三种写法都能达到目的,但我们首选的是nullptr。

因为NULL最初来自于C语言,在C语言中NULL被定义为 0,或者是 0L。但无论哪种定义,NULL在刚刚创建没有指向任何东西的时候,就有了一个类型:int,它是一个整型数。这会导致在我们使用重载函数时出现一些意想不到的情况:

void f(int i);
void f(char *s);

f(NULL)			// call f(int), not f(char *)

上例中,因为NULL拥有整型的数据类型,在调用重载函数时,int 比 char * 的优先级更高。所以,在C++11中引入了nullptr,它是一个unique type而不是整形,并且可以转换成任意的指针类型。

解引用

下一个话题是解引用:当我们拥有一个指针变量p,我们可以用 *p 来获取到 p 所指向的对象。

int i = 13;
unsigned long ul = 42;           
int *pi = &i;
unsigned long *pul = &ul;

*pi = 14;                         //把 i 的值变成 14
*pul += 2;                        //把 ul 的值加 2

当然,要注意避免对一个空指针解引用,它是一个 未定义行为。我们不知道会发生什么坏事,但肯定不是好事。

指针的生命周期

通常来说,一个指针的生命周期和它指向对象的生命周期没什么关系。

void f(int *p){
	return;
}

int i = 10;
f(&i);

在上例中,每次调用 f 函数都会重新创建一个新的 p 实例,在函数调用完后,这个实例就会销毁,它的生命周期只有这一次调用。但在函数调用之前,p 所指向的对象 i 就已经存在了,而且在调用结束之后,i 依然存在。
当对象的生命周期长于指针时,不会有什么问题。但如果指针的生命周期长于对象,就会产生一些意外的情况:

int *g(){
	int i = 0;    // i lifetime begins
	return &i;    
}                 // i lifetime ends

int *pi = g();    // pi points to dead i

上例中,指针 pi 指向了一个死去的对象,我们称之为悬空指针(dangling pointer),访问 *pi 也是一个未定义行为。

数组中的指针

首先,我们定义一个最简单的定长数组:

char x[N];     // 长度为 N 的 char 型数组

表面上,C++中的数组和其他语言中的数组没有什么不同,但首当其冲要注意的是,如果我们用 N 初始化数组长度,N 必须是一个整数常量,即 const int,它必须给编译器提供一个可评估的量用以决定分配给数组多大的内存。

然后我们可以随机访问下标为 0 到 N-1 的数组元素:

int k;

x[0] = 'a';
x[k] = 'c';

数组中的指针计算

我们也可以使用指针 pc 访问数组中的元素,++pc 会使它指向数组的下一个元素,无论一个数组元素的大小是多少。通过这种方法,你也可以遍历整个数组:

char x[N];
char *pc = &x[0];

*pc = 'a';      // same as : x[0] = 'a'

++pc;           // pc now points to x[1]
*pc = 'c';      // same as : x[1] = 'c'

int in[5] = {1,2,3,4,5};
for(int *p = in; p < x + 5; ++p){   // step through the array
	~~~
}

上述遍历数组的代码图示如下,初始指针指向 x 的首个元素,然后开始逐个后移:在这里插入图片描述
但当循环结束时,指针将会指向数组外的第一个元素(虚线处)。此时它会指向数组在内存中的存放位置的下一个位置,对它进行写入或读出操作可能会导致无法预料的行为。

上文中说到“无论一个数组元素的大小是多少”,它的实际含义是,数组中的指针进行加减计算时,好像是被单位化了,它总是以数组元素为单位,与该元素实际占几个内存单元无关。请看下面的例子:

int i,j;
int x[5];
int *p = &x[i], *q = &x[j];

int n = q - p;
int m = j - i;
if(n == n) { ~~~ } // always true

这里尤其要注意的是,这种指针之间的减法只有在两个指针指向同一个数组中的元素时才有效,它们的结果是一个int值。

数组下标和指针

你可以直接把上例的 x 视作指针,它指向的是 x[0] 的位置

int *pi = x;   // same as : pi = &x[0]
*x = 4;        // same as : x[0] = 4

再引申一下呢,事实上,数组下标 [ ] 实际上代表了一种指针操作而不是数组操作。

x[i]   // same as : *(x + i)

for(int i = 0; i < n; ++i){
	sum += x[i];
	sum += *(x + i);   // equivalent but not recommended
}

说到这里,你可能会认为,数组实际上只是一个指针。但事实上并不是,我们可以把 x 直接赋值给指针,是因为编译器会将数组 x 视作一个指向 int 的指针。这称之为“衰减(decays)”。

这种衰减是暂时的,它只会持续到赋值语句结束。就好像我们可以把一个 double 值和 int 值相加再赋给 double,编译器在计算时将 int 值视为double,但不代表把这个值真正变成了double。

double d;
int i;
~~~
d += i;     ==      { 
				      double t = static_cast<double>(i)
					  d += t;
					}  // t在此处结束生命周期

同理在编译器中,x 被暂时的衰减成 &x[0]。

但值得一提的是,当数组作为函数参数时,它实际上是一个指针,下例中的三个写法相同:

void foo(int *x){
	cout << sizeof(x);           // sizeof(x) = sizeof(int *) 	
}
void foo(int x[]){
	cout << sizeof(x);           // sizeof(x) = sizeof(int *)
}
void foo(int x[10]){
	cout << sizeof(x);           // sizeof(x) = sizeof(int *)
}

size_t和ptrdiff_t

在标准库中,有许多函数使用的参数或者返回值,都是用一个字节为单位的对象,例如:

T *p = malloc(N);   // N 是需要被分配的字节数
memcpy(dst,src,N);  // N 是需要从src拷贝到dst的字节数

这里N的类型是什么呢?最容易想到的是INT
我们希望这些函数在处理任意对象时都可用,那就需要N能代表任意对象的大小。于是设计者们专门设计了新的类型size_t,事实上,它内置了sizeof()函数,表示目标对象的大小:

#include <cstddef>
using namespace std;

size_t n = sizeof(widget);

在不同的平台上,size_t 的定义也许略有区别,但有两个通用的原则是:

  • size_t是一个无符号整型,因为对象的大小不可能是负数
  • size_t可能是一个int,可能是一个long甚至是long long,它应该能够表示目标机器上最大的对象。

再看一个 size_t 作为返回值的例子:

size_t strlen(char const *);

sizeof(array) 返回了一个数组所占的字节大小,同时,它也表示了数组最多可能含有多少个元素(每个元素的大小至少是 1 )。

上面说到size_t一定是一个正数,但数组中两个指针相减的结果却不一定是正数,又该如何表示呢:
在这里插入图片描述
很显然,这里 p - q = -3,设计者提供了新的类型 ptrdiff_t,他用来表示两个指针加减计算的结果,通常是一个有符号整型。

size_t 和 ptrdiff_t 一个表示无符号数一个表示有符号数,当我们把一个有符号数和一个无符号数放在一起比较时,编译器会把有符号数转化为无符号数,这可能会导致一些意外的bug:

char buffer[64];
char const *field_end = strchr(field,',');
ptrdiff_t length = field_end - field;
if(lenght < sizeof(buffer)){         // 无符号数和有符号数进行了比较
	~~~
}

const和指针

const遇到指针经常让很多程序员晕头转向,本节将总结一下const是如何影响指针的。

让我们从 T *p 开始:

const T *p;            // (1)
T const *p;            // (2)
T *const p;            // (3)
const T *const p;      // (4)
T const *const p;      // (5)

它们都分别表示什么意思呢?

对于(1)和(2),const在 * 号左侧,此时,p 表示"指向常量 T 的指针"。这意味着 T 是常量,我们不能改变 T 的值,但可以改变指针的指向:

T x,y;
p = &x;   // OK : can modify p
*p = y;   // NO : can not modify T referenced by *p

对于(3),const在 * 号右侧,此时 p 表示“指向 T 的指针常量”,也就是说 p 只能只想T,而 T 对象是可以被修改的:

T x,y;
p = &x;   // NO : can not modify p
*p = y;   // OK : can modify T referenced by *p

那么,对于(4)(5)就很显然了,既无法修改 T 也不能修改 p,它是一个指向常量 T 的常量指针 p。

然后需要介绍的是constexpr,constexpr和const并不是等价的,constexpr指的永远是指针常量:

char constexpr *p;
~~~
char const     *p;  // not equivalent
char *const     p;  // equivalent

最后要聊的是指针的类型转换,这里只用记住一个例子:

T *p;
T const *pc;

pc = p    // OK
p = pc    // NO : lost const 

上例中,T 对象被const修饰时,如果让 p 也指向它,那么我们就可以通过 p 来修改常量 T,这是不被允许的。

指针类型转换

在C语言早期,不同类型指针之间的意外转换导致过很多bug,对于这种转换,编译器通常只会给出一个warning,但在C++中,这种转换是不被允许的,编译器会抛出一个error。

gadget *pg;
widget *pw;  // gadget and widget are distinct types
~~~
pg = pw;
pw = pg;     // warning in C, error in C++

当然你可以使用reinterpret_cast让编译器闭嘴,强制完成转换,但这并不代表这种转换就是安全的。

pg = reinterpret_cast<gadget *>(pw); 

那么,哪些类型转换是安全的呢:

  • 指向派生类的指针转换为指向基类的指针
  • 任何类型的指针都可以直接赋值给void *

第一点不用赘述。
关于第二点,C语言中的一些函数(例如 malloc 和 free)被设计来操作一种指向任意对象的指针,C 和 C++ 就提供了 void * 来表示可以指向任意数据类型的指针:

void *malloc(size_t n);
void free(void *p);

上面我们说到任何类型的指针都可以直接赋值给void *,但需要注意的是,反过来并不成立。无类型可以包容有类型,因为一个void指针,程序无法通过它做任何操作(访问,赋值,加减),但有类型就不能包容无类型。

总结

这篇文章介绍了“原始指针”的用法,而理解智能指针需要的前置知识比较多,笔者会在介绍完这些前置知识后更新智能指针。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值