Rust为什么这么难学?

在使用 Rust 的过程中,相信很多朋友都有过类似的吐槽:真不确定自己要掌握多少语言知识、多少独门编程技巧和多么旺盛的好奇心,才能坚持做完这项最最琐碎的工作。绝望之下,我们往往会去 rust/issues 寻找解决办法,然后突然发现了一种在理论上根本不成立的 API 设计思路。这种矛盾源自某种微妙的语言Bug,简直神奇。

我从四年前开始接触Rust。目前为止,我写过相关的书和文章,也翻译了不少语言发布的公告。我还设法用Rust 编写过一些生产代码,甚至有幸在一场关注 Rust 的在线研讨上发过言。

虽然也算是身经百战,但我还是动不动就会跟Rust的借用检查器和类型系统“闹出”些不愉快。现在的我,虽然已经慢慢理解了Rust “无法返回对临时值的引用”之类的错误,也设计出一些启发式的策略来处理生命周期问题,但最近一个意外再次打击了我的信心……

初次尝试:用来处理更新的函数

我正打算编写一个聊天机器人,来改善用户的使用体验。通过长轮询或 webhooks,我开始一个个获取服务器更新流。我有一个面向全体更新的处理程序向量,其中每个处理程序都会接收对更新的引用,再把后续解析返回至()。这个处理程序向量由Dispatcher所有,每次有更新传入时,Dispatcher都会按顺序执行各个处理程序。

下面,试试具体实现。这里省略掉处理程序的执行部分,只关注push_handler函数。初次尝试:省略处理程序的执行,只关注push_handler函数。第一次尝试:

use futures::future::BoxFuture;use std::future::Future;



#[derive(Debug)]struct Update;



type Handler = Box<dyn for<'a> Fn(&'a Update) -> BoxFuture<'a, ()> + Send + Sync>;



struct Dispatcher(Vec<Handler>);



impl Dispatcher {

    fn push_handler<'a, H, Fut>(&mut self, handler: H)

    where

        H: Fn(&'a Update) -> Fut + Send + Sync + 'a,

        Fut: Future<Output = ()> + Send + 'a,

    {

        self.0.push(Box::new(move |upd| Box::pin(handler(upd))));

    }

}



fn main() {

    let mut dp = Dispatcher(vec![]);





    dp.push_handler(|upd| async move {

        println!("{:?}", upd);

    });

}

在这里,我使用由HRTB生命周期for<'a>限制的动态类型Fn trait来表示每个更新处理程序。因为我希望返回的future由&'a Update函数参数中的'a部分决定。之后,我们又定义了拥有 Vec<Handler>的 Dispatcher类型。

在push_handler当中,我们接受一个静态类型的泛型H来返回Fut;为了将此类型的值推送至self.0,我们需要将处理程序打包至新的装箱处理程序当中,再使用Box::pin将返回的 future转换为来自futures箱的BoxFuture。

下面来看看这个解决思路行不行得通:

error[E0312]: lifetime of reference outlives lifetime of borrowed content...

  --> src/main.rs:17:58

   |17 |         self.0.push(Box::new(move |upd| Box::pin(handler(upd))));

   |                                                          ^^^

   |

note: ...the reference is valid for the lifetime `'a` as defined here...

  --> src/main.rs:12:21

   |12 |     fn push_handler<'a, H, Fut>(&mut self, handler: H)

   |                     ^^

note: ...but the borrowed content is only valid for the anonymous lifetime #1 defined here

  --> src/main.rs:17:30

   |17 |         self.0.push(Box::new(move |upd| Box::pin(handler(upd))));

很遗憾,这办法行不通。

问题在于push_handler会接收一个具体的生命周期'a ,也就是我们试图将HRTB的生命周期归结成for<'a>。在这种情况下,我们就需要证明for<'a, 'b> 'a: 'b(其中'b为来自push_handler 的'a),这显然不成立。

对于这个问题,我们可以尝试几种不同的处理方法:替换掉Fut泛型,转而强制要求user handler返回由 for<'a>限定的BoxFuture:

use futures::future::BoxFuture;



#[derive(Debug)]struct Update;



type Handler = Box<dyn for<'a> Fn(&'a Update) -> BoxFuture<'a, ()> + Send + Sync>;



struct Dispatcher(Vec<Handler>);



impl Dispatcher {

    fn push_handler<H>(&mut self, handler: H)

    where

        H: for<'a> Fn(&'a Update) -> BoxFuture<'a, ()> + Send + Sync + 'static,

    {

        self.0.push(Box::new(move |upd| Box::pin(handler(upd))));

    }

}



fn main() {

    let mut dp = Dispatcher(vec![]);





    dp.push_handler(|upd| {

        Box::pin(async move {

            println!("{:?}", upd);

        })

    });

}

现在编译部分没问题了,但最终得到的API还是有问题:理想情况下,我并不希望用户通过 Box::pin打包每个处理程序。毕竟push_handler才是专门干这个的,负责把静态类型的处理程序转换成动态类型空间中的等效形式。但如果我强行要求处理程序保持静态,又会如何?

要探究答案,我们可以用异构列表来试试。

第二次尝试:异构列表

异构列表这名称看着唬人,实际上就是大家熟悉的元组。也就是说,我们需要的是(H1, H2, H3, ...),其中每个H代表不同的处理程序类型。但同时,push_handler和execute 操作又要求我们能够迭代这个元组——单靠原版Rust肯定是不行。要达成这个效果,我们就得借助其他一些神奇的表达机制。

首先来看我们的异构列表表示:

struct Dispatcher<H, Tail> {

    handler: H,

    tail: Tail,

}



struct DispatcherEnd;

是不是有点不知所云?确实如此,我们只是想要构建起Dispatcher<H1, Dispatcher<H2, Dispatcher<H3, DispatcherEnd>>>这种形式的类型,其等同于(H1, H2, H3)元组。因此,我们现在可以使用简单的类型归纳来定义push_handler函数:

trait PushHandler<NewH> {

    type Out;

    fn push_handler(self, handler: NewH) -> Self::Out;

}



impl<NewH> PushHandler<NewH> for DispatcherEnd {

    type Out = Dispatcher<NewH, DispatcherEnd>;





    fn push_handler(self, handler: NewH) -> Self::Out {

        Dispatcher {

            handler,

            tail: DispatcherEnd,

        }

    }

}



impl<H, Tail, NewH> PushHandler<NewH> for Dispatcher<H, Tail>where

    Tail: PushHandler<NewH>,

{

    type Out = Dispatcher<H, <Tail as PushHandler<NewH>>::Out>;





    fn push_handler(self, handler: NewH) -> Self::Out {

        Dispatcher {

            handler: self.handler,

            tail: self.tail.push_handler(handler),

        }

    }

}

有些朋友可能不太熟悉所谓类型级归纳,其实这就是一种常规递归,只是适用对象是类型(trait)、而非值:

这里的base case就是impl<NewH> PushHandler<NewH> for DispatcherEnd。我们构建一个 dispatcher,其中只包含一个处理程序。

而step case则是impl<H, Tail, NewH> PushHandler<NewH> for Dispatcher<H, Tail>。这里我们只将归纳传播至self.tail。我们再以同样的方式实现execute:

trait Execute<'a> {

    #[must_use]

    fn execute(&'a self, upd: &'a Update) -> BoxFuture<'a, ()>;

}



impl<'a> Execute<'a> for DispatcherEnd {

    fn execute(&'a self, _upd: &'a Update) -> BoxFuture<'a, ()> {

        Box::pin(async {})

    }

}



impl<'a, H, Fut, Tail> Execute<'a> for Dispatcher<H, Tail>where

    H: Fn(&'a Update) -> Fut + Send + Sync + 'a,

    Fut: Future<Output = ()> + Send + 'a,

    Tail: Execute<'a> + Send + Sync + 'a,

{

    fn execute(&'a self, upd: &'a Update) -> BoxFuture<'a, ()> {

        Box::pin(async move {

            (self.handler)(upd).await;

            self.tail.execute(upd).await;

        })

    }

}

但这还不够。因为我们Execute<'a>的实现要依赖于具体的'a,而且dispatcher还要能够处理不同生命周期的更新,所以最后一步就是面向全部更新生命周期对execute进行抽象:

async fn execute<Dp>(dp: Dp, upd: Update)where

    Dp: for<'a> Execute<'a>,

{

    dp.execute(&upd).await;

}

好了,现在我们要来测试这种神奇的解决方案:

#[tokio::main]async fn main() {

    let dp = DispatcherEnd;





    let dp = dp.push_handler(|upd| async move {

        println!("{:?}", upd);

    });

    execute(dp, Update).await;

}

可惜还是行不通:

error: implementation of `Execute` is not general enough

  --> src/main.rs:83:5

   |

83 |     execute(dp, Update).await;

   |     ^^^^^^^ implementation of `Execute` is not general enough

   |

   = note: `Dispatcher<[closure@src/main.rs:80:30: 82:6], DispatcherEnd>` must implement `Execute<'0>`, for any lifetime `'0`...

   = note: ...but it actually implements `Execute<'1>`, for some specific lifetime `'1`

到这里,很多朋友应该体会到借用检查器的可惜之处了吧?而且无论怎么调整,以上代码都没办法正常编译。原因如下:传递给dp.push_handler的闭包接收到一条具体生命周期为'1的upd,但因为where子句中引入了HRTB边界,所以execute要求Dp只在生命周期'0上实现Execute<'0>。但如果我们用常规函数试试运气,代码倒是可以正常编译:

#[tokio::main]async fn main() {

    let dp = DispatcherEnd;





    async fn dbg_update(upd: &Update) {

        println!("{:?}", upd);

    }





    let dp = dp.push_handler(dbg_update);

    execute(dp, Update).await;

}

这里会将Update传递为标准输出。

这种借用检查器的特殊行为确实不太合理,毕竟函数和闭包不仅各自trait不同,而且处理生命周期的方式也有所区别。虽然接受引用的闭包要受到特定生命周期的限制,但像我们使用的dbg_update这类函数应该可以在一切生命周期'a上接受&'a Update才对。以下示例代码就演示了这种区别:

let dbg_update = |upd| {

    println!("{:?}", upd);

};





{

    let upd = Update;

    dbg_update(&upd);

}





{

    let upd = Update;

    dbg_update(&upd);

}





由于调用了dbg_update,所以我们会得到以下编译错误:
 

error[E0597]: `upd` does not live long enough

  --> src/main.rs:11:20

   |

11 |         dbg_update(&upd);

   |                    ^^^^ borrowed value does not live long enough

12 |     }

   |     - `upd` dropped here while still borrowed

...

16 |         dbg_update(&upd);

   |         ---------- borrow later used here





这是因为dbg_update闭包只能处理一个特定的生命周期,而第一与第二个upd的生命周期显然并不一样。

相比之下,作为函数的dbg_update在这里倒是可以完美运行:

fn dbg_update_fn(upd: &Update) {

    println!("{:?}", upd);

}





{

    let upd = Update;

    dbg_update_fn(&upd);

}





{

    let upd = Update;

    dbg_update_fn(&upd);

}

我们甚至可以很方便地使用let () = ...;来追踪该函数的确切签名:

fn dbg_update_fn(upd: &Update) {

    println!("{:?}", upd);

}





let () = dbg_update_fn;

跟预想的一样,签名为for<'r> fn(&'r Update):

error[E0308]: mismatched types

 --> src/main.rs:9:9

  |

9 |     let () = dbg_update_fn;

  |         ^^   ------------- this expression has type `for<'r> fn(&'r Update) {dbg_update_fn}`

  |         |

  |         expected fn item, found `()`

  |

  = note: expected fn item `for<'r> fn(&'r Update) {dbg_update_fn}`

           found unit type `()`

话虽如此,但这样一个包含异构列表的答案也不符合我们的预期:它太过混乱、僵化、复杂,而且也装不进闭包。另外,这里不建议在Rust中使用复杂的类型机制。如果大家在处理 dispatcher类型时突然遇到类型检查失败,那麻烦可就大了。想象一下,我们正在维护一套用Rust编写的生产系统,而且需要尽快修复一些关键Bug。而在完成了代码库的必要更改之后,却看到了以下编译输出:

error[E0308]: mismatched types

   --> src/main.rs:123:9

    |

123 |     let () = dp;

    |         ^^   -- this expression has type `Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update0}, Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update1}, Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update2}, Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update3}, Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update4}, Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update5}, Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update6}, Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update7}, Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update8}, Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update9}, DispatcherEnd>>>>>>>>>>`

    |         |

    |         expected struct `Dispatcher`, found `()`

    |

    = note: expected struct `Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update0}, Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update1}, Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update2}, Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update3}, Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update4}, Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update5}, Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update6}, Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update7}, Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update8}, Dispatcher<for<'_> fn(&Update) -> impl futures::Future<Output = ()> {dbg_update9}, DispatcherEnd>>>>>>>>>>`

            found unit type `()`

在现实用例中,实际错误可能比演示的还要多20倍。

第三次尝试:使用 Arc

在刚开始接触Rust的时候,我曾经以为引用要比智能指针更简单。但现在我基本只用Rc/Arc 了,毕竟牺牲一点点性能就可以跟生命周期保持距离,这有什么不好?而且信不信由你,前面提到的所有问题,都是由type Handler, 'a 中的单一生命周期引起的。

让我们把它替换成Arc<Update>的形式:

use futures::future::BoxFuture;

use std::future::Future;

use std::sync::Arc;





#[derive(Debug)]

struct Update;





type Handler = Box<dyn Fn(Arc<Update>) -> BoxFuture<'static, ()> + Send + Sync>;





struct Dispatcher(Vec<Handler>);





impl Dispatcher {

    fn push_handler<H, Fut>(&mut self, handler: H)

    where

        H: Fn(Arc<Update>) -> Fut + Send + Sync + 'static,

        Fut: Future<Output = ()> + Send + 'static,

    {

        self.0.push(Box::new(move |upd| Box::pin(handler(upd))));

    }

}





fn main() {

    let mut dp = Dispatcher(vec![]);





    dp.push_handler(|upd| async move {

        println!("{:?}", upd);

    });

}

成了,这不就正常编译了吗!我们甚至都不需要在每个闭包里手动指定Arc<Update> ——类型推断就能帮我们完成繁琐的操作。

Rust 的问题

“随心所欲地并发”这话,大家都听过吧?虽然原则上也没错,但这句话其实很有误导性。没错,我们不用在怕数据竞争,可除此之外还有别的麻烦随之而来。

其实在前文的演示中,我们还没涉及到Rust的全部特性和缺陷,这些毛病其实相当不少。首先就是装箱future的大量使用:之前提到的所有BoxFuture类型,以及Box::new和Box::pin 相应的优化,都没办法用泛型来替代。如果大家多少了解一点Rust,就会知道Vec只能容纳固定大小的类型,所以才需要把BoxFuture放在type Handler之内。但在Execute trait中使用 BoxFuture(而非async函数签名)时,这个问题就不那么容易被发现。

这背后的原因也很复杂,但简单来说就是,我们没办法在traits中定义async fn函数;相反,大家只能使用其他类型擦除方法,例如async-trait板条箱或者手动future装箱,也就是我们在示例中采取的办法。事实上,async-trait走的也是这个路线,但我还是会尽量少用,因为它会使用过程宏来处理编译时错误。

另外,返回BoxFuture这个办法也有自己的问题:首先就是我们得牢记为每个async fn指定 #[must_use],否则即使是在没有.await 的情况下调用execute,编译器也不会给出任何警告。从本质上讲,装箱静态实体实在太多,所以futures箱会经常暴露在常见traits的动态变体面前,包括BoxStream, LocalBoxFuture以及LocalBoxStream(后两个不要求Send)。

其次,upd的显式类型注释又是另一个大问题:


 

use tokio; // 1.18.2





#[derive(Debug)]

struct Update;





#[tokio::main]

async fn main() {

    let closure = |upd: &Update| async move {

        println!("{:?}", upd);

    };





    closure(&Update).await;

}



编译器输出:


 

error: lifetime may not live long enough

  --> src/main.rs:8:34

   |

8  |       let closure = |upd: &Update| async move {

   |  _________________________-______-_^

   | |                         |      |

   | |                         |      return type of closure `impl Future<Output = ()>` contains a lifetime `'2`

   | |                         let's call the lifetime of this reference `'1`

9  | |         println!("{:?}", upd);

10 | |     };

   | |_____^ returning this value requires that `'1` must outlive `'2`



(如果去掉类型注释 &Update,则编译成功。)

相信很多朋友都看不懂这里到底出了什么错,这很正常,我们可以参阅一个问题 #70791(

https://github.com/rust-lang/rust/issues/70791)。查看问题标签列表中的C-Bug,可以看到它将问题归类为编译器Bug。

另外,就连注册处理程序这种简单的任务,我们也得尽量想办法让它绕过Rust语言,否则就容易受到rustc 问题的影响。在Rust中设置接口就像是趟雷区:要想成功,就得小心翼翼在理想接口和可用功能之间求取平衡。

有些朋友可能要说,编程语言不都这样吗?那可不是,Rust的问题特殊得多、也烦人得多。

我们在使用其他稳定生产语言的时候,一般至少可以预判理想中的接口要如何适应语言语义,但在用Rust编程时,设计API的过程总会受到语言自身的种种限制和影响。刚开始,我们当然想正常通过借用检查器验证引用,用类型机制处理程序实体,但最终结果永远是Box、Pin和Arc 满天飞、身陷Rust类型系统那孱弱的表达能力难以自拔。

作为这一段的结尾,我们来看同样的需求在Golang中的实现方法:

dispatcher.go



package main





import "fmt"





type Update struct{}

type Handler func(*Update)





type Dispatcher struct {

    handlers []Handler

}





func (dp *Dispatcher) pushHandler(handler Handler) {

    dp.handlers = append(dp.handlers, handler)

}





func main() {

    dp := Dispatcher{handlers: nil}

    dp.pushHandler(func(upd *Update) {

        fmt.Println(upd)

    })

}

Rust 为什么这么难用?

首先,面对这类问题的时候,希望大家能抛掉“因为XX就是逊啦”或者“因为XX的设计者太弱智”这类粗暴又毫无意义的情绪宣泄。

那么,Rust 为什么这么难用?

首先,Rust是一种系统语言。作为系统编程语言,Rust绝对不能阻止程序员直接接触底层计算机内存的管理机制。也正因为如此,Rust才向程序员们开放了其他高级语言所极力隐藏的种种细节。例如:指针、引用和相关等元素,内存分配器、不同字符串类型、各种Fn trats、std::pin板条箱等等。

其次,Rust是一种静态语言。具有静态类型系统(或等效功能)的语言,更倾向于在静态和动态层级上复制功能,借此引入静态-动态二元性。将静态抽象转换为动态对应抽象,被称为向上转换;由动态转换到静态则称为向下转换。在push_handler当中,我们使用向上转换将静态处理程序转换为动态Handler类型,再把它推送给最终向量。

另外,Rust在设计上还高度强调直观性和内存安全性。正是这种复杂的组合,在计算机语言的设计中强调了人为边界的重要性。

说到这里,大家应该能够理解为什么Rust用起来总感觉哪里有毛病。事实上,它能运行起来本身就已经是个奇迹了。计算机语言是一种由无数组件紧密交织而成的体系:每当引入新的语言抽象时,都得保证它能跟系统的其余部分良好配合,避免引发Bug或不一致。所以,我们真的应该感谢和敬佩那些愿意全职开发这类语言的贡献者,最好能给他们捐点款。

Rust还能不能变得更好?

现在,我们假设Rust的所有问题一夜之间都被解决了,而且整个rustc和std也都经过了正式验证。就是说,Rust突然就获得了包含多个1级实现的完整语言规范、能够跟GCC比肩的硬件平台支持能力、稳定的ABI(虽然还不清楚具体该怎么处理泛型),结果会怎么样?那Rust当然就是系统编程的理想语言喽。

我们也可以从另一个角度设想,Rust的问题确实消失了,而且变成了一种彻头彻尾的高级语言。那它就足以干掉一切现有主流编程语言。毕竟Rust的默认功能相当丰富,支持多态,包管理器也非常方便。相比之下,愚蠢的JavaScript语义、恐怖的Java企业应用、C中的NULL 指针问题、C++的不可控UB、C#中多到毫无必要的同种功能实现等等,简直就是一场荒谬的畸形秀。

但现实告诉我们,即使这些语言各自有着不同的缺点,人们仍然用它编写生产软件,而且当前的Rust还远远挤不进编程语言的第一梯队。

另外,我估计Rust永远也达不到Java或者Python那样的人气。这背后的原因更多在于社区、而非技术:由于Rust语言天生更为复杂,所以相关的专业工程师数量不可能比得上Java或者Python。

更糟糕的是,Rust工程师的稀缺也让他们的平均薪酬相对更高。毕竟作为企业雇主,确实没必要用更多的钱和更长的招聘周期来无脑支持 Rust。

最后再做这样的设想:Rust的问题全都消失了,它变成了一套高级且统一的功能集。这可能也是Rust开发者们的终极目标:让它成为一种面向大众的高级泛化编程语言。有趣的是,设计这样一种语言可能反而比开发现有Rust的难度更低,毕竟我们可以把所有低级细节都隐藏在那层厚厚的语言运行时外壳之下。

好日子会到来的

所以我好像突然想通了,为什么不开发这样一个终极版的Rust呢?但我可不打算亲自动手,毕竟这工作没准得耗上十年、二十年,而且最终成果能在编程语言中脱颖而出的几率也实在不高。

在我看来,目前常用生产语言的成功其实有着很强的随机性——我们虽然能从特定的流行语言身上总结出一些明确的优势,但却没法解释为什么其他一些更好的替代语言始终默默无闻。有大企业的支持?无意中契合了下一阶段的IT发展趋势?但大企业为什么要支持,这种契合又是怎么达成的?残酷的现实告诉我们:有些事情就是随机发生,再强的技能、再无私的奉献都改变不了。

所以如果大家也想创造一种面向未来的编程语言,我建议大家三思而后行——除非你既勇猛无比,又是个无可救药的理想主义者。

有人指出,dispatcher例子主要影响的是库维护者群体,应用程序开发者一般不会受到这类特殊问题的影响。这话其实没毛病,但我写这篇文章主要是想讨论Rust语言的设计思路。

Rust并不适合泛型async编程,这是事实。当我们输入async时,总会观察到语言中的其他功能突然崩溃:引用、闭包、类型系统等等。从语言设计的角度来看,这正好体现了Rust完全不符合“正交语言”(如果在编程时调用程序语言不用考虑其是否影响其它语言特性,就称此语言为正交程序语言)的基本原则。我在原文中想要表达的,其实就是这个意思。

另外,能不能编写出高质量的库,在很大程度上反映出了语言的真正潜力。毕竟库的任务就是处理泛化代码,所以直接对应语言设计得提供的功能表示能力。这种能力也会直接影响到常规应用编程:我们的库越优雅,日常任务的解决难度就越低。例如:不具备GATs,我们就无法获得泛化运行时接口,并只通过一行代码就直接把日志中的Tokio全部替换为正确的 Tokyo。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值