现代化程序开发笔记(8)——通俗易懂的函数式编程入门

本系列文章以我的个人博客的搭建为线索(GitHub 仓库:Evian-Zhang/evian-blog),记录我在现代化程序设计中的一些笔记。在这篇文章中,我将尽量用通俗易懂的方法,介绍函数式编程的入门知识。

什么是函数式编程

作为一个开发者,面对一个需求,我们掌握的知识常常是一定能完成这个需求,但是完成的方法多种多样,我们需要选择更好的技术来完成这些需求。就比如说我们是做汉堡的厨师,那么只要具有这方面的知识,这汉堡总能做成,无非是将制作好的肉、酱、蔬菜放在两片面包之间。但是,完成这个汉堡的手法有多种,比如说先加蔬菜,再加酱,最后加肉,再比如说加烤制的肉,速冻的蔬菜,在这些方法间权衡,才能做出最优的汉堡。

函数式编程就是一种方法,它并非一种特定的语言,而是一种理念,一种技术,让我们在编程开发的过程中,能够多一种挑选权衡的思路。函数式编程能让我们更清晰地完成我们的代码,在学习函数式编程的时候对于我们的编码思维也是一种提升,很多人往往学了函数式编程就离不开了。那么,我就介绍一下函数式编程的入门级知识。

假设我们使用Rust语言,在完成我们汉堡的制作:

struct Hamburger { }
impl Hamburger {
    fn make_hamburger(meat: Meat, vegetable: Vegetable, sauce: Sauce) -> Hamburger { /* hide */ }
}

fn main() {
    let mut hamburger_count = 0;
    let mut make = |meat, vegetable, sauce| {
        hamburger_count += 1;
        Hamburger::make_hamburger(meat, vegetable, sauce)
    }
    let hamburger1 = make_hamburger(Meat::new(), Vegetable::new(), Sauce::new());
    let hamburger2 = make_hamburger(Meat::new(), Vegetable::new(), Sauce::new());
}

这是我们最初的设计思路。

基本思想

函数式编程有一个最基本的思想,函数是一等公民,我们可以像传递别的变量一样,在函数的参数、返回值中传递函数。这一点绝大多数的编程语言都已经支持了,而且十分符合逻辑(事实上,函数不是一等公民才不符合逻辑),所以这一点就不再赘述了。

纯函数

在函数式编程中,函数作为它的招牌,必然有很多理念。首先,我们介绍的是纯函数。

我们在数学中遇到过函数,在编程中也遇到过函数,这两个函数有什么不同点呢?我们有一个数学里的函数 f ( x ) = x + 1 f(x)=x+1 f(x)=x+1, 在编程中,我们有这个函数:

int f1(int x) { return x + 1; }

这和我们数学里的函数好像差不多噢。

那我们改写一下:

int y = 0;
int f2(int &x) {
    int adder;
    printf("Input a number: ");
    scanf("%d", &adder);
    int z = x + adder;
    x -= 1;
    y++;
    return z;
}

f1函数和我们的数学里的函数是完全一致的,而f2则不同,它有两个重要的不同点:

  • f2改变了xy的值。
  • 对于相同的x, f2的输出结果是不确定的。

这样的函数十分不利于我们的编程,因为它在内部改变了状态,也会改变外部的状态,而如果我们并不知道,那么擅自调用可能会导致几百行之外的某个地方出问题。

因此,我们有纯函数的概念,它要求:

  • 函数无副作用,即不改变全局变量,捕获的变量,或传入的可变引用
  • 如果输入相同,那么输出相同

这样的函数就可以比较清晰地反应我们的思路。

观察上面制作汉堡的流程,我们的make函数似乎并不是纯函数。它改变了外部捕获的变量hamburger_count。因此,我们可以这样将它改变成纯函数:

struct Hamburger { }
impl Hamburger {
    fn make_hamburger(meat: Meat, vegetable: Vegetable, sauce: Sauce) -> Hamburger { /* hide */ }
}

fn main() {
    let hamburger_count = 0;
    let make = |meat, vegetable, sauce, hamburger_count| {
        (Hamburger::make_hamburger(meat, vegetable, sauce), hamburger_count + 1)
    };
    let (hamburger1, hamburger_count) = make_hamburger(Meat::new(), Vegetable::new(), Sauce::new(), hamburger_count);
    let (hamburger2, hamburger_count) = make_hamburger(Meat::new(), Vegetable::new(), Sauce::new(), hamburger_count);
}

变量不可变

在刚刚解决纯函数的问题时,我们发现改完代码以后,原来代码里的mut都没有了,所有的变量都成了不可变的变量。这自然也符合函数式编程的思想——变量不可变。在如Haskell等纯函数式编程语言中,这是强制的,但在现在流行的一些编程语言中,也提供了这样的支持,无论是Rust的letlet mut, Swift的letvar,Kotlin的valvar,JavaScript的letconst,都代表对不可变变量的支持。不可变变量能够最大程度地降低开发者的误操作,将一个不应当被改变的变量声明为不可变,那么后续的可能会改变它的误操作在编译期就会被发现。

函数式开发

函数式编程的技巧都是基于上述的基本理念而成的。使用函数式编程的技巧,我们可以在合适的地方进行函数式地编程,并解决一些其他编程范式的问题。

变量默认不可变带来的,是如果我们想改变一个变量,就创造一个新的变量,也就像我们刚刚对hamburger_count所做的那样。但是,有下面这种情况,我们似乎不得不使用可变的变量:

我们在做完了汉堡之后,需要把汉堡包起来。之前厨房的小哥累死累活,一口气做了二十多个汉堡,我们要把每个汉堡包起来之后再给顾客。调用make_hamburgers之后,我们得到了一个Hamburger数组,然后,我们需要将每个汉堡包起来,得到一个新的数组。因此,我们的代码如下:

fn make_hamburgers() -> Vec<Hamburger> { /* hide */ }
fn wrap(hamburger: Hamburger) -> Wrapped<Hamburger> { /* hide */ }
fn main() {
    let hamburgers = make_hamburgers();
    let mut wrapped_hamburgers = vec![];
    for (hamburger in hamburgers) {
        wrapped_hamburgers.push(wrap(hamburgers));
    }
}

这里wrapped_hamburgers似乎不得不作为可变的变量而存在,因为我们每遍历一个汉堡,就需要把它包起来之后再放进这个数组中。这时候该怎么解决呢?

支持函数式编程的语言,都会支持流操作,而这就是为了解决这类问题而生的。流操作一般包含三个函数:map, filterfold,它们的都可以作为我们汉堡工厂的得意助手:

map

fn wrap(hamburger: Hamburger) -> Wrapped<Hamburger> { /* hide */ }

fn wrap_hamburgers_in_for_loop(hamburgers: Vec<Hamburgers>) -> Vec<Wrapped<Hamburger>> {
    let mut wrapped_hamburgers = vec![];
    for (hamburger in hamburgers) {
        wrapped_hamburgers.push(wrap(hamburgers));
    }
    wrapped_hamburgers
}

fn wrap_hamburgers_in_map(hamburgers: Vec<Hamburgers>) -> Vec<Wrapped<Hamburger>> {
    hamburgers.into_iter().map(wrap).collect()
}

用Haskell的类型系统来表示的话,map的类型为

map :: (a -> b) -> M a -> M b

其中M可以是任意的集合类型,如List, Set等等。以List为例,也就是:

map :: (a -> b) -> [a] -> [b]

最简单的理解方法是,map接收一个a -> b类型的函数f和一个[a]类型数组[x1, x2, ..., xn], 输出一个[b]类型的数组[f(x1), f(x2), ..., f(xn)]. 它通过这样的函数,避免了我们之前wrap_hamburgers_in_for_loop里不得不使用可变变量和for循环来完成需求的尴尬境地。

filter

包装完了汉堡之后,我们需要检查每个汉堡是不是好的,有没有被包装的人偷吃,最终只返回好的汉堡。我们的两种做法是:

fn check(wrapped_hamburger: &Wrapped<Hamburger>) -> bool { /* hide */ }

fn check_hamburgers_in_for_loop(wrapped_hamburgers: Vec<Wrapped<Hamburger>>) -> Vec<Wrapped<Hamburger>> {
    let mut good_hamburgers = vec![];
    for wrapped_hamburger in wrapped_hamburgers {
        if check(wrapped_hamburger) {
            good_hamburgers.push(wrapped_hamburger);
        }
    }
    good_hamburgers
}

fn check_hamburgers_in_filter(wrapped_hamburgers: Vec<Wrapped<Hamburger>>) -> Vec<Wrapped<Hamburger>> {
    wrapped_hamburgers.into_iter().filter(check).collect()
}

我们仔细对比一下需求,发现这次的需求和包装汉堡的需求不一样了。包装汉堡的输入的数组和输出的数组长度是一样的,并且每个元素是一一对应的,一个汉堡则对应一个包装好的汉堡。而这次的需求中,输出的数组长度也许会小于输入的数组,因为我们要在其中挑选符合条件的汉堡,只有符合条件的汉堡才能组成输出。这一点,map并不能做到。因此,如果不用for循环和可变变量,我们必须想到一种函数式的解决方案,这就是filter

用Haskell的类型系统来表示的话,filter的类型为

filter :: (a -> Bool) -> M a -> M a

同样地,我们以MList为例:

filter :: (a -> Bool) -> [a] -> [a]

filter接受一个判断函数,它输入的是数组中的每一个元素,输出的是一个布尔值,表示这个元素该不该放到输出的数组中。然后,filter对输入的[a]类型的数组参数中的每一个元素施以这个函数,把符合条件的元素放到输出数组中,最终输出[a]类型的输出。

fold

fold函数的作用最为强大,可以解决一切for循环的改写,但是它最初的本意并不是为了解决一切的for循环,而是为了解决除了上两种情况外的别的情况。

在汉堡全部都准备好了之后,我们终于要把汉堡卖出去了。那么,该如何统计制作的这么多汉堡的总价呢?

fn sell(wrapped_hamburger: Wrapped<Hamburger>) -> u64 { /* hide */ }

fn sell_hamburgers_in_for_loop(wrapped_hamburgers: Vec<Wrapped<Hamburger>>) -> u64 {
    let mut sum = 0;
    for wrapped_hamburger in wrapped_hamburgers {
        sum = sum + sell(wrapped_hamburger);
    }
    sum
}

fn sell_hamburgers_in_fold(wrapped_hamburgers: Vec<Wrapped<Hamburger>>) -> u64 {
    wrapped_hamnburgers.into_iter().fold(0, |sum, wrapped_hamburger| {
        sum + sell(wrapped_hamburger)
    })
}

mapfilter都做不到的一点,是在数组中的元素之间产生关系。mapreduce的能力只有在输入的数组中的某个元素和输出数组中对应的元素间产生关系,但是,在我们销售汉堡的时候,需要把每个汉堡的价格加起来,这就需要输入的数组里的元素之间产生关系。这时候,就是fold出场的时候了。

用Haskell的类型系统来表示的话,fold的类型为

fold :: (b -> a -> b) -> b -> M a -> b

其中M必须是可以使用fold的类型,比如说List等。

这个类型看上去最为复杂,但我们仍然以List为例:

fold :: (b -> a -> b) -> b -> [a] -> b

它和前面的函数map, filter不一样,它接受三个参数:折叠函数,初始值和输入数组。这分别对应于我们卖汉堡中的更新总价函数(sum = sum + sell(wrappped_hamburger)),初始总价(let mut sum = 0)和准备售卖的汉堡(wrapped_hamburgers: Vec<Wrapped<Hamburger>>)。

fold做的事是:

  1. 用初始总价作为第一个总价
  2. 从输入的汉堡中取出一个汉堡
  3. 利用更新总价函数,输入当前的总价和当前的汉堡,得到一个新的总价(这里不是改变数值,而是输出一个新的值)
  4. 将新的总价作为下一个总价,重复步骤2和3,直到汉堡都被卖光了

我之所以说fold能解决一切for循环的改写,是因为它做的实际上就是for循环所做的事,我们甚至可以用fold来实现mapfilter:

fn push_to_the_end<T>(original: Vec<T>, new: T) -> Vec<T> { /* hide */ }
fn silly_map<F>(map_function: F, my_structs: Vec<MyStruct1>) -> Vec<MyStruct2>
	where
		F: Fn(MyStruct) -> MyStruct2 {
    my_structs.into_iter().fold(
        vec![],
        |my_structs2, my_struct| {
            my_structs2.push_to_the_end(map_function(my_struct))
        }
    )
}

简而言之,就是做了和当初for循环一样的事,把每一个新的元素放到新数组的尾部,只不过for循环是每次改变输出数组,而我们的愚蠢实现是得到一个新的数组。

虽然fold如此强大,但是除了在我们上述讲的算总价的情况下,其他情况中它的可读性并不是特别好,并且如果像上面实现map这么用fold,那和for循环实际上就没有区别了,所以尽管fold功能强大,我们还是需要挑选正确的情况使用相应的函数。

链式调用

为什么会把这三种函数叫做流呢?这三种函数是不是灵活性不够呢?事实上,我们可以灵活地链式调用这三种函数,用清爽的代码完成我们的实现。

fn make_hamburgers() -> Vec<Hamburger> { /* hide */ }
fn wrap(hamburger: Hamburger) -> Wrapped<Hamburger> { /* hide */ }
fn check(wrapped_hamburger: &Wrapped<Hamburger>) -> bool { /* hide */ }
fn sell(wrapped_hamburger: Wrapped<Hamburger>) -> u64 { /* hide */ }

fn process_in_for_loop() -> u64 {
    let mut sum = 0;
    for hamburger in make_hamburgers {
        let wrapped_hamburger = wrap(hamburger);
        if check(&wrapped_hamburger) {
            sum = sum + sell(wrapped_hamburger);
        }
    }
    sum
}

fn process_in_stream() -> u64 {
    make_hamburgers.into_iter()
    	.map(wrap)
    	.filter(check)
    	.fold(0, |sum, wrapped_hamburger| {
            sum + sell(wrapped_hamburger)
    	})
}

process_in_stream中,我们把这三种流函数链式调用了,不仅减少了很多中间变量,而且既没有可变变量,也没有for循环了。

我们关于流的讨论就这么结束了么?然而并不。我们添加输出看看(我们这里添加了输出,实际上并不是纯函数了):

fn process_in_for_loop() -> u64 {
    let mut sum = 0;
    for hamburger in make_hamburgers {
        println!("Wrapping...");
        let wrapped_hamburger = wrap(hamburger);
        println!("Checking...");
        if check(&wrapped_hamburger) {
            println!("Selling...");
            sum = sum + sell(wrapped_hamburger);
        }
    }
    sum
}

fn process_in_stream() -> u64 {
    make_hamburgers.into_iter()
    	.map(|hamburger| {
            println!("Wrapping...");
            wrap(hamburger)
    	})
    	.filter(|wrapped_hamburger| {
            println!("Checking...");
            check(wrapped_hamburger)
    	})
    	.fold(0, |sum, wrapped_hamburger| {
            println!("Selling...");
            sum + sell(wrapped_hamburger)
    	})
}

毫无疑问,process_in_for_loop的输出会是:

Processing...
Checking...
Selling...
...
Processing...
Checking...
Selling...

process_in_stream的输出会是和上面一样,还是

Processing...
Processing...
...
Checking...
Checking...
...
Selling...
Selling...
...

这种顺序呢?

答案是,这两种函数的写法的输出都是第一种。

首先,我们需要理解每种顺序意味着什么。第一种顺序是遍历一遍汉堡,对于每个汉堡,包装,挑选出好的,然后卖掉,接下来再处理第二个汉堡;第二种顺序就是先遍历一遍汉堡,每一个都包装一下;然后遍历一遍我们包装好的汉堡,再挑选好的汉堡;最后把挑选好的汉堡一个一个卖掉。这个两种顺序看似都十分可行嘛。然而,从程序员的角度来看,就不是这样了。假设我们有M个制作的汉堡,然后一共挑选了N个出来卖,那么从第一个顺序来看,我们只需要M个汉堡大小的空间就可以了;而使用第二种顺序呢,就是需要我们先声明M个汉堡的空间用来制作汉堡,然后包装汉堡得到的M个包装好的汉堡,又需要M大小的空间,再挑选得到N个汉堡,又需要N大小的空间,这空间占用也太大了吧。所以,从正常人的角度来看,我们链式调用流函数,其计算顺序应该是和我们for循环一样才是最好的。

但是,这又带来了问题。我们都知道在冯·诺伊曼架构下,代码是一行一行顺序执行的,那么我们用流的链式写法,也应该一行一行执行呀,这不就产生矛盾了吗。这就和函数式语言惰性求值有关了。总的来说,Haskell这类函数式语言,它的求值顺序可以看作,当我们用到这个变量的时候,才会去求这个变量的值。而我们现在一般的语言虽然仍然是正常的非惰性求值,但是需要用惰性求值的手段来优化我们流函数的链式调用,以达到for循环的效率。应此mapfilter,实际上并没有用到它的结果,只是写出了应该怎样得到值的过程,只有在我们fold的时候,或者之前collect成数组的时候,才会真正需要它们的值,这时候才是真正求值的时候。因此,通过这种手法,就可以达到for循环的求值顺序了。

Option

Option并非函数式编程的一个特性,但我要用这个类型来说明函数式编程的一些理念。

在Rust中,我们可以用Option枚举值来做简单的错误处理,Option的定义为

enum Option<T> {
    Some(T),
    None
}

它的值有两种可能:一种是None,就是什么都没有;另一种是Some,同时带有我们真正需要的值。我们可以这样用Option来做错误处理:

fn make_hamburger() -> Option<Hamburger> {
    /* do some making... */
    if everything_all_right {
        Some(hamburger)
    } else {
        None
    }
}

通过这种方法,我们既避免了误使用返回的空指针,也避免了使用额外的变量表示错误。

那么加上了Option之后,我们仔细来看看炸鸡生产线。

Functor

我们做炸鸡的过程为:

fn get_chicken() -> Option<Chicken1>;
fn slice_chicken(chicken: Chicken1) -> Chicken2;
fn fry_chicken(chicken: Chicken2) -> Chicken;

假设只有第一步会出错。那么,我们可以这样写总的生产函数:

fn make_chicken() -> Option<Chicken> {
    if let Some(chicken1) = get_chicken() {
        fry_chicken(slice_chicken(chicken1))
    } else {
        None
    }
}

看上去虽然挺符合逻辑的,但感觉这个else { None }显得很突兀。

有什么解决方法嘛?

有噢!

fn make_chicken() -> Option<Chicken> {
    get_chicken()
    	.map(slice_chicken)
    	.map(fry_chicken)
}

看上去舒服多了,终于是一个函数一路执行下去,而不是中间再夹个判断了。

这么做的原理是什么呢?我们来看map函数的签名:

impl<T> Option<T> {
    pub fn map<U, F>(self, f: F) -> Option<U>
    where
        F: FnOnce(T) -> U
}

看上去好像很复杂,但实际上做的就是我们刚刚用if let来做的事,如果此时Option的值为Some,则对其中的值施加函数f,再加上一层Some,否则就返回None。我们可以简单地实现这个函数:

pub fn map<U, F>(self, f: F) -> Option<U>
where
    F: FnOnce(T) -> U {
        if let Some(value) = self {
            Some(f(value))
        } else {
            None
        }
}

它实际上做的事就是我们刚刚做的事,只不过抽象为了这个函数。不过,有了这个函数,我们的代码似乎更加函数式了。

那这个函数和函数式究竟有什么关系呢?我们用Haskell的语言来说:

class Functor f where
  fmap :: (a -> b) -> f a -> f b

如果一个类型有上面的这个fmap函数的实现,那么就称这个类型是一个Functor。并且,fmap需满足

fmap id = id
fmap (g . h) = (fmap g) . (fmap h)

毫无疑问,在我们的Option中,id就是一个什么都不做的函数,直接输出自己的输入。

fmap就像是真正的函数一样,它其实就是在我们刚刚讲的流函数map的基础上加上两条约定而已。

Monad

我们刚刚的假设也许过于乐观了,下面我们假设做炸鸡仍然需要这三个函数,但每一步都可能出错,产生None值。

fn get_chicken() -> Option<Chicken1>;
fn slice_chicken(chicken: Chicken1) -> Option<Chicken2>;
fn fry_chicken(chicken: Chicken2) -> Option<Chicken>;

那么,我们来写这样的一个总的生产函数该怎么写呢?

fn make_chicken() -> Option<Chicken> {
    match get_chicken() {
        Some(chicken1) => {
            match slice_chicken(chicken1) {
                Some(chicken2) => {
                    match fry_chicken(chicken2) {
                        Some(chicken) => Some(chicken),
                        None => None
                    }
                },
                None => None
            }
        },
        None => None
    }
}

这是最原始的写法,好在Rust给我们提供了if let的bind方案,我们可以稍微简化为

fn make_chicken() -> Option<Chicken> {
    if let Some(chicken1) = get_chicken() {
        if let Some(chicken2) = slice_chicken(chicken1) {
            return fry_chicken(chicken2);
        }
    }
    None
}

但仍然让人看着很难受。有没有好一点的办法呢?

有噢!

如果我们有了之前的map函数,能不能做这样的事情呢?并不能直接做这样的事情。因为

get_chicken()
	.map(slice_chicken)

根据我们刚刚看的map的功能,和slice_chicken的返回值,这样的返回值应当是Option<Option<Chicken2>>了。如果在get_chicken没出错,在slice_chicken也没出错,那么结果是Some(Some(chicken2)),而如果slice_chicken出错了,结果甚至是Some(None)。这个时候,另一个帮手出现了:

get_chicken()
	.map(slice_chicken)
	.flatten()

这样竟然就能解决问题了,这是为什么呢?

事实上,flatten的声明如下:

impl<T> Option<T> {
    pub fn flatten(self) -> Option<T>
}

它很奇特,但它的作用正像它的名字所说的那样,把多个Option合并为一个。也就是说,Some(Some(chicken2))会被合并为Some(chicken2)Some(None)会被合并成None。通过这样的函数,我们似乎解决了问题。

可是,能不能就用一个函数解决呢?

能噢!

fn make_chicken() -> Option<Chicken> {
    get_chicken()
    	.and_then(slice_chicken)
    	.and_then(fry_chicken)
}

and_then似乎帮了我们的大忙。它的声明如下:

impl<T> Option<T> {
    pub fn and_then<U, F>(self, f: F) -> Option<U>
    where
        F: FnOnce(T) -> Option<U>
}

map的差别只在F的返回值上。它的作用是:如果Option值为None,那么返回None,否则,将f作用在Some的值上。说起来很麻烦,但实现起来很简单:

impl<T> Option<T> {
    pub fn and_then_1<U, F>(self, f: F) -> Option<U>
    where
        F: FnOnce(T) -> Option<U> {
            if let Some(value) = self {
                f(value)
            } else {
                None
            }
    }
    
    pub fn and_then_2<U, F>(self, f: F) -> Option<U>
    where
        F: FnOnce(T) -> Option<U> {
            self.map(f).flatten()
    }
}

and_then_1和我们之前用if let实现的功能的代码近乎一致。而正是通过and_then_2这种map+flatten,或者and_then,才达到了我们函数式的需求。

事实上,用Haskell的语言来描述:

class Monad M where
    return :: a -> M a
    join :: M (M a) -> M a

或者,我们也可以这样定义:

class Monad M where
    bind :: M a -> (a -> M b) -> M b

效果都是一样的。它和Functor的区别就在于,一个需要直接对里面的值进行操作,一个则需要同时协调里面的值和包裹的类型。

Monad的好处不仅在于可以函数式地一个函数接着一个函数地写一些需要错误处理的函数而不需要在中间停下判断,它还可以在纯函数式语言中完成IO操作。

我们知道,纯的函数式语言要求函数都是纯函数,因此函数内部不应该进行IO。而我们如果想读入、写出,都得在函数内部调用相关的API,这可怎么办呢?

我们忽然又想到,函数式语言对相同的输入应当具有相同的输出,那么,可不可以把我们需要读入的东西作为函数的输入,需要输出的东西作为函数的输出呢?

我们原来不纯的函数是这么写的:

fn not_pure() {
    let string = io.in();
    io.out(another_string);
}

我们是不是可以这么写:

fn pure(value_from_io: String) -> IO {
    let string = value_from_io;
    return IO.out(another_string);
}

并不在函数内部做实际的IO操作,而是等待它的调用者来实现IO操作呢?

事实上,这是完全可以的。我们可以给IO实现一个Monad,通过它的bind函数来实现IO的输入输出。IO的bind接受一个IO,并将我们写的pure函数施加在IO的值里,然后将我们的pure输出进行输出,这完全能达到我们的目的,只不过我们写的函数是纯函数,但语言调用我们pure的函数变成了不纯的函数。只需要将调用的函数藏在语言的运行时里,就能完全达到写的代码都是纯函数但仍然能达到IO的效果了。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值