Golang最細節篇— struct{} 空結構體究竟是啥?

2021-12-25 Go語言中文網


背景

原理解密

定義的各種姿勢

`struct {}` 作為 receiver

配合使用姿勢

`map` & `struct{}`

`chan` & `struct{}`

`slice` & `struct{}`

總結


golang 正常的 struct 就是普通的一個內存塊,必定是佔用一小塊內存的,並且結構體的大小是要經過邊界,長度的對齊的,但是「空結構體」是不佔內存的,size 為 0;

提示:以下都是基於 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 字節。

本質上來講,使用空結構體的初衷只有一個:節省內存,但是更多的情況,節省的內存其實很有限,這種情況使用空結構體的考量其實是:根本不關心結構體變量的值



特殊變量:zerobase

空結構體是沒有內存大小的結構體。這句話是沒有錯的,但是更準確的來說,其實是有一個特殊起點的,那就是 zerobase 變量,這是一個 uintptr 全局變量,佔用 8 個字節。當在任何地方定義無數個 struct {} 類型的變量,編譯器都只是把這個 zerobase 變量的地址給出去。換句話說,在 golang 裡面,涉及到所有內存 size 為 0 的內存分配,那麼就是用的同一個地址 &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.zerobase  的地址出去,這個代碼邏輯是在 mallocgc 函數裡面:

代碼如下:

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
    // 分配 size 為 0 的結構體,把全局變量 zerobase 的地址給出去即可;
 if size == 0 {
  return unsafe.Pointer(&zerobase)
 }
    // ... 

小結:golang 使用 mallocgc 分配內存的時候,如果 size 為 0 的時候,統一返回的都是全局變量 zerobase 的地址。

有這種全局唯一的特殊的地址也方便後面一些邏輯的特殊處理。

定義的各種姿勢原生定義
a := struct{}{}

struct{}  可以就認為是一種類型,a 變量就是 struct {}  類型的一種變量,地址為 runtime.zerobase ,大小為 0 ,不佔內存。

重定義類型

golang 使用 type  關鍵字定義新的類型,比如:

type emptyStruct struct{}

定義出來的 emptyStruct  是新的類型,具有對應的 type 結構,但是性質 struct{} 完全一致,編譯器對於 emptryStruct 類型的內存分配,也是直接給 zerobase 地址的。

匿名嵌套類型

struct{}  作為一個匿名欄位,內嵌其他結構體。這種情況是怎麼樣的?

匿名嵌套方式一

type emptyStruct struct{}
type Object struct {
    emptyStruct
}

匿名嵌套方式二

type Object1 struct {
    _ struct {}
}

記住一點,空結構體還是空結構體,類型變量本身絕對不分配內存( size=0 ),所以編譯器對以上的 Object,Object1 兩種類型的處理和空結構體類型是一致的,分配地址為 runtime.zerobase 地址,變量大小為0,不佔任何內存大小。

內置欄位

內置欄位的場景沒有什麼特殊的,主要是地址和長度的對齊要考慮。還是只需要注意 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.s 是一致的,變量 o1  的內存大小對齊到 1 字節;&o2  和 &o2.s 是一致的,變量 o2  的內存大小對齊到 8 字節;

這種分配是滿足對齊規則的,編譯器也不會對這種 struct {} 欄位做任何特殊的字節填充。

場景二:struct {} 在中間

// Object1 類型變量佔用 16 個字節
type Object1 struct {
 b  byte
 s  struct{}
 b1 int64
}

o1 := Object1{ }

編譯器不會對 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 { } 作為最後一個欄位,會被填充對齊到前一個欄位的大小,地址偏移對齊規則不變;

可以現在心裡思考下,o1,o2,o3,o4  這四個對象的內存分配分別佔多少空間?下面解密:

這種情況,需要先把 struct {}  按照前一個欄位的長度分配 padding 內存,然後整個變量按照地址和長度的對齊規則不變。

struct {} 作為 receiver

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>

通過這段代碼證實幾個點:

receiver 其實就是一種語法糖,本質上就是作為第一個參數傳入函數;receiver 為值的場景,不需要傳空結構體做第一個參數,因為空結構體沒有值;receiver 為一個指針的場景,對象地址作為第一個參數傳入函數,函數調用的時候,編譯器傳入 zerobase 的值(編譯期間就可以確認);

在二進位編譯之後,一般 e.FuncA  的調用,第一個參數是直接壓入 &zerobase 到棧裡。

總結幾個知識點:

receiver 本質上是非常簡單的一個通用思路,就是把對象值或地址作為第一參數傳入函數;對象值作為 receiver 的時候,涉及到一次值拷貝;golang 對於值做 receiver 的函數定義,會根據現實需要情況可能會生成了兩個函數,一個值版本,一個指針版本(思考:什麼是「需要情況」?就是有 interface 的場景 );空結構體在編譯期間就能識別出來的場景,編譯器會對既定的事實,可以做特殊的代碼生成;

可以這麼說,編譯期間,關於空結構體的參數基本都能確定,那麼代碼生成的時候,就可以生成對應的靜態代碼。

程序 debug 技巧和工具介紹可以翻看:golang 調試分析的高階技巧。



空結構體 struct{ }  為什麼會存在的核心理由就是為了節省內存。當你需要一個結構體,但是卻絲毫不關係裡面的內容,那麼就可以考慮空結構體。golang 核心的幾個複合結構 map ,chan ,slice 都能結合 struct{}  使用。

map & struct{}

map 和 struct {} 一般的結合姿勢是這樣的:

// 創建 map
m := make(map[int]struct{})
// 賦值
m[1] = struct{}{}
// 判斷 key 鍵存不存在
_, ok := m[1]

一般 map 和 struct {} 的結合使用場景是:只關心 key,不關注值。比如查詢 key 是否存在就可以用這個數據結構,通過 ok  的值來判斷這個鍵是否存在,map 的查詢複雜度是 O(1) 的,查詢很快。

你當然可以用 map[int]bool 這種類型來代替,功能也一樣能實現,很多人考慮使用 map[int]struct{} 這種使用方式真的就是為了省點內存,當然大部分情況下,這種節省是不足道哉的,所以究竟要不要這樣使用還是要看具體場景。

chan & struct{}

channel 和 struct{} 結合是一個最經典的場景,struct{} 通常作為一個信號來傳輸,並不關注其中內容。chan 的分析在前幾篇文章有詳細說明。chan 本質的數據結構是一個管理結構加上一個 ringbuffer ,如果 struct{} 作為元素的話,ringbuffer 就是 0 分配的。

chan 和 struct{} 結合基本只有一種用法,就是信號傳遞,空結構體本身攜帶不了值,所以也只有這一種用法啦,一般來說,配合 no buffer 的 channel 使用。

// 創建一個信號通道
waitc := make(chan struct{})

// ...
goroutine 1:
    // 發送信號: 投遞元素
    waitc <- struct{}
    // 發送信號: 關閉
    close(waitc)

goroutine 2:
    select {
    // 收到信號,做出對應的動作
    case <-waitc:
    }    

這種場景我們思考下,是否一定是非 struct{} 不可?其實不是,而且也不多這幾個字節的內存,所以這種情況真的就只是不關心 chan 的元素值而已,所以才用的 struct{}。

slice & struct{}

形式上,slice 也結合 struct{} 。

s := make([]struct{}, 100)

我們創建一個數組,無論分配多大,所佔內存只有 24 字節(addr, len, cap),但實話說,這種用法沒啥實用價值。

創建 slice 其實調用的是 makeslice 來分配內存,其中是調用 malllocgc ,而 mallocgc 我們知道在分配 size 為 0 的內存則是直接返回 zerobase 的地址而已。而 slice 在擴展的時候在遇到這種 size 為 0 的時候,也是直接返回 zerobase 的地址。

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

總結空結構體也是結構體,只是 size 為 0 的類型而已;所有的空結構體都有一個共同的地址:zerobase 的地址;空結構體可以作為 receiver ,receiver 是空結構體作為值的時候,編譯器其實直接忽略了第一個參數的傳遞,編譯器在編譯期間就能確認生成對應的代碼;map 和 struct{} 結合使用常常用來節省一點點內存,使用的場景一般用來判斷 key 存在於 map;chan 和 struct{} 結合使用是一般用於信號同步的場景,用意並不是節省內存,而是我們真的並不關心 chan 元素的值;slice 和 struct{} 結合好像真的沒啥用。。。

相關焦點

  • golang的空結構體struct{}介紹
    1.
  • Golang 入門 : 結構體(struct)
    結構體由一系列命名的元素組成,這些元素又被稱為欄位,每個欄位都有一個名稱和一個類型。結構體的目的就是把數據聚集在一起,以便能夠更加便捷地操作這些數據。結構體的概念在 C 語言裡很常見,被稱為 struct。Golang 中的結構體也是 struct。Go 語言中沒有類的概念,因此在 Go 中結構體有著更為重要的地位。
  • Golang避坑系列 - 結構體struct
    var user Uservar user1 *User = &User{}var user2 *User = new(User)struct使用下面示例中user1和user2為指針類型,訪問的時候編譯器會自動把 user1.Name 轉為 (*user1).Namefunc main() {
  • Golang入門教程——面向對象篇
    今天是golang專題的第9篇文章,我們一起來看看golang當中的面向對象的部分。在現在高級語言當中,面向對象幾乎是不可或缺也是一門語言最重要的部分之一。struct在golang當中沒有類的概念,代替的是結構體(struct)這個概念。
  • C語言結構體(struct)詳解
    char b[] = "hell";也不行(C和C++都不行)少了整型變量a又會讓整個結構體長度為0,compiler不允許編譯通過!不同的是,其實C++形式上是允許空結構體的,本質上是通過機制避免了純空結構體和類對象,自動給空結構體對象分配一個字節(sizeof()返回1)方便區分對象,避免地址重合!
  • C語言結構體(struct)最全的講解
    ——結構體,它就將不同類型的數據存放在一起,作為一個整體進行處理。結構體在函數中的作用不是簡便,其最主要的作用就是封裝。封裝的好處就是可以再次利用。讓使用者不必關心這個是什麼,只要根據定義使用就可以了。結構體的大小不是結構體元素單純相加就行的,因為我們現在主流的計算機使用的都是32Bit字長的CPU,對這類型的CPU取4個字節的數要比取一個字節要高效,也更方便。
  • C語言結構體(Struct)
    在C語言中,可以使用結構體(Struct)來存放一組不同類型的數據。結構體的定義形式為:struct 結構體名{    結構體所包含的變量或數組};結構體是一種集合,它裡面包含了多個變量或數組,它們的類型可以相同,也可以不同,每個這樣的變量或數組都稱為結構體的成員(Member)。
  • 細說Golang的JSON解析
    生成JSON場景相對簡單一些,json.Marshal()會根據傳入的結構體生成JSON數據。解析JSON會把數據解析到結構體中,由於JSON格式的自由組合的特點,尤其是那些結構複雜的JSON數據對新手來說聲明接受JSON數據的結構體類型就會陷入不知從何下手的困擾。
  • C語言結構體(struct)最全的講解(萬字乾貨)
    ——結構體,它就將不同類型的數據存放在一起,作為一個整體進行處理。結構體在函數中的作用不是簡便,其最主要的作用就是封裝。封裝的好處就是可以再次利用。讓使用者不必關心這個是什麼,只要根據定義使用就可以了。結構體的大小不是結構體元素單純相加就行的,因為我們現在主流的計算機使用的都是32Bit字長的CPU,對這類型的CPU取4個字節的數要比取一個字節要高效,也更方便。
  • C語言之結構體(struct)
    就靠 struct 了。結構體,顧名思義,就是將一個個數據類型構成一個數據類型以方便使用。比如說一個 24 位的像素,有 R、G、B 三種顏色,每種顏色都用 8 bit 表示,如果使用一般的方法怎麼表示呢?
  • Go 經典入門系列 26:結構體取代類​
    歡迎來到 Golang 系列教程[1]的第 26 篇。Go 支持面向對象嗎? Go 並不是完全面向對象的程式語言。Go 官網的 FAQ[2] 回答了 Go 是否是面向對象語言,摘錄如下。可以說是,也可以說不是。
  • C語言中結構體struct的用法
    #include <stdio.h>int main() { struct {    char *name;   int age;     char group;   } stu1;   stu1.name = "Tom"; stu1.age = 18;  stu1.group = 'A'; printf("%s
  • 體驗golang語言的風騷編程
    中的結構體和繼承8、 golang中的空interface類似於java中的object,空interface(interface{})不包含任何的method,正因為如此,所有的類型都實現了空interface。
  • C語言結構體(struct)常見使用方法
    結構體定義:第一種:只有結構體定義[cpp] view plainstruct stuff{ char job[20]; int age; float
  • Golang 語言 Struct 中欄位的 Tag 怎麼使用?
    Tag 實際上就是一個字符串,只不過有特定的格式,也就是說 Tag 字符串必須由 key:"value"組成,key 必須是非空字符串,value 必須由雙引號引起來。其中,每個 key 都是一個非空字符串,由除空格 (U+0020 ' ')、引號 (U+0022 '"') 和冒號 (U+003A ':') 以外的非控制字符組成;每個 value 都使用 U+0022 '"' 字符和 Go 字符串語法引用。
  • Golang中interface內部構造與面試真題分析
    本篇通過一些面試真題,來分析interface的幾點注意及內部底層結構, 提綱如下:interface的內部結構(
  • 【Golang】Context基礎篇
    type emptyCtx inttype cancelCtx struct {    Context    mu       sync.Mutex                done     chan struct{}             children map[canceler]struct{}     err      error
  • 深入理解Golang Channel 結構
    make chan上述任務隊列的例子第三行,使用 make 創建了一個長度為 3 的帶緩衝的 channel,channel 在底層是一個 hchan 結構體,位於 src/runtime/chan.go 裡。
  • 關於Golang的4個小秘密
    快來看看你是不是也遇到過吧,希望這篇文章能幫助到你。PS:豐富的一線技術、多元化的表現形式,盡在「360雲計算」,點關注哦!請看下面的代碼,你覺得輸出結果會是啥?Values是「1 2 3」,Addresses是三個不同的地址?
  • golang | 是返回struct還是返回struct的指針
    收錄於話題 #golang>對於這個問題,我想大部分人的回答,肯定都是返回指針,因為這樣可以避免結構體的拷貝,使代碼的效率更高,性能更好。但真的是這樣嗎?在回答這個問題之前,我們先寫幾個示例,來確定一些基本事實:上圖中,函數f返回的是結構體S的指針,即一個地址,這個可以通過其彙編來確認: