从简单析构到析构链:C 语言里对象内部资源的释放顺序
从简单析构到析构链C 语言里对象内部资源的释放顺序作者QuirkybrainGitHub 仓库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 expressionMakefile 继续使用-stdgnu11。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 Cats member if have) I am Tom. (destroy a cat) (destroy Dogs foodName member: bone.) I am Max. (destroy a dog)前半段还是行为多态调用。后半段可以看到Cat和Dog都先进入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()只是把调用转发给具体类型的destroyvoiddestroyAnimal(Animal**self){(*self)-vtblptr-destroy(self);}004 里抽象层开始承担“销毁顺序”的责任voiddestroyAnimal(Animal**self){(*self)-vtblptr-cleanUp(*self);(*self)-vtblptr-release(*self);*selfNULL;}这里有一个小细节很重要destroyAnimal()仍然接收Animal**但虚表里的cleanUp和release接收的是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(dogNULL)returnNULL;dog-base.vtblptrdogVtbl;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*dogcontainer_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*dogcontainer_of(self,Dog,base);printf((destroy Dogs foodName member: %s.)\n,dog-foodName);free(dog-foodName);}它只做一件事释放Dog自己额外申请出来的foodName。注意它不释放dog本体。这样destroyAnimal()在进入下一阶段时self仍然是有效的releaseDog()还能继续从Animal*找回完整对象staticvoidreleaseDog(Animal*self){Dog*dogcontainer_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;};所以Cat的cleanUp阶段暂时没有真正要释放的资源staticvoidcleanUpCat(Animal*self){(void)self;printf((destroy Cats member if have)\n);return;}但它仍然在虚表里提供了cleanUpstaticconstAnimalVtbl catVtbl{.speakcatSpeak,.drinkcatDrink,.releasereleaseCat,.cleanUpcleanUpCat};这样做的意义是让所有具体类型都遵守同一套销毁协议。Cat现在没有私有资源所以cleanUpCat()为空以后如果给Cat增加char*favoriteFood;那么只需要把释放逻辑补进cleanUpCat()destroyAnimal()的整体流程不需要再改。Cat的第二阶段仍然负责释放完整对象本体staticvoidreleaseCat(Animal*self){Cat*catcontainer_of(self,Cat,base);printf(I am %s. (destroy a cat)\n,self-name);free(cat);}调用端销毁接口没有变复杂虽然内部从一个destroy拆成了cleanUp release但调用端并没有变复杂。main.c仍然只需要把对象放进Animal*数组Cat*catnewCat(Tom);Dog*dognewDog(Max,bone);Animal*animals[2]{catAsAnimal(cat),dogAsAnimal(dog)};然后统一调用for(inti0;i2;i){destroyAnimal(animals[i]);}调用端不知道Dog有foodName也不知道Cat当前没有额外资源。它只知道一件事这个 Animal* 结束生命周期了。具体该清理哪些资源、对象本体该怎么释放都由对象自己的虚表决定。为什么不把所有 free 都写进一个 destroy 函数003 的写法其实也可以继续扩展成这样staticvoiddestroyDog(Animal**self){Dog*dogcontainer_of(*self,Dog,base);free(dog-foodName);free(dog);*selfNULL;}这段代码不是不能工作。对于当前这个小例子它甚至更短。但它有一个问题所有事情都混在了同一个函数里。当对象变复杂以后这种写法会让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本体。Cat和Dog都遵守同一套cleanUp release协议。调用端仍然只面向Animal*和destroyAnimal()。Animal*在销毁完成后仍然会被置为NULL。但这个示例仍然保留了一些问题没有解决没有做引用计数没有处理多个指针别名同时指向同一个对象的情况。这些都可以继续作为后续章节扩展。当前这一章先把最核心的顺序讲清楚先释放成员资源再释放对象本体。小结001 让对象可以多态调用行为。002 让具体实现可以从Animal*找回完整对象。003 让调用端可以通过统一接口释放对象本体。004 则继续补上对象内部资源的释放顺序对象不一定只拥有自己这一块内存它还可能拥有其他堆资源。销毁时应该先清理这些资源再释放对象本体。从 003 到 004最关键的变化不是多了一个foodName字段而是销毁协议从destroy变成了cleanUp - release这让“析构”从一个简单的free()动作变成了一个可以继续扩展、可以分层讲清楚的生命周期过程。