这一章在干嘛?
前三章我们只会用”一个变量存一个数”。但真实程序要处理一整年的销售记录、一个人的姓名、一名球员的多项数据。本章讲的就是 C++ 把基本类型”拼装”成更复杂数据的手段:数组(一排同类型格子)、字符串(字符数组 + string 类)、结构(不同类型打包)、共用体/枚举,以及最重要也最容易翻车的——指针与
new/delete动态内存管理。学完这章,你才算真正摸到了 C++ 的门把手。
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 数组元素结果不同;能说出下标越界的后果。
自测:
int hotelTips[5] = {5.0, 2.5};之后 hotelTips[3] 是多少?是 0。部分初始化时,编译器把其余元素全部置 0(注意 5.0 还会被收窄规则盯上,C++11 下这句其实是非法的,应写 5.0f 或改成 double 数组)。
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()清掉残留的换行符。
自测:
cin.get(name, ArSize); cin.get(dessert, ArSize);连写为什么第二次读不到东西?因为第一个 get() 把行尾换行符留在队列里,第二个 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;
}Enter a kind of feline: ocelot
Enter another kind of feline: tiger
Here are some felines:
ocelot jaguar tiger panther
The third letter in jaguar is g
The third letter in panther is n最大区别:string 对象声明成简单变量,长度由类自动管理——cin >> str1; 时 str1 会自动调整大小装下输入。这比数组更方便也更安全。
string 类的其他便利(程序清单 4.8 演示):
// strtype2.cpp -- 赋值、拼接、附加
#include <iostream>
#include <string>
int main()
{
using namespace std;
string s1 = "penguin";
string s2, s3;
cout << "You can assign one string object to another: s2 = s1\n";
s2 = s1;
cout << "s1: " << s1 << ", s2: " << s2 << endl;
s2 = "buzzard";
cout << "s2: " << s2 << endl;
s3 = s1 + s2;
cout << "s3: " << s3 << endl;
s1 += s2;
cout << "s1 += s2 yields s1 = " << s1 << endl;
s2 += " for a day";
cout << "s2 += \" for a day\" yields s2 = " << s2 << endl;
return 0;
}s1: penguin, s2: penguin
s2: buzzard
s3: penguinbuzzard
s1 += s2 yields s1 = penguinbuzzard
s2 += " for a day" yields s2 = buzzard for a day数组做不到的它都能做:charr1 = charr2; 非法,str1 = str2; 合法;str1 + str2 直接拼接。C 风格要做同样的事得调用库函数并提防目标数组装不下:程序清单 4.9 对比了两种写法。
// strtype3.cpp -- string 类的更多功能
#include <iostream>
#include <string>
#include <cstring> // C 风格字符串库
int main()
{
using namespace std;
char charr1[20];
char charr2[20] = "jaguar";
string str1;
string str2 = "panther";
str1 = str2; // 对象赋值
strcpy(charr1, charr2); // 复制字符串到数组
str1 += " paste"; // 附加
strcat(charr1, " juice");
int len1 = str1.size(); // string 的长度
int len2 = strlen(charr1); // C 字符串的长度
cout << "The string " << str1 << " contains "
<< len1 << " characters.\n";
cout << "The string " << charr1 << " contains "
<< len2 << " characters.\n";
return 0;
}The string panther paste contains 13 characters.
The string jaguar juice contains 12 characters.strlen(charr1) 是普通函数,字符串作为参数传入;str1.size() 是类方法,对象名在前、点号连接——str1 是对象,size() 是只能由该类对象调用的方法。等价于 str3 = str1 + str2; 的 C 写法是 strcpy(charr3, charr1); strcat(charr3, charr2); 两步,而且 strcat 有溢出风险(char site[10] = "house"; strcat(site, " of pancakes"); 会写穿内存),C 库的 strncat()/strncpy() 虽然安全些但多一层复杂度。
string 的行输入
按行读入 string 对象的语法不一样(程序清单 4.10):
// strtype4.cpp -- 行输入
#include <iostream>
#include <string>
#include <cstring>
int main()
{
using namespace std;
char charr[20];
string str;
cout << "Length of string in charr before input: "
<< strlen(charr) << endl;
cout << "Length of string in str before input: "
<< str.size() << endl;
cout << "Enter a line of text:\n";
cin.getline(charr, 20); // 类方法,要指明最大长度
cout << "You entered: " << charr << endl;
cout << "Enter another line of text:\n";
getline(cin, str); // 普通函数,cin 作参数,无需长度
cout << "You entered: " << str << endl;
cout << "Length of string in charr after input: "
<< strlen(charr) << endl;
cout << "Length of string in str after input: "
<< str.size() << endl;
return 0;
}Length of string in charr before input: 27
Length of string in str before input: 0
Enter a line of text:
peanut butter
You entered: peanut butter
Enter another line of text:
blueberry jam
You entered: blueberry jam
Length of string in charr after input: 13
Length of string in str after input: 13注意两个细节:未初始化的 char 数组里 strlen 返回 27——比数组还大!因为 strlen 从头数到碰巧出现的第一个 \0 为止,未初始化内存里 \0 位置是随机的;而未初始化的 string 对象长度自动为 0。另外 cin.getline(charr, 20) 是 istream 类方法(istream 类诞生早于 string 类,不认识 string),getline(cin, str) 是普通函数——cin 只是参数。
补充:C++11 还有原始字符串(raw string),用 R"( ... )" 作定界符,内部 \n、" 都按字面意义处理:cout << R"(Jim "King" Tutt uses "\n" instead of endl.)";。
自测:为什么
getline(cin, str)没有长度参数,而cin.getline(charr, 20)有?因为 char 数组大小固定必须防溢出,而 string 对象会自动扩容,根本不需要长度上限。
4.4 结构 struct
数组只能装同类型,想用一个单元装下球员的姓名、薪水、身高、体重,就要用结构。定义结构分两步:先写结构描述(定义类型),再用它创建变量。
struct inflatable // 结构声明
{
char name[20];
float volume;
double price;
};
inflatable hat; // hat 是 inflatable 类型的变量
▲ 图 4.6 结构描述的组成
inflatable 是标记名,从此可以像 int、char 一样当作类型名使用(C++ 里声明变量不必再写 struct 关键字)。花括号里的每一项叫成员,用成员运算符 . 访问:hat.volume、hat.price。hat 是结构,但 hat.price 就是个普普通通的 double。顺带一提,cin.getline() 这种点号语法的渊源正是结构成员访问。
程序清单 4.11 演示结构的声明、初始化与使用:
// structur.cpp -- 一个简单的结构
#include <iostream>
struct inflatable // 结构声明
{
char name[20];
float volume;
double price;
};
int main()
{
using namespace std;
inflatable guest =
{
"Glorious Gloria", // name 的值
1.88, // volume 的值
29.99 // price 的值
};
inflatable pal =
{
"Audacious Arthur",
3.12,
32.99
};
cout << "Expand your guest list with " << guest.name;
cout << " and " << pal.name << "!\n";
cout << "You can have both for $";
cout << guest.price + pal.price << "!\n";
return 0;
}Expand your guest list with Glorious Gloria and Audacious Arthur!
You can have both for $62.98!结构声明放哪有讲究:放在函数外面叫外部声明,其后的所有函数都能用这个类型;放在函数内部则只有该函数能用。多函数程序通常把结构声明放外部(变量不建议放外部,但结构声明和符号常量放外部是惯例)。
初始化同样是花括号逗号列表,成员按顺序对应:inflatable duck = {"Daphne", 0.12, 9.98};。C++11 下 = 可省、空大括号全置零、禁止收窄。pal.name 是 char 数组所以能存字符串,pal.name[0] 是字符 A,但 pal[0] 无意义——pal 是结构不是数组。
程序清单 4.12 展示了结构的整体赋值:
// assign_st.cpp -- 结构赋值
#include <iostream>
struct inflatable
{
char name[20];
float volume;
double price;
};
int main()
{
using namespace std;
inflatable bouquet =
{
"sunflowers",
0.20,
12.49
};
inflatable choice;
cout << "bouquet: " << bouquet.name << " for $";
cout << bouquet.price << endl;
choice = bouquet; // 结构整体赋值(逐成员复制)
cout << "choice: " << choice.name << " for $";
cout << choice.price << endl;
return 0;
}bouquet: sunflowers for $12.49
choice: sunflowers for $12.49即使成员里有数组,choice = bouquet; 也会逐成员复制——数组做不到的事结构做到了。
结构数组
数组的元素也可以是结构,初始化就是”数组套结构”两层花括号:
inflatable guests[2] =
{
{"Bambi", 0.5, 21.99},
{"Godzilla", 2000, 565.99}
};程序清单 4.13:
// arrstruc.cpp -- 结构数组
#include <iostream>
struct inflatable
{
char name[20];
float volume;
double price;
};
int main()
{
using namespace std;
inflatable guests[2] =
{
{"Bambi", 0.5, 21.99},
{"Godzilla", 2000, 565.99}
};
cout << "The guests " << guests[0].name << " and " << guests[1].name
<< "\nhave a combined volume of "
<< guests[0].volume + guests[1].volume << " cubic feet.\n";
return 0;
}The guests Bambi and Godzilla have a combined volume of 2000.5 cubic feet.注意 guests[0].volume 合法(元素是结构),但 guests.volume 非法(guests 是数组)。结构成员还可以是位字段(unsigned int SN : 4; 表示只占 4 位),常用于底层硬件编程。
▲ 图 4.7 局部与外部结构声明
自测:
struct perks { int key_number; char car[12]; } mr_smith, ms_jones;这种写法做了什么?定义结构类型的同时直接创建了两个变量 mr_smith 和 ms_jones。功能上没问题,但把类型定义和变量声明分开写通常更易读。
4.5 共用体与枚举
共用体 union
结构是”同时拥有多种”,共用体(union)是”同一时刻只能装一种”。语法像结构,含义完全不同:
union one4all
{
int int_val;
long long_val;
double double_val;
};
one4all pail;
pail.int_val = 15; // 装 int
pail.double_val = 1.38; // 装 double,原来的 int 值丢了共用体的大小等于最大成员的大小——三种类型共用同一块内存,成员名只是告诉你”现在把它当什么用”。用途是节省内存(嵌入式系统里空间宝贵)和处理”同一数据有多种格式但不同时使用”的场景。它还能匿名嵌在结构里:
struct widget
{
char brand[20];
int type;
union // 匿名共用体
{
long id_num;
char id_char[20];
};
};
// prize.id_num 和 prize.id_char 直接访问,共享同一地址枚举 enum
枚举是创建符号常量的另一种工具(比 const 更适合成组的相关常量):
enum spectrum {red, orange, yellow, green, blue, violet, indigo, ultraviolet};这条语句做了两件事:把 spectrum 定义为新类型名;把 redultraviolet 定义为 07 的符号常量(叫枚举量)。枚举变量只能被赋枚举量:
spectrum band;
band = blue; // 合法
band = 2000; // 非法,2000 不是枚举量
++band; // 非法,枚举没有定义 ++ 运算
band = orange + red; // 非法:虽然枚举量在表达式中会提升为 int
// 得到 int 结果 1,但 int 不能赋回 spectrum
band = spectrum(3); // 合法,显式类型转换可以显式指定枚举量的值,未指定的比前一个大 1:
enum bits{one = 1, two = 2, four = 4, eight = 8};
enum bigstep{first, second = 100, third}; // first=0, third=101
enum {zero, null = 0, one, numero_uno = 1}; // 可不写类型名,只要常量通过强制转换还能赋值范围内(不必是枚举量)的整数:myflag = bits(6); 合法。范围上界=不小于最大枚举量的最小 2 的幂再减 1(bigstep 最大 101,上界 127)。实践中枚举更多被当作定义一组相关符号常量的手段(比如给 switch 提供标签),而不是真正的类型。
自测:
int color = blue;和band = 3;(band 是 spectrum)哪个合法?第一个合法:枚举量会自动提升为 int。第二个非法:int 不会自动转换为枚举类型,必须写
band = spectrum(3);。
4.6 指针与自由存储
程序存储数据要盯三件事:存在哪、存了什么、是什么类型。普通变量的策略是”值是主角,位置由编译器内部管理”;指针的策略反过来:指针是存储地址的变量——地址是主角,值通过地址间接获得。
对普通变量施加地址运算符 & 就能得到它的地址。程序清单 4.14:
// address.cpp -- 用 & 运算符找地址
#include <iostream>
int main()
{
using namespace std;
int donuts = 6;
double cups = 4.5;
cout << "donuts value = " << donuts;
cout << " and donuts address = " << &donuts << endl;
cout << "cups value = " << cups;
cout << " and cups address = " << &cups << endl;
return 0;
}donuts value = 6 and donuts address = 0x0065fd40
cups value = 4.5 and cups address = 0x0065fd44两个地址相差 4,正好是 int 的大小。而指针变量用间接值运算符(解除引用运算符)* 取出地址处存的值:指针名代表地址,*指针名 代表那个地址上的值。
程序清单 4.15:
// pointer.cpp -- 第一个指针变量
#include <iostream>
int main()
{
using namespace std;
int updates = 6; // 普通变量
int *p_updates; // 指向 int 的指针
p_updates = &updates; // 把 int 的地址赋给指针
cout << "Values: updates = " << updates;
cout << ", *p_updates = " << *p_updates << endl;
cout << "Addresses: &updates = " << &updates;
cout << ", p_updates = " << p_updates << endl;
*p_updates = *p_updates + 1; // 通过指针改值
cout << "Now updates = " << updates << endl;
return 0;
}Values: updates = 6, *p_updates = 6
Addresses: &updates = 0x0065fd48, p_updates = 0x0065fd48
Now updates = 7
▲ 图 4.8 一枚硬币的两面
updates 和 p_updates 是同一份数据的两种视角:updates 以值为主、用 & 取地址;p_updates 以地址为主、用 * 取值。*p_updates 完全等价于 updates,可以赋值、可以运算。
声明与初始化指针
int * p_updates; 声明的是”组合 *p_updates 是 int”,所以 p_updates 本身是指针,类型记作 int*(pointer-to-int)。指针必须说明指向什么类型——地址本身只是个数字,类型信息告诉程序”从这个地址起读几个字节、按什么格式解释”:
double * tax_ptr; // 指向 double
char * str; // 指向 char注意:tax_ptr 和 str 指向的数据大小不同(8 字节 vs 1 字节),但两个指针变量本身通常一样大(都是地址)。就像门牌号 1016 可以是百货商场、1024 可以是小木屋,地址大小跟房子大小无关。
int* p1, p2; 只创建了一个指针 p1 和一个普通 int p2——每个指针名前都要带 *。风格上 int *ptr; 强调 *ptr 是 int,int* ptr; 强调 int* 是类型,编译器不在乎空格。
▲ 图 4.9 指针存储地址
声明时初始化,初始化的是指针本身而不是它指向的值(程序清单 4.16):
// init_ptr.cpp -- 初始化指针
#include <iostream>
int main()
{
using namespace std;
int higgens = 5;
int * pt = &higgens;
cout << "Value of higgens = " << higgens
<< "; Address of higgens = " << &higgens << endl;
cout << "Value of *pt = " << *pt
<< "; Value of pt = " << pt << endl;
return 0;
}Value of higgens = 5; Address of higgens = 0012FED4
Value of *pt = 5; Value of pt = 0012FED4常见坑:野指针——未初始化就解引用
long * fellow; *fellow = 223323;是灾难:fellow 没被赋过地址,它的随机值会被当成地址,223323 会被写到那个”地址”上——可能是程序代码中间,可能破坏其他数据,而且这种 bug 极难追查。指针黄金法则:对指针施加 * 之前,必须先把它初始化为确定的、合适的地址。
指针不是整数。pt = 0xB8000000; 类型不匹配,必须显式转换:pt = (int *) 0xB8000000;。整数可以加减乘除,“两个地址相乘”则毫无意义,所以两者是不同的类型。
通关标准:
能说清
&、*各干什么;能解释”指针为什么必须带类型”;看到int* p1, p2;能立刻指出谁是指针;牢记”解引用前必须初始化”。
4.7 new、delete 与动态数组
为什么要 new
OOP 强调运行时决策而非编译期决策。传统数组必须在写代码时定死大小——为了偶尔的 200 个元素而常备 200 格数组,大部分时间都在浪费内存。new 让你在程序运行时才申请内存。
int * pn = new int; // 为一个 int 申请内存,返回地址new 根据类型确定需要多少字节,找到空闲块返回地址,你把地址存进指针。这块内存没有名字,指针是访问它的唯一途径(术语上叫”数据对象”——任何为数据分配的内存块,比”变量”更宽泛)。通用格式:typeName * pointer_name = new typeName;
程序清单 4.17:
// use_new.cpp -- 使用 new 运算符
#include <iostream>
int main()
{
using namespace std;
int nights = 1001;
int * pt = new int; // 为 int 分配空间
*pt = 1001; // 在那里存一个值
cout << "nights value = ";
cout << nights << ": location " << &nights << endl;
cout << "int ";
cout << "value = " << *pt << ": location = " << pt << endl;
double * pd = new double; // 为 double 分配空间
*pd = 10000001.0;
cout << "double ";
cout << "value = " << *pd << ": location = " << pd << endl;
cout << "location of pointer pd: " << &pd << endl;
cout << "size of pt = " << sizeof(pt);
cout << ": size of *pt = " << sizeof(*pt) << endl;
cout << "size of pd = " << sizeof pd;
cout << ": size of *pd = " << sizeof(*pd) << endl;
return 0;
}nights value = 1001: location 0028F7F8
int value = 1001: location = 00033A98
double value = 1e+007: location = 000339B8
location of pointer pd: 0028F7FC
size of pt = 4: size of *pt = 4
size of pd = 4: size of *pd = 8普通变量 nights 和指针 pd 本身住在栈(stack)里,new 分配的内存住在堆(heap)/自由存储区(free store)——两个不同的内存区域。注意 pt 和 pd 都是 4 字节(都是地址),但 *pt 是 4 字节的 int、*pd 是 8 字节的 double——类型声明让 cout 知道读几个字节、怎么解释。
内存不够时,现代实现让 new 抛出异常;老实现返回 空指针(值为 0,C++ 保证它不指向有效数据),可用 if 检测。
delete:归还内存
int * ps = new int; // 申请
delete ps; // 用完归还delete 释放的是 ps 指向的内存,指针 ps 本身还在,可以再次指向别的 new 分配。配对规则:
- 不要 delete 不是 new 分配的内存;
- 不要对同一块内存 delete 两次;
- 对空指针 delete 是安全的(什么也不发生);
- 关键是地址相同,不要求是同一个指针变量。
不配对归还就会造成内存泄漏——分配了却再也用不了的内存,泄漏严重时程序会耗尽内存而崩溃。
动态数组
编译期定大小叫静态联编;运行时创建、运行时定大小叫动态联编,这样的数组叫动态数组:
int * psome = new int [10]; // 10 个 int 的块
delete [] psome; // 方括号告诉程序释放整个数组new[] 与 delete[] 必须配对:new 带方括号 delete 就带,new 不带 delete 就不带,错配的后果未定义。动态数组要自己记住元素个数(编译器不会帮你跟踪,sizeof 也问不出来)。
用起来最惊喜的一点:把指针当数组名用。psome[0]、psome[1]……完全合法,因为 C/C++ 内部本来就是用指针处理数组的。程序清单 4.18:
// arraynew.cpp -- 用 new 创建数组
#include <iostream>
int main()
{
using namespace std;
double * p3 = new double [3]; // 3 个 double 的空间
p3[0] = 0.2; // 把 p3 当数组名用
p3[1] = 0.5;
p3[2] = 0.8;
cout << "p3[1] is " << p3[1] << ".\n";
p3 = p3 + 1; // 指针前移
cout << "Now p3[0] is " << p3[0] << " and ";
cout << "p3[1] is " << p3[1] << ".\n";
p3 = p3 - 1; // 指回首元素
delete [] p3; // 释放内存
return 0;
}p3[1] is 0.5.
Now p3[0] is 0.5 and p3[1] is 0.8.p3 = p3 + 1; 对指针合法、对数组名非法——数组名是常量不能改。p3 加 1 后 p3[0] 变成了原来的第二个元素。别忘了减回去,否则 delete[] 拿到的地址就不对了。
和 delete[] 不配对
int * pt = new int; short * ps = new short [500]; delete [] pt; delete ps;两个都是未定义行为。规则记牢:带方括号的 new 配带方括号的 delete;单个 new 配单个 delete。另外 new 完记得 delete,否则内存泄漏。
4.8 指针算术与数组名的真面目
对指针 +1,加的不是 1 个字节,而是它指向的类型的字节数。程序清单 4.19 一并揭示了”数组名就是地址”:
// addpntrs.cpp -- 指针加法
#include <iostream>
int main()
{
using namespace std;
double wages[3] = {10000.0, 20000.0, 30000.0};
short stacks[3] = {3, 2, 1};
double *pw = wages; // 数组名 = 首元素地址
short *ps = &stacks[0]; // 或者用 & 加在元素上
cout << "pw = " << pw << ", *pw = " << *pw << endl;
pw = pw + 1;
cout << "add 1 to the pw pointer:\n";
cout << "pw = " << pw << ", *pw = " << *pw << "\n\n";
cout << "ps = " << ps << ", *ps = " << *ps << endl;
ps = ps + 1;
cout << "add 1 to the ps pointer:\n";
cout << "ps = " << ps << ", *ps = " << *ps << "\n\n";
cout << "access two elements with array notation\n";
cout << "stacks[0] = " << stacks[0]
<< ", stacks[1] = " << stacks[1] << endl;
cout << "access two elements with pointer notation\n";
cout << "*stacks = " << *stacks
<< ", *(stacks + 1) = " << *(stacks + 1) << endl;
cout << sizeof(wages) << " = size of wages array\n";
cout << sizeof(pw) << " = size of pw pointer\n";
return 0;
}pw = 0x28ccf0, *pw = 10000
add 1 to the pw pointer:
pw = 0x28ccf8, *pw = 20000
ps = 0x28ccea, *ps = 3
add 1 to the ps pointer:
ps = 0x28ccec, *ps = 2
access two elements with array notation
stacks[0] = 3, stacks[1] = 2
access two elements with pointer notation
*stacks = 3, *(stacks + 1) = 2
24 = size of wages array
4 = size of pw pointer
▲ 图 4.10 指针加法
pw 加 1 地址增加 8(double 8 字节),ps 加 1 地址只增加 2(short 2 字节)——都正好指向下一个元素。编译器处理 stacks[1] 的方式就是 *(stacks + 1)(括号必须有,否则 + 的优先级低于 *,会变成给值加 1):
arrayname[i] 变成 *(arrayname + i)
pointername[i] 变成 *(pointername + i)指针与数组名几乎互通,但有两个关键区别:
- 指针是变量可以改,数组名是常量不能改:
pointername = pointername + 1;合法,arrayname = arrayname + 1;非法。 - sizeof 数组名得到整个数组大小,sizeof 指针得到指针本身大小(24 vs 4)。这是数组名不被当作地址的少数场合之一。
另一处是对数组名取地址:tell 是首元素地址(指向 2 字节块),&tell 是整个数组的地址(指向 20 字节块)——数值相同,类型不同(short (*)[20] vs short *),所以 tell + 1 走 2 字节而 &tell + 1 走 20 字节。
通关标准:
能默写
arrayname[i] ≡ *(arrayname + i);能说出指针与数组名的两大区别(可变性、sizeof 行为);能解释*(stacks + 1)为什么必须加括号。
4.9 动态结构、字符串复制与内存管理
指针与 C 风格字符串
cout 遇到 char 地址(char 数组名、char 指针、字符串常量都算)时,会从那里开始打印直到 \0——因为它们都是字符串首字符的地址。
char flower[10] = "rose";
cout << flower << "s are red\n"; // 传的都是地址程序清单 4.20 演示了字符串指针的正确与错误用法:
// ptrstr.cpp -- 使用字符串指针
#include <iostream>
#include <cstring> // 声明 strlen()、strcpy()
int main()
{
using namespace std;
char animal[20] = "bear"; // animal 存着 bear
const char * bird = "wren"; // bird 存字符串的地址
char * ps; // 未初始化
cout << animal << " and "; // 显示 bear
cout << bird << "\n"; // 显示 wren
// cout << ps << "\n"; // 可能显示垃圾、可能崩溃
cout << "Enter a kind of animal: ";
cin >> animal; // 输入短于 20 就没问题
// cin >> ps; // 大错特错:ps 没指向已分配的空间
ps = animal; // ps 指向该字符串
cout << ps << "!\n";
cout << "Before using strcpy():\n";
cout << animal << " at " << (int *) animal << endl;
cout << ps << " at " << (int *) ps << endl;
ps = new char[strlen(animal) + 1]; // 按需分配新空间
strcpy(ps, animal); // 复制字符串到新空间
cout << "After using strcpy():\n";
cout << animal << " at " << (int *) animal << endl;
cout << ps << " at " << (int *) ps << endl;
delete [] ps;
return 0;
}bear and wren
Enter a kind of animal: fox
fox!
Before using strcpy():
fox at 0x0065fd30
fox at 0x0065fd30
After using strcpy():
fox at 0x0065fd30
fox at 0x004301c8几个要点:const char * bird = "wren";——字符串常量是常量,用 const 防止通过指针修改它。ps = animal; 只是复制地址,两个指针指向同一字符串;想要一份真正的拷贝,必须先 new char[strlen(animal) + 1](+1 给 \0)再 strcpy(ps, animal)。想看字符串的地址得强转成别的指针类型如 (int *) ps,因为 cout 对 char* 特殊对待。另外给数组赋新字符串要用 strcpy()/strncpy() 而不是 =(初始化时才能用 =),strncpy 不补 \0 时记得手动补上。
常见坑:把字符串读进未初始化的指针
char * ps; cin >> ps;会把输入写到随机的内存位置。读入字符串永远要给出已分配好的地址:要么是 char 数组,要么是已经 new 过的指针。
用 new 创建动态结构
inflatable * ps = new inflatable; 与内置类型语法完全一致。麻烦在于结构没名字,. 用不上——C++ 为此准备了箭头成员运算符 ->:结构名用点,结构指针用箭头。
▲ 图 4.11 标识结构成员
程序清单 4.21:
// newstrct.cpp -- 对结构使用 new
#include <iostream>
struct inflatable
{
char name[20];
float volume;
double price;
};
int main()
{
using namespace std;
inflatable * ps = new inflatable; // 为结构分配内存
cout << "Enter name of inflatable item: ";
cin.get(ps->name, 20); // 方法一:箭头
cout << "Enter volume in cubic feet: ";
cin >> (*ps).volume; // 方法二:(*ps).成员
cout << "Enter price: $";
cin >> ps->price;
cout << "Name: " << (*ps).name << endl;
cout << "Volume: " << ps->volume << " cubic feet\n";
cout << "Price: $" << ps->price << endl;
delete ps;
return 0;
}Enter name of inflatable item: Fabulous Frodo
Enter volume in cubic feet: 1.4
Enter price: $27.99
Name: Fabulous Frodo
Volume: 1.4 cubic feet
Price: $27.99(*ps).volume 也行但难看,且括号不能省。口诀:结构名用点,指针用箭头。
new/delete 综合示例与三种存储方式
程序清单 4.22 的 getname() 函数用临时数组接收输入,再 new 一块恰好合适的内存存副本并返回其地址:
// delete.cpp -- 使用 delete 运算符
#include <iostream>
#include <cstring> // 或 string.h
using namespace std;
char * getname(void); // 函数原型
int main()
{
char * name; // 创建指针但没有存储空间
name = getname(); // 把字符串地址赋给 name
cout << name << " at " << (int *) name << "\n";
delete [] name; // 释放内存
name = getname(); // 复用已释放的内存
cout << name << " at " << (int *) name << "\n";
delete [] name; // 再次释放
return 0;
}
char * getname() // 返回指向新字符串的指针
{
char temp[80]; // 临时存储
cout << "Enter last name: ";
cin >> temp;
char * pn = new char[strlen(temp) + 1];
strcpy(pn, temp); // 复制到更合适的大小
return pn; // 函数结束后 temp 就没了
}Enter last name: Fredeldumpkin
Fredeldumpkin at 0x004326b8
Enter last name: Pook
Pook at 0x004301c8如果 1000 个字符串每个最长 79 字符,用 80 字符数组要 80000 字节且大半浪费;用 new 按需分配能省下大量内存。为什么必须 new?看三种存储方式:
- 自动存储:函数内定义的普通变量,函数被调用时产生、结束时消亡,通常放在栈上(LIFO)。getname() 若返回 temp 的地址,回到 main 后那块内存很快被复用,指针就悬空了。
- 静态存储:整个程序运行期间都存在——在函数外定义,或用
static double fee = 56.50;声明。 - 动态存储:new/delete 管理的堆/自由存储区,生命周期完全由你控制,可以在一个函数里分配、另一个函数里释放。
类型组合
数组、结构、指针可以层层嵌套。trio 是结构数组,arp 是结构指针数组,ppa 是指向指针的指针(const antarctica_years_end ** ppa = arp;,或用 C++11 的 auto ppb = arp; 让编译器推断)。访问走逐步降级:ppa 是地址 → *ppa 是结构指针 → (*ppa)->year 是成员,括号不能省(*ppa->year 会先算 ppa->year,而它不是指针)。
程序清单 4.23:
// mixtypes.cpp -- 一些类型组合
#include <iostream>
struct antarctica_years_end
{
int year;
};
int main()
{
antarctica_years_end s01, s02, s03;
s01.year = 1998;
antarctica_years_end * pa = &s02;
pa->year = 1999;
antarctica_years_end trio[3]; // 3 个结构的数组
trio[0].year = 2003;
std::cout << trio->year << std::endl;
const antarctica_years_end * arp[3] = {&s01, &s02, &s03};
std::cout << arp[1]->year << std::endl;
const antarctica_years_end ** ppa = arp;
auto ppb = arp; // C++11 自动类型推断
std::cout << (*ppa)->year << std::endl;
std::cout << (*(ppb+1))->year << std::endl;
return 0;
}2003
1999
1998
19994.10 类型组合与数组替代品 vector 和 array
vector 模板类
vector 是自动管理内存的动态数组(内部就是用 new/delete 实现的,但全自动)。用法:
#include <vector>
using namespace std;
vector<int> vi; // 0 个 int 的可增长数组
int n;
cin >> n;
vector<double> vd(n); // n 个 double,n 可以是变量通用格式 vector<typeName> vt(n_elem);,n_elem 可以是整型常量或变量——这直接绕过了内置数组”大小必须编译期确定”的限制。
array 模板类(C++11)
vector 功能多但效率略低。如果只需要定长数组又想要更多便利和安全,用 array(头文件 <array>)——它和内置数组一样用栈存储、长度固定,但支持整体赋值、更安全:
#include <array>
using namespace std;
array<int, 5> ai; // 5 个 int
array<double, 4> ad = {1.2, 2.1, 3.4, 4.3}; // 定长,长度不能是变量三者对比
程序清单 4.24 同屏使用三种数组:
// choices.cpp -- 数组的各种形式
#include <iostream>
#include <vector> // STL C++98
#include <array> // C++11
int main()
{
using namespace std;
// C、原始 C++
double a1[4] = {1.2, 2.4, 3.6, 4.8};
// C++98 STL
vector<double> a2(4); // 4 个元素的 vector
a2[0] = 1.0/3.0;
a2[1] = 1.0/5.0;
a2[2] = 1.0/7.0;
a2[3] = 1.0/9.0;
// C++11 -- 创建并初始化 array 对象
array<double, 4> a3 = {3.14, 2.72, 1.62, 1.41};
array<double, 4> a4;
a4 = a3; // 同尺寸的 array 对象可以整体赋值
// 使用数组记法
cout << "a1[2]: " << a1[2] << " at " << &a1[2] << endl;
cout << "a2[2]: " << a2[2] << " at " << &a2[2] << endl;
cout << "a3[2]: " << a3[2] << " at " << &a3[2] << endl;
cout << "a4[2]: " << a4[2] << " at " << &a4[2] << endl;
// 恶行
a1[-2] = 20.2;
cout << "a1[-2]: " << a1[-2] <<" at " << &a1[-2] << endl;
cout << "a3[2]: " << a3[2] << " at " << &a3[2] << endl;
cout << "a4[2]: " << a4[2] << " at " << &a4[2] << endl;
return 0;
}a1[2]: 3.6 at 0x28ccc8
a2[2]: 0.142857 at 0x28cc90
a3[2]: 1.62 at 0x28ccc0
a4[2]: 1.62 at 0x28ccb8
a1[-2]: 20.2 at 0x28ccc8
a3[2]: 20.2 at 0x28ccc8
a4[2]: 1.62 at 0x28ccb8从地址看:a1、a3、a4 都在栈上(地址相邻),a2 在自由存储区。a1[-2] = 20.2; 相当于 *(a1 - 2) = 20.2;——往数组前面两格的地方写数据,结果正好砸中了 a3 的内存(别的编译器可能砸中别的对象)!这就是内置数组的不安全之处。
vector/array 的下标写法同样不检查(a2[-2] = .5; 也合法),但它们提供了 at() 成员函数:a2.at(1) = 2.3;——用 at() 时无效下标会在运行时被捕获并默认中止程序(代价是运行变慢)。这就是 C++ 的一贯哲学:给你自由,也给你自选的安全等级。
自测:
a1[-2] = 20.2;为什么编译器不报错?它等价于什么?它等价于
*(a1 - 2) = 20.2;,是完全合法的指针运算,只是语义上写到了数组外。C/C++ 不检查下标范围,安全要靠自己或改用 at()。
自测:vector 和 array 最大的两个不同是什么?
长度:vector 的长度可以在运行时指定并自动增长,array 长度编译期固定;存储位置:vector 用自由存储区(堆),array 用栈(或静态存储),因此 array 更接近内置数组的效率。
自测:
delete ps;释放的是 ps 这个指针变量吗?不是。delete 释放的是 ps 指向的那块内存;指针 ps 本身安然无恙,之后还能指向别处继续使用。
自测:
int * pn = new int;与int higgens; int * pt = &higgens;有何本质区别?前者的内存在运行时从自由存储区分配且没有名字,指针是唯一入口;后者指向的是编译期就分配好的有名变量,可以直接用 higgens 访问,不必经由指针。