☰
C++深入讲解类与对象之OOP面向对象编程与封装
2026/10/21 14:59:22 网站建设 项目流程

前言


面向对象编程(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 成员、把多态接口的析构写成虚函数,这三点做到,封装才算真正落地。剩下的继承层次设计,应当服从接口稳定性,而不是为了复用几行代码强行抽象。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询