C++ 模板学习总结(四)函数模板

按照之前的进度我们已经讲了模板三种参数类型,那么本应该乘胜追击的来讲讲变长模板,但是我想把这个议题讲的更深刻一点,为啥呢,因为作为C++11最闪耀的一个新特性,它太重要了,而模板从变长模板加入以后这个学习的路线突然给打乱了,想要讲解变长模板需要其他的知识,而其他的知识又穿插了变长模板的内容,所以干脆我暂且不考虑变长模板的情形,专心得讲解原先C98老标准中的知识,等最后再来讲解它。好,我们看看今天的内容,函数模板。

之前的两篇文都着重在写类模板的知识,其实相对而言类模板是比较简单的,它的使用在实际项目中虽然更多更广泛,但模板真正繁杂的部分几乎都出自于函数模板,但在实际项目中见到的少绝不代表他不重要,以我自己的理解,在大多数的模板工具库中,函数模板真正起到了承上启下,画龙点睛的作用,好了,我们先从简单的来看看。

首先应该明白的是,模板包含了类模板和函数模板,类模板我们之前已经讲过了,剩下的内容几乎都是函数模板了,所以按照后续的章节估算来看,函数模板的内容显然是多于类模板的,其实类模板非常简单,不过就是几种形式,把模板参数套用进去就好了,而函数模板就远不止于此了,包含的议题非常之多。

我们先来看个例子:

template<typename T, int N>
void function()
{}

这是一个看起来非常简单的模板函数,但是不得不说很多人理解不了,我听过太多的人问我这个函数定义里,T和N两个参数都没有在函数中实际使用,为什么这两个参数还能存在,首先,谁告诉你的模板参数一定要和定义有关系?这个问题换句话说,模板参数到底定义了什么?无论对于类模板还是函数模板来讲,这个答案简单来说,不同的模板参数对应了不同的模板实例,而模板本身是否使用这些模板参数这完全是需求上的问题了,可是对于模板来讲,如果没有在定义中用到模板参数那么每个版本的定义岂不是没有差异了,是的,没有差异,但是他们是不同的实例,而且实例间毫无关系,这点一定要搞清楚,否则很多概念会混乱不堪。

那么,在使用上我们之前讲了模板的实例化,一个模板自身是不可以被直接使用的,一定要在被实例化后才能生成一个可用的实例,同样对于函数模板而言也存在实例话,不仅如此还同样存在显式实例化和隐式实例化,我们先来看看函数的显式实例化:

在此多说两句,大家对实例化和特化的格式一定要熟悉,至少要一眼看出来是特化还是实例化才行,表面上看去很简单,其实有些情况不是很好分别,特别是阅读一些本来就很难读懂的代码正头大的时候就很容易出错,还有一些生僻的用法不是很常见,更容易出错,例如下面例子,得能不用想一眼清才行:

template int PrimaryDef<char>::sMem;

template<>
int PrimaryDef<int>::sMem = 10000;
好,发散了一下,回到正题,再看看函数模板的实例化,对于上面的function函数的实例化并没有上面特别之处按照格式套入即可,

template void function<int, 10>();
对于上面的例子,这种实例化方法已经足够了,但是对于一些特殊的函数模板还存在另一种实例化的方式,比如:

template <typename T>
void func(T arg)
{}
对于上面这个函数来讲,我们可以通过指定参数来进行实例化:

template void func(int);
template void func<>(int);

这种方式虽然比较少见,但如果遇到一定要认识,否则就悲剧了,对于这个函数模板,其模板参数T在函数中给函数参数做了类型,所以只要函数类型确定了,模板参数也就可以确定了,所以同样能实例化。这个小括号要和不要都可以。

对于函数的特化就不再啰嗦了,没有太大意义,跟类模板属于一个套路,唯一不同的而且要强调的是,函数模板不支持偏特化,如果在使用上遇到这样的情况那么要通过函数重载来解决。关于这点我们后面再说,现在首先要介绍的是对于函数模板一个非常重要的议题,叫做函数模板的实参演绎(Template arguments deduction)。

首先介绍一下什么叫实参演绎,不敢胡诌,所以把cppreference上面的一段搬出来:

In order to instantiate a function template, every template argument must be known, but not every template argument has to be specified. When possible, the compiler will deduce the missing template arguments from the function arguments. This occurs when a function call is attempted, when an address of a function template is taken, and in some other contexts.


还是大致介绍一下,意思是说当实例化一个函数模板时,必须清楚每一个模板参数,但是并不是说必须要显示指定,如果编译器可以从传递的实参推导出来模板参数的也同样可以,那么实参演绎通常发生在函数调用或者对函数模板取地址赋值的时候,还有一些特殊情况,后面再说。

我们先来看个例子,为了提高效率代码不再指出格式细节,比如不再强调函数定义格式等等,我尽量从cppreference上取原汁原味的例子:

template<typename To, typename From> To convert(From f);
 
void g(double d) 
{
    int i = convert<int>(d);    // calls convert<int, double>(double)
    char c = convert<char>(d);  // calls convert<char, double>(double)
    int(*ptr)(float) = convert; // instantiates convert<int, float>(float)
}

我们可以看到convert函数有两个模板参数,To和From,那么指定了其中的一个,对于这种情况,有其基本原则,指定的模板参数按照模板参数列表从左往右的顺序来指定,没有被指定的模板参数会根据函数的实参来演绎,注意,除非你想使用SFINAE的特性,否则这里指定的模板参数跟函数参数类型一定不能有前后不一的情况,这点我们后续再来讨论。所以按照规则,例子中前两处对convert的使用分别是通过指定了模板参数To,而From则从传入的参数来推导,以为实参d的类型为double,所以From很自然的被推断为double类型,而最后一个比较特殊,对ptr指针赋值,其类型为int (*)(float),将convert赋值给ptr时会通过其类型的返回值和参数共同推导出To和From,注意,这里比较特殊,连返回值类型都用上了,而函数调用涉及的实参演绎返回值类型是没有资格参加的。

编译器根据调用方传入的实参类型Ai形成与形参Pi之间的配对,从而确定每一模板参数(在此设定模板类型参数为Ti,模板模板参数为TTi,非类型模板参数Ii),其中的i表示多个参数存在时的下标。那么在匹配时,每一个Ai和Pi都会形成一对,然后根据每一对来分别做推导,如果推导出来的结果间有矛盾会出现匹配失败的情况。在这个时期是不存在隐式转型的。比如:

#include <iostream>

template<typename T>
void func(T p1, T p2)
{}

int main()
{
    func(1, 2.5);
}   
在这个例子中func的参数p1,p2都是T类型的,但是在调用方分别使用了int和double类型的参数,套用上述的规则P1 = T ,A1=1, T = int, P2 = T ,A2 = 2.5, T = double,这两对分别推导出不同的T所以会发生匹配失败的情况,编译信息如下

Test.cpp:4:6: note:   template argument deduction/substitution failed:
Test.cpp:9:16: note:   deduced conflicting types for parameter ‘T’ (‘int’ and ‘double’) 

不过需要注意的是我在上面所用的词是匹配失败,但强调一点匹配失败并不是一个错误,可能有人会比较疑惑为什么不是错误还是报错了?这是因为我们没能为func的调用找到一个匹配成功的情况,换句话说,如果存在一个func定义能满足func(1, 2.5)这样,那么就不会报错了,这点其实就是著名的SFINAE的最基本意思。我们留在以后有机会讨论。


我想对模板参数演绎已经有一个基本的了解了,那现在看看这个语法的细节部分。

首先,有些特殊的情况要介绍一下,对于函数的模板是一个函数指针类型时,如果传入的实参是一组重载函数,那么如果仅有一个函数可以最佳匹配参数类型的时候就会以此函数版本作为参数调用,否则会报错,这点只需要看个例子就好,不用太在意。

template<class T> int f(T(*p)(T));
int g(int);
int g(char);
f(g); // P = T(*)(T), A = overload set
      // P = T(*)(T), A1 = int(int): deduced T = int
      // P = T(*)(T), A2 = int(char): fails to deduce T
      // only one overload works, deduction succeeds

在模板参数演绎以前,编译器通常会做一些工作来让匹配更容易,主要如下:

  • 如果P不是一个引用类型
1)如果A是一个数组类型,那么A将被替换为该数组成员类型的指针类型。
2)如果A是一个函数类型,那么A将被替换为该函数的指针类型
3)如果A是一个CV限定类型(也就是说有const或volatile),则忽略其cv属性。
template<class T> void f(T);
int a[3];
f(a); // P = T, A = int[3], adjusted to int*: deduced T = int*
const int b = 13;
f(b); // P = T, A = const int, adjusted to int: deduced T = int
void g(int);
f(g); // P = T, A = void(int), adjusted to void(*)(int): deduced T = void(*)(int)
  • 如果P是一个CV限定类型,则忽略其CV属性
  • 如果P是一个引用类型,那么P将被修正为所引用的类型
  • 如果P是一个cv限定的右值引用,而A是一个左值,那么A将被修正为该左值类型的左值引用(C++11)
前面两个都比较容易理解,那么最后一个是比较麻烦了,为什么要这样的原因是可以通过引用折叠来处理这种特殊情况。还是用一个例子来说明一下:
template<class T>
int f(T&&);       // P is an rvalue reference to cv-unqualified T (forwarding reference)
template<class T>
int g(const T&&); // P is an rvalue reference to cv-qualified T (not special)
 
int main()
{
    int i;
    int n1 = f(i); // argument is lvalue: calls f<int&>(int&) (special case)
    int n2 = f(0); // argument is not lvalue: calls f<int>(int&&)
 
//  int n3 = g(i); // error: deduces to g<int>(const int&&), which
                   // cannot bind an rvalue reference to an lvalue
}
f是一个非cv的右值引用类型,而实参i是一个左值,所以按照上面的规则会被调整为左值引用,所以T=int&,而f的形参类型int&&&会产生引用折叠为一个左值引用。多说一句,什么是左值什么是右值,简单来说能取地址的就是左值,不能取地址就是右值,而不是说等号左右的区别。
那么当进行完上述的修正之后,就会开始进行推导,那么也有以下的一些规则:
  • 推导后的类型可以比实参类型A多顶层的cv限定,举个下面的例子:
template<typename T> void f(const T& t);
bool a = false;
f(a); // P = const T&, adjusted to const T, A = bool:
      // deduced T = bool, deduced A = const bool
      // deduced A is more cv-qualified than A
首先,因为f的P是一个引用类型,所以去掉引用,P=const T,而A = bool,那么T只能是bool,而代入后类型为const bool比A多一个const,这在C++中是允许的。
  • 如果传递的实参类型可以通过限定转型(qualifiers conversion)转换成推导后的类型,则是允许的,还是看个例子
template<typename T> void f(const T*);
int* p;
f(p); // P = const T*, A = int*:
      // deduced T = int, deduced A = const int*
      // qualification conversion applies (from int* to const int*)
是不是觉得跟上面的例子没区别?实际上是有很大区别的,这例子里的f形参类型是指针类型,而const的位置在*之前,所以,这个指针类型不是const的。那么限定转型可以将int*转换成const int*,所以可以编译通过。
  • 如果P是一个模板类型的引用或直接,而传递的A是P的子类模板类型,那么同样可以,说起来有点绕,还是看个例子来说明。
template<class T> struct B { };
template<class T> struct D : public B<T> { };
template<class T> void f(B<T>&) { }
 
void f()
{
    D<int> d;
    f(d); // P = B<T>&, adjusted to P = B<T> (a simple-template-id), A = D<int>:
          // deduced T = int, deduced A = B<int>
          // A is derived from deduced A
}
根据之前的描述,f的参数为引用类型,转变为P=B<T>,而A为D<int>,推导出T=int后,P为B<int>而A是其子类。是不是觉得巨恶心巨无趣,没错就是这样。

下面要介绍一点更实用和有趣的东西,不知道大家有没有想过,是否所有的P的表示都是可以被推导演绎的呢?这么问很显然就不是了,那么有哪些模板参数是可以被推导哪些不能被推导,C++标准给出了我们很多限定,我们先来看看不可推导上下文(Non-deduced contexts)的情况。
  • 如果P是一个嵌套名说明符(nested-name-specifier),那么其所含的模板参数是不可推导的。首先要说的是啥叫嵌套名称说明符,其实就是用来限定作用域的::符号的左边那部分。比如std::out中std就是嵌套名说明符。那么这里所特指的主要是内中嵌套内部类的情况。不举个栗子肯定不行:
// the identity template, often used to exclude specific arguments from deduction
template<typename T> struct identity { typedef T type; };
template<typename T> void bad(std::vector<T> x, T value = 1);
template<typename T> void good(std::vector<T> x, typename identity<T>::type value = 1);
std::vector<std::complex<double>> x;
bad(x, 1.2);  // P1 = std::vector<T>, A1 = std::vector<std::complex<double>>
              // P1/A1: deduced T = std::complex<double>
              // P2 = T, A2 = double
              // P2/A2: deduced T = double
              // error: deduction fails, T is ambiguous
good(x, 1.2); // P1 = std::vector<T>, A1 = std::vector<std::complex<double>>
              // P1/A1: deduced T = std::complex<double>
              // P2 = identity<T>::type, A2 = double
              // P2/A2: uses T deduced by P1/A1 because T is to the left of :: in P2
              // OK: T = std::complex<double>
这个例子也不知道是哪个大神想出来的,太经典以至于我第一次居然没看懂,简单解释一下:
有bad和good两个函数,其第一个参数是可推断的类型,而第二个参数就有花样了,bad的第二个参数直接是T,good第二个参数是模板类identity的内部定义的type,但是有趣的是identity<T>的type实际上还是T,那么这样的包装看上去似乎是多此一举,然而我们看一下x的类型,对bad而言,x将和第一个参数组成P/A对,而1.2和T会组成另一组P/A对,我们之前说过了,每一个P/A的推导是独立完成的,如果最后出现歧义,那么就会出现匹配失败的情况,对P1/A1来讲,T被推导为std::complex<double>,而P2/A2将T推导为double,两次推导产生歧义,所以匹配失败,而对于good函数而言,P1/A1同样推导出T为std::complex<double>而对第二个参数,由于嵌套名说明符中的模板参数是不可推导上下文,P2/A2不能推导T,但是T只要被一处推导出来且没有产生匹配失败就OK了,于是T被确定为std::complex<double>,没有匹配失败的地方,而且只有一个模板参数,所以推导成功,而1.2会通过std::complex<double>的隐式转换生成临时变量,于是编译通过。
  • 如果非类型模板参数存在于子表达式中,则模板参数不可推导,这个比较简单,举例说明就可以了
template<std::size_t N> void f(std::array<int, 2 * N> a);
std::array<int, 10> a;
f(a); // P = std::array<int, 2 * N>, A = std::array<int, 10>:
      // 2 * N is non-deduced context, N cannot be deduced
      // note: f(std::array<int, N> a) would be able to deduce N
  • 函数的默认参数不能用来做推导T
template<typename T, typename F>
void f(const std::vector<T>& v, const F& comp = std::less<T>());
std::vector<std::string> v(3);
f(v); // P1 = const std::vector<T>&, A1 = std::vector<std::string> lvalue
      // P1/A1 deduced T = std::string
      // P2 = const F&, A2 = std::less<std::string> rvalue
      // P2 is non-deduced context for F (template parameter) used in the
      // parameter type (const F&) of the function parameter comp,
      // that has a default argument that is being used in the call f(v)
  • 如果A是一组重载函数,且存在多于一个的匹配或不存在匹配时,则T不可推导。
template<typename T> void out(const T& value) { std::cout << value; }
out("123");     // P = const T&, A = const char[4] lvalue: deduced T = char[4]
out(std::endl); // P = const T&, A = function template: T is in non-deduced context
  • 如果A是一个初始化列表,而P不是std::initializer_list类型或其引用类型,则P中的T不可推导
#include <vector>
#include <initializer_list>

template<class T> void g1(std::vector<T>);
template<class T> void g2(std::vector<T>, T x);
template<class T> void g3(std::initializer_list<T>);

g1({1, 2, 3});     // P = std::vector<T>, A = {1, 2, 3}: T is in non-deduced context
                   // error: T is not explicitly specified or deduced from another P/A

g2({1, 2, 3}, 10); // P1 = std::vector<T,> A1 = {1, 2, 3}: T is in non-deduced context
                   // P2 = T, A2 = int: deduced T = int

g3({1, 2, 3})     // P = std::initializer_list<T>, A = {1, 2, 3}: deduced T=int



  • 如果P中包含一个模板参数列表,且有参数扩展包(pack expansion  C++11),如果其位置不在P的模板参数列表的最后,则不可推导。
template<int...> struct T { };
 
template<int... Ts1, int N, int... Ts2>
void good(const T<N, Ts1...>& arg1, const T<N, Ts2...>&);
 
template<int... Ts1, int N, int... Ts2>
void bad(const T<Ts1..., N>& arg1, const T<Ts2..., N>&);
 
T<1, 2> t1;
T<1, -1, 0> t2;
good(t1, t2); // P1 = const T<N, Ts1...>&, A1 = T<1, 2>:
              // deduced N = 1, deduced Ts1 = [2]
              // P2 = const T<N, Ts2...>&, A2 = T<1, -1, 0>:
              // deduced N = 1, deduced Ts2 = [-1, 0]
bad(t1, t2);  // P1 = const T<Ts1..., N>&, A1 = T<1, 2>:
              // <Ts1..., N> is non-deduced context
              // P2 = const T<Ts2..., N>&, A2 = T<1, -1, 0>:
              // <Ts2..., N> is non-deduced context
  • 如果P是一个非引用数组类型,则其主边界不可推导。
template<int i> void f1(int a[10][i]);
template<int i> void f2(int a[i][20]);    // P = int[i][20], array type
template<int i> void f3(int (&a)[i][20]); // P = int(&)[i][20], reference to array
 
void g()
{
    int a[10][20];
    f1(a);     // OK: deduced i = 20
    f1<20>(a); // OK
    f2(a);     // error: i is non-deduced context
    f2<10>(a); // OK
    f3(a);     // OK: deduced i = 10
    f3<10>(a); // OK
}


在此介绍完以后我们先来看一个gcc的bug,这个例子略有超前,如果看不懂也没关系,直接跳过吧。
#include <iostream>
using namespace std;

template<typename T> class TEMP {};

struct A
{class In{};};

struct B{};

template<template<typename> class TT, typename T>
void func(TT<T> t, typename T::In*)
{    cout << "#1\n"; }

template<template<typename> class TT, typename T>
void func(TT<T> t, ...)
{    cout << "#2\n"; }

template<template<typename> class TT, typename T>
void forwarding(TT<T> a)
{    func(a, nullptr); }

int main() {
    TEMP<A> ta;
    TEMP<B> tb;

    forwarding(ta);
    forwarding(tb);
}

先看在4.8和4.9上分别运行的情况。图1为gcc4.8的运行结果,图2位gcc4.9的运行结果。

图1


图2

这个例子正确的结果应该是#1 #2,也就是gcc4.9的运行结果,然而4.8上为何会得到不同的结果,我后来发现按照标准嵌套名说明符理应是不可推导的,但是在这个例子中编译莫名其妙的将nullptr和 T::In*进行了P/A配对,大家知道nullptr_t才是nullptr的类型,于是它认为匹配失败,我当时想是否是因为指针类型的缘故,后来改变示例直接用T::In*来进行推导,但是编译器却告诉我不可以推导,这样就只能说这是gcc的一个bug了,但是在4.9的版本就没有了,说明这的确是个bug被修掉了。

看完了不可推导的情况,那么我们看一下函数模板的参数类型哪些是合法可推导的,当一个函数模板的参数P包含模板参数的时候,当其有一下类型格 式时,在接收相应A的时候,其中的模板参数是可以被推导出来的:
  • T; (P的类型为T)

  • cv-list T; (P的类型为带const或volatile的T)

  • T*;(P的类型为T的指针类型)

  • T&;(P的类型为T的引用类型)

  • T&&;(P的类型为T的右值引用类型,C++11)

  • T[integer-constant];(P的类型为T的数组类型,其中integer-constant为已知的常数,注意这里不是引用类型,如果把integer-constant改成一个非类型参数N,那么N是不能不推导出来的。但是引用类型可以,见后述)

  • T (&)[N];(P的类型为T类型长度为N的数组引用类型,这个类型可能很多人不认识,比如说int a[5],那么它的类型为int[5]但是其引用类型为int (&)[N],指针类型为int (*)[N],如果要定义一个变量其格式为int (&p)[N],p的类型就是int[5]的引用类型。)

  • class-template-name<T>;(P为一个模板类以模板参数T为实例化的类型的参数)

  • type(T);(这个类型估计见过的也不多,是返回值为type参数为T的函数类型,注意,不是函数指针类型)

  • T();(同上函数类型)

  • T(T);(同上函数类型)

  • T type::*;(这个类型估计见过的更少,是类的成员非static成员指针类型,其自身类型为T,是type的一个成员,此处type已知,注意这里只是说起成员类型,并不特指成员指针和成员函数,这两者都包含其中,例如type中有一个成员变量a,和一个成员方法func,那么在传递参数A的时候,可以传递&type::a和&type::func,有人说type是一个类型,不是一个实例,怎么能通过type使用a,嗯,确实可以,不明白的话google)

  • type T::*;(同上,只是此处其自身类型已知,所属的类未知,有人说不是嵌套名说明符不可推导吗,嗯,你这么想也对,不过这里和上面有明显的差别,上面的情况是嵌套类,而这里不是,是其成员)

  • T T::*;(同上,类型和所属类都不知。这里并不要求两个T是相同的,也就是说可以是T1 T2::*)

  • T(type::*)();(P是type类的成员函数,其自身类型为T())

  • type(T::*)();(同上成员函数)

  • type(type::*)(T);(同上成员函数)

  • type(T::*)(T);(同上成员函数)

  • T (type::*)(T);(同上成员函数)

  • T (T::*)();(同上成员函数)

  • T (T::*)(T);(同上成员函数,忘了说明一下,如果要声明一个具体参数p,格式为T (T::*p)(T),其中的三个T可以分别替换为T1,T2,T3,均可推导)

  • type[i];(数组类型)

  • class-template-name<I>;(模板类型,只是其参数为非类型模板参数I)

  • TT<T>;(模板模板参数,TT和T均可推导)

  • TT<I>;(模板模板参数,I为非类型模板参数,TT和I均可推导)

  • TT<>;(模板模板参数,这个比较特殊是匹配C++11中可变模板参数的。)

个人觉得上面的表述已经极尽详细了,就不再赘述,下面列出一个例子,没找到合适的例子所以自己写了一个,由于是示例所以命名比较随意,凑合看吧,O(∩_∩)O哈哈~:

#include <iostream>
using namespace std;

struct  A
{
	void func(int){}
	int a;
	/* data */
};

template<typename...T>
class TA
{};

template<typename T1, typename T2, typename T3>
void func1(T1 (T2::*p)(T3))
{}

template<typename T1, typename T2>
void func2(T1 T2::*p)
{}

template<typename T1, int I>
void func3(T1 (&p)[I])
{}

template<typename T1, int I>
void func4(T1 (*p)[I])
{}

template<template<typename...> class TT>
void func5(TT<> p)
{}

int main() {
	int a[5];
	TA<> ta;
    func1(&A::func);
    func2(&A::func);
    func2(&A::a);
    func3(a);
    func4(&a);
    func5(ta);	
}



函数模板的知识,其实还远没完结,还有 函数模板重载及选择,ADL(实参依赖查找),SFINAE(匹配失败不是一个错误),等等, 函数模板重载及选择的重点在于选择,跟普通重载函数匹配是一个道理,也就是说如果有多个重载版本的时候该选择哪一个的问题,这个议题巨细巨烦巨恶心,但是又绕不过去,后续一定会有一篇来说明,ADL是说在调用一个函数模板的实例时,由于命名空间的关系调用方和被调用方不在一个命名空间下照理是需要using一下才能使用的,但是有些函数,比如操作符重载函数通常是和相关的类定义在同一个命名空间的,而由于模板函数的参数不确定所以其命名空间也不能提前确定下来,所以需要有一种方法来查找到,这就是通过传递给函数的实参来查找和这个实参相关的一些命名空间,这个话题一般会穿插在不同的文章中遇到了再说。但说到模板的技巧,SFINAE简直是汇聚了各种奇技淫巧,个人觉得SFINAE是非常有趣的,有点脑筋急转弯的意思,后续也会专门来说。但对于我们普通的使用,现在讲的这些知识已经足够了,其他的就是技巧,我们后续有空再单独聊,先到此为止。


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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值