GetLastError()是一种设计模式吗?它是一个好的机制吗?

21

Windows API使用GetLastError()机制来检索有关错误或失败的信息。由于我正在为专有模块编写API,因此考虑使用相同的机制来处理错误。我的问题是,直接返回错误代码是否更好?GetLastError()是否具有任何特定优势?请考虑下面的简单Win32 API示例:

HANDLE hFile = CreateFile(sFile,
    GENERIC_WRITE, FILE_SHARE_READ,
    NULL, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL);

if (hFile == INVALID_HANDLE_VALUE)
{
    DWORD lrc = GetLastError();

    if (lrc == ERROR_FILE_EXISTS)
    {
          // msg box and so on
    }
}
我写自己的API时发现,GetLastError() 机制意味着 CreateFile() 必须在所有退出点上设置最后的错误代码。如果有许多退出点且其中一个可能被忽略,这可能有点容易出错。这是如何完成的?是否有某种设计模式可用?另一种选择是向函数提供额外的参数,可以直接填写错误代码,因此不需要单独调用 GetLastError() 。还有一种方法如下所示。我将坚持使用上述示例来进行分析。在这里,我将更改格式(假设)。
result =  CreateFile(hFile, sFile,
    GENERIC_WRITE, FILE_SHARE_READ,
    NULL, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL);

if (result == SUCCESS)
{
   // hFile has correct value, process it
}
else if (result == FILE_ALREADY_EXIT )
{
   // display message accordingly
  return;
}
else if ( result == INVALID_PATH )
{
   // display message accordingly.
  return;
}

我的终极问题是,从API或者函数中返回错误代码的首选方式是什么,因为它们都是一样的?


在这种情况下,使用switch语句似乎更为合适。 - Mysticial
9
为什么不使用异常? - GManNickG
@GMan,请查看我对StilesCrises回复关于异常的评论。 - zar
3
当我编写自己的API时,我意识到GetLastError()机制意味着CreateFile()必须在所有出口点设置最后的错误代码。其实不是这样的。只有在通过返回值告知实际已经被设置了时,你才应该检查GetLastError()。其他情况下它将是未定义的,并且保留上次设置的值。 - Deanna
1
@Deanna:一个很好的例子,说明为什么像GetLastError这样的错误机制很难使用——很容易不一致。 - Frerich Raabe
显示剩余4条评论
7个回答

22

总的来说,这是一个糟糕的设计。这不仅适用于Windows的GetLastError函数,Unix系统也有全局的errno变量。它之所以糟糕,是因为它是函数的输出结果,这是隐式的。这会带来一些严重的后果:

  1. 同时执行(在不同线程中)的两个函数可能会覆盖全局错误代码。因此您可能需要每个线程的错误代码。正如各条评论所指出的那样,这正是GetLastErrorerrno所做的 - 如果您考虑使用全局错误代码来编写API,则在多个线程使用API时需要执行相同操作。

  2. 两个嵌套的函数调用可能会丢弃由内部设置的错误代码,如果外部函数覆盖了它。

  3. 很容易忽略错误代码。实际上,很难记住它存在的原因是并非每个函数都使用它。

  4. 在自己实现函数时很容易忘记设置它。可能有许多不同的代码路径,如果您不注意其中之一,则可能允许控制流在未正确设置全局错误代码的情况下逃逸。

通常,错误条件是异常的。它们并不经常发生,但可能会发生。您需要的配置文件可能无法读取 - 但大多数情况下可以读取。对于这样的异常错误,您应该考虑使用C++异常。任何一本值得一看的C++书籍都会列出一些原因,解释为什么异常在任何语言(不仅仅是C ++)中都是很好的,但有一件重要的事情需要考虑:

异常会展开堆栈。

这意味着当您有一个产生异常的函数时,它会传播到所有调用者(直到被某个人捕获,可能是C运行时系统)。这反过来又带来了几个后果:

  1. 所有调用者的代码都需要意识到异常存在,因此所有获取资源的代码必须能够在面对异常时释放它们(在C++中,“RAII”技术通常用于处理它们)。

  2. 事件循环系统通常不允许异常逃逸事件处理程序。在这种情况下没有很好的处理方式。

  3. 处理回调函数的程序(例如普通函数指针,或者Qt库使用的“信号和槽”系统)通常不会预期一个被调用的函数(槽)可能产生异常,因此它们不会尝试捕获它。

总之,如果您知道异常的作用,请使用它们。由于您似乎对该主题比较新,因此现在最好使用函数返回代码,但请记住,这通常不是一种好的技术手段。在任何情况下,都不要使用全局错误变量/函数。


3
errno 被设计成一个宏,它会扩展为某个线程本地的错误码。总体而言你说得对。 - Alexandre C.
7
在Windows操作系统中,“GetLastError”返回的是每个线程的值。如果不是这样,它将无法使用。 - Martin James
6
你对GetLastError的许多批评都是虚假的。它是线程本地的。嵌套函数?你必须在每次API调用时检查错误,嵌套是无关紧要的。很容易忽略错误代码?针对不支持异常的语言的API除了使用错误代码之外别无选择。 - David Heffernan
3
@DavidHeffernan:你写道“必须在每个API调用中检查错误”-但并不是每个API调用都设置了最后的错误!考虑Windows套接字API。无论使用什么语言,这都是一个糟糕的设计。如果它是返回值的一部分,那就更好了-这样,至少每个函数的输入/输出都清晰可见。设计的整个重点是使正确的事情变得容易,使不正确的事情变得难以做到。全局值并不完全适用于此。 - Frerich Raabe
2
通常,错误条件是异常情况。我认为使用异常报告故障最有用的属性是函数提供成功作为后置条件。如果方法执行后立即执行代码,则知道该方法已完成其工作。无需冗长和复杂的防御性检查来检查null、超出范围的值、空缓冲区等。 - Raedwald
显示剩余4条评论

7

GetLastError模式很容易出错,是最不受欢迎的选择。

返回一个状态码enum是更好的选择。

另一种选择是抛出异常来处理失败情况。如果要正确地执行此操作(并且不泄漏资源或在半设置状态下留下对象),则需要非常小心的编码,但可以导致非常优美的代码,其中所有核心逻辑都在一个地方,错误处理被整齐地分离出来。


3
如果您使用“正常”的C ++技术(即RAII和浏览标准库),则异常很容易使用。不过,我同意这样一个暗示的事实:C ++大部分难点来自于需要编写异常安全的代码。 - Alexandre C.
1
我不太确定外部库(在我的情况下是dll)是否应该向外界抛出异常。首先,库API应该是通用的,最好是C风格,这样任何人都可以使用它,但即使是C ++,通过使用异常,我也会强制客户端应用程序使用/处理这些异常,这(我认为)并不常见(对于我的库用户来说是更高级的工作)。我认为异常可以在我的库内部使用,但不能在外部公开?这让我们回到了我的最初的问题,如何返回错误。 - zar
1
如果你正在编写一个供外部用户使用的库,那么异常处理将是不好的形式。许多地方都避免使用异常处理(包括我工作的地方!),他们可能会仅仅因为这个原因而拒绝使用你的库。我并不是在建议每个人都应该使用异常处理,但是如果没有提到现代错误处理的讨论就不完整了。 - StilesCrisis

3
如果您的API位于DLL中,且希望支持使用不同编译器的客户端,则无法使用异常。因为没有关于异常的二进制接口标准。
因此,您几乎必须使用错误代码。但是,不要以“GetLastError”作为您的示例来建模系统。如果您想要一个好的返回错误代码的示例,请查看COM。每个函数都返回一个“HRESULT”。这使得调用者可以编写简洁的代码,将COM错误代码转换为本机异常。像这样:
Check(pIntf->DoSomething());

在这里,Check() 是一个由你编写的函数,它接收一个 HRESULT 作为其唯一参数,并在 HRESULT 表示失败时引发异常。正是函数的返回值表示状态,才使得这种更简洁的编码成为可能。想象一下通过参数返回状态的替代方案:

pIntf->DoSomething(&status);
Check(status);

或者更糟糕的是,Win32中的做法:
if (!pIntf->DoSomething())
    Check(GetLastError());

另一方面,如果您准备要求所有客户端使用与您相同的编译器,或者将库作为源代码交付,则应使用异常。

在Windows上有一个标准 - 只有Unix又落后了。SEH在C和C++之间是兼容的,每个自称兼容Windows的编译器都必须以某种方式支持SEH,因为基本的Windows函数也会引发SEH异常。 - Lothar
@Lothar:抱歉又挖起了一个老问题,但我必须反对“Unix再次落后”的说法。首先,标准C甚至没有SEH。这意味着它是编译器扩展。在Linux上,二进制兼容性甚至不是什么大问题。几乎所有东西都以二进制和源代码的形式分发。还有一点需要注意的是:我们都知道微软忽略标准会发生什么。比如IE。 - Linuxios
@Linuxios 我同意。SEH在这里是一个误导。标准的Windows API使用GetLastError或HRESULT,而不是SEH。 - David Heffernan
@DavidHeffernan: 没错。虽然MFC、COM、OLE或者更基于C++的东西可能会,但Win32绝对不会。 - Linuxios

3

我认为GetLastError是多线程出现之前的遗物。除非错误非常罕见,否则不应再使用这种模式。问题在于错误代码必须是每个线程独有的。

GetLastError的另一个烦恼是它需要两级测试。您首先必须检查返回代码以查看是否指示错误,然后您必须调用GetLastError以获取错误信息。这意味着您必须执行以下两种操作之一,但都不是特别优雅:

1)您可以返回一个布尔值表示成功或失败。但是,为什么不只是返回带有零的错误代码表示成功呢?

2)您可以针对每个基于非法值作为其主要返回值的函数进行不同的返回值测试。但是对于任何返回值都合法的函数怎么办?而且这是一种非常容易出错的设计模式。(对于某些函数,零是唯一的非法值,因此您可以在这种情况下返回零表示错误。但是对于零是合法的情况,您可能需要使用-1或其他值。很容易出错。)


1
GetLastError是线程安全的。 - Mooing Duck
当然,错误代码被存储为线程特定数据,使得每次访问都更加笨拙和昂贵。 (在UNIX世界中发生了同样的事情。 errno 变量早于线程,因此必须被制作成线程特定。在新的API(如pthread API)中使用了更明智的方法。) - David Schwartz
1
线程本地存储在Windows、Linux和Solaris上的开销为0,因为它只与FS段寄存器相关,而不是DS或SS。并且它的访问方式与全局变量的访问方式一样方便。 - Lothar

3
我必须说,我认为全局错误处理器(带有适当的线程本地存储)是在无法使用异常处理时最实际适用的。这肯定不是最优解,但如果您生活在我的世界中(懒惰的开发人员不像他们应该那样经常检查错误状态),那么它是最实际的选择。
原因:开发人员往往忽略检查错误返回值。我们可以指出多少在实际项目中函数返回错误状态,而调用方却忽略了它们的例子?或者我们看到过多少次,一个函数甚至没有正确返回错误状态,即使它正在分配内存(可能会失败)?我见过太多这样的例子,回去修复它们有时甚至需要大量的设计或重构代码库。
在这方面,全局错误处理器更加宽容:
  • 如果函数未能返回布尔值或某个 ErrorStatus 类型以表示失败,我们不必修改其签名或返回类型以表示失败并改变整个应用程序中的客户端代码。我们只需修改它的实现来设置全局错误状态。当然,我们仍然需要在客户端添加检查,但如果我们在调用点立即错过一个错误,仍然有机会稍后捕获它。

  • 如果客户端未能检查错误状态,我们仍然可以稍后捕获错误。当然,错误可能会被后续的错误覆盖,但我们仍然有机会看到某个时刻发生了错误,而调用代码只是忽略了调用点处的错误返回值,不会让错误稍后被注意到。

虽然这是一个次优解,但如果无法使用异常处理并且我们正在与一组糟糕的代码开发人员一起工作,他们有忽略错误返回值的可怕习惯,那么这是我所看到的最实用的解决方案。
当然,具有适当异常安全(RAII)的异常处理是远远优越的方法,但有时无法使用异常处理(例如:我们不应该跨模块边界抛出异常)。虽然像Win API的 GetLastError 或OpenGL的 glGetError 这样的全局错误处理器听起来比严格的工程学角度来说是一种次优解,但它比开始将所有东西都返回一些错误代码并开始强制调用这些函数的所有调用者来检查它们要容易得多。
如果应用这种模式,就必须注意确保它可以在多个线程中正常运行,并且没有显着的性能损失。我实际上不得不设计自己的线程本地存储系统来实现这一点,但我们的系统主要使用异常处理,并且只使用这个全局错误处理器将模块边界上的错误转换为异常。
总的来说,异常处理是正确的方式,但如果出于某些原因无法实现,我不同意这里大多数答案的看法,并建议对于规模较大、纪律性较差的团队采用类似 GetLastError 的方法(对于规模较小、纪律性较好的团队,我建议通过调用栈返回错误),这是因为如果忽略了返回的错误状态,这样至少可以在以后注意到错误,并且它允许我们通过简单修改其实现而不修改接口来将错误处理嵌入到未正确设计以返回错误的函数中。

如果客户端未能检查错误状态,我们仍然可以稍后捕获错误。只有在每个函数(无论它是否使用此方法返回错误)都需要在成功调用时保留错误状态的情况下,才会出现这种情况。据我所知,没有人这样做。(例如,printf允许修改存储的错误代码。)这种方法的一个巨大缺点是您无法轻松地稍后检查错误代码。 - David Schwartz
我们也不能使用基于返回错误值的方法。我来自的世界经常有人写f(),而不是像if (f())...或ErrorStatus status = f(); if (status != success)...当调用者未检查错误时,错误就会被彻底忽略。十行代码后我们无法发现发生了什么错误: 我们毫无头绪和方向。如果我们使用像OpenGL这样的库,人们很少在每个gl调用之后检查错误。但是我们可以找到后来发生的GL错误,并最终追踪到调用点。 - stinky472
这就是我的意思。使用类似GL方法的东西,即使人们不在每个GL调用后都进行检查,您至少可以在以后的某个地方找到错误发生的位置。未经过错误检查而导致的失败并不要求我们返回并重写所有内容以开始检测错误的来源,这是我更喜欢的妥协(尽管是次优的)方法,因为它在这种意义上更具有宽容性。当然,尽可能地进行异常处理。 - stinky472
继续,让我们假设printf覆盖了一个原始错误。 假设我们那懒惰、草率的程序员都没有检查原始错误或printf错误。 至少我可以在某个层面上检测到最后一个错误,也就是printf错误,并开始深入调查。 至少懒惰编程并没有掩盖所有的错误,就像被忽略的错误返回代码一样。 不同之处在于,即使它被覆盖,最后一个错误仍然存在于我们要检测的范围内,我们至少晚于没有捕获到一个错误。 - stinky472
很高兴看到一种方法的优缺点,感谢您的见解,非常感激。当我编写API时,我的直觉告诉我不想使用GetLastError()方法,因为它有些“分散”,并且似乎不太模块化。 - zar
+1 因为我意识到我在谈论 API 时不能使用异常。 - zar

1

在非托管代码中进行异常处理并不推荐。没有异常处理的内存泄漏是一个大问题,有了异常处理会变成噩梦。

为错误代码设置线程本地变量并不是一个坏主意,但正如其他人所说,它有些容易出错。

我个人更喜欢每个方法返回一个错误代码。这对于函数方法来说可能有些不方便,因为需要把原来返回的结果改为使用指针来返回。

int a = foo();

你需要编写以下内容:
int a;
HANDLE_ERROR(foo(a));

这里,HANDLE_ERROR 可能是一个宏,用于检查从 foo 返回的代码,如果是错误,则向上传播(返回该错误)。

如果您准备好一组处理不同情况的宏,在没有异常处理的情况下编写良好的错误处理代码将成为可能。

现在,当您的项目开始增长时,您会注意到错误的调用堆栈信息非常重要。您可以扩展您的宏以将调用堆栈信息存储在线程本地存储变量中。这非常有用。

然后,您会注意到即使调用堆栈也不足够。在许多情况下,“文件未找到”的错误代码在说fopen(path,...)的行处并不能为您提供足够的信息来找出问题所在。哪个文件没有找到。此时,您可以扩展您的宏以能够存储消息。然后,您可以提供未找到的文件的实际路径。

问题是为什么要费劲做所有这些事情,你可以使用异常来完成。有几个原因:

  1. 再次强调,在非托管代码中进行异常处理很难做到正确
  2. 基于宏的代码(如果正确执行)比需要异常处理的代码更小更快
  3. 它更加灵活。您可以启用或禁用功能。

在我目前工作的项目中,我实现了这样的错误处理。花费了我2天时间来准备好一个级别以开始使用它。而且大约一年来,我可能总共花费了2周时间来维护和添加功能。


0

你还应该考虑使用基于对象/结构的错误代码变量。就像stdio C库为FILE流所做的那样。

例如,在我的一些io对象上,当设置错误状态时,我只是跳过所有后续操作,这样用户在一系列操作之后仅需检查一次错误即可。

这种模式可以更好地微调错误处理方案。

C/C++的一个糟糕设计在这里完全暴露出来,例如与谷歌的GO语言进行比较。从函数中返回一个值。GO不使用异常,而是始终返回两个值:结果和错误代码。

有一小部分人认为异常大多数情况下都是不好的和被误用的,因为错误不是异常,而是你必须预料到的事情。并且它没有证明软件变得更可靠和更容易。特别是在C++中,现在唯一的编程方式是RIIA技术。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接