Go语言--函数(function)

 0 函数简述

函数是组织好的、可重复使用的、用来实现单一或相关联功能的代码段,其可以提高应用的模块性和代码的重复利用率。

Go语言支持普通函数、匿名函数和闭包,从设计上对函数进行了优化和改进,让函数使用起来更加方便。

Go语言的函数属于“一等公民”(first class),表现在:

  • 函数是一种数据类型,函数类型变量可以像其他类型变量一样使用,可以作为其他函数的参数或返回值,也可以直接调用执行。
  • 支持多返回值。
  • 支持匿名函数和闭包。
  • 函数支持可变参数。
  • 支持命名返回值。
  • 无须前置声明函数。

Go语言函数也有一些不支持其他编程语言的函数特性的地方:

  • 不支持命名函数的嵌套定义(nested),但支持嵌套定义匿名函数。
  • 不支持同名函数重载(overload)。
  • 不支持默认值参数。

1 函数声明

函数是Go程序源代码的基本构造单位,一个函数的声明包括:函数声明关键字func,函数名、参数列表、返回值列表和函数体。函数名遵循标志符的命名规则,首字母的大小写决定该函数在其他包的可见性:大写时,其他包可见;小写时,只能在函数所在的包内被访问,其他包不可见。函数的参数和返回值需要使用小括号()包裹,如果只有一个返回值,而且使用的是非命名的函数返回值参数,则返回值参数的小括号()可以省略。函数体用花括号{}包裹,并且左花括号“{” 必须位于函数返回值同行的行尾。

func funcName(param-list) (result-list) {
    function-body
}
  •  func:定义函数的关键字。
  • funcName:函数名,函数名命名规则和标志符的命名规则一致。
  • param-list:参数列表,一个参数有参数变量和参数类型组成,例如:
func foo(a int, b string)

其中,参数列表中的变量作为函数的局部变量而存在。

  • result-list:返回值参数列表,可以是返回值类型列表,也可以是类似参数列表那样,是变量名+类型的组合。函数在声明由返回值时,必须在函数体中使用return语句提供返回值列表。
  • function-body:函数体,被重复调用执行的代码片段。

<提示> 包(package)是Go源码的一种组织方式,一个包可以认为是一个文件夹/目录。

1.1 函数的特点

1、函数可以没有输入参数、也可以没有返回值(默认返回0)。例如:

func funA() {
    //do something
    ......
}

func funcB() int {
    //do something
    ......
    return 1
}

2、在参数列表中,如有多个参数变量,则用逗号分隔;如果多个相邻参数变量是同类型的,则可以使用简写模式。例如:

func add(a, b int) int {      //参数变量a,b都是int类型
    return a+b
}

func add2(a, b, c int) int {  //参数变量a,b,c都是int类型
    return a+b+c
}

func main(){
    fmt.Println(add(1, 2))      // 3
    fmt.Println(add2(1, 2, 3))  // 6
}

3、支持有名的返回值,参数变量就相当于函数体内最外层的局部变量,命名返回值变量会被初始化为类型零值,最后的return语句可以不带参数名直接返回。例如:

func add(a, b int) (sum int) {
    sum = a + b
    return       //return sum的简写模式
    
    //sum := a + b  //如果是sum := a + b,则相当于新声明一个sum变量名,它会屏蔽掉函数返回值参数变量sum
    //return sum    //最后需要显式地调用return sum,将这个sum的值赋值给函数返回值参数变量sum,然后执行RET指令
}

 <提示> 因为Go语言的return语句的底层实现并不是原子操作,它分为给返回值赋值和执行RET汇编指令两步。

4、Go函数不支持默认值参数。

5、Go函数不支持同名函数的重载。

6、不支持函数嵌套,严格来说是不支持命名函数的嵌套定义,但是支持嵌套定义匿名函数。例如:

func add(a, b int) (sum int) {
    anonymous := func(x, y int) int {
        return x+y
    }(a, b)
    
    return anonymous
}

《代码说明》在add()函数中,嵌套定义了一个匿名函数,并将这个匿名函数的返回值赋值给变量anonymous。

1.2 函数的返回值

Go语言支持多返回值,定义多返回值的返回值参数列表时要是小括号()包裹,并且支持命名返回值参数。Go语言经常使用对返回值的最后一个返回参数返回函数执行中可能发生的错误。例如:

// 示例1
func swap(a, b int) (int, int) {
    return b, a
}
//代码说明: swap()函数返回两个整型参数

//示例2
conn, err := connectToNetwork()
//代码说明: connectToNetwork()函数返回两个参数,conn表示连接对象,err表示返回的错误对象。

 <提示>

  • C/C++语言中只支持一个返回值结果,在需要返回多个返回值时,则需要使用结构体返回结果,或者在参数中使用指针变量,然后在函数内部修改外部传入的变量值,实现返回计算结果。C++语言为了安全性,建议在参数返回数据时使用“引用”代替指针。
  • C#语言也没有多值返回的特性。C#后期加入的ref 和 out 关键字能够通过函数的调用参数获得函数体中修改的数据。
  • Lua语言没有指针,但是支持多值返回,在大块数据使用时方便很多。
  • Go语言既支持安全指针,也支持多值返回,因此在使用函数进行逻辑编写时更为方便。

##】带有变量名的返回值(命名返回值)

Go语言支持对返回值进行命名,这样返回值就和参数一样拥有参数变量名和类型。

命名的返回值变量的默认值是类型的零值,如数值类型是0,字符串类型是空字符串,布尔类型是false、指针类型是nil等。

示例代码如下:

func namedRetValues() (a, b int) {
    a = 1
    b = 2
    
    return   //使用return隐式返回
}

《代码说明》

(1)函数声明时带有返回值变量a和b,因此可以在函数体中直接对函数返回值变量进行赋值。在命名的返回值方式的函数体中,在函数结束前需要显式地使用return语句进行返回。

(2)函数命名返回值和函数参数一样,也是函数的局部变量。

(3)当函数使用命名返回值时,可以在return中不填写返回值列表,当然填写也是可以的。下面代码的执行效果和上面代码的效果一样。

func namedRetValues() (a, b int) {
    
    return 1, 2
}

《代码说明》执行return语句的时候,分两步:(1) 1=>a, 2=>b;(2)执行RET汇编指令。

<注意> 同一种类型返回值和命名返回值两种形式只能二选一,混用时将会发生编译错误。例如下面的代码:

func namedRetValues() (a, b int, int)

//编译报错提示: mixed named and unnamed function parameters
//意思是: 在函数参数中混合使用了命名和非命名参数

<建议> 如果函数返回值类型能够明确表明其含义,就尽量不要使用命名返回值。

func NewUser() (*User, error)  //返回值含义明确,可以不使用命名返回值

(4)命名返回值会被不同层级的同名变量屏蔽掉,但是编译器能检查到此类状况,只要改为显式return返回值即可。示例代码如下:

import (
    "fmt"
	"error"
)

func div(x, y int) (z int, err error) {
	if y == 0 {
		err = errors.New("division by zero")
		return
	}
	z = x / y
	return       //相当于 "return z, err"
}

func add(x, y int) (z int) {
	z := x + y   //新定义了同名局部变量z,会屏蔽掉同名的返回值变量z
	//return     //错误: z is shadowed during return 即返回值变量z被屏蔽掉了
	return z     //正确 显式返回z 返回过程分两步: 1.z(新定义的同名变量z)=>z(返回值变量z) 2.执行RET汇编指令
}

1.3 函数调用

函数在定义后,可以通过调用的方式使用,即让当前代码跳转到被调用的函数体中执行。调用前的函数实参列表中的变量都会被保存起来,然后进行“实参列表=>形参列表”的参数传递,参数传递包括值传递/引用传递。被调用函数执行结束后,回到主调函数中继续执行下一行代码。

函数内的局部变量只能在函数体中使用,函数调用结束后,这些局部变量都会被释放掉而失效。

Go语言的函数调用格式如下:

返回值变量列表 = funcName(实参列表)
  • funcName:被调用的函数名。
  • 实参列表:实参变量以逗号隔开,尾部无须以分号结尾。
  • 返回值变量列表:多个返回值使用逗号分隔。

示例代码如下:

// 声明函数
func namedRetValues() (a, b int) {
    a = 1
    b = 2
    
    return
}

//调用函数
x, y := namedRetValues()

1.4 实参到形参的传递

Go函数实参到形参的传递本质上来说都是值传递,有时函数调用后实参指向的值发生了变化,那是因为参数传递的是指针值,实参是一个指针类型变量或者是一个引用类型的变量,传递给形参的是这个指针变量或引用变量的副本,二者指向同一地址,所以说参数传递本质上仍然是值传递。

  • 值类型:数值类型、string、bool、数组、struct。
  • 引用类型:指针、slice、map、channel、interface、函数类型。

 <说明> 值类型传递的内容是实参值的副本;引用类型传递的内容只是引用变量本身的值的副本,

<注意> 需要注意的是,Go语言的数组类型它是值传递的,这点和C/C++语言是不同的。怎么理解呢?当使用数组做函数参数时,它是将整个数组的元素的副本拷贝给函数形参的,而不是像C/C++那样,只是将数组的地址传递给形参,因此,当我们想要在Go函数中访问函数外的数组时,一般是使用数组的切片变量做函数参数。示例代码如下:

func chValue(a int) int {
    a += 1
    return a
}

func chPointer(a *int) {
    *a += 1
    return
}

func main(){
    a := 10
    
    chValue(a)
    fmt.Println("a =", a)  //a = 10
    
    b := 20
    chPointer(&b)
    fmt.Println("b =", b)  //b = 21
}

《代码说明》在chValue()函数中是值传递,从运行结果可以看出,形参值的改变并不会影响实参值。而在chPointer()函数中是址传递,因此在chPointer函数中的指针变量a指向的是main()函数的变量b,然后通过指针运算修改了变量b的值,这种通过指针访问变量的方式是一种间接访问方式。

示例:函数中参数值传递效果,传递的是结构体对象。

//定义结构体Data
type Data struct{
    complax []int        //切片
    instance InnerData   //内嵌结构体
    ptr *InnerData       //结构体指针
}

//定义结构体InnerData
type InnerData struct{
    a int
}

//值传递测试函数
func passByValue(inFunc Data) Data{
    //输出参数的成员情况
    fmt.Printf("inFunc value: %+v\n", inFunc)
    
    //打印inFunc的指针
    fmt.Printf("inFunc ptr: %p\n\n", &inFunc)
    
    return inFunc   //返回结构体对象
}

func main(){
    //声明并初始化一个结构体对象
    in := Data{
        complax: []int{1,2,3},
        instance: InnerData{a: 5},
        ptr: &InnerData{a: 1},
    }

    //输出结构的成员情况
    fmt.Printf("in value: %+v\n", in)
    //输出结构的指针地址
    fmt.Printf("in ptr: %p\n\n", &in)

    //传入结构体,返回同类型的结构体
    out := passByValue(in)
    //输出结构的成员情况
    fmt.Printf("out value: %+v\n", out)
    //输出结构的指针地址
    fmt.Printf("out ptr: %p\n", &out)
}

运行结果:go run passByValue.go

in value: {complax:[1 2 3] instance:{a:5} ptr:0xc0000b6010}
in ptr: 0xc000098150

inFunc value: {complax:[1 2 3] instance:{a:5} ptr:0xc0000b6010}
inFunc ptr: 0xc0000981e0

out value: {complax:[1 2 3] instance:{a:5} ptr:0xc0000b6010}
out ptr: 0xc0000981b0

《代码说明》

  • 格式化动词 "%+v" 表示输出结构体变量in的详细结构,包括字段名和字段值。
  • 从运行结果中发现,所有的Data结构体变量的地址是不同的,意味着所有的结构都是一块新的内存区域,无论是将Data结构通过参数传递给函数,还是通过函数返回值传回Data结构,都会发生复制行为。
  • 所有的Data结构中的成员变量值都没有发生变化,原样传递,意味着所有参数都是值传递。
  • Data结构的ptr成员在传递过程中保持一致,说明指针在函数参数值传递过程中传递的是指针本身的值,不会复制指针指向的内存区域。

1.5 可变参数函数

Go函数支持可变参数函数,即参数数量不固定的函数形式。Go语言可变参数函数格式如下:

func 函数名(固定参数列表, v ...T) (返回值参数列表) {
    函数体
}

Go函数的可变参数特点:

(1)所有的可变参数类型必须是相同的。

(2)可变参数必须是函数的最后一个参数,前面是固定参数列表,当没有固定参数时,所有参数就都是可变参数。

(3)可变参数变量名v,类型为[]T,即切片类型,v和T之间由3个点号组成。

(4)T为可变参数类型,当T为 interface{}时,传入的可以是任意类型。

(5)切片可以作为参数传递给可变参数,切片名后要加上3个点号(...)。

(6)形参为可变参数的函数和形参为切片的函数类型不相同。

示例1:fmt包中的可变参数函数举例。

// 所有参数都是可变参数函数: fmt.Println 函数声明
func Println(a ...interface) (n int, err error) {
    return Fprintln(os.Stdout, a...)
}

// 调用fmt.Println,传入的参数值类型不受限制
fmt.Println(5, "hello", true, &struct{a int}{1})

//部分参数是可变参数函数: fmt.Printf 函数声明
func Printf(format string, a ...interface{}) (n int, err error) {
    return Fprintf(os.Stdout, format, a...)
}

// 调用fmt.Printf,第一个参数必须传入字符串,对应参数format,后面的参数数量是可变的
fmt.Printf("pure string\n")
fmt.Printf("value: %v %f\m", true, math.Pi)

示例2:遍历可变参数列表,获取每一个参数的值。

package main

import (
    "bytes"     //需要导入bytes包
    "fmt"
)

// 定义一个可变参数函数,类型为字符串
func joinStrings(str ...string) string {
    //定义一个字节缓冲,快速地连接字符串
    var b bytes.Buffer
    
    //遍历可变参数列表str,类型为[]string
    for _, s := range str {
        //将遍历出的字符串连续写入字节数组
        b.WriteString(s)
    }
    
    //将连接好的字节数组转换为字符串并返回
    return b.String()
}

func main() {
    //调用joinStrings函数
    fmt.Println(joinStrings("pig ", "and ", "rat"))
    fmt.Println(joinStrings("hammer ", "mom ", "and ", "hawk"))
}

运行结果:

pig and rat
hammer mom and hawk

《代码说明》可变参数名在函数体内相当于切片,对切片的操作同样适用于可变参数变量。如果在函数内想要获取可变参数的数量,可以使用内置的len()函数对可变参数变量进行求长度操作,以获得可变参数数量值。

示例3:切片作为实参,传递给可变参数。

func sum(arr ...int) (sum int) {
    for _, v := range arr {
        sum += v
    }
    
    return
}

func main() {
    slice := []int{1, 2, 3, 4}
    array := [...]{1, 2, 3, 4}
    
    //数组不可以作为实参传递给可变参数函数
    sum(slice...)  //切片做实参传递给可变参数需要加上3个点号
}

【##】当可变参数类型为interface{} 时,意味着可以传入任意类型的值。此时,如果需要获得传入的实参类型,可以通过switch类型分支获得实际传入的参数类型。

示例4:下面的示例代码将演示将一系列不同类型的值传入可变参数函数,然后在函数内输出这些不同参数的值和实际类型的信息。

package main

import (
    "bytes"
    "fmt"
)

func printTypeValue(slist ...interface{}) string {
    //字节缓冲作为快速字符串连接
    var b bytes.Buffer

    //遍历可变参数列表
    for _, s := range slist {
        //将interface{}类型格式化为字符串类型
        str := fmt.Sprintf("%v", s)
        
        var typeString string
        
        //对s进行类型断言
        switch s.(type) {
            case bool:
                typeString = "bool"
            case string:
                typeString = "string"
            case int:
                typeString = "int"
        }
        //写入字符串前缀
        b.WriteString("value: ")
        //写入值
        b.WriteString(str)
        
        b.WriteString("\ttype: ")
        //写入类型字符串
        b.WriteString(typeString)
        
        //写入换行符
        b.WriteString("\n")
    }
    return b.String()  //返回转换后的字符串
}

func main() {
    //将不同类型的变量通过可变参数函数打印出来
    fmt.Println(printTypeValue(100, "golang", true))
}

运行结果:go run printTypeValue.go

value: 100      type: int
value: golang   type: string
value: true     type: bool

《代码说明》

  • printTypeValue()函数输入不同类型的值并输出值和类型的描述信息。
  • bytes.Buffer 字节缓冲作为快速字符串连接。
  • 通过遍历slist的每个元素,类型为interface{},本质上slist是 []interface{}切片类型。
  • 使用fmt.Sprintf 配合"%v"格式符动词,可以将interface{}格式的任意值转换为字符串值。
  • s.(type) 可以对interface{}类型进行类型断言,可以判断出变量的实际类型,然后通过switch类型分支获取类型字符串。
  • return b.String() 将字节数组转换为字符串并返回。

【##】可变参数变量本质上是一个包含所有参数的切片,如果要在多个可变参数函数中传递可变参数,可以在传递时在可变参数变量名后添加3个点号(...),将切片中的元素进行传递,而不是传递可变参数变量本身的值。

示例5:下面的示例代码模拟print()函数以及实际调用的rawPrint()函数,两个函数都拥有可变参数,需要将print()函数的可变参数传递到rawPrint()函数的可变参数中。

package main

import (
    "fmt"
)

//实际打印函数
func RawPrint(rawList ...interface{}){
    //打印rawList变量的数据类型
    fmt.Printf("data type: %T\n", rawList)
    //遍历切片rawList
    for _, a := range rawList{
        fmt.Println(a)
    }
}

//打印函数封装
func Print(slist ...interface{}){
    //将slist可变参数切片完整传递给下一个可变参数函数,需要添加"..."
    RawPrint(slist...)
    //RawPrint(slist)
}

func main(){
    Print(1,2,3,"godlike")
}

运行结果:

data type: []interface {}
1
2
3
godlike

//如果将 RawPrint(slist...) 修改为 RawPrint(slist),输出结果为:
data type: []interface {}
[1 2 3 godlike]

《代码说明》

  • RawPrint(slist...) 与 RawPrint(slist) 的区别是,前者对切片元素是逐个地访问,而后者是整体访问。
  • 可变参数使用"..."进行传递与切片使用append连接是同一个特性。

示例6:形参为可变参数的函数与形参为切片的函数类型不相同。

func sumA(arr ...int) (sum int) {
    for v := range arr {
        sum += v
    }
    return
}

func sumB(arr []int) (sum int) {
    for v := range arr {
        sum += v
    }
    return
}

func main() {
    //sumA 和 sumB 的函数类型不一样
    fmt.Printf("sumA: %T\n", sumA)
    fmt.Printf("sumB: %T\n", sumB)
}

运行结果:

sumA: func(...int) int
sumB: func([]int) int

1.6  函数类型和函数变量

函数类型又叫函数签名,一个函数的类型就是函数声明首行去掉函数名、参数名和左花括号{,可以使用fmt.Printf的格式化动词"%T"打印出函数的类型。

 示例1:打印出函数名的函数类型。

func add(a, b int) int {
    return a + b
}

func main(){
    fmt.Printf("add() type: %T\n", add)
}

运行结果:

add() type: func(int, int) int

《代码说明》可以看到,add()函数的函数类型为:func(int, int) int。

##】两个函数类型相同的条件:拥有相同的形参列表和返回值列表(列表元素的次序、个数和类型都相同),形参名可以不同。

func funcA(a, b int) int { return a + b }
func funcB(x int, y int) (c int) { c=x-y; return c }

func main(){
    fmt.Printf("funcA type: %T\n", funcA)
    fmt.Printf("funcB type: %T\n", funcB)
}

 运行结果:

funcA type: func(int, int) int
funcB type: func(int, int) int

 《代码说明》从输出结果可以看出,函数funcA() 和 funcB()的函数类型是相同的。

##】可以使用 type 关键字定义函数类型,函数类型变量可以作为函数的参数或返回值。示例2如下:

package main

import (
    "fmt"
)

func add(a, b int) int {
    return a + b
}

func sub(a, b int) int {
    return a - b
}

//定义一个函数类型,函数参数是两个int类型,返回值是一个int类型
type Op func(int, int) int

//定义一个函数,第一个参数是函数类型Op
func do(f Op, a, b int) int {
    return f(a, b)  //函数类型变量可以直接用来进行函数调用
}


func main(){
    a := do(add, 1, 2)  //函数名add可以相同函数类型形参,不需要强制类型转换
    fmt.Println(a)      // 3
    fmt.Printf("add type: %T\n\n", add)
    
    s := do(sub, 1, 2)
    fmt.Println(s)      // -1
    fmt.Printf("sub type: %T\n", sub)
}

运行结果:

3
add type: func(int, int) int

-1
sub type: func(int, int) int

《代码说明》可以看到,add()、sub()函数的函数类型和自定义的函数类型Op是相同的函数类型。因此,可以将函数名add、sub作为实参传递给相同函数类型的形参,传递的值是函数的入口地址,即指针值。

<说明1> 函数类型和 slice、map、chan 一样,也是引用类型,传递的是函数的入口地址。函数类型变量的零值是nil,调用一个空的函数类型变量将导致宕机。

var f func(int) int  //声明了一个函数类型变量,默认初始化值为nil
f(3)                 // 宕机:调用空函数

<说明2> 函数变量只能和nil 相比较,函数变量之间不能做比较,也不能作为key出现在map中。

var f func(int) int
if f != nil {
    f(3)
}

<说明3> Go中的函数是“第一公民”(first class)。有名函数的函数名可以看作函数类型的常量,函数名的值本质上是函数的入口地址。可以直接使用函数名调用函数,也可以将函数名赋值给函数类型变量,后续通过这个函数变量来调用该函数。

func sum(a, b int) int {
    return a + b
}

func main(){
    fmt.Printf("sum type: %T\n", sum)        //输出函数类型
    fmt.Printf("sum address: %p\n", sum)     //输出函数的入口地址
    fmt.Printf("sum(1,2) = %d\n", sum(1,2))  //直接使用函数名调用函数
    
    var f func(int, int) int                 //声明一个函数类型变量
    f = sum                                  //将函数名赋值给函数类型变量
    fmt.Printf("f type: %T\n", f)            //输出函数变量的类型
    fmt.Printf("f(1,2) = %d\n", f(1,2))      //使用函数变量调用函数,实际调用的是sum()函数
}

运行结果:

sum type: func(int, int) int
sum address: 0x49a620
sum(1,2) = 3
f type: func(int, int) int
f(1,2) = 3

《代码说明》从运行结果可以看出,使用函数名调用函数和使用函数类型变量调用函数的效果是一样的。

1.7 常用内置函数 

Go语言内置函数
内置函数名功能
close主要用来关闭channel
len用来求长度,比如string、array、slice、map、channel
new用来分配内存,主要用来分配值类型,比如int、struct。返回的是指针
make用来分配内存,主要用来分配引用类型,比如chan、map、slice
append用来追加元素到数组、slice中
panic和recover

用来做错误处理

 1.8 递归函数

函数递归调用,就是函数自己调用自己。递归调用必须要有一个明确的退出条件。递归适合用来处理带有递归特性的问题。

 示例1:使用递归实现n的阶乘,即n!。

阶乘公式:n! = n * (n-1)!

func Factorial(n uint64) uint64 {
    if n <= 1 {     //递归退出条件
        return 1
    }
    return n * Factorial(n-1)  //递归调用
}

func main() {
    var n int = 6
	
    //需要将int类型的n强制类型转换为uint64
    fmt.Printf("%d! = %d\n", n, Factorial(uint64(n))) // 6! = 720
}

示例2:使用递归函数实现斐波拉西数列。

斐波拉西数列:1 1 2 3 5 8 13 21 34 ...

公式:f(1)=1, f(2)=1, f(n) = f(n-1) + f(n-2) (n>=3)

func Fibonacci(n int) int {
    if n == 1 {
        return 1
    } else if n == 2 {
        return 1
    } else {
        return Fibonacci(n-1) + Fibonacci(n-2)
    }
    
}

func main() {
    for i:=1; i<=10; i++ {
        fmt.Printf("%d ", Fibonacci(i)) // 1 1 2 3 5 8 13 21 34 55
    }
	fmt.Println()
}

示例3:上台阶问题。有n个台阶,一次可以走1步,也可以走2步,有多少种走法?

问题分析:

n=1,有1种走法。

n=2,有2种走法

n=3,有3种走法。

n=4,有5种走法。

规律:当n>=3时,f(n) = f(n-1) + f(n-2)

func taijie(n int) int {
    if n == 1 {
        return 1
    } else if n == 2 {
        return 2
    } else {
        return taijie(n-1) + taijie(n-2)
    }
    
}

func main() {
    fmt.Println("taijie(5)=", taijie(5)) //taijie(5)= 8
}

参考 

《Go语言核心编程》

《Go语言从入门到进阶实战(视频教学版)》

《Go程序设计语言》

《Go语言学习笔记》

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值