前言

最近在看一些书补下C的基础(大学第一门是C++,跟C还是有一点差异的),虽说大部分语法层面的内容都上手很快+容易记住,但涉及一些指针层面的东西,经常看完就忘了,所以还是决定以博客的方式记录下,方便后续自己 or 他人查阅。


mac无法更改指针来修改常量

我们先试着在win上用指针修改常量,发现是可以成功的👇🏻:
image.png

但是在mac上却失败了👇🏻:
image.png

我一开始尝试debug引用查看内存是否发生变化,结果是变了的,如下图所示👇🏻:
image.png

之后想了想会不会是预编译的时候做了跟宏一样的优化方式,也是预编译了一下,发现并不是,预编译后内容如下(可以看见宏已经被替换了)👇🏻:
image.png

剩下的猜测只能是mac做了编译层的优化了,应该类似于Java的标量替换,意思是最终的代码做了跟宏差不多的事情👇🏻:

// 最终代码反编译可能就是这样的,至于是在哪层被优化了,之后可能要查看汇编指令等排查。

#include <stdio.h>

# define COLOR_RED 0xFF0000

int main() {
    // const <type> readonly variable
    const int kRed = 0xFF0000;
    printf("kRed: %#x\n", kRed);

    int *p_k_red = &kRed;
    *p_k_red = 0x000000;
    printf("kRed: %#x\n", 0xFF0000);

    // 仅用于预编译的演示
    printf("COLOR_RED:%#x", 0xFF0000);

    return 0;
}

指针问题

以下内容绝大部分参考《C与指针》

基础概念

一条指针等式

先来看看,有下面这一段代码,*&x = 17;是什么意思?

# include "stdio.h"

int main() {
    int x = 1;
    printf("%#x\n", x);

    *&x = 17;
    printf("%#x\n", x);

    return 0;
}

其实*&x = 17;等价于x=17,输出结果如下:

0x1
0x11

我们可以通过debug查看内存,*&x = 17;之前👇🏻:
image.png

字节序倒过来是大小端的问题,这里不在展开。

*&x = 17;之后👇🏻:
image.png

对于上述步骤,你可以把*&x = 17换成x=17,得到的结果是一样的,在这里只需要记住作为左值*&x = x就行了。

左值和右值下面就会介绍。


左值和右值

首先介绍下我理解的概念:

  • 左值:表达式可以容纳右值,它必须指向一个明确的地址来存储。
  • 右值:表达式的结果可以被赋予到左值,右值只能被存储 or 被指针用于间接访问。

这个概念第一次看的时候会比较懵逼,但多看几次后其实就没啥大问题了。

假设有如下代码:

int a = 1;
int *b = &a;
  • *b在这里就是左值
  • &a在这里就是右值

那可不可以写这样一行代码呢?👇🏻:

&a = 2;

答案是不可以,因为&a的结果是16进制的地址,你可以理解为写下面这样的代码是非法的:

1 = 2;

如果这里看不懂那还没关系,后面说到指针表达式的时候,还会有很多例子。


指针的指针

我们来看下面一个代码例子:

# include "stdio.h"

int main() {

    int a = 12;
    int *b = &a;
    int **c = &b;
    printf("%d", **c);
    return 0;

}
// 删除结果就是12

对于上面的例子,我们需要想起前面的一条等式,即*&x = x,我们可以通过约简来得到结果:

// 以下是已知条件
a = 12;
*b = &a; // 可以看做b = &a,*b = *&a = a = 12

// 开始约简
**c = &b也可以像上面那样先看做c = &b,然后👇🏻
**c = **&b = *b = *(&a) = a = 12

至少我通过这种方式记忆其实会好很多。


指针表达式

如果只是单个指针变量,通常看起来没什么难度,但如果是指针配合表达式,那么它的意思往往会跟我们想象的有些许偏差,下面就举一些例子,来说说一些表达式左右值的合法性和含义。

《C与指针》中会把右值放在表格左边,左值放在右边,看的时候带上自己的方向感会很容易混乱。。

前置代码如下:

int main() {

    char ch = 'a';
    char *cp = &ch;

}
  • 我之所以用char,只是因为它刚好一个字节,便于到内存中根据偏移debug查看

先来看简单的,这部分就展开含义了,毕竟都是基础语法:

  • 表达式ch

    • 左值 ✅,ch = 'b';成立
    • 右值 ✅,char cx = ch;成立
  • 表达式&ch

    • 左值 ❌,&ch = 任何常量/变量/引用;不成立
    • 右值 ✅,char *cp = &ch;成立
  • 表达式*cp

    • 左值 ✅,char *cp = &ch;成立
    • 右值 ✅,char cx = *cp;成立

接下来看一些比较复杂的:

  • 表达式*cp + 1

    • 左值 ❌:*cp + 1 = 任何不成立
    • 右值 ✅:ch = *cp + 1;成立
    • 含义看例子就懂了👇🏻:
    • image.png
    • *cp + 1就是*cp就是取出a的ASCII码+1,结果就是b。
  • 表达式*(cp + 1)

    • 左值 ✅:*(cp + 1) = 98成立,含义为对cp地址的后一位设置为62(98的16进制)。
    • 右值 ✅:ch = *(cp + 1)成立,含义为将cp地址后一位的内容赋予变量ch。
    • 左值例子👇🏻:
    • image.png
    • 右值例子👇🏻:
    • image.png

从这里开始注意,但凡有++的,返回的都是拷贝指针,以下文字中可能会省略

  • 表达式++cp

    • 左值 ❌:++cp = 任何不成立。
    • 右值 ✅:char *cx = ++cp成立,含义为cp指针的地址指向后一位,并把新指针赋值给*cx。
    • 右值例子👇🏻:
    • image.png
    • 可以在char *cx = ++cp后加一行--cpcp的输出结果会变成a,但cx依旧是b,证明cx不是指向cp。
  • 表达式cp++

    • 左值 ❌:cp++ = 任何不成立。
    • 右值 ✅:char *cx = cp++成立,含义为先把cp的指针赋予*cx,然后cp地址指向后一位。
    • 右值例子👇🏻:
    • image.png
    • 同样可以在char *cx = cp++后加一行--cp,*cp的结果变成a,*cx依旧是a,证明cx不是指向cp。
  • 表达式*++cp

    • 左值 ✅:*++cp = 98成立,含义就是把cp的地址指向后一位,然后把98放到在这个地址。
    • 右值 ✅:char cx = *++cp成立,++cp是返回地址,而在前面加个*就是把地址的值返回。
    • 左值例子👇🏻:
    • image.png
    • 同样的把cp--注释去掉,cp会变成a,cx依旧不变,证明cx不是指向cp。
    • 右值例子👇🏻:
    • image.png
  • 表达式*cp++

    • 左值 ✅:*cp++ = 98;成立,内部逻辑:①++操作符产生cp值的拷贝;②cp指针指向下一位;③将98放到①中的拷贝地址。从结果看很容易误会成先执行*再++,但实际上不是
    • 右值 ✅:char cx = *cp++;成立,内部逻辑除了赋值,跟上面基本相同。
    • 左值例子👇🏻:
    • image.png
    • cp--注释去掉,则cp=b,cx依旧为c。
    • 右值例子👇🏻:
    • image.png
    • cp--注释掉后,cp也会变成a,cx依旧为a。
  • 表达式++*cp

    • 左值 ❌:++*cp = 任何不成立。
    • 右值 ✅:char cx = ++*cp成立,转化下就是char cx = ++*&ch = ++ch,最终ch为b,赋值给cx。
    • 右值例子👇🏻:
    • image.png
    • 如果把cp++注释去掉则,cp为c,cx为b,证明cx不是指向cp。
  • 表达式(*cp)++

    • 左值 ❌:(*cp)++不成立。
    • 右值 ✅:char cx = (*cp)++;成立,跟*cp++不同,这里是先取cp地址的值,然后再++(作用于原来的地址,也就是把a改成b),不会改变指针位置。
    • 右值例子👇🏻:
    • image.png
    • 如果把cp++注释去掉,那么cp为d,cx为a。
  • 表达式++*++cp

    • 左值 ❌:++*++cp = 任何不成立。
    • 右值 ✅:char cx = ++*++cp;成立,从右到左看,*++cp是把cp地址指向后一位,然后取出当前地址的值;取出值为b,然后执行++b=c,当前地址的b被改成c了。
    • 右值例子👇🏻:
    • image.png
    • 注意这里*(cp + 1)已经被改成c了,所以把cp–注释掉后,cp为a。
  • 表达式++*cp++

    • 左值 ❌:++*cp++ = 任何不成立。
    • 右值 ✅:char cx = ++*cp++;成立,这里的含义有些复杂,首先会把*cp的值给前面的加号,得到结果为b,赋予cx(cx=b);之后再执行cp++,把cp指向后一位地址。
    • 右值例子👇🏻:
    • image.png
    • 如果把cp--注释去掉,则cp为b,如果在此基础上把cp = cp + 2注释去掉,那么cb就为c。

好了,基础的算式先记到这里,实际上后面还有数组算式等各种各样的初见杀,等之后有时间再一一补上了。


指针-一维数组

让我们来看看如下代码👇🏻:

#include "stdio.h"

#define SIZE 7

int main() {
    char chars[SIZE] = {'a', 'b', 'c', 'd', 'e', 'f', 'g'};
    char *ptr1 = chars;
    char *str = "abcdefg";

    for (int i = 0; i < SIZE; i++) {
        printf("%c", *(ptr1 + i));
    }
    puts("");

    for (int i = 0; i < SIZE; i++) {
        printf("%c", *(str + i));
    }
    puts("");

    return 0;
}
// 输出结果👇🏻
abcdefg
abcdefg

根据以上程序的结果,我们可以得知chars[i] = *(chars + i) = *(ptr1 + i),这是因为数组的内存是连续的,无论是下标还是其它方式都是根据内存偏移的方式获取值的,所以最终结果都是相同的。

除此之外,指向一维数组的指针还有如下等式:
image.png


指针-多维数组

这里就拿二维数组做实验,代码如下:

#include "stdio.h"

#define SIZE 4

int main() {

    char chars[SIZE][SIZE] = {
            {'q', 'w', 'e', 'r'},
            {'a', 's', 'd', 'f'},
            {'z', 'x', 'c', 'v'},
            {'u', 'j', 'n', 'l'}
    };
    char *ptr = chars; // 实际上已经指向了char[0][0]的位置,所以无法像chars一样直接根据数组大小偏移直接"跳行"

    puts("chars首字母遍历:");
    for (int i = 0; i < SIZE; i++) {
        printf("%c\n", *(*(chars + i) + 0));// 等效于chars[i][0]
    }
    puts("");

    puts("chars第二字母遍历:");
    for (int i = 0; i < SIZE; i++) {
        printf("%c\n", *(*(chars + i) + 1));// 等效于chars[i][1]
    }
    puts("");

    puts("一些等式");
    printf("%c\n", chars[2][2]);
    printf("%c\n", *(*(chars + 2) + 2));
    printf("%c\n", *(ptr + 10));


    return 0;
}

输出结果如下:

chars首字母遍历:
q
a
z
u

chars第二字母遍历:
w
s
x
j

一些等式
c
c
c

指针常量

《C与指针》第8章中提到过,数组变量本质上就是指向某个类型的常量指针,比如char chars[1] = {'a'};就是指向char类型的常量指针

下面附上一个常量指针例子的代码(注释含解释):

#include <stdio.h>
#include <io_utils.h>

int main() {

  int a = 20;
  int b = 99;
  int *p = &a;
  PRINT_INT(*p);
  PRINT_INT(a);

  // 从右往左看,一个只读指针(*const)指向一个整形变量(int)
  // 因为const只用来修饰指针,所以指针指向的地址不可变,但地址的值可变(int不受约束)
  int *const cp = &a;
  *cp = 2; // OK
  //cp = &b; // ERROR

  // 一个指针(*)指向只读变量int(int const)
  // const只修饰int,指针无修饰,所以不能修改值(int),但是指针是自由的,所以能被修改
  int const * cp2 = &a;
  // *cp2 = 2; ERROR
  cp2 = &b; // OK

  // 一个只读指针(*const)指向一个只读的int变量(int const)
  // 从右到左第一个const既作用于指针,第二个const作用于int,所以 只读指针+只读变量 都不可以修改
  int const *const ccp = &a;
  // ccp = &b; ERROR
  //*ccp = 2; ERROR
  return 0;
}

C的volatile关键字

  • C标准中的volatile并不像Java那样能保证可见性(但MSVC可以)。
  • 它的作用是禁止编译器优化被修饰的元素。
  • 理所当然的也不保证原子性。

C的继承与多态

众所周知,在C里面没有类、继承等关键字,但却依旧有继承的概念。C的继承跟Go有些类似,是使用包装实现的继承。

多说无益,直接看下面一个包含继承、强转(多态)代码例子👇🏻:

#include <stdio.h>

struct Creature {
    char const *name;
    int age;
    void (*print) (struct Creature *);
};

typedef void (* printName) (struct Creature *);

void print(struct Creature * creature) {
    printf("%s\n", creature->name);
}

struct Creature newCreature() {
    struct Creature creature = {.print=print, .name="Creature"};
    return creature;
}


struct Human {
    struct Creature parent_class;
    char const *address;
    int gender;

    void (*print) (struct Human *);
};

struct Human newHuman() {
    struct Human human = {.parent_class=newCreature()};
    human.print=(void *)human.parent_class.print;
    human.parent_class.name="Human";
    return human;
}

struct Student {
    struct Human parent_class;
    char const *student_number;
    char const *school;

    void (*print) (struct Student *);
};

struct Student newStudent() {
    struct Student student = {.parent_class=newHuman()};
    student.print=(void *)student.parent_class.print;
    student.parent_class.parent_class.name = "Student";
    return student;
}

int main() {
    // 整体继承关系:Student 继承 Human 继承 Creature
    struct Creature creature = newCreature();
    struct Human human = newHuman();
    struct Student student = newStudent();

    puts("调用各print函数");
    creature.print(&creature);
    human.print(&human);
    student.print(&student);
    puts("");

    puts("\"父类\"强转\"子类\":");
    struct Human *forceHuman = (struct Human *) &creature;
    // forceHuman->print(forceHuman); // 虽然强转了,但此Human实例的print指针是空的,所以这里调用不会有任何反应
    printf("%s\n", forceHuman->parent_class.name);
    printf("父类结构体大小:%lu\n", sizeof(creature));
    printf("子类结构体大小:%lu\n", sizeof(*forceHuman)); // 去掉*就是指针大小,64位机器上通常固定8字节,32位4字节
    puts("");

    puts("\"子类\"强转\"父类\":");
    struct Creature *forceCreature = (struct Creature *) &student;
    forceCreature->print(forceCreature);
    printf("父类结构体大小:%lu\n", sizeof(student));
    printf("子类结构体大小:%lu\n", sizeof(*forceCreature)); // 去掉*就是指针大小,64位机器上通常固定8字节,32位4字节
    puts("");

    return 0;
}

输出结果如下👇🏻:

调用各print函数
Creature
Human
Student

"父类"强转"子类":
Creature
父类结构体大小:24
子类结构体大小:48

"子类"强转"父类":
Student
父类结构体大小:72
子类结构体大小:24

你可能会好奇为啥C能做到这一点,其实这都得益于结构体内存对齐,拿父类转子类来说,其实就是创建一个子类的指针,然后指向父类即可,如下图所示👇🏻:
image.png

  • 因为内存对齐的缘故,当指针指向父类时,刚好可以完整获取到对应范围的内存,于是看起来就强转成功了。
  • 另外,也由于内存对齐的缘故,如果父类没有声明在结构体的第一行,那也会翻车,有点类似于Java中super()必须写在第一行(当然我不确定Java这么做的原因是不是因为内存对齐)

更多待补充

Q.E.D.