关于变量,作用域与闭包;也再讨论一下C++0x的lambda表达式(预演……)

http://rednaxelafx.iteye.com/blog/184199


在程序设计语言的语境下,一个“闭包”到底是什么?这还是得从一些别的基本概念说起。 

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

基本概念解释: 

在程序设计语言中,变量可以分为自由变量(free variable)与约束变量(bound variable)两种。简单来说,一个函数里局部变量和参数都被认为是约束变量;而不是约束变量的则是自由变量。 
在冯·诺依曼(von Neumann)计算机体系结构的影响下,命令式语言(imperative language)里一个变量的属性可以看作一个六元组:(名字,地址,值,类型,生命期,作用域)。 
名字:顾名思义。 
地址:变量所关联的存储器地址。这个属性有明显的冯·诺依曼体系结构的色彩。 
:变量所关联的存储器单元的内容。 
类型:规定了变量可以取的值得范围,以及该类型的值可以进行的操作。根据类型的值的可赋值状况,可以把类型分为三类: 
- 1、一级的(first class)。该等级类型的值可以传给子程序作为参数,可以从子程序里返回,可以赋给变量。大多数程序设计语言里,整型、字符类型等简单类型都是一级的。 
- 2、二级的(second class)。该等级类型的值可以传给子程序作为参数,但是不能从子程序里返回,也不能赋给变量。 
- 3、三级的(third class)。该等级类型的值连作为参数传递也不行。 
生命期:变量与一个特定的存储区地址相绑定的过程。一个变量的生命期从它与一个特定的存储器地址相绑定开始,到它与存储器解除绑定为止。根据生命期分类,变量可以被分为四类: 
- 1、静态(static)。该种变量的存储器分配在程序开始运行之前就决定,并且在程序运行过程中一直不变,直到程序结束为止。 
- 2、栈动态(stack-dynamic)。该种变量的存储器分配在声明它的语句被执行到的时候才决定,但变量类型是静态决定的。顾名思义,空间是在运行时栈上分配的。 
- 3、显式堆动态(explicit heap-dynamic)。该种变量由程序员显式使用运行时指令(或运算符)来指定在堆上空间的分配(和/或回收)。典型的例子是使用newdelete运算符。 
- 4、隐式堆动态(implicit heap-dynamic)。该种变量每次被赋值时都会重新在堆上分配空间,不需要特别的运行时指令(或运算符)来指定。 
作用域:变量在语句中可见的范围。如果在某个语句中可以引用某个变量,则该变量在该语句中可见。根据作用域的特征分类,程序设计语言中所支持的作用域可以被分为: 
- 1、静态作用域。在静态作用域的规定下,变量的作用域与其在代码中所处的位置相关;因为代码可以静态决定(运行前就可以决定),所以变量的作用域也可以被静态决定,所以这种规定被称为静态作用域。 
- 2、动态作用域。相对的,在动态作用域的规定下,变量的作用域与代码的执行顺序相关;执行顺序只有在程序运行时才能被决定,所以这种规定被称为动态作用域。 
某种意义上来说,这两种规定是统一的:静态作用域由空间(代码字面上的空间)范围决定,动态作用域由时间(程序的执行)顺序决定。但由于作用域本身是个空间概念,所以一般而言静态作用域更容易被人理解。 

变量的生命期与作用域并不一样,一个是时间概念,一个是空间(源代码字面上的)概念。但支持静态作用域,并且支持栈动态变量的程序设计语言中,这两个概念会有一些联系,例如:一种支持静态作用域与栈动态变量的语言(例如C)中,一个原始类型(或者在某些语言中,值类型)的局部变量会在栈上分配空间,它的生命期从其所在的函数被调用的时候开始,到调用结束的时候为止;如此生命期就与作用域联系在了一起。 
在允许在全局层次定义变量的语言里,全局变量是个特例。全局变量的存储器空间分配与局部变量不一样,一般是静态决定其分配的地址(在存储器的全局空间部分),所以它的生命期涵盖程序的整个执行过程。因此,虽然对于所有函数来说全局变量都是自由变量,但下面的讨论中我们不需要对其做讨论(因为它不涉及动态的存储器空间分配)。某些语言里的static关键字也可以对局部变量指定使用静态存储器空间分配,这样它们虽然仍遵守静态作用域,生命期却与一般的栈动态局部变量不同。 

在静态作用域中,也可以分为两类:可以嵌套定义子程序的与不可以的。基于C的语言(指标准C、C++、Java)都无法在一个函数里再嵌套定义函数;而其它的一些,像是Scheme、Pascal、Ada、JavaScript、C#等则允许嵌套的函数定义。 
虽然基于C的语言不允许嵌套定义函数,但这类语言都支持块结构。通过代码块,我们可以在一个静态作用域里新建一个嵌套的静态作用域。这样就可以有效的控制变量的作用域,使其尽量的小,便于减少变量名冲突的问题。 

(不过等C++0x正式成为标准后,基于C的语言里也将允许这种嵌套的函数定义) 

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

让我们来看一个支持静态作用域,同时支持栈动态局部变量的例子:(C++) 

Cpp代码   收藏代码
  1. int global;  
  2. // ...  
  3. void m( int paramOfM ) {  
  4.     int localOfM;  
  5.     // ...  
  6.     for ( int localToFor = 0; localToFor < 10; ++localToFor ) {  
  7.         int localToForBody;  
  8.         // ...  
  9.     }  
  10. }  
  11.   
  12. void n( int paramOfN ) {  
  13.     int localOfN;  
  14.     // ...  
  15. }  

上面的代码所对应的作用域状况是: 
 
很正常对吧,学过C/C++的人都知道这个。 
在第一行声明的global变量是一个全局变量。它的作用域是它所在的整个源文件,在它声明了之后的部分。 
paramOfM是函数m()的形式参数。它的作用域覆盖整个m()的范围。 
localOfM是函数m()之内的一个局部变量。它的作用于从它声明的位置开始,到m()结束的地方为止。 
localToFor是m()中的一个for循环中的一个控制变量。它是一个局部于for语句的变量,作用域覆盖了整个for语句中的部分,直到离开for语句块为止。注意到早期的C++并不是这样规定的;早期的C++规定,for语句开头的括号中声明的变量的作用域一直到包含这个for循环的代码块结束的位置为止。因此用过Visual C++ 6.0的人肯定都有过痛苦的经历: 
Cpp代码   收藏代码
  1. for ( int i = 0; i < 10; ++i) {  
  2.     // ...  
  3. }  
  4.   
  5. for ( int i = 0; i < 10; ++i) { // error: variable i is already defined  
  6.     // ...  
  7. }  

这个问题也不能怪VC++6不标准,VS6出的时候C++98还没什么编译器能完全正确的实现出来吧。 
回到主题。前面代码里,localToForBody是在for语句的循环体里定义的一个局部变量。它的作用域从它声明的位置开始,到for的循环体结束的位置为止。值得注意的是,由于for语句开头的括号在for的循环体之前出现,所以循环体里定义的局部变量在开头的括号里都是不可见的。 
后面是函数n()的作用域。可以看到m()与n()的作用域相互没有影响——它们相互没有交集。 

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

让我们来看看,在现实程序中一个函数调用是如何实现的。下面的代码来自AQUAPLUS的ToHeart2。 
my_inc2/MM_std.cpp, line 766: 
C代码   收藏代码
  1. BOOL STD_CheckFile( char *fname )  
  2. {  
  3.     HANDLE fh;  
  4.   
  5.     fh = CREATE_READ_FILE( fname );  
  6.     if( fh == INVALID_HANDLE_VALUE ) return FALSE;  
  7.     CloseHandle( fh );  
  8.   
  9.     return TRUE;  
  10. }  

其中CREATE_READ_FILE是一个宏,在my_inc2/MM_std.h定义: 
C代码   收藏代码
  1. #define CREATE_READ_FILE(FNAME)     CreateFile( FNAME, GENERIC_READ , FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL)  

于是通过宏展开,上面的函数变为: 
C代码   收藏代码
  1. BOOL STD_CheckFile( char *fname )  
  2. {  
  3.     HANDLE fh;  
  4.   
  5.     fh = CreateFile( fname, GENERIC_READ , FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);  
  6.     if( fh == INVALID_HANDLE_VALUE ) return FALSE;  
  7.     CloseHandle( fh );  
  8.   
  9.     return TRUE;  
  10. }  

然后在程序启动时会调用上述函数,在WIN_Init()中,ScriptEngine/src/Winmain.cpp, line 1844: 
C代码   收藏代码
  1. InitBootFlag = !STD_CheckFile( "Sys.sav" );  

调用关系是: 
WIN_Init() 
- STD_CheckFile() 
-- CreateFile() 
让我们仔细观察一下这个调用是如何进行的。注意到这里都采用C调用约定(cdecl),所以参数从右向左压入栈中,由调用者负责清理栈。 
调用者的汇编代码是: 
Java代码   收藏代码
  1. 004B9025  push ToHeart2.00542C18                       ;  ASCII "Sys.sav"  
  2. 004B902A  call ToHeart2.004A6BA0  

运行到这个call时,相应的运行时栈的内容是: 
Java代码   收藏代码
  1. 0012FD30   00542C18  ASCII "Sys.sav"  

这个地方就是当前栈顶了。 

跟进这个调用,看到栈的内容发生了变化: 
Java代码   收藏代码
  1. 0012FD2C   004B902F  返回到 ToHeart2.004B902F 来自 ToHeart2.004A6BA0  
  2. 0012FD30   00542C18  ASCII "Sys.sav"  

栈顶变为0012FD2C。可以看到,call指令的执行导致函数的返回地址被压入栈顶。 

然后来看看被调用者(STD_CheckFile())的汇编代码: 
Java代码   收藏代码
  1. 004A6BA0  mov eax,dword ptr ss:[esp+4]                 ; 将第一个参数读入EAX寄存器中  
  2. 004A6BA4  push 0                                       ; /hTemplateFile = NULL  
  3. 004A6BA6  push 80                                      ; |Attributes = NORMAL  
  4. 004A6BAB  push 3                                       ; |Mode = OPEN_EXISTING  
  5. 004A6BAD  push 0                                       ; |pSecurity = NULL  
  6. 004A6BAF  push 1                                       ; |ShareMode = FILE_SHARE_READ  
  7. 004A6BB1  push 80000000                                ; |Access = GENERIC_READ  
  8. 004A6BB6  push eax                                     ; |FileName  
  9. 004A6BB7  call dword ptr ds:[<&KERNEL32.CreateFileA>]  ; \CreateFileA <- 调用CreatFileA()。上面是压入参数的过程  
  10. 004A6BBD  cmp eax,-1                                   ; 将返回值与INVALID_HANDLE_VALUE比对  
  11. 004A6BC0  jnz short ToHeart2.004A6BC5                  ; 若返回值是INVALID_HANDLE_VALUE则跳转到004A6BC5  
  12. 004A6BC2  xor eax,eax                                  ; 将EAX清零  
  13. 004A6BC4  retn                                         ; 正常返回  
  14. 004A6BC5  push eax                                     ; /hObject  
  15. 004A6BC6  call dword ptr ds:[<&KERNEL32.CloseHandle>]  ; \CloseHandle  
  16. 004A6BCC  mov eax,1                                    ; 将EAX设置为1  
  17. 004A6BD1  retn                                         ; 无法打开文件,出错返回  

该函数的第一行将调用时传入的第一个参数读入了EAX。对应C的源代码看,源代码的第一行声明了一个局部变量。这个变量跑哪儿去了呢? 
其实是被编译器优化掉了。如果编译器没有优化的话,这个变量很可能位于当前栈顶的“上面”(低地址方向)。于是这个例子里我们不会在栈上看到属于STD_CheckFile()的局部变量。它在\CreateFileA()返回后被放置在EAX里了。 

继续运行到对CreateFileA的call那句时,栈的状况是: 
Java代码   收藏代码
  1. 0012FD10   00542C18  |FileName = "Sys.sav"  
  2. 0012FD14   80000000  |Access = GENERIC_READ  
  3. 0012FD18   00000001  |ShareMode = FILE_SHARE_READ  
  4. 0012FD1C   00000000  |pSecurity = NULL  
  5. 0012FD20   00000003  |Mode = OPEN_EXISTING  
  6. 0012FD24   00000080  |Attributes = NORMAL  
  7. 0012FD28   00000000  \hTemplateFile = NULL  
  8. 0012FD2C   004B902F  返回到 ToHeart2.004B902F 来自 ToHeart2.004A6BA0  
  9. 0012FD30   00542C18  ASCII "Sys.sav"  

留意到现在从栈顶开始7个存储器单元(每个单元是一个双字,DWORD,32位)都是对CreateFileA()调用而传入的参数。 

同样,跟进这个调用,看看栈的变化: 
Java代码   收藏代码
  1. 0012FD0C   004A6BBD  /CALL 到 CreateFileA 来自 ToHeart2.004A6BB7  
  2. 0012FD10   00542C18  |FileName = "Sys.sav"  
  3. 0012FD14   80000000  |Access = GENERIC_READ  
  4. 0012FD18   00000001  |ShareMode = FILE_SHARE_READ  
  5. 0012FD1C   00000000  |pSecurity = NULL  
  6. 0012FD20   00000003  |Mode = OPEN_EXISTING  
  7. 0012FD24   00000080  |Attributes = NORMAL  
  8. 0012FD28   00000000  \hTemplateFile = NULL  
  9. 0012FD2C   004B902F  返回到 ToHeart2.004B902F 来自 ToHeart2.004A6BA0  
  10. 0012FD30   00542C18  ASCII "Sys.sav"  

跟前一个调用一样,call指令使返回地址被压入栈顶。 

在CreatFileA()运行完毕,返回到STD_CheckFile()之后,栈的内容是: 
Java代码   收藏代码
  1. 0012FD2C   004B902F  返回到 ToHeart2.004B902F 来自 ToHeart2.004A6BA0  
  2. 0012FD30   00542C18  ASCII "Sys.sav"  

可以看到,跟调用CreatFileA()之前一样。retn指令会从栈顶弹出一个值作为返回地址,然后把EIP(指令指针)设置到那个值上,完成返回跳转。 

程序从STD_CheckFile()返回到WIN_Init()之后,栈顶是: 
Java代码   收藏代码
  1. 0012FD30   00542C18  ASCII "Sys.sav"  

由于C调用约定是由调用者来清理栈,而碰巧WIN_Init()接下来还要用到这个值,所以编译器优化让这个值保持在了栈顶。否则这个值也应该被抛弃掉了。 

上面的例子主要是想说明C语言中的函数调用在现实中运行的情况,以便说明其语义。 
每个函数被调用时,都会有一个 活动记录 (activation record)伴随产生。像C这样的语言,活动记录是在运行时栈上分配空间的,所以也成为 栈帧 (stack frame)。一个函数的活动记录以及该函数能访问到的任何“东西”(变量之类)的总和,被称为这个函数拥有的 引用环境 (referencing environment)。 
一般来说,一个函数的参数和返回地址都会放置在栈帧里,在未经优化的时候局部变量也会放在栈帧里。这种运行方式使得一个函数的参数和局部变量都只在其运行当中才被分配空间;函数的运行一结束,分配的空间就解除了与相应变量的绑定(约束)。由于参数与局部变量的状况都是可以在程序运行前确定的,所以它们在栈帧中的位置(偏移量)可以静态确定。栈帧本身的存在于否只能到运行的时候才能知道,有动态性,所以这种生命期得名“栈动态”。 
这样,变量的生命期就与静态作用域的规定产生了联系。 

那么假如允许嵌套定义函数,并且允许内层函数访问外围函数里的变量会怎样呢? 
可以观察到,内层函数只能为自己的参数和局部变量分配空间,而无法为外围函数中的变量分配空间;但同时,内层函数需要能够访问到外围函数里的局部变量,意味着它的活动记录里必须有某种手段去访问到外围函数的活动记录,否则内层函数的“引用环境”就不包含外围函数的活动记录也就无法访问外围函数里的局部变量。 

(本来是想画图来表示的……时间不足,只好直接贴文字。下次补完吧……) 

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

在接着讨论之前,先得说明一点:静态作用域并不总是意味着被嵌套的作用域(nested scope)中能访问包围它的外围作用域(enclosing scope)中的变量。 
如果一种语言中,被嵌套的作用域总是能访问其外围作用域中的变量(或者说名字),则该语言被认为支持 词法作用域 (lexical scope)。反之则不算支持词法作用域。 
(有些资料上将“静态作用域”与“词法作用域”写为同一个概念,或许这里还有值得商榷的地方吧。) 
从一般意义的词法作用域概念来说,Python虽然支持静态作用域,但并不是一般的词法作用域。虽然有些Python用户可能会争论这点,但Python的词法作用域很明显与“一般的”不一样: 
引用
If a name is assigned to anywhere in a code block (even in unreachable code), and is not mentioned in a global statement in that code block, then it refers to a local name throughout that code block.

Python代码   收藏代码
  1. C:\Python25>python  
  2. ActivePython 2.5.1.1 (ActiveState Software Inc.) based on  
  3. Python 2.5.1 (r251:54863, May  1 200717:47:05) [MSC v.1310 32 bit (Intel)] on win32  
  4. Type "help""copyright""credits" or "license" for more information.  
  5. >>> def outer(x):  
  6. ...   def inner():  
  7. ...     print x  
  8. ...   inner()  
  9. ...   inner()  
  10. ...  
  11. >>> outer(3)  
  12. 3  
  13. 3  

Python里只能对函数内的局部变量或者全局变量赋值,而不能对非局部非全局的变量赋值;可以读取非局部非全局变量,但是一旦尝试赋值,Python就会自动创建一个新的同名的局部变量。根据词法作用域,上面的例子表明Python中嵌套的内部函数可以读取外围函数里的变量:对inner()来说x是一个自由变量,非局部非全局,并且被inner()外围的闭包所捕获因而可以访问。但下面的例子却会失败: 
Python代码   收藏代码
  1. C:\Python25>python  
  2. ActivePython 2.5.1.1 (ActiveState Software Inc.) based on  
  3. Python 2.5.1 (r251:54863, May  1 200717:47:05) [MSC v.1310 32 bit (Intel)] on win32  
  4. Type "help""copyright""credits" or "license" for more information.  
  5. >>> def outer(x):  
  6. ...   def inner():  
  7. ...     x += 1  
  8. ...     print x  
  9. ...   inner()  
  10. ...   inner()  
  11. ...  
  12. >>> outer(2)  
  13. Traceback (most recent call last):  
  14.   File "<stdin>", line 1in <module>  
  15.   File "<stdin>", line 5in outer  
  16.   File "<stdin>", line 3in inner  
  17. UnboundLocalError: local variable 'x' referenced before assignment  

+=运算符包含了三个操作,先计算运算符左边的表达式,再将运算符右边的表达式的值加上左边表达式原本的值,最后赋值回到左边的表达式对应的变量上。由于包含了赋值操作,Python在查找变量x时只在局部或全局范围内查找,而此时符合这样条件的局部变量x还不存在(或者说还未被赋值过),所以出现了错误。这是设计者为了安全而做的设计取舍,不过无论如何它与一般意义上的词法作用域不一样。 
最典型的地方是Python里成员方法都必须以self为第一个参数的做法(名字不一定要是self,不过约定上都叫这个名字)……不熟悉Python的话算了,这个不用深究。 

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

允许嵌套定义子程序的语言也可以被分为三类:允许将函数作为参数传递的与不允许的,还有中间比较奇怪的(=_=)。如果一种语言允许将函数作为参数传递,意味着这种语言里的函数至少是二级的类型。 

先从不能将函数作为参数传递的开始讨论。 

(待补完) 

------------ 

然后是允许将函数作为参数传递的。 

标准Pascal(ISO 7185:1990) : 
引用 Wikipedia 上的例子: 
Java代码   收藏代码
  1. function E(x: real): real  
  2.    
  3.     function F(y: real): real  
  4.     begin  
  5.         F := x + y  
  6.     end  
  7.    
  8. begin  
  9.     E := F(3)  
  10. end  

留意到在F()里,外围的E()中的x是如何被“捕获”到当前作用域的。对于F()来说,y是一个形式参数,因而是一个“约束变量”;x既不是局部变量或者参数,也不是全局变量,因而是一个“自由变量”。F()的执行依赖于外部环境提供的“x”变量才可以进行。 
Pascal是支持栈动态变量的语言。上面的例子中,x和y的存储器空间都是在运行时栈上分配的。 

ISO 7185:1990,6.6.3 Parameters部分说明了将过程或者函数作为参数传递的一些规定。 

------------ 

GNU C: 
GNU C算是个比较特殊的实现吧。上面Pascal的例子在GNU C里也可以写出来: 
C代码   收藏代码
  1. float E(float x)  
  2. {  
  3.     float F(float y)  
  4.     {  
  5.         return x + y;  
  6.     }  
  7.     return F(3);  
  8. }  

注意:只是GNU C支持嵌套定义函数,GNU C++是不支持的。 
C里的函数指针可以传递函数的代码,却无法传递函数的环境。因此,当允许嵌套定义函数时,内层函数只有在外围函数活动的时候才能捕获到其引用环境。下次再详细说…… 


接下来让我们看看JavaScript的例子。 
 

(待补完) 

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

闭包与对象的等价性  

(待补完) 

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

闭包的应用意义 : 
1、状态隐藏 
2、算法封装 
3、自定义控制流 
... 

(待补完) 

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

Lambda Expressions and Closures: Wording for Monomorphic Lambdas (Revision 4)  
在C++0x之前,我们经常会使用functor来解决一些问题: 
引用
Cpp代码   收藏代码
  1. class between {  
  2.     double low, high;  
  3. public:  
  4.     between(double l, double u) : low(l), high(u) { }  
  5.     bool operator()(const employee& e) {  
  6.         return e.salary() >= low && e.salary() < high;  
  7.     }  
  8. }  
  9.   
  10. ....  
  11. double min_salary;  
  12. ....  
  13. std::find_if(employees.begin(), employees.end(),  
  14.     between(min_salary, 1.1 * min_salary));  

The constructor call  between(min_salary, 1.1 * min_salary) creates a function object, which is comparable to 
what, e.g., in the context of functional programming languages is known as a  closure. A closure stores the 
environment, that is the values of the local variables, in which a function is defined. Here, the environment 
stored in the  between function object are the values  low and  high, which are computed from the value of the 
local variable  min_salary.

但是这样挺麻烦的。明明只是需要一个函数,却需要写一整个类出来。而有了C++0x的lambda表达式之后,就可以在函数内直接定义匿名的嵌套函数了。 
Cpp代码   收藏代码
  1. double min_salary = ....  
  2. ....  
  3. double u_limit = 1.1 * min_salary;  
  4. std::find_if(employees.begin(), employees.end(),  
  5.     [&](const employee& e) {  
  6.         return e.salary() >= min_salary && e.salary() < u_limit;  
  7.     });  


Cpp代码   收藏代码
  1. #include <algorithm>  
  2. #include <vector>  
  3.   
  4. void outer( ) {  
  5.     std::vector<int> v;  
  6.     int i;  
  7.     // ...  
  8.     std::for_each( v.begin(), v.end(), [ &i ]( int& elem ) {  
  9.         std::cout << elem << ' ';  
  10.     });  
  11.     // ...  
  12. }  

这段代码里的作用域: 
 
(哎呀,图做完了才发觉for_each之前漏了std::……算了懒得重新做图,凑合吧) 

不过这个例子在C++0x里也未必要用到lambda表达式了,直接用新增加的for-each循环就行: 
Cpp代码   收藏代码
  1. std::vector<int> v;  
  2. int i;  
  3. // ...  
  4. for ( int elem : v ) {  
  5.     cout << elem << ' ';  
  6.     ++i;  
  7. }  

如果在elem前面加上&来修饰,则对elem的赋值也会反映到容器内。真好。 

引用
If every name in the effective capture set is preceded by  &F is publicly derived from std::reference_closure< 
R(P)>
 (20.5.17), where  R is the return type and  P is the parameter-type-list of the lambda expression. Converting 
an object of type  F to type  std::reference_closure<R(P)> and invoking its function call operator shall 
have the same effect as invoking the function call operator of  F. [ Note: This requirement effectively means that such 
F must be implemented using  a pair of a function pointer and a static scope pointer. —end note ]


照这么说,C++0x里的闭包也是D 2.0早期所定义的“动态闭包”了。关于D的动态闭包,可以看看我去年写的 这帖 ,靠下面的部分。 

(待补完) 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值