news 2026/11/6 4:10:31

C++对象模型和this指针详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++对象模型和this指针详解

前言


「C++ 对象在内存里长什么样」是很多困惑的源头。为什么空类的大小是 1 而不是 0?为什么加了虚函数之后sizeof会变大?为什么成员函数里能直接写成员名,而静态函数里不行?这些问题的答案都指向同一套机制:对象模型与this指针。


常见的误解有两类。一类是把「标准规定的语义」和「某个编译器的实现」混为一谈,比如断言虚函数表一定在对象起始处、虚表指针一定是 8 字节。标准并没有规定多态对象的布局,只规定了可观察的行为;具体排布由 ABI(如 Itanium C++ ABI、MSVC ABI)决定。另一类是低估this的约束,比如在构造函数里把this交给别的线程、对空指针调用成员函数、或者随手写delete this。


本文先讲对象里到底存了什么、成员函数存在哪里,再讲this的类型与来源,最后讨论继承与虚函数对布局的影响,以及this的实战用法与禁忌。文中对「标准规定」与「实现定义 / 标准不保证」会明确区分。示例以 C++17 为准。


一、对象里到底存了什么


一个类类型的对象,其存储中至少包含:



  • 所有非静态数据成员(按某种顺序,可能带填充);

  • 可能的填充字节(padding),用于满足对齐要求;

  • 若类是多态类(含虚函数),可能包含指向虚函数表的指针(vptr)——这是实现细节,标准不要求,也不规定位置。


不包含在对象里的有:非静态成员函数(代码只有一份,存在代码段)、静态数据成员(存在静态存储区,所有对象共享)、静态成员函数、类型别名、嵌套类型定义。


// 适用于 C++17
#include <cstddef>
#include <iostream>

struct Empty {};

struct Pair {
int a;
int b;
};

struct WithStatic {
int value;
static int shared;
};

int WithStatic::shared = 0;

int main() {
std::cout << sizeof(Empty) << '\n'; // 标准要求至少为 1
std::cout << sizeof(Pair) << '\n'; // 通常为 8
std::cout << sizeof(WithStatic) << '\n'; // 通常为 4,static 不占对象空间
return 0;
}

sizeof(Empty)为 1 是标准要求:两个不同的空类对象必须有不同的地址,所以至少要占一个字节。WithStatic的大小只由int value决定,shared在静态区。至于Pair是否是 8,取决于int的大小与对齐,标准只保证sizeof(int) >= 1,具体值是实现定义的。


填充是另一个常被忽略的点。下面的结构体在典型 64 位平台上会有不同的sizeof:


// 适用于 C++17
struct A {
char c; // 1 字节
int i; // 4 字节,前面通常填充 3 字节
char d; // 1 字节,后面可能填充 3 字节
};

struct B {
int i;
char c;
char d; // 两个 char 相邻,尾部填充 2 字节
};

在常见 ABI 下sizeof(A)与sizeof(B)可能都是 12,但成员偏移不同。要减少浪费,可以把大对齐的成员放前面;不过这属于微优化,除非有明确的内存压力或缓存布局需求,否则不应以可读性为代价重排字段。标准只规定了alignof与填充的存在,不规定具体填充多少。


需要按字节访问对象表示时,可以用unsigned char或std::byte的指针;用其他类型的指针「重新解释」同一块内存并访问,容易违反严格别名规则(strict aliasing),属于 UB。std::memcpy是安全的手段。


二、成员函数在哪里,this从哪来


非静态成员函数在概念上有一个隐藏参数:指向调用对象的指针。编译器把


// 适用于 C++17
struct Point {
int x;
int y;
int sum() const { return x + y; }
};

int main() {
Point p{1, 2};
int s = p.sum();
return s;
}

大致翻译成「一个接收const Point*的自由函数」,p.sum()相当于把&p作为隐藏实参传入。函数体里的x实际是this->x。这也解释了为什么成员函数不占对象空间:它只是带了一个额外参数的普通函数。


几点直接推论:



  1. 成员函数只有一份代码,所有对象共用。对象里存的是数据,不是函数。

  2. 静态成员函数没有这个隐藏参数,所以没有this,也就不能直接访问非静态成员。

  3. 成员函数指针不是普通函数指针。int (Point::*)() const需要绑定对象才能调用,且大小与实现有关。

  4. 对空指针调用成员函数是 UB,即使函数体没有解引用任何成员。因为「通过空指针调用成员函数」本身就违反了成员访问的前提。


// 适用于 C++17
Point* p = nullptr;
// p->sum(); // UB,不是「碰巧能跑」

有些编译器会把不访问成员的成员函数优化成不依赖this,于是「看起来能跑」,但这只是未定义行为的一种表现形式,不是保证。


三、this的类型与const成员函数


在非静态成员函数中,this是一个指向当前对象的指针。它的类型随成员函数的const、volatile限定而变化:




成员函数形式this的类型(概念上)



void f()X*

void f() constconst X*

void f() volatilevolatile X*

void f() const volatileconst volatile X*



this本身不可赋值(它是 prvalue,C++11 起明确为右值),不能写this = other;。但*this是对象本身,可以返回引用、可以赋值。


const成员函数里的this是const X*,因此不能修改非mutable的数据成员。mutable允许在const成员函数里修改特定成员,常用于缓存、互斥量、调试计数:


// 适用于 C++17
#include <string>

class Cache {
public:
const std::string& get() const {
if (!ready_) {
value_ = "computed";
ready_ = true; // mutable 成员可以在 const 函数里改
}
return value_;
}
private:
mutable bool ready_ = false;
mutable std::string value_;
};

注意这里的线程安全问题:mutable只是绕过了编译期的const检查,不提供任何同步。多个线程同时调用get()会产生数据竞争,属于 UB。需要并发访问时,应加互斥量(互斥量本身通常声明为mutable std::mutex),或用std::once_flag。


还有一个细节:const对象只能调用const成员函数;非const对象两个版本都能调用,重载时优先选非const版本。这就是为什么容器常常提供begin()与begin() const两个重载。


this在构造函数和析构函数中同样存在,但此时对象的动态类型处于「构造中 / 析构中」状态。在基类构造函数里调用虚函数,不会派发到派生类的重写版本——因为派生部分尚未构造完成。这是标准明确规定的行为,不是编译器缺陷。析构函数同理,从派生到基类逐层析构时,虚调用只会落到当前层及更上层。


四、继承与虚函数对布局的影响


单继承、无虚函数时,派生类对象通常就是「基类子对象 + 派生类新增成员」,基类子对象位于起始处。但这同样是实现约定,标准只要求通过基类指针访问基类子对象时行为正确。


引入虚函数后,实现通常会在多态对象中放一个 vptr,指向该类的虚函数表。由此产生几个常见现象:



  • 含虚函数的类,sizeof通常比同数据成员的普通类大一个指针的宽度;

  • 多重继承下,派生类对象中可能出现多个基类子对象,指针在转换时可能需要 adjusted this 调整;

  • 虚继承下,虚基类子对象的位置通常通过额外的偏移信息定位,布局更复杂。


// 适用于 C++17
#include <iostream>

struct Base {
virtual ~Base() = default;
virtual void speak() const { std::cout << "Base\n"; }
int b = 1;
};

struct Derived : Base {
void speak() const override { std::cout << "Derived\n"; }
int d = 2;
};

int main() {
Derived obj;
Base& ref = obj;
ref.speak(); // 动态派发:Derived
std::cout << sizeof(Base) << ' ' << sizeof(Derived) << '\n';
return 0;
}

sizeof(Base)在不同平台上可能是 16(8 字节 vptr + 4 字节 int + 4 字节填充),也可能不同;sizeof(Derived)通常再加 4 字节并补齐。不要把这些数字写进依赖布局的逻辑里。标准没有规定 vptr 的存在、位置与大小,也没有规定虚函数表的组织方式。


如果确实需要跨语言或跨编译器共享内存布局,必须使用standard-layout类型并遵守相关约束(所有非静态数据成员同访问控制、无虚函数、无虚基类等),此时才可以用offsetof取成员偏移。对非 standard-layout 类型使用offsetof是条件支持的,可移植性没有保证。


指针在继承层次间转换时,编译器可能调整地址:


// 适用于 C++17
Derived d;
Base* pb = &d; // 可能等于 &d,也可能偏移
Derived* pd = static_cast<Derived*>(pb); // 正确还原

因此,不要假设「基类指针的数值等于派生类对象的地址」,也不要用reinterpret_cast在类层次间强转——那会绕过必要的地址调整,属于 UB。需要向下转换时用dynamic_cast(要求多态类型),或确认安全后用static_cast。


五、this的实战用法与禁忌


最常见的正当用法是链式调用:成员函数返回*this的引用,从而可以连续调用。


// 适用于 C++17
#include <string>
#include <utility>

class QueryBuilder {
public:
QueryBuilder& table(std::string name) {
table_ = std::move(name);
return *this;
}

QueryBuilder& where(std::string clause) {
where_ = std::move(clause);
return *this;
}

std::string build() const {
std::string sql = "SELECT * FROM " + table_;
if (!where_.empty()) {
sql += " WHERE " + where_;
}
return sql;
}

private:
std::string table_;
std::string where_;
};

int main() {
QueryBuilder qb;
std::string sql = qb.table("users").where("id > 0").build();
return sql.empty() ? 1 : 0;
}

返回*this的引用而不是值,避免了每次链式调用都拷贝整个对象;返回类型写QueryBuilder&而非QueryBuilder,才能继续在同一个对象上调用。若对象是临时量,链式调用结束后临时对象销毁,这也是安全的,因为整条表达式结束前它一直存活。


另一个常见模式是 CRTP(奇异递归模板模式),用this做静态多态:


// 适用于 C++17
#include <iostream>

template <typename Derived>
class Shape {
public:
void describe() const {
static_cast<const Derived*>(this)->draw();
}
};

class Circle : public Shape<Circle> {
public:
void draw() const { std::cout << "circle\n"; }
};

int main() {
Circle c;
c.describe();
return 0;
}

static_cast<const Derived*>(this)把基类指针还原为派生类指针,不发生运行期检查;如果Derived写错,就是 UB。CRTP 省掉了虚函数调用的间接性,但代价是编译期耦合和错误信息变长。


必须警惕的禁忌:


其一,delete this。语法上合法,但要求对象是用new单独分配的、之后不再访问任何成员、且调用方知道对象已销毁。它在引用计数实现里偶尔出现,但在普通业务代码中几乎总是设计错误的信号。若析构函数是private,delete this需要类内可访问的删除方式,更增复杂度。


其二,在构造函数里泄漏this。把this注册到全局回调、放进容器或交给另一个线程,会让其他代码看到一个「尚未构造完成」的对象。若此时发生虚调用,只会派发到当前构造层;若另一线程访问成员,可能与构造过程产生数据竞争。正确做法是构造完成后通过单独的start()或工厂函数发布对象。


其三,在对象销毁后继续使用this。典型场景是异步回调捕获了this,而对象先被销毁。解决办法是std::enable_shared_from_this配合weak_ptr,或显式取消注册,而不是假设回调一定在对象生命周期内触发。


其四,依赖this的地址做身份判断。对象可能被移动(如果类型可移动),地址会变;把this当唯一 ID 存起来,移动后就失效了。


常见坑点



  1. ❌ 认为成员函数占用每个对象的空间。✅ 成员函数代码只有一份,对象只存非静态数据成员与可能的 vptr。

  2. ❌ 对空指针调用不访问成员的成员函数,认为「不会崩就没事」。✅ 通过空指针调用成员函数是 UB,与函数体是否解引用无关。

  3. ❌ 在const成员函数里直接修改普通成员。✅ 用mutable明确标注可变的缓存/同步成员,并自行保证线程安全。

  4. ❌ 假设虚函数表指针在对象起始位置、大小固定。✅ 布局由 ABI 决定,标准不保证;跨边界传递对象时用序列化或稳定的 C 接口。

  5. ❌ 用reinterpret_cast在基类与派生类指针间转换。✅ 用static_cast(确认类型)或dynamic_cast(多态类型、运行期检查)。

  6. ❌ 在基类构造函数里调用虚函数,期望派发到派生类。✅ 构造期间动态类型是当前层,需在构造完成后调用。

  7. ❌ 把this交给异步任务却不管理生命周期。✅ 用shared_from_this+weak_ptr,或在析构时取消回调。

  8. ❌ 在普通业务代码里写delete this。✅ 用智能指针与 RAII 管理生命周期,删除职责交给所有者。


总结




主题标准层面实现层面



对象内容非静态数据成员 + 可能的填充具体偏移由 ABI 决定

成员函数不占对象空间,隐含this参数调用约定由 ABI 决定

空类大小至少 1通常就是 1

this类型随成员函数const限定变化通常是隐藏的第一参数

虚函数机制只规定可观察行为vptr/vtable 是实现细节

布局假设standard-layout才有可移植保证其余不可跨编译器依赖

构造函数中的虚调用只派发到当前构造层各编译器一致遵循



对象模型的价值不在于记住「vptr 在开头」这类实现细节,而在于理解三条边界:数据在对象里、代码在对象外、布局由 ABI 而非语言保证。this则是这套模型在成员函数里的投影——它让成员访问成为可能,也带来生命周期与空指针的约束。把「标准保证什么」和「这台机器上恰好怎样」分开,代码才能在换编译器、换平台后仍然正确。




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

AI驱动软件测试智能化转型的技术实践

1. 测试行业的技术变革趋势过去三年间&#xff0c;软件测试领域正在经历前所未有的智能化转型。根据2023年全球质量报告显示&#xff0c;78%的头部企业已将AI技术纳入测试流程&#xff0c;而这一比例在中小型企业中也达到了43%。测试工程师的工作内容已经从传统的手工用例执行&…

作者头像 李华
网站建设 2026/11/6 4:08:36

数组最值查找的7种实现与性能优化实战

1. 数组最值查找的核心逻辑与实现价值在编程实践中&#xff0c;数组最值查找是最基础却高频使用的操作之一。无论是数据分析中的极值统计、游戏开发中的分数排行&#xff0c;还是算法优化中的边界条件判断&#xff0c;都离不开高效的最值查找方法。传统教科书往往只给出基础实现…

作者头像 李华
网站建设 2026/11/6 4:07:55

水库抽样算法:链表随机节点的优化解决方案

1. 链表随机节点问题解析这道来自力扣101的算法题看似简单&#xff0c;却蕴含着几个非常有意思的计算机科学概念。题目要求我们设计一个数据结构&#xff0c;能够从链表中随机返回一个节点&#xff0c;且每个节点被选中的概率必须完全相同。我第一次接触这个问题时&#xff0c;…

作者头像 李华
网站建设 2026/11/6 4:07:49

Redis listpack数据结构解析与性能优化

1. Redis listpack 模块解析概述在Redis 6.2源码中&#xff0c;listpack.c文件实现了一种高效紧凑的列表结构。与传统的ziplist相比&#xff0c;listpack在内存布局和操作效率上都有显著优化。本文将深入剖析该文件0-534行的核心实现&#xff0c;重点解读其内存结构设计、基础A…

作者头像 李华
网站建设 2026/11/6 4:07:21

朴素贝叶斯与SVM:垃圾邮件识别的经典算法与工程实践

简介&#xff1a;面向机器学习入门与文本分类实战场景&#xff0c;这份资源以朴素贝叶斯与支持向量机&#xff08;SVM&#xff09;两类经典分类算法为核心&#xff0c;构建了一套完整的垃圾邮件识别系统&#xff0c;适合希望把分类算法从理论落到代码的学习者与课程设计者参考。…

作者头像 李华
网站建设 2026/11/6 4:07:21

Flink+Hologres构建实时数仓的核心技术与实践

1. 实时数仓的现代解法去年双十一大促期间&#xff0c;某电商平台的实时大屏突然卡死&#xff0c;技术团队花了40分钟才恢复。事后复盘发现&#xff0c;传统Lambda架构下的实时计算层和离线数仓之间存在至少15分钟的数据延迟&#xff0c;导致大促峰值流量下的实时决策完全失效。…

作者头像 李华