这一章在干嘛?
第 10、11 章的类成员都是”定长”的。如果想让对象在运行时决定用多少内存(比如字符串长度不限),就得在构造函数里用 new——但这一步会引爆一连串隐藏问题:编译器偷偷生成的复制构造函数和赋值运算符只会浅拷贝指针,导致重复释放内存、程序崩溃。本章用”故意写坏的 StringBad 类”现场演示事故全过程,再一步步修成正确的 String 类,最后用队列 ADT(ATM 排队模拟)实战。这是 OOP 三部曲的终章,也是全书最重要的章节之一。
12.1 动态内存与静态类成员
名字存字符数组,太短装不下长名字,太长又浪费内存。更好的思路是让程序运行时再决定分配多少——构造函数里用 new 分配恰好够用的空间。本章的主角 StringBad 类(名字就是提醒你:它是个反面教材)就这么干:
// stringbad.h -- flawed string class definition
#ifndef STRRGBAD_H_
#define STRRGBAD_H_
#include <iostream>
class StringBad
{
private:
char * str; // pointer to string
int len; // length of string
static int num_strings; // number of objects
public:
StringBad(const char * s); // constructor
StringBad(); // default constructor
~StringBad(); // destructor
friend std::ostream & operator<<(std::ostream & os, const StringBad & st);
};
#endif两个要点:
①用指针代替数组。类声明只为指针 str 分配空间,字符串本体存在堆内存里,构造函数负责用 new 分配并拷贝内容。切记不能写 str = s;——那只拷贝了地址,没拷贝字符串。
②静态类成员。static int num_strings; 属于类而不属于任何对象:无论创建多少个对象,它只有一份,全体共享(像全家共用的电话号码)。用它统计活着的对象个数。

▲ 图 12.1 静态数据成员:每个对象各有一份 str 和 len,但 num_strings 全类只有一份
静态成员的初始化很特别——不能在类声明里初始化(类声明只是描述,不分配内存),要在方法文件里单独写一句:
int StringBad::num_strings = 0; // 注意:写类型、用 ::,不写 static构造函数里 num_strings++,析构函数里 --num_strings,对象计数就维持住了。析构函数还有更重要的职责:
StringBad::~StringBad() // necessary destructor
{
--num_strings; // required
delete [] str; // required!
}对象销毁时,str 指针本身随对象消失,但它指向的堆内存不会自动释放——必须靠析构函数里的 delete。
常见坑:new 与 delete 必须配对
构造函数里用 new 分配的内存,析构函数里必须用 delete 释放;用
new []就配delete []。张冠李戴的后果是未定义行为。
12.2 特殊成员函数与复制构造函数
事故现场:vegnews.cpp
测试程序开头一切正常(三个对象创建、显示无误),随后却怪象频出:传值调用后原对象字符串变成乱码;程序结束时字符串内容支离破碎;对象计数居然出现 -1、-2;有些编译器直接崩溃报 “double free or corruption”。
破案线索是计数:析构次数比构造次数多 2 次——说明有没报数的构造函数在偷偷造对象。元凶就是编译器自动生成的复制构造函数。
C++ 自动提供哪些成员函数?
即使你什么都不写,编译器也会自动提供:
- 默认构造函数(仅当你不写任何构造函数);
- 默认析构函数;
- 复制构造函数(未定义时按需生成);
- 赋值运算符(未定义时按需生成);
- 地址运算符(返回 this,一般没问题)。
后三者是按需生成的:程序中出现了对应的用法,编译器就补一个定义。StringBad 的灾难正是来自第 3、4 项。
何时调用复制构造函数?
复制构造函数把一个对象复制给一个新建的对象,原型 StringBad(const StringBad &);。触发场景:显式初始化 StringBad ditto(motto);、StringBad metoo = motto;,以及更隐蔽的——函数按值传参(callme2(headline2); 会用它初始化形参 sb)、按值返回对象、生成临时对象。这也是”传对象尽量用引用”的又一理由:按值传递要调用复制构造函数,费时费空间。
默认复制构造函数干了什么?
逐成员复制非静态成员(memberwise copying,又称浅复制 shallow copying)。对 StringBad sailor = sports; 来说,效果等价于:
sailor.str = sports.str; // 拷贝的是指针!不是字符串!
sailor.len = sports.len;于是两个对象的 str 指向同一块内存。显示时没毛病,可当 sailor 析构时 delete [] sailor.str 把字符串删了;轮到 sports 析构时,再 delete 同一块内存——重复释放,未定义行为,字符串乱码、计数错乱、程序崩溃都由此而来。另外默认复制构造不更新 num_strings,所以计数变负。

▲ 图 12.2 浅复制内幕:只拷贝了指针值,两个对象共享同一字符串
解药:深复制
自己写复制构造函数,复制指向的数据而不是指针:
StringBad::StringBad(const StringBad & st)
{
num_strings++; // handle static member update
len = st.len; // same length
str = new char [len + 1]; // allot space
std::strcpy(str, st.str); // copy string to new location
}每个对象有了自己的字符串,析构时各删各的,天下太平。

▲ 图 12.3 深复制内幕:为新对象分配新内存,数据各自独立
判断准则
只要类里有”用 new 初始化的指针成员”,就应该自己写复制构造函数(做深复制)。若类还有随对象增减的静态成员,复制构造函数还得负责记账。
12.3 赋值运算符
复制构造 ≠ 赋值运算
两个概念要分清:
StringBad metoo = knot; // 初始化:调用复制构造函数(可能附带临时对象赋值)
knot = headline1; // 赋值:调用 operator=,把一个对象赋给"已存在"的对象默认赋值运算符同样有病
原型 StringBad & StringBad::operator=(const StringBad &);,默认实现也是逐成员浅复制——knot = headline1; 之后两个 str 又指向同一内存,析构时重复释放,与复制构造函数的问题同源。
修复:自己写 operator=
比复制构造函数多三个讲究:
StringBad & StringBad::operator=(const StringBad & st)
{
if (this == &st) // ① 自我赋值检查
return *this; // 自己赋给自己,直接返回
delete [] str; // ② 释放旧内存(否则旧字符串泄漏)
len = st.len;
str = new char [len + 1]; // ③ 深复制
std::strcpy(str, st.str);
return *this; // ④ 返回调用对象的引用,支持 S0 = S1 = S2 连锁赋值
}为什么检查自我赋值?如果 knot = knot;,第一步 delete [] 会把自己即将复制的数据先删掉——内容就没了。另外赋值不创建新对象,不用调整 num_strings。
修好这两处后,vegnews 的输出终于干净:计数从 4 递减到 0,字符串原样释放。
常见坑:把"初始化"当成"赋值"
String a = b;走复制构造函数,a = b;走赋值运算符(a 已存在)。两者默认实现都是浅复制,使用 new 的类两个都得自己写,漏一个照样崩。
12.4 改良版 String 类
修完 bug 的 StringBad 摇身一变成了 String 类(string1.h),新增一批能力:
- 比较:友元
operator<() / operator>() / operator==(),用 strcmp 实现,如return (std::strcmp(st1.str, st2.str) < 0);。做成友元后 C 字符串也能比较:"love" == answer先把 “love” 转换成 String 再匹配友元版; - 下标访问:重载
operator[]()返回char &,既能读也能写(means[0] = 'r';把 “might” 变 “right”)。还要为 const 对象提供只读重载版本const char & operator[](int i) const;——C++ 区分 const 与非 const 的函数签名; - 静态成员函数:
static int HowMany();两个特点:不用对象调用(String::HowMany();),没有 this 指针,只能访问静态成员; - 再进一步重载赋值:
String & operator=(const char * s);让name = temp;(C 字符串直接赋值)免去”构造临时对象→赋值→析构临时对象”的三步开销。
一个精妙的细节——改进后的默认构造函数:
String::String()
{
len = 0;
str = new char[1]; // 而不是 new char
str[0] = '\0';
}为什么是 new char[1] 而不是 new char?因为析构函数用的是 delete [] str;——new 与 delete 的形式必须匹配。也可以直接 str = nullptr;(C++11 新关键字,替代含混的 0 和 NULL 宏;对空指针做 delete 是安全的)。
sayings1.cpp 测试程序把新能力串起来用:让用户输入最多 10 句格言(空行结束),存进 String sayings[ArSize]; 对象数组,然后用 length() 找最短、用 operator<() 找字典序最靠前、用 sayings[i][0](下标运算符)打印每句的首字母,最后调 String::HowMany() 报告用了多少个对象(输出 “This program used 11 String objects”,包含 name 和 6 句格言等)。示例运行节选:
Shortest saying:
penny wise, pound foolish
First alphabetically:
a fool and his money are soon parted
This program used 11 String objects. Bye.注意 sayings[i] = temp; 这一行的重载赋值(const char * 版本)在默默为你做深复制——正是前面修补的成果让这段代码安全无虞。
通关标准:
合上书能默写 StringBad 翻车的三大证据(乱码、计数为负、double free)及其病理(浅复制共享指针 + 静态计数漏记),并能默写深复制版复制构造函数与 operator=(含自我赋值检查、释放旧内存、返回 *this)。这两段代码是 C++ 面试的永恒考题,也是理解 std::string 之类类库为何如此设计的基础。
12.5 在构造函数中使用 new 的注意事项
- 构造函数里用 new 初始化指针成员 ⇒ 析构函数里必须 delete;
- new 与 delete 配对:
new配delete,new []配delete []; - 多个构造函数必须用同一种 new 方式(析构函数只有一个,得兼容所有构造函数)。例外:某个构造函数可以把指针初始化为空指针,因为对空指针 delete 安全;
- 复制构造函数做深复制,复制数据而非地址,并更新受影响的静态成员;
- 赋值运算符:检查自我赋值、先释放旧内存、深复制、返回调用对象的引用。
反面教材三连(别这么写):
String::String() { str = "default string"; } // 没 new,析构 delete 会出事
String::String(const char * s) { str = new char; ... } // new 少了 [],空间也不够
String::~String() { delete str; } // 应为 delete [] str;顺带安心一下:如果类的成员本身是 String 或 std::string 这样的对象(如 Magazine 类含 String title 成员),默认的逐成员复制会自动调用成员类型的复制构造/赋值运算符,Magazine 类自己不用为此重写——默认机制是有”智能”的。
12.6 返回对象的学问
函数返回对象有四种选择,各自适用场景不同:
- 返回 const 引用——最快,前提是返回的对象在调用函数中仍存在。典型:返回两个传入参数中较大者(第 10 章 topval 的思路)。返回对象要调复制构造函数,返回引用则省了;
- 返回非 const 引用——两大经典:operator=(支持连锁赋值,返回 *this)和 operator<<(必须返回 ostream &,因为 ostream 连公有的复制构造函数都没有,想返回对象都不行);
- 返回对象——返回的对象是函数内的局部变量时只能这样:函数一结束局部对象就析构了,引用会悬空。典型是算术运算符:
Vector Vector::operator+(const Vector & b) const { return Vector(x + b.x, y + b.y); }。复制构造函数的开销无法避免; - 返回 const 对象——防止”发昏写法”:
force1 + force2 = net;这种把临时对象当左值的代码,返回类型写成 const Vector 就直接编译报错。平时可以不用,但它是一道免费的保险。
自测:为什么 operator= 返回引用而 operator+ 返回对象?
operator= 返回的是调用对象 *this,它在调用函数作用域内活着,引用安全且省一次复制;operator+ 的结果是新算出来的局部对象,函数结束就销毁,只能按值返回。
12.7 对象指针与定位 new
sayings2.cpp 演示对象指针的常见姿势:
String * shortest = &sayings[0]; // 指向已有对象,不创建新对象
String * favorite = new String(sayings[choice]); // new 创建新对象(调用复制构造函数)
if (sayings[i] < *first) // * 解引用得到对象
first = &sayings[i]; // & 取地址
shortest->length(); // -> 通过指针访问方法
delete favorite; // new 出来的对象用完要 deletenew Class_name(value) 按参数匹配构造函数;new Class_name 调用默认构造函数。delete 整个对象时(不带括号),析构函数会自动执行,把对象内部 new 出来的字符串也清理掉——这就是”两级 new/delete”。

▲ 图 12.4 析构函数的调用时机:自动变量离开代码块、静态变量程序结束、new 对象执行 delete

▲ 图 12.5 用 new 创建对象:对象本身(str 指针 + len)在堆上,字符串由构造函数再分配

▲ 图 12.6 指针对象速查:声明、指向已有对象、new 默认构造、new 带参构造、-> 与 * 的用法
定位 new 的两大坑
定位 new(placement new)让你指定内存位置:pc1 = new (buffer) JustTesting; 把对象放进 buffer 那块内存。但它配合类使用有两个大坑:
坑一:重复覆盖。再次 new (buffer) JustTesting("Bad Idea", 6); 会原地覆盖第一个对象,第一个对象的析构函数永远没机会调用。正确做法是错开位置:
pc1 = new (buffer) JustTesting;
pc3 = new (buffer + sizeof(JustTesting)) JustTesting("Better Idea", 6);坑二:delete 管不了它。delete pc1; 是运行时错误——pc1 不是 new 返回的地址(它是 buffer 内部的位置),new/delete 系统只认识整块 buffer,不认识块里塞了什么。而且 delete [] buffer; 只释放内存块,不会调用里面对象的析构函数。唯一解法是显式调用析构函数(极少数必须手动调析构的场景):
pc3->~JustTesting(); // 先销毁对象(后建先毁)
pc1->~JustTesting();
delete [] buffer; // 最后才释放整块内存顺序也有讲究:定位 new 建的对象按相反顺序销毁,最后才释放 buffer。
12.8 实战:Queue 队列与 ATM 模拟
超市要装 ATM,管理层想知道排队会怎么样。队列(queue)是天然模型:先进先出(FIFO),从队尾加入、从队头移除(对比第 10 章栈的 LIFO)。

▲ 图 12.7 队列:从队尾加入顾客,从队头移除顾客
数据结构:链表 + 嵌套结构体
数组不适合队列(从头删元素得整体挪动),链表正合适。每个节点装数据 + 指向下一节点的指针:
class Queue
{
private:
// 嵌套结构体:Node 只在类内可见,避免与全局 Node 冲突
struct Node { Item item; struct Node * next; };
enum {Q_SIZE = 10};
Node * front; // 队头
Node * rear; // 队尾
int items; // 当前元素数
const int qsize; // 容量上限
public:
Queue(int qs = Q_SIZE);
~Queue();
bool isempty() const; bool isfull() const;
int queuecount() const;
bool enqueue(const Item &item); // 入队
bool dequeue(Item &item); // 出队
};
▲ 图 12.8 单链表:顺着 next 指针就能遍历所有节点

▲ 图 12.9 Queue 对象:front 指向链表头,rear 指向链表尾
成员初始化列表
qsize 是 const 成员,不能在构造函数体内赋值——必须在对象创建之时初始化。语法是冒号加逗号分隔的初始化列表:
Queue::Queue(int qs) : qsize(qs) // 在函数体执行前初始化 qsize
{
front = rear = nullptr;
items = 0;
}规则:只有构造函数能用这种语法;非静态 const 成员和引用成员必须用它初始化;成员按类声明中的顺序初始化,与列表顺序无关。C++11 还允许类内直接初始化:int mem1 = 10;,等价于在每个构造函数里写进初始化列表(会被显式列表覆盖)。

▲ 图 12.10 入队(enqueue):新建节点→链接到 rear→更新 rear 指针

▲ 图 12.11 出队(dequeue):取出 front 数据→front 前移→delete 旧节点
析构与”防复制”技巧
Queue 虽然自己的构造函数没直接用 new,但 enqueue 会 new 节点——队列消亡时若还有节点没出队,就得靠析构函数逐个 delete:
Queue::~Queue()
{
Node * temp;
while (front != NULL)
{
temp = front;
front = front->next;
delete temp;
}
}那复制构造函数和赋值运算符呢?浅复制会让两个队列共享同一条链表,互相干扰——按本章规则必须深复制。但这个模拟程序根本不需要复制队列,怎么办?有个”占位”妙招:把它们定义成私有哑函数,既覆盖了编译器的默认版本,又让外界用不了(想复制直接编译报错,而不是运行时莫名其妙崩溃):
private:
Queue(const Queue & q) : qsize(0) { } // preemptive definition
Queue & operator=(const Queue & q) { return *this;}C++11 之后还可以直接用 = delete 禁用(第 18 章)。想真复制时再回来补深复制版即可。
Customer 类与模拟主程序
顾客只需要两个属性:到达时间、办理时长(随机 1~3 分钟)。模拟主循环每分钟一格:随机决定是否来新客(来客概率让平均每 6 分钟来一人即可,公式 rand() * x / RAND_MAX < 1 营造真实的”扎堆”感);空闲就从队头取人办理;统计服务人数、流失人数、累计等待时间。运行不同参数(队列上限、模拟时长、每小时平均客流)就能看出:客流量接近承载上限时,等待时间和流失率会急剧恶化——这正是队列 ADT 给业务决策提供的价值。
自测:为什么 Queue 类的 qsize 必须用成员初始化列表初始化?
qsize 是 const 成员。构造函数体执行时对象已经存在,此时只能”赋值”,而 const 不允许赋值。初始化列表在对象创建之时(函数体执行之前)完成初始化,是 const 成员和引用成员唯一的初始化途径。
自测:定位 new 创建的对象为什么不能 delete,该怎么办?
delete 只能作用于 new 返回的地址;定位 new 返回的是你提供的 buffer 内部地址,new/delete 系统并不掌握它。正确做法:显式调用析构函数
pc->~JustTesting();(后建先毁),最后用与分配方式匹配的delete [] buffer;释放整块内存。
自测:
String s2 = s1;和s2 = s1;分别调用什么?前者是初始化(s2 正在诞生),调用复制构造函数;后者是赋值(s2 已存在),调用赋值运算符。使用 new 的类两者都要自定义深复制版本。
自测:静态成员函数有什么限制和特权?
特权:不需要对象就能调用(
String::HowMany());限制:没有 this 指针,只能访问静态数据成员,不能访问 str、len 这类普通成员。