搞清楚数组和指针

181 阅读7分钟

C 语言中对于数组有一些相关点需要注意:

  1. C 语言中只有 1 维数组, 且 数组的大小必须在编译期间就作为一个 常数 确定下来.

  2. C 语言的数组中可以存放任意类型的数据, 也可以嵌套数组实现多维数组.

  3. 对一个数组, 只能做 2 件事:
    a. 确定数组的大小
    b. 获得指向该数组下标为 0 的元素的指针, (其他有关数组的操作, 即使是数组下标进行计算, 实际上都是通过 指针进行的.)


关于指针的理解:

  1. 任何指针 都是指向某一种数据类型的变量
  2. 给一个指针加上 1 个整数 与 将该指针的 2 进制表示加上同样的整数的含义不同 !
声明数组
int arr[6] {0, 1, 2, 3, 4, 5};

实际上是 把数组 arr 中下标为 0 的元素赋值给 p
int *p = arr;

// 初始化进行隐式类型转换
int brr[5] = {'a', 'b', 'c', 'd', 'e'};

brr[4] 和 4[brr] 等效

数组名只有在作为 sizeof() 运算符的操作数时, 计算的才是整个数组的大小, 其他情况下退化为指针, 代表的是指向数组中 下标为 0 的元素的指针

所以可以理解为: *a 是数组总下标为 0 的元素的引用, 所以 *a = 100 可以改变对应的值

*(a + i) 是数组中下标为 i 的元素的引用, 可以简写为 a[i]

实际上: 由于 a + ii + a 的含义一样, 所以 a[i]i[a] 也是完全等价的, 只是用方括号的下标形式简化了 指针的表达,

不建议使用 i[a] 这种写法, 公式: a[b] = *(a + b)

2 维数组

int aa[3][4] {
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}};
std::cout << aa[2][2] << std:: endl;

// 其实相当于
std::cout << *(aa[2] + 2) << std:: endl;

// 也相当于
std::cout << *(*(aa + 2) + 2) << std:: endl;

// 声明指向 数组的指针,
int (*parr)[4];

实际意义是: 声明 *parr 是一个拥有 4 个整型数据的 数组, 因此 parr 就是一个指向  每一个元素都是 int [4] 的数组的指针,

或者理解为, 对 parr 解引用的每一个数据都是 int[4]

所以 parr 类型(形参类型)为 int **, 

// 遍历 2 维数组
// a. 下标形式
// e.g. 清空数组

int aa[3][4] {
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}};

int count;
for (count = 0; count < 3; count ++) {
    int m;
    for (m = 0; m < 4; m ++) {
        aa[count][m] = 0;
        
    }
}



// b. 指针形式
int (*pointer)[4];
std::cout << "&aa[0]          : " << &aa[0] << std::endl;
std::cout << "&aa[2]          : " << &aa[2] << std::endl;
std::cout << "&aa[3]          : " << &aa[3] << std::endl;

for (pointer = aa; pointer < &aa[3]; pointer ++) {
    std::cout << "address pointer start : " << pointer << std::endl;
    
    // 处理 int 数组元素
    int *ps;
    for (ps = *pointer; ps < &( (*pointer)[4]); ps ++) {  // 地址偏移
        std::cout << "address : " << ps << " ,val : " << *ps << std::endl;
    }
}

// 数组中最后一个元素(12) 的首地址:  *(aa + 3) - 0x04 
std::cout << "address of end : " << ( *(cc + 3) - 0x4 )  << std::endl;

// 如果要取出元素 7,  aa[1][2]  或者  *( *(cc + 1) + 2)

&aa[0]          : 0x7ffc6233ff80
&aa[2]          : 0x7ffc6233ffa0
&aa[3]          : 0x7ffc6233ffb0
address pointer start : 0x7ffc6233ff80
address : 0x7ffc6233ff80 ,val : 1
address : 0x7ffc6233ff84 ,val : 2
address : 0x7ffc6233ff88 ,val : 3
address : 0x7ffc6233ff8c ,val : 4
address pointer start : 0x7ffc6233ff90
address : 0x7ffc6233ff90 ,val : 5
address : 0x7ffc6233ff94 ,val : 6
address : 0x7ffc6233ff98 ,val : 7
address : 0x7ffc6233ff9c ,val : 8
address pointer start : 0x7ffc6233ffa0
address : 0x7ffc6233ffa0 ,val : 9
address : 0x7ffc6233ffa4 ,val : 10
address : 0x7ffc6233ffa8 ,val : 11
address : 0x7ffc6233ffac ,val : 12
address of end : 0x7ffc6233ffa0

修改数组.png

int arr2[3] = {0, 1, 2};
std::cout << *( &(arr2[1] + 1)) << std::endl;  // 2

1. 数组并非指针

// 声明 x 是一个 int 型的指针
extern int *x;

// 声明 y 是一个 int 型的数组
// 长度尚未确定 (所以是不完整的类型), 其存储在别处定义
extern int y[];

什么是声明 ? 什么是定义 ?

C 语言中的对象(这里指的是 函数和变量之类的) 必须有且只有一个 定义, 但是可以有多个 extern 声明,

定义是一种特殊的声明, 它创建了一个对象

声明 简单地说明了在其他地方创建的对象的名字, 它允许你使用这个名字

// 定义, 只能出现在一个地方
// 确定对象的类型 并 分配内存,  用于创建新的对象, 如:
int my_array[100];

// 声明,  可以多次出现,
// 用来 描述对象的类型, 用于指代 其他地方定义的对象, 例如在其他的文件里, 如:
extern int my_arr[];

extern 对象声明告诉编译器对象的类型 和 名字, 具体对象的 内存分配则在别处进行
由于 并未在声明中为数组分配内存, 所以并不需要 提供关于数组的长度等信息,

但是, 对于多维数组, 声明时需要 提供除最左边一维之外的其他维的长度 ---> 需要给编译器足够的信息产生对应的代码


同时还需要明确一个概念: 地址 x 和 地址 x 的内容 的区别

 x = y;
 符号 x 的含义是 x 所代表的地址
 这 被称为 左值
 左值在编译期可知, 左值实际上表示的是  存储结果的地方
 
 y 的含义是 y所代表的地址的 内容
 这 被称为 右值
 右值是 直到运行时才知的, 如果没有特别说明, 右值表示 y 的内容

出现在赋值运算符 = 左边的称为 左值 (右边称为 右值),

编译器为每个变量分配一个地址(左值), 这个地址在编译期可知, 而且该变量在运行时 一直保存在这个地址,
相反,
存储于变量中的值(它的右值), 只有在运行时才可知, 如果需要用到变量中存储的值, 编译器就会发出指令从 指定的地址中读入变量值 并 将它存储到寄存器中.

关键之处在于:

  1. 每一个符号的地址都是在编译期可知, 如果编译器需要一个地址(可以添加偏移量)来执行某操作, 就可以直接进行操作, 并不需要增加指令来先获取具体的地址
  2. 对于指针, 必须首先在 运行时 取得它的当前值, 然后才能对它进行解引用操作

对数组下标的引用

数组的下标引用.drawio.png

这就是为什么 extern char arr[]extern char arr[100] 等价的原因.

这两个声明都提示 arr 是一个数组, 也就是一个 内存地址, 数组内的字符可以从这个地址找到, 编译器并不需要知道数组一共有多长, 因为它只产生 偏离起始地址的偏移地址, 如: 从数组中提取一个字符, 只需要简单的从符号表显示的 arr 的地址 + 下标即可,

对指针的引用

如果声明为 extern char *ptr; 将会告诉编译器这是一个指针, 指向的对象是一个字符,

为了取得这个字符, 必须得到地址 ptr 的内容, 把它作为字符的地址并从这个地址中取得这个字符

指针的访问更加灵活, 但是 需要增加一次额外的提取

对指针的引用.drawio.png

定义为指针, 引用为数组的问题

test1.cpp

// 定义为指针
int *mango;

test2.cpp

// 引用为数组
extern int mango[100];

编译器对内存 mango 进行直接引用 (把 mango 当作符号从符号表中直接取地址), 但是实际上操作的是指针 mango, 进行的是间接取地址,

定义为指针用数组引用.drawio.png

声明为什么, 编译器就会将其解释成什么

如果定义为数组, 引用为指针: test1.cpp

// 定义为数组
int mango[100];
// 引用为指针
extern int *mango;

这种情况下, 如果使用 mango[i] 这种形式提取这个声明中的内容, 实际上得到的 是一个字符 (因为原先定义 mango 是数组名),

但是参考上述对指针的引用, 编译器会需要从 mango 中读取地址, 然后拿着这个地址去取对应地址的内容, 但是在这里 mango 取出的内容是字符, 所以出错

把 ascii 字符解释为地址 存在风险 !, 会污染程序地址空间的内容, 将来会出现莫名的错误

如果引用使用 extern int *mango; 会出现段错误

正确的声明为 extern int mango[];

结论:
如果声明为指针, 只有原来定义为指针时才是合法的 ~


数组和指针的区别

指针数组
保存数据的地址保存数据
间接访问数据, 首先取得地址的内容, 把该内容作为一个地址, 然后从新地址中获取数据直接访问数据, a[i] 只是简单的以 a+i 为地址获取数据, 等价于 *(a + i)
如果 指针有一个下标[], 即 ptr[i] 表示直接把 指针的内容 加上 i 作为新地址, 并从中提取数据
通常用于动态数据结构通常用于存储 固定数目 且 数据类型相同的元素
相关函数, malloc(), free()隐式分配 和 删除
通常指向 匿名数据自身就是数据名

注意点

数组和指针都可以在 定义时用 字符串常量 进行初始化, but 底层机制不同:

定义指针时, 编译器不会为指针所指向的对象分配空间, 只是分配指针本身的空间, 除非在指针定义同时 给它赋予一个字符串常量进行初始化, 例如:

char *p = "hello";  // 程序会为字符串常量分配内存

// 但是仅仅局限于 字符串常量
float *ptr = 1.23;  // 编译错误

// 初始化指针所创建的字符串常量 被 定义为只读
// 如果尝试通过指针修改这个字符串的值, 程序会出现为定义行为


// 数组也可以用 字符串常量进行初始化, 由字符串常量初始化的数组是可以修改的
char arr[] = "helloworld";

测试:

// 验证数组 和 指针使用字符串常量进行初始化的区别
char *ptr1 = "helloworld1";
char ptr2[] = "helloworld2";
printf("ptr1[10] = %c\r\n", ptr1[10]);  // 读取 ok
// ptr1[10] = 3;  // 段错误

printf("ptr2[10] = %c\r\n", ptr2[10]);  // 读取 ok
ptr2[10] = 114;  // 修改 ok, 这里会进行隐式类型转船, int -> char
printf("ptr2[10] = %c\r\n", ptr2[10]);  // 读取 ok