C语言中的数组和指针

Last Updated: 2023-12-22 08:56:34 Friday

-- TOC --

数组和指针,在C语言里面,一定要放在一起来学习和理解!

编写C语言代码,要记住数组和指针本质上相同,都是一块内存,分割成相同大小,位移访问,但用法和语法细节上也有一些不同。

申明指针

char *k = NULL;

定义一个指向char类型的指针,初始化为NULL。

还可以在定义的同时做初始化:

char *k = malloc(n); // implicitly convert void* to char*, OK in C!

定义一个指向char类型的指针,这个指针指向的内存空间大小文n个bytes,这块空间在heap中,使用完后必须用free来释放,否则就是内存泄漏。

访问指针指向的内容,既可以用指针风格,也可以用数组风格:

char a = *(k+i);
char b = k[i];

这两种写法含义相同,语法都OK。

k+i,就是C语言中非常重要的 pointer arithmatic operation,它表示移动一个sizeof(type of pointer)的距离

用数组indexing的风格,自带了一次Dereferencing!

数组的申明和初始化

#define N 256
char k[N];

定义一个字符数组k,其大小为N个bytes,这块空间在函数调用栈中,函数返回空间自动释放,因为在栈中,因此N不宜太大,防止栈溢出!

数组在定义时,必须在初始化是指定大小,下面这种定义数组的方法是错的:

char k[];   // 错误的定义,编译器无法确定大小,但函数入参却写成这样可以!

还可以这样来定义并初始化数组:

char k[] = {1,2,3,4,5};

编译器能够处理这种定义风格,自动计算出这个数组的大小。

还有一种比较容易迷惑的数组定义方式:

char k[N] = {};  // all zero
char k[N] = {0};

这种定义方式,指定数组空间大小为N个bytes,同时初始化这个空间为0。其实,代码只是将k[0]=0,其它的从k[1]到k[N-1],是有编译器自动初始化为0的,要特别注意这个细节!编译器会将有初始化的数组申明中的没有用户指定初始化的部分,全部设置为0(我理解为编译器首先把整个空间设置为0,然后再将用户申明的值定点赋值)。再看个示例:

char k[10] = {1,2,3};  // k[0]==1,k[1]==2,k[2]==3,其它位置是0

访问数组的内容,既可以用数组风格,也可以用指针风格:

char a = k[i];
char b = *(k+i);

这两种写法是一样的,k就是指向数组开始地址的指针。

数组申明时的初始化,看下面的测试代码:

$ cat test_array_assign.c
#include <stdio.h>

int main(void) {
    char a[2] = {1};  // only set a[0]=1, the rest are all zero!!
    printf("a[0] = %d\n", a[0]);
    printf("a[1] = %d\n", a[1]);
    return 0;
}
$ ./test_array_assign
a[0] = 1
a[1] = 0
char a[10] = {0};  // only set a[0]=0

这个初始化,让a指向的整个空间全部初始化为0,只是将a[0]显式得设置为0,其它没有显式初始化的元素,是编译器默认设置为0的。

C99给数组申明时的初始化,增加了更多的灵活性,可以用index下标指定位置进行初始化,请看下面的测试代码:

$ cat test2.c
#include <stdio.h>

int main(void) {
    int k[8] = {1,2,[5]=3,[7]=9};
    for (int i=0; i<8; ++i)
        printf("%d ", k[i]);
    printf("\n");
    return 0;
}
$ gcc test2.c
$ ./a.out
1 2 0 0 0 3 0 9 

以上代码,指定了0,1,5,7这4个位置的初始化值,其它位置,编译器代劳,初始化为0。

函数接口入参

上文那种错误的定义数组的方式(不指定大小),在作为函数入参的时候,就成了正确的了:

void function(char *k);
void function(char k[]);

专业术语:Array decay to pointer!

常识:函数入参不可能是数据!以上申明,都是指针。C++可以有数组的引用。

以上两种写法,都正确,语义也是一样的,表示将一个char类型的指针传入函数。此时没有k指向空间大小的信息,一不小心就会越界,因此,在某些场景下,有了下面的申明:

void function(char *k, size_t size);
void function(char k[], size_t size);

下面是多此一举的申明方式:

void function(char k[123], size_t size);

123这个信息,是代码自身无法使用的信息,这行代码虽能够编译通过,但123是无意义的,也许仅仅是在某些时候提供多一点点的可读性。

字符串和字符数组

定义一个字符数组:

char k[] = "abcdefg";  // just big enough to hold abcdefg and '\0'

虽没有直接指明k指向空间的大小,但编译器可以用过字符串计算出需要的空间大小,这段空间也在栈中,特别注意最后的那个\0也被包含在这个空间的最后一个字符中,此时,k指向一个字符串。

如果用下面这样的定义:

char k[] = {'a','b','c'};  // no `\0` at the end

k指向不再是一个完整的字符串,只是普通的字符数组,没有最后的\0

下面是定义一个指针,但指向一个字符串常量,它的空间不在栈中,而是在readonly区域:

char *k = "abcdefg";  // k points to a readonly string constant, only C
const char *k = "abcdefg"; // both C and C++

以上定义方式的区别在于,字符数组的定义方式可以修改每个字符的内容,而指针定义的方式,本质上是定义了一个指针,指向一个字符串常量,如果修改k的地址,会导致这个字符串常量永远无法再被访问,如果修改这个字符串常量,C89的资料说,这是undefined behavior,gcc测试结果,segmentation fault。(这个常量在ELF文件的readonly section中,运行时被加载到readonly区域)

VLA (Variable Length Array)

C99开始,C语言开始支持不太建议使用的VLA,Variable Lenght Array,即可以用一个变量来申明数组,也叫作变长数组。

int a = 1024;
int b[a];  // VLA, cannot be initialized

VLA的实现原理,即编译器会动态的在stack上开辟一块空间(或在heap上动态申请,C语言标准没有规定这块内存一定要在哪个区域)。这个特性,gcc支持,msvc不支持!

程序员完全可以用fixed length,malloc或memory pool的方式,代替VLA。如网友所说,似乎VLA的唯一好处,就是这块内存会被自动回收,降低了memory leak的风险,但同时它也带来了其它问题:

#include <stdio.h>


int main(){
    goto end;

    {   // must be scoped
        int a = 128;
        int b[a];  // can not be initialized
    }

end:
    printf("end...\n");
    return 0;
}

gcc可以使用-Wvla来增加出现VLA时的warning...

多维数组

C语言擅长表达底层概念...它也没有提供高层抽象,这事儿C++干了...

二维数组

#include <stdio.h>

int main(){
    char a[2][3] = {{},{1,2}};
    char (*b)[3] = a;
    printf("%p %p\n", a[0], b[0]);  // same addr
    printf("%p %p\n", a[1], b[1]);  // same addr
    for(int i=0; i<2; ++i)
        for(int j=0; j<3; ++j)
            printf("%d ", b[i][j]);
    printf("\n");
    return 0;
}

三维数组

#include <stdio.h>

int main(){
    int n = 4;
    char a[2][3][n];
    a[0][1][2] = 9;
    char (*b)[3][n] = a;
    for(int i=0; i<2; ++i){
        for(int j=0; j<3; ++j){
            for(int k=0; k<n; ++k)
                printf("%d ", b[i][j][k]);
        }
        printf("\n");
    }
    return 0;
}

LeetCode第6题,字符串Z型转换,有个C语言版本,使用了VLA和多维数组技巧。

指针的指针

最经典的,main接口的第2个入参,以下两种定义方式等价:

int main(int argc, char **argv);
int main(int argc, char *argv[]);

但是在函数体中,定义指针的指针,如果使用[],如前所述,需要有更详细的定义,让编译器可以计算长度。示例代码,注释为打印输出的内容:

#include <stdio.h>


int main(int argc, char **argv){
    printf("sizeof(argv) = %zu\n", sizeof(argv));     // 8
    printf("sizeof(*argv) = %zu\n", sizeof(*argv));   // 8
    printf("sizeof(**argv) = %zu\n", sizeof(**argv)); // 1

    int a = 1;
    int b = 2;
    int c = 3;
    int *p[] = {&a,&b,&c};
    printf("%d %d %d\n", *p[0], *p[1], *p[2]);  // 1 2 3
    return 0;
}

指针的指针,看起来也像是个二维数组,但下面两种申明是不一样的:

int (*a)[3];
int **b;

测试代码:

#include <stdio.h>

int main(int argc, char **argv){
    int (*a)[3];
    int **b;
    printf("sizeof(a) = %zu\n", sizeof(a));    // 8
    printf("sizeof(b) = %zu\n", sizeof(b));    // 8
    printf("sizeof(*a) = %zu\n", sizeof(*a));  // 12
    printf("sizeof(*b) = %zu\n", sizeof(*b));  // 8
    return 0;
}

更多烧脑的C/C++申明

本文链接:https://cs.pynote.net/sf/c/202112213/

-- EOF --

-- MORE --