青训营X豆包MarsCode 技术训练营 - Golang 语法解析 - 4 | 豆包MarsCode AI 刷题

98 阅读10分钟

本文记录训练营语法基础课部分的相关内容,对于课程中讲的不够充分的地方,结合了go入门指南中的详细介绍进行了补充。笔记同步更新在我的博客

本篇讲述了golang中结构体相关的内容。

概述

Go 通过类型别名(alias types)和结构体的形式支持用户自定义类型,或者叫定制类型。

结构体也是值类型,因此可以通过 new 函数来创建。

结构体的概念在软件工程上旧的术语叫 ADT(抽象数据类型:Abstract Data Type) , 在 C 家族的编程语言中它也存在,并且名字也是 struct,在面向对象的编程语言中,跟一个无方法的轻量级类一样。不过因为 Go 语言中没有类的概念,因此在 Go 中结构体有着更为重要的地位。

定义和创建

结构体定义的一般方式如下:

type identifier struct {
    field1 type1
    field2 type2
    ...
}

结构体里的字段都有 名字,像 field1、field2 等,如果字段在代码中从来也不会被用到,那么可以命名它为 _

结构体的字段可以是任何类型,甚至是结构体本身,也可以是函数或者接口。

type T struct {a, b int} 也是合法的语法,它更适用于简单的结构体。可以声明结构体类型的一个变量,然后像下面这样给它的字段赋值:

var s T
s.a = 5
s.b = 8

数组可以看作是一种结构体类型,不过它使用下标而不是具名的字段。

使用 new

使用 new 函数给一个新的结构体变量分配内存,它返回指向已分配内存的指针:var t *T = new(T)

var t *T
t = new(T)

变量 t 是一个指向 T的指针,此时结构体字段的值是它们所属类型的零值。

声明 var t T 也会给 t 分配内存,并零值化内存,但是这个时候 t 是类型T。

混合字面量语法

 ms := &struct1{10, 15.5, "Chris"} // 此时ms的类型是 *struct1
var ms struct1
    ms = struct1{10, 15.5, "Chris"} // 此时ms的类型是 struct1

混合字面量语法(composite literal syntax)&struct1{a, b, c} 是一种简写,底层仍然会调用 new (),这里值的顺序必须按照字段顺序来写。在下面的例子中能看到可以通过在值的前面放上字段名来初始化字段的方式。表达式 new(Type) 和 &Type{} 是等价的。

intr := Interval{0, 3}            (A)
intr := Interval{end:5, start:1}  (B)
intr := Interval{end:5}           (C)

在(A)中,值必须以字段在结构体定义时的顺序给出, & 不是必须的。(B)显示了另一种方式,字段名加一个冒号放在值的前面,这种情况下值的顺序不必一致,并且某些字段还可以被忽略掉,就像(C)中那样。

内存分配

两者的内存分配位置在 Go 中并不明确,因为 Go 会根据需要自动决定是否在堆上分配内存。不过,在语义上:

  • new(T)用于需要获取指向类型 T 的指针的场景。通常更适合需要共享或修改同一个结构体实例的场景。
  • var t T用于直接使用类型 T 的值,而无需通过指针访问。

使用new

获得一个指针类型

使用混合字面量

是否使用引用符号决定是否获得一个指针类型

Go 语言中,结构体和它所包含的数据在内存中是以连续块的形式存在的,即使结构体中嵌套有其他的结构体,这在性能上带来了很大的优势。不像 Java 中的引用类型,一个对象和它里面包含的对象可能会在不同的内存空间中,这点和 Go 语言中的指针很像。下面的例子清晰地说明了这些情况

使用

赋值取值

就像在面向对象语言所作的那样,可以使用点号符给字段赋值:structname.fieldname = value。同样的,使用点号符可以获取结构体字段的值:structname.fieldname

在 Go 语言中这叫 选择器(selector) 。无论变量是一个结构体类型还是一个结构体类型指针,都使用同样的 选择器符(selector-notation) 来引用结构体的字段:

type myStruct struct { i int }
var v myStruct    // v是结构体类型变量
var p *myStruct   // p是指向一个结构体类型变量的指针
v.i
p.i
package main
import (
    "fmt"
    "strings"
)

type Person struct {
    firstName   string
    lastName    string
}

func upPerson(p *Person) {
    p.firstName = strings.ToUpper(p.firstName)
    p.lastName = strings.ToUpper(p.lastName)
}

func main() {
    // 1-struct as a value type:
    var pers1 Person
    pers1.firstName = "Chris"
    pers1.lastName = "Woodward"
    upPerson(&pers1)
    fmt.Printf("The name of the person is %s %s\n", pers1.firstName, pers1.lastName)

    // 2—struct as a pointer:
    pers2 := new(Person)
    pers2.firstName = "Chris"
    pers2.lastName = "Woodward"
    (*pers2).lastName = "Woodward"  // 这是合法的
    upPerson(pers2)
    fmt.Printf("The name of the person is %s %s\n", pers2.firstName, pers2.lastName)

    // 3—struct as a literal:
    pers3 := &Person{"Chris","Woodward"}
    upPerson(pers3)
    fmt.Printf("The name of the person is %s %s\n", pers3.firstName, pers3.lastName)
}

在上面例子的第二种情况中,可以直接通过指针,像 pers2.lastName="Woodward" 这样给结构体字段赋值,没有像 C++ 中那样需要使用 -> 操作符,Go 会自动做这样的转换。

注意也可以通过解指针的方式来设置值:(*pers2).lastName = "Woodward"

递归结构体

C中的写法很类似,不赘述。例子定义二叉树的一个节点。

type Node struct {
    pr      *Node
    data    float64
    su      *Node
}

GC

Go 开发者不需要写代码来释放程序中不再使用的变量和结构占用的内存,在 Go 运行时中有一个独立的进程,即垃圾收集器(GC),会处理这些事情,它搜索不再使用的变量然后释放它们的内存。可以通过 runtime 包访问 GC 进程。

通过调用 runtime.GC() 函数可以显式的触发 GC,但这只在某些罕见的场景下才有用,比如当内存资源不足时调用 runtime.GC(),它会在此函数执行的点上立即释放一大片内存,此时程序可能会有短时的性能下降(因为 GC 进程在执行)。

如果想知道当前的内存状态,可以使用:

var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("%d Kb\n", m.Alloc / 1024)

如果需要在一个对象 obj 被从内存移除前执行一些特殊操作,比如写到日志文件中,可以通过如下方式调用函数来实现:

runtime.SetFinalizer(obj, func(obj *typeObj))

func(obj *typeObj) 需要一个 typeObj 类型的指针参数 obj,特殊操作会在它上面执行。func 也可以是一个匿名函数。

在对象被 GC 进程选中并从内存中移除以前,SetFinalizer 都不会执行,即使程序正常结束或者发生错误。

工厂方法

type File struct {
    fd      int     // 文件描述符
    name    string  // 文件名
}

func NewFile(fd int, name string) *File {
    if fd < 0 {
        return nil
    }

    return &File{fd, name}
}

f := NewFile(10, "./test.txt")

工厂方法像面向对象的语言那样实例化对象。可以用 size := unsafe.Sizeof(T{}) 来查看一个实例占用了多少内存。

强制使用工厂方法

type matrix struct {
    ...
}

// 仅仅暴露工厂方法
func NewMatrix(params) *matrix {
    m := new(matrix) // 初始化 m
    return m
}

结构体 tag

package main

import (
	"fmt"
	"reflect"
)

type User struct {
	Name     string `label:"用户名" required:"true" maxlen:"30"`
	Email    string `label:"电子邮件" required:"true" maxlen:"50"`
	Age      int    `label:"年龄" required:"false"`
	Location string `label:"地址" required:"false" maxlen:"100"`
}

func main() {
	user := User{
		Name:     "Alice",
		Email:    "alice@example.com",
		Age:      25,
		Location: "Wonderland",
	}
	printFieldTags(user)
}

// printFieldTags 函数通过反射读取结构体字段标签并打印标签信息
func printFieldTags(data interface{}) {
	val := reflect.ValueOf(data)
	typ := reflect.TypeOf(data)

	fmt.Printf("结构体 %s 的字段标签:\n", typ.Name())
	for i := 0; i < typ.NumField(); i++ {
		field := typ.Field(i)
		value := val.Field(i).Interface()

		label := field.Tag.Get("label")
		required := field.Tag.Get("required")
		maxlen := field.Tag.Get("maxlen")

		fmt.Printf("字段: %s\n", field.Name)
		fmt.Printf("  标签 - 名称: %s, 必填: %s, 最大长度: %s\n", label, required, maxlen)
		fmt.Printf("  当前值: %v\n\n", value)
	}
}

在 Go 语言中,结构体标签(tag)紧跟在结构体字段的类型之后,并由反引号包裹。结构体标签的完整规则包括格式、键值对结构、解析方式等方面。以下是 Go 中结构体标签的完整规则

标签必须放在反引号 ``(backticks)内,并采用键值对的格式。多个键值对之间以空格分隔,每个键值对由键、冒号、值组成。

type StructName struct {     FieldName FieldType `key1:"value1" key2:"value2"` }
type User struct {     Name string `json:"name" xml:"name" validate:"required"` }

这里标签中包含三个键值对:json:"name"xml:"name" 和 validate:"required",它们分别提供了 JSON 序列化、XML 序列化以及验证的规则。

键和值的规则

  • :键必须是非空的字符串,可以包含字母、数字和一些特殊符号(一般只使用字母和数字)。
  • :值必须是一个合法的字符串,用双引号包裹(不能使用单引号)。
  • 标签的值中可以包含任何字符,但如果需要使用反斜杠 `` 或双引号 ", 则需要进行转义。

解析方式

Go 语言标准库的 reflect 包提供 StructTag 类型,通过它可以解析标签值。

  • StructTag.Get 方法可以通过键获取单个标签的值。
  • reflect.StructField.Tag 可以获取结构体字段的完整标签,之后可以使用 Get 或手动解析。
type Product struct {
    ID    int    `db:"primary_key" json:"id"`
    Name  string `json:"name" validate:"required"`
}

field, _ := reflect.TypeOf(Product{}).FieldByName("ID")
fmt.Println(field.Tag.Get("json")) // 输出: "id"
fmt.Println(field.Tag.Get("db"))   // 输出: "primary_key"

标签的应用

结构体标签最常用于标准库和第三方库中的特定功能,如:

  • jsonxmlyaml:用于序列化和反序列化(序列化相关库会寻找 jsonxml 或 yaml 等键值)。
  • ORM:数据库映射库,如 GORM 或 XORM 会使用 gorm 或 xorm 标签定义主键、列名、索引等。
  • 验证validate 标签可用于定义字段的验证规则,常用的验证库如 go-playground/validator 支持此标签。
  • 表单映射:HTTP 表单解析库(如 gorilla/schema)使用 form 标签来指定字段名。

标签的零值

如果标签键不存在或者没有为标签赋值,Get 方法会返回空字符串 ""。这通常用于判断一个标签是否存在。

标签的最佳实践

  • 避免在标签中使用过多的键值对,以保持清晰易读。
  • 标签的值应简明扼要且与字段的使用场景密切相关。
  • 使用驼峰或小写的标签键,通常符合惯例,例如 json:"field_name" 而不是 JSON:"field_name"

标签的限制

  • 标签内容不被 Go 编译器直接检查,因此错误标签在编译时不会报错,只有在运行时出错。
  • 反射解析的开销较高,频繁使用标签会增加一定的性能开销。

标签的复杂解析

复杂标签可以在值中嵌入多个条件,例如 "required,min=1,max=100"。库需要自己解析标签中的复杂内容,以支持多条件的验证或序列化。

命名冲突

当两个字段拥有相同的名字(可能是继承来的名字)时该怎么办呢?

  1. 外层名字会覆盖内层名字(但是两者的内存空间都保留),这提供了一种重载字段或方法的方式;
  2. 如果相同的名字在同一级别出现了两次,如果这个名字被程序使用了,将会引发一个错误(不使用没关系)。没有办法来解决这种问题引起的二义性,必须由程序员自己修正。

使用 c.a 是错误的,到底是 c.A.a 还是 c.B.a 呢?会导致编译器错误:ambiguous DOT reference c.a disambiguate with either c.A.a or c.B.a

type A struct {a int}
type B struct {a, b int}

type C struct {A; B}
var c C

使用 d.b 是没问题的:它是 float32,而不是 B 的 b。如果想要内层的 b 可以通过 d.B.b 得到。

type D struct {B; b float32}
var d D