这一章在干嘛?

前三章我们只会用”一个变量存一个数”。但真实程序要处理一整年的销售记录、一个人的姓名、一名球员的多项数据。本章讲的就是 C++ 把基本类型”拼装”成更复杂数据的手段:数组(一排同类型格子)、字符串(字符数组 + string 类)、结构(不同类型打包)、共用体/枚举,以及最重要也最容易翻车的——指针与 new/delete 动态内存管理。学完这章,你才算真正摸到了 C++ 的门把手。

4.1 数组初见

4.2 字符串:C 风格与输入的坑

4.3 string 类入门

4.4 结构 struct

4.5 共用体与枚举

4.6 指针与自由存储

4.7 new、delete 与动态数组

4.8 指针算术与数组名的真面目

4.9 动态结构、字符串复制与内存管理

4.10 类型组合与数组替代品 vector 和 array

4.1 数组初见

数组(array)是一种能存放多个同类型值的数据形式。声明数组要交代三件事:每个元素的类型、数组名、元素个数:

short months[12]; // 12 个 short,下标 0~11

通用格式:typeName arrayName[arraySize];。注意 arraySize 必须是编译期就能确定的整数常量或常量表达式(如 8 * sizeof(int)),不能是运行时才确定的变量——这个限制后面会用 new 绕过去。

数组之所以叫”复合类型”,是因为它建立在别的类型之上:float loans[20]; 的类型不是”数组”,而是”float 数组”。没有万能的”数组”类型,只有具体的 int 数组、char 数组……

下标从 0 开始,没有商量余地。 12 个元素的下标是 0~11,最后一个元素的下标比数组长度小 1。访问元素用方括号加下标:months[0] 是第一个,months[11] 是最后一个。

▲ 图 4.1 创建一个数组

常见坑:编译器不检查下标越界

给不存在的 months[101] 赋值,编译器一声不吭,但运行时可能破坏数据甚至让程序崩溃。C++ 把”用合法下标”的责任完全交给了程序员。

程序清单 4.1 演示了数组声明、逐个赋值和初始化列表:

// arrayone.cpp -- 小整型数组
#include <iostream>
int main()
{
    using namespace std;
    int yams[3];            // 三个元素的数组
    yams[0] = 7;            // 给第一个元素赋值
    yams[1] = 8;
    yams[2] = 6;
 
    int yamcosts[3] = {20, 30, 5}; // 声明的同时初始化
 
    cout << "Total yams = ";
    cout << yams[0] + yams[1] + yams[2] << endl;
    cout << "The package with " << yams[1] << " yams costs ";
    cout << yamcosts[1] << " cents per yam.\n";
    int total = yams[0] * yamcosts[0] + yams[1] * yamcosts[1];
    total = total + yams[2] * yamcosts[2];
    cout << "The total yam expense is " << total << " cents.\n";
 
    cout << "\nSize of yams array = " << sizeof yams;
    cout << " bytes.\n";
    cout << "Size of one element = " << sizeof yams[0];
    cout << " bytes.\n";
    return 0;
}

输出:

Total yams = 21
The package with 8 yams costs 30 cents per yam.
The total yam expense is 410 cents.
Size of yams array = 12 bytes.
Size of one element = 4 bytes.

注意 sizeof 用于数组名得到整个数组的字节数(3×4=12),用于单个元素则得到该元素的大小(4 字节)——yams 是数组,yams[1] 只是个 int。

初始化规则

  • 初始化列表 {...} 只能在定义时用,之后不能再用,也不能把一个数组整体赋给另一个:
int cards[4] = {3, 6, 8, 10}; // 可以
int hand[4];
hand[4] = {5, 6, 7, 9};       // 不行!
hand = cards;                 // 不行!
  • 初始值可以比元素少,剩下的一律补 0。所以把数组全部清零只需 long totals[500] = {0};(注意 {1} 只会把第一个元素设为 1,其余仍是 0)。
  • 方括号留空让编译器数:short things[] = {1, 5, 3, 8}; 得到 4 元素数组。想知道元素个数可以写 int num_elements = sizeof things / sizeof (short);
  • 函数内定义且未初始化的数组,元素的值是未定义的(内存里原来是什么就是什么)。

C++11 又放宽了几条:可以省略 =(double earnings[4] {1.2e4, 1.6e4, 1.1e4, 1.7e4};);空大括号全部清零(unsigned int counts[10] = {};);列表初始化禁止收窄转换——long plifs[] = {25, 92, 3.0}; 非法(浮点转整型算收窄),char slifs[4] {'h', 'i', 1122011, '\0'}; 非法(超出 char 范围),char tlifs[4] {'h', 'i', 112, '\0'}; 合法。

通关标准:

能不看书写出”声明 12 个 short 的数组""部分初始化其余补零""让编译器数元素个数”三种写法;能解释为什么 sizeof 数组名 和 sizeof 数组元素 结果不同;能说出下标越界的后果。

4.2 字符串:C 风格与输入的坑

字符串是内存中连续字节存放的一串字符。C 风格字符串有个特殊约定:最后一个字符是空字符 \0(ASCII 码 0),用来标记字符串结束。所有处理字符串的函数(包括 cout)都是逐字符处理直到遇到 \0 才停。

char dog[8] = {'b', 'e', 'a', 'u', 'x', ' ', 'I', 'I'}; // 不是字符串!没有 \0
char cat[8] = {'f', 'a', 't', 'e', 's', 's', 'a', '\0'}; // 是字符串!

如果让 cout 显示 dog,它打印完 8 个字符后还会继续往后把内存当字符打印,直到碰巧遇到某个 0 字节为止。

更省事的写法是用带双引号的字符串常量,编译器会自动补上 \0:

char bird[11] = "Mr. Cheeps"; // \0 是隐含的
char fish[] = "Bubbles";      // 让编译器数,7 个字节

计算数组大小时别忘了 \0 也占一格。字符串常量与字符常量不可混用:'S' 就是 83(ASCII 码),而 "S" 是”字符 S + \0”组成的字符串,代表的是它的内存地址,char shirt_size = "S"; 是类型不匹配的非法语句。

▲ 图 4.2 用字符串初始化数组

拼接字符串常量

两个只隔空白符(空格/制表/换行)的字符串常量会自动拼接成一体,换行写长字符串时很好用:

cout << "I'd give my right arm to be" " a great violinist.\n";

程序清单 4.2 同时演示了用 cin 读入、用 strlen() 量长度、用下标截断字符串:

// strings.cpp -- 把字符串存进数组
#include <iostream>
#include <cstring> // 为了使用 strlen() 函数
int main()
{
    using namespace std;
    const int Size = 15;
    char name1[Size];           // 空数组
    char name2[Size] = "C++owboy"; // 已初始化的数组
 
    cout << "Howdy! I'm " << name2;
    cout << " ! What's your name?\n";
    cin >> name1;
    cout << "Well, " << name1 << ", your name has ";
    cout << strlen(name1) << " letters and is stored\n";
    cout << "in an array of " << sizeof(name1) << " bytes.\n";
    cout << "Your initial is " << name1[0] << ".\n";
    name2[3] = '\0';    // 设为空字符
    cout << "Here are the first 3 characters of my name: ";
    cout << name2 << endl;
    return 0;
}
Howdy! I'm C++owboy! What's your name?
Basicman
Well, Basicman, your name has 8 letters and is stored in an array of 15 bytes.
Your initial is B.
Here are the first 3 characters of my name: C++

要点:sizeof(name1) 给出整个数组的 15 字节,strlen(name1) 只统计到 \0 为止的可见字符(Basicman 是 8 不是 9);name2[3] = '\0'; 把第 4 个字符换成空字符,字符串就在 3 个字符处”提前结束”,尽管数组后面还存着其余字符——存放字符串的函数只认 \0 不认数组长度。

▲ 图 4.3 用 \0 缩短字符串

cin 读字符串的问题

cin >> 数组名 以空白符(空格、制表、换行)为界,一次只读一个单词。程序清单 4.3 暴露了这个坑:

// instr1.cpp -- 读取多个字符串
#include <iostream>
int main()
{
    using namespace std;
    const int ArSize = 20;
    char name[ArSize];
    char dessert[ArSize];
 
    cout << "Enter your name:\n";
    cin >> name;
    cout << "Enter your favorite dessert:\n";
    cin >> dessert;
    cout << "I have some delicious " << dessert;
    cout << " for you, " << name << ".\n";
    return 0;
}

运行结果(根本没机会输入甜点!):

Enter your name:
Alistair Dreeb
Enter your favorite dessert:
I have some delicious Dreeb for you, Alistair.

▲ 图 4.4 cin 眼中的字符串输入

cin 把 Alistair 存进 name 后,Dreeb 还赖在输入队列里,第二次读取直接把 Dreeb 当成了甜点。此外 cin >> 对”输入比数组还长”也毫无防护。

getline() 与 get():按行读取

面向行的读取有两个选择:

  • cin.getline(name, ArSize):读整行直到换行符,最多读 ArSize-1 个字符(留一个位置给 \0),读完后丢弃换行符。
  • cin.get(name, ArSize):参数和读取行为相同,但把换行符留在输入队列里。

▲ 图 4.5 getline() 读取并替换换行符

程序清单 4.4 用 getline() 就能正确处理带空格的名字:

// instr2.cpp -- 用 getline 读取多单词输入
#include <iostream>
int main()
{
    using namespace std;
    const int ArSize = 20;
    char name[ArSize];
    char dessert[ArSize];
 
    cout << "Enter your name:\n";
    cin.getline(name, ArSize); // 读到换行为止
    cout << "Enter your favorite dessert:\n";
    cin.getline(dessert, ArSize);
    cout << "I have some delicious " << dessert;
    cout << " for you, " << name << ".\n";
    return 0;
}
Enter your name:
Dirk Hammernose
Enter your favorite dessert:
Radish Torte
I have some delicious Radish Torte for you, Dirk Hammernose.

get() 因为留下换行符,连续两次 cin.get(name, ArSize); cin.get(dessert, ArSize); 会让第二次调用一头撞上残留的换行符、读个空。解决办法是中间插一句不带参数的 cin.get(); 把换行符吃掉,或者用拼接调用:

cin.get(name, ArSize).get(); // 读字符串,再吃掉换行符

之所以能拼,是因为 cin.get(name, ArSize) 返回 cin 对象本身,可以继续调它的方法。程序清单 4.5 用的就是这种写法:

// instr3.cpp -- 用 get() 读取多单词输入
#include <iostream>
int main()
{
    using namespace std;
    const int ArSize = 20;
    char name[ArSize];
    char dessert[ArSize];
 
    cout << "Enter your name:\n";
    cin.get(name, ArSize).get(); // 读字符串、换行符
    cout << "Enter your favorite dessert:\n";
    cin.get(dessert, ArSize).get();
    cout << "I have some delicious " << dessert;
    cout << " for you, " << name << ".\n";
    return 0;
}

两个补充:get() 读到空行会设置 failbit 导致后续输入被阻塞,可用 cin.clear(); 恢复;输入超过限定长度时,两者都把多余字符留在队列,但 getline() 还会额外设置 failbit 关闭后续输入。

数字输入与行输入混用的坑

程序清单 4.6 是初学者必踩的经典坑:

// numstr.cpp -- 数字输入后紧跟行输入
#include <iostream>
int main()
{
    using namespace std;
    cout << "What year was your house built?\n";
    int year;
    cin >> year;
    cout << "What is its street address?\n";
    char address[80];
    cin.getline(address, 80);
    cout << "Year built: " << year << endl;
    cout << "Address: " << address << endl;
    cout << "Done!\n";
    return 0;
}
What year was your house built?
1966
What is its street address?
Year built: 1966
Address:
Done!

cin >> year 读走 1966 后,把回车产生的换行符留在了队列;紧接着的 getline() 一看:换行符?空行!直接存了个空字符串。修复方法:读地址前先把换行符吃掉——(cin >> year).get(); 或分开写 cin >> year; cin.get();。

常见坑:数字输入后紧跟 getline()/get() 会读到空行

记住口诀:”>> 不吃回车,getline 只吃回车”。凡是 cin >> 之后要按行读字符串,中间必须用 cin.get() 清掉残留的换行符。

4.3 string 类入门

C++98 标准引入了 string 类(头文件 <string>,位于 std 命名空间),让你像用普通变量一样用字符串:

string str1;              // 空的 string 对象
string str2 = "panther";  // 初始化的 string 对象

程序清单 4.7 对比了 char 数组和 string 对象:

// strtype1.cpp -- 使用 C++ string 类
#include <iostream>
#include <string>    // 让 string 类可用
int main()
{
    using namespace std;
    char charr1[20];
    char charr2[20] = "jaguar";
    string str1;
    string str2 = "panther";
 
    cout << "Enter a kind of feline: ";
    cin >> charr1;
    cout << "Enter another kind of feline: ";
    cin >> str1;
    cout << "Here are some felines:\n";
    cout << charr1 << " " << charr2 << " "
         << str1 << " " << str2 << endl;
    cout << "The third letter in " << charr2 << " is "
         << charr2[2] << endl;
    cout << "The third letter in " << str2 << " is "
         << str2[2] << endl;    // string 也能用数组记法
 
    return 0;
}