跳转到内容

使用 C 和 C++ 的编程语言概念/C++ 中的异常处理

来自 Wikibooks,开放世界的开放书籍

类似于 Java,C++ 中的异常通常(并非总是!)是类类型的对象。也就是说,除了返回特定类型的返回值之外,函数还可以返回异常对象。但是,也可以抛出原始类型的异常对象。以下是一个不常见情况的示例。

示例:抛出非对象类型的异常。

enum ERESULT { arg_neg = -1, arg_toobig = -2}; long fact(short n) { if (n < 0) throw arg_neg; if (n > MAX_ARG) throw arg_toobig; if (n == 0 || n == 1) return 1; else return (n * fact(n 1)); } // end of long fact(short)

C++ 中异常的其他特性与它们被指定和处理的方式有关。除了通过异常规范列出从函数抛出的异常的精确列表之外,还可以选择删除规范并获得抛出任何异常的自由。

示例:异常规范。

// f1 可以抛出类型为 E1 和 E2 的异常 void f1(...) throw(E1, E2); // f2 根本不抛出任何异常 void f2(...) throw(); // f3 可以抛出任何类型的异常。这可能是项目初始阶段的一个不错的选择。 void f3(...);

如果我们显式列出从函数抛出的异常,并且事实证明抛出了一个意外异常(即规范中未列出的异常)并且未在函数调用链中处理,则会调用在 C++ 标准库中定义的 unexpected()。换句话说,规范违规的检测在运行时进行。如果控制流从未到达抛出意外异常的点,程序将运行而不会出现问题。

与它的设计理念一致,C++ 并不强制要求具有抛出异常潜力的语句必须在 try 块中发出。类似于从 Java 中的 RuntimeException 派生的异常,C++ 异常不需要被保护。如果我们能够弄清楚异常永远不会发生,我们可以删除 try-catch 关键字并获得更简洁的代码。

示例:没有强制的 try-catch 块。

Rational divide_by_five(double x) { Rational rat1 = Rational(x); Rational nonzero_rat = Rational(5); Rational ret_rat = rat1.divide(nonzero_rat); return ret_rat; } // end of Rational divide_by_five(double)

异常类

[编辑 | 编辑源代码]
Queue_Exceptions
#ifndef QUEUE_EXCEPTIONS_HXX
#define QUEUE_EXCEPTIONS_HXX

#include <iostream>
using namespace std;

namespace CSE224 {
  namespace DS {
    namespace Exceptions {

请注意,我们的异常类没有任何成员字段。换句话说,我们没有办法识别情况的详细信息。我们只知道出了问题,仅此而已!虽然在我们的例子中我们不需要任何关于问题性质的细节,但这并不总是这样。

例如,以阶乘为例。我们可能希望传递导致异常情况的参数值。这等效于说我们想区分同一个类的异常对象。实际上,我们可以将问题表述为区分同一个类的对象,无论是异常对象类还是其他任何类。我们可以通过简单地向类定义添加字段来实现这一点。

Fact_Exceptions

class Negative_Arg { public: void error(void) { cerr << "负参数 " << _arg_value << endl; } // end of void error(void) Negative_Arg(short arg) { _arg_value = arg; } private: short _arg_value; } // end of class Negative_Arg class TooBig_Arg { ... }

Factorial.cxx

... long fact(short n) throw(Negative_Arg, TooBig_Arg) { if (n < 0) throw Negative_Arg(n); if (n > MAX_ARG) throw TooBig_Arg(n); if (n == 0 || n == 1) return 1; else return(n * fact(n 1)); } // end of long fact(short) throw(Negative_Arg, TooBig_Arg) ...

      class Queue_Empty {        
      public:

请注意,我们类的唯一函数被声明为 static,这意味着我们可以通过类作用域运算符调用它,而无需创建类的实例。类似地,可以定义 static 数据字段,这些字段由类的所有实例共享,并且没有义务通过类的对象访问这些字段。

        static void error(void) { cerr << "Queue Empty!!!" << endl; }
      }; // end of class Queue_Empty
    } // end of namespace Exceptions
  } // end of namespace DS
} // end of namespace CSE224

#endif
队列
#ifndef QUEUE_HXX
#define QUEUE_HXX

#include <iostream>
using namespace std;

#include "ds/exceptions/Queue_Exceptions"
using namespace CSE224::DS::Exceptions;

namespace CSE224 {
  namespace DS {

以下是前向类声明。它的目的与 C 中的前向声明类似:我们声明了要使用名为 Queue_Node 的类的意图,并将其定义推迟到其他地方。

请注意,我们不能声明这种类型的对象。这是因为 C++ 不允许您声明变量为其定义尚未完成的类型。因为编译器无法确定对象所需的内存量。但是,我们可以声明变量为指向这种类的指针或引用——理解为“常量指针”。

    class Queue_Node;
    class Queue {

在某些情况下,允许某个函数/类访问类的非公开成员,而不允许程序中的其他函数/类访问,这很方便。C++ 中的friend 机制允许类授予函数/类对其非public 成员的自由访问权。

friend 声明以关键字 friend 开头。它只能出现在类定义中。换句话说,是类声明某个函数/类为其 friend,而不是反过来。也就是说,您不能简单地将某个类声明为您的 friend 并访问它的字段。

由于 friend 不是授予友谊的类的成员,因此它们不受 publicprotectedprivate 部分的影响,它们在类体中声明。也就是说,friend 声明可以在类定义中的任何地方出现。

根据以下声明,重载的移位运算符 (<<) 可以自由访问 Queue 对象的内部,该对象的引用作为第二个参数传递,就像它们是 public 一样。

如果我们选择将移位运算符设为实例函数,我们就无法实现我们的目标。以以下示例为例

cout << q1 << q2;

此语句将首先将 q1 打印到标准输出文件,然后打印 q2。我们可以通过以下语句达到相同的效果。

cout << q1; cout << q2;

事实上,这就是幕后发生的事情。我们可以通过应用以下转换来了解发生了什么。

cout << q1 << q2; cout.operator<<(q1).operator<<(q2); x.operator<<(q2);

移位消息发送了两次:一次发送到名为 cout 的对象,一次发送到第一次调用相应函数 (x) 返回的对象。这意味着我们需要一个函数签名,其中返回值和第一个参数类型相同:ostreamostream&。了解实例函数以指向正在定义的类的实例的指针作为其隐式第一个参数,我们得出结论,移位运算符不能是 Queue 类的实例函数。解决方法是提供 friend 声明,如下所示。

      friend ostream& operator<<(ostream&, const Queue&);
    public:
      Queue(void) : _front(NULL), _rear(NULL), _size(0) { }
      Queue(const Queue&);
      ~Queue(void);
      Queue& operator=(const Queue&);
      bool operator==(const Queue&);

请注意异常说明中使用的类型。第一个函数可以异常返回,抛出 Queue_Empty 对象,而第二个函数将返回指向此类对象的指针。这并不奇怪。与仅在堆中创建对象的 Java 不同,C++ 允许您在所有三个区域创建对象——即堆、运行时堆栈和静态数据区域。由于异常对象本质上是 C++ 对象,因此您可以在您喜欢的任何数据区域中创建它。

只要相应地声明异常处理程序,以下异常说明之间几乎没有区别。[1] 第一个的处理程序将期望一个对象,而第二个将期望一个指向堆中某个区域的指针。[2]

      double peek(void) throw(Queue_Empty);
      double remove(void) throw(Queue_Empty*);
      void insert(double);
      bool empty(void);
    private:
      Queue_Node *_front, *_rear;
      unsigned int _size;
    }; // end of class Queue

请注意,以下类定义的所有字段都是 private。也没有函数来操作对象。因此,看起来我们需要一些魔法来创建和操作该类的对象。答案在于 Queue_NodeQueue 的关系:Queue_NodeQueue 密切耦合。一个 Queue_Node 对象只能在 Queue 对象的上下文中存在。friend 声明反映了这一事实。由于此声明,我们可以 [间接] 通过对某个 Queue 对象的操作来操作一个 Queue_Node 对象。

    class Queue_Node {
      friend class Queue;

下一条语句声明移位运算符是 Queue_Node 类的 friend。在 Queue 类中也做出了类似的声明,这意味着一个函数将有权查看两个不同类的内部。

      friend ostream& operator<<(ostream&, const Queue&);
    private:
      double _item;
        Queue_Node *_next;
        Queue_Node(double val = 0) : _item(val), _next(NULL) { }
    }; // end of class Queue_Node
  } // end of namespace DS
} // end of namespace CSE224

#endif
Queue.cxx
#include <iomanip>
#include <iostream>
using namespace std;

#include "ds/Queue"
#include "ds/exceptions/Queue_Exceptions"
using namespace CSE224::DS::Exceptions;

namespace CSE224 {
  namespace DS {
    Queue::
    Queue(const Queue& rhs) : _front(NULL), _rear(NULL), _size(0) {
      Queue_Node *ptr = rhs._front;
      for(unsigned int i = 0; i < rhs._size; i++) { 
        this->insert(ptr->_item);
        ptr = ptr->_next;
      } // end of for(unsigned int i = 0; i < rhs._size; i++)
    } // end of copy constructor

我们的析构函数由程序员隐式调用(通过 delete 在释放堆对象时)或由编译器合成的代码调用(在释放静态和运行时堆栈对象的过程中),它删除队列中的所有节点,然后继续清理为字段预留的空间。如果我们忘记删除这些项目,我们将最终得到下面给出的图片,这实际上与没有析构函数的情况下得到的图片相同。

Creating garbage in the heap memory

请注意,阴影区域表示由 delete 运算符本身返回给分配器的内存,而不是析构函数。[3] 只能通过阴影区域中的字段访问的所有队列节点现在都变成了垃圾。因此,在删除队列时,我们必须删除所有队列项目,这正是我们在析构函数体中所做的。

另请注意,我们没有在 try-catch 块中编写代码。与 Java 不同,这对 C++ 来说是可以的;如果您认为它们永远不会发生,可以选择省略 try-catch 块。在这种情况下,删除的次数保证与队列中的项目数量一样多,这不会产生任何异常情况。

    Queue::
    ~Queue(void) {
      unsigned int size = _size;
      for(unsigned int i = 0; i < size; i++) remove();
    } // end of destructor

    Queue& Queue::
    operator=(const Queue& rhs) {
      if (this == &rhs) return (*this);

      for(unsigned int i = _size; i > 0; i--) remove();

      Queue_Node *ptr = rhs._front;
      for(unsigned int i = 0; i < rhs._size; i++) { 
        this->insert(ptr->_item);
        ptr = ptr->_next;
      } // end of for(unsigned int i = 0; i < rhs._size; i++)

      if (rhs._size == 0) { 
        _front = _rear = NULL;
        _size = 0;
        return(*this);
      } // end of if(rhs._size == 0)

      return (*this);
    } // end of assignment operator

    bool Queue::
    operator==(const Queue& rhs) {
      if (_size != rhs._size) return false;
      if (_size == 0 || this == &rhs) return true;

      Queue_Node *ptr = _front;
      Queue_Node *ptr_rhs = rhs._front;

      for (unsigned int i = 0; i < _size; i++) {
        if (ptr->_item != ptr_rhs->_item) 
          return false;
        ptr = ptr->_next;
        ptr_rhs = ptr_rhs->_next;
      } // end of for(unsigned int i = 0; i < _size; i++)

      return true;
    } // end of equality-test operator

    double Queue::
    peek(void) throw(Queue_Empty) {
      if (empty()) throw Queue_Empty();

      return(_front->_item);
    } // end of double Queue::peek(void)

    double Queue::
    remove(void) throw(Queue_Empty*) {
      if (empty()) throw new Queue_Empty();

      double ret_val = _front->_item;
      Queue_Node *temp_node = _front;

      if (_front == _rear) _front = _rear = NULL;
        else _front = _front->_next;

      delete temp_node;
      _size--;

      return ret_val;
    } // end of double Queue::remove(void)

    void Queue::
    insert(double value) {
      Queue_Node *new_node = new Queue_Node(value);

      if (empty()) {
        _front = _rear = new_node;
        _size = 1;
        return;
      } // end of if (empty())
 
      _rear->_next = new_node;
      _rear = _rear->_next;
      _size++;
    } // end of void Queue::insert(double)

    bool Queue::
    empty(void) { return (_size == 0); }

以下输出运算符定义同时使用了 QueueQueue_Node 类。它首先通过使用 Queue 类的私有字段打印队列的长度,然后通过遍历每个节点(它们是 Queue_Node 类型)输出相应队列的内容。为此,我们必须使此函数成为两个类的 friend。

    ostream& operator<<(ostream& os, const Queue& rhs) {
      os << "( " << rhs._size << " )";

      if (rhs._size == 0) {
        os << endl;
        return(os);
      } // end of if (rhs._size == 0)

      os << "(front: ";
      Queue_Node *iter = rhs._front;
      while(iter != NULL) {
        os << iter->_item << " ";
        iter = iter->_next;
      } // end of while(*iter != NULL)
      os << " :rear )\n";

      return(os);
    } // end of ostream& operator<<(ostream&, const Queue&)
  } // end of namespace DS
} // end of namespace CSE224

测试程序

[编辑 | 编辑源代码]
Queue_Test.cxx
#include <fstream>
#include <string>
using namespace std;

#include "ds/Queue"
using namespace CSE224::DS;

#include "ds/exceptions/Queue_Exceptions"
using namespace CSE224::DS::Exceptions;

int main(void) {
  Queue q1;
  string fname("Queue_Test.input");
  ifstream infile(fname.c_str());

  if (!infile) {
    cout << "Unable to open file: " << fname << endl;
    return 1;
  } // end of if(!infile)

现在,处理程序的参数 (q) 指向某个堆内存,我们必须在完成异常处理后立即销毁该区域。这就是我们在处理程序中使用 delete 运算符所做的。

如果我们更倾向于传递一个对象而不是一个指向对象的指针,就像我们在 peek 中所做的那样,那么就不需要这样的清理活动;由于编译器合成的代码,它会在退出处理程序时自动执行。

请注意,我们可以将处理程序的第一个语句写成 Queue_Empty::error(); 这样做是可以的,因为我们异常类中的唯一函数是 static,这意味着我们可以通过类名调用它。

  try { q1.remove(); } 
    catch(Queue_Empty* q) { q->error(); delete q; }

  for (int i = 0; i < 10; i++) {
    double val;
    infile >> val;
    q1.insert(val);
  } // end of for(int i = 0; i < 10; i++)
  infile.close();

  cout << q1;
  Queue q2 = q1;

  cout << "Queue 1: " << q1;
  cout << "Queue 2: " << q2;

  if (q1 == q2) cout << "OK" << endl; 
    else cout << "Something wrong with equality testing!" << endl;

  q2.remove(); q2.remove();
  cout << "Queue 2: " << q2;
  if (q1 == q2) cout << "Something wrong with equality testing!" << endl;
    else cout << "OK" << endl;

  return(0);
} // end of int main(void)

C++ 中的输入/输出

[edit | edit source]

C++ 中的输入/输出功能,作为标准库的一部分,是通过iostream 库提供的,该库作为类层次结构实现,利用多重继承和虚继承。此层次结构包括处理来自用户终端、磁盘文件和内存缓冲区的输入和/或输出的类。

iostream library partial class hierarchy

特定流类型的属性以某种方式混杂在它的名称中。例如,ifstream 代表一个文件流,我们将其用作输入源。类似地,ostringstream 是一个内存缓冲区——一个字符串对象——流,用作输出接收器。

基流类:ios

[edit | edit source]

无论使用的类名是什么,它最终都源自 ios,iostream 库的基类。此类包含所有流共有的功能,例如用于操作状态和格式的访问器-修改器函数。在前面组中包含以下函数

  • iostate rdstate() const:返回当前流对象的状态,可以是以下任何组合:goodeoffailbad
  • void setstate(iostate new_state):除了已设置的标志外,还将流的状态设置为 new_state。请注意,此函数不能用于取消设置标志值。
  • void clear(iostate new_state = ios::goodbit):将状态设置为在 new_state 中传递的值。
  • int good(void):如果流上的最后一次操作成功,则返回 true
  • int eof(void):如果流上的最后一次操作找到文件末尾,则返回 true
  • int fail(void):如果流上的最后一次操作不成功并且由于操作没有丢失数据,则返回 true
  • int bad(void):如果流上的最后一次操作不成功并且由于操作而丢失了数据,则返回 true

为了操作格式,我们有

  • char fill(void) const:返回当前使用的填充字符。默认字符为空格。
  • char fill(char new_pad_char):将填充字符设置为 new_pad_char 并返回先前的值。
  • int precision(void) const:返回用于输出浮点数的有效数字数。默认值为 6。
  • int precision(int new_pre):将精度设置为 new_pre 并返回先前的值。
  • int width(void) const:返回输出字段宽度。默认值为 0,这意味着使用尽可能多的字符。
  • int width(int new_width):将宽度设置为 new_width 并返回先前的值。
  • fmtflags setf(fmtflags flag):设置一个标志,用于控制输出的生成方式。 flag 可以是以下之一:(用于输出整数值的基值)ios::decios::octios::hex、(用于显示浮点值)ios::scientificios::fixed、(用于对齐文本)ios::leftios::rightios::internal、(用于显示额外信息)ios::showbaseios::showpointios::showposios::uppercase。就像接下来的四个函数一样,此函数返回调用之前生效的状态。
  • fmtflags setf(fmtflags flag, fmtflags mask):清除在 mask 中传递的标志组合,然后设置在 flag 中传递的标志。
  • fmtflags unsetf(fmtflags flag)setf 的反向,此函数确保在 flag 中传递的标志组合未设置。
  • fmtflags flags(void) const: 返回当前的格式状态。
  • fmtflags flags(fmtflags new_flags): 将格式状态设置为 new_flags

输入流

[edit | edit source]

除了上一节中列出的功能外,C++ 中的所有输入流都支持以下函数。

  • istream& operator>>(type data): 重载的移入(或 *提取*)运算符用于读取各种类型的数值,并且可以由程序员进一步重载。它可以级联使用,如果输入操作不成功,它将返回 false,这意味着它也可以在布尔表达式中使用。
  • int get(void): 返回读头下的字符,并将其前进一位。
  • int peek(void): 与前一个函数类似,peek 返回读头下的字符,但不会移动它。也就是说,peek 不会改变流的内容。
  • istream& get(char& c): get(void) 的级联版本,此函数等效于 operator>>(char&)。也就是说
in_str.get(c1).get(c2).get(c3);in_str >> c1 >> c2 >> c3;
  • istream& get(char* str, streamsize len, char delim = '\n'): 将一个以 null 结尾的字符串读入 str。此字符串的长度取决于第二个和第三个参数,它们分别保存缓冲区的大小和哨兵字符。如果扫描 len - 1 而不读取哨兵字符,则在缓冲区中追加 '\0' 并返回第一个参数。如果在填充缓冲区之前遇到哨兵字符,则读头将停留在哨兵字符上,并且所有读入该点的所有内容以及终止符 '\0' 将返回到缓冲区中。
  • istream& getline(ctype* str, streamsize len, char delim = '\n'): 与前一个函数类似,getline 用于将一个以 null 结尾的字符串读入其第一个参数。但是,如果在填充缓冲区之前遇到哨兵字符,则哨兵字符不会保留在流中,而是被读取和丢弃。注意第一个参数的类型是指向 charunsigned charsigned char 之一。
  • istream& read(void* buf, streamsize len): 将 len 字节读入 buf,除非输入先结束。如果在读取 len 字节之前输入结束,此函数将设置 ios::fail 标志并返回不完整的结果。
  • istream& putback(char c): 对应于 C 的 ungetc(char),此函数尝试回退一个字符,并将回退的字符替换为 c。注意此操作仅保证能工作一次。连续使用它可能会或可能不会起作用。
  • istream& unget(void): 尝试回退一个字符。
  • istream& ignore(streamsize len, char delim = traits::eof): 此函数读取并丢弃多达 len 个字符,或所有字符,直到且包括 delim

输出流

[edit | edit source]

对上一节中列出的操作进行补充的是对输出流执行的操作。在我们列出这些操作之前,我们应该提到一个关键点:为了使输出操作生效,必须满足以下条件之一

  1. 一个 endl 操纵器或 '\n' 被插入到流中。
  2. 一个 flush 操纵器被插入到流中或向流发送一个 flush() 消息。
  3. 附加到流的缓冲区已满。
  4. 与流绑定的 istream 对象执行输入操作。绑定两个流意味着它们的运算将同步。一个流行的例子是 cin-cout 对:在向 cin 发送消息之前,cout 将被刷新。也就是说,

cout << "Your name:"; cout << "Your name:"; cout.flush(); cout << "Your name" << flush; cin >> name; cin >> name; cin >> name;

  • ostream& operator<<(type data): 重载的移出(或 *插入*)运算符用于写入各种类型的数值,并且可以由程序员进一步重载。与提取运算符一样,它可以级联使用。
  • ostream& put(char c): 将 c 插入到当前流中。
  • ostream& write(string str, streamsize len): 将 str 中的 len 个字符插入到当前流中。由于 string 对象可以从 [const] char* 构造,因此第一个参数也可以是 C 样式的字符字符串。

在继续讨论面向文件的流之前,我们应该提到 istreamostream 的功能在 iostream 类中被组合起来,它派生于这两个类。也就是说,可以使用同一个流同时进行输入和输出。

文件输入和输出

[edit | edit source]

使用 ifstreamofstream 可以从文件读取和写入文件。由于这些类继承了相关的流类——分别是 istreamostream——它们的实例可以接收前面各节中给出的消息。除了这些,还可以使用以下列表。

  • ifstream(const char* fn, int mde = ios::in, int prt = 644), ofstream(const char* fn, int mde = ios::out, int prt = 644): 将正在构造的流连接到名为 fn 的磁盘文件。第二个和第三个参数是可选的,用于指定流的使用方式。第三个参数特定于基于 Unix 的操作系统,表示文件保护位。第二个参数指定如何打开磁盘文件,可以是以下内容的 [合理] 组合
    • ios::in: 以输入模式打开文件,并将读指针定位在文件开头。
    • ios::out: 以输出模式打开文件。这样做时,文件将被截断。
    • ios::app: 以输出模式打开文件。文件内容不会被破坏,每次输出操作都会将数据插入到文件末尾。
    • ios::bin: 将文件内容视为原始数据。在 '\n' 映射到单个字节的环境中,这并不需要。
  • ifstream(void)& ofstream(void): 创建一个流对象,但不将其连接到磁盘文件。
  • void open(const char* fn, int mde = def_mde, int prt = 644): 将先前构造的 [未连接] 流对象连接到磁盘文件。
  • ios::pos_type tellg/tellp(void): 返回文件标记的位置。这些函数的最后字母,g 表示获取,p 表示放置,用于提醒文件标记是读指针还是写指针。
  • void seekg/seekp(pos_type n_p): 这些函数将文件标记(即读指针或写指针)移动到由 n_p 指定的绝对字节号。seekg(读作“寻求下一个获取的新位置”)影响读指针,而 seekp(读作“寻求下一个放置的新位置”)影响写指针。
  • void seekg/seekp(off_type offset, ios::seekdir dir): 相对于 dir 指定的位置移动最多 offset 个字节,dir 可以取以下值之一: [文件开头] ios::beg, [当前文件标记位置] ios::cur, 和 [文件结尾] ios::end

作为这份讲义的结束语,我们应该提到同时从同一个文件读取和写入的可能性。在这种情况下,可以构造一个 fstream 对象并使用它来实现我们的目标。

注释

[edit | edit source]
  1. 实际上,存在差异。无论是普通对象还是异常对象,在堆中创建的对象都由程序员管理,必须由她释放。
  2. 事实上,你可以传递指向地址空间其他部分(例如静态数据或运行时堆栈区域)的指针。但随后如何决定是否释放该区域?如果它指向堆中的某个位置,则程序员有责任,她必须释放该对象;如果指向的对象不在堆中,其生命周期将由编译器管理。我们最好更确定性,在堆中创建所有此类对象,或者让处理程序接受一个额外的参数。或者更好的是,选择传递对象,而不是指向对象的指针。
  3. 观察到这基本上与在 malloced 堆对象的情况下使用 free 返回的区域相同:指针指向的区域。这种相似性使我们得出一个非正式定义: delete 运算符是析构函数的隐式调用加上 free
华夏公益教科书