Go 语言入门指南 (3)| 青训营

183 阅读14分钟

接着上一篇的知识点继续。。

2.8 函数

为完成某一功能的程序指令(语句)的集合,称为函数。

2.8.1 函数分类

在Go语言中,函数是第一类对象,我们可以将函数保持到变量中。函数主要有具名匿名之分,包级函数一般都是具名函数,具名函数是匿名函数的一种特例,当匿名函数引用了外部作用域中的变量时就成了闭包函数,闭包函数是函数式编程语言的核心。

  1. 具名函数:就和c语言中的普通函数意义相同,具有函数名、返回值以及函数参数的函数。
func Add(a, b int) int {
    return a+b 
}
  1. 匿名函数:指不需要定义函数名的一种函数实现方式,它由一个不带函数名的函数声明和函数体组成。
var Add = func(a, b int) int {
    return a+b 
}
  • 闭包函数:返回为函数对象,不仅仅是一个函数对象,在该函数外还包裹了一层作用域,这使得,该函数无论在何处调用,优先使用自己外层包裹的作用域。
  • 一级对象:支持闭包的多数语言都将函数作为第一级对象,就是说函数可以存储到变量中作为参数传递给其他函数,最重要的是能够被函数动态创建和返回。
  • 包:go的每一个文件都是属于一个包的,也就是说go是以包的形式来管理文件和项目目录结构的。

2.8.2 函数的声明和定义

Go 语言函数定义格式如下:

func fuction_name([parameter list])[return types]{ 
    函数体 
}

函数命名规范:
Go语言约定,函数名首字母大写,表示该函数是可以被其他模块的函数调用;函数名首字母小写,表示该函数只在模块内使用。不知道同学们有没有观察到,当我们向控制台打印输出时,使用的是fmt.PrintLn(),可以看到大写的P,同时需要使用import "fmt",正好说明了该函数的使用需要导入外部模块。

2.8.3 函数传参

Go语言中的函数可以有多个参数和多个返回值,参数和返回值都是以传值的方式和被调用者交换数据。在语法上,函数还支持可变数量的参数,可变数量的参数必须是最后出现的参数,可变数量的参数其实是一个切片类型的参数。

当可变参数是一个空接口类型时,调用者是否解包可变参数会导致不同的结果,我们解释一下解包的含义,代码如下:

func main(){
    var a = []int{1, 2, 3} 
    Print(a...) // 解包 
    Print(a) // 未解包 
} 
func Print(a ...int{}) { 
    fmt.Println(a...) 
}

以上当传入参数为a...时即是对切片a进行了解包,此时其实相当于直接调用Print(1,2,3)。当传入参数直接为 a时等价于直接调用Print([]int{}{1,2,3})

2.8.4 函数返回值

不仅函数的参数可以有名字,也可以给函数的返回值命名。
举例代码如下:

func Find(m map[int]int, key int)(value int, ok bool) {
	value,ok = m[key]
	return
}

如果返回值命名了,可以通过名字来修改返回值,也可以通过defer语句在return语句之后修改返回值,举例代码如下:

func mian() {
	for i := 0 ; i<3; i++ {
		defer func() { println(i) }
	}
}

// 该函数最终的输出为:
// 3
// 3
// 3

以上代码中如果没有defer其实返回值就是0,1,2,但defer语句会在函数return之后才会执行,也就是或只有以上函数在执行结束return之后才会执行defer语句,而该函数return时的i值将会达到3,所以最终的defer语句执行printlin的输出都是3。

defer语句延迟执行的其实是一个匿名函数,因为这个匿名函数捕获了外部函数的局部变量v,这种函数我们一般叫闭包。闭包对捕获的外部变量并不是传值方式访问,而是以引用的方式访问。

这种方式往往会带来一些问题,修复方法为在每一轮迭代中都为defer函数提供一个独有的变量,修改代码如下:

func main() {
    for i := 0; i < 3; i++ {
        i := i // 定义一个循环体内局部变量i
        defer func(){ println(i) } ()
    }
}

func main() {
    for i := 0; i < 3; i++ {
        // 通过函数传入i
        // defer 语句会马上对调用参数求值
        // 不再捕获,而是直接传值
        defer func(i int){ println(i) } (i)
    }
}

2.8.5 函数递归调用

Go语言中,函数还可以直接或间接地调用自己,也就是支持递归调用。Go语言函数的递归调用深度逻辑上没有限制,函数调用的栈是不会出现溢出错误的,因为Go语言运行时会根据需要动态地调整函数栈的大小。这部分的知识将会涉及goroutint和动态栈的相关知识。

它的语法和c很相似,格式如下:

func recursion() {
   recursion() /* 函数调用自身 */
}
func main() {
   recursion()
}

2.8.6 常用函数

常用函数:错误处理、字符串操作、JSON处理、获取时间、数字解析等

2.8.6.1 错误处理

在Go中,当我们在编写执行一些特定功能的函数时,出现异常是很正常的事情,如果函数无法正确执行下去,那么应该及时地向调用它的老大报告这个错误,而老大也应该想法设法地捕获这些异常,而Go语言则巧妙地利用了函数可以返回多个值来实现这一个机制。

我们编写一个打招呼的函数来进一步说明。该函数接受一个姓名,返回定制的问候语。

func greeting(name string) string {
    GreetToYou := fmt.Sprintf("你好,%v", name)
    return GreetToYou
}

很容易想到,该函数希望接受一个姓名作为参数,如果name为空字符串""时,应该抛出一个错误,只需要添加一个类型为Error的返回值就可以。

go
复制代码
func greeting(name string) (string, error) {
    if name == "" {
        return name, errors.New("姓名为空")
    }

    GreetToYou := fmt.Sprintf("你好,%v", name)
    return GreetToYou, nil
}

errors.New()需要导入errors包。

  • 添加了一个新的返回值类型error,用于返回函数的执行状态
  • name函数参数进行判断,若输入无效,函数对应的返回值类型stringerror分别返回""空字符串和errors.New(),其中包含自定义的错误信息。
  • 若函数正常执行,stringerror分别返回对应的结果字符串和nil类型。在Go语言中,nil表示指针、通道、切片、接口等类型的零值,在错误处理中,nil就表示程序没有错误。

当程序调用该函数时,可以使用以下的方式接受异常。

name := "谦谦"
slogan, err := greeting(name)

// 若函数抛出异常
if err != nil { 
    // 打印日志,发现错误信息
    log.Fatal(err)
}

// 否则正常执行

log.Fatal()需要导入log包。

2.8.6.2 字符串操作

  str := "Go is a powerful and efficient programming language"
	fmt.Println(strings.Contains(str, "Go"))                 // true
	fmt.Println(strings.Count(str, "g"))                     // 4
	fmt.Println(strings.HasPrefix(str, "Go"))                // true
	fmt.Println(strings.HasSuffix(str, "language"))          // true
	fmt.Println(strings.Index(str, "powerful"))              // 8
	fmt.Println(strings.Join([]string{"Go", "is", "language"}, " ")) // Go is language
	fmt.Println(strings.Repeat(str, 2))                      // Go is a powerful and efficient programming languageGo is a powerful and efficient programming language
	fmt.Println(strings.Replace(str, "language", "GoLang", 1)) // Go is a powerful and efficient programming GoLanguage
	fmt.Println(strings.Split("apple,orange,banana", ","))    // [apple orange banana]
	fmt.Println(strings.ToLower(str))                        // go is a powerful and efficient programming language
	fmt.Println(strings.ToUpper(str))                        // GO IS A POWERFUL AND EFFICIENT PROGRAMMING LANGUAGE
	fmt.Println(len(str))                                    // 51

	strWithSpaces := "   trim spaces    "
	fmt.Println(strings.TrimSpace(strWithSpaces)) // "trim spaces"

上述函数需要导入strings包。

2.8.6.3 JSON处理

序列化

序列化是指将数据结构对象转换为线性数据流(通常是字节流),以便在存储或传输时能够保持其状态和结构。这样的数据流可以保存到文件中,通过网络传输,或者存储到数据库中。

在序列化过程中,数据结构中的字段和值被编码成一个特定格式,通常是JSON、XML等。这样,数据就能够被传输或保存,并在后续需要时可以重新还原为原始的数据结构。

在Go语言中,使用json.Marshal函数可以将一个Go数据结构序列化为JSON格式的字节流。

反序列化

反序列化是序列化的逆过程,它将之前序列化的数据流(通常是字节流)重新转换回原始的数据结构或对象。在反序列化过程中,解码器会根据预定的格式和规则解析数据流,并恢复原始数据结构的字段和值。

在Go语言中,使用json.Unmarshal函数可以将JSON格式的字节流反序列化为相应的Go数据结构。

程序中的数据结构和对象,要想保存到本地磁盘或者在网络上传输,就必须先序列化。当需要读取这些信息时,再进行反序列化即可。

数值和字符串在内存中已经是一段连续的字节序列,不需要额外的序列化操作。它们是原始数据类型,直接存储在内存中的连续地址上。因此,直接将它们用于传输或存储时,就是在传输或存储它们的原始字节序列。

例如,一个整数类型的变量在内存中存储为几个字节的二进制表示,而一个字符串在内存中存储为一系列字符的连续字节。

这也从侧面印证了,对于复杂的数据结构和对象,尤其是涉及到指针、切片、映射等数据结构时,它们在内存中的存储可能是非连续的。因为这些数据结构中的元素可以动态地分配内存,它们在内存中可能以散布的方式存储,并使用指针链接起来。

所以,在对复杂的数据结构和对象进行传输时,我们需要将它们转换为一段连续的字节序列,以便能够方便地进行传输和存储。

type Person struct {
	Name   string `json:"name"`
	Age    int    `json:"age"`
	Gender string `json:"gender"`
}

func main() {
	// 序列化:将数据结构转换为JSON格式的字节流
	person := Person{Name: "谦谦", Age: 40, Gender: "男"}
	jsonData, err := json.Marshal(person)
	if err != nil {
		fmt.Println("Serialization error:", err)
		return
	}
	fmt.Println("Serialized data:", string(jsonData))

	// 反序列化:将JSON格式的字节流转换为数据结构
	var newPerson Person
	err = json.Unmarshal(jsonData, &newPerson)
	if err != nil {
		fmt.Println("Deserialization error:", err)
		return
	}
	fmt.Println("Deserialized data:", newPerson)
}

需要导入encoding/json

函数的输出。


Serialized data: {"name":"谦谦","age":40,"gender":"男"}
Deserialized data: {谦谦 40 男}

使用函数时,注意:

  • json.Marshal函数返回json格式的字节流和序列化结果(成功/失败)。
  • json.Unmarshal函数接受两个参数,第一个参数jsonData是序列化之后的字节流,第二个参数是一个对特定对象的引用。

2.9切片

切片是Go语言特有的类型,可以将其类比为动态数组,可以在声明时不指定大小。

2.9.1 声明并初始化切片

切片的结构定义,reflect.SliceHeader:

type SliceHeader struct {
    Data uintptr   // 指向底层的的数组指针
    Len  int	   // 切片长度
    Cap  int	   // 切片最大长度
}

和数组一样,内置的len函数返回切片中有效元素的长度,内置的cap函数返回切片容量大小,容量必须大于或等于切片的长度。

切片可以和nil进行比较,只有当切片底层数据指针为空时切片本身为nil,这时候切片的长度和容量信息将是无效的。如果有切片的底层数据指针为空,但是长度和容量不为0的情况,那么说明切片本身已经被损坏了

只要是切片的底层数据指针、长度和容量没有发生变化的话,对切片的遍历、元素的读取和修改都和数组是一样的。在对切片本身赋值或参数传递时,和数组指针的操作方式类似,只是复制切片头信息(reflect.SliceHeader),并不会复制底层的数据。对于类型,和数组的最大不同是,切片的类型和长度信息无关,只要是相同类型元素构成的切片均对应相同的切片类型。

当我们想定义声明一个切片时可以如下:

在对切片本身赋值或参数传递时,和数组指针的操作方式类似,只是复制切片头信息·(reflect.SliceHeader),并不会复制底层的数据。对于类型,和数组的最大不同是,切片的类型和长度信息无关,只要是相同类型元素构成的切片均对应相同的切片类型。

2.9.2 向切片中添加元素

append() :内置的泛型函数,可以向切片中增加元素。

  1. 在切片尾部追加N个元素
var a []int
a = append(a, 1)               // 追加1个元素
a = append(a, 1, 2, 3)         // 追加多个元素, 手写解包方式
a = append(a, []int{1,2,3}...) // 追加一个切片, 切片需要解包

注意:尾部添加在容量不足的条件下需要重新分配内存,可能导致巨大的内存分配和复制数据代价。即使容量足够,依然需要用append函数的返回值来更新切片本身,因为新切片的长度已经发生了变化。

  1. 在切片开头位置添加元素
var a = []int{1,2,3}
a = append([]int{0}, a...)        // 在开头位置添加1个元素
a = append([]int{-3,-2,-1}, a...) // 在开头添加1个切片

注意:在开头一般都会导致内存的重新分配,而且会导致已有的元素全部复制1次。因此,从切片的开头添加元素的性能一般要比从尾部追加元素的性能差很多。

  1. append链式操作
var a []int
a = append(a[:i], append([]int{x}, a[i:]...)...)     // 在第i个位置插入x
a = append(a[:i], append([]int{1,2,3}, a[i:]...)...) // 在第i个位置插入切片

每个添加操作中的第二个append调用都会创建一个临时切片,并将a[i:]的内容复制到新创建的切片中,然后将临时创建的切片再追加到a[:i]。

  1. append和copy组合
a = append(a, 0)     // 切片扩展1个空间
copy(a[i+1:], a[i:]) // a[i:]向后移动1个位置
a[i] = x             // 设置新添加的元素

第三个操作中会创建一个临时对象,我们可以借用copy函数避免这个操作,这种方式操作语句虽然冗长了一点,但是相比前面的方法,可以减少中间创建的临时切片。

2.9.3 获取切片大小

len(greetings)

2.9.4 产生切片的拷贝

another := make([]string, len(greetings))
copy(another, greetings)

2.9.5 选取切片中的部分

Go语言使用左闭右开区间选取对应下标范围内的的部分元素。

// slice[n:m]
// 开始下标:n
// 结束下标:m - 1
// 元素个数:m - n
greetings[1:]
greetings[:2]
greetings[1:3]

2.10接口

2.10.1 什么是接口

Go 语言提供了另外一种数据类型即接口,它把所有的具有共性的方法定义在一起,任何其他类型只要实现了这些方法就是实现了这个接口。

Go的接口类型是对其它类型行为的抽象和概括;因为接口类型不会和特定的实现细节绑定在一起,通过这种抽象的方式我们可以让对象更加灵活和更具有适应能力。很多面向对象的语言都有相似的接口概念,但Go语言中接口类型的独特之处在于它是满足隐式实现的鸭子类型。

所谓鸭子类型说的是:只要走起路来像鸭子、叫起来也像鸭子,那么就可以把它当作鸭子。Go语言中的面向对象就是如此,如果一个对象只要看起来像是某种接口类型的实现,那么它就可以作为该接口类型使用。

就比如说在c语言中,使用printf在终端输出的时候只能输出有限类型的几个变量,而在go中可以使用fmt.Printf,实际上是fmt.Fprintf向任意自定义的输出流对象打印,甚至可以打印到网络甚至是压缩文件,同时打印的数据不限于语言内置的基础类型,任意隐士满足fmt.Stringer接口的对象都可以打印,不满足fmt.Stringer接口的依然可以通过反射的技术打印。

2.10.2 结构体类型

interface实际上就是一个结构体,包含两个成员。其中一个成员是指向具体数据的指针,另一个成员中包含了类型信息。空接口和带方法的接口略有不同,下面分别是空接口的数据结构:

struct Eface
{
    Type*    type;
    void*    data;
};

其中的Type指的是:

struct Type
{
    uintptr size;
    uint32 hash;
    uint8 _unused;
    uint8 align;
    uint8 fieldAlign;
    uint8 kind;
    Alg *alg;
    void *gc;
    String *string;
    UncommonType *x;
    Type *ptrto;
};
1

和带方法的接口使用的数据结构:

struct Iface
{
    Itab*    tab;
    void*    data;
};

其中的Iface指的是:


struct    Itab
{
    InterfaceType*    inter;
    Type*    type;
    Itab*    link;
    int32    bad;
    int32    unused;
    void    (*fun[])(void);   // 方法表
};

2.10.3 具体类型向接口类型赋值

将一个具体类型数据赋值给interface这样的抽象类型,需要进行类型转换。这个转换过程中涉及哪些操作呢?

如果转换为空接口,返回一个Eface,将Eface中的data指针指向原型数据,type指针会指向数据的Type结构体。

如果将其转化为带方法的interface,需要进行一次检测,该类型必须实现interface中声明的所有方法才可以进行转换,这个检测将会在编译过程中进行。检测过程具体实现式通过比较具体类型的方法表和接口类型的方法表来进行的。

  • 具体类型方法表:Type的UncommonType中有一个方法表,某个具体类型实现的所有方法都会被收集到这张表中。
  • 接口类型方法表:Iface的Itab的InterfaceType中也有一张方法表,这张方法表中是接口所声明的方法。Iface中的Itab的func域也是一张方法表,这张表中的每一项就是一个函数指针,也就是只有实现没有声明。

这两处方法表都是排序过的,只需要一遍顺序扫描进行比较,应该可以知道Type中否实现了接口中声明的所有方法。最后还会将Type方法表中的函数指针,拷贝到Itab的fun字段中。Iface中的Itab的func域也是一张方法表,这张表中的每一项就是一个函数指针,也就是只有实现没有声明。

2.10.4 获取接口类型数据的具体类型信息

接口类型转换为具体类型(也就是反射,reflect),也涉及到了类型转换。reflect包中的TypeOf和ValueOf函数来得到接口变量的Type和Value。

2.11 map

Go语言中的map类型,类似其他语言中的字典或哈希表。

2.11.1 声明并初始化map

使用make函数声明并初始化map

slogans := make(map[string]string)

2.11.2 添加键值对

slogans["飞书"] = "先进团队,先用飞书"
slogans["抖音"] = "记录美好生活"
slogans["B站"] = "哔哩哔哩,干杯"

2.11.3 删除键值对

delete(slogans, "B站")