《C++ Primer Plus》第八章 方法详解(Adventure in Function)

内联函数

内联函数和普通函数不同在于编译器对它们的处理不同,在编码阶段其实没有太大差异。
先看看机器在运行程序的流程。
首先代码被编译器编译为可执行程序,程序由机器语言指令组成。在程序被启动后,这些指令就被加载到计算机内存中,每条指令都有了一个地址。然后机器就逐条执行这些指令。而当程序出现循环或者分支语句,程序就会发生跳转,跳到指定的地址。方法调用也会触发这种跳转,程序会跳转到被调用方法的地址,然后在被调用方法结束后再跳转回原处。
具体的跳转流程是:

  1. 到达方法调用语句
  2. 将紧跟在该语句之后的下一条语句的地址存起来
  3. 将要传入方法的参数复制到栈内(一个专门预留的存储区间)
  4. 跳转到标记为被调用方法的开始的地址
  5. 执行被调用方法(如果有返回值的话,将返回值放入寄存器)
  6. 跳转回第二步存储的地址

这样跳来跳去,还要存这个存那个就意味着方法调用是要耗费额外的时间和空间的。内联函数就是针对这个额外的耗费提供的一个特性。内联函数在编译时会被嵌入到程序中。也就是,程序中对内联函数的调用语句会在编译时被内联函数的方法体替代。也就是说如果一段程序只调用了内联函数,那么在编译后这段程序内就不会存在调用,也就省去了跳转的麻烦。
使用内联函数的程序运行要比使用普通函数快一点,但其代价是存储空间耗费会增加。比如说一段程序调用了一个内联函数十次,那么这个内联函数的就会被复制十次。如果这个内联函数代码还很长,那么整个程序在编译结束后就也会变得非常地长。

内联函数使用建议

如果程序总运行时间相对于处理方法调用机制的时间来说相当的长,内联函数带来的时间收益对于整个程序的效率提升来说就无关紧要。而如果程序执行的时间很短,那么使用内联函数就可以带来高比例的时间收益。

使用内联函数

要使用内联函数需要至少做到以下两点的其中之一:

  • 在方法声明中使用inline关键字
  • 在方法定义中使用inline关键字

一般的写法是省略方法原型,直接将方法定义(方法头+方法体)写在方法原型原本的位置。
注意的是编译器在内联函数这件事上并不完全会听你的。在其发现方法体过长或该方法调用自身(在内联函数中不允许也不可能出现递归)又或者这一特性在该编译器中未开启或未实现。

示例代码

#include <iostream>

inline double square(double x){return x*x;}

int main()
{
	using namespace std;
	double a;
	a = square(5.0);             //将会被替换为a = 5.0*5.0;
	a = square(5.0+2.0);         //将会被替换为a = (5.0+2.0)*(5.0+2.0);
	a = square(a);               //将会被替换为a = a*a;
}

引用变量

C++增添的新的复合类型——引用变量。引用就是类似于别名一样的存在。比如让ns成为nintendo_switch的别名那么nsnintendo_switch就能互相表示,二者代表同一个变量。引用的主要用处在于用作形式参数。如果方法使用引用作为参数,那么方法就是在对传入参数的原数据直接进行操作。接下来先看看引用的基本用法。

创建引用变量

在C和C++中符号&是代表变量的地址。C++给予了这个符号新的功能——用于声明引用。比如让ns成为nintendo_switch的引用,写法如下:

int nintendo_switch;
int & ns = nintendo_switch;

要注意的是引用必须在声明的同时初始化。当它成为了一个变量的引用,它就不能再变了。

int nintendo_switch;
int & ns;
ns = nintendo_switch;  //不行

传入引用作为方法参数

大多数情况下,引用都是用作方法参数,使得方法内的变量名成为传入参数的引用。这种传参方式让被调用方法可以直接访问发起调用方法传入的变量本身。原本在C中是只有值传递的,这种传递让被调用方法对传入参数的副本进行操作。当然了,在C中也可以通过指针的使用让被调用方法操作传入参数本身。
来看看对于同一问题,传入引用和传入指针两种做法有什么不同。比如交换两个变量的值的方法。传统的值传递是肯定不行的,因为方法需要对传入的两个参数本身进行操作才能达到交换的目的。接下来看看指针实现和引用实现:

void swapp(int * a, int * b);
void swapr(int & a, int & b);

int main()
{
	int v1 = 1;
	int v2 = 2;
	swapp(&v1, &v2);
	swapr(v1, v2);
}

void swapp(int * a, int * b)
{
	int temp;
	temp = *a;
	*a = *b;
	*b = temp;
}

void swapr(int & a, int & b)
{
	int temp;
	temp = a;
	a = b;
	b = temp;
}

两个方法都可以完成交换,区别在于写法不同以及在调用方法时发生的事情不太相同。swapp方法传入的是两个地址,方法在被调用时声明a和b两个指针,并用传入的两个地址进行初始化。swapr则是传入了两个变量,方法在被调用时声明两个引用,并将这两个引用初始化为传入的两个变量的引用。

引用的属性和特异

const引用

先看以下代码:

int square(int & a);
int main()
{
	int x = 5;
	int squareX = square(x);
}
int square(int & a)
{
	a = a*a;
	return a;
}

注意的是,在square方法调用之后,x的值变为25.因为square方法是引用传递,方法内的a是x的引用,a=a*a语句修改了x的值。而显然我们只想让方法返回x的平方,而并不想改变x的值。所以对于这个方法,是应该使用普通的值传递。
但是对于一些比较大的数据单元比如struct和类对象,使用引用传递会更有效率(后面会说到),那么这时候就可以使用const来让被调用方法无法对原数据进行修改:

int square(const int & a);

当引用参数被声明为const,方法内试图对a进行修改时,编译器就会报错。

临时变量与引用参数

普通的值传递参数可以接收多种传入:

double function(double a);
int main()
{
	double a = 5.0;
	int b = 8;
	double arr[2] = {1.0, 2.0};
	function(5.0);
	function(a);
	function(b);
	function(arr[1]);
	function(a+5.0);
}

而如果这个方法参数是引用传递,这些传入似乎就有点问题。毕竟方法内的变量名是传入变量的引用。假如function将a声明为引用传递,在方法中对a赋值。而调用时传入了一个类似5.0+1.0的表达式。那么方法中对a的赋值语句就等价于5.0+1.0=2.0这种一看就不对的语句。
实际上,如果向引用参数传入表达式,现在的C++是会报错的,大多数的编译器都会如此。但有些旧的编译器只会给出一个warning:
Warning: Temporary used for parameter ‘a’ in call to function(double &).
只警告不报错是因为在早年,C++是允许向引用参数传入表达式的。而对于某些情况,现在也依旧允许。那么向引用参数传入表达式究竟会发生什么?
其实,C++会在传入参数与声明的参数类型不符时生成临时变量。到了现在,临时变量的生成只会在引用参数是const的时候发生。
先看临时变量生成的触发条件,首先引用参数必须是const,然后满足以下条件的其中之一:

  • 传入参数类型正确但不是一个lvalue1
  • 传入参数类型不对但可以转换为正确的类型

具体说明一下。看以下代码:

double function(double & a);
int main()
{
	double a = 5.0;
	int b = 8;
	double arr[2] = {1.0, 2.0};
	function(5.0);    //调用1
	function(a);      //调用2
	function(b);      //调用3
	function(arr[1]); //调用4
	function(a+5.0);  //调用5
}

调用1、2和4传入的参数类型正确且是lvalue,不需要生成临时变量。调用3传入的是lvalue但类型不对,调用5传入的类型对但不是lvalue,这两个调用就会生成临时变量。对于这两个调用,编译器会生成一个临时且匿名的变量,将b转换为double或算出a+5.0的值,用这个值初始化这个临时匿名变量,然后让function内的a成为这个临时匿名变量的引用。临时匿名变量在方法调用期间有效,在方法执行结束之后就会被丢弃。
那么现在再来讨论为什么只有const引用才允许使用临时变量机制。把swapr方法再贴一遍:

void swapr(int & a, int & b)
{
	int temp;
	temp = a;
	a = b;
	b = temp;
}

这个方法是为让将传入的两个int变量的值交换。那如果传入的是两个double变量,然后允许使用临时变量机制会发生什么?编译器会生成两个临时匿名变量,将传入的两个double变量转换为int并用于初始化两个临时变量,然后方法内的a和b就成为这两个临时变量的引用。那么这时候这个swapr实际上就是将两个临时变量的值进行互换,而并不会对传入的两个double变量进行任何操作,两个发生了交换的临时变量在方法结束后就被丢弃。也就是说,方法等于什么都没做。这就是为什么只有const引用参数允许用临时变量解决传入参数不匹配的问题,而非const引用参数不允许传入不匹配的参数。就是为了防止被调用方法试图在方法内修改原数据却因为临时变量机制失败从而导致程序无法达成其设计目的(出现bug)。
所以该用const的时候就用,理由有三:

  1. 使用const可以防止你不经意间修改原数据
  2. 使用const允许方法同时处理const和非const传入,而方法原型中没有写const的函数就只能处理非const传入。
  3. 使用const引用让方法可以处理更广范围的传入并正确使用临时变量机制

返回引用

使用struct和类的引用

先说struct和类的引用,它们的使用方法和普通的引用没什么不同,也是在声明的时候使用&符号。
需要特别说明的是关于类的继承问题。类的继承会在十三章讲到,简单来说就是一个类可以继承另一个类的特性。被继承的类称为基类,继承了它的类称为衍生类(也可以成为父类与子类)。衍生类继承了基类的成员方法,所以衍生类也可以调用这些成员方法。
在这里需要讲到的是一个基类的引用,可以成为衍生类对象的引用且不需要强转。比如有一个类A,另一个类B是A的衍生类。那么可以有:

B b;
A & a = b;

为什么要返回引用?

返回值也可以是引用,只需要在方法原型和方法头处在返回值类型后加上&符号即可。

int & function();

返回引用是为了节省空间和时间。普通的返回过程是先将返回值复制到临时的位置,然后如果这个返回值是用于赋值,就再将返回值从临时位置复制到被赋值的对象。这个过程对于基础类型来说无关紧要,但如果返回值是一个struct或者一个类对象,它的数据量就可能会很大,这个复制就会耗费时间和空间。这时就可以通过返回引用省去这个复制过程。

注意返回引用的指向

使用返回引用唯一需要特别注意的就是返回的这个引用是谁的引用。看以下代码:

int & function()
{
	int a = 0;
	return a;
}

方法返回的引用是在方法内声明的变量a的引用。这个方法中声明的变量a在方法结束后就会失效(第九章会讨论各种变量的有效期),所以返回的这个引用就是一个失效变量的引用,这是需要避免出现的状况。
避免这种问题最简单的方法就是返回作为参数传入的引用。作为参数传入的引用是发起调用方法内的变量的引用,在被调用方法结束时不会失效,所以这个引用自然也不会出问题。
另一个方法是用new创建新的存储:

int & function()
{
	int *a;
	*a = 1;
	return *a;
}

指针的声明创建了一个匿名的int变量,然后指针a指向这个变量。在对着变量赋值后,方法返回了这个变量的引用。
这个方法的问题在于,平时程序new了一个对象,在这个对象没用之后就要用delete删除它以解放那块存储。但这个方法在方法体内进行了new,然后返回了一个引用。对于发起调用方法来说,new这个行为隐藏在了方法调用内,这就回到导致在编写发起调用方法时很容易忘记对这个返回的引用指向的变量进行delete。十六章讨论的auto_ptr或者更好的C++11的unique_ptr可以帮助进行自动删除。

为什么要对返回引用使用const?

来看代码:

int & function(int & a)
{
	a = 10;
	return a;
}
int main()
{
	int x = 5;
	function(x) = 20;
}

x的引用被传入到function之后,function通过引用将x的值修改为10。然而function返回的是传入的x的引用,function(x)=20这一句马上就把x的值修改为20。这样一来function的工作就完全白做了。
这是一段非常无厘头的代码,正常人都不会这么写。但是在实际编码中,是有可能写出类似代码的。方法返回了一个引用,然后在之后编码时无意间又对这个引用进行了赋值。那么如果想要让方法返回的引用无法被赋值,就可以将方法返回的引用声明为const:

const int & function();

这样方法返回的引用就无法被赋值修改了。

什么时候使用引用变量

总的来说,该什么时候使用引用变量呢?简单来说,使用引用变量是基于两个理由:

  1. 让被调用方法修改传入参数的原数据
  2. 通过传入引用而非整个数据对象来加速程序的执行

这两点其实用指针也可以实现。那究竟什么时候用指针什么时候用引用什么时候用值传递?
如果方法不需要修改传入参数的原数据:

  • 传入数据对象很小,比如是基础类型或者很小的struct,那就用值传递。
  • 传入的是数组,那就只能用指针。将指针声明为const防止方法修改原数组。
  • 如果传入的是很大的struct,使用const指针或者const引用来增加程序效率。节省复制返回值的时间和空间。
  • 如果传入的是类对象,使用const引用。类设计从语义上来说就是要用引用的,这其实也是C++添加引用这一特性的主要原因。所以传递类对象的标准做法就是引用传递。

如果方法需要修改传入参数的原数据:

  • 如果传入的是基础内置数据类型,使用指针。当然其实引用也是可以的。
  • 如果传入的是数组,那只能用指针。
  • 如果传入的是struct,用指针和引用皆可。
  • 如果传入的是类对象,用引用。

默认参数

默认参数就是你在方法原型中可以指定参数的默认值。在指定了参数默认值后,在调用方法时就可以省略传入这个参数,参数会使用默认值进行初始化。

int function(int a = 1);
int main()
{
	int a = function(3);
	int b = function();
}
int function(int a)
{
	return a+1;
}

a等于4,b等于2。第二次调用function时没有传入参数,方法的参数a就使用了默认值1.
设置参数默认值注意要从右到左进行设置。也就是说如果一个参数有默认值,那么它右侧的所有参数都要有默认值。

int function(int a, int b, int c = 1);  //可以
int function(int a, int b = 1, int c);  //不行

在调用方法时,省略参数也是要从右到左省略。也即是如果一个参数你传入了,那么它左侧的参数都要传入。一个参数省略了,它右侧参数都要省略。

方法重载

方法重载可以让多个方法共享同一个方法名。方法重载的关键在于方法的参数列表,也叫方法签名。如果两个方法有相同的参数数量,相同的参数类型,相同的参数顺序,那么它们的签名就是相同的。(参数的名字是没用的)C++允许允许定义两个有相同名字但不同签名的方法。签名可以是参数数量不同或者类型不同或者二者都有。示例:

void function(int a, double d);
void function(int a, long l);
void function(int a);

重载要注意的几点:

  1. 一个类型的变量和该类型的引用是一样的
    也就是说,以下两个方法对于重载来说是一样的。
    void function(int a);
    void function(int & a);
    因为这两个方法在调用的时候都是传入一个int变量。编译器根据调用传入的参数来判断被调用方法时,它没法判断这两个方法哪个更适合,因为两个都可以接收int变量作为参数。
  2. 但是const和非const对于重载来说是不一样的
    因为写了const的方法可以同时接收const和非const的传入,而没有写const的方法只能接收非const的传入。编译器可以根据传入的参数是否是const来选择被调用方法。
  3. 只有返回值不同是不行的
    以下重载是无效的:
    int function(int a);
    double function(int a);
    同样是因为编译器需要通过调用传入的参数判断谁是被调用方法。而两个有着一样签名的方法对于同样的传入参数类型有着同样的匹配度,编译器就无法做出选择。
  4. 多个重载方法会让编译器迷糊
    比如说本来只有一个方法void function(double a)。当发生function(6)的时候,编译器就会将6转为double,然后进行调用。
    但如果function被重载,现在有三个function:
    void function(double a);
    void function(long a);
    void function(float a);
    6是一个int,它可以被转换为double也可以被转为long也可以被转为float,这么一来,编译器就不知道该调用这三个方法中的哪一个了。这时候程序就会报错。

方法模板

方法模板就是在用通用类型替代具体类型的情况下描述方法定义。举个例子:

template <typename T>
void swap(T &a, T &b)
{
	T temp;
	temp = a;
	a = b;
	b = temp;
}

第一行是将任意类型的类型名设置为T。关键字template和typename是必须的,不过typename可以替换为class。然后尖括号也是必须的。T这个类型名就是自定义的了,只要遵循C++基本命名规则即可。随后的代码是将两个T类型的变量引用的值进行交换。
模板并不会生成任何的方法,它只会向编译器提供一个如何定义方法的描述。当程序对这个模板描述的方法进行调用时,编译器才会根据模板和需求生成对应的方法。
在程序进行类似swap(a, b)的调用,而a和b都是int变量时,编译器就会生成一个类似以下的方法:

void swap(int &a, int &b)
{
	int temp;
	temp = a;
	a = b;
	b = temp;
}

注意的是,使用方法模板不会让程序变短。比如在程序中调用了int版的swap,double版的swap,long版的swap。那么将这三个版的swap都写出来和写一个方法模板,在编译后的代码是一样的。且编译后代码中十把不存在模板代码的。
一般来说,模板都是写在头文件中的,这会在第九章说到。

模板重载

使用模板是为了对同一算法输入不同类型的参数。但有时候并不是所有类型的输入都是要使用相同的算法。这时候就用到了模板方法重载。和普通重载一样,模板重载需要的是不同的签名。举例:

template <typename T>
void swap(T &a, T &b);

template <typename T>
void swap(T &a, T &b, int n);

模板限制

要注意的是,模板方法中对通用类型进行的操作是会限制通用类型的适用范围的。比如说在模板方法中对通用类型T的变量进行了赋值(如a=b)。如果实际调用时a和b是int之类的基础类型当然可以,但是如果a和b是两个数组名,那么对于数组类型来说,根本就不存在a=b这种操作,那么这个模板就无法根据数组名这个参数类型生成相应的方法。同样的还有模板进行了a>b的判断,而调用时a和b是两个类对象,根本没有定义>运算,那么编译器同样也无法生成相应的方法。

方法模板定制

显式定制

比如说现在有一个struct:

struct job
{
	char name[10];
	double salary;
}

然后同样有一个swap的方法模板。如果用两个job变量调用了swap方法,编译器当然是可以生成对应的方法交换两个job变量的值的。因为struct是定义了赋值的。但是如果现在我们不想在调用swap方法时交换两个变量的值,而只想让两个job变量交换各自的salary的值。那么这就需要另外的算法了,但是调用输入的参数个数和类型又和本来的swap模板相同(参数是两个相同类型的变量),那么就无法进行重载了。
那么这时候就可以用到显式定制。在早期多次的改版后,C++98标准确定了显式定制的方法:

  • 对于一个给定的方法名,你可以有一个非模板的方法、一个模板方法和一个显式定制模板方法,而这三个都可以被重载若干次
  • 显式定制的原型和定义要使用template关键字且要写明定制针对的类型名
  • 定制模板会覆盖普通模板,而非模板方法会覆盖前二者。

接下来看看swap这个方法名的最大潜力:

void swap(job &, job&);   //非模板方法

template <typename T>
void swap(T &, T&);        //模板方法

template <> void swap<job>(job&, job&);      //定制模板方法

当三种方法定义同时存在,编译器调用时会优先考虑非模板方法,然后是定制模板方法,最后才根据模板生成方法。
定制模板在方法名后的是可以不写的,因为后面参数表也表明了参数类型。模板定制比非模板要好的一点是模板定制和普通模板共用原型。也即是原型只要有普通模板的原型即可,在定义模板时额外写上定制模板的定义即可。

实例和定制

详细说模板方法,要注意的是模板代码其实不算是方法定义,编译器根据调用生成的方法代码才是方法定义。比如编译器生成了int版本的swap,这个生成出来的swap的代码才是方法定义。这个生成出来的swap方法称作一个方法实例。这个实例是一个隐式实例因为编码时它是不可见乃至不存在的。
既然有隐式的说法,就有显式的。也就是你可以指定编译器生成的方法版本:

template void swap<int>(int,int);

这一语句的意思就是让编译器生成一个int版本的swap方法,不管这个版本在程序中究竟有没有用到。这一语句和显式定制模板只有稍微不同:

template void swap<int>(int,int);        //显式实例
template <> void swap<int>(int, int);    //显示定制

这两句差别只在于template后面的尖括号,但是意思差别却很大。第一句是让编译器根据模板生成int版本的swap。第二句是让编译器在需要int版本swap时不要根据原模板生成,而是使用这一句之后的方法描述来生成int版本swap。
模板方法显式实例还用一种用法是直接写在调用处:

int a = 1;
double b = 2.0;
swap<double>(a, b);

这个写法就会指定编译器在此处生成的方法的版本。原本因为a和b的类型不同,编译器会因为找不到符合的模板无法调用swap。但这一显式实例调用的写法会强制编译器生成一个double版本的swap使用。这样a就会被转换为double传入到swap。(小提一句,这个调用其实会由于swap的两个参数不是const引用导致无法接收类型不匹配的参数而调用失败。不过这个不是此处的重点。)

编译器究竟是怎么挑选方法的?

  • 阶段一:生成一个候选方法列表。列表内是所有方法名与被调用方法相同的方法和模板。
  • 阶段二:从上述列表中生成一个可行方法的列表。新列表内的方法是拥有正确数量的参数并存在可行的转换序列使传入参数类型可以匹配签名内的参数类型。
  • 阶段三:查看是否存在所谓最佳的可行方法,如果有,调用之。如果没有,那么这次调用失败。

完美匹配与最佳匹配

C++在进行完美匹配时允许所谓“不重要转换”。

转换至
typetype&
type&type
type[]*type
type(argument-list)2type(*)(argument-list)
typeconst type
typevolatile type
type*const type*
type*volatile type*
void func(int);
void func(const int);
void func(int&);
void func(const int&);

int main()
{
	int a = 0;
	func(a);
}

对于main中的调用,四个原型均是完美匹配,所以编译器无法判断该调用那个方法,从而导致调用失败。
不过有时出现两个完美匹配也可以成功调用。首先指向非const的指针和引用会更优先匹配非const指针和引用参数。也就是如果上面的原型中只余后二,那么第三个方法会被调用,因为a不是const。但如果原型只余一三,那么调用还是失败。
另外的完美匹配优先级是模板与非模板方法。上面也提过,非模板方法先于模板定制方法先于模板方法。
即便同是定制模板,也有优先级之分。那就是需要的转换数量的不同。比如:

template <class T> void func(T t);
template <class T> void func(T * t);

int t = 1;
func(&t);

这个调用两个都可以符合。如果是第一个,T就是int*,如果是第二个,T就是int。对于这两个模板,第二个会有更高的优先级,因为它需要更少的转换。因为第二个写明了参数是一个指针,所以T直接就可以被识别为int。而第一个的参数类型就是T,所以T必须要被解释为一个指向int的指针。意思就是第二个模板里T已经是一个指针了,它的定制程度更高。

decltype

C++11新增了一个关键字decltype。其用处是:

int x;
decltype(x) y;   //y的类型与x相同

这个关键字还能用于表达式:

decltype(x + y) xpy = x + y;   //xpy的类型与x+y相同

所以这个关键字可以用到模板中:

template <class T1, class T2>
void func(T1 x,T2 y)
{
	decltype(x+y) xpy = x+y;
}

这个关键字判断类型的选择有四步:

  1. 如果表达式是不带括号的标识,那么被声明的变量就是这个标识的类型。
  2. 如果表达式是一个方法调用,那么被声明的变量和方法的返回值的类型相同。
  3. 如果表达式是一个lvalue,那么被声明变量是表达式的类型的引用。3

  1. lvalue是什么?lvalue是可以用地址进行引用的数据对象,比如变量、数组元素、struct成员、应用和指针等等。non-lvalue就比如常量(除了双引号括起的字符串,它是有地址的),表达式等等。原来C中lvalue的定义是可以放在赋值语句左侧的数据对象,在const出现之后这个定义就不严谨了因为被const修饰的对象是不能放在赋值语句左侧的,但它们依然可以是lvalue。所以现在的lvalue定义是可以用地址引用的对象。 ↩︎

  2. 这是一个方法。方法可以“不重要转换”为相应的方法指针,只要二者的签名与返回值相同即可。
    有了所谓“不重要转换”,那么以下代码: ↩︎

  3. 这似乎与第一点冲突。比如decltype(w) y,w是一个int。根据第一点,y应该与w类型相同是个int。而根据第三点,y应该是w的引用,是一个int&。关于这个冲突,答案是第一点有更高优先级,类型选择是从第一点一次向下判断。所以decltype(w) y会直接在第一步判断结束,y是个int。而decltype((w)) y,表达式是(w),带括号 ↩︎

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值