真的理解C语言么?–《征服C指针》笔记

彻底理解这种话说不出口,能越来越接近彻底理解即可

《征服C指针》笔记并不能涵盖书中的巧妙,还是希望大家可以自行阅读,把本文当作一个备忘录

前言

用C语言开发了大半年了,对其中一些奇怪的约束和用法表示迷惑和不理解,《征服C指针》这本书是周末偶尔发现的,大致了解了一下,“这就是我要的”脱口而出,能让自己在热爱的计算机领域进步的书籍总是让人兴奋的,更何况是C语言相关的并且通俗易懂。

不将学到的知识记录下来甚是可惜,于是有了这篇文章。

打好基础

C语言是什么样的语言

C语言曾经是只能使用标量的语言。

什么是标量呢?:char、int、double、枚举类型等算术类型以及指针。相对的,像数组、结构体和联合体这种由多个标量组合而成的类型,我们称为聚合类型

早期C语言能够一起实现的功能,只有将标量这种小巧的类型从右边放到左边(赋值),或者标量间的运算、标量间的比较等。

所以为什么不支持if (str == "abc")的原因浮出水面,字符串不是标量,他是char类型的数组,在C中不能用==一下子对数组里的所有元素进行比较。

关于指针

关于指针,K&R中有如下说明:

指针是一种保存变量地址的变量。在C语言中,指针的使用非常广泛

这里先介绍一下变量的概念,C程序里使用的变量值是被保存在内存中的。也就是说,各个变量都被分配了某个地址的内存。向变量赋值,就是把值保存在这个地址的内存中。在C语言中,单是保存整数的变量就有诸如char类型、short类型、int类型和long类型等多种类型。用来保存变量的内存上的空间叫做对象,而被保存为对象的数据类型叫做对象类型

此外,C语言标准对于“指针”一词是如下定义的:

指针类型可以由函数类型、对象类型或不完全类型派生,派生指针类型的类型被称为引用类型

所以,可以这么概括:

  • 指针类型是类型
  • 指针类型由其他类型派生而来,例如其类型可以为指向int的指针类型
  • 指针类型也是类型,也存在指针类型的变量指针类型的值
  • 先有指针类型,因为有了指针类型,所以有了指针类型的变量和指针类型的值
  • 指针类型的值,实际上就是内存的地址

借着这个机会,纠正一下读法:

// 指向int的指针 类型 的 变量hoge_p
int *hoge_p;

再看一段神奇的代码:

int *p = 3; // 警告
int *p = 0; // 无警告

为什么第二行代码没有警告?在C语言中,在应当被当作指针处理的上下文中,0这个常量会被当作空指针处理。

关于数组

下标运算符[]与数组毫无关系!

选自书中原文,一开始就亮明这句话,体现它的重要性。

关键就在于,在表达式中,不论数组名后是否加[],数组都会被解读成指向其初始化元素指针,所以,有一句话是错的。

在C语言中,如果数组名后不加[],而只是写数组名,那么此名称就表示“指向数组初始元素的指针”

可以注意到,是表达式中,因为声明时用的*&[]与表达式中的他们是风马牛不相及的。

而且下标运算符也是一种运算符,他需要访问下标和指针,也算是一个二元运算符,既然二元运算符a + b可以改写为b + a,那么同理如下:

#include <stdio.h>

int main(void) {
   
   int array[5];
   int *p;

   for (int  i = 0; i < 5; i++) {
       array[i] = i;
  }

   p = &array[0];
   for (int  i = 0; i < 5; i++) {
       printf("%d\n", *(p + i));
  }
   printf("=====================\n");
   for (int  i = 0; i < 5; i++) {
       printf("%d\n", i[p]);
  }
   printf("=====================\n");
   for (int  i = 0; i < 5; i++) {
       printf("%d\n", i[array]);
  }
   return 0;
}

// 结果是一样的

所以,虽然有些违背常理,下标运算符[]的确与数组毫无关系,p[i]只是*(p + i)的简便写法罢了,可以理解成一个语法糖。

在声明函数形参时,才可以将数组的声明解读为指针,也只有这种情况下int a[]int *a具有相同的意义了:

// 一下形参声明是一个意思
int func(int *a);
int func(int a[]);
int func(int a[10]); // 编译器会直接无视元素个数

有了以上知识背景,可以理解C语言为什么不进行数组边界检查了,这只是个语法糖啊,写的时候看上去制定了边界,但直接就被解读成指针了。

C语言是怎样使用内存的

小tips:fflush()是用于输出流的,不能用于输入流,在C语言标准中,fflush()用于输入流的行为时未定义的。

C语言中内存的使用方法

作用域:

  • 全局变量
  • 静态变量
  • 局部变量

存储器:

  • 静态存储器
  • 自动存储器

生命周期:

  • 静态变量:生命周期从程序运行时开始,到程序关闭时结束
  • 自动变量:生命周期直至程序离开该变量声明所带代码块为止
  • 通过malloc()分配的内存空间:生命周期直至free()被调用为止

在C语言中,表达式的数组会被解读为指针,同样的,表达式中的函数也意味着指向函数的指针。

栈在运行时可以延伸,所以我们可以在栈上配置可变长数组(VLA),只有自动变量可以使用VLA。

函数与字符串字面量

在如今大多数操作系统中,函数主体与字符串字面量时一并配置在同一个只读内存区域中的。

书上写了写关于函数的汇编代码,眼见为实,自己调用看看,程序如下:

int add_func(int a, int b)
{
   int result;
   result = a + b;
   return result;
}

int main()
{
   int ans = add_func(1, 2);
   return 0;
}

汇编关键部分如下:

整体还是能猜出来的:

  • 调用方将实参的值从后往前压入栈中
  • 函数参数优先传递给了寄存器edi,esi,add_func中,将两个寄存器的值赋值给局部变量a,b
  • a,b求和,最终放入eax,调用约定规定函数的返回值要保存在寄存器eax中

可变长参数

typedef char* va_list;
void va_start ( va_list ap, prev_param ); /* ANSI version */
type va_arg ( va_list ap, type );
void va_end ( va_list ap );

说明:

1)va_list:一个字符指针,可以理解为指向当前参数的一个指针,取参必须通过这个指针进行。

2)va_start:对ap进行初始化,让ap指向可变参数表里面的第一个参数。第一个参数是 ap 本身,第二个参数是在变参表前面紧挨着的一个变量,即“…”之前的那个参数;

3)va_arg: 获取参数。它的第一个参数是ap,第二个参数是要获取的参数的指定类型。按照指定类型获取当前参数,返回这个指定类型的值,然后把 ap 的位置指向变参表中下一个变量的位置;

4)va_end:释放指针,将输入的参数 ap 置为 NULL。通常va_start和va_end是成对出现。

利用malloc()动态分配内存

是否应该强制转换malloc()的返回值类型?

  • ANSI C之前,C语言没有void *,所以malloc()的返回值为char *,需要强制转换
  • ANSI C之后,malloc()返回值改为了void *,不需要转换
  • C++中,无法将void *赋值给普通指针变量,需要强制转换

这小节关于内存的介绍以及下一小节对齐的介绍还是较为清晰的,要是不了解相关知识的可以去看看,暂时不做描述。

语法揭秘

解读C语言声明

C语言阅读起来有时候会反直觉,答案很简单:C语言原本是在美国诞生的语言,所以我们应该用英文来读,可以遵循以下规则:

  1. 先看标识符(变量名或函数名)
  2. 从贴近标识符的地方开始,按如下优先级解释派生类型(指针、数组、函数)
    • 用于整合声明的括号
    • 用于表示数组的[]、表示函数的()
    • 表示指针的*
  3. 完成对派生类型的解释之后,通过of、to或returning连接句子
  4. 添加类型修饰符(位于左侧,比如int、double)
C语言英语表达
int hoge;hoge is int
int hoge[10];hoge is array of int
int hoge[10][3];hoge is array of array of int
int *hoge[10];hoge is array of pointer to int
double (*hoge)[3];hoge is pointer to array of double
int func(int a);func is funciton returning int
int (*func_p)(int a) ;funcc_p is pointer to function returning int

英文表达清晰多了,也不容易有歧义。

C语言数据类型的模型

指针:

数组:

数组和指针都是派生类型,单独都较为清晰,混在一起呢,比如指向数组的指针

一听到“指向数组的指针”,有人也许要说: 这不是很简单嘛,数组名后不加[],不就是“指向数组的指针”吗? 抱有这个想法的人,请将 前文重新阅读一下!

的确,在表达式中,数组可以被解读成指针。但是,这不是“指向数组的指针”,而是“指向数组初始元素的指针”

int main()
{
   int (*array_p)[3];
   int array[3];
   array_p = &array;
   // array_p = array;
   // warning: assignment to 'int (*)[3]' from incompatible pointer type 'int *' [-Wincompatible-pointer-types]
   return 0;
}

但注意到,这只是warning,没有强制报错,从地址的角度来看,array 和&array也许就是指向同一地址。但要说起它们的不同之处,那就是它们在做指针运算时结果不同。

因为int 类型的长度是4个字节,所以给“指向 int 的 指针”加1,指针前进4个字节。

但对于“指向 int 的数组(元素个数 3)的指针”,这个指针指向的类型为“int 的数组(元素个数 3)”,当前数组的尺寸为12个字节(如果 int 的长度为 4 个字节),因此给这个指针加1,指针就前进12 个字节。

C语言不存在多维数组

int hoge[3][2]的读法是什么:hoge ia array of array,是数组的数组,有多维数组么,多维是便于逻辑上理解的概念,实际排布如下:

如果函数的形参需要是所谓的“多维数组”,该怎么声明函数呢?如下都是可以的,要记住,函数会被解读成指针,上文提到:在声明函数形参时,才可以将数组的声明解读为指针,也只有这种情况下int a[]int *a具有相同的意义了。

void func(int (*hoge)[2]);
void func(int hoge[3][2]);
void func(int hoge[][2]);

函数类型的派生

函数类型也是一种派生类型,“参数(类型)”是它的属性。

下文原封不动来自书本,特地使用引用,最好理解这段话。

可是,函数类型和其他派生类型有不太相同的一面。

无论是int还是double,亦或数组、指针、结构体,只要是函数以外的类型,大体都可以作为变量被定义。而且,这些变量在内存占用一定的空间。

因此,通过sizeof运算符可以取得它们的大小。 像这样,有特定长度的类型,在标准中称为对象类型。

可是,函数类型不是对象类型。因为函数没有特定长度。 所以C中不存在“函数类型的变量”(其实也没有必要存在)。

数组类型就是将几个派生类型排列而成的类型。因此,数组类型的全体长度为: 派生源的类型的大小×数组的元素个数。

可是,函数类型是无法得到特定长度的,所以从函数类型派生出数组类型是不可能的。也就是说,不可能出现“函数的数组”这样的类型。

可以有“指向函数的指针”类型,但不幸的是,对指向函数类型的指针不能做指针运算,因为我们无法得到当前指针类型的大小。

此外,函数类型也不能成为结构体和共用体的成员。

总而言之:从函数类型是不能派生出除了指针类型之外的其他任何类型的。

不过“指向函数的指针类型”,可以组合成指针或者作为结构体、共用体的的数据类型的成员。

毕竟“指向函数的指针类型”也是指针类型,而指针类型又是对象类型。 另外,函数类型也不可以从数组类型派生。

表达式

基本表达式:

  • 标识符(变量名、函数名)
  • 常量(包括整数常量和浮点数常量)
  • 字符串常量(使用“”括起来的字符串)
  • 使用()括起来的表示式

表达式代表某处的内存区域的时候,我们称当前的表达式为左值(lvalue);相对的是,表达式只是代表值的时候,我们称当前的表达式为右值。

在表达式中,数组会被解读为指针,除了以下三个情况:

  • 当作为sizeof操作数时,返回的是数组整体的长度
  • 作为&运算符操作数时,这个上文有例子,指向数组的指针
  • 初始化数组时的字符串字面量。我们都知道字符串常量是“char 的数组”,在表达式中它通常被解读成“指向char 的指针”。其实,初始化char的数组时的字符串常量,作为在花括号 中将字符用逗号分开的初始化表达式的省略形式

数组被解读为指针时,该指针不是左值,所以以下代码是错误的:

char str[10];
str = "abc";

只能对左值赋值。

解读C语言声明(续)

const 是在ANSI C中追加的修饰符,它将类型修饰为“只读”。

char *my_strcpy(char *dest, const char *src) 
{
src = NULL; // ←即使对 src 赋值,编译器也没有报错
}
// 此时,成为只读的不是 src,而是 src 所指向的对象。
char *my_strcpy(char *dest, const char *src)
{
*src = 'a'; // ←ERROR!!
}

// 如果将 src 自身定义为只读,需要写成下面这样:
char *my_strcpy(char *dest, char * const src)
{
src = NULL; // ←ERROR!!
}

// 如果将 src 和 src 指向的对象都定义为只读,可以写成下面这样:
char *my_strcpy(char *dest, const char * const src)
{
src = NULL; // ←ERROR!!
*src = 'a'; // ←ERROR!!
}

字符串字面量

""包裹的字符串称为字符串字面量,类型时char的数组,保存在只读区域,在表达式中,会被解读为指向char的指针。

可是,char 数组的初始化是个例外。此时的字符串常量,作为在花括号中分开书写的初始化表达式的省略形式,编译器会进行特殊处理。

char str[] = "abc"; 
char str[] = {'a', 'b', 'c', '\0'};

从ANSI C开始,即使是自动变量的数组,也可以被整合来进行初始化。

关于指向函数的指针引发的混论

对于 C 语言,表达式中的函数可以被解读成 “指向函数的指针”。

/*如果发生 SIGSEGV(Segmentation falut),回调函数 segv_handler */ 
signal(SIGSEGV, segv_handler);
signal(SIGSEGV, &segv_handler); // 尽然也是对的
func_p();
(*func_p)(); // 也是对的

上述代码看了之后很迷茫,都解读成指针了,怎么是否取地址,解引用都一样呢?

为了照顾到这种混乱,ANSI C 标准对语法做了以下例外的规定:

  • 表达式中的函数自动转换成“指向函数的指针”。但是,当函数是地址运算符&或者sizeof运算符的操作数时,表达式中的函数不能变换成 “指向函数的指针”
  • 函数调用运算符()的操作数不是“函数”,而是“函数的指针”

如果对“指向函数的指针”使用解引用*,它暂时会成为函数,但是因为在表达式中,所以它会被瞬间地变回成“指向函数的指针”。

结论就是,即使对“指向函数的指针”使用*运算符,也是对牛弹琴,因 为此时的运算符*发挥不了任何作用。

阅读完书上这段话的时候我就觉的一言难尽……毕竟C语言历史较久,包容一下。

解读复杂声明

int atexit(void (*func)(void));
/*
* atexit is function
* para is func
* returning int
* func is pointer to function returning void
*/
void (*signal(int sig, void (*func)(int)))(int);
/*
* signal function, para is sig and func
* sig is int
* func is pointer to function, para is int returning void
* signal function returning pointer to function which para is int returning void
*/

我的感觉是就别翻译成中文了,直接用英文梳理比较方便。

请记住:数组与指针截然不同

大家都说C语言的指针比较难,可是真正地让初学者“挠墙”的,并不是指针自身的使用,而是“混淆了数组和指针”。此外,很多“坑爹”的入门书对指针和数组的讲解也是极其混乱。

数组和指针的常见用法

这一章节主要是代码上的例子,翻阅即可。

遇到了一个还是容易有歧义的例子。

double (*p[5])[2];

因为[]的优先级比*高,所以一上来就是:p is an array!

读法如下:

  • p is an array
  • each element is a pointer to array of double
double (*p[5])[2];
double element[2] = {1.0, 2.0};
p[0] = &element;
printf("%lf\n", (*p[0])[1]);

数据结构

第五章主要是指针在数据结构上的应用。

拾遗

指定初始化

typedef struct {
   int a;
   double b;
   int array[10];
   char *str;
   char array2[4];
} Hoge;

void printfHoge(Hoge hoge)
{
   printf("hoge a:%d\n", hoge.a);
   printf("hoge b:%lf\n", hoge.b);
   printf("hoge array[4]:%d\n", hoge.array[4]);
   printf("hoge array[5]:%d\n", hoge.array[5]);
   printf("hoge str:%s\n", hoge.str);
   printf("hoge array2:%s\n", hoge.array2);
   return;
}

int main()
{
   Hoge hoge = {
      .a = 1,
      .b = 2.0,
      .array = {[4] = 5, 3},
      .str = "hello",
      .array2 = {'b', 'y', 'e', '\0'}
  };
   printfHoge(hoge);
   Hoge hoge2 = (Hoge){
      .a = 1,
      .b = 2.0,
      .array = {[4] = 5, 3},
      .str = "hello",
      .array2 = {'b', 'y', 'e', '\0'}
  };
   printfHoge(hoge2);
   return 0;
}

直接对结构体的元素指定值并初始化,或者传递一个结构体字面量都是可以的。

留下评论