🎯 前言:快速复习策略
基于老师最后一课讲义整理 · 覆盖所有考题类型
📋 总策略:先理解核心机制,再背诵固定答案
大题(代码追踪/编程)靠 理解,选择填空靠 背诵。
🧠 需要理解(会变数字/变形式,靠理解机制拿分)
| 题号 | 考点 | 必须理解的机制 | 关键词 |
|---|---|---|---|
| 2, 6 | 代码运行结果 | 构造/析构顺序 · 虚函数动态绑定 · 非虚静态绑定 · 对象切片 · 默认参数是静态绑定 | virtual → 动态绑定 · Animal a = d → 切片 · 指针/引用不切片 · 析构逆序 |
| 34 | 纯虚函数追踪 | 纯虚函数 = 0 · 抽象类不能实例化 · 子类必须实现纯虚函数 · 基类指针调虚函数 | virtual ... = 0 · 抽象类 · 动态绑定 |
| 35 | 异常处理追踪 | throw 后直接跳到 catch · try 块内 throw 之后的代码不执行 · catch 匹配类型 | try监控 · throw抛出 · catch捕获 · 跳转 |
| 31 | 静态成员追踪 | static 成员属于类(全局只有一份)· 所有对象共享 · 必须类外定义 | static · 共享一份 · 类名::变量 |
| 32 | 引用返回值 | 返回引用 = 返回变量本身(可出现在 = 左边)· 返回非引用 = 返回副本(右值) | int& f() → 左值 · f() = x 合法 |
| 37 | 浅拷贝危害 | 编译器默认拷贝是浅拷贝 → 指针成员指向同一内存 → 析构两次崩溃 · 必须深拷贝 | 浅拷贝 · 深拷贝 · 内存泄漏 · 双重释放 |
| 3, 38, 39 40, 42, 43 |
编程题 | 模板写法 · 文件读写 ofstream/ifstream · 继承+虚函数编程 · String三大函数 · Stack实现 | template <typename T> · ifstream · virtual · new[]/delete[] · 扩容翻倍 |
📝 需要背诵(死答案,不会变)
| 题号 | 题型 | 要背的答案 | 记忆口诀 |
|---|---|---|---|
| 7 | 构造调用时机 | A · 声明对象时调用 | 声明即构造 |
| 8 | const 成员函数 | C · int f() const | const 放最后 |
| 9 | this 指针访问 | A · this->x | this 是指针用 -> |
| 10 | 不可重载运算符 | C · ?: | 四大天王:. .* :: ?: |
| 11 | new 返回值 | A · 返回指向对象的指针 | new 返回指针 |
| 12 | 拷贝构造时机 | D · 以上都是 | 初始化/传参/返回 → 三种都触发 |
| 13 | 非成员函数 | C · 友元函数 | 友元不是成员 |
| 14 | inline 特性 | D · 提速+增体积 | 空间换时间 |
| 15 | 抽象类 | D · 不能实例化 | 含纯虚 = 不能 new |
| 16 | 纯虚函数声明 | C · virtual void f() = 0; | virtual + 参数 + = 0 |
| 17 | 构造函数特性 | B · 不能有返回类型 | void 都不能写 |
| 18 | 拷贝构造传参 | C · 只能引用传递 | 值传递 → 无限递归 |
| 19 | 运行时决定函数 | C · Dynamic binding | 动态绑定 = 虚函数机制 |
| 20 | 模板关键字 | C · template | 模板以 template 开头 |
| 21 | 指针赋值错误 | B · 基类→派生指针 | 父不能转子 |
| 22 | 不可重载函数 | C · 析构函数 | 析构无参 → 无法重载 |
| 23 | 析构定义 | A · A::~A() | 无返回无参数 |
| 24 | 异常捕获 | B · catch 块 | catch 捕获处理 |
| 25 | new 失败异常 | B · bad_alloc | 分配失败 → bad_alloc |
| 26 | 静态成员特性 | A、C(多选) | 加 static + 类外引用加类名:: |
| 27 | 引用参数含义 | C · 对象引用作参数 | A &a 是引用 |
| 28 | 默认构造函数 | D · A和B都对 | 两种默认构造 |
| 29 | 引用 vs 指针 | D · 以上全是 | 引用:非空/不可改绑/自动解引 |
| 30 | 显式调基类 | C · Base::fun(); | 类名::函数名() |
| 33 | 模板推导 | B · 5 和 5.2 | 各自匹配 int / double |
| 36 | 野指针后果 | 篡改动态内存,后果难料 | free 后 str != NULL 仍是原址 |
| 41 | 私有成员访问 | p.height = 1.85 编译错误 | private 只能类内访问 |
🧬 第 1 章 — 继承与多态
对应考题:第2题、第6题、第34题、第40题 | ⭐⭐⭐⭐⭐ 必考
1.1 先搞懂三个核心概念
什么是"多态"(Polymorphism)?
多态 = 同一个接口,不同的行为。
比如老师说"回答问题",每个学生回答的内容不同——接口一样("回答问题"),行为因人而异(具体回答)。
在 C++ 中,多态靠 virtual 虚函数 实现:通过父类指针或引用调用虚函数时,实际跑的是子类的版本。这就是动态绑定——运行时才决定调哪个函数。
Animal* p = new Dog(); // p 声明为 Animal*,但实际指向 Dog 对象
p->speak(); // speak 是 virtual → 运行时发现实际是 Dog → 调 Dog::speak()
// ↑ 这就是多态:同一个 p->speak() 调用,实际行为取决于 p 指向什么对象
| 概念 | 含义 | 通俗理解 |
|---|---|---|
| 继承 (Inheritance) | 子类自动拥有父类的成员变量和函数 | 儿子继承老爸的房子和财产(可以在此基础上加自己的东西) |
| 虚函数 (Virtual Function) | 用 virtual 关键字标记的函数。通过指针/引用调用时,运行哪个版本取决于实际对象类型,而不是指针/引用的类型 | 遥控器上有"发声"按钮——按下去,电视出声,收音机也出声,但发出来的声音不同 |
| 动态绑定 (Dynamic Binding) | 运行时根据对象实际类型决定调用哪个函数版本(也叫"后期绑定"、"运行时多态") | 考试时老师叫"交卷",你交你的,同桌交同桌的——具体交什么,看你是谁 |
1.2 构造/析构的黄金法则
规则:创建派生类对象时,父类部分必须先构造好,子类才能在其基础上构造。销毁时反过来,子类先清理自己的东西,父类再清理。
Step 2 Dog() 子类构造 — 在上面建"房子"
Step 2 ~Animal() 父类析构 — 再拆"地基"
virtual,通过父类指针 delete 时才会正确先调子类析构再调父类析构。否则只调父类析构 → 子类部分的内存泄漏。
Animal* p = new Dog();
delete p; // 如果 ~Animal() 不是 virtual → 只调 ~Animal(),Dog 部分泄漏!
// 如果 ~Animal() 是 virtual → 先调 ~Dog(),再调 ~Animal() ✅
1.3 对象切片 —— 考试最爱考的坑
对象切片(Object Slicing):把派生类对象按值赋给父类对象时,派生类多出来的成员被切掉,只保留父类部分。就像把一个圆(子类)塞进方的模具(父类)。
Dog d; // d 是完整的 Dog 对象,包含 Animal 部分 + Dog 独有部分
Animal a = d; // ⚠️ 对象切片!a 是从 d 拷贝构造而来
// a 只拷贝了 Animal 那部分,Dog 独有部分被"切掉"了
// a.speak() 调的是 Animal::speak,不是 Dog::speak
| 场景 | 是否切片? | 多态还能生效吗? |
|---|---|---|
Animal a = dogObj; 值拷贝 | ✅ 切片 | ❌ 多态失效 |
Animal& ref = dogObj; 引用绑定 | ❌ 不切片 | ✅ 多态生效 |
Animal* ptr = &dogObj; 指针指向 | ❌ 不切片 | ✅ 多态生效 |
void f(Animal a) 非引用传参 | ✅ 切片 | ❌ 多态失效 |
记忆:想多态 → 用指针或引用;值拷贝 → 切片,多态消失。
1.4 虚函数 vs 非虚函数 —— 一张表记清楚
| 调用方式 | virtual 虚函数 | 非虚函数 |
|---|---|---|
通过对象调用 dog.speak() | 静态绑定 → 调自己版本 | 静态绑定 → 调自己版本 |
通过指针调用 ptr->speak() | 动态绑定 → 调实际对象版本 🎯 | 静态绑定 → 调指针类型版本 |
通过引用调用 ref.speak() | 动态绑定 → 调实际对象版本 🎯 | 静态绑定 → 调引用类型版本 |
🔥 虚函数默认参数陷阱
虚函数的默认参数值是静态绑定的——看声明类型,不看实际类型。
class Animal {
public:
virtual void speak(int n = 5) const { ... } // 父类默认值 n=5
};
class Dog : public Animal {
public:
void speak(int n = 9) const override { ... } // 子类默认值 n=9(但有坑!)
};
Animal* p = new Dog(); // p 声明为 Animal*
p->speak(); // 不传参数 → 用谁的默认值?
// 函数体用 Dog 的(动态绑定)✅ → 输出 "D::speak"
// 默认值用 Animal 的(静态绑定)⚠️ → n=5 而不是 n=9
// 最终输出:D::speak 5(不是 9!)
规则:指针/引用是什么类型,默认值就用那个类型的。这里 p 是 Animal*,所以用 Animal 的 n=5。
📝 第2题 — 执行流程表
从上到下按执行顺序阅读,"正在执行"列对应下方源码中的步骤标记
class ClassA {
public:
// 构造函数(Constructor):创建对象时自动调用
ClassA() { cout << "ClassA" << endl; } // 默认构造:无参数
ClassA(int i) { cout << "ClassA" << i << endl; } // 有参构造:输出 ClassA + 数字
ClassA(const ClassA& src) { cout << "ClassA copy" << endl; } // 拷贝构造
ClassA& operator=(const ClassA& rhs) { // 赋值运算符(本题没用到)
cout << "call ClassA" << endl;
}
virtual ~ClassA() { cout << "Delete ClassA" << endl; } // 虚析构
void invoke(int i = 7) const { // invoke 是非虚函数!注意:没有 virtual!
cout << "invoke ClassA" << i << endl; // 输出 invoke ClassA + 参数值
}
};
class ClassB: public ClassA { // ClassB 继承自 ClassA
public:
ClassB() : ClassA(1) { // 构造时先调 ClassA(1),再执行自己的构造体
cout << "ClassB" << endl;
}
virtual ~ClassB() { cout << "Delete ClassB" << endl; } // 虚析构
void invoke(int i = 9) const { // 同名函数,隐藏了 ClassA::invoke(非虚,所以是隐藏不是覆盖)
cout << "invoke ClassB" << i << endl;
}
};
void find(const ClassA& inKlep) { // 形参 inKlep 是 ClassA 的引用
ClassA theClassA; // ← 第4步在此执行:创建局部对象
inKlep.invoke(2); // ← 第5步在此执行:invoke 不是虚函数!
} // ← 第6步:theClassA 离开作用域,自动销毁
int main() {
ClassB deri; // ← 第1步:创建 ClassB 对象
ClassA base = deri; // ← 第2步:拷贝构造(等号左边是新对象 → 不是赋值!)
find(deri); // ← 第3步:调用 find,传引用(不切片)
cout << "Done!" << endl; // ← 第7步:打印 Done!
return 0; // ← 第8步:main 结束,局部变量按逆序析构
}
| # | 正在执行 | 输出 | 为什么(关键理解) |
|---|---|---|---|
| 1 | ClassB deri; |
ClassA1ClassB |
创建 ClassB 对象。先调用 ClassA(1)(初始化列表中指定了参数1)→ 输出 ClassA1。父类构造完毕后,执行 ClassB() 自身的构造体 → 输出 ClassB。 |
| 2 | ClassA base = deri; |
ClassA copy |
这里 base 是新创建的对象 → 触发拷贝构造函数,不是赋值运算符! 从 deri(ClassB)拷贝到 base(ClassA)→ 对象切片,只拷贝 ClassA 部分。 |
| 3 | find(deri); |
— | 调用 find 函数。实参 deri 绑定到形参 const ClassA& inKlep。因为是引用绑定,不触发拷贝构造,不切片。inKlep 虽然是 ClassA& 类型,但实际引用的仍然是原来的 ClassB 对象。 |
| 4 | ClassA theClassA; (find 内) |
ClassA |
在 find 函数内部创建一个局部 ClassA 对象 → 调用默认构造函数(无参)→ 输出 ClassA。 |
| 5 | inKlep.invoke(2); (find 内) |
invoke ClassA2 |
🔥 本题最关键一步!invoke 函数没有 virtual 关键字 → 是非虚函数 → 静态绑定。编译器看 inKlep 声明的类型是 const ClassA& → 调用 ClassA::invoke(2) → 输出 invoke ClassA2。📌 如果 invoke 是虚函数,这里会动态绑定到 ClassB::invoke(2),输出 invoke ClassB2。 |
| 6 | find 结束 → theClassA 离开作用域 |
Delete ClassA |
局部对象 theClassA 被销毁,调用 ~ClassA()。 |
| 7 | cout << "Done!"; |
Done! |
直接输出字符串。 |
| 8 | return 0; → main 结束 |
Delete ClassADelete ClassBDelete ClassA |
局部变量按构造的逆序析构(后构造的先销毁): ① base 析构 → ~ClassA() 输出 Delete ClassA② deri 析构 → 虚析构 → 先 ~ClassB() 输出 Delete ClassB,再 ~ClassA() 输出 Delete ClassA |
✅ 完整输出(按顺序)
ClassA1
ClassB
ClassA copy
ClassA
invoke ClassA2
Delete ClassA
Done!
Delete ClassA
Delete ClassB
Delete ClassA
核心教训:invoke 没有 virtual 关键字 → 非虚函数 → 静态绑定 → 不管 inKlep 实际引用的是什么,只调 ClassA 的版本。
📝 第6题 — 最经典的多态综合题
这道题融合了虚函数、非虚函数、对象切片、虚析构、默认参数——全部考点。
class Animal {
public:
Animal() { cout << "A ctor" << endl; } // 默认构造:输出 "A ctor"
Animal(int n) { cout << "A ctor " << n << endl; } // 有参构造:输出 "A ctor N"
Animal(const Animal& src) { cout << "A copy ctor" << endl; } // 拷贝构造
virtual ~Animal() { cout << "A dtor" << endl; } // 虚析构(保证安全 delete)
virtual void speak(int n = 5) const { // 虚函数!默认值 n=5
cout << "A::speak " << n << endl;
}
void breathe() const { // 非虚函数!
cout << "A::breathe" << endl;
}
};
class Dog : public Animal { // Dog 继承 Animal
public:
Dog() : Animal(3) { // 初始化列表:先调 Animal(3)
cout << "D ctor" << endl; // 再执行自己的构造体
}
virtual ~Dog() { cout << "D dtor" << endl; } // 虚析构
void speak(int n = 9) const override { // 重写(override)父类虚函数
cout << "D::speak " << n << endl;
}
void breathe() const { // 同名非虚函数,隐藏父类版本
cout << "D::breathe" << endl;
}
};
void bar(const Animal& a) { // a 是 Animal 的引用(引用不切片!)
Animal tmp; // ← 步骤4:创建局部 Animal 对象
a.speak(1); // ← 步骤5:通过引用调虚函数
a.breathe(); // ← 步骤6:通过引用调非虚函数
} // ← 步骤7:tmp 离开作用域
int main() {
Dog d; // ← 步骤1~2:创建 Dog 对象(构造链)
Animal a = d; // ← 步骤3:拷贝构造 → 对象切片!
bar(d); // 调用 bar,d 以引用方式传入
cout << "---" << endl; // ← 步骤8:分隔线
Animal* p = new Dog(); // ← 步骤9~10:在堆上创建 Dog 对象
p->speak(); // ← 步骤11:虚函数 + 默认参数
delete p; // ← 步骤12~13:虚析构链
return 0; // ← 步骤14~16:局部变量栈上析构
}
| # | 正在执行 | 输出 | 为什么(关键理解) |
|---|---|---|---|
| 1 | Dog d; → 先 Animal(3) | A ctor 3 | Dog 构造,先执行初始化列表中的 Animal(3)(父类有参构造)。 |
| 2 | Dog d; → Dog() | D ctor | 父类构造完后,执行 Dog() 的构造函数体。 |
| 3 | Animal a = d; | A copy ctor | 🔥 对象切片!左边是新对象 → 拷贝构造。d 是 Dog,a 是 Animal → 只拷贝 Animal 部分。 |
| 4 | bar(d) → Animal tmp; | A ctor | 进入 bar 函数,d 以引用方式传入(const Animal& a,不切片)。tmp 是局部 Animal 对象,默认构造。 |
| 5 | a.speak(1)(bar 内) | D::speak 1 | a 是 Animal&,但 speak 是 virtual → 动态绑定 → 实际对象是 Dog → 调用 Dog::speak(1)。 |
| 6 | a.breathe()(bar 内) | A::breathe | breathe 是非虚函数 → 静态绑定 → a 声明为 Animal& → 调 Animal::breathe。 |
| 7 | bar 结束 → tmp 析构 | A dtor | tmp 离开作用域 → ~Animal()。 |
| 8 | cout << "---"; | --- | 分隔线,回到 main。 |
| 9 | new Dog() → Animal(3) | A ctor 3 | 堆上分配 Dog → 先父类构造。 |
| 10 | new Dog() → Dog() | D ctor | 子类构造函数体。 |
| 11 | p->speak(); | D::speak 5 | 🔥 默认参数陷阱! p 是 Animal* → 默认值用 Animal 的 n=5。speak 是虚函数 → 函数体动态绑定 Dog 的。所以输出 D::speak 5(不是 9!)。 |
| 12 | delete p; → ~Dog() | D dtor | 虚析构!先调子类析构。 |
| 13 | delete p; → ~Animal() | A dtor | 子类析构完后自动调父类析构。 |
| 14 | main 结束 → a 析构 | A dtor | 局部变量逆序析构。a 是 Animal(切片后的对象),普通析构。 |
| 15 | main 结束 → d 析构 → ~Dog() | D dtor | d 是 Dog → 虚析构 → 先 ~Dog()。 |
| 16 | d 析构 → ~Animal() | A dtor | 子类析构后自动调父类析构。 |
✅ 完整输出
A ctor 3
D ctor
A copy ctor
A ctor
D::speak 1
A::breathe
A dtor
---
A ctor 3
D ctor
D::speak 5
D dtor
A dtor
A dtor
D dtor
A dtor
五大考点总结:
- 虚函数动态绑定:步骤5
a.speak(1)输出 Dog::speak(a 是引用) - 非虚函数静态绑定:步骤6
a.breathe()输出 Animal::breathe - 对象切片:步骤3
Animal a = d;拷贝构造,Dog 部分丢失 - 默认参数静态绑定:步骤11
p->speak()默认值用 Animal 的 n=5 - 虚析构保证安全:步骤12~13 delete p 正确调用 ~Dog() → ~Animal()
📝 第34题 — 纯虚函数与抽象类
纯虚函数:用 = 0 声明的虚函数,没有函数体。
抽象类:包含至少一个纯虚函数的类。不能直接创建对象,必须被继承并实现所有纯虚函数后才能用。
class Oyster {
public:
// 构造函数:初始化 genus 成员
Oyster(string genusString) { genus = genusString; }
string getPhylum() const { return "Mollusca"; } // 非虚函数:直接继承使用
virtual string getName() const { return "Oyster class"; } // 虚函数:可被子类覆盖
string getGenus() const { return genus; } // 非虚函数:直接继承使用
virtual void print() const = 0; // ← 纯虚函数!= 0 表示没有实现,子类必须实现
// Oyster 因此成为抽象类,不能 Oyster o; ❌
private:
string genus;
};
class VirginiaOyster : public Oyster {
public:
// 构造时通过初始化列表调用 Oyster("Crassostrea")
VirginiaOyster() : Oyster("Crassostrea") {}
// 覆盖父类的 getName 虚函数
virtual string getName() const { return "VirginiaOyster class"; }
// 实现父类的纯虚函数 print —— 必须实现,否则 VirginiaOyster 也是抽象类
virtual void print() const {
cout << "Phylum:" << getPhylum().c_str() // 调用继承的 getPhylum()
<< "\tGenus: " << getGenus().c_str() // 调用继承的 getGenus()
<< "\tName: " << getName().c_str(); // 调用自己的 getName()(虚函数,动态绑定)
}
};
int main() {
VirginiaOyster oyster; // 创建 VirginiaOyster 对象(合法,它实现了所有纯虚函数)
Oyster *baseClassPtr; // 声明基类指针
baseClassPtr = &oyster; // 基类指针指向派生类对象(向上转型,安全)
baseClassPtr->print(); // print 是虚函数 → 动态绑定 → 调 VirginiaOyster::print()
cout << endl;
return 0;
}
输出:Phylum:Mollusca Genus: Crassostrea Name: VirginiaOyster class
print()是纯虚函数,被 VirginiaOyster 实现getName()是虚函数 → 动态绑定到 VirginiaOyster::getName()getPhylum()和getGenus()是非虚函数 → 直接继承 Oyster 的版本baseClassPtr->print()通过基类指针调用 → 多态生效
✍️ 第40题 — 继承 + 虚函数编程
定义 computer 和 Macintosh 类。关键要求:通过 computer* 指针调用 print() 时,必须输出 Macintosh 的完整信息(包括 color)。
这要求 print 必须是 virtual —— 否则永远调 computer 的版本。
class computer {
protected: // protected:派生类可以访问,外部不能
string name; // 名字成员
public:
computer(string n) : name(n) {} // 构造函数:初始化 name
virtual void print() const { // ⚠️ 必须是 virtual!否则 p->print() 永远调这个
cout << "name: " << name << endl;
}
virtual ~computer() {} // ⚠️ 基类析构必须 virtual(好习惯)
};
class Macintosh : public computer {
string color; // 派生类独有的成员
public:
// 构造函数:先调 computer(n) 初始化 name,再初始化 color
Macintosh(string n, string c) : computer(n), color(c) {}
void print() const override { // override 关键字:确认这是重写父类的虚函数
cout << "name: " << name << endl; // name 从父类继承(protected 可访问)
cout << "color:" << color << endl; // color 是自己的成员
}
};
int main() {
computer *p; // 声明基类指针
Macintosh imac("Joe's IMAC", "Blue"); // 创建 Macintosh 对象
p = &imac; // 基类指针指向派生类对象
p->print(); // print 是 virtual → 动态绑定 → 调 Macintosh::print()
return 0;
}
💡 为什么 print 必须是 virtual?
如果不加 virtual:p->print() 中 p 是 computer* 类型 → 静态绑定 → 永远调 computer::print() → 只输出 name,color 永远不会出现。
加上 virtual:运行时发现 p 实际指向 Macintosh → 动态绑定 → 调 Macintosh::print() → name 和 color 都输出。
这就是多态的核心价值:通过基类指针/引用,调用同一个接口,得到不同的行为。
📋 第1章自测
class Base {
public:
Base() { cout << "B"; } // 构造:输出 B
virtual ~Base() { cout << "~B"; } // 虚析构:输出 ~B
virtual void f() { cout << "Bf"; } // 虚函数
void g() { cout << "Bg"; } // 非虚函数
};
class Derived : public Base {
public:
Derived() { cout << "D"; } // 构造:先调 Base() 输出 B,再输出 D
~Derived() { cout << "~D"; } // 析构:先输出 ~D,再自动调 ~Base() 输出 ~B
void f() override { cout << "Df"; } // 重写虚函数
};
int main() {
Base* p = new Derived(); // 创建 Derived → B D
p->f(); // f() 是 virtual → 动态绑定 → Df
p->g(); // g() 非虚 → 静态绑定 → Bg
delete p; // 虚析构 → ~D ~B
}
🏗️ 第 2 章 — 构造、析构、拷贝(三大函数)
对应考题:第12、17、18、23、28、37、42题 | ⭐⭐⭐⭐⭐
2.1 什么是"三大函数"?
每一个 C++ 类都至少需要关注三个特殊函数:构造函数(怎么创建)、析构函数(怎么销毁)、拷贝控制(怎么复制)。如果你不写,编译器会帮你自动生成——但自动生成的版本只做浅拷贝,有指针成员时会造成严重问题。
2.2 编译器悄悄帮你生成的代码
| 函数 | 编译器自动生成版本做了什么 | 类里有指针成员时安全吗? |
|---|---|---|
| 默认构造函数 | 什么都不做(或调成员的默认构造) | ✅ 还行 |
| 拷贝构造函数 | 浅拷贝——把每个成员逐字节拷贝 | ❌ 两个对象的指针指向同一块内存 |
| 赋值运算符 | 浅拷贝——把每个成员逐字节赋值 | ❌ 同上 + 旧内存没释放(泄漏) |
| 析构函数 | 什么都不做(或调成员的析构) | ❌ 指针指向的堆内存没人释放 |
2.3 Rule of Three(三法则)
规则:如果你的类需要自定义析构函数、拷贝构造函数、赋值运算符中的任意一个,那么你几乎一定需要全部三个。
为什么? 需要自定义析构 → 说明你管理了资源(如堆内存)→ 拷贝时不能简单浅拷贝 → 必须深拷贝 → 所以拷贝构造和赋值运算符也要自定义。
这三个函数是绑定的——写一个就要写三个。
2.4 浅拷贝 vs 深拷贝 —— 可视化对比
对象 obj1
堆内存 0x1000
对象 obj2
两个对象的 ptr 指向同一块内存!
析构时 obj1 先释放 → obj2 再释放(double free)→ 💥 崩溃
对象 obj1
堆内存 0x1000
对象 obj2
堆内存 0x2000
各自分配了独立的内存副本!
析构时各自释放各自的内存 → ✅ 安全
浅拷贝 = 只拷贝指针的值(地址)→ 两个指针指向同一个地方。
深拷贝 = 分配新内存 + 拷贝内容 → 两个指针指向各自的副本。
2.5 String 类完整实现 —— 必背!
⚠️ 这是每年必考的手写代码!四个函数必须能手写出来。
class String {
private:
char *m_data; // 指向堆上动态分配的字符数组(这才是真正的字符串)
public:
// ========== ① 普通构造函数 ==========
// 作用:根据 C 风格字符串创建 String 对象
String(const char *str = NULL) { // 默认参数 NULL:也充当了默认构造函数
if (str == NULL) { // 如果传入 NULL 或没传参
m_data = new char[1]; // 分配 1 字节
m_data[0] = '\0'; // 空字符串(只有结束符)
} else {
m_data = new char[strlen(str) + 1]; // +1 是为了 '\0' 结束符
strcpy(m_data, str); // 把 str 内容拷贝到 m_data
}
}
// ========== ② 拷贝构造函数 ==========
// 作用:用一个已存在的 String 对象创建新的 String(深拷贝!)
// 参数必须是 const 引用(不能按值传递,否则会无限递归)
String(const String &other) {
m_data = new char[strlen(other.m_data) + 1]; // 分配新内存(不是共享!)
strcpy(m_data, other.m_data); // 拷贝内容
}
// ========== ③ 赋值运算符 ==========
// 作用:把一个 String 对象的内容赋给另一个已经存在的 String
// 四步走:一看(自赋值)→ 二清(释放旧)→ 三拷(分配+拷贝)→ 四返(return *this)
String& operator=(const String &other) {
// 第1步:防止自赋值(s = s 这种操作)
if (this == &other) return *this;
// 第2步:释放自己原来持有的旧内存(否则会泄漏)
delete[] m_data; // 注意是 delete[] 不是 delete(因为是 char 数组)
// 第3步:分配新内存并拷贝
m_data = new char[strlen(other.m_data) + 1]; // 新内存
strcpy(m_data, other.m_data); // 拷贝内容
// 第4步:返回 *this(支持链式赋值 a = b = c)
return *this;
}
// ========== ④ 析构函数 ==========
// 作用:对象销毁时释放它持有的堆内存
~String() {
delete[] m_data; // new[] 必须配 delete[]!否则 undefined behavior
}
};
为什么拷贝构造函数必须用引用传参?
// ❌ 错误写法:按值传递(pass-by-value)
String(const String other) { ... }
// 问题:为了把实参传给形参 other,编译器需要拷贝实参
// → 拷贝需要调用拷贝构造函数 → 又要传形参 → 又要拷贝 → 无限递归!
// ✅ 正确写法:按引用传递(pass-by-reference)
String(const String &other) { ... }
// 引用不触发拷贝——只是给原对象起了个别名
new[] 必须配 delete[],new 配 delete
int* a = new int; // 单个 int
delete a; // ✅ 配 delete
int* b = new int[10]; // int 数组
delete[] b; // ✅ 配 delete[](不能漏掉 [])
char* c = new char[100]; // char 数组
delete c; // ❌ 错误!应该用 delete[] c
// 后果:只释放了第一个元素,剩下的 99 个字节泄漏
2.6 第37题 — 浅拷贝导致的内存泄漏 + 双重释放
问题代码:Person 类有指针成员 pFirstName,但没有写拷贝构造和赋值运算符。编译器生成默认版本 → 浅拷贝。
Person Mike("Mike", 25); // Mike.pFirstName 指向 "Mike"
Person John("John", 30); // John.pFirstName 指向 "John"
Mike = John; // ⚠️ 默认赋值运算符 = 浅拷贝!
// 发生了什么:
// 1. Mike.pFirstName 现在指向 "John"(和 John.pFirstName 一样)
// 2. 原来 Mike.pFirstName 指向的 "Mike" 没有被释放 → 内存泄漏!
// 3. 两个对象的 pFirstName 指向同一块内存 "John"
// 4. 析构时两次 delete[] 同一块内存 → 双重释放 → 程序崩溃!
Mike
John
Mike
John
两个 pName 指向同一块内存
析构时 double free → 💥 崩溃
✅ 解决方案(重载赋值运算符)
Person& Person::operator=(const Person& rhs) {
if (this == &rhs) return *this; // 防止自赋值
delete[] pFirstName; // 释放旧内存(解决泄漏)
pFirstName = new char[strlen(rhs.pFirstName) + 1]; // 分配新内存
strcpy(pFirstName, rhs.pFirstName); // 拷贝内容(深拷贝)
return *this;
}
📋 第2章自测
📝 第 3 章 — 选择填空题详解
对应考题:第7~33题 | 每道题含原题、选项、答案、详尽解析
👆 速查卡片 · 下拉看每道题的完整解析 👇
题7 · 下列哪个叙述是正确的(A)
原题:下列哪个叙述是正确的( )
- 构造函数在声明对象的时候被调用
- 构造函数在使用对象的时候被调用
- 构造函数在声明类的时候被调用
- 构造函数在使用类的时候被调用
✅ 答案:A
为什么选 A?
构造函数在创建对象(声明/定义对象)时自动调用。例如 Dog d; 这行代码执行时,构造函数立刻运行。
其他选项为什么错?
- B 错:"使用对象"是指调用它的成员函数或访问成员变量——此时对象早已构造好了。
- C 错:类是编译期的概念,只是一个模板/蓝图。你在写
class Foo { };时不会有构造函数运行。 - D 错:同 C,"使用类"没有意义——你用的是对象,不是类。
题8 · 下列正确声明const函数的是(C)
原题:下列正确声明const函数的是( )
const int ShowData(void) { /* statements */ }int const ShowData(void) { /* statements */ }int ShowData(void) const { /* statements */ }- Both A and B
✅ 答案:C
为什么选 C?
const 成员函数的关键是 const 放在函数签名的最末尾(在参数列表右括号后面)。它承诺这个函数不会修改任何成员变量。
| 写法 | 含义 |
|---|---|
int f() const | ✅ const 成员函数,承诺不修改成员 |
const int f() | ❌ 不是 const 成员函数,是返回值类型为 const int |
int const f() | ❌ 同 A,int const = const int |
题9 · 使用this指针访问数据成员正确的是(A)
原题:下列使用this指针访问类的数据成员中,正确的是( )
this->xthis.x*this.x*this-x
✅ 答案:A
为什么选 A?
this 是指针(不是引用、不是对象),访问成员必须用箭头 ->。
- B 错:
.用于对象/引用访问成员,this是指针不能用.。 - C 错:
.优先级高于*,所以*this.x等价于*(this.x),而this.x本身就是错的。 - D 错:语法错误,毫无意义。
题10 · 哪个运算符不能被重载(C)
原题:下列哪个运算符不能被重载( )
[]->?:*
✅ 答案:C
不可重载的运算符只有 4 个(四大天王):
| 运算符 | 名称 | 为什么不能重载 |
|---|---|---|
. | 成员访问 | 语言核心机制,重载会破坏语义 |
.* | 成员指针访问 | 同上 |
:: | 作用域解析 | 不是运算符,是语法符号 |
?: | 三元条件 | 它是唯一的三元运算符,重载太复杂 |
[]、->、* 都可以重载。
题11 · 关于new运算符说法正确的是(A)
原题:关于new运算符,下面说法正确的是( )
- 返回指向已创建的对象的指针
- 创建名为new的对象
- 获取一个新类的内存
- 告知为对象分配了多少内存
✅ 答案:A
new 在堆上分配内存 + 调用构造函数,然后返回指向这块内存的指针。
int* p = new int(42); // p 是指针,指向堆上值为42的int
Dog* d = new Dog(); // d 是指针,指向堆上新构造的Dog对象
- B 错:
new是运算符,不是对象名。 - C 错:是在堆上分配内存给对象,不是"获取一个新类"。
- D 错:
new不返回分配了多少内存,它只返回指针。
题12 · 拷贝构造函数调用时机(D)
原题:以下哪种情况将执行拷贝构造函数( )
- 当用类的一个对象去初始化该类的另一个对象时
- 将一个对象作为实参传递给一个非引用类型的形参
- 从一个返回类为非引用的函数返回一个对象时
- 以上都是
✅ 答案:D — 三种都会触发
// 场景A:用对象初始化新对象
ClassA b = a; // 拷贝构造
ClassA b(a); // 也是拷贝构造
// 场景B:非引用传参
void func(ClassA obj) { } // 传参时拷贝实参到形参
func(a); // ← 触发拷贝构造
// 场景C:返回非引用对象
ClassA getObj() {
ClassA tmp;
return tmp; // ← 触发拷贝构造(可能被编译器优化RVO)
}
判断技巧:只要新生成一个对象,并且源是同类对象 → 就是拷贝构造。注意区分:ClassA b = a;(b是新对象)是拷贝构造,而 b = a;(b已存在)是赋值。
题13 · 哪种函数不是类的成员函数(C)
原题:下列哪种函数不是类的成员函数( )
- 构造函数
- 析构函数
- 友元函数
- 拷贝构造函数
✅ 答案:C
友元函数不是成员函数。友元函数是在类外部定义的普通函数,只是被"授权"可以访问类的私有成员。它没有 this 指针,不能被对象调用。
class MyClass {
friend void show(MyClass& obj); // 声明为友元,但定义在类外
};
// show 是普通全局函数,不是 MyClass 的成员!
构造函数、析构函数、拷贝构造函数都是类的特殊成员函数。
题14 · inline函数叙述正确的是(D)
原题:下列关于inline函数叙述正确的是( )
- 提高运行速度
- 降低运行速度
- 增加代码大小
- Both A and C
✅ 答案:D — A和C都对
inline 的本质:编译器把函数体代码直接"展开"到调用处,省去函数调用指令(压栈、跳转、返回)的开销。
| ✅ 优点(A) | ❌ 代价(C) |
|---|---|
| 省去函数调用开销 → 提速 | 函数体被复制多份 → 代码膨胀 |
所以是"以空间换时间"。适合短小、频繁调用的函数。
题15 · 抽象类(D)
原题:抽象类( )
- 最多包含一个纯虚函数
- 可以实例化为对象
- 不能有抽象派生类
- 不能被实例化为对象
✅ 答案:D
抽象类 = 包含至少一个纯虚函数的类。因为有未实现的函数,所以不能直接创建对象。
class Shape {
public:
virtual void draw() = 0; // 纯虚函数
};
Shape s; // ❌ 编译错误!抽象类不能实例化
- A 错:抽象类可以有多个纯虚函数,没有上限。
- B 错:不能实例化是抽象类的定义性特征。
- C 错:抽象类派生出来的子类如果不实现所有纯虚函数,它自己也是抽象类——可以有抽象派生类。
题16 · 纯虚函数声明方式(C)
原题:下列声明纯虚函数的正确方式是( )
virtual void Display(void){0};virtual void Display = 0;virtual void Display(void) = 0;void Display(void) = 0;
✅ 答案:C
纯虚函数 = virtual + 函数声明 + = 0
virtual void Display(void) = 0; // ✅ 正确
// ❌ 错误写法分析:
virtual void Display(void){0}; // {0} 是函数体,这不是纯虚函数!
virtual void Display = 0; // 缺少参数列表 ()
void Display(void) = 0; // 缺少 virtual 关键字
记忆:virtual 返回类型 函数名(参数) = 0; — 必须有 virtual,必须有 = 0。
题17 · 构造函数说法正确的是(B)
原题:下面关于类的构造函数的说法正确的是( )
- 一个类只能有一个构造函数
- 不能有返回类型
- 可以为void类型
- 不能使用缺省参数
✅ 答案:B
构造函数没有返回类型——连 void 都不能写!
class Foo {
public:
Foo(); // ✅ 正确
void Foo(); // ❌ 错误!构造函数不能有返回类型
};
// 如果写了 void Foo(),编译器会把它当成普通成员函数!
- A 错:构造函数可以重载,可以有多个(不同参数)。
- C 错:构造函数没有任何返回类型。
- D 错:构造函数可以使用默认参数,如
Foo(int x = 0)。
题18 · 拷贝构造函数如何接收参数(C)
原题:拷贝构造函数如何接收参数( )
- either pass-by-value or pass-by-reference
- only pass-by-value
- only pass-by-reference
- only pass by address
✅ 答案:C — 只能引用传递
为什么不能值传递? 如果拷贝构造函数按值传递:
// ❌ 如果这样写:
String(const String other) { ... } // 按值传递!
// 为了把实参拷贝到形参 other,又需要调用拷贝构造函数
// → 又进入拷贝构造 → 又要拷贝 → 无限递归!
所以 C++ 标准规定:拷贝构造函数的参数必须是 const ClassName&。
为什么不能地址传递(D)? 地址传递就是传指针,String(String* other) 不符合拷贝构造的语法要求,且调用方式会变成 String b(&a) 而非自然的 String b(a)。
题19 · 运行时决定所调用的函数(C)
原题:下列哪个概念意味着在运行时决定所调用的函数( )
- Data hiding
- Dynamic Typing
- Dynamic binding
- Dynamic loading
✅ 答案:C — Dynamic binding(动态绑定)
| 术语 | 中文 | 含义 |
|---|---|---|
| Data hiding | 数据隐藏 | 用 private 封装数据 |
| Dynamic Typing | 动态类型 | 变量类型在运行时确定(如Python) |
| Dynamic binding | 动态绑定 | 运行时决定调用哪个版本的函数(虚函数机制) |
| Dynamic loading | 动态加载 | 运行时加载库/DLL |
题20 · 函数模板定义关键词(C)
原题:所有的函数模板定义都以( )关键词开头
- class
- virtual
- template
- operator
✅ 答案:C — template
template <typename T> // ← 以 template 开头
T max(T a, T b) {
return a > b ? a : b;
}
A 是干扰项:类模板也以 template 开头,class 可以在 <> 里替代 typename —— 但模板定义本身永远以 template 开头。
题21 · 哪种赋值产生编译错误(B)
原题:下列哪种赋值会产生编译错误( )
- 将基类对象的地址赋值给基类指针
- 将基类对象的地址赋值给派生类指针
- 将派生类对象的地址赋值给基类指针
- 将派生类对象的地址赋值给派生类指针
✅ 答案:B
class Base { };
class Derived : public Base { };
Base b; Derived d;
Base* pb = &b; // A✅ 同类指针
Derived* pd = &b; // B❌ 编译错误!基类对象不是派生类
Base* pb2 = &d; // C✅ 向上转型,合法(派生类是基类)
Derived* pd2 = &d; // D✅ 同类指针
原因:基类对象"不完整"——它缺少派生类新增的成员。Derived 指针期望访问的成员在 Base 对象中不存在。
口诀:子类指针不能指向父类对象("你爸不是你"),父类指针可以指向子类对象("你是你爸的孩子")。
题22 · 不能重载的函数(C)
原题:下列函数中,( )不能重载
- 成员函数
- 非成员函数
- 析构函数
- 构造函数
✅ 答案:C — 析构函数不能重载
重载 = 同名不同参。析构函数没有参数,也没有返回类型,所以天然无法重载。每个类只有一个析构函数:~ClassName()。
构造函数可以有多个(不同参数),普通成员函数和非成员函数都可以重载。
题23 · 析构函数正确定义(A)
原题:下面( )选项是对类的析构函数的正确定义
A::~A()void A::~A(参数)A::~A(参数)void A::~A()
✅ 答案:A
析构函数:无返回类型(包括 void)、无参数。
class A {
public:
~A(); // 声明
};
A::~A() { // ✅ 定义:无返回类型,无参数
// 清理代码
}
- B/D 错:析构函数不能有返回类型(void 也不行)。
- C 错:虽然没有返回类型,但析构函数不能有参数。
题24 · 异常处理中捕获异常的代码块(B)
原题:( )封装捕获到异常时所执行的代码
- try块
- catch块
- throw
- exception
✅ 答案:B
| 关键字 | 作用 |
|---|---|
try | 包围可能抛出异常的代码 |
catch | 捕获并处理异常 |
throw | 抛出异常 |
try {
// 可能抛异常的代码 ← try 块
} catch (ExceptionType& e) {
// 捕获后执行的代码 ← catch 块(本题答案)
}
题25 · new失败抛出的异常(B)
原题:new操作失败时抛出的特殊异常是( )
- exception
- bad_alloc
- DivideByZero
- no_exception
✅ 答案:B — bad_alloc
try {
int* p = new int[999999999999]; // 内存不够
} catch (bad_alloc& e) { // ← 捕获 bad_alloc
cout << "内存分配失败!";
}
- A:
exception是所有异常的基类,太泛。 - C:
DivideByZero不是 C++ 标准异常(整数除零是未定义行为)。 - D:C++ 标准库没有
no_exception这个异常。
题26 · 静态数据成员正确的是(A、C)
原题:关于静态数据成员下列正确的是( )(多选)
- 说明静态数据成员时前边要加修饰符static
- 静态数据成员可以在类体内进行初始化
- 在类外引用公有静态的数据成员时,必须在静态数据成员前加类名和作用域运算符
- 静态数据成员不是所有对象所共享的
✅ 答案:A、C
class MyClass {
public:
static int count; // A✅ 声明时加 static
};
int MyClass::count = 0; // C✅ 类外引用:类名::成员
// B❌ 为什么不能在类体内初始化?
// 静态成员属于类,不属于对象,必须在类外定义。
// 例外:const static 整型可以在类内初始化(C++11+)
class Foo {
const static int x = 10; // 特例,可以
};
// D❌ 静态成员是所有对象共享的!这是它的核心特性。
题27 · A &a 的含义(C)
原题:已知类A中一个成员函数说明如下:void Set(A &a); 其中,A &a 的含义是( )
- 定义指向类A的指针为a
- 将a的地址值赋给变量Set
- a是类A的对象引用,用来做函数Set()的参数
- 变量A与a按位相与作为函数Set()的参数
✅ 答案:C
A &a 声明了一个 A 类型的引用,作为函数参数。
void Set(A &a) {
// a 是引用,操作 a 就是操作实参本身
// 不需要 *,不像指针
}
- A 错:
&在类型后面是引用,不是指针。指针是A* a。 - B 错:没有"赋给Set"这回事,Set 是函数名。
- D 错:按位与运算符不会出现在函数参数声明中。
题28 · 默认构造函数是(D)
原题:默认构造函数是( )
- 构造函数的参数均为默认参数
- 当没有提供构造函数时,编译器提供的构造函数
- 不执行任何初始化操作
- A和B
✅ 答案:D — A和B都对
"默认构造函数"有两种形式:
| 形式 | 示例 | 说明 |
|---|---|---|
| 编译器自动生成 | class Foo { }; // 没写构造 | 编译器提供一个无参的 |
| 你写的全默认参数 | Foo(int x = 0) { } | 所有参数有默认值,可无参调用 |
⚠️ 陷阱:只要你自己写了任意一个构造函数(哪怕是有参的),编译器就不再自动提供默认构造函数。此时必须显式写 Foo() { }。
- C 错:默认构造也可以执行初始化,比如默认参数构造可以赋初值。
题29 · 引用与指针的区别(D)
原题:引用与指针的区别( )
- 引用不能为null
- 引用一旦建立不能更改
- 引用无需显式解引用
- 以上全是
✅ 答案:D — 全部正确
| 特性 | 引用 | 指针 |
|---|---|---|
| 可为 null | ❌ 不允许 | ✅ int* p = nullptr; |
| 可改指向 | ❌ 绑定后不能改绑定 | ✅ p = &b; |
| 解引用 | ❌ 自动,直接用 ref | ✅ 需要 *ptr |
int x = 10;
int& r = x; // 引用:r 就是 x 的别名
r = 20; // 等价于 x = 20,无需 *
int* p = &x; // 指针
*p = 30; // 需要 * 解引用
p = &y; // 可以指向别的变量
// r = y; // ❌ 不能改引用绑定!这会把 y 的值赋给 r(即x)
题30 · 显式调用基类函数(C)
原题:应在下列程序划线处填入的正确语句是( )
#include <iostream>
class Base{
public:
void fun(){ cout << "Base::fun" << endl; }
};
class Derived : public Base{
void fun(){
____________ //显式调用基类的函数fun()
cout << "Derived::fun" << endl;
}
};
fun();Base.fun();Base::fun();Base->fun();
✅ 答案:C — Base::fun()
在派生类中显式调用被隐藏的基类函数,用 类名::函数名()。
- A 错:
fun();会调用Derived::fun()自身 → 无限递归。 - B 错:
Base.fun()的.只能用于对象/引用,Base 是类名不是对象。 - D 错:
Base->fun()毫无意义的语法。
题31 · static成员代码追踪(B · 4 3)
原题:以下程序输出为( )(代码有笔误,abc 应为 complex)
class complex {
public:
static int w; // 静态成员:所有对象共享一份
int i; // 普通成员:每个对象独立
complex() { i = ++w; }
};
int complex::w = 1; // 类外初始化,w = 1
int main() {
complex a, b, c; // 连续构造 3 个对象
cout << a.w << " " << b.i << endl;
}
- 3 1
- 4 3
- 4 1
- 3 3
✅ 答案:B — 4 3
逐步追踪:
| 执行 | w 值(共享) | i 值 |
|---|---|---|
complex::w = 1; | 1 | — |
complex a; → w=++w=2 | 2 | a.i = 2 |
complex b; → w=++w=3 | 3 | b.i = 3 |
complex c; → w=++w=4 | 4 | c.i = 4 |
a.w 输出最终值 4(所有对象看到同一个 w),b.i 输出 3。
本质:w 属于类(全局区只有一份),i 属于每个对象(栈上各有各的)。
题32 · 引用返回值(D · 20)
原题:以下程序输出为( )
int z = 15;
int& f() { return z; } // 返回 z 的引用(别名)
int main() {
f() = 20; // 等价于 z = 20
cout << z;
return 0;
}
- 15
- z的地址
- 编译错误
- 20
✅ 答案:D — 20
f() 返回 int&(引用),意味着 f() 就是 z 本身。所以 f() = 20; 等价于 z = 20;。
核心:返回引用的函数调用可以出现在赋值号左边(作为左值)。
如果返回类型是 int(非引用),f() = 20 会编译错误,因为返回值是临时右值。
题33 · 函数模板调用返回值(B · 5 和 5.2)
原题:给定下述函数模板,调用 maximum(2, 5) 和 maximum(2.3, 5.2) 的返回值是( )
template <class T>
T maximum(T value1, T value2) {
if (value1 > value2) return value1;
else return value2;
}
- 5 和 类型不匹配错误
- 5 和 5.2
- 2 和 2.3
- 两条错误信息
✅ 答案:B — 5 和 5.2
模板自动推导:
maximum(2, 5)→ 两个实参都是int→T = int→ 返回较大的5(int)maximum(2.3, 5.2)→ 两个实参都是double→T = double→ 返回较大的5.2(double)
注意:两个调用分别实例化,类型各自匹配,互不干扰。不存在类型不匹配!
但如果写 maximum(2, 5.2) 就会编译错误——两个参数类型不同,T 无法推导。
声明时调用 · 无返回类型 · 可重载 · 可用默认参数
无返回无参数 · 不可重载 · virtual 析构保证安全
只能 const & · 初始化新对象时调用 · 传值/返回也触发
const 在函数签名最后 · 承诺不修改成员
类内声明加 static · 类外定义 · 所有对象共享
. .* :: ?: · 析构函数
含纯虚 = 0 · 不能实例化
try-catch · new 失败抛 bad_alloc
💻 第 4 章 — 编程题实战
对应考题:第3、38、39、40、42、43题 | ⭐⭐⭐⭐
4.1 第3题:输入验证 + 求平方
题目:从键盘输入一个整数,求它的平方。要求做容错处理(检查输入的是否真的是数字)。
解题思路:读入字符串 → 逐字符检查是否全为数字 → 转换 → 计算。不能直接用 cin >> int,因为那不会检查输入是否正确。
#include <iostream> // cin, cout:输入输出
#include <string> // string 类型:存储用户输入
#include <cctype> // isdigit():判断字符是否为数字 '0'~'9'
using namespace std;
int main() {
string input; // 用字符串接收输入(而不是 int)
cout << "请输入一个整数:";
cin >> input; // 用户输入存到 input
// === 检查1:是否为空 ===
if (input.empty()) { // input.empty() 检查字符串是否有内容
cout << "错误:输入为空!" << endl;
return -1; // 非0返回值表示程序异常退出
}
// === 检查2:处理负号 ===
// 如果第一个字符是 '-',说明是负数,从第二个字符开始检查
int start = (input[0] == '-') ? 1 : 0; // 三元运算符:有负号则 start=1
if (start == 1 && input.length() == 1) { // 只有一个 '-' 没有数字
cout << "错误:请输入有效整数!" << endl;
return -1;
}
// === 检查3:逐字符判断是否全为数字 ===
for (int i = start; i < input.length(); i++) {
if (!isdigit(input[i])) { // isdigit() 检查字符是否为 '0'~'9'
cout << "错误:包含非数字字符!" << endl;
return -1; // 发现非数字立即退出
}
}
// === 所有检查通过,安全转换 ===
int num = stoi(input); // stoi: string → int 转换
cout << num << " 的平方是:" << num * num << endl;
return 0; // 0 表示程序正常结束
}
关键知识点
- isdigit(ch):标准函数,判断字符 ch 是不是
'0'到'9'之间的数字字符。需要#include <cctype>。 - stoi(str):C++11 函数,把 string 转成 int。如果字符串不是合法整数会抛异常。
- 为什么先读字符串再检查? 因为
cin >> int遇到非数字会静默失败,不告诉你出了问题。 - empty():string 的成员函数,返回 true 表示字符串长度为 0。
4.2 第38题:数组最大值模板函数
题目:编写求数组最大值的模板函数。
提示:模板函数 = 把类型当作参数,写一次代码,int、char、double 都能用。
template <typename T> // template 关键字 + 类型参数 T(就像一个占位符)
T arrMax(T arr[], int n) { // 返回类型 T、参数类型 T 都由调用者决定
T maxVal = arr[0]; // 假设第一个元素是最大值
for (int i = 1; i < n; i++) { // 从第二个元素开始遍历
if (arr[i] > maxVal) { // 如果当前元素更大
maxVal = arr[i]; // 更新最大值
}
}
return maxVal; // 遍历结束,返回找到的最大值
}
// ===== 调用示例 =====
int main() {
int arr1[] = {10, 20, 15, 12};
int n1 = sizeof(arr1) / sizeof(arr1[0]); // 计算数组长度:总字节/单元素字节
char arr2[] = {1, 2, 3}; // char 也能比较,因为内部转成整数比较
int n2 = sizeof(arr2) / sizeof(arr2[0]);
cout << arrMax<int>(arr1, n1) << endl; // T=int,找出 int 数组中最大的 → 20
cout << arrMax<char>(arr2, n2); // T=char,找出 char 数组中最大的 → 3
return 0;
}
模板关键点
template <typename T>声明这是一个模板,T 是类型占位符。arrMax<int>(arr1, n1)中<int>告诉编译器:把 T 换成 int 来生成一份函数。- 模板函数不是真正的函数,编译器根据调用生成具体版本(叫实例化)。
4.3 第39题:文件读写
题目:编写程序读取 example.txt 的内容并显示。
概念:fstream 是 C++ 用来读写文件的库。有两种流:ofstream(output,写文件)和 ifstream(input,读文件)。
| ofstream(写文件) | ifstream(读文件) | |
|---|---|---|
| 用途 | 把数据输出到文件 | 从文件读取数据 |
| 打开 | ofstream f("文件名") | ifstream f("文件名") |
| 操作 | f << 数据(和 cout 一样) | getline(f, str)(逐行读) |
| 关闭 | f.close() | f.close() |
#include <iostream> // cout 输出到屏幕
#include <fstream> // ifstream, ofstream:文件读写
#include <string> // string 类型
using namespace std;
int main() {
// === 打开文件 ===
ifstream infile("example.txt"); // ifstream: 输入文件流,用于读取
if (!infile.is_open()) { // is_open() 检查文件是否成功打开
cout << "无法打开文件!" << endl; // 文件不存在或没权限
return -1;
}
// === 逐行读取并输出 ===
string line; // 存储每一行内容
while (getline(infile, line)) { // getline: 读一行到 line
cout << line << endl; // 输出到屏幕
} // 当读到文件末尾,getline 返回 false,循环自动结束
// === 关闭文件 ===
infile.close(); // 释放文件资源(好习惯)
return 0;
}
关键知识点
- getline(流, string):从文件中读取一行(遇到换行符停止),自动去掉换行符。
- is_open():返回 true 表示文件成功打开。
- 为什么用 while 循环? getline 读取到文件末尾时会返回"假",循环自动停止——不需要手动判断文件有多长。
- close():虽然对象销毁时会自动关闭,但显式关闭是好习惯。
4.4 第43题:Stack 类模板(栈)
题目:实现一个泛型栈(后进先出,LIFO),支持 push、pop、peek、扩容。
栈 Stack:就像一摞盘子——最后放上去的,最先拿下来。
#include <iostream>
#include <stdexcept> // underflow_error:栈空时抛出的异常类型
using namespace std;
template <typename T> // 模板:T 是栈中存储的元素类型
class Stack {
private:
T* data; // 指向动态分配的数组,存放栈中元素
int top; // 栈顶索引(空栈 = -1,栈非空 = 最上面元素的位置)
int capacity; // 数组当前容量(满了就扩容)
// --- 扩容函数(private,只有 push 内部调用)---
void resize() {
capacity *= 2; // 容量翻倍(*2 是高效策略)
T* newData = new T[capacity]; // 分配更大的新数组
for (int i = 0; i <= top; i++) { // 把旧数据拷贝到新数组
newData[i] = data[i];
}
delete[] data; // 释放旧数组内存(配 new[] 用 delete[])
data = newData; // data 指向新数组
}
public:
// --- 构造函数:初始化空栈 ---
Stack(int cap = 8) // 默认容量 8
: top(-1), capacity(cap) { // 初始化列表:top 从 -1 开始(空栈)
data = new T[capacity]; // 在堆上分配数组内存
}
// --- 析构函数:释放分配的内存 ---
~Stack() {
delete[] data; // new[] 必须配 delete[]
}
// --- 入栈 push:把一个元素放到栈顶 ---
void push(T value) {
if (top + 1 >= capacity) { // 栈满了吗?(top+1 = 当前元素个数)
resize(); // 满了就扩容
}
data[++top] = value; // 先 ++top(向上移),再把 value 放进去
}
// --- 出栈 pop:移除并返回栈顶元素 ---
T pop() {
if (top < 0) { // 栈空 = top == -1
throw underflow_error("Stack is empty"); // 抛异常通知调用者
}
return data[top--]; // 返回 data[top],然后 top--(下移)
}
// --- 查看栈顶 peek:只看不删 ---
T peek() const { // const:承诺不修改栈内容
if (top < 0) { // 同样是空栈检查
throw underflow_error("Stack is empty");
}
return data[top]; // 直接返回,不修改 top
}
// --- 获取当前元素个数 ---
int getSize() const { return top + 1; } // top 是索引,个数 = top + 1
// --- 输出运算符重载:从栈顶到栈底打印 ---
// friend 友元函数:不是成员函数但可以访问私有成员 data、top
friend ostream& operator<<(ostream& os, const Stack<T>& s) {
for (int i = s.top; i >= 0; i--) { // 从栈顶向下遍历
os << s.data[i]; // 输出当前元素
if (i > 0) os << " "; // 元素之间用空格分隔(最后一个不加空格)
}
return os; // 返回 ostream 引用,支持链式调用
}
};
push(42) 前: [ ][ ][ ][ ] top=-1push(42) 后: [42][ ][ ][ ] top=0
pop() 返回值: 42pop() 后: [42][ ][ ][ ] top=-1(值还在但逻辑上已删除)
peek() 返回值:data[top],top 不改变
关键知识点
- top 为什么从 -1 开始? 空栈时没有元素,索引用 -1 表示。push 时先 ++top 变成 0,再放元素。
- new[] 配 delete[]:数组形式必须用带
[]的版本,否则可能内存泄漏。 - 友元函数 friend:声明为友元后,这个函数虽然定义在类外,但可以访问 private 成员。
- throw:当操作非法时,抛出异常比返回特殊值更好,因为调用者无法忽略。
- const 成员函数:
peek() const和getSize() const承诺不修改成员变量。 - 扩容策略:容量 × 2(翻倍)是常用的摊销策略,使得多次 push 的平均时间复杂度保持 O(1)。
⚠️ 第 5 章 — 常见陷阱与易错题
对应考题:第31、32、35、36、37、41题 | ⭐⭐⭐
5.1 野指针(第36题)—— free 后指针不会自动变 NULL
野指针(Dangling Pointer):指针指向的内存已经被释放了,但指针还保存着那个地址。 这时再通过这个指针读写 = 未定义行为,后果不可预测(可能崩溃、可能篡改其他数据)。
#include <cstdlib> // malloc, free:C 语言风格的内存分配/释放
#include <cstring> // strcpy:字符串拷贝
#include <cstdio> // printf:打印输出
int main() {
char *str = (char *)malloc(100); // 在堆上申请 100 字节内存
strcpy(str, "hello"); // 把 "hello" 拷贝到 str 指向的内存中
free(str); // 释放 str 指向的内存(还给操作系统)
// ⚠️ 下面是大坑!free 之后 str 仍然指向原来的地址!
// free() 只释放内存,不修改指针变量的值
if (str != NULL) { // str 不是 NULL!它还是原来的地址值 0x...
strcpy(str, "world"); // 💥 往已释放的内存写数据!非常危险!
printf(str); // 读取已释放的内存,输出什么不可预知
}
// 正确做法:free(str); str = NULL; ← 手动置空
return 0;
}
str = 0x1000
str = 0x1000
指针的值仍是 0x1000
但内存已经不属于这个程序了
核心规则
free(ptr) 或 delete ptr 之后,立即把指针设为 NULL:
free(str);
str = NULL; // 必须手动置空!否则 str 就是野指针
delete ptr;
ptr = nullptr; // C++ 推荐用 nullptr 代替 NULL
5.2 私有成员不能外部访问(第41题)
访问控制:C++ 用 private(私有)、protected(保护)、public(公有)控制谁能访问成员。
这叫做封装——把内部细节藏起来,只暴露必要的接口。
class Person {
private: // private:只有类内部的函数可以访问
double height; // 这个数据对外部是隐藏的
public: // public:任何人都可以访问
void setHeight(double h) { // 通过这个公有函数间接修改 height
height = h;
}
double getHeight() { // 通过这个公有函数间接读取 height
return height;
}
};
int main() {
Person p;
p.height = 1.85; // ❌ 编译错误!height 是 private,外部不能直接访问
p.setHeight(1.85); // ✅ 通过 public 函数设置
cout << p.getHeight(); // ✅ 通过 public 函数读取
return 0;
}
| 修饰符 | 类内部 | 派生类(子类) | 外部(main 等) |
|---|---|---|---|
private | ✅ 可访问 | ❌ 不可 | ❌ 不可 |
protected | ✅ 可访问 | ✅ 可访问 | ❌ 不可 |
public | ✅ 可访问 | ✅ 可访问 | ✅ 可访问 |
记忆:private 是秘密(只有自己知道),protected 是家传(儿子也知道),public 是公开的(谁都知道)。
5.3 异常处理(第35题)—— try / throw / catch
异常处理:当程序遇到意外情况(如除零、内存不足)时, 不直接崩溃,而是"抛出"一个异常,让专门的代码去"捕获"和处理它。
class DivideZero { // 自定义异常类
public:
DivideZero() : out("EXCEPTION: Division by zero attempted.") {}
string display() const { return out; } // 返回错误信息
private:
string out;
};
double arithmetic(int n, int d) {
if (d == 0)
throw DivideZero(); // 💥 分母为0,抛出异常(函数直接返回)
return static_cast<double>(n) / d; // 安全时正常计算
}
int main() {
try { // try:监控这段代码
cout << arithmetic(24, 6) << endl; // d=6 ≠0 → 正常 → 输出 4
cout << arithmetic(1, 3) << endl; // d=3 ≠0 → 正常 → 输出 0.333...
cout << arithmetic(9, 0.0) << endl; // d=0 → 抛出 DivideZero 异常!
// ⚠️ 抛异常后,下面这行不会执行!
cout << "这行不会输出" << endl;
}
catch (DivideZero &e) { // catch:捕获 DivideZero 类型的异常
cout << e.display() << endl; // 打印错误信息
}
// catch 执行完后,程序继续从这里往下走
return 0;
}
// 最终输出:
// 4
// 0.333333
// EXCEPTION: Division by zero attempted.
三关键字分工
| 关键字 | 通俗理解 | 代码位置 |
|---|---|---|
try | "这一片区域可能出事,我看着" | 包围可能出错的代码 |
throw | "出事了!具体情况是这个对象" | 检测到错误的地方 |
catch | "我专门处理这类问题" | 紧跟在 try 后面 |
关键:throw 之后,当前函数立即退出,程序沿着调用栈往上找最近的 catch。try 块里 throw 之后的代码不会执行。
5.4 static 成员(第31题)—— 所有对象共享一份
static 成员:属于类本身,不属于任何一个对象。 不管创建多少个对象,static 成员在内存中只有一份,所有对象共享。
class complex {
public:
static int w; // static 成员:所有对象共享(类内声明)
int i; // 普通成员:每个对象独立一份
complex() {
i = ++w; // 构造时:w 先自增,再把新值赋给 i
}
};
int complex::w = 1; // static 成员必须在类外定义+初始化
int main() {
complex a, b, c; // 连续创建三个对象
// a 构造: w 从 1 变成 2 → a.i = 2
// b 构造: w 从 2 变成 3 → b.i = 3
// c 构造: w 从 3 变成 4 → c.i = 4
cout << a.w << " " << b.i << endl;
// a.w 输出 4(所有对象读到同一个 w,最终值是 4)
// b.i 输出 3(b 自己的 i 在构造时赋的值)
return 0;
}
全局区(类级别)
对象 a
对象 b
对象 c
三个对象共享同一个 w(在全局区),但各有各的 i(在各自的内存空间)
5.5 引用作为返回值(第32题)—— 函数调用可以出现在 = 左边
引用返回 vs 值返回:值返回是"给你一个副本",引用返回是"给你原件本身"。 因为返回的是原件,所以可以赋值。
int z = 15; // 全局变量
int& f() { // 返回类型是 int&(int 的引用,即 int 的别名)
return z; // 返回 z 的引用,不是 z 的副本
}
int main() {
f() = 20; // 等价于 z = 20!因为 f() 返回的就是 z 本身
cout << z; // 输出 20(z 已经被改了)
return 0;
}
对比
| 返回 int | 返回 int& | |
|---|---|---|
| 返回的是什么 | z 的副本(临时值) | z 本身(别名) |
f() = 20; 能编译吗 | ❌ 不能(临时值是右值,不能放 = 左边) | ✅ 能(引用是左值) |
记忆:引用返回 = 函数调用可以当变量用(可读可写)。
🏆 终极自测
下面代码犯了 5 个错误,你能全部找出来吗?(对应第37题和第42题的考点)
class Resource {
char* buffer; // 指针成员:管理堆内存
public:
Resource(const char* str) {
buffer = new char[strlen(str)]; // ❌ 问题1: 没有 +1,'\0' 会溢出
strcpy(buffer, str); // 拷贝字符串到 buffer
}
~Resource() { delete buffer; } // ❌ 问题2: new[] 应配 delete[]
// ❌ 问题3/4/5: 没有写拷贝构造和赋值运算符 = 违反 Rule of Three
};
int main() {
Resource a("hello"); // 构造 a
Resource b = a; // ❌ 问题3: 默认拷贝构造 → 浅拷贝
b = a; // ❌ 问题4: 默认赋值运算 → 内存泄漏
// ❌ 问题5: 以上导致析构时双重释放
}