【进阶】C语言易错概念理解

参考链接:C陷阱与缺陷(上)-嵌入式系统C陷阱与缺陷(下)C语言常见易混易犯错误
注:原文中内容全面,但有部分符号错误,本文中已修正。
适合有C语言基础后阅读。



语法陷阱

理解函数声明

一个问题,当计算机启动时, 硬件将调用首地址为 0 位置的子例程。为了模拟开机启动时的情形,以显式调用该子例程。使用的语句如下,

(*(void(*)())0)();

这样的表达式会令每个 C 程序员的内心都“不寒而栗”。任何 C 变量的声明都由两部分组成: 类型以及一组类似表达式的声明符(declarator)。声明符从表面上看与表达式有些类似,对它求值应该返回一个声明中给定类型的结果。

  • 最简单的声明符就是单个变量,如 float f; 这个声明的含义是: 当对其求值时,表达式 f 的类型为浮点数类型(float)。因为声明符与表达式的相似,也可以在声明符中任意使用括号 float ((f)) ;

  • 同样的逻辑也适用于函数和指针类型的声明,例如 float ff(); 这个声明的含义是表达式 ff() 求值结果是一个浮点数,即 ff 是一个返回值为浮点类型的函数

  • 类似地float *pf; 这个声明的含义是 *pf 是一个浮点数,即 pf 是一个指向浮点数的指针

以上这些形式在声明中还可以组合起来,例如下面的声明,

float (*h)();

表示 h 是一个"指向返回值为浮点类型的函数"的指针

(float (*)());

表示一个“指向返回值为浮点类型的函数的指针”的类型转换符

拥有了这些预备知识,分两步来分析表达式 (*(void(*)())0)()。

1、假定变量 fp是一个函数指针,调用fp 所指向的函数方法就是 (*fp) ();

因为 fp 是一个函数指针,那么*fp 就是该指针所指向的函数,所以(*fp)()就是调用该函数的方法。 (*fp)( ); 等于 fp( );
在表达式(*fp)()中,*fp 两侧的括号非常重要,因为函数运算符()的优先级高于单目运算符。如果*fp 两侧没有括号,那么*fp()实际上与*(fp())的含义完全一致。

2、找到一个恰当的表达式来替换fp

将常数0转型为“指向返回值为void的函数的指针”类型,应该这样写:

(void(*)())0

因此,用(void(*)())0替换(*fp) ();中的fp,就得到:

(*(void(*)())0)()
(*((void(*)())0))()

注意作为语句结束标志的分号

还有一种情形,有分号与没分号的实际效果相差极为不同。那就是当一个声明的结尾紧跟一个函数定义时,如果声明结尾的分号被省略,编译器可能会把声明的类型视作函数的返回值类型。考虑下面的例子:

struct logrec{
    int date;
    int time;
    int code;
}
main()
{    
}

第一个}与紧随其后的函数 main 之间遗漏了一个分号,上面代码段实际的效果是声明函数 main 的返回值是结构 logrec 类型。如果分号没有被省赂,函数 main 的返回值类型会缺省定义为 int 类型。

语义陷阱

指针与数组

C 语言中指针与数组这两个概念之间的联系密不可分,值得注意的地方有以下两点:

  • 数组的大小必须在编译前就作为一个常数确定下来,数组的元素可以是任何类型的对象。

  • 一个数组只能够做两件事: 确定该数组的大小,以及获得指向该数组下标为 0 的元素的指针,其他有关数组的操作,实际上都是通过指针进行的。换句话说,任何一个数组下标运算都等同于一个对应的指针运算,因此完全可以依据指针行为定义数组下标的行为。

声明一个数组,例如,

struct {
    int p[4];
    double x;
}b[17];

以上,声明了b 是一个拥有 17 个元素的数组,每个元素都是一个结构体,该结构体包括了一个拥有 4 个整型元素的数组(命名为 p) 和一个双精度类型的变量(命名为x)。

int calendar [12][31];

这个语句声明了 calendar 是一个数组,该数组拥有 12 个数组类型的元素,其中每个元素都是一个拥有 31 个整型元素的数组。sizeof(calendar) 的值是372 (31*12)与 sizeof(int)的乘积。

如果 calendar 不是用于 sizeof 的操作数,而是用于其他的场合, 那么 calendar总是被转换成一个指向 calendar 数组的起始元素的指针。任何指针都指向某种类型的变量。例如:

int *ip; // 指向整型变量的指针ip
int i; // 整型变量i
ip = &i; // 取变量i的地址并赋给ip

ip 是一个指向整型变量的指针,可以将整型变量i的地址赋给指针ip,如果给*ip 赋值,就能够改变i的取值。

注意:int * ip;语句中,int* 是在一起的,代表指针类型,p指的是指针变量。

如果一个指针指向的是数组中的一个元素,那么,给指针加 1,就能够得到指向该数组中下一个元素的指针。同样地,如果给指针减 1得到就是指向该数组中前一个元素的指针。如果两个指针指向的是同一个数组中的元素,可以把这两个指针相减。这样做是有意义的,例如:

int *q = p+i;

可以通过 q-p 而得到 i 的值。如果p与q指向的不是同一个数组中的元素,则不能直接加减。

int a[3] 是一个拥有 3 个整型元素的数组。如果在应该出现指针的地方,采用数组名来替换,那么数组名就被当作指向该数组下标为0的整型元素的指针:

p=a; // 正确

就会把数组 a 中下标为0 的元素的地址赋值给 p。注意,这里我们并没有写成:

p = &a; // 错误

这种写法在 ANSIC 中是非法的,因为 &a 是一个指向数组的指针,而 p 是一个指向整型变量的指针,它们的类型不匹配。现在 p 指向数组 a 中下标为 0 的元素,p+1 指向数组 a 中下标为1的元素依次类推,如果希望p 指向数组 a 中下标为 1 的元素,可以这样写 p=p+1,该语句完全等同于 p++。

a除了被用作运算符 sizeof 的参数这一情形,sizeof(a)的结果是整个数组 a 的大小,而不是指向数组 a 的元素的指针的大小。在其他所有的情形中数组名 a 都代表指向数组 a 中下标为 0 的元素的指针

*a 即数组 a 中下标为 0 的元素的引用。例如可以这样写 *a = 84;这个语句将数组 a 中下标为 0 的元素的值设置为 84。同样道理,*(a+1)是数组 a中下标为 1的元素的引用;*(a+i)即数组 a 中下标为 i 的元素的引用,这种写法简记为a[i]。正是这一概念让许多 C 语言新手难于理解。

二维数组,正如前面所讨论的,实际上是以数组为元素的数组,也可以完全依据指针编写操纵一维数组的程序,这样做在一维情形下并不困难, 但是对于二维数组从记法上的便利性来说采用下标形式就几乎是不可替代的了。

int calendar[12][31];
int *p;
int i;

calendar[4]的含义是什么?因为 calendar 是一个有着 12 个数组类型元素的数组, 它的每个数组类型元素又是一个有着 31 个整型元素的数组,所以 calendar[4]是 calendar数组的第 5 个元素,表现为一个有着 31 个整型元素的数组的行为。例如,sizeof(calendar[4])的结果是 31 与 sizeof(int)的乘积。

又如:

p = calendar[4];

这个语句使指针 p 指向了数组 calendar[4]中下标为 0 的元素。因为 calendar[4]是一个数组,可以通过下标的形式来指定这个数组中的元素,就像下面这样,

i = calendar[4] [7];

也可以写成下面这样而表达的意思保持不变;

i= *(calendar[4]+7);

这个语句还可以进一步写成,

i = *(*(calendar+4)+7);

很明显,用带方括号的下标形式很明显地要比完全用指针来表达简便得多,也更不易出错。

下面再看:

p = calendar; // 非法

这个语句是非法的,因为 calendar 是一个二维数组,即“数组的数组” ,在此处 calendar 会将其转换为一个指向数组的指针,而 p 是一个指向整型变量的指针, 赋值是非法的。

很显然,我们需要一种声明指向数组的指针的方法:

int (*month) [31];

这个语句实际的效果是,声明了*month 是一个拥有 31 个整型元素的数组,month 就是一个指向拥有 31 个整型元素的数组的指针。因而可以这样写:

int calendar [12][31];
int (*month) [31];
month = calendar;

这样,month将指向数组calendar的第一个元素,也就是数组calendar的12个有着31个元素的数组之一。

假定需要清空 calendar 数组,用下标形式可以很容易做到:

int month;
for (month=0;month<12; month++) {
    int day;
    for (day = 0 ;day < 31 ;day++)
        calendar [month][day] = 0;
}

如果采用指针应该如何表示呢?

calendar [month][day] = 0;

表示为

*(*(calendar+month)+day) = 0;

非数组的指针

在 C 语言中, 字符串常量代表了一块包括字符串中所有字符以及一个空字符(‘’) 的内存区域的地址。假定有字符串 s 和t,希望将这两个字符串连接成单个字符串 r 。借助库函数 strcpy 和 strcat。下面的方法似乎一目了然,可是却不能满足目标:

char *r;
strcpy (r,s);
strcat (r,t);

之所以不行的原因在于不能确定 r 指向何处,不仅要让r指向一个地址,而且r 所指向的地址处还应该有内存空间可供容纳字符串,修改给r 分配一定的内存空间:

char r[100];
strcpy (r,s);
strcat (r,t);

只要s 和上t 指向的字符串并不是太大,现在所用的方法就能够正常工作。不幸的是,C 语言强制要求必须声明数组大小为一个常量,因此不够确保r足够大。

大多数 C 语言提供库函数 malloc,该函数接受一个整数,然后分配能够容纳同样数目的一块内存,还提供库函数 strlen,该函数返回一个字符串中所包括的字符数,有了这两个库函数,似乎能够像下面这样操作了:

char *r;
r = malloc(strlen(s) + strlen(t));
strcpy (r,s);
strcat (r,t);

但这个例子还是错的,问题有三点。

  • 1、malloc 函数有可能无法提供请求的内存,通过返回一个空指针来作为“内存分配失败”事件的信号,需要检查是否调用成功。

  • 2、给 r 分配的内存在使用完之后应该及时释放,这一点务必记住。在前面的程序例子中 r 是一个局部变量,当离开r作用域时自动被释放,修订后的程序显式地给r 分配了动态内存,为此就必须显式地释放内存。

  • 3、最重要的原因,就是前面的例程在调用 malloc 函数时并未分配足够的内存。字符串以空字符作为结束标志,库函数strlen 返回参数中字符串所包括的字符数目, 而作为结束标志的空字符并未计算在内。因此, 如果 strlen(s)的值是 n, 那么字符串实际需要 n+1 个字符的空间;所以必须为 r 多分配一个字符的空间。

正确的结果:

char *r;
r = malloc(strlen(s) + strlen(t) + 1);
if(!r){
   complain(); // 内存分配失败的处理函数
   exit(1);
}
strcpy (r,s);
strcat (r,t);
/* 一段时间之后再使用 */
free(r); //释放内存

作为参数的数组声明

C 语言中没有办法将一个数组作为函数参数直接传递,如果使用数组名作为参数,那么数组名会被转换为指向该数组第 1 个元素的指针。

例如下面的语句:

char hello[] = "hello";

声明 hello 是一个字符数组。如果将该数组作为参数传递给一个函数,

printf("%sn",hello);

实际上与将该数组第 1 个元素的地址作为参数传递给函数的作用完全等效,即:

printf("%sn"&hello[0]);

C 语言中会自动地将作为参数的数组声明转换为相应的指针声明

int strlen(char s[])
{
 /* 具体内容 */
}

//两种写法完全相同
int strlen(char* s)
{
 /* 具体内容 */
}

当数组名作为参数传递的时候,需要一起传递数组的长度。因为,数组名作为参数传递之后,转换为指针。此时,通过sizeof关键字无法获取数组的长度,那么,在操作数据的时候,无法正确判断数组的长度,容易产生数组越界。

指针的优先级

注意,()的优先级高于*。区分以下内容:

  • float *g() 等于 float *(g()),即函数g()的返回值类型是指向浮点数的指针。
  • float (*h)() 中,h是函数指针,h所指向的函数的返回值类型是浮点数。
  • (float (*h)())是“指向返回值类型是浮点数的函数的指针”的类型转换符。

库函数

ANSI C 标准定义了一个包含大量标准库函数的集合。使用库函数时,ANSI C 标准强制要求使用系统头文件,因为头文件中包括了库函数的参数类型以及返回类型的声明。

返回整数的 getchar 函数

考虑以下例子

#include <stdio.h>
main()
{
    char c;
    while ((c = getchar()) != EOF)
        putchar (c);
}

getchar 函数在一般情况下返回的是标准输入文件中的下一个字符,当没有输入时返回 EOF(在头文件 stdio.h 中定义,不同的操作系统和编译器中EOF的值可能有所不同,但通常都是一个负整数,如-1 )。这个程序乍一看似乎是把标准输入复制到标准输出,实则不然。

原因在于程序中的变量 c 被声明为 char 类型,而不是 int 类型,这意味着 c 无法容下所有可能的字符,特别是可能无法容下 EOF。

最终存在三种可能:

  • 一种可能是某些合法的输入字符在被“截断”后使得 c 的取值与 EOF 相同,程序将在文件复制的中途终止。
  • 另一种可能是 c 根本不可能取到 EOF 这个值,程序将陷入死循环。
  • 第三种情况是程序表面上能够正常工作,但完全是因为巧合。尽管函数 getchar 的返回结果在赋给 char 类型的变量 c 时会发生“截断”操作,尽管 while 语句中比较运算的操作数不是函数 getchar 的返回值,而是被“截断”的值 c,然而令人惊讶地是许多编译器对上述表达式的实现并不正确。这些编译器确实对函数 getchar 的返回值作了“截断”处理,并把低端字节部分赋给了变量 c。但是,它们在比较表达式中并不是比较 c 与 EOF,而是比较 getchar函数的返回值与 EOF,编译器如果采取的是这种做法,上面的例子看上去就能够正常运行。

预处理器

编译开始之前,C 语言预处理器首先对程序代码作了必要的转换处理,预处理器使得编程者可以简化某些工作,它的重要性可以由两个主要的原因说明。

1、也许会遇到这样的情况,需要将某个特定数量如数组大小,在程序中出现的所有实例统统加以修改,希望在程序中只改动一处数值,然后重新编译就可以实现。预处理器做到这一点可以说是轻而易举,即使这个数值在程序中的很多地方出现。只需要将这个数值定义为一个显式常量 (manifest constant),然后在程序中需要的地方使用这个常量即可。

2、大多数 C 语言实现在函数调用时都会带来重大的系统开销。因此,希望有这样一种程序块,它看上去像一个函数,但却没有函数调 用的开销。一些小的功能代码被实现为宏,以避免在每次执行时,都要调用相应的函数而造成系统效率的下降。

虽然宏非常有用,不只是对程序的文本起作用,宏既可以使一段看上去完全不合语法的代码成为一个有效的 C 程序,也能使一段看上去无害的代码成为一个可怕的怪物。

宏并不是函数

宏从表面上看其行为与函数非常相似,程序员有时会禁不住把两者视为完全等同。常常可以看到类似下面的写法:

#define abs(x) (((x)>=0)?(x) : -(x))
#define max(a,b) ((a)>(b)?(a):(b))

请注意宏定义中出现的括号,它们的作用是预防引起与优先级有关的问题。例如,假设宏 abs 被定义成了这个样子:

#define abs(x) x>=0?x:-x

abs(a-b)求值后会得到怎样的结果?表达式被展开为 a-b>0?a-b:-a-b 这里的子表达式-a-b 不是我们期望的-(a-b),无疑会得到错误的结果。因此,最好在宏定义中把每个参数都用括号括起来。同样,整个结果表达式也应该用括号括起来,以防止当宏用于一个更大的表达式中可能出现的问题。

即使宏定义中的各个参数与整个结果表达式都被括号括起来,也仍然还可能有其他问题存在,比如一个操作数如果在两处被用到,就会被求值两次。如在表达式 max(a,b)中,如果 a 大于 b,那么 a 将被求值两次,第一次是在 a与b 比较期间,第二次是在计算 max 应该得到的结果值时。这种做法不但效率低下,而且可能是错误的。

biggest = x[0];
i= 1;
while (i<n)
    biggest = max (biggest,x[i++]);

如果 max 是一个真正的函数,上面的代码可以正常工作,而如果 max 是一个宏,那么就不能正常工作。上面代码中的赋值语句将被扩 展为:

biggest = ((biggest)>(x[i++])?(biggest):(x[i++]));

变量 biggest 将与 x[i++]比较。i的值发生了变化,如果前面关系运算的结果为 false(假),后面 i++的副作用导致结果错误。解决这类问题的一个办法是,确保宏 max 中的参数没有副作用。

biggest = x[0];
for (i = 1; i<n ;i++)
  biggest = max (biggest, x[i]);

另一个办法是让 max 作为函数而不是宏。

使用宏的一个危险是,宏展开可能产生非常庞大的表达式,占用的空间远远超过了编程者所期望的空间,这种情况下封装为函数更合适。

宏并不是类型定义

宏的一个常见用途是使多个不同变量的类型可在一个地方说明:

#define FOOTYPE struct foo
FOOTYPE a;
FOOTYPE b, c;

这样编程者只需在程序中改动一行代码,即可改变 a、b、c 的类型,但是,最好还是使用类型定义;

typedef struct foo FOOTYPE;

这个语句定义了 FOOTYPE 为一个新的类型,与 struct foo 完全等效。两种命名类型的方式似乎差不多, 但是使用 typedef 的方式要更加通用。例如,考虑下面的代码:

#define T1 struct foo *
typedef struct foo *T2;

表面来看,T1 和 T2 从概念上完全符同,都是指向结构 foo 的指针。但是,当试图用它们来声明多个变量时,问题就来了:

T1 a,b;
T2 a,b;

第一个声明被扩展为 struct foo *a, b;

这个语句中 a 被定义为一个指向结构的指针,而 b 却被定义为一个结构,而不是指针。第二个声明则不同,它定义了a 和 b都是指向结构的指针,因为这里T2 的行为完全与一个真实的类型相同。

可移植性缺陷

C 语言在许多不同的系统平台上都有实现,能够方便地在不同的编程环境中移植。然而, 由于C 语言实现是如此之多, 各个实现之间有着或多或少的细微差别,以至于没有两个实现是完全相同的。程序员如果希望自己写的程序在另一个编程环境也能够工作,就必须了解许多这类细小的差别。

应对 C 语言标准变更

C标准的多个版本,不同C编译器的支持不同,导致代码在不同的环境下表现不同,许多有关可移植性的语法都有类似的特点。比如:

int i;
for ( int j = 0 ; j<100; j++ ) 
{
    //...
}

int j =0 在C99标准合法,但早期版本会提示错误。

整数的大小

int、short、long的字符长度是几位取决于硬件特性,为保证可移植性,最好声明变量为新变量,如果遇到字符长度与实际需要不符的情况,也可以直接更改变量类型来解决。

typedef char int8_t;
typedef short int16_t;
typedef int int32_t;

typedef unsigned char uint8_t;
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;

内存位置 0

NULL指针并不指向任何对象。除非是用于赋值或比较运算,出于其他目的使用 null 指针都是非法的。如果 p 或 q 是一个 null 指针,那么strcmp(p, q)的值就是未定义的,在这种情况下究竟会得到什么结果呢? 不同的编译器有不同的结果。

某些C语言实现对内存位置 0 强加了硬件级的读保护,在其上工作的程序如果错误使用了 null 指针, 将立即终止执行。其他一些 C 语言实现对内存位置0 只允许读,不允许写。在这种情况下,一个 null 指针似乎指向的是某个字符串,但其内容通常不过是一堆“垃圾信息”。还有一些 C 语言实现对内存位置 0 既允许读,也允许写,在这种实现上面工作的程序如果错误使用了null 指针,则很可能履盖了操作系统的部分内容,造成彻底的灾难!

严格说来,在所有的 C 程序中,误用 null 指针的效果都是未定义的。

除法运算时发生的截断

假定我们让 a 除以b(假定b 大于 0),商为 q,余数为r

q=a/b;
r=a%b;

希望 a、b、q、r之间维持怎样的关系呢?

  • 最重要的一点,q*b +r == a,因为这是定义余数的关系。
  • 如果改变 a 的正负号,希望改变 q 的符号,但不改变q的绝对值。
  • 当 b>0 时, 希望保证 r>=0 且 r<b。

这三条性质是整数除法和余数操作所应该具备的。很不幸的是,它们不可能同时成立。

考虑一个简单的例子: 3/2,商为1,余数也为 1。此时第 1 条性质得到了满足。(-3)/2 的值应该是多少呢? 如果要满足第 2 条性质,答案应该是-1,但如果是这样,余数就必定是-1,这样第 3 条性质就无法满足了。如果首先满足第3 条性质,即余数是 1,这种情况下根据第 1 条性质则商是-2,那么第 2 条性质又无法满足了。因此,C 语言在实现整数除法截断运算时,必须放弃上述三条原则中的至少一条。大多数程序设计语言选择了放弃第 3 条,而改为要求余数与被除数的正负号相同。

假定一个数 n代表经过某种运算后的结果,希望通过除法运算得到 h,满足 0<=h< HASHSIZE。又如果已知 n 恒为非负,那么只需要像下面一样简单地写: h=n % HASHSIZE; 然而,如果n 有可能为负数,而此时h 也有可能为负,那么这样做就不一定总是合适的了。不过已知 h>-HASHSIZE,可以这样写:

h=n % HASHSIZE;
if (h< 0)
    h += HASHSIZE;

更好的做法是程序在设计时就应该避免n值为负的情形,并且声明n 为无符号数。

随机数的大小

最早的C 语言提供了一个称为 rand 的函数,该函数的作用是产生一个(伪) 随机非负整数,当时计算机上的整数长度为16 位,后来机器上整数的长度为 32 位,那 rand 函数的返回值范围应该是多少呢?

当时有两组人员同时分别实现 C 语言,做出的选择互不相同。一组人员认为 rand 函数的返回值范围应该包括所有可能的非负整数取值, 因此们设计的 rand 函数返回一个介于0到2^32 -1的整数。

另一组人员地 rand 函数返回值范围与早期计算机上的一样,即介于0到2^15-1之间的整数,这样造成的后果是,如果程序中用到了 rand 函数,在移植时就必须根据特定的 C 语言实现作出裁剪, ANSI C 标准中定义了一个常数 RAND_MAX,它的值等于随机数的最大取值。

大小写转换

库函数 toupper 和 tolower 也有与随机数类似的历史。它们起初被实现为宏:

#define touppetrt(c) ((c)+'A'-'a')
#define tolower(c) ((c)+'a'-'A')

当给定一个小写字母作为输入,toupper 将返回对应的大写字母,而 tolower的作用正好相反。两个宏都依赖于特定字符集的性质,即所有的大写字母与相应的小写字母之间的差值是一个常量。这个假定对 ASCII 字符集来说都是成立的。然而,这些宏有一个不足之处, 如果输入的字母大小写不对,那么返回的就都是无用的垃圾信息。

大多数toupper和 tolower 的使用,都需要先进行检查以保证参数是合法的,后来两个宏重写如下:

#define toupper(c) ((c) >= 'a'&& (c) <= 'z'? (C) + 'A' -'a' : (c))
#define tolower(c) ((c) >= 'A'&& (c) <= 'Z'? (c) + 'a' -'A' : (c))

但这样做有可能在每次宏调用时,致使被求值 1 到 3 次。如果遇到类似 toupper(*p++)这样的表达式,可能造成不良后果。因此又重写 toupper和 tolower 为函数,重写后的 toupper 函数看上去大致这样:

int toupper (int c)
{
  if (c >= 'a' && c <= 'z')
       return c+'A' -'a';
   return c;
}

重写后的 tolower 函数也与此类似。改动之后程序的健壮性得到了增强,而代价是每次使用时引入了函数调用的开销,某些人也许不愿意付出效率方面损失的代价,因此又重新引入了这些宏,不过使用了新的宏名:

#define _toupper(c) ((c)+'A'- 'a')
#define _tolower(c) ((c)+'a'- 'A')

宏的使用者就可以在速度与方便之间自由选择,但是这意味着使用 toupper 和 tolower 时,传入一个大小写不合适的字母作为参数,在其他一些 C 语言实现上有可能无法运行。如果编程者不了解这段历史,要跟踪这类程序失败就很困难。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值