尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++类深度解析:从内存布局到多态机制

C++类深度解析:从内存布局到多态机制 写这篇的时候我犹豫了一下开头从哪切入。因为“C 类”这个关键词太宽泛了上过课的会背一句“类就是用户自定义类型”做过两三个小项目的人会说“类就是数据和方法的封装”但这两种答案都只摸到了表皮。真正让人卡住的点是类不是一套语法而是编译器怎么在底层把你的代码变成内存操作的一套规则。你只有从内存布局的角度去看它才能解释为什么this指针存在、为什么虚函数要耗费一次间接调用、为什么拷贝一个对象会莫名其妙地崩掉。这篇东西就是照着这个思路来的从最基本的对象内存模型聊到继承和虚函数再落到工程里那几个高频编译错误最后补一点游戏开发场景里类和类的不同用法希望能把“C类”这件事讲成一条完整的线而不是零散的知识点。1. 从class关键字开始类到底是一个什么东西1.1 类不是图纸对象才是放在内存里的真实存在很多资料喜欢把类比作“图纸”把对象比作“按图纸建出来的房子”这比喻在概念上没错但有一个误导类本身不占运行时的数据内存对象才占。你在代码里定义一个空类class Empty { };然后用sizeof(Empty)去看结果通常不是0而是1。为什么因为C标准要求同一类型的两个不同对象必须拥有不同的地址如果空类大小是0数组里两个元素就会挤在同一块地址上这会让指针比较和容器实现全部乱套。于是编译器给空类塞一个无意义的字节。这个细节是典型的“类内存规则”的例子你怎么定义类编译器就怎么安排对象的存储。一个更常见的类通常包含成员变量和成员函数。这里有一个新手特别容易误解的点成员函数不占用对象的内存。同一个类的十个对象各自有一份成员变量但成员函数只有一份代码存在代码段里所有对象共用。既然共用那函数怎么知道“当前操作的是哪个对象”靠的就是this指针。当你写obj.show()编译器的真实意图是调用show(obj)把obj的地址作为隐藏参数传进去。类里写的show()其实等同于这样一段普通的函数void show(Student* this) { cout this-name; }所以你写show()的时候可以随便访问name本质上就是通过this-name取的。这也是为什么你定义一个空的、只有成员函数的类sizeof结果还是1而不是0函数代码不在对象里。1.2 访问限定符封装并不是为了防黑客public、private、protected这三个访问限定符经常被初学者当成“不重要的规矩”甚至有人会图省事把所有成员都放public。我的建议是这个习惯趁早改。private的真正意义不是“别人看不了你的秘密”而是把你的类的内部实现和外部使用契约切开。你对外暴露的public接口是契约内部成员是随时可以重构的实现细节。今天你用vector存数据明天想换deque只要数据成员是private你改内部实现不影响任何调用方代码。如果全public外部代码直接依赖你的数据成员一改全盘崩。protected的定位则更微妙它只对类和它的派生类可见外部不可见。设计继承体系时你可以把“允许子类使用、但不允许外界触碰”的辅助方法放protected。不过从我看到的项目经验来说protected成员变量用多了很容易失控因为派生类对基类状态的修改链条根本刹不住最后出bug时你很难定位是谁改坏了状态。现代C更推荐基类只保留纯虚接口和必要的protected方法数据成员尽量封装好。一个规范的类第一步长这样class Student { public: Student(string name, int age); string getName() const; void setAge(int age); private: string name_; int age_; };成员变量名加后缀下划线是我个人偏好的风格主要是为了和函数参数区分开。这个东西没有统一标准但团队里必须约定一个并全部遵守。2. 对象的一生构造、拷贝、析构里藏着最深的坑2.1 构造函数和初始化列表先初始化再进入函数体构造函数人人都知道但“初始化列表”和“函数体内赋值”的差别很多人没细想过。看这个class File { public: File(string path) : path_(std::move(path)), handle_(open(path_)) {} private: string path_; int handle_; };问题在哪path_和handle_的初始化顺序取决于它们在类里声明的顺序而不是初始化列表里写的顺序。编译器会无视你在初始化列表里的排列严格按照成员变量声明顺序初始化。如果handle_在path_前面声明但你如上写那handle_会先于path_初始化此时path_还是乱七八糟的值open拿到一个未定义路径。这是C里一个非常经典又非常隐蔽的坑。我的建议是初始化列表的书写顺序永远跟成员声明顺序保持一致然后编译器告警选项能开就开-Wreorder之类别觉得那是噪音。另外有些成员只能用初始化列表初始化const成员、引用成员、没有默认构造函数的类类型成员。因为它们的“值”必须在构造发生的那一刻确定不能先默认构造再赋新值。这个如果你试过在构造函数函数体里给const int赋值编译器会直接报错。2.2 三法则和五法则拷贝控制不是边角料当一个类管理了堆资源比如new出来的指针编译器默认生成的拷贝构造函数和拷贝赋值运算符是浅拷贝。两个对象的指针指向同一块内存析构时各自delete一次于是产生了经典的double free崩掉。这个坑每个用C的人迟早都会踩。对应的对策是“三法则”如果你自定义了析构函数、拷贝构造函数、拷贝赋值运算符中的任何一个通常三个都要自定义因为这意味着你在管理资源默认的浅拷贝行为必然不稳。到了C11移动语义加入后升级成“五法则”再加移动构造函数和移动赋值运算符。实操上如果不想手写这一堆最省心的方案是遵循“零法则”类里别直接管理裸资源把资源扔给智能指针、容器、string这些现成的RAII类型编译器默认生成的拷贝行为就是安全的。比如class Buffer { private: std::vectorchar data_; // 不用自己管内存 };什么都不用写拷贝、移动、析构全对。只有当类需要实现资源独有的语义比如某个句柄在拷贝时要复制一份底层资源而不是共享时才值得手写五法则。2.3 移动语义让返回大对象不再尴尬在C11之前从一个函数返回一个大的vector或自定义容器往往要通过“返回值优化RVO”避免拷贝。C11之后有了移动语义即使编译器没优化掉也只会把源对象的资源“偷”过来而不是深拷贝一整份数据。移动构造函数的典型写法class Buffer { public: Buffer(Buffer other) noexcept : data_(other.data_), size_(other.size_) { other.data_ nullptr; other.size_ 0; } // 拷贝构造... private: char* data_; size_t size_; };关键点除了“转移指针”外还必须把源对象复位成有效状态让它的析构函数能安全执行。别忘了标noexcept。移动构造函数如果不标noexceptstd::vector扩容时可能退回到拷贝路径因为标准库容器在保证异常安全时会优先选择不会抛异常的操作这个细节会影响容器性能。3. 静态成员、const成员、友元类的“横向”能力3.1static成员它是类的不是对象的普通成员变量每个对象都有一份static成员变量全类只有一份本质上就是个带类作用域的全局变量。它常用于计数器、共享配置、单例实例等场景。C17之后inline static成员变量可以直接在类内初始化之前则必须在类外定义一次class App { public: static int instanceCount; static void showCount() { cout instanceCount; } }; // 普通的静态成员变量必须在某个.cpp文件里定义 int App::instanceCount 0;static成员函数也很特殊它没有this指针因此不能访问非静态成员变量。你可以把它当成一个普通的全局函数只是名字和调用方式带了类的命名空间。这也是“类级别的工具方法”最常见的实现方式。3.2const成员函数给调用者一份安全承诺类里经常能看到这样的声明string getName() const;这里的const修饰的是this指针意思是这个成员函数内部不会修改对象的状态。编译器强制保证这一点如果你在函数体里给成员变量赋值直接编译不过。这不止是规范问题它会影响你能否在const对象上调用该方法。如果一个成员函数没标const那const Student s; s.getName()就会报错因为编译器不允许你在只读对象上调用可能修改它的函数。反过来如果你确实需要在一个const成员函数里修改某个成员——比如一个缓存变量、一个计数器、一个需要惰性计算的字段——可以把那个成员声明成mutable。这会绕过const的约束属于刻意给“逻辑上不改变对象状态”的变量开的后门不要滥用。3.3 友元关系到位规则可以通融friend允许某个外部函数或其他类访问本类的private成员。这个东西好用但特别容易被人当成破坏封装的借口。我的经验是友元通常只适合两类场景。一类是运算符重载比如重载让自定义类型能用cout obj输出这个运算符左侧是ostream必然不能是类的成员函数所以只能靠友元访问内部状态。另一类是真正紧密协作的“伴侣类”比如缓存类和被缓存的数据类它们本来就是一个模块拆出来的两半可以相互友元。除这两类之外尽量别用。4. 继承与多态覆盖、隐藏、纯虚函数一次说清4.1 派生类对象的构造顺序和内存布局创建一个派生类对象时先构造基类部分再构造派生类自己的部分析构时顺序严格相反先析构派生类部分后析构基类部分。这个顺序是有底层逻辑的派生类构造函数运行前必须确保基类状态已经可用否则派生类访问基类成员时拿到的是一堆垃圾。内存布局上派生类对象通常是“基类子对象”在前、派生类新增成员在后。这意味着基类部分的地址与整个派生类对象的起始地址一致派生类对象指针可以安全地隐式转换成基类指针这种转换在编译期就能确定偏移。但是这个结论只在“单继承”下成立。多继承时代就不同了一个派生类对象里有多个基类子对象转成第二个基类的指针时要加上对应的偏移量。这个过程由编译器自动完成但你要有意识在多继承场景下基类指针的地址值不一定等于派生类对象地址值比较它们的原始数值是没有意义的这也是很多多继承bug的来源。4.2 虚函数与虚表多态的成本藏在一次间接里当你在基类里写virtual void draw() 0;并在派生类里通过基类指针调用p-draw()时编译器无法在编译期确定具体调用哪个版本于是改用“动态绑定”。实现机制是含有虚函数的类会生成一张虚函数表vtable对象内部藏一个指向该表的虚指针vptr调用虚函数时先取出vptr再到表里查函数的真实地址。这带来两个后果一是对象内存变大了一点点通常多一个指针大小二是一次虚函数调用比普通函数调用多一次间接寻址。这些都是很小成本但如果你在性能敏感的代码里跑大量虚函数还是能感觉到的。更需要注意的是构造和析构函数里调用虚函数不会触发动态绑定。因为在基类构造函数执行期间vptr指向的还是基类的虚表尚未切到派生类的表。如果你在基类构造函数里调了一个看起来是虚函数的draw()它实际调用的是基类自己的版本而不是最终派生类的版本。4.3 覆盖、隐藏这两个概念几乎每次面试都会被问到“覆盖”和“隐藏”是C里极其容易混淆的两个词。覆盖override派生类用一个同签名函数重写了基类的虚函数。要求是基类函数必须是virtual派生类函数签名完全一致C11后建议加override关键字让编译器帮你检查。调用时动态绑定到最终版本。隐藏hiding只要派生类声明了与基类同名的函数不管参数列表是否一样、不管基类函数是不是虚函数基类的这个同名函数在派生类作用域里就被“遮住”了。哪怕你在派生类里写void print(int)基类的void print(string)也会被隐藏编译器不再自动匹配基类版本除非你用using Base::print;把基类版本提出来。隐藏之所以危险是因为它不报错但它会让调用行为变成“你以为调了基类版本实际上被拦截了”。比如class Base { public: virtual void run(); void stop(); }; class Derived : public Base { public: void run() override; // 覆盖 void stop(int x); // 隐藏了基类的 stop() };外部调用d.stop()会直接编译错误因为Derived里只有stop(int)。要想限制这种偶发状况建议在不同层级的接口中避免无谓的同名函数真要重载就把基类版本用using带回来。4.4 抽象类与普通类的区别本质上是接口契约一个包含纯虚函数的类就是抽象类class Shape { public: virtual double area() const 0; virtual ~Shape() default; };抽象类不能被实例化它只定义接口派生类必须实现所有纯虚函数才能创建对象。抽象类和普通类的区别不在于语法而在于它强制约束了派生类的行为契约所有具体图形都必须提供area()忘一个就编译失败不会留到运行时才崩。对于需要设计“接口”的场景C没有Java里interface这种独立关键字抽象类就当C的接口用。注意一点既然基类有多态行为析构函数一定要是虚函数。否则通过基类指针delete一个派生类对象时只会调用基类析构派生类的资源直接泄漏。这也是C面试老生常谈但实战里反复有人犯的错。5. 类和工程现场的“表达式必须包含类类型”之谜5.1 前向声明和include编译器知道多少你才能用多少搜索“C 类”的人里很大一部分是碰上了编译错误其中最常见的一类就是“表达式必须包含类类型”expression must have class type。这个错误的根因往往不是类写错了而是编译器在需要完整类型的地方只看到了不完整类型。所谓“完整类型”是指编译器知道这个类型的所有成员变量、成员函数的完整定义。前向声明只会告诉编译器“这个类存在”class Student; // 前向声明这种声明允许你定义指向该类型的指针或引用因为类指针只需要知道对象的大小吗不需要指针大小固定。但如果你试图在“只前向声明”的地方写student-getName()编译器就不知道getName是什么函数于是在这里报出“表达式必须包含类类型”或“指向不完整类型的指针”。这提醒我们头文件里能用前向声明就用前向声明别贪省事把每个依赖类的头文件全include进来。前向声明能编译得更快、依赖更少但如果你真要调用对方的方法、访问成员或创建对象就必须在对应的编译单元.cpp里包含完整的头文件。这个边界是每个C工程都要反复处理的平衡。5.2 为什么编译器说“必须包含类类型”“表达式必须包含类类型”最典型的触发场景是这样的class Test { public: void func(); }; void test() { Test* t new Test(); t.func(); // 错误t是指针不是对象 }t的类型是Test*访问成员应该用t-func()用点运算符就会得到“表达式必须包含类类型”。类似的变体还有期望函数返回对象但你写成了返回指针、把一个原本是对象的变量顺手存成了引用后忘了解引用、或者把对象的拷贝写成了对指针的拷贝。这类报错的本质是t这个表达式的类型不是“类类型”编译器没有可访问的成员。解决思路也比较简单先别急着改代码看一下报错位置的表达式到底是什么类型。如果是指针用-如果是引用或对象用.如果你在用一个容器迭代器却忘了解引用那it和*it也完全是两个类型。养成看报错信息里“表达式类型”的好习惯比死记错误码有用得多。5.3 RAII和智能指针让类自己处理资源聊到类和工程实践的配合绕不开一个词RAIIResource Acquisition Is Initialization。说人话就是资源在构造函数里拿在析构函数里还。一个负责锁的类class LockGuard { public: LockGuard(std::mutex mtx) : mtx_(mtx) { mtx_.lock(); } ~LockGuard() { mtx_.unlock(); } LockGuard(const LockGuard) delete; LockGuard operator(const LockGuard) delete; private: std::mutex mtx_; };只要这个对象在作用域中创建离开作用域时析构函数必然执行锁必然释放。不用你手动写unlock()也不会因为提前return或异常跳转而漏释放。用这个思路去管理堆内存就是智能指针。unique_ptr表示独占所有权不能拷贝只能移动shared_ptr表示共享所有权靠引用计数决定是否释放weak_ptr是shared_ptr的旁观者不增加引用计数常用来打破循环引用。写新代码时“裸new/delete”应该很少出现了类的构造和析构里也不该直接管理堆资源把资源交给这些RAII类型类自然就满足“零法则”省心又安全。6. 类这门功夫最终要在哪类项目里见真章6.1 游戏开发里C类和C#类的差异热搜里有“游戏开发C和C#的区别”这正好和类有关。C#的类默认是引用类型对象在托管堆上分配垃圾收集器帮你回收。C的类默认是值类型栈上创建作用域结束直接析构堆上创建要手动或靠智能指针管理生命周期。在设计思路上C#因为有GC随便new对象成本相对低类的粒度可以做得比较细。C则要考虑对象拷贝的成本、生命周期边界、是否放栈上还是堆上类设计会更“抠”。C侧常见的做法是尽量让类小而值化能用栈就用栈能用容器管理生命周期就让容器管避免到处传递裸指针。具体到游戏引擎常见的一种做法是组件模式Component实体本身不再用深继承树而是一个轻量的Entity对象挂载一堆组件组件之间通过消息通信。这样做的好处是避免“一个怪物类既要起身为敌人的逻辑又要继承飞行逻辑”这种多重继承地狱也让多态只出现在“组件接口”这一层类的职责足够单一。对于C这种设计还能减少跨DLL接口的虚函数调用频率性能上更可控。6.2 你的类设计能力怎么练我见过很多同学看完“类的六大特性”之后到真正写项目时依然不知道该怎么设计类。我的建议是务实地练两个方向第一是建模练习。每次看到现实世界的一个对象或业务流程强迫自己把它拆成类。比如设计一个餐厅点餐系统不要一上来就画一堆“顾客类”“菜品类”“订单类”把现实名词全部塞成类。先想清楚数据和行为的边界哪些数据是一个订单真正要记录的哪些行为是订单自己响应的哪些应该是外部服务处理的这种思考比会背“封装继承多态”的概念有用得多。第二是看真实代码的类组织方式。别只看语法书去GitHub上读那些精悍的开源项目看看别人的类为什么这么设计、构造函数为什么是explicit、为什么有些接口用纯虚类、为什么数据成员大多私有。代码里的设计意图比看书更能培养“类感”。C的类从起步到能写出地道的面向对象代码中间一定会经历很多次“为什么这里崩了”“为什么编译报错”的挣扎。上面这些内容不是让你背的是让你在踩坑的时候能往回找——找到是内存布局的问题、拷贝控制的问题还是虚函数调度的问题路就不会乱。
返回列表