文章目录
- 引言
- [一、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
classvsstruct:唯一的区别)
- 三、封装的真正意义:不是"藏起来",而是"保护不变量"
- [四、`public` 和 `private` 的完整规则](#四、
public和private的完整规则) -
- [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_;
};
这就是封装的价值:不变量只需要在一个地方维护,而不是散布在整个代码库的每个调用点。
四、public 和 private 的完整规则
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让真正的编译器替你做了这件事------而且零运行时开销 struct和class的区别只是默认权限------用哪个是给读者发信号,不是给编译器发指令
从下一篇开始,我们将进入 C++ 最核心的机制之一:构造与析构函数------它们如何让对象从诞生的那一刻起就是合法的,又是如何在生命周期结束时自动打扫战场。这是 C 语言"手动挡"到 C++ "自动挡"的关键跃迁。
📝 动手练习:
- 把你之前写过的一个 C 结构体改成 C++ class,给所有字段加上
private,写 setter/getter 保护不变量- 写一个
class Temperature,存储开尔文温度。set_celsius和set_fahrenheit自动转换为开尔文,确保温度不低于 0K- 尝试在外部代码中访问
private成员,仔细观察编译器的报错信息------这些信息就是你的"免费文档"