在C语言中,我们都知道给函数传参,有传址调用和传值调用的差别。但是,很少有书籍、文章专门论述到,C语言的函数传参,还有另外一大类应用,是变参处理。举个例子,我们常用的printf函数,是典型的变参函数,它的参数不固定,可以使用格式化字符控制输出格式。这个大家可能都很熟悉。
变参函数用途很多,其通过设计,对外提供变参接口,允许上层业务层自由地通过格式化字符串来实现对自己输出行为的控制,这在很多debug和syslog日志输出场合很有用,我的书《0bug-C/C++商用工程之道》里面,第五章开篇在讲这个设计方法。这也是几乎所有C底层库进行格式化输出的基本手段。
关于如何使用C语言变参函数,实现有效的字符串格式化处理,我想大家可能很早学会了,但是,近期几个朋友问我问题,我才发现,很多人还是不了解如何设计变参函数。正好,近期我优化我的工程库,特别重新设计的变参函数的处理方法。我这里share一下,供大家参考。
还是那句话哈,一家之言,欢迎拍砖。
由于前期我很多博文,我的书《0bug-C/C++商用工程之道》,都大量讲过变参处理办法,我这里不细讲了,大家有兴趣,可以看看我的SafePrintf这个函数,这在过去很多博文中都出现过了,呵呵,算是“代码明星”了。
Code:
int SafePrintf(char* szBuf,int nMaxLength,char *szFormat, ...) { int nListCount=0; va_list pArgList; if (!szBuf) goto SafePrintf_END_PROCESS; va_start (pArgList,szFormat); nListCount+=Linux_Win_vsnprintf(szBuf+nListCount, nMaxLength-nListCount,szFormat,pArgList); va_end(pArgList); if(nListCount>(nMaxLength-1)) nListCount=nMaxLength-1; *(szBuf+nListCount)=''; SafePrintf_END_PROCESS: return nListCount; }
不过,这里面有个潜在的问题,我一直没有解决好,是说,虽然我提供了一个SafePrintf函数来处理变参,但如果另外一个函数,也提供变参界面,这时候,很不好把自己的变参参数传递给SafePrintf来处理。如下例:
Code:
void Func(char* szFormat,...) { char szBuf[256]; SafePrintf(szBuf,256,...); //??? }
这样直接传递...是肯定错误的,根据ANSI C99的定义,此时要传递变参,必须使用void va_copy(va_list dst, va_list src); 这个宏来处理,以va_list这种隐式数据结构的显式拷贝动作,来把Func这个函数的变参,传递给SafePrintf。并且,由va_copy初始化的va_list在使用结束时必须使用va_end来“释放”。
这显然太麻烦了,我以前一直很抵制这种又是显式,又是隐式,变来变去的接口方式。所以,我在《0bug-C/C++商用工程之道》这本书的库代码中,一直是把中间处理变参这段代码拷来拷去使用,哪个函数处理变参,在哪个函数一开始的地方,来上这么一段,把变参先处理成定参,再向下传递。
不过,这也有问题,我的习惯,同样逻辑的代码只写一次,以后都是调用,避免无谓的笔误和代码冗余。这显然不符合我的习惯,所以,我也一直在想怎么优化这一块。
近期我想了一下,决定采用函数型宏来处理这个问题,这虽然像inline一样,并不能真实地减少代码,但是,它使程序变得很简洁,程序员看起来清清爽爽,同时,由于函数型宏可以固化操作,不会再出现笔误问题,算是个比较好的折中方案。嗯,抵制使用宏的C++er们注意了哈,这是一个inline无法替代宏的实例了。呵呵。
当然,在讨论字符串处理的前面,首先要给大家一些include的头文件,以及一些基本的定义,我呢,懒得一一分辨了,把《0bug-C/C++商用工程之道》的总跨平台include表列出来,大家直接用哈。当然,由于这些定义,下面的代码必然是跨平台的。
Code:
#include <stdio.h> #include <stdlib.h> #include <stdarg.h> #include <time.h> #include <fcntl.h> #include <signal.h> #ifdef WIN32 #include <conio.h> #include <windows.h> #include <process.h> #include <winsock.h> #else // not WIN32 #include <unistd.h> #include <errno.h> #include <pthread.h> #include <fcntl.h> #include <unistd.h> #include <netinet/in.h> #include <string.h> #include <sys/time.h> #include <arpa/inet.h> #include <errno.h> #include <termios.h> #include <netdb.h> #include <getopt.h> #include <netinet/in.h> #include <arpa/inet.h> #include <unistd.h> #endif //////////////////////////////////////////////////////////////////// #ifdef WIN32 #pragma warning (disable : 4800) #pragma warning (disable : 4996) #pragma warning (disable : 4200) #pragma warning (disable : 4244) #pragma warning (disable : 4010) #define Linux_Win_vsnprintf _vsnprintf #else // not WIN32 #define Linux_Win_vsnprintf vsnprintf #endif #ifndef null #define null 0 #endif
开始做事,我首先作了如下函数型宏代码:
Code:
#define TONY_FORMAT(nPrintLength,szBuf,nBufferSize,szFormat) { va_list pArgList; va_start (pArgList,szFormat); nPrintLength+=Linux_Win_vsnprintf(szBuf+nPrintLength, nBufferSize-nPrintLength,szFormat,pArgList); va_end(pArgList); if(nPrintLength>(nBufferSize-1)) nPrintLength=nBufferSize-1; *(szBuf+nPrintLength)=''; }
这个宏有4个参数,我解释一下:
nPrintLength:这个很重要,C的规约,处理变参的函数一般要返回一个int,表示变参展开后,真实的字节数,注意,这里没有包括字符串这个''的位宽,即仅仅是strlen的长度。很多时候,C语言程序员习惯于要采纳这个值参与后续计算,嗯,我们后面有这个例子,所以,外部传进来一个变量nPrintLength,是求这个值。
这也看出来,函数型宏,全部相当于传址调用,可以直接修改外部的变量的值的。
szBuf,nBufferSize:是希望把变参展开,填充到的缓冲区和缓冲区长度,我强调0bug编程,很多时候,外部传入一个缓冲区要求函数填充的时候,都必须给一个边界,避免内存写出界导致崩溃,这个nBufferSize是干这个的,内部的设计会保证不超过这个边界。
szFormat:精华了哈,前面说那么麻烦的va_list传递变参模式,在此简化为直接把szFormat传进来好了。我认为这是这个设计漂亮的一点,大大简化了调用者的程序行为,再也不麻烦了,呵呵。
ok,有了这个宏,我们来改写一下前面经典的SafePrintf看看:
Code:
//安全的变参打印函数 inline int SafePrintf(char* szBuf,int nBufSize,char* szFormat, ...) { if(!szBuf) return 0; if(!nBufSize) return 0; if(!szFormat) return 0; int nRet=0; TONY_FORMAT(nRet,szBuf,nBufSize,szFormat); return nRet; }
大家注意到什么没有?SafePrintf里面复杂的逻辑不见了,全部被整合成为TONY_FORMAT这个函数宏的调用。
嗯,考虑到很多时候,我们做Debug或者日志输出,需要打印的时候自动加上一个时间戳,因此,我又做了变参处理宏的时间戳版本:
Code:
#define TONY_FORMAT_WITH_TIMESTAMP(nPrintLength,szBuf,nBufferSize,szFormat) { time_t t; struct tm *pTM=NULL; time(&t); pTM = localtime(&t); nPrintLength+=SafePrintf(szBuf,nBufferSize,"[%s",asctime(pTM)); szBuf[nPrintLength-1]=''; nPrintLength--; nPrintLength+=SafePrintf(szBuf+nPrintLength,nBufferSize-nPrintLength,"] "); TONY_FORMAT(nPrintLength,szBuf,nBufferSize,szFormat); }
大家注意没,这里面,TONY_FORMAT_WITH_TIMESTAMP马上在调用前面的SafePrintf,以及TONY_FORMAT。这是我做程序的习惯,每个模块写出来是要给人用的,自己往往是第一个用户,函数接口,api设计得好不好,自己一用知道,不好用调整,调整到自己爽为止。把自己站在用户的立场上,把程序调整到自己用起来都“爽”,你的程序能获得用户的好评。
我一直说,“程序员的用户,不仅仅是终端用户,还包括和你自己一样的,甚至是你自己,程序员。”是这个意思,大家能理解吗?
这里面有个细节大家注意一下,asctime这个系统函数很讨厌,它格式化的字符串,后自动带着一个回车,这会打乱我的输出顺序,所以我用了 szBuf[nPrintLength-1]=''; 这句话来回退,消灭这个多余的回车。
当然,有了这个时间戳宏,我们也可以很轻松写出SafePrintf的时间戳版本:
Code:
inline int SafePrintfWithTimestamp(char* szBuf,int nBufSize,char* szFormat, ...) { if(!szBuf) return 0; if(!nBufSize) return 0; if(!szFormat) return 0; int nRet=0; TONY_FORMAT_WITH_TIMESTAMP(nRet,szBuf,nBufSize,szFormat); return nRet; }
还是要给个测试嘛:
Code:
inline void Test_TONY_FORMAT(void) { char szBuf[256]; int nLength=0; nLength=SafePrintf(szBuf,256,"Test: %d",100); printf("[%d] %s ",nLength,szBuf); nLength=SafePrintfWithTimestamp(szBuf,256,"Test: %d",100); printf("[%d] %s ",nLength,szBuf); }
结果:
[9] Test: 100
[36] [Wed May 12 10:10:32 2010] Test: 100
不过,为了仔细甄别,我还是单独写了两个变参处理函数来验证这个变参传递情况,第一个模拟printf,第二个模拟fprintf,大家可以看看代码。
这是printf版本:
Code:
#define TONY_LINE_MAX 1024 //大一行输出的字符数 //输出到控制台 inline int TonyPrintf(bool bWithTimestamp, //是否带时间戳标志 char* szFormat, ...) //格式化字符串 { if(!szFormat) return 0; char szBuf[TONY_LINE_MAX]; int nLength=0; if(!bWithTimestamp) { //注意,由于内部是函数型宏,if...else这个大括号必不可少 TONY_FORMAT(nLength,szBuf,TONY_LINE_MAX,szFormat); } //注意,由于内部是函数型宏,if...else这个大括号必不可少 else { //注意,由于内部是函数型宏,if...else这个大括号必不可少 TONY_FORMAT_WITH_TIMESTAMP(nLength,szBuf,TONY_LINE_MAX,szFormat); } //注意,由于内部是函数型宏,if...else这个大括号必不可少 return printf(szBuf); } inline void TestTonyPrintf(void) { int i=0; double dTest=123.456; unsigned int unTest=0xAABBCC; for(i='A';i<='E';i++) { TonyPrintf(0,"[%d]: %0.2f, %c, 0x%08X ",i,dTest,i,unTest); } for(i='A';i<='E';i++) { TonyPrintf(1,"[%d]: %0.2f, %c, 0x%08X ",i,dTest,i,unTest); } }
运行结果:
[65]: 123.46, A, 0x00AABBCC [66]: 123.46, B, 0x00AABBCC [67]: 123.46, C, 0x00AABBCC [68]: 123.46, D, 0x00AABBCC [69]: 123.46, E, 0x00AABBCC [Wed May 12 09:17:43 2010] [65]: 123.46, A, 0x00AABBCC [Wed May 12 09:17:43 2010] [66]: 123.46, B, 0x00AABBCC [Wed May 12 09:17:43 2010] [67]: 123.46, C, 0x00AABBCC [Wed May 12 09:17:43 2010] [68]: 123.46, D, 0x00AABBCC [Wed May 12 09:17:43 2010] [69]: 123.46, E, 0x00AABBCC
fprintf版本比较麻烦一点,需要先创建一根文件指针。
Code:
//输出到文件 inline int TonyFPrintf(FILE* fp, //文件指针 bool bWithTimestamp, //是否带时间戳标志 char* szFormat, ...) //格式化字符串 { if(!fp) return 0; if(!szFormat) return 0; char szBuf[TONY_LINE_MAX]; int nLength=0; if(!bWithTimestamp) { //注意,由于内部是函数型宏,if...else这个大括号必不可少 TONY_FORMAT(nLength,szBuf,TONY_LINE_MAX,szFormat); } //注意,由于内部是函数型宏,if...else这个大括号必不可少 else { //注意,由于内部是函数型宏,if...else这个大括号必不可少 TONY_FORMAT_WITH_TIMESTAMP(nLength,szBuf,TONY_LINE_MAX,szFormat); } //注意,由于内部是函数型宏,if...else这个大括号必不可少 return fprintf(fp,szBuf); } inline void TestTonyFPrintf(void) { FILE* fp=null; int i=0; double dTest=123.456; unsigned int unTest=0xAABBCC; fp=fopen("test.txt","at"); if(fp) { for(i='A';i<='E';i++) { TonyFPrintf(fp,0,"[%d]: %0.2f, %c, 0x%08X ",i,dTest,i,unTest); } for(i='A';i<='E';i++) { TonyFPrintf(fp,1,"[%d]: %0.2f, %c, 0x%08X ",i,dTest,i,unTest); } fclose(fp); } }
这个函数运行完后,屏幕上没有,不过,磁盘上会出现一个文件,叫做test.txt,里面的内容和前面的一样。
经过这些测试,我认为这次改版基本上成功了,使用这几个变参处理宏,我可以大幅度缩减很多变参函数的书写长度,程序显得很清爽,且功能比较完备。
我的计划是,这些代码目前先自己用,等用个一年半载,稳定性差不多了,在《0bug-C/C++商用工程之道》的第二版中,我会应用到新的工程库中去,供各位读者使用哈。
上述代码在VS2008下测试通过,不过,我的理解是跨平台的,由于全部是C的函数,处理的都是函数内部私有变量,因此,也是线程安全的。
大家看看,有问题再问哈。