-
C 标准库提供了名为
assert
的断言宏; -
C# 语言提供了名为
Debug.Assert
的断言方法; -
Java 语言提供名为
assert
的断言关键字。
主流编程语言不约而同的在语言层面上提供了 断言
机制。
-
David R. Jamson,编译器 Icc 的开发者之一,在他的《C 语言接口与实现——创建可重用软件的技术》一书中,教授如何实现断言(assert)接口,这是其它接口的基础;
-
Stephen A.Maguire,Excel 开发者和领导者,在他的《编程精粹:Microsoft 编写优质无错 C 程序秘诀》一书中,用一章来讲述,如何自己设计并使用断言(assert);
-
Andy Hunt,著名顾问,敏捷宣言成员之一,在他和 Dave Thomas 合著的《程序员修炼之道》一书中,介绍断言式编程,认为这是注重时效的编程方法。
编程专家们不约而同的提倡使用 断言
。
-
ST 外设驱动固件库中随处可见
assert_param
断言宏; -
网络协议栈 lwip 中随处可见
LWIP_ASSERT
断言宏; -
操作系统 FreeRTOS 中随处可见
configASSERT
断言宏。
优秀的代码不约而同的已经使用了 断言
。
即使是第一次听说断言,你也应该意识到,这个东西应该挺重要。那么接下来的问题是,什么是断言(assert)?
断言,就是明确且坚定的指出某事是真的!
to state clearly and firmly that sth is true 《牛津词典》
在 C 语言环境中,断言是一个宏,如果其参数的计算结果为假,就中止调用程序的执行。
就这?
听上去好像很一般嘛,不就是用来做参数检查的嘛,就这也值得特意开个专题?
很值得!
随着技术人员见解的增长,他们都会使用断言,或早或晚,殊途同归。
与其说这是技术水平不断提升的结果,不如说这是编程思想转变的结果。这个转变是:从被动的调试 BUG,开始转变为主动的发现 BUG!
检查不可能发生的情况
每一个程序员似乎都必须在其职业生涯的早期记住一句咒语。它是计算机技术的基本原则,也是我们所做的每一件事情的核心信仰。那就是:
这绝不会发生… 1
比如设计一个处理字符串的内部函数:
void StrDoSomething(char* str);
{
...
}
参数 str
绝不应该是 NULL
,或许你会说,调用这个函数时,我决不会让 str
为空!
醒醒吧,我们不要这样自我欺骗,特别是在编码时。如果它不可能发生,用断言确保它不会发生!
If It Can’t Happen, Use Assertions to Ensure That It Won’t
就像这样:
void StrDoSomething(char* str);
{
ASSERT(str != NULL);
...
}
如果你认为参数 str
不可能为空,就用断言 ASSERT(str != NULL)
来确保它不会为空;
如果你认为变量 count
不可能为负,就用断言 ASSERT(count >= 0)
来确保它不会为负;
如果你认为 switch
的 default
分支不可能执行,就在 default
分支中用 ASSERT(false)
来确保它不会执行…
这些宏是无价的财富,你和使用该函数的人都将受益,如果将来某个程序员错误的使用了这个函数,函数自己会以明确的方式告知:嗨,你犯了个错误!
对函数参数进行确认
设计一个将无符号数转为字符串的函数,转换后的字符串可以是二进制、十进制、十六进制样式,函数为:
void UnsignedToStr(unsigned u, char* str, unsigned base);
{
...
}
参数 str
指向转换后的结果,必须非空;
参数 base
指定何种进制样式,可能的值为 2、10、16,分别表示二进制、十进制、十六进制样式。
我们可以使用断言对参数进行确认,之后这个函数在每个调用点都会对参数进行检查,如果用户发生了错误,就可以很快的、自动的把它们检查出来:
void UnsignedToStr(unsigned u, char* str, unsigned base);
{
ASSERT(str != NULL);
ASSERT(base == 2 || base == 10 || base == 16);
...
}
这里值得注意的是,断言确认并不能代替异常判断,如果 UnsignedToStr
是一个外部使用的函数,并且外部调用时 str
有可能为空,则必须对这种异常情况做明确的处理:
void UnsignedToStr(unsigned u, char* str, unsigned base);
{
ASSERT(base == 2 || base == 10 || base == 16);
if(str == NULL)
{
//卫语句,对异常做处理
...
}
...
}
使用断言来消除未定义行为
memcpy
是定义在 string.h
中的一个库函数,函数原型为:
void *memcpy(void *s1, const void *s2, size_t n)
函数 memcpy
从 s2 指向的对象中复制 n 个字符到 s1 指向的对象中。如果复制发生在两个重叠的对象中,这种行为未定义。2
行为未定义
在 C/C++ 中很常见,如果某些行为标准没有明确规定、也不限制编译器的具体实现,那么这些行为就是未定义的。因此,未定义行为的执行结果取决于编译器,可能各家编译器都不相同,理论上,即使执行结果把你的硬盘格式掉责任都在你方。未定义行为就相当于非法行为3,我们可以用断言来消除未定义行为。
对于 memcpy
函数,可以使用断言来进行重叠检查:
/*封装内存拷贝函数*/
void *s_memcpy(void *s1, const void *s2, size_t n)
{
ASSERT(s1 != NULL && s2 != NULL);
/*检查内存重叠*/
ASSERT((char *)s1 >= (char *)s2 + n || (char *)s2 >= (char *)s1 + n);
memcpy(s1, s2, n); //调用库函数
}
利用断言检查隐式假设的正确性
如果假设 long
占用 4 个字节,可以使用以下断言来检查假设是否正确:
ASSERT(sizeof(long) == 4);
这里困难的不是理解这句话的意思,而是如何意识到自己的代码是基于了某个假设!
罗伯特 B.西奥迪尼(Robert B.Cialdini)在他的《影响力》一书中指出:如果你是个售货员,那么当顾客准备购买毛衣和套装时,你应该总是先给顾客看套装然后再给顾客看毛衣。这样做的理由是可以增加销售额,因为在顾客买了一件 $500 元的套装之后,相比之下,一件 $80 元的毛衣就显得不那么贵了。但是如果你先给顾客看毛衣,那么 $80 元一件的价格可能会使其无法接受,最后也许你只能卖出一件 $30 元的毛衣。
任何人只要花 30 秒的时间想一想,就会明白这个道理。**可是,又有多少人花时间想过这一问题呢? **
在编写函数时,要进行反复的思考,并且自问:“我打算做哪些假设?”
一旦确定了相应的假设,就要使用断言对所做的假设进行检验,或者重新编写代码去掉相应的假设。
在进行防御性编程设计时,利用断言进行报警
防御性编程设计通常是一种很好的编码风格,他可以让程序更加健壮,当出现异常时,能以一种优雅的方式退出或者降级运行。比如在恶劣电磁环境中,从存储器中读出的数据通常认为是不可靠的,所以我们会对数据进行校验,然后将数据和校验值一起写入存储器。
使用数据时,把数据和校验一起读取出来,然后再次计算数据的校验值,将新的校验值与读出的校验值做对比,如果不相等,表示数据遭到了破坏,则进行相应的异常处理:
/*从存储器读出数据*/
lwnvrb_peek(&resume_nvrb_s, RESUME_LEN_BYTES, len, resume_read_buf);
crc16 = to_uint16_low_first(&resume_read_buf[len - 2]);
/*防御性编码*/
if(crc16 != cal_sensor_crc16(resume_read_buf, len - 2))
{
clear_resume(); //错误处理
return 0;
}
这是一种典型的防御性编程设计,你能看出这样的代码隐含着什么问题吗?
它安静的处理了异常!那些本该在设计阶段就应该规避的异常,比如硬件设计失误、软件设计失误,被隐瞒了!
看上去风平浪静,实则暗涛汹涌,代码撒了谎!
想象你正在设计一个温度传感器,温度不会突变。所以你对 ADC 采集的数据做了防御性编程设计:在计算真实温度前,忽略了突变的 ADC 数据。这是一个常规的操作,称为滤波(滤除干扰)。
但正是这种操作,掩盖了设计缺陷,由于信号调理链路的设计问题,会周期的产生尖脉冲,但是防御性编程的存在,这个问题被隐瞒了。其结果就是,信号调理硬件问题一直没能解决,温度传感器的精度始终差强人意。
那我们还要不要防御性编程设计?
当然要,不过要做一点改动,在进行防御性编程设计的同时,用断言对错误进行报警。
回到存储器读取数据的例子。在开发阶段,办公室环境中,我们判断电磁环境良好,因此认为存储器是可靠的,我们在保留防御性编程的基础上,增加了一条断言,如果进入防御性代码,则让程序“死”在这里:
/*从存储器读出数据*/
lwnvrb_peek(&resume_nvrb_s, RESUME_LEN_BYTES, len, resume_read_buf);
crc16 = to_uint16_low_first(&resume_read_buf[len - 2]);
/*防御性编码*/
if(crc16 != cal_sensor_crc16(resume_read_buf, len - 2))
{
ASSERT(0); //如果进入防御代码,则触发断言,通常程序会停在这句代码中
clear_resume();
return 0;
}
死掉的代码不会撒谎!
这段代码在联调阶段,触发了断言,给出了触发断言的文件名和位置(第几行)。经过调试,很快锁定了触发断言的原因,不是存储器不可靠,而是软件逻辑的问题。如果没有断言,这个问题将会被隐瞒,并以其它的形式困扰我很久,且难以查找。
利用断言检查契约
想象一下这种场景:你的设备通过一套协议和上位机软件通讯,协议规定了数据的格式。我们以 设置报警
为例:协议规定,报警类型占 1 个字节,可能的值为 0
(无报警)、1
(上限报警)和 2
(下限报警)。传统编程这样解析上位机发来的数据:
switch(alarm_type)
{
case ALARM_NONE: //无报警
alarm_value = ...;
break;
case ALARM_UPPER:: //上限报警
alarm_value = ...;
break;
case ALARM_LOWER: //下限报警
alarm_value = ...;
break;
default:
break;
}
但是,第一次联调时,你用上位机软件设置了报警值,随后发现你的设备并没有按照预期报警。这种事情在联调阶段很常见,问题在哪里?是发送的协议不正确还是你的报警逻辑不正确,不得而知。你只好抓取上位机下发的数据,然后对照协议分析,费时费力。
让我们换一种思路。
这种使用协议进行通讯的场景,是典型的契约式编程。协议即契约,契约作用于双方。作为契约的一方,你必须履行契约,而且有责任检查对方是否遵守契约,如果契约被破坏,必须以合适的方式处理。触发断言,就是一种很好的处理方式。
还是以上面的设置报警为例,使用断言检查契约,代码为:
switch(alarm_type)
{
case ALARM_NONE: //无报警
alarm_value = ...;
break;
case ALARM_UPPER:: //上限报警
alarm_value = ...;
break;
case ALARM_LOWER: //下限报警
alarm_value = ...;
break;
default:
ASSERT(false); //如果数据不合法,触发断言
break;
}
修改后的代码仅仅多了一行断言(ASSERT(false)
),但意义迥然不同。这句断言可以证明对方遵守了契约,或者在对方违反契约时主动报告错误。
还是第一次联调,你在上位机软件上刚点击了设置报警值的下发按钮,就发现你的设备显示屏上输出了一行断言:
你按照文件名和行号,找到这句断言,立刻明白,上位机程序员出错了,因为数据格式违反了契约。
如何实现断言
认识到断言的好处后,我们自然想知道如何在项目中实现断言。这里给出一个断言接口的实现。
assert.h
提供对外接口,也就是 ASSERT
宏,代码如下所示:
#ifndef __assert_h__
#define __assert_h__
#include "app_assert_cfg.h"
#define __STR(x) __VAL(x)
#define __VAL(x) #x
#ifdef ASSERTS
extern void _Assert(char *str_file, char *str_line);
#define ASSERT(e) ((e) ? (void)0 : _Assert(__FILE__ ":", __STR(__LINE__)))
#else
#define ASSERT(e)
#endif //#ifdef ASSERTS
#endif //#ifndef __assert_h__
在设计上,这个宏需要一些技巧:
- 宏依赖一个外部函数
_Assert
,用于输出断言信息。这个函数需要你自己实现,因为信息的显示依赖特定硬件。嵌入式设备没有标准输出,有的设备使用显示屏,有的使用 UART,各不相同,根据硬件而定。 - 代码要紧凑。通常编译器提供的符号
__LINE__
是一个十进制常量,这里用__STR
宏在编译阶段转换成字符串类型,避免在代码中做格式转换,这是一个小技巧。 - 为了生成的代码最小,这里没有输出测试条件,对于断言
ASSERT(str != NULL)
,其中str != NULL
称为测试条件。C 标准提供的断言会输出这个测试条件,但是嵌入式系统通常存储容量不大,并且根据文件和行号能容易的找到测试条件,因此本接口不输出测试条件 - 为了生成的代码最小,没有把函数
_Assert
的参数str_file
和str_line
粘连在一起,而是分成了两个参数。C 标准实现是将这两个参数在编译阶段粘连成一个mesg
参数。这是因为如果在同一个文件中定义了多个断言,那么本接口的实现方法生产的代码更小(文件名__FILE__
只会存储一份)。 - 接口需要一个外部头文件
app_assert_cfg.h
,在这个头文件中可以使能或者禁用断言。如果想使能断言,则在这个头文件中定义宏ASSERTS
,否则不要定义宏ASSERTS
。为什么不在assert.h
文件中定义或取消宏ASSERTS
呢?这涉及到模块化代码的一个原则:模块化代码应该是只读的。具体参见 随想007:模块化代码。
函数 _Assert
的实现比较简单,给出一个在 LCD 屏上显示断言信息的例子:
#include "lcd_func.h"
/*用于LCD打印输出的接口*/
#define ASSERT_LCD_BUF_NUM 65
void _Assert(char *str_file, char *str_line)
{
char lcd_buf[ASSERT_LCD_BUF_NUM];
snprintf(lcd_buf, ASSERT_LCD_BUF_NUM, "%s%s", str_file, str_line);
disp_txt_by_specify_location(LCD_ROW_1, LCD_COLUMN_1, lcd_buf);
while(1)
{
WWDT_Feed();
}
}
读后有收获,资助博主养娃 - 千金难买知识,但可以买好多奶粉 (〃‘▽’〃)
《程序员修炼之道》 ↩︎
《C 标准库》 ↩︎
《编程精粹》 ↩︎