C++基础的三大特性之一:封装

封装是面向对象编程的第一块基石。它解决的核心问题是:如何把数据和对数据的操作绑在一起,并控制外部的访问权限。以下是我在职坐标的学习记录:

一、什么是封装

封装包含两层含义:

  1. 打包(Bundling):把一组相关的数据(属性)和操作这些数据的函数(方法)放在一起,形成一个「类(class)」。
  2. 隐藏(Hiding):把内部的实现细节藏起来,只对外暴露必要的接口。外部不需要知道、也不应该直接改动内部状态。

一个贴切的类比是电视机

  • 内部:电路板、显像管、电源模块(复杂且易损坏)→ 被外壳包住,用户看不到
  • 外部:遥控器上的几个按键(开机、换台、调音量)→ 用户只需要知道这些

如果电视机不封装,用户必须直接操作电路板才能换台——既麻烦又容易把机器弄坏。类也一样:暴露过多内部细节,调用者就会写出依赖实现细节的代码,一旦内部改动,所有调用方全部崩溃。

二、访问控制:public / private / protected

C++ 通过三个访问说明符实现「隐藏」:

访问说明符 类内部 派生类 类外部(普通函数、main)
public ✅ 可访问 ✅ 可访问 ✅ 可访问
protected ✅ 可访问 ✅ 可访问 ❌ 不可访问
private ✅ 可访问 ❌ 不可访问 ❌ 不可访问

注意class 的默认访问权限是 privatestruct 的默认访问权限是 public。这是 classstruct 在 C++ 中唯一的功能性区别。

2.1 一个反面例子:不封装

struct BankAccount_Bad {
    std::string owner;
    double balance;   // 余额完全暴露
};

int main() {
    BankAccount_Bad acc{"Alice", 1000.0};
    acc.balance = -99999.0;   // 余额被随意改成负数,没有任何约束
    acc.balance *= 100;       // 随意篡改
}

问题在于:数据的合法性完全依赖调用者的自觉。只要有一处代码写错,整个程序的状态就不可信了。

2.2 封装后的写法

#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>   // std::move

class BankAccount {
private:
    std::string owner_;     // 下划线后缀是常见的成员命名约定
    double balance_ = 0.0;

    // 内部辅助函数,外界完全不需要知道它的存在
    void validateAmount(double amount) const {
        if (amount <= 0) {
            throw std::invalid_argument("金额必须大于 0");
        }
    }

public:
    // 构造函数:对象诞生时就必须处于合法状态
    BankAccount(std::string owner, double initBalance)
        : owner_(std::move(owner)), balance_(initBalance) {
        if (initBalance < 0) throw std::invalid_argument("初始余额不能为负");
    }

    // 只读访问器(getter)—— const 修饰表示不修改对象
    const std::string& owner() const { return owner_; }
    double balance() const { return balance_; }

    // 行为接口:每一步都做合法性校验
    void deposit(double amount) {
        validateAmount(amount);
        balance_ += amount;
    }

    void withdraw(double amount) {
        validateAmount(amount);
        if (amount > balance_) {
            throw std::runtime_error("余额不足");
        }
        balance_ -= amount;
    }
};

int main() {
    BankAccount acc("Alice", 1000.0);
    acc.deposit(500);
    acc.withdraw(200);
    std::cout << acc.owner() << " 的余额: " << acc.balance() << '\n'; // Alice 的余额: 1300

    // acc.balance_ = -1;  // 编译错误:'balance_' is private
    // acc.withdraw(1e9);  // 运行时抛出 "余额不足"
}

现在余额只能通过与业务规则绑定的 deposit / withdraw 修改,非法状态在源头就被挡住了

三、封装的关键机制

3.1 构造函数与初始化列表

封装要求「对象一出生就是合法的」,因此构造函数是封装的重要一环。

class Point {
private:
    int x_, y_;
public:
    // ✅ 推荐:成员初始化列表(直接构造,效率更高)
    Point(int x, int y) : x_(x), y_(y) {}

    // ❌ 不推荐:先默认初始化再赋值,多一次操作;const 成员和引用成员无法这样写
    // Point(int x, int y) { x_ = x; y_ = y; }
};

初始化顺序陷阱:成员按声明顺序初始化,与初始化列表中的书写顺序无关。下面的写法会导致编译器告警(-Wreorder):

class Foo {
    int a_;  // 先声明,先初始化
    int b_;
public:
    Foo(int v) : b_(v), a_(b_) {}  // ⚠️ 实际先初始化 a_,此时 b_ 还未初始化 → 未定义行为
};

3.2 const 成员函数

成员函数后面加 const,表示「这个函数承诺不修改对象状态」:

double balance() const;   // const 对象也能调用
double balance();         // const 对象无法调用

规则很重要:只要函数不修改成员,就应该加 const。这不是洁癖——只有 const 成员函数才能被 const BankAccount& 类型的参数调用,漏写会导致接口不可用。

3.3 friend:有控制地开后门

有时确实需要让某个外部函数或类访问私有成员(例如运算符重载、单元测试)。用 friend 精确授权,而不是把成员改成 public

class Vec2 {
private:
    double x_, y_;
public:
    Vec2(double x, double y) : x_(x), y_(y) {}

    friend Vec2 operator+(const Vec2& a, const Vec2& b) {
        return {a.x_ + b.x_, a.y_ + b.y_};   // 友元函数可直接访问私有成员
    }
    friend std::ostream& operator<<(std::ostream& os, const Vec2& v) {
        return os << '(' << v.x_ << ", " << v.y_ << ')';
    }
};

friend 破坏了封装,应当克制使用。它适合运算符重载这类「逻辑上属于该类、但语法上必须在类外」的场景。

四、封装的价值:一个完整对比

假设需求变更——余额从 double 改为「以分为单位的整数」以避免浮点误差。

未封装版本:所有写 acc.balance 的地方(可能散落在几十个文件)全部要改,且 acc.balance = 1.5 这类代码会静默产生错误结果。

封装版本

class BankAccount {
private:
    long long cents_ = 0;   // 内部表示变了
public:
    // 对外接口完全不变,调用方零改动
    double balance() const { return cents_ / 100.0; }
    void deposit(double amount) { /* 转为分存储 */ }
};

这就是封装最实际的收益:内部实现可自由演化,外部契约保持稳定

五、小结

要点 说明
本质 数据 + 操作绑定,隐藏实现细节,暴露最小接口
手段 public / private / protected 访问控制
保障 构造函数建立类不变量;成员函数校验每一次状态变更
进阶 const 成员函数、mutablefriend、Pimpl
收益 内部实现可自由重构,调用方代码零改动;bug 被拦截在源头

下一步:封装解决了「单个类如何自我保护」,接下来看 继承 如何复用已有类的能力,以及 多态 如何让代码在运行时动态选择行为。

posted @ 2026-09-19 10:38  Solitude0420  阅读(1)  评论(0)    收藏  举报