go程序设计语言第七章-接口类型

The Go Programming Language

7 Interfaces

7.1 Interfaces as Contracts 接口作为合约

之前所讲的都是具体的类型concrete type。一个具体类型规定了它的值的表达形式,暴露内在的操作方法,例如数字的算术运算,slice的index、append、range操作。一个具体的类型通过它的方法也提供了另外的行为。当有一个具体类型的值时,你精确的知道它是什么和它能干什么。

还有另外一个类型在go中称为interface类型。它是一个抽象类型abstract type。它不暴露值的表现形式、内在结构和支持的内在操作,它只展示方法的一部分。当你有一个interface type的value时,你对它一无所知;仅仅知道它能做什么,或者说仅仅知道它提供了哪些方法。

我们使用过两个相似的函数,fmt.Printf(将结果写入标准输出,一个文件),fmt.Sprintf(将结果作为stirng返回)。得益于使用接口,我们不必可悲的因为返回结果在使用方式上的一些浅显不同就必需把格式化这个最困难的过程复制一份。实际上,这两个函数都使用了另一个函数fmt.Fprintf来进行封装。fmt.Fprintf这个函数对它的计算结果会被怎么使用是完全不知道的。

package fmt

func Fprintf(w io.Writer, format string, arg ...interface{}) (int, error)

func Printf(format string, args ...interface{}) (int, error) {
	return Fprintf(os.Stdout, format, args...)
}

func Sprintf(format string, args ...interface{}) string {
	var buf bytes.Buffer
	Fprintf(&buf, format, args...)
	return buf.String()
}

其中Fprintf接收的第一个参数是接口类型,定义如下:

package io

type Writer interface {
Write(p []byte) (n int, err error)
}

任何满足了io.Writer接口的任意具体类型值都可以作为函数参数。一个类型可以自由的被另一个满足相同接口的类型替换,被称作可替换性。这是面向对象的特征。

7.2 接口类型

接口类型描述了方法的集合,实现此接口即这些方法的具体类型是这个接口的实例。
io.Writer提供了所有可以写入bytes的抽象,如文件、内存缓冲区、网络连接、HTTP客户端、压缩工具、hash等。
io.Reader代表了任意可以读取bytes的类型,Closer是任意可以关闭的值,如文件或网络连接。

  • 接口内嵌:
type ReadWriter interface {
    Read(p []byte) (n int, err error)
    Write(p []byte) (n int, err error)
}

7.3实现接口的条件

一个类型拥有一个接口的所有方法,则实现了此接口。
如*os.File四线了io.Reader, Writer, Closer, ReadWriter接口
*byte.Buffer是心啊了Reader,Writer, ReadWriter接口,但没有Closer接口。

接口指定,即可以赋值:

var w io.Writer
w = os.Stdout  // ok, *os.File
w = new(bytes.Buffer) // ok, *.bytes.Buffer
w = time.Second // error

var rwc io.ReadWriterCloser
rwc = os.Stdout   // ok, *os.File
rwc = new(bytes.Buffer) // error,

// 接口间也能赋值
w = rwc // ok
rwc = w // error

一个类型持有一个方法,方法的接收者可以是T类型本身,还可以是T指针。在T类型上调用T的方法只有在可取地址时才有效(例如变量),但如果不可取地址则会报错。

type IntSet struct { /* ... */ }
func (*intSet) String() string
var _ = Inset{}.String() // error

var s IntSet
var _ = s.String() // OK

因为只有IntSet类型有String方法,因此也只有InsSet类型实现了fmt.Stringer接口:

var _ fmt.Stringer = &s // OK
var _ fmt.Stringer = s // error

12.8章包含了一个打印出任意值的所有方法的程序,然后可以使用godoc -analysis=type tool(§10.7.4)展
示每个类型的方法和具体类型和接口之间的关系.

接口类型封装和隐藏了具体类型和它的值。即使具体类型有其他方法,也只有接口类型暴露出来的方法才能被调用。

os.Stdout.Writer([]byte("hello"))  // ok
os.Stdout.Close()                       // ok

var w io.Writer
w = os.Stdout
w.Write([]byte("hello")) // ok
w.Close() // error

拥有更多方法的接口,对实现它的类型更严格。那么interface{}类型,没有任何方法,因此可以将任意一个值赋给空接口类型。
空接口可以持有bool, float, string, map, pointer或者任意类型,因为它没有方法,因此不能对他直接操作,需要用类型断言来获取接口中值的方法。

7.4 flag.Value接口

7.5 接口值

一个接口的值,包括两个部分–一个具体的类型和这个类型的值。称为动态类型dynamic type和动态值dynamic value。
静态语言,如go,类型都是在编译期的概念,因此类型并不是值(是说类型不是值的一部分?)。在我们的内存模型中,被称为类型描述type descriptors的一系列值提供了关于每种类型的信息,如他的名字和方法(就是go中用整数表示的所有类型的枚举值)。在一个接口值中,类型部分就是这里的类型描述符type descriptor。

下面语句中w有三种不同的值,其中声明时的默认值和最后一个语句的值相同。

var w io.Writer
w = os.Stdout
w = new(bytes.Buffer)
w = nil

声明时,w的type和value(动态类型和动态值)都为nil。
在这里插入图片描述
一个接口值是否为nil取决于它的动态类型,因此这里w是一个nil interface value。可以使用```w == nil``来进行判断。在nil interface value上调用任何方法会导致panic。

第二个语句w = os.Stdout*os.File类型赋值给w,这里涉及到从一个具体类型到接口类型的隐式转换,和显式的io.Writer(os.Stdout)转换是等价的。无论显式还是隐式,转换过程中都会捕获操作数的类型和值。接口的动态值被设置为代表*os.File指针类型的类型描述符type descriptor, 动态值贮存一份os.Stdou的副本,它是一个指向os.File的指针,代表进程的标准输出。

ps: 这里type拿到的是底层类型,value拿到的是拷贝的副本?那假如副本不是引用类型,则调用副本的方法,并不会真正修改副本内的值?
在这里插入图片描述
调用这个包含*os.File指针的接口的Write方法,将会调用(*os.File).Write方法。
通常,在编译期间我们不知道一个接口的动态类型,因此这种通过接口的方法调用必须使用动态分发dynamic dispatch。不同于直接调用,编译器必须根据类型描述符生成代码,包含名字为Write的方法的地址,然后对这个地址进行间接调用。这个调用的接收者参数是接口动态值os.Stdout的一个拷贝。效果等同于我们直接调用:os.Stdout.Write([]byte("hello"))

ps: 就是说,接口的方法调用,不是在编译器决定的,而是动态分配的,是在类型描述符上的Write方法上生成的代码,调用时先找到类型描述符,再去找Write方法。

第三个语句将*byte.Buffer类型的值赋给接口w, 接口的动态类型变为*bytes.Buffer, 动态值是一个指向新申请内存buffer的指针。
在这里插入图片描述
调用Write方法就是调用(*bytes.Buffer).Write方法,buffer的地址作为方法的接收者。

最后,将nil赋值给w,这将重置接口的type和value都为nil,恢复为刚声明时的状态。

接口值可以hold任意的大的动态值。例如可以将time.Time复赋值给接口,它包含几个不能导出的字段。
结果将如下表示:
在这里插入图片描述
原则上,无论动态值的类型有多大,动态值将总是填充在接口值内部(这只是一个概念上的模型;具体的实现可能会非常不同)。

接口值可以使用==!=进行比较。两个接口进行比较时,如果都是nil则相等;或者他们的动态类型完全相同,动态值根据==也比较而相等,则他们也是相等的。因为接口值是可比较的,因此他们可以用作map的key或者switch语句的操作数。

然而,如果两个接口值有相同的动态类型,但此动态类型不可比较(如slice),则进行比较操作时会导致panic。

var x interface{} = []int{1, 2, 3}
fmt.Println(x == x) // error

从这一点来说,接口类型是不寻常的。其他类型要么是可以安全的可比较(如基本类型和指针),要么完全不可比较(如slice、map、function),但是当比较接口类型或包含接口的聚会类型,我们必须小心潜在的panic错误。类似的还有将接口作为map的key值和作为switch语句的操作符。只有你确定接口包含的动态类型是可比较的时,才可以对接口值进行比较。

使用fmt package’s %T形式来查看接口的动态类型

	var w io.Writer
	fmt.Printf("%T\n", w)  // "<nil>"

	w = os.Stdout
	fmt.Printf("%T\n", w)  // "*os.File"

	w = new(bytes.Buffer)
	fmt.Printf("%T\n", w)  // "*bytes.Buffer"

fmt使用反射来获取接口动态类型的名字,将在第十二章中学习。

警告:包含一个nil 指针的接口是非nil的接口

一个nil interface的值,是其中什么也不包含,它不等同于包含一个空指针的接口。
查看下面的例子:

const debug = true
func main() {
	var buf *bytes.Buffer
	if debug {
		buf = new(bytes.Buffer) // enable collection of output
	}
	f(buf) // NOTE: subtly incorrect!
	if debug {
		// ...use buf...
	}
}

// If out is non-nil, output will be written to it.
func f(out io.Writer) {
	// ...do something...
	if out != nil {
		out.Write([]byte("done!\n"))
	}
}

我们希望在dubug为false时,f()函数不执行任务操作。但是结果发现f()函数仍然走到lout.Write([]byte(“done!\n”))且报错,这是因为传给f()函数的是一个bytes.Buffer的nil值,因此out这个接口参数的动态类型是bytes.Buffer,动态值为nil,因此它满足out != nil的条件,但out.Write([]byte("done!\n"))调用的是*bytes.Buffer的Write方法,当为nil时会报错。
在这里插入图片描述

修改方法: 将var buf *bytes.Buffer声明改为var buf *io.Writer.

7.6 sort.Interface接口

就像字符串格式一样,排序功能也很常用。
sort包提供了任意序列对象的排序功能。它的设时是不寻常的。在许多语言中,排序程序和数据类型光想,排序函数与元素类型相关。与之相对应的,go的sort。Sort函数对元素类型不做要求。相反,它使用接收,sort.Interface,来指定在通用排序程序和每个排序类型的约定关系。这个接口的应用取决于序列的具体表达,通常是slice,和元素的要排序顺序。

已给原地排序程序需要三件事:序列的长度,两个元素之间的比较关系,和一个交换两个元素位置的方式。因此他们就是sort.Interface的三个方法:

package sort

type Interface interface {
	Len() int
	Less(i, j int) bool // i, j are indices of sequence elements
	Swap(i ,j int)
}

为了排序任意序列,我们需要定义一个实现这三个方法的类型,然后在它的实例桑调用sort.Sort。下面是一个最简单的string slice 类型和它实现的方法:

type StringSlice []string

func (p StringSlice) Len() int              { return len(p) }
func (p StringSlice) Less(i, j int) bool    { return p[i] < p[j]}
func (p StringSlice) Swap(i, j int)          { p[i], p[j] = p[j], p[i]}

然后通过显式类型转换将一个slice of strings类型的names变量转换为StringSlice类型进行排序:
sort.Sort(StringSlice(names))
转换后返回一个有着相同长度、容量和底层数组的slice,并且有三个函数。
由于string slice排序很常用,sort包有提供了StringSlice类和Strings函数,因此可以使用sort.Strings(names)

7.8 error接口

error其实是一个接口类型,有一个返回错误信息的函数:

type error interface {
	Error() string
}

创建一个error最简单的方法就是调用errors.New函数,它会根据传入的错误信息返回一个新的error。整个errors包仅只有4行:

package error

func New(text string) error { return &errorString{text} } 

type errorString struct {text string} 

func (e *errorString) Error() string { return e.text }

承载errorString的类型是一个结构体而非一个字符串,这是为了保护它表示的错误避免粗心(或有意)的更新。并且因为是指针类型*errorString满足error接口而非errorString类型,所以每个New函数的调用都分配了一个独特的和其他错误不相同的实例。我们也不想要重要的error例如io.EOF和一个刚好有相同错误消息的error比较后相等。

fmt.Println(errors.New("EOF") == errors.New("EOF")) // "false"

调用errors.New函数是非常稀少的,因为有一个方便的封装函数fmt.Errorf,它还会处理字符串格式化。我们曾多次在第5章中用到它。

package fmt

import "errors"

func Errorf(format string, args ...interface{}) error {
    return errors.New(Sprintf(format, args...))
}

虽然*errorString可能是最简单的错误类型,但远非只有它一个。例如,syscall包提供了Go语言底层系统调用API。在多个平台上,它定义一个实现error接口的数字类型Errno,并且在Unix平台上,Errno的Error方法会从一个字符串表中查找错误消息,如下面展示的这样:

package syscall

type Errno uintptr // operating system error code

var errors = [...]string{
    1:   "operation not permitted",   // EPERM
    2:   "no such file or directory", // ENOENT
    3:   "no such process",           // ESRCH
    // ...
}

func (e Errno) Error() string {
    if 0 <= int(e) && int(e) < len(errors) {
        return errors[e]
    }
    return fmt.Sprintf("errno %d", e)
}

下面的语句创建了一个持有Errno值为2的接口值,表示POSIX ENOENT状况:

var err error = syscall.Errno(2)
fmt.Println(err.Error()) // "no such file or directory"
fmt.Println(err)         // "no such file or directory"

err的值图形化的呈现在图7.6中。
在这里插入图片描述
Errno是一个系统调用错误的高效表示方式,它通过一个有限的集合进行描述,并且它满足标准的错误接口。我们会在第7.11节了解到其它满足这个接口的类型。

7.10 类型断言Type Assertions

类型断言是应用在接口值上的一个操作。它的形式看起来像是这样: x.(T),x是一个接口值的表达式,T是一个类型。类型断言检查一个接口值的动态类型是否符合要断言的类型。

有两种可能:(1): T是一个具体类型,断言检查接口的动态类型是否和T完全一致(identical to T)。如果成功,结果返回x的动态值,且类型为T。换句话说,转化为具体类型的类型断言就是从它的操作中取出具体的值。如果检查失败,抛出panic。

var w io.Writer
w = os.Stdout
f := w.(*os.File) // sucee
c := w.(*bytes.Buffer) // panic

(2): T是一个接口类型,类型断言检查x的动态类型是否满足T(satisfies T)。如果成功,动态值不会被提出出来,结果仍然是一个接口值,只是类型变为T。换句话说,转化为接口类型的类型断言改变了表达式的类型,产生了一个不同的方法集合(通常更大),但它保留动态类型和动态值仍在接口值中。

var w io.Writer
w = os.Stdout
rw := w.(io.Reader) // success, *os.File has both Read and Write

w = new(ByteCounter)
rw = w.(io.Reader) // panic

不管断言成那种类型,只要操作数是nil interface, 断言就会失败。转换为一个更少方法的断言通常是很少见的,因为它的行为看起来就像赋值(除了nil情况外)

w = rw                     // io.ReadWriter is assignable to io.Writer
w = rw.(io.Writer)    // fails only if rw = nil    只有为nil是断言会失败

如果一个接口值的动态类型,想要测试它是否是某种类型。可使用两个返回值的接口断言,将不会有panic,用bool类型的值判断是否检查成功:

var w io.Writer = os.Stdout
f, ok := w.(*os.File)   // succ: ok, f == os.Stdout
b, ok := w.(*bytes.Buffer) // fail: !ok, b = nil

失败时ok为false,第一个参数为要断言类型的零值。
通常将ok断言放在if语句中:

if f, ok := w.(*os.File); ok {
	// ...use f...
}

当要断言的操作数是变量时,通常返回结果中不用新定义变量名,而重用原变量名:

if w, ok := w.(*os.File); ok {
	// ... use w ...
}

7.11类型断言的错误分类

考虑os包中文件操作的错误。IO操作可以很多原因的错误,担忧三种错误通常需要特殊解决:: file already exists (for create operations), , file not found (for read operations), 和per mission denied. os包提供三个函数来判断是那种错误:

package os
func IsExist(err error) bool
func IsNotExist(err error) bool
func IsPermission(err error) bool

一个简单的方法是在函数内部利用err.Error()返回的字符串判断,但不同返回的文字不同,这种方法是不健壮的。

一个更可靠的方法是使用一个专用的类型来表示这种结构化错误。os包定义了一个PathError类型用来描述失败,包括在一个路径上的操作如Open和Delete,还有一个LinkError来描述包含两个路径的操作失败如Symlink和Rename。

package os
type PathError struct {
	Op string
	Path string
	Err error
}
func (e *PathError) Error() string {
	return e.Op + " " + e.Path + ": " + e.Err.Error()
}

大多数的客户端并不知道PathError并且通过调用Error()这个统一的方法来处理错误。尽管PathError的Error()方法只是将它的字段做简单字符串拼接,这个结构体保护了内部的错误组件。哪些需要区分错误类型的人可以使用类型断言来检测具体类型的错误,具体类型的错误可以提供比字符串更多的细节。

_, err := os.Open("/no/such/file")
fmt.Println(err)  // "open /no/such/file: No such file or directory"
fmt.Printf("%#v\n", err) //  &os.PathError{Op:"open", Path:"/no/such/file", Err:0x2}

这就是上面三个函数的工作方式。例如,对IsNotExist,

var ErrNotExist = errors.New("file does not exist")

func IsNotExist(err error) bool {
	if pe, ok := err.(*PathError); ok {   // 这里使用断言,看能否转化为PathError类型
		err = pe.Err
	}
	return err = syscall.ENOENT || err == ErrNotExist
}

真实使用场景如下:

_, err := os.Open("/no/such/file")
fmt.Println(os.IsNotExist(err)) // "true

当然,如果一个错误信息被组合成一个更大的字符串,则PathError的结构则不可知,例如对fmt.Errof的调用。区别错误通常必须在失败操作后,错误传回调用者前进行。(这句话什么含义呢?)

这一小结主要说了什么?
对一些错误类型的判断,如果函数内部只是用该错误的Error()方法的字符串判断,则是不准确、不健壮的。
修改后的函数,内部先对error类型做断言,如果符合PathError类型则取内部的err字段,接着不用字符串,直接与syscall.ENOENT和自定义的ErrNotExist做相等判断。

7.12 通过类型断言查询行为

下面这个函数在进行Write时需要将string转为[]byte, 这需要额外的内存分配。但其实w的动态类型又要给WriteString方法,允许string更加有效率的写入,避免了临时的内存分配。(许多符合io.Writer接口的类型都有一个WriteString方法,例如*bytes.Buffer, *os.File,*bufio.Writer)。

func writeHeader(w io.Writer, contentType string) error {
	if _, err := w.Write([]byte("Content-Type: ")); err != nil {
		return err
	}
	if _, err := w.Write([]byte(contentType)); err != nil {
		return err
	}
		// ...
}

不能假设所有io.Writer接口都有一个WriteString方法,我们可以定义一个新接口,只包含WriteString方法,然后判断w的动态类型是否满足此新接口。


func writeString(w io.Writer, s string) (n int, err error) {
	type stringWriter interface {
		WriteString(string) (n int, err error)
	}
	if sw, ok := w.(stringWriter); ok {
		return sw.WriteString(s) // avoid a copy
	}
	return w.Write([]byte(s))    // allocate temp copy
}

func writeHeader(w io.Writer, contentType string) error {
	if _, err := writeString(w, "Content-Type: "); err != nil {
		return err
	}
	if _, err := writeString(w, contentType); err != nil{
		return err
	}
	// ...
}

标准库提供了io.WriteString方法,用来提供向一个io.Write写入string。

例子中奇怪的是没有一个标准的接口用来定义WriteString方法和说明它要求的行为。并且,判断一个具体类型是否满足stringWriter接口只取决于它自身的方法,不依赖任何关于它自身和此接口关系的声明。这意味着上面的实现依赖于这样一个假设,如果一个类型满足下面这个接口,则WriteString(s)必须与Write([]bytes(s))有相同的效果。
(ps: 这段话什么意思?)

interface {
	io.Writer
	WriteString(s string) (n int, err error)
}

尽管io.WriteString实施了这个假设,但是调用它的函数极少可能会去实施类似的假设。定义一个特定类型的方法隐式地获取了对特定行为的协约。对于Go语言的新手,特别是那些来自有强类型语言使用背景的新手,可能会发现它缺乏显式的意图令人感到混乱,但是在实战的过程中这几乎不是一个问题。除了空接口interface{},接口类型很少意外巧合地被实现。

上面writeString方法使用了类型断言来判断一个通用的接口类型是否满足一个更具体的接口类型。如果满足,则使用后者的接口方法。这个技术既可以用于标准的接口如io.ReadWriter也可以用于用户自定义的接口如stringWriter。

这也是fmt.Printf用于区分值是满足error或者fmt.Stringer。在fmt.Fprintf内部,有一个步骤转化一个单操作数为string,如下:

package fmt

func formatOneValue(x interface{}) string {
	if err, ok := x.(error); ok {
		return err.Error()	
	}
	if str, ok := x.(Stringer); ok {
		return str.String()
	}
}

如果x满足两者中的一个,具体满足的接口取决于对值的格式化方式。否则,默认的case或多或少会统一的使用反射来处理所有的其他类型。

再次强调,它假设任意一个实现String()方法的类型都实现了fmt.Stringer的行为约束,它将返回一个适合打印的字符串格式。

7.13 类型选择

接口被以两种方式使用。一种,形如io.Reader, io.Writer, fmt.Stringer, sort.INterface, http.Handler,和erro,一个接口的方法表现出满足此接口的具体类型的相似性,但是隐藏了表现细节和这些具体类型的内在操作。这种方式重点是方法,而不是具体的类型。
另一种利用接口的能力去保存各种各样的具体类型的值,把接口看作是这些类型的集合uninon。类型断言就是动态的辨别各种类型,然后加以区别对待。在这种方式中,重点是具体的类型,而不是方法,这将不会隐藏任何信息。我们将介绍这种称为discriminated unions方式的接口。

如果你熟悉面向对象编程,你会将这两种方式称为子类型多态subtype polymorphism和非参数多态ad hoc polymorphism,但你不需要记住这些名字。

go中sql查询如下所示,

import "database/sql"
func listTracks(db sql.DB, artist string, minYear, maxYear int) {
	result, err := db.Exec(
		"SELECT * FROM tracks WHERE artist = ? AND ? <= year AND year <= ?",
		artist, minYear, maxYear)
	// ...
}

Exec中的?对应各种类型的参数,因此其中这个一个函数:

func sqlQuote(x interface{}) string {
	if x == nil {
		return "NULL"
	} else if _, ok := x.(int); ok {
		return fmt.Sprintf("%d", x)
	} else if _, ok := x.(uint); ok {
		return fmt.Sprintf("%d", x)
	} else if b, ok := x.(bool); ok {
		if b {
			return "TRUE"
		}
		return "FALSE"
	} else if s, ok := x.(string); ok {
		return sqlQuoteString(s) // (not shown)
	} else {
		panic(fmt.Sprintf("unexpected type %T: %v", x, x))
	}
}

就像普通的switch可以代替if-else一样,这里类型断言也可以使用swith:

switch x.(type) {
case nil: // ...
case int, uint: // ...
case bool: // ...
case string: // ...
default: // ...
}

这里x.(type)中type是关键字,case语句中有一个或多个type类型。
case语句的顺序很重要,因为如果有多个case语句是interface类型则有可能匹配多个。default相对其他case的位置是无所谓的。它不会允许落空No fallthrough发生。

注意到在原来的函数中,对于bool和string情况的逻辑需要通过类型断言访问提取的值。因为这个做法很典型,类型分支语句有一个扩展的形式,它可以将提取的值绑定到一个在每个case范围内都有效的新变量。

switch x := x.(type)

这里我们把新变量也称为x,在类型断言中,重用变量名是很常见的。就像switch语句,type switch语句也隐式的创建一个词法块,因此新变量x的声明不会和外部的变量x发生冲突。每个case语句也隐式的创建一个词法块。

func sqlQuote(x interface{}) string {
	switch x := x.(type) {
	case nil:
		return "NULL"
	case int:  // 这里好像不能写两个类型 x has type interface{} here
		return fmt.Sprintf("%d", x)
	case bool:
		if x {
			return "TRUE"
		}
		return "FALSE"
	case string:
		return sqlQuoteString(s)
	default:
		panic(fmt.Sprintf("unexpected type %T: %v", x, x))
	}
}

在这个版本中,在单类型的case语句中,变量x和case语句的类型相同。例如boolcase中x的类型为bool,string case中x类型为string。在其他case中,x是interface类型。多个case的处理相同时,可以合为一处。
尽管这个函数可以接收任意参数,但我们之只能处理case中的类型,否则就报错。这里尽管x是interface{}类型,但我们把他当作各种类型的集合。
ps: 实际代码中好像不能一个case写两种类型

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值