目录
C++是在C语言的基础上发展而来的,因此在复习C++之前,有必要对C语言的各个重点进行梳理,以巩固基础。
一、数组和指针
1、常量指针 VS 指针常量
常量指针:指针指向的空间的值是个常量。即指针指向的空间的值不能发生改变,也不能通过指针的解引用来修改指针指向的空间的值。
但是可以改变指针指向的空间,即指针本身可以修改。
指针常量:指针本身是个常量。即指针本身不可修改,也就是说不能修改指针指向的空间。
但是指针指向的空间的值可以修改,即可以通过指针解引用来修改指针指向的空间的值。
如何在代码层面区分常量指针和指针常量?
利用 const 和 * 的位置来区分:
const 在 * 的左边——常量指针
cosnt 在 * 的右边——指针常量
const int* p1; // 常量指针 int* const p2; // 指针常量
2、数组指针 VS 指针数组
指针数组:顾名思义,即为存放指针的数组
int* arr1[10]; // 存放整型指针的指针数组 char* arr2[10]; // 存放一级字符指针的指针数组 char** arr3[10]; // 存放二级字符指针的指针数组
数组指针:指向数组的指针
int (*p)[10]; // 数组指针 // 解释:p 先和 * 结合,说明 p 首先是一个指针,然后指向的是一个存储着10个整型变量的数组,因此 p 首先是个指针,指向数组,所以是个数组指针 // 值得注意的是,[]的优先级要高于 *,因此 * 上必须加个括号才能确保 p 先和 * 结合
3、函数指针、函数指针数组
函数指针就是指向函数的指针,用来保存函数的地址。
3.1 函数地址的一个重要特性
来看一段代码
#include <stdio.h> void Test() { printf("Hello World\n"); } int main() { printf("&Test: %p\n", &Test); // 函数名也指向函数地址 printf("Test: %p\n", Test); // &函数名可以得到函数地址 return 0; }
运行可以发现,打印结果相同。
这说明了说明函数地址的一个重要特性:函数名可以被视为指向该函数的指针。这意味着可以直接将将函数名赋值给相应类型的函数指针变量,并通过该指针调用函数。
类似下面这样:
void (*func_ptr)() = Test; // func_ptr 是一个指向 void 类型的无参数函数的指针 func_ptr(); // 通过指针调用 Test 函数 /* 需要注意的是,要先用括号和 * 结合说明是个指针,否则如果写成 void* func_ptr(); 会被当成一个无参函数,返回值是 void* */
3.2 函数指针数组
函数指针数组就是储存函数指针的数组,把若干个函数的地址存到一个数组里面。
// 函数指针数组的正确定义方式,parr先和[]结合,说明是数组,里面保存int(*)()类型的指针 int (*parr[10])();
3.3 指向函数指针数组的二级指针
#include <stdio.h> void Test() { printf("Hello World\n"); } int main() { void (*func_ptr)() = Test; // 函数指针 void (*func_ptr_arr[2])() = { Test, Test }; // 函数指针数组 void (*(*ptr_func_ptr_arr)[2])() = &func_ptr_arr; // 指向函数指针数组的二级指针 return 0; }
二、几大重要库函数的介绍和手撕
1、memcpy
1.1 memcpy的介绍
memcpy是一个内存操作函数,需要包含头文件 <string.h>(C++是 <string>)。 函数原型如下:
// 返回目标空间的起始地址 void* memcpy( void* destination /* 通用类型指针,可以接受任意类型数据的指针*/, const void* source, size_t num);
该函数的作用是从 source 所在的位置开始,向后复制 num 个字节的数据(可以传任意类型)给 destination 的内存位置。(遇到 '\0' 也不会停下来)
示例代码:
#include <stdio.h> #include <string.h> int main() { int arr1[] = { 1, 3, 5, 7, 9 }; int arr2[5] = { 0 }; memcpy(arr2, arr1, sizeof(arr1)); for (int i = 0; i < 5; i++) { printf("%d ", arr2[i]); } return 0; }
运行结果
(PS:当 destination 和 source 在内存位置上有重叠,结果将是未定义的,会因为覆盖而发生改变。
在某些编译器(比如VS2022)和运行环境,可能会由于优化而展示正确结果,但其结果仍然是未定义的,不能保证任何环境下都正确。在发生内存重叠的拷贝时,建议还是使用下文会介绍的memove。)
1.2 手撕memcpy
手撕memcpy的时候需要注意的几点
1、memcpy函数返回的是目标空间(即 dest )的起始地址
2、memcpy函数里面的 void* ,虽然可以接收任意类型的指针,但是不能进行解引用和加减操作。需要强转成别的数据类型进行操作。
3、memcpy函数拷贝是以字节为单位的,因此我们以 char* 为数据类型,按字节拷贝,防 止由于数据类型导致拷贝出错。
// 模拟实现memcpy void* my_memcpy(void* dest, const void* source, size_t num) { void* res = dest; assert(dest && source); while (num--) { *(char*)dest = *(char*)source; // 操纵内存一般都使用这种 dest = (char*)dest + 1; source = (char*)source + 1; } return res; }
2、memmove
2.1 memmove的介绍
memcpy是一个内存操作函数,同样需要包含头文件 <string.h>(C++是 <string>)。 函数原型如下:
void* memove( void* destination, const void* source, size_t num)
该函数的作用与memcpy类似,同样是是从 source 所在的位置开始,向后复制 num 个字节的数据(可以传任意类型)给 destination 的内存位置。(遇到 '\0' 也不会停下来)
但是有一个区别,就是memove函数处理的源内存块和目标内存块是可以重叠的,但源空间和目标空间发生重叠的时候,就要使用memove而不是memcpy。
也就是说,memove实际上是mecopy的上位替代。
2.2 手撕memmove
手撕memove的时候需要注意的几点
1、为了防止内存块重叠导致的覆盖,dest 和 source 一旦发生重叠,二者的位置将决定拷贝是从前往后拷贝还是从后往前拷贝:
当 source 在 dest 之前,从后往前拷贝;当 source 在 dest之后,从前往后拷贝
// 模拟实现memmove void* my_memmove(void* dest, const void* source, size_t num) { assert(dest && source); void* res = dest; if (source < dest) { // 从后往前拷贝 while (num--) { *((char*)dest + num) = *((char*)source + num); } } else { // 从前往后拷贝 while (num--) { *(char*)dest = *(char*)source; dest = (char*)dest + 1; source = (char*)source + 1; } } return res; }
3、strstr
3.1 strstr的介绍
strstr是一个字符串查找函数,需要包含头文件<string.h>(C++是 <string>),函数原型如下:
char* strstr(const char* str1, const char* str2);
该函数的作用是:从字符串 str1 中找字符串 str2 第一次出现的位置,找到后会返回第一次出现位置的第一个字符的地址;找不到,返回空指针。
使用示例:
#include <string> #include <stdio.h> int main() { char str1[] = { "Hello World" }; char str2[] = { "World" }; char* begin = strstr(str1, str2); printf("str2 在 str1 中的起始地址:%p\n", begin); printf("str2 在 str1 中:%s\n", begin); return 0; }
![]()
3.2 手撕strstr
手撕 strstr 的时候需要注意的几点
1、在遍历寻找的时候,有可能 str2 走到最后,str1 前面都没有匹配的;但有可能 str1 后面有和 str2 匹配的字符串。此时 str2 需要回到起点,str1 也要回到上次遍历的起点的下一个字符,再次进行比较。为了找到这个两个起点,我们不能直接使用 str1 和 str2 作为遍历指针,而是使用其它指针进行遍历,str1 和 str2 则留在起点以备后用。
2、开始一趟遍历时,如果第一个字符匹配,还需要使用一个指针记录它,如果整个字符串完全匹配,作为首元素地址来返回。
char* my_strstr(char* str1, char* str2) { char* begin = str1; // 作为每一次遍历的起点,也是返回值 // 真正遍历用的指针 char* s1 = nullptr; char* s2 = nullptr; while (*begin != '\0') { // 每一次遍历,s1 和 s2 都重新回归遍历起点 s1 = begin; s2 = str2; // 循环判断遍历,只要有一方走到'\0',也就没有判断的必要了 while (*s1 && *s2 && *s1 == *s2) { s1++; s2++; } if (*s2 == '\0') { // 等于\0,说明s2已经走到末尾,全部匹配,返回起点 return begin; } begin++; // 循环结束,又没有完全匹配,起点++向后遍历 } return nullptr; // 全部遍历都没有完全匹配的,返回空指针 }
三、结构体与联合
1、结构体内存对齐
C语言中的结构体作为C++的类的基础,其基本使用不再多做赘述。(唯有一点要提醒一下,结体传参的时候,要传结构体地址,防止因为参数压栈造成过大的系统开销)
那么,结构体的大小如何计算呢?这就要用到我们下文将着重介绍的结构体内存对齐。
1.1 重要:结构体内存对齐规则
1、第一个成员在与结构体变量偏移量为0的地址处(第一个成员永远从 0 开始往下存放)
2、其它成员变量要对齐到对齐数的整数倍的地址处,然后从此处往下存放字节(取决于这个成员变量所占字节的大小)
对齐数 = Min(编译器默认对齐数(VS系列编译器默认为8,Linux的 gcc 环境下没有默认对齐数,对齐数就是成员自身大小。可以通过 #pragma pack( x ) 预处理指令来更改默认对齐数为 x,#pragma pack() 不给x值也可以取消自己设置的默认对齐数,还原为系统默认),该成员所占字节大小)
3、结构体总大小 = 最大对齐数的整数倍(如果最后一个位置没到整数倍,浪费空间也要对齐)(每个成员变量都有他们自己的对齐数)
4、如果结构体中还嵌套了结构体,嵌套的结构体对齐到自己最大对齐数的整数倍处。
结构体整体大小 = 所有最大对齐数(包含嵌套结构体的对齐数)的整数倍。
1.2 结构体大小计算实例
#include <stdio.h> struct s3 { double d; char c; int i; }; struct s4 { char c1; struct s3 s; double d; }; int main() { printf("s3大小:%d\n", sizeof(struct s3)); printf("s4大小:%d\n", sizeof(struct s4)); return 0; }
我们首先计算结构体 s3
1、先计算内存对齐
2、计算结构体大小:最大对齐数的整数倍,即 16。
然后计算结构体 s4
1、先计算内存对齐
2、计算结构体大小:最大对齐数的整数倍,即 32。
运行上文代码,结果正确。
1.3 为什么要存在内存对齐?
1、平台原因(移植原因)
不是所有的硬件平台都可以访问任意地址上的任意数据,某些硬件平台只能在某些地址处取出某些特定类型的数据,否则将抛出硬件异常。
2、性能原因
数据结构(尤其是栈)应该尽可能地在自然边界上对齐,处理器访问对齐的内存只需要访问一次;而访问未对齐的内存需要访问两次
总而言之,内存对齐可以视作一种以空间换时间的做法。
所以,在设计结构体的时候,为了既满足内存对齐,又节省空间,我们应该把占用空间小的成员集中在一起。
比如:
struct S1 { char c1; int i; char c2; }; struct S2 { char c1; char c2; int i; };
计算可以发现,S1的大小是12,而S2的大小仅有8。
2、联合(共用体)
联合是一种特殊的自定义类型,关键字是 union。联合定义的变量与结构体类似,同样包含了一系列的成员,但特点是这些成员共用同一块内存空间(所以又叫共用体)。
因此使用联合的时候需要注意,同一时间内只能使用其中的一名成员,无法同时使用。
四、C语言的编译链接和预处理
1、程序的翻译环境与执行环境
在ANSI C(关于C语言的标准)的任何一种实现中,都存在两个不同的环境。
1、翻译环境:包含编译器 + 链接器,用于把源代码转换为可执行的机器指令。
2、执行环境:用于实际执行代码。

1.1 翻译环境
翻译环境由编译器 + 链接器 组成。
组成一个程序的每个源文件都要通过编译器的编译过程分别转化成目标文件 -> 每个目标文件由链接器捆绑在一起,形成一个单一而完整的可执行程序。(链接器同时也会引入标准C函数库中任何被该程序所能用到的函数,而且其还可以搜索程序员个人的程序库,将其需要的函数也链接到程序中)
![]()
翻译环境的具体执行流程
1.2 执行环境
程序执行的具体流程:
1、程序首先必须载入内存中。在有操作系统的环境中,这一步一般由操作系统完成;在独立的环境(如单片机)中,程序的载入则必须由手工安排,也可能是通过可执行代码置入只读内存来完成。
2、开始执行函数,首先调用main函数。
3、开始执行程序代码。这时候程序将使用一个运行时堆栈(stack,又称函数栈帧),用来存储函数的局部变量和返回地址;程序同时也可以使用静态(static)内存,存储静态变量,静态变量在程序的整个执行过程中会一直保存。
4、终止程序。正常终止 main 函数 / 意外终止。
2、预处理详解
预处理一般是使用各种预处理指令,在预编译阶段会执行。
2.1 C语言预定义的一些符号
C语言本身内置了一些预定义符号,可以直接使用
__FILE__ // 当前正在编译的源文件,打印格式:%s __LINE__ // 文件当前行号,打印格式:%d __DATE__ // 文件被编译的日期,打印格式:%s __TIME__ // 文件被编译的时间,打印格式:%s __STDC__ // 如果编译器遵循C语言标准,值为1,否则未定义(VS并不严格遵循C语言标准,不支持此符号, gcc编译器是支持的)
举例:
#include <stdio.h> int main() { printf("当前被编译的源文件:%s\n当前行数:%d\n", __FILE__, __LINE__); return 0; }
2.2 #define
#define 一般是用来对某些符号进行用户向替换。一般有两种用途,定义标识符、定义宏。
2.2.1 #define 定义标识符
#define 定义标识符是一种常用的 #define 用途,一般是用来给某个符号取别名 / 下定义,以增强代码可读性和便于修改。
举例:
#define MAX 1000 // 给1000取个别名MAX,在定义数组/其他一些结构时,就可以以MAX代替 1000 #define reg register // 为 register 这个关键字,创建一个简短的名字 #define do_forever for(;;) // 用更形象的符号来替换一种实现 #define CASE break;case // 在写case语句的时候自动把 break写上。 // 如果定义的 stuff过长,可以分成几行写,除了最后一行外,每行的后面都加一个反斜杠(续行符)。 #define DEBUG_PRINT printf("file:%s\tline:%d\t \ date:%s\ttime:%s\n" ,\ __FILE__,__LINE__ , \ __DATE__,__TIME__ )
在#define定义标识符的时候,要不要加分号 ; ?
建议不要加,否则标识符定义时会把 ; 也带进去造成语法错误。
2.2.2 #define定义宏
#define 机制中包括了一项规定,就是允许把参数替换到文本中,这种机制通常称为宏 / 定义宏。
宏的声明方式:
#define 宏名( 参数列表 ) 宏体 // 注意:参数列表的左括号必须与宏名相邻,否则参数列表会被解释成宏体的一部分
我们可以发现,定义宏非常类似于定义函数,实际上这两者也确实有很多相似之处,后文会进行介绍。
PS:
当宏参数在宏的定义中出现超过一次的时候,如果参数带有副作用,那么你在使用这个宏的时候就可能会出现危险,导致不可预测的后果。副作用就是表达式求值的时候出现的永久性效果。(但是函数副作用就没有这么大)
例如:
x+1; // 不带副作用 x++; // 带有副作用
#define 定义宏使用举例1:
#define SQUARE( x ) x * x
这个宏会接收一个参数 x ,如果在上述声明之后,把 SQUARE(5) 置于程序中,预处理器就会用 5 * 5 来替换这个表达式。
但这个宏实际上存在一个问题:
如果使用下面这个代码:
int a = 5; printf("%d\n" ,SQUARE(a + 1));
乍一看或许会认为,这个代码将会打印36这个值,但实际上:
为什么呢?
因为替换文本时,参数x被替换成a + 1,所以这条语句实际上变成了:
printf ("%d\n",a + 1 * a + 1 );
由替换产生的表达式并没有按照预想的次序进行求值。 在宏定义上加上两个括号,这个问题也就解决了:
#define SQUARE(x) (x) * (x)
#define 定义宏使用举例2:
#define DOUBLE(x) (x) + (x)
定义中使用了括号,想避免之前的问题,但是这个宏可能会出现新的错误。int a = 5; printf("%d\n" ,10 * DOUBLE(a));
结果本来应该是100,实际上却是55。这是因为乘法运算先于宏定义的加法。
解决方法就是是在宏定义表达式两边加上一对括号。
#define DOUBLE(x) ( ( x ) + ( x ) )
综上所述,使用 #define 定义宏的时候有一个注意事项:
用于对数值表达式进行求值的宏定义都应该用这种方式加上括号,避免在使用宏时由于参数中的操作符或邻近操作符之间不可预料的相互作用。
#define 定义宏和函数的区别
2.2.3 #define 的替换规则
#define 替换时,会把所有的都替换完再进行计算,共涉及下面几个步骤。
1. 在调用宏时,首先对参数进行检查,看看是否包含任何由#define定义的符号。如果是,它们首先被替换。
2. 替换文本随后被插入到程序中原来文本的位置。对于宏,参数名被他们的值所替换。
3. 最后,再次对结果文件进行扫描,看看它是否还包含任何由#define定义的符号。如果是,就重复上述处理过程。
注意:
1. 宏参数和 #define 的定义中可以出现其他 #define 定义的符号。但是对于宏,不能出现递归。
2. 当预处理器搜索 #define 定义的符号的时候,字符串常量的内容并不被搜索。