14、指针介绍

厨子大约 7 分钟C语言基础程序程序厨

前言

在C/C++中,很多操作都依赖于地址,而指针就提供了对地址操作的方法。

那指针长什么样,一般带的都是指针,一般带修饰的变量就是指针变量,比如:

int *a;
char *b;
double *c;

class A {};
A *d;

这里的a、b、c、d就称为指针变量,它们保存的是对应类型变量的地址。

拿一段代码举例:

int value = 100;
int *ptr = &value;
std::cout << *ptr << std::endl; // 100
*ptr = 20;
std::cout << *ptr << std::endl; // 20
std::cout << value << std::endl; // 20

这里的&表示取地址,int *ptr = &value; 表示ptr指针变量指向value的地址。如图:

暂时无法在飞书文档外展示此内容

这里假设value变量的地址是0x12345678,有int *ptr = &value,这样ptr与value就有了关联,ptr其实就是value取地址的值,也就是0x12345678。

ptr就是操作这个地址上的值,所有当ptr = 20时,其实是操作了0x12345678地址,也就是0x12345678地址里的值改为20,相应的value的值也变成了20。

所以上面代码的最后两行都会输出20。

到这里有必要介绍一个概念:内存与地址。

数据都是存放在内存里的,而计算机里每块内存都有一个地址编码,然后计算机通过地址编号,方便找到对应的内存。

上面就提到过ptr其实就是一个地址,是的,指针其实就是一个地址,贴一段内核里的代码:

void verify_area (void *addr, int size) {
  unsigned long start; 
  start = (unsigned long) addr; // here

  size += start & 0xfff;
  start &= 0xfffff000;
  start += get_base (current->ldt[2]);
  while (size > 0) {
      size -= 4096;
      write_verify (start);
      start += 4096;
 }
}

在操作系统内核代码里,很明显的表明了指针本质就是一个地址的概念,到最后都把addr指针转成了unsigned long的一个数字,然后再做相应操作。

那指针有什么作用?

它有一个很重要的作用就是可以直接操作地址,减少拷贝

举个例子:

struct A {
  int a;
  int b;
  int c;
  int d;
};

void Print(A a) {
  std::cout << a.a << a.b << a.c << a.d << std::endl;
}

每次调用Print()函数时,都会对整个结构体做一次拷贝,想象一下,如果结构体特别大,那这种拷贝操作是很耗时的,那怎么避免这种拷贝的问题呢?这里就可以使用指针:

void Print(A *a) {
   std::cout << a->a << a->b << a->c << a->d << std::endl;
}

int main() {
   A a;
   Print(&a);
}

在调用函数时,直接传递变量的指针,这样在函数内部就可以直接操作变量的地址,传递的仅仅是一个指针占用的大小,也就是unsigned long的大小,这就减少了整个结构体的拷贝。

关于指针还有个知识点,就是++ptr的偏移量问题,看这段代码:

int *p1;
++p1; // sizeof(int)

A *p2;
++p2; // sizeof(A)

double *p3;
++p3; // sizeof(double)

char* p4;
++p4; // sizeof(char)

这里的++ptr不一定是偏移量+1,而应该是偏移量+对应的类型大小,即sizeof(T)

详细介绍

基本概念理解

  • 指针是变量:指针本质上是一种变量,但它存储的不是数据本身,而是数据在内存中的地址。
  • 存储内存地址:通过指针,你可以直接访问和操作存储在内存中的数据。
  • 指针与所指向数据类型的关联:指针的类型决定了它所指向的数据的大小和如何解释该内存地址中的数据。例如,int*类型的指针指向一个整型数据,而double*类型的指针指向一个双精度浮点型数据。

指针类型与大小

  • 指针类型:决定了指针所指向数据的大小和解引用的权限。例如,int*解引用后得到的是一个int类型的值,而char*解引用后得到的是一个char类型的值。
  • 指针大小:在32位系统上,指针的大小通常是4字节;在64位系统上,指针的大小通常是8字节。

指针的赋值与初始化

  • 初始化指针:在声明指针时,最好立即对其进行初始化,以避免野指针(未初始化或未正确赋值的指针)的出现。例如:

    • int a = 10;
      int* p = &a; // 正确初始化指针
      
  • 避免野指针:未初始化的指针可能指向任意内存地址,导致未定义行为。我们最好始终确保指针在使用前已正确初始化。

  • 类型一致性:在指针赋值时,确保类型一致。例如,不能将int*类型的指针赋值给double*类型的指针,除非进行显式类型转换(通常不推荐,因为它可能导致数据损坏)。

指针的解引用

通过指针变量访问它所指向的内存地址中的值。解引用操作使用*运算符。

空指针

在C语言中,NULL是一个特殊的指针值,表示空指针,表示指针不指向任何有效的内存地址。将指针初始化为NULL是一个好习惯,可以防止指针在未经初始化的情况下被使用。

#include <stdio.h>

int main() {
    int *ptr = NULL;
    if (ptr == NULL) {
        printf("ptr是一个空指针\n");
    }
    return 0;
}

指针的运算

  • 加减运算:指针的加减运算基于指针所指向的数据类型的大小。例如,对于int*类型的指针,加1意味着向前移动4个字节(假设int占4个字节)。

    • int arr[5];
      int* p = &arr[0];
      p++; // p现在指向arr[1]
      
  • 指针比较运算:比较两个指针是否相等或不等,通常用于检查它们是否指向同一个内存位置或是否在同一个数组中。

指针与数组

推荐阅读:数组不是指针,指针也不是数组open in new window

  • 通过指针访问数组元素:数组名在大多数表达式中会被转换为指向数组第一个元素的指针。因此,可以使用指针来遍历数组。

    • int arr[5] = {1, 2, 3, 4, 5};
      int* p = arr; // 等价于 int* p = &arr[0];
      for (int i = 0; i < 5; i++) {
          std::cout << *(p + i) << std::endl; // 通过指针访问数组元素
      }
      
  • 数组名作为指针数组名在表达式中通常被视为指向数组第一个元素的常量指针,但你不能修改它指向的位置(即不能对它进行赋值操作)。

指针与函数

  • 指针作为函数参数:通过指针作为函数参数,可以在函数内部修改调用者传递的数据。

    • void increment(int* x) {
          (*x)++;
      }
      
  • 指针作为返回值:函数可以返回指针,可以返回动态分配的内存或指向特定数据的指针。

    • int* createArray(int size) {
          return (int*)malloc(sizeof(int) * size); // 返回动态分配的数组
      }
      

指针的安全使用

  • 避免野指针:始终确保指针在使用前已正确初始化,并在不再需要时将其设置为NULL
  • 避免内存泄漏:动态分配的内存必须在使用完毕后释放(使用free)。
  • 指针有效性检查:在解引用指针之前,检查它是否为NULL
  • 指针的类型:在C语言中,指针的类型非常重要。不同类型的指针有不同的长度和表示方式。因此,在使用指针时,一定要确保指针的类型与它所指向的变量的类型相匹配。
  • 使用智能指针:C++11引入了智能指针(如std::unique_ptrstd::shared_ptr),它们可以自动管理内存,减少内存泄漏的风险,建议更多的使用智能指针而非裸指针。

练习

  1. 指针的大小通常是固定的,对于32位系统,其大小为几个字节;对于64位系统,其大小为几个字节?
  2. 编写一个程序,定义一个整型数组arr,包含5个元素{1, 2, 3, 4, 5},通过指针遍历并打印数组中的所有元素。
  3. 编写一个函数,该函数接收一个整型数组和数组的长度作为参数,通过指针参数修改数组中的元素,使所有元素的值加倍。