ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++类为何不能包含自身对象?从sizeof与不完整类型说起

C++类为何不能包含自身对象?从sizeof与不完整类型说起 1. 一个让新手程序员集体懵圈的编译错误先还原一个我见过无数次的场景。某个刚学C的朋友写了这样一段代码class Person { public: Person() {} private: Person other; // 错误字段“other”具有不完整的类型 };编译器直接甩出一句“field ‘other’ has incomplete type”新手当场就懵了我不是已经写了一个Person类吗怎么又“不完整”了再换一种写法class Person { public: Person() {} private: Person* other; // 这个没报错 Person ref; // 这个也没报错 };指针和引用都能用唯独不能直接放一个自身类对象。这背后藏着的其实是C对象模型里一个非常基础、但又特别容易被忽略的设计问题。很多人背下了“不能包含自身类对象”这个结论但从来没人讲清楚“为什么”。这篇文章就把这件事彻底拆开讲透底层原理顺便把相关的内存布局、编译机制、以及工程里常见的替代方案全部梳理一遍。适合人群刚学完类与对象、正在被各种编译错误折磨的初学者面试前想搞懂C对象内存布局的人以及写了几年业务代码但从来没深究过这个问题的工程师。2. 递归定义与无限内存问题的本质是“套娃”2.1 为什么直接放对象会死循环先做个最简单的推理。假设允许这样写class A { A child; };那A里面就包含了一个A而这个A里面又包含一个A这个A里面又包含一个A……无限套下去。编译器要为类计算大小sizeof(A)就成了一个永远算不完的递归。这就好比你在镜子前面放另一面镜子镜中影像无限延伸永远看不到尽头。C是一门需要提前确定所有对象内存大小的语言。你在栈上创建一个对象栈指针要往下挪多少字节编译期必须知道你用new在堆上分配对象要分配多少字节编译期也得知道。所以类的sizeof必须是一个确定的、有限的值。一旦允许类直接包含自身对象sizeof就变成了无穷大编译器直接没法干活。有人可能会想那我把递归定义写成这样行不行class A { A child; int data; };还是不行。你加再多其他成员child本身还是无限递归。除非某个分支能停下来否则整个结构都是病态的。这就引出下面一个关键问题嵌套的终止条件在哪里。2.2 套娃的天然出口指针或引用回到我们开头看到的写法class Person { private: Person* other; };为什么指针和引用没问题因为指针本质就是一个存放地址的整数在64位系统上固定占8字节在32位系统上固定占4字节。编译器根本不需要知道Person类长什么样只需要知道“这里存了一个地址这个地址指向某个Person对象”。所以sizeof(A)可以正常算出来不管Person内部多么复杂指针的尺寸是固定的。引用也是同理引用在底层实现上通常就是一个指针占用固定大小的存储空间。所以A* ptr占用固定指针大小A ref占用固定大小A obj需要知道A的完整内存布局导致无限递归一句话总结类的直接对象成员要求该类必须是“完整类型”而自身类在这个语境下永远是不完整的因为要算完自身大小必须先算完自身大小。2.3 用数学归纳法理解这件事再换个角度理解。类的内存布局可以看作一棵树根节点是这个类叶子节点是各种基础类型成员int、char、double等以及指针成员。int占4字节不需要继续展开是天然的叶子。指针占固定字节指向的内容不需要在当前对象内展开也是叶子。自定义类对象作为成员则要展开成子树继续看这个类内部有哪些成员。当树中出现“自己包含自己”的环时这棵树就永远无法遍历到叶子编译器就只能报错。static成员不在这里面因为static成员不属于对象实例它存储在静态存储区不会参与sizeof计算。后面会详细说。3. 编译器视角完整类型、不完整类型与前置声明3.1 完整类型和不完整类型的区别C标准里有个重要概念叫“完整类型”complete type。简单来说一个类型是完整的当且仅当编译器知道它的大小和内存布局。int、double、自定义的已完成定义的类都是完整类型。反过来不完整类型就是编译器还没法确定大小的类型。常见的几种情况class B; // 前置声明编译器只知道“有个类叫B”不知道它多大 struct Node; // 同上 void func(A obj); // 函数声明中按值传参A只需要前置声明即可前置声明本身不报错问题在于你怎么用它。如果你是声明一个指针或引用没问题因为指针大小已知。如果你声明一个按值传递的函数参数也没问题因为函数声明阶段编译器不需要知道参数对象的完整布局真正需要完整类型是在函数定义和调用处。但只要你想定义一个对象变量就必须有完整类型。下面这行代码必挂class B; // 前置声明 B b; // error: aggregate ‘B b’ has incomplete type and cannot be defined编译器看到B b需要计算b占多少字节结果B只有前置声明没有定义算不出来直接报错。3.2 自身类对象为什么永远是“不完整类型”现在回到自身类对象的场景class A { A child; };当你开始定义类A时在类定义“结束”之前A都是一个不完整类型。而你在A的成员列表里写A child等于是在A还没完整定义完的时候要求编译器创建一个A类型的对象。编译器此刻根本不知道A有多大自然报错。有人会问难道编译器不能先假设一下等定义完再回头算吗这就回到第2节说的无限递归问题。即使编译器想算完它也会发现A里面有个A没完没了。所以这条路被标准直接封死根本不需要考虑“稍后再算”的降级方案。3.3 为什么“指针 前置声明”就能活常见的链表节点写法struct Node { int data; Node* next; };Node里有一个指向Node的指针next。编译器的处理方式遇到struct Node开始构建类型。遇到int data知道data占4字节。遇到Node* next下一阶段指针本身占固定8字节指针指向的目标不需要现在展开。类定义结束Node成为一个完整类型sizeof(Node) 4 8暂不考虑对齐填充是一个确定的有限值。这个模式是整个链表、树、图等数据结构的地基。没有“自身指针成员”你就没法在C里写出链表。指针就是那个“套娃出口”它负责切断无限递归。4. 内存布局实验用sizeof实测验证光讲理论不够我直接放一段测试代码大家在自己的环境里跑一遍就一目了然。#include iostream class Empty { }; class WithInt { int a; }; class WithPointer { int a; WithPointer* next; }; class WithSelfObject { // 这行如果取消注释会编译失败 // WithSelfObject child; public: int a; WithSelfObject* next; }; int main() { std::cout sizeof(int) sizeof(int) std::endl; std::cout sizeof(Empty) sizeof(Empty) std::endl; std::cout sizeof(WithInt) sizeof(WithInt) std::endl; std::cout sizeof(WithPointer) sizeof(WithPointer) std::endl; std::cout sizeof(WithSelfObject) sizeof(WithSelfObject) std::endl; return 0; }在我的64位Linux g 11环境下输出sizeof(int) 4 sizeof(Empty) 1 sizeof(WithInt) 4 sizeof(WithPointer) 16 sizeof(WithSelfObject) 16几个关键点Empty类是空的但sizeof是1因为C标准要求每个对象都必须有独一无二的地址如果是0两个不同对象就会共享同一地址所以编译器硬塞了1字节占位。WithPointer有int4字节和指针8字节按理说是12字节但因为8字节对齐实际占16字节多了4字节填充。WithSelfObject有int和自指指针布局和WithPointer一样是16字节确认了指针成员不参与类大小的递归计算。试试把注释打开class WithSelfObject { WithSelfObject child; // 编译报错 int a; };g报错信息如下error: field ‘child’ has incomplete type ‘WithSelfObject’clang的报错更直白error: field type WithSelfObject is an incomplete type两个主流编译器给出了完全一致的结论这件事的确定性非常高。5. 如果语言允许“自身类对象”会怎样从其他语言看设计取舍5.1 Java、Python、Go中为什么没有这个编译错误很多刚从Java或Python转过来的人会疑惑Java里写下面这段完全没问题class Node { int data; Node next; // 没问题 }为什么C偏偏不让写关键在于Java的引用语义。Java里的Node next并不是一个嵌套的Node对象而是一个指向Node对象的引用底层就是一个指针。它和C里写Node* next本质上是一回事只是Java把指针包装成了引用语法上看起来像是直接放对象。所以Java里类包含“自身对象”只是表面现象实际包含的还是指针。Python同理。Python的变量本质上是绑定到对象的名称赋值传的都是引用self.next同样是指向另一个Node对象的引用。Python的对象都在堆上分配名称只是引用天然就绕开了“对象内部直接嵌入完整对象”这个问题。Go的情况稍微特别一点。Go的slice、map、channel本质上是带指针的结构体所以type Node struct { next *Node }是常规操作。如果直接写next NodeGo同样会报无限递归错误。Go也遵循“直接嵌入自身结构体会导致无限大小”的规则。5.2 值语义与引用语义的关键分野C的特殊之处在于它有严格的值语义。A b a;这行语句会把a的内容完整地拷贝到b中两者占用同样大小的内存各自独立。而Java的Node b a;只是把引用的地址复制了一份两个变量指向同一个对象。值语义让C拥有“对象就是一块确定大小的内存”的强确定性这是C高性能特质的重要来源但同时也带来了“类不能直接包含自身对象”的限制。有一种情况需要单独说明如果在类中包含另一个类的对象而这个类又包含它会不会有问题比如class B; class A { B b; // 需要B是完整类型 }; class B { A a; // 需要A是完整类型 };这里A需要B完整B需要A完整A需要B完整……又是一个循环依赖仍然编译失败。解决办法还是老一套至少把其中一个改成指针或引用。5.3 C里实现环形结构的正确姿势实际工程中环形链表、树、图这些结构都需要自引用标准做法就是指针struct TreeNode { int value; TreeNode* left; TreeNode* right; };也可以用智能指针管理生命周期避免裸指针带来的内存泄漏风险#include memory struct TreeNode { int value; std::shared_ptrTreeNode left; std::shared_ptrTreeNode right; };注意用shared_ptr构建环形结构时两个节点互相持有对方的shared_ptr会导致引用计数永远无法归零形成内存泄漏。要么用weak_ptr打破环要么干脆用裸指针配合手动的生命周期管理。如果是树这类父子关系明确的结构用unique_ptr管理子节点会更合适父节点销毁时自动释放所有子节点不会成环。6. 工程中的连环坑从“自身对象”到“循环依赖”6.1 类头文件的循环包含与“类包含自身对象”同源的问题是头文件循环包含。当两个类需要互相持有对方的指针时最常见的老实人写法是互相#include对方的头文件// A.h #include B.h class A { B* b; }; // B.h #include A.h class B { A* a; };这样写往往会碰到“重定义”或“找不到声明”之类的诡异问题。正确的做法是用前置声明代替#include// A.h class B; // 前置声明告诉编译器B是一个类 class A { B* b; };因为A只用B的指针不需要知道B的完整定义所以前置声明就够了。同理B.h里前置声明A两边都不互相包含头文件编译速度还快了不少。这是很多新手从“成员对象报错”踩出来的第一个连环坑。理解了“指针只需要前置声明”的原理这个坑就很好躲。6.2 为什么vector自身类可以还有一种特殊的“自身类成员”变体也经常让人困惑class A { std::vectorA children; };这个能编译通过。为什么vector在大多数实现里本质上是“三个指针”start、finish、end_of_storage它的大小是固定的和T的类型无关。所以std::vector 占用的内存是固定的24字节64位系统不依赖A的完整布局。这其实又是一个“间接层”的例子。数组、链表、二叉树这些场景里只要你能把“无限递归”切断编译器就能算清楚大小。标准库容器之所以能用正是因为它们内部使用动态堆分配对象本身只存指针或迭代器信息。同样的逻辑std::unique_ptr 、std::shared_ptr 也都能作为自身的成员它们的内部实现都是基于指针大小固定。6.3 static成员不参与大小计算但可以打破僵局static成员变量不归属于某个具体对象它存储在静态存储区所有对象共享同一份数据。所以class A { public: static A instance; // 合法static成员不参与sizeof不存在递归 };这里即使instance的类型是A也不需要递归计算大小因为对象里并不包含instance的完整副本。static成员的大小对整个类来说占0字节类的大小只取决于非静态成员和基类。这为“全局唯一实例”或者单例模式提供了基础支持。7. 从“为什么”延伸出的对象模型悟性理解这个问题的价值远不止于通过一次编译。我自己的体会是它帮你建立起一套看类型系统的眼光第一看一个类型能否作为另一个类的成员本质是在问“编译器现在能不能确定这个成员的大小” 一旦你从这个角度出发很多奇怪报错都能瞬间解释清楚。比如模板类里的成员函数直到实例化时才需要完整类型、shared_ptr能前向声明而unique_ptr在某些情况下要求完整类型等等。第二值语义和引用语义的分野决定了你设计对象关系时选哪种表达方式。想要独立的副本就用值想要共享、引用、多态就用指针或智能指针。C里选择成员类型很多时候不是语法问题而是所有权和生命周期的设计问题。第三所有“递归类型”的解决方案底层都是同一招在递归链上引入一个大小固定的间接层。指针是间接层vector是间接层智能指针是间接层引用也是间接层。把这一步想明白了你以后看到任何嵌套数据结构的代码都会觉得豁然开朗。我自己带新人的时候经常说不用背“类不能包含自身对象”这个结论把它当成一道推理题——试着算一下这个类有多大算不出来编译器自然不让你过。能把这个推理过程讲清楚的人对C对象模型的理解就已经上了一个台阶。如果面试官问你这个问题你先讲无限递归的sizeof再讲指针如何切断递归最后提一句值语义和引用语义的区别这套回答基本就是满分的路径。最后补一个小技巧如果你在实际工程里真的需要在类内部持有“同一个类型的对象”但又不是链表场景先停下来问自己两个问题。第一个这个成员的生命周期应该由谁管理第二个我到底要共享还是拷贝把这两个问题想清楚指针、引用、值、智能指针的选择就顺理成章了。千万别一上来就抄一个写法那才是最容易埋坑的地方。
返回列表