资讯动态

C 语言基础教程(我的C之旅开始了)[十]

发布时间:2026/10/22 7:07:34 来源:尧图企业网站定制
前言这是「我的 C 之旅」系列的第十讲。前九讲大概已经走过了变量、运算符、分支、循环、函数和数组从这一讲开始程序要处理的不再是一两个孤立的数字而是「一组有内在关联的数据」。结构体struct负责把不同类型的数据打包成一个整体指针负责在函数之间高效地传递和修改这些整体动态内存则让容器的长度可以在运行期增长。这三样东西合在一起才够写出一个像样的小程序。需要先说明两点。第一本讲是独立成篇的不依赖前九讲的具体内容读者只要能读懂变量、循环和函数即可。第二C 不是 CC 没有类、引用、模板、异常也没有new/delete动态内存要靠malloc/free一族函数手工管理。用 C 的习惯写 C最容易在「要不要强制转换malloc返回值」「结构体要不要typedef」这些地方绕弯。本文以 C11 为基准C99 的指定初始化器、snprintf、//注释都可放心使用代码只按标准语义推演本机没有 C 工具链未编译、未运行也不对输出作断言。示例中的地址与大小均以sizeof表达不写死具体数值因为结构体填充和对齐是实现定义的。一、结构体把相关数据打包结构体用struct定义把若干个可以是不同类型的成员放在一起// 适用于 C11struct Student {int id;char name[32];double score;};定义之后struct Student是完整的类型名。C 与 C 不同标签名不会自动成为类型名声明变量时必须写struct Student s;。为了少打几个字通常配合typedef// 适用于 C11typedef struct Student {int id;char name[32];double score;} Student;这样Student s;就等价于struct Student s;。也可以写成typedef struct { ... } Student;的匿名标签形式但保留了标签Student之后结构体内部还可以引用自身例如写链表节点// 适用于 C11typedef struct Node {int value;struct Node *next; // 这里必须用 struct Node不能用 typedef 名} Node;在typedef尚未完成的那一刻Node这个名字还不可见所以自引用成员必须写struct Node *。这是初学者定义链表时最常遇到的编译错误。结构体变量的初始化有几种写法。按顺序初始化依赖成员顺序成员一多就容易错位C99 引入的指定初始化器designated initializer按名字赋值可读性更好// 适用于 C11Student a {1, Ada, 91.5}; // 按顺序Student b {.id 2, .name Bob, .score 88.0}; // 按名字Student c {.id 3}; // 其余成员补零没有被显式初始化的成员会被初始化为零指针为NULL数组元素为 0。这一点只对「有初始化器」的对象成立如果是局部变量且完全不带初始化器成员的值是未指定的读取它们是未定义行为undefined behavior, UB。结构体可以整体赋值a b;会逐成员复制数组成员也会被复制填充字节是否复制由实现决定。但结构体不能用比较a b在 C 里非法必须逐成员比较或者用memcmp——后者只在「填充字节也一致」时才可靠而填充字节的值是实现定义的所以逐成员比较才是稳妥做法。成员在内存中的排列顺序与声明顺序一致但编译器可能在成员之间插入填充字节padding以满足对齐要求。因此sizeof(Student)不一定等于各成员大小之和具体数值由实现决定。需要知道某个成员偏移时用stddef.h里的offsetof不要手算。二、指向结构体的指针指向结构体的指针在函数传参时非常常用。按值传结构体会复制全部成员成员里有大数组时开销明显传指针只复制一个地址函数内还能修改原对象。// 适用于 C11void print_student(const Student *s) {if (s NULL) {return;}printf(%d %s %.1f\n, s-id, s-name, s-score);}void bump_score(Student *s, double delta) {if (s NULL) {return;}s-score delta;}s-id是(*s).id的语法糖两者完全等价。.的优先级高于*所以*s.id会被解析成*(s.id)而s是指针不能对它取成员编译会报错。记住「指针用箭头对象用点」即可。const Student *s表示「指向的内容只读」不能通过s修改成员但s本身可以指向别处Student *const s表示「指针本身只读」指向的内容可以改。函数参数里更常见的是前者用来表达「我只读不修改」。如果连指针都不想改写const Student *const s不过函数参数按值传递指针本身本来就是副本通常没必要。把结构体数组传给函数时数组会退化成指向首元素的指针长度信息丢失必须额外传一个长度参数。这一点与普通数组相同// 适用于 C11void print_all(const Student *students, size_t count) {for (size_t i 0; i count; i) {print_student(students[i]);}}students[i]也可以写成students i两者类型都是const Student *。循环变量用size_t而不是int因为count是无符号类型混用会在比较时触发符号转换警告。三、动态内存四个函数与一条铁律C 的动态内存管理只有四个核心函数全部声明在stdlib.h中函数作用关键点malloc(n)分配n字节内容未初始化失败返回NULLcalloc(count, size)分配并全部置零参数相乘可能溢出realloc(p, n)调整已分配块的大小失败时原块仍有效free(p)释放malloc/calloc/realloc返回的块只能释放一次free(NULL)安全一条铁律每一个malloc/calloc/realloc成功返回的指针最终都必须交给free且只能交一次free的参数必须是分配函数返回的原始地址不能是偏移过的指针。malloc的参数是字节数惯用写法是「元素个数乘以单个元素大小」单个元素大小用sizeof *p表达// 适用于 C11size_t n 10;int *p malloc(n * sizeof *p);if (p NULL) {/* 处理分配失败 */}sizeof *p在p的类型改变时自动跟着变比写sizeof(int)更不容易出错。注意在 C 中不要给malloc的返回值加强制转换void *可以隐式转换成任何对象指针类型加强制转换反而会掩盖「忘记包含stdlib.h」导致的错误——在旧标准下未声明的函数会被假定返回int强制转换会让这个错误静默通过。realloc的返回值必须先用临时变量接住// 适用于 C11int *tmp realloc(p, new_count * sizeof *tmp);if (tmp NULL) {/* p 仍然有效不要丢失它 */} else {p tmp;}如果直接写p realloc(p, ...)失败时返回NULL原来的p就被覆盖既泄漏了旧内存又失去了继续使用它的机会。realloc成功时可能把块搬到新地址旧地址随即失效所以之前保存的其它指向该块的指针都要重新计算。calloc会把内存全部置零适合「先分配、后逐项填充」的数组但它接收两个参数相乘时可能溢出分配大数组前要检查count SIZE_MAX / size。malloc的乘法同样可能溢出习惯上先判断再乘。free之后指针的值不会自动变成NULL它变成悬垂指针dangling pointer。继续解引用是 UB再次free是 UB。稳妥的做法是释放后立即置空// 适用于 C11free(p);p NULL;这只是一种纪律不是语言保证如果还有别的指针指向同一块内存置空一个并不能让其它指针变安全。根本办法是明确「谁拥有这块内存」同一时刻只保留一个所有者。四、完整示例可增长的数组下面把结构体、指针和动态内存组合成一个「学生列表」容量不够时用realloc扩容追加元素最后统一释放。代码包含分配失败与容量溢出的检查展示的是防御性写法。// 适用于 C11#include stdint.h#include stdio.h#include stdlib.h#include string.htypedef struct {int id;char name[32];double score;} Student;typedef struct {Student *data;size_t size;size_t capacity;} StudentList;void list_init(StudentList *list) {list-data NULL;list-size 0;list-capacity 0;}void list_free(StudentList *list) {free(list-data);list-data NULL;list-size 0;list-capacity 0;}int list_push(StudentList *list, Student value) {if (list-size list-capacity) {if (list-capacity SIZE_MAX / 2) {return 0; // 翻倍会溢出}size_t new_cap list-capacity 0 ? 4 : list-capacity * 2;if (new_cap SIZE_MAX / sizeof *list-data) {return 0;}Student *tmp realloc(list-data,new_cap * sizeof *list-data);if (tmp NULL) {return 0;}list-data tmp;list-capacity new_cap;}list-data[list-size] value;list-size;return 1;}void student_set_name(Student *s, const char *name) {snprintf(s-name, sizeof s-name, %s, name);}int main(void) {StudentList list;list_init(list);Student a {.id 1, .name , .score 91.5};student_set_name(a, Ada);Student b {.id 2, .name , .score 88.0};student_set_name(b, Bob);if (!list_push(list, a) || !list_push(list, b)) {fprintf(stderr, allocation failed\n);list_free(list);return 1;}for (size_t i 0; i list.size; i) {const Student *s list.data[i];printf(%d %s %.1f\n, s-id, s-name, s-score);}list_free(list);return 0;}几个细节值得说明。第一list_init把三个字段都设成有效初值调用者不需要记住「先清零」。第二扩容时先算new_cap再检查乘法是否溢出SIZE_MAX来自stdint.h。第三realloc的结果先放进tmp成功后才赋给list-data失败时原块不受影响。第四list_free可以安全地重复调用因为free(NULL)是合法的置空之后再次释放也不会出错。第五名字用snprintf写入固定长度数组超长部分被截断至少保证有终止符。这段代码分配失败时只做了最简单的处理打印错误并退出。真实项目可以把失败向上传递由调用方决定重试还是降级但无论怎么处理都不要忽略malloc/realloc的返回值。常见坑点❌ 调用malloc后直接使用不检查是否为NULL。✅ 每次分配后都判断失败时释放已有资源并返回错误码。❌ 写malloc(sizeof(p))其中p是指针结果只分配了一个指针大小。✅ 写malloc(n * sizeof *p)让大小跟随p的类型。❌ 在 C 里写int *p (int *)malloc(n * sizeof(int));并因此忘记包含stdlib.h。✅ C 中不需要强制转换void *会隐式转换保留转换反而掩盖声明缺失。❌ 写p realloc(p, new_size);失败时丢失原指针。✅ 用临时变量接住返回值确认非空后再赋回。❌free(p)之后继续读写p或对同一块内存free两次。✅ 释放后立即置NULL并明确单一所有者悬垂访问与重复释放都是 UB。❌ 对malloc返回的指针做偏移后再free例如free(p 1)。✅free只接受分配函数返回的原始地址需要偏移时另存原始指针。❌ 用strcpy把任意长度的字符串复制进固定大小的char数组。✅ 用snprintf(dst, sizeof dst, %s, src)确保不越界且以空字符结尾。❌ 用scanf(%s, buf)读入用户输入没有宽度限制。✅ 写scanf(%31s, buf)限定最大字符数或改用fgets并检查返回值。总结主题要点结构体定义struct标签不自动成为类型名常配typedef初始化C99 指定初始化器按名字赋值未写成员补零访问成员对象用.指针用-传参大结构体传指针只读加const分配malloc不初始化calloc置零检查NULL扩容realloc用临时变量接住失败保留原块释放只释放原始地址、只释放一次释放后置空安全长度校验、snprintf、避免悬垂与重复释放结构体让数据有了形状指针让函数能够共享和修改这些数据动态内存让形状可以随需求增长。三者组合起来就是 C 语言里最基础的「容器」雏形。写这类代码时真正需要时刻盯住的不是语法而是所有权这块内存是谁分配的、谁负责释放、还有没有别的指针指向它。把所有权想清楚悬垂指针、重复释放和泄漏就会少一大半。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑