随想012:断言

news2024/10/5 12:28:30
  • 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) 来确保它不会为负;

如果你认为 switchdefault 分支不可能执行,就在 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__

在设计上,这个宏需要一些技巧:

  1. 宏依赖一个外部函数 _Assert,用于输出断言信息。这个函数需要你自己实现,因为信息的显示依赖特定硬件。嵌入式设备没有标准输出,有的设备使用显示屏,有的使用 UART,各不相同,根据硬件而定。
  2. 代码要紧凑。通常编译器提供的符号 __LINE__ 是一个十进制常量,这里用 __STR 宏在编译阶段转换成字符串类型,避免在代码中做格式转换,这是一个小技巧。
  3. 为了生成的代码最小,这里没有输出测试条件,对于断言 ASSERT(str != NULL) ,其中 str != NULL 称为测试条件。C 标准提供的断言会输出这个测试条件,但是嵌入式系统通常存储容量不大,并且根据文件和行号能容易的找到测试条件,因此本接口不输出测试条件
  4. 为了生成的代码最小,没有把函数 _Assert 的参数 str_filestr_line 粘连在一起,而是分成了两个参数。C 标准实现是将这两个参数在编译阶段粘连成一个 mesg 参数。这是因为如果在同一个文件中定义了多个断言,那么本接口的实现方法生产的代码更小(文件名 __FILE__ 只会存储一份)。
  5. 接口需要一个外部头文件 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();
    }
}






读后有收获,资助博主养娃 - 千金难买知识,但可以买好多奶粉 (〃‘▽’〃)
千金难买知识,但可以买好多奶粉


  1. 《程序员修炼之道》 ↩︎

  2. 《C 标准库》 ↩︎

  3. 《编程精粹》 ↩︎

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/699199.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

掌握IO流这一篇就够了

IO流(几种常见的流) IO流概述IO的分类顶级父类 字节流、字符流字节输出流OutputStream字节输入流InputStreamFileOutputStream类FileOutputStream**写出字节数据** FileInputStream类FileInputStream读取字节数据复制图片 字符流字符输入流Reader字符输出…

主动式和被动式电容笔哪个好用?苹果平板平替笔排行

被动式电容笔与主动式电容笔最大的不同在于主动式电容笔具有更广泛的应用领域,可以与不同种类的电容屏幕进行匹配。随着人们对其了解的不断深入,其应用也日益广泛。除此之外,平替电容笔的技术,也在不断的改进和提高,逐…

ResizeKit.NET 自动更改所有控件和字体大小 -Crack Version

ResizeKit2.NET ---Added support for Microsoft .NET 7.0. 使您的应用程序大小和分辨率独立。 ResizeKit.NET 自动更改所有控件和字体的大小,以便它们可以显示在任何大小的表单上。提供完全控制来自定义调整大小过程。即使用户在运行应用程序时切换表单的大小&…

ReentrantLock源码

介绍 ReentrantLock是Java中的一种可重入锁,也称为重入锁。是一种递归无阻塞的同步机制。它可以等同于 synchronized 的使用,但是 ReentrantLock 提供了比 synchronized 更强大、灵活的锁机制,可以减少死锁发生的概率。 ReentrantLock实现了…

Spring学习笔记---下篇

文章目录 Spring下篇1、代理模式1.1、静态代理1.2、加深理解1.3、动态代理 2、AOP2.1、什么是AOP2.2 、Aop在Spring中的作用2.3、在Spring中使用Aop 3、整合MyBatis3.1、[mybatis-spring介绍](https://mybatis.org/spring/zh/getting-started.html)3.2、整合步骤 4、事务4.1、事…

Linux基础工具|文本编辑器Vim的使用

0.前言 您好这里是limou3434的个人博客,感兴趣可以看看我的其他内容。 本次我给您带来的是Linux下Vim文本编辑器的使用,关于vim,您只需要知道一些常用的指令和操作即可,快速上手的秘诀是实践,并且是多次实践。 1.Vi…

12 MFC常用控件(一)

文章目录 button 按钮设置默认按钮按下回车后会响应禁用开启禁用设置隐藏设置显示设置图片设置Icon设置光标 Cbutton 类创建按钮创建消息单选按钮多选按钮 编辑框组合框下拉框操作 CListBox插入数据获取当前选中 CListCtrl插入数据设置表头修改删除 button 按钮 设置默认按钮按…

将 YAPF 设置为默认的 Python 代码格式化工具 (VS Code, PyCharm)

yapf 是一个 python 代码格式化工具, 和 black, autopep8, pycharm 自带的格式化功能相同用途. 使用 yapf 作为我的默认格式化工具, 出于以下考虑: 我和团队使用多种 ide, 而 pycharm 自带的格式化功能在其他 ide 上没法用. 所以我需要一个通用的格式化方案来保持代码风格的一…

Unity | HDRP高清渲染管线学习笔记:Rendering Debugger窗口

HDRP给我们提供了一套完整的可视化Debug工具,集成在Rendering Debugger窗口。通过顶部菜单Window→Analysis→Rendering Debugger可以打开窗口。Rendering Debugger窗口不仅仅可以在编辑模式下使用,也可以在真机上运行时使用。(要在真机上运行…

数据结构--栈(Stack)的基本概念

数据结构–栈(Stack)的基本概念 线性表是具有相同数据类型的n ( n ≥ 0 n\ge0 n≥0)个数据元素的有限序列,其中n为表长,当n 0时线性表是一个空表。若用L命名线性表,则其一般表示为: L ( a 1 , a 2 . . . , a i , a i 1 , . . …

JavaScript 手写代码 第七期(重写数组方法三) 用于遍历的方法

文章目录 1. 为什么要手写代码?2. 手写代码2.1 forEach2.1.1 基本使用2.1.2 手写实现 2.2 map2.2.1 基本使用2.2.2 手写实现 2.3 filter2.3.1 基本使用2.3.2 手写实现 2.4 every2.4.1 基本使用2.4.2 手写实现 2.5 some2.5.1 基本使用2.5.2 手写实现 2.6 reduce2.6.1…

大学实训报告范文6篇

大学实训报告范文篇一:js实训报告 一、简介: Web标准并不是一个单一的标准,而是一个系列的标准的集合。Web标准中具有代表性的几种语言有:_ML可扩展标记语言、_HTML可扩展超文本标记语言、CSS层叠样式表、DOM文档对象模型、Java…

助你丝滑过度到 Vue3 常用的组合式API ②④

作者 : SYFStrive 博客首页 : HomePage 📜: VUE3~TS 📌:个人社区(欢迎大佬们加入) 👉:社区链接🔗 📌:觉得文章不错可以点点关注 👉…

HOT25-环形链表

leetcode原题链接:环形链表 题目描述 给你一个链表的头节点 head ,判断链表中是否有环。 如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数…

数据库分库分表(上)

数据库分库分表 1,概念 分库分表是一种数据库架构设计的方法,用于解决大规模数据存储和查询的性能问题。它将一个大型数据库拆分成多个小型数据库,每个数据库负责存储一部分数据,从而提高数据的读写效率和并发处理能力。 分库分…

MySQL数据库基础 17

第十七章 触发器 1. 触发器概述2. 触发器的创建2.1 创建触发器语法2.2 代码举例 3. 查看、删除触发器3.1 查看触发器3.2 删除触发器 4. 触发器的优缺点4.1 优点4.2 缺点4.3 注意点 在实际开发中,我们经常会遇到这样的情况:有 2 个或者多个相互关联的表&a…

虚幻引擎(UE5)-大世界分区WorldPartition教程(三)

文章目录 前言LevelInstance的使用1.ALevelInstance2.选择Actor创建关卡3.运行时加载LevelInstance 总结 上一篇:虚幻引擎(UE5)-大世界分区WorldPartition教程(二) 前言 在制作大关卡时,可能会遇到这样一种情况,就是关卡中的某些Actor会重复…

【每日一题】——Majority

🌏博客主页:PH_modest的博客主页 🚩当前专栏:每日一题 💌其他专栏: 🔴 每日反刍 🟡 C跬步积累 🟢 C语言跬步积累 🌈座右铭:广积粮,缓称…

A*算法学习笔记

1 算法思路 1、Dijkstra算法与A*算法 (1)Dijkstra算法(贪心策略 优先队列): 集合S:已确定的顶点集合,初始只含源点s。 集合T:尚未确定的顶点集合。 算法反复从集合T中选择当前到…

开闭架构

在《不过时的经典层架构》里,有朋友留言关于Manager和Engine的概念,虽然朋友留言把概念解释清楚了。为了避免其他人有同样的疑问,这里我还是再解释一下。 以上是经典的四层架构,在这个架构中,Manager和Engine(引擎)都是…