C的结构体与对象模型
概述
C不是“去掉class的C++”C主要通过“数据结构 + 普通函数 + 明确约定”组织程序
|
1 2 3 4 5 6 7 8 9 10 |
class Client { public: Client(int fd, std::string ip, std::uint16_t port); void disconnect(); private: int fd_; std::string ip_; std::uint16_t port_; }; |
|
1 2 3 4 5 6 |
struct Client { /* 数据 */ }; bool client_init(struct Client* client, ...); void client_disconnect(struct Client* client); |
C没有构造函数
- 下面这段
|
1 |
struct Client client; |
- 只是在栈上定义了对象,成员没有自动初始化
- 此时读取:
- 会读取不确定值,行为可能未定义
|
1 |
printf("%d\n", client.fd); |
- 应该明确初始化
|
1 |
struct Client client = {0}; |
C23新增了空初始化
|
1 2 3 |
struct Client client = {}; /* C23 */ // 但为了兼容 C17 项目,目前 {0} 更常见 |
指定成员初始化
C99就有的功能- 没有写出的成员会被初始化为零,因此
ip会全部填充为'\0'
- 没有写出的成员会被初始化为零,因此
|
1 2 3 4 5 |
struct Client client = { .fd = 42, .port = 8899, .connected = true }; |
|
1 2 3 |
// 它不是赋值语句 .fd = 42 // 而是初始化器中的“指定初始化” |
修改对象通常传指针
|
1 2 3 |
void client_disconnect(struct Client* client); client_disconnect(&client); |
|
1 2 3 |
// 如果只读取,不修改 void client_print(const struct Client* client); |
C中资源所有权主要靠约定
- 当前
Client没有析构函数,所以必须明确规定- 谁负责关闭
fd - 哪个函数初始化对象
- 初始化失败后对象处于什么状态
client_disconnect()能否重复调用- 对象销毁后是否还存在异步任务引用它
- 谁负责关闭
数组、指针与字符串
数组和指针是两种不同对象
|
1 2 |
char buffer[128]; char* pointer = buffer; |
buffer:包含128个char的数组对象pointer:保存buffer[0]地址的指针对象
|
1 2 3 4 |
// 因此 sizeof buffer /* 128 */ sizeof pointer /* 指针大小,64 位平台通常是 8 */ |
|
1 2 3 4 5 6 7 8 9 10 11 12 |
#include <stdio.h> int main(void) { char buffer[128]; char* pointer = buffer; printf("sizeof buffer = %zu\n", sizeof buffer); printf("sizeof pointer = %zu\n", sizeof pointer); return 0; } |
数组为什么看起来像指针?
- 在大多数表达式中,数组会发生“数组到指针转换”:
|
1 2 3 4 5 |
char buffer[128]; char* p = buffer; // 这里的 buffer 转换为 &buffer[0] |
- 但以下两种情况不会发生这个转换
|
1 2 |
sizeof buffer /* 整个数组的大小 */ &buffer /* 整个数组的地址 */ |
- 注意这几个表达式
|
1 2 3 |
buffer /* 转换后类型通常是 char* */ &buffer[0] /* char* */ &buffer /* char (*)[128] */ |
- 虽然它们通常具有相同的数值地址,但类型和指针运算含义不同
|
1 2 |
buffer + 1 /* 前进 1 个 char */ &buffer + 1 /* 前进整个 char[128] */ |
数组作为函数参数时会退化
- 下面三个函数声明本质相同
|
1 2 3 |
void process(char buffer[128]); void process(char buffer[]); void process(char* buffer); |
- 函数真正收到的是指针,不是完整数组
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#include <stdio.h> void inspect(char buffer[128]) { /* * 这里的 buffer 已经是 char*。 * sizeof 得到指针大小,而不是 128。 */ printf("inside function: %zu\n", sizeof buffer); } int main(void) { char buffer[128]; printf("inside main: %zu\n", sizeof buffer); inspect(buffer); return 0; } |
|
1 2 3 4 5 6 7 8 9 10 11 12 |
// 所以这种接口无法让函数知道数组容量 void receive_data(char buffer[]); // 工程中应该同时传递指针和长度 void process_data( const unsigned char* data, size_t length); // 如果函数需要写入缓冲区,还应传入容量 size_t read_data( unsigned char* buffer, size_t capacity); |
字符数组不一定是字符串
C字符串必须以'\0'结束
|
1 2 3 4 |
char text[] = {'T', 'C', 'P', '\0'}; // 等价于: char text[] = "TCP"; |
|
1 2 3 4 |
// 此时 sizeof text /* 4:包括 '\0' */ strlen(text) /* 3:不包括 '\0' */ |
|
1 2 3 4 5 6 7 8 9 |
// 但是 char data[] = {'T', 'C', 'P'}; // 只是包含三个字符的数组,不是合法的 C 字符串 // 下面的调用存在未定义行为 printf("%s\n", data); strlen(data); // 因为函数会继续向数组外查找 '\0' |
字符串字面量的陷阱
- 创建了一个可以修改的数组
|
1 2 |
char writable[] = "hello"; writable[0] = 'H'; /* 正确 */ |
- 字符串字面量通常位于只读存储区域
- 虽然历史原因导致
C中字符串字面量的类型不是const char[],但程序仍然禁止修改它
- 虽然历史原因导致
|
1 2 3 4 5 |
char* pointer = "hello"; pointer[0] = 'H'; /* 未定义行为 */ // 推荐写成 const char* pointer = "hello"; |
strlen 不知道缓冲区容量
strlen()的工作方式相当于- 它只寻找
'\0' - 不知道数组容量
- 不会阻止越界
- 时间复杂度是
O(n) - 不能处理任意二进制数据
- 它只寻找
|
1 2 3 4 5 6 7 8 9 10 |
size_t my_strlen(const char* text) { size_t length = 0; while (text[length] != '\0') { ++length; } return length; } |
- 示例
recv()只保证前received个字节有效,不会自动添加 `'\0'
|
1 2 3 4 5 6 |
char buffer[1024]; int received = recv(sock, buffer, sizeof buffer, 0); printf("received: %s\n", buffer); /* 错误 */ strlen(buffer); /* 错误 */ |
正确处理 Socket 数据
- 二进制协议
- 二进制数据可能包含零字节
|
1 2 3 4 5 6 7 8 9 10 11 |
unsigned char buffer[1024]; ssize_t received = recv( sock, buffer, sizeof buffer, 0); if (received > 0) { process_data(buffer, (size_t)received); } |
|
1 2 3 4 5 6 7 8 9 10 |
void process_data( const unsigned char* data, size_t length) { for (size_t i = 0; i < length; ++i) { printf("%02X ", (unsigned int)data[i]); } putchar('\n'); } |
- 文本协议
- 如果确认收到的是文本,并且需要使用字符串函数,应给结束符预留一个字节
|
1 2 3 4 5 6 7 8 9 10 11 12 |
char buffer[1024]; ssize_t received = recv( sock, buffer, sizeof buffer - 1, 0); if (received > 0) { buffer[received] = '\0'; printf("%s\n", buffer); } |
memcpy 也需要“指针+长度”
- 调用者必须保证:
source至少有length个可读字节destination至少有length个可写字节- 两块区域不重叠
- 长度计算没有整数溢出
|
1 |
memcpy(destination, source, length); |
- 如果内存区域可能重叠,应使用
|
1 |
memmove(destination, source, length); |
- 错误的容量判断
|
1 2 3 4 5 |
// used + incoming 本身可能发生无符号整数溢出 if (used + incoming <= capacity) { memcpy(buffer + used, data, incoming); } |
|
1 2 3 4 5 6 |
// 更可靠的判断顺序 if (used <= capacity && incoming <= capacity - used) { /* 容量足够 */ } |
指针运算、void* 与动态内存
概述
- 指针不仅是一个地址,还隐含了它所指向对象的类型、边界和生命周期
指针运算按“元素”移动
pointer + 2不是地址增加 2 字节,而是前进两个int
|
1 2 3 4 |
int values[4] = {10, 20, 30, 40}; int* pointer = values; printf("%d\n", *(pointer + 2)); /* 30 */ |
|
1 |
新地址 = pointer地址 + 2 × sizeof(int) |
|
1 2 3 4 5 6 7 8 9 |
// 因此 pointer + i // 等价于 &pointer[i] *(pointer + i) // 等价于 pointer[i] |
指针运算不能越过对象边界
|
1 2 3 4 5 6 7 8 9 |
// 对于 int values[4]; // 合法指针范围是 &values[0] &values[1] &values[2] &values[3] &values[4] /* 尾后指针 */ |
- 允许构造尾后指针
|
1 |
int* end = values + 4; |
|
1 2 |
// 但不能解引用 *end; /* 未定义行为 */ |
- 典型遍历
- 原则上,指针加减以及指针比较应当限制在同一个数组对象及其尾后位置内
|
1 2 3 4 5 |
for (int* current = values; current != values + 4; ++current) { printf("%d\n", *current); } |
指针类型影响偏移量
- 假设
|
1 2 3 4 5 6 |
struct DataHeader { short command; int length; }; struct DataHeader* header; |
|
1 2 3 4 |
header + 1 // 前进的是 sizeof(struct DataHeader) // 而不是一个字节 |
|
1 2 3 4 5 6 7 8 9 |
// 因此,这种代码 Login* login = (Login*)(header + header->length); // 含义是前进 header->length 个 DataHeader,不是前进相应字节数 // 如果协议长度以字节为单位,应当先转换成字节指针 unsigned char* bytes = (unsigned char*)header; unsigned char* payload = bytes + sizeof *header; |
标准C不允许对 void* 做算术
void没有确定大小,所以标准C中不允许
|
1 2 3 4 5 6 7 |
void* memory = malloc(100); memory + 1; /* 非标准 */ // GCC 在 GNU 模式下可能把它当作扩展,按一个字节处理,但使用 // gcc -std=c17 -Wpedantic // 通常会给出警告 |
- 需要按字节移动时,应转换为
unsigned char非常适合检查和复制对象的原始字节表示
|
1 2 |
unsigned char* bytes = memory; bytes += 1; |
malloc 只分配未初始化的存储
- 成功时获得能够容纳
10个int的内存;失败时返回NULL
|
1 2 3 |
#include <stdlib.h> int* numbers = malloc(10 * sizeof *numbers); |
- 必须检查
|
1 2 3 |
if (numbers == NULL) { /* 分配失败 */ } |
malloc不会初始化内容
|
1 2 3 4 5 6 7 8 |
printf("%d\n", numbers[0]); /* 读取未初始化值 */ // 正确做法 for (size_t i = 0; i < 10; ++i) { numbers[i] = 0; } // 或者使用 calloc |
calloc将分配的字节清零- 不过它提供的是“所有位为零”,不要泛化成任何
C类型都必然得到语义上的零值
- 不过它提供的是“所有位为零”,不要泛化成任何
|
1 |
int* numbers = calloc(10, sizeof *numbers); |
C中不要转换 malloc 返回值
- 推荐
|
1 |
int* numbers = malloc(count * sizeof *numbers); |
- 不推荐
|
1 2 |
int* numbers = (int*)malloc(count * sizeof(int)); |
- 原因:
C允许void*隐式转换为其他对象指针sizeof *numbers自动跟随指针类型- 改变变量类型时不容易忘记同步修改
- 强制转换在某些旧代码中可能掩盖缺少
<stdlib.h>的问题
- 在
C++中,void*不能隐式转换为int*
分配长度也可能溢出
- 错误代码
- 如果
count特别大,乘法可能发生size_t无符号整数回绕,最终只分配一小块内存
- 如果
|
1 |
int* numbers = malloc(count * sizeof *numbers); |
- 安全检查
|
1 2 3 4 5 6 7 8 9 |
#include <stdint.h> #include <stdlib.h> if (count > SIZE_MAX / sizeof *numbers) { /* 乘法会溢出 */ return false; } numbers = malloc(count * sizeof *numbers); |
动态数组示例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
#include <stdbool.h> #include <stdint.h> #include <stdio.h> #include <stdlib.h> struct IntArray { int* data; size_t size; }; bool int_array_create( struct IntArray* array, size_t count) { if (array == NULL) { return false; } *array = (struct IntArray){0}; if (count == 0) { return true; } if (count > SIZE_MAX / sizeof *array->data) { return false; } array->data = malloc(count * sizeof *array->data); if (array->data == NULL) { return false; } array->size = count; return true; } void int_array_destroy(struct IntArray* array) { if (array == NULL) { return; } free(array->data); /* * 防止当前对象继续保留悬空指针, * 并让重复调用 destroy 变得安全。 */ *array = (struct IntArray){0}; } int main(void) { struct IntArray array; if (!int_array_create(&array, 5)) { fprintf(stderr, "allocation failed\n"); return 1; } for (size_t i = 0; i < array.size; ++i) { array.data[i] = (int)(i * 10); } for (const int* current = array.data; current != array.data + array.size; ++current) { printf("%d\n", *current); } int_array_destroy(&array); return 0; } |
free 结束内存生命周期
- 调用后:
- 分配的内存不能再访问
- 所有指向该内存的指针都悬空
- 只是将其中一个指针设为
NULL,不能修复其他副本
|
1 |
free(array.data); |
|
1 2 3 4 5 6 7 8 9 |
// second 仍保存旧地址,但内存生命周期已经结束 int* first = malloc(sizeof *first); int* second = first; free(first); first = NULL; printf("%d\n", *second); /* use-after-free */ |
- 其他规则
|
1 2 3 4 |
free(NULL); /* 安全,什么也不做 */ free(pointer); free(pointer); /* double-free,未定义行为 */ |
realloc 的正确使用方式
- 危险写法
|
1 2 3 |
buffer = realloc(buffer, new_capacity); // 如果分配失败,realloc 返回 NULL,原来的指针会被覆盖并造成内存泄漏 |
- 正确写法
|
1 2 3 4 5 6 7 8 9 10 |
unsigned char* temporary = realloc(buffer, new_capacity); if (temporary == NULL) { /* 原来的 buffer 仍然有效 */ return false; } buffer = temporary; capacity = new_capacity; |
- 成功后:
- 原内存可能被释放
- 数据可能被移动到新地址
- 所有旧指针、成员指针、解析出来的
header指针都应视为失效 - 应当重新通过新缓冲区地址计算偏移
|
1 2 3 4 5 6 7 8 9 |
// 因此这种模式存在危险 struct DataHeader* header = (struct DataHeader*)buffer; buffer = realloc(buffer, new_capacity); /* header 可能已经悬空 */ printf("%d\n", header->length); |
- 不要依赖
realloc(pointer, 0)的特殊行为- 需要清空时明确写
|
1 2 3 |
free(pointer); pointer = NULL; capacity = 0; |
动态内存与异步任务
- 假设连接对象拥有接收缓冲区
|
1 2 3 4 5 |
struct Connection { unsigned char* receive_buffer; size_t used; bool closed; }; |
|
1 2 3 4 5 6 7 8 9 10 11 12 |
// 主线程关闭连接后 free(connection->receive_buffer); free(connection); // 但线程池里还有 struct Task { struct Connection* connection; }; // 仅设置 connection->closed = true; // 不能解决生命周期问题,因为任务读取 closed 时,connection 本身可能已经释放 |
- 需要同时解决两个问题:
- 状态问题:连接是否已经关闭?
- 生命周期问题:任务访问期间,连接对象是否仍存在?
- 常见方案包括:
- 关闭前等待所有相关任务完成
- 任务只保存复制出来的数据,不保存连接裸指针
- 使用显式引用计数
- 连接统一归事件循环线程销毁
- 使用任务取消加安全回收机制
const、typedef 与函数指针
常见的 const 指针
- 指向常量的指针
pointer可以指向其他对象- 不能通过
pointer修改对象 const星左,底被指
|
1 2 3 4 5 |
const int* pointer; // 等价于: int const* pointer; |
|
1 2 3 4 5 6 7 |
int first = 10; int second = 20; const int* pointer = &first; pointer = &second; /* 正确 */ *pointer = 30; /* 编译错误 */ |
- 常量指针
pointer必须初始化- 不能再指向其他对象
- 可以通过它修改当前对象
const星右,顶指针
|
1 |
int* const pointer = &first; |
|
1 2 |
*pointer = 30; /* 正确 */ pointer = &second; /* 编译错误 */ |
- 指向常量的常量指针
|
1 |
const int* const pointer = &first; |
|
1 2 |
*pointer = 30; /* 错误 */ pointer = &second; /* 错误 */ |
const 并不拥有对象
|
1 |
void print_client(const struct Client* client); |
- 它只表示函数承诺不通过
client修改对象,不代表:client指向的对象永久不变- 其他线程不能修改对象
- 指针拥有对象
- 对象在函数调用后仍然存活
- 访问自动获得线程安全
- 因此,
const是访问限制,不是生命周期和并发机制 const通常是浅层的buffer指向的结构体不可修改,但结构体里的data仍然是char*,它指向的数据并没有自动变成只读
|
1 2 3 4 5 6 7 8 9 10 11 12 |
struct Buffer { char* data; size_t size; }; void modify(const struct Buffer* buffer) { buffer->size = 10; /* 错误:不能修改结构体成员 */ buffer->data = NULL; /* 错误:不能修改成员指针 */ buffer->data[0] = 'A'; /* 允许 */ } |
不要强行修改真正的 const 对象
|
1 2 3 4 |
const int value = 10; int* pointer = (int*)&value; *pointer = 20; /* 未定义行为 */ |
- 强制转换只能改变表达式的类型,不能改变原对象确实是
const的事实 - 如果原始对象不是
const,只是临时通过const指针观察它,则原对象仍可由其他非const指针修改
|
1 2 3 4 5 6 7 |
int value = 10; const int* reader = &value; int* writer = &value; *writer = 20; /* 正确 */ printf("%d\n", *reader); |
C中的const int 不一定是编译期常量
|
1 2 |
const int size = 10; int array[size]; |
- 在函数内部,这通常是
C99的变长数组VLA,而不是传统的编译期定长数组 - 下面在
C17中不能作为文件作用域数组长度
|
1 2 |
const int size = 10; int array[size]; /* 文件作用域下不合法 */ |
- 宏可以形成整数常量表达式
|
1 2 3 4 5 6 7 |
#define ARRAY_SIZE 10 int array[ARRAY_SIZE]; enum { ARRAY_SIZE = 10 }; |
typedef 只是类型别名
Port并不是一个全新的强类型,它仍然与unsigned short兼容
|
1 2 3 |
typedef unsigned short Port; Port port = 8899; |
|
1 2 3 4 5 6 7 |
typedef struct Client Client; struct Client { int socket; }; Client client; |
|
1 2 3 |
typedef struct Client { int socket; } Client; |
|
1 2 3 4 5 6 |
// 结构体定义尚未结束时,Node 这个 typedef 名称还不能用于声明 next,所以要写 struct Node* typedef struct Node { int value; struct Node* next; } Node; |
指针 typedef 会隐藏 const 位置
- 这是一个高频陷阱
|
1 2 3 4 |
typedef int* IntPointer; int value = 10; const IntPointer pointer = &value; |
|
1 2 3 4 5 6 7 8 9 |
// 很多人会误以为 const IntPointer // == const int* // 实际上,IntPointer 整体代表 int*,因此: const IntPointer pointer; // == int* const pointer; |
- 这也是为什么普通对象指针经常不建议隐藏在
typedef中
|
1 2 3 4 5 6 7 8 |
// 推荐 typedef struct Client Client; Client* client; const Client* client; // 不太推荐 typedef struct Client* ClientPointer; |
如何阅读复杂声明
|
1 |
int* values[4]; |
- 从
values开始:values[4]:values是包含 4 个元素的数组*:每个元素是指针int:指向int- 所以它是:包含 4 个
int*的数组
|
1 |
int (*values)[4]; |
- 括号改变结合顺序:
*values:values 是指针(*values)[4]:指向一个包含 4 个元素的数组- 元素类型为
int - 所以它是:指向
int[4]数组的指针
|
1 |
int* find_value(void); |
- 先看
find_value(void),它是函数;然后看左边的*:- 函数没有参数,返回
int*
- 函数没有参数,返回
|
1 |
int (*find_value)(void); |
- 由于括号:
find_value是指针,指向一个没有参数、返回int的函数
C17中,f()和f(void)不一样
- 在
C17中- 表示参数信息没有说明,不是明确的无参数函数
|
1 2 3 4 |
void run(); // 才明确表示不接受参数 void run(void); |
|
1 2 3 4 5 6 |
// 因此 C17 项目应该写: int main(void); void worker_stop(void); // 而不是 void worker_stop(); |
函数指针
- 普通函数
|
1 2 3 4 |
void handle_message(int command) { printf("command=%d\n", command); } |
- 对应函数指针
|
1 |
void (*handler)(int) = handle_message; |
|
1 2 3 4 5 |
// 调用方式 handler(10); // == (*handler)(10); |
- 使用
typedef简化
|
1 2 3 |
typedef void (*MessageHandler)(int command); MessageHandler handler = handle_message; |
- 注意:
- 函数指针必须指向兼容签名的函数
- 强制转换后以错误类型调用会产生未定义行为
用 void* context 模拟回调上下文
C没有lambda捕获和成员函数回调,通常把函数指针与上下文指针组合起来
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
#include <stdbool.h> #include <stddef.h> #include <stdio.h> typedef void (*MessageHandler)( const unsigned char* data, size_t length, void* context); struct Dispatcher { MessageHandler handler; void* context; }; bool dispatcher_emit( const struct Dispatcher* dispatcher, const unsigned char* data, size_t length) { if (dispatcher == NULL || dispatcher->handler == NULL || (data == NULL && length != 0)) { return false; } dispatcher->handler( data, length, dispatcher->context); return true; } struct PrintContext { const char* name; size_t message_count; }; void print_message( const unsigned char* data, size_t length, void* raw_context) { struct PrintContext* context = raw_context; if (context == NULL) { return; } ++context->message_count; printf( "%s message #%zu:", context->name, context->message_count); for (size_t i = 0; i < length; ++i) { printf(" %02X", (unsigned int)data[i]); } putchar('\n'); } int main(void) { struct PrintContext context = { .name = "server", .message_count = 0 }; struct Dispatcher dispatcher = { .handler = print_message, .context = &context }; const unsigned char packet[] = { 0x10, 0x20, 0x00, 0xFF }; if (!dispatcher_emit( &dispatcher, packet, sizeof packet)) { fprintf(stderr, "dispatch failed\n"); return 1; } return 0; } |
异步回调的生命周期陷阱
- 上面的例子同步执行,因此:
|
1 2 |
// 是安全的 .context = &context |
- 如果把任务放入线程池
- 函数返回后,局部变量
context生命周期结束 - 线程池以后调用回调时,保存的指针已经悬空
- 函数返回后,局部变量
|
1 2 3 4 5 6 7 8 |
void submit_task(void) { struct PrintContext context = { .name = "worker" }; thread_pool_submit(print_message, &context); } |
- 因此异步任务必须保证:
- 上下文比任务存活得更久
- 或者任务复制上下文
- 或者动态分配并明确由谁释放
- 或者使用引用计数
- 停止线程池时等待任务结束
void*只携带地址,不携带类型检查、所有权或生命周期
声明:本文为原创文章,版权归Aet所有,欢迎分享本文,转载请保留出处!
你可能也喜欢
- ♥ C++_volatile10/08
- ♥ COM组件_303/07
- ♥ C相关记述一08/14
- ♥ 行为型:策略模式09/07
- ♥ 创建型:工厂方法模式08/25
- ♥ Reading 2021 《抗压力》07/31
热评文章
- C标准库_cctype 0
- 关于创建文件以及umask的问题 0
- Zlib记述:一 0
- C相关记述一 0
- C++_volatile 0