C语言中的断言错误处理对齐

大家好,我是TT。

这一篇,我们来看一看与C标准库相关的三个话题:断言、错误处理,以及对齐。

断言为我们提供了一种可以静态或动态地检查程序在目标平台上整体状态的能力,与它相关的接口由头文件assert.h提供。错误处理则涉及C程序如何通过特定方式,判断其运行是否发生错误,以及错误的具体类型,头文件errno.h中则定义了与此相关的宏。除此之外,C语言还具有自定义数据对齐方式的能力,借助stdalign.h头文件提供的宏,我们可以轻松地做到这一点。

可以很直观地看到,我们分别在代码的第4行和第9行使用到了运行时断言与静态断言。因为这里的重点内容是两种断言的具体使用方式,因此我们没有去实现一个完整的可运行程序,但这并不影响你对相关概念的理解。

接下来,我们进一步看看静态断言和运行时断言在C语言中的使用方式和适用场景。

静态断言

从刚才的例子中可以看到,在main函数内部,通过名为static_assert的宏,我们可以限定程序在被编译时,其所在平台上int类型的宽度需要大于等于4字节。否则,编译会被终止,对应的错误信息也会被打印出来。

实际上,在预处理阶段,static_assert宏会被展开成名为_Static_assert的C关键字。该关键字以类似“函数调用”的形式在C代码中使用,它的第一个参数接收一个常量表达式。程序在被编译时,编译器会对该表达式进行求值,并将所得结果与数字0进行比较。若两者相等,则程序终止编译,并会将通过第二个参数指定的错误信息,与断言失败信息合并输出。若两者不相等,程序会被正常编译,且该关键字对应的C代码不会生成任何对应的机器指令。

一般来说,我们会在程序运行前使用静态断言,来检查它所需要满足的一系列要求。比如,在上面这个例子中,程序的正常运行便依赖于一个前置条件,即int类型的宽度需要满足至少4字节。而通过静态断言,开发者便可以提前得知,程序如果运行在当前平台上,是否能正常工作。

类似的用例还有很多,比如判断char类型的默认符号性(借助CHAR_MIN宏常量),或是判断指针类型与int类型的宽度是否相等,或是判断某个结构体的大小是否满足预期要求,等等。这些都是可能影响C程序运行正确性的因素,而通过静态断言,它们都可以在编译时被提前检测出来。

运行时断言

还是上面那段代码,在代码中名为sqrt的函数实现里,我们使用到了运行时断言。这里,通过名为assert的宏,程序可以在函数主要逻辑被调用时,首先判断用于支持函数正常运作的假设性条件是否成立。

这个函数的功能是计算给定数字值的平方根,因此要保证传入参数x的值大于0。而通过运行时断言,我们便能够做到这一点。但与静态断言使用的static_assert不同,assert并不支持自定义错误消息。那么,我在这里留下一个小问题:你知道怎样才能在运行时断言失败时,将我们自定义的错误消息也显示给开发者吗?欢迎在评论区分享你的思考。

另外还需要注意的是,C程序中的运行时断言是否可用,也会受到宏常量NDEBUG的影响。当该宏常量的定义先于#includeassert.h语句出现时,编译器会忽略对assert宏函数调用代码的编译。反之,它便会在程序运行时进行正常的断言检查。通过这种方式,我们可以相对灵活地控制运行时断言的启用与关闭。

通常来说,运行时断言可被应用于“契约式编程(DesignbyContract)”与“防御式编程(DefensiveProgramming)”这两种软件设计方法中。

最后,让我们再从这两种软件设计方法的角度,回顾一下函数sqrt内部使用的运行时断言。这里,函数sqrt通过运行时断言,保证了它的主要逻辑被执行前,相关必要条件(即传入的参数值大于0)需要被首先满足。从防御式编程的角度来看,这是预防函数调用时发生错误的一种措施。而从契约式编程的角度来看,这就是被调用函数进行契约(函数调用前置条件)检查的过程。

错误处理

在C语言中,名为errno的预处理器宏会被展开为一个int类型的可修改全局左值,也就是说,我们可以直接对它进行赋值操作(这里为了便于描述,下面我再次提及errno时,均指代这个左值)。

在这个值中,便存放有程序自上一次调用C标准库函数后的状态信息。该宏由标准库头文件errno.h提供,在默认情况下,errno中存放着数字值0,表示程序正常运行。随着程序不断调用各种标准库函数,当某一时刻某个函数的执行产生了不符合预期的结果时,函数便会通过修改errno的值,来向程序传达这一消息。我们来看下面这个例子:

这里,在代码的第6行,我们用实参值“-1”调用了用于求取平方根的标准库函数sqrt。可以看到,由于负数在实数域内没有平方根,因此这是一种错误的使用方式。我们在上一小节中使用了运行时断言来终止程序执行,而这里,标准库函数会通过设置errno来向程序反馈相应的错误信息,但不会终止程序的运行。

紧接着,在代码的第7行,我们可以通过strerror函数,来得到当前errno中存放的数字值所表示状态对应的可读文本。然后,借由fprintf函数,该文本被“发送”到标准错误流中。同样地,使用string.h头文件提供的perror函数,我们也可以达到类似的效果。

实际上,C11标准中仅规定了errno可能取得的三个枚举值,我将它们的具体值,以及对这些值的描述信息整理在了下面的表格中,供你参考。

除此之外,POSIX标准、C++标准库,甚至不同的操作系统实现,都可能会为errno定义额外的可选枚举值,用来表示更多不同场景下的错误情况。

不仅如此,C语言还为errno添加了线程本地属性。这也就意味着,在程序不同线程中发生的错误,将会使用专属于本线程的errno来存放相应的错误标识数值。你可以通过下面这段代码来验证这个结论:

这样看起来,errno在错误检查方面表现得还不错,但在使用时我们仍然需要注意很多问题。通常来说,我们可以把标准库函数在遇到执行错误时的具体行为分为下面四类:

设置errno,并返回仅用于表示执行错误的值,如ftell;

设置errno,并返回可同时用于表示执行错误及正常执行结果的值,如strtol;

不承诺设置errno,但可能会返回表示执行错误的值,如setlocale;

在不同标准(比如ISOC和POSIX)下有不同行为,如fopen。

可以看到,在执行发生错误时,并非所有C标准库函数都会对errno的值进行合理的设置。因此,仅通过该值来判断函数执行是否正常可能并不明智。

更加合适的做法是,当你明确知道所调用库函数会返回唯一的、不具有歧义,且不会与其正常返回值混用的错误值时,应直接使用该值来进行判断。而当不满足这个条件时,再使用errno来判断错误是否发生,以及错误的具体类型。同时,建议你养成一个好习惯:每一次调用相应的库函数前,都应首先将errno置零,并在函数调用后,及时对它的值进行检测。

自定义数据对齐

最后,我们再来看看有关对齐的内容。

这里,我们来看看如何在C语言中为数据指定自定义的对齐方式。默认情况下,编译器会采用自然对齐,来约束数据在内存中的起始位置。但实际上,我们也可以使用C11提供的关键字_Alignas,来根据自身需求为数据指定特殊的对齐要求。并且,头文件stdalign.h还为我们提供了与其对应的宏alignas,可以简化关键字的使用过程。来看下面这段代码:

这里,在代码的第4行,我们首先通过验证宏常量__alignas_is_defined与__alignof_is_defined的值是否为1,来判断当前编译环境是否支持alignas与alignof这两个宏。

紧接着,在代码第5行,通过在变量n的定义中添加alignas()标识符,我们可以限定,变量n的值被存放在内存中时,其起始地址必须为的倍数。而在接下来代码的第6~7行,我们分别通过使用alignof宏函数和直接查看地址这两种方式,来验证我们对变量n指定的对齐方式是否生效。

同alignas类似的是,宏函数alignof在展开后也会直接对应于C11新引入的运算符关键字_Alignof,而该关键字可用于查看指定变量需要满足的对齐方式。并且,通过打印变量n的地址,你会发现,这个例子中结尾处的三位16进制数字“c00”,也表示该地址已经在的边界上对齐。

表面上看,alignas只是用来修改数据在内存中的对齐方式的。但实际上,合理地运用这个功能,我们还可以优化程序在某些情况下的运行时性能。

好了,讲到这里,今天的内容也就基本结束了。最后我来给你总结一下。

在这一讲中,我们主要讨论了如何借助C标准库提供的相关接口,在C程序中使用各种断言和检查库函数调用错误,以及使用自定义数据对齐方式。

通过assert.h头文件提供的static_assert与assert宏函数,我们可以在C代码中使用静态断言与运行时断言。其中,前者主要用于约束程序在编译时需要满足的环境要求,而后者则可被应用于防御式编程与契约式编程等程序设计方法中。

通过访问errno.h头文件提供的预处理器宏errno,我们能够得知程序在调用某个标准库函数后,该函数的执行是否发生了错误。而通过定义在stdalign.h头文件中的宏函数alignas与alignof,我们可以为数据指定除自然对齐外的其他对齐方式。




转载请注明:http://www.aierlanlan.com/tzrz/4438.html

  • 上一篇文章:
  •   
  • 下一篇文章: