这一章在干嘛?

前三章我们只会用”一个变量存一个数”。但真实程序要处理一整年的销售记录、一个人的姓名、一名球员的多项数据。本章讲的就是 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;
}
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.)";

4.4 结构 struct

数组只能装同类型,想用一个单元装下球员的姓名、薪水、身高、体重,就要用结构。定义结构分两步:先写结构描述(定义类型),再用它创建变量。

struct inflatable    // 结构声明
{
    char name[20];
    float volume;
    double price;
};
 
inflatable hat;      // hat 是 inflatable 类型的变量

▲ 图 4.6 结构描述的组成

inflatable标记名,从此可以像 int、char 一样当作类型名使用(C++ 里声明变量不必再写 struct 关键字)。花括号里的每一项叫成员,用成员运算符 . 访问:hat.volumehat.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 局部与外部结构声明

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 提供标签),而不是真正的类型。

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)

指针与数组名几乎互通,但有两个关键区别:

  1. 指针是变量可以改,数组名是常量不能改pointername = pointername + 1; 合法,arrayname = arrayname + 1; 非法。
  2. 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
1999

4.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++ 的一贯哲学:给你自由,也给你自选的安全等级。