这应该是 C 语言的最后部分,讲解 C 语言的三大自定义类型——结构体、联合以及枚举。并为 C++的面向对象做铺垫。
9.1 帝国的毁灭 C 的局限性 #
其一 #
“无论哪一个社会形态,在它所能容纳的全部生产力发挥出来以前,是决不会灭亡的;而新的更高的生产关系,在它的物质存在条件在旧社会的胎胞里成熟以前,是决不会出现的。”
——卡尔·马克思
其二 #
(C++送来了对象,大家一筹莫展)
元首:这是个对象!C++送来的是个对象!
将军:我的元首…我们只有结构体,没有类,无法处理这个对象…
元首:什么?!你们怎么完全不能处理对象!事情到了今天这个地步…
将军:元首,我们的变量类型太简单!没一个数据结构符合要求,甚至是结构体和联合!
元首:这些数据结构不过只是一群卑鄙、不完整的类型! 连个方法都没有!没有封装!没有继承!
将军:元首,我们不能要求结构体去做类的工作…
元首:胆小鬼!叛徒!懦夫!(妨碍咱的渣渣!) 这些数据结构是 C 语言的垃圾!没有面向对象的荣誉感!
将军:元首,您说的太过分了…
元首:你们能称自己是数据结构,只不过因为你们能装几个变量。 但你们只学会了如何存储数据!多少年了,C 语言只会阻碍面向对象的发展!
将军:元首,我们至少还有
struct、union和enum…元首:叛徒!从一开始我就被 C 语言的局限性出卖了! 这是对面向对象不可饶恕的背叛!但是所有这些局限都会付出代价。
将军:元首,也许我们可以用函数指针模拟方法…看看 WinAPI……
元首:那太丑陋了!那是黑客的行为,不是优雅的解决方案!你难道想被空指针炸死吗?!! 我们被 C 语言的局限性彻底出卖了!
元首:算了……想干什么干什么去吧。(瘫坐在椅子上)
如上所说,C 语言的结构体、联合体(以及枚举)这三种自定义类型,身上总带着一种“缝合感”。它们诞生于纯粹的面向过程环境,却阴差阳错地成为了实现面向对象范式的核心工具,堪称是面向对象思想在“面向过程”土壤中萌出的最初嫩芽。它们本身是过程的、原始的,却阴差阳错地为“对象”的概念铺下了第一块基石——结构体能捆绑数据,联合体能共享内存,甚至还有 _Generic 在编译期进行手工构造的泛型机制——这已然触及了对象“封装”的皮毛(甚至 WinAPI 就在 C 的基础上利用指针实现了一套相对完整的对象管理机制!)。但它们囿于 C“简单、接近硬件”的核心范式,古怪地卡在语言演进的门槛上,而没有迈出最后的、决定性的 继承、多态 的一步——而这既是 C 语言逻辑的终点,也是 C++面向对象革命的起点。这种“早产的面向对象”特性,让它们成为 C 语言中最矛盾也最迷人的部分,也直接催生了 C++(C++一开始就叫做 C with Class)。现在,我们就来拆解这“我们仨”的 Dénouement(最终章)。
9.2 千貌的容器——结构体 #
如果只看名字,容易以为 union 才是将多个数据捆绑在一起的数据类型——至少笔者当时是这么想的。但其实是 struct 承担了这一职责——它将数据 有结构地 组织起来,形成一个新的数据整体,便于统一分析处理。
9.2.1 结构体的定义和初始化 #
结构体的定义以 struct 打头,后面紧跟着这个结构类型的标签(名称),然后接下来的花括号快里面就是结构体内部的数据:
struct 标签{
int member1;
double member2;
......
}var1, var2; // ---> 你可以顺手声明几个该类型的变量
类型名、成员、变量声明三个中至少要出现两个,下面的都是合理的:
struct {
int member1;
}var1;
struct type2{
int member1;
};
struct type3{
int member1;
}var3;
定义完毕后,还可以使用 struct 标签 变量名; 来定义新的变量(对于 C++和某些编译器的 C,可以省略 struct)。举一个实际的例子,定义一个结构体存储个人信息:
struct Person{
// 完全可以使用std::string,
// 我们这里保持C风格,使用传统形式
// Ovuvuẹnvuẹnvuẹn Eyẹntuẹnwẹnvuẹn Ugbem-ugbem Osas(确信)
char name[100];
int age;
// F -> female; M -> male
// 其实这里使用enum更清晰
char gender;
int id;
// 如果要自己套娃必须使用指针
Person* father;
Person* mother;
};
然后进行初始化
第二、三个例子是使用成员运算符 . 访问成员,完全可以把 . 理解成“的”
很合理吧 读音都一样
// 按照定义的顺序向后赋值
struct Person Bocchi = {"Gotou Hitori", 15, 'F', 123456, &Naoki, &Mechiyo};
// C99引入了指定初始化器,使用.指定成员,可以乱序
// 没有初始化的会自动填0
// struct Person Bocchi = {.age = 15, .name = "Gotou Hitori", .id = 123456, ……(后面我懒得打了) };
// 或者先定义然后赋值
struct Person Nijika;
// 字符串赋值只会试图修改char* const name的地址
// 使用strcpy完成“深拷贝”
strcpy(Nijika.name, "Ijichi Nijika");
Nijika.age = 16;
......
至于动态结构体这种依赖手工管理内存、有着各种使用限制地语言特性,我们不做介绍。有愿意考古地同学欢迎自行了解。
碎碎念:套娃? 如前所述,完全可以套娃,但是得使用指针,想想为什么直接套娃会报错(这是个数学逻辑问题):
struct NODE{ // 习惯上m_表示成员member char m_name[100]; struct NODE* m_previousNode; struct NODE* m_nextNode; }通过上面的方式,就定义了一个链表的节点
NODE,可以通过m_previousNode和m_nextNode访问上、下一个节点。还有一种互相包含的二人转:
struct A{ struct B* memberB; }; struct B{ struct A* memberA; };但是,
struct A声明时并没有struct B,所以我们必须进行一次“前向声明”,告诉编译器有struct B;这么一回事,但是具体内容一会再说,下面的代码才能通过编译:struct B; struct A{ struct B* memberB; }; struct B{ struct A* memberA; };
9.2.2 C 中 struct 的省略——typedef 的妙用
#
typedef 是给变量起别名的方法,它的语法很简单:
typedef type alias;
type 肯定可以带空格(unsigned long long int),但是 alias 不行。
如果你查阅 <stdint.h>,你会看到类似的定义:
typedef signed char int8_t;
typedef unsigned char uint8_t;
typedef short int16_t;
typedef unsigned short uint16_t;
typedef int int32_t;
typedef unsigned uint32_t;
这就是之前提到的定长变量。顺便补充下:由于 C 语言标准只规定了基本类型的最小字节长度,但没有指定确切的字节长度,所以诞生了这种类型。这些类型是通过 typedef 定义的,而不是新的数据类型。typedef signed char int8_t; 意味着,signed char 在这个编译器下就是 8 位长。
我们可以使用类似的方式简化结构体变量的声明:
struct Person {
char name[100];
int age;
char gender;
int id;
struct Person* father;
struct Person* mother;
};
typedef struct Person Person;
// 现在可以直接用 Person 代替 struct Person
或者更简洁的写法:
typedef struct {
char name[100];
int age;
char gender;
int id;
Person* father;
Person* mother;
} Person;
9.2.3 成员变量的访问 #
前面已经示范过一种情况,也就是使用 . 来访问结构体的成员,也就是这个例子:
struct Person Nijika;
strcpy(Nijika.name, "Ijichi Nijika");
Nijika.age = 16;
但如果 Nijika 是指针,指针本身当然没有子成员,如何访问呢?我们当然可以先解引用再访问:
struct Person* Nijika;
strcpy((*Nijika).name, "Ijichi Nijika");
但这样很不方便。所以 C 提供了一个语法糖 ->。一个箭头可以同时实现解引用和子成员的操作:
struct Person* Nijika;
Nijika->name = "Ijichi Nijika";
strcpy(Nijika->name, "Ijichi Nijika");
9.2.4 结构体参数和指针 #
结构体完全可以作为参数使用:
void foo(const struct Person p){
//干一些事情
}
// 可以直接传入
foo(Nijika);
但是,对于一些很大的结构体,进行内容拷贝需要消耗很多时间。比如 Ovuvuẹnvuẹnvuẹn Eyẹntuẹnwẹnvuẹn Ugbem-ugbem Osas 所在的国家的户籍管理系统(逃) 这时候,就建议使用指针。
另外,如果结构体内部包含指针,作为参数传递时浅拷贝不会分配新的内存——这容易不小心修改传入结构体的内容——也建议使用指针提醒自己。这一点在 8.4 也有详细的阐述。遗忘的读者可以再回头看一眼。
9.3 共位的幻形——联合体 #
联合体的“联合”,当作“内存共享”解。也就是这个 union 内部的变量,同时只能使用一个,他们紧密地重叠在一起,以节省内存。因此他们占用的空间由最大的那个变量决定。
9.3.1 联合体的定义和初始化 #
联合体的声明和结构体相似:
union Performance {
int grade; // 0~100
char level; // A~F
// C需要<stdbool.h>
bool pass; // 1/0
}AnyaForger, DamianDesmond;
联合的初始化比较奇怪,有以下几种方式:
// {}会匹配第一个成员,也就是int grade。
union Performance AnyaForger = {13};
// C99引入了指定初始化器,使用.指定成员
// AnyaForger = {.level = 'A'};
// 或者先不初始化
//使用.访问成员
union Performance DamianDesmond;
DamianDesmond.grade = 96;
9.3.2 猜猜*我们*是谁:共用内存的风险 #
联合体最迷人的特性——内存共享,赋予了它无可替代的灵活性,同时也埋下了最大的陷阱。由于所有成员共享同一块内存,你对任何一个成员的修改,都会直接影响其他成员的值。这种“人格分裂”式的特性,让联合体成了一块危险的雷区。正是这种危险,要求我们使用这种数据结构必须小心谨慎。
让我们继续用 AnyaForger 和 DamianDesmond 来演示:
union Performance DamianDesmond;
DamianDesmond.grade = 0x41; // 赋值65,ASCII码的'A'
printf("Damian's grade: %d\n", DamianDesmond.grade); // 输出: 65
printf("Damian's level: %c\n", DamianDesmond.level); // 输出: 'A'
printf("Did Damian pass? %s\n", DamianDesmond.pass ? "Yes" : "No"); // 输出: Yes (因为65 != 0)
看到了吗,我们只是给 .grade 赋了值,但 .level 和 .pass 都“被动”地被修改了了。这是因为它们共享着同一片内存空间,起始地址也相同,写入 .grade 的字节(0x41)被 .level 解读为一个字符(‘A’),也被 .pass 解读为一个非零的布尔值(true)。这就是所谓的 类型混淆。
如果没有搞清楚类型,发生了混淆,编译器根本不会也无法检查,轻则导致错误地解读数据,重则修改指针导致段错误:
DamianDesmond.level = 'B'; // 赋值66
printf("Now Damian's grade is: %d\n", DamianDesmond.grade); // 输出: 66
AnyaForger.pass = true; // 赋值1
printf("Anya's level is: %c (ASCII %d)\n", AnyaForger.level, AnyaForger.level); // 输出: (不可见的ASCII 1)
printf("Anya's grade is: %d\n", AnyaForger.grade); // 输出: 1
9.3.3 魔法的使用 #
正因为这种风险,联合体绝对不是常规情况下数据存储的首选。然而这也是一种绕过类型检查,进行底层操作的方法之一。我们看一个经典的“魔法”:最最经典的快速平方根倒数算法,它利用一步位运算提前进行了数次近似的牛顿迭代法,从而节省了迭代次数(具体原理可以观看:BV1v64y1i7KH)。以下是原始实现:
/*
** float q_rsqrt( float number )
*/
float Q_rsqrt( float number )
{
long i;
float x2, y;
const float threehalfs = 1.5F;
x2 = number * 0.5F;
y = number;
i = * ( long * ) &y; // evil floating point bit level hacking 邪恶的浮点型位级操作
i = 0x5f3759df - ( i >> 1 ); // what the fuck?
y = * ( float * ) &i;
y = y * ( threehalfs - ( x2 * y * y ) ); // 1st iteration 第一次迭代
// y = y * ( threehalfs - ( x2 * y * y ) ); // 2nd iteration, this can be removed 第二次迭代(考虑到性能)可以删除
return y;
}
我们完全可以使用联合来避开难看的指针转换:
float Q_rsqrt_union(float number) {
union {
float f;
uint32_t i;
} converter;
const float threehalfs = 1.5F;
float x2 = number * 0.5F;
converter.f = number;
// 直接通过union访问底层二进制表示
converter.i = 0x5f3759df - (converter.i >> 1);
float y = converter.f;
// 一次牛顿迭代提高精度
y = y * (threehalfs - (x2 * y * y));
return y;
}
这才是 union 真正的用武之地——进行极其底层的运算。更多的例子不再赘述。不过请注意:虽然 union 让这种 “魔法” 变得更清晰,但它 仍然是魔法。在实际项目中,除非有极致的性能要求且完全了解目标平台的细节,否则还是应该优先使用普通的数据类型。
9.4 定序的刻度——枚举(类) #
枚举是“用于定义一组具有离散值的常量”。说白了就是给数字起名字,让它们更易懂,而不是看着像魔法。
9.4.1 枚举的定义 #
如果不用枚举,使用 #define(后面会专门讲)来定义,画风是这样的:
#define MON 1
#define TUE 2
#define WED 3
#define THU 4
#define FRI 5
#define SAT 6
#define SUN 7
看着就很长而且啰嗦,不是吗?如果使用 enum,一下就变得和善起来了:
enum DAY
{
MON=1, TUE, WED, THU, FRI, SAT, SUN
};
// 声明变量day
enum DAY day;
与前面的两种相同,类型名、成员、变量声明三个中至少要出现两个;同样可以使用 typedef 省略 enum 说明符。不再一一举例。
默认情况下,第一个枚举值是 0,然后按照 1 的步长向后类推。所以为了让周一是 1,上面的例子特地说明了 MON=1。当然,你可以一直“打断”枚举:
enum http_status {
HTTP_OK = 200,
HTTP_CREATED, // 201
HTTP_ACCEPTED, // 202
HTTP_BAD_REQUEST = 400,
HTTP_UNAUTHORIZED, // 401
HTTP_FORBIDDEN = 403,
HTTP_NOT_FOUND, // 404
HTTP_INTERNAL_SERVER_ERROR = 500
};
一般情况下,enum 的内部类型是 int,但是如果超出 int 限制,就会自动升级为 long、long long 等更大的类型。
C++推荐使用更安全的 enum class,它不会自动转换,也不会污染命名空间。
9.4.2 枚举的使用 #
在 C 中,可以直接将枚举名拿来使用:
printf("HTTP_OK: %d\n", HTTP_OK);
printf("HTTP_CREATED: %d\n", HTTP_CREATED);
printf("HTTP_ACCEPTED: %d\n", HTTP_ACCEPTED);
printf("HTTP_BAD_REQUEST: %d\n", HTTP_BAD_REQUEST);
printf("HTTP_UNAUTHORIZED: %d\n", HTTP_UNAUTHORIZED);
printf("HTTP_FORBIDDEN: %d\n", HTTP_FORBIDDEN);
printf("HTTP_NOT_FOUND: %d\n", HTTP_NOT_FOUND);
printf("HTTP_INTERNAL_SERVER_ERROR: %d\n", HTTP_INTERNAL_SERVER_ERROR);
也可以自动发生类型转换(转换为合适的整型),甚至从一种枚举转为另一种,因为共享一套命名空间:
if (status >= 100 && status < 200) {
printf("Category: Informational\n");
} else if (status >= 200 && status < 300) {
printf("Category: Success\n");
} else if (status >= 400 && status < 500) {
printf("Category: Client Error\n");
} else if (status >= 500 && status < 600) {
printf("Category: Server Error\n");
}
在 C++中,引入了 enum class,它是所谓“强类型”,不会自动转换,而且使用枚举值时必须使用作用域解析符 ::
enum class http_status {
// 不再重复...
}
// 使用作用域解析符::
std::string getHttpStatusStr(HttpStatus status) {
switch (status) {
case HttpStatus::OK: return "OK";
case HttpStatus::CREATED: return "Created";
case HttpStatus::NOT_FOUND: return "Not Found";
case HttpStatus::INTERNAL_SERVER_ERROR: return "Internal Server Error";
// ...
default: return "Unknown Status";
}
}
// 必须强制转换
std::cout << "Status code: " << static_cast<int>(status) << std::endl;
使用枚举,可以清晰、方便的记录一组数字的类型和意义,这极大地提高了程序的阅读效率和维护性。
9.5 番外:C++ 的 struct 与 class
#
在 C++中,struct 这个牢玩家得到了加强,加强后,struct 和 class 同样都是 类类型(断句:类/类型 派蒙派蒙派!)被当作 类(class) 处理,都有了成员变量和方法、以及继承和多态的特性,struct 和 class 的差别在于默认“继承”的方式分别是公有和私有,其余完全等价。看不懂没关系,这将在几章后详细展开。
由于 struct 和 class 都是 类类型,所以定义完成后可以直接使用类型名,而不需要像 C 那样加 struct 前缀——这也就解释了 9.2.2 节为何只说到了 C——C++本身不需要 typedef 就能达到目的。
至此,C 语言中那个笨拙的 struct 终于在新时代完成了它的进化之路——从单纯的数据集合蜕变为真正的对象载体,从面向过程的粗糙工具升格为面向对象的一大基石。它挣脱了 C 语言“接近硬件、足够简单”的束缚,重获新生,成为了 C++面向对象王朝的开国元勋。
课后习题:
-
创建一个结构体
Book以及其数组(50 个),用于记录书的标题、定价、ISBN 号码。循环读入书的信息,达到上限或者在书名处输入%%结束,然后依次输出书的信息。下面是示例交互:-----Book1----- Name: Karakai Jouzu No Takagi-san Series Price: 248 ISBN: 9784091990846 -----Book2----- Name: Aharen-san wa Hakarenai 01 Price: 30 ISBN: 9787511072696 -----Book2----- Name: %% -----Book1----- Name: Karakai Jouzu No Takagi-san Series Price: 248 ISBN: 9784091990846 -----Book2----- Name: Aharen-san wa Hakarenai 01 Price: 30 ISBN: 9787511072696 2 Book(s) in total. -
创建一个
shape结构体,包含以下成员:- 描述具体形状(Rect、Circle)的枚举
- 一个结构体,描述其坐标(二位)
- 一个存储长、宽 或者 半径的数据结构(自己想想如何实现)
实现如下交互:
一:
Shape(R=Rect; C=Circle): D Error Shape 'D'! Shape(R=Rect; C=Circle): R Enter its position: 400 300 Enter its width and height: 100 200 Then the coordinates of the point in the bottom right corner are (500, 500) The area of it is 1200二
Shape(R=Rect; C=Circle): C Enter its position: 100 200 Enter its radius: 20 Then the coordinates of the point at the bottom are (100, 220) The area of it is 1256 -
创建一个数据包解析器:
设计一个结构体来表示网络数据包,数据包可能是以下两种类型之一:
- 类型 A:包含命令码(int)和数据长度(int)
- 类型 B:包含状态码(int)和时间戳(long long)
给出了打印时间戳的函数:
// C++记得用ctime #include <time.h> void print_timestamp(long long timestamp) { time_t raw_time = (time_t)(timestamp / 1000); // 假设是毫秒时间戳 struct tm *time_info = localtime(&raw_time); printf("%04d-%02d-%02d %02d:%02d:%02d", time_info->tm_year + 1900, time_info->tm_mon + 1, time_info->tm_mday, time_info->tm_hour, time_info->tm_min, time_info->tm_sec); }交互一:
Packet Type (A=Command, B=Status): A Enter command code: 0x1001 Enter data length: 512 --- Packet A Info --- Command: 0x1001 Data Length: 512 bytes交互二:
Packet Type (A=Command, B=Status): B Enter status code: 404 Enter timestamp: 1747221621000 --- Packet B Info --- Status: 404 Timestamp: 2025-5-14 19:19:81提示:可以使用结构+联合节省空间。
-
在 2.的基础上继续修改,输出将图形放大 2 倍后的结果,比如:
Shape(R=Rect; C=Circle): C Enter its position: 100 200 Enter its radius: 20 Then the coordinates of the point in the bottom right corner are (100, 220) The area of it is 1256 If resized to 2x: Then the coordinates of the point at the bottom are (100, 240) The area of it is 5024