前言这是 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,只有少数例外(比如多个标准布局结构体共享公共初始序列)。想做「带类型标签的联合」,C++17 的std::variant更安全。下面按数组、字符串、结构体、共用体四块讲,每块给出 C 风格写法与现代 C++ 写法的对照。一、数组:从原生数组到 std::array原生数组的大小是类型的一部分,必须在编译期确定(C99 的可变长数组不是 C++ 标准):// 适用于 C++17int 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])计算,C++17 起可以用std::size(arr)(需要<iterator>)。越界访问a[5]是 UB,不保证报错,也不保证崩溃。数组元素个数为 0 不合法;需要动态大小请用std::vector。std::array是固定大小的容器,保留了原生数组的零开销,同时可以整体赋值、可以按值传递、知道自己的大小:// 适用于 C++17#include <array>#include <iostream>int main() {std::array<int, 5> a{1, 2, 3, 4, 5};std::array<int, 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_range,operator[]不会检查。它的元素仍然连续存储,data()返回指向首元素的指针,可以传给需要 C 数组的接口。二、字符串:字符数组与 std::stringC 风格字符串是以'\0'结尾的字符数组。字面量"hello"的类型是const char[6],末尾有一个隐藏的'\0':// 适用于 C++17#include <cstring>char buf[6] = "hello"; // 5 个字符 + 结尾 '\0'std::size_t n = std::strlen(buf); // 5,不含 '\0'strlen从指针开始数到第一个'\0',所以传入没有结尾符的数组就会越界。strcpy不检查目标容量,是缓冲区溢出的经典来源;应改用std::string,或在必须用 C 接口时用snprintf并检查返回值。std::string自己管理内存和长度,不依赖结尾符(但c_str()仍保证返回以'\0'结尾的缓冲区):// 适用于 C++17#include <iostream>#include <string>int 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做任何可能改变内容的操作(追加、插入、清空、重新赋值)之后,这个指针都可能失效;把它存起来跨语句使用是常见的悬垂指针来源。C++17 还提供了std::string_view,它是一个「指针 + 长度」的非拥有视图,拷贝成本很低,适合只读的函数参数:// 需要 C++17#include <string_view>std::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默认 public,class默认 private),都可以有成员函数、构造函、继承。// 适用于 C++17#include <array>#include <string>struct Student {std::string name;std::array<int, 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}}; // 聚合初始化}聚合初始化按成员声明顺序赋值。C++20 起支持指定初始化器(designated initializers):// 需要 C++20Student s{.name = "Bob", .scores = {70, 80, 90}};指定初始化器要求按声明顺序书写,不能跳过再回头,也不能与普通初始化混用。C++17 及以前只能按顺序写全。结构体的内存布局受对齐(alignment)影响:编译器可能在成员之间插入填充字节,使每个成员的地址满足其对齐要求。因此sizeof(Student)不一定等于各成员大小之和。不要用memcmp比较两个结构体是否相等,填充字节的值是不确定的;逐字段比较才可靠。// 适用于 C++17struct A { char c; int i; }; // 很可能占 8 字节而不是 5struct B { int i; char c; }; // 也可能占 8 字节具体大小由实现决定,上面的注释只是常见情况。需要精确布局时用alignas、#pragma pack(平台相关)或静态断言static_assert(sizeof(A) == 8)把假设写死,让编译在假设不成立时失败。四、共用体:同一块内存的不同解释union的所有成员共享同一块存储,大小等于最大成员的大小(按最大对齐向上取整)。同一时刻只有一个成员是「活跃」的:// 适用于 C++17union 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),但这不是可移植的写法。要查看对象的字节表示,正确做法是通过字符指针读取对象表示:// 适用于 C++17#include <cstring>unsigned char raw[sizeof(Value)];std::memcpy(raw, &v, sizeof(Value)); // 复制字节,合法要做「浮点位模式转整数」这类操作,C++20 提供了std::bit_cast(需要<bit>):// 需要 C++20#include <bit>#include <cstdint>std::uint32_t bits = std::bit_cast<std::uint32_t>(3.14f);如果union的成员有非平凡的构造/析构函数(比如std::string),编译器会删除默认的构造、拷贝、析构函数,必须自己用 placement new 和显式析构来管理活跃成员。这类场景几乎都应该直接用std::variant(C++17):// 需要 C++17#include <string>#include <variant>using Field = std::variant<int, double, std::string>;Field f = 42;f = std::string("hello"); // 自动析构 int,构造 stringif (auto p = std::get_if<std::string>(&f)) {// 只有当 f 当前持有 string 时才进入}std::variant自带类型标签,访问前可以查询当前类型;类型不匹配时std::get抛std::bad_variant_access。代价是它的大小通常大于手写 union,因为要存标签。完整示例下面把结构体、数组、字符串和 union 放在一个例子里:用结构体描述一条记录,用 union 保存「整数或浮点」的读数,并用一个标签区分当前类型。// 适用于 C++17#include <array>#include <iostream>#include <string>struct 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_cast(C++20)。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::array<int, 5>可赋值、可传值、有size()变长数组malloc/new[]std::vector<T>自动管理容量与生命周期字符串char[]+strlenstd::string自带长度,不依赖'\0'只读视图const char*std::string_view带长度,不拥有数据记录structstruct+ 成员函数可封装行为,布局仍受对齐影响联合union+ 手写标签std::variant自动管理活跃成员与类型检查数组和字符串的核心是「边界」:长度信息一旦丢失,越界就不可避免。结构体的核心是「布局」:填充字节让「大小等于字段之和」不成立。共用体的核心是「活跃成员」:读错成员不是「得到奇怪的值」,而是 UB。把这几条记住,再逐步把原生数组换成std::array、把字符数组换成std::string,入门阶段的大部分坑都能避开。
前言这是 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,只有少数例外(比如多个标准布局结构体共享公共初始序列)。想做「带类型标签的联合」,C++17 的std::variant更安全。下面按数组、字符串、结构体、共用体四块讲,每块给出 C 风格写法与现代 C++ 写法的对照。一、数组:从原生数组到 std::array原生数组的大小是类型的一部分,必须在编译期确定(C99 的可变长数组不是 C++ 标准):// 适用于 C++17int 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])计算,C++17 起可以用std::size(arr)(需要<iterator>)。越界访问a[5]是 UB,不保证报错,也不保证崩溃。数组元素个数为 0 不合法;需要动态大小请用std::vector。std::array是固定大小的容器,保留了原生数组的零开销,同时可以整体赋值、可以按值传递、知道自己的大小:// 适用于 C++17#include <array>#include <iostream>int main() {std::array<int, 5> a{1, 2, 3, 4, 5};std::array<int, 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_range,operator[]不会检查。它的元素仍然连续存储,data()返回指向首元素的指针,可以传给需要 C 数组的接口。二、字符串:字符数组与 std::stringC 风格字符串是以'\0'结尾的字符数组。字面量"hello"的类型是const char[6],末尾有一个隐藏的'\0':// 适用于 C++17#include <cstring>char buf[6] = "hello"; // 5 个字符 + 结尾 '\0'std::size_t n = std::strlen(buf); // 5,不含 '\0'strlen从指针开始数到第一个'\0',所以传入没有结尾符的数组就会越界。strcpy不检查目标容量,是缓冲区溢出的经典来源;应改用std::string,或在必须用 C 接口时用snprintf并检查返回值。std::string自己管理内存和长度,不依赖结尾符(但c_str()仍保证返回以'\0'结尾的缓冲区):// 适用于 C++17#include <iostream>#include <string>int 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做任何可能改变内容的操作(追加、插入、清空、重新赋值)之后,这个指针都可能失效;把它存起来跨语句使用是常见的悬垂指针来源。C++17 还提供了std::string_view,它是一个「指针 + 长度」的非拥有视图,拷贝成本很低,适合只读的函数参数:// 需要 C++17#include <string_view>std::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默认 public,class默认 private),都可以有成员函数、构造函、继承。// 适用于 C++17#include <array>#include <string>struct Student {std::string name;std::array<int, 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}}; // 聚合初始化}聚合初始化按成员声明顺序赋值。C++20 起支持指定初始化器(designated initializers):// 需要 C++20Student s{.name = "Bob", .scores = {70, 80, 90}};指定初始化器要求按声明顺序书写,不能跳过再回头,也不能与普通初始化混用。C++17 及以前只能按顺序写全。结构体的内存布局受对齐(alignment)影响:编译器可能在成员之间插入填充字节,使每个成员的地址满足其对齐要求。因此sizeof(Student)不一定等于各成员大小之和。不要用memcmp比较两个结构体是否相等,填充字节的值是不确定的;逐字段比较才可靠。// 适用于 C++17struct A { char c; int i; }; // 很可能占 8 字节而不是 5struct B { int i; char c; }; // 也可能占 8 字节具体大小由实现决定,上面的注释只是常见情况。需要精确布局时用alignas、#pragma pack(平台相关)或静态断言static_assert(sizeof(A) == 8)把假设写死,让编译在假设不成立时失败。四、共用体:同一块内存的不同解释union的所有成员共享同一块存储,大小等于最大成员的大小(按最大对齐向上取整)。同一时刻只有一个成员是「活跃」的:// 适用于 C++17union 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),但这不是可移植的写法。要查看对象的字节表示,正确做法是通过字符指针读取对象表示:// 适用于 C++17#include <cstring>unsigned char raw[sizeof(Value)];std::memcpy(raw, &v, sizeof(Value)); // 复制字节,合法要做「浮点位模式转整数」这类操作,C++20 提供了std::bit_cast(需要<bit>):// 需要 C++20#include <bit>#include <cstdint>std::uint32_t bits = std::bit_cast<std::uint32_t>(3.14f);如果union的成员有非平凡的构造/析构函数(比如std::string),编译器会删除默认的构造、拷贝、析构函数,必须自己用 placement new 和显式析构来管理活跃成员。这类场景几乎都应该直接用std::variant(C++17):// 需要 C++17#include <string>#include <variant>using Field = std::variant<int, double, std::string>;Field f = 42;f = std::string("hello"); // 自动析构 int,构造 stringif (auto p = std::get_if<std::string>(&f)) {// 只有当 f 当前持有 string 时才进入}std::variant自带类型标签,访问前可以查询当前类型;类型不匹配时std::get抛std::bad_variant_access。代价是它的大小通常大于手写 union,因为要存标签。完整示例下面把结构体、数组、字符串和 union 放在一个例子里:用结构体描述一条记录,用 union 保存「整数或浮点」的读数,并用一个标签区分当前类型。// 适用于 C++17#include <array>#include <iostream>#include <string>struct 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_cast(C++20)。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::array<int, 5>可赋值、可传值、有size()变长数组malloc/new[]std::vector<T>自动管理容量与生命周期字符串char[]+strlenstd::string自带长度,不依赖'\0'只读视图const char*std::string_view带长度,不拥有数据记录structstruct+ 成员函数可封装行为,布局仍受对齐影响联合union+ 手写标签std::variant自动管理活跃成员与类型检查数组和字符串的核心是「边界」:长度信息一旦丢失,越界就不可避免。结构体的核心是「布局」:填充字节让「大小等于字段之和」不成立。共用体的核心是「活跃成员」:读错成员不是「得到奇怪的值」,而是 UB。把这几条记住,再逐步把原生数组换成std::array、把字符数组换成std::string,入门阶段的大部分坑都能避开。