Golang最细节篇— struct{} 空结构体究竟是啥?_栈

大纲

Golang最细节篇— struct{} 空结构体究竟是啥?_go_02

  • 背景

  • 原理解密

    • 定义的各种姿势

    • `struct {}` 作为 receiver

  • 配合使用姿势

    • `map` & `struct{}`

    • `chan` & `struct{}`

    • `slice` & `struct{}`

  • 总结

Golang最细节篇— struct{} 空结构体究竟是啥?_hash_03

背景

Golang最细节篇— struct{} 空结构体究竟是啥?_编程语言_04

struct

提示:以下都是基于 go1.13.3 linux/amd64 分析。

普通的结构体定义如下:

// 类型变量对齐到 8 字节;
type Tp struct {
    a uint16
    b uint32
}

按照内存对齐规则,这个结构体占用 8 个字节的内存。关于内存分配的基础知识可以翻看:Golang 数据结构到底是怎么回事?gdb调一调?,golang 内存管理分析。

空结构体:

var s struct{}
// 变量 size 是 0 ;
fmt.Println(unsafe.Sizeof(s))

该空结构体的变量占用内存 0 字节。

本质上来讲,使用空结构体的初衷只有一个:节省内存,但是更多的情况,节省的内存其实很有限,这种情况使用空结构体的考量其实是:根本不关心结构体变量的值

Golang最细节篇— struct{} 空结构体究竟是啥?_go_05

原理解密

Golang最细节篇— struct{} 空结构体究竟是啥?_hash_06

特殊变量:zerobase

zerobaseuintptrstruct {}zerobase&zerobase

举个例子:

package main

import "fmt"

type emptyStruct struct {}

func main() {
 a := struct{}{}
 b := struct{}{}
 c := emptyStruct{}

 fmt.Printf("%p\n", &a)
 fmt.Printf("%p\n", &b)
 fmt.Printf("%p\n", &c)
}

dlv 调试分析一下:

(dlv) p &a
(*struct {})(0x57bb60)
(dlv) p &b
(*struct {})(0x57bb60)
(dlv) p &c
(*main.emptyStruct)(0x57bb60)
(dlv) p &runtime.zerobase
(*uintptr)(0x57bb60)

小结:空结构体的变量的内存地址都是一样的。

内存管理特殊处理

mallocgc

struct {}runtime.zerobasemallocgc

代码如下:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    // 分配 size 为 0 的结构体,把全局变量 zerobase 的地址给出去即可;
 if size == 0 {
  return unsafe.Pointer(&zerobase)
 }
    // ... 
mallocgczerobase

有这种全局唯一的特殊的地址也方便后面一些逻辑的特殊处理。

定义的各种姿势

原生定义

a := struct{}{}
struct{}struct {}runtime.zerobase

重定义类型

type
type emptyStruct struct{}
emptyStructtypestruct{}emptryStructzerobase

匿名嵌套类型

struct{}

匿名嵌套方式一

type emptyStruct struct{}
type Object struct {
    emptyStruct
}

匿名嵌套方式二

type Object1 struct {
    _ struct {}
}
ObjectObject1runtime.zerobase

内置字段

内置字段的场景没有什么特殊的,主要是地址和长度的对齐要考虑。还是只需要注意 3 个要点:

  • 空结构体的类型不占内存大小;

  • 地址偏移要和自身类型对齐;

  • 整体类型长度要和最长的字段类型长度对齐;

我们分 3 种场景讨论这个问题:

struct {}
struct {}
// Object1 类型变量占用 1 个字节
type Object1 struct {
 s struct {}
 b byte
}

// Object2 类型变量占用 8 个字节
type Object2 struct {
 s struct {}
 n int64
}

o1 := Object1{ }
o2 := Object2{ }

内存怎么分配?

&o1&o1.so1&o2&o2.so2
struct {}
struct {}
// Object1 类型变量占用 16 个字节
type Object1 struct {
 b  byte
 s  struct{}
 b1 int64
}

o1 := Object1{ }
o1&o1.s&o1.b1
struct { }
struct {}

这个场景稍微注意下,因为编译器遇到之后会做特殊的字节填充补齐,如下;

type Object1 struct {
 b byte
 s struct{}
}

type Object2 struct {
 n int64
 s struct{}
}

type Object3 struct {
 n int16
 m int16
 s struct{}
}

type Object4 struct {
 n  int16
 m  int64
 s  struct{}
}

o1 := Object1 { }
o2 := Object2 { }
o3 := Object3 { }
o4 := Object4 { }
struct {}struct { }
o1o2o3o4
o1o2o3o4
struct {}
struct {}

receiver 这个是 golang 里 struct 具有的基础特点。空结构体本质上作为结构体也是一样的,可以作为 receiver 来定义方法。

type emptyStruct struct{}

func (e *emptyStruct) FuncB(n, m int) {
}
func (e emptyStruct) FuncA(n, m int) {
}

func main() {
 a := emptyStruct{}

 n := 1
 m := 2

 a.FuncA(n, m)
 a.FuncB(n, m)
}

receiver 这种写法是 golang 支撑面向对象的基础,本质上的实现也是非常简单,常规情况(普通的结构体)可以翻译成:

func FuncA (e *emptyStruct, n, m int) {
}
func FuncB (e  emptyStruct, n, m int) {
}

编译器只是把对象的值或地址作为第一个参数传给这个参数而已,就这么简单。 但是在这里要提一点,空结构体稍微有一点点不一样,空结构体应该翻译成:

func FuncA (e *emptyStruct, n, m int) {
}
func FuncB (n, m int) {
}

极其简单的代码,对应的汇编实际代码如下:

FuncA,FuncB 就这么简单,如下:

00000000004525b0 <main.(*emptyStruct).FuncB>:
  4525b0: c3                    retq   

00000000004525c0 <main.emptyStruct.FuncA>:
  4525c0: c3                    retq    

main 函数

00000000004525d0 <main.main>:
  4525d0: 64 48 8b 0c 25 f8 ff  mov    %fs:0xfffffffffffffff8,%rcx
  4525d9: 48 3b 61 10           cmp    0x10(%rcx),%rsp
  4525dd: 76 63                 jbe    452642 <main.main+0x72>
  4525df: 48 83 ec 30           sub    $0x30,%rsp
  4525e3: 48 89 6c 24 28        mov    %rbp,0x28(%rsp)
  4525e8: 48 8d 6c 24 28        lea    0x28(%rsp),%rbp
  4525ed: 48 c7 44 24 18 01 00  movq   $0x1,0x18(%rsp)
  4525f6: 48 c7 44 24 20 02 00  movq   $0x2,0x20(%rsp)
  4525ff: 48 8b 44 24 18        mov    0x18(%rsp),%rax
  452604: 48 89 04 24           mov    %rax,(%rsp)   // n 变量值压栈(第一个参数)
  452608: 48 c7 44 24 08 02 00  movq   $0x2,0x8(%rsp)  // m 变量值压栈(第二个参数)
  452611: e8 aa ff ff ff        callq  4525c0 <main.emptyStruct.FuncA>
  452616: 48 8d 44 24 18        lea    0x18(%rsp),%rax
  45261b: 48 89 04 24           mov    %rax,(%rsp)   // $rax 里面是 zerobase 的值,压栈(第一个参数);
  45261f: 48 8b 44 24 18        mov    0x18(%rsp),%rax
  452624: 48 89 44 24 08        mov    %rax,0x8(%rsp)  // n 变量值压栈(第二个参数)
  452629: 48 8b 44 24 20        mov    0x20(%rsp),%rax
  45262e: 48 89 44 24 10        mov    %rax,0x10(%rsp)  // m 变量值压栈(第三个参数)
  452633: e8 78 ff ff ff        callq  4525b0 <main.(*emptyStruct).FuncB>
  452638: 48 8b 6c 24 28        mov    0x28(%rsp),%rbp
  45263d: 48 83 c4 30           add    $0x30,%rsp
  452641: c3                    retq   
  452642: e8 b9 7a ff ff        callq  44a100 <runtime.morestack_noctxt>
  452647: eb 87                 jmp    4525d0 <main.main>

通过这段代码证实几个点:

zerobase
e.FuncA&zerobase

总结几个知识点:

interface

可以这么说,编译期间,关于空结构体的参数基本都能确定,那么代码生成的时候,就可以生成对应的静态代码。

程序 debug 技巧和工具介绍可以翻看:golang 调试分析的高阶技巧。

Golang最细节篇— struct{} 空结构体究竟是啥?_go_07

配合使用姿势

Golang最细节篇— struct{} 空结构体究竟是啥?_指针_08

struct{ }mapchanslicestruct{}
mapstruct{}
mapstruct {}
// 创建 map
m := make(map[int]struct{})
// 赋值
m[1] = struct{}{}
// 判断 key 键存不存在
_, ok := m[1]
mapstruct {}okmap
map[int]boolmap[int]struct{}
chanstruct{}
channelstruct{}struct{}struct{}
chanstruct{}
// 创建一个信号通道
waitc := make(chan struct{})

// ...
goroutine 1:
    // 发送信号: 投递元素
    waitc <- struct{}
    // 发送信号: 关闭
    close(waitc)

goroutine 2:
    select {
    // 收到信号,做出对应的动作
    case <-waitc:
    }    
struct{}chanstruct{}
slicestruct{}
slicestruct{}
s := make([]struct{}, 100)

我们创建一个数组,无论分配多大,所占内存只有 24 字节(addr, len, cap),但实话说,这种用法没啥实用价值。

makeslicemalllocgcmallocgczerobase
func growslice(et *_type, old slice, cap int) slice {
    // 如果元素的 size 为 0,那么还是直接赋值了 zerobase 的地址;
    if et.size == 0 {
        return slice{unsafe.Pointer(&zerobase), old.len, cap}
    }
}

总结

zerobasemapstruct{}mapchanstruct{}slicestruct{}

Golang最细节篇— struct{} 空结构体究竟是啥?_hash_09

往期推荐

Golang最细节篇— struct{} 空结构体究竟是啥?_hash_10

往期推荐

golang 内存管理分析

Golang 数据结构到底是怎么回事?gdb调一调?

Golang 语法到底是怎么回事?gdb调一调?

坚持思考,方向比努力更重要。