4.1 数组

数组 (Array) 是一种复合类型 (Compound Type)**,它允许你存储多个相同类型的值。数组中的每个值称为一个元素 (Element),可以通过索引 (Index)** 或下标 (Subscript) 来访问特定元素。

4.1.1 程序说明

想象一下,你需要存储一年中每个月的销售额。你可以声明12个独立的 double 变量:

1
double salesJan, salesFeb, salesMar, /* ..., */ salesDec; 

这种方法非常繁琐,尤其是在需要处理大量数据时。数组提供了一种更简洁、更强大的方式来处理这种情况。

数组声明:

声明一个数组需要指定:

  1. 元素类型: 数组中存储的数据类型。
  2. 数组名称: 变量名。
  3. 数组大小: 数组可以容纳的元素数量,必须是一个常量表达式(在编译时就能确定其值的表达式,例如字面常量、const 常量、枚举量或 sizeof 表达式的结果),并且必须放在方括号 [] 内。

语法:

1
typeName arrayName[arraySize]; 

示例:

1
2
3
4
5
6
7
8
9
// 声明一个可以存储 12 个 double 类型值的数组,名为 monthlySales
double monthlySales[12];

// 声明一个可以存储 5 个 int 类型值的数组,名为 scores
int scores[5];

// 使用 const 常量定义数组大小
const int NUM_STUDENTS = 30;
int studentGrades[NUM_STUDENTS];

访问数组元素:

使用数组名和方括号内的索引来访问数组元素。C++数组的索引从 0 开始。对于大小为 N 的数组,有效的索引范围是 0N-1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 访问 monthlySales 数组的第一个元素 (一月)
monthlySales[0] = 1500.50;

// 访问 monthlySales 数组的第三个元素 (三月)
monthlySales[2] = 2100.75;

// 访问 scores 数组的最后一个元素
scores[4] = 95;

// 读取 scores 数组的第二个元素
int secondScore = scores[1];

std::cout << "March sales: " << monthlySales[2] << std::endl;
std::cout << "Second score: " << secondScore << std::endl;

重要: C++ 不会自动检查数组索引是否越界。访问 arrayName[N]arrayName[-1](对于大小为 N 的数组)是**未定义行为 (Undefined Behavior)**,可能导致程序崩溃或数据损坏。程序员有责任确保使用的索引在有效范围内 (0arraySize - 1)。

数组大小必须是常量表达式:

1
2
3
4
5
6
7
int n = 10;
// int dynamicArray[n]; // 错误! C++ 标准不允许使用变量作为数组大小 (虽然某些编译器可能作为扩展支持)

const int SIZE = 5;
int staticArray[SIZE]; // 正确! SIZE 是 const 常量

int anotherArray[10]; // 正确! 10 是字面常量

如果需要在运行时确定数组大小,应该使用动态内存分配(new)或标准库提供的容器(如 std::vector),我们将在后续章节学习。

4.1.2 数组的初始化规则

在声明数组时,可以同时对其进行初始化。初始化使用花括号 {} 括起来的**初始化列表 (Initializer List)**。

规则:

  1. 完整初始化: 提供与数组大小相同数量的初始值。
    1
    2
    int scores[5] = {90, 85, 92, 78, 88}; // scores[0]=90, scores[1]=85, ..., scores[4]=88
    double lengths[3] = {1.2, 3.4, 0.5};
  2. 部分初始化: 如果提供的初始值数量少于数组大小,则剩余的元素会被自动初始化为 0(对于数值类型)或相应的零等价值(对于其他类型,如字符数组的空字符 \0)。
    1
    2
    3
    4
    5
    6
    7
    int counts[10] = {1, 2, 3}; // counts[0]=1, counts[1]=2, counts[2]=3
    // counts[3] 到 counts[9] 都被初始化为 0

    float readings[5] = {9.8f}; // readings[0]=9.8f, 其他元素为 0.0f

    // 将数组所有元素初始化为 0 的常用方法
    int allZeros[100] = {0};
  3. 省略数组大小: 如果在声明时提供了初始化列表,可以省略方括号中的数组大小。编译器会根据初始化列表中的元素数量自动推断数组大小。
    1
    2
    short values[] = {10, 20, 30, 40}; // 编译器推断数组大小为 4
    char message[] = {'H', 'e', 'l', 'l', 'o', '\0'}; // 大小为 6 (包括空字符)
  4. 不允许初始化列表元素过多: 初始化列表中的元素数量不能超过数组声明的大小。
    1
    // int errors[3] = {1, 2, 3, 4}; // 错误! 初始化列表元素过多
  5. 未初始化数组: 如果在声明数组时没有提供初始化列表(仅适用于非静态局部数组),则数组元素的值是未定义的 (indeterminate)**,它们会包含内存中遗留的垃圾值。使用未初始化的变量是常见的错误来源。**
    1
    2
    3
    4
    5
    6
    7
    8
    int main() {
    int garbage[5]; // 数组元素的值是未定义的 (垃圾值)
    // std::cout << garbage[0]; // 错误! 使用未初始化的值

    static int staticGarbage[5]; // 静态存储数组会被默认初始化为 0
    // std::cout << staticGarbage[0]; // 输出 0
    return 0;
    }
    (静态存储持续性的变量,如全局变量、命名空间变量、静态局部变量,会被默认零初始化)

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>

int main() {
// 完整初始化
int fibonacci[8] = {0, 1, 1, 2, 3, 5, 8, 13};
std::cout << "Fibonacci[5]: " << fibonacci[5] << std::endl; // 输出 8

// 部分初始化
double data[5] = {1.1, 2.2};
std::cout << "Data[0]: " << data[0] << std::endl; // 输出 1.1
std::cout << "Data[1]: " << data[1] << std::endl; // 输出 2.2
std::cout << "Data[2]: " << data[2] << std::endl; // 输出 0
std::cout << "Data[3]: " << data[3] << std::endl; // 输出 0
std::cout << "Data[4]: " << data[4] << std::endl; // 输出 0

// 省略大小
char vowels[] = {'a', 'e', 'i', 'o', 'u'};
std::cout << "Number of vowels: " << sizeof(vowels) / sizeof(char) << std::endl; // 输出 5

// 初始化所有元素为 0
int results[20] = {0};
std::cout << "Results[15]: " << results[15] << std::endl; // 输出 0

return 0;
}

4.1.3 C++11数组初始化方法

C++11 引入了更统一的初始化语法,称为列表初始化 (List Initialization) 或**花括号初始化 (Brace Initialization)**,它也可以用于数组。

主要变化:

  1. 可以省略等号 =: 在使用初始化列表时,可以省略声明语句中的等号。
  2. 禁止缩窄转换 (Narrowing Conversion): 列表初始化不允许可能导致数据丢失的“缩窄”转换。例如,不能将浮点数直接初始化给整型数组元素,也不能将超出范围的整数值初始化给较小范围的整型数组元素。

语法:

1
2
typeName arrayName[arraySize] {initializer_list}; // C++11 列表初始化 (可省略等号)
typeName arrayName[] {initializer_list}; // 省略大小

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>

int main() {
// C++11 列表初始化 (省略等号)
int scores[5] {90, 85, 92, 78, 88};
double lengths[3] {1.2, 3.4, 0.5};
int counts[10] {1, 2, 3}; // 部分初始化,剩余元素为 0
int allZeros[100] {}; // 所有元素初始化为 0 (空列表)
short values[] {10, 20, 30, 40}; // 省略大小

std::cout << "Scores[1]: " << scores[1] << std::endl; // 输出 85
std::cout << "Counts[5]: " << counts[5] << std::endl; // 输出 0
std::cout << "allZeros[50]: " << allZeros[50] << std::endl; // 输出 0
std::cout << "Size of values: " << sizeof(values) / sizeof(short) << std::endl; // 输出 4

// 禁止缩窄转换示例
// int errors[3] {1, 2, 3.0}; // 错误! double (3.0) 到 int 是缩窄转换
// char chars[2] { 'a', 300 }; // 错误! 300 超出 char 的范围 (假设 char 是 8 位)

// 允许非缩窄转换
char chars_ok[3] { 'a', 66, 'c' }; // 66 在 char 范围内,可以隐式转换为 'B'
std::cout << "Chars OK: " << chars_ok[0] << chars_ok[1] << chars_ok[2] << std::endl; // 输出 aBc

return 0;
}

建议: C++11 的列表初始化提供了更一致、更安全的初始化方式,推荐在支持 C++11 及更高标准的项目中使用。特别是 typeName arrayName[size] {}; 这种将所有元素初始化为零值的形式非常方便。

4.2 字符串

字符串是程序中用于表示文本信息的重要数据类型。C++处理字符串有两种主要方式:

  1. C风格字符串 (C-Style String): 这是继承自C语言的方式,将字符串视为存储在 char 数组中并以空字符 (\0) 结尾的字符序列。
  2. string 类: C++标准库提供了一个强大的 string 类,提供了更方便、更安全的字符串操作(将在 4.3 节介绍)。

本节主要关注 C 风格字符串。

字符串字面值 (String Literal) 或字符串常量 (String Constant):
在代码中用双引号 "" 括起来的字符序列,例如 "Hello, world!", "C++", "" (空字符串)。它们存储在内存的只读区域。编译器会自动在字符串字面值的末尾添加空字符 \0

4.2.1 拼接字符串常量

C++允许将相邻的字符串字面值自动拼接(连接)成一个单独的字符串。这对于将较长的字符串分成多行书写非常有用,可以提高代码的可读性。

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>

int main() {
// 相邻的字符串字面值会被自动拼接
std::cout << "This is the first part, " "and this is the second part." << std::endl;
// 输出: This is the first part, and this is the second part.

// 可以跨越多行
std::cout << "Line 1: Some text...\n"
"Line 2: More text...\n"
"Line 3: Final line." << std::endl;
/*
输出:
Line 1: Some text...
Line 2: More text...
Line 3: Final line.
*/

// 拼接结果是一个单独的字符串常量
const char* long_message = "Part 1. "
"Part 2. "
"Part 3.";
std::cout << long_message << std::endl;
// 输出: Part 1. Part 2. Part 3.

// 注意:变量和字符串字面值不能自动拼接
std::string part1 = "Hello";
// std::cout << part1 " world!"; // 错误! 不能这样拼接变量和字面值
// 需要使用 string 类的拼接操作 (见 4.3 节) 或 cout 的链式输出
std::cout << part1 << " world!" << std::endl; // 正确

return 0;
}

4.2.2 在数组中使用字符串

C风格字符串本质上是 char 类型的数组,其特殊之处在于最后一个字符必须是**空字符 (\0)**。这个空字符标记了字符串的实际结束位置。

声明和初始化:

可以使用字符串字面值来初始化 char 数组。编译器会自动计算大小(包括末尾的 \0)并将其复制到数组中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <cstring> // 包含 C 风格字符串函数库 (例如 strlen)

int main() {
// 使用字符串字面值初始化 char 数组
char dog[8] = "fatcat"; // 数组大小必须足够容纳字符串 + 空字符 ('f','a','t','c','a','t','\0')
// dog 数组大小为 8,实际使用 7 个字符 + 1 个空字符

char bird[] = "wren"; // 编译器自动推断大小为 5 ('w','r','e','n','\0')

// 使用列表初始化 (需要显式包含空字符)
char fish[] = {'t', 'r', 'o', 'u', 't', '\0'};

// 错误示例:数组大小不足
// char cat[3] = "cat"; // 错误! 需要大小 4 来存储 'c','a','t','\0'

std::cout << "Dog: " << dog << std::endl; // cout 遇到空字符停止输出
std::cout << "Bird: " << bird << std::endl;
std::cout << "Fish: " << fish << std::endl;

// 访问单个字符
std::cout << "Third letter of dog: " << dog[2] << std::endl; // 输出 't'
dog[0] = 'p'; // 可以修改数组内容
std::cout << "Modified dog: " << dog << std::endl; // 输出 "patcat"

// strlen() 函数计算字符串长度 (不包括空字符)
std::cout << "Length of dog: " << std::strlen(dog) << std::endl; // 输出 6 (因为现在是 "patcat")
std::cout << "Length of bird: " << std::strlen(bird) << std::endl; // 输出 4

// sizeof() 计算整个数组占用的内存大小 (包括空字符和未使用的空间)
std::cout << "Size of dog array: " << sizeof(dog) << " bytes" << std::endl; // 输出 8
std::cout << "Size of bird array: " << sizeof(bird) << " bytes" << std::endl; // 输出 5

return 0;
}

关键点:

  • 存储 C 风格字符串的 char 数组大小必须至少是字符串长度加 1(为 \0 留出空间)。
  • 字符串字面值初始化会自动添加 \0
  • 列表初始化需要手动添加 \0
  • strlen() 计算的是到 \0 为止的字符数。
  • sizeof() 计算的是整个数组的字节大小。

4.2.3 字符串输入

使用 cin>> 运算符读取 C 风格字符串(存储在 char 数组中)时,存在一个主要限制:cin 默认以空白字符(空格、制表符、换行符)作为输入的分隔符。这意味着 cin >> 只会读取到第一个空白字符之前的部分。

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>

int main() {
const int SIZE = 20;
char name[SIZE];
char dessert[SIZE];

std::cout << "Enter your first name: ";
std::cin >> name; // 读取直到遇到第一个空白字符

std::cout << "Enter your favorite dessert: ";
std::cin >> dessert; // 读取直到遇到第一个空白字符

std::cout << "Hello, " << name << "!" << std::endl;
std::cout << "I see you like " << dessert << "." << std::endl;

return 0;
}

运行示例及问题:

如果用户输入:

1
2
Enter your first name: Ada Lovelace
Enter your favorite dessert: Chocolate Cake

程序输出将会是:

1
2
Hello, Ada!
I see you like Lovelace.

原因:

  1. cin >> name; 读取到 “Ada” 后遇到空格停止,”Ada” 被存入 name 数组(并自动添加 \0)。
  2. “ Lovelace\nChocolate Cake\n” 仍然留在输入缓冲区中。
  3. cin >> dessert; 从缓冲区开始读取,跳过开头的空格,读取到 “Lovelace” 后遇到换行符停止,”Lovelace” 被存入 dessert 数组。

这显然不是我们期望的结果。cin >> 不适合读取包含空格的字符串。此外,如果用户输入的单词长度超过了数组的大小(减去 \0 的空间),还会导致**缓冲区溢出 (Buffer Overflow)**,这是严重的安全隐患。

4.2.4 每次读取一行字符串输入

为了解决 cin >> 的问题,iostream 库提供了其他成员函数来读取整行输入,包括其中的空格,直到遇到换行符为止。常用的有两个:getline()get()

1. cin.getline(char* buffer, int size, char delimiter = '\n')

  • buffer: 用于存储输入的 char 数组。
  • size: 缓冲区的大小。getline() 最多读取 size - 1 个字符,以确保有空间存放末尾的空字符 \0
  • delimiter (可选): 指定读取停止的分隔符,默认为换行符 \n

行为:

  • 读取字符到 buffer 中,直到读取了 size - 1 个字符、遇到 delimiter 或到达文件末尾。
  • 如果遇到 delimiter,它会读取并丢弃该分隔符(通常是换行符)。
  • 总是在读取的字符序列末尾添加空字符 \0

2. cin.get(char* buffer, int size, char delimiter = '\n')

  • 参数与 getline() 类似。

行为:

  • 读取字符到 buffer 中,直到读取了 size - 1 个字符、遇到 delimiter 或到达文件末尾。
  • getline() 不同的是,如果遇到 delimiter,它不会读取该分隔符,而是将其留在输入缓冲区中
  • 总是在读取的字符序列末尾添加空字符 \0

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>

int main() {
const int SIZE = 50;
char fullName[SIZE];
char address[SIZE];

// 使用 getline()
std::cout << "Enter your full name: ";
std::cin.getline(fullName, SIZE); // 读取整行,包括空格,丢弃换行符

std::cout << "Enter your address: ";
std::cin.getline(address, SIZE); // 读取下一行

std::cout << "Name: " << fullName << std::endl;
std::cout << "Address: " << address << std::endl;
std::cout << "--------------------" << std::endl;

// 使用 get() - 注意换行符问题
char title[SIZE];
char author[SIZE];

std::cout << "Enter book title: ";
std::cin.get(title, SIZE); // 读取整行,换行符留在缓冲区

// 如果直接调用下一个 get(),它会立即读到上一个留下的换行符并停止
// std::cin.get(author, SIZE); // 这将导致 author 为空

// 需要处理掉留下的换行符
// 方法一:再调用一次 get() 读取单个字符
// std::cin.get(); // 读取并丢弃换行符

// 方法二:使用 ignore() 跳过字符
std::cin.ignore(100, '\n'); // 跳过最多100个字符,直到遇到换行符(并丢弃换行符)

std::cout << "Enter author name: ";
std::cin.get(author, SIZE); // 现在可以正确读取作者名

std::cout << "Title: " << title << std::endl;
std::cout << "Author: " << author << std::endl;


return 0;
}

选择 getline() 还是 get()?

  • getline() 通常更方便,因为它会自动处理掉行尾的换行符,使得连续读取多行输入更简单。
  • get() 提供了更精细的控制,因为它允许你检查下一个字符是否是换行符,但需要你手动处理留在缓冲区的分隔符。

空行和 getline(): 如果 getline() 遇到空行(即用户直接按 Enter),它会读取这个空行,将一个空字符串(只包含 \0)存入缓冲区,并丢弃换行符。

4.2.5 混合输入字符串和数字

当程序需要交替读取数字(使用 cin >>)和整行字符串(使用 cin.getline()cin.get())时,经常会遇到一个问题:cin >> 读取数字后,会将数字后面的换行符留在输入缓冲区中。

如果紧接着调用 cin.getline()cin.get(),它们会立即读到这个残留的换行符,并认为已经到达行尾,导致读取失败或读到空字符串。

问题示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>

int main() {
int age;
char name[50];

std::cout << "Enter your age: ";
std::cin >> age; // 读取年龄,换行符留在缓冲区

std::cout << "Enter your full name: ";
std::cin.getline(name, 50); // 立即读到残留的换行符,getline 结束,name 为空

std::cout << "Age: " << age << std::endl;
std::cout << "Name: [" << name << "]" << std::endl; // 输出 Name: []

return 0;
}

解决方法:

在读取数字后、调用 getline()get() 读取整行之前,需要消耗掉输入缓冲区中残留的换行符。

  1. 使用 cin.ignore(): 这是常用的方法。cin.ignore(n, delim) 会跳过输入流中的字符,直到跳过了 n 个字符,或者遇到了 delim 分隔符(并丢弃该分隔符),以先到者为准。通常用于丢弃换行符:
    1
    2
    3
    std::cin.ignore(100, '\n'); // 跳过最多100个字符,直到并包括下一个换行符
    // 或者更简单地,如果确定只有一个换行符需要丢弃
    // std::cin.ignore(); // 跳过下一个字符 (即换行符)
  2. 使用 (cin >> ws): C++11 引入了 std::ws 输入流操纵符,它可以读取并丢弃输入流开头的所有空白字符(包括换行符)。
    1
    (std::cin >> std::ws).getline(name, 50); 
  3. 使用 cin.get() 读取单个字符:
    1
    std::cin.get(); // 读取并丢弃换行符

修正后的示例 (使用 cin.ignore()):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
#include <limits> // 为了 numeric_limits (更健壮的 ignore)

int main() {
int age;
char name[50];

std::cout << "Enter your age: ";
std::cin >> age;

// 清除输入缓冲区,特别是换行符
std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
// numeric_limits<streamsize>::max() 获取流能处理的最大字符数,确保清除整行

std::cout << "Enter your full name: ";
std::cin.getline(name, 50); // 现在可以正确读取姓名

std::cout << "Age: " << age << std::endl;
std::cout << "Name: [" << name << "]" << std::endl;

return 0;
}

修正后的示例 (使用 ws):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <ios> // 为了 ws

int main() {
int age;
char name[50];

std::cout << "Enter your age: ";
std::cin >> age;

std::cout << "Enter your full name: ";
// 在 getline 之前使用 ws 跳过空白符
(std::cin >> std::ws).getline(name, 50);

std::cout << "Age: " << age << std::endl;
std::cout << "Name: [" << name << "]" << std::endl;

return 0;
}

总结: 混合输入数字和整行字符串时,务必记得在 cin >> number; 之后、调用 getline()get() 之前,清除输入缓冲区中残留的换行符。使用 cin.ignore()(cin >> ws) 是推荐的做法。

4.3 string类简介

虽然 C 风格字符串(字符数组)在 C++ 中仍然可用,但 C++ 标准库提供了一个更强大、更方便、更安全的替代品:std::string 类。

string 类是标准库的一部分,它封装了字符序列的操作,提供了自动内存管理和丰富的成员函数来处理字符串。要使用 string 类,需要包含 <string> 头文件。

基本概念:

  • 对象: string 类型的变量是**对象 (Object)**。对象是类的实例。
  • 自动内存管理: 与需要手动管理内存(确保数组足够大,处理空字符)的 C 风格字符串不同,string 对象会自动处理内存分配和释放。它可以根据需要动态增长或缩小。
  • 成员函数: string 类提供了许多内置的操作(成员函数),如获取长度、拼接、查找、替换等,使得字符串处理更加容易。

基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <string> // 必须包含 string 头文件

int main() {
// 声明 string 对象
std::string s1; // 创建一个空字符串
std::string s2 = "Hello"; // 使用 C 风格字符串字面值初始化
std::string s3("World"); // 使用 C 风格字符串字面值初始化 (构造函数语法)
std::string s4 = s2; // 使用另一个 string 对象初始化 (复制)

std::cout << "s1: " << s1 << std::endl; // 输出空行
std::cout << "s2: " << s2 << std::endl; // 输出 Hello
std::cout << "s3: " << s3 << std::endl; // 输出 World
std::cout << "s4: " << s4 << std::endl; // 输出 Hello

// string 对象可以像普通变量一样使用
std::string message = s2 + ", " + s3 + "!"; // 字符串拼接
std::cout << "Message: " << message << std::endl; // 输出 Hello, World!

return 0;
}

4.3.1 C++11字符串初始化

C++11 引入的列表初始化(花括号初始化)也可以用于 string 对象,其行为类似于使用 C 风格字符串字面值进行初始化。

语法:

1
2
3
4
5
6
#include <string>

std::string str1 { "Initialized with braces" }; // 使用 C 风格字符串字面值
std::string str2 = { "Also works with =" };
// std::string str3 { 'a', 'b', 'c' }; // C++11 中通常不直接用字符列表初始化 string (会尝试调用匹配的构造函数)
// std::string str4 = { 'x', 'y', 'z' }; // 同上

注意: 直接使用字符列表 { 'a', 'b', 'c' } 来初始化 std::string 在 C++11/14 中通常不会按预期工作,因为它会尝试查找接受 std::initializer_list<char> 的构造函数,而标准 std::string 没有这样的构造函数。它通常会被解释为尝试调用接受 C 风格字符串 ( const char* ) 的构造函数,但这需要列表恰好能形成一个有效的 C 风格字符串(例如,包含 \0)。

最常用和清晰的初始化方式仍然是使用字符串字面值或另一个 string 对象。

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <string>

int main() {
std::string greeting1 { "Good morning" };
std::string greeting2 = { "Good afternoon" };
// std::string letters { 'H', 'i', '\0' }; // 可能不按预期工作或编译错误

std::cout << greeting1 << std::endl; // 输出 Good morning
std::cout << greeting2 << std::endl; // 输出 Good afternoon

// 推荐的初始化方式
std::string s1 = "Hello"; // C 风格字面值
std::string s2("World"); // 构造函数语法
std::string s3 = s1; // 复制构造
std::string s4(10, 'c'); // 创建包含 10 个 'c' 的字符串 "cccccccccc"

std::cout << "s4: " << s4 << std::endl;

return 0;
}

4.3.2 赋值、拼接和附加

string 类重载了常见的运算符,使得赋值、拼接和附加操作非常直观。

  • 赋值 (=): 可以将一个 string 对象、一个 C 风格字符串字面值或一个 char 赋给一个 string 对象。
  • 拼接 (+): 可以使用 + 运算符将两个 string 对象、string 对象和 C 风格字符串字面值、或者 string 对象和 char 拼接起来,生成一个新的 string 对象。注意:不能直接拼接两个 C 风格字符串字面值,至少有一个操作数需要是 string 对象。
  • 附加 (+=): 可以使用 += 运算符将一个 string 对象、一个 C 风格字符串字面值或一个 char 附加到现有 string 对象的末尾(修改原字符串)。

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <iostream>
#include <string>

int main() {
std::string s1 = "Hello";
std::string s2 = "World";
std::string s3;
std::string s4;

// 赋值
s3 = s1; // s3 变为 "Hello"
std::cout << "s3 after assignment: " << s3 << std::endl;
s3 = "Goodbye"; // s3 变为 "Goodbye"
std::cout << "s3 after new assignment: " << s3 << std::endl;
s3 = 'X'; // s3 变为 "X"
std::cout << "s3 after char assignment: " << s3 << std::endl;

// 拼接 (+) - 创建新字符串
s4 = s1 + " " + s2 + "!"; // s4 变为 "Hello World!"
std::cout << "s4 (concatenated): " << s4 << std::endl;

std::string s5 = s1 + '!'; // s5 变为 "Hello!"
std::cout << "s5 (string + char): " << s5 << std::endl;

// 错误: 不能直接拼接两个 C 风格字符串字面值
// std::string error_str = "String1" + "String2"; // 编译错误!

// 正确: 至少有一个是 string 对象
std::string ok_str1 = s1 + " String2";
std::string ok_str2 = "String1" + s2;
std::cout << "ok_str1: " << ok_str1 << std::endl;
std::cout << "ok_str2: " << ok_str2 << std::endl;

// 附加 (+=) - 修改原字符串
s1 += " "; // s1 变为 "Hello "
s1 += s2; // s1 变为 "Hello World"
s1 += '!'; // s1 变为 "Hello World!"
std::cout << "s1 after append: " << s1 << std::endl;

return 0;
}

4.3.3 string类的其他操作

string 类提供了大量成员函数来执行各种字符串操作。以下是一些常用的操作:

  • 获取长度/大小:
    • size()length(): 返回字符串中的字符数(两者功能相同)。
  • 检查是否为空:
    • empty(): 如果字符串为空,返回 true,否则返回 false
  • 访问字符:
    • [] 运算符: 像数组一样通过索引访问字符(不进行边界检查)。
    • at(): 通过索引访问字符(进行边界检查,如果越界会抛出 std::out_of_range 异常)。
  • 查找:
    • find(): 查找子字符串或字符首次出现的位置,返回索引;如果未找到,返回 std::string::npos (一个特殊的静态成员常量)。
    • rfind(): 从后向前查找。
    • find_first_of(), find_last_of(), find_first_not_of(), find_last_not_of(): 查找字符集中的任意字符或非任意字符。
  • 子字符串:
    • substr(pos, count): 返回从位置 pos 开始,长度为 count 的子字符串。
  • 比较:
    • compare(): 比较字符串(字典序),返回负数、零或正数。
    • 重载的关系运算符 (==, !=, <, >, <=, >=): 可以直接比较 string 对象。
  • 修改:
    • insert(): 在指定位置插入字符或字符串。
    • erase(): 删除指定位置和数量的字符。
    • replace(): 替换指定范围的字符。
    • clear(): 清空字符串。
    • append(): 等同于 +=

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <iostream>
#include <string>
#include <stdexcept> // 为了 std::out_of_range

int main() {
std::string text = "Hello C++ World!";

// 长度和空检查
std::cout << "Text: \"" << text << "\"" << std::endl;
std::cout << "Length: " << text.length() << std::endl; // 输出 16
std::cout << "Is empty? " << std::boolalpha << text.empty() << std::endl; // 输出 false

// 访问字符
std::cout << "First char (operator[]): " << text[0] << std::endl; // 输出 H
std::cout << "Fifth char (at()): " << text.at(4) << std::endl; // 输出 o
// std::cout << text.at(20); // 会抛出 std::out_of_range 异常

// 查找
size_t pos_cpp = text.find("C++");
if (pos_cpp != std::string::npos) {
std::cout << "\"C++\" found at index: " << pos_cpp << std::endl; // 输出 6
} else {
std::cout << "\"C++\" not found." << std::endl;
}

size_t pos_l = text.find('l'); // 查找第一个 'l'
std::cout << "First 'l' found at index: " << pos_l << std::endl; // 输出 2

size_t pos_last_l = text.rfind('l'); // 查找最后一个 'l'
std::cout << "Last 'l' found at index: " << pos_last_l << std::endl; // 输出 12

// 子字符串
std::string sub = text.substr(6, 3); // 从索引 6 开始,取 3 个字符
std::cout << "Substring (6, 3): \"" << sub << "\"" << std::endl; // 输出 "C++"

// 比较
std::string s1 = "apple";
std::string s2 = "apply";
if (s1 < s2) {
std::cout << "\"" << s1 << "\" comes before \"" << s2 << "\"" << std::endl;
}
int cmp_result = s1.compare(s2); // 返回负数,因为 "apple" < "apply"
std::cout << "Compare result: " << cmp_result << std::endl;

// 修改
text.insert(10, " beautiful"); // 在索引 10 处插入
std::cout << "After insert: \"" << text << "\"" << std::endl; // 输出 "Hello C++ beautiful World!"

text.erase(6, 4); // 从索引 6 开始,删除 4 个字符 ("C++ ")
std::cout << "After erase: \"" << text << "\"" << std::endl; // 输出 "Hello beautiful World!"

text.replace(6, 9, "gorgeous"); // 从索引 6 开始,替换 9 个字符 ("beautiful")
std::cout << "After replace: \"" << text << "\"" << std::endl; // 输出 "Hello gorgeous World!"

text.clear(); // 清空字符串
std::cout << "After clear, is empty? " << std::boolalpha << text.empty() << std::endl; // 输出 true

return 0;
}

4.3.4 string类I/O

可以使用标准的输入输出流对象 cincout 来方便地读写 string 对象。

  • 输出 (cout <<): << 运算符被重载,可以直接将 string 对象输出到 cout
  • 输入 (cin >>): >> 运算符被重载,可以从 cin 读取一个单词(以空白符——空格、制表符、换行符分隔)到 string 对象中。它会自动跳过开头的空白符,然后在遇到下一个空白符时停止读取。
  • 读取整行 (getline()): 如果需要读取包含空格的整行文本,应该使用 getline() 函数(这是一个全局函数,不是 string 的成员函数)。
    • getline(cin, str): 从 cin 读取一行(直到遇到换行符 \n),并将内容(不包括换行符)存储到 string 对象 str 中。
    • getline(cin, str, delimiter): 读取直到遇到指定的 delimiter 字符为止。

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
#include <string>

int main() {
std::string word1, word2;
std::string line;

// 输出
std::string message = "Enter two words:";
std::cout << message << std::endl;

// 输入 (cin >> 读取单词)
std::cin >> word1 >> word2; // 输入 "Hello World" (用空格隔开)
std::cout << "Word 1: " << word1 << std::endl; // 输出 Hello
std::cout << "Word 2: " << word2 << std::endl; // 输出 World

// 清除输入缓冲区中可能残留的换行符 (cin >> 之后通常需要)
std::cin.ignore(10000, '\n');

// 输入 (getline 读取整行)
std::cout << "Enter a line of text: ";
getline(std::cin, line); // 输入 "This is a test line."
std::cout << "You entered: \"" << line << "\"" << std::endl; // 输出 "This is a test line."

return 0;
}

注意: 在混合使用 cin >>getline(cin, ...) 时要特别小心。cin >> 读取单词后,会将换行符留在输入缓冲区中。如果紧接着调用 getline(),它会立即读到这个换行符并认为读取结束,导致得到一个空字符串。通常需要在 cin >> 之后、getline() 之前清除缓冲区中的换行符,例如使用 std::cin.ignore()

4.3.5 其他形式的字符串字面值

C++11 引入了新的字符串字面值形式,提供了对不同字符编码(如 Unicode)的更好支持。

  1. 原始字符串字面值 (Raw String Literal):

    • 语法: R"delimiter(raw_characters)delimiter"
    • delimiter 是一个可选的、最多16个字符的序列(不能包含空格、括号、反斜杠)。
    • raw_characters 是字符串内容,其中的反斜杠 \ 和引号 " 等特殊字符不会被转义,按原样解释。
    • 主要用于书写包含大量特殊字符的字符串,如正则表达式、文件路径、HTML/XML代码等,避免大量的反斜杠转义。
    • 示例: R"(C:\Program Files\)", R"delimiter(String with "quotes" and \backslashes)delimiter"
  2. Unicode 字符串字面值:

    • u8"string": UTF-8 编码的字符串 (类型是 const char[],但应存储在 std::string 或处理 UTF-8 的地方)。
    • u"string": UTF-16 编码的字符串 (类型是 const char16_t[])。
    • U"string": UTF-32 编码的字符串 (类型是 const char32_t[])。

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>
#include <string>

int main() {
// 原始字符串字面值
std::string path1 = "C:\\Program Files\\My App\\"; // 需要转义反斜杠
std::string path2 = R"(C:\Program Files\My App\)"; // 使用原始字符串,无需转义
std::string regex = R"(\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b)"; // 正则表达式
std::string html = R"delimiter(
<html>
<head><title>Raw String</title></head>
<body><p>"Hello!"</p></body>
</html>)delimiter";

std::cout << "Path 1: " << path1 << std::endl;
std::cout << "Path 2: " << path2 << std::endl;
std::cout << "Regex: " << regex << std::endl;
std::cout << "HTML:\n" << html << std::endl;

// Unicode 字符串字面值 (主要用于需要特定编码的场景)
const char* utf8_str = u8"你好,世界"; // UTF-8 (需要支持 UTF-8 的环境/终端才能正确显示)
const char16_t* utf16_str = u"你好,世界"; // UTF-16
const char32_t* utf32_str = U"你好,世界"; // UTF-32

std::cout << "UTF-8 String (may not display correctly): " << utf8_str << std::endl;

// 处理和打印 UTF-16/32 通常需要专门的库或函数
// std::cout << utf16_str; // 不能直接用 cout 打印 char16_t* / char32_t*

return 0;
}

原始字符串字面值在处理包含特殊字符的文本时非常方便。Unicode 字符串字面值则为处理国际化文本提供了标准化的基础。

4.4 结构简介

数组允许我们存储多个相同类型的数据。但有时我们需要将不同类型的数据组合成一个单一的、有意义的单元。例如,描述一件商品可能需要商品名称(字符串)、数量(整数)和单价(浮点数)。C++ 的结构 (Structure) 就提供了这种能力。

结构是一种用户定义的复合类型,它允许将多个不同类型的数据项(称为成员 (member) 或**字段 (field)**)捆绑在一起,形成一个新的数据类型。

4.4.1 在程序中使用结构

使用结构通常涉及以下步骤:

  1. 定义结构: 使用 struct 关键字定义一个新的结构类型,并在花括号 {} 内声明其成员。结构定义通常放在 main() 函数之前或单独的头文件中。
  2. 声明结构变量: 使用定义好的结构类型名来声明变量。
  3. 访问结构成员: 使用成员运算符(点运算符 .) 来访问结构变量的特定成员。

结构定义语法:

1
2
3
4
5
struct StructureName {
memberType1 memberName1;
memberType2 memberName2;
// ... more members
}; // 注意定义末尾的分号

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>
#include <string> // 为了使用 string

// 1. 定义结构: 描述充气产品的结构体
struct Inflatable {
std::string name; // 商品名称 (string 类型成员)
float volume; // 体积 (float 类型成员)
double price; // 价格 (double 类型成员)
};

int main() {
// 2. 声明结构变量
Inflatable product1; // 声明一个 Inflatable 类型的变量 product1
Inflatable product2; // 声明另一个 Inflatable 类型的变量 product2

// 3. 访问并赋值结构成员
product1.name = "Awesome Air Mattress";
product1.volume = 1.8f;
product1.price = 49.99;

// 也可以在声明时进行初始化 (C++98/03 风格,需要按顺序)
Inflatable product3 = {"Giant Swan Floatie", 2.5f, 79.95};

// 访问并输出成员
std::cout << "Product 1 Name: " << product1.name << std::endl;
std::cout << "Product 1 Price: $" << product1.price << std::endl;

std::cout << "Product 3 Name: " << product3.name << std::endl;
std::cout << "Product 3 Volume: " << product3.volume << " cubic meters" << std::endl;

// 结构变量之间可以直接赋值 (成员逐个复制)
product2 = product1;
std::cout << "Product 2 Name (after assignment): " << product2.name << std::endl;

return 0;
}
  • struct Inflatable { ... };: 定义了一个名为 Inflatable 的新类型。
  • Inflatable product1;: 创建了一个 Inflatable 类型的变量(对象)。
  • product1.name = ...;: 使用点运算符访问 product1name 成员并赋值。

4.4.2 C++11结构初始化

C++11 引入的列表初始化(花括号初始化)也适用于结构体,提供了更灵活、更安全的初始化方式。

特点:

  1. 可以省略等号 =: 与数组类似,可以在初始化时省略等号。
  2. 可以按成员顺序初始化: StructType var {value1, value2, ...};
  3. 可以初始化部分成员 (C++20 designated initializers): C++20 允许通过指定成员名进行初始化,可以不按顺序或只初始化部分成员。但在 C++11/14/17 中,通常需要按顺序提供值。
  4. 空花括号初始化: StructType var {}; 会将所有成员进行零初始化(数值类型为0,指针为 nullptrboolfalse,类类型会调用默认构造函数)。
  5. 禁止缩窄转换: 与数组一样,列表初始化不允许可能丢失信息的缩窄转换。

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
#include <string>

struct Product {
std::string id;
int quantity;
double price;
};

int main() {
// C++11 列表初始化 (省略等号)
Product item1 { "A123", 50, 19.95 }; // 按顺序初始化所有成员

// 省略等号,部分初始化 (C++11/14/17 中,未提供的成员会被值初始化/零初始化)
// 注意:这种部分初始化对于包含 std::string 等类类型成员时,行为依赖于这些类的默认构造函数
// Product item2 { "B456", 100 }; // price 会被零初始化为 0.0

// 空花括号进行零初始化
Product item3 {}; // id 为空字符串, quantity 为 0, price 为 0.0

std::cout << "Item 1 ID: " << item1.id << ", Qty: " << item1.quantity << ", Price: " << item1.price << std::endl;
// std::cout << "Item 2 ID: " << item2.id << ", Qty: " << item2.quantity << ", Price: " << item2.price << std::endl;
std::cout << "Item 3 ID: \"" << item3.id << "\", Qty: " << item3.quantity << ", Price: " << item3.price << std::endl;

// 禁止缩窄转换
// Product item_error { "C789", 10.5, 25.0 }; // 错误! 10.5 (double) 到 int 是缩窄转换

// C++20 Designated Initializers (如果编译器支持 C++20)
// Product item4 { .id = "D001", .price = 99.99 }; // quantity 会被零初始化
// std::cout << "Item 4 ID: " << item4.id << ", Qty: " << item4.quantity << ", Price: " << item4.price << std::endl;

return 0;
}

4.4.3 结构可以将string类作为成员吗

是的,绝对可以。 正如在 4.4.14.4.2 的示例中看到的 (InflatableProduct 结构),std::string 对象可以像 intdouble 或其他任何类型一样作为结构的成员。

这使得结构能够方便地包含文本信息,并利用 string 类提供的所有功能(自动内存管理、拼接、查找等)。

示例回顾:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <string>

struct Student {
std::string name; // string 成员
int studentID;
double gpa;
};

int main() {
Student s1 { "Alice Wonderland", 12345, 3.8 };
Student s2;
s2.name = "Bob The Builder"; // 可以像普通 string 一样操作
s2.studentID = 67890;
s2.gpa = 3.5;

std::cout << s1.name << " has GPA: " << s1.gpa << std::endl;
std::cout << s2.name << " has ID: " << s2.studentID << std::endl;

return 0;
}

4.4.4 其他结构属性

结构在 C++ 中具有一些方便的属性:

  1. 赋值 (Assignment): 可以使用赋值运算符 = 将一个结构变量的值赋给同类型的另一个结构变量。这会执行**成员逐一复制 (memberwise copy)**,即将源结构每个成员的值复制到目标结构对应成员中。
    1
    2
    3
    Student s1 = {"Charlie", 111, 3.9};
    Student s2;
    s2 = s1; // s2 的 name, studentID, gpa 都被设置为 s1 的值
  2. 作为函数参数 (Pass by Value): 可以将结构变量按值传递给函数。函数会收到结构的一个副本,对副本成员的修改不会影响原始结构变量。
    1
    2
    3
    4
    5
    6
    7
    void displayStudent(Student s) { // s 是传入结构的一个副本
    std::cout << "ID: " << s.studentID << ", Name: " << s.name << std::endl;
    s.name = "Changed"; // 只修改副本
    }
    // ...
    displayStudent(s1); // 传递 s1 的副本
    std::cout << s1.name; // 输出 "Charlie",未被改变
  3. 作为函数参数 (Pass by Reference/Pointer): 为了避免复制整个结构的开销,或者需要在函数中修改原始结构,通常按引用或指针传递结构。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    void updateGPA(Student& s, double newGPA) { // 按引用传递,可以修改原始结构
    s.gpa = newGPA;
    }
    void printID(const Student* sPtr) { // 按指针传递 (const 防止意外修改)
    std::cout << "ID: " << sPtr->studentID << std::endl; // 使用 -> 访问指针指向的结构成员
    }
    // ...
    updateGPA(s1, 4.0); // 修改原始 s1
    printID(&s1); // 传递 s1 的地址
  4. 作为函数返回值: 函数可以返回一个结构。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    Student createStudent(std::string name, int id, double gpa) {
    Student temp;
    temp.name = name;
    temp.studentID = id;
    temp.gpa = gpa;
    return temp; // 返回一个 Student 结构
    }
    // ...
    Student s3 = createStudent("David", 222, 3.7);

这些特性使得结构成为组织和传递相关数据的强大工具。

4.4.5 结构数组

可以创建**结构数组 (Array of Structures)**,即数组的每个元素都是一个结构变量。这对于处理一组具有相同结构的数据非常有用,例如一个班级的学生信息、一个商店的库存列表等。

声明和初始化:

声明结构数组与声明普通数组类似,只是元素类型是结构类型。初始化可以使用嵌套的花括号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <iostream>
#include <string>

struct Student {
std::string name;
int studentID;
};

const int CLASS_SIZE = 3;

int main() {
// 声明一个包含 CLASS_SIZE 个 Student 结构的数组
Student classRoster[CLASS_SIZE];

// 初始化结构数组 (使用嵌套花括号)
Student graduates[2] = {
{"Alice", 101}, // 初始化 graduates[0]
{"Bob", 102} // 初始化 graduates[1]
};

// C++11 列表初始化
Student freshmen[CLASS_SIZE] {
{"Charlie", 201},
{"David", 202},
{"Eve", 203}
};

// 访问结构数组中的元素及其成员
classRoster[0].name = "Frank";
classRoster[0].studentID = 301;

std::cout << "Graduate 1: " << graduates[0].name << " (ID: " << graduates[0].studentID << ")" << std::endl;
std::cout << "Freshman 3 ID: " << freshmen[2].studentID << std::endl;
std::cout << "Roster 1 Name: " << classRoster[0].name << std::endl;

// 遍历结构数组
std::cout << "\nFreshmen List:" << std::endl;
for (int i = 0; i < CLASS_SIZE; ++i) {
std::cout << " - " << freshmen[i].name << " (ID: " << freshmen[i].studentID << ")" << std::endl;
}

return 0;
}
  • Student classRoster[CLASS_SIZE];: 声明了一个数组,每个元素都是 Student 结构。
  • graduates[0] = {"Alice", 101};: 初始化数组的第一个元素(一个 Student 结构)。
  • freshmen[i].name: 访问数组 freshmen 中索引为 i 的元素的 name 成员。

4.4.6 结构中的位字段

位字段 (Bit Field) 是一种特殊的结构成员,它允许你指定成员变量占用的**位数 (bits)**。这主要用于需要精确控制内存布局或与硬件寄存器交互的场景。

语法:

在结构定义中,成员名后面跟一个冒号 : 和一个整数常量,表示该成员占用的位数。

1
2
3
4
5
6
7
struct RegisterFlags {
unsigned int readEnable : 1; // 占用 1 位
unsigned int writeEnable : 1; // 占用 1 位
unsigned int mode : 2; // 占用 2 位
unsigned int reserved : 4; // 占用 4 位 (通常用于填充或对齐)
// ...
};

特点和注意事项:

  • 类型: 位字段的类型通常是 unsigned intsigned int(或 int,其符号性取决于实现),也可以是 bool (C++11,等效于 : 1)。
  • 内存节省: 当多个标志或小范围数值需要存储时,位字段可以显著节省内存,将它们打包到单个整数或几个字节中。
  • 硬件接口: 常用于映射硬件设备寄存器的特定位。
  • 访问: 像普通结构成员一样使用点运算符访问,但不能获取位字段的地址(& 运算符不能用于位字段)。
  • 可移植性: 位字段的内存布局(位的排列顺序、跨字节边界的处理)可能因编译器和平台而异,因此在需要跨平台兼容性的代码中应谨慎使用。
  • 大小限制: 位数不能超过其基础类型的位数(例如,unsigned int 的位字段不能超过 int 的位数)。
  • 匿名位字段: 可以使用未命名的位字段来填充或对齐,例如 unsigned int : 2;

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>

// 假设用于控制某个设备的状态
struct DeviceStatus {
// 打包到单个字节 (假设 unsigned int 至少 8 位)
unsigned int powerOn : 1;
unsigned int errorFlag : 1;
unsigned int channel : 3; // 可以表示 0-7
unsigned int : 3; // 填充未使用的 3 位,使总共 8 位
};

int main() {
DeviceStatus status;

// 设置位字段的值
status.powerOn = 1; // 设备开机
status.errorFlag = 0; // 无错误
status.channel = 5; // 设置通道为 5

// 读取位字段的值
std::cout << "Device Status:" << std::endl;
std::cout << " Power On: " << status.powerOn << std::endl;
std::cout << " Error Flag: " << status.errorFlag << std::endl;
std::cout << " Channel: " << status.channel << std::endl;

// 位字段通常打包存储
std::cout << "Size of DeviceStatus struct: " << sizeof(DeviceStatus) << " byte(s)" << std::endl;
// 输出通常是 1 或 4 (取决于编译器如何对齐和打包,以及 int 的大小)

// 检查特定标志
if (status.powerOn) {
std::cout << "Device is powered on." << std::endl;
}

// 不能获取位字段地址
// unsigned int* pPower = &status.powerOn; // 错误!

return 0;
}

位字段是一种底层工具,适用于特定场景,但在常规应用程序开发中不常用。

4.5 共用体

共用体 (Union) 是一种特殊的数据结构,它也允许在一个结构中存储不同的数据类型,但与结构体 (struct) 不同的是,共用体的所有成员共享同一块内存空间

核心特点:

  • 内存共享: 共用体的大小由其最大的成员的大小决定。所有成员都从相同的内存地址开始存储。
  • 同一时间只有一个成员有效: 在任何时刻,你只能有效地存储和使用共用体中的一个成员的值。当你给一个成员赋值时,可能会覆盖掉其他成员的数据。
  • 节省内存: 当你需要存储多种类型的数据,但知道在任何时候只需要用到其中一种时,共用体可以节省内存,因为它只需要分配足够容纳最大成员的空间。

定义共用体:

使用 union 关键字定义,语法与 struct 类似。

1
2
3
4
5
union UnionName {
memberType1 memberName1;
memberType2 memberName2;
// ... more members
}; // 注意定义末尾的分号

访问成员:

与结构体一样,使用成员运算符(点运算符 .) 来访问共用体变量的成员。

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <iostream>

// 定义一个共用体,可以存储 int, long 或 double
union DataValue {
int i_val;
long l_val;
double d_val;
};

int main() {
DataValue data; // 声明一个 DataValue 共用体变量

std::cout << "Size of DataValue union: " << sizeof(DataValue) << " bytes" << std::endl;
// 输出的大小通常等于 sizeof(double),因为它是最大的成员

// 存储并使用 int 成员
data.i_val = 100;
std::cout << "Stored as int: " << data.i_val << std::endl;
// 此时访问 l_val 或 d_val 的结果是未定义的/无意义的

// 存储并使用 double 成员 (会覆盖之前的 int 值)
data.d_val = 3.14159;
std::cout << "Stored as double: " << data.d_val << std::endl;
// 此时访问 i_val 或 l_val 的结果是未定义的/无意义的
// std::cout << "Reading i_val after storing double: " << data.i_val << std::endl; // 结果不可靠

// 存储并使用 long 成员 (会覆盖之前的 double 值)
data.l_val = 1234567890L;
std::cout << "Stored as long: " << data.l_val << std::endl;

// --- 追踪当前有效成员 ---
// 通常需要一个额外的变量来记录当前哪个成员是有效的
enum DataType { INT, LONG, DOUBLE };

struct DataPacket {
DataType type; // 记录当前存储的数据类型
DataValue value; // 共用体存储实际值
};

DataPacket packet;
packet.type = INT;
packet.value.i_val = 255;

// 根据类型访问
if (packet.type == INT) {
std::cout << "Packet contains int: " << packet.value.i_val << std::endl;
}
// ... 其他类型的检查

return 0;
}

重要: 程序员有责任跟踪共用体中当前哪个成员是活动的(有效的)。读取非活动成员的值会导致未定义行为或得到无意义的数据。通常会结合一个枚举类型或整数标志来指示当前存储的数据类型,如 DataPacket 示例所示。

匿名共用体 (Anonymous Union):

共用体可以不带名称直接定义在结构体或类内部(或函数局部作用域)。匿名共用体的成员可以直接通过结构/类变量访问,就像它们是结构/类的直接成员一样。匿名共用体的所有成员仍然共享相同的内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>

struct Widget {
char type; // 'I' for integer, 'F' for float
union { // 匿名共用体
int intVal;
float floatVal;
}; // 注意这里没有变量名
};

int main() {
Widget w;
w.type = 'I';
w.intVal = 123; // 直接访问匿名共用体的成员

if (w.type == 'I') {
std::cout << "Widget value (int): " << w.intVal << std::endl;
} else if (w.type == 'F') {
// w.floatVal = 3.14f; // 如果要存 float
// std::cout << "Widget value (float): " << w.floatVal << std::endl;
}

std::cout << "Size of Widget: " << sizeof(Widget) << std::endl;
// 大小通常是 char 的大小 + 最大成员(int 或 float)的大小 + 可能的对齐填充

return 0;
}

使用场景:

  • 节省内存: 当数据项有多种可能类型,但一次只使用一种时。
  • 类型双关 (Type Punning): 以不同的类型解释同一块内存区域(例如,将一个 float 的位模式解释为一个 int)。这是一种低级技巧,通常不可移植且可能违反 C++ 的严格别名规则 (strict aliasing rules),应谨慎使用或避免。

与结构的比较:

  • 内存: 结构的所有成员都有自己独立的内存地址;共用体的所有成员共享起始地址。
  • 大小: 结构的大小约等于其所有成员大小之和(加上可能的对齐填充);共用体的大小等于其最大成员的大小。
  • 有效性: 结构的所有成员可以同时有效;共用体只有一个成员能同时有效。

C++11 及以后的共用体:

C++11 放宽了对共用体成员类型的限制,允许包含具有非平凡构造函数、析构函数或赋值运算符的类类型成员(如 std::string)。但是,如果共用体包含这样的成员,编译器不会自动生成默认的构造函数、析构函数或复制/移动操作。程序员必须手动管理这些成员的生命周期(例如,使用 placement new 在共用体内存上构造对象,并在不再需要时显式调用析构函数)。这使得包含复杂类型的共用体使用起来更加复杂和易错。对于只包含 POD (Plain Old Data) 类型(如 int, float, 指针, C 风格数组/结构)的共用体,其行为与 C 语言中类似。

4.6 枚举

C++ 的 enum 工具提供了一种创建符号常量 (Symbolic Constant) 的方式,常用于定义一组相关的、具有名称的整数常量。这比使用 const int#define 来定义一组相关常量更方便、更具可读性。

基本概念:

  • 枚举类型 (Enumeration Type): enum 关键字用于创建一个新的用户定义的整数类型。
  • 枚举量 (Enumerator): 在枚举类型定义中列出的标识符。它们是具名的常量,代表整数值。

定义枚举:

使用 enum 关键字,后跟枚举类型的名称,然后在花括号 {} 内列出枚举量,用逗号分隔。

1
enum spectrum {red, orange, yellow, green, blue, violet, indigo, ultraviolet};

工作原理:

  1. 创建新类型: 上述语句创建了一个名为 spectrum 的新类型。
  2. 定义枚举量: red, orange, yellow 等成为 spectrum 类型的符号常量。
  3. 自动赋值: 默认情况下,编译器将整数值赋给枚举量,从 0 开始,依次递增 1。
    • red 值为 0
    • orange 值为 1
    • yellow 值为 2
    • ultraviolet 值为 7

声明和使用枚举变量:

可以像使用其他类型一样声明枚举类型的变量。枚举变量通常只能被赋予该枚举类型中定义的枚举量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>

enum spectrum {red, orange, yellow, green, blue, violet, indigo, ultraviolet};

int main() {
spectrum band; // 声明一个 spectrum 类型的变量 band

band = blue; // 将枚举量 blue 赋给 band (合法)

std::cout << "Current band (enum value): " << band << std::endl; // 输出 blue 对应的整数值 5

// band = 2000; // 错误! 不能直接将整数赋给枚举变量 (类型不匹配)
// band = red + orange; // 错误! + 运算符未对枚举类型定义 (虽然 red 和 orange 有整数值)

// 可以将枚举量赋给 int 变量 (枚举量会自动提升为 int)
int colorCode = green;
std::cout << "Color code for green: " << colorCode << std::endl; // 输出 3

// 可以强制将 int 转换为枚举类型 (需要显式转换,且需谨慎)
band = static_cast<spectrum>(3); // 将 3 转换为 spectrum 类型 (对应 green)
std::cout << "Band after cast: " << band << std::endl; // 输出 3

// 比较
if (band == green) {
std::cout << "The band is green." << std::endl;
}

// 在循环中使用 (需要注意类型转换和范围)
for (band = red; band <= ultraviolet; band = static_cast<spectrum>(band + 1)) {
std::cout << "Processing band: " << band << std::endl;
}

return 0;
}

枚举的优点:

  • 提高可读性: 使用有意义的名称(如 red, blue)代替神秘的数字(0, 4)。
  • 类型安全: 枚举创建了新的类型,有助于防止将不相关的整数值赋给枚举变量(虽然可以通过强制转换绕过)。
  • 代码维护: 如果需要更改某个常量的值或添加新常量,只需修改枚举定义。

4.6.1 设置枚举量的值

可以显式地为枚举量指定整数值。

规则:

  • 使用赋值运算符 = 为枚举量指定值。
  • 未被显式赋值的枚举量的值将基于前一个枚举量的值加 1。
  • 第一个枚举量如果未显式赋值,默认为 0。
  • 不同的枚举量可以具有相同的值。

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <iostream>

enum BitField {
BIT_ZERO, // 默认值为 0
BIT_ONE, // 默认值为 1
BIT_TWO = 4, // 显式赋值为 4
BIT_THREE, // 值为 BIT_TWO + 1 = 5
BIT_FOUR = 8, // 显式赋值为 8
BIT_FIVE = 8, // 显式赋值为 8 (与 BIT_FOUR 相同)
BIT_SIX, // 值为 BIT_FIVE + 1 = 9
BIT_SEVEN = BIT_THREE + BIT_FOUR // 值为 5 + 8 = 13
};

int main() {
std::cout << "BIT_ZERO: " << BIT_ZERO << std::endl; // 0
std::cout << "BIT_ONE: " << BIT_ONE << std::endl; // 1
std::cout << "BIT_TWO: " << BIT_TWO << std::endl; // 4
std::cout << "BIT_THREE: " << BIT_THREE << std::endl; // 5
std::cout << "BIT_FOUR: " << BIT_FOUR << std::endl; // 8
std::cout << "BIT_FIVE: " << BIT_FIVE << std::endl; // 8
std::cout << "BIT_SIX: " << BIT_SIX << std::endl; // 9
std::cout << "BIT_SEVEN: " << BIT_SEVEN << std::endl; // 13

BitField flags = BIT_THREE;
std::cout << "Flags: " << flags << std::endl; // 5

return 0;
}

4.6.2 枚举的取值范围

虽然枚举量是 int 类型的常量,但枚举类型本身 (spectrum, BitField 等) 的取值范围并不一定等同于 int

C++98/03 标准:

  • 底层类型 (Underlying Type): 编译器会选择一种能够容纳所有枚举量值的整型作为该枚举的底层类型。这个类型至少要和 int 一样大,但如果所有枚举量的值可以用更小的类型(如 charshort)表示,编译器可能会选择更小的类型来节省内存。
  • 取值范围: 枚举变量理论上可以存储的值的范围由其底层类型决定。然而,C++ 标准对枚举变量可以合法持有的值有更严格的规定。一个枚举变量可以持有的值,其上限是大于最大枚举量值的最小的 2 的幂减 1,下限类似(如果存在负枚举量值,则为小于最小枚举量值的最大的 2 的幂加 1;如果枚举量都非负,则下限为 0)。
    • 例如,对于 enum spectrum {red=0, ..., ultraviolet=7},最大枚举量是 7。大于 7 的最小的 2 的幂是 8,所以上限是 8 - 1 = 7。下限是 0。因此,spectrum 变量理论上可以持有 0 到 7 范围内的值。
    • 对于 enum BitField {..., BIT_SEVEN=13},最大枚举量是 13。大于 13 的最小的 2 的幂是 16,上限是 16 - 1 = 15。下限是 0。BitField 变量理论上可以持有 0 到 15 范围内的值。
  • 赋值限制: 尽管范围可能比枚举量的值域宽,但 C++ 通常不允许直接将超出枚举量定义范围的整数值赋给枚举变量(即使该整数在理论范围内),需要显式类型转换。

C++11 作用域内枚举 (Scoped Enumeration):

C++11 引入了 enum class (或 enum struct),称为作用域内枚举,提供了更强的类型安全和作用域控制:

  • 强类型: enum class 的枚举量不会隐式转换为整数。
  • 作用域: 枚举量的名称被限制在枚举类型的作用域内,访问时需要使用 EnumType::Enumerator
  • 可指定底层类型: 可以显式指定底层整数类型,例如 enum class Color : unsigned char { Red, Green, Blue };
  • 无隐式转换: 不能将整数直接赋给 enum class 变量,也不能将 enum class 变量隐式转换为整数,都需要显式转换 (static_cast)。

用法与示例 (范围和 C++11):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>

enum OldEnum { A = 1, B = 2, C = 4 }; // 最大值 4, 范围可能是 0-7

// C++11 作用域内枚举
enum class NewEnum { X = 10, Y = 20, Z = 30 };
enum class Status : char { OK = 'O', Error = 'E', Pending = 'P' }; // 指定底层类型为 char

int main() {
OldEnum oe;
// oe = 7; // 编译错误 (通常不允许直接赋 int)
oe = static_cast<OldEnum>(7); // 合法 (因为 7 在理论范围 0-7 内)
std::cout << "OldEnum value: " << oe << std::endl; // 输出 7

// oe = static_cast<OldEnum>(8); // 行为未定义或可能编译错误 (超出理论范围)

NewEnum ne = NewEnum::Y; // 必须使用作用域解析符
// int ne_val = ne; // 错误! 不能隐式转换为 int
int ne_val = static_cast<int>(ne); // 需要显式转换
std::cout << "NewEnum value as int: " << ne_val << std::endl; // 输出 20

// ne = 20; // 错误! 不能将 int 赋给 enum class
ne = static_cast<NewEnum>(10); // 需要显式转换
std::cout << "NewEnum value after cast: " << static_cast<int>(ne) << std::endl; // 输出 10

Status s = Status::OK;
char s_char = static_cast<char>(s); // 转换为底层类型 char
std::cout << "Status as char: " << s_char << std::endl; // 输出 O

return 0;
}

总结:
传统的 enum 提供了一种创建命名常量的方式,但类型安全较弱,且枚举量会污染所在的作用域。C++11 的 enum class 提供了更强的类型安全和作用域控制,是现代 C++ 中更推荐的选择。在使用传统 enum 时,要注意其取值范围和与整数类型转换的规则。

4.7 指针和自由存储空间

到目前为止,我们创建的变量(包括数组、结构等)在声明时,编译器会为其分配内存。这些变量的内存管理是自动的(自动存储或静态存储)。但是,有时我们需要在程序运行时根据需要动态地分配和释放内存。指针 (Pointer)自由存储空间 (Free Store)**(也常称为堆 Heap**)是实现这一目标的关键。

指针是一种特殊的变量,它存储的是另一个变量的内存地址。通过指针,我们可以间接地访问和修改该内存地址处的数据。

自由存储空间是程序可以动态申请使用的内存区域。与自动变量(函数执行完就销毁)或静态变量(程序整个生命周期都存在)不同,程序员需要手动管理自由存储空间中分配的内存的生命周期。

4.7.1 声明和初始化指针

声明指针:

声明指针需要指定它将指向的数据类型,并在变量名前加上星号 *(星号可以靠近类型名、变量名或在两者之间)。

1
typeName * pointerName; 
  • typeName: 指针将要指向的数据的类型。
  • *: 表明 pointerName 是一个指针。
  • pointerName: 指针变量的名称。

示例:

1
2
3
4
int * p_int;      // 声明一个指向 int 类型的指针 p_int
double * p_double; // 声明一个指向 double 类型的指针 p_double
char * p_char; // 声明一个指向 char 类型的指针 p_char
std::string * p_str; // 声明一个指向 string 对象的指针 p_str

获取地址 (& 运算符):

地址运算符 & 用于获取一个变量的内存地址。

初始化指针:

指针在声明时应被初始化,以避免指向不确定的内存地址。常见的初始化方式:

  1. 初始化为 nullptr (C++11 及以后): nullptr 是表示空指针的关键字,表示该指针当前不指向任何有效的内存地址。这是推荐的初始化空指针的方式。
  2. 初始化为 0NULL: 在 C++11 之前,通常使用 0 或宏 NULL (通常定义为 0) 来表示空指针。虽然仍可用,但 nullptr 类型更安全。
  3. 初始化为变量地址: 使用 & 运算符获取一个已存在变量的地址来初始化指针。指针的类型必须与变量的类型匹配(或能隐式转换)。

解引用 (* 运算符):

解引用运算符 * 用于访问指针所指向的内存地址处存储的值。当 * 用于已初始化的有效指针变量前时,它表示“获取指针指向的值”。

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>
#include <string>

int main() {
int updates = 6;
int * p_updates; // 声明一个指向 int 的指针,未初始化 (危险!)

// 初始化指针
int * p_value = nullptr; // 初始化为空指针 (推荐)
int * p_zero = 0; // 初始化为空指针 (旧式)
// NULL 宏通常在 <cstddef> 或其他 C 头文件中定义

// 使用地址初始化
p_updates = &updates; // 将 updates 变量的地址赋给 p_updates

// 访问指针信息
std::cout << "Value of updates: " << updates << std::endl; // 输出 6
std::cout << "Address of updates: " << &updates << std::endl; // 输出 updates 的内存地址
std::cout << "Value of p_updates (address): " << p_updates << std::endl; // 输出存储在 p_updates 中的地址 (同上)

// 使用解引用运算符访问指针指向的值
std::cout << "Value at *p_updates: " << *p_updates << std::endl; // 输出 6 (updates 的值)

// 使用解引用运算符修改指针指向的值
*p_updates = *p_updates + 1; // 将 p_updates 指向的值 (updates) 加 1
std::cout << "Now updates has value: " << updates << std::endl; // 输出 7

// 指向其他类型
double price = 99.99;
double * p_price = &price; // p_price 指向 price
std::cout << "Value at *p_price: " << *p_price << std::endl; // 输出 99.99

// int * p_wrong = &price; // 错误! 指针类型 (int*) 与变量类型 (double) 不匹配

return 0;
}

关键点:

  • int updates;: updates 是一个 int 变量。
  • int * p_updates;: p_updates 是一个指针变量,它存储的是一个 int 变量的地址。
  • p_updates: 存储的地址值。
  • *p_updates: 存储在该地址处的 int 值。

4.7.2 指针的危险

指针非常强大,但也容易出错,是 C++ 中常见的 bug 来源。

  1. 解引用未初始化的指针: 如果指针没有被初始化,它会包含一个随机的地址(垃圾值)。解引用这种指针(试图访问该随机地址处的值)会导致未定义行为,通常导致程序崩溃。
    1
    2
    3
    int * p_uninitialized;
    // std::cout << *p_uninitialized; // 极度危险! 程序可能崩溃
    // *p_uninitialized = 100; // 极度危险! 可能覆盖关键数据或导致崩溃
  2. 解引用空指针: 解引用 nullptr (或 0, NULL) 同样是未定义行为,通常也会导致程序崩溃。在使用指针前,最好检查它是否为空。
    1
    2
    3
    4
    5
    int * p_null = nullptr;
    // std::cout << *p_null; // 危险! 程序可能崩溃
    if (p_null != nullptr) { // 检查指针是否有效
    std::cout << *p_null;
    }
  3. 悬挂指针 (Dangling Pointer): 当指针指向的内存已经被释放或不再有效时,该指针就成为悬挂指针。解引用悬挂指针也是未定义行为。这通常发生在 delete 之后(见 4.7.5)或指向局部变量的指针在其作用域结束后仍然存在时。
    1
    2
    3
    4
    5
    6
    int * p_dangle;
    {
    int local_var = 10;
    p_dangle = &local_var; // p_dangle 指向局部变量
    } // local_var 在这里被销毁,内存可能被回收
    // std::cout << *p_dangle; // 危险! p_dangle 是悬挂指针
  4. 内存泄漏 (Memory Leak): 如果使用 new 分配了内存(见 4.7.4),但忘记使用 delete 释放,或者丢失了指向该内存的唯一指针,这块内存就无法再被程序访问或释放,造成内存泄漏。程序运行时间越长,泄漏的内存越多,最终可能耗尽系统资源。

安全使用指针的建议:

  • 总是初始化指针: 声明指针时立即初始化为 nullptr 或一个有效的地址。
  • 在使用前检查: 在解引用指针前,检查它是否为 nullptr
  • 谨慎处理指针生命周期: 确保指针指向的内存在指针使用期间是有效的。
  • 配对 newdelete: 动态分配的内存必须手动释放。

4.7.3 指针和数字

虽然指针存储的是内存地址,而地址本质上是数字,但指针类型和整数类型是不同的。不能随意将整数赋给指针(除了 0/nullptr),也不能直接将指针当作普通整数进行算术运算(指针算术有特殊规则,见 4.8)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>

int main() {
int value = 10;
int * p_value = &value;

// 指针的值是地址 (一个数字)
std::cout << "Address stored in p_value: " << p_value << std::endl;

// 不能直接将任意整数赋给指针
// p_value = 1000; // 错误! 类型不匹配 (除非使用 reinterpret_cast,但通常不安全)

p_value = nullptr; // 合法,赋空指针
p_value = 0; // 合法,赋空指针

// 指针可以转换为整数 (通常使用 reinterpret_cast,不推荐)
// uintptr_t address_as_int = reinterpret_cast<uintptr_t>(p_value);
// std::cout << "Address as integer: " << address_as_int << std::endl;

// 整数不能直接转换为指针 (除了 0)
int address_int = 2000;
// int * p_from_int = address_int; // 错误!
// int * p_from_int = reinterpret_cast<int*>(address_int); // 可以编译,但极度危险

return 0;
}

将指针视为地址,而不是普通的数字,有助于避免类型错误和不安全的操作。

4.7.4 使用new来分配内存

new 运算符用于在程序的自由存储区 (Free Store)堆 (Heap) 上动态分配内存。这允许你在运行时根据需要创建变量或对象,而不是在编译时就确定。

语法:

1
2
3
4
5
6
pointerVariable = new typeName;
// 或者在声明时分配
typeName * pointerVariable = new typeName;
// 也可以带初始化器
typeName * pointerVariable = new typeName (initializer); // C++98/03
typeName * pointerVariable = new typeName {initializer}; // C++11 列表初始化
  • new: 运算符。
  • typeName: 要分配内存的数据类型。
  • initializer (可选): 用于初始化新分配内存的值。

工作流程:

  1. new 在自由存储区找到一块足够大的、未使用的内存块,以存储 typeName 类型的数据。
  2. new 返回这块内存的起始地址
  3. 这个地址被赋给一个相应类型的指针变量。

如果 new 无法分配所需的内存(例如内存不足),它会抛出一个 std::bad_alloc 异常(除非使用了 new (std::nothrow) 版本,该版本在失败时返回 nullptr)。

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
#include <new> // 为了 std::bad_alloc (虽然不一定需要显式包含) 和 std::nothrow

int main() {
// 分配一个 int 的内存
int * p_int = new int; // 在自由存储区分配一个 int 大小的内存
if (p_int == nullptr) { // 理论检查 (对于普通 new,失败会抛异常)
std::cout << "Memory allocation failed!" << std::endl;
return 1;
}
*p_int = 101; // 通过指针访问并赋值

// 分配一个 double 并初始化
double * p_double = new double (99.99); // 分配并初始化为 99.99
// 或者 C++11 列表初始化
// double * p_double = new double {99.99};

std::cout << "Dynamically allocated int: " << *p_int << std::endl;
std::cout << "Dynamically allocated double: " << *p_double << std::endl;
std::cout << "Address of int: " << p_int << std::endl;
std::cout << "Address of double: " << p_double << std::endl;

// --- 内存释放将在下一节讲解 ---
// delete p_int;
// delete p_double;

// 使用 nothrow 版本 (失败时返回 nullptr)
int * p_lots_of_ints = new (std::nothrow) int[1000000000]; // 尝试分配巨大数组
if (p_lots_of_ints == nullptr) {
std::cout << "Huge memory allocation failed, but program continues." << std::endl;
} else {
std::cout << "Huge memory allocation succeeded (unlikely)." << std::endl;
// delete[] p_lots_of_ints; // 如果成功,需要释放
}

return 0;
}

动态分配的内存不会像自动变量那样在作用域结束时自动释放。程序员必须负责在不再需要时手动释放它。

4.7.5 使用delete释放内存

delete 运算符用于释放由 new 分配的内存,将其归还给自由存储区,以便后续可以重新分配使用。

语法:

1
delete pointerVariable; 
  • delete: 运算符。
  • pointerVariable: 指向由 new(**不是 new[]**)分配的内存的指针。

工作流程:

  1. delete 接收一个指针,该指针必须指向由 new 分配的内存块的起始地址。
  2. delete 释放该指针指向的内存块。
  3. 指针变量本身的值不会被自动修改(它仍然存储着那个现在无效的地址),成为**悬挂指针 (Dangling Pointer)**。

重要规则:

  • newdelete 必须配对使用: 每个 new 都应该对应一个 delete
  • 不要 delete 同一块内存两次: 对同一块内存执行两次 delete 是未定义行为。
  • 不要 delete 不是由 new 分配的内存: 例如,不要 delete 指向自动变量(栈变量)或静态变量的指针。
  • 不要 delete 空指针 (nullptr): 对空指针执行 delete 是安全且无效果的。
  • delete 之后将指针设为 nullptr: 释放内存后,最好立即将指针设置为 nullptr,以防止它成为悬挂指针被意外使用。

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>

int main() {
// 1. 分配内存
int * p_value = new int (42);
double * p_temp = new double;
*p_temp = 123.45;

std::cout << "Before delete:" << std::endl;
std::cout << " p_value points to: " << *p_value << " at address " << p_value << std::endl;
std::cout << " p_temp points to: " << *p_temp << " at address " << p_temp << std::endl;

// 2. 释放内存
delete p_value;
delete p_temp;

std::cout << "\nAfter delete:" << std::endl;
// p_value 和 p_temp 现在是悬挂指针!
std::cout << " p_value still holds address: " << p_value << std::endl;
// std::cout << " Accessing *p_value: " << *p_value << std::endl; // 危险! 未定义行为

// 3. 将指针设为 nullptr (好习惯)
p_value = nullptr;
p_temp = nullptr;

std::cout << "After setting to nullptr:" << std::endl;
std::cout << " p_value holds: " << p_value << std::endl; // 输出 0 或类似表示空指针的值

// 对 nullptr 调用 delete 是安全的
delete p_value; // 无效果
delete p_temp; // 无效果

// 错误示例:
int stack_var = 10;
int * p_stack = &stack_var;
// delete p_stack; // 严重错误! 不能 delete 栈内存

int * p1 = new int;
int * p2 = p1; // p1 和 p2 指向同一块内存
delete p1;
// delete p2; // 严重错误! 删除了同一块内存两次

return 0;
}

忘记 delete 会导致内存泄漏,而错误地使用 delete 则可能导致程序崩溃或数据损坏。正确管理动态内存是 C++ 编程中的一项重要技能。

4.7.6 使用new来创建动态数组

除了分配单个变量的内存,new 也可以用来动态分配数组。这在你需要在运行时确定数组大小时非常有用。

语法:

1
2
3
pointerVariable = new typeName [numberOfElements];
// 或者
typeName * pointerVariable = new typeName [numberOfElements];
  • new: 运算符。
  • typeName: 数组元素的数据类型。
  • numberOfElements: 数组的大小,可以是一个变量或表达式,在运行时计算其值。
  • []: 表明要分配的是一个数组。

new[] 会分配一块连续的内存,足以容纳 numberOfElementstypeName 类型的元素,并返回指向数组第一个元素的指针。

释放动态数组 (delete[]):

释放由 new[] 分配的数组内存必须使用 delete[] 运算符,而不是 delete

1
delete [] pointerVariable;
  • delete[]: 用于释放数组内存的运算符。
  • pointerVariable: 指向由 new[] 分配的数组内存的指针。

delete[]delete 的区别至关重要:

  • delete[] 知道需要释放的是一个数组,它会正确地调用数组中每个对象(如果是类类型)的析构函数(如果需要),并释放整个数组占用的内存。
  • 如果对 new[] 分配的内存使用 delete(没有 []),行为是未定义的。对于基本类型可能看似正常工作(但仍是错误的),但对于包含对象的数组,很可能只调用第一个对象的析构函数,并可能导致内存损坏或泄漏。

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>

int main() {
int size;
std::cout << "Enter the size of the dynamic array: ";
std::cin >> size;

if (size <= 0) {
std::cout << "Invalid size." << std::endl;
return 1;
}

// 1. 使用 new[] 分配动态数组
int * dynArray = new int[size];

// 检查分配是否成功 (可选,对于普通 new[] 失败会抛异常)
if (dynArray == nullptr) {
std::cout << "Memory allocation failed!" << std::endl;
return 1;
}

// 2. 像普通数组一样使用指针访问动态数组元素
for (int i = 0; i < size; ++i) {
dynArray[i] = i * 10; // 使用数组下标访问
}

std::cout << "Dynamic array elements:" << std::endl;
for (int i = 0; i < size; ++i) {
std::cout << " dynArray[" << i << "] = " << *(dynArray + i) << std::endl; // 也可以使用指针算术访问
}

// 3. 使用 delete[] 释放动态数组内存
delete [] dynArray;

// 4. 将指针设为 nullptr (好习惯)
dynArray = nullptr;

std::cout << "Dynamic array deleted." << std::endl;

// 错误示例:
double * p_arr = new double[10];
// delete p_arr; // 错误! 应该使用 delete [] p_arr;

return 0;
}

总结:

  • 使用 new typeName[size] 分配动态数组。
  • 使用 delete [] pointerVariable 释放动态数组。
  • 必须匹配 new[]delete[],否则行为未定义。
  • 动态数组提供了在运行时确定数组大小的灵活性,但需要程序员负责内存管理。

4.8 指针、数组和指针算术

指针和数组在 C++ 中有着非常紧密的联系。理解这种关系以及指针算术对于有效地使用 C++ 处理内存和数据集合至关重要。

4.8.1 程序说明

指针算术允许我们对指针执行一些特殊的算术运算,主要是加法和减法,以便在内存中移动,特别是在数组中。

指针与数组名的关系:

在 C++ 中,数组名在很多情况下会被隐式地当作指向其第一个元素的指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>

int main() {
double wages[3] = {1000.0, 2000.0, 3000.0};
double *p_wages = wages; // 数组名 wages 被当作指向第一个元素的指针 (&wages[0])

std::cout << "Address of first element (&wages[0]): " << &wages[0] << std::endl;
std::cout << "Value of array name (wages): " << wages << std::endl; // 输出与 &wages[0] 相同
std::cout << "Value of pointer (p_wages): " << p_wages << std::endl; // 输出与 &wages[0] 相同

// 通过指针访问数组元素
std::cout << "First element via pointer (*p_wages): " << *p_wages << std::endl; // 输出 1000.0

// 指针算术: 将指针移动到下一个元素
p_wages = p_wages + 1; // 指针加 1
std::cout << "Address after p_wages + 1: " << p_wages << std::endl; // 指向 wages[1] 的地址
std::cout << "Value at *(p_wages + 1) (now *p_wages): " << *p_wages << std::endl; // 输出 2000.0

// 使用数组下标访问 (即使是通过指针)
p_wages = wages; // 重置指针指向第一个元素
std::cout << "Accessing via pointer subscript p_wages[1]: " << p_wages[1] << std::endl; // 输出 2000.0
std::cout << "Accessing via array name subscript wages[1]: " << wages[1] << std::endl; // 输出 2000.0

// 数组名和指针的区别:
// 1. sizeof: sizeof(wages) 是整个数组的大小 (3 * sizeof(double))
// sizeof(p_wages) 是指针本身的大小 (通常 4 或 8 字节)
// 2. 地址: &wages 是整个数组的地址 (类型是 double(*)[3])
// &p_wages 是指针变量 p_wages 自身的地址
// 3. 修改: 数组名 wages 是常量,不能修改 (不能 wages = wages + 1;)
// 指针 p_wages 是变量,可以修改指向其他地址 (p_wages = p_wages + 1;)

std::cout << "sizeof(wages): " << sizeof(wages) << std::endl;
std::cout << "sizeof(p_wages): " << sizeof(p_wages) << std::endl;

return 0;
}

指针算术规则:

  • 指针加整数 p + n: 结果是一个指向 p 原来指向位置之后第 n元素的地址。编译器会根据指针指向的类型大小自动计算实际地址偏移量(地址 = p的地址 + n * sizeof(指向的类型))。
  • 指针减整数 p - n: 结果是一个指向 p 原来指向位置之前第 n 个元素的地址。
  • 指针减指针 p1 - p2: 结果是两个指针之间相隔的元素数量(一个整数)。只有当两个指针指向同一个数组(或超出末尾一个位置)中的元素时,这个操作才有意义。结果的类型是 std::ptrdiff_t (在 <cstddef> 中定义)。
  • 递增/递减: ++p, p++, --p, p-- 分别使指针指向下一个或上一个元素。
  • 比较: 可以使用关系运算符 (<, >, <=, >=) 比较指向同一个数组元素的指针,判断它们的相对位置。也可以使用 ==!= 比较指针是否指向同一个地址(或是否都为空)。

数组下标和指针的关系:

表达式 arrayName[i] 在 C++ 中等价于 *(arrayName + i)
同样,如果 p 是一个指向数组元素的指针,p[i] 等价于 *(p + i)

这意味着你可以对数组名使用指针算术(概念上),也可以对指针使用数组下标表示法。

4.8.2 指针小结

让我们回顾一下关于指针的关键概念:

  1. 声明: 使用 typeName * pointerName; 声明一个指向 typeName 类型数据的指针。
  2. 初始化:
    • 使用 & 获取变量地址: pointerName = &variableName;
    • 初始化为空指针: pointerName = nullptr; (C++11) 或 pointerName = 0;
    • 使用 new 分配动态内存: pointerName = new typeName;pointerName = new typeName[size];
  3. 解引用: 使用 * 访问指针指向的值: value = *pointerName;*pointerName = newValue;
  4. 指针与数组: 数组名通常可视为指向第一个元素的常量指针。指针算术允许在数组元素间移动。array[i] 等价于 *(array + i)
  5. 动态内存: 使用 new 分配,必须使用 delete (对应 new) 或 delete[] (对应 new[]) 释放。
  6. 危险: 未初始化指针、空指针解引用、悬挂指针、内存泄漏、错误的 delete/delete[] 使用。
  7. 指针本身 vs 指向的值: pointerName 存储的是地址,*pointerName 是该地址处的值。

4.8.3 指针和字符串

C 风格字符串本质上是 char 类型的数组,以空字符 \0 结尾。因此,指针在处理 C 风格字符串时非常常用。

  • 字符串字面值: 字符串字面值(如 "Hello") 在内存中存储为 const char 数组,并以 \0 结尾。字符串字面值本身可以被当作指向其第一个字符的 const char* 指针。
  • char 指针: 可以声明 char*const char* 指针来指向 C 风格字符串。

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <iostream>
#include <cstring> // 为了 strlen()

int main() {
char animal[20] = "bear"; // animal 是 char 数组
const char *bird = "wren"; // bird 是指向 "wren" 字符串字面值第一个字符 'w' 的指针
char *p_animal; // 未初始化的 char 指针

std::cout << animal << " and " << bird << std::endl; // cout 知道如何处理 char* (打印直到 \0)

// 指针指向数组
p_animal = animal; // p_animal 指向 animal 数组的第一个字符 'b'
std::cout << "Pointer p_animal points to: " << p_animal << std::endl; // 输出 "bear"

// 访问字符串内容
std::cout << "First char via array: " << animal[0] << std::endl; // b
std::cout << "First char via pointer: " << *p_animal << std::endl; // b

// 指针算术遍历字符串
std::cout << "Using pointer arithmetic:" << std::endl;
const char *p_bird = bird;
while (*p_bird != '\0') { // 循环直到遇到空字符
std::cout << *p_bird << " ";
p_bird++; // 指针移动到下一个字符
}
std::cout << std::endl;

// 字符串字面值和指针
const char *p_literal = "This is a literal";
std::cout << p_literal << std::endl;
// p_literal[0] = 't'; // 错误! 字符串字面值通常是只读的 (const char*)

// 动态分配字符串
char *p_dynamic_str = new char[strlen("Dynamic String") + 1]; // +1 为了空字符
strcpy(p_dynamic_str, "Dynamic String"); // 使用 strcpy 复制 (不安全,最好用 strncpy 或 C++ string)
std::cout << "Dynamic string: " << p_dynamic_str << std::endl;
delete [] p_dynamic_str; // 释放动态分配的数组
p_dynamic_str = nullptr;

return 0;
}

注意:

  • coutchar* 有特殊处理,它会打印从指针指向地址开始直到遇到空字符 \0 的所有字符。
  • 修改字符串字面值是未定义行为,应使用 const char* 指向它们。
  • 处理 C 风格字符串时要特别注意缓冲区溢出问题(例如使用 strcpy 时目标数组不够大),并确保字符串以 \0 结尾。std::string 类通常是更安全、更方便的选择。

4.8.4 使用new创建动态结构

就像可以动态分配基本类型和数组一样,也可以使用 new 动态创建结构体(或类)对象。

分配:

1
StructureName * pointerVariable = new StructureName;

这会在自由存储区分配足够存储 StructureName 结构所有成员的内存,并调用该结构的构造函数(如果是类或有构造函数的结构),然后返回指向新创建结构的指针。

访问成员:

当通过指针访问结构或类的成员时,不能直接使用点运算符 .。有两种方式:

  1. 解引用再用点 ((*ptr).member): 先解引用指针 *ptr 得到结构本身,然后使用点运算符访问成员。括号是必需的,因为点运算符的优先级高于解引用运算符。
  2. 箭头运算符 (ptr->member): 这是更常用、更简洁的方式。箭头运算符 -> 专门用于通过指针访问其指向的结构或类的成员。ptr->member 完全等价于 (*ptr).member

释放:

使用 delete 释放由 new 创建的单个结构对象。delete 会先调用该对象的析构函数(如果需要),然后释放内存。

1
2
delete pointerVariable;
pointerVariable = nullptr; // 好习惯

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <string>

struct Inflatable {
std::string name;
float volume;
double price;
};

int main() {
// 动态创建 Inflatable 结构
Inflatable *ps = new Inflatable; // 在自由存储区创建结构

// 访问成员
// 方法 1: 解引用和点运算符
(*ps).name = "Dynamic Floatie";
(*ps).volume = 1.5f;

// 方法 2: 箭头运算符 (更常用)
ps->price = 29.99;

std::cout << "Dynamically created structure:" << std::endl;
std::cout << " Name: " << ps->name << std::endl;
std::cout << " Volume: " << (*ps).volume << std::endl; // 两种方式都可以用
std::cout << " Price: " << ps->price << std::endl;
std::cout << " Address: " << ps << std::endl;

// 释放动态创建的结构
delete ps;
ps = nullptr;

std::cout << "Structure deleted." << std::endl;

return 0;
}

4.8.5 自动存储、静态存储和动态存储

C++ 程序中的变量和数据根据其内存分配方式和生命周期,可以分为三种主要的存储类别:

  1. 自动存储持续性 (Automatic Storage Duration):

    • 内存区域: 通常在称为栈 (Stack) 的内存区域分配。
    • 分配/释放: 内存的分配和释放在函数(或代码块)进入和退出时自动进行。
    • 生命周期: 变量在声明它的函数或代码块执行期间存在,块结束时自动销毁。
    • 例子: 函数内部声明的非 static 局部变量(包括函数参数)。
    • 特点: 分配和释放速度快,管理简单(自动),但空间有限,生命周期受限于作用域。
  2. 静态存储持续性 (Static Storage Duration):

    • 内存区域: 在程序的整个生命周期内都存在于内存的某个固定区域(通常是静态/全局数据区)。
    • 分配/释放: 内存在程序启动时分配(或首次使用时,对于某些静态变量),在程序结束时释放。
    • 生命周期: 从程序开始执行到程序结束。
    • 例子: 在函数外部声明的变量(全局变量)、使用 static 关键字在函数内部或类内部声明的变量。
    • 特点: 生命周期长,可以跨函数调用保持其值,但全局变量可能导致命名冲突和管理复杂性。
  3. 动态存储持续性 (Dynamic Storage Duration):

    • 内存区域: 在称为自由存储区 (Free Store)堆 (Heap) 的内存区域分配。
    • 分配/释放: 内存由程序员使用 new (或 malloc 等 C 函数) 显式分配,并且必须使用 delete (或 free) 显式释放。
    • 生命周期:new 分配成功开始,直到程序员使用 delete 释放为止。生命周期与函数或代码块的作用域无关。
    • 例子: 使用 new 创建的变量、数组或对象。
    • 特点: 提供了最大的灵活性,可以在运行时根据需要分配任意大小的内存,生命周期由程序员控制。但管理复杂,容易出现内存泄漏(忘记 delete)或悬挂指针(delete 后仍使用指针)等问题。

示例对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>

int global_static_var = 10; // 静态存储 (全局)
static int file_static_var = 20; // 静态存储 (文件作用域)

void func() {
int auto_var = 30; // 自动存储 (栈)
static int func_static_var = 40; // 静态存储 (函数作用域,只初始化一次)

int *dynamic_var = new int(50); // 动态存储 (堆)

std::cout << " Inside func:" << std::endl;
std::cout << " auto_var address: " << &auto_var << " value: " << auto_var << std::endl;
std::cout << " func_static_var address: " << &func_static_var << " value: " << ++func_static_var << std::endl;
std::cout << " dynamic_var address: " << dynamic_var << " value: " << *dynamic_var << std::endl;

delete dynamic_var; // 必须手动释放动态内存
dynamic_var = nullptr;
} // auto_var 在这里销毁

int main() {
std::cout << "Global static var address: " << &global_static_var << " value: " << global_static_var << std::endl;
std::cout << "File static var address: " << &file_static_var << " value: " << file_static_var << std::endl;

std::cout << "\nCalling func first time:" << std::endl;
func();

std::cout << "\nCalling func second time:" << std::endl;
func(); // 注意 func_static_var 的值会保持

// int* dangling_ptr;
// {
// int temp_auto = 100; // 自动存储
// dangling_ptr = &temp_auto;
// } // temp_auto 销毁
// std::cout << *dangling_ptr; // 错误! 悬挂指针

return 0;
}

理解这三种存储方式对于编写健壮、高效且无内存错误的 C++ 程序至关重要。现代 C++ 倾向于使用 RAII (Resource Acquisition Is Initialization) 技术和智能指针(如 std::unique_ptr, std::shared_ptr)来自动管理动态内存,以减少手动 new/delete 带来的风险。

4.9 类型组合

C++ 的强大之处在于其类型系统允许你将基本类型、复合类型(数组、结构、共用体、枚举)和指针以多种方式组合起来,创建更复杂的数据结构来精确地模拟现实世界的问题。

本章我们已经接触了一些组合:

  • 结构数组 (Array of Structures): 数组的每个元素都是一个结构体 (见 4.4.5)。
  • 结构包含 std::string 成员: 结构体可以包含类类型的成员 (见 4.4.3)。
  • 指针指向结构: 可以声明指向结构体对象的指针 (见 4.8.4)。
  • 指针指向数组 (或数组名视为指针): 指针可以用来操作数组 (见 4.8.1)。

本节将进一步探讨一些常见的类型组合方式。

1. 结构包含数组成员:

结构体可以包含数组作为其成员。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
#include <string>

struct StudentGrades {
std::string studentName;
int grades[5]; // 包含一个 int 数组作为成员
int numGrades; // 记录实际存储的成绩数量
};

int main() {
StudentGrades alice;
alice.studentName = "Alice";
alice.grades[0] = 95;
alice.grades[1] = 88;
alice.grades[2] = 92;
alice.numGrades = 3;

std::cout << alice.studentName << "'s first grade: " << alice.grades[0] << std::endl;

double sum = 0;
for (int i = 0; i < alice.numGrades; ++i) {
sum += alice.grades[i];
}
if (alice.numGrades > 0) {
std::cout << "Average grade: " << sum / alice.numGrades << std::endl;
}

return 0;
}

2. 结构包含指针成员:

结构体可以包含指针作为成员。这常用于指向动态分配的内存或指向其他数据结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <iostream>
#include <string>

struct DynamicData {
std::string label;
int* data_ptr; // 指向动态分配的 int 数组
int size;
};

int main() {
DynamicData myData;
myData.label = "Sensor Readings";
myData.size = 10;
myData.data_ptr = new int[myData.size]; // 动态分配内存

// 初始化动态数据
for (int i = 0; i < myData.size; ++i) {
myData.data_ptr[i] = i * i;
}

std::cout << "Label: " << myData.label << std::endl;
std::cout << "Data at index 3: " << myData.data_ptr[3] << std::endl; // 输出 9

// **重要:** 必须手动释放指针成员指向的动态内存
delete [] myData.data_ptr;
myData.data_ptr = nullptr; // 避免悬挂指针

return 0;
}

注意: 当结构包含指针成员指向动态内存时,需要特别注意内存管理(复制、赋值、析构),这通常涉及到类的特殊成员函数(拷贝构造函数、拷贝赋值运算符、析构函数),我们将在后续章节深入学习。

3. 指针数组 (Array of Pointers):

可以创建数组,其每个元素都是一个指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>

int main() {
int a = 10, b = 20, c = 30;
int* ptr_array[3]; // 声明一个包含 3 个 int* 指针的数组

ptr_array[0] = &a; // 第一个元素指向 a
ptr_array[1] = &b; // 第二个元素指向 b
ptr_array[2] = &c; // 第三个元素指向 c

std::cout << "Values via pointer array:" << std::endl;
for (int i = 0; i < 3; ++i) {
std::cout << " Value at *ptr_array[" << i << "]: " << *ptr_array[i] << std::endl;
}

// 也可以指向动态分配的内存
int* dyn_ptr_array[2];
dyn_ptr_array[0] = new int(100);
dyn_ptr_array[1] = new int(200);

std::cout << "\nDynamic values:" << std::endl;
std::cout << *dyn_ptr_array[0] << std::endl;
std::cout << *dyn_ptr_array[1] << std::endl;

// 释放动态内存
delete dyn_ptr_array[0];
delete dyn_ptr_array[1];
dyn_ptr_array[0] = nullptr;
dyn_ptr_array[1] = nullptr;

return 0;
}

指针数组常用于存储 C 风格字符串数组(const char*[])或管理一组动态分配的对象。

4. 指向指针的指针 (Pointer to Pointer):

指针本身也是变量,它也有自己的内存地址。因此,可以声明一个指向指针的指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>

int main() {
int value = 42;
int *ptr = &value; // ptr 指向 value (int*)
int **pptr = &ptr; // pptr 指向 ptr (int**)

std::cout << "Value: " << value << std::endl; // 42
std::cout << "Address of value (&value): " << &value << std::endl;

std::cout << "Value of ptr (address of value): " << ptr << std::endl; // value 的地址
std::cout << "Value via ptr (*ptr): " << *ptr << std::endl; // 42
std::cout << "Address of ptr (&ptr): " << &ptr << std::endl;

std::cout << "Value of pptr (address of ptr): " << pptr << std::endl; // ptr 的地址
std::cout << "Value via pptr (*pptr): " << *pptr << std::endl; // ptr 的值 (即 value 的地址)
std::cout << "Value via pptr (**pptr): " << **pptr << std::endl; // value 的值 (42)

// 修改值
**pptr = 50;
std::cout << "New value: " << value << std::endl; // 输出 50

return 0;
}

指向指针的指针常用于:

  • 在函数中修改调用者传入的指针本身(使其指向不同的地址)。
  • 处理动态分配的指针数组(例如 char** argv in main)。

总结:

通过组合基本类型、数组、结构、指针等,可以构建出非常灵活和强大的数据结构。理解每种组合方式的内存布局、访问方式以及(特别是涉及指针和动态内存时)生命周期管理规则是编写复杂 C++程序的关键。随着学习的深入,我们将看到更多高级的组合和抽象方式,例如使用类和标准库容器。

4.10 数组的替代品

虽然 C++ 内置的数组(包括动态分配的数组)功能强大,但它们存在一些固有的缺点:数组大小通常需要在编译时确定(对于栈上的数组),或者需要手动进行动态内存管理(对于堆上的数组),并且不提供边界检查等安全特性。

C++ 标准模板库 (STL) 提供了更安全、更灵活的数组替代品:vectorarray

4.10.1 模板类vector

std::vector 是 STL 提供的一个动态数组模板类。它封装了动态大小的数组,可以根据需要自动增长或缩小,并负责管理其元素的内存。

特点:

  • 动态大小: 可以在运行时添加或删除元素,vector 会自动处理内存的重新分配。
  • 内存管理: 自动管理元素存储的内存(通常在自由存储区/堆上分配)。
  • 随机访问: 像普通数组一样,可以通过索引 [] 快速访问任何元素。
  • 边界检查 (可选): 提供 at() 成员函数进行带边界检查的元素访问。
  • 丰富的成员函数: 提供 push_back(), pop_back(), size(), empty(), clear(), insert(), erase() 等多种方便的操作。
  • 模板类: vector 是一个模板,需要指定存储的元素类型,例如 std::vector<int>, std::vector<double>, std::vector<std::string>

使用方法:

  1. 包含头文件: #include <vector>
  2. 声明和初始化:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    #include <vector>
    #include <string>

    // 声明
    std::vector<int> scores; // 创建一个空的 int vector
    std::vector<double> lengths(10); // 创建包含 10 个 double 元素的 vector (默认初始化为 0.0)
    std::vector<std::string> names(5, "Unknown"); // 创建包含 5 个 string 元素,都初始化为 "Unknown"

    // C++11 列表初始化
    std::vector<int> primes {2, 3, 5, 7, 11}; // 创建并初始化
    std::vector<char> vowels {'a', 'e', 'i', 'o', 'u'};
  3. 访问元素:
    • [] 运算符: scores[0], names[i] (不进行边界检查)。
    • at() 函数: scores.at(0), names.at(i) (进行边界检查,越界抛出 std::out_of_range 异常)。
  4. 常用操作:
    • push_back(value): 在 vector 末尾添加一个元素。
    • size(): 返回 vector 中元素的数量。
    • empty(): 检查 vector 是否为空。
    • clear(): 移除所有元素。
    • pop_back(): 移除末尾的元素。

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>
#include <vector>
#include <string>

int main() {
// 创建和初始化
std::vector<int> numbers; // 空 vector
std::vector<std::string> tasks {"Read", "Write", "Code"};

// 添加元素
numbers.push_back(10); // numbers: {10}
numbers.push_back(20); // numbers: {10, 20}
numbers.push_back(30); // numbers: {10, 20, 30}

// 访问元素
std::cout << "First number: " << numbers[0] << std::endl; // 输出 10
std::cout << "Second task: " << tasks.at(1) << std::endl; // 输出 Write
// std::cout << tasks.at(3); // 会抛出异常

// 修改元素
numbers[0] = 15;

// 获取大小
std::cout << "Number of tasks: " << tasks.size() << std::endl; // 输出 3

// 遍历 vector (C++11 基于范围的 for 循环)
std::cout << "Numbers:";
for (int num : numbers) {
std::cout << " " << num;
}
std::cout << std::endl; // 输出 Numbers: 15 20 30

// 遍历 vector (传统 for 循环)
std::cout << "Tasks:";
for (size_t i = 0; i < tasks.size(); ++i) { // 使用 size_t 作为索引类型
std::cout << " " << tasks[i];
}
std::cout << std::endl; // 输出 Tasks: Read Write Code

// 移除末尾元素
numbers.pop_back(); // numbers: {15, 20}
std::cout << "Last number after pop: " << numbers.back() << std::endl; // back() 访问最后一个元素

return 0;
}

std::vector 是 C++ 中替代动态数组的首选方案,因为它更安全、更易于管理。

4.10.2 模板类array(C++11)

C++11 引入了 std::array 模板类,它封装了固定大小的数组。与 C 风格数组类似,其大小在编译时确定,但它提供了更现代的接口和一些 vector 具有的便利性(如 size(), at())。

特点:

  • 固定大小: 数组大小必须在编译时指定为模板参数,之后不能改变。
  • 内存位置: 通常在栈上分配内存(如果作为局部变量声明),与 C 风格数组类似,除非显式使用 new 创建。
  • 类型安全: 大小是类型的一部分 (std::array<int, 5>std::array<int, 10> 是不同的类型)。
  • STL 接口: 提供与 vector 类似的接口,如 size(), empty(), at(), [], front(), back(),以及迭代器支持,可以方便地与 STL 算法一起使用。
  • 无开销抽象: 通常不会比 C 风格数组带来额外的运行时性能开销。
  • 模板类: 需要指定元素类型和大小,例如 std::array<int, 10>, std::array<double, 3>

使用方法:

  1. 包含头文件: #include <array>
  2. 声明和初始化:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    #include <array>
    #include <string>

    // 声明
    std::array<int, 5> scores; // 创建包含 5 个 int 的 array (元素值未定义,除非是静态存储)
    std::array<double, 3> coords {}; // 创建包含 3 个 double 的 array, 零初始化 {0.0, 0.0, 0.0}

    // C++11 列表初始化
    std::array<int, 4> values {1, 2, 3, 4}; // 创建并初始化
    std::array<std::string, 2> names {"Alice", "Bob"};

    // 注意: 初始化列表的元素数量不能超过 array 的大小
    // std::array<int, 3> errors {1, 2, 3, 4}; // 错误!
    // 如果元素数量少于 array 大小,剩余元素会被值初始化 (通常为 0)
    std::array<int, 5> partial {10, 20}; // {10, 20, 0, 0, 0}
  3. 访问元素:
    • [] 运算符: scores[0], names[i] (不进行边界检查)。
    • at() 函数: scores.at(0), names.at(i) (进行边界检查,越界抛出 std::out_of_range 异常)。
  4. 常用操作:
    • size(): 返回数组的大小(编译时常量)。
    • empty(): 检查数组是否为空(对于大小 > 0 的 std::array 总是返回 false)。
    • fill(value): 将所有元素设置为指定值。
    • front(): 访问第一个元素。
    • back(): 访问最后一个元素。

用法与示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>
#include <array>
#include <string>
#include <numeric> // 为了 std::accumulate

int main() {
// 创建和初始化
std::array<int, 5> data {10, 20, 30, 40, 50};
std::array<std::string, 3> colors {"Red", "Green", "Blue"};
std::array<double, 4> temps {}; // {0.0, 0.0, 0.0, 0.0}

// 访问元素
std::cout << "First data element: " << data[0] << std::endl; // 输出 10
std::cout << "Second color: " << colors.at(1) << std::endl; // 输出 Green
std::cout << "A temperature: " << temps[2] << std::endl; // 输出 0

// 获取大小
std::cout << "Size of data array: " << data.size() << std::endl; // 输出 5

// 填充
temps.fill(25.5);
std::cout << "Filled temperature: " << temps[0] << std::endl; // 输出 25.5

// 遍历 (C++11 基于范围的 for 循环)
std::cout << "Colors:";
for (const auto& color : colors) { // 使用 const 引用避免复制
std::cout << " " << color;
}
std::cout << std::endl; // 输出 Colors: Red Green Blue

// 与 STL 算法一起使用
double sum = std::accumulate(temps.begin(), temps.end(), 0.0); // 计算总和
std::cout << "Sum of temperatures: " << sum << std::endl;

return 0;
}

std::array 是替代需要固定大小数组的 C 风格数组的现代 C++ 方案。

4.10.3 比较数组、vector对象和array对象

特性 C 风格数组 (T[]) std::vector<T> std::array<T, N> (C++11)
大小 固定 (编译时确定栈数组,运行时确定堆数组) 动态 (可运行时改变) 固定 (编译时确定)
内存分配 栈 (局部) 或 静态区 或 堆 (new[]) 通常在堆上 (自由存储区) 通常在栈上 (除非用 new 创建)
大小信息 无内置方法获取大小 (需单独传递) size() 成员函数 size() 成员函数 (编译时常量)
边界检查 无 (不安全) at() 提供检查, [] 不提供 at() 提供检查, [] 不提供
赋值/复制 不能直接赋值/复制整个数组 可以直接赋值/复制 (深拷贝) 可以直接赋值/复制 (成员逐一复制)
作为函数参数 通常退化为指针 (丢失大小信息) 可以按值、引用或指针传递 可以按值、引用或指针传递
STL 兼容性 有限 (需要指针和大小) 完全兼容 (提供迭代器等) 完全兼容 (提供迭代器等)
性能 通常最快 (直接内存访问) 访问速度快,添加/删除可能涉及内存重分配 通常与 C 风格数组性能相同
头文件 无需 <vector> <array>

选择建议:

  • 需要动态大小: 如果数组大小在运行时才能确定,或者需要在程序运行期间改变大小,**std::vector 是最佳选择**。
  • 需要固定大小 (编译时已知): 如果数组大小在编译时就确定且不会改变:
    • **优先选择 std::array (C++11 及以后)**。它提供了与 C 风格数组相同的性能和内存布局(通常在栈上),但具有更安全、更方便的接口(如 size(), at(), 迭代器)。
    • 如果不能使用 C++11 或有特定 C 接口兼容性需求,可以使用 C 风格数组,但要特别注意安全性和大小管理。
  • 性能关键且大小固定: std::array 和 C 风格数组通常性能最佳。

总的来说,在现代 C++ 中,应优先使用 std::vectorstd::array 而不是 C 风格数组,以获得更好的类型安全、内存管理和易用性。

4.11 总结

本章介绍了C++的**复合类型 (Compound Types)**,它们允许我们将多个值组合成一个数据单元。

我们首先学习了**数组 (Array)**,它用于存储一系列相同类型的数据。我们了解了如何声明数组、使用索引访问元素(从0开始),以及初始化数组的各种规则,包括C++11引入的更安全的列表初始化方法。我们强调了数组大小必须是常量表达式,并且访问数组时需要注意边界,避免越界访问。

接着,我们探讨了处理文本数据的两种方式。第一种是传统的C风格字符串,即以空字符 \0 结尾的 char 数组。我们学习了如何拼接字符串常量、在数组中使用字符串、以及使用 cingetline 读取字符串输入,并特别注意了混合输入数字和整行字符串时可能遇到的问题。

第二种,也是C++中更推荐的方式,是使用标准库提供的 stringstring 类提供了自动内存管理、方便的赋值、拼接 (+) 和附加 (+=) 操作,以及大量用于查找、修改、比较和访问字符的成员函数。我们还学习了如何使用 cin, cout, 和 getlinestring 对象进行输入输出,并了解了C++11引入的原始字符串字面值和Unicode字符串字面值。

结构 (Structure) 被引入作为一种创建自定义复合类型的方式,允许将不同类型的数据项(成员)组合在一起。我们学习了如何定义结构、声明结构变量、使用点运算符 (.) 访问成员,以及C++11的列表初始化。我们还看到结构体可以包含 string 对象或数组作为成员,结构变量可以相互赋值,可以作为函数参数(按值、按引用、按指针)和返回值。结构数组允许我们管理一组结构对象,而位字段则提供了一种在结构内精确控制成员占用位数的方式。

共用体 (Union) 作为另一种复合类型被介绍,其特点是所有成员共享同一块内存空间,主要用于节省内存或进行类型双关(需谨慎)。我们了解了如何定义和访问共用体,以及使用匿名共用体。

枚举 (Enum) 提供了一种创建具名整数常量的方式,提高了代码的可读性和类型安全。我们学习了如何定义枚举、显式设置枚举量的值、枚举的取值范围,并简要介绍了C++11引入的更安全的**作用域内枚举 (enum class)**。

本章的一个核心内容是指针 (Pointer) 和**自由存储空间 (Free Store / Heap)**。指针是存储内存地址的变量。我们学习了如何声明和初始化指针(包括使用 & 获取地址和初始化为 nullptr),如何使用解引用运算符 * 访问指针指向的值,并强调了使用未初始化指针、空指针或悬挂指针的危险。

我们学习了使用 new 运算符在自由存储区动态分配内存(用于单个变量或对象),以及使用 delete 运算符释放这些内存。同样,我们学习了使用 new[] 动态分配数组,并强调必须使用 delete[] 来释放动态数组内存。正确配对 new/deletenew[]/delete[] 对于避免内存泄漏和程序崩溃至关重要。

指针与数组的紧密关系以及指针算术也被详细讨论。数组名通常可视为指向第一个元素的指针,指针算术允许在数组元素间移动。我们还看到了如何使用指针处理C风格字符串,以及如何动态创建结构并使用箭头运算符 (->) 通过指针访问其成员。最后,我们区分了三种主要的存储持续性:自动存储(栈)、静态存储和动态存储(堆)。

我们还探讨了如何组合这些类型,例如创建包含数组或指针成员的结构、指针数组以及指向指针的指针,以构建更复杂的数据表示。

最后,我们介绍了C++标准库提供的内置数组的现代替代品:**std::vector**(动态大小数组)和 **std::array**(固定大小数组,C++11)。它们提供了更安全、更方便的接口和自动内存管理(对于 vector),是现代C++编程中推荐的选择。

通过本章的学习,我们掌握了创建和使用各种复合数据类型以及进行动态内存管理的基本技能。

评论