前言
「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。这也解释了为什么成员函数不占对象空间:它只是带了一个额外参数的普通函数。
几点直接推论:
- 成员函数只有一份代码,所有对象共用。对象里存的是数据,不是函数。
- 静态成员函数没有这个隐藏参数,所以没有
this,也就不能直接访问非静态成员。 - 成员函数指针不是普通函数指针。
int (Point::*)() const需要绑定对象才能调用,且大小与实现有关。 - 对空指针调用成员函数是 UB,即使函数体没有解引用任何成员。因为「通过空指针调用成员函数」本身就违反了成员访问的前提。
// 适用于 C++17
Point* p = nullptr;
// p->sum(); // UB,不是「碰巧能跑」有些编译器会把不访问成员的成员函数优化成不依赖this,于是「看起来能跑」,但这只是未定义行为的一种表现形式,不是保证。
三、this的类型与const成员函数
在非静态成员函数中,this是一个指向当前对象的指针。它的类型随成员函数的const、volatile限定而变化:
| 成员函数形式 | this的类型(概念上) |
|---|
void f() | X* |
void f() const | const X* |
void f() volatile | volatile X* |
void f() const volatile | const 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 存起来,移动后就失效了。
常见坑点
- ❌ 认为成员函数占用每个对象的空间。✅ 成员函数代码只有一份,对象只存非静态数据成员与可能的 vptr。
- ❌ 对空指针调用不访问成员的成员函数,认为「不会崩就没事」。✅ 通过空指针调用成员函数是 UB,与函数体是否解引用无关。
- ❌ 在
const成员函数里直接修改普通成员。✅ 用mutable明确标注可变的缓存/同步成员,并自行保证线程安全。 - ❌ 假设虚函数表指针在对象起始位置、大小固定。✅ 布局由 ABI 决定,标准不保证;跨边界传递对象时用序列化或稳定的 C 接口。
- ❌ 用
reinterpret_cast在基类与派生类指针间转换。✅ 用static_cast(确认类型)或dynamic_cast(多态类型、运行期检查)。 - ❌ 在基类构造函数里调用虚函数,期望派发到派生类。✅ 构造期间动态类型是当前层,需在构造完成后调用。
- ❌ 把
this交给异步任务却不管理生命周期。✅ 用shared_from_this+weak_ptr,或在析构时取消回调。 - ❌ 在普通业务代码里写
delete this。✅ 用智能指针与 RAII 管理生命周期,删除职责交给所有者。
总结
| 主题 | 标准层面 | 实现层面 |
|---|
| 对象内容 | 非静态数据成员 + 可能的填充 | 具体偏移由 ABI 决定 |
| 成员函数 | 不占对象空间,隐含this参数 | 调用约定由 ABI 决定 |
| 空类大小 | 至少 1 | 通常就是 1 |
this类型 | 随成员函数const限定变化 | 通常是隐藏的第一参数 |
| 虚函数机制 | 只规定可观察行为 | vptr/vtable 是实现细节 |
| 布局假设 | standard-layout才有可移植保证 | 其余不可跨编译器依赖 |
| 构造函数中的虚调用 | 只派发到当前构造层 | 各编译器一致遵循 |
对象模型的价值不在于记住「vptr 在开头」这类实现细节,而在于理解三条边界:数据在对象里、代码在对象外、布局由 ABI 而非语言保证。this则是这套模型在成员函数里的投影——它让成员访问成为可能,也带来生命周期与空指针的约束。把「标准保证什么」和「这台机器上恰好怎样」分开,代码才能在换编译器、换平台后仍然正确。