异常与IO
异常
错误
- 语法错误
- 编译系统
- 逻辑错误
- 测试
异常 Exception
- 运行环境造成
- 内存不足、文件操作失败等
- 异常处理
- 可以预见
- 无法避免
- 提高程序鲁棒性
核心机制:Try-Throw-Catch
C++ 异常处理主要由三个关键字组成:
1. try (监控)
-
圈定一个范围,在这个范围内运行的代码如果出现问题,会被捕获。
try { // 可能抛出异常的语句序列 f(); }
2. throw (抛出)
- 动作:当底层检测到错误时,创建一个异常对象并抛出。
- 语法:
throw <表达式>; - 特例:无参数的
throw;表示将当前捕获的异常原样重新抛出(用于部分处理后交给更上层处理)。
3. catch (捕获)
- 动作:捕获特定类型的异常并处理。
- 语法:
catch (<类型> [<变量>]) { ... } - 匹配规则:类似函数重载,根据
throw抛出的数据类型来匹配对应的catch块。throw 1;-> 匹配catch(int)throw "error";-> 匹配catch(char*)
**异常的传播与匹配规则 **
- 栈展开与多层传播 (Stack Unwinding)
- 调用链:假设
f() -> g() -> h()。 - 传播机制:
- 如果
h()抛出异常,先看h内部有没有catch。 - 没有则跳出
h,回到g()寻找catch。 - 还找不到则回到
f()。
- 如果
- 终极后果:如果异常一直传播到最顶层(如
main)仍未被捕获,系统会调用abort()强制终止程序。
- 多态与 Catch 顺序
当异常类存在继承关系时:
- Catch Exceptions by Reference:
- 原则:始终建议使用引用捕获异常(例如
catch(FileErrors& e))。 - 原因:避免对象切割(Slicing)并减少拷贝开销。
- 原则:始终建议使用引用捕获异常(例如
- Catch 块的排列顺序:
- 原则:子类(具体错误)在上,父类(通用错误)在下。
- 原因:
catch是按顺序匹配的。如果基类FileErrors写在最前面,它会捕获所有派生类异常,导致后面的NonExist永远无法执行。- 错误/警告写法:基类
catch (FileErrors&)放在第一位。 - 正确写法:基类
catch (FileErrors&)放在最后一位,作为兜底。
- 错误/警告写法:基类
特殊技巧
catch(...)- 捕获所有异常
- 语法:
catch(...) { ... } - 作用:相当于“默认处理程序”(Default Handler),可以捕获任何类型的异常。通常放在所有
catch块的最后,防止程序因未捕获异常而崩溃。
- 异常重抛 (Re-throw)
-
场景:在当前层级只能处理异常的一部分(比如记录日志),剩下的需要交给上层处理。
-
写法:
catch (int) { // 记录日志... throw; // 不带参数,将刚才捕获的异常原封不动抛给上一级 }
- 调试与断言
Assert模板函数:- 利用
throw实现断言机制:if (!exp) throw e; - 这种方式不影响对象布局,且将程序状态检查与异常处理器结合。
- 利用
总结代码示例
// 1. 定义异常类体系
class FileErrors {};
class NonExist : public FileErrors {};
class WrongFormat : public FileErrors {};
void function_h() {
// 2. 发现错误,抛出具体异常
throw NonExist();
}
void function_g() {
function_h(); // g 未捕获,异常继续向上传播
}
void main() {
try {
function_g();
}
// 3. 注意 Catch 顺序:先子类,后父类
catch (NonExist& e) {
// 处理文件不存在
}
catch (WrongFormat& e) {
// 处理格式错误
}
catch (FileErrors& e) {
// 处理其他所有文件错误(兜底)
}
catch (...) {
// 4. 捕获所有其他未知异常,防止 Crash
std::cout << "Unknown error occurred";
}
}
I/O处理
- 基于函数库的I/O
- 基于类库的I/O
重定向:利用 streambuf 和 rdbuf() 函数,可以将 cin/cout 重定向到文件,或者恢复回来。
// 1. 打开一个输入文件流,准备读取 "in.txt"
ifstream in("in.txt");
// 2. 保存旧的缓冲区:获取当前 cin (标准输入,通常是键盘) 的缓冲区指针,存起来以便稍后恢复
streambuf *cinbuf = cin.rdbuf(); //save old buf
// 3. 重定向输入:将 cin 的缓冲区设置为 in 的缓冲区
// 这意味着以后使用 cin 读取数据时,实际是从 "in.txt" 文件中读取
cin.rdbuf(in.rdbuf()); //redirect cin to in.txt!
// 4. 打开一个输出文件流,准备写入 "out.txt"
ofstream out("out.txt");
// 5. 保存旧的缓冲区:获取当前 cout (标准输出,通常是屏幕) 的缓冲区指针,存起来以便稍后恢复
streambuf *coutbuf = cout.rdbuf(); //save old buf
// 6. 重定向输出:将 cout 的缓冲区设置为 out 的缓冲区
// 这意味着以后使用 cout 输出数据时,实际是写入到 "out.txt" 文件中
cout.rdbuf(out.rdbuf()); //redirect cout to out.txt!
// 7. 实际操作:定义字符串变量 word,并用 cin 读取
// 由于发生了重定向,这里实际上是从文件 "in.txt" 中读取了一个单词
string word; cin >> word; //input from the file in.txt
// 8. 实际操作:用 cout 输出
// 由于发生了重定向,这里实际上是将 word 的内容写入到了文件 "out.txt" 中
cout << word << " "; //output to the file out.txt
// 9. 恢复输入:将 cin 的缓冲区设置回原来的标准输入缓冲区 (即 cinbuf)
// 之后的 cin 操作将变回从键盘读取
cin.rdbuf(cinbuf); //reset to standard input again
// 10. 恢复输出:将 cout 的缓冲区设置回原来的标准输出缓冲区 (即 coutbuf)
// 之后的 cout 操作将变回输出到屏幕
cout.rdbuf(coutbuf); //reset to standard output again
// 11. 验证恢复:再次用 cin 读取
// 此时是从键盘(标准输入)等待用户输入
cin >> word; //input from the standard input
// 12. 验证恢复:再次用 cout 输出
// 此时是将内容显示在屏幕上(标准输出)
// (注:PPT原文注释写的是 standard input,实际上应为 standard output)
cout << word; //output to the standard input
-
操作符重载:
-
operator<<通常重载为全局友元函数,以便支持cout << obj的写法。// 定义一个二维点类 class CPoint2D { double x, y; // 私有成员变量 x 和 y public: // 声明友元函数,允许该全局函数访问 CPoint2D 的私有成员 (x, y) // 这是一个全局函数重载,不是成员函数 friend ostream& operator << (ostream&, CPoint2D &); }; // 实现 CPoint2D 的输出流重载 ostream& operator << (ostream& out, CPoint2D& a) { // 将对象的 x 和 y 坐标写入输出流 out << a.x << "," << a.y << endl; // 返回流对象引用,支持链式调用 (如 cout << a << b;) return out; } // ... 使用示例 ... // CPoint2D a; // cout << a; // 调用上面的函数,输出 x,y // 定义三维点类,继承自 CPoint2D class CPoint3D: public CPoint2D { double z; // 新增私有成员 z // ... (PPT省略了部分代码) ... // 如果没有为 CPoint3D 专门重载 operator<<, // 下面的友元声明是解决问题所需的(对应PPT右下角的实现) friend ostream& operator << (ostream &, CPoint3D &); }; // ... 问题场景 ... // CPoint3D b; // cout << b; // 现象:只显示 b.x 和 b.y,而没显示 b.z // 原因:如果没有专门为 CPoint3D 重载 operator<<,编译器会向上转型, // 匹配到基类 CPoint2D 的 operator<<,导致 z 成员被忽略。 // 专门为 CPoint3D 重载输出流运算符 ostream& operator << (ostream& out, CPoint3D & b) { // 这里意图是打印所有成员,包括父类的 x,y 和自己的 z // 注意:在实际代码中,访问 b.x 和 b.y 需要它们在父类中是 protected // 或者通过父类的 getter 方法,或者父类将此函数也设为友元。 // 这里的逻辑是演示打印所有坐标: out << b.x << "," << b.y << "," << b.z << endl; return out; } // 上述解决方法需要b.x b.y 是protected 不是一个好的解决方法 // 而 << 不可能使用虚函数 所以只能想别的方法 如下-
在基类和子类中定义虚函数
display:class CPoint2D { // ... public: // 定义虚函数,负责实际的打印逻辑 virtual void display(ostream& out) { out << x << "," << y; } }; class CPoint3D : public CPoint2D { // ... public: // 重写虚函数 void display(ostream& out) override { CPoint2D::display(out); // 先打印基类部分 out << "," << z; // 再打印自己部分 } }; -
全局
operator<<只写一份(针对基类):// 只需要这一个全局函数!不需要为 CPoint3D 再写一个 operator<< ostream& operator << (ostream& out, CPoint2D& a) { a.display(out); // 核心!利用多态调用实际类型的 display return out; }
这样写的好处:
CPoint3D p3d(1, 2, 3); CPoint2D* p = &p3d; cout << *p << endl; // 1. 调用 operator<<(..., CPoint2D&) // 2. 内部调用 p->display() // 3. 由于 display 是虚函数,会找到 CPoint3D::display // 4. 正确输出:1, 2, 3 -
-
虚函数与多态的特殊应用
Never treat arrays polymorphically (永远不要多态地处理数组)
核心结论: 永远不要把派生类的数组传给接受基类数组的函数。
// 基类
class BST { ... };
// 派生类,通常包含更多数据,所以 sizeof(BalancedBST) > sizeof(BST)
class BalancedBST: public BST { ... };
// 一个函数,意图是打印数组中的每个元素
// 参数声明为 const BST array[],实际上等同于 const BST* array
void printBSTArray(ostream& s, const BST array[], int numElements)
{
// 问题出在这里:array[i]
for (int i=0; i < numElements; i++)
s << array[i];
}
// 调用代码
BalancedBST bBSTArray[10]; // 创建派生类数组
printBSTArray(cout, bBSTArray, 10); // 传入派生类数组 -> 灾难发生
为什么会出错?(底层原理)
这是一个关于 指针算术 (Pointer Arithmetic) 的问题。
在 C++ 中,array[i] 仅仅是 *(array + i) 的语法糖。编译器为了计算 array[i] 的内存地址,执行的逻辑是:
\[\text{地址} = \text{首地址} + (i \times \text{sizeof(元素类型)})\]- 函数内部视角:
printBSTArray认为传入的是BST类型,所以它计算array[1]的地址时,增加的偏移量是sizeof(BST)。 - 实际内存视角: 你传入的是
BalancedBST数组。每个元素的实际大小是sizeof(BalancedBST)。通常派生类比基类大(因为有额外成员)。
结果: 当 i=1 时,函数访问的地址并没有指向第二个对象的开头,而是指向了第一个对象的中间(或者第二个对象的中间,取决于大小差异)。这会导致读取垃圾数据,或者直接程序崩溃(Crash)。
虚构造函数 (Virtual Constructor):
- 问题:C++ 没有“虚构造函数”,无法直接通过基类指针“拷贝”一个不知道具体类型的子类对象。
- 解法:Prototype 模式 / Clone 模式。在基类定义纯虚函数
clone(),子类实现它并返回new CurrentClass(*this)。
Virtualizing constructors (怎么拷贝多态列表?)
核心问题: 我们有一个存着基类指针的链表(实际上指向各种派生类对象),当我们想拷贝这个链表时,怎么深拷贝出正确的子类对象?
class NLComponent { ... }; // 基类
class TextBlock : public NLComponent { ... }; // 子类 A
class Graphic : public NLComponent { ... }; // 子类 B
class NewsLetter {
// 这是一个多态容器,里面混杂着 TextBlock* 和 Graphic*
list<NLComponent *> components;
public:
// 拷贝构造函数
NewsLetter(const NewsLetter& rhs)
{
// 遍历右侧对象的组件列表
for (list<NLComponent *>::iterator it = rhs.component.begin();
it != rhs.component.end(); ++it)
{
// 问题来了:
// 我们手里只有一个基类指针 (*it),我们不知道它具体指向的是 TextBlock 还是 Graphic。
// 既然不知道具体类型,我们该 new 谁呢?
components.push_back( ??? ); // 填 new TextBlock? 还是 new Graphic?
}
}
};
困难点
构造函数不能是虚函数(Virtual)。你必须在创建对象之前就知道具体的类型。但在拷贝 NewsLetter 时,你只看得到 NLComponent*,编译器无法自动帮你判断应该创建哪个子类的副本。
Virtualizing constructors (原型模式)
核心结论: 使用 clone() 方法来模拟“虚构造函数”。这是设计模式中的 原型模式 (Prototype Pattern)。
// 1. 基类定义纯虚函数 clone
virtual NLComponent * clone() const = 0;
// 2. 派生类 TextBlock 实现 clone
virtual TextBlock * clone() const
{
// 调用自己的拷贝构造函数,复制当前对象 (*this)
return new TextBlock(*this);
}
// 3. 派生类 Graphic 实现 clone
virtual Graphic * clone() const
{
// 调用自己的拷贝构造函数
return new Graphic(*this);
}
// 4. NewsLetter 的拷贝构造函数现在可以工作了
NewsLetter::NewsLetter(const NewsLetter& rhs)
{
for (list<NLComponent *>::iterator it = rhs.component.begin();
it != rhs.component.end(); ++it)
{
// 关键点:
// (*it) 是一个指针,调用 ->clone()。
// 由于 clone 是虚函数,多态机制会确保调用的是实际对象(如 TextBlock)的 clone 方法。
// TextBlock::clone 会 new 一个新的 TextBlock 返回给我们。
component.push_back((*it)->clone());
}
}
原理解析
- 多态分发: 当你调用
(*it)->clone()时,如果*it指向的是TextBlock对象,C++ 运行时会自动调用TextBlock::clone()。 - 自我复制: 在
TextBlock::clone()内部,代码是new TextBlock(*this)。这利用了标准的拷贝构造函数,把当前对象的所有数据复制到一块新内存中,并返回指针。 - 结果:
NewsLetter成功获得了一个全新的、类型正确的对象副本,而不需要知道对象的具体类型。