取名好难啊啊头像
关注
Java 转 go 学习 - 结构体封面图

Java 转 go 学习 - 结构体

本系列文章:


前两篇文章中我们学习了函数的用法,这篇文章就来学习结构体。


1. 结构体基础

1.1 什么是结构体

首先来看下结构体的定义:

type 结构体名 struct {
    字段名1 类型1
    字段名2 类型2
    // ...
}

Go 中不存在类,结构体就对应了 Java 中的类,属于纯数据容器,没有继承和方法,函数传参是值传参,结构体里面只能定义属性,比如下面我们可以定义一个用户信息结构体。

type User struct {
    ID       int
    Username string
    Email    string
    Age      int
    IsActive bool
}

关于结构体也有一些命名规范:

  1. 结构体名: 大写字母开头(导出/公开),如 User。
  2. 字段名: 大写字母开头表示公开(其他包可访问),小写字母开头表示私有(仅当前包可访问)。

1.2 结构体初始化

Go 提供了多种初始化结构体的方式。


1.2.1 零值初始化

零值初始化就是声明之后不赋值,这种情况下结构体里面的字段自动初始化为类型的零值:int=0, string="", bool=false, 切片/指针=nil。

type User struct {
	ID       int
	Username string
	Email    string
	Age      int
	IsActive bool
}

func main() {
	var u1 User
	fmt.Println(u1)
	fmt.Println(u1.Age, u1.IsActive, u1.Username == "", u1.Email == "", u1.Age)
}

输出如下:

{0   0 false}
0 false true true 0

可以看到默认初始化的结构体不为 nil,但是初始化出来的结构体里面的各个字段都是默认零值。


1.2.2 字面量初始化(推荐 ⭐)

推荐用这种方式来初始化,也就是使用 {} 直接赋值,这样可读性比较高,字面量初始化可以用下面几种方式。

首先是指定字段名,在 {} 中指定想要初始化的字段。

// 指定字段名初始化
func test2() {
	u2 := User{
		ID:       1,
		Username: "alice",
		Email:    "[email protected]",
		Age:      25,
		IsActive: true,
	}

	fmt.Println(u2)
	// {1 alice [email protected] 25 true}
}

这种方式是比较推荐的,不容易出错,也可以按顺序赋值,不需要指定字段名,这种就不推荐了。

func test2() {
	// 不指定字段名
	// {1 bob [email protected] 30 true}
	u3 := User{1, "bob", "[email protected]", 30, true}
	fmt.Println(u3)
}

如果是不指定字段名的情况下需要将全部字段都初始化,指定字段名可以一部分初始化,没有指定的字段就默认设置成零值。

func test2() {
	// 指定部分字段名
	u4 := User{
		ID:       1,
		Username: "alice2",
		Email:    "[email protected]",
	}

	fmt.Println(u4)
	// {1 alice2 [email protected] 0 false}

}

最后也可以用 new 关键字去创建结构体,new 比较特殊的是会返回结构体的指针,所有字段都是零值。

func test3() {
	u5 := new(User)
	u5.ID = 3
	u5.Username = "john"
	fmt.Println(u5)
	// &{3 john  0 false}
}

用 new 的方式创建结构体默认等价于 &User{}。


1.3 结构体访问

不管是结构体还是结构体指针,都可以通过 . 访问或者修改结构体里面的属性,如果是结构体指针,通过 . 访问属性的时候,Go 会自动解引用访问具体的值,当然也可以 (*struct).xxx 这样来访问。

func test4() {
	t1 := User{ID: 1, Username: "A"}
	t2 := &User{ID: 2, Username: "B"}

	fmt.Println(t1.ID) // 1
	fmt.Println(t2.ID) // 2
}

1.4 结构体的内存布局

Go 语言中结构体和结构体所包含的数据在内存是以连续块的方式存在的,哪怕是结构体里面嵌套了其他的结构体,不像 Java 的引用类型,一个对象和里面包含的对象可能在不同的内存空间,下面我们就用嵌套结构体来看下这些特性。

// 基础结构体:Point(2个int32字段,共8字节)
type Point struct {
	X int32 // 4字节
	Y int32 // 4字节
}

// 嵌套结构体:Rect(包含2个Point + 1个uint32)
type Rect struct {
	Min   Point  // 嵌套Point,占8字节
	Max   Point  // 嵌套Point,占8字节
	Color uint32 // 4字节
}

func test5() {
	// 初始化 Rect 实例
	r := Rect{
		Min:   Point{X: 1, Y: 2},
		Max:   Point{X: 3, Y: 4},
		Color: 0xFF0000, // 红色
	}

	// 打印每个字段的内存地址和大小
	fmt.Printf("Rect 实例起始地址: %p\n", &r)
	fmt.Printf("Min.X 地址: %p(大小: %d 字节)\n", &r.Min.X, unsafe.Sizeof(r.Min.X))
	fmt.Printf("Min.Y 地址: %p(大小: %d 字节)\n", &r.Min.Y, unsafe.Sizeof(r.Min.Y))
	fmt.Printf("Max.X 地址: %p(大小: %d 字节)\n", &r.Max.X, unsafe.Sizeof(r.Max.X))
	fmt.Printf("Max.Y 地址: %p(大小: %d 字节)\n", &r.Max.Y, unsafe.Sizeof(r.Max.Y))
	fmt.Printf("Color 地址: %p(大小: %d 字节)\n", &r.Color, unsafe.Sizeof(r.Color))
	fmt.Printf("Rect 总大小: %d 字节\n", unsafe.Sizeof(r))
}

上面结果输出如下:

Rect 实例起始地址: 0xc00001c0d8
Min.X 地址: 0xc00001c0d8(大小: 4 字节)
Min.Y 地址: 0xc00001c0dc(大小: 4 字节)
Max.X 地址: 0xc00001c0e0(大小: 4 字节)
Max.Y 地址: 0xc00001c0e4(大小: 4 字节)
Color 地址: 0xc00001c0e8(大小: 4 字节)
Rect 总大小: 20 字节

可以看到的是 Rect 中的所有字段都是依次连续分布在内存的:

  • Min.X(0xc00001c0d8)→ Min.Y(+4 字节)→ Max.X(+4 字节)→ Max.Y(+4 字节)→ Color(+4 字节)

也就是说结构体里面嵌套结构体,Point 的数据也是直接嵌入 Rect 的内存块里面,不会说 Point 单独存放到一个地方,然后 Rect 里面存引用地址,这种是值类型的分布,可以看到的是这样分布好处就是没有内存碎片,读取性能比较高。

但是同样的写法如果换成 Java 就不行了,因为 Java 中类是引用类型,比如换成下面的写法。

public class MemoryDemo {
    static class Point {
        int x;
        int y;
        Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }

    static class Rect {
        Point min; // 引用类型
        Point max; // 引用类型
        int color; // 基本类型
        Rect(Point min, Point max, int color) {
            this.min = min;
            this.max = max;
            this.color = color;
        }
    }

    public static void main(String[] args) {
        Point p1 = new Point(1, 2);
        Point p2 = new Point(3, 4);
        Rect r = new Rect(p1, p2, 0xFF0000); // Rect堆对象
    }
}

这种情况下由于 Point 是引用类型,自然 Rect 中存储的就是堆内存的地址,执行堆中的 Point 对象了,两种写法内存分布大概如下面图中所示。
在这里插入图片描述

但是对于结构体,如果想要也像 Java 这样存储地址,那结构体里面存结构体的指针就行了,也就是换成下面的定义方式。

type RectPointer struct {
	Min   *Point // 结构体指针, 8 字节
	Max   *Point // 结构体指针, 8 字节
	Color uint32 // 4字节
}

这时候再输出:

Rect 实例起始地址: 0xc0000080a8
Min.X 地址: 0xc00000a088(大小: 4 字节)
Min.Y 地址: 0xc00000a08c(大小: 4 字节)
Max.X 地址: 0xc00000a0a0(大小: 4 字节)
Max.Y 地址: 0xc00000a0a4(大小: 4 字节)
Color 地址: 0xc0000080b8(大小: 4 字节)	// 最后一个就不是连续的了
Rect 总大小: 24 字节

这时候会发现 Max、Min 和 Color 的内存地址就不是连续的了。


1.5 结构体标签 ⭐

结构体字段后面可以跟一个反引号包裹的字符串,称为 Tag,给这个字段打上标签。

  • 作用: 在结构体后面打上标签之后通过 reflect 可以获取到标签的内容,读取元数据。
  • 格式:key:"value"。

最常见的就是 JSON 序列化了,我们给结构体中的字段打上标签,和 JSON 中的字段一一对应。

type Product struct {
	ID     int     `json:"id"`                // JSON key:  "id"
	Name   string  `json:"name"`              // JSON key:  "name"
	Price  float64 `json:"price"`             // JSON Key:  "price"
	IsSold bool    `json:"is_sold,omitempty"` // omitempty 表示如果是 false, 序列化时忽略这个字段
	Secret string  `json:"-"`                 // 序列化时完全忽略该字段
}

然后测试下序列化和反序列化。


func test7() {
	p := Product{
		ID:     101,
		Name:   "test",
		Price:  999.99,
		IsSold: false,
		Secret: "internal_code",
	}

	// 序列化
	data, _ := json.Marshal(p)
	fmt.Println(string(data))

	p2 := &Product{}
	// 反序列化
	_ = json.Unmarshal(data, &p2)
	fmt.Println(*p2)
}

最终输出如下:

{"id":101,"name":"test","price":999.99}
{101 test 999.99 false }

1.6 匿名结构体

这种不需要定义 type,直接使用 struct{ 字段名 } { 初始值 } 创建一次性结构体。

func test8() {
	// 1. 定义并初始化
	user := struct {
		Name string
		Age  int
	}{
		Name: "Tom",
		Age:  18,
	}

	// 2. 创建结构体切片, 在切片中使用
	users := []struct {
		ID   int
		Role string
	}{
		// 初始化写法 1
		{ID: 1, Role: "admin"},
		// 初始化写法 2
		{2, "guest"},
	}

	fmt.Println(users)
	fmt.Println(user)
	// [{1 admin} {2 guest}]
	// {Tom 18}
}

又或者作为函数返回值返回。

// 3. 作为函数返回值
func getPoint() struct {
    X, Y int
} {
    return struct {
        X, Y int
    }{X: 10, Y: 20}
}

1.7 结构体比较

结构体比较主要看里面的字段,如果结构体的所有字段都可比较 (如 int,string,bool,数组等),那么结构体本身可以用 == 比较,然后包含 切片、Map、函数 这些引用类的属性,就不能直接用 == 比较,因为地址肯定是不相等的,需要用 reflect.DeepEqual。

type P struct {
	X, Y int
}

func main() {
	p1 := P{1, 2}
	p2 := P{1, 2}
	fmt.Println(p1 == p2) // true
}

如果加上切片类型,就会编译的时候报错。

type P struct {
	X, Y int
	Date []int
}

func main() {
	p1 := P{1, 2, make([]int, 0)}
	p2 := P{1, 2, make([]int, 0)}
	fmt.Println(p1 == p2) // Invalid operation: p1 == p2 (the operator == is not defined on P)
}

1.8 匿名嵌入

上面 1.6 小结我们学习了结构体的嵌套,也就是结构体里面定义结构体,实际上结构体的名字也可以省略掉,这样就相当于把内层结构体的字段嵌套进外层结构体中, 这种就是 匿名嵌入。

type Address struct {
	City string
	Name string
}

type Person struct {
	Name    string
	Address // 匿名嵌入
}

func main() {
	p := Person{
		Name: "John",
		Address: Address{
			City: "Shanghai",
			Name: "John-inner",
		},
	}
	// 可以直接访问内层结构体的字段
	fmt.Println(p.City)
	fmt.Println(p.Name)
	fmt.Println(p.Address.Name)
}

但是要注意像上面的例子中如果内层结构体有一个字段和外层的一样,通过 p.Name 访问到的是外层的字段,内层需要通过 p.Address.Name 去访问才行。


2. 结构体和方法

2.1 方法的定义

在 Go 中,方法就是 带有接收者的函数,之前我们说过结构体就类似 Java 的类,但是结构体里面不能定义方法,所以 Go 中我们可以通过这种方式将函数绑定到某个结构体上,从而实现像 Java 中的类方法的写法,下面是基本语法。

func (r ReceiverType) MethodName(params) ReturnTypes {
    // 方法体
    // 可以通过 r 访问结构体的字段
}

可以理解成就是在原来函数的基础上加了 (r ReceiverType),就可以绑定到某个结构体上。

// 1. 定义结构体
type Rectangle struct {
	Width  float64
	Height float64
}

// 2. 计算面积
func (r Rectangle) Area() float64 {
	return r.Width * r.Height
}

// 3. 计算周长
func (r Rectangle) Perimeter() float64 {
	return 2 * (r.Width + r.Height)
}

func main() {
	rect := Rectangle{Width: 10, Height: 5}

	// 调用方法:使用点号
	fmt.Printf("面积: %.2f\n", rect.Area())      // 面积: 50.00
	fmt.Printf("周长: %.2f\n", rect.Perimeter()) // 周长: 30.00
}

2.2 值接收和指针接收

特性值接收者 (r T)指针接收者 (r *T)
定义func (r T) Method()func (r *T) Method()
数据拷贝拷贝结构体的副本,方法内修改不影响原结构体。传递地址,方法内修改直接影响原结构体。
性能小结构体没问题,大结构体拷贝开销大。无论结构体多大,只拷贝指针(8字节),效率高。
适用场景1. 不需要修改原数据
2. 类型是不可变的(如 map, slice 包装器)
3. 小型简单结构体
1. 需要修改原结构体字段
2. 结构体很大,避免拷贝
3. 一致性原则(推荐默认使用)
调用方式val.Method() 或 ptr.Method() (Go 自动解引用)ptr.Method() 或 val.Method() (Go 自动取地址)

推荐使用 *T 的写法,下面举个例子。

type Counter struct {
	Count int
}

func (c Counter) Inc1() {
	c.Count++
	fmt.Println("值类型计数当前值: ", c.Count)
}

func (c *Counter) Inc2() {
	c.Count++
	fmt.Println("引用类型计数当前值: ", c.Count)
}

func test2_2() {
	c := Counter{Count: 0}

	// 1. 调用值接收者方法
	c.Inc1()
	fmt.Println("调用后 (值):", c.Count)

	// 2. 调用指针接收者方法
	c.Inc2()
	fmt.Println("调用后 (指针):", c.Count)
	
	// 值类型计数当前值:  1
	// 调用后 (值): 0
	// 引用类型计数当前值:  1
	// 调用后 (指针): 1
}

可以看到值接收者方法是不会修改原始结构体的,而指针接收者会修改。


2.3 Go 自动转换机制

正常来说我们都是用的指针接收者,这样就能避免有时候调用方法修改了结构体,有时候没有修改结构体。

如果方法定义成了值接收者,通过 (&c).Inc1() 调用也是没问题的,编译器会自动转成 (*(&c)).Inc1(),也就是 c.Inc1()。

	c.Inc1()
	(&c).Inc1()	// 和上面是一样的
	fmt.Println("调用后 (值):", c.Count)

如果方法定义成了指针接收者,通过上面例子中的 c.Inc2() 来调用,Go 编译器会自动转换成 (&t).Inc2()。


2.4 嵌套结构体的方法调用

如果结构体 A 匿名嵌入了结构体 B,那么 B 的方法也可以通过 A 直接调用,来看下面例子。

package main

import "fmt"

type Logger struct {
	Prefix string
}

// Logger 方法
func (l *Logger) Log(msg string) {
	fmt.Printf("[%s] %s\n", l.Prefix, msg)
}

type Service struct {
	Logger // 嵌入
	Name   string
}

func test() {
	s := Service{
		Logger: Logger{Prefix: "Service"},
		Name:   "Payment",
	}

	//  直接调用结构体 Logger 的 Log 方法
	s.Log("Starting service...")
}

func main() {
	test()
	// [Service] Starting service...
}

可以看到 Service 嵌入结构体 Logger 后可以直接调用 Log 方法打印日志。


3. 小结

结构体的文章就先学习到这,下一篇文章来看下接口的内容。





如有错误,欢迎指出!!!

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/laohuangaa/article/details/159349416

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--