C++基础入门教程(三):数组、字符串、结构体、共用体
前言这是 C 入门系列的第三篇讲四种把数据组织在一起的方式数组、字符串、结构体、共用体。它们横跨 C 与 C 两套风格——既有从 C 继承来的原生数组和字符数组也有 C 标准库提供的std::array、std::string、std::variant。第一个典型误解是「数组名就是指针」。数组名在大多数表达式里会退化decay成指向首元素的指针但数组类型本身不是指针sizeof(arr)得到的是整个数组的字节数arr的类型是「指向整个数组的指针」而arr 1跳过的是一个元素。第二个误解是「char buf[10]就是字符串」。它只是一个字符数组只有以\0结尾时才能按 C 字符串使用。没有结尾符却传给strlen、printf(%s)会一直读到相邻内存属于越界读。第三个误解是「union可以随便读另一个成员」。C 里读取非活跃成员通常是 UB只有少数例外比如多个标准布局结构体共享公共初始序列。想做「带类型标签的联合」C17 的std::variant更安全。下面按数组、字符串、结构体、共用体四块讲每块给出 C 风格写法与现代 C 写法的对照。一、数组从原生数组到 std::array原生数组的大小是类型的一部分必须在编译期确定C99 的可变长数组不是 C 标准// 适用于 C17int a[5]; // 未初始化元素值不确定int b[5] {1, 2, 3}; // 后两个元素被初始化为 0int c[] {1, 2, 3, 4}; // 大小为 4int d[2][3] {{1, 2, 3}, {4, 5, 6}};几个必须记住的性质数组不能整体赋值也不能按值传递。int e[5] b;无法编译函数参数写成void f(int arr[5])时5会被忽略实际类型是int*。数组大小用sizeof(arr) / sizeof(arr[0])计算C17 起可以用std::size(arr)需要iterator。越界访问a[5]是 UB不保证报错也不保证崩溃。数组元素个数为 0 不合法需要动态大小请用std::vector。std::array是固定大小的容器保留了原生数组的零开销同时可以整体赋值、可以按值传递、知道自己的大小// 适用于 C17#include array#include iostreamint main() {std::arrayint, 5 a{1, 2, 3, 4, 5};std::arrayint, 5 b a; // 可以整体拷贝b[0] 100; // 不检查边界std::cout b.at(1) \n; // 检查边界越界抛 std::out_of_rangestd::cout b.size() \n; // 5for (int x : b) std::cout x ;}std::array的at()会抛std::out_of_rangeoperator[]不会检查。它的元素仍然连续存储data()返回指向首元素的指针可以传给需要 C 数组的接口。二、字符串字符数组与 std::stringC 风格字符串是以\0结尾的字符数组。字面量hello的类型是const char[6]末尾有一个隐藏的\0// 适用于 C17#include cstringchar buf[6] hello; // 5 个字符 结尾 \0std::size_t n std::strlen(buf); // 5不含 \0strlen从指针开始数到第一个\0所以传入没有结尾符的数组就会越界。strcpy不检查目标容量是缓冲区溢出的经典来源应改用std::string或在必须用 C 接口时用snprintf并检查返回值。std::string自己管理内存和长度不依赖结尾符但c_str()仍保证返回以\0结尾的缓冲区// 适用于 C17#include iostream#include stringint main() {std::string s hello;s , world; // 追加s.push_back(!);std::cout s.size() \n; // 13std::cout s.substr(0, 5) \n;std::size_t pos s.find(world);if (pos ! std::string::npos) {std::cout found at pos \n;}const char* raw s.c_str(); // 只在 s 不再被修改时有效std::cout raw \n;}c_str()返回的指针归std::string所有。对s做任何可能改变内容的操作追加、插入、清空、重新赋值之后这个指针都可能失效把它存起来跨语句使用是常见的悬垂指针来源。C17 还提供了std::string_view它是一个「指针 长度」的非拥有视图拷贝成本很低适合只读的函数参数// 需要 C17#include string_viewstd::size_t count_spaces(std::string_view sv) {std::size_t n 0;for (char ch : sv) {if (ch ) n;}return n;}string_view不拥有字符数据也不保证以\0结尾。如果它指向的std::string被销毁或被修改视图就悬垂了。不要把string_view存进生命周期更长的对象里除非你能保证源数据的生命周期。三、结构体把相关字段绑在一起struct把多个不同类型的值组合成一个类型。C 的struct与class只差默认访问权限struct默认 publicclass默认 private都可以有成员函数、构造函、继承。// 适用于 C17#include array#include stringstruct Student {std::string name;std::arrayint, 3 scores;int total() const {int sum 0;for (int x : scores) sum x;return sum;}};Student make_student() {return Student{Alice, {90, 85, 77}}; // 聚合初始化}聚合初始化按成员声明顺序赋值。C20 起支持指定初始化器designated initializers// 需要 C20Student s{.name Bob, .scores {70, 80, 90}};指定初始化器要求按声明顺序书写不能跳过再回头也不能与普通初始化混用。C17 及以前只能按顺序写全。结构体的内存布局受对齐alignment影响编译器可能在成员之间插入填充字节使每个成员的地址满足其对齐要求。因此sizeof(Student)不一定等于各成员大小之和。不要用memcmp比较两个结构体是否相等填充字节的值是不确定的逐字段比较才可靠。// 适用于 C17struct A { char c; int i; }; // 很可能占 8 字节而不是 5struct B { int i; char c; }; // 也可能占 8 字节具体大小由实现决定上面的注释只是常见情况。需要精确布局时用alignas、#pragma pack平台相关或静态断言static_assert(sizeof(A) 8)把假设写死让编译在假设不成立时失败。四、共用体同一块内存的不同解释union的所有成员共享同一块存储大小等于最大成员的大小按最大对齐向上取整。同一时刻只有一个成员是「活跃」的// 适用于 C17union Value {int i;double d;char bytes[8];};Value v;v.i 42; // i 成为活跃成员int x v.i; // 正确读活跃成员// double y v.d; // 错误读非活跃成员UB读取非活跃成员在标准里是 UB。很多编译器把它当扩展支持常被称为 type punning但这不是可移植的写法。要查看对象的字节表示正确做法是通过字符指针读取对象表示// 适用于 C17#include cstringunsigned char raw[sizeof(Value)];std::memcpy(raw, v, sizeof(Value)); // 复制字节合法要做「浮点位模式转整数」这类操作C20 提供了std::bit_cast需要bit// 需要 C20#include bit#include cstdintstd::uint32_t bits std::bit_caststd::uint32_t(3.14f);如果union的成员有非平凡的构造/析构函数比如std::string编译器会删除默认的构造、拷贝、析构函数必须自己用 placement new 和显式析构来管理活跃成员。这类场景几乎都应该直接用std::variantC17// 需要 C17#include string#include variantusing Field std::variantint, double, std::string;Field f 42;f std::string(hello); // 自动析构 int构造 stringif (auto p std::get_ifstd::string(f)) {// 只有当 f 当前持有 string 时才进入}std::variant自带类型标签访问前可以查询当前类型类型不匹配时std::get抛std::bad_variant_access。代价是它的大小通常大于手写 union因为要存标签。完整示例下面把结构体、数组、字符串和 union 放在一个例子里用结构体描述一条记录用 union 保存「整数或浮点」的读数并用一个标签区分当前类型。// 适用于 C17#include array#include iostream#include stringstruct Reading {std::string sensor;int day;union {int count;double level;} value;bool is_level; // 标签true 表示 value.level 有效};int main() {Reading r;r.sensor temp-01;r.day 7;r.value.level 21.5;r.is_level true;if (r.is_level) {std::cout r.sensor day r.day : r.value.level \n;} else {std::cout r.sensor day r.day : r.value.count \n;}}这里union的成员都是平凡类型所以Reading仍可以默认构造。标签is_level必须由写入方维护——这正是手写 union 容易出错的地方忘记更新标签就会读到错误解释的字节。生产代码里应改用std::variant。常见坑点1. 用sizeof指针当数组长度❌void f(int arr[10]) { int n sizeof(arr) / sizeof(arr[0]); }✅ 形参写成int* arr, std::size_t n或改用std::array/std::vector的引用。2. 字符数组忘了结尾符❌char buf[5] {h,e,l,l,o}; strlen(buf);✅ 留出\0的位置或用std::string。3. 用strcpy复制未知长度的字符串❌char dst[16]; strcpy(dst, src);✅ 用std::string必须用 C 接口时用snprintf并检查返回值是否被截断。4. 返回局部数组的指针❌int* f() { int a[3] {1,2,3}; return a; }✅ 返回std::array或std::vector由值返回。5. 假设结构体没有填充字节❌ 用memcmp(a, b, sizeof(A))判断相等。 ✅ 逐字段比较或为类型定义operator。6. 读 union 的非活跃成员❌ 写v.i之后读v.d。 ✅ 记录标签并只读活跃成员需要位模式转换用std::bit_castC20。7. 两个字符串字面量相加❌std::string s abc def;✅std::string s std::string(abc) def;8. 保存c_str()的返回值❌const char* p s.c_str(); s x; printf(%s, p);✅ 在需要时重新调用c_str()或把内容复制到自己的缓冲区。总结主题C 风格现代 C关键差异定长数组int a[5]std::arrayint, 5可赋值、可传值、有size()变长数组malloc/new[]std::vectorT自动管理容量与生命周期字符串char[]strlenstd::string自带长度不依赖\0只读视图const char*std::string_view带长度不拥有数据记录structstruct 成员函数可封装行为布局仍受对齐影响联合union 手写标签std::variant自动管理活跃成员与类型检查数组和字符串的核心是「边界」长度信息一旦丢失越界就不可避免。结构体的核心是「布局」填充字节让「大小等于字段之和」不成立。共用体的核心是「活跃成员」读错成员不是「得到奇怪的值」而是 UB。把这几条记住再逐步把原生数组换成std::array、把字符数组换成std::string入门阶段的大部分坑都能避开。