news 2026/5/26 22:37:45

从简单析构到析构链:C 语言里对象内部资源的释放顺序

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从简单析构到析构链:C 语言里对象内部资源的释放顺序

从简单析构到析构链:C 语言里对象内部资源的释放顺序

  • 作者:Quirkybrain
  • GitHub 仓库:Quirkybrain/C-learning-note

作者今天晚上刚刚考完《思想道德与法治》。
只能说,这门课背到最后,人已经不是人了。
更像一个没有设计好析构函数的对象,表面还在运行,实际上内部资源早就乱成一团。
现在考完试只想把缓存、焦虑和临时记忆全部释放掉。
作者才大一就体会到了坐牢的感觉。
但坐牢归坐牢,代码里的对象释放顺序还是不能乱。
于是这一章刚好接上一个很应景的问题:资源怎么有计划的释放


001-c-polymorphism-with-vtable先用AnimalVtbl做出了多态调用:调用端只拿着Animal*,真正执行的是catSpeak()还是dogSpeak(),由对象内部的函数表决定。

002-c-container-of补上了一个关键能力:当Animal base不在结构体第一个成员时,具体实现仍然可以从Animal*安全地找回完整的Cat*Dog*

003-c-object-lifetime-management又往前走了一步:把“释放对象本体”也放进虚表。调用端不需要判断对象到底是Cat还是Dog,只需要调用统一的destroyAnimal()

但是 003 还留下了一个新的问题:

如果具体对象内部还有自己 malloc() 出来的成员资源, 只 free 对象本体够不够?

答案是不够。

这一章的Dog新增了一个堆分配的foodName成员。这样一来,销毁Dog时就不能只做:

free(dog);

因为dog->foodName指向的那块堆内存也需要释放。于是 004 的重点变成了:

  • 先清理具体类型自己额外持有的资源。
  • 再释放完整对象本体。
  • 这个顺序由抽象层统一调度,而不是每个类型随手写一团free()

这就是本章所说的“析构链”:销毁不再只是一个终点动作,而是一个有顺序的过程。

构建与运行

当前工程仍然按include/src/目录组织。由于container_of()使用了 GNU C 扩展里的typeof和 statement expression,Makefile 继续使用-std=gnu11

makemakerun

运行结果:

I am Tom. (Init a cat) I am Max. (Init a dog) miaow~ Tom drink water. woof~ Max drink water. miaow~ I am Tom, a cat, with 9 lives. woof~ I am Max, a dog, like to eat bone. (destroy Cat's member if have) I am Tom. (destroy a cat) (destroy Dog's foodName member: bone.) I am Max. (destroy a dog)

前半段还是行为多态调用。后半段可以看到,CatDog都先进入cleanUp阶段,再进入release阶段。

这一章相对于 003 改了什么

003 里的虚表是这样:

structAnimalVtbl{void(*speak)(Animal*self);void(*drink)(Animal*self);void(*destroy)(Animal**self);};

也就是说,具体类型只需要实现一个destroy函数。这个函数既可以释放对象内部资源,也可以释放对象本体。

004 把这个单一动作拆成两个槽位:

structAnimalVtbl{void(*speak)(Animal*self);void(*drink)(Animal*self);void(*cleanUp)(Animal*self);void(*release)(Animal*self);};

这两个新槽位的分工很明确:

  • cleanUp:清理对象内部额外持有的资源,但不释放对象本体。
  • release:释放完整对象本体。

于是销毁流程从:

destroyAnimal() -> 具体类型 destroy() -> free(各种资源) -> free(对象本体)

变成了:

destroyAnimal() -> 具体类型 cleanUp() -> free(成员资源) -> 具体类型 release() -> free(对象本体) -> 把调用方持有的 Animal* 置空

这个变化看起来只是多拆了一个函数,但它表达了一个更重要的设计意图:

资源清理和对象释放不是同一件事。

抽象层:destroyAnimal 负责固定销毁顺序

003 里,destroyAnimal()只是把调用转发给具体类型的destroy

voiddestroyAnimal(Animal**self){(*self)->vtblptr->destroy(self);}

004 里,抽象层开始承担“销毁顺序”的责任:

voiddestroyAnimal(Animal**self){(*self)->vtblptr->cleanUp(*self);(*self)->vtblptr->release(*self);*self=NULL;}

这里有一个小细节很重要:destroyAnimal()仍然接收Animal**,但虚表里的cleanUprelease接收的是Animal*

原因是:

  • destroyAnimal()需要把调用方手里的指针置为NULL,所以它需要Animal**
  • cleanUp()只需要访问对象内容并清理资源,所以Animal*足够。
  • release()只需要从Animal*找回完整对象并释放它,所以Animal*也足够。

也就是说,双重指针只留在最外层统一入口里。具体类型的析构阶段不再负责修改调用方变量,它们只处理对象本身。

Dog:新增一个需要单独清理的堆成员

003 里的Dog很简单:

structDog{Animal base;};

对象里没有额外资源,所以释放完整对象本体就够了。

004 里给Dog增加了一个私有成员:

structDog{char*foodName;Animal base;};

foodName是一块单独申请出来的堆内存,不属于Dog结构体本体那一块malloc(sizeof(Dog))。因此销毁时必须分两步:

先 free(dog->foodName) 再 free(dog)

如果直接free(dog)Dog对象本体确实被释放了,但foodName指向的那块内存就再也找不到了,这就是内存泄漏。

Dog 初始化:对象本体和成员资源是两次分配

newDog()负责分配对象本体:

Dog*newDog(constchar*name,constchar*foodName){Dog*dog=(Dog*)malloc(sizeof(Dog));if(dog==NULL)returnNULL;dog->base.vtblptr=&dogVtbl;dogInit(dog,name,foodName);returndog;}

真正初始化foodName的动作放在dogInit()里:

staticvoiddogInit(Dog*self,constchar*name,constchar*food){strncpy(self->base.name,name,MAX_NAME_LEN-1);self->base.name[MAX_NAME_LEN-1]=0;self->foodName=(char*)calloc(MAX_DOG_FOOD_NAME,sizeof(char));strncpy(self->foodName,food,MAX_DOG_FOOD_NAME-1);self->foodName[MAX_DOG_FOOD_NAME-1]=0;printf("I am %s. (Init a dog)\n",self->base.name);}

这里有两层生命周期:

  • Dog* dog来自malloc(sizeof(Dog))
  • dog->foodName来自calloc(MAX_DOG_FOOD_NAME, sizeof(char))

既然创建时有两次分配,销毁时也应该有对应的两次释放。

这正是 004 相比 003 多出来的点:一个对象不一定只对应一块堆内存。对象本体里面还可能“拥有”其他资源。

Dog 行为:访问私有字段仍然要靠 container_of

因为Dog现在长这样:

structDog{char*foodName;Animal base;};

Animal base已经不在结构体第一个成员位置。调用端拿到的Animal*指向的是dog->base,不是Dog对象起点。

所以dogSpeak()不能把Animal*直接强转成Dog*,而要继续使用 002 引入的container_of()

staticvoiddogSpeak(Animal*self){Dog*dog=container_of(self,Dog,base);printf("woof~ I am %s, a dog, like to eat %s.\n",self->name,dog->foodName);}

这说明container_of()不只是服务于Cat。只要具体类型的base不在首位,或者我们不想把对象布局绑定到“base 必须放第一个”这个约定上,就应该用同一套方式恢复完整对象。

Dog 的两阶段销毁

Dog的第一阶段是cleanUpDog()

staticvoidcleanUpDog(Animal*self){Dog*dog=container_of(self,Dog,base);printf("(destroy Dog's foodName member: %s.)\n",dog->foodName);free(dog->foodName);}

它只做一件事:释放Dog自己额外申请出来的foodName

注意,它不释放dog本体。这样destroyAnimal()在进入下一阶段时,self仍然是有效的,releaseDog()还能继续从Animal*找回完整对象:

staticvoidreleaseDog(Animal*self){Dog*dog=container_of(self,Dog,base);printf("I am %s. (destroy a dog)\n",self->name);free(dog);}

于是完整顺序就是:

destroyAnimal(&animal) -> cleanUpDog(animal) -> free(dog->foodName) -> releaseDog(animal) -> free(dog) -> animal = NULL

这个顺序不能反过来。如果先free(dog),再去访问dog->foodName,那就是释放对象后继续访问对象内部字段,属于未定义行为。

Cat:没有私有堆资源,也要遵守同一协议

Cat当前没有像Dog一样新增堆成员,它的结构仍然是:

structCat{intlives;Animal base;};

所以CatcleanUp阶段暂时没有真正要释放的资源:

staticvoidcleanUpCat(Animal*self){(void)self;printf("(destroy Cat's member if have)\n");return;}

但它仍然在虚表里提供了cleanUp

staticconstAnimalVtbl catVtbl={.speak=catSpeak,.drink=catDrink,.release=releaseCat,.cleanUp=cleanUpCat};

这样做的意义是让所有具体类型都遵守同一套销毁协议。

Cat现在没有私有资源,所以cleanUpCat()为空;以后如果给Cat增加:

char*favoriteFood;

那么只需要把释放逻辑补进cleanUpCat()destroyAnimal()的整体流程不需要再改。

Cat的第二阶段仍然负责释放完整对象本体:

staticvoidreleaseCat(Animal*self){Cat*cat=container_of(self,Cat,base);printf("I am %s. (destroy a cat)\n",self->name);free(cat);}

调用端:销毁接口没有变复杂

虽然内部从一个destroy拆成了cleanUp + release,但调用端并没有变复杂。

main.c仍然只需要把对象放进Animal*数组:

Cat*cat=newCat("Tom");Dog*dog=newDog("Max","bone");Animal*animals[2]={catAsAnimal(cat),dogAsAnimal(dog)};

然后统一调用:

for(inti=0;i<2;++i){destroyAnimal(&animals[i]);}

调用端不知道DogfoodName,也不知道Cat当前没有额外资源。它只知道一件事:

这个 Animal* 结束生命周期了。

具体该清理哪些资源、对象本体该怎么释放,都由对象自己的虚表决定。

为什么不把所有 free 都写进一个 destroy 函数

003 的写法其实也可以继续扩展成这样:

staticvoiddestroyDog(Animal**self){Dog*dog=container_of(*self,Dog,base);free(dog->foodName);free(dog);*self=NULL;}

这段代码不是不能工作。对于当前这个小例子,它甚至更短。

但它有一个问题:所有事情都混在了同一个函数里。

当对象变复杂以后,这种写法会让destroy同时承担很多职责:

  • 从抽象指针恢复完整对象。
  • 清理具体类型自己的堆成员。
  • 释放对象本体。
  • 修改调用方指针。
  • 维护这些动作之间的顺序。

004 把这些职责拆开之后,边界更清楚:

  • destroyAnimal()负责统一入口和销毁顺序。
  • cleanUp()负责对象内部资源清理。
  • release()负责对象本体释放。
  • container_of()负责从基类成员指针恢复完整对象。

这样做的好处不是“代码行数变少”,而是生命周期规则变得更稳定。

以后某个类型新增私有资源时,通常只需要改自己的cleanUp()。对象本体释放仍然放在release(),调用端也不用知道这件事。

这种设计比单个 destroy 好在哪里

第一,释放顺序更明确。

对象内部资源必须在对象本体释放之前清理。把cleanUp放在release前面,顺序直接写在抽象层里,读代码时不用到每个类型的destroy里猜它到底先做什么。

第二,职责更单一。

cleanUpDog()只关心foodNamereleaseDog()只关心free(dog)。这比把所有释放逻辑塞进一个函数里更容易检查,也更容易给别人讲清楚。

第三,具体类型更容易演进。

今天Dog只有一个foodName。明天它可能有更多资源:

char*foodName;char*toyName;int*trainingScores;

这些都可以继续放进cleanUpDog()。只要releaseDog()仍然最后释放对象本体,整体生命周期顺序就不会乱。

第四,调用端仍然保持抽象。

main.c不需要因为Dog多了一个堆成员就改销毁逻辑。调用端依然只写:

destroyAnimal(&animals[i]);

这说明抽象接口没有被具体类型的内部变化污染。

第五,它更接近底层工程里的习惯。

很多 C 工程会把“对象从系统里摘掉”“清理对象持有资源”“最后释放对象内存”拆成不同阶段。Linux 内核里也经常能看到类似的思想:通用层负责生命周期入口和顺序,具体类型在自己的回调里处理自己拥有的资源。

这里不需要把它理解成 C++ 那种语言自动帮你串起来的析构函数。它更像一种手写约定:

具体类型先清理自己拥有的东西, 然后统一释放对象本体。

这一章解决了什么,还没解决什么

004 已经解决的问题:

  • Dog可以拥有自己单独申请的堆成员。
  • 销毁时会先释放dog->foodName,再释放dog本体。
  • CatDog都遵守同一套cleanUp + release协议。
  • 调用端仍然只面向Animal*destroyAnimal()
  • Animal*在销毁完成后仍然会被置为NULL

但这个示例仍然保留了一些问题没有解决:

  • 没有做引用计数
  • 没有处理多个指针别名同时指向同一个对象的情况。

这些都可以继续作为后续章节扩展。当前这一章先把最核心的顺序讲清楚:

先释放成员资源,再释放对象本体。

小结

001 让对象可以多态调用行为。

002 让具体实现可以从Animal*找回完整对象。

003 让调用端可以通过统一接口释放对象本体。

004 则继续补上对象内部资源的释放顺序:对象不一定只拥有自己这一块内存,它还可能拥有其他堆资源。销毁时应该先清理这些资源,再释放对象本体。

从 003 到 004,最关键的变化不是多了一个foodName字段,而是销毁协议从:

destroy

变成了:

cleanUp -> release

这让“析构”从一个简单的free()动作,变成了一个可以继续扩展、可以分层讲清楚的生命周期过程。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/26 22:36:12

一文啃完DNS:原理+查询+BIND部署全攻略

DNS 服务器 摘要&#xff1a;本文系统性地介绍了 DNS&#xff08;域名系统&#xff09;的核心原理与实战配置。内容涵盖 DNS 层次结构、递归与迭代查询机制、各类资源记录&#xff08;A、NS、MX、SOA 等&#xff09;的解析&#xff0c;并详细演示了如何在 Linux 上使用 BIND 搭…

作者头像 李华
网站建设 2026/5/26 22:31:32

鸿蒙英语备考页面构建:学习模块网格与单词卡片详解

鸿蒙英语备考页面构建&#xff1a;学习模块网格与单词卡片详解 前言 在 HarmonyOS 6.0 应用开发中&#xff0c;教育类页面的学习模块入口和单词学习卡片是用户日常学习的核心交互区域。本文将以“英语备考”应用中的“学习模块”网格和“今日单词”卡片为例&#xff0c;深入解析…

作者头像 李华
网站建设 2026/5/26 22:27:58

影像技术实战26:视频文件损坏怎么提前发现?FFprobe 元信息校验与解码测试方案

影像技术实战26:视频文件损坏怎么提前发现?FFprobe 元信息校验与解码测试方案 一、问题场景:文件上传成功,但转码和抽帧全部失败 在视频系统里,一个常见误区是: 文件存在 = 视频可用真实项目中经常遇到: 1. 文件大小正常,但无法播放 2. ffprobe 能读到信息,但转码…

作者头像 李华
网站建设 2026/5/26 22:26:33

Azkaban 安装完整教程(基于 WSL2/Ubuntu)

本教程详细指导在Windows11的WSL2&#xff08;Ubuntu24.04&#xff09;中安装Azkaban工作流调度系统。 关键步骤包括&#xff1a; 环境准备&#xff1a;确保Java8、Gradle5.0和Node.js16已安装&#xff0c;需通过update-alternatives切换Java版本。编译安装&#xff1a;克隆Azk…

作者头像 李华
网站建设 2026/5/26 22:23:06

Linux命令:pidstat

pidstat 命令 基本介绍 pidstat&#xff08;Process ID Statistics&#xff09;是 Linux 系统中用于报告进程级统计信息的工具&#xff0c;属于 sysstat 工具包的一部分。它可以显示指定进程或所有进程的 CPU、内存、I/O 等资源使用情况&#xff0c;是系统管理员进行进程性能分…

作者头像 李华