C 语言基础知识梳理
C 语言
主要参考源 C 语言简介
C 语言能够直接操作硬件、管理内存、跟操作系统对话,这使得它是一种非常接近底层的语言,也就是低级语言,非常适合写需要跟硬件交互、有极高性能要求的程序。C 语言的哲学是“信任程序员,不要妨碍他们做事”。比如,它让程序员自己管理内存,不提供内存自动清理功能。另外,也不提供类型检查、数组的负索引检查、指针位置的检查等保护措施。
C 语言是一种编译型语言,源码都是文本文件,本身无法执行。必须通过编译器,生成二进制的可执行文件,才能执行。编译器将代码从文本翻译成二进制指令的过程。生成的编译产物文件a.out(assembler output 的缩写,Windows 平台为a.exe
语法
C 语言规定,语句必须使用分号结尾
单个分号也是有效语句,称为“空语句”,虽然毫无作用。
C 语言允许多个语句使用一对大括号{},组成一个块,也称为复合语句
stdio.h是标准库的头文件,只有在源码头部加上#include <stdio.h>,才能使用printf()
不同的功能定义在不同的文件里面,这些文件统称为“头文件”(header file)。如果系统自带某一个功能,就一定还会自带描述这个功能的头文件,比如printf()的头文件就是系统自带的stdio.h。头文件的后缀通常是.h。
变量名区分大小写
**变量的作用域:文件作用域(file scope)和块作用域(block scope)。**文件作用域(file scope)指的是,在源码文件顶层声明的变量,从声明的位置到文件结束都有效。块作用域(block scope)指的是由大括号({})组成的代码块,它形成一个单独的作用域。凡是在块作用域里面声明的变量,只在当前代码块有效,代码块外部不可见,内层代码块可以使用外层声明的变量,但外层不可以使用内层声明的变量。如果内层的变量与外层同名,那么会在当前作用域覆盖外层变量
++var和-var是先执行自增或自减操作,再返回操作后var的值;var++和var--则是先返回操作前var的值,再执行自增或自减操作
C 语言有一个三元表达式?:,可以用作if...else的简写形式。a = b? c:d;
break语句有两种用法。一种是与switch语句配套使用,用来中断某个分支的执行 ,另一种用法是在循环体内部跳出循环,不再进行后面的循环了。
continue语句用于在循环体内部终止本轮循环 ,进入下一轮循环。只要遇到continue语句,循环体内部后面的语句就不执行了,回到循环体的头部,开始执行下一轮循环
在计算机内部,字符类型使用一个字节(8位)存储 。C 语言将其当作整数处理,所以字符类型就是宽度为一个字节的整数。每个字符对应一个整数(由 ASCII 码确定),比如B对应整数66。
C 语言使用signed关键字,表示一个类型带有正负号,包含负值;使用unsigned关键字,表示该类型不带有正负号,只能表示零和正整数。int类型,默认是带有正负号的,也就是说int等同于signed int
signed char c; // 范围为 -128 到 127
unsigned char c; // 范围为 0 到 255
编译器将一个整数字面量指定为int类型,但是程序员希望将其指定为long类型,这时可以为该字面量加上后缀l或L,编译器就知道要把这个字面量的类型指定为long。
int x = 1234;
long int x = 1234L;
long long int x = 1234LL
unsigned int x = 1234U;
unsigned long int x = 1234UL;
unsigned long long int x = 1234ULL;
float x = 3.14f;
double x = 3.14;
long double x = 3.14L;
sizeof是 C 语言提供的一个运算符,返回某种数据类型或某个值占用的字节数量。它的参数可以是数据类型的关键字,也可以是变量名或某个具体的值。
// 参数为数据类型
int x = sizeof(int);
// 参数为变量
int i;
sizeof(i);
// 参数为数值
sizeof(3.14);
只要在一个值或变量的前面,使用圆括号指定类型(type),就可以将这个值或变量转为指定的类型,这叫做“类型指定” (casting)。(unsigned char) ch
C 语言的整数类型(short、int、long)在不同计算机上,占用的字节宽度可能是不一样的,无法提前知道它们到底占用多少个字节。程序员有时控制准确的字节宽度,这样的话,代码可以有更好的可移植性,头文件stdint.h创造了一些新的类型别名。
(1)精确宽度类型(exact-width integer type),保证某个整数类型的宽度是确定的。
* `int8_t`:8位有符号整数。
* `int16_t`:16位有符号整数。
* `int32_t`:32位有符号整数。
* `int64_t`:64位有符号整数。
* `uint8_t`:8位无符号整数。
* `uint16_t`:16位无符号整数。
* `uint32_t`:32位无符号整数。
* `uint64_t`:64位无符号整数。
指针
指针是什么?首先,它是一个值,这个值代表一个内存地址 ,因此指针相当于指向某个内存地址的路标。字符*表``示指针,通常跟在类型关键字的后面,表示指针指向的是什么类型的值。比如,char*表示一个指向字符的指针,float*表示一个指向float类型的值的指针。
指针变量就是一个普通变量,只不过它的值是内存地址而已。
一个指针指向的可能还是指针,这时就要用两个星号*表示。 int** foo;
*这个符号除了表示指针以外,还可以作为运算符,用来取出指针变量所指向的内存地址里面的值(取地址的值)
void increment(int* p) {
*p = *p + 1;
}
&运算符用来取出一个变量所在的内存地址。(取地址)
&运算符与``运算符互为逆运算,下面的表达式总是成立。
int i = 5;
if (i == *(&i)) // 正确
指针变量的初始化
声明指针变量之后,编译器会为指针变量本身分配一个内存空间,但是这个内存空间里面的值是随机的,也就是说,指针变量指向的值是随机的。这时一定不能去读写指针变量指向的地址,因为那个地址是随机地址,很可能会导致严重后果。
int* p;
*p = 1; // 错误
上面的代码是错的,因为p指向的那个地址是随机的,向这个随机地址里面写入1,会导致意想不到的结果。
正确做法是指针变量声明后,必须先让它指向一个分配好的地址,然后再进行读写,这叫做指针变量的初始化。
int* p;
int i;
p = &i;
*p = 13;
上面示例中,p是指针变量,声明这个变量后,p会指向一个随机的内存地址。这时要将它指向一个已经分配好的内存地址,上例就是再声明一个整数变量i,编译器会为i分配内存地址,然后让p指向i的内存地址(p = &i;)。完成初始化之后,就可以对p指向的内存地址进行赋值了(*p = 13;)。
为了防止读写未初始化的指针变量,可以养成习惯,将未初始化的指针变量设为NULL。
int* p = NULL;
NULL在 C 语言中是一个常量,表示地址为0的内存空间,这个地址是无法使用的,读写该地址会报错。
C 语言规定,main()是程序的入口函数,即所有的程序一定要包含一个main()函数。程序总是从这个函数开始执行,如果没有该函数,程序就无法启动。其他函数都是通过它引入程序的。main()的写法与其他函数一样,要给出返回值的类型和参数的类型,
int main(void) {
printf("Hello World\n");
return 0;
}
上面示例中,最后的return 0;表示函数结束运行,返回0。
C 语言约定,返回值0表示函数运行成功,如果返回其他非零整数,就表示运行失败,代码出了问题。系统根据main()的返回值,作为整个程序的返回值,确定程序是否运行成功。
如果函数的参数是一个变量,那么调用时,传入的是这个变量的值的拷贝,而不是变量本身。
void increment(int a) {
a++;
}
int i = 10;
increment(i);
printf("%d\n", i); // 10
上面示例中,调用increment(i)以后,变量i本身不会发生变化,还是等于10。因为传入函数的是i的拷贝,而不是i本身,拷贝的变化,影响不到原始变量。这就叫做“传值引用”
如果想要传入变量本身,只有一个办法,就是传入变量的地址。
void Swap(int* x, int* y) {
int temp;
temp = *x;
*x = *y;
*y = temp;
}
int a = 1;
int b = 2;
Swap(&a, &b);
C 语言还规定,函数名本身就是指向函数代码的指针,通过函数名就能获取函数地址。也就是说,print和&print是一回事
static用于函数内部声明变量时,表示该变量只需要初始化一次,不需要在每次调用时都进行初始化。也就是说,它的值在两次调用之间保持不变。
#include <stdio.h>void counter(void) {
static int count = 1; // 只初始化一次
printf("%d\n", count);
count++;
}
int main(void) {
counter(); // 1
counter(); // 2
counter(); // 3
counter(); // 4
}
static可以用来修饰函数本身。tatic关键字表示该函数只能在当前文件里使用,如果没有这个关键字,其他文件也可以使用这个函数(通过声明函数原型)
函数参数里面的const说明符,表示函数内部不得修改该参数变量。
void f(int* p) {
// ...
}
上面示例中,函数`f()`的参数是一个指针`p`,函数内部可能会改掉它所指向的值`*p`,从而影响到函数外部。为了避免这种情况,可以在声明函数时,在指针参数前面加上`const`说明符,告诉编译器,函数内部不能修改该参数所指向的值。只限制修改p所指向的值,而p本身的地址是可以修改的
void f(const int* p) {
*p = 0; // 该行报错
}
有些函数的参数数量是不确定的,声明函数的时候,可以使用省略号...表示可变数量的参数。
`int printf(const char* format, ...);`
C 语言没有单独的字符串类型,字符串被当作字符数组,即char类型的数组。比如,字符串“Hello”是当作数组{'H', 'e', 'l', 'l', 'o'}处理的。双引号之中的字符,会被自动视为字符数组。单引号里面是字符
strlen()函数返回字符串的字节长度,不包括末尾的空字符\0
内存管理
C 语言的内存管理,分成两部分。一部分是系统管理的,另一部分是用户手动管理的。
系统管理的内存,主要是函数内部的变量(局部变量)。这部分变量在函数运行时进入内存,函数运行结束后自动从内存卸载。这些变量存放的区域称为”栈“(stack),”栈“所在的内存是系统自动管理的。
用户手动管理的内存,主要是程序运行的整个过程中都存在的变量(全局变量),这些变量需要用户手动从内存释放。如果使用后忘记释放,它就一直占用内存,直到程序退出,这种情况称为”内存泄漏“(memory leak)。这些变量所在的内存称为”堆“(heap),”堆“所在的内存是用户手动管理的
- C 语言提供了一种不定类型的指针,叫做 void 指针。它只有内存块的地址信息,没有类型信息,等到使用该块内存的时候,再向编译器补充说明,里面的数据类型是什么。void 指针等同于无类型指针,可以指向任意类型的数据,但是不能解读数据。void 指针与其他所有类型指针之间是互相转换关系,任一类型的指针都可以转为 void 指针,而 void 指针也可以转为任一类型的指针。 void 指针的重要之处在于,很多内存相关函数的返回值就是 void 指针,只给出内存块的地址信息,所以放在最前面进行介绍。
C 语言提供了struct关键字,允许自定义复合数据类型,将不同类型的值组合在一起。这样不仅为编程提供方便,也有利于增强代码的可读性。C 语言没有其他语言的对象(object)和类(class)的概念,struct 结构很大程度上提供了对象和类的功能。
typedef命令用来为某个类型起别名。type代表类型名,name代表别名。
typedef unsigned char BYTE;
BYTE c = 'z';
上面示例中,typedef命令为类型unsign char起别名BYTE,然后就可以使用BYTE声明变量
预处理指令
C 语言编译器在编译程序之前,会先使用预处理器(preprocessor)处理代码。
预处理器首先会清理代码,进行删除注释、多行的语句合成一个逻辑行等等。然后,执行#开头的预处理指令。
#define是最常见的预处理指令,用来将指定的词替换成另一个词。它的参数分成两个部分,
#define MAX 100
上面示例中,#define指定将源码里面的MAX,全部替换成100。MAX就称为一个宏。
宏的强大之处在于,它的名称后面可以使用括号,指定接受一个或多个参数
`#define SQUARE(X) X*X` 上面示例中,宏`SQUARE`可以接受一个参数`X`,替换成`X*X`。
这种写法很像函数,但又不是函数,而是完全原样的替换,会跟函数有不一样的行为。
#define SQUARE(X) X*X
// 输出19
printf("%d\n", SQUARE(3 + 4));
上面示例中,SQUARE(3 + 4)如果是函数,输出的应该是49(7*7);宏是原样替换,所以替换成3 + 4*3 + 4,最后输出19。
#undef指令用来取消已经使用#define定义的宏。
#define LIMIT 400
#undef LIMIT
上面示例的undef指令取消已经定义的宏LIMIT,后面就可以重新用 LIMIT 定义一个宏。
有时候想重新定义一个宏,但不确定是否以前定义过,就可以先用#undef取消,然后再定义。因为同名的宏如果两次定义不一样,会报错,而#undef的参数如果是不存在的宏,并不会报错。
#include指令用于编译时将其他源码文件,加载进入当前文件。它有两种形式。
// 形式一
#include <foo.h> // 加载系统提供的文件// 形式二
#include "foo.h" // 加载用户提供的文件
//形式一,文件名写在尖括号里面,表示该文件是系统提供的,通常是标准库的库文件,不需要写路径。因为编译器会到系统指定的安装目录里面,去寻找这些文件。
//形式二,文件名写在双引号里面,表示该文件由用户提供,具体的路径取决于编译器的设置,可能是当前目录,也可能是项目的工作目录。如果所要包含的文件在其他位置,就需要指定路径,下面是一个例子。
#if...#endif指令用于预处理器的条件判断,满足条件时,内部的行会被编译,否则就被编译器忽略。
#ifdef...#endif指令用于判断某个宏是否定义过。 #ifdef可以与#else指令配合使用。
#ifdef EXTRA_HAPPY
printf("I'm extra happy!\n");
#elseprintf("I'm just regular\n");
#endif
#ifndef...#endif指令跟#ifdef...#endif正好相反。它用来判断,如果某个宏没有被定义过,则执行指定的操作。
#ifdef EXTRA_HAPPY
printf("I'm extra happy!\n");
#endif#ifndef EXTRA_HAPPY
printf("I'm just regular\n");
#endif
C 语言提供一些预定义的宏,可以直接使用。
* `__DATE__`:编译日期,格式为“Mmm dd yyyy”的字符串(比如 Nov 23 2021)。
* `__TIME__`:编译时间,格式为“hh:mm:ss”。
* `__FILE__`:当前文件名。
* `__LINE__`:当前行号。
* `__func__`:当前正在执行的函数名。该预定义宏必须在函数作用域使用。
* `__STDC__`:如果被设为1,表示当前编译器遵循 C 标准。
* `__STDC_HOSTED__`:如果被设为1,表示当前编译器可以提供完整的标准库;否则被设为0(嵌入式系统的标准库常常是不完整的)。
* `__STDC_VERSION__`:编译所使用的 C 语言版本,是一个格式为`yyyymmL`的长整数,C99 版本为“199901L”,C11 版本为“201112L”,C17 版本为“201710L”。
#include <stdio.h>int main(void) {
printf("This function: %s\n", __func__);
printf("This file: %s\n", __FILE__);
printf("This line: %d\n", __LINE__);
printf("Compiled on: %s %s\n", __DATE__, __TIME__);
printf("C Version: %ld\n", __STDC_VERSION__);
}
/* 输出如下
This function: main
This file: test.c
This line: 7
Compiled on: Mar 29 2021 19:19:37
C Version: 201710
*/
变量说明符
C 语言允许声明变量的时候,加上一些特定的说明符(specifier),为编译器提供变量行为的额外信息。它的主要作用是帮助编译器优化代码,有时会对程序行为产生影响
const说明符表示变量是只读的,不得被修改。
const double PI = 3.14159;
PI = 3; // 报错
// const 表示地址 x 不能修改
int* const x
// const 表示指向的值 *x 不能修改
int const * x
// 或者
const int * x
static说明符对于全局变量和局部变量有不同的含义。
(1)用于局部变量(位于块作用域内部)。
static用于函数内部声明的局部变量时,表示该变量的值会在函数每次执行后得到保留,下次执行时不会进行初始化,就类似于一个只用于函数内部的全局变量。由于不必每次执行函数时,都对该变量进行初始化,这样可以提高函数的执行速度,详见《函数》一章。
(2)用于全局变量(位于块作用域外部)。
static用于函数外部声明的全局变量时,表示该变量只用于当前文件,其他源码文件不可以引用该变量,即该变量不会被链接(link)。
extern说明符表示,该变量在其他文件里面声明,没有必要在当前文件里面为它分配空间。通常用来表示,该变量是多个文件共享的。 extern int a;
register说明符向编译器表示,该变量是经常使用的,应该提供最快的读取速度,所以应该放进寄存器。但是,编译器可以忽略这个说明符,不一定按照这个指示行事
头文件 .c 和 .h的说明,在多个文件编译时,往往会有一个主文件(包含main)和多个待引用文件,在主文件中引用别的文件中定义的函数时需要在主文件中定义函数原型。如下:函数add定义在别的文件中,须在编译时在主文件中声明函数原型,
// File foo.c
#include <stdio.h>
int add(int, int);
int main(void) {
printf("%d\n", add(2, 3)); // 5!
}
如果有多个文件都使用这个函数add(),那么每个文件都需要加入函数原型。一旦需要修改函数add()(比如改变参数的数量),就会非常麻烦,需要每个文件逐一改动。所以,通常的做法是新建一个专门的头文件xxx.h,放置所有在xxx.c里面定义的函数的原型。
然后使用include命令,在用到这个函数的源码文件(包含main)里面加载这个头文件bar.h。
// File foo.c
#include <stdio.h>
#include "bar.h"
int main(void) {
printf("%d\n", add(2, 3)); // 5!
}
上面代码中,#include "bar.h"表示加入头文件bar.h。这个文件没有放在尖括号里面,表示它是用户提供的;它没有写路径,就表示与当前源码文件在同一个目录
重复加载
头文件里面还可以加载其他头文件,因此有可能产生重复加载。比如,a.h和b.h都加载了c.h,然后foo.c同时加载了a.h和b.h,这意味着foo.c会编译两次c.h。
最好避免这种重复加载,虽然多次定义同一个函数原型并不会报错,但是有些语句重复使用会报错,比如多次重复定义同一个 Struct 数据结构。解决重复加载的常见方法是,在头文件里面设置一个专门的宏,加载时一旦发现这个宏存在,就不再继续加载当前文件了。
// File bar.h
#ifndef BAR_H
#define BAR_H
int add(int, int);
#endif
上面示例中,头文件bar.h使用#ifndef和#endif设置了一个条件判断。每当加载这个头文件时,就会执行这个判断,查看有没有设置过宏BAR_H。如果设置过了,表明这个头文件已经加载过了,就不再重复加载了,反之就先设置一下这个宏,然后加载函数原型。
