变参函数和可变参数宏¶
一、变参函数的设计与实现¶
对于一个普通函数,我们在函数实现中,不用关心实参,只需要在函数体内对形参直接引用即可。当函数调用时,传递的实参和形参个数和格式是匹配的。
变参函数,顾名思义,跟 printf 函数一样:参数的个数、类型都不固定。我们在函数体内因为预先不知道传进来的参数类型和个数,所以实现起来会稍微麻烦一点。首先要解析传进来的实参,保存起来,然后才能接着像普通函数一样,对实参进行处理。
1.变参函数初体验¶
我们接下来,就定义一个变参函数,实现的功能很简单,即打印传进来的实参值。
void print_num(int count, ...)
{
int *args;
args = &count + 1;
for( int i = 0; i < count; i++)
{
printf("*args: %d\n", *args);
args++;
}
}
int main(void)
{
print_num(5,1,2,3,4,5);
return 0;
}
*args:1
*args:2
*args:3
*args:4
*args:5
2.变参函数改进版¶
上面的程序使用一个 int * 的指针变量依次去访问实参列表。我们接下来把程序改进一下,使用 char * 类型的指针来实现这个功能,使之兼容更多的参数类型。
void print_num2(int count,...)
{
char *args;
args = (char *)&count + 4;
for(int i = 0; i < count; i++)
{
printf("*args: %d\n", *(int *)args);
args += 4;
}
}
int main(void)
{
print_num2(5,1,2,3,4,5);
return 0;
}
*args:1
*args:2
*args:3
*args:4
*args:5
3.变参函数 V3.0 版本¶
对于变参函数,编译器或计算机系统一般会提供一些宏给程序员使用,用来解析函数的参数。这样程序员就不用自己解析参数了,直接使用封装好的宏即可。编译器提供的宏有: - va_list:定义在编译器头文件中 typedef char* va_list; 。 - va_start(args,fmt):根据参数 fmt 的地址,获取 fmt 后面参数的地址,并保存在 args 指针变量中。 - va_end(args):释放 args 指针,将其赋值为 NULL。 - va_arg(va_list ap, type):该宏返回下一个额外的参数,是一个类型为 type 的表达式。
有了这些宏,我们的工作就简化了很多。我们就不用撸起袖子,自己解析了。
void print_num3(int count,...)
{
va_list args;
va_start(args,count);
for(int i = 0; i < count; i++)
{
printf("*args: %d\n", va_arg(args, int));
}
va_end(args);
}
int main(void)
{
print_num3(5,1,2,3,4,5);
return 0;
}
3.变参函数 V4.0 版本¶
在 V3.0 版本中,我们使用编译器提供的三个宏,省去了解析参数的麻烦。但打印的时候,我们还必须自己实现。在 V4.0 版本中,我们继续改进,使用 vprintf 函数实现我们的打印功能。vprintf 函数的声明在 stdio.h 头文件中。
CRTIMP int __cdecl __MINGW_NOTHROW \
vprintf (const char*, __VALIST);
void my_printf(char *fmt,...)
{
va_list args;
va_start(args,fmt);
vprintf(fmt,args);
va_end(args);
}
int main(void)
{
int num = 0;
my_printf("I am litao, I have %d car\n", num);
return 0;
}
I am litao, I have 0 car
4.变参函数 V5.0 版本¶
上面的 my_printf() 函数,基本上实现了跟 printf() 函数相同的功能:支持变参,支持多种格式的数据打印。接下来,我们还需要对其添加 format 属性声明,让编译器在编译时,像检查 printf 一样,检查 my_printf() 函数的参数格式。 GNU 通过 attribute 扩展的 format 属性,用来指定变参函数的参数格式检查。 它的使用方法如下:
__attribute__(( format (archetype, string-index, first-to-check)))
void LOG(const char *fmt, ...) __attribute__((format(printf,1,2)));
V5.0 版本如下:
void __attribute__((format(printf,1,2))) my_printf(char *fmt,...)
{
va_list args;
va_start(args,fmt);
vprintf(fmt,args);
va_end(args);
}
int main(void)
{
int num = 0;
my_printf("I am litao, I have %d car\n", num);
return 0;
}
二、可变参数宏的设计与实现¶
1.什么是可变参数宏¶
在上面的教程中,我们学会了变参函数的定义和使用,基本套路就是使用 va_list 、 va_start 、 va_end 等宏,去解析那些可变参数列表我们找到这些参数的存储地址后,就可以对这些参数进行处理了:要么自己动手,自己处理;要么继续调用其它函来处理。
void print_num(int count, ...)
{
va_list args;
va_start(args,count);
for(int i = 0; i < count; i++)
{
printf("*args: %d\n", va_arg(args, int));
}
}
void __attribute__((format(printf,2,3))) LOG(int k,char *fmt,...)
{
va_list args;
va_start(args,fmt);
vprintf(fmt,args);
va_end(args);
}
#define LOG(fmt, ...) printf(fmt, ##__VA_ARGS__)
#define DEBUG(...) printf(__VA_ARGS__)
int main(void)
{
LOG("Hello! I'm %s\n","Wanglitao");
DEBUG("Hello! I'm %s\n","Wanglitao");
return 0;
}
2.宏连接符##的作用¶
如果这个宏没有##
#define LOG(fmt, ...) printf(fmt, __VA_ARGS__)
#define LOG(fmt,...) printf(fmt,__VA_ARGS__)
int main(void)
{
LOG("hello\n");
return 0;
}
printf("hello\n", );
宏连接符 ## 的主要作用就是连接两个字符串,我们在宏定义中可以使用 ## 来连接两个字符。预处理器在预处理阶段对宏展开时,会将## 两边的字符合并,并删除 ## 这两个字符。
#define CONNECT2(__A, __B) __A##__B
int safe_atom_code(void)
{
uint32_t CONNECT2(wTemp,__LINE__) = __disable_irq();
/* do something here */
__set_PRIMASK(CONNECT2(wTemp,__LINE__));
return 0;
}
知道了宏连接符 ## 的使用方法,我们接下来就可以就对 LOG 宏做一些分析。
#define LOG(fmt,...) printf(fmt, ##__VA_ARGS__)
int main(void)
{
LOG("hello\n");
return 0;
}
使用宏连接符 ##要注意一下两条结论: - 第一条:任何使用到胶水运算“##”对形参进行粘合的参数宏,一定需要额外的再套一层 - 第二条:其余情况下,如果要用到胶水运算,一定要在内部借助参数宏来完成粘合过程
为了理解这一“结论”,我们不妨举一个例子:在前面的代码中,我们定义过一个用于自动关闭中断并在完成指定操作后自动恢复原来状态的safe_atom_code函数,现在我们把它改为宏来表示:
#define SAFE_ATOM_CODE(...) \
{ \
uint32_t wTemp##__LINE__ = __disable_irq(); \
__VA_ARGS__; \
__set_PRIMASK(wTemp##__LINE__); \
}
假设这里 SAFE_ATOM_CODE 所在行的行号是 123,那么我们期待的代码展开是这个样子的(我重新缩进过了):
{
uint32_t wTemp123 = __disable_irq();
__VA_ARGS__;
__set_PRIMASK(wTemp);
}
{
uint32_t wTemp__LINE__ = __disable_irq();
__VA_ARGS__;
__set_PRIMASK(wTemp);
}
从内容上看,SAFE_ATOM_CODE() 要粘合的对象并不是形参,根据结论第二条,需要借助另外一个参数宏来帮忙完成这一过程。为此,我们需要引入一个专门的宏:
#define CONNECT2(__A, __B) __A##__B
#define __CONNECT2(__A, __B) __A##__B
#define CONNECT2(__A, __B) __CONNECT2(__A, __B)
#define __CONNECT3(__A, __B, __C) __A##__B##__C
#define CONNECT3(__A, __B, __C) __CONNECT3(__A, __B, __C)
#define SAFE_ATOM_CODE(...) \
{ \
uint32_t CONNECT2(wTemp,__LINE__) = \
__disable_irq(); \
__VA_ARGS__; \
__set_PRIMASK(CONNECT2(wTemp,__LINE__)); \
}
3.可变参数宏的另一种写法¶
当我们定义一个变参宏时,除了使用预定义标识符 __ VA_ARGS __ 表示变参列表外,还可以使用下面这种写法。
#define LOG(fmt,args...) printf(fmt, args)
#define LOG(fmt,args...) printf(fmt,##args)
int main(void)
{
LOG("hello\n");
return 0;
}
三、利用变参函数和可变参数宏实现自己的代码模块¶
1.实现函数重载¶
前边我们定义过CONNECT2, CONNECT3的宏,如果我们要粘连的字符串数量不同,比如,2个、4个、5个……n个,我们就要编写对应的版本:
#define __CONNECT2(__0, __1) __0##__1
#define __CONNECT3(__0, __1, __2) __0##__1##__2
#define __CONNECT4(__0, __1, __2, __3) __0##__1##__2##__3
...
#define __CONNECT8(__0, __1, __2, __3, __4, __5, __6, __7) \
__0##__1##__2##__3##__4##__5##__6##__7
#define __CONNECT9(__0, __1, __2, __3, __4, __5, __6, __7, __8) \
__0##__1##__2##__3##__4##__5##__6##__7##__8
//! 安全“套”
#define CONNECT2(__0, __1) __CONNECT2(__0, __1)
#define CONNECT3(__0, __1, __2) __CONNECT3(__0, __1, __2)
#define CONNECT4(__0, __1, __2, __3) __CONNECT4(__0, __1, __2, __3)
...
#define CONNECT8(__0, __1, __2, __3, __4, __5, __6, __7) \
__CONNECT8(__0, __1, __2, __3, __4, __5, __6, __7)
#define CONNECT9(__0, __1, __2, __3, __4, __5, __6, __7, __8) \
__CONNECT9(__0, __1, __2, __3, __4, __5, __6, __7, __8)
比如,我们举一个组装16进制数字的例子:
#define HEX_U8_VALUE(__B1, __B0) \
CONNECT3(0x, __B1, __B0)
#define HEX_U16_VALUE(__B3, __B2, __B1, __B0) \
CONNECT5(0x, __B3, __B2, __B1, __B0)
#define HEX_U32_VALUE(__B7, __B6, __B4, __B4, __B3, __B2, __B1, __B0)\
CONNECT9(0x, __B7, __B6, __B4, __B4, __B3, __B2, __B1, __B0)
#define HEX_U8_VALUE(__B1, __B0) \
CONNECT(0x, __B1, __B0)
#define HEX_U16_VALUE(__B3, __B2, __B1, __B0) \
CONNECT(0x, __B3, __B2, __B1, __B0)
#define HEX_U32_VALUE(__B7, __B6, __B4, __B4, __B3, __B2, __B1, __B0)\
CONNECT(0x, __B7, __B6, __B4, __B4, __B3, __B2, __B1, __B0)
无论实际给出的参数是多少个,我们都可以使用同一个参数宏CONNECT(),而CONNCT() 会自动计算用户给出参数的个数,从而正确的替换为CONNETn()版本。假设这一切都是可能做到的,那么实际上我们还可以对上述宏定义进行简化:
#define HEX_VALUE(...) CONNECT(0x, __VA_ARGS__)
怎么实现宏的重载呢?为了简化这个问题,我们假设有一个“魔法宏”:它可以告诉我们用户实际传递了多少个参数,我们不妨叫它 VA_NUM_ARGS():
#define VA_NUM_ARGS(...) /* 这里暂时先不管怎么实现 */
#define CONNECT(...) \
CONNECT2(CONNECT, VA_NUM_ARGS(__VA_ARGS__)) /*part1*/\
(__VA_ARGS__) /*part2*/
假设用户想用 HEX_VALUE() 组装一个数字
uint16_t hwValue = HEX_VALUE(D, E, A, D); //! 0xDEAD 它会被首先展开为:
uint16_t hwValue = CONNECT(0x, D, E, A, D);
uint16_t hwValue =
CONNECT2(CONNECT, VA_NUM_ARGS(0x, D, E, A, D))
(0x, D, E, A, D);
uint16_t hwValue =
CONNECT5(0x, D, E, A, D);
#define VA_NUM_ARGS_IMPL(_1,_2,_3,_4,_5,_6,_7,_8,_9,__N,...) __N
#define VA_NUM_ARGS(...) \
VA_NUM_ARGS_IMPL(__VA_ARGS__,9,8,7,6,5,4,3,2,1)
-
这个宏的返回值就是第十个参数的内容;
-
多出来的部分会被"..."吸收掉,不会产生任何后果
VA_NUM_ARGS() 的巧妙在于,它把 __VA_ARGS__ 放在了参数列表的最前面,并随后传递了 "9,8,7,6,5,4,3,2,1" 这样的序号: 1. 当 __VA_ARGS__ 里有1个参数时,“1”对应第十个参数__N,所以返回值是1 2. 当 __VA_ARGS__ 里有2个参数时,“2”对应第十个参数__N,所以返回值是2 ... 3. 当 __VA_ARGS__ 里有9个参数时,"9"对应第十个参数__N,所以返回值是9
如果觉得上述过程似懂非懂,我们不妨对前面的例子做一个展开:
VA_NUM_ARGS(0x, D, E, A, D)
展开为:
VA_NUM_ARGS_IMPL(0x, D, E, A, D,9,8,7,6,5,4,3,2,1)
宏的重载非常有用,可以极大的简化用户"选择困难",你甚至可以将VA_NUM_ARGS() 与 函数名结合在一起,从而实现简单的函数重载(即,函数参数不同的时候,可以通过这种方法在编译阶段有预编译器根据用户输入参数的数量自动选择对应的函数),比如:
extern device_write1(const char *pchString);
extern device_write2(uint8_t *pchStream, uint_fast16_t hwLength);
extern device_write3(uint_fast32_t wAddress, uint8_t *pchStream, uint_fast16_t hwLength);
#define device_write(...) \
CONNECT2(device_write, VA_NUM_ARGS(__VA_ARGS__)) \
(__VA_ARGS__)
device_write("hello world"); //!< 发送字符串
extern uint8_t chBuffer[32];
device_write(chBuffer, 32); //!< 发送缓冲
//! 向指定偏移量写数据
#define LCD_DISP_MEM_START 0x4000xxxx
extern uint16_t hwDisplayBuffer[320*240];
device_write(
LCD_DISP_MEM_START,
(uint8_t *)hwDisplayBuffer,
sizeof(hwDisplayBuffer)
);
2.实现using结构¶
在C#中有一个类似的语法,叫做 using(),其典型的用法如下:
using (StreamReader tReader = File.OpenText(m_InputTextFilePath))
{
while (!tReader.EndOfStream)
{
...
}
}
- 当用于代码离开 using 结构的时候,using 会自动执行一个“扫尾工作”,而这个扫尾工作是对应的类事先定义好的。在上述例子中,所谓的扫尾工作就是关闭 与 类StreamReader的实例tReader 所关联的文件——简单说就是using会自动把文件关闭,而不必用户亲自动手。
要实现类似using的结构,首先要考虑如何构造一个"至执行一次"的for循环结构。要做到这一点,毫无难度:
for (int i = 1; i > 0; i++) {
...
}
#define using(__declare, __on_enter_expr, __on_leave_expr) \
for (__declare, *_ptr = NULL; \
_ptr++ == NULL ? \
((__on_enter_expr),1) : 0; \
__on_leave_expr \
)
using(int a = 0,printf("========= On Enter =======\r\n"),
printf("========= On Leave =======\r\n"))
{
printf("\t In Body a=%d \r\n", ++a);
}
========= On Enter =======
In Body a=1
========= On Leave =======
for (int a = 0, *_ptr = NULL;
_ptr++ == NULL ? ((printf("========= On Enter =======\r\n")),1) : 0;
printf("========= On Leave =======\r\n") )
{
printf("\t In Body a=%d \r\n", ++a);
}
#define using(__declare, __on_enter_expr, __on_leave_expr) \
for (__declare, *CONNECT3(__using_, __LINE__,_ptr) = NULL; \
CONNECT3(__using_, __LINE__,_ptr)++ == NULL ? \
((__on_enter_expr),1) : 0; \
__on_leave_expr \
)
更进一步,如果用户有不同的需求:比如想定义两个以上的局部变量,或是想省确 __on_enter_expr 或者是 __on_leave_expr ——我们完全可以定义多个不同版本的 using:
#define __using1(__declare) \
for (__declare, *CONNECT3(__using_, __LINE__,_ptr) = NULL; \
CONNECT3(__using_, __LINE__,_ptr)++ == NULL; \
)
#define __using2(__declare, __on_leave_expr) \
for (__declare, *CONNECT3(__using_, __LINE__,_ptr) = NULL; \
CONNECT3(__using_, __LINE__,_ptr)++ == NULL; \
__on_leave_expr \
)
#define __using3(__declare, __on_enter_expr, __on_leave_expr) \
for (__declare, *CONNECT3(__using_, __LINE__,_ptr) = NULL; \
CONNECT3(__using_, __LINE__,_ptr)++ == NULL ? \
((__on_enter_expr),1) : 0; \
__on_leave_expr \
)
#define __using4(__dcl1, __dcl2, __on_enter_expr, __on_leave_expr) \
for (__dcl1, __dcl2, *CONNECT3(__using_, __LINE__,_ptr) = NULL; \
CONNECT3(__using_, __LINE__,_ptr)++ == NULL ? \
((__on_enter_expr),1) : 0; \
__on_leave_expr \
)
#define using(...) \
CONNECT2(__using, VA_NUM_ARGS(__VA_ARGS__))(__VA_ARGS__)
3.实现一个原子操作宏¶
我们曾有意无意的提供过一个实现原子操作的封装:即在代码的开始阶段关闭全局中断并记录此前的中断状态;执行用户代码后,恢复关闭中断前的状态。其代码如下:
#define SAFE_ATOM_CODE(...) \
{ \
uint32_t CONNECT2(temp, __LINE__) = __disable_irq(); \
__VA_ARGS__ \
__set_PRIMASK((CONNECT2(temp, __LINE__))); \
}
#define SAFE_NAME(__NAME) CONNECT3(__,__NAME,__LINE__)
# define safe_atom_code() \
using( uint32_t SAFE_NAME(temp) = \
({ uint32_t SAFE_NAME(temp2)=__get_PRIMASK(); \
__disable_irq(); \
SAFE_NAME(temp2);}), \
__set_PRIMASK(SAFE_NAME(temp)))
4.实现foreach结构¶
很多高级语言都有专门的 foreach 语句,用来实现对数组(或是链表)中的元素进行逐一访问。原生态C语言并没有这种奢侈,即便如此,Linux也定义了一个“野生”的 foreach 来实现类似的功能。为了演示如何使用 using 结构来构造 foreach,我们不妨来看一个例子:
typedef struct example_lv0_t {
uint32_t wA;
uint16_t hwB;
uint8_t chC;
uint8_t chID;
} example_lv0_t;
example_lv0_t s_tItem[8] = {
{.chID = 0},
{.chID = 1},
{.chID = 2},
{.chID = 3},
{.chID = 4},
{.chID = 5},
{.chID = 6},
{.chID = 7},
};
foreach(example_lv0_t, s_tItem) {
printf("Processing item with ID = %d\r\n", _.chID);
}
这里的难点在于,如何定义一个局部的指针,并且它的作用范围仅仅只覆盖 foreach 的循环体。 __with1() 的功能就是允许用户定义一个局部变量,并覆盖由第三方所编写的、由 {} 包裹的区域:
#define dimof(__array) (sizeof(__array)/sizeof(__array[0]))
#define foreach(__type, __array) \
__using1(__type *_p = __array) \
for ( uint_fast32_t CONNECT2(count,__LINE__) = dimof(__array); \
CONNECT2(count,__LINE__) > 0; \
_p++, CONNECT2(count,__LINE__)-- \
)
for (example_lv0_t *_p = s_tItem, *__using_177_ptr = NULL;
__using_177_ptr++ == NULL ? ((_p = _p),1) : 0;
)
for ( uint_fast32_t count177 = (sizeof(s_tItem)/sizeof(s_tItem[0]));
count177 > 0;
_p = _p+1, count177-- )
{
printf("Processing item with ID = %d\r\n", (*_p).chID);
}
#define foreach2(__type, __array) \
using(__type *_p = __array) \
for ( uint_fast32_t CONNECT2(count,__LINE__) = dimof(__array); \
CONNECT2(count,__LINE__) > 0; \
_p++, CONNECT2(count,__LINE__)-- \
)
#define foreach3(__type, __array, __item) \
using(__type *_p = __array, *__item = _p, _p = _p, ) \
for ( uint_fast32_t CONNECT2(count,__LINE__) = dimof(__array); \
CONNECT2(count,__LINE__) > 0; \
_p++, __item = _p, CONNECT2(count,__LINE__)-- \
)
#define foreach(...) \
CONNECT2(foreach, VA_NUM_ARGS(__VA_ARGS__))(__VA_ARGS__)
foreach(example_lv0_t, s_tItem, ptItem) {
printf("Processing item with ID = %d\r\n", ptItem->chID);
}
for (example_lv0_t *_p = s_tItem, ptItem = _p, *__using_177_ptr = NULL;
__using_177_ptr++ == NULL ? ((_p = _p),1) : 0;
)
for ( uint_fast32_t count177 = (sizeof(s_tItem)/sizeof(s_tItem[0]));
count177 > 0;
_p = _p+1, ptItem = _p, count177-- )
{
printf("Processing item with ID = %d\r\n", ptItem->chID);
}