不只是printf:探究C/C++语言中的可变参数函数

本文涉及的产品
云解析 DNS,旗舰版 1个月
日志服务 SLS,月写入数据量 50GB 1个月
全局流量管理 GTM,标准版 1个月
简介: 不只是printf:探究C/C++语言中的可变参数函数

前言

可变参数函数是一种可以接受任意数量、任意类型的参数的函数,在C语言和C++中都有很广泛的应用。可变参数函数的基本概念是在函数定义时使用省略号(…)来表示函数可以接受可变数量的参数,然后通过使用特定的函数库或者语言特性来处理这些参数。
可变参数函数有很多应用场景,最常见的就是格式化输出函数,如printf和sprintf。这些函数可以接受一个格式化字符串和一个可变数量的参数,然后根据格式化字符串的格式将这些参数格式化输出。可变参数函数还可以用于实现各种日志输出函数、类型转换函数、变长参数列表的处理等。
虽然可变参数函数很方便,但是也有一些问题需要注意。首先,可变参数函数的参数数量和类型比较灵活,容易出错。其次,可变参数函数的类型检查比较困难,容易导致运行时错误。因此,在使用可变参数函数时,需要注意参数的类型和数量匹配,保证函数的安全性和正确性。
C/C++ 语言支持可变参数函数(Variable Argument Functions),即参数的个数可以是不定个,在函数定义的时候用(…)表示,比如我们常用的printf()函数:int printf(const char *format, ...);
在本文中,我们将探讨C/C++语言中可变参数函数的概念、实现方法、应用场景、安全性和最佳实践,希望本文能够对读者了解和使用可变参数函数有所帮助。


C/C++语言中的可变参数函数概述

C/C++语言中的可变参数函数是一种可以接受任意数量、任意类型的参数的函数。在C语言中,可变参数函数使用stdarg.h头文件中的函数库来实现;在C++语言中,可变参数函数使用std::stdarg头文件中的函数库来实现。
可变参数函数的基本原理是使用省略号(…)来表示函数可以接受可变数量的参数,然后通过使用特定的函数库或者语言特性来处理这些参数。在C语言中,使用stdarg.h头文件中的va_list、va_start、va_arg、va_copy和va_end等函数来处理可变参数;在C++语言中,使用std::stdarg头文件中的std::tuple、std::apply等模板来处理可变参数。
在C语言中,使用stdarg.h头文件中的函数库来实现可变参数函数。其中,va_list是一个指向参数列表的指针,va_start用于初始化参数列表,va_arg用于获取参数列表中的参数,va_end用于结束参数列表的处理。需要注意的是,va_arg只能获取参数的类型和参数列表中的顺序相匹配的参数。
在C++语言中,使用std::stdarg头文件中的函数库来实现可变参数函数。其中,std::tuple是一个模板类,用于存储任意数量、任意类型的参数;std::apply用于将可变参数函数的参数列表作为一个std::tuple对象传递给可变参数函数。
总之,C/C++语言中的可变参数函数是一种非常灵活和方便的函数类型,可以用于处理各种变长参数列表的情况。在使用可变参数函数时,需要注意参数的类型和数量匹配,保证函数的安全性和正确性。

printf函数的内部实现

printf函数是C语言中最常用的可变参数函数之一,其基本用法是将一个格式化字符串和一个可变数量的参数传递给函数,然后根据格式化字符串的格式将这些参数格式化输出。printf函数的参数列表包含一个格式化字符串和一系列可变参数,格式化字符串用于指定输出格式,可变参数用于填充输出格式的占位符。
printf函数的内部实现原理是通过使用可变参数函数的方式来处理参数列表。printf函数首先使用va_start函数来初始化可变参数列表,然后遍历格式化字符串中的每个字符,如果遇到占位符,则根据占位符的类型从可变参数列表中获取对应的参数值,然后将参数值格式化输出。在处理占位符时,printf函数会根据占位符的类型进行不同的处理,如整型、浮点型、字符型、字符串型等。
在printf函数内部,还需要对格式化字符串进行解析和处理。printf函数将格式化字符串分解成一个个的格式化指令,然后对每个指令进行解析和处理,包括格式化字符、字段宽度、精度、标志位等。在处理格式化指令时,printf函数需要考虑到各种边界情况和错误处理,如格式化字符串错误、参数类型不匹配、参数越界等。
总之,printf函数的内部实现原理是通过使用可变参数函数和格式化字符串解析来实现的。printf函数需要对参数列表和格式化字符串进行逐个处理,保证输出结果的正确性和安全性。在使用printf函数时,需要注意格式化字符串的格式和参数类型的匹配,避免出现运行时错误。

可变参数函数的实现方法

可变参数函数的实现方法有两种:递归处理和指针偏移。
这两种方法都是通过使用va_list、va_start、va_arg、va_copy、va_end等函数来处理可变参数。
递归处理是可变参数函数的常用实现方法之一。在递归处理中,可变参数函数首先获取第一个参数,并根据参数的类型进行处理,然后通过递归调用处理下一个参数。在每次递归调用中,可变参数函数使用va_arg函数来获取下一个参数,并根据参数的类型进行处理,直到处理完所有的参数。递归处理适用于参数个数较少的情况,处理速度较快。
指针偏移是可变参数函数的另一种实现方法。在指针偏移中,可变参数函数首先使用va_start函数来初始化可变参数列表,然后通过指针偏移来访问参数列表中的每个参数。在处理完所有参数之后,可变参数函数使用va_end函数来结束参数列表的处理。指针偏移适用于参数个数较多的情况,处理速度较慢。
在使用可变参数函数时,需要注意使用va_list、va_start、va_arg、va_copy、va_end等函数的正确用法。其中,va_list是一个指向参数列表的指针,va_start用于初始化参数列表,va_arg用于获取参数列表中的参数,va_copy用于拷贝可变参数列表,va_end用于结束参数列表的处理。需要注意的是,va_arg只能获取参数的类型和参数列表中的顺序相匹配的参数。
总之,可变参数函数的实现方法有递归处理和指针偏移两种。在使用可变参数函数时,需要注意使用va_list、va_start、va_arg、va_copy、va_end等函数的正确用法,保证函数的正确性和安全性。

可变参数函数取参接口

接口函数:

  • va_list ptr: 定义一个指向可变参数列表的指针
  • va_start(ptr,a):初始化指针,其中第二个参数为函数可变参数列表之前的固定参数
  • va_arg(ptr,int):取出指针指向的元素,第二个参数为元素的类型,返回值为取出的元素,同时指针后移。
  • va_end(ptr) : 还原ptr指针

void va_start(va_list ap, last);//取第一个可变参数(如上述printf中的i)的指针给ap,last是函数声明中的最后一个固定参数(比如printf函数原型中的*fromat);
type va_arg(va_list ap, type);//返回当前ap指向的可变参数的值,然后ap指向下一个可变参数;type表示当前可变参数的类型(支持的类型位int和double);
void va_end(va_list ap);//将ap置为NULL


  • 说明

当一个函数被定义位可变参数函数时,其函数体内首先要定义一个va_list的结构体类型,这里沿用原型中的名字,ap。
va_start使ap指向第一个可选参数。va_arg返回参数列表中的当前参数并使ap指向参数列表中的下一个参数。
va_end把ap指针清为NULL。函数体内可以多次遍历这些参数,但是都必须以va_start开始,并以va_end结尾。

除此之外,我们还需要注意一个陷阱,即va_arg宏的第2个参数不能被指定为char、short或者float类型。
《C和C++经典著作:C陷阱与缺陷》在可变参数函数传递时,因为char和short类型的参数会被提升为int类型,而float类型的参数会被提升为double类型 。


  • 示例
#include <stdarg.h>



double average(int count, ...)

{

   va_list ap;

   int j;

   double tot = 0;

   va_start(ap, count); //使va_list指向起始的參數

   for(j=0; j<count; j++)

       tot+=va_arg(ap, double); //檢索參數,必須按需要指定類型

   va_end(ap); //釋放va_list

   return tot/count;

}

__VA_ARGS__和##VA_ARGS(需要配合 define 使用)

  • __VA_ARGS__介绍

To use variadic macros, the ellipsis may be specified as the final formal argument in a macro definition, and the replacement identifier VA_ARGS may be used in the definition to insert the extra arguments. VA_ARGS is replaced by all of the arguments that match the ellipsis, including commas between them.
The C Standard specifies that at least one argument must be passed to the ellipsis, to ensure that the macro does not resolve to an expression with a trailing comma. The Visual C++ implementation will suppress a trailing comma if no arguments are passed to the ellipsis.


__VA_ARGS__是一个可变参数的宏,这个可变参数的宏是新的C99规范中新增的)。实现思想就是宏定义中参数列表的最后一个参数为省略号(也就是三个点)。

__VA_ARGS__缺点

  • 只支持字符串,不支持可变参数或者多个参数;[VA_ARGS]只能是一些不含任何变量的字符串常量。
  • 如果 VA_ARGS 含有变量,整个 printf 输出与变量便不能一一对应,输出会出错;

// variadic_macros.cpp
#include <stdio.h>
#define EMPTY

#define CHECK1(x, ...) if (!(x)) { printf(__VA_ARGS__); }
#define CHECK2(x, ...) if ((x)) { printf(__VA_ARGS__); }
#define CHECK3(...) { printf(__VA_ARGS__); }
#define MACRO(s, ...) printf(s, __VA_ARGS__)

int main() {
    CHECK1(0, "here %s %s %s", "are", "some", "varargs1(1)\n");
    CHECK1(1, "here %s %s %s", "are", "some", "varargs1(2)\n");   // won't print

    CHECK2(0, "here %s %s %s", "are", "some", "varargs2(3)\n");   // won't print
    CHECK2(1, "here %s %s %s", "are", "some", "varargs2(4)\n");

    // always invokes printf in the macro
    CHECK3("here %s %s %s", "are", "some", "varargs3(5)\n");

    MACRO("hello, world\n");

    MACRO("error\n", EMPTY); // would cause error C2059, except VC++ 
                             // suppresses the trailing comma 
 }

  • ##VA_ARGS 介绍

##__VA_ARGS__ 宏前面加上##的作用在于,当可变参数的个数为0时,这里的##起到把前面多余的","去掉的作用,否则会编译出错。

如果可变参数被忽略或为空,## 操作将使预处理器(preprocessor)去除掉它前面的那个逗号.
如果你在宏调用时,确实提供了一些可变参数,GNU CPP 也会工作正常,它会把这些可变参数放到逗号的后面。

  • 示例
#include <stdio.h>
#include <time.h>

//带有更多的调试信息如时间,TAG,文件名和函数名与行数
#define TAG "MY_MODULE_NAME"

//第一种,精确到秒 /*
#define LOGPRINT(format, ...) do\
    {\
        time_t cur_time = time(NULL);\
        struct tm *ptm = gmtime(&cur_time);\
        printf("[%d-%02d-%02d %02d:%02d:%02d [%s] %s:%s:%d]"format, ptm->tm_year + 1900, ptm->tm_mon + 1, \
            ptm->tm_mday, ptm->tm_hour, ptm->tm_min, ptm->tm_sec, TAG, __FILE__,__func__, __LINE__, ##__VA_ARGS__);\
        printf("\n");\
    }while(0)\
#endif
*/ //第二种方式 精确到毫秒
#define LOGPRINT(format, ...) do\
    {\
        char _format[256] = "";\
        struct timeval tv;\
        struct tm * tm;\
        gettimeofday(&tv, NULL);\
        tm = localtime(&tv.tv_sec);\
        strftime(_format, sizeof(_format), "%c", tm);\
        sprintf(_format+strlen(_format), " [%03d] [%s][%s:%s:%d]", (tv.tv_usec + 1)/1000,\
            TAG, __FILE__, __func__, __LINE__);\
        strcat(_format, format);\
        printf(_format, ##__VA_ARGS__);\
        printf("\n");\
    }while(0)\


#define LOPRINT2(...) do\
    {\
        printf(__VA_ARGS__);\
        printf("\n");\
    }while(0)\

int main(int argc, char *argv[]) {
    LOGPRINT("log print test");
    LOGPRINT("[%s] more args test", "MORE_ARGS");

    LOGPRINT2("LOGPRINT2 one argc test");
    LOGPRINT2("[%s] more args test", "MORE_ARGS");
    return 0; }

第一个参数(形如const char *format)有名形参的作用

  • 用来指出后续形参的数目和类型,例如printf函数就是通过%X的形式来指定的,有多少个%就有多少个后续参数,参数的类型由%后面的标识符来指定,例如%c,%d,%X。实际上fmt字符串就是一套标准化的传输协议而已,你完全可以自定义一套你自己的实参解析协议。
  • 用来提供本函数的栈的首地址,只有借助这个首地址,才能根据偏移取出后续参数。换句话说,如果你拿不到本函数运行时栈的地址,根本就无法取参。

C++11中的可变参数模板

C++11中新增了可变参数模板,可以用于实现与C语言中可变参数函数类似的功能。可变参数模板是一种模板类,可以接受任意数量、任意类型的参数。
使用可变参数模板时,需要使用std::tuple模板类来存储可变参数列表。std::tuple是一种元组类型,可以存储任意数量、任意类型的参数。使用std::apply函数可以将可变参数模板的参数列表作为一个std::tuple对象传递给可变参数模板,然后对参数进行处理。
可变参数模板的基本用法是在模板参数列表中使用省略号(…)来表示可变数量的参数,然后在模板类定义中使用std::tuple来存储可变参数列表,使用std::apply函数来对参数列表进行处理。
例如,下面是一个简单的可变参数模板示例:

template<typename... Args>
void my_printf(const char* format, Args... args)
{
   std::tuple<Args...> t(args...);
   // 使用std::apply函数对std::tuple中的参数进行处理
   std::apply([&](auto... x) { std::printf(format, x...); }, t);
}

在上面的示例中,my_printf是一个可变参数模板函数,使用std::tuple来存储可变参数列表,使用std::apply函数将参数列表作为一个std::tuple对象传递给printf函数进行处理。
总之,C++11中新增的可变参数模板可以用于实现与C语言中可变参数函数类似的功能。使用可变参数模板时,需要使用std::tuple模板类来存储可变参数列表,使用std::apply函数来对参数列表进行处理。可变参数模板可以大大提高代码的可读性和可维护性。


变参数函数的应用场景

  • 格式化输出:最常见的应用就是printf和sprintf函数,可以根据格式化字符串的格式将参数列表中的参数格式化输出。
  • 日志输出:日志输出函数通常需要接受可变数量的参数,可以使用可变参数函数来处理。
  • 类型转换:有时需要将一个参数列表中的参数转换为另一种类型,也可以使用可变参数函数来处理。
  • 变长参数列表:有时需要处理不定数量的参数,如可变长度的数组、可变数量的变量等,也可以使用可变参数函数来处理。
  • 动态函数调用:有时需要调用不同的函数并传递不同数量和类型的参数,可以使用可变参数函数来实现。
  • 模板元编程:模板元编程是一种C++编程技术,可以在编译期间进行计算和类型推导。可变参数模板可以用于实现模板元编程中的可变参数列表。
  • 反射:反射是一种程序能够访问、检测和修改自己的结构的能力。可变参数函数可以用于实现反射机制中的可变参数列表。
  • 回调函数:回调函数是一种常见的编程模式,可变参数函数可以用于实现回调函数中的可变参数列表。
  • 容器类:容器类是一种常见的数据结构,可变参数函数可以用于实现容器类中的可变参数列表。

总之,可变参数函数在实际编程中有很多用途,可以用于处理动态函数调用、模板元编程、反射、回调函数、容器类等情况。使用可变参数函数时,需要注意参数的类型和数量匹配,保证函数的安全性和正确性。


可变参数函数的安全性和最佳实践

保证可变参数函数的安全性是非常重要的,可以通过以下几种方式来实现:

  • 参数类型匹配:可变参数函数中的参数类型必须与参数列表中的类型匹配,否则会导致运行时错误。可以使用类型检查来保证参数类型的匹配性。
  • 参数个数匹配:可变参数函数中的参数个数必须与参数列表中的个数匹配,否则会导致运行时错误。可以使用参数个数检查来保证参数个数的匹配性。
  • 参数范围检查:可变参数函数中的参数必须满足一定的范围要求,如不能越界、不能为空等。可以使用参数范围检查来保证参数的正确性和安全性。
  • 错误处理:可变参数函数中可能会出现各种错误,如参数类型不匹配、参数个数不匹配、参数越界等。可以使用错误处理机制来处理这些错误,如抛出异常、返回错误码等。

在使用可变参数函数时,需要遵循以下最佳实践:

  • 使用宏定义:可以使用宏定义来简化可变参数函数的调用,提高代码的可读性和可维护性。
  • 使用函数重载:可以使用函数重载来实现不同数量、不同类型的参数处理,提高代码的可读性和可维护性。
  • 使用默认参数:可以使用默认参数来简化可变参数函数的调用,提高代码的可读性和可维护性。
  • 使用格式化字符串:可以使用格式化字符串来指定参数的类型和数量,避免参数类型和数量的匹配错误。

总之,保证可变参数函数的安全性是非常重要的,可以通过参数类型匹配、参数个数匹配、参数范围检查、错误处理等方式来实现。在使用可变参数函数时,需要遵循最佳实践,如使用宏定义、函数重载、默认参数、格式化字符串等方式来提高代码的可读性和可维护性。

C/C++语言可变参数函数取参示例

方法1:通过地址偏移的方式从栈中取出实参

#pragma once
#include <stdio.h>
#include <stdlib.h>

void printArgs(char* format, char* stackPtr);

int main(void) {
   char* format = "test:%!d(MISSING)-%!c(MISSING)-%!f(MISSING)-%!s(MISSING).";
   int num = 20;
   char ch = 'a';
   double dnum = 11.22;
   char* str = "finish";
   int argCount = 4;
   int stackSize = sizeof(int) + sizeof(char) + sizeof(int) + sizeof(char*);

   // 分配栈空间并初始化为0
   char* stack = (char*)calloc(stackSize, sizeof(char));
   if (stack == NULL) {
       printf("Failed to allocate stack.\n");
       return -1;
   }

   // 将参数依次压入栈中,注意使用int类型的变量进行对齐
   char* stackPtr = stack;
   *(int*)stackPtr = num;
   stackPtr += sizeof(int);
   *(int*)stackPtr = (int)ch;
   stackPtr += sizeof(int);
   *(double*)stackPtr = dnum;
   stackPtr += sizeof(double);
   *(char**)stackPtr = str;

   // 调用函数打印参数
   printArgs(format, stack);

   // 释放栈空间
   free(stack);

   return 0;
}

void printArgs(char* format, char* stackPtr) {
   // 打印格式字符串
   printf("%!s(MISSING)", format);

   // 逐个获取参数值并打印
   while (*format != '\0') {
       if (*format == '%!'(MISSING)) {
           format++;
           switch (*format) {
               case 'd': {
                   int val = *(int*)stackPtr;
                   printf("%!d(MISSING)", val);
                   stackPtr += sizeof(int);
                   break;
               }
               case 'f': {
                   double val = *(double*)stackPtr;
                   printf("%!f(MISSING)", val);
                   stackPtr += sizeof(double);
                   break;
               }
               case 'c': {
                   int val = *(int*)stackPtr;
                   printf("%!c(MISSING)", (char)val);
                   stackPtr += sizeof(int); //注意,每次压栈都是int的整数倍,所以sizeof(char)应替换为sizeof(int)
                   break;
               }
               case 's': {
                   char* val = *(char**)stackPtr;
                   printf("%!s(MISSING)", val);
                   stackPtr += sizeof(char*);
                   break;
               }
               default:
                   break;
           }
       } else {
           printf("%!c(MISSING)", *format);
       }
       format++;
   }

   printf("\n");
}

方法2:使用C库函数从栈取出实参

#pragma once

#include <iostream>
    va_list ptr;//等价于char * ptr;
    va_start(ptr, para_cnt);
    para1 = va_arg(ptr, int);
    para2 = va_arg(ptr, char);
    para3 = va_arg(ptr, double);
    para4 = va_arg(ptr, float);
    va_end(ptr);//本质上就是ptr = NULL,显然,这一句有和没有对程序没啥影响
    printf("库函数取出的实参: %d, %c, %f, %f\r", para1, para2, para3, para4);


总结

本文主要介绍了可变参数函数在C/C++语言中的概念、实现方法、应用场景、安全性和最佳实践等方面的内容。可变参数函数是一种非常有用的编程技术,可以用于处理不定数量和类型的参数,例如格式化输出、日志输出、类型转换、变长参数列表等。在使用可变参数函数时,需要注意参数的类型和数量匹配,保证函数的安全性和正确性,并遵循最佳实践,提高代码的可读性和可维护性。

展望:

随着计算机科学和软件工程的不断发展,可变参数函数在C/C++语言中的应用越来越广泛。未来,可变参数函数可能会在以下方面得到进一步的发展:
更加安全的实现方式:随着编程语言和编译器的不断发展,可变参数函数的实现方式可能会更加安全,例如使用模板元编程、类型推导等技术来提高安全性。
更加优化的性能:随着计算机硬件和系统的不断升级,可变参数函数的性能可能会得到进一步的优化,例如使用向量化技术、多线程技术等来提高性能。
更加智能的应用场景:随着人工智能和大数据技术的不断发展,可变参数函数可能会在更加智能的应用场景得到应用,例如自动化测试、机器学习、自然语言处理等。

参考文献


C++ 可变参数模板. https://zh.cppreference.com/w/cpp/language/parameter_pack
C++可变参数模板探索:编程技巧与实战应用
C++ 可变参数模板的妙用:解决参数不足问题


目录
相关文章
|
25天前
|
程序员 C++ 容器
在 C++中,realloc 函数返回 NULL 时,需要手动释放原来的内存吗?
在 C++ 中,当 realloc 函数返回 NULL 时,表示内存重新分配失败,但原内存块仍然有效,因此需要手动释放原来的内存,以避免内存泄漏。
|
29天前
|
算法 C++
2022年第十三届蓝桥杯大赛C/C++语言B组省赛题解
2022年第十三届蓝桥杯大赛C/C++语言B组省赛题解
28 5
|
1月前
|
存储 前端开发 C++
C++ 多线程之带返回值的线程处理函数
这篇文章介绍了在C++中使用`async`函数、`packaged_task`和`promise`三种方法来创建带返回值的线程处理函数。
45 6
|
1月前
|
C++
C++ 多线程之线程管理函数
这篇文章介绍了C++中多线程编程的几个关键函数,包括获取线程ID的`get_id()`,延时函数`sleep_for()`,线程让步函数`yield()`,以及阻塞线程直到指定时间的`sleep_until()`。
22 0
C++ 多线程之线程管理函数
|
1月前
|
编译器 C语言 C++
C++入门6——模板(泛型编程、函数模板、类模板)
C++入门6——模板(泛型编程、函数模板、类模板)
38 0
C++入门6——模板(泛型编程、函数模板、类模板)
|
1月前
|
存储 编译器 C语言
深入计算机语言之C++:类与对象(上)
深入计算机语言之C++:类与对象(上)
|
1月前
|
存储 分布式计算 编译器
深入计算机语言之C++:C到C++的过度-2
深入计算机语言之C++:C到C++的过度-2
|
1月前
|
编译器 Linux C语言
深入计算机语言之C++:C到C++的过度-1
深入计算机语言之C++:C到C++的过度-1
|
4天前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
22 4
|
6天前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
18 4