C语言遐想(瞎想)

作者: 01_Jack | 来源:发表于2019-11-07 19:55 被阅读0次

    导读

    本文为笔者对C语言的一点思考,内容较杂,难免出错。如果阅读过程中发现什么问题,望不惜赐教。推荐顺序阅读,否则可能出现断片现象。

    全文共包含以下几部分:

    • 变量与地址
    • 内存空间的申请
    • 变量类型
    • 编码方式
    • 字符型数组与字符串
    • \0作为字符串结束符的必要性
    • 数组、指针、数组指针及指针数组

    变量与地址

    先说下变量与变量名,这两者的区别可以简单理解为:定义的时候叫变量名,使用的时候叫变量。

    int a = 1;    // 变量名
    a = 2;    // 变量
    printf("%d\n", a);    // 变量
    

    如上所示,可以对变量a进行读写操作,那么变量a的数据存储在哪里?计算机如何找到a存储的位置?

    一个程序的运行需要加载到内存中,而程序就是函数与变量的集合,计算机会为每个加载到内存中的函数与变量分配内存,被分配的内存所在的位置就是内存地址。计算机通过对变量a寻址就可以找到a存储数据的位置,读取地址存储的内容就可以获取变量a存储的数据。

    C语言通过&符号对变量寻址,通过*符号对指针变量读址

    int *p = &a;    // 获取变量a的地址并赋值给指针变量p
    *p = 3;    // 读取指针变量p所存储的地址,并对这个地址所存储的内容赋值
    printf("%d\n", a);    // 此时a的值为3
    

    可见变量的读写实际是对内存的读写,而变量名可以辅助我们找到存储变量所在内存的地址,内存地址可以辅助我们操作内存。试想一下,如果没有变量名,只能通过地址操作内存,每次要写一堆的0x123456789abc之类的代码这太难受了。

    通过变量名找到内存地址,通过内存地址操作内存,这条线可以简单的类比为:通过姓名找到身份证号,通过身份证号找到具体个人。

    有了内存地址就可以正确的读写数据吗?最直接的问题,除非计算机只读取当前地址所在字节存储的内容,但是当存储内容大于1 byte时,计算机并不知道读到什么位置结束。所以当我们为变量申请内存空间时,需指明要申请空间的大小。

    那么如何为变量申请内存空间?

    内存空间的申请

    内存空间的申请分为两种:静态申请与动态申请。

    两者的区别在于:
    静态申请内存空间,变量存储在栈上,当变量生命周期结束时,内存空间自动释放。动态申请内存空间(除alloca外,使用alloca函数申请的内存空间在栈区,无需手动释放),变量存储在堆上,当变量生命周期结束时,内存需手动释放,否则内存泄漏。

    如上文中的int a = 1就属于静态申请内存空间。C语言中可以通过,malloc、calloc、realloc等函数动态申请内存空间。

    void test1() {
        printf("----%s----\n", __func__);
        int a = 1;
        int *p = &a;
        printf("a的地址:%p\n", &a);
        printf("p的值:%lx\n", (u_long)p);
        printf("p的地址%p\n", &p);
    }
    
    void test2() {
        printf("----%s----\n", __func__);
        int *p = (int *)malloc(sizeof(int));
        *p = 1;
        int a = *p;
        printf("a的地址:%p\n", &a);
        printf("p的值:%lx\n", (u_long)p);
        printf("p的地址:%p\n", &p);
        int *q = (int *)malloc(sizeof(int));
        printf("q的地址:%p\n", &q);
        free(p);
        free(q);
    }
    
    int main() {
    
        test1();
        test2();
        return 0;
    }
    
    内存空间的申请-1

    test1中,变量a与指针变量p都属于静态申请内存空间,两者都存储在栈区,可自动释放。由于p晚于a申请内存空间,所以p位于栈顶,a位于栈底,p的地址小于a的地址(栈顶地址小于栈底地址)。此时将指针变量p的值与变量a的地址相同,因为p本来就存储着a的地址。

    test2中,指针变量p与q属于动态申请内存空间,存储在堆区,需手动释放内存。由于q晚于p申请内存,所以堆区中q的地址大于p的地址。变量a属于静态申请内存空间,位于栈区,可以自动释放,所以a的地址小于p的地址(栈区地址小于堆区地址)。

    test2中,指针变量p的值为存储着数据1的内存地址,可以通过下面的例子来验证:

    u_long test3() {
        printf("----%s----\n", __func__);
        int *p = (int *)malloc(sizeof(int));
        *p = 99;
        return (u_long)p;
    }
    
    int main() {
        u_long t = test3();
        int *p = (int *)t;
        printf("%d\n", *p);     // 99
        free((void *)t);
        return 0;
    }
    

    注意,这里之所以将指针强转成数值,是为了更好的理解指针内部存储的本来就是数值,只不过这个数值是某个变量的地址。显然,test3中的指针变量p未释放,所以函数体外仍然可以获取指针。

    变量类型

    所谓变量类型其实就是对变量进行种类划分,如同狗分为藏獒、阿拉斯加、哈奇士等不同品种。C语言中除掉存在char、int、float、double等基本数据类型外,还存在enum、struct、union、指针、数组等类型。如果强转变量类型,可能会造成数据丢失、错误甚至crash。

    int main() {
        short *p = (short *)malloc(sizeof(int));
        *p = 999;
        char *q = (char *)p;
        printf("%d\n", *p);     // 999
        printf("%d\n", *q);     // -25
        return 0;
    }
    

    999的二进制数据为0000_0011_1110_0111,-25的二进制数据为1001_1001。由于计算机以补码存储数据,所以计算机中存储的数据为1110_0111,这与999二进制数据的后8位相同。显然,小端模式下将存储着999数据的short *类型指针转成char *类型指针后,读取的数值为-25。

    同一串二进制数据切割后,以不同的方式读取(数据类型),所读出的数据显然是不同的。其实,编码方式也是对二进制数据的切割(填补)。

    编码方式

    int main() {
        char a[] = {-28, -67, -96, -27, -91, -67};
        printf("%s\n", a);      // 你好
        char c = 'x';
        printf("%c\n", c);      // x
        printf("%d\n", c);      // 120
        
        return 0;
    }
    

    对于变量c为什么以字符形式输出是x,以数字形式输出是120,想必都能答出ASCII码。但是变量a为什么以字符串形式输出的是你好

    再来看另一个例子:

    int main() {
        char *s = "你好";
        for (size_t i = 0; i < strlen(s); i++) {
            printf("%d\n", *(s + i));
        }
    }
    
    编码方式-1

    可见,指针s指向地址的连续空间内,存储的数据就是第一个例子a数组中存储的数据。但是为什么这组数据可以表示你好?这又要回到上文提到的二进制数据切割了,也就是这一小节的主题编码

    对应{-28, -67, -96}这组数字,转成二进制为{1001_1100, 1100_0011, 1110_0000},计算机中存储的补码为{1110_0100, 1011_1101, 1010_0000}。也就是说,最终计算机中存储的这组补码数据对应着汉字

    我们知道,汉字对应着utf8编码(也存在utf16、utf32等编码方式,但是并不常用)

    摘自百科

    对应的Unicode为\u4f60,对应着0800~FFFF这个范围,所以占3个字节。转成二进制后变为:0100_1111_0110_0000,套用utf8格式1110_xxxx_10xx_xxxx_10xx_xxxx,从低到高用刚才的二进制填补x,二进制不足位用0填补x,最后得到utf8编码后的数据1110_0100_1011_1101_1010_0000,这与{-28, -67, -96}这组数据计算机中存储的转成二进制后的补码相同。

    所谓的编码方式就是将字符串转成Unicode码后,以某种格式重新填补形成的新的二进制数据。

    字符型数组与字符串

    既然说到字符串,顺便说说字符型数组与字符串:

    int main() {
        char a[] = {'0', '1', '_', 'J', 'a', 'c', 'k'};
        printf("----------------\n");
        printf("%p\n", a);
        printf("%lu\n", sizeof(a));
        printf("%s\n", a);
        char b[] = "world";
        printf("----------------\n");
        printf("%p\n", b);
        printf("%lu\n", sizeof(b));
        printf("%s\n", b);
        char c[] = {'h', 'e', 'l', 'l', 'o'};
        printf("----------------\n");
        printf("%p\n", c);
        printf("%lu\n", sizeof(c));
        printf("%s\n", c);
        return 0;
    }
    
    字符型数组与字符串-1
    1. 首先以静态方式申请了数组变量a、b、c的内存,他们存储在栈区,因为c晚于b晚于a申请内存,所以c位于栈顶,a位于栈底,c的地址小于b的地址小于a的地址(栈顶地址小于栈底),且a、b、c各占7、6、5个字节,c的地址加5后就是b的地址,b的地址加6就是a的地址。可见,a、b、a是一段连续的内存空间。
    2. 由于字符串以\0作为结束符,所以b占用5+1,即6byte。而a和c是以单个字符形式定义的字符数组,所以a和b仅占用数组个数的字节数,分别为7byte和5byte。
    3. 最后一个问题,为啥a、b输出的是赋值数据,而c输出的是helloworld?c的赋值明明是{'h', 'e', 'l', 'l', 'o'}这个数组。答案其实就是上边1、2两小点的结合,由于c与b是一段连续的内存空间,且c不包含\0字符串结束符,当把b按字符串格式输出时,会在以b地址为起始的连续内存空间寻找\0,所以b输出helloworld。再回头看a数组,因为a处于栈底,当没有\0字符串结束符时,仅输出数组内定义的数组,不会额外增加一个字符用于结束当前数组。

    再来看个例子:

    int main() {
        char a[] = {'t', 'e', '\0', 's', 't'};
        printf("----------------\n");
        printf("%lu\n", sizeof(a));
        printf("%s\n", a);
        
        char b[] = "te\0st";
        printf("----------------\n");
        printf("%p\n", b);
        printf("%lu\n", sizeof(b));
        printf("%s\n", b);
    
        return 0;
    }
    
    字符型数组与字符串-2

    虽然a仍然占用定义的数组个数5byte,但是并未输出全部数组内容,而是到\0字符结束,b同样到\0字符结束,两者均输出te。由于b本身定义为字符串,所以末尾会多出1byte用于添加\0作为字符串结束符,占用6byte。

    \0作为字符串结束符的必要性

    由于C语言没有专门的字符串类型,当我们通过字符型数组或者字符型指针来定义字符串时,很容易数组或者指针越界,以\0作为字符串结束符虽然会增加1byte的存储量,但是此时可以通过判断当前数组或者指针内容是否为\0来甄别字符串是否结束,从而避免越界情况的发生。

    但是,为什么是以\0而不是其他字符作为字符串结束符?\0的ASCII码为0,作为字符输出是空白,此时添加到字符串结尾并不影响字符串本身的显示与使用。同时用\0作为占位符,占位符的副作用相对较小,因为通常我们并不需要输出或显示一个空白字符。但是如上文例子所示,当一个字符串中间包含\0时,字符串输出会在\0除被截断,无法输出完整的原字符串。

    如果想输出\0这种字符串,可以这样写\\0:

    int main() {
        char a[] = "te\\0st";
        printf("%s\n", a);      // te\0st
        return 0;
    }
    

    数组、指针、数组指针及指针数组

    • 一维数组与一级指针
      一维数组与一级指针其实没什么好说的,数组名就是数组的起始地址,并且是直接寻址,而指针则是通过*间接寻址。由于数组名就是数组的起始地址,也可以用*数组名的组合方式,进行间接寻址(与指针使用相同)。除此之外,数组是数组,指针是指针,两者是不同的类型,仅仅是由于数组名就是数组的起始地址导致的部分使用方式重叠。

    • 一维数组名与一维数组名取地址

    int main() {
        int a[3];
        printf("a:%p\n", a);
        printf("a+1:%p\n\n", a + 1);
        printf("&a:%p\n", &a);
        printf("&a+1:%p\n", &a + 1);
        return 0;
    }
    
    数组、指针、数组指针及指针数组-1

    虽然a与&a的地址相同,但是a+1与&a+1的结果并不同,根本原因在于两者不是同一种类型。
    a相当于&a[0],他的类型是int *,a+1表示首地址+sizeof(int),所以a+1在首地址的基础上向后移动4个字节。
    &a的类型为int (*)[3],是个数组指针,他指向包含3个int元素的一维数组,&a+1表示首地址+sizeof(a),所以&a+1相当于在首地址的基础上向后移动12个字节

    • 二维数组与数组指针
      如果真正理解了一维数组与一级指针间的关系,二维数组与数组指针间的关系不在话下:
    int main() {
        int a[2][3] = {1, 2, 3, 4, 5, 6};
        int (*p)[3] = a;
        printf("%d\n", (*p)[0]);
        printf("%d\n", (*p)[1]);
        printf("%d\n", (*p)[2]);
        printf("%d\n", (*(p + 1))[0]);
        printf("%d\n", (*(p + 1))[1]);
        printf("%d\n", (*(p + 1))[2]);
        return 0;
    }
    
    数组、指针、数组指针及指针数组-2

    例子中的p是个数组指针,他指向包含3个int元素的一维数组。当把二维数组a的首地址赋值给数组指针p时,显然(*p)[0]到(*p)[2]对应访问的是a[0][0]到a[0][2],由于变量p的类型为int (*)[3],本质是个数组指针,所以p+1相当于一维数组中的&数组名+1。因此p+1移动sizeof(int*3)个数,从而*(p+1)[0]到*(p+1)[2]指向a[1][0]到a[1][2]

    • 数组指针与指针数组

    两者的区别很简单:数组指针是个指针,他指向一个数组;指针数组是个数组,他内部存储着一组相同类型的指针;

    这两句话慢慢意会吧☺

    你可能会有这种疑问,这样不能描述数组指针吗?

    数组、指针、数组指针及指针数组-3

    很遗憾,看起来好像是一个指针指向了一个数组,然而int *只能表示指针的特性,编译器无法得知指针指向的是数组(反而告诉编译器指向的是int),所以数组指针的正确表达方式只能是类型 (*变量名)[数组个数]这种格式

    • 二维数组与二级指针(真没什么关系)

    为了避免混淆,最后再来说一下二维数组与二级指针:

    int main() {
        int a[2][3] = {1, 2, 3, 4, 5, 6};
        int (*p)[3] = a;
        printf("%d\n", **p);    // a[0][0]
        printf("%d\n", *(*p+1));    // a[0][1]
        printf("%d\n", *(*(p+1)));  // a[1][0]
        printf("%d\n", *(*(p+1)+1));  // a[1][1]
    }
    
    数组、指针、数组指针及指针数组-4

    如你所见,仅此而已。这并不表示二维数组对应着二级指针,显然我们定义的p是个数组指针而非二级指针。之所能通过**p这种形式来访问二维数组,可以通过如下伪代码来表示:

    *p == int b[3]
    int b[3] == a[1][0]
    *p+1 == b+1
    b+1 == a[0][1]
    *(p+1) == &b+1
    &b+1 == a[1][0]
    *(p+1)+1 == &b+1+1
    &b+1+1 == a[1][1]
    

    最根本原因就在伪代码的第一行,*p指向包含3个int元素的一维数组,而数组名又可以当做地址进一步访问。虽然如此,二维数组与二级指针仍然没有一毛钱关系。


    Have fun!

    相关文章

      网友评论

        本文标题:C语言遐想(瞎想)

        本文链接:https://www.haomeiwen.com/subject/sdgybctx.html