C 语言中宏的使用

50 篇文章 1 订阅

作者:  Hai Shalom                                      译者: KISSMonX

原文地址:http://www.rt-embedded.com/blog/archives/macros-in-the-c-programming-language/

===============================================================================================================================

///
// 声明:
// 本人水平相当有限, 英文很烂, 不是谦虚, 翻译这两篇文章纯属 5.1 节
// 没事干, 外加学习英文, 专业知识.
// 括号中 ??? 代表意思不确定. 当然可能还有很多我认为对的其实是错的.
// 翻译/理解出错很抱歉, 欢迎指正, 不胜感激, 谢谢大家了!! (联系: omonkerman@gmail.com)
///

===============================================================================================================================

宏的使用在C语言中很常见, 频繁用于对常量的声明, 例如字符串, 地址或者像表示数组最大容量等常量的声明. 对于这些类型, C 都是用 #define 指令声明的. 宏也常用于定义一些类似于内联函数这种能完成基本的功能, 只接受一个或者更多无类型参数的函数.

总的来说,宏描述的代码有以下优势:

a)     可维护性 (maintainability):   当确定一个值需要被修改的时候, 用宏定义的常量可以提供一个简便而正确的方法. 并且代码中的所有实例都将自动的被修正(确切的说应该是被替换掉了). 不然的话, 程序可能会出现异常 (incorrectly) 甚至崩溃(crash) .

b)     可读性(readability):   对于一个需要阅读你代码的人来说, 相比于单纯地在代码中嵌用地址值或者数字来说, 用宏名来替换自然要更易读, 更易懂. 

当然,它也会有些不足

1)    难以扩展 (hard to extend) :   当这些宏变得越来越复杂而又需要改变的时候, 引入的任何错误都将可能导致编译器产生含混不清的编译错误(yield vague compilation errors).  要命的是, 这些编译错误的行号常常指向是宏定义的开始处, 很难弄清楚错误究竟在哪里.

2)    调试困难 (hard to debug):  对于一个内嵌用宏的代码来说, 调试器很难为单步执行提供一个清晰明确的入口.

关于宏的一些事实(Some facts about macros): 

  • 文件中宏的展开是从头到尾进行的.
  • 宏可以被定义, 取消定义, 甚至重定义.
  • 宏不像其他代码, 她不能被编译, 就算你用宏去写入一些垃圾玩意儿, 源文件也会被编译. 由于这个原因, 宏可以包含任意的句, 甚至于他们从来都没被声明或者定义过.
  • 宏只有在代码被用到的时候才进行展开. 如果你曾在宏定义中使用了一些垃圾东西, 那么他们也将被转换为垃圾玩意, 并导你的编译失败.
  • 宏只能在单独一行延续, 如果在行尾标记有 ‘\’, 则可以延伸至新的一行, 这也是唯一的方法. 除此之外, 新行标记符‘\’ 之后的他任何字符都是不被允许的.
  • 你总是可以用编译命令 “–E” 来检查宏展开后的代码. 这种情况下, 输出的将是被预处理器处理过, 并且所有的宏都展开了的代码. 


基础应用篇(Basic  Macro  Usage)


如何定义(Macro definition)

在C语言中, 宏是用关键字 #define 来定义的. 可以定义成一个常量, 一段代码, 当被定义成一个值的时候, 在代码中这些宏名都将先被他所定义成的常量值替换掉. 然后才将被编译器编译. 宏是可以接受无类型参数(一个或更多)或者占位符(place holders)的. 这些宏参数在被编译之前, 将会在宏展开期间被实参或者相应的语句替换掉. 下面的这段代码就是一些简单的例子, 宏用于代表某个值或接受一个参数执行逻辑运算. 最后一行表明了如何取消定义过的宏名 (un-define). 注意每个值外层的圆括号非常重要, 因为很多错误和副作用都是它引起的.

#define ENABLE_MY_FEAUTRE

#define MAX_ITERATIONS   (4)
#define IS_POSITIVE( _x ) ( _x > 0 )

#undef ENABLE_MY_FEATURE


条件检查/条件编译(Conditional checks)

宏容许使用 #if 和 #ifdef 指令对代码进行逻辑和条件检查. 如果一个宏已经定义, 并且应用了OR , AND 和 NOT或者像 < > = 和 != 这些逻辑操作对这些代码进行了特定的实现. 那么就需要以 #endif 指令进行结尾. 当然了, 你也可以用 #else 和 #elif 来进行特定的实现. 这里是个例子: 

#ifdef ENABLE_MY_FEATURE
/* Implement my feature */
...
#endif

#if (MAX_ITERATIONS > 5) && defined(ENABLE_MY_FEATURE)
	/* Implement the better implementation */
#else
	/* Do something else */
#endif


中级应用篇(Intermediate  Macro  Usage)



do-while的秘密(The do-while(0) mystery)

我敢打赌你肯定与这些古怪的宏定义”邂逅” (encountered) 过, 尤其是当你在Linux目录下查看一些头文件 (header file) 的时候. 这些封装器(???wrapper)打开一个新的名字空间(name space)并只执行一个循环操作. 因为进入循环并不需要任何条件, 而循环的判断条件一直为假. 封装器使用宏执行一个以上的命令, 并且要确保他们被执行到, 以防止意外条件引起的错误.

       让我们来看看下面这个例子, 我们用它来检查条件, 如果是真, 不久就会用到那个将要定义的宏.

if( condition )
   	DO_SOMETHING_HERE(x);
else
    		...
下面我们来定义一下 DO_SOMETHING_HERE 这个宏, 并且展开她.

#define DO_SOMETHING_HERE(_x) foo(_x); bar(_x);

if( condition )
	foo(_x); bar(_x);;    // 调用两个函数, 然后执行一次空操作
else
	...

如果条件为真的话, 这里会有一个编译错误(compilation error), 因为我们调用了 foo(), 但是 bar() 将一直被调用, 并一直执行一个空操作, 而if 语句已经终止, 但是 else 没能跟if 匹配(attached)上, 就是这个问题导致的错误.

现在, 我们在宏的外面加上一对花括号:

#define DO_SOMETHING_HERE(_x) { foo(_x); bar(_x); } 

if( condition )
	{ foo(_x); bar(_x); };
else
	...

编译一下仍会有一个错误, 因为在if语句后面有个多余的分号, 这是不被语法允许的, 和上面的错误本质上是一样的. 如果想编译通过, 你得先移除 ‘;’.

现在, 我们试试下面这个版本:

#define DO_SOMETHING_HERE(_x) do{ foo(_x); bar(_x); }while(0) 
if( condition )
	do{ foo(_x); bar(_x); } while(0);
else
	...

这下就会编译通过, 工作正常了. 秘密已经敞开胸膛.呵呵. . . .. .

准确定位(Where  am  I)

当用到GCC编译器(或者其他的编译器)时, 会有一些内置的变量可以告诉你当前正在执行的代码. 尽管这些并不完全是宏的东西(取决于编译器), 我还是将他们加入到了讨论中, 因为他们对于我们讨论的宏来说很有用处. 下面这些宏一般是可用的(这些宏在一些非ISO 的编译器中很常用):
__FUNCTION__ : 包含当前函数名字符串.  (更多详见:  http://gcc.gnu.org/onlinedocs/gcc/Function-Names.html ) .
__LINE__       : 当前源程序行的行号, 用十进制整数常量表示.
__FILE__       : 当前源文件的名称, 用字符串常量来表示. (更多详见:  http://gcc.gnu.org/onlinedocs/cpp/Standard-Predefined-Macros.html)

如果你将它们和 printf 一起使用, 你就可以弄明白一些消息的确切来源(exact origin). 比如, 他能够发现具体是谁调用了这个函数, 他会在函数每次被调用的时候打印出一则消息. 你要做的就是在原始函数名前面加上 ”__” 前缀, 用这个新的函数名创建一个宏. 在这个宏内, 你可以用上面提到的三个预定义宏(Predefined Macros), 然后用其作为 printf 函数的参数, 之后再调用原始函数就可以了. 

如果你将它们和 printf 一起使用, 你就可以弄明白一些消息的确切来源(exact origin). 例如, 他能够发现具体是谁调用了这个函数, 他会在函数每次被调用的时候打印出一则消息. 你要做的就是在原始函数名前面加上 ”__” 前缀, 用这个新的函数名创建一个宏. 在这个宏内, 你可以用上面提到的三个预定义宏(Predefined Macros), 然后用其作为 printf 函数的参数, 之后再调用原始函数就可以了. 

字符串化(Stringification)

宏有个很Cool的特性就是她可以将任意的代码文本转换为字符串. 自动的将代码中任意的文本转换成可读性既好又可打印的文本, 这是非常有用的. 基本上, 所有需要转换的字符串之前都要加上 ‘#’ 标志符. 不管咋地, 当你要将代码转换成字符串时, 下面是种很常用的格式:

#define STR(_x)   #_x
#define XSTR(_x)  STR(_x)
上面的这个宏的展开要分为两个阶段. 因为XSTR 的参数首先需要展开, 最后输出的将是参数的内容而非其名字.

例如: 

#define RT_EMBEDDED cool


STR(RT_EMBEDDED)  /* Results in "RT_EMBEDDED" */
XSTR(RT_EMBEDDED) /* Results in "cool"        */
(关于本节, 更多请参考: http://gcc.gnu.org/onlinedocs/cpp/Stringification.html)

连结性(Concatenation)

另一个很COOL的特性(cool feature)就是他能将多段代码粘合成一段新的代码(即: 代码生成(code generation)), 为了要连成一串, 需要用到 “##” 运算符(operator), 用于在宏内进行包装(???wrap this inside a macro), 这是一种常见的用法:

#define MACRO_CONCAT( _x, _y )   _x##_y
#define FUNC_PROTO( _handler ) int handler_func_##_handler( int );
MACRO_CONCAT( hello, dolly ) /* Results in hellodolly */
FUNC_PROTO( integer )        /* Results in: "int handler_func_integer( int );"*/ 
		                /* It's a function prototype declaration        */
在这里, 宏 MACRO_CONCAT 将会创建一串新的代码, 这段代码就是你写入宏参数 _x 和 _y 粘合后的结果. 需要提醒的是, 这个宏可以加入很多文本, 所以结果就会比单个的’##’连结更复杂, 而这些文本是与 _x 和 _y 一块儿组成的.宏 FUNC_PROTO 产生一个函数的原型声明, 参数类型即是你所给出的数据类型关键字(i.e.int long char unsigned 等等).

(关于本节更多详见:http://gcc.gnu.org/onlinedocs/cpp/Concatenation.html)

多参数(Multiple arguments)

宏是支持可变参数列表的, 和 printf 函数家族类似, 参数列表的大小是可任意指定的, 要以’...’标志符作为完成标志. 下面这个例子取自PCD(Process Control Daemon)的一段源代码, 它表明了如何调用 printf 来添加更多的信息:

extern bool_t verboseOutput;

#define PCD_PRINT_PREFIX     "pcd: "
#define PCD_PRINTF_STDOUT( _format, _args... ) \
	do { if( verboseOutput ) fprintf( stdout, "%s"_format "%s", PCD_PRINT_PREFIX, 
	##_args, ".\n" ); } while( 0 );

当使用这段宏的时候, verboseOutput 为真则展开, 也只有当她为真的时候, 才将以 “pcd: ” 为前缀(prefix)的字符串格式化输出到控制台, 并在字符串结尾处添加 ‘.’ 然后换行. 在这里, 程序所有的打印输出结果都有相同的观感(???same look and feel.). 

(PCD 源代码下载: http://sourceforge.net/projects/pcd/ )

返回值(Return value)

宏也常用于一些计算或者”返回”一个值. 是的, 宏是有返回值, 不过实际上, 她和一般的函数调用的返回值是不一样的, 其结果更像是一个”流”. 下面这个例程就是用宏来检查其参数的奇偶性(Even or Odd), 返回值就是个字符串. 我们用其来输出结果(conclusion):

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

#define IS_EVEN_STR( _x )  ( _x & 1 ? "odd" : "even" )

int main( int argc, char *argv[] )
{   
    int val;

    if(argc < 2)
        return ;

    /* Convert to integer */
    val = atoi(argv[1]);

    /* Print our conclusion */
    printf( "The number %d is %s\n", val, IS_EVEN_STR(val));

    return 0;
}

程序的输出:

$ ./even 45
The number 45 is odd
$ ./even 64
The number 64 is even


高级应用篇(Advanced  Macro  Usage)


断言(Assertion)

接下来的这个宏在调试用户空间程序(debugging user space applications)是非常有用的. 实际上, 你只需要使用宏 MY_ASSERT 就可一劳永逸(???The rest are helper macros). 我为他设计了如下操作: 一旦诊断出错误(条件为假), 宏就会输出详细的相关信息, 包括文件名, 函数名, 行号等等, 然后终止(terminate)程序运行. 你应该将其加入到程序中任何一个需要认真(sanity)检查的地方. 她接受输入的条件然后检查, 一旦条件不符就会格式化输出显示参数消息.

/* Crash the process */
#define __CRASH()    (*(char *)NULL)

/* Generate a textual message about the assertion */
#define __BUG_REPORT( _cond, _format, _args ... ) \
    fprintf( stderr, "%s:%d: Assertion error in function '%s' for condition '%s': " _format "\n", \
    __FILE__, __LINE__, __FUNCTION__, # _cond, ##_args ) && fflush( NULL ) != (EOF-1)

/* Check a condition, and report and crash in case the condition is false */
#define MY_ASSERT( _cond, _format, _args ... ) \
do { if(!(_cond)) { __CRASH() = __BUG_REPORT( _cond, _format, ##_args ); } } while( 0 )

下面来瞅个简短的例程, 她诊断参数的数目是否在 3 和 4 之间(包括3, 4). 诊断宏(assertion macro)就是要保证(guarantee)这些条件符合要求, 不然的话, 就输出错误消息, 并终止程序.

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

#define MIN_PARAMS 3		
#define MAX_PARAMS 4

int main( int argc, char *argv[] )
{
    int params = argc - 1;
    MY_ASSERT( params >= MIN_PARAMS && params <= MAX_PARAMS,
        "Invalid parameters! must specify at least %d parameters, where %d specified", MIN_PARAMS, params );

    return 0;
}
然后, 我们输入不同的参数来看看结果啥样:
$ ./macro 1 2
macro.c:21: Assertion error in function 'main' for condition 'params >= 3 && params <= 5': Invalid parameters! must specify at least 3 parameters, where 2 specified
Segmentation fault
$ ./macro 1 2 3
$ ./macro 1 2 3 4
$ ./macro 1 2 3 4 5
macro.c:21: Assertion error in function 'main' for condition 'params >= 3 && params <= 4': Invalid parameters! must specify at least 3 parameters, where 5 specified
Segmentation fault

(更多请参考:1.  http://gcc.gnu.org/ml/gcc/2000-09/msg00355.html    2http://www.pixelbeat.org/programming/gcc/static_assert.html)

代码生成器(???Code Generation)

你刚刚肯定觉得诊断宏真是太牛叉了. 不过... ...宏还有一个更神奇更牛叉的用法-----代码生成器. 虽然他可能会在代码可读性或者可维护性上差点, 但另一方面她可以自动的完成很多事情从而防止出错. 尤其是当你以模板(???patterns) 的形式加入进去, 非常方便. 如果你发觉自己做的事情中有很多是在重复做或是在每件处理中都有一些相同的或相似的操作, 要想简化(simplify)这些东西, 就该用宏来解决.

这里有个例子, 假设你有一份关键字的表单. 你想将这些关键字给列举(enumeration)出来, 这样对于每一个关键字都需要定义一个回调函数(??? Callback function)和标识(flag).  你还想要每个关键字作为字符串以用于其他目的. 没有宏的话, 你将不得不对每一个加入的关键字做一些雷同的操作. 而且这也很容易出错, 因为一旦你忘了加入某个关键字, 就很可能会引起与枚举词之间不匹配的错误.

这里是我自定义的一些关键字(是从PCD工程那个例子中弄来的): RULE, START_COND, COMMAND, END_COND, END_COND_TIMEOUT, FAILURE_ARTION, ACTIVE, SCHED, DAEMON, USER, VERSION and INCLUDE. 最有5 个关键字对于执行(implementation)并不是必须的(mandatory)------这些属性标志(property flag)是和关键字相关的.

第一步需要做的就是得创建一个可以包含所有关键字的逻辑列表(logical list). 在列表内, 我们将每个关键字与其他任何你想要包括的与关键字有关的信息置于宏内. 在我们的例子中, 它是个必须得设置的标识. 这个内部的宏就是每一行为我们提供选取(???selectively extract)信息的工具.  记着, 这个宏还没被定义过呢, 我们根据需要产生代码的位置来定义她. 这样并不会引起编译错误, 因为直到被用到这些宏列表也才会进行展开. 注意, 我们并没有在每一行都强行(obligated)使用全部的信息.

/* Keyword,        Mandatory */
#define PCD_PARSER_KEYWORDS \
    PCD_PARSER_KEYWORD( RULE,               1 )\
    PCD_PARSER_KEYWORD( START_COND,         1 )\
    PCD_PARSER_KEYWORD( COMMAND,            1 )\
    PCD_PARSER_KEYWORD( END_COND,           1 )\
    PCD_PARSER_KEYWORD( END_COND_TIMEOUT,   1 )\
    PCD_PARSER_KEYWORD( FAILURE_ACTION,     1 )\
    PCD_PARSER_KEYWORD( ACTIVE,             1 )\
    PCD_PARSER_KEYWORD( SCHED,              0 )\
    PCD_PARSER_KEYWORD( DAEMON,             0 )\
    PCD_PARSER_KEYWORD( USER,               0 )\
    PCD_PARSER_KEYWORD( VERSION,            0 )\
    PCD_PARSER_KEYWORD( INCLUDE,            0 )

现在开始从列表中产生代码. 我们先在头文件中列举产生的代码. 下面的代码就是从列表中自动的产生枚举变量. 例子中, PCD_PARSER_KEYWORD 被定义成了两个参数(因为需要在列表中声明), 但是, 实际上只用到一个关键字. 然后与SET_KEYWORD_ENUM 连结, 创建一个以PCD_PARSER_KEYWORD_ 为前缀的名字列表. 
/***********************************************
 * Keyword enumeration
 ***********************************************/
#define SET_KEYWORD_ENUM(x) \
 PCD_PARSER_KEYWORD_##x

#define PCD_PARSER_KEYWORD( keyword, mandatory ) \
 SET_KEYWORD_ENUM( keyword ),

typedef enum parserKeywords_e
{
    PCD_PARSER_KEYWORDS
    PCD_PARSER_KEYWORD_LAST

} parserKeywords_e;

#undef PCD_PARSER_KEYWORD
下面就是预处理之后的输出结果(实际上, 每个关键字的输出并没有换行, 只是被我美化了一下, 再说了, 反正编译器会忽略空白符):
typedef enum parserKeywords_e
{
    PCD_PARSER_KEYWORD_RULE,
    PCD_PARSER_KEYWORD_START_COND,
    PCD_PARSER_KEYWORD_COMMAND,
    PCD_PARSER_KEYWORD_END_COND,
    PCD_PARSER_KEYWORD_END_COND_TIMEOUT,
    PCD_PARSER_KEYWORD_FAILURE_ACTION,
    PCD_PARSER_KEYWORD_ACTIVE, P
    CD_PARSER_KEYWORD_SCHED,
    PCD_PARSER_KEYWORD_DAEMON,
    PCD_PARSER_KEYWORD_USER,
    PCD_PARSER_KEYWORD_VERSION,
    PCD_PARSER_KEYWORD_INCLUDE,
    PCD_PARSER_KEYWORD_LAST

} parserKeywords_e;
现在, 我们就可以产生一个回调函数的原型了(callback  function`s  prototypes). 为了获取一个关键字并产生完整的函数声明, 将为每一个关键字加上前缀(类似返回值和标准名字的前缀)和后缀(函数参数和分号). 在我们的例子中, 所有的函数都是静态的, 返回值都是 “int32_t” 类型的. 共同的前缀都是 PCD_parser_handle_. 所有函数都只接受单个类型为 char * 的参数. 我们是这么做的:

/**************************************************
 * Declarations for the keyword handlers.
 **************************************************/
#define SET_HANDLER_FUNC(x)   PCD_parser_handle_##x
#define PCD_PARSER_KEYWORD( keyword, mandatory )\
    static int32_t SET_HANDLER_FUNC( keyword ) ( char *line );

PCD_PARSER_KEYWORDS

#undef PCD_PARSER_KEYWORD

下面是预处理后的输出(这次我可没有自作主张的换行): 

static int32_t PCD_parser_handle_RULE ( char *line ); static int32_t 
PCD_parser_handle_START_COND ( char *line );
static int32_t PCD_parser_handle_COMMAND ( char *line ); static int32_t 
PCD_parser_handle_END_COND ( char *line );
static int32_t PCD_parser_handle_END_COND_TIMEOUT ( char *line ); 
static int32_t PCD_parser_handle_FAILURE_ACTION ( char *line );
static int32_t PCD_parser_handle_ACTIVE ( char *line ); static int32_t 
PCD_parser_handle_SCHED ( char *line );
static int32_t PCD_parser_handle_DAEMON ( char *line ); static int32_t 
PCD_parser_handle_USER ( char *line );
static int32_t PCD_parser_handle_VERSION ( char *line ); static int32_t 
PCD_parser_handle_INCLUDE ( char *line );

注意当你要执行函数的时候, 宏能产生的总有限制. 但是, 如果把函数制作成模板的形式, 你就可以同样的产生他们. 提示一下: 对于表中声明的每个PCD_PARSER_KEYWORD, 在每行你都可以用这些信息构成一个完整的函数. 用下述的方法你可以决定每个关键字在运行期间或是编译期间做啥, 这完全基于你所提供的信息, 如果你有两种类型的处理函数, 那么你得需要先用 PCD_PARSER_KEYWORD_HANDLER1 再用 PCD_PARSER_KEYWORD_HANDLER2. 之后在用这俩宏为没个关键字组产生不同的函数.

    在源文件中, 假设我们有个结构体来保存关键字的名字, 回调函数指针和标识符的值. 没初始化时, 可以保存其他的信息. 用下面的代码就可以自动的构造这个结构体. 结构体内的每行都保存着定义的宏.

typedef struct configKeywordHandler_t
{
    char      *name;
    int32_t     (*handler)(char *line);
    /* set at run time. */
    u_int32_t    parse_flag;
    /* indicate if this is a mandatory field. */
    u_int32_t    mandatory_flag;
} configKeywordHandler_t;
/**************************************************************************
 * Initialize keyword array
 **************************************************************************/
#define PCD_PARSER_KEYWORD( keyword, mandatory ) \
 { XSTR( keyword ), SET_HANDLER_FUNC( keyword ), 0, mandatory },

configKeywordHandler_t keywordHandlersList[] =
{
    PCD_PARSER_KEYWORDS
    { NULL,       NULL,          0, 0},
};

#undef PCD_PARSER_KEYWORD

这里 XSTR 将关键字转换成字符串, 而 SET_HANDLER_FUNC 产生函数名.

下面这段代码是预处理后的输出(我再次对其每行进行了美化操作, 看到么? NULL 已经被展开成为 ((void *)0)):

configKeywordHandler_t keywordHandlersList[] =
{
    { "RULE", PCD_parser_handle_RULE, 0, 1 },
    { "START_COND", PCD_parser_handle_START_COND, 0, 1 },
    { "COMMAND", PCD_parser_handle_COMMAND, 0, 1 },
    { "END_COND", PCD_parser_handle_END_COND, 0, 1 },
    { "END_COND_TIMEOUT", PCD_parser_handle_END_COND_TIMEOUT, 0, 1 },
    { "FAILURE_ACTION", PCD_parser_handle_FAILURE_ACTION, 0, 1 },
    { "ACTIVE", PCD_parser_handle_ACTIVE, 0, 1 },
    { "SCHED", PCD_parser_handle_SCHED, 0, 0 },
    { "DAEMON", PCD_parser_handle_DAEMON, 0, 0 },
    { "USER", PCD_parser_handle_USER, 0, 0 },
    { "VERSION", PCD_parser_handle_VERSION, 0, 0 },
    { "INCLUDE", PCD_parser_handle_INCLUDE, 0, 0 },
    { ((void *)0), ((void *)0), 0, 0},
};

这样写又有啥好处呢?

1.大量产生的代码不需要我们亲自来写了.

2.没啥错, 一般也不会有编译错误.

3. 加入一个新的关键字时, 只需要在主表中加一行代码就可以了.


总结(Conclusions):


一旦你掌握了宏的主体思想, 理解这些模板和啥时用啥时进行展开的特点, 就可以用宏来创造出许多精妙的事情来, 就像我们给出的这些例子.^_^

===============================================================================================================================



  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值