《编程机制探析》第十七章 函数式编程

《编程机制探析》第十七章 函数式编程

当我们能够像掌握循环一样熟练地掌握递归之后,我们就可以正式向函数式编程进军了。当然,即使我们还没有熟练掌握递归,我们还是可以向函数式进军。我们可以在学习函数式编程的过程中,逐步习惯递归的写法。
函数式编程并非主流编程模型,函数式语言亦非主流编程语言。但我们在描述某些具体问题时,不可避免地要涉及到一些代码。本书会尽量选择有代表性的函数式语言。本书的选择是Haskell和ErLang这两门函数式语言。
Haskell是一门学术性很强的、特性很全面、形式很正规的函数式语言,很适合用来描述函数式编程的一些重要核心概念。因此,Haskell是一种常见的教学语言。本书也不能免俗。
ErLang是一门比较实用、对分布式计算支持比较好的函数式语言。本书选择这门语言,出于两个理由。一是ErLang简单易懂,学习成本比较低。二是ErLang在函数式编程中还算是应用比较广的,学习这门语言的回报率也相对大一些。
本书在讲解函数式编程的时候,仍然遵守一贯的原则:尽量使用文字——而不是代码——来描述基本原理;遇到具体代码时,只对特殊的语法现象进行解释,不讲解语法方面的基础入门知识。
我们首先讲解函数式编程语言的两种核心数据类型——Tuple(元组)和List(列表)。
Tuple类型类似于命令式语言的数组,其中元素数据类型不定。Tuple类型用圆括号()来表达。比如,(1,”abc”, 2.5, ‘c’ )就是一个长度为4的Tuple。
比如,(1, (2, 3))就是一个长度为2的Tuple,其中第二个元素也是一个长度为2的元组(2, 3)。
在函数式语言中,同其他数据一样,Tuple只能在创建时赋值一次,以后就再也不能改变了。因此,Tuple一旦创建,其长度和内容都已经固定了。
在有些函数式语言中,Tuple用大括号{}来表达,含义和用法是一致的。
长度为2的Tuple叫做二元组。在有些函数式编程语言中,二元组也叫做Pair,即数据对。二元组在函数式编程模型中的地位十分重要。因为,在函数式编程模型中,二元组是另一个核心数据类型——List(列表)——的构成基石。
List是一种链表结构,每一个链表结点都是一个二元组,其中第一个元素叫做head(头),存放数据内容,第二个元素叫做tail(尾),存放着后面的二元组。所以,List本身就是一种特殊的二元组。
先让我们明确一个概念的用法和写法。在Java、Python、Ruby等语言中有一种表示“无”、“空”的概念,写做null。在函数式编程中,也有同样的概念。在有的语言中写做null,在有的语言中写做Nil。为了统一起见,在没有明确指定具体语言时,我们都用null。
我们从最基本的List例子看起。(15, null)这个二元组就是一个长度为1的List。这是怎么说的呢?在这里,List的数据只有一个——15。表示下一个List元素的尾部数据为null,就是说这个List已经结束了。因此,这个List的长度为1,其中只有一个元素15。
在函数式语言中,List并不是一种特殊的类型,而是Tuple的一种——二元组。本来,使用Tuple结构——即圆括号()——来表示,已经足够。但是,List在函数式编程中应用如此普遍,函数式语言为List结构提供了很多语法上的直接支持,提供了很多List的简写方法。
这种简化语法形式并非代表着实质的结构,而是为了读写方便。这种做法有个名字,叫做语法糖。
在函数式语言中,List的简写形式就是方括号[]。前面的例子中,(15, null)就可以写成[15]。
下面我们再来看两个元素的List。(14, (15, null))这个嵌套的二元组,就是一个拥有两个元素的List。这是怎么说的呢?在这里,最外层的二元组中,头元素(head)用来存放数据内容14,尾元素(tail)用来存放下一个二元组(15, null)。在下一个二元组中,,头元素(head)用来存放数据内容15,尾元素(tail)用来存放下一个二元组null。由于tail是null,就表示List结束。于是,List的长度就是二元组的嵌套深度——2。这个List可以简写为[14, 15]。
同理,我们可以写出长度为3的List。(13, (14, (15)))可以简写为[13, 14,15]。更长的List可以同理类推,不再赘述。
通过上面的例子,我们可以发现List的一个重要特性——List是从后向前,即从尾到头来构造的。
我们无法从头到尾来构造List,我们只能从尾到头来构造List。这是为什么呢?这是因为,List是由嵌套的二元组构成的,而且是头元素二元组,嵌套尾元素二元组。在函数式语言中,变量只能在声明时赋值一次,之后就不能改编。我们不可能先创建一个二元组A,再把另一个B二元组设置到A二元组的尾元素中去。被包含的二元组永远只能先被创建。而尾元素才是被包含的二元组。因此,List只能从尾到头来构建。
但是,当我们访问List的时候,却只能从头到尾来访问。因为,我们读取一个List的时候,只能从最外层的头元素来访问。一个List的遍历方法只能是从头到尾。
针对这个特性,函数式编程语言又引入了一个语法糖——[head | tail],用来标志一个List的头元素和之后的尾元素(也是一个List)。其含义与二元组表达方式(head, tail)完全一致。
函数式编程语言都支持这个[head | tail]语法糖,而且这个语法糖远远比(head, tail)这种最原始的表达方式应用得广。
其中的道理,我不是很明白。也许是为了表达上的一致性,也许是为了强调说明这是一种List类型,增加可读性。
有了这个语法糖,List的表达方式又多了一种。
[13, 14, 15]可以表达为[13 | [14, 15]],也可以表达为[13 | [14 | [15]]],这和最原始的二元组表达为(13, (14, (15, null))),已经非常接近了。另,这个List还可以写成[13 | 14 | 15]。
在有些函数式语言中,这种语法糖与最原始形式的元组比较相近,使用()圆括号包裹List元素,使用 : 分隔头元素和尾元素。比如,(1 : 2 : 3 : [ 4, 5, 6])。这种表达方式等价于[1, 2, 3, 4, 5, 6]。
无论是[head : tail],还是[head | tail],其真实结构都是二元组(head, tail)。
函数式语言的初学者经常遇到的问题之一,就是面对各式各样的List表达方式而无所适从,不明所以。
只要我们时时刻刻记住List的二元组本质,就不会被函数式语言中眼花缭乱的List表达方式迷昏了头。
现在,我们来看一个颇有迷惑性的例子。[ [1, 2] | [3, 4]]。这个List是什么意思呢?这是一个长度为3的List。头元素数据本身就是一个List——[1, 2]。整个List用二元组原始形式来表达就是((1, (2, null)), (3, (4,null)))。
明确了List表达方式之后,我们就可以来研究List这个数据结构的本质特性了。前面分析过了,构造的时候,List只能从尾到头构造;访问的时候,List只能从头到尾访问。这是个什么数据结构呢?
答案已经呼之欲出了。对了,这就是一个栈结构(Stack)——我们经常遇到的、先入后出、后入先出的栈结构。只不过,List这个栈结构并不是基于数组结构,而是基于二元组链表结构来建立的。
我们要牢牢地记住,函数式编程中的List是一个栈结构,而且仅仅是一个栈结构,只能当作一个栈结构来用,不能当别的数据结构(比如队列Queue)来用。
我为什么要不厌其烦地强调这个问题呢?因为List这个名字很有迷惑性。命令式程序员一看这个名字,通常就产生一种误解,以为这是一种可以在尾部添加数据元素的队列。
我在这里明确地断定:不能。函数式List不能在尾部添加数据元素,因此,不能当作先入先出、后入后出的队列结构来用。函数式List只能在头部添加数据元素,因此,只能当作栈结构来用
本书有一个原则,尽可能多讲基本原理,尽可能将必须涉及到语法和代码的部分向后推。毕竟,一门陌生的新语言看起来总是面目可憎的。
在讲述函数式语言基本语法和代码之前,我们先把List相关的基本操作算法过一遍,这样心里才有数,后面接触到具体语法和代码时,也不会感到有太大的障碍。
List操作在函数式语言中非常重要,所有的函数式语言都有一套专门针对List操作的List函数库。
我们自己设想一下,List函数库中都有那些函数?
懂得比较多的读者,可能会说,当然是MapReduce了,大名鼎鼎的Google搜索引擎用的就是C++语言的MapReduce库。
这个答案是正确的。Map和Reduce是两种不同的List基础操作,List函数库必然会支持Map和Reduce这两种算法。不过,Map和Reduce这两个算法并非最基础的List操作函数。
我们继续问问题,最基础的List操作函数是什么呢?
比较实际的读者,深思熟虑片刻之后,脸上露出了笑容,道:“我知道了,是长度函数。用来求List的长度的函数。”
这个答案,咳,也没错,确实非常基础。所有的List函数库都支持。我们就来看这个函数应该如何实现。
这还用说吗?有读者已经不耐烦了。当然是从头到尾遍历一遍,统计元素的个数了。
没错,就是这样。如果List比较长,并且计算长度的操作很多的时候,我们可以自己做一些优化,另外用一个数值来存放当前List的长度。比如,这样一个二元组,(length, list)。其中length就是list的长度。当list增加一个头元素的时候,就形成一个新的二元组,(length + 1, [head | list])。当list减少一个头元素的时候,也形成一个新的二元组,(length – 1, tail list)。
关于计算长度的内容,也就这些。计算长度,确实是非常基础的List操作,但却不是我想问的。还有呢?还有没有其他的、非常基础的List操作?好好想想?
哎,算了,我不卖关子了。直接给出答案吧。List函数库中一个非常非常基本的操作就是,从头到尾,反转List。
这是为什么呢?因为List只能从尾到头构造。而通常来说,我们获取数据的时候,都是从头到尾获取的。
这里可以采取两种方案——尾递归算法和非尾递归算法。
当采用尾递归算法时,我们从头到尾获取数据,并根据数据构造List时,我们构造出来的List是反顺序的。最先读出的头数据实际上放进了List中最内部的尾结点(二元组中)。当这个List构造完毕之后,我们必须把这个List倒过来。
List反转操作的实现也非常简单和直观。List虽然构造的时候是从尾到头的,但是,访问的时候,却是从头到尾的。我们只需要从头到尾再访问这个List一遍,然后,根据读出来的元素,再重新构造一个List,得到的新List自然是反转过来的。
当我们采用非尾递归算法时,我们就可以根据一个List从头到尾地构造另一个List。但这种算法的实质是这样:先从前往后遍历,递归一步步深入进栈,把遇到的所有元素都处理一遍,所有的中间结果都保存在运行栈中,直到遇到最后一个尾部数据,递归开始退栈,从尾部向头部开始构造List。这种算法虽然避免了尾递归算法的List反转,但是,空间上的开销却多于尾递归算法,而且,进栈出栈也有时间开销。
对于我们来说,我们考虑List算法时,还是首先要考虑尾递归算法,养成这种习惯。这就不可避免地要遇到List反转的情况。
以下的算法分析,除非特别声明,全都采用尾递归的解决方案,也就是说,全都涉及到List反转操作。
当我们在函数式编程使用尾递归时,一定要对List的元素数据顺序保持高度的关注和敏感。
现在,我们来考虑另外一个常见的List操作——两个List拼接——的算法。
假设有两个List,一个是[1, 2, 3],另一个是[4, 5, 6]。我们想把这两个List拼接起来,变成一个List,[1, 2, 3, 4, 5, 6]。我们应该如何做?
对于任何List操作问题,我们要时刻在头脑中记住一个原则——所有的List都是从尾到头来构造的。我们考虑List算法的时候,也要根据结果List从尾到头来考虑。
在这个例子中,我们想要达到的结果是[1, 2, 3, 4, 5, 6]。按照List的构造顺序,这个List应该这样构造。首先要构造[6],然后构造[5, 6],然后构造[4, 5, 6]。当然,我们已经拥有[4, 5, 6]这个List了。我们可以在这个基础上继续构造,过程仍然是从后往前的。我们首先要得到[3, 4, 5, 6],然后再得到[2, 3, 4, 5, 6],最后得到[1, 2, 3, 4, 5, 6]。
根据这个过程,整个算法的流程就应该是这样。首先,我们要把[1, 2, 3]这个List反转过来,变成[3, 2, 1]。然后,我们从头遍历[3, 2, 1],依次把3、2、1三个元素添加到[4, 5, 6]的头部,最后得到结果List。
这个例子可以继续深化为把多个List拼接在一起的算法。比如,把[1, 2, 3]、[4, 5, 6]、[7, 8, 9]、[10, 11, 12]。
由于这些List的个数是不定的,我们可以把它们都放到一个数组里。这个问题就转换成,一个List中的每一个元素都是一个List。我们如何把这个List中的所有List都取出来,拼在一起,形成一个List?具体到这个例子,就是。
all_lists = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]]
我们要把all_lists里的所有List都取出来,并拼在一起,组成一个大的List,[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]。
同样,我们需要根据结果List,从尾到头来考虑整个算法。我们已经拥有了[10, 11, 12],我们需要在此基础上依次构造[9, 10, 11, 12],[8, 9, 10, 11, 12]…直到最后的结果,[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]。
我们先要把[7, 8, 9] 这个List反转过来,变成[9, 8, 7],然后,把9、8、7依次放入到[10, 11, 12]的头部,依次形成[9, 10, 11, 12]、[8, 9, 10, 11, 12]、[7, 8, 9, 10, 11, 12]。
之后,我们再把[4, 5, 6]反转成[6, 5, 4],把6、5、4依次贴到中间结果List的头部,然后再把[1, 2, 3]反转成[3, 2, 1],把3、2、1依次贴到中间结果List的头部。最后得到最终结果。
综上所述,此种问题的通用算法应该是这样。首先,把整个List反转。这时候,List中的最后一个子List(在这个例子中,就是[10, 11, 12])就变成了第一个List(即List头部)。这个List头部是我们构建大List的基础,我们取出这个List头部,不动它。剩下的tail List中,就是那些需要依次反转、依次粘贴到中间结果List头部的其它List。
我们看到,这个算法中存在多次List反转。
我们再把这个例子扩充为这样:一个List的树形结构,全部展开成一个扁平的List。
这个操作在List函数库中叫做flatten(扁平化)操作。
这个算法就更复杂了,涉及到的List反转操作就更多了。不过,无论数据结构多么复杂,原则都是一样的,我们首先要考虑List的尾部。
由此可见,List反转操作在各种List操作中是多么的重要。复杂情况下,List反转操作如此之多,一不小心,就很容易进行重复的、不必要的List反转操作。我们在头脑中必须绷紧这根弦,时刻谨记着List反转操作,并避免在算法中进行重复的List反转操作。
对于函数式程序员来说,List反转操作,是除了尾递归之外的另一个重要的基本功。这也是为什么我不厌其烦地强调List反转操作重要性的原因。
我是一个命令式程序员,对函数式编程完全是半路出家。我的这些经验体会,都是在一头雾水中摸爬滚打中积累出来的。我希望,这些经验体会能有效地帮助想学习函数式编程的命令式程序员,达到少走弯路、节省时间的目的。
List函数库中的其他的一些List操作,就是一些老生常谈了。我们简单地过一遍。
Map操作,这个名字和Java里面的Map接口一样,但两者除了名字中都有映射的含义,其它没有太多的共同点。函数式编程的Map操作是这样一种操作,对一个List中的所有元素都进行某一种操作,(比如,加1,乘2之类的操作),并根据计算结果生成一个新的List。
Map操作的算法很简单,就是从头遍历List,对每个元素进行计算,把计算结果添加到一个新List中去。遍历结束后,新List也构建完了。
等等,这就完了吗?不,还没有完。不要忘记,这时候构造出来的新List是反顺序的,并不能和原来的List形成映射关系。所以,我们还要把构造出来的List反转过来。可见,List反转操作无处不在,不可轻忽。
Reduce操作是一种统计操作,遍历List中的所有元素,进行一种统计计算(比如,计数,求和,求乘积等),最后得到一个结果值。Reduce的含义是缩减。用在这里,其寓意就是把一个List缩减为一个结果值。
在很多函数式语言的List函数库中,Reduce算法被写成fold(折叠)。其寓意也是相同的。把一个List折叠成一个结果值。
fold算法(即Reduce算法)有两种版本,一种是从头到尾遍历(从左到右),进行统计,这种算法写成foldl(即fold left,从左到右进行fold);一种是从尾到头遍历(从左到右),进行统计,这种算法写成foldr(即fold right,从右到左进行fold)。
foldr算法自然是要先把List反转一遍。这里,我们又遇到了List反转这个老熟人。
有一个英语翻译的笑话是这样讲的。
“How are you?”
中文翻译:怎么是你?
“How old are you?”
中文翻译:怎么老是你?
每次遇到List反转,我都会有这种感觉,“怎么老是你?”
Map和Reduce算法都接受一个操作函数作为参数,用来对List中的元素进行操作。这是一个很明显的Visitor模式。那个作为参数的操作函数就是Visitor函数。
注意,在命令式的面向对象编程中,Visitor是一个对象,算法调用的是Visitor的visit方法。但是,在函数式编程中,函数本身就是一种作为参数和返回值的对象。所以,这里我们作为参数传递给算法的就是Visitor函数本身。
不仅Map和Reduce算法是Visitor Pattern,List函数库里的几乎所有算法都是Visitor Pattern。这是显而易见的。因为List就是一种集合结构,其算法基本上就是遍历算法。而遍历算法只有两种设计模式,一种是Iterator Pattern,一种就是Visitor Pattern。既然函数式编程不支持循环和Iterator Pattern,那唯一的选择就是递归和Visitor Pattern。
List函数库中,有一个Filter算法,类似于Map算法,也是接受一个List参数,返回一个List结果。
Map算法和Filter算法的不同之处如下。
Map算法接受一个Visitor函数,返回的List长度与原来的List相同,两个List之间存在着一一对应的关系。
Filter算法也接受一个Visitor函数;不过,这个Visitor函数是一个筛选函数,根据当前元素的值来决定是否保留这个元素;这个Visitor筛选函数的返回值为真,则保留该元素,这个元素将被加入到结果List中;这个Visitor筛选函数的返回值为假,则过滤掉该元素,不把这个元素加入到结果List中;因此,Filter算法返回的结果List的长度和原来的List通常不相同,而且,两个List之间也通常失去了一一映射的关系。
Filter算法不难理解,前面的讲解设计模式(组合模式,Composite Pattern)的章节中,有一个FileFilter的例子,那就是一个典型的Filter算法。
同样,Filter算法是Visitor Pattern。
同样,Filter算法的最后,需要一个List反转的操作。(唉,怎么老是你?)
如果我们不想老是遇到List反转,我们可以采用非尾递归算法。对于List来说,非尾递归算法写起来简单直观,这里就不多费口舌了。等我们学习了函数式语言具体语法之后,我们再来看具体的代码。
Map、Filter、Reduce是List函数库的三种基础算法。用得多,也容易理解。List函数库中还存在着很多其他算法,比如,排序算法,查找算法,统计算法(最小值,最大值,平均值),取出头N个元素,取出第N个元素,取出最后一个元素,等等。还有前面前面举的List合并的例子,还有树形List结构“扁平化”(flatten)的情况,都属于List函数库中的算法。还有其他林林总总的List算法。这些算法中,有些是Visitor Pattern,有些不是。
有些算法过于简单和常见,比如,排序算法,查找算法,统计算法(最小值,最大值,平均值),取出头几个元素,取出某个位置的元素,等等。有些算法比较复杂,而且比较少用。这些算法我们都不讲。
前面讲述的List算法——Map、Reduce、Filter——全都是List函数库中最有代表性、最能体现函数式编程思路的List算法。掌握了这几个基本的List算法之后,我们就可以比较彻底地理解函数式语言针对List的一种更加强大的语法糖——List Comprehension。
Comprehension这个词的本意是理解、领会的意思。用在这里,我也不知道该怎么翻译。有些资料翻译成List推导,但我不能领会其深意。这里,我们就直接用英文原词。
List Comprehension的语法结构是这样的,一个双竖线分隔符 || 把结果List和源List分开。双竖线分隔符 || 前面的部分,定义的是结果List内容的运算表达式,实际上相当于一个Visitor函数。双竖线分隔符 || 后面的部分,定义的是源List的内容,实际上相当于List遍历算法,其中还可能包括过滤条件,相当于一个起过滤作用的Filter Visitor。
我们从最简单的List Comprehension例子看起。
[ 2 * x | | x <- [1, 2, 3] ]
这个List Comprehension表达式的含义是,x来自于源List,即[1, 2, 3]。而结果List中的每个元素是2 * x。得到的结果List是 [2, 4, 6]。
很明显,这是一个Map操作。2 * x 就是 Visitor 函数的内容。
我们再看一个例子。
[ 2 * x | | x <- [1, 2, 3] , x > 1]
这个List Comprehension表达式里面添加了一个过滤条件,x > 1。得到的结果List是[4, 6]。
这是一个Map操作和Filter操作的组合。首先进行的Filter操作,只保留源List——即[1, 2, 3]中大于1的元素,得到的中间结果List为[2, 3]。这里,x > 1 就是 Filter Visitor函数的内容。得到了过滤结果 [2, 3]之后,再针对这个[2, 3]进行Map操作,得到[4, 6]。
这是最简单的情况,源List只有一个。List Comprehension还可以支持两个源List。比如:
[ x + y || x <- [1, 2], y <-[10, 20] ]
这个List Comprehension表达式里面有两个源List。第一个源List的所有元素,都需要和第二个源List中的任何一个元素配对,组成一对参数,实际上,是一个二元组结构。这就形成了一个类似于笛卡尔乘积的大List。我们姑且称之为笛卡尔List。
产生这个笛卡尔List的操作,实际上是两个Reduce操作的嵌套。第一层Reduce操作里面,
针对第一个源List的每一个元素,都进行第二层Reduce操作。在第二层Reduce操作里面,来自于第一个源List的元素,和第二个源List中的每一个元素配对,形成一个二元组,添加到Reduce的中间结果里,即一个不断积累的元素数据内容为二元组(x, y)的List。
然后,针对这个笛卡尔List进行Map操作,这里的Visitor函数就需要操作一对参数——二元组形式的(x, y)。
注:笛卡尔乘积是集合论中的简单概念。这里不赘述,就当作读者已经掌握这个基础知识了。如果不知道这个知识,请自行查阅。
这个List Comprehension表达式的结果是[11, 21, 12, 22]。
我们还可以给给这个List Comprehension表达式加上条件。
[ x + y || x <- [1, 2], y <-[10, 20], x > 1 ]
得到的结果是[12, 22]。
x > 1是其中的过滤条件。现在,有一个问题。这个过滤条件放置的位置存在两种方案。
第一种方案,先求出笛卡尔List,然后对笛卡尔List进行过滤。
这里,我们就需要先得到笛卡尔List,即[(1, 10), (1, 20), (2, 10), (2, 20)]。
然后,对该List进行Filter操作。这时候,Filter Visitor函数接受一个二元组(x, y)作为参数,该函数返回 x > 1 的结果。
最后,对过滤后的List,进行Map操作。
第二种方案,先过滤[1, 2] 这个List,得到[2],然后再进行笛卡尔乘积操作,最后再进行Map操作。
很明显,第二种方案更优。但是,第一种方案更加通用,适用于过滤条件同时涉及到两个源List的时候。
List Comprehension语法处理器可能会采用这样的优化策略。如果过滤条件同时涉及到两个源List,那么,就采用第一种方案。如果过滤条件只涉及到一个源List,就采用第二种方案。
需要特别说明的是,这里用的“笛卡尔”List只是一种为了方便描述而生造出来的词汇。前面描述的过程中,笛卡尔List的生成,和最后的Map操作分成了两步。但这是不必要的。这两步完全可以合成一步,笛卡尔List并不需要被真正创建。
在实际的算法中,笛卡尔List可能只代表着一种两层的嵌套递归结构,具体来说,就是两层嵌套的Reduce操作。在这两层Reduce操作中,Map操作中的Visitor函数可以放在第二层Reduce操作中直接进行——根据(x, y)计算结果,添加到结果List中。这样,笛卡尔List就不用真正被创建。
这里描述的List Comprehension实现是我自己的设想思路。并不一定与真实情况一致。对这方面感兴趣的读者,可以对函数式语言的编译结果进行反编译,从中可以看到解开语法糖衣之后的真实算法。
在有些函数式语言中,List Comprehension表达式中的双竖线分隔符 || ,有时也写成单竖线分隔符 | ,其含义和用法是一样的。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值