使用 Golang 的開發(fā)者都知道,Go 語言里有指針的概念梢为,它比 C++ 的指針要簡單的多渐行,同時(shí)你需要記住一個(gè)概念:Go 語言是 值傳遞轰坊。我們今天探討的是在編碼的時(shí)候到底該使用指針呢還是值類型?在作為參數(shù)和返回值的時(shí)候該如何去使用祟印?兩種傳遞方式有什么區(qū)別肴沫?
基礎(chǔ)概念
這幅圖中展示了常用的值類型和引用類型(引用類型和傳引用是兩個(gè)概念)。在左邊是我們常用的一些值類型蕴忆,函數(shù)調(diào)用時(shí)需要使用指針修改底層數(shù)據(jù)颤芬;而右邊是 “引用類型”,我們可以理解為它們的底層都是指針類型套鹅,所以右邊的類型在使用的時(shí)候會(huì)有些不同站蝠,下文中會(huì)舉例說明。
舉個(gè)栗子
type Foo struct {
Name string
}
var bar = "hello biezhi"
// -------------方法返回值----------------
func returnValue() string {
return bar
}
func returnPoint() *string {
return &bar
}
// --------------方法入?yún)?----------------
func modifyNameByPoint(foo *Foo) {
foo.Name = "emmmm " + foo.Name
}
func nameToUpper(foo Foo) string {
foo.Name = strings.ToUpper(foo.Name)
return foo.Name
}
// --------------實(shí)例方法-----------------
func (foo Foo) setName(name string) {
foo.Name = name
}
func (foo *Foo) setNameByPoint(name string) {
foo.Name = name
}
這里我列出了 3 組方法卓鹿,分別是指針類型和值類型的示例菱魔。這幾個(gè)方法在編寫代碼的過程中都會(huì)經(jīng)常遇到,我們從使用者的維度和內(nèi)存的視角來分析一下這幾個(gè)方法:
使用區(qū)別
大部分人都在討論函數(shù)的入?yún)⑹侵羔樳€是值類型呢吟孙?我們先來看看第一組方法澜倦,返回值的情況:
s1 := returnValue()
s2 := returnPoint()
fmt.Printf("s1: %v \n", s1) // s1: hello biezhi
fmt.Printf("s2: %v \n", *s2) // s2: hello biezhi
這兩個(gè)方法一個(gè)返回了指針一個(gè)返回值類型,值類型是非 nil
的(在 Go 中所有的值類型都會(huì)有 初值)杰妓,指針類型可以判斷是否為 nil
藻治。 獲取到的數(shù)據(jù)是相同的,不同之處在于取值的方式巷挥,指針類型需要使用 *
號(hào)讀取數(shù)據(jù)桩卵。
下面嘗試傳遞參數(shù),分別是指針類型參數(shù)和值類型參數(shù):
foo := Foo{Name:"biezhi"}
fmt.Println("foo.name:", foo.Name) // foo.name: biezhi
modifyNameByPoint(&foo)
fmt.Println("foo.name:", foo.Name) // foo.name: emmmm biezhi
fmt.Println("foo.name:", nameToUpper(foo)) // foo.name: EMMMM BIEZHI
fmt.Println("foo.name:", foo.Name) // foo.name: emmmm biezhi
-
modifyNameByPoint
需要指針類型倍宾,所以我們?nèi)?foo
的指針傳入(foo 是值類型所以這里用&
取其地址)雏节。 -
nameToUpper
需要一個(gè)值類型的參數(shù),所以foo
直接傳入凿宾,返回值是轉(zhuǎn)大寫的 Name矾屯。 -
nameToUpper
不會(huì)修改foo.Name
的數(shù)據(jù)兼蕊,最后一次輸出還是舊數(shù)據(jù)
綜上例子初厚,我們可以看出 指針類型會(huì)修改指向的數(shù)據(jù),值類型的數(shù)據(jù)只有在返回的時(shí)候被使用孙技,不會(huì)修改底層數(shù)據(jù)产禾。
Go 中是值傳遞,一個(gè)方法 / 函數(shù)總是獲取這個(gè)傳遞的拷貝牵啦,只是有一個(gè)分配聲明給這個(gè)參數(shù)分配這個(gè)數(shù)值亚情。拷貝一個(gè)指針的值就做了這個(gè)指針的拷貝哈雏,而不是指針指向的數(shù)據(jù)(重點(diǎn)理解)楞件。
內(nèi)存變化
我們使用值類型和指針類型在內(nèi)存的視角上會(huì)有什么不同之處嗎衫生?這將使得我們對(duì)這兩個(gè)概念理解更加深入。
返回值的情況
var bar = "hello biezhi"
s1 := returnValue()
s2 := returnPoint()
fmt.Printf("bar: %v address: %p \n", bar, &bar) // 1
fmt.Printf("s1 : %v address: %p \n", s1, &s1) // 2
fmt.Printf("s2 : %v address: %p \n", *s2, &s2) // 3
// output
? bar: hello biezhi address: 0x115f480
? s1 : hello biezhi address: 0xc00000e1e0
? s2 : hello biezhi address: 0xc00000c030
從這個(gè)輸出中可以看到數(shù)據(jù)都是一樣的土浸,這里使用 %p
輸出一個(gè)指針的值(內(nèi)存地址)都不同罪针。第一個(gè)毋庸置疑是初始的內(nèi)存地址,s1
是調(diào)用返回值類型的結(jié)果黄伊,s2
是返回指針類型的結(jié)果泪酱。照這樣看的話好像返回指針還是值類型沒有區(qū)別,地址都是新的还最。
來分析一下墓阀,首先 s1
的內(nèi)存地址發(fā)生變化是因?yàn)榉椒▍?shù)被拷貝后產(chǎn)生了一份新的值給 s1
,此時(shí) s1
分配了新地址拓轻。對(duì)于 s2
也拷貝了一份新值斯撮,只不過這個(gè)值是 指針類型,所以在取數(shù)據(jù)的時(shí)候用了 *
扶叉。
既然都分配了地址吮成,到底使用值類型還是指針類型作為返回值,我的推薦是這樣的:
- 當(dāng)返回類型不涉及狀態(tài)變更并且是較簡單的數(shù)據(jù)結(jié)構(gòu)辜梳,一律返回值類型
- 當(dāng)返回類型可能遇到狀態(tài)變更或者你關(guān)心它的生命周期則使用指針類型
- 當(dāng)返回的結(jié)構(gòu)比較大的時(shí)候使用指針類型
方法參數(shù)情況
我們?cè)?nameToUpper
中添加一句輸出:
func nameToUpper(foo Foo) string {
foo.Name = strings.ToUpper(foo.Name)
fmt.Printf("nameToUpper foo: %v address: %p \n", foo, &foo) // 2
return foo.Name
}
foo := Foo{Name:"biezhi"}
fmt.Printf("foo: %v address: %p \n", foo, &foo) // 1
nameToUpper(foo)
fmt.Printf("foo: %v address: %p \n", foo, &foo) // 3
// output
? foo: {biezhi} address: 0xc00000e1e0
? nameToUpper foo: {BIEZHI} address: 0xc00000e200
? foo: {biezhi} address: 0xc00000e1e0
nameToUpper
接收值類型的參數(shù)粱甫,觀察輸出你會(huì)發(fā)現(xiàn)在外部的 foo
變量內(nèi)存地址是沒有發(fā)生變化的。
在方法內(nèi)部接收這個(gè) 值類型變量 的時(shí)候作瞄,內(nèi)存地址和外面不同茶宵,這意味著 Go 會(huì)將這個(gè)值類型參數(shù)作為一個(gè)拷貝傳遞過去,在方法內(nèi)部的改變都不會(huì)影響到外面的變量宗挥。
如果方法接收一個(gè)指針類型呢乌庶?來試試 modifyNameByPoint
方法:
func modifyNameByPoint(foo *Foo) {
fmt.Printf("modifyNameByPoint foo: %v address: %p \n", foo, &foo) // 2
foo.Name = "emmmm " + foo.Name
}
foo := &Foo{Name:"biezhi"}
fmt.Printf("foo: %v address: %p \n", foo, &foo) // 1
modifyNameByPoint(foo)
fmt.Printf("foo: %v address: %p \n", foo, &foo) // 3
// output
? foo: &{biezhi} address: 0xc00000c028
? modifyNameByPoint foo: &{biezhi} address: 0xc00000c038
? foo: &{emmmm biezhi} address: 0xc00000c028
可以看到,數(shù)據(jù)被修改了契耿,因?yàn)閭鬟f的是指針瞒大;內(nèi)存地址沒有發(fā)生變化,作為入?yún)⒌?foo
在方法內(nèi)部的地址也是一份新的拷貝搪桂,這一點(diǎn)和前面返回值是相同的(0xc00000c028
和 0xc00000c038
指向同一份數(shù)據(jù))透敌。
Receiver Type
如果你編寫 Java 代碼的話經(jīng)常會(huì)看到這樣的代碼
public class Bar {
String name;
public void setName(String name){
this.name = name;
}
}
可以看到這里有 this
關(guān)鍵字,在 Go 中是沒有的踢械,這里的 this
可以調(diào)用當(dāng)前對(duì)象的成員變量和實(shí)例方法酗电,當(dāng)使用 this
修改了成員變量就相當(dāng)于在 Go 中使用了指針,看看下面的 Go 代碼:
func (foo *Foo) setNameByPoint(name string) {
foo.Name = name
}
func (foo Foo) setName(name string) {
foo.Name = name
}
Go 中想要為結(jié)構(gòu)體定義屬于自己的方法就使用如上的兩種方式内列,這兩個(gè)方法在 Go 中稱為 Receiver Type
(接受者類型)撵术,可以使用結(jié)構(gòu)體變量調(diào)用,我們今天只討論結(jié)構(gòu)體這種情況话瞧,來看看這兩個(gè)方法有什么不同:
foo := Foo{Name:"biezhi"}
foo.setName("2333")
fmt.Println("foo.Name:", foo.Name) // foo.Name: biezhi
foo.setNameByPoint("2333")
fmt.Println("foo.Name:", foo.Name) // foo.Name: 2333
根據(jù)輸出發(fā)現(xiàn)一個(gè)結(jié)構(gòu)體嫩与,如果不使用指針類型的時(shí)候值是不會(huì)被修改的寝姿。這點(diǎn)也很容易理解,在 setName
方法中 foo 變量被作為值傳遞划滋,所以如果這時(shí)候輸出 foo
的內(nèi)存地址會(huì)發(fā)現(xiàn)和外面調(diào)用的是不一樣的会油,來看看:
func (foo Foo) setName(name string) {
fmt.Printf("setName: %v address: %p \n", foo, &foo) // 2
foo.Name = name
}
func (foo *Foo) setNameByPoint(name string) {
fmt.Printf("setNameByPoint: %v address: %p \n", foo, &foo) // 4
foo.Name = name
}
foo := Foo{Name:"biezhi"}
fmt.Printf("src foo: %v address: %p \n", foo, &foo) // 1
foo.setName("set name")
fmt.Printf("by value foo: %v address: %p \n", foo, &foo) // 3
foo.setNameByPoint("2333")
fmt.Printf("by point foo: %v address: %p \n", foo, &foo) // 5
// output
? src foo: {biezhi} address: 0xc00000e1e0
? setName: {biezhi} address: 0xc00000e200
? by value foo: {biezhi} address: 0xc00000e1e0
? setNameByPoint: &{biezhi} address: 0xc00000c030
? by point foo: {2333} address: 0xc00000e1e0
而 setNameByPoint
方法和前面的指針類型傳遞是一樣的,方法內(nèi)部內(nèi)存地址是一份指針的拷貝古毛,修改數(shù)據(jù)會(huì)影響到外部指針變量的數(shù)據(jù)翻翩。
一般而言,工程化的項(xiàng)目中會(huì)出現(xiàn)非常多結(jié)構(gòu)體定義方法的代碼稻薇,這些方法的調(diào)用也會(huì)很頻繁嫂冻,使用結(jié)構(gòu)體將其封裝起來,和 Java 中類封裝是一樣的塞椎,大多數(shù)情況下建議都使用指針傳遞桨仿,避免值拷貝的情況。
其他類型
在前面我們有一張圖中分了值類型和引用類型案狠,除了那些常用的基本類型服傍,還有像 map
和 slice
這種引用類型,它們?cè)谑褂蒙嫌悬c(diǎn)像指針(但不用任何操作符如 &
骂铁、*
)吹零,來看個(gè)例子:
func updateMap(mmp map[string]int) {
mmp["biezhi"] = 2333
}
func main() {
mmp := make(map[string]int)
mmp["biezhi"] = 1024
fmt.Printf("src mmp: %v address: %p \n", mmp, &mmp) // 1
updateMap(mmp)
fmt.Printf("new mmp: %v address: %p \n", mmp, &mmp) // 2
}
// output
? src mmp: map[biezhi:1024] address: 0xc000094018
? new mmp: map[biezhi:2333] address: 0xc000094018
如果你嘗試 slice
的話是同樣的效果,可以看到給方法傳遞的并非是一個(gè)指針類型拉庵,但是 map
的值確實(shí)被修改了灿椅,這是為什么呢?
其實(shí)拷貝一個(gè) map
或者 slice
的時(shí)候并沒有拷貝這個(gè)類型(引用類型)里面指向的數(shù)據(jù)钞支,而是拷貝了引用類型(可簡單理解為指針)茫蛹,如何驗(yàn)證這一說法呢?我們?cè)?updateMap
中添加一行輸出代碼:
func updateMap(mmp map[string]int) {
fmt.Printf("param mmp: %v address: %p \n", mmp, &mmp)
mmp["biezhi"] = 2333
}
再次運(yùn)行代碼
src mmp: map[biezhi:1024] address: 0xc000094018
input mmp: map[biezhi:1024] address: 0xc00000c038
new mmp: map[biezhi:2333] address: 0xc000094018
你會(huì)發(fā)現(xiàn) input mmp
這行的地址發(fā)生了變化烁挟,正因?yàn)榭截惖氖沁@個(gè)特殊的 “引用類型”婴洼,會(huì)產(chǎn)生一個(gè)新的地址,而這個(gè)地址 0xc00000c038
和 0xc000094018
指向的是同一份數(shù)據(jù)撼嗓,所以修改后外部的變量也會(huì)得到新的數(shù)據(jù)柬采。
小結(jié)
前面我們通過一些代碼示例來演示了在 Go 中值類型和指針類型的一些具體表現(xiàn),最后我們要回答這么幾個(gè)問題静稻,希望你能夠在使用 Go 編程的過程中更加清晰的掌握這些技巧警没。
Receiver Type 為什么推薦使用指針匈辱?
- 推薦在實(shí)例方法上使用指針(前提是這個(gè)類型不是一個(gè)自定義的
map
振湾、slice
等引用類型) - 當(dāng)結(jié)構(gòu)體較大的時(shí)候使用指針會(huì)更高效
- 如果要修改結(jié)構(gòu)內(nèi)部的數(shù)據(jù)或狀態(tài)必須使用指針
- 當(dāng)結(jié)構(gòu)類型包含
sync.Mutex
或者同步這種字段時(shí),必須使用指針以避免成員拷貝 - 如果你不知道該不該使用指針亡脸,使用指針押搪!
“結(jié)構(gòu)較大” 到底多大才算大可能需要自己或團(tuán)隊(duì)衡量树酪,如超過 5 個(gè)字段或者根據(jù)結(jié)構(gòu)體內(nèi)占用來計(jì)算。
方法參數(shù)該使用什么類型大州?
-
map
续语、slice
等類型不需要使用指針(自帶 buf) - 指針可以避免內(nèi)存拷貝,結(jié)構(gòu)大的時(shí)候不要使用值類型
- 值類型和指針類型在方法內(nèi)部都會(huì)產(chǎn)生一份拷貝厦画,指向不同
- 小數(shù)據(jù)類型如
bool
疮茄、int
等沒必要使用指針傳遞 - 初始化一個(gè)新類型時(shí)(像
NewEngine() *Engine
)使用指針 - 變量的生命周期越長則使用指針,否則使用值類型
參考資料
- Should I define methods on values or pointers?
- Receiver Type
- Pointers vs. values in parameters and return values
轉(zhuǎn)載于
https://blog.biezhi.me/2018/10/values-or-pointers-in-golang.html
https://keer.me/values-or-pointers-in-golang.html