1、数据类型
在 C 语言中,数据类型可以分为如下几类:
-
基本数据类型:
-
整数类型(Integer Types):是算数类型,包括如下几种:
int
:用于表示整数数据,通常占用四个字节short
:用于表示短整数数据,通常占用两个字节long
:用于表示长整数数据,占用字节长度可变,一般为四个字节,也有可能为八个字节,取决于编译器和目标系统的实现
-
浮点类型(Floating-Point Types):也是算数类型,包含两种:
float
:用于表示单精度浮点数,通常占用四个字节double
:用于表示双精度浮点数,通常占用八个字节
-
字符型:
char
:用于表示字符数据,通常占用一个字节 -
无符号类型(Unsigned Types):
unsigned char
:无符号字符类型,表示非负整数unsigned int
:无符号整数类型,表示非负整数unsigned short
:无符号短整数类型,表示非负整数unsigned long
:无符号长整数类型,表示非负整数
-
-
enum
:枚举类型,用于定义一组常量 -
void
:表示无类型,通常用于函数返回类型或指针类型 -
bool
:布尔类型,表示逻辑值(true 或 false)。C 语言中布尔类型只要不是 0 值(包括 0.0)就为 true -
派生类型:包括数组类型、指针类型和结构体类型
基本数据类型的大小(占用的字节数)可能在不同的编译器和平台上有所差异(比如 long)。此外,C 语言还提供了类型修饰符(如signed
和const
)和类型限定符(如volatile
和restrict
)用于进一步修饰这些基础数据类型。
示例代码如下:
#include <stdio.h>
#include <string.h>
// 如函数实现在调用之后,需要先声明函数
void dataTypeTest();
int main() {
dataTypeTest();
return 0;
}
void dataTypeTest() {
int i = 100;
float f = 200;
double d = 200;
long l = 300;
short s = 100;
char c = 'd';
char *str = "Tracy";
// int占4个字节,float占4个字节,double占8个字节,long占4个字节,short占2个字节,
// char占1个字节,字符串Tracy占5个字节
printf("int占%llu个字节,float占%llu个字节,double占%llu个字节,long占%llu个字节,short占%llu个字节,"
"char占%llu个字节,字符串Tracy占%llu个字节", sizeof(int),sizeof(float), sizeof(double), sizeof(long),
sizeof(short),sizeof(char), strlen(str));
}
Java 的 long 是 8 个字节,而在 C/C++ 中是 4 个字节(取决于具体的系统和编译器,在 64 位 Mac 系统上就是 8 个字节)。
此外,C/C++ 的基本类型和引用类型都是按值传递,基本类型传递的是变量值,引用类型(数组、指针)传递的是地址值;Java 的基本类型按值传递,而引用类型按引用传递,传递的是引用的副本(但是副本和原引用指向同一地址)。因此可以认为 C/C++/Java 的基本类型都传值,引用类型都传地址。
2、函数指针与指针运算
指针是 C 语言众多类型之一,也是 C 语言的重点。以下会通过一些例子演示指针的使用。
2.1 理解指针
指针存放的是内存地址,但是指针变量自己也有地址,多级指针存放的就是指针变量的地址:
void pointerSample1() {
int num = 999;
// 指针变量 num_p 存放 num 的地址
int *num_p = #
// 指针变量 num_p_p 存放的是指针变量 num_p 的地址
int **num_p_p = &num_p;
// 指针变量 num_p_p_p 存放的是指针变量 num_p_p 的地址
int ***num_p_p_p = &num_p_p;
// num的地址:000000a9593ff954,num_p的地址:000000a9593ff948,num_p_p的地址:000000a9593ff940
printf("num的地址:%p,num_p的地址:%p,num_p_p的地址:%p\n", num_p, num_p_p, num_p_p_p);
// *num_p的值:999,**num_p_p的值:999,***num_p_p的值:999
printf("*num_p的值:%d,**num_p_p的值:%d,***num_p_p的值:%d\n", *num_p, **num_p_p, ***num_p_p_p);
}
按照严格的语法,指针是不能跨类型赋值的。比如 num_p 是一个指向 int 类型变量的指针,那么就不能将 num_p 赋值给一个指向 double 类型的指针 num_p_d。但是在 CLion 之类的 IDE 中,这样操作只会警告而不会报错:
void pointerSample2() {
int num = 12;
int *num_p = #
// CLion 中跨类型的指针赋值只会警告,但编译不会报错,
// 如果在 VS 中会严格按照语法规则直接报错
double *num_p_d = num_p;
printf("%llu\n", sizeof num_p); // 8
printf("%llu\n", sizeof num_p_d); // 8
/*
* 32 位系统,内存地址为 32 位,64 位系统内存地址为 64 位。
* 由于内存地址为 64 位,因此任何类型的指针,都占 8 个字节。
* 那为什么还要对指针的类型加以区分,出现 int * 不能赋值给
* double * 的情况呢?这就要思考指针类型的用途了,它是为了
* 指导取值时的寻址单位。
* 比如说 int * 表示指向 int 类型的数据,每个 int 占 4 个
* 字节,那么其地址的表现方式就类似:0x0000、0x0004、0x0008
* 而 double * 表示指针指向 double 类型的数据,每个 double
* 占 8 个字节,其地址排列方式类似:0x0000、0x0008、0x0010
*/
// num_p = 000000868cfffbdc,num_p + 1 = 000000868cfffbe0
// dc -> e0 是 4 个字节,对应 int 类型的 4 个字节
printf("num_p = %p,num_p + 1 = %p\n", num_p, (num_p + 1));
// num_p_d = 000000c3375ffa0c,num_p_d + 1 = 000000c3375ffa14
// 0c -> 14 是 8 个字节,对应 double 类型的 8 个字节
printf("num_p_d = %p,num_p_d + 1 = %p\n", num_p_d, (num_p_d + 1));
}
至于 int *
不能赋值给 double *
的原因,大致有两点:
- int 是整型,double 是双精度浮点型,如果允许赋值可能会导致类型不匹配的问题,进而引发运行时错误
- 注释上写的很清楚,int 占 4 个字节,double 占 8 个字节,指针进行操作时以字节为单位,int 类型指针加 1 会移动 4 个字节,double 类型指针加 1 会移动 8 个字节,这个字节数也不匹配
2.2 指针与数组
指针可以指向数组地址以访问数组内容:
void pointerSample3() {
int arr[4] = {1, 2, 3, 4};
// 1.通过数组下标访问数组元素
// CLion 中允许在循环内创建临时变量,但有的 IDE 不允许这种写法
for (int i = 0; i < 4; ++i) {
printf("arr[%d] = %d\n", i, arr[i]);
}
// 如下三种形式都表示数组的首地址,输入一样
printf("arr = %p\n&arr = %p\n&arr[0] = %p\n", arr, &arr, &arr[0]);
// 2.通过指针(首地址)位移的方式访问数组元素
int *p = arr;
for (int i = 0; i < 4; ++i) {
// p + i 是在 p 的基础上偏移 i 个变量地址,而不是绝对地址。比如 arr 数组装的是 int
// 类型的变量,每个 int 类型占 4 个字节,因此 p + i 实际上是在 p 的地址上偏移了
// i * 4 个字节的地址才能取到相应的数组内的变量
printf("arr[%d] = %d\n", i, *(p + i));
}
}
通过指针为数组元素赋值:
void pointerSample4() {
int arr[4];
int *p = arr;
for (int i = 0; i < 4; ++i) {
*(p + i) = i + 100;
}
// sizeof 后面接关键字只能用括号,接表达式可以直接写在后面
// sizeof arr = 4 * 4 = 16,sizeof(int) = 4
for (int i = 0; i < sizeof arr / sizeof(int); ++i) {
printf("arr[%d] = %d\n", i, *(p + i));
}
}
2.3 函数指针
函数指针是指向函数的指针,声明时需要指定函数的返回值类型和参数类型:
/**
* void 表示函数返回值类型为空
* *method 表示这是一个函数指针类型,method 是形参上的函数名
* (int, int) 表示 method 函数的参数类型均为 int,在 pointerSample6() 内
* 调用 operate() 传入的 add 与 minus 传的就是这两个方法的地址
*/
void(*method)(int, int)
代码示例:
void add(int num1, int num2) {
printf("num1 + num2 = %d\n", num1 + num2);
}
void minus(int num1, int num2) {
printf("num1 - num2 = %d\n", num1 - num2);
}
void operate(void(*method)(int, int), int num1, int num2) {
// 可直接调用 method 指针表示的方法,省略完整形式 *method() 前面的 *
method(num1, num2);
// method 内函数指针地址:00007ff6e44119c4
// method 内函数指针地址:00007ff6e44119f2
printf("method 内函数指针地址:%p\n", method);
}
void pointerSample6() {
operate(add, 20, 10);
operate(minus, 100, 30);
// add 指针地址:00007ff6e44119c4
printf("add 指针地址:%p\n", add);
// minus 指针地址:00007ff6e44119f2
printf("minus 指针地址:%p\n", minus);
}
使用函数指针可以实现回调,看下例:
/**
* 使用函数指针进行回调
* 部分编译器不允许在声明函数指针类型的变量时直接赋值,而是先声明再赋值:
* void(*callbackMethod)(char *, int, int);
* callbackMethod = callback;
* 此外,对于函数指针变量而言,在赋值与调用时,在其前面加上 & 也是可以的,即
* compress("test.png", callback) 与 compress("test.png", &callback)
* 皆可
*/
void pointerSample7() {
compress("test.png", callback);
// compress("test.png", &callback);
}
/**
* 回调方法
* @param fileName 文件名称,传入的是 char 类型数组的首地址,相当于字符串
*/
void callback(char *fileName, int current, int total) {
printf("%s 文件压缩进度:%d/%d\n", fileName, current, total);
}
void compress(char *fileName, void(*callback)(char *, int, int)) {
for (int i = 0; i < 100; ++i) {
callback(fileName, (i + 1), 100);
}
}
通过指向函数的指针可以传递函数,这就联想到了不同语言间函数作为参数的对比:
- C/C++:使用函数指针传递函数作为函数上的参数
- Java:方法不能直接作为方法参数,只能传递一个 Callback 对象,在方法内回调 Callback 的方法
- Kotlin:高阶函数直接支持函数作为参数
3、内存分配
3.1 内存划分
C 语言的内存划分为如下五个部分:
RAM 的三个分区用来存放不同的对象:
- 栈区:方法执行时入栈,执行完毕出栈,因此栈区保存的主要是方法的形参,以及方法内定义的非静态局部变量
- 堆区:通过 malloc() 等函数动态申请的变量保存在堆区
- 全局静态区:保存全局变量与静态变量(包含全局静态变量与局部静态变量)
3.2 内存分配方式
可以通过静态分配与动态分配两种方式分配内存,静态分配发生在全局静态区,动态分配在堆区和栈区都有可能发生。
分配内存时,堆区与栈区可以分配的最大空间是平台相关的,栈区最大大概在 2M,而堆区最大可以达到编译器最大内存的 80%。
静态分配
静态分配是在编译时为变量分配固定大小的内存空间:
- 在编译时分配内存,内存在程序开始运行之前就已经确定
- 分配给全局变量、静态变量(全局与局部静态变量),放在内存的全局静态区
- 内存在程序的整个生命周期内保持不变
- 由编译器负责内存的分配与回收,无需手动释放
动态分配
动态分配需要将堆区和栈区分开来看。栈区动态分配主要是指方法执行入栈时,申请的方法形参以及方法内的非静态局部变量,堆区动态分配则是指通过 malloc()
、calloc()
、realloc()
这些方法在堆上动态申请的变量。
堆区动态分配:
- 在运行时分配内存,内存大小和生命周期可以在运行时确定
- 由程序员负责显式的进行内存的分配与回收
- 内存分配函数包括
malloc()
、calloc()
、realloc()
等,释放函数为free()
注意,不论是
malloc()
、calloc()
还是realloc()
分配的都是连续的内存空间,所以才可以在声明地址后:int *arr = (int *) malloc(sizeof(int) * num);
通过 arr[1]、arr[2] 这种形式来获取对应内存位置上的元素。
栈区动态分配:
- 在运行时分配内存,内存大小和生命周期可以在运行时确定
- 由编译器生成的机器代码进行内存分配,当函数运行完毕弹栈后会回收内存,无需程序员手动处理
- 分配给函数形参以及函数内的非静态局部变量
对于局部变量的内存分配,确切的说法是由编译器生成的机器代码来完成的。在编译阶段,编译器会为每个局部变量分配所需的内存空间,并在生成的机器代码中包含相应的指令来执行内存分配操作。
编译器会根据变量的类型和作用域确定所需的内存大小,并为每个变量分配适当大小的内存空间。这个内存分配的过程是在编译时静态完成的,也就是说,编译器在编译阶段就已经确定了局部变量的内存分配情况。
当程序执行到包含局部变量声明的代码块或函数时,生成的机器代码会在运行时为局部变量分配内存。这是因为内存的实际分配发生在程序运行时,而不是在编译时。
因此,尽管内存分配的细节是由编译器生成的机器代码来完成的,但在程序的运行时阶段才会真正分配局部变量所需的内存空间。
思考与辨析
局部数组是静态分配还是动态分配呢?
void sample1() {
int arr[3] = {1, 2, 3};
}
虽然编译器在编译时就能确定这个数组的大小,但是该数组始终还是一个非静态局部变量,不是静态分配的目标(全局变量与静态变量),因此它不是静态分配的,而是在函数执行期间的栈帧中动态分配的。
3.3 动态分配内存的简单示例
malloc() 与 realloc()
使用 malloc() 与 realloc() 动态分配内存:
/**
* 使用 realloc() 进行动态分配内存,有三种结果:
* 1.分配成功,在原地址上扩容
* 2.分配成功,但是由于原来的空间不够,在新的地址上开辟空间,返回新的首地址
* 3.分配失败,剩余空间不足以分配扩容后的空间,返回 nullptr
*/
void sample4() {
printf("请输入元素个数:");
int original_count;
scanf("%d", &original_count);
int *arr = (int *) malloc(sizeof(int) * original_count);
for (int i = 0; i < original_count; ++i) {
arr[i] = i + 1;
printf("第%d个元素是:%d,其地址为%p\n", i + 1, arr[i], arr + i);
}
// 使用 realloc() 扩容
printf("请输入新的总元素个数:");
int new_count;
scanf("%d", &new_count);
int *new_arr = (int *) realloc(arr, sizeof(int) * new_count);
// 如果扩容失败 realloc() 会返回 nullptr,这时应该释放掉 arr
if (!new_arr) {
printf("扩容失败");
// 释放指针之前一定要判断指针是否为空,避免重复释放而发生异常
// 当然,CLion 在这里又做了优化,即便重复释放也不会报错
if (arr != nullptr) {
free(arr);
arr = nullptr;
}
return;
}
for (int i = 0; i < new_count; ++i) {
new_arr[i] = i + 1;
printf("第%d个元素是:%d,其地址为%p\n", i + 1, new_arr[i], new_arr + i);
}
// 扩容成功的话,使用完毕应该释放 new_arr,不要忘记让 arr 指向 nullptr
if (new_arr != nullptr) {
free(new_arr);
new_arr = nullptr;
if (arr != nullptr) {
arr = nullptr;
}
}
}
测试结果:
请输入元素个数:2
第1个元素是:1,其地址为0000018808012450
第2个元素是:2,其地址为0000018808012454
请输入新的总元素个数:4
第1个元素是:1,其地址为0000018808012450
第2个元素是:2,其地址为0000018808012454
第3个元素是:3,其地址为0000018808012458
第4个元素是:4,其地址为000001880801245c
可以看到 realloc() 是基于原指针的地址进行的扩容,前两个元素的地址没有变化。
悬空指针与野指针
在 C 语言中,“悬空指针”(dangling pointer)和"野指针"(wild pointer)是指针的两种不正确使用情况。
- 悬空指针(Dangling Pointer):
- 悬空指针是指指向已释放或无效内存的指针。
- 当你释放了一个指针所指向的内存块,但仍然保留该指针,那么这个指针就成为悬空指针。
- 悬空指针的存在是危险的,因为你无法保证该指针所指向的内存是否已被其他程序或操作重新分配或使用。
- 引用悬空指针可能导致未定义的行为,如访问无效的内存、程序崩溃等。
- 野指针(Wild Pointer):
- 野指针是指未初始化或未被明确赋值的指针。
- 当你声明一个指针变量但未给它分配有效的内存地址或将其初始化为一个确定的值时,该指针被称为野指针。
- 野指针指向未知的内存位置,它可能指向任意的内存内容。
- 引用野指针可能导致未定义的行为、内存访问错误或程序崩溃。
避免悬空指针和野指针的最佳做法是在使用指针之前进行初始化,并在释放内存后将指针置为 nullptr 或另一个有效的地址。此外,确保指针在使用之前指向有效的内存区域,并在不再使用指针时及时释放相应的内存。
/**
* 不给 aar 初始化它就会成为一个野指针
* 在回收了 aar 指向的堆内存后不让 arr = nullptr 就会使其悬空
*/
void sample2() {
// 在堆上动态开辟内存,如果未初始化 arr,写成 int * arr;
// 那么 arr 就是一个野指针
int *arr = malloc(1024 * 1024);
// 堆区开辟的内存首地址为:00000251e11b3040,其指针地址为:00000084e69ff898
printf("堆区开辟的内存首地址为:%p,其指针地址为:%p\n", arr, &arr);
if(arr != nullptr) {
// 使用完毕要释放内存
free(arr);
// free() 仅仅是释放了 aar 指向的堆区的内存区域,aar 仍会指向这个已经被回收的地址,
// 为了避免 arr 悬空(即指向已经被回收的地址),让其指向 nullptr,nullptr 的地址为 0
arr = nullptr;
// arr 指向的内存地址:0000000000000000
printf("arr 指向的内存地址:%p\n", arr);
}
}
简单讲释放内存时要执行两步:
- free(arr) 释放 arr 指向的内存空间
- arr = nullptr 让 arr 指向地址 0x0000 而不是继续指向原来已经被回收的地址
需要注意在执行这两步之前要判断 arr 是否为 nullptr,如果为 nullptr 说明已经执行过释放操作,不应再重复释放,否则会运行异常(当然 CLion 针对这一点做了优化,即便重复释放也不会异常)。
4、字符串
4.1 字符串声明
字符串有三种声明方式:
- 字符数组:char str[] = “Hello, World!”; 或 char str[] = {‘H’, ‘e’, ‘l’, ‘l’, ‘o’, ‘!’, ‘\0’};
- 字符指针:char* str = “Hello, World!”;
- 字符串常量:“Hello, World!”
前两种方式可以修改字符串,字符数组可以直接修改字符内容,而字符指针可以通过指向其他字符串常量的方式实现字符串的“修改”。字符串常量由于保存在只读存储区域,因此不可更改。
以下是示例代码:
/**
* 字符串的声明与修改
*/
void sample1() {
// 1.字符数组,有两种声明方式,第一种需要在末尾补 \0,第二种会自动补充,无需手动补 \0
char str1[] = {'H', 'e', 'l', 'l', 'o', '!', '\0'};
str1[5] = '~';
char str2[] = "Hello!";
str2[5] = '~';
printf("str1 = %s,str2 = %s\n", str1, str2);
// 2.字符指针,指向一个字符串常量,不能直接修改原字符串中的内容,
// 只能通过指向其他字符串实现修改
char *str3 = "Hello, World!";
// 不能这样直接改
// str3[11] = '~';
str3 = "Hello, World~";
printf("str3 = %s\n", str3);
// 3.字符串常量
printf("字符串常量:%s\n", "Hello, World!");
}
关于三种方式对字符串修改的解释:
- 字符数组:看似是直接修改了字符串常量的值,但是前面也提到过“字符串常量不可修改”这个大前提,因此字符数组也肯定不是直接修改了字符串常量的,只是将字符串常量拷贝到了字符数组中,修改数组中的内容
- 字符指针:指向字符串常量,因此还是无法直接修改字符串常量的值,只能是指向另一个字符串常量
- 字符串常量:保存在内存的只读区域(常量区)不可修改,在编译时会自动转换为字符数组
最后再解释一下为什么字符串常量不能修改:
当我们使用字符串常量时,编译器将它们存储在只读内存区域,这是出于安全性和性能的考虑。
- 安全性:
- 将字符串常量存储在只读内存区域可以防止意外的修改。这对于确保字符串的内容不被不经意的修改是非常重要的。
- 如果允许直接修改字符串常量,那么当多个变量共享同一个字符串常量时,一个变量的修改可能会影响到其他变量,导致意想不到的行为。
- 通过将字符串常量标记为只读,可以确保其内容不会被修改,提高程序的稳定性和可靠性。
- 性能:
- 将字符串常量存储在只读内存区域可以节省内存空间。
- 由于字符串常量是不可修改的,可以共享同一个字符串常量的内存空间,而不需要为每个变量分配单独的内存。
- 这样可以减少内存消耗,并提高程序的性能和效率。
然而,如果我们需要修改字符串的内容,我们就需要使用字符数组或字符指针来声明字符串。这样我们可以将字符串存储在可写的内存区域,从而允许对字符串进行修改。
4.2 字符串使用示例
计算字符串长度
基础用法:
void sample2() {
char str[] = {'A', 'B', 'C', 'D', '\0'};
printf("字符串长度为:%d", getLen(str)); // 4
}
int getLen(char *str) {
int length = 0;
// 也可以 *str != '\0'
while (*str) {
length++;
str++;
}
return length;
}
可能你会想到直接用 sizeof 通过计算得出字符串的长度:
/**
* 错误示范,数组在做函数参数时会被优化为指针,因此在通过
* sizeof 做除法运算计算字符串长度时永远都是 8/1=8,IDE
* 也给出了相应的提示,见下方注释
*/
int getStrLengthWrong(char str[]) {
// Clang-Tidy: Suspicious usage of sizeof pointer 'sizeof(T*)/sizeof(T)'
// 'sizeof str' will return the size of the pointer, not the array itself
return sizeof str / sizeof(char);
}
但是这种用法是错误的。由于 C 语言会做出优化,当函数形参是数组时,会被优化为指针(64 位系统一个指针就 8 字节,而数组往往不止 8 个字节,传指针效率更高),因此 sizeof str / sizeof(char) 的计算结果固定为 8 / 1 = 8。所以还是要用 getLen() 的方式。
字符串格式转换
使用 atoi() 等函数转换:
/**
* 字符串格式转换:
* atoi() 是字符串转 int,类似的还有 atof()、atol()、atoll()
*/
void sample4() {
// 转换为整数
char *arr = "123456";
int result = atoi(arr);
if (result) {
printf("转换成功,结果为:%d\n", result);
} else {
printf("转换失败!");
}
// 转换为浮点数
arr = "123.456";
double fResult = atof(arr);
if (fResult) {
printf("转换成功,结果为:%lf\n", fResult);
} else {
printf("转换失败!");
}
}
比较、查找、包含、拼接
void sample5() {
// 1.比较
char *str1 = "Test";
char *str2 = "test";
// strcmp 区分大小写,strcmpi 不区分,0 表示相等
// 两个函数在 string.h 中
int result1 = strcmp(str1, str2);
int result2 = strcmpi(str1, str2);
// result1 = -1,result2 = 0
printf("result1 = %d,result2 = %d\n", result1, result2);
// 2.查找、包含
char *text = "name is Tracy";
char *subText = "T";
// 在字符串中查找指定字符串的第一次出现
char *pop = strstr(text, subText);
// 返回非 NULL 结果证明找到了指定字符串
if (pop) {
// 查找到了,pop 的值是Tracy
printf("查找到了,pop 的值是%s\n", pop);
} else {
printf("查找失败\n");
}
// 计算出目标在字符串中的索引
long long index = pop - text;
// T第一次出现的位置索引为:8
printf("%s第一次出现的位置索引为:%lld\n", subText, index);
// 3.拼接
char dest[25];
char *to = " To ", *java = "Java", *c = "C";
// 先将第一段拷贝到 dest 中
strcpy(dest, java);
strcat(dest, to);
strcat(dest, c);
// 拼接结果:Java To C
printf("拼接结果:%s\n", dest);
}
大小写转换
/**
* 大小写转换,tolower() 和 toupper() 在 <ctype.h> 中
*/
void sample6(int useStringArray) {
// 使用字符数组定义字符串
if (useStringArray) {
char string[] = "this is a string";
unsigned long long length = strlen(string);
for (int i = 0; i < length; ++i) {
// 使用字符数组时可以直接修改数组内的字符
string[i] = toupper(string[i]);
}
printf("转换结果:%s\n", string);
} else {
// 使用字符指针定义字符串
char *string = "this is a string";
// 使用字符指针时无法直接修改 string,新建一个字符数组保存转换结果
char result[20];
toUpper(result, string);
printf("转换结果:%s\n", result);
}
}
/**
* 将 origin 字符串内的字符全部转换成大写字母写入 dest
*/
void toUpper(char *dest, char *origin) {
// 临时指针指向 origin,不要操作传入的指针
char *temp = origin;
while (*temp) {
*dest = toupper(*temp);
dest++;
temp++;
}
// 字符数组最后一位要写上 \0
*dest = '\0';
}
截取字符串
void sample7(int cmd) {
char *string = "this is a string";
switch (cmd) {
case 0: {
// 由于 C 中的 switch 规定每个 case 分支内部只能定义常量表达式或使用
// 花括号创建新的作用域,因此要么将 result 的声明提到 switch 之前,要
// 么像现在这样在 case 内用花括号创建新作用域
char result[20];
subString0(result, string, 0, 16);
if (result[0] != '\0') {
printf("截取结果:%s\n", result);
}
}
break;
case 1: {
char result[20];
subString1(result, string, 0, 16);
if (result[0] != '\0') {
printf("截取结果:%s\n", result);
}
}
break;
case 2: {
char result[20];
subString2(result, string, 0, 16);
if (result[0] != '\0') {
printf("截取结果:%s\n", result);
}
}
break;
case 3: {
// char * 不需要结尾符 \0
char *result;
subString3(&result, string, 0, 16);
printf("截取结果:%s\n", result);
if (!useStaticAllocation) {
free(result);
result = NULL;
}
}
break;
}
}
/**
* 将原始字符串 origin 从 startIndex 开始截取到 endIndex,结果保存在
* result 中(结果中包含 startIndex 不包含 endIndex)
* 这个写的有点啰嗦
*/
void subString0(char *result, char *origin, int startIndex, int endIndex) {
if (startIndex < 0 || endIndex > strlen(origin)) {
printf("index 越界,请检查输入\n");
// 将结果置为空字符串,表示截取失败
result[0] = '\0';
return;
}
if (startIndex > endIndex) {
printf("startIndex 大于 endIndex,请检查输入\n");
// 将结果置为空字符串,表示截取失败
result[0] = '\0';
return;
}
int index = 0;
for (int i = startIndex; i < endIndex; ++i, index++) {
result[index] = origin[i];
}
result[index] = '\0';
}
/**
* 对上一版的改进,使用指针操作会更简洁一些
*/
void subString1(char *result, char *origin, int startIndex, int endIndex) {
if (startIndex < 0 || endIndex > strlen(origin)) {
printf("index 越界,请检查输入\n");
result[0] = '\0';
return;
}
if (startIndex > endIndex) {
printf("startIndex 大于 endIndex,请检查输入\n");
result[0] = '\0';
return;
}
for (int i = startIndex; i < endIndex; i++) {
*(result++) = *(origin + i);
}
// 需要加,不然结尾有乱码
*result = '\0';
}
/**
* 直接使用库函数
*/
void subString2(char *result, char *origin, int startIndex, int endIndex) {
if (startIndex < 0 || endIndex > strlen(origin)) {
printf("index 越界,请检查输入\n");
result[0] = '\0';
return;
}
if (startIndex > endIndex) {
printf("startIndex 大于 endIndex,请检查输入\n");
result[0] = '\0';
return;
}
strncpy(result, origin + startIndex, endIndex - startIndex);
}
/**
* 使用 result 的二级指针是为了故意练习二级指针的使用,不是因为这里必须使用二级指针
* 本函数对于 resultArr 的内存分配介绍了静态和动态两种方式,第一种会直接崩溃(可能
* 部分 IDE 会成功),第二种也不是很好的方式,权且当做反面教材吧
*/
void subString3(char **result, char *origin, int startIndex, int endIndex) {
// 临时指针,别动传进来的原始指针
char *temp = origin;
if (useStaticAllocation) {
// 按需声明数组大小
char resultArr[endIndex - startIndex];
for (int i = 0; i < endIndex - startIndex; i++) {
resultArr[i] = temp[startIndex + i];
}
// 不能让一级指针指向 resultArr,因为后面这个数组随着方法弹栈就被回收了
// *result = resultArr; // The address of the local variable may escape the function
// 视频中讲的通过 strcpy 将 resultArr 拷贝到 result 的一级指针指向的内存
// 可以解决,但是因为这个指针是野指针,这样拷贝会直接导致程序崩溃
strcpy(*result, resultArr);
} else {
// 在堆上动态开辟内存,这个方法也不好,因为 resultArr 这个堆内存不能在这个方法内释放,
// 只能在调用这个方法的位置,使用完 result 的一级指针后再回收
char *resultArr = malloc(sizeof(char) * (endIndex - startIndex));
for (int i = 0; i < endIndex - startIndex; i++) {
resultArr[i] = temp[startIndex + i];
}
// 要有这个结尾
resultArr[endIndex - startIndex] = '\0';
// 传给 result 的一级指针
*result = resultArr;
}
}
以上代码有几点需要注意:
- 不论是使用字符数组还是字符指针构造字符串时,都要手动在最后一个位置上加 ‘\0’,字符串常量则不用,编译器会自动添加,调用库函数时也不用,几乎都会给你处理好
- 关于 subString3(),这是一个反面教材,介绍的两种方式都不好:
- 第一种尝试用 strcpy() 将结果数组拷贝给 result 的一级数组,某些 IDE 确实会成功,但是我在 CLion 上运行崩溃了,原因就是在 sample7() 的 case3 中声明的 result 指针是一个野指针,让 strcpy() 把数组拷贝到这个野指针指向的内存上肯定会崩溃的
- 第二种使用 malloc() 在堆上动态申请空间,本意是为了避开在方法内申请的局部数组在方法执行完毕后弹栈导致数组内存无法持续使用。但是这样一来,堆上的空间就无法在 subString3() 内被释放,只能等到在 sample7() 内使用完 result 这个指针后再回收,这是不合理的。动态申请的内存应该是谁申请谁负责回收,你不能 subString3() 申请的内存让 sample7() 回收
5、结构体
5.1 基础使用
// 声明一个结构体
struct Dog {
char name[10];
int age;
char sex;
};
void sample1() {
// 声明结构体变量
struct Dog dog;
// 结构体内属性没有默认初始化,使用前需要先赋值
dog.age = 3;
dog.sex = 'M';
// 字符串不能直接给 char name[10] 赋值,需要通过 strcpy
strcpy(dog.name, "旺财");
printf("dog.name = %s,dog.age = %d,dog.sex = %c\n", dog.name, dog.age, dog.sex);
}
在声明结构体时可以直接声明结构体变量:
// 可以在结构体声明后直接声明若干个结构体变量
struct Person {
char *name;
int age;
char sex;
} person1 = {"Tracy", 20, 'M'},
person2, person3;
void sample2() {
printf("person1.name = %s,person1.age = %d,person1.sex = %c\n", person1.name, person1.age, person1.sex);
// 初始化 person2,由于 Person 的 name 是 char*,因此可以直接指向一个字符串
person2.name = "James";
person2.age = 18;
person2.sex = 'M';
printf("person2.name = %s,person2.age = %d,person2.sex = %c\n", person2.name, person2.age, person2.sex);
}
person1 是在声明时直接初始化了,而 person2 是在 sample2() 内初始化的。
5.2 结构体的嵌套
结构体可以持有另一个结构体作为属性,也可以在结构体内定义另一个结构体:
struct Study {
// 学习的内容
char *content;
};
// 结构体作为属性、结构体嵌套
struct Student {
char *name;
int age;
char sex;
// VS 声明结构体属性可以不带 struct 关键字
struct Study study;
struct Play {
char *game;
} play;
};
// 嵌套结构体的使用
void sample3() {
// 每个结构体都用 {} 括起来
struct Student student = {"Tim", 38, 'M',
{"C Language"},
{"2k24"}
};
printf("student 的基本信息:name = %s,age = %d,sex = %c\nstudent 正在学习%s,爱玩%s",
student.name, student.age, student.sex, student.study.content, student.play.game);
}
5.3 结构体指针与结构体数组
直接看示例代码与注释:
struct Cat {
char *name;
int age;
};
// 结构体指针
void sample4() {
// 1.在栈区为结构体申请内存
struct Cat cat = {"NULL", 0};
struct Cat *catP = &cat;
catP->name = "Mi";
catP->age = 3;
printf("cat name = %s,cat age = %d\n", catP->name, catP->age);
// 2.在堆区动态申请结构体内存
// VS 的写法Cat *cat1 = (Cat *)malloc(sizeof(Cat));
struct Cat *cat1 = malloc(sizeof(struct Cat));
cat1->name = "Miao";
cat1->age = 1;
printf("cat name = %s,cat age = %d\n", cat1->name, cat1->age);
if (cat1) {
free(cat1);
cat1 = NULL;
}
}
// 结构体数组
void sample5() {
// 1.在栈区开辟空间
struct Cat cat[4] = {{"cat1", 1},
{"cat2", 2},
{"cat3", 3}};
// 给数组赋值,VS 可以通过 {} 直接给指定位置赋值
// cat[3] = {"cat4", 4};
// 而 CLion 要求等号右侧必须为表达式,因此需要借助结构体变量赋值
struct Cat cat4 = {"cat4", 4};
cat[3] = cat4;
printf("cat4 name = %s,age = %d\n", cat[3].name, cat[3].age);
// 2.在堆区动态开辟空间
int size = 5;
struct Cat *catPointer = malloc(sizeof(struct Cat) * size);
for (int i = 0; i < size; ++i) {
catPointer->name = "cat";
catPointer->age = i + 1;
printf("cat name = %s,age = %d\n", catPointer->name, catPointer->age);
}
free(catPointer);
catPointer = NULL;
}
5.4 通过 typedef 定义类型别名
过往的例子中有很多代码因为 IDE 不同致使写法也不同,在结构体上体现的尤为明显。比如上一节中的结构体 Cat,在 CLion 中凡是要用到 Cat 类型时前面都必须加一个 struct 关键字:
struct Cat cat = {"NULL", 0};
struct Cat *catP = &cat;
struct Cat *cat1 = malloc(sizeof(struct Cat));
但是在 VS 中就不用写 struct 关键字。为了避免一份代码放在不同的 IDE 中可能无法运行的情况,可以使用 typedef 关键字定义类型别名来解决这个问题。
在 C 语言中,
typedef
关键字用于为已有的数据类型创建新的类型别名。它的作用是使代码更易读、更具可维护性,并提供了一种简化和抽象数据类型的方式,是 C 语言中非常有用的特性之一。
通过使用typedef
,可以为各种数据类型创建简明扼要的别名,使代码更加清晰易懂。以下是一些typedef
的常见用法和好处:
简化复杂的类型名:通过
typedef
,可以将复杂的类型名缩短为更简洁的别名,提高代码的可读性。例如:typedef unsigned long long int ULLong; typedef struct { int x; int y; } Point; ``` 在上述示例中,`ULLong` 是 `unsigned long long int` 的别名,`Point` 是一个结构体类型的别名。这样,以后在代码中使用这些类型时,可以直接使用别名,而不必写出完整的类型名。
提供平台无关性:使用
typedef
可以为不同平台上的特定类型创建统一的别名,提高代码的可移植性。例如:typedef unsigned int uint; typedef unsigned char byte; ``` 在上述示例中,`uint` 和 `byte` 分别是无符号整型和无符号字符型的别名。这样,代码在不同平台上编译时,可以根据具体平台为别名指定适当的类型,而不必修改实际的代码。
创建抽象数据类型:使用
typedef
可以将数据类型的具体实现细节隐藏起来,仅暴露出抽象的名称,提供更高层次的抽象和封装。这有助于实现信息隐藏和模块化编程。例如:typedef struct LinkedListNode { int data; struct LinkedListNode* next; } LinkedListNode; ``` 在上述示例中,`LinkedListNode` 是一个结构体类型的别名,表示链表节点。通过使用别名,可以隐藏节点的具体实现细节,只暴露出节点的抽象概念,使代码更加模块化和易于维护。
我们可以通过 typedef 定义结构体的别名:
typedef struct Cat Cat;
// 使用类型别名后,使用结构体就可以不用带 struct 了
void sample6() {
Cat cat = {"NULL", 0};
Cat *catP = &cat;
Cat *cat1 = malloc(sizeof(Cat));
free(cat1);
cat1 = NULL;
}
也可以在定义结构体时直接定义它的别名:
// 声明结构体时直接定义别名
typedef struct Animal {
char *name;
int age;
} Animal;
// 声明匿名结构体时定义别名
typedef struct {
char *name;
int age;
} Ani;
void sample7() {
Animal *animal = malloc(sizeof(Animal));
Ani *ani = malloc(sizeof(Ani));
// 回收内存代码...
}
声明匿名结构体然后定义其别名的方式是声明结构体最简洁的并且兼容的方式。这样一来,在不同的 IDE 之间使用结构体的代码就一样了,也就是说通过 typedef 达到了不同 IDE 之间的兼容。
5.5 枚举
与结构体类似,也涉及到 CLion 中使用枚举类型必须要带 enum 关键字,而 VS 则没有强制要求的情况。处理方法也与结构体类似,采用 typedef:
// 枚举类型,情况与结构体类似,也采用别名
typedef enum CommentType {
// 如果不赋值,那么默认是从 0 开始的
TEXT = 10,
TEXT_IMAGE,
IMAGE
} CommentType;
void sample8() {
CommentType commentType0 = TEXT;
CommentType commentType1 = TEXT_IMAGE;
CommentType commentType2 = IMAGE;
printf("%d %d %d\n", commentType0, commentType1, commentType2);
}
6、文件操作
主要介绍一些文件操作示例。
6.1 基本的文件读写
通过 fopen() 打开文件,fgets() 向 buffer 中读取文件内容,fputs() 向文件中写:
/**
* mode 是 fopen() 的参数,它有四种模式:
* r 读,w 写,rb 作为二进制文件读,rw 作为二进制文件写
*/
FILE *openFile(char *name, char *mode) {
FILE *file = fopen(name, mode);
if (!file) {
printf("打开文件失败,请检查文件路径:%s", fileName);
exit(0);
}
return file;
}
// 读取文件内容
void sample1() {
FILE *file = openFile(fileName, "r");
// 读取文件内容的缓存
char buffer[10];
while (fgets(buffer, 10, file)) {
printf("%s", buffer);
}
// 关闭文件
fclose(file);
}
// 向文件中写
void sample2() {
FILE *file = openFile(fileName, "w");
// 覆盖文件中的内容写入如下字符串
fputs("Write to file.", file);
fclose(file);
}
6.2 复制文件
从源文件读取内容然后写到目标文件中,这个例子中使用 fread() 和 fwrite() 读写文件:
// 复制文件
void sample3() {
FILE *sourceFile = openFile(fileName, "rb");
// 写文件时,如果提供的路径文件不存在,会直接创建一个
FILE *targetFile = openFile(targetFileName, "wb");
// 这个地方不能声明为 int 类型的,否则在 fwrite() 时会有 bug
// int buffer[256];
char buffer[256];
unsigned long long len;
// fread() 参数含义依次为:读取到哪个地址(指针)、要读取的每个元素大小、要读取的元素个数、从哪个文件读取
// 当然,数组元素个数可以用 sizeof(buffer) / sizeof(char) 来计算,就是 256
while ((len = fread(buffer, sizeof(char), 256, sourceFile)) != 0) {
fwrite(buffer, sizeof(char), len, targetFile);
}
fclose(sourceFile);
fclose(targetFile);
}
这里有个问题需要注意一下,就是 buffer 的类型不能声明成 int 类型的,而应该是 char 类型,并且 fread() 和 fwrite() 内传的参数应该是 sizeof(char) 而不是 sizeof(int),否则 fwrite() 在写文件时可能会出现拷贝不完全的 bug:
Write to fil
下面来解释原因。
假如我在 sourceFile 中写了一个字符串 “Write to file.”,这个字符串占 14 个字节,由于 int buffer[256],可以读取 256 * 4 = 1024 个字节的数据,因此 while 循环执行一次就可以把 sourceFile 中的内容读取到 buffer 中。问题出在计算 len 变量时,这里 len = 3,应该是 14 个字节除以 int 的 4 个字节计算出来的,把这个参数传入 fwrite() 就也会写 3 个字节,导致 14 个字节的最后两个字母没有被写入 targetFile,产生如上的 bug。
还有一种解决办法,就是 int buffer[256],然后在 fread() 读取元素时以 char 为单位读取:
int buffer[256];
unsigned long long len;
// fread() 参数含义依次为:缓冲数组、数组元素大小、数组元素个数、从哪个文件读取
// 当然,数组元素个数可以用 sizeof(buffer) / sizeof(char) 来计算,就是 256
while ((len = fread(buffer, sizeof(char), 256 * 4, sourceFile)) != 0) {
fwrite(buffer, sizeof(char), len, targetFile);
}
6.3 计算文件大小
void sample4() {
FILE *file = openFile(fileName, "r");
// 移动文件指针,从偏移量为 0,即文件开头移动到 SEEK_END 表示的
// 文件末尾,该函数返回 0 表示操作成功
fseek(file, 0, SEEK_END);
// ftell() 获取文件指针的当前位置,即文件末尾的偏移量,从而得到文件的大小
long fileSize = ftell(file);
printf("文件大小为%ld字节", fileSize);
fclose(file);
}
6.4 文件加密解密
通过异或加密,解密时再做一次异或,就是一个简单的加密示例:
// 文件加密解密
void sample5() {
char *sourceFileName = "F:\\Temp\\source.jpg";
char *encodedFileName = "F:\\Temp\\source_encoded.jpg";
char *decodedFileName = "F:\\Temp\\source_decoded.jpg";
printf("请输入加密密码:\n");
int password;
scanf("%d", &password);
printf("加密中...\n");
encodeFile(sourceFileName, encodedFileName, &password);
printf("加密完成\n");
printf("请输入解密密码:\n");
scanf("%d", &password);
decodeFile(encodedFileName, decodedFileName, &password);
printf("解密完成\n");
}
// 通过从源文件读取的字符与密码对应位置的值做异或运算进行加密,解密也是与相同的密码做异或运算
void encodeFile(char *sourceFileName, char *encodedFileName, int *password) {
FILE *sourceFile = openFile(sourceFileName, "rb");
FILE *encodedFile = openFile(encodedFileName, "wb");
int c;
int *temp = password;
while ((c = fgetc(sourceFile)) != EOF) {
fputc(c ^ (*temp), encodedFile);
temp++;
if (*temp == '\0') {
temp = password;
}
}
fclose(sourceFile);
fclose(encodedFile);
}
void decodeFile(char *encodedFileName, char *decodedFileName, int *password) {
FILE *encodedFile = openFile(encodedFileName, "rb");
FILE *decodedFile = openFile(decodedFileName, "wb");
int c;
int *temp = password;
while ((c = fgetc(encodedFile)) != EOF) {
fputc(c ^ (*temp), decodedFile);
temp++;
if (*temp == '\0') {
temp = password;
}
}
fclose(encodedFile);
fclose(decodedFile);
}