跳过正文

丙加·第9章·大型C语言纪录片之《我 们 仨》(结构、联合和枚举)

·1353 字·7 分钟·
目录

这应该是 C 语言的最后部分,讲解 C 语言的三大自定义类型——结构体、联合以及枚举。并为 C++的面向对象做铺垫。

9.1 帝国的毁灭 C 的局限性
#

其一
#

“无论哪一个社会形态,在它所能容纳的全部生产力发挥出来以前,是决不会灭亡的;而新的更高的生产关系,在它的物质存在条件在旧社会的胎胞里成熟以前,是决不会出现的。”

——卡尔·马克思

其二
#

(C++送来了对象,大家一筹莫展)

元首:这是个对象!C++送来的是个对象!

将军:我的元首…我们只有结构体,没有类,无法处理这个对象…

元首:什么?!你们怎么完全不能处理对象!事情到了今天这个地步…

将军:元首,我们的变量类型太简单!没一个数据结构符合要求,甚至是结构体和联合!

元首:这些数据结构不过只是一群卑鄙、不完整的类型! 连个方法都没有!没有封装!没有继承!

将军:元首,我们不能要求结构体去做类的工作…

元首:胆小鬼!叛徒!懦夫!(妨碍咱的渣渣!) 这些数据结构是 C 语言的垃圾!没有面向对象的荣誉感!

将军:元首,您说的太过分了…

元首:你们能称自己是数据结构,只不过因为你们能装几个变量。 但你们只学会了如何存储数据!多少年了,C 语言只会阻碍面向对象的发展!

将军:元首,我们至少还有 structunionenum

元首:叛徒!从一开始我就被 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_previousNodem_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 猜猜*我们*是谁:共用内存的风险
#

​ 联合体最迷人的特性——内存共享,赋予了它无可替代的灵活性,同时也埋下了最大的陷阱。由于所有成员共享同一块内存,你对任何一个成员的修改,都会直接影响其他成员的值。这种“人格分裂”式的特性,让联合体成了一块危险的雷区。正是这种危险,要求我们使用这种数据结构必须小心谨慎。

​ 让我们继续用 AnyaForgerDamianDesmond 来演示:

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 限制,就会自动升级为 longlong 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++ 的 structclass
#

在 C++中,struct 这个牢玩家得到了加强,加强后,structclass 同样都是 类类型(断句:类/类型 派蒙派蒙派!)被当作 类(class) 处理,都有了成员变量和方法、以及继承和多态的特性,structclass 的差别在于默认“继承”的方式分别是公有和私有,其余完全等价。看不懂没关系,这将在几章后详细展开。

由于 structclass 都是 类类型,所以定义完成后可以直接使用类型名,而不需要像 C 那样加 struct 前缀——这也就解释了 9.2.2 节为何只说到了 C——C++本身不需要 typedef 就能达到目的。

至此,C 语言中那个笨拙的 struct 终于在新时代完成了它的进化之路——从单纯的数据集合蜕变为真正的对象载体,从面向过程的粗糙工具升格为面向对象的一大基石。它挣脱了 C 语言“接近硬件、足够简单”的束缚,重获新生,成为了 C++面向对象王朝的开国元勋。


课后习题:

  1. 创建一个结构体 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.
    
  2. 创建一个 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
    
  3. 创建一个数据包解析器:

    设计一个结构体来表示网络数据包,数据包可能是以下两种类型之一:

    • 类型 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
    

    提示:可以使用结构+联合节省空间。

  4. 在 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
    
命令提示符@CommandPrompt-Wang
作者
命令提示符@CommandPrompt-Wang