9

我只是一个初出茅庐的程序员,至少尝试编写比最佳情况更多的程序。到目前为止,我一直在阅读 Herb Sutter 的“Exceptional C++”,并且已经阅读了三次异常安全章节。但是,除了他提出的示例(堆栈)之外,我不确定何时应该争取异常安全与速度,以及何时这样做很愚蠢。

例如,我目前的家庭作业项目是一个双向链表。因为我已经编写了其中的几个,所以我想花时间来了解一些更深层次的概念,比如 ES。

这是我的弹出式功能:

void List::pop_front()
{
    if(!head_)
        throw std::length_error("Pop front:  List is empty.\n");
    else
    {
        ListElem *temp = head_;
        head_          = head_->next;
        head_->prev    = 0;
        delete temp;
        --size_;
    }
}

我对此感到有些困惑。

1)当列表失败时我真的应该抛出错误吗?我不应该简单地什么都不做并返回,而不是强迫列表的用户执行 try {] catch() {} 语句(这也很慢)。

2)有多个错误类(加上我老师要求我们在课堂上实现的 ListException)。这样的事情真的需要自定义错误类吗?是否有关于何时使用特定异常类的一般指南?(例如,范围、长度和边界听起来都一样)

3) 我知道在所有引发异常的代码完成之前,我不应该更改程序状态。这就是我最后减小 size_ 的原因。在这个简单的例子中这真的有必要吗?我知道删除不能扔。head_->prev 在分配为 0 时是否有可能抛出?(头是第一个节点)

我的 push_back 函数:

void List::push_back(const T& data)
{
    if(!tail_)
    {
        tail_ = new ListElem(data, 0, 0);
        head_ = tail_;
    }
    else
    {
    tail_->next = new ListElem(data, 0, tail_);
    tail_ = tail_->next;
    }
    ++size_;
}

1) 我经常听说C++ 程序中的任何事情都可能失败。测试 ListElem 的构造函数是否失败(或 tail_ 在newing 期间)是否现实?

2)是否有必要测试数据的类型(目前很简单typedef int T,直到我将所有内容都模板化)以确保该类型对于结构是可行的?

我意识到这些都是过于简单的例子,但我目前只是对何时应该真正练习良好的 ES 以及何时不应该感到困惑。

4

5 回答 5

9

当列表失败时,我真的应该抛出错误吗?我不应该简单地什么都不做并返回,而不是强迫列表的用户执行 try {] catch() {} 语句(这也很慢)。

绝对抛出异常。

如果列表为空,用户必须知道发生了什么 - 否则调试将是地狱。强制用户使用 try/catch 语句;如果异常是意外的(即只能由于程序员错误而发生),则没有理由尝试捕获它。当一个异常未被捕获时,它会落入 std::terminate ,这是非常有用的行为。无论如何,try/catch 语句本身也不慢。实际抛出异常和展开堆栈的代价是什么。如果没有抛出异常,它几乎没有任何成本。

有多个错误类(加上我的老师要求我们在课堂上实现的 ListException)。这样的事情真的需要自定义错误类吗?是否有关于何时使用特定异常类的一般指南?(例如,范围、长度和边界听起来都一样)

尽可能具体。使用您自己的错误类是最好的方法。使用继承对相关异常进行分组(以便调用者更容易捕获它们)。

我知道在所有引发异常的代码完成之前,我不应该更改程序状态。这就是我最后减小 size_ 的原因。在这个简单的例子中这真的有必要吗?我知道删除不能扔。head_->prev 在分配为 0 时是否有可能抛出?(头是第一个节点)

如果head_为 null,则取消引用它(作为尝试分配给 的一部分head_->prev)是未定义的行为。抛出异常是未定义行为的可能结果,但不太可能(它要求编译器不遗余力地握住你的手,用一种被认为是荒谬的语言;)),而不是一个我们担心,因为未定义的行为是未定义的行为 - 这意味着您的程序无论如何都已经错了,并且尝试使错误的方式更正确是没有意义的。

另外,您已经明确地检查了它head_是否不为空。所以没有问题,假设你没有对线程做任何事情。

我经常听到 C++ 程序中的任何事情都可能失败。

这有点偏执。:)

测试 ListElem 的构造函数是否失败(或在 newing 期间 tail_)是否现实?

如果new失败,则std::bad_alloc抛出一个实例。抛出异常正是你想要在这里发生的事情,所以你不想或不需要做任何事情 - 让它传播。将错误重新描述为某种列表异常并没有真正添加有用的信息,并且可能只会进一步掩盖事情。

如果构造函数 ListElem 失败,它应该通过抛出异常而失败,并且大约是 999 比 1,你也应该让那个失败。

这里的关键是,每当此处抛出异常时,无需进行清理工作,因为您尚未修改列表,并且构造/新建的对象 Officially Never Existed(TM)。只要确保它的构造函数是异常安全的,就可以了。如果new调用未能分配内存,则构造函数甚至不会被调用。

当您在同一个地方进行多个分配时,您必须担心。在这种情况下,您必须确保如果第二次分配失败,您会捕获异常(无论它是什么),清理第一次分配,然后重新抛出。否则,您会泄漏第一个分配。

是否有必要测试数据的类型(目前是一个简单的 typedef int T 直到我模板化所有内容)以确保该类型对于结构是可行的?

在编译时检查类型。您实际上无法在运行时对它们做任何事情,您也永远不需要这样做。(如果你不想要所有的类型检查,那么你为什么要使用一种强制你在所有地方都输入类型名的语言?:))

于 2010-12-19T12:12:35.107 回答
8

我不确定什么时候应该争取异常安全与速度

您应该始终争取异常安全。请注意,“异常安全”并不意味着“如果出现任何问题就抛出异常”。它的意思是“提供三个异常保证之一:弱、强或不抛出”。抛出异常是可选的。异常安全对于让您的代码调用者确信他们的代码在发生错误时可以正确运行是必要的。

您将看到来自不同 C++ 程序员/团队的关于异常的非常不同的风格。有些人经常使用它们,有些人几乎不使用它们(甚至根本不使用它们,尽管我认为现在这种情况相当罕见。谷歌可能是最(最著名)的例子,如果你有兴趣,请查看他们的 C++ 风格指南以了解其原因. 嵌入式设备和游戏的内部可能是下一个最有可能找到人们完全用 C++ 避免异常的例子的地方)。标准 iostreams 库允许您在流上设置一个标志,当 I/O 错误发生时它们是否应该抛出异常。默认情况下这样做,这对于几乎所有其他存在异常的语言的程序员来说都是一个惊喜。

当列表失败时,我真的应该抛出错误吗?

这不是“列表”失败,而是pop_front在列表为空失败时专门调用它。你不能概括一个类的所有操作,它们应该总是在失败时抛出异常,你必须考虑特定的情况。在这种情况下,您至少有五个合理的选择:

  • 返回一个值以指示是否弹出任何内容。调用者可以用它做任何他们喜欢的事情,或者忽略它。
  • 记录当列表为空时调用是未定义的行为pop_front,然后忽略代码中的可能性pop_front。弹出一个空的标准容器是 UB,并且一些标准库实现不包含检查代码,尤其是在发布版本中。
  • 记录它是未定义的行为,但无论如何都要进行检查,要么中止程序,要么抛出异常。您也许可以只在调试版本中进行检查(这assert是为了什么),在这种情况下,您可能还可以选择触发调试器断点。
  • 记录如果列表为空,则调用无效。
  • 记录如果列表为空则引发异常。

除了最后一个之外,所有这些都意味着您的函数可以提供“nothrow”保证。你选择哪一个取决于你希望你的 API 是什么样子,以及你想给调用者什么样的帮助来发现他们的错误。请注意,抛出异常不会强制您的直接调用者捕获它。异常应该只被能够从错误中恢复的代码(或者可选地在程序的最顶部)捕获。

就个人而言,我倾向于为用户错误抛出异常,并且我也倾向于说弹出一个空列表是用户错误。这并不意味着在调试模式下进行各种检查没有用,只是我通常不定义 API 来保证此类检查将在所有模式下执行。

这样的事情真的需要自定义错误类吗

不,没有必要,因为这是可以避免的错误。调用者总是可以确保它不会被抛出,方法是在调用之前检查列表是否为非空pop_frontstd::logic_error抛出一个完全合理的例外。使用特殊异常类的主要原因是调用者可以只捕获该异常:您是否认为调用者需要针对特定​​情况执行此操作取决于您。

head_->prev 在分配为 0 时是否有可能抛出?

除非您的程序以某种方式引发了未定义的行为,否则不会。所以是的,您可以在此之前减小大小,并且您可以delete在确定 ListElem 的析构函数不能抛出之前减小它。并且在编写任何析构函数时,您应该确保它不会抛出异常。

我经常听到 C++ 程序中的任何事情都可能失败。测试 ListElem 的构造函数是否失败(或在 newing 期间 tail_)是否现实?

并非一切都会失败。理想情况下,函数应该记录它们提供的异常保证,进而告诉您它们是否可以抛出。如果他们真的有很好的记录,他们会列出他们可以扔的所有东西,以及在什么情况下扔。

您不应该测试是否new失败,您应该允许 from 的异常new(如果有)从您的函数传播到您的调用者。然后你可以只记录push_front可以抛出std::bad_alloc以指示内存不足的文件,也许它还可以抛出任何由复制构造函数抛出的东西T(没有,在 的情况下int)。您可能不需要为每个功能单独记录这一点 - 有时涵盖多个功能的一般说明就足够了。如果一个被调用的函数push_front可以抛出,那么它可以抛出的其中一件事对任何人来说都不应该是一个巨大的惊喜bad_alloc. 对于模板容器的用户来说,如果包含的元素抛出异常,那么这些异常可以传播,这也不足为奇。

是否有必要测试数据的类型(目前是一个简单的 typedef int T 直到我模板化所有内容)以确保该类型对于结构是可行的?

您可能可以编写您的结构,以便 T 的所有要求是它是可复制构造和可分配的。无需为此添加特殊测试 - 如果有人尝试使用不支持您对其执行的操作的类型来实例化您的模板,他们将收到编译错误。不过,您应该记录要求。

于 2010-12-19T12:32:26.967 回答
4

这是一个很长的问题。我会回答所有编号的问题1)

1)当列表失败时我真的应该抛出错误吗?我不应该简单地什么都不做并返回,而不是强迫列表的用户执行 try {] catch() {} 语句(这也很慢)。

不会。如果您的用户关心性能,他们会在尝试弹出之前检查长度,而不是弹出并捕获异常。如果他们忘记首先检查长度,那么通知您的用户是一个例外,此时您真的希望应用程序在他们面前爆炸。如果你什么都不做,它可能会导致稍后才会出现的细微问题,这将使调试更加困难。

1) 我经常听说 C++ 程序中的任何事情都可能失败。测试 ListElem 的构造函数是否失败(或在 newing 期间 tail_)是否现实?

例如,如果内存不足,构造函数可能会失败,但在这种情况下,它应该抛出异常,而不是返回 null。因此,您不需要显式测试构造函数是否失败。有关更多详细信息,请参阅此问题:

于 2010-12-19T11:56:36.977 回答
2

我经常听到 C++ 程序中的任何事情都可能失败。测试 ListElem 的构造函数是否失败(或在 newing 期间 tail_)是否现实?

是的,这是现实的。否则,如果您的程序内存不足并且分配失败(或构造函数由于其他内部原因而失败),您稍后将遇到问题。

基本上,当代码无法完全执行其 API 声明将执行的操作时,您必须在任何时候发出失败信号。

唯一的区别是您如何表示失败 - 通过返回值或通过异常。如果存在性能考虑,则返回值可能比异常更好。但是这两种方法都需要调用者中的特殊错误捕获代码。

于 2010-12-19T12:02:32.220 回答
1

对于您的第一组问题:

  1. 是的,出于@Mark答案中的所有原因,您应该扔掉。(给他+1)
  2. 这不是真的必要,但它可以让你的来电者的生活更轻松。异常处理的好处之一是它将代码本地化以在一个地方一起处理特定类别的错误。通过抛出特定的异常类型,您允许调用者专门捕获该错误,或​​者通过捕获您抛出的特定异常的超类来更一般地了解它。
  3. 您的所有陈述均else提供无担保。

对于您的第二组:

  1. 不,测试是不现实的。您不知道底层构造函数会抛出什么。它可能是预期的项目(即std::bad_alloc),也可能是奇怪的东西(即int),因此您可以处理它的唯一方法是将其放入 a catch(...)which is evil :)

    另一方面,您现有的方法已经是异常安全的,只要在if块内创建的虚拟结束节点将被您的链表的析构函数破坏。(即news 之后的所有内容都没有提供)

  2. 假设任何操作T都可以抛出,除了析构函数。

于 2010-12-19T12:12:10.657 回答