C++类和对象之封装详解

发布时间:2026/10/26 18:40:38
C++类和对象之封装详解

前言


封装(encapsulation)常被简化成「成员变量设为 private,再给每个变量配一对 getter/setter」。这不叫封装,只是把公有数据换了个写法,类的内部表示仍然暴露在接口上:调用方照样可以随时把余额改成负数、把年龄改成三百岁,类也无法保证自己始终处于合法状态。


封装的真正内容有三层。第一层是访问控制,用private、protected、public决定谁能看到什么;第二层是不变量(invariant),也就是「一个对象在任何时刻都必须满足的条件」,由成员函数负责维持;第三层是接口与实现的分离,调用方只依赖稳定的行为,不依赖内部数据结构,这样内部换成别的容器、换一种算法时,调用方不用改代码。


本文按这三层展开,先讲访问控制的实际规则,再讲如何用行为接口守住不变量,最后讲返回内部数据、友元、Pimpl 这些容易破坏封装的细节,并给出一个完整的账户类示例。示例以 C++17 为准。


一、访问控制与 class/struct 的区别


类体里可以出现三种访问说明符:public成员对所有人可见,protected成员对本类及派生类可见,private成员只对本类(以及声明为友元的函数或类)可见。访问控制是在编译期检查的,不影响对象布局,也不带来运行开销。


class和struct的唯一语言层面区别是默认访问级别:class的成员默认private,struct默认public;继承时默认的继承方式也同样不同。所以「struct 没有封装、class 才有」是错的,struct里完全可以写private:,也可以有成员函数、构造函数和虚函数。惯例上,只承载数据、没有不变量的聚合体用struct,需要维护不变量的类型用class,但这只是风格约定。


还有一个常见误区是以为private能防住一切。它对编译器可见的代码有效,但挡不住reinterpret_cast、指针运算、#define private public这类手段。封装的目标不是对抗恶意代码,而是让「正常写代码的人不容易误用」,并在误用时尽量在编译期报错。


二、封装的核心是维护不变量


不变量是对象在任何公开操作结束后都必须成立的条件。比如「余额永远不小于零」「矩形的宽高永远为正」「链表节点的next要么为空要么指向本链表中的节点」。一旦不变量被破坏,后续所有成员函数的假设都会失效,bug 会扩散到离原因很远的地方。


维护不变量靠三件事:



  1. 构造函数建立不变量,非法参数直接拒绝,不要构造出半成品对象。

  2. 每个修改状态的成员函数在入口校验参数,在出口保证不变量仍然成立。

  3. 不把内部表示以可写方式交出去,否则类无法在事后检查。


以账户为例,balance_设为private,只提供deposit和withdraw两个行为。withdraw先检查金额为正,再检查余额是否足够,两步都通过才修改。调用方无法写出「余额减到负数」这种状态,因为根本没有直接写余额的入口。


这也解释了为什么「给每个字段配 setter」是反模式。setBalance(int v)和公有字段没有本质区别,它把校验责任推给了调用方。正确的做法是按业务动作设计接口:不是「设置余额」,而是「存款」「取款」「转账」,每个动作自己负责校验和记账。


三、接口与实现的分离


接口是调用方能看到的东西:public 成员函数的签名、公有的类型别名、public 继承关系。实现是私有的数据成员、私有辅助函数、包含的头文件。分离得好,内部换个容器不需要重新编译调用方,二进制接口也更稳定。


最直接的做法是「不要返回内部数据的可写视图」。返回std::vector<int>&等于把整个容器交给调用方,对方可以clear()、可以push_back(),类完全失控。需要读取时返回const std::vector<int>&;需要让调用方遍历时提供begin/end(const 版)或者一个接受回调的forEach;确实要交出所有权时返回值拷贝。


友元(friend)是封装的有意破口。运算符重载、序列化函数、测试夹具有时确实需要访问私有成员,这时用友元比加一堆 public 访问器更干净。但友元关系写在类内部,一旦加多,说明接口设计可能缺了某个操作。经验法则是:能用公开成员函数实现的功能,就不要用友元;能写成非成员非友元函数的操作,就不要写成成员函数,因为非成员函数不会增加类的接口面积。


当私有实现需要包含大量头文件时,可以用 Pimpl(pointer to implementation)把实现藏到一个不完整类型后面。头文件里只留一个std::unique_ptr<Impl>,真正的数据成员定义在源文件中。代价是多一次指针间接访问,以及需要显式管理析构、拷贝和移动。


四、完整示例


下面的BankAccount把余额和户名设为私有,只通过行为接口修改,构造函数负责建立不变量,存款时检查溢出,取款时检查余额。


// 适用于 C++17
#include <iostream>
#include <limits>
#include <stdexcept>
#include <string>
#include <utility>

class BankAccount {
public:
BankAccount(std::string owner, long long cents)
: owner_(std::move(owner)), balance_(cents) {
if (owner_.empty()) {
throw std::invalid_argument("owner must not be empty");
}
if (cents < 0) {
throw std::invalid_argument("initial balance must not be negative");
}
}

const std::string& owner() const noexcept { return owner_; }
long long balanceCents() const noexcept { return balance_; }

void deposit(long long cents) {
if (cents <= 0) {
throw std::invalid_argument("deposit must be positive");
}
if (balance_ > std::numeric_limits<long long>::max() - cents) {
throw std::overflow_error("balance overflow");
}
balance_ += cents;
}

void withdraw(long long cents) {
if (cents <= 0) {
throw std::invalid_argument("withdraw must be positive");
}
if (cents > balance_) {
throw std::runtime_error("insufficient funds");
}
balance_ -= cents;
}

void transferTo(BankAccount& other, long long cents) {
withdraw(cents);
try {
other.deposit(cents);
} catch (...) {
balance_ += cents; // 回滚,保持两边总额不变
throw;
}
}

private:
std::string owner_;
long long balance_;
};

int main() {
BankAccount a("alice", 10000);
BankAccount b("bob", 500);

a.transferTo(b, 2500);

std::cout << a.owner() << ": " << a.balanceCents() << '\n';
std::cout << b.owner() << ": " << b.balanceCents() << '\n';

try {
a.withdraw(1000000);
} catch (const std::runtime_error& e) {
std::cout << "failed: " << e.what() << '\n';
}
return 0;
}

这段代码的要点如下。构造函数按值接收std::string再std::move到成员,避免多一次拷贝;参数校验放在成员初始化之后、函数体之内,一旦抛异常,已构造的成员会按逆序析构,不会泄漏。deposit在加法之前检查上界,因为long long的有符号溢出是未定义行为,不能等溢出发生后再判断。transferTo先取款再存款,如果存款因溢出失败,就把取出的金额加回去并重新抛出,保证两个账户的总额不变;这是最基本的异常安全处理,真实系统还需要事务或日志来应对进程中途崩溃。


owner()返回const std::string&而不是std::string&,调用方只能读不能改。如果返回可写引用,调用方就能把户名改成空串,构造函数里的校验形同虚设。balanceCents()按值返回long long,因为它是算术类型,拷贝成本可以忽略,按值返回还能避免调用方持有内部引用。


五、Pimpl 的骨架


当头文件不想暴露实现细节时,可以这样写:


// 适用于 C++17 —— Engine.h
#include <memory>

class Engine {
public:
Engine();
~Engine();
Engine(const Engine&) = delete;
Engine& operator=(const Engine&) = delete;
Engine(Engine&&) noexcept;
Engine& operator=(Engine&&) noexcept;

void run();

private:
struct Impl;
std::unique_ptr<Impl> impl_;
};

Impl只在这里声明,不定义;它的完整定义放在Engine.cpp里,源文件才包含那些沉重的头文件。析构函数必须在Engine.cpp中定义,因为编译器生成默认析构时需要Impl是完整类型,而头文件里它不是。拷贝操作被删除,移动操作显式声明并需要在源文件里定义,同样是因为移动unique_ptr会触发Impl的析构。


常见坑点


坑点一:把字段全设 private,却给每个字段配 getter/setter。


❌void setBalance(long long v) { balance_ = v; },调用方仍然可以随意设置非法值。


✅ 按业务动作设计接口,例如deposit、withdraw,由接口负责校验。


坑点二:getter 返回内部数据的非 const 引用。


❌std::vector<int>& items() { return items_; },调用方可以清空或重排。


✅const std::vector<int>& items() const,需要修改时提供专门的成员函数。


坑点三:构造函数不校验,留下半成品对象。


❌Account(std::string name, int age) : name_(name), age_(age) {},允许空名字和负年龄。


✅ 在构造函数体内检查参数,非法时抛异常,让对象要么完整存在、要么根本不存在。


坑点四:以为 struct 就没有封装。


❌ 认为只有class才能写private,把所有聚合体都改成class。


✅struct同样可以有访问说明符和成员函数,选择哪个只取决于默认访问级别和表达意图。


坑点五:友元加得太多。


❌ 每个需要读私有成员的函数都声明成friend,类的私有状态实际上全公开。


✅ 优先提供公开的只读接口;确实需要突破封装时,写成非成员非友元函数并只依赖公开接口。


坑点六:Pimpl 的析构函数写在头文件里。


❌ 头文件里写~Engine() = default;,而Impl在此处不完整,编译报错。


✅ 头文件只声明~Engine();,在源文件中Engine::~Engine() = default;。


坑点七:转账只做一半,异常时总额改变。


❌withdraw(cents); other.deposit(cents);,存款抛异常后钱凭空消失。


✅ 捕获异常并回滚已取出的金额,或者把整个操作放进事务。


总结




层面手段目的



访问控制private、protected、public限制可见范围

不变量构造校验、修改前检查、修改后保持对象始终合法

接口设计行为接口而非字段访问器调用方不依赖内部表示

数据暴露返回 const 引用或值防止外部绕过校验

实现隐藏Pimpl、源文件内定义降低编译依赖

友元少量、必要时使用有意识地破口



封装不是把字段藏起来就完事,而是让「对象始终处于合法状态」成为类自己的责任。判断一个类封装得好不好,可以看它的公开接口里有没有可以直接写入内部状态的入口,以及构造完成后是否还存在非法组合。把这两点守住,内部结构换成什么都不会影响调用方。