news 2026/10/14 5:53:11

C++数据封装以及定义结构的详细讲解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++数据封装以及定义结构的详细讲解

前言


"数据封装"(encapsulation)大概是被讲得最多、也被做得最浅的一个概念。很多教程把它等价成一条操作指令:把成员变量改成private,再给每个变量配一个getXxx()和一个setXxx()。这样做完之后代码看起来"面向对象"了,实际上封装的目的一点都没达到。


封装的真正目的只有一个:让对象任何时候都处在合法状态。数学上把这种"对象必须始终满足的条件"叫做类不变式(class invariant)。一个Range类必须保证下界不超过上界;一个Email类必须保证字符串里有且只有一个@;一个链表必须保证头尾指针一致。这些条件只有在所有修改入口都把关时才能成立,而"给每个字段配一个 setter"恰恰是把把关的机会全部让出去了。


第二个常见误解是关于struct和class。两者的唯一区别是默认访问权限和默认继承方式:struct默认public,class默认private。除此之外没有任何区别——struct可以有构造函数、虚函数、私有成员,class也可以用聚合初始化。选哪个纯粹是表达"这东西是个纯数据袋还是一个有不变式的类型"。


本文以 C++17 为基准,代码在 GCC 13 / Clang 17 / MSVC 19.3x 上均可编译。


一、struct 与 class:只有两个默认值不同




项目struct默认class默认



成员访问权限publicprivate

继承方式(struct D : B)公有继承私有继承



#include <iostream>

struct Point { // 默认 public
int x = 0;
int y = 0;

int sum() const { return x + y; }
};

class Blob { // 默认 private
int x_ = 0; // 没写访问说明符,所以是 private

public:
int x() const { return x_; }
void set_x(int v) { x_ = v; }
};

class Derived1 : Point {}; // class 默认私有继承
struct Derived2 : Point {}; // struct 默认公有继承

int main() {
Point p{1, 2}; // 聚合初始化
std::cout << p.sum() << '\n'; // 3

Blob b;
b.set_x(5);
std::cout << b.x() << '\n'; // 5

// Derived1 d1; d1.sum(); // 错误:私有继承,sum 对外不可访问

Derived2 d2; // 公有继承
d2.x = 1;
d2.y = 2;
std::cout << d2.sum() << '\n'; // 3

return 0;
}

注意class Derived1 : Point {}这一行——很多人以为不写public就是公有继承,结果发现基类的接口全都不见了。这是私有继承的典型症状:继承关系存在,但对外完全不可见。


二、封装的实质:守住不变式


下面这个Range类把"下界不大于上界"作为不变式,在每一个可以改变状态的入口都做了检查:


#include <iostream>
#include <stdexcept>

class Range {
public:
Range(int lo, int hi) : lo_(lo), hi_(hi) {
if (lo > hi) {
throw std::invalid_argument("lo 不能大于 hi");
}
}

int lo() const noexcept { return lo_; }
int hi() const noexcept { return hi_; }

// 只暴露操作,不暴露字段
bool contains(int v) const noexcept { return lo_ <= v && v <= hi_; }

void set_lo(int v) {
if (v > hi_) {
throw std::invalid_argument("set_lo: lo 不能大于 hi");
}
lo_ = v; // 不变式 lo_ <= hi_ 依然成立
}

void set_hi(int v) {
if (v < lo_) {
throw std::invalid_argument("set_hi: hi 不能小于 lo");
}
hi_ = v;
}

private:
int lo_;
int hi_;
};

int main() {
Range r(1, 10);
std::cout << r.contains(5) << ' ' << r.contains(50) << '\n'; // 1 0
r.set_lo(3);
try {
r.set_lo(99); // 被拒绝,对象状态不变
} catch (const std::invalid_argument& e) {
std::cout << "被拒绝: " << e.what() << '\n';
}
std::cout << r.lo() << ' ' << r.hi() << '\n'; // 3 10
return 0;
}

对比一下"每个字段配一个 setter"的做法:如果lo_和hi_都是public,调用方可以随时写出r.lo_ = 99;,对象立刻进入非法状态;而所有后续依赖不变式的代码(比如contains、二分查找、区间合并)都会跟着出错,而且出错的现场离肇事现场很远。封装换来的不是"好看",而是"错误在发生的那一刻就被拦住"。


判断一个类封装得好不好,有个简单的自检:把它的所有 public 成员函数列出来,看看有没有哪一对是"先 setA 再 setB 才有意义"的。如果有,说明应该提供的是一个同时改两者的操作,而不是两个 setter。


三、const 正确性与 mutable


const成员函数承诺"调用它不会改变对象的可观察状态"。它是封装契约的一部分:只有const成员函数能对const对象调用,所以一个类型能不能在只读上下文里用,取决于你把哪些成员函数标成了const。


#include <iostream>

class Cached {
public:
explicit Cached(int seed) : seed_(seed) {}

// const 成员函数内部修改 mutable 成员是允许的:
// 缓存不影响对象的"可观察状态",所以不违反 const 承诺
int value() const {
if (!valid_) {
cache_ = compute();
valid_ = true;
}
return cache_;
}

int seed() const noexcept { return seed_; }

private:
int compute() const { return seed_ * 2; }

int seed_;
mutable int cache_ = 0;
mutable bool valid_ = false;
};

int main() {
const Cached c(21);
std::cout << c.value() << ' ' << c.value() << '\n'; // 42 42
return 0;
}

这里必须强调一句:上面这种带mutable的惰性缓存不是线程安全的。两个线程同时第一次调用value()会同时写cache_和valid_,这是数据竞争,属于 UB。要在线程间共享,得加锁(锁对象本身也要是mutable才能在const函数里用),或者干脆去掉缓存。


另一种更隐蔽的破坏方式来自指针成员。const作用在一个指针成员上,得到的是"常量指针"(T* const,指向不能改),而不是"指向常量的指针"(const T*,内容不能改)。所以一个const成员函数完全可以合法地交出一份可写权限:


#include <iostream>

class Holder {
public:
explicit Holder(int* p) : p_(p) {}

// const 成员函数:p_ 本身不能改指向,但 p_ 指向的 int 照样能改
int* get() const { return p_; }

private:
int* p_;
};

int main() {
int value = 1;
const Holder h(&value); // h 是 const 对象
*h.get() = 99; // 通过 const 对象改了外部数据
std::cout << value << '\n'; // 99
return 0;
}

这段代码完全合法,h是const对象,get()是const成员函数,但外部数据照样被改了。要真正封住,get()应该返回const int*,或者干脆不把指针暴露出去。同理,要暴露内部集合时,返回const引用只能挡住直接修改,却会把内部表示绑进公开接口;更好的做法是返回迭代器范围,或者直接提供for_each风格的操作接口。


四、结构的三种定义方式


定义一个新的数据结构时,先想清楚它属于哪一类:




类型特征典型写法



聚合(aggregate)纯数据,没有不变式,允许逐字段初始化struct,成员全 public,不加构造函数

有不变式的类型必须经由构造函数校验,字段全私有class,构造函数 + 最小接口

需要隐藏实现的类型头文件不能暴露实现细节(编译依赖、ABI)Pimpl 惯用法



聚合在 C++17 里的判定条件是:没有用户提供(user-provided)、没有explicit、也没有继承来的构造函数;没有 private 或 protected 的非静态数据成员;没有虚函数;没有虚基类、私有或受保护的基类。


这里有一个版本差异必须记住:C++20 把条件改成了"没有用户声明或继承来的构造函数"(提案 P1008R1)。后果是下面这个类在 C++17 里是聚合,在 C++20 里就不是了:


struct Config {
Config() = default; // 用户声明但没有用户提供
int port = 8080;
bool debug = false;
};

int main() {
// C++17:Config 是聚合,这行合法
// C++20:Config 不是聚合(因为有用户声明的构造函数),这行编不过
Config c{9090, true};
return c.port == 9090 ? 0 : 1;
}

写 C++17 代码时它是合法的;将来升到 C++20,需要把这行改成Config c; c.port = 9090; c.debug = true;,或者删掉那个= default的构造函数。


Pimpl(Pointer to Implementation,也叫"编译期防火墙")是把实现藏进 .cpp 的标准手段:


// widget.h
#ifndef WIDGET_H
#define WIDGET_H

#include <memory>

class Widget {
public:
Widget();
~Widget(); // 必须在 .cpp 里定义

Widget(const Widget&);
Widget& operator=(const Widget&);
Widget(Widget&&) noexcept;
Widget& operator=(Widget&&) noexcept;

void set(int v);
int get() const;

private:
struct Impl; // 只声明,不定义
std::unique_ptr<Impl> pimpl_;
};

#endif // WIDGET_H

// widget.cpp
#include "widget.h"

#include <string>
#include <utility>

struct Widget::Impl { // 完整定义只出现在这里
int value = 0;
std::string note;
};

Widget::Widget() : pimpl_(std::make_unique<Impl>()) {}

Widget::~Widget() = default; // Impl 在这里已是完整类型

Widget::Widget(const Widget& other)
: pimpl_(std::make_unique<Impl>(*other.pimpl_)) {}

Widget& Widget::operator=(const Widget& other) {
if (this != &other) {
*pimpl_ = *other.pimpl_; // 自赋值必须先判断
}
return *this;
}

Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;

void Widget::set(int v) { pimpl_->value = v; }
int Widget::get() const { return pimpl_->value; }

这段代码里有三个必须同时满足的约束:



  1. 析构函数不能在头文件里写= default。默认的析构函数会销毁std::unique_ptr<Impl>,而销毁动作要求Impl是完整类型;头文件里Impl只是声明,编译器会报"不完整类型"相关的错误。

  2. 移动赋值同理:unique_ptr的移动赋值会析构它原来持有的对象,所以也必须在Impl完整的地方定义。

  3. 拷贝要手写:std::unique_ptr不可拷贝,所以想让Widget可拷贝,就必须自己写深拷贝(如上面的std::make_unique<Impl>(*other.pimpl_)),并且处理自赋值。


代价是每次访问成员都要多一次指针间接,收益是头文件只依赖<memory>,改Impl的字段不会触发任何包含这个头文件的 .cpp 重新编译。


常见坑点




场景❌ 错误写法✅ 正确写法说明



默认继承方式class D : Base {};以为拿到的是公有继承写全class D : public Base {};class的默认继承是私有继承,基类接口对外全部不可见

形式化封装每个字段配一个get/set暴露操作,把不变式守在构造函数和修改函数里只加 setter 等于把校验机会让给调用方

构造函数不校验构造函数直接赋值,不做范围检查在构造函数里校验,非法就抛std::invalid_argument否则对象一出生就可能处于非法状态,且无处可查

Pimpl 析构头文件里~Widget() = default;头文件只声明,在 .cpp 里写= default默认析构需要Impl完整类型,头文件里只有声明

Pimpl 移动赋值头文件里operator=(Widget&&) noexcept = default;同样放到 .cpp 里定义移动赋值会析构原对象,也需要完整类型

返回内部引用std::vector<int>& items() { return v_; }或返回内部元素的非 const 引用返回迭代器、const引用,或提供操作接口一旦交出去,封装就失效了

mutable缓存在多线程共享的对象上用mutable做惰性缓存加锁(锁对象也标mutable),或去掉缓存无同步的并发写是数据竞争,属 UB

聚合判定以为struct一定是聚合检查是否满足聚合条件C++20 起,有用户声明构造函数的类不再是聚合



总结




主题关键结论



struct与class只有默认访问权限和默认继承方式不同,其余完全等价

封装的目的维持类不变式,让非法状态无法被构造出来

接口设计暴露操作而不是字段;构造函数是校验的第一道也是最重要的一道关

const承诺不改可观察状态;mutable用于缓存和互斥量这类"物理上要改、逻辑上不改"的成员

结构定义纯数据用聚合,有不变式用封装类,要隔离编译依赖用 Pimpl

Pimpl 约束析构、移动赋值等特殊成员函数必须在实现文件里定义,因为那里才有完整类型



数据封装不是把变量改成private就完事的仪式,而是把你对"什么状态是合法的"这个判断,从每个调用者脑子里,搬进类型本身的代码里。判断有没有做到,就看一个问题:一个不懂这个类的人,能不能只用它的 public 接口,把它搞成一个非法状态?如果答案是能,那它还没封装好。



版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 5:52:52

微信小程序天气预报开发实战:从定位授权到上线避坑全流程

我一直觉得&#xff0c;做天气类的小程序是练手性价比最高的项目类型之一。它不像后台管理系统那样全是列表表单&#xff0c;也不像电商项目那样纠缠支付和售后&#xff0c;但它把微信小程序日常开发里最常碰到的环节全部串起来了&#xff1a;用户授权、定位获取、网络请求、数…

作者头像 李华
网站建设 2026/10/14 5:52:44

WebLogic三种部署协议:HTTP/T3/IIOP原理与生产选型指南

1. 项目概述&#xff1a;这不是“上传”&#xff0c;而是WebLogic容器级的部署行为“WebLogic通过HTTP/T3/IIOP协议部署war包”——这个标题里藏着一个被大量初学者误解的核心事实&#xff1a;它根本不是在讲“怎么把war文件拖进控制台”&#xff0c;而是在描述WebLogic服务器内…

作者头像 李华
网站建设 2026/10/14 5:52:33

数字化转型重塑周期股:底部抬升的价值投资新逻辑

周期性行业这四个字&#xff0c;在大多数投资者眼里几乎等于"低估值陷阱"的代名词&#xff1b;数字化转型则被默认是科技股、互联网公司的故事。但偏偏有个现象值得注意&#xff1a;过去几年&#xff0c;一批看似笨重的传统产业公司&#xff0c;在数字化改造之后&…

作者头像 李华
网站建设 2026/10/14 5:52:32

GPT-6全面升级:免费用户可体验AI生成可交互界面能力

这一波更新来得有点突然&#xff0c;但确实是很多人等了好一阵的东西。ChatGPT 全面切换到 GPT-6&#xff0c;最抓眼球的不是参数数字又变大了&#xff0c;而是官方明确说了句&#xff1a;免费用户今天就能用。更让我在意的&#xff0c;是更新说明里那句带点画面感的描述——“…

作者头像 李华
网站建设 2026/10/14 5:52:09

电磁波传播优化算法(EMWPA)原理与MATLAB实现

1. 电磁波传播优化算法&#xff08;EMWPA&#xff09;概述电磁波传播优化算法&#xff08;Electromagnetic Wave Propagation Algorithm, EMWPA&#xff09;是一种受电磁波在介质中传播特性启发的新型智能优化算法。2025年最新发表在1区SCI期刊的这项研究成果&#xff0c;通过模…

作者头像 李华
网站建设 2026/10/14 5:51:57

IP查询服务SLA监控实战:延迟、错误率与命中率指标设计

1. 从一次线上事故说起&#xff0c;为什么IP查询服务也需要SLA监控先讲一个我实际经历过的场景。某天凌晨两点&#xff0c;运营同学在群里发了一张截图&#xff0c;说某个渠道的用户批量反馈页面显示的IP归属地不对&#xff0c;紧接着客服那边也开始有零星投诉。我当时的反应是…

作者头像 李华