从 struct 到 class:封装与访问控制的真正意义

文章目录

  • 引言
  • [一、C 的 struct:数据敞着门,全靠自觉](#一、C 的 struct:数据敞着门,全靠自觉)
    • [1.1 最基本的 struct 用法](#1.1 最基本的 struct 用法)
    • [1.2 C 的应对方案:命名约定](#1.2 C 的应对方案:命名约定)
    • [1.3 方案B:不透明指针(Opaque Pointer)](#1.3 方案B:不透明指针(Opaque Pointer))
  • [二、C++ 的答案:`private` 一把锁](#二、C++ 的答案:private 一把锁)
    • [2.1 第一版:把 C 代码直接翻译成 C++](#2.1 第一版:把 C 代码直接翻译成 C++)
    • [2.2 `class` vs `struct`:唯一的区别](#2.2 class vs struct:唯一的区别)
  • 三、封装的真正意义:不是"藏起来",而是"保护不变量"
  • [四、`public` 和 `private` 的完整规则](#四、publicprivate 的完整规则)
    • [4.1 访问权限速查](#4.1 访问权限速查)
    • [4.2 一个成员函数可以访问同类的其他对象的 private 成员](#4.2 一个成员函数可以访问同类的其他对象的 private 成员)
    • [4.3 声明顺序无所谓,访问标签可以多次出现](#4.3 声明顺序无所谓,访问标签可以多次出现)
  • [五、从 C 到 C++ 的封装演进:完整对照](#五、从 C 到 C++ 的封装演进:完整对照)
    • [阶段1:纯 C ------ 裸 struct + 独立函数](#阶段1:纯 C —— 裸 struct + 独立函数)
    • [阶段2:C 风格的不透明指针 ------ 强制封装但代价高](#阶段2:C 风格的不透明指针 —— 强制封装但代价高)
    • [阶段3:C++ class ------ 用最少代码获得最强保证](#阶段3:C++ class —— 用最少代码获得最强保证)
  • 总结

本系列为《C++深度修炼:基础、STL源码与多线程实战》第2篇

前置条件:理解 C 语言 struct 的基本用法,读过第1篇了解 C/C++ 的差异

引言

在 C 语言中,struct 只是一个数据的容器 。你可以把几个变量打包在一起,然后通过 .-> 访问它们------仅此而已。至于"哪些字段能改、哪些不能改"、"改了之后对象还合法吗",全靠程序员的自觉和命名约定来维持。

C++ 的 class 不是 struct 的简单改名。它在语言层面增加了一道编译器强制执行的边界------告诉所有代码:"这个成员是公开 API,那个成员是内部实现,碰它就报错。"

本文从 C 程序员最熟悉的 struct 出发,一步步展示为什么需要封装,C 是如何"模拟"封装的,以及 C++ 如何用一行代码解决 C 需要靠约定才能维持的东西。


一、C 的 struct:数据敞着门,全靠自觉

1.1 最基本的 struct 用法

cpp 复制代码
// demo_struct_basic.c
#include <stdio.h>
#include <string.h>

struct BankAccount {
    char owner[32];
    double balance;
};

int main() {
    struct BankAccount acc;
    strcpy(acc.owner, "张三");
    acc.balance = 1000.0;

    // 任何人都可以直接改 balance,没有任何阻拦
    acc.balance = -500.0;  // 余额变负数!银行不会允许,但C编译器不管

    printf("%s 的余额: %.2f\n", acc.owner, acc.balance);
    // 输出: 张三 的余额: -500.00 --- 业务逻辑被破坏
}
text 复制代码
$ gcc -std=c17 -Wall demo_struct_basic.c && ./a.out
张三 的余额: -500.00

没有任何编译错误,也没有运行时检查。balance 应该是"余额",但负数怎么可能是合法的余额?

1.2 C 的应对方案:命名约定

有经验的 C 程序员会用一套"约定"来弥补:

cpp 复制代码
// bank_account.h --- C风格封装,依赖约定
#ifndef BANK_ACCOUNT_H
#define BANK_ACCOUNT_H

// 方案A:命名约定 ------ 名字里带"私有"提示
struct BankAccount {
    char owner[32];
    double _private_balance;  // 靠命名警告:"别直接碰我"
};

// "公开API" 函数
void bank_account_init(struct BankAccount *acc, const char *owner, double initial);
int  bank_account_deposit(struct BankAccount *acc, double amount);
int  bank_account_withdraw(struct BankAccount *acc, double amount);
double bank_account_get_balance(const struct BankAccount *acc);

#endif
cpp 复制代码
// bank_account.c
#include "bank_account.h"
#include <string.h>

void bank_account_init(struct BankAccount *acc, const char *owner, double initial) {
    strncpy(acc->owner, owner, 31);
    acc->owner[31] = '\0';
    acc->_private_balance = initial >= 0 ? initial : 0;
}

int bank_account_deposit(struct BankAccount *acc, double amount) {
    if (amount <= 0) return -1;
    acc->_private_balance += amount;
    return 0;
}

int bank_account_withdraw(struct BankAccount *acc, double amount) {
    if (amount <= 0 || amount > acc->_private_balance) return -1;
    acc->_private_balance -= amount;
    return 0;
}

double bank_account_get_balance(const struct BankAccount *acc) {
    return acc->_private_balance;
}

这个方案在工程上能用 ,但它有一个致命缺陷:约定是给人读的,编译器不执行

cpp 复制代码
// 任何.c文件里,绕过API直接改:
acc._private_balance = -999999.0;  // 编译器:没问题啊,它就是 double

1.3 方案B:不透明指针(Opaque Pointer)

更高级的 C 封装手法是把结构体完全藏起来

cpp 复制代码
// bank_account_opaque.h --- 结构体定义不暴露给外部
typedef struct BankAccount BankAccount;  // 只声明类型,不暴露成员

BankAccount* bank_account_create(const char *owner, double initial);
void         bank_account_destroy(BankAccount *acc);
int          bank_account_deposit(BankAccount *acc, double amount);
int          bank_account_withdraw(BankAccount *acc, double amount);
double       bank_account_get_balance(const BankAccount *acc);
cpp 复制代码
// bank_account_opaque.c --- 只有这个.c文件知道结构体长什么样
#include "bank_account_opaque.h"
#include <stdlib.h>
#include <string.h>

struct BankAccount {  // 定义放在.c里
    char owner[32];
    double balance;
};

BankAccount* bank_account_create(const char *owner, double initial) {
    BankAccount *acc = malloc(sizeof(BankAccount));
    if (!acc) return NULL;
    strncpy(acc->owner, owner, 31);
    acc->owner[31] = '\0';
    acc->balance = initial >= 0 ? initial : 0;
    return acc;
}
// ... 其他函数实现类似

这下外部代码连结构体成员都看不到------真正做到了封装。但代价也很明显:

  • 对象必须分配在堆上(malloc),不能放栈上
  • 每个操作多一次指针解引用
  • 需要手动 destroy
  • 代码量翻倍------每个结构体要写一整套 create/destroy/accessor

二、C++ 的答案:private 一把锁

2.1 第一版:把 C 代码直接翻译成 C++

cpp 复制代码
// bank_account_v1.cpp
#include <iostream>
#include <cstring>

class BankAccount {
public:   // 公开接口
    void init(const char *owner, double initial) {
        strncpy(owner_, owner, 31);
        owner_[31] = '\0';
        balance_ = (initial >= 0) ? initial : 0;
    }

    int deposit(double amount) {
        if (amount <= 0) return -1;
        balance_ += amount;
        return 0;
    }

    int withdraw(double amount) {
        if (amount <= 0 || amount > balance_) return -1;
        balance_ -= amount;
        return 0;
    }

    double get_balance() const {
        return balance_;
    }

private:  // 内部数据------外部代码不能碰
    char owner_[32];
    double balance_;
};

int main() {
    BankAccount acc;
    acc.init("张三", 1000.0);

    // acc.balance_ = -500.0;  // ❌ 编译错误!'balance_' is private
    acc.deposit(500);
    acc.withdraw(200);
    std::cout << acc.get_balance() << '\n';  // 1300
}
text 复制代码
$ g++ -std=c++17 -Wall bank_account_v1.cpp
# 如果把注释的 acc.balance_ = -500.0 放开:
# error: 'double BankAccount::balance_' is private within this context

核心变化就两个关键词:

C 依赖 C++ 方案
命名约定 _private_xxx private: 标签,编译器强制执行
"大家都是讲规矩的人" "你不讲规矩,编译器直接报错"

2.2 class vs struct:唯一的区别

C++ 保留了 struct 关键字,它的功能和 class 几乎完全一样 ------唯一的区别是默认访问权限

cpp 复制代码
// 这两个是等价的:
class Point {
    int x, y;       // class:默认 private
};

struct Point {
    int x, y;       // struct:默认 public
};

// 写成下面这样就完全一样了:
class Point {
public:
    int x, y;
};

struct Point {
private:
    int x, y;
};

💡 工程惯例 :用 struct 当纯数据容器(所有字段 public,类似 C 的 POD),用 class 当有封装逻辑的对象。这是一条"给人类读者看的信号",编译器并不关心。


三、封装的真正意义:不是"藏起来",而是"保护不变量"

很多 C 程序员第一次看到 private,直觉反应是"把数据藏起来,不让别人看"。

这理解偏了。 封装的真正目的是保护不变量(Invariant)

BankAccount 来说,核心不变量只有一个:balance_ 永远不能小于 0

  • C 的做法:依赖每个调用者都不犯错误。一百个调用者里有一个忘记检查,不变量就破了。
  • C++ 的做法:能改 balance_ 的只有 deposit()withdraw(),它们各自保证了不变量。外面的代码想改也改不了------编译器替你挡住了。
cpp 复制代码
class Thermometer {
public:
    void set_celsius(double c) {
        celsius_ = c;
        if (celsius_ < -273.15) celsius_ = -273.15;  // 不变量:不低于绝对零度
    }

    double get_celsius() const { return celsius_; }

    // 不在 set_celsius 之外,没有任何办法把一个非法温度塞进去

private:
    double celsius_;
};

这就是封装的价值:不变量只需要在一个地方维护,而不是散布在整个代码库的每个调用点。


四、publicprivate 的完整规则

4.1 访问权限速查

cpp 复制代码
class Example {
public:     // 任何人可见
    int public_member;
    void public_method();

private:    // 只有本类的成员函数和友元可见
    int private_member_;
    void private_method_();

protected:  // 本类 + 派生类可见(下一篇讲继承时详谈)
    int protected_member_;
};

4.2 一个成员函数可以访问同类的其他对象的 private 成员

cpp 复制代码
class Point {
public:
    // 初始化列表见第3篇(构造与析构),此处关注访问规则
    Point(int x, int y) : x_(x), y_(y) {}

    // 可以访问 other 的 private 成员------因为 other 也是 Point
    double distance_to(const Point &other) const {
        int dx = x_ - other.x_;  // 访问 other.x_,合法!
        int dy = y_ - other.y_;  // 访问 other.y_,合法!
        return sqrt(dx * dx + dy * dy);
    }

private:
    int x_, y_;
};

这个规则背后的道理是:封装是类的边界,不是对象的边界。 同一个类的两个对象,"互相信任"。

4.3 声明顺序无所谓,访问标签可以多次出现

cpp 复制代码
class Widget {
public:
    int get_width() const;   // public 区段 1
private:
    int width_;
public:
    int get_height() const;  // public 区段 2 ------ 完全合法
private:
    int height_;
};

不过工程上通常把同一访问级别的内容放在一起,保持代码可读。


五、从 C 到 C++ 的封装演进:完整对照

让我们把一个简单的"矩形"结构体做一遍从 C 到 C++ 的完整演进:

阶段1:纯 C ------ 裸 struct + 独立函数

cpp 复制代码
// rect_v1.c
#include <stdio.h>

struct Rect {
    int x, y, w, h;
};

void rect_init(struct Rect *r, int x, int y, int w, int h) {
    r->x = x; r->y = y;
    r->w = w > 0 ? w : 0;
    r->h = h > 0 ? h : 0;
}

int rect_area(const struct Rect *r) {
    return r->w * r->h;
}

int main() {
    struct Rect r;
    rect_init(&r, 10, 20, 100, 50);

    r.w = -100;  // 鬼鬼祟祟改了个非法值,编译器不管
    printf("area = %d\n", rect_area(&r));  // -5000
    // 业务上 width 不可能是负数,但没有机制能保证
}

阶段2:C 风格的不透明指针 ------ 强制封装但代价高

cpp 复制代码
// rect_v2.h
typedef struct Rect Rect;
Rect* rect_create(int x, int y, int w, int h);
void  rect_destroy(Rect *r);
int   rect_area(const Rect *r);

// rect_v2.c
struct Rect { int x, y, w, h; };
// 外部完全看不到成员------但也意味着不能放栈上、必须手动管理内存

阶段3:C++ class ------ 用最少代码获得最强保证

cpp 复制代码
// rect_v3.cpp
#include <iostream>

class Rect {
public:
    Rect(int x, int y, int w, int h)
        : x_(x), y_(y), w_(w > 0 ? w : 0), h_(h > 0 ? h : 0) {}

    int area() const { return w_ * h_; }

    void set_width(int w) { w_ = w > 0 ? w : 0; }
    void set_height(int h) { h_ = h > 0 ? h : 0; }

    int width() const { return w_; }
    int height() const { return h_; }

private:
    int x_, y_, w_, h_;
};

int main() {
    Rect r(10, 20, 100, 50);  // 栈上分配,无需手动释放

    // r.w_ = -100;  // ❌ 编译错误
    r.set_width(-100);  // ✅ setter 内部做了守卫,width 实际被设为 0

    std::cout << "area = " << r.area() << '\n';  // 0
    // 不变量 intact:width 永远不会是负数
}
维度 C 裸 struct C 不透明指针 C++ class
栈上分配 ❌(必须 malloc)
封装强度 全靠约定 编译器强制 编译器强制
代码量 少(但脆弱) 中等
不变量保证
自动释放 ❌(手动 free) ✅(析构函数,下篇讲)

总结

封装不是"把变量藏起来不让人看",而是把不变量围起来,只留几条经过守卫的门

  • C 依赖的命名约定和不透明指针,本质上是在用人肉编译器的过程保证安全
  • C++ 的 private 让真正的编译器替你做了这件事------而且零运行时开销
  • structclass 的区别只是默认权限------用哪个是给读者发信号,不是给编译器发指令

从下一篇开始,我们将进入 C++ 最核心的机制之一:构造与析构函数------它们如何让对象从诞生的那一刻起就是合法的,又是如何在生命周期结束时自动打扫战场。这是 C 语言"手动挡"到 C++ "自动挡"的关键跃迁。


📝 动手练习

  1. 把你之前写过的一个 C 结构体改成 C++ class,给所有字段加上 private,写 setter/getter 保护不变量
  2. 写一个 class Temperature,存储开尔文温度。set_celsiusset_fahrenheit 自动转换为开尔文,确保温度不低于 0K
  3. 尝试在外部代码中访问 private 成员,仔细观察编译器的报错信息------这些信息就是你的"免费文档"
相关推荐
计算机安禾1 小时前
【c++面向对象编程】第22篇:输入输出运算符重载:<< 与 >> 的友元实现
java·前端·c++
北山有鸟2 小时前
解决香橙派没有适配ov13855的3A算法
linux·c++·相机·isp
故事和你912 小时前
洛谷-【图论2-1】树4
开发语言·数据结构·c++·算法·动态规划·图论
故事和你912 小时前
洛谷-【图论2-1】树1
开发语言·数据结构·c++·算法·深度优先·动态规划·图论
不会C语言的男孩2 小时前
C++ SLTL编程
java·开发语言·c++
码农-阿杰3 小时前
Java 线程等待唤醒机制深度解析:synchronized、ReentrantLock、LockSupport 底层实现对比
java·开发语言·c++
十五年专注C++开发3 小时前
TypePerf:Windows 命令行性能计数器工具(CPU利用率、内存利用率、GPU利用率等)
c++·windows·typeperf
王老师青少年编程3 小时前
csp信奥赛C++高频考点专项训练之字符串 --【字符串排序】:字符排序
c++·字符串·csp·高频考点·信奥赛·字符串排序·字符排序
杜子不疼.3 小时前
【 C++ AI 大模型接入 SDK】 - 日志模块
开发语言·javascript·c++