T.TAO
返回博客
/7 min read/Programming

C++ #5 对象与类

#C++#Programming#ComputerSystems

这篇笔记主要写一下 C++ 中类的定义、对象的生命周期,以及这些语言特性在内存层面对应的东西。

类就是带了访问控制的结构体

在 C++ 里,classstruct 唯一的语言学差别是默认访问权限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》

本系列文章

C++ 程序设计
  1. 01C++ #1 数据与内存
  2. 02C++ #2 Struct 和 Union
  3. 03C++ #3 指针与数组
  4. 04C++ #4 函数
  5. 05C++ #5 对象与类
  6. 06C++ #6 继承
  7. 07C++ #7 拷贝控制与运算符重载
  8. 08C++ #8 多态
  9. 09C++ #9 C++11 新特性
  10. 10C++ #10 C++14 新特性
  11. 11C++ #11 C++17 新特性