C/C++实践:DO?DO!DO...
个人原创文章,转载请注明出处,谢谢
1 规则
在 C/C++ 的使用场景中,通常都会有意或无意,抑或根据业务逻辑而不可避免的出现一个函数中多个 return 的情况,而这种情况应该要尽可能避免,而在 safety-critical 的领域,应该要尽可能杜绝。
MISRA 标准中的规则描述:
Rule 15.5 A function should have a single point of exit at the end
[IEC 61508-3 Table B.9] [ISO 26262-6 Table 8]
Amplification
A function should have no more than one return statement.
When a return statement is used, it should be the final statement in the compound statement that forms the body of the function.
Rationale
A single point of exit is required by IEC 61508 and ISO 26262 as part of the requirements for a modular approach.
Early returns may lead to the unintentional omission of function termination code.
if a function has exit points interspersed with statements that produce persistent side effects, it is not easy to determine which side effects will occur when the function is executed.
Example
in the following non-compliant code example, early returns are used to validate the function parameters.
bool_t f(uint64_t n, char *p)
{
if(n > MAX)
{
return false;
}
if(p == NULL)
{
return false;
}
return true;
}
根据上述规则,在 C/C++ 编程中应尽量避免甚至杜绝一个函数中出现多个 return 语句的情况,在诸如航空航天、交通运输、操作系统内核、硬实时控制等对代码可靠性要求很高的领域尤为显得重要,为了规避多 return 的情况,在编码实践中,通常采用 do-while 的方式来实现分支跳转,例如上述的 non-compliant 代码,通过 do-while 改造为单 return 形态,如下所示:
bool_t f(uint64_t n, char *p)
{
bool_t ret = false;
do
{
if(n > MAX)
{
break;
}
if(p == NULL)
{
break;
}
ret = true;
} while(0);
return ret;
}
2 方案
函数中单 return 要求的主要目的,主要是为了避免编码失误导致资源未释放、内存泄漏等问题,例如下面的代码片段:
void foo()
{
resource_t * ptr = alloc_resource();
if(f1(ptr))
return;
if(f2(ptr))
return;
f3(ptr);
free_resource(ptr);
return;
}
在 C/C++ 的编程实践中,上述代码片段是一种常见的编码失误,一种解决方案是在每一个 return 的地方都加上回收资源的调用,如下所示:
void foo()
{
resource_t * ptr = alloc_resource();
if(f1(ptr))
{
free_resource(ptr);
return;
}
if(f2(ptr))
{
free_resource(ptr);
return;
}
f3(ptr);
free_resource(ptr);
return;
}
按照上述方法,一方面引入了冗余代码,另一方面仍然没有解决潜在的风险,当一个函数的过程较为复杂时,后续对该函数的修改或增量开发,仍然容易导致开发者犯错,遗漏掉 return 前必要的调用逻辑,从而产生内存泄漏,基于此,通常也可以采纳 goto 的实践方式,如下所示:
void foo()
{
resource_t * ptr = alloc_resource();
if(f1(ptr))
goto EXIT;
if(f2(ptr))
goto EXIT;
f3(ptr);
EXIT:
free_resource(ptr);
return;
}
尽管 goto 是一种不被推荐的语法,但凡事没有绝对,上述的解决方案仍然不失为一种经典的实践被广泛的采用,总体上,在一个函数中的处理逻辑可被分为三个步骤:
- 分配资源
- 执行业务逻辑
- 按需释放资源
在执行业务逻辑的过程中,通常,任何的失败或异常的情况将决定当前处理流程立即中止并进入释放资源的步骤,因此,使用 goto 在异常分支下向释放资源步骤进行跳转,使得代码可读性增加,代码结构上也能够得到有效的简化,例如下述代码片段,来自微软官方提供的驱动样例,则是这种风格。
// https://github.com/microsoft/Windows-driver-samples/blob/main/storage/class/cdrom/src/cdrom.c
// Find the lowest device number currently available.
do {
status = RtlStringCchPrintfW((NTSTRSAFE_PWSTR)wideDeviceName,
64,
L"\\Device\\CdRom%d",
deviceNumber);
if (!NT_SUCCESS(status)) {
TracePrint((TRACE_LEVEL_FATAL, TRACE_FLAG_PNP,
"DriverEvtDeviceAdd: Format device name failed with error: 0x%X\n", status));
goto Exit;
}
RtlInitUnicodeString(&unicodeDeviceName, wideDeviceName);
status = WdfDeviceInitAssignName(DeviceInit, &unicodeDeviceName);
if (!NT_SUCCESS(status))
{
TracePrint((TRACE_LEVEL_FATAL, TRACE_FLAG_PNP,
"DriverEvtDeviceAdd: WdfDeviceInitAssignName() failed with error: 0x%X\n", status));
goto Exit;
}
status = WdfDeviceCreate(&DeviceInit, &attributes, &device);
deviceNumber++;
} while (status == STATUS_OBJECT_NAME_COLLISION);
当然,相比于使用 goto 可能带来的潜在争议,更加经典的改造方式则是使用 do-while 循环,改造如下:
void foo()
{
resource_t * ptr = alloc_resource();
do
{
if(f1(ptr))
break;
if(f2(ptr))
break;
f3(ptr);
}while(0);
free_resource(ptr);
return;
}
例如下述微软的代码片段,则是使用 do-while 的典型的工业级代码的实践。
// https://github.com/microsoft/Windows-driver-samples/blob/main/network/wlan/WDI/PLATFORM/NDIS6/N62C_Oids.c
do
{
// Validate if the OID is issued in the correct state and mode
ndisStatus = N62CValidateOIDCorrectness(pTargetAdapter, NdisRequest);
if(ndisStatus != NDIS_STATUS_SUCCESS) {
RT_TRACE((COMP_OID_SET | COMP_OID_QUERY), DBG_LOUD, ("%s: State Error!\n", __FUNCTION__));
break;
}
switch (NdisRequest->RequestType)
{
// Query
case NdisRequestQueryInformation:
case NdisRequestQueryStatistics:
ndisStatus = N62C_QUERY_OID_DOT11_ADDITIONAL_IE(
pTargetAdapter,
NdisRequest->DATA.QUERY_INFORMATION.Oid,
NdisRequest->DATA.QUERY_INFORMATION.InformationBuffer,
NdisRequest->DATA.QUERY_INFORMATION.InformationBufferLength,
(PULONG)&NdisRequest->DATA.QUERY_INFORMATION.BytesWritten,
(PULONG)&NdisRequest->DATA.QUERY_INFORMATION.BytesNeeded
);
break;
// Set
case NdisRequestSetInformation:
ndisStatus = N62C_SET_OID_DOT11_ADDITIONAL_IE(
pTargetAdapter,
NdisRequest->DATA.SET_INFORMATION.Oid,
NdisRequest->DATA.SET_INFORMATION.InformationBuffer,
NdisRequest->DATA.SET_INFORMATION.InformationBufferLength,
(PULONG)&NdisRequest->DATA.SET_INFORMATION.BytesRead,
(PULONG)&NdisRequest->DATA.SET_INFORMATION.BytesNeeded
);
break;
// Method
case NdisRequestMethod:
ndisStatus = NDIS_STATUS_NOT_SUPPORTED;
break;
default:
ndisStatus = NDIS_STATUS_NOT_SUPPORTED;
break;
}
}while(FALSE);
3 封装
基于 do-while 的模式是解决函数单 return 问题的较好的并广泛采用的方案,通常我们可能需要在异常场景下打印日志,因此上述改造就变成如下所示的模式:
void foo()
{
resource_t * ptr = alloc_resource();
do
{
if(f1(ptr))
{
print_log("error1");
break;
}
if(f2(ptr))
{
print_log("error2");
break;
}
f3(ptr);
}while(0);
free_resource(ptr);
return;
}
更进一步,如果 foo 函数的返回值为 int,-1 代表失败,0 代表成功,继续改造上述代码,变成如下模式:
int foo()
{
int ret = -1;
resource_t * ptr = alloc_resource();
do
{
if(f1(ptr))
{
print_log("error1");
break;
}
if(f2(ptr))
{
print_log("error2");
break;
}
f3(ptr);
ret = 0;
}while(0);
free_resource(ptr);
return ret;
}
如此之下,函数代码会变得愈加复杂且难以阅读,也增加了潜在的编码失误的风险,因此,我们对 do-while 进行适当的封装,以便让代码在形式上变得简洁,我们定义的宏如下所示:
// do_clause.h
#ifndef DO_CLAUSE_H
#define DO_CLAUSE_H
char* _do_cpy(char* dst, int size, char* src);
void _do_log(char const * func, int line, char* info, char* fmt, ...);
#define DO_BEGIN int _do_ret_ = 0; int _do_ln_ = 0; char _do_info_[128] = {0}; do {
#define DO_ASSERT(x) if(!(x)) { _do_cpy(_do_info_, sizeof(_do_info_), #x); _do_ln_ = __LINE__; break; }
#define DO_ASSERT_FAIL_WITH(x, y) if(!(x)) { y; _do_cpy(_do_info_, sizeof(_do_info_), #x); _do_ln_ = __LINE__; break; }
#define DO_ASSERT_FAIL_INFO(x, y) if(!(x)) { _do_cpy(_do_info_, sizeof(_do_info_), y); _do_ln_ = __LINE__; break; }
#define DO_BREAK _do_ln_ = __LINE__; break
#define DO_END _do_ret_ = 1; _do_ln_ = __LINE__; } while(0)
#define DO_RESULT (_do_ret_)
#define DO_FAILED (_do_ret_ == 0)
#define DO_SUCCEED (_do_ret_ == 1)
#ifdef CA_WIN
#define DO_LOG(fmt, ...) _do_log(__FUNCTION__, _do_ln_, _do_info_, fmt, __VA_ARGS__)
#endif
#ifdef CA_POSIX
#define DO_LOG(fmt, args...) _do_log(__FUNCTION__, _do_ln_, _do_info_, fmt, ##args)
#endif
#endif
在该头文件中 do_clause.h 中,我们定义了 DO_XXX 的一系列的宏来封装 do-while 循环以及循环中的条件判断语句,为了能够同时兼容 C 和 C++,我们没有使用 STL,字符串相关的处理都采用了最原生的方式。
在该套宏中,我们为 do-while 循环定义了以下变量:
_do_ret_
整形变量,用于记录 do-while 循环的结果,do-while 从中途 break 方式退出,代表失败,取值为 0,从 while(0) 处退出,代表成功,取值为 1,宏 DO_FAILED 和 DO_SUCCEED 分别用于代表执行是失败还是成功,宏 DO_RESULT 用于获取 _do_ret_ 的取值
_do_ln_
整形变量,用于记录中途 break 方式退出对应的行号,便于开发人员通过日志排查问题
_do_info_
字符串变量,用于记录中途 break 方式退出对应的语句或者用户自定义信息,便于开发人员通过日志排查问题
关于该套宏定义的说明:
DO_BEGIN
代表 do-while 的开始
DO_ASSERT(x)
对语句 x 的执行结果进行断言,如果断言失败,则 break 退出
DO_ASSERT_FAIL_WITH(x, y)
对语句 x 的执行结果进行断言,如果断言失败,则执行 y 语句然后 break 退出
DO_ASSERT_FAIL_INFO(x, y)
对语句 x 的执行结果进行断言,如果断言失败,则设置失败信息为 y 所指的字符串,然后 break 退出
DO_BREAK
无条件 break 退出
DO_END
代表 do-while 的结束
DO_RESULT
代表 do-while 的执行结果,中途 break 退出,值为 0,代表失败,在 while 处退出,值为 1,代表成功
DO_FAILED
返回执行结果是否为失败的布尔判断
DO_SUCCEED
返回执行结果是否为成功的布尔判断
DO_LOG
打印日志,可由用户自行添加自定义打印信息,由于 VC++ 和 gcc 对可变参数宏的定义有差别,因此 DO_LOG 通过条件编译来定义,在 Windows 平台,用户需要定义 CA_WIN 的宏,在 Linux 或 Unix 平台,用户需要定义 CA_POSIX 的宏,该宏实际是通过调用 _do_log 函数,该函数默认将日志打印到屏幕,用户可以根据自身需求对该函数进行修改或重写。
函数 _do_cpy 和 _do_log 的实现在 do_clause.c 中,如下所示:
// do_clause.c
#include <stdarg.h>
#include <string.h>
#include <stdio.h>
char* _do_cpy(char* dst, int size, char* src)
{
char* p = dst;
while(*src != 0 && p - dst < size - 1)
{
*p++ = *src++;
}
*p = 0;
return dst;
}
void _do_log(char const * func, int line, char* info, char* fmt, ...)
{
char buf[256] = {0};
int header_size = 0;
va_list args;
va_start(args, fmt);
printf("%s(%d)", func, line);
if(info[0] != '\0')
{
printf(" : %s", info);
}
vsnprintf(buf, sizeof(buf) - 1, fmt, args);
if(buf[0] == '\0')
{
printf("\n");
}
else
{
printf(" : %s\n", buf);
}
va_end(args);
}
基于上述的宏,我们的示例代码可以改造如下所示,要打印的错误信息通过 DO_ASSERT_FAIL_INFO 记录,在跳出 while 后,通过 DO_FAILED 宏判断是否出错并打印错误信息,错误信息中除了用户自身显式设置的信息外,还会包含出错所在的函数和所在的行号以及出错的语句。
#include "do_clause.h"
int foo()
{
resource_t * ptr = alloc_resource();
DO_BEGIN
{
DO_ASSERT_FAIL_INFO(f1(ptr), "error1");
DO_ASSERT_FAIL_INFO(f2(ptr), "error2");
f3(ptr);
}DO_END;
free_resource(ptr);
if(DO_FAILED)
{
DO_LOG("");
}
return DO_SUCCEED ? 0 : -1;
}
通过上述改造,在形式上可以变得更加简洁清晰,并且由于 DO_LOG 的加持,日志打印信息也更加丰富且完善,可以有效减轻编码的负担,这套宏在应对更加复杂的函数逻辑时,会愈加展现出其简洁高效的特性。
下述的测试代码,是对该套宏的 DO_LOG 打印场景的一个展示。
#include "do_clause.h"
#include <string.h>
int main(int argc, char** args)
{
DO_BEGIN
{
DO_ASSERT(argc == 3);
DO_ASSERT(strcmp(args[1], "hello") == 0);
DO_ASSERT_FAIL_INFO(strcmp(args[2], "world") == 0, "fail case test")
}
DO_END;
if(DO_SUCCEED)
{
DO_LOG("succeed");
}
if(DO_FAILED)
{
DO_LOG("failed, argc %d", argc);
}
return DO_RESULT;
}
执行如下所示:
# renren @ ubuntu in ~/share/do_test [20:20:59]
$ gcc -o test.out do_clause.c test.c -DCA_POSIX
# renren @ ubuntu in ~/share/do_test [20:21:02]
$ ./test.out
main(8) : argc == 3 : failed, argc 1
# renren @ ubuntu in ~/share/do_test [20:21:11]
$ ./test.out a b
main(10) : strcmp(args[1], "hello") == 0 : failed, argc 3
# renren @ ubuntu in ~/share/do_test [20:21:18]
$ ./test.out hello b
main(12) : fail case test : failed, argc 3
# renren @ ubuntu in ~/share/do_test [20:21:24]
$ ./test.out hello world
main(14) : succeed
第一次执行未传递参数,在 argc == 3 的判断处异常退出,因此打印了 main(8) : argc == 3 : failed, argc 1,代表在 main 函数中,文件第 8 行退出,对应执行的判断语句为 argc == 3,用户自定义的附加信息为 failed, argc 1
第二次执行传参为 a b,在第一次 strcmp 的断言处退出,打印与上述第一处断言类似
第三次执行传参为 hello b,在第二次 strcmp 的断言处退出,此处使用了 DO_ASSERT_FAIL_INFO 的宏,设置了 fail case test 的错误信息,用户如果设置了自定义错误信息,则会覆盖对判断语句的打印,因此,第三处的打印变成了 main(12) : fail case test : failed, argc 3
4 收益
首先,并不是所有函数中都必须要按照 do-while 的方式来组织代码,在一些不涉及资源分配和释放的简单的值条件判断的函数中,通过每一个 if 处对应一个 return,可以使得代码更加简洁易懂。do-while 主要用于解决先分配资源,然后在异常场景下需要释放资源的处理逻辑中,在此种场景下,do-while 才能够发挥避免编码失误的问题,而通过使用我们定义的 DO 系列的宏,可以使得代码在形式上更加简洁易懂,更加有利于在一个函数中去实现复杂的流程和逻辑。
总体上而言,我们在实践中采用这套 DO 宏,能够获得如下好处:
-
减少代码行数
-
减少封装和层次
-
代码形式更加简洁
-
更好的贯彻单一抽象层次的原则
-
编码更加轻松
例如下述在 Linux 上创建抽象 event 对象的代码,如果不适用 DO 宏,整个流程和逻辑将变得比较复杂,函数的代码行数也会急剧增加,以至于开发者可能会考虑再封装一些小函数,这无异于也增加了编码的难度和工作量。
ca_handle ca_create_event()
{
event_handle_t* event_h = malloc(sizeof(event_handle_t));
memset(event_h, 0, sizeof(event_handle_t));
DO_BEGIN
{
DO_ASSERT(pthread_mutex_init(&event_h->mutex, NULL) == 0);
SET_FLAG(event_h->flag, EVENT_MUTEX_MASK);
DO_ASSERT(pthread_condattr_init(&event_h->attr) == 0);
SET_FLAG(event_h->flag, EVENT_ATTR_MASK);
DO_ASSERT(pthread_condattr_setclock(&event_h->attr, CLOCK_MONOTONIC) == 0);
DO_ASSERT(pthread_cond_init(&event_h->cond, &event_h->attr) == 0);
SET_FLAG(event_h->flag, EVENT_COND_MASK);
event_h->state = false;
}
DO_END;
if(DO_FAILED)
{
DO_LOG("create event failed");
ca_delete_event((ca_handle)event_h);
event_h = NULL;
}
return (ca_handle)event_h;
}
5 参考资料
MISRA C: 2012 Guidelines for the use of the C language in critical systems: https://misra.org.uk/misra-c/
Notes on structured programming: https://www.cs.utexas.edu/users/EWD/ewd02xx/EWD249.PDF
Where did the notion of "one return only" come from: https://softwareengineering.stackexchange.com/questions/118703/where-did-the-notion-of-one-return-only-come-from
浙公网安备 33010602011771号