前言
面向对象编程(object-oriented programming,OOP)在 C++ 中落地为三个相互关联的机制:封装(encapsulation)、继承(inheritance)和多态(polymorphism)。其中封装最容易被讲成「把成员变量写成 private,再给每个变量配一对 get/set」,这只是形式,不是封装的目的。封装的真正含义是:由类自己维护一组不变式(invariant),外界只能通过类提供的操作来改变状态,从而保证对象在任何时刻都是合法的。
另一个常见误解是「class 和 struct 只有默认访问权限的区别,所以随便用」。从语言规则看确实如此——class默认私有继承、默认私有成员,struct默认公有——但工程上它们传达了不同的意图:struct常用于聚合数据,成员之间没有不变式;class用于有行为、有约束的抽象类型。混用不会报错,却会让读者误判类型的设计意图。
本文从类与对象的内存模型讲起,说明访问控制、构造函数、const成员函数与静态成员如何共同支撑封装,并给出一个带不变式的完整示例。最后简要交代封装与继承、多态的关系,以及多态基类为什么必须虚析构。代码以 C++17 为基准。
一、类与对象的基本模型
对象是「一块具有类型和生命周期的存储」。类的非静态数据成员按声明顺序排列在对象内部(具体布局受访问控制、虚函数、对齐影响,标准只对标准布局类型给出较强保证),而成员函数不占用对象空间——它们在代码段中只有一份,调用时通过隐式的this指针知道操作的是哪个对象。
this的类型在普通成员函数中是ClassName*,在const成员函数中是const ClassName*。这解释了两件事:为什么const成员函数不能修改非mutable的数据成员;为什么在const成员函数里调用非const成员函数会编译失败。this本身是右值表达式,不能对this赋值,但可以解引用后修改它指向的对象(在非 const 成员函数中)。
访问控制有三个关键字,还有继承语境下的第四种组合:
| 写法 | 谁能访问 |
|---|
public | 任何代码 |
protected | 本类及派生类的成员、友元 |
private | 仅本类成员与友元 |
默认(class) | private |
默认(struct) | public |
访问控制发生在编译期,是接口约束而非运行时保护。它不影响布局,也不提供任何安全性——通过指针运算或reinterpret_cast绕过访问控制属于未定义行为,编译器不会阻止你写出这种代码,但程序不再受标准保护。
对象生命周期的起点是构造函数完成,终点是析构函数开始。在构造函数体执行之前,成员已经按声明顺序完成初始化;在析构函数体执行之后,成员按相反顺序销毁。理解这个顺序,是理解 RAII 和「零规则」的基础。
二、封装:用接口维护不变式
封装的落地方式是「私有数据 + 公有操作」,但判断封装是否成立,标准不是有没有 getter,而是:能否从类外部把对象置于非法状态。如果每个成员都有setBalance,调用方可以随意设成负数,那么不变式形同虚设,封装只是把字段改名成了函数。
下面这个例子把「余额不得为负」作为不变式,所有修改都必须经过会检查参数的操作:
// 适用于 C++17
#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>
class BankAccount {
public:
BankAccount(std::string owner, double initial)
: owner_(std::move(owner)), balance_(initial) {
if (initial < 0.0) {
throw std::invalid_argument("initial balance must not be negative");
}
}
const std::string& owner() const noexcept {
return owner_;
}
double balance() const noexcept {
return balance_;
}
void deposit(double amount) {
if (amount <= 0.0) {
throw std::invalid_argument("deposit amount must be positive");
}
balance_ += amount;
}
void withdraw(double amount) {
if (amount <= 0.0) {
throw std::invalid_argument("withdraw amount must be positive");
}
if (amount > balance_) {
throw std::runtime_error("insufficient balance");
}
balance_ -= amount;
}
private:
std::string owner_;
double balance_{0.0};
};
int main() {
BankAccount account("Alice", 100.0);
account.deposit(50.0);
account.withdraw(30.0);
std::cout << account.owner() << ": " << account.balance() << '\n';
return 0;
}几个设计点值得说明。构造函数在初始化列表中完成成员构造,函数体只做校验;如果校验失败抛出异常,对象不会被构造,析构函数也不会执行,这是安全的。owner()返回const std::string&而不是值,避免了拷贝,同时const引用阻止调用方修改内部字符串;前提是调用方不能长期保存这个引用——对象销毁后引用即悬垂,这是接口使用者的责任,文档应写清楚。balance()按值返回double,因为内置类型拷贝成本可以忽略,返回值也不会暴露内部状态。
const成员函数在这里不仅是风格:它让const BankAccount&也能读取余额,使接口在常量语境下可用。成员函数只要不修改对象状态,就应加const;noexcept用于承诺不抛异常的函数,标准库容器在扩容搬移元素时会参考这个属性。
如果确实需要暴露内部容器的只读视图,返回const std::vector<T>&是可以的;若要允许修改,更好的做法是提供语义明确的操作(如addItem、removeItem),而不是返回非常量引用让调用方随意改。后者等于把不变式的维护责任交给了外部。
三、构造函数、特殊成员与静态成员
构造函数负责建立不变式,因此它的写法直接影响类的正确性。成员初始化顺序由声明顺序决定,与初始化列表的书写顺序无关;如果两者不一致,编译器可能给出警告,但不会改变实际顺序。若一个成员依赖另一个成员初始化,必须保证被依赖者先声明,否则读到的是未初始化值。
// 适用于 C++17
class Buffer {
public:
explicit Buffer(std::size_t size)
: size_(size), data_(size, 0) {} // 先 size_ 后 data_,与声明顺序一致
std::size_t size() const noexcept { return size_; }
private:
std::size_t size_;
std::vector<int> data_;
};explicit阻止单参数构造函数参与隐式转换。没有它,Buffer b = 10;这样的代码会意外通过,读起来像是赋值,实际是构造。对于接受一个实参的构造函数,除非确实需要隐式转换(如std::string接受const char*),否则都应加explicit。
特殊成员函数包括默认构造、析构、拷贝构造、拷贝赋值、移动构造、移动赋值。C++11 起可以用= default和= delete精确控制。工程上推荐「零规则」:让成员自己管理资源(std::string、std::vector、std::unique_ptr),类就不需要手写析构、拷贝和移动,编译器生成的版本已经正确。反之,一旦手写了析构函数,往往意味着你持有裸资源,此时要按「规则五」同时考虑拷贝与移动,否则容易出现双重释放或浅拷贝。
静态成员属于类而不属于对象。静态数据成员在类内声明、在类外定义(C++17 起可以用inline static在类内定义,避免单独写定义),静态成员函数没有this,因此不能访问非静态成员。它们适合放与类型相关但不依赖具体对象的常量或工具函数。
| 成员种类 | 是否占对象空间 | 是否有this | 典型用途 |
|---|
| 非静态数据成员 | 是 | — | 对象状态 |
| 静态数据成员 | 否 | — | 类级共享数据 |
| 非静态成员函数 | 否 | 是 | 操作对象 |
| 静态成员函数 | 否 | 否 | 与对象无关的工具 |
| 虚函数 | 否(但有虚表指针开销) | 是 | 运行期多态 |
四、从封装到继承与多态
继承表达「is-a」关系,派生类在基类接口之上扩展。封装在继承语境下多了一层含义:private成员对派生类也不可见,派生类只能通过基类的protected或public接口协作。把数据成员设为protected会削弱封装,因为它把维护不变式的责任扩散到了所有派生类;更稳妥的做法是保持数据私有,提供受保护的钩子函数。
多态依赖虚函数。基类中声明virtual,派生类用override标注(C++11),让编译器检查签名是否真的覆盖。通过基类指针或引用调用时,实际执行派生类版本;按值传递则发生对象切片(slicing),派生部分被丢弃。
最关键的一条规则是:多态基类的析构函数必须是virtual。否则通过基类指针delete派生类对象时,只调用基类析构函数,派生类的资源不会释放,标准规定这是未定义行为。若基类不打算被多态删除,可以把析构函数设为protected且非虚,从语言层面阻止这种用法。
// 适用于 C++17
#include <iostream>
#include <memory>
#include <string>
#include <vector>
class Shape {
public:
virtual ~Shape() = default;
virtual double area() const = 0;
virtual std::string name() const = 0;
};
class Rectangle : public Shape {
public:
Rectangle(double w, double h) : width_(w), height_(h) {}
double area() const override { return width_ * height_; }
std::string name() const override { return "rectangle"; }
private:
double width_;
double height_;
};
int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Rectangle>(2.0, 3.0));
for (const auto& shape : shapes) {
std::cout << shape->name() << " " << shape->area() << '\n';
}
return 0;
}这里用std::unique_ptr<Shape>管理对象,析构时通过虚析构正确释放派生类;用容器存值则会发生切片。= 0的纯虚函数让Shape成为抽象类,不能直接实例化,只能作为接口使用。
常见坑点
❌ 所有成员都提供setXxx,外部可以随意改状态。 ✅ 只暴露有语义的操作,在操作内部校验参数并维护不变式。
❌ 构造函数里先默认构造成员,再在函数体中赋值。 ✅ 用初始化列表直接构造;注意初始化顺序由声明顺序决定。
❌ 单参数构造函数不加explicit,导致意外的隐式转换。 ✅ 除非有意支持隐式转换,否则一律写explicit。
❌ 返回内部成员的非常量引用,让调用方绕过校验修改状态。 ✅ 返回const引用或按值返回;需要修改时提供专门的操作函数。
❌ 该加const的成员函数不加,导致const对象无法调用。 ✅ 不修改对象状态的成员函数都加const,并考虑是否需要noexcept。
❌ 多态基类析构函数不写virtual,却通过基类指针delete。 ✅ 多态基类写virtual ~Base() = default;,派生类用override。
❌ 以为private能阻止所有访问,或用指针运算绕过访问控制。 ✅ 把访问控制当编译期接口约束,不当作安全机制;需要严格隔离时用接口与实现分离。
❌ 手写析构函数管理裸new,却没有配套的拷贝/移动语义。 ✅ 优先「零规则」,用 RAII 成员;必须手写时按规则五处理拷贝与移动。
总结
| 概念 | 要点 |
|---|
| 封装 | 私有数据加受控操作,核心是维护不变式 |
| 对象模型 | 数据成员占对象空间,成员函数共享,this区分对象 |
| 构造 | 初始化列表按声明顺序执行,构造完成才算对象诞生 |
const成员函数 | 承诺不修改状态,使接口可用于常量对象 |
| 特殊成员 | 优先零规则,让标准库类型管理资源 |
| 继承与多态 | 表达 is-a;多态基类必须虚析构,接口用指针或引用传递 |
类设计的判断标准不是「有没有用 private」,而是「能否从外部破坏对象的不变式」。把校验收进操作、把资源交给 RAII 成员、把多态接口的析构写成虚函数,这三点做到,封装才算真正落地。剩下的继承层次设计,应当服从接口稳定性,而不是为了复用几行代码强行抽象。