这篇笔记主要写一下 C++ 中类的定义、对象的生命周期,以及这些语言特性在内存层面对应的东西。
类就是带了访问控制的结构体
在 C++ 里,class 和 struct 唯一的语言学差别是默认访问权限:struct 默认 public,class 默认 private。除此之外它们完全一样——都能有成员函数、继承、虚函数。
C++class Vector3 {
public:
Vector3(float x, float y, float z) : x_(x), y_(y), z_(z) {}
float length() const;
private:
float x_, y_, z_;
};
习惯上我们用 struct 表示"只是一堆数据放在一起",用 class 表示"有不变量需要维护"。编译器不在乎,但读代码的人在乎。
一个重要的事实:成员函数不占对象的空间。sizeof(Vector3) 是 12 字节,只有三个 float。成员函数被编译成普通函数,只是多了一个隐藏的第一参数——this 指针。上面的 length() 实际上更接近:
C++float Vector3_length(const Vector3* this);
这也解释了为什么在 const 成员函数里不能修改成员:const 修饰的其实是 this 指向的类型,this 变成了 const Vector3*。
构造函数
初始化列表不是语法糖
C++class Widget {
public:
Widget(const std::string& name) : name_(name) {} // 初始化
// Widget(const std::string& name) { name_ = name; } // 赋值
private:
std::string name_;
};
这两种写法有实质区别。所有成员在进入构造函数体之前就已经被构造完毕了。用初始化列表是直接调用拷贝构造;写在函数体里则是先默认构造一个空 string,再调用拷贝赋值——多了一次构造。
对于 const 成员和引用成员,函数体赋值根本不可能,只能用初始化列表。
还有一个坑:成员的初始化顺序由声明顺序决定,与初始化列表里写的顺序无关。
C++class Buffer {
public:
Buffer(size_t n) : size_(n), data_(new char[size_]) {} // 看起来没问题
private:
char* data_; // 先声明,所以先初始化
size_t size_; // 后声明
};
这段代码是错的。data_ 先被初始化,此时 size_ 还是未定义的值。把声明顺序和初始化顺序写成一致,是避免这类问题最省事的办法。
默认构造与显式构造
只要你自己写了任何构造函数,编译器就不再生成默认构造函数。需要的话可以要回来:
C++Widget() = default;
单参数构造函数会带来隐式转换,这往往不是你想要的:
C++class Buffer {
public:
Buffer(size_t n);
};
void consume(Buffer b);
consume(42); // 编译通过:42 被隐式转成了 Buffer(42)
加 explicit 就能挡住这种转换。经验规则是:单参数构造函数默认加 explicit,除非你确实想要这个转换(比如 std::string(const char*))。
析构函数与对象生命周期
析构函数在对象离开作用域时自动调用,顺序与构造相反(栈的语义)。
C++{
A a; // 1. a 构造
B b; // 2. b 构造
} // 3. b 析构 4. a 析构
这套"构造获取资源、析构释放资源"的机制就是 RAII(Resource Acquisition Is Initialization),是 C++ 相对于 C 最有价值的东西之一。文件句柄、互斥锁、内存,全都可以用它管理:
C++class FileHandle {
public:
explicit FileHandle(const char* path) : f_(std::fopen(path, "rb")) {}
~FileHandle() { if (f_) std::fclose(f_); }
private:
std::FILE* f_;
};
无论函数是正常返回还是抛出异常,析构函数都会被调用,文件一定会被关闭。C 里靠 goto cleanup 解决的问题,在这里由语言本身保证。
析构函数不要抛异常。 如果在栈展开(stack unwinding)过程中析构函数又抛出一个异常,程序会直接 std::terminate。C++11 起析构函数默认就是 noexcept。
static 成员
static 成员属于类而不是对象,所有实例共享一份。
C++class Counter {
public:
static int count; // 声明
static int get() { return count; } // 静态成员函数没有 this
};
int Counter::count = 0; // 定义,必须在类外(C++17 起可用 inline 避免)
C++17 允许 inline static int count = 0; 直接在类内定义,省掉那行别扭的类外定义。
静态成员函数没有 this,所以不能访问非静态成员。它的常见用途是工厂函数,以及作为需要函数指针的 C 接口的回调。
一点内存视角
理解类最好的方式是看它在内存里长什么样:
- 一个没有虚函数的类,其内存布局就是成员按声明顺序排列,加上对齐填充——和等价的
struct完全一致。 - 有虚函数以后,对象头部会多一个指向虚表的指针(64 位下 8 字节)。这就是为什么给一个只有一个
int的类加上虚函数,sizeof会从 4 跳到 16(8 字节 vtable 指针 + 4 字节 int + 4 字节填充)。 - 空类的
sizeof是 1,不是 0——因为两个不同的对象必须有不同的地址。
这些细节在写普通业务代码时无关紧要,但在设计需要紧密打包的数据结构时(粒子、顶点、组件数组),它们直接决定 cache 的行为。关于内存布局,可以回看《C++ #2 Struct 和 Union》。