异常与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*)

**异常的传播与匹配规则 **

  1. 栈展开与多层传播 (Stack Unwinding)
  • 调用链:假设 f() -> g() -> h()
  • 传播机制
    • 如果 h() 抛出异常,先看 h 内部有没有 catch
    • 没有则跳出 h,回到 g() 寻找 catch
    • 还找不到则回到 f()
  • 终极后果:如果异常一直传播到最顶层(如 main)仍未被捕获,系统会调用 abort() 强制终止程序。
  1. 多态与 Catch 顺序

当异常类存在继承关系时:

  • Catch Exceptions by Reference
    • 原则:始终建议使用引用捕获异常(例如 catch(FileErrors& e))。
    • 原因:避免对象切割(Slicing)并减少拷贝开销。
  • Catch 块的排列顺序
    • 原则子类(具体错误)在上,父类(通用错误)在下
    • 原因catch 是按顺序匹配的。如果基类 FileErrors 写在最前面,它会捕获所有派生类异常,导致后面的 NonExist 永远无法执行。
      • 错误/警告写法:基类 catch (FileErrors&) 放在第一位。
      • 正确写法:基类 catch (FileErrors&) 放在最后一位,作为兜底。

特殊技巧

  1. catch(...) - 捕获所有异常
  • 语法catch(...) { ... }
  • 作用:相当于“默认处理程序”(Default Handler),可以捕获任何类型的异常。通常放在所有 catch 块的最后,防止程序因未捕获异常而崩溃。
  1. 异常重抛 (Re-throw)
  • 场景:在当前层级只能处理异常的一部分(比如记录日志),剩下的需要交给上层处理。

  • 写法

    catch (int) {
        // 记录日志...
        throw; // 不带参数,将刚才捕获的异常原封不动抛给上一级
    }
    
  1. 调试与断言
  • 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

重定向:利用 streambufrdbuf() 函数,可以将 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 不是一个好的解决方法
      // 而 << 不可能使用虚函数 所以只能想别的方法 如下
      
      1. 在基类和子类中定义虚函数 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;        // 再打印自己部分
            }
        };
        
      2. 全局 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());
    }
}

原理解析

  1. 多态分发: 当你调用 (*it)->clone() 时,如果 *it 指向的是 TextBlock 对象,C++ 运行时会自动调用 TextBlock::clone()
  2. 自我复制:TextBlock::clone() 内部,代码是 new TextBlock(*this)。这利用了标准的拷贝构造函数,把当前对象的所有数据复制到一块新内存中,并返回指针。
  3. 结果: NewsLetter 成功获得了一个全新的、类型正确的对象副本,而不需要知道对象的具体类型。