开篇介绍:
hello 大家,本篇博客我们继续解析语法以及实战创建通讯录。
一、前置基础:Proto3 核心规则回顾
在学习嵌套 Message 和 repeated 限定符之前,我们需要先回顾 Proto3 的 3 个核心基础规则,这是后续所有内容的前提,也是 ProtoBuf 使用的 “通用准则”,贯穿整个开发过程。
1.1 Proto3 的基本文件结构
一个标准的 Proto3 文件,核心包含 3 个必选部分 + 可选的注释 / 导入 / 嵌套等内容,基本结构如下:
// 1. 声明使用Proto3语法(必选,Proto2需声明syntax="proto2",不声明默认Proto2)
syntax = "proto3";
// 2. 声明包名(必选,用于隔离命名空间,避免不同文件的Message重名)
package 包名;
// 3. 定义Message(必选,ProtoBuf的核心数据结构,类比C++的结构体/类)
message 消息名 {
// 字段定义:字段类型 + 字段名 = 唯一标识数值(字段编号)
标量类型/自定义类型 字段名 = 数字;
}
// 可选:注释(单行//,多行/* */)、嵌套Message、repeated字段、导入其他proto文件等
示例:一个简单的联系人基础信息 Proto3 定义
syntax = "proto3";
package connects; // 包名:connects(通讯录相关)
// 联系人基础信息Message
message PersonBase {
string name = 1; // 姓名:字符串类型,字段编号1
sint32 age = 2; // 年龄:32位有符号整型,字段编号2
}
1.2 字段编号(唯一标识数值)的核心规则
字段编号是 ProtoBuf 中最核心、最基础的规则,也是后续嵌套 Message、repeated 字段的通用规则,必须牢牢记住:
ProtoBuf 的字段编号(= 后面的数字),仅需要在「同一个 Message 的大括号 {} 内部」唯一即可,不同 Message 之间的字段编号可以完全重复,无任何冲突。
关键说明
- 字段编号的作用:ProtoBuf 序列化时,并非存储字段名(如 name、age),而是存储字段编号(如 1、2),以此减少二进制数据体积,提升序列化效率;
- 编号范围:有效编号为 1~536870911,其中 19000~19999 为 ProtoBuf 保留编号,不可使用;
- 编号一旦确定,严禁修改:如果后续需要扩展字段,新增字段使用新的未用编号即可,修改已有字段的编号会导致旧的序列化数据无法反序列化,造成数据兼容问题;
- 可预留编号:为后续扩展预留部分编号(如 1~10 用做基础字段,11~20 预留),避免后续新增字段时编号混乱。
示例:不同 Message 的字段编号可重复
syntax = "proto3";
package connects;
// Message A:字段编号1为name
message A {
string name = 1;
}
// Message B:字段编号1为age(与A重复,完全合法)
message B {
sint32 age = 1;
}
// Message C:包含A和B,字段编号1为A,2为B(与A、B内部编号不冲突)
message C {
A a = 1;
B b = 2;
}
1.3 标量类型的定义与 Getter 函数规范
ProtoBuf 提供了一系列原生标量类型(如 string、int32、bool),对应 C++ 的原生类型(如 std::string、int32_t、bool),编译器会为每个标量字段自动生成Getter 函数(只读获取字段值),且 Getter 函数遵循统一的规范,这是后续调用生成代码的基础。
下表为实际开发中最常用的标量类型、Proto3 定义方式、C++ 对应的 Getter 函数语法、调用示例及返回值,建议熟记:
| Proto3 字段类型 | 字段定义示例 | C++ Getter 函数语法 | 调用示例 | 返回值说明 |
|---|---|---|---|---|
| string | string name=1 | const std::string& name() const; | person.name(); | 字符串 const 引用,避免值拷贝,提升效率,如 "张三" |
| sint32 | sint32 age=2 | int32_t age() const; | person.age(); | 32 位有符号整型值,如 25、-10 |
| bool | bool is_vip=3 | bool is_vip() const; | person.is_vip(); | 布尔值,true/false |
| uint64 | uint64 timestamp=4 | uint64_t timestamp() const; | person.timestamp(); | 64 位无符号整型值,如 1735689600000 |
| double | double score=5 | double score() const; | person.score(); | 双精度浮点值,如 98.5 |
| bytes | bytes data=6 | const std::string& data() const; | person.data(); | 字节序列 const 引用,用于存储二进制数据 |
| uint32 | uint32 id=7 | uint32_t id() const; | person.id(); | 32 位无符号整型值,如 1001 |
| fixed64 | fixed64 hash=8 | uint64_t hash() const; | person.hash(); | 64 位无符号定长整型值,如 1234567890123456789 |
Getter 函数核心共性
- 函数名与字段名完全一致(如字段 name 对应函数 name (),字段 age 对应函数 age ());
- 函数末尾带const修饰,表明是纯读操作,调用后不会修改 Message 对象的任何字段;
- 标量类型(除 string、bytes)直接返回对应 C++ 原生类型值,string/bytes 返回const 引用(避免大字符串的拷贝开销,提升效率)。
二、嵌套 Message 全解析:定义、规则、两种方式对比
在实际业务中,“一个对象包含另一个对象” 是最常见的场景,比如:
- 通讯录中的 “联系人” 包含 “电话号码”(电话号码有自己的属性,如号码、类型);
- 订单中的 “订单信息” 包含 “商品信息”(商品信息有自己的属性,如商品 ID、名称、价格);
- 用户中的 “用户信息” 包含 “地址信息”(地址信息有自己的属性,如省、市、详细地址)。
ProtoBuf 中的嵌套 Message就是为了适配这种场景而生,其本质类比 C++ 中的嵌套结构体 / 嵌套类,核心作用是将关联的复杂数据结构整合在一起,让.proto 文件的结构更清晰,数据关系更直观。
ProtoBuf 支持两种嵌套 Message 的定义方式,分别是「Message 内部直接嵌套定义子 Message」和「外部定义独立 Message + 内部声明使用」
2.1 嵌套 Message 的核心通用规则
无论使用哪种嵌套方式,都遵循以下 3 条通用规则,这是嵌套 Message 的基础,必须遵守:
- 子 Message 的字段编号仍需遵循 “同 Message 内部唯一” 的规则;
- 父 Message 与子 Message、不同子 Message 之间的字段编号可以重复,无任何冲突;
- 嵌套 Message 的使用本质是 “将子 Message 作为父 Message 的一个字段类型”,与使用原生标量类型(如 string、int32)的逻辑一致,仅需将 “字段类型” 替换为子 Message 的名称即可。
2.2 方式 1:Message 内部直接嵌套定义子 Message
2.2.1 语法规则
在父 Message 的大括号 {} 内部,直接定义需要嵌套的子 Message,子 Message 作为父 Message 的 “内部成员”,语法要点如下:
- 子 Message 的定义语法与普通 Message 完全一致,包含
message 子消息名 { ... }; - 子 Message 本身不需要写字段编号(即不用加
= 数字),因为子 Message 是 “数据结构定义”,而非父 Message 的一个 “字段”; - 子 Message内部的普通字段(如 string、int32),必须按标准语法定义(字段类型 + 字段名 = 字段编号),且字段编号仅需在子 Message 内部唯一;
- 父 Message 若要使用内部定义的子 Message 作为字段,需将 “字段类型” 设为子 Message 的名称,按标准语法定义(子 Message 名 + 字段名 = 字段编号)。
2.2.2 完整代码示例
以 “联系人包含电话号码” 为例,父 Message 为 PersonInfo,内部嵌套定义子 Message Phone(电话号码):
syntax = "proto3";
package connects;
// 父Message:联系人信息
message PersonInfo {
// 父Message的普通标量字段
string name = 1; // 姓名:字段编号1
sint32 age = 2; // 年龄:字段编号2
// 内部直接嵌套定义子Message:电话号码(作为父Message的内部成员)
// 注意:子Message Phone本身无需加字段编号,不是父Message的字段
message Phone {
// 子Message的普通字段:号码(字段编号1,与父Message的name=1重复,合法)
string number = 1;
// 子Message的普通字段:类型(如移动、联通,字段编号2,子Message内部唯一)
string type = 2;
}
// 父Message使用内部嵌套的子Message作为字段
// 字段类型:Phone(内部定义的子Message名),字段名:phone,字段编号:3
Phone phone = 3;
}
2.2.3 编译后 C++ 类的特性
使用 ProtoBuf 编译器(protoc)编译上述.proto 文件后,会生成对应的 C++ 类,核心特性如下:
- 父 Message 生成独立的 C++ 类:
connects::PersonInfo(包名::消息名); - 内部嵌套的子 Message 生成组合命名的 C++ 类:
connects::PersonInfo_Phone,命名规则为父消息名_子消息名(中间用下划线连接); - 父 Message 类内部会自动生成子 Message 的类型别名:
typedef PersonInfo_Phone Phone;,因此在使用时,可通过PersonInfo::Phone简写,无需写完整的PersonInfo_Phone,简化代码书写; - 子 Message 类的访问权限:内部嵌套定义的子 Message,其生成的 C++ 类为全局可见,但从语义上,它是父 Message 的专属子结构,建议仅在父 Message 内部使用。
2.2.4 C++ 简单使用示例(基于内部嵌套定义)
#include "connects.pb.h"
#include <iostream>
using namespace std;
int main() {
// 创建父Message对象
connects::PersonInfo person;
person.set_name("张三");
person.set_age(25);
// 操作内部嵌套的子Message字段:两种方式均可(别名/完整类名)
// 方式1:使用父类内部的别名 PersonInfo::Phone
connects::PersonInfo::Phone* phone = person.mutable_phone();
phone->set_number("13800138000");
phone->set_type("移动");
// 方式2:使用完整类名 PersonInfo_Phone(不推荐,代码冗余)
// connects::PersonInfo_Phone* phone = person.mutable_phone();
// 打印数据
cout << "姓名:" << person.name() << endl;
cout << "年龄:" << person.age() << endl;
cout << "电话号码:" << person.phone().number() << endl;
cout << "电话类型:" << person.phone().type() << endl;
return 0;
}
2.3 方式 2:外部定义独立 Message + 内部声明使用(推荐)
2.3.1 语法规则
先在所有 Message 的外部(全局作用域),定义一个独立的 Message(类比 C++ 的全局结构体),然后在需要使用该 Message 的父 Message 大括号 {} 内部,将其作为普通字段类型声明使用,语法要点如下:
- 独立 Message 的定义语法与普通 Message 完全一致,无任何特殊要求;
- 父 Message 使用该独立 Message 时,完全遵循 Proto3 的标准字段定义语法:
独立Message名 + 字段名 + = + 字段编号,与使用原生标量类型(如 string、int32)的格式完全一致; - 独立 Message 的字段编号仅需在其自身内部唯一,与父 Message、其他 Message 的字段编号可重复;
- 多个父 Message 可以复用同一个独立 Message,无需重复定义。
2.3.2 完整代码示例
同样以 “联系人包含电话号码” 为例,先全局外部定义独立的 Phone Message,再在 PersonInfo 中声明使用:
syntax = "proto3";
package connects;
// 步骤1:全局外部定义独立的Message:电话号码(可被所有Message复用)
message Phone {
string number = 1; // 号码:字段编号1
string type = 2; // 类型:字段编号2
}
// 步骤2:父Message:联系人信息,声明使用外部独立的Phone
message PersonInfo {
string name = 1; // 姓名:字段编号1(与Phone的number=1重复,合法)
sint32 age = 2; // 年龄:字段编号2(与Phone的type=2重复,合法)
Phone phone = 3; // 使用外部独立Message作为字段:类型=Phone,字段名=phone,编号=3
}
// 步骤3:其他Message可直接复用Phone(无需重复定义)
message CompanyInfo {
string company_name = 1;
Phone office_phone = 2; // 复用外部的Phone,实现公司办公电话的定义
}
2.3.3 编译后 C++ 类的特性
编译上述.proto 文件后,生成的 C++ 类与内部嵌套定义有明显区别,核心特性如下:
- 每个独立的 Message 都会生成独立的 C++ 类,类名与 Message 名完全一致,无组合命名:
connects::Phone、connects::PersonInfo、connects::CompanyInfo; - 所有生成的 C++ 类均为全局可见,可直接通过 “包名::类名” 访问,无需通过父类别名;
- 复用独立 Message 的字段,直接调用对应类的操作函数即可,逻辑与标量字段一致。
2.3.4 C++ 简单使用示例(基于外部定义 + 内部使用)
#include "connects.pb.h"
#include <iostream>
using namespace std;
int main() {
// 1. 操作联系人信息,使用外部的Phone
connects::PersonInfo person;
person.set_name("张三");
person.set_age(25);
person.mutable_phone()->set_number("13800138000");
person.mutable_phone()->set_type("移动");
// 2. 操作公司信息,复用外部的Phone(无需重复定义)
connects::CompanyInfo company;
company.set_company_name("XX科技有限公司");
company.mutable_office_phone()->set_number("010-12345678");
company.mutable_office_phone()->set_type("固定电话");
// 打印数据
cout << "=== 联系人信息 ===" << endl;
cout << "姓名:" << person.name() << endl;
cout << "电话:" << person.phone().number() << "(" << person.phone().type() << ")" << endl;
cout << "=== 公司信息 ===" << endl;
cout << "公司名:" << company.company_name() << endl;
cout << "办公电话:" << company.office_phone().number() << "(" << company.office_phone().type() << ")" << endl;
return 0;
}
2.4 两种嵌套方式的对比与实际开发推荐
2.4.1 核心对比表
为了更直观地看出两种方式的区别,我们制作了核心对比表,从定义位置、复用性、可读性、维护性、编译后类名等维度进行对比:
| 对比维度 | 方式 1:内部直接嵌套定义 | 方式 2:外部定义 + 内部声明使用(推荐) |
|---|---|---|
| 定义位置 | 父 Message 内部,作为内部成员 | 全局外部,独立于所有 Message |
| 复用性 | 仅能被父 Message 使用,无法被其他 Message 复用 | 可被所有 Message 复用,无需重复定义 |
| 可读性 | 子 Message 与父 Message 紧耦合,阅读时需在父 Message 内部查找子 Message 定义 | 先定义后使用,符合常规编码习惯,阅读更顺畅 |
| 维护性 | 修改子 Message 时,需在父 Message 内部操作,若多个地方需要类似结构,需重复定义并多次修改 | 子 Message 仅需定义一次,修改一次即可作用于所有使用的地方,维护成本低 |
| 编译后类名 | 组合命名(父_子),需通过父类别名简写 | 独立命名(与 Message 名一致),直接访问 |
| 适用场景 | 子 Message 仅为父 Message 专属,无复用需求 | 子 Message 为通用结构(如 Phone、Address、Goods),有复用需求 |
2.4.2 实际开发推荐用法
优先使用方式 2:外部定义独立 Message + 内部声明使用,原因如下:
- 符合常规编码习惯:先定义后使用,是所有编程语言的通用编码逻辑,团队协作时,其他开发者能快速找到 Message 的定义位置,降低阅读成本;
- 提升代码复用性:通用的子 Message(如 Phone、Address、Goods)仅需定义一次,即可被所有需要的父 Message 复用,避免重复定义相同的结构,减少代码冗余;
- 降低维护成本:当通用子 Message 的结构需要修改(如给 Phone 新增
int32 area_code = 3字段)时,仅需修改一次独立的 Message 定义,所有使用该 Message 的父 Message 都会自动生效,无需逐个修改,避免漏改、错改; - 便于扩展:后续若新增一个需要使用该子 Message 的父 Message,直接声明使用即可,无需重新定义,提升开发效率。
方式 1 的适用场景:仅当子 Message 是父 Message 的专属结构,且确定永远不会被其他 Message 复用(如父 Message 的一个临时子结构,仅用于内部数据封装)时,可使用方式 1,让子 Message 与父 Message 的耦合度更高,结构更紧凑。
2.5 嵌套 Message 的进阶使用:多层嵌套
ProtoBuf 支持多层嵌套(即子 Message 内部再嵌套定义孙 Message),无论是方式 1 还是方式 2,都可以实现,且遵循所有通用规则。以下为方式 2 的多层嵌套示例(推荐方式),适配 “通讯录→联系人→电话号码→号码归属地” 的多层数据结构:
syntax = "proto3";
package connects;
// 第一层:独立Message:号码归属地
message Area {
string province = 1; // 省
string city = 2; // 市
}
// 第二层:独立Message:电话号码(嵌套使用Area)
message Phone {
string number = 1; // 号码
string type = 2; // 类型
Area area = 3; // 归属地:嵌套使用Area
}
// 第三层:独立Message:联系人(嵌套使用Phone)
message PersonInfo {
string name = 1; // 姓名
sint32 age = 2; // 年龄
Phone phone = 3; // 电话号码:嵌套使用Phone
}
// 第四层:独立Message:通讯录(嵌套使用PersonInfo)
message Connects {
string name = 1; // 通讯录名称
repeated PersonInfo contacts = 2; // 联系人列表:后续会讲的repeated字段
}
上述示例中,每层 Message 都是独立定义的,可单独复用,比如 Area 可被 Phone、Address 等其他 Message 复用,Phone 可被 PersonInfo、CompanyInfo 等复用,完美适配多层复杂数据结构,且结构清晰、易于维护。
三、repeated 限定符深度解析:背景、本质、语法与核心特性
在实际业务中,除了 “对象包含对象”,还有一个常见需求是 **“一个字段对应多个值”**,比如:
- 一个联系人可以有多个电话号码(移动、联通、固定电话);
- 一个通讯录可以有多个联系人;
- 一个用户可以有多个邮箱地址;
- 一个订单可以有多个商品。
但 ProtoBuf3 中,所有字段的默认规则是单值的,即一个字段只能存储 0 个或 1 个值,无法直接存储多个值。为了解决这个问题,ProtoBuf 提供了repeated 限定符,用于将字段标记为可重复字段,支持存储任意多个值,这也是处理 “多值集合” 场景的核心方案。
3.1 字段的两个核心限定符:singular(默认单值)与 repeated(可重复多值)
ProtoBuf3 中,所有字段都有一个限定符,用于指定字段的 “值数量规则”,核心有两个限定符:singular和repeated,其中 singular 是默认值,无需手动声明。
3.1.1 单值限定符:singular(proto3 默认,无需手动声明)
核心规则
指定字段为单值字段,该字段在 Message 中可以被设置0 次(不设置)或 1 次,最多只能有 1 个值,无法设置多个值,若尝试设置多个值,后设置的值会覆盖前一个值。
语法
无需手动声明singular关键字,直接按标准语法定义字段即可,以下两种写法等价,推荐省略 singular(简化代码):
// 推荐写法:默认singular,省略关键字
string name = 1;
sint32 age = 2;
// 等价写法:手动声明singular(无必要,不推荐)
singular string name = 1;
singular sint32 age = 2;
使用场景
适用于一个字段对应唯一一个值的场景,比如联系人的姓名、年龄、身份证号,商品的 ID、名称,订单的编号等,这些字段在业务中天然只有一个值,使用默认的 singular 即可。
C++ 示例:单值字段多次设置会被覆盖
#include "connects.pb.h"
#include <iostream>
using namespace std;
int main() {
connects::PersonInfo person;
// 多次设置单值字段name,后设置的值覆盖前一个
person.set_name("张三");
person.set_name("李四");
person.set_name("王五");
// 多次设置单值字段age,后设置的值覆盖前一个
person.set_age(20);
person.set_age(25);
// 最终仅保留最后一次设置的值
cout << "姓名:" << person.name() << endl; // 输出:王五
cout << "年龄:" << person.age() << endl; // 输出:25
return 0;
}
3.1.2 可重复多值限定符:repeated(需手动声明)
核心规则
指定字段为可重复字段,该字段在 Message 中可以被设置0 次、1 次或任意多次,支持存储多个值,且重复设置的值会保留顺序(即先添加的值在前,后添加的值在后,不会被覆盖)。
语法
必须在字段类型前手动声明 repeated 关键字,其余语法规则与单值字段完全一致(字段类型 + 字段名 = 字段编号),无任何额外要求:
// 正确写法:repeated + 字段类型 + 字段名 = 字段编号
repeated Phone phone = 3; // 自定义Message类型的可重复字段
repeated string email = 4; // 原生标量类型的可重复字段
repeated uint32 id_list = 5; // 原生标量类型的可重复字段
使用场景
适用于一个字段对应多个值的场景,比如联系人的多个电话号码、多个邮箱,通讯录的多个联系人,订单的多个商品,用户的多个地址等,这些字段在业务中需要存储多个值,必须使用 repeated 限定符。
核心优势
- 保留顺序:添加的值按顺序存储,遍历的时候按添加顺序读取,适配需要 “有序集合” 的场景;
- 动态扩展:支持 0 个值(未设置)、1 个值或任意多个值,无需提前指定长度,适配数据量不固定的业务场景;
- 类型兼容:可修饰所有 ProtoBuf 类型,包括原生标量类型(string、int32 等)和自定义 Message 类型(Phone、PersonInfo 等),使用方式统一。
3.2 repeated 限定符的核心本质:等价于 C++ 结构体中的动态数组成员
这是理解 repeated 字段的最核心、最关键的知识点,也是后续学习 repeated 字段所有操作函数的基础 ——ProtoBuf 中被 repeated 修饰的字段,其本质完全等同于 C++ 中结构体的动态数组成员 **,二者的底层设计逻辑、实际使用目的、存储数据的形式完全一致 **。
3.2.1 核心类比:ProtoBuf repeated 字段 ↔ C++ 结构体动态数组成员
我们以 “通讯录包含多个联系人” 为例,将 ProtoBuf 的 repeated 字段定义与 C++ 的结构体动态数组成员定义做逐行等价类比,让你直观理解其本质:
步骤 1:定义 “数组元素类型”(二者一致)
无论是 ProtoBuf 的 repeated 字段,还是 C++ 的数组成员,都需要先定义数组的元素类型,这是定义数组的前提:
// ProtoBuf:定义数组元素类型——PersonInfo(Message)
message PersonInfo {
string name = 1;
sint32 age = 2;
repeated Phone phone = 3;
}
// C++:定义数组元素类型——PersonInfo(结构体)
struct PersonInfo {
std::string name;
int32_t age;
std::vector<Phone> phone; // Phone为另一个结构体
};
步骤 2:定义 “包含数组的外层容器”(二者一致)
ProtoBuf 中的 Message 和 C++ 中的结构体,都是 “封装数组的外层容器”,用于将数组字段和其他相关字段整合在一起:
// ProtoBuf:外层容器——Connects(Message),包含repeated字段(动态数组)
message Connects {
string book_name = 1; // 通讯录名称(单值字段)
repeated PersonInfo contacts = 2; // 联系人列表(repeated字段,本质是PersonInfo类型的动态数组)
}
// C++:外层容器——Connects(结构体),包含动态数组成员
struct Connects {
std::string book_name; // 通讯录名称(普通成员)
std::vector<PersonInfo> contacts; // 联系人列表(动态数组成员,与ProtoBuf的repeated字段完全等价)
};
3.2.2 为什么是 “动态数组” 而非 “静态数组”?
C++ 中的数组分为静态数组(如PersonInfo arr[10])和动态数组(如std::vector<PersonInfo>),而 ProtoBuf 的 repeated 字段对应的是动态数组,原因如下:
- 业务需求适配:实际开发中,“多值字段” 的元素数量是动态变化的(比如一个联系人可能有 1 个电话,也可能有 3 个电话;一个通讯录可能有 5 个联系人,也可能有 100 个联系人),静态数组需要提前指定固定长度,无法适配这种动态变化的场景;
- ProtoBuf 底层实现:repeated 字段在 C++ 中是通过 ProtoBuf 专属的RepeatedPtrField模板类实现的,其本质是一个动态的对象指针数组,会自动管理内存的分配与释放,支持动态添加、删除元素,无需开发者手动调用
new/delete,与 C++ 的std::vector功能一致,但对 ProtoBuf 的 Message 对象做了专门优化。
3.2.3 RepeatedPtrField:ProtoBuf 专属的动态对象指针数组(底层实现)
当我们在.proto 文件中定义了一个 repeated 字段后,ProtoBuf 编译器会在生成的 C++ 代码中,将该字段声明为RepeatedPtrField <元素类型> 类型,比如:
// .proto文件中的repeated字段
repeated PersonInfo contacts = 2;
对应的 C++ 生成代码(简化版):
namespace connects {
class Connects : public ::PROTOBUF_NAMESPACE_ID::Message {
private:
// repeated字段的底层实现:RepeatedPtrField<PersonInfo>
::PROTOBUF_NAMESPACE_ID::RepeatedPtrField<::connects::PersonInfo> contacts_;
};
}
RepeatedPtrField 的核心特性
- 自动内存管理:添加元素时,自动创建对象并分配内存;删除元素时,自动销毁对象并释放内存;清空数组时,自动销毁所有元素并释放全部内存,无需开发者手动管理;
- 对象指针存储:内部存储的是元素对象的指针,而非对象本身,这样在添加、删除元素时,仅需操作指针,无需拷贝整个对象,提升效率(尤其对于大的 Message 对象);
- 支持动态增删:提供了
Add()、Remove()、Clear()、Size()等方法,支持动态添加、删除、清空元素,获取元素个数; - 兼容 STL 迭代器:支持范围 for 循环、迭代器遍历,与 C++ 的 STL 容器(如
std::vector)的遍历方式一致,降低使用成本。
总结:你可以将
RepeatedPtrField<元素类型>理解为 “ProtoBuf 定制版的std::vector<元素类型*>”,它在std::vector的基础上,针对 ProtoBuf 的 Message 对象做了内存管理、效率优化等适配,让开发者可以更安全、更高效地操作多值字段。
3.3 repeated 限定符的语法规则与注意事项
repeated 限定符的语法看似简单,但实际使用中仍有一些需要注意的细节,接下来我们将梳理 repeated 的完整语法规则和核心注意事项,避免踩坑。
3.3.1 完整语法规则
- 位置要求:repeated 关键字必须写在字段类型的前面,顺序不能颠倒,否则编译器会报错;
- 正确:
repeated Phone phone = 3; - 错误:
Phone repeated phone = 3;/Phone phone repeated = 3;
- 正确:
- 类型兼容:repeated 可以修饰所有 ProtoBuf 支持的类型,包括:
- 原生标量类型:string、sint32、bool、uint64、double 等;
- 自定义 Message 类型:Phone、PersonInfo、Area 等(嵌套或外部定义的均可);
- 后续会学到的枚举类型(enum)、Map 类型等;
- 字段编号规则:repeated 字段的字段编号仍需遵循 “同 Message 内部唯一” 的规则,与单值字段、其他 repeated 字段之间不能重复,不同 Message 之间的字段编号可重复;
- 命名规范:repeated 字段的字段名建议使用复数形式,直观表明这是一个 “多值集合”,比如:
- 多个电话号码:
repeated Phone phones = 3;(而非 phone) - 多个联系人:
repeated PersonInfo contacts = 2;(而非 contact) - 多个邮箱:
repeated string emails = 4;(而非 email)
- 多个电话号码:
- 与嵌套 Message 结合:repeated 可以修饰嵌套的 Message 类型,无论是方式 1 还是方式 2 的嵌套 Message,使用方式完全一致,无任何特殊要求。
3.3.2 核心注意事项
- repeated 仅改变存储形式,不影响其他规则:给字段添加 repeated 修饰后,仅将字段的存储形式从 “单值” 改为 “多值数组”,ProtoBuf 的其他所有规则(如字段编号、包名、命名规范、编译规则)均保持不变;
- repeated 字段的 “0 个值” 与 “未设置” 等价:如果一个 repeated 字段没有添加任何值,那么它的状态为 “空”,与单值字段的 “未设置” 状态一致,序列化时不会被写入二进制数据,减少存储体积;
- 元素的独立性:repeated 数组中的每一个元素,都是对应元素类型的独立、完整的对象,彼此之间相互独立,修改其中一个元素的字段值,不会影响其他元素;
- 索引从 0 开始:遍历 repeated 数组时,元素的索引从0开始计数,与 C++、Java 等编程语言的数组索引规则一致,且索引不能超过 “元素个数 - 1”,否则会触发越界错误;
- 无需手动初始化:repeated 字段的底层实现 RepeatedPtrField 会在 Message 对象创建时自动初始化为空数组,无需开发者手动调用初始化函数,直接调用添加、遍历等函数即可。
3.3.3 不同类型的 repeated 字段示例(全覆盖)
为了让你全面掌握 repeated 的使用,以下给出原生标量类型、外部定义的 Message 类型、内部嵌套的 Message 类型三种常见场景的 repeated 字段完整示例,覆盖实际开发中的 99% 使用场景:
syntax = "proto3";
package connects;
// 场景1:外部定义的Message类型(可复用)
message Phone {
string number = 1;
string type = 2;
}
// 场景2:父Message内部嵌套定义的Message类型(专属)
message PersonInfo {
string name = 1;
sint32 age = 2;
// 内部嵌套定义:紧急联系人(专属)
message EmergencyContact {
string name = 1;
string phone = 2;
}
// repeated修饰:外部定义的Phone类型(复数命名phones)
repeated Phone phones = 3;
// repeated修饰:内部嵌套的EmergencyContact类型(复数命名emergency_contacts)
repeated EmergencyContact emergency_contacts = 4;
// repeated修饰:原生标量类型string(复数命名emails)
repeated string emails = 5;
// repeated修饰:原生标量类型uint32(复数命名ids)
repeated uint32 ids = 6;
}
// 场景3:repeated修饰自定义Message类型(PersonInfo)
message Connects {
string book_name = 1; // 通讯录名称(单值)
repeated PersonInfo contacts = 2; // 联系人列表(repeated,复数命名contacts)
}
3.4 repeated 字段的核心特性总结
- 多值存储:支持 0 次、1 次或任意多次赋值,适配 “一个字段对应多个值” 的业务场景;
- 顺序保留:添加的值按顺序存储,遍历顺序与添加顺序一致,支持有序集合;
- 动态扩展:底层基于 RepeatedPtrField 实现动态数组,无需提前指定长度,支持动态增删;
- 类型兼容:可修饰所有 ProtoBuf 类型(原生标量、自定义 Message、枚举等),使用方式统一;
- 自动内存管理:无需手动 new/delete,RepeatedPtrField 自动管理元素的内存分配与释放;
- 空值优化:无元素时不序列化,减少二进制存储体积;
- 元素独立:数组内元素彼此独立,修改互不影响;
- 语法简单:仅需在字段类型前添加 repeated 关键字,其余规则与单值字段一致。
四、ProtoBuf 编译核心知识:编译命令、生成文件、C++ 类基础特性
在学习编译后 C++ 常用函数之前,我们需要先掌握 ProtoBuf 的编译核心知识,包括 “如何将.proto 文件编译为 C++ 代码”、“编译后生成哪些文件”、“生成的 C++ 类有哪些基础特性”,这是调用后续所有操作函数的前提,也是将 ProtoBuf 落地到 C++ 开发中的关键步骤。
4.1 ProtoBuf 编译器:protoc
ProtoBuf 的编译器是protoc,它是一个跨平台的命令行工具,能够将.proto 定义文件编译为 C++、Java、Python、Go 等多种编程语言的代码,其核心作用是 “将.proto 中的 Message 定义、字段定义、repeated 限定符等,转换为对应语言的类、函数、数据结构”。
4.1.1 protoc 的安装(Linux Ubuntu 示例)
如果你的系统中尚未安装 protoc 和 Protobuf 开发库,可以通过以下命令快速安装(基于 Ubuntu 18.04/20.04/22.04):
# 更新软件源
sudo apt update
# 安装protoc编译器和Protobuf C++开发库
sudo apt install -y protobuf-compiler libprotobuf-dev
# 验证安装成功(查看版本,需3.x及以上)
protoc --version
# 输出示例:libprotoc 3.6.1(版本号可不同,3.x即可)
4.1.2 核心编译命令
protoc 的核心编译命令格式为:
protoc -I=PROTO源文件目录 --cpp_out=C++代码输出目录 PROTO源文件路径
命令参数详解
- -I=PROTO 源文件目录:指定.proto 源文件的所在目录(简称 “源目录”),如果.proto 文件在当前目录,可简写为
-I=.(.表示当前目录); - --cpp_out=C++ 代码输出目录:指定编译后生成的 C++ 代码的输出目录,该目录会自动创建(如果不存在);
- PROTO 源文件路径:指定需要编译的.proto 文件的具体路径(可指定多个,用空格分隔)。
简化编译命令(最常用)
如果.proto 文件在当前目录,且希望编译后的 C++ 代码也生成在当前目录,可使用简化命令:
protoc -I=. --cpp_out=. xxx.proto
其中xxx.proto是你的.proto 文件名(如connects.proto)。
多文件编译示例
如果有多个.proto 文件(如connects.proto、order.proto、user.proto)都在当前目录,需要一次性编译为 C++ 代码,可使用:
protoc -I=. --cpp_out=. connects.proto order.proto user.proto
4.2 编译后生成的两个核心文件
将.proto 文件编译为 C++ 代码后,会在指定的输出目录中生成两个核心文件:xxx.pb.h和xxx.pb.cc(其中xxx为.proto 文件的名称,如connects.pb.h、connects.pb.cc),这两个文件包含了所有操作 Message 对象的类和函数,是 C++ 开发中唯一需要引入和编译的文件。
4.2.1 xxx.pb.h:头文件(声明所有类和函数)
该文件是 C++ 的头文件,核心包含:
- 所有 Message 对应的 C++ 类的声明(包括嵌套 Message 的组合类、独立 Message 的类);
- 所有字段的操作函数声明(如 Getter、Setter、clear、mutable、repeated 字段的 add、size 等);
- 序列化 / 反序列化函数的声明(如 SerializeToString、ParseFromString、SerializeToOstream、ParseFromIstream 等);
- 其他辅助函数的声明(如 CopyFrom、Clear、IsInitialized 等);
- 必要的头文件引入、命名空间定义、宏定义等。
使用方式:在 C++ 代码中,通过#include "xxx.pb.h"引入该头文件,即可使用所有生成的类和函数。
4.2.2 xxx.pb.cc:源文件(实现所有函数)
该文件是 C++ 的源文件,核心包含:
- 所有 Message 对应的 C++ 类的构造函数、析构函数实现;
- 所有字段操作函数的具体实现(如 set_name、name、add_phone、phone_size 等);
- 序列化 / 反序列化函数的具体实现(ProtoBuf 的核心序列化逻辑都在这里);
- 内存管理、对象拷贝、字段检查等辅助函数的具体实现。
使用方式:该文件不需要手动修改,也不需要在 C++ 代码中引入,只需在编译 C++ 程序时,将该文件与你的业务代码一起编译即可(如g++ main.cpp connects.pb.cc -o main)。
4.2.3 核心注意事项
- 不要手动修改生成的文件:
xxx.pb.h和xxx.pb.cc是 protoc 自动生成的文件,手动修改会导致编译错误、序列化 / 反序列化异常,若需要修改数据结构,应修改.proto 文件后重新编译; - 生成的文件与.proto 文件一一对应:每个.proto 文件编译后会生成独立的
xxx.pb.h和xxx.pb.cc,多个.proto 文件编译后会生成多组对应的文件,彼此独立; - 生成的文件跨平台:protoc 生成的 C++ 代码是跨平台的,可在 Linux、Windows、Mac 等系统上编译运行,无需修改;
- 依赖 Protobuf 开发库:编译生成的 C++ 代码时,需要链接 Protobuf 的 C++ 开发库(libprotobuf),否则会出现链接错误。
4.3 编译后 C++ 类的基础特性
无论.proto 文件中定义的是普通 Message、嵌套 Message,还是包含 repeated 字段的 Message,编译后生成的 C++ 类都遵循以下基础特性,这些特性是所有操作函数的通用前提,适用于所有生成的类:
4.3.1 继承自 ProtoBuf 的 Message 基类
所有生成的 C++ 类都会公有继承自 ProtoBuf 的核心基类::PROTOBUF_NAMESPACE_ID::Message(PROTOBUF_NAMESPACE_ID 是 ProtoBuf 的命名空间宏,对应实际的google::protobuf),该基类提供了所有 Message 对象的通用核心功能,包括:
- 序列化 / 反序列化功能(如 SerializeToString、ParseFromString);
- 对象拷贝功能(如 CopyFrom、MergeFrom);
- 字段清空功能(如 Clear);
- 字段初始化检查功能(如 IsInitialized);
- 内存管理功能(如析构函数、Swap)。
优势:所有生成的类都拥有统一的基础接口,开发者无需记忆不同类的通用函数,做到 “一次学习,所有类通用”。
4.3.2 被 final 修饰,不可被继承
生成的 C++ 类都会被final 关键字修饰(C++11 及以上特性),比如:
class PersonInfo final : public ::PROTOBUF_NAMESPACE_ID::Message {
// 类的内容
};
final 关键字的作用:禁止该类被其他类继承,原因如下:
- 保证 ProtoBuf 的核心逻辑不被破坏:ProtoBuf 的 Message 基类的核心功能(如序列化、反序列化)是高度封装的,子类继承并修改后,可能会导致序列化 / 反序列化异常;
- 提升编译效率:编译器对 final 修饰的类会做更多的优化,提升代码的运行效率;
- 简化设计:ProtoBuf 的设计初衷是 “数据结构定义→自动生成代码→调用函数”,无需开发者进行类的继承扩展,所有需求都可通过修改.proto 文件实现。
4.3.3 自动生成默认构造函数和析构函数
protoc 会为每个生成的 C++ 类自动生成无参默认构造函数和析构函数,且析构函数为虚函数(因为继承自 Message 基类),比如:
namespace connects {
class PersonInfo final : public ::PROTOBUF_NAMESPACE_ID::Message {
public:
// 无参默认构造函数
PersonInfo();
// 虚析构函数(继承自Message基类,保证多态析构)
virtual ~PersonInfo();
};
}
使用优势:开发者可以直接通过类名 对象名;创建 Message 对象,无需手动定义构造函数,析构函数会自动释放对象的所有资源(包括 repeated 字段的 RepeatedPtrField、字符串等),无需手动管理内存。
4.3.4 自动生成 CopyFrom 函数(深拷贝)
所有生成的 C++ 类都会自动生成CopyFrom 成员函数,用于实现同类型 Message 对象的深拷贝,将源对象的所有字段值(包括单值字段、repeated 字段)完全拷贝到当前对象,覆盖当前对象的原有值。
CopyFrom 函数的语法
// 类内声明
void CopyFrom(const 类名& from);
// 示例:PersonInfo类的CopyFrom函数
void CopyFrom(const PersonInfo& from);
核心特性
- 深拷贝:不仅拷贝单值字段,还会拷贝 repeated 字段的所有元素,源对象和目标对象的字段值完全独立,修改其中一个对象的字段值,不会影响另一个;
- 覆盖原有值:拷贝前会先清空当前对象的所有字段值,再将源对象的字段值拷贝过来,最终当前对象与源对象完全一致;
- 参数为 const 引用:参数类型为
const 类名&,既避免了修改源对象,又减少了对象拷贝的开销(尤其是大对象); - 通用继承:除了类专属的 CopyFrom 函数,还会继承 Message 基类的 CopyFrom 函数,支持基类指针的多态拷贝。
C++ 使用示例
#include "connects.pb.h"
#include <iostream>
using namespace std;
int main() {
// 创建源对象并设置值
connects::PersonInfo src_person;
src_person.set_name("张三");
src_person.set_age(25);
src_person.add_phones()->set_number("13800138000");
src_person.add_phones()->set_number("13900139000");
// 创建目标对象,初始为空
connects::PersonInfo dst_person;
cout << "拷贝前目标对象姓名:" << dst_person.name() << endl; // 空
cout << "拷贝前目标对象电话数:" << dst_person.phones_size() << endl; // 0
// 深拷贝:将src_person拷贝到dst_person
dst_person.CopyFrom(src_person);
// 拷贝后目标对象与源对象完全一致
cout << "拷贝后目标对象姓名:" << dst_person.name() << endl; // 张三
cout << "拷贝后目标对象电话数:" << dst_person.phones_size() << endl; // 2
cout << "拷贝后目标对象第一个电话:" << dst_person.phones(0).number() << endl; // 13800138000
// 修改源对象,目标对象不受影响(深拷贝特性)
src_person.set_name("李四");
src_person.mutable_phones(0)->set_number("18800188000");
cout << "修改源对象后,目标对象姓名:" << dst_person.name() << endl; // 仍为张三
cout << "修改源对象后,目标对象第一个电话:" << dst_person.phones(0).number() << endl; // 仍为13800138000
return 0;
}
4.3.5 自动生成 Clear 函数(清空所有字段)
所有生成的 C++ 类都会自动生成Clear 成员函数,用于清空对象的所有字段值,将对象恢复到 “刚创建时的初始状态”,包括:
- 单值字段:重置为 “未设置” 状态(如 string 为空,int32 为 0,bool 为 false);
- repeated 字段:清空所有元素,释放内存,元素个数置 0;
- 嵌套 Message 字段:调用嵌套对象的 Clear 函数,清空其所有字段。
Clear 函数的语法
// 类内声明
void Clear();
// 示例:PersonInfo类的Clear函数
void Clear() override; // override表示重写Message基类的Clear函数
C++ 使用示例
#include "connects.pb.h"
#include <iostream>
using namespace std;
int main() {
connects::PersonInfo person;
person.set_name("张三");
person.set_age(25);
person.add_phones()->set_number("13800138000");
cout << "Clear前:" << endl;
cout << "姓名:" << person.name() << endl; // 张三
cout << "年龄:" << person.age() << endl; // 25
cout << "电话数:" << person.phones_size() << endl; // 1
// 清空所有字段
person.Clear();
cout << "Clear后:" << endl;
cout << "姓名:\"" << person.name() << "\"" << endl; // 空字符串
cout << "年龄:" << person.age() << endl; // 0
cout << "电话数:" << person.phones_size() << endl; // 0
return 0;
}
4.3.6 支持序列化 / 反序列化的核心函数
所有生成的 C++ 类都会继承 Message 基类的核心序列化 / 反序列化函数,这是 ProtoBuf 的核心功能,也是我们使用 ProtoBuf 的最终目的。常用的序列化 / 反序列化函数包括:
- SerializeToString:将 Message 对象序列化为std::string(二进制格式);
- ParseFromString:从std::string(二进制格式)反序列化为 Message 对象;
- SerializeToOstream:将 Message 对象序列化为输出流(如文件流、标准输出流),适用于文件存储、网络传输;
- ParseFromIstream:从输入流(如文件流、标准输入流)反序列化为 Message 对象,适用于文件读取、网络接收。
这些函数的详细用法、参数、返回值,我们会在实战案例中结合完整代码详细讲解,这里先做简单介绍,让你有一个整体认知。
五、编译后 C++ 常用函数全解析:单值字段 + repeated 字段
掌握 ProtoBuf 的核心,最终落脚到调用编译后生成的 C++ 函数操作 Message 对象。本文的这一部分是核心中的核心,也是实际开发中使用频率最高的内容。
前置说明:
- 所有示例均基于以下.proto 文件(简易通讯录,覆盖单值字段、repeated 字段、嵌套 Message),确保示例的一致性和实用性;
syntax = "proto3"; package connects; // 外部定义的Phone Message message Phone { string number = 1; // 单值字段:号码 string type = 2; // 单值字段:类型 } // 包含单值字段和repeated字段的PersonInfo Message message PersonInfo { string name = 1; // 单值字段:姓名 sint32 age = 2; // 单值字段:年龄 repeated Phone phones = 3; // repeated字段:电话号码列表 } // 包含repeated字段的Connects Message message Connects { string book_name = 1; // 单值字段:通讯录名称 repeated PersonInfo contacts = 2; // repeated字段:联系人列表 } - 所有生成的 C++ 类均在
connects命名空间下,使用时需加connects::前缀; - 函数名中的字段名均为.proto 文件中定义的字段名(如 name、age、phones、contacts),遵循 “字段名与函数名联动” 的规则,方便记忆;
- 所有函数均为成员函数,通过 Message 对象调用(如
person.set_name("张三")、person.phones_size())。
5.1 模块 1:单值字段通用操作函数
ProtoBuf 中,所有单值字段(无论原生标量类型还是自定义 Message 类型)都会生成一套通用的操作函数,共 4 个核心函数,分别实现 “清空字段”、“只读获取字段值(Getter)”、“设置字段值(Setter)”、“获取可修改字段指针(mutable)” 的功能,覆盖单值字段的所有操作场景。
这 4 个函数的命名规则、语法、使用逻辑完全一致,仅需将 “字段名” 替换为实际的字段名即可,做到 “一次学习,所有单值字段通用”。
5.1.1 函数 1:clear_字段名 () —— 清空单值字段
核心功能
将指定的单值字段重置为初始状态(未设置状态),释放字段占用的内存(如 string 的内存),序列化时该字段不会被写入二进制数据。
- 对于 string/bytes 类型:重置为空字符串
""; - 对于数值类型(sint32、uint64、double 等):重置为 0;
- 对于 bool 类型:重置为 false;
- 对于自定义 Message 类型:调用该 Message 对象的 Clear () 函数,清空其所有字段。
语法(函数签名)
// 通用语法
void clear_字段名();
// 示例:清空name字段、age字段、book_name字段
void clear_name(); // PersonInfo类:清空姓名
void clear_age(); // PersonInfo类:清空年龄
void clear_book_name(); // Connects类:清空通讯录名称
void clear_number(); // Phone类:清空号码
参数
无参数。
返回值
void(无返回值),直接修改当前 Message 对象的字段状态。
实现本质
直接修改 Message 对象内部的字段存储变量,将其置为初始值,并释放非基础类型(如 string)的内存。
使用场景
- 当需要取消某个字段的设置时(如用户误输入,需要清空该字段);
- 序列化前清理无用字段,减少二进制数据体积;
- 重复使用 Message 对象时,清空旧的字段值,避免影响新值。
C++ 使用示例
#include "connects.pb.h"
#include <iostream>
using namespace std;
int main() {
connects::PersonInfo person;
// 设置字段值
person.set_name("张三");
person.set_age(25);
person.mutable_phones(0)->set_number("13800138000");
cout << "清空前:" << endl;
cout << "姓名:" << person.name() << endl; // 张三
cout << "年龄:" << person.age() << endl; // 25
// 清空指定字段
person.clear_name();
cout << "清空name后:" << endl;
cout << "姓名:\"" << person.name() << "\"" << endl; // 空字符串
cout << "年龄:" << person.age() << endl; // 25(未被清空)
// 清空所有字段(使用Clear()函数,前面已讲)
person.Clear();
return 0;
}
5.1.2 函数 2:字段名 () const —— 只读获取字段值(Getter)
核心功能
只读获取指定单值字段的当前值,函数末尾的const修饰表明这是纯读操作,调用后不会修改 Message 对象的任何字段,是获取字段值的最常用函数。
该函数的返回值类型与.proto 中的字段类型一一对应,具体对应关系见本文1.3 节 标量类型的定义与 Getter 函数规范。
语法(函数签名)
// 通用语法
返回值类型 字段名() const;
// 示例:不同类型字段的Getter函数
const std::string& name() const; // PersonInfo类:获取姓名(string→const string&)
int32_t age() const; // PersonInfo类:获取年龄(sint32→int32_t)
const std::string& book_name() const; // Connects类:获取通讯录名称(string→const string&)
const std::string& number() const; // Phone类:获取号码(string→const string&)
double score() const; // 若有double score=4→double score() const;
bool is_vip() const; // 若有bool is_vip=5→bool is_vip() const;
参数
无参数。
返回值
与.proto 字段类型对应的 C++ 类型,核心规则:
- 原生标量类型除 string/bytes 外,直接返回对应 C++ 原生类型值(如 sint32→int32_t,uint64→uint64_t,bool→bool);
- string/bytes 类型返回const std::string&(常量引用),避免大字符串的拷贝开销,提升效率;
- 自定义 Message 类型返回const 类名 &(常量引用),如
const Phone& phone() const;。
实现本质
直接返回 Message 对象内部的字段存储变量的值或引用,无额外计算,效率极高。
使用场景
所有需要读取字段值的场景,如打印字段值、判断字段值、将字段值赋值给其他变量等,是实际开发中使用频率最高的函数之一。
核心注意事项
返回的是const 引用 / 值,不可直接修改,若需要修改字段值,需使用set_字段名()或mutable_字段名()函数。
C++ 使用示例
#include "connects.pb.h"
#include <iostream>
#include <string>
using namespace std;
int main() {
connects::PersonInfo person;
person.set_name("张三");
person.set_age(25);
// 1. 直接打印字段值(最常用)
cout << "姓名:" << person.name() << endl;
cout << "年龄:" << person.age() << endl;
// 2. 将字段值赋值给其他变量
string name = person.name(); // const string& 赋值给 string,自动拷贝
int32_t age = person.age(); // int32_t 直接赋值
cout << "赋值后的姓名:" << name << endl;
cout << "赋值后的年龄:" << age << endl;
// 3. 判断字段值
if (person.age() >= 18) {
cout << "该联系人是成年人" << endl;
} else {
cout << "该联系人是未成年人" << endl;
}
// 错误示例:不可直接修改const引用的值(编译报错)
// person.name() = "李四"; // 编译错误:assignment of read-only reference
// person.age() = 30; // 编译错误:lvalue required as left operand of assignment
return 0;
}
5.1.3 函数 3:set_字段名 () —— 设置字段值(Setter)
核心功能
为指定的单值字段设置新值,覆盖字段的原有值(如果有),是设置单值字段值的最常用函数。
该函数支持多种参数类型(如 const 引用、右值、原生类型),适配不同的使用场景,且编译器会自动生成对应的重载函数,无需开发者手动处理。
语法(函数签名)
// 通用语法(编译器自动生成重载,支持多种参数类型)
void set_字段名(参数类型 值);
// 示例:不同类型字段的Setter函数
// string类型:支持const string&、const char*、string&&
void set_name(const std::string& value);
void set_name(const char* value);
void set_name(std::string&& value);
// sint32类型:支持int32_t
void set_age(int32_t value);
// uint64类型:支持uint64_t
void set_id(uint64_t value);
// double类型:支持double
void set_score(double value);
// bool类型:支持bool
void set_is_vip(bool value);
// 自定义Message类型:无set函数,需使用mutable_字段名()
// (原因:自定义Message类型是对象,需要先获取指针再设置其内部字段)
参数
与.proto 字段类型对应的 C++ 类型,核心规则:
- 原生标量类型:参数为对应 C++ 原生类型(如 sint32→int32_t,uint64→uint64_t);
- string/bytes 类型:支持三种参数类型(最灵活):
const std::string& value:常量引用,适用于已有 string 对象的场景,避免拷贝;const char* value:字符串常量指针,适用于直接传字符串字面量的场景(如set_name("张三"));std::string&& value:右值引用,适用于临时 string 对象的场景,支持移动语义,提升效率;
- 自定义 Message 类型:无 set_字段名 () 函数,因为自定义 Message 类型是一个对象,包含多个自身的字段,无法通过一个 set 函数设置所有值,需使用
mutable_字段名()获取对象指针后,再设置其内部字段。
返回值
void(无返回值),直接修改当前 Message 对象的字段值。
实现本质
将传入的参数值赋值给 Message 对象内部的字段存储变量,对于 string 类型,会自动管理内存(释放原有内存,分配新内存)。
使用场景
所有需要设置单值字段值的场景,如初始化字段值、修改字段值、从用户输入 / 数据库读取后设置字段值等,是实际开发中使用频率最高的函数之一。
C++ 使用示例(覆盖所有参数类型)
#include "connects.pb.h"
#include <iostream>
#include <string>
using namespace std;
int main() {
connects::PersonInfo person;
// 1. string类型字段的三种设置方式(推荐使用const char*,最简洁)
// 方式1:const char*(直接传字符串字面量,最常用)
person.set_name("张三");
// 方式2:const std::string&(传已有string对象)
string name = "李四";
person.set_name(name);
// 方式3:std::string&&(传临时string对象,移动语义)
person.set_name(string("王五"));
// 2. 数值类型字段的设置(直接传对应原生类型值)
person.set_age(25); // sint32→int32_t
// person.set_id(1001); // 若有uint64 id=4→person.set_id(1001ULL);(ULL表示uint64_t)
// person.set_score(98.5); // 若有double score=5→person.set_score(98.5);
// 3. bool类型字段的设置
// person.set_is_vip(true); // 若有bool is_vip=6→person.set_is_vip(true);
// 打印最终值
cout << "姓名:" << person.name() << endl; // 王五
cout << "年龄:" << person.age() << endl; // 25
return 0;
}
5.1.4 函数 4:mutable_字段名 () —— 获取可修改字段指针
核心功能
返回指定单值字段的可修改指针,通过该指针可以直接修改字段值,或对于自定义 Message 类型字段,通过该指针设置其内部的字段值(这是自定义 Message 类型字段的唯一修改方式)。
语法(函数签名)
// 通用语法
字段类型* mutable_字段名();
// 示例:不同类型字段的mutable函数
std::string* mutable_name(); // PersonInfo类:name字段→string*
Phone* mutable_phone(); // 若有Phone phone=4→Phone*(自定义Message类型)
std::string* mutable_number(); // Phone类:number字段→string*
参数
无参数。
返回值
字段类型对应的可修改指针(非 const):
- 原生标量类型(仅 string/bytes):返回
std::string*(因为数值类型 /bool 类型直接用 set_字段名 () 更简洁,无需指针); - 自定义 Message 类型:返回对应 Message 类的指针(如
Phone*、PersonInfo*),这是修改自定义 Message 类型字段的唯一方式。
实现本质
- 对于未设置的字段:先自动初始化该字段(如创建空 string 对象、创建空的 Message 对象),再返回其指针;
- 对于已设置的字段:直接返回其指针。
核心优势:无需手动检查字段是否初始化,直接调用即可,避免空指针异常。
使用场景
- 修改 string/bytes 类型字段的部分值(如在原有字符串后追加内容),而不是整体覆盖;
- 操作自定义 Message 类型字段(这是唯一方式),通过返回的指针设置其内部的字段值;
- 需要多次修改同一个字段时,获取一次指针后多次使用,避免多次调用 set_字段名 () 的开销。
核心注意事项
- 返回的是可修改指针,可以直接修改字段值,修改后会直接反映到 Message 对象中;
- 无需手动释放指针:指针指向的是 Message 对象内部的字段存储变量,由 Message 对象统一管理内存,当 Message 对象被析构时,指针指向的内存会自动释放,无需开发者手动调用
delete; - 数值类型 /bool 类型不推荐使用该函数,直接使用
set_字段名()更简洁、更高效。
C++ 使用示例
#include "connects.pb.h"
#include <iostream>
#include <string>
using namespace std;
int main() {
connects::PersonInfo person;
person.set_name("张三");
// 场景1:修改string类型字段的部分值(追加内容)
std::string* name_ptr = person.mutable_name();
name_ptr->append("(先生)"); // 在原有姓名后追加内容
cout << "修改后的姓名:" << person.name() << endl; // 张三(先生)
// 场景2:操作自定义Message类型字段(唯一方式)
// 假设PersonInfo有一个单值的Phone字段:Phone main_phone = 4;
// 1. 获取Phone对象的可修改指针(自动初始化,无需手动创建)
// connects::Phone* main_phone_ptr = person.mutable_main_phone();
// 2. 通过指针设置Phone对象的内部字段
// main_phone_ptr->set_number("13800138000");
// main_phone_ptr->set_type("移动");
// 3. 读取Phone对象的字段值
// cout << "主电话:" << person.main_phone().number() << endl;
// 场景3:多次修改同一个字段,减少开销
std::string* desc_ptr = person.mutable_name(); // 假设有string desc=5;
desc_ptr->clear();
desc_ptr->append("姓名:");
desc_ptr->append("张三");
desc_ptr->append(",年龄:25");
return 0;
}
5.1.5 单值字段 4 个核心函数的使用总结表
| 函数名 | 核心功能 | 主要使用场景 | 核心特点 |
|---|---|---|---|
| clear_字段名 () | 清空字段,重置为初始状态 | 取消字段设置、清理无用字段、重复使用对象 | 无参、无返回值、释放内存 |
| 字段名 () const | 只读获取字段值(Getter) | 打印、赋值、判断字段值、所有读操作 | const、返回值 /const 引用、不可修改 |
| set_字段名 () | 设置字段值(Setter) | 初始化、修改字段值、所有写操作(原生类型) | 多参数重载、无返回值、覆盖原有值 |
| mutable_字段名 () | 获取可修改字段指针 | 修改 string 部分值、操作自定义 Message 类型字段 | 无参、返回可修改指针、自动初始化 |
5.2 模块 2:repeated 字段专属操作函数)
repeated 字段的操作函数是 ProtoBuf 的核心重点,也是实际开发中处理 “多值集合” 的关键。所有 repeated 字段都会生成一套专属的操作函数,共 6 个核心函数,分别实现 “获取元素个数”、“清空整个数组”、“按索引只读获取元素”、“按索引获取可修改元素指针”、“新增元素并返回指针”、“获取整个数组引用(遍历)” 的功能,覆盖 repeated 字段的所有操作场景(增、删、改、查、遍历、清空)。
这 6 个函数的命名规则、语法、使用逻辑完全一致,仅需将 “字段名” 替换为实际的 repeated 字段名即可,且遵循 “数组操作逻辑”(与 C++ 的 std::vector 操作逻辑一致),做到 “一次学习,所有 repeated 字段通用”。
前置核心记忆点:repeated 字段的本质是动态数组,因此其操作函数的命名和功能完全贴合 “数组操作”,比如:
- 获取元素个数:
字段名_size()(类比vector::size()); - 清空数组:
clear_字段名()(类比vector::clear()); - 按索引获取元素:
字段名(index)(类比vector::operator[]); - 新增元素:
add_字段名()(类比vector::push_back());
所有函数均基于.proto中定义的复数命名 repeated 字段(phones/contacts)讲解,完全贴合实际开发的命名规范。
5.2.1 函数 1:字段名_size() const —— 获取数组元素个数
核心功能
只读获取当前 repeated 字段(动态数组)中的元素总个数,是遍历、判断数组是否为空的基础函数,类比 C++vector::size()。
语法(函数签名)
// 通用语法
int 字段名_size() const;
// 示例:基于通讯录proto的实际函数
int phones_size() const; // PersonInfo类:获取电话号码个数
int contacts_size() const; // Connects类:获取联系人个数
参数
无参数,函数末尾const修饰为纯读操作,不修改对象。
返回值
int:数组中当前的元素个数,无元素时返回 0,有元素时返回实际数量(如 3 个电话返回 3)。
实现本质
调用底层RepeatedPtrField的size()方法,直接返回动态数组的当前元素数量,无额外计算,效率 O (1)。
使用场景
- 遍历 repeated 数组:作为 for 循环的边界条件(
for(int i=0; i<xxx_size(); i++)); - 判断数组是否为空:
if(xxx_size() == 0),避免遍历空数组; - 统计元素数量:如统计通讯录的联系人总数、单个联系人的电话总数。
C++ 使用示例
#include "connects.pb.h"
#include <iostream>
using namespace std;
int main() {
connects::PersonInfo person;
// 初始状态:无元素,返回0
cout << "初始电话数:" << person.phones_size() << endl; // 输出:0
// 新增3个电话号码
person.add_phones()->set_number("13800138000");
person.add_phones()->set_number("13900139000");
person.add_phones()->set_number("18800188000");
cout << "新增后电话数:" << person.phones_size() << endl; // 输出:3
// 判断数组是否为空
if (person.phones_size() > 0) {
cout << "该联系人有电话号码" << endl;
} else {
cout << "该联系人无电话号码" << endl;
}
return 0;
}
5.2.2 函数 2:clear_字段名() —— 清空整个 repeated 数组
核心功能
清空repeated 字段中的所有元素,销毁每个元素对象并释放内存,将数组恢复为空状态,元素个数置 0,类比 C++vector::clear()。
语法(函数签名)
// 通用语法
void clear_字段名();
// 示例:基于通讯录proto的实际函数
void clear_phones(); // PersonInfo类:清空所有电话号码
void clear_contacts(); // Connects类:清空所有联系人
参数
无参数。
返回值
void(无返回值),直接修改当前 Message 对象的 repeated 字段状态。
实现本质
调用底层RepeatedPtrField的Clear()方法,逐个销毁数组中的所有元素对象,释放其占用的内存,最终将数组大小置 0。
使用场景
- 批量删除所有元素:如清空联系人的所有电话、清空整个通讯录;
- 重复使用 Message 对象:清空旧的元素数据,避免影响新的业务逻辑;
- 处理无效数据:当 repeated 字段的所有元素均为无效数据时,直接清空。
C++ 使用示例
#include "connects.pb.h"
#include <iostream>
using namespace std;
int main() {
connects::PersonInfo person;
// 新增2个电话号码
person.add_phones()->set_number("13800138000");
person.add_phones()->set_number("13900139000");
cout << "清空前电话数:" << person.phones_size() << endl; // 输出:2
// 清空所有电话号码
person.clear_phones();
cout << "清空后电话数:" << person.phones_size() << endl; // 输出:0
cout << "清空后是否为空:" << (person.phones_size() == 0 ? "是" : "否") << endl; // 输出:是
return 0;
}
5.2.3 函数 3:字段名(int index) const —— 按索引只读获取元素
核心功能
按索引只读获取repeated 数组中指定位置的元素,函数末尾const修饰为纯读操作,不可修改元素的字段值,类比 C++vector::at(index)(带越界检查)。
语法(函数签名)
// 通用语法
const 元素类型& 字段名(int index) const;
// 示例:基于通讯录proto的实际函数
const ::connects::Phone& phones(int index) const; // PersonInfo类:获取指定索引的电话
const ::connects::PersonInfo& contacts(int index) const; // Connects类:获取指定索引的联系人
参数
int index:数组的索引值,从 0 开始计数,必须满足0 ≤ index < 字段名_size(),否则会触发越界错误(程序崩溃)。
返回值
const 元素类型 &:指定索引位置元素的常量引用,元素类型为 repeated 字段的原始类型(如Phone/PersonInfo)。
实现本质
调用底层RepeatedPtrField的索引访问方法,返回指定位置元素对象的常量引用,带越界检查,索引非法时直接终止程序。
使用场景
所有需要按索引读取元素的场景,如获取第一个电话号码、获取最后一个联系人、指定位置的元素数据查询。
核心注意事项
- 索引从 0 开始,与 C++/Java 等编程语言的数组索引规则一致;
- 必须做越界判断(
index < 字段名_size()),否则会导致程序崩溃; - 返回的是常量引用,仅能读取元素的字段值,不可直接修改,修改需使用
mutable_字段名(int index)。
C++ 使用示例
#include "connects.pb.h"
#include <iostream>
using namespace std;
int main() {
connects::PersonInfo person;
// 新增2个电话号码
person.add_phones()->set_number("13800138000");
person.add_phones()->set_number("13900139000");
int phone_count = person.phones_size();
// 按索引读取第一个电话(index=0)
if (phone_count > 0) {
const connects::Phone& first_phone = person.phones(0);
cout << "第一个电话号码:" << first_phone.number() << endl; // 输出:13800138000
}
// 按索引读取最后一个电话(index=phone_count-1)
if (phone_count > 0) {
const connects::Phone& last_phone = person.phones(phone_count - 1);
cout << "最后一个电话号码:" << last_phone.number() << endl; // 输出:13900139000
}
// 错误示例:索引越界(程序崩溃)
// person.phones(10); // 10 >= 2,触发越界错误
return 0;
}
5.2.4 函数 4:mutable_字段名(int index) —— 按索引获取可修改元素指针
核心功能
按索引获取repeated 数组中指定位置元素的可修改指针,通过该指针可以直接修改元素的所有字段值,是按索引修改 repeated 元素的唯一方式。
语法(函数签名)
// 通用语法
元素类型* mutable_字段名(int index);
// 示例:基于通讯录proto的实际函数
::connects::Phone* mutable_phones(int index); // PersonInfo类:获取指定索引电话的可修改指针
::connects::PersonInfo* mutable_contacts(int index); // Connects类:获取指定索引联系人的可修改指针
参数
int index:数组的索引值,从 0 开始计数,必须满足0 ≤ index < 字段名_size(),否则触发越界错误。
返回值
元素类型*:指定索引位置元素的可修改指针,元素类型为 repeated 字段的原始类型(如Phone*/PersonInfo*)。
实现本质
调用底层RepeatedPtrField的索引访问方法,返回指定位置元素对象的可修改指针,带越界检查,索引非法时终止程序。
使用场景
所有需要按索引修改元素的场景,如修改联系人的第一个电话号码、修改通讯录中指定联系人的姓名 / 年龄、更新元素的某个字段值。
核心注意事项
- 索引需做越界判断,避免程序崩溃;
- 返回的是可修改指针,修改指针指向的字段值会直接反映到原 Message 对象中;
- 无需手动释放指针:指针由底层
RepeatedPtrField管理,Message 对象析构时自动释放,无需调用delete。
C++ 使用示例
#include "connects.pb.h"
#include <iostream>
using namespace std;
int main() {
connects::PersonInfo person;
// 新增1个电话号码(初始值)
person.add_phones()->set_number("13800138000");
cout << "修改前电话号码:" << person.phones(0).number() << endl; // 输出:13800138000
// 按索引获取可修改指针,修改电话号码
if (person.phones_size() > 0) {
connects::Phone* phone_ptr = person.mutable_phones(0);
phone_ptr->set_number("18800188000"); // 修改号码
phone_ptr->set_type("移动"); // 新增并设置电话类型
}
cout << "修改后电话号码:" << person.phones(0).number() << endl; // 输出:18800188000
cout << "修改后电话类型:" << person.phones(0).type() << endl; // 输出:移动
// 修改通讯录中指定联系人的信息
connects::Connects book;
book.add_contacts()->set_name("张三");
book.add_contacts()->set_age(25);
if (book.contacts_size() > 1) {
connects::PersonInfo* p_ptr = book.mutable_contacts(1);
p_ptr->set_name("李四");
p_ptr->set_age(30);
}
return 0;
}
5.2.5 函数 5:add_字段名() —— 新增元素并返回可修改指针(最高频)
核心功能
在 repeated 数组的末尾新增一个空的元素对象,并返回该元素的可修改指针,通过指针可直接为新元素的所有字段赋值,是向 repeated 字段添加元素的最核心、最高频函数,类比 C++vector::push_back(),
oi,这个函数我们要格外注意了,我们要知道,这个函数返回的就是向repeated的字段数组新增加的一个空对象的指针,就是说proto直接就帮我们创建好了一个新的字段对象,然后将其指针返回,我们可以调用该对象的设置函数去给这个对象的字段进行赋值,也就是set_xxx
语法(函数签名)
// 通用语法
元素类型* add_字段名();
// 示例:基于通讯录proto的实际函数
::connects::Phone* add_phones(); // PersonInfo类:新增电话并返回指针
::connects::PersonInfo* add_contacts(); // Connects类:新增联系人并返回指针
参数
无参数。
返回值
元素类型*:新创建元素对象的可修改指针,元素类型为 repeated 字段的原始类型(如Phone*/PersonInfo*)。
实现本质
调用底层RepeatedPtrField的Add()方法,自动创建空的元素对象(分配内存),将对象添加到数组末尾,返回其可修改指针,全程无需开发者手动调用new。
使用场景
所有向 repeated 字段添加元素的场景,如为联系人新增电话号码、为通讯录新增联系人、向订单新增商品,是实际开发中使用频率最高的 repeated 字段函数。
核心优势
- 一步到位:新增元素 + 返回可修改指针,直接赋值,无需额外的索引操作;
- 自动内存管理:无需手动创建元素对象,底层自动分配内存,避免内存泄漏;
- 保留顺序:新元素始终添加到数组末尾,严格保留添加顺序。
C++ 使用示例(两种写法,推荐链式调用)
#include "connects.pb.h"
#include <iostream>
using namespace std;
int main() {
connects::PersonInfo person;
// 写法1:链式调用(推荐,代码简洁,一行完成新增+赋值)
person.add_phones()->set_number("13800138000");
person.add_phones()->set_number("13900139000")->set_type("联通"); // 多字段链式赋值
// 写法2:先获取指针,再赋值(适合多字段赋值,逻辑清晰)
connects::Phone* phone_ptr = person.add_phones();
phone_ptr->set_number("18800188000");
phone_ptr->set_type("移动");
// 打印所有电话,验证添加顺序
cout << "共" << person.phones_size() << "个电话号码:" << endl;
for (int i=0; i<person.phones_size(); i++) {
cout << "电话" << i+1 << ":" << person.phones(i).number()
<< "(" << person.phones(i).type() << ")" << endl;
}
return 0;
}
运行结果:
共3个电话号码:
电话1:13800138000()
电话2:13900139000(联通)
电话3:18800188000(移动)
5.2.6 函数 6:字段名() const —— 获取整个数组常量引用(遍历)
核心功能
获取整个 repeated 字段(动态数组)的常量引用,返回底层RepeatedPtrField容器的引用,支持范围 for 循环和迭代器遍历,是批量遍历 repeated 元素的最优方式。
语法(函数签名)
// 通用语法
const ::PROTOBUF_NAMESPACE_ID::RepeatedPtrField<元素类型>& 字段名() const;
// 示例:基于通讯录proto的实际函数
const ::PROTOBUF_NAMESPACE_ID::RepeatedPtrField< ::connects::Phone >& phones() const;
const ::PROTOBUF_NAMESPACE_ID::RepeatedPtrField< ::connects::PersonInfo >& contacts() const;
参数
无参数,函数末尾const修饰为纯读操作。
返回值
const RepeatedPtrField <元素类型>&:底层动态数组容器的常量引用,RepeatedPtrField是 ProtoBuf 专属的动态对象指针数组,兼容 C++ 迭代器和范围 for 循环。
实现本质
直接返回 Message 对象中 repeated 字段的底层RepeatedPtrField容器引用,无额外计算,效率 O (1)。
使用场景
批量遍历 repeated 数组的所有元素,如打印联系人的所有电话号码、遍历通讯录的所有联系人并输出信息、批量判断元素的字段值。
核心优势
- 遍历简洁:支持 C++11 及以上的范围 for 循环,代码量少,可读性高;
- 兼容迭代器:支持普通迭代器和 const 迭代器,适配各种遍历场景;
- 效率高:直接操作底层容器,无元素拷贝,遍历效率与
vector一致。
C++ 使用示例(推荐范围 for 循环)
#include "connects.pb.h"
#include <iostream>
using namespace std;
int main() {
connects::PersonInfo person;
// 新增3个电话号码
person.add_phones()->set_number("13800138000")->set_type("移动");
person.add_phones()->set_number("010-12345678")->set_type("固定电话");
person.add_phones()->set_number("18800188000")->set_type("电信");
// 方式1:范围for循环(推荐,代码最简洁)
cout << "=== 范围for循环遍历电话 ===" << endl;
int i = 1;
for (const auto& phone : person.phones()) {
cout << "电话" << i++ << ":" << phone.number() << "(" << phone.type() << ")" << endl;
}
// 方式2:迭代器遍历(兼容C++11之前的版本)
cout << "\n=== 迭代器遍历电话 ===" << endl;
i = 1;
for (RepeatedPtrField<connects::Phone>::const_iterator it = person.phones().begin();
it != person.phones().end(); ++it) {
cout << "电话" << i++ << ":" << it->number() << "(" << it->type() << ")" << endl;
}
return 0;
}
运行结果:
=== 范围for循环遍历电话 ===
电话1:13800138000(移动)
电话2:010-12345678(固定电话)
电话3:18800188000(电信)
=== 迭代器遍历电话 ===
电话1:13800138000(移动)
电话2:010-12345678(固定电话)
电话3:18800188000(电信)
5.2.7 repeated 字段 6 个核心函数使用总结表
| 函数名 | 核心功能 | 类比 C++vector | 使用场景 | 核心特点 |
|---|---|---|---|---|
| 字段名_size () const | 获取数组元素个数 | vector::size() | 遍历边界、统计数量、判断空 | const、返回 int、O (1) 效率 |
| clear_字段名 () | 清空所有元素并释放内存 | vector::clear() | 批量删除、重复使用对象 | 无参、无返回、销毁所有元素 |
| 字段名 (int index) const | 按索引只读获取元素 | vector::at(index) | 按索引读取元素值 | const、返回常量引用、带越界检查 |
| mutable_字段名 (int index) | 按索引获取可修改元素指针 | 无直接类比 | 按索引修改元素字段 | 返回可修改指针、带越界检查 |
| add_字段名 () | 末尾新增元素并返回指针 | vector::push_back() | 新增元素(最高频) | 无参、返回指针、自动创对象 |
| 字段名 () const | 获取整个数组常量引用 | vector::cbegin() | 批量遍历所有元素 | const、支持范围 for / 迭代器 |
5.3 单值 + repeated 字段函数通用调用规则
- 所有函数均为 Message 类的成员函数:通过 Message 对象直接调用,如
person.set_name("张三")、book.add_contacts(); - 字段名严格一致:函数名中的字段名与.proto 中定义的字段名完全一致(大小写、拼写),如.proto 中
phones对应函数phones_size()/add_phones(); - const 函数不可修改对象:末尾带
const的函数为纯读操作,不可通过该函数修改对象的任何字段; - 指针无需手动管理:所有
mutable_xxx()/add_xxx()返回的指针,由 ProtoBuf 底层自动管理,无需手动new/delete,避免内存泄漏; - 越界检查必做:所有按索引操作的函数(
xxx(index)/mutable_xxx(index)),必须判断index < xxx_size(),否则程序崩溃。
六、实战案例:基于 Proto3 实现简易通讯录系统
结合前文讲解的嵌套 Message、repeated 限定符、编译后 C++ 函数,实现一个可交互、支持数据持久化的简易通讯录系统,覆盖用户输入、数据封装、序列化(字符串 + 文件)、反序列化、数据打印全流程,
6.1 需求分析
- 支持新增联系人:输入姓名、年龄、多个电话号码(回车结束电话输入);
- 支持数据序列化:将通讯录数据序列化为二进制字符串和本地二进制文件(持久化);
- 支持数据反序列化:从二进制字符串和本地文件中恢复通讯录数据;
- 支持数据打印:遍历并打印反序列化后的所有联系人信息;
- 输入处理:解决 C++
cin/getline混合输入的缓冲区残留问题。
6.2 项目结构
项目仅需 3 个文件,结构极简,适合快速上手:
contact_system/
├── connects.proto # Proto3数据结构定义
├── main.cpp # C++业务逻辑实现(核心)
└── CMakeLists.txt # 编译脚本(或直接使用g++命令)
6.3 完整 Proto3 定义(connects.proto)
使用外部定义独立 Message的推荐方式,包含Phone(电话)、PersonInfo(联系人)、Connects(通讯录)三层结构,核心使用repeated实现多电话、多联系人的存储:
syntax = "proto3";
package connects;
// 电话信息:独立Message,可复用
message Phone {
string number = 1; // 电话号码
string type = 2; // 电话类型(移动/联通/电信/固定电话,可选)
}
// 联系人信息:包含单值字段+repeated Phone字段
message PersonInfo {
string name = 1; // 姓名(单值)
sint32 age = 2; // 年龄(单值)
repeated Phone phones = 3; // 多个电话号码(repeated)
}
// 通讯录信息:包含repeated PersonInfo字段
message Connects {
repeated PersonInfo contacts = 1; // 多个联系人(repeated)
}
6.4 完整 C++ 业务代码(main.cpp)
逐行添加注释,解析核心函数的调用和业务逻辑,解决输入缓冲区、序列化 / 反序列化异常等问题:
#include "connects.pb.h"
#include <iostream>
#include <string>
#include <fstream>
#include <google/protobuf/repeated_field.h>
using namespace std;
using namespace connects;
// 函数声明:新增联系人(封装用户输入逻辑)
void AddPersonInfo(PersonInfo* person_info_ptr);
// 函数声明:打印通讯录所有联系人信息
void PrintContacts(const Connects& con);
int main(int argc, char* argv[]) {
// 验证ProtoBuf库版本,避免版本兼容问题
GOOGLE_PROTOBUF_VERIFY_VERSION;
// 命令行参数检查:需传入持久化文件路径(如./main contacts.bin)
if (argc != 2) {
cerr << "Usage: " << argv[0] << " [PERSIST_FILE_PATH]" << endl;
return -1;
}
const string persist_file = argv[1];
// 1. 初始化通讯录对象,新增联系人
Connects con;
// 新增空联系人,返回可修改指针(核心repeated函数:add_contacts())
PersonInfo* person_ptr = con.add_contacts();
// 传入指针,封装用户输入
AddPersonInfo(person_ptr);
// 2. 序列化1:序列化为二进制字符串(用于网络传输/内存交互)
string bin_str;
if (!con.SerializeToString(&bin_str)) {
cerr << "Error: 序列化到二进制字符串失败!" << endl;
return -1;
}
cout << "\n✅ 序列化到二进制字符串成功,字符串长度:" << bin_str.size() << " 字节" << endl;
// 3. 反序列化1:从二进制字符串恢复数据
Connects con_from_str;
if (!con_from_str.ParseFromString(bin_str)) {
cerr << "Error: 从二进制字符串反序列化失败!" << endl;
return -1;
}
cout << "\n=== 从二进制字符串反序列化的通讯录数据 ===" << endl;
PrintContacts(con_from_str);
// 4. 序列化2:序列化为本地二进制文件(持久化存储,核心)
fstream wf(persist_file, ios::binary | ios::out | ios::trunc);
if (!wf.is_open()) {
cerr << "Error: 打开文件" << persist_file << "失败!" << endl;
return -1;
}
if (!con.SerializeToOstream(&wf)) {
cerr << "Error: 序列化到文件" << persist_file << "失败!" << endl;
wf.close();
return -1;
}
wf.close();
cout << "\n✅ 序列化到本地文件" << persist_file << "成功!" << endl;
// 5. 反序列化2:从本地二进制文件恢复数据(持久化读取)
fstream rf(persist_file, ios::binary | ios::in);
if (!rf.is_open()) {
cerr << "Error: 打开文件" << persist_file << "失败!" << endl;
return -1;
}
Connects con_from_file;
if (!con_from_file.ParseFromIstream(&rf)) {
cerr << "Error: 从文件" << persist_file << "反序列化失败!" << endl;
rf.close();
return -1;
}
rf.close();
cout << "\n=== 从本地文件" << persist_file << "反序列化的通讯录数据 ===" << endl;
PrintContacts(con_from_file);
// 销毁ProtoBuf全局对象,释放内存
google::protobuf::ShutdownProtobufLibrary();
return 0;
}
/**
* @brief 新增联系人:处理用户输入,封装到PersonInfo对象
* @param person_info_ptr PersonInfo对象的可修改指针(由add_contacts()返回)
*/
void AddPersonInfo(PersonInfo* person_info_ptr) {
cout << "============ 开始新增联系人 ============" << endl;
// 输入姓名:使用getline接收带空格的姓名(如"张三 先生")
cout << "请输入联系人姓名:";
string name;
getline(cin, name);
person_info_ptr->set_name(name); // 单值字段函数:set_name()
// 输入年龄:cin接收数值,需处理缓冲区残留的换行符
cout << "请输入联系人年龄:";
int age;
while (!(cin >> age) || age < 0 || age > 150) {
cin.clear(); // 清除输入错误标志
cin.ignore(1024, '\n'); // 忽略缓冲区错误输入
cout << "年龄输入无效,请输入0-150的整数:";
}
person_info_ptr->set_age(age); // 单值字段函数:set_age()
cin.ignore(1024, '\n'); // 忽略cin后缓冲区残留的换行符,避免影响后续getline
// 输入多个电话号码:死循环,回车结束输入
cout << "请输入电话号码(直接回车结束输入):" << endl;
int phone_idx = 1;
while (true) {
cout << "电话" << phone_idx << ":";
string phone_num;
getline(cin, phone_num);
// 回车结束:phone_num为空
if (phone_num.empty()) {
break;
}
// 新增电话,链式赋值(核心repeated函数:add_phones())
Phone* phone_ptr = person_info_ptr->add_phones();
phone_ptr->set_number(phone_num);
// 可选:输入电话类型
cout << "请输入该电话类型(移动/联通/电信/固定电话,默认空):";
string phone_type;
getline(cin, phone_type);
if (!phone_type.empty()) {
phone_ptr->set_type(phone_type);
}
phone_idx++;
}
cout << "============ 联系人新增成功 ============" << endl;
}
/**
* @brief 打印通讯录所有联系人信息
* @param con 通讯录Connects对象的常量引用(纯读,不修改)
*/
void PrintContacts(const Connects& con) {
// 获取联系人个数(repeated函数:contacts_size())
int person_count = con.contacts_size();
if (person_count == 0) {
cout << "通讯录为空!" << endl;
return;
}
// 遍历所有联系人:范围for循环(repeated函数:contacts())
for (int i = 0; i < person_count; i++) {
const PersonInfo& person = con.contacts(i); // 按索引只读获取联系人
cout << "-------- 联系人" << i+1 << " --------" << endl;
cout << "姓名:" << person.name() << endl; // 单值字段函数:name()
cout << "年龄:" << person.age() << endl; // 单值字段函数:age()
// 遍历该联系人的所有电话
int phone_count = person.phones_size();
if (phone_count == 0) {
cout << "电话号码:无" << endl;
continue;
}
cout << "电话号码:" << endl;
for (int j = 0; j < phone_count; j++) {
const Phone& phone = person.phones(j); // 按索引只读获取电话
cout << " " << j+1 << ". " << phone.number();
if (!phone.type().empty()) {
cout << "(" << phone.type() << ")";
}
cout << endl;
}
}
}
6.5 编译与运行
6.5.1 步骤 1:编译.proto 文件生成 C++ 代码
在项目根目录执行 protoc 编译命令,生成connects.pb.h和connects.pb.cc:
# 核心编译命令:当前目录编译,生成C++代码到当前目录
protoc -I=. --cpp_out=. connects.proto
执行成功后,项目根目录会新增两个文件:connects.pb.h(头文件)、connects.pb.cc(源文件)。
6.5.2 步骤 2:编译 C++ 业务代码
直接使用 g++ 命令(推荐,极简)
# 编译main.cpp + connects.pb.cc,链接ProtoBuf库,生成可执行文件main
g++ -std=c++11 main.cpp connects.pb.cc -o main `pkg-config --cflags --libs protobuf`
参数说明:
-std=c++11:支持 C++11 的范围 for 循环等特性;pkg-config --cflags --libs protobuf:自动获取 ProtoBuf 的头文件路径和链接库,避免手动指定。
6.5.3 步骤 3:运行可执行文件
# 直接运行:传入持久化文件路径(如contacts.bin)
./main contacts.bin
6.5.4 典型运行结果
============ 开始新增联系人 ============
请输入联系人姓名:张三
请输入联系人年龄:25
请输入电话号码(直接回车结束输入):
电话1:13800138000
请输入该电话类型(移动/联通/电信/固定电话,默认空):移动
电话2:010-12345678
请输入该电话类型(移动/联通/电信/固定电话,默认空):固定电话
电话3:
============ 联系人新增成功 ============
✅ 序列化到二进制字符串成功,字符串长度:45 字节
=== 从二进制字符串反序列化的通讯录数据 ===
-------- 联系人1 --------
姓名:张三
年龄:25
电话号码:
1. 13800138000(移动)
2. 010-12345678(固定电话)
✅ 序列化到本地文件contacts.bin成功!
=== 从本地文件contacts.bin反序列化的通讯录数据 ===
-------- 联系人1 --------
姓名:张三
年龄:25
电话号码:
1. 13800138000(移动)
2. 010-12345678(固定电话)
运行成功后,项目根目录会生成二进制持久化文件contacts.bin,即使删除可执行文件,该文件仍保留通讯录数据,重新运行程序时可直接从该文件反序列化恢复数据。
完整示例代码
connects.proto:
syntax="proto3";
package connects;
//ok,那么在本文件中,我们来进一步学习proto的语法,那么其实也就是对上次所学习的扩展啦
//那么我们首先需要知道的就是,我们是可以是在一个message结构体中定义新的message结构体的
//其实这也就是嵌套定义,就像我们C++所使用的嵌套类,嵌套结构体等等
//但是需要注意,里面的message不需要我们加上唯一标识数值哦
//但是里面的message的里面的字段,依旧是需要加上唯一标识数值,但是是可以和外面的字段字段唯一标识数值一样
//因为 ProtoBuf 的字段编号(唯一标识)只需要在「同一个 Message 内部唯一」,不同 Message 之间的字段编号可以重复。
//这一点我们需要知道,因为我们以后也有可能会用到这个
//eg:
/*
message Example
{
sint32 age=1;
message Example1
{
string name=1;//注意这里是可以和外面的字段唯一标识数值一样的哦,因为是不同的message结构体,所以是可以相同的
//因为 ProtoBuf 的字段编号(唯一标识)只需要在「同一个 Message 内部唯一」,不同 Message 之间的字段编号可以重复。
}
}
*/
//当然了,也支持不在message结构体内定义message结构体就可以使用别的message结构体
//其实也就在外面创建message结构体,然后在要使用的那个message结构体中,声明要被使用的message结构体即可
//依旧是遵循 字段类型 字段名 = 唯一标识数值
//只不过定义message结构体的话,字段类型就是换为要被使用的message结构体的名字
//eg:
/*
message Example1
{
string name=1;//注意这里是可以和别的message的字段唯一标识数值一样的哦,因为是不同的message结构体,所以是可以相同的
//因为 ProtoBuf 的字段编号(唯一标识)只需要在「同一个 Message 内部唯一」,不同 Message 之间的字段编号可以重复。
}
message Example
{
sint32 age=1;
Example1 ex=2;
}
*/
//这两种嵌套使用方式,我一般比较推荐第二种啦,更加符合我们平时定义字段的习惯
//像第一种方式的话,就没有在嵌套message结构体加唯一标识数值,总感觉是怪怪的哈哈哈
// 下面是ProtoBuf嵌套Message的详细解析
// 嵌套Message:简单说就是一个Message里用到另一个Message,类比C++的嵌套类/嵌套结构体
// 作用:把关联的复杂数据结构整合在一起,让proto文件的结构更清晰,
// 适配「一个对象包含另一个对象」的业务场景(比如联系人包含多个电话对象)
// 【最核心前置规则】:所有字段的唯一标识数值(=后面的数字),仅需要在「它自己所属的同一个Message大括号内部」唯一即可!
// 【通俗解释】:不同的Message之间,字段的数字标识可以重复,完全不冲突;只有在同一个{}里的字段,数字不能重复
/************************ 方式1:Message内部直接嵌套定义子Message ************************/
// 通俗特点:在父Message的{}里面,直接定义需要用到的子Message,子Message是父Message的“内部成员”
// 核心规则1:内部嵌套的子Message本身(只是结构体定义),不需要写唯一标识数值(不用加 = 数字),因为它不是一个“字段”,只是一个数据结构定义
// 核心规则2:子Message内部的普通字段(string/int32等),必须写唯一标识数值,且这个数字仅需在「该子Message自己的{}内部」唯一
// 核心规则3:父Message的普通字段,唯一标识数值仅需在「父Message自己的{}内部」唯一,和子Message的字段数字可重复,无任何冲突
/*
// 父Message:Example(相当于外层大结构体)
message Example {
// 父Message的普通字段:年龄
// 唯一标识=1:仅在Example这个{}内部唯一即可,这是父Message的第一个字段
sint32 age = 1;
// 内部嵌套定义子Message Example1:是Example的内部结构体,仅能在Example内部使用
// 注意:这个子Message Example1本身,不用写 = 数字,因为它不是字段,只是结构体定义
message Example1 {
// 子Message的普通字段:姓名
// 唯一标识=2:仅在Example1这个{}内部唯一即可,和父Message的age=1重复也没关系,完全不冲突
string name = 2;
// 重点提醒:如果在Example1里再加一个字段,就不能写=2了(同一个{}里数字不能重复),比如不能再写int32 id=2
}
}
*/
/************************ 方式2:外部定义Message,内部声明使用 【推荐使用】************************/
// 通俗特点:先在所有Message外面,单独定义一个独立的Message(相当于全局结构体),然后在需要用的父Message{}里,直接把它当作普通字段来用
// 核心规则1:使用时完遵循ProtoBuf基础写法——「字段类型 + 字段名 = 唯一标识数值」,和定义string/int32字段的格式一模一样
// 核心规则2:这里的“字全段类型”,直接换成「外部定义好的独立Message名称」就行,其他要求完全不变
// 核心规则3:唯一标识数值的约束,还是遵循最核心规则——仅在「使用它的父Message{}内部」唯一即可
// 推荐原因:① 符合平时写代码的习惯,先定义再使用,读起来更顺;② 这个独立的Message,其他任意Message都能直接用,不用重复定义;③ 后续修改这个Message,所有用到它的地方都能生效,维护更方便
// 简单说:这种方式“好读、好复用、好维护”,实际开发中优先用这种!
// 步骤1:全局外部定义独立的Message(相当于全局结构体),和定义普通Message的写法完全一样
/*
// 独立Message:Example1,所有Message都能直接使用它
message Example1 {
// 普通字段:姓名
// 唯一标识=1:仅在Example1这个{}内部唯一即可,这是Example1的第一个字段
string name = 1;
// 假如再加一个字段,比如手机号,就可以写 string phone = 2; (同一个{}里数字依次排就行)
}
*/
// 步骤2:在父Message的{}里,声明使用上面定义好的独立Message
/*
// 父Message:Example2
message Example2 {
// 父Message的普通字段:年龄
// 唯一标识=1:仅在Example2这个{}内部唯一即可,和外部Example1里的name=1重复,完全不冲突!
sint32 age = 1;
// 声明使用外部独立的Example1:把Example1当作“字段类型”,和写string/int32的格式完全一样
// 字段类型:Example1(外部定义的Message名称)
// 字段名称:ex(自定义,随便起,符合命名规范就行)
// 唯一标识=2:仅在Example2这个{}内部唯一即可(因为age已经用了1,所以这里用2)
Example1 ex = 2;
// 假如再加一个字段,比如地址,就可以写 string address = 3; (按顺序排,同一个{}里不重复就行)
}
*/
// 【PersonInfo关联Phone的两种核心写法解析:独立写法 & 内部嵌套写法】
// 独立写法:Phone单独全局定义,是独立的Message,仅被PersonInfo引用,二者无嵌套关系
// 编译后C++类名:connects::Phone(无PersonInfo_Phone类,类名与Message名一致)
// C++访问方式:直接用connects::Phone
// C++ add_phone()返回值:connects::Phone*
// 内部嵌套写法:Phone定义在PersonInfo内部,是PersonInfo的专属子Message,二者为嵌套关系
// 编译后C++类名:connects::PersonInfo_Phone(按主消息名_子消息名规则生成)
// C++访问方式:PersonInfo内自动生成别名,可简写为connects::PersonInfo::Phone(等价完整类名)
// C++ add_phone()返回值:connects::PersonInfo::Phone*(或connects::PersonInfo_Phone*)
//那么接下来我们再思考一个问题,我们定义的字段,有时候是肯定要支持多个数据的,比如一个人的电话号码就不可能只有一个
//那么这就需要我们知道了,在protobuffer中,默认是给所有字段都支持一个数据的,不支持同一个字段有多个值
//但是,肯定也能由我们手动指定可重复,而这就需要我们去在字段类型前面加上repeated限定符
//加上这个之后,同一个字段,我们就可以令其有多个值(其实本质是转换为数组了,这个我下面会详细说)
//那么默认是单个值的,就是在字段类型前面加上singular限定符了
//•singular:消息中可以包含该字段零次或一次(不超过一次)。 proto3 语法中,字段默认使用该规则。
//•repeated:消息中可以包含该字段任意多次(包括零次),其中重复值的顺序会被保留。可以理解为定义了一个数组。
// ---------------------- ProtoBuf 字段单值/多值限定符:singular / repeated ----------------------
// 业务场景:实际开发中部分字段需要存储多个值(比如一个人会有多个电话号码、多个地址)
// 核心前提:ProtoBuf中所有字段,默认只支持「单个值」,无法直接存储多个同字段数据
// 解决办法:通过添加「限定符」手动指定字段是「单值」还是「可重复多值」,限定符写在「字段类型」前面
// 两个核心限定符:singular(单值,默认)、repeated(可重复多值,手动指定),以下详细讲解
// 1. 单值限定符:singular(proto3 语法默认规则,无需手动写这个关键字!)
// 作用:指定字段为「单值字段」,该字段在消息中可写0次(不写)或1次,最多只能有1个值,绝对不能写多个
// 通俗理解:就是常规的单个字段,比如一个人的姓名、年龄,一般只有一个,用默认规则即可
// 示例:string name = 1; 等价于 singular string name = 1; (手动写singular也可以,但是没必要)
// 2. 可重复多值限定符:repeated(需手动写在字段类型前,核心重点!)
// 作用:指定字段为「可重复字段」,该字段在消息中可写0次、1次或任意多次,支持存储多个值
// 关键特性:重复写入的值会「保留顺序」,不会乱序
// 本质理解:加上repeated后,该字段会被转换成「数组/列表」类型,专门用来存多个同类型数据
// 核心用法:直接把 repeated 写在「字段类型」前面,其余语法(类型+字段名=唯一标识)完全不变
// 适用场景:需要存储多个同类型数据的字段,比如电话号码、邮箱、地址等
// ---------------------- 经典示例:联系人信息(姓名/年龄单值 + 多个电话号码多值)----------------------
// 结合之前的嵌套Message,实现「一个联系人对应多个电话号码」的实际业务需求
// 重点:repeated 修饰嵌套Message类型字段,用法和修饰原生类型(string/int32)完全一致
// 嵌套定义:电话号码子消息(单个电话的信息)
/*
message Phone
{
string number = 1; // 单个电话号码(单值,默认singular)
}
*/
// 联系人主消息
/*
message PeopleInfo
{
// 单值字段:姓名、年龄(默认singular,无需手动加关键字,最多各存1个值)
string name = 1; // 姓名:单值,只能写1个
int32 age = 2; // 年龄:单值,只能写1个
// 可重复多值字段:电话号码列表(手动加repeated,支持存多个Phone类型数据)
// 写法:repeated + 字段类型(Phone) + 字段名(phones) + = + 唯一标识
// 理解:phones 本质是一个Phone类型的数组/列表,可添加多个电话号码,且保留添加顺序
repeated Phone phones = 3; // 电话号码:多值,可写0个、1个或多个(完美适配实际业务)
}
*/
// ---------------------- 核心要点提炼----------------------
// 1. proto3 默认单值:所有字段不写限定符,就是singular单值,最多1个值,不能多写;
// 2. 多值手动加repeated:需要存多个值时,直接在字段类型前加repeated,其余语法不变;
// 3. repeated 本质是数组:保留值的顺序,支持0/1/任意多次赋值,原生类型/自定义Message类型都能修饰;
message Phone
{
string number = 1;
//ProtoBuf 的字段编号(唯一标识)只需要在「同一个 Message 内部唯一」,不同 Message 之间的字段编号可以重复。
}
message PersonInfo
{
string name = 1;
sint32 age = 2;
repeated Phone phone = 3;
}
message Connects
{
repeated PersonInfo contacts = 1;//联系人信息,通讯录中记录的肯定是联系人的信息啊哇
}
//ok,那么接下来,我就来解析一下,repeated的定义为数组,究竟是什么意思???
//那么其实也是很好理解的,大家可以类比为我们在结构体中定义一个 数组成员变量 !!!
//那么在proto中的repeated字段中,就是相当于将该字段定义为一个数组,一个存储着该类型字段的数组
//比如
/*
message Connects
{
repeated PersonInfo = 1;//联系人信息,通讯录中记录的肯定是联系人的信息啊哇
}
*/
//其实就是相当于:
/*
struct Connects
{
PersonInfo arr[];
}
*/
//大家可以这么理解,那么知道了这一点之后,我们后续理解repeated相关的函数就会非常好理解了
// 给字段添加repeated限定符后,说它被「定义为数组」,具体到底是什么意思?
// 这个知识点是后续学习所有repeated相关操作函数的核心基础,只有彻底理解其本质,后续学习增、删、改、查等函数时
// 才能做到一看就懂、一通百通,所以一定要认真理解这个核心类比逻辑,把底层本质吃透!
// 首先给出最核心、最直白的理解结论:proto中被repeated修饰的字段,其本质完全等同于我们在C++编程语言中
// 定义结构体时,在结构体内部声明的「数组成员变量」,二者的底层设计逻辑、实际使用目的、存储数据的形式完全一致
// 我们可以直接用C++结构体数组成员的思路,来理解proto中repeated字段的所有逻辑,零门槛无难度!
// 再进一步通俗解释:当我们在proto的message中,给某个字段加上repeated限定符后,就相当于明确告诉ProtoBuf编译器
// 这个字段不再是存储单个数据的普通字段,而是要被处理成一个「专用数组」,这个数组的唯一作用,就是专门存储
// 该字段类型对应的所有数据,数组中的每一个元素,都是该类型的一个独立、完整的数据单元,和C++数组中存储
// 同类型结构体对象的逻辑完全一样!
// 这里要先明确一个重要前提:和C++中定义数组成员必须先确定数组元素的类型一样,在proto中使用repeated定义数组类型字段
// 也必须先提前定义好「数组要存储的元素类型」,这个元素类型可以是ProtoBuf的原生基础类型(如string/int32/sint32等)
// 也可以是我们自己自定义的任意message类型(比如联系人信息、电话号码信息等),只有先定义好元素类型,后续才能
// 用repeated基于该类型定义对应的数组字段,这是语法规范,也是实际使用的必要前提,缺一不可!
// 接下来我们结合实际的业务场景示例,用proto代码+对应的C++结构体代码做直接对比,让大家能最直观、最清晰地
// 理解二者的对应关系,示例选用最贴近实际开发的「通讯录」场景——通讯录的核心需求就是存储多个联系人信息,
// 正好需要用到数组类型,完美适配repeated的使用场景,能更好地贴合实际开发,理解起来也更有代入感!
// 第一步:提前定义好repeated数组要存储的元素类型——联系人信息message
// 这个PersonInfo就等同于C++中我们为数组提前定义的结构体类型,是数组中每个元素的具体数据结构
// 里面定义了联系人的核心基础信息,每个字段都是普通的单值字段(默认singular),符合常规数据定义逻辑
// message PersonInfo {
// string name = 1; // 联系人姓名:单值字段,每个联系人仅有一个姓名
// int32 age = 2; // 联系人年龄:单值字段,每个联系人仅有一个年龄
// string phone = 3; // 联系人电话号码:单值字段,单个联系人的一个电话号码(如需多个可结合嵌套+repeated)
// string address = 4; // 联系人居住地址:单值字段,每个联系人的核心居住地址
// }
// 第二步:在proto的message中,使用repeated定义数组类型字段,实现存储多个联系人信息的需求
// 这个Connects就等同于C++中的外层结构体,专门用来封装通讯录相关的所有数据,其中核心的contacts字段
// 就是用repeated定义的数组字段,专门存储多个PersonInfo类型的联系人信息,完全匹配通讯录的业务需求
// 重点强调:repeated字段的完整语法必须是「repeated + 数组元素类型 + 自定义字段名 + = + 唯一标识数值」
// 五个部分缺一不可,尤其是不能省略自定义字段名,这是proto的语法硬性要求,省略会直接导致编译报错!
// message Connects {
// // repeated + PersonInfo(元素类型) + contacts(自定义字段名) + = + 1(唯一标识)
// // 本质理解:这个contacts字段,就是一个专门用于存储PersonInfo类型数据的数组,数组的长度不固定
// // 支持动态添加、删除元素,和C++中动态数组/结构体数组的逻辑完全一致,能完美适配“通讯录中存储任意多个联系人”的需求
// // 字段名contacts是自定义的,可根据实际业务需求修改(如person_list、contact_list等),只要符合命名规范即可
// // 唯一标识数值1,依旧遵循之前的核心规则:仅在当前Connects这个message的{}内部唯一即可,和其他message的字段标识无冲突
// repeated PersonInfo contacts = 1; // 通讯录核心字段:存储多个联系人信息,对应C++结构体中的数组成员变量
// }
// 对应C++结构体的直观类比代码(完全等价,一看就懂)
// 这部分代码把proto中的定义直接转换成了C++中大家最熟悉的结构体写法,二者的每一个部分都是一一对应的
// 能让大家从最熟悉的C++语法角度,彻底理解proto中repeated字段的底层本质,消除理解障碍!
/*
// 第一步:先定义数组的元素类型——联系人结构体,完全对应proto中的PersonInfo message
// 结构体中的每个成员变量,都和proto中PersonInfo的字段一一对应,存储单个联系人的完整信息
// struct PersonInfo {
// string name; // 对应proto PersonInfo.name,联系人姓名
// int32_t age; // 对应proto PersonInfo.age,联系人年龄
// string phone; // 对应proto PersonInfo.phone,联系人电话号码
// string address; // 对应proto PersonInfo.address,联系人居住地址
// };
// 第二步:定义外层通讯录结构体,完全对应proto中的Connects message
// 结构体中定义数组成员变量,专门存储多个PersonInfo类型的联系人信息,对应proto中repeated修饰的contacts字段
// struct Connects {
// // arr数组:专门存储PersonInfo类型数据,完全对应proto中Connects.contacts字段
// // 这里用动态数组形式更贴合ProtoBuf的实际实现,和proto中repeated支持动态增删的特性完全一致
// // 其本质就是一个存储自定义结构体类型的数组,和proto中repeated字段的本质毫无区别!
// PersonInfo arr[];
// };
*/
// 现在我们做一次「proto与C++的逐行核心对应拆解」,把二者的对应关系讲透、讲细,彻底夯实理解基础:
// 1. proto中的message PersonInfo <====> C++中的struct PersonInfo:二者都是「数组元素的基础类型定义」,
// 用于规定数组中每个元素的具体数据结构,是定义数组的前提和基础;
// 2. proto中的message Connects <====> C++中的struct Connects:二者都是「封装数组的外层容器」,
// 用于把数组字段和其他可能的相关字段封装在一起,形成一个完整的业务数据结构;
// 3. proto中的repeated PersonInfo contacts = 1 <====> C++中的PersonInfo arr[]:二者都是「结构体/消息中的数组成员」,
// 核心作用就是存储多个同类型的独立数据单元,是实现“多值存储”的核心载体,也是repeated字段的本质所在;
// 4. proto中repeated后的PersonInfo <====> C++中数组前的PersonInfo:二者都是「数组的元素类型」,
// 规定了数组中只能存储该类型的数据,保证数组内数据类型的一致性;
// 补充几个关于repeated数组本质的关键注意点,进一步完善理解,避免后续踩坑:
// 1. repeated定义的数组是「动态数组」:和C++中的静态数组不同,ProtoBuf中的repeated数组支持动态增、删、改元素,
// 无需提前指定固定长度,能根据实际业务需求灵活存储任意多个数据(0个、1个、数十个、数百个均可),
// 这也是ProtoBuf适配实际开发中“数据量不固定”需求的重要特性;
// 2. 数组内元素是「独立的完整数据」:repeated数组中的每一个元素,都是对应元素类型的一个独立、完整的对象,
// 比如contacts数组中的每个元素,都是一个完整的PersonInfo对象,包含name、age、phone、address等所有字段,
// 彼此之间相互独立,修改其中一个元素的字段值,不会影响其他元素;
// 3. 语法规则不冲突:给字段加repeated修饰后,仅改变字段的存储形式(从单值变为数组),其他所有proto基础语法规则
// 均保持不变,尤其是字段的唯一标识数值规则,依旧遵循「所属message内部唯一」的核心规则,不受repeated修饰的影响;
// 最后再强调一次:掌握「repeated字段 = C++结构体数组成员」这个核心类比关系的重要性!
// 因为ProtoBuf编译器根据.proto文件生成的C++/Java/Python等各语言的代码中,对repeated字段的所有操作函数
// 都是基于「数组操作逻辑」设计的,比如C++中会生成add_contacts()(向数组中添加一个新的PersonInfo元素)、
// contacts_size()(获取数组的当前元素个数,即通讯录中联系人的数量)、mutable_contacts(int index)(获取数组中
// 指定索引位置的元素,用于修改该位置的联系人信息)、clear_contacts()(清空数组中所有元素,即清空通讯录)等函数,
// 这些函数的命名、功能、使用逻辑,完全和我们平时在C++中操作结构体数组的思路一致,只要现在吃透了repeated的数组本质,
//下面我们来学习一下关于我们上面所学的一些函数使用方法
//还是那句话,要牢记就是数组,就是类!!!
// ---------------------- ProtoBuf编译后C++代码(contacts.pb.h) 实用常用函数全解析 ----------------------
// 核心前置知识:
// 1. protoc根据proto自动生成对应C++类,嵌套Message生成「外部类_内部类」命名(PeopleInfo.Phone→PeopleInfo_Phone);
// 2. 所有类继承ProtoBuf的Message基类,自带通用拷贝/合并能力,final修饰不可继承,保证代码稳定性;
// 3. repeated数组字段,C++底层通过RepeatedPtrField实现——ProtoBuf专属动态对象指针数组,自动管理内存,无需手动new/delete;
// 4. 仅解析实际开发中80%场景会用到的高频函数,剔除release_/set_allocated_等极少使用的方法;
// ================================= 模块1:嵌套Message生成类 - PeopleInfo_Phone 【单值字段常用函数】=================================
// 生成逻辑:proto中PeopleInfo内嵌套定义Phone消息,protoc自动生成该类,对应单个电话号码的操作,所有函数为string number=1字段的配套操作
//这个我们就要格外注意了,我们上面说到,可以嵌套定义message结构体,也就是一个结构体中可以有其他的结构体
//那么当我们想去访问被嵌套的结构体时,我们要怎么访问呢?如何去调用它的函数呢???
//其实此时该被嵌套的结构体的类名字就是 外层结构体名字_内层结构体名字 这个形式,从外到内,使用_隔开
//那么这是我们使用一个结构体内嵌套定义另一个结构体时,我们要这样子的
//而要是是在外面定义,然后结构体内部自己使用外面定义的结构体的话,就不需要这样子
//那就是和直接访问一样,不需要所谓的 外层结构体名字_内层结构体名字
class PeopleInfo_Phone final : public ::PROTOBUF_NAMESPACE_ID::Message {
public:
// 通用拷贝方法【常用】:实现同类型对象深拷贝,覆盖当前对象所有字段值
// 语法:void CopyFrom(const 类名& from);
// 参数:const PeopleInfo_Phone& from - 源对象(const+引用,避免修改源对象、减少拷贝开销)
// 返回值:void - 无返回值,直接深拷贝源对象所有字段到当前对象
// 实现本质:逐字段拷贝源对象内容,包括空字段状态,完全覆盖当前对象原有值
// 用法:PeopleInfo_Phone p1, p2; p2.set_number("13800138000"); p1.CopyFrom(p2); // p1的number与p2完全一致
using ::PROTOBUF_NAMESPACE_ID::Message::CopyFrom;
void CopyFrom(const PeopleInfo_Phone& from);
// ---------------------- 单值字段string number=1 专属核心常用函数 ----------------------
// 1. 字段清空【高频】:将number字段重置为空状态,释放内存
// 语法:void clear_字段名();
// 参数:无
// 返回值:void - 无返回值,重置字段为“未设置”,释放字符串占用内存
// 实现本质:销毁当前字段的字符串对象,标记字段为空,后续调用number()返回空字符串
// 用法:PeopleInfo_Phone p; p.set_number("13800138000"); p.clear_number(); // p.number() 返回""
void clear_number();
// 2. 常量获取字段值【高频】:只读获取number值,不可修改
// 语法:const 字段类型& 字段名() const;
// 参数:无(末尾const,保证调用后对象字段不被修改,纯读操作)
// 返回值:const std::string& - 字符串常量引用,避免值拷贝,提升效率
// 实现本质:返回对象内number字段的原生字符串引用,未设置则返回空字符串常量
// 用法:PeopleInfo_Phone p; p.set_number("13800138000"); std::string num = p.number(); // num = "13800138000"
const std::string& number() const;
// 3. 设置字段值【高频】:为number赋值,支持多种传参形式(字符串、引用、右值等)
// 语法:void set_字段名(字段类型值/引用/右值);
// 参数:const std::string& / std::string&& / const char* - 待设置的电话号码字符串
// 返回值:void - 无返回值,直接为字段赋值,覆盖原有值
// 实现本质:将传入值赋值给对象内的number字段,自动管理字符串内存,无需手动分配
// 用法:PeopleInfo_Phone p; p.set_number("13800138000"); // 直接传字符串常量;p.set_number(std::string("13900139000")); // 传字符串对象
template <typename ArgT0 = const std::string&, typename... ArgT>
void set_number(ArgT0&& arg0, ArgT... args);
//这是什么?这不就是万能模版吗!!!
// 4. 可变获取字段指针【高频】:返回number字段的可修改指针,直接修改字段值
// 语法:字段类型* mutable_字段名();
// 参数:无
// 返回值:std::string* - 字符串可修改指针,若字段为空则自动初始化空字符串
// 实现本质:检查字段是否初始化,未初始化则创建空字符串,返回其可修改指针
// 用法:PeopleInfo_Phone p; p.mutable_number()->assign("13800138000"); // 直接通过指针修改值
// 这个函数就是返回该字段的指针啦,就相当于我们平时定义类时,提供返回成员变量指针的API,用于外界通过传址引用修改值
};
// ================================= 模块2:主消息类 - PeopleInfo 【单值+repeated字段常用函数】=================================
// 生成逻辑:对应proto中的PeopleInfo消息,包含单值字段(name/age)和repeated Phone字段,重点解析repeated phone=3的高频操作
class PeopleInfo final : public ::PROTOBUF_NAMESPACE_ID::Message {
public:
using ::PROTOBUF_NAMESPACE_ID::Message::CopyFrom;
void CopyFrom(const PeopleInfo& from); // 通用拷贝,用法同PeopleInfo_Phone,不再赘述
// 类型别名【实用】:将嵌套类PeopleInfo_Phone简写为Phone,简化代码书写,开发中优先用该别名
// 用法:PeopleInfo::Phone phone; // 等价于 PeopleInfo_Phone phone; 代码更简洁
typedef PeopleInfo_Phone Phone;
// ---------------------- repeated .PeopleInfo.Phone phone=3 专属核心常用函数【开发中最高频】 ----------------------
// 底层实现:该字段在C++中是RepeatedPtrField<PeopleInfo_Phone>类型——ProtoBuf动态对象指针数组,自动管理内存,支持动态增删
// 核心规律:repeated字段的操作函数均以「字段名」为核心,遵循「_size/clear_/add_/字段名(索引)/mutable_字段名(索引)」命名规则
// 这个是很重要的,但是也很好理解,我们依旧是要牢牢掌握一点,本质就是数组!!!
// 1. 获取数组元素个数【高频】:返回当前电话号码的数量
// 其实就是相当于调用vector xxx.size()获取数组长度罢了
// 语法:int 字段名_size() const;
// 参数:无(const,纯读操作,不修改对象)
// 返回值:int - 数组中元素的个数,无电话则返回0
// 实现本质:调用RepeatedPtrField的size()方法,返回底层动态数组的元素数量
// 用法:PeopleInfo p; int phoneCount = p.phone_size(); // 初始为0,添加后返回实际数量
int phone_size() const;
// 那么这个函数其实就是相当于返回我们所定义的repeated字段,有几个值!!!
// 2. 清空整个数组【高频】:删除所有电话号码,重置为空数组
// 其实就是相当于调用vector xxx.clear()清空数组罢了
// 语法:void clear_字段名();
// 参数:无
// 返回值:void - 无返回值,删除底层数组所有对象,释放内存,元素个数置0
// 实现本质:调用RepeatedPtrField的Clear()方法,逐个销毁数组内的Phone对象,清空数组
// 用法:PeopleInfo p; p.add_phone()->set_number("13800138000"); p.clear_phone(); // p.phone_size() 变为0
void clear_phone();
// 3. 按索引获取不可修改元素【高频】:只读获取指定位置的电话号码,不可修改
// 其实就是相当于调用vector xxx[]获取数组指定下标的元素罢了,依旧是从0开始计数哦
// 语法:const 元素类型& 字段名(int index) const;
// 参数:int index - 数组索引,从0开始,必须小于phone_size(),否则越界报错
// 返回值:const PeopleInfo_Phone& - Phone对象常量引用,仅可读,不可修改字段值
// 实现本质:通过RepeatedPtrField按索引访问,返回对应位置对象的常量引用
// 用法:PeopleInfo p; p.add_phone()->set_number("13800138000"); std::string num = p.phone(0).number(); // 获取第0个电话
const ::PeopleInfo_Phone& phone(int index) const;
// 4. 按索引获取可修改元素指针【高频】:返回指定位置Phone对象的可修改指针,直接修改该电话
// 这个函数就是返回该字段的指针啦,就相当于我们平时定义类时,提供返回成员变量指针的API,用于外界通过传址引用修改值
// 语法:元素类型* mutable_字段名(int index);
// 参数:int index - 数组索引,从0开始,必须小于phone_size(),否则越界报错
// 返回值:PeopleInfo_Phone* - Phone对象可修改指针,可直接调用set_number()等方法
// 实现本质:通过RepeatedPtrField按索引访问,返回对应位置对象的可修改指针
// 用法:PeopleInfo p; p.add_phone()->set_number("13800138000"); p.mutable_phone(0)->set_number("13900139000"); // 修改第0个电话
::PeopleInfo_Phone* mutable_phone(int index);
// 5. 新增数组元素【最高频】:向数组末尾添加一个空的Phone对象,返回其可修改指针
// 这个函数非常的重要!!!就类似调用vector xxx.push_back()向数组添加新元素
// 但是本函数不同的是,当我们调用了该函数之后,proto会直接就组末尾创建空的对象
// 然后把这个新的对象的指针返回,我们要获取到该返回值,然后再调用该指针所指向的对象的set等等函数啦
// 这就相当于是直接修改新添加的元素!!!
// 语法:元素类型* add_字段名();
// 参数:无
// 返回值:PeopleInfo_Phone* - 新创建的Phone对象可修改指针,直接调用set_*方法赋值即可
// 实现本质:RepeatedPtrField自动在数组末尾创建空的Phone对象,管理其内存,返回可修改指针(无需手动new)
// 用法:PeopleInfo p; p.add_phone()->set_number("13800138000"); p.add_phone()->set_number("13900139000"); // 添加2个电话
::PeopleInfo_Phone* add_phone();
// 6. 获取整个不可修改数组【常用】:返回整个repeated字段的常量引用,用于遍历所有元素
// 语法:const RepeatedPtrField<元素类型>& 字段名() const;//和上面的是重载关系
// 参数:无(const,纯读操作)
// 返回值:const RepeatedPtrField<PeopleInfo_Phone>& - 底层动态数组常量引用,支持范围for遍历
// 实现本质:直接返回底层的RepeatedPtrField容器,用于批量遍历、读取所有元素
// 用法:PeopleInfo p; /* 添加电话 */ for (const auto& phone : p.phone()) { std::cout << phone.number() << std::endl; }
const ::PROTOBUF_NAMESPACE_ID::RepeatedPtrField< ::PeopleInfo_Phone >& phone() const;
};
// ================================= 模块3:通讯录主类 - Contacts 【repeated PeopleInfo字段常用函数】=================================
// 生成逻辑:对应proto中的Contacts消息,核心为repeated PeopleInfo contacts=1字段,操作函数与PeopleInfo的repeated Phone字段完全一致
// 核心规律:所有repeated字段的操作函数命名、用法、参数、返回值均遵循统一规则,掌握一种即可触类旁通
class Contacts final : public ::PROTOBUF_NAMESPACE_ID::Message {
public:
using ::PROTOBUF_NAMESPACE_ID::Message::CopyFrom;
void CopyFrom(const Contacts& from); // 通用拷贝,用法同前,不再赘述
// ---------------------- repeated .PeopleInfo contacts=1 专属核心常用函数【与repeated Phone完全一致】 ----------------------
// 底层实现:RepeatedPtrField<PeopleInfo>动态对象指针数组,自动管理联系人对象内存
// 1. 获取联系人个数【高频】:返回通讯录中联系人的数量
// 语法:int 字段名_size() const;
// 用法:Contacts c; int peopleCount = c.contacts_size(); // 初始0,添加后返回实际数量
int contacts_size() const;
// 2. 清空通讯录【高频】:删除所有联系人,释放内存
// 语法:void clear_字段名();
// 用法:Contacts c; /* 添加联系人 */ c.clear_contacts(); // c.contacts_size() 变为0
void clear_contacts();
// 3. 按索引获取不可修改联系人【高频】:只读获取指定位置的联系人信息
// 语法:const 元素类型& 字段名(int index) const;
// 用法:Contacts c; /* 添加联系人 */ std::string name = c.contacts(0).name(); // 获取第0个联系人姓名
const ::PeopleInfo& contacts(int index) const;
// 4. 按索引获取可修改联系人指针【高频】:修改指定位置的联系人信息
// 语法:元素类型* mutable_字段名(int index);
// 用法:Contacts c; /* 添加联系人 */ c.mutable_contacts(0)->set_age(25); // 修改第0个联系人年龄
::PeopleInfo* mutable_contacts(int index);
// 5. 新增联系人【最高频】:向通讯录末尾添加空的PeopleInfo对象,返回可修改指针
// 语法:元素类型* add_字段名();
// 实现本质:自动创建空PeopleInfo对象,无需手动new,内存由ProtoBuf管理
// 用法:Contacts c; c.add_contacts()->set_name("张三"); // 新增联系人并设置姓名;c.add_contacts()->set_age(25);
::PeopleInfo* add_contacts();
// 6. 获取整个不可修改通讯录【常用】:遍历所有联系人
// 语法:const RepeatedPtrField<元素类型>& 字段名() const;
// 用法:Contacts c; /* 添加联系人 */ for (const auto& people : c.contacts()) { std::cout << people.name() << std::endl; }
const ::PROTOBUF_NAMESPACE_ID::RepeatedPtrField< ::PeopleInfo >& contacts() const;
};
connects.cpp:
#include "connects.pb.h"
#include <iostream>
#include <string>
#include <fstream>
using namespace std;
/*
message Phone
{
string number = 1;
//ProtoBuf 的字段编号(唯一标识)只需要在「同一个 Message 内部唯一」,不同 Message 之间的字段编号可以重复。
}
message PersonInfo
{
string name = 1;
sint32 age = 2;
repeated Phone phone = 3;
}
message Connects
{
repeated PersonInfo contacts = 1;//联系人信息,通讯录中记录的肯定是联系人的信息啊哇
}
*/
void AddPersonInfo(connects::PersonInfo* person_info_ptr)
{
cout << "-------------新增联系人-------------" << endl;
cout << "请输入联系人姓名: ";
string name;
getline(cin,name);
person_info_ptr->set_name(name);
cin.ignore(256,'\n');//忽略输入缓冲区的前256个字符或者忽略第一次碰到的'\n'前的字符
// cin.ignore(256, '\n') 解析:C++输入流缓冲区清理函数(需包含<iostream>)
// 功能:清除标准输入缓冲区中字符,解决缓冲区残留字符导致后续cin/getline输入异常的问题
// 参数1:256 → 本次最多忽略/清除256个字符,足够覆盖常规输入残留
// 参数2:'\n' → 终止符,遇到该字符立即停止操作,且会将'\n'本身也清除
// 执行逻辑:从缓冲区头部开始清字符,遇'\n'或清满256个字符,满足其一即停止
// 核心适用场景:cin >> 读取数据后(会残留换行符),接getline/cin.get()前必加,避免后续读空内容
// 这么一来下次读取输入的话,就不会被前面的输入影响到,而是从上一次回车之后的输入开始进行
cout << "请输入联系人年龄: ";
int age;
cin >> age;
person_info_ptr->set_age(age);
cin.ignore(256, '\n');
//接下来我们就要让用户输入电话号码了,而由于我们不知道用户有多少个号码
//所以我们只能死循环进行让用户输入电话号码的操作,不然就嘎嘣脆了哈哈哈
for(int i = 1; ; i++)//这也是死循环哦
{
cout << "请输入联系人电话" << i << "(只输入回车完成电话新增): ";
string phone_number;
getline(cin,phone_number);
//如果新一次读取的phone_number为空,那么就说明用户没有新电话号码要输入了,那么我们就可以直接终止循环了
if(phone_number.empty())
{
break;
}
//将用户输入的电话号码存进PersonInfo中的Phone结构体字段中的num
person_info_ptr->add_phone()->set_number(phone_number);//真复杂,我们不妨拆分一下
//其实就是像下面这样子
// connects::Phone* phone = person_info_ptr->add_phone();
// phone->set_number(phone_number);
}
cout << "-----------添加联系人成功-----------" << endl;
}
/**
* 打印联系人列表
*/
void PrintfContacts(const connects::Connects& con1,int size)
{
for (int i = 0; i < size; ++i)
{
//就像遍历数组一样,也是使用下标哦
//只不过是将下标传入函数作为参数
const connects::PersonInfo& people = con1.contacts(i);
cout << "------------联系人" << i+1 << "------------" << endl;
cout << "姓名:" << people.name() << endl;
cout << "年龄:" << people.age() << endl;
int j = 1;
//就像遍历数组一样,也是使用下标哦
//只不过是将下标传入函数作为参数
//Phone也是可重复字段,所以我们也需要进行遍历哦
for (const connects::Phone& phone : people.phone())//范围for循环,字段名()函数是直接返回原始数组的哦,支持范围for
{
cout << "电话" << j++ << ": " << phone.number() << endl;
}
}
}
int main(int argc,char* argv[])
{
if (argc != 2)
{
cerr << "Usage: " << argv[0] << " CONTACTS_FILE" << endl;
return -1;
}
connects::Connects con;
//向connects中添加新的联系人信息
connects::PersonInfo* person_info_ptr= con.add_contacts();
//此时就相当于新建了一个新的PersonInfo对象,我们可以直接调用该对象的函数进行赋值等等操作
//传入函数,进行添加新联系人信息
AddPersonInfo(person_info_ptr);
//接下来依旧是进行序列化和反序列化的老套操作
//向我们创建的数据结构添加了数据之后,我们就可以进行尝试序列化和反序列化了
//序列化
//注意:虽然是可重复字段,但是序列化和反序列化是不需要循环遍历的哦,直接序列化反序列化就行
//proto自己会进行处理,也就是当我们输入数据、获取数据时,才需要循环遍历的哦
string out;
if(!con.SerializeToString(&out))//传入string的地址哦
{
cout<<"string序列化失败"<<endl;
return -1;
}
cout<<"string序列化成功,序列化后的数据为:"<<out<<endl;
//反序列化
//对于可重复字段的反序列化,不需要使用遍历
connects::Connects con1;
if(!con1.ParseFromString(out))//从字符串中
{
cout<<"string反序列化失败"<<endl;
return -1;
}
//那么我们要想将所有联系人的信息都打印出来的话,就得使用循环去遍历P~~~数组
//至于我们怎么知道数组有多少个元素呢???不是有提供字段名_size函数吗哈哈哈
int size=con1.contacts_size();
PrintfContacts(con1,size);
//我们再来使用文件的方式进行序列化和反序列化试试
//序列化
fstream wf("connects.bin",std::ios::binary|std::ios::out);
if(!con.SerializeToOstream(&wf)) //传入输出流的地址
{
cout<<"文件序列化失败"<<endl;
return -1;
}
wf.close();
cout<<"文件序列化成功"<<endl;
//反序列化
fstream rf("connects.bin",std::ios::binary|std::ios::in);
connects::Connects con2;
if(!con2.ParseFromIstream(&rf)) //传入输入流的地址
{
cout<<"文件反序列化失败"<<endl;
return -1;
}
rf.close();
//那么我们要想将所有联系人的信息都打印出来的话,就得使用循环去遍历P~~~数组
//至于我们怎么知道数组有多少个元素呢???不是有提供字段名_size函数吗哈哈哈
int size1=con1.contacts_size();
PrintfContacts(con1,size1);
// for (int i = 0; i < size1; ++i)
// {
// //就像遍历数组一样,也是使用下标哦
// //只不过是将下标传入函数作为参数
// const connects::PersonInfo& people = con1.contacts(i);
// cout << "------------联系人" << i+1 << "------------" << endl;
// cout << "姓名:" << people.name() << endl;
// cout << "年龄:" << people.age() << endl;
// int j = 1;
// //就像遍历数组一样,也是使用下标哦
// //只不过是将下标传入函数作为参数
// //Phone也是可重复字段,所以我们也需要进行遍历哦
// for (const connects::Phone& phone : people.phone())//范围for循环,字段名()函数是直接返回原始数组的哦,支持范围for
// {
// cout << "电话" << j++ << ": " << phone.number() << endl;
// }
// }
// for(int i=0;i<size1;++i)
// {
// //就像遍历数组一样,也是使用下标哦
// //只不过是将下标传入函数作为参数
// cout<<"string反序列化成功,name="<<con1.contacts(i).name()<<",age="<<con1.contacts(i).age()<<endl;
// }
return 0;
}
七、二进制数据验证:protoc --decode 命令的使用
ProtoBuf 提供了官方的二进制数据验证工具——protoc --decode命令,可直接将二进制序列化数据(字符串 / 文件)解析为人类可读的文本格式,无需编写代码,是验证序列化数据正确性的最便捷、最可靠方式,适合开发调试和问题排查。
7.1 命令核心作用
从标准输入中读取指定 ProtoBuf 类型的二进制数据,将其解析为结构化的文本格式并输出到标准输出,支持验证本地二进制文件、网络传输的二进制字符串等所有 ProtoBuf 序列化数据。
7.2 完整命令语法
protoc --decode=包名.消息类型 Proto文件路径 < 二进制数据文件路径
关键参数解析
--decode=包名.消息类型:指定要解析的 ProtoBuf 数据类型,包名 + 消息类型必须与.proto 中定义的完全一致(如connects.Connects);Proto文件路径:指定数据结构对应的.proto 文件路径(如./connects.proto),编译器需要通过该文件解析字段含义;< 二进制数据文件路径:Linux 重定向符,将本地二进制文件的内容作为标准输入传递给 protoc 命令。
7.3 实际使用示例(验证通讯录二进制文件)
基于前文实战案例生成的contacts.bin二进制文件,使用protoc --decode命令解析为文本格式:
7.3.1 执行验证命令
protoc --decode=connects.Connects ./connects.proto < ./contacts.bin
7.3.2 解析结果(文本格式)
contacts {
name: "张三"
age: 25
phones {
number: "13800138000"
type: "移动"
}
phones {
number: "010-12345678"
type: "固定电话"
}
}
7.3.3 特殊字符解析说明
如果联系人姓名包含中文,解析结果中可能会出现八进制转义字符(如\345\274\240\344\270\211),这是因为 protoc 将 UTF-8 编码的中文转换为了八进制表示,属于正常现象,不影响数据正确性:
contacts {
name: "\345\274\240\344\270\211" # 对应中文“张三”
age: 25
phones {
number: "13800138000"
}
}
7.4 命令使用场景
- 开发调试:快速验证序列化数据是否正确,无需编写反序列化代码;
- 问题排查:当反序列化失败时,通过该命令判断二进制数据是否损坏或格式错误;
- 数据校验:验证生产环境的 ProtoBuf 二进制数据是否符合预期的字段结构;
- 跨语言验证:验证其他语言(Java/Python/Go)序列化的二进制数据是否与 C++ 定义的.proto 兼容。
八、核心知识点总结
8.1 核心知识点总结
8.1.1 嵌套 Message
- 两种定义方式:内部直接嵌套(专属结构,无复用)、外部定义 + 内部使用(推荐,可复用、易维护);
- 核心规则:子 Message 字段编号仅需自身内部唯一,与父 Message 字段编号可重复;
- 编译后 C++ 类:内部嵌套生成
父_子组合类(如PersonInfo_Phone),外部定义生成独立类(如Phone)。
8.1.2 repeated 限定符
- 核心本质:等价于 C++ 结构体中的动态数组成员,底层由
RepeatedPtrField实现; - 核心特性:支持任意次赋值、保留添加顺序、自动内存管理、无元素时不序列化;
- 语法:仅需在字段类型前加
repeated,其余规则与单值字段一致,字段名建议复数命名。
8.1.3 编译后 C++ 函数
- 单值字段:4 个核心函数(
clear_xxx()/xxx() const/set_xxx()/mutable_xxx()),覆盖清、读、写、修改全操作; - repeated 字段:6 个核心函数(
xxx_size()/clear_xxx()/xxx(index)/mutable_xxx(index)/add_xxx()/xxx()),覆盖增、删、改、查、遍历、统计全操作; - 通用规则:函数名与字段名一致、const 函数纯读、指针无需手动管理、按索引操作必做越界判断。
8.1.4 序列化 / 反序列化
- 四种核心函数:
SerializeToString()/ParseFromString()(字符串)、SerializeToOstream()/ParseFromIstream()(文件 / 流); - 核心特点:序列化后为二进制数据(体积小、效率高),支持跨语言、跨平台传输;
- 注意事项:repeated 字段序列化 / 反序列化无需遍历,ProtoBuf 底层自动处理。
8.2 开发最佳实践
8.2.1 .proto 文件编写规范
- 包名规范:使用项目 / 模块名作为包名(如
connects/order/user),避免命名冲突; - 字段编号规范:① 1~10 用于基础字段,11~20 预留扩展;② 编号一旦确定严禁修改,避免兼容问题;③ 不使用 19000~19999 保留编号;
- 命名规范:① Message 名使用大驼峰(如
PersonInfo);② 字段名使用小驼峰(如phoneNumber)或下划线(如phone_number,推荐);③ repeated 字段名复数命名(如phones/contacts); - 注释规范:为每个 Message、字段添加注释,说明业务含义、使用场景,提升可维护性;
- 结构规范:通用结构(如
Phone/Address)统一外部定义,避免重复定义。
8.2.2 编译与运行规范
- 编译命令:使用
protoc -I=源目录 --cpp_out=输出目录 文件名.proto,确保.proto 文件路径正确; - C++ 编译:① 支持 C++11 及以上;② 链接 ProtoBuf 库(
pkg-config --cflags --libs protobuf);③ 将生成的xxx.pb.cc与业务代码一起编译; - 版本规范:开发、测试、生产环境使用同一版本的 ProtoBuf 库(protoc+libprotobuf),避免版本兼容问题。
8.2.3 C++ 代码编写规范
- 命名空间:使用.proto 定义的包名作为 C++ 命名空间(如
using namespace connects;),避免类名冲突; - 输入处理:混合使用
cin/getline时,必须调用cin.ignore()清理缓冲区残留的换行符; - 错误处理:对序列化 / 反序列化、文件操作、索引访问的返回值 / 状态做严格的错误判断,避免程序崩溃;
- 内存管理:① 不手动
new/deleteProtoBuf 生成的对象 / 指针;② 程序结束时调用google::protobuf::ShutdownProtobufLibrary()释放全局内存; - const 使用:纯读操作的函数 / 参数使用
const修饰(如void Print(const Connects& con)),提升代码健壮性和效率。
8.2.4 性能优化
- 减少拷贝:优先使用引用 / 指针传递 Message 对象,避免大对象的值拷贝;
- 批量操作:向 repeated 字段添加大量元素时,直接使用
add_xxx()链式赋值,避免多次函数调用; - 空字段优化:不设置无效的字段值,ProtoBuf 会自动忽略空字段,减少序列化后的二进制体积;
- 复用 Message 对象:重复使用同一个 Message 对象时,调用
Clear()清空旧数据,避免频繁创建 / 销毁对象的开销。
8.2.5 兼容性设计
- 字段扩展:新增字段使用预留的字段编号,不修改已有字段的编号和类型;
- 字段可选:非必填字段不强制设置值,ProtoBuf 会自动处理空字段,保证旧版本程序能正常解析新版本数据;
- 类型兼容:尽量使用通用标量类型(如
sint32/uint64),避免使用小众类型,提升跨语言兼容性; - 测试兼容:新增字段后,必须测试旧版本程序解析新版本数据、新版本程序解析旧版本数据,确保双向兼容。
至此,我们从 Proto3 的核心基础规则出发,完成了嵌套 Message、repeated 限定符的深度解析,吃透了编译后 C++ 的高频操作函数,并最终落地到简易通讯录系统的完整实战,实现了从语法定义到代码开发、从数据封装到持久化存储、从功能实现到二进制验证的全流程学习,构建了一套完整的 ProtoBuf C++ 开发知识体系。
本次学习的核心,并非单纯记忆语法和函数,而是理解 ProtoBuf 的设计本质 —— 以二进制序列化为核心,通过简洁的语法定义数据结构,借助编译器自动生成跨语言代码,实现高效的数据传输与存储;而嵌套 Message 的复用设计、repeated 字段的动态数组本质,更是 ProtoBuf 适配实际业务复杂数据结构的关键,吃透这些底层逻辑,才能做到 “一通百通”,无论面对何种业务场景的数结构定义,都能快速上手。
从实战中我们也能发现,ProtoBuf 的开发并非孤立的语法使用,而是与 C++ 工程化开发深度结合的:从 protoc 编译命令的使用、生成文件的编译链接,到输入缓冲区的处理、文件流的操作、错误边界的判断,再到代码规范与性能优化,每一个环节都是实际项目中必须关注的点。而 protoc --decode 这个官方验证工具,更是开发调试的 “利器”,能帮助我们快速验证序列化数据的正确性,大幅提升问题排查效率。
ProtoBuf 作为跨语言、跨平台的高性能序列化框架,是后端开发、中间件开发、分布式系统通信中的核心工具之一,本次通讯录实战所掌握的知识,是 ProtoBuf 开发的基础也是核心 —— 嵌套 Message、repeated 字段、序列化 / 反序列化、编译后函数调用,这四大核心内容覆盖了实际开发中 90% 以上的使用场景。
后续大家可以基于本次实战进行扩展:为通讯录添加联系人修改 / 删除功能、实现多联系人批量添加、结合网络库完成通讯录数据的网络传输、对接数据库实现持久化数据的增删改查,或是尝试用 Java/Python 解析本次 C++ 序列化的二进制文件,体验 ProtoBuf 跨语言的核心优势。
技术的掌握源于反复的实践,建议大家多动手修改.proto 文件、调试 C++ 代码,在实践中巩固语法规则和函数使用,理解开发规范与兼容性设计的重要性。相信通过本次学习,大家已经具备了将 ProtoBuf 应用到实际项目中的能力,后续只需结合业务需求灵活变通,就能发挥其高效、简洁、跨平台的核心价值。
最后,感谢大家的跟随学习,希望这篇内容能成为大家 ProtoBuf C++ 开发路上的实用指南,也祝愿大家在后续的开发中能学以致用,打造更高效、更健壮的分布式系统与数据交互模块。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/2503_92929084/article/details/157149016




