全面分析Go语言中的类型和类型指针的抉择

目录 [−]

在编程语言深入讨论中,经常被大家提起也是争论最多的讨论之一就是按值(by value)还是按引用传递(by reference, by pointer),你可以在C/C++或者Java的社区经常看到这样的讨论,也会看到很多这样的面试题。

对于Go语言,严格意义上来讲,只有一种传递,也就是按值传递(by value)。当一个变量当作参数传递的时候,会创建一个变量的副本,然后传递给函数或者方法,你可以看到这个副本的地址和变量的地址是不一样的。

当变量当做指针被传递的时候,一个新的指针被创建,它指向变量指向的同样的内存地址,所以你可以将这个指针看成原始变量指针的副本。当这样理解的时候,我们就可以理解成Go总是创建一个副本按值转递,只不过这个副本有时候是变量的副本,有时候是变量指针的副本。

这是Go语言中你理解后续问题的基础。

但是Go语言的情况比较复杂,我们什么时候选择 T 作为参数类型,什么时候选择 *T作为参数类型? []T是传递的指针还是值?选择[]T还是[]*T? 哪些类型复制和传递的时候会创建副本?什么情况下会发生副本创建?

本文将详细介绍Go语言的变量的副本创建还是变量指针的副本创建的case以及各种类型在这些case的情况。

副本的创建

前面已经讲到,T类型的变量和*T类型的变量在当做函数或者方法的参数时会传递它的副本。我们先看看例子。

T的副本创建

首先看一下 参数类型为T的函数调用的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main
import "fmt"
type Bird struct {
Age int
Name string
}
func passV(b Bird) {
b.Age++
b.Name = "Great" + b.Name
fmt.Printf("传入修改后的Bird:\t %+v, \t内存地址:%p\n", b, &b)
}
func main() {
parrot := Bird{Age: 1, Name: "Blue"}
fmt.Printf("原始的Bird:\t\t %+v, \t\t内存地址:%p\n", parrot, &parrot)
passV(parrot)
fmt.Printf("调用后原始的Bird:\t %+v, \t\t内存地址:%p\n", parrot, &parrot)
}

运行后输入结果(每次运行指针的值可能不同):

1
2
3
原始的Bird: {Age:1 Name:Blue}, 内存地址:0xc420012260
传入修改后的Bird: {Age:2 Name:GreatBlue}, 内存地址:0xc4200122c0
调用后原始的Bird: {Age:1 Name:Blue}, 内存地址:0xc420012260

可以看到,在T类型作为参数的时候,传递的参数parrot会将它的副本(内存地址0xc4200122c0)传递给函数passV,在这个函数内对参数的改变不会影响原始的对象。

*T的副本创建

修改上面的例子,将函数的参数类型由T改为*T:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main
import "fmt"
type Bird struct {
Age int
Name string
}
func passP(b *Bird) {
b.Age++
b.Name = "Great" + b.Name
fmt.Printf("传入修改后的Bird:\t %+v, \t内存地址:%p, 指针的内存地址: %p\n", *b, b, &b)
}
func main() {
parrot := &Bird{Age: 1, Name: "Blue"}
fmt.Printf("原始的Bird:\t\t %+v, \t\t内存地址:%p, 指针的内存地址: %p\n", *parrot, parrot, &parrot)
passP(parrot)
fmt.Printf("调用后原始的Bird:\t %+v, \t内存地址:%p, 指针的内存地址: %p\n", *parrot, parrot, &parrot)
}

运行后输出结果:

1
2
3
原始的Bird: {Age:1 Name:Blue}, 内存地址:0xc420076000, 指针的内存地址: 0xc420074000
传入修改后的Bird: {Age:2 Name:GreatBlue}, 内存地址:0xc420076000, 指针的内存地址: 0xc420074010
调用后原始的Bird: {Age:2 Name:GreatBlue}, 内存地址:0xc420076000, 指针的内存地址: 0xc420074000

可以看到在函数passP中,参数p是一个指向Bird的指针,传递参数给它的时候会创建指针的副本(0xc420074010),只不过指针0xc420074000和0xc420074010都指向内存地址0xc420076000。 函数内对*T的改变显然会影响原始的对象,因为它是对同一个对象的操作。

当然,一位对Go有深入了解的读者都已经对这个知识有所了解,也明白了T和*T作为参数的时候副本创建的不同。

如何选择 T 和 *T

在定义函数和方法的时候,作为一位资深的Go开发人员,一定会对函数的参数和返回值定义成T和*T深思熟虑,有些情况下可能还会有些苦恼。
那么什么时候才应该把参数定义成类型T,什么情况下定义成类型*T呢。

一般的判断标准是看副本创建的成本和需求。

  1. 不想变量被修改。 如果你不想变量被函数和方法所修改,那么选择类型T。相反,如果想修改原始的变量,则选择*T
  2. 如果变量是一个大的struct或者数组,则副本的创建相对会影响性能,这个时候考虑使用*T,只创建新的指针,这个区别是巨大的
  3. (不针对函数参数,只针对本地变量/本地变量)对于函数作用域内的参数,如果定义成T,Go编译器尽量将对象分配到栈上,而*T很可能会分配到对象上,这对垃圾回收会有影响

什么时候发生副本创建

上面举的例子都是作为函数参数时发生的副本的创建,还有很多情况下会发生副本的创建,甚至有些“隐蔽”的情况。
编程的时候如何小心这些情况呢,一条原则就是:

A go assignment is a copy of the value itself 
赋值的时候就会创建对象副本

Assignment的语法表达式如下:

Assignment = ExpressionList assign_op ExpressionList .
assign_op = [ add_op | mul_op ] "=" .

Each left-hand side operand must be addressable, a map index expression, or (for = assignments only) the blank identifier. Operands may be parenthesized.

最常见的case

最常见的赋值的例子是对变量的赋值,包括函数内和函数外:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main
import "fmt"
type Bird struct {
Age int
Name string
}
type Parrot struct {
Age int
Name string
}
var parrot1 = Bird{Age: 1, Name: "Blue"}
var parrot2 = parrot1
func main() {
fmt.Printf("parrot1:\t\t %+v, \t\t内存地址:%p\n", parrot1, &parrot1)
fmt.Printf("parrot2:\t\t %+v, \t\t内存地址:%p\n", parrot2, &parrot2)
parrot3 := parrot1
fmt.Printf("parrot2:\t\t %+v, \t\t内存地址:%p\n", parrot3, &parrot3)
parrot4 := Parrot(parrot1)
fmt.Printf("parrot4:\t\t %+v, \t\t内存地址:%p\n", parrot4, &parrot4)
}

输出结果:

1
2
3
4
parrot1: {Age:1 Name:Blue}, 内存地址:0xfa0a0
parrot2: {Age:1 Name:Blue}, 内存地址:0xfa0c0
parrot2: {Age:1 Name:Blue}, 内存地址:0xc42007e0c0
parrot4: {Age:1 Name:Blue}, 内存地址:0xc42007e100

可以看到这几个变量的内存地址都不相同,说明发生了赋值。

map、slice和数组

slice,map和数组在初始化和按索引设置的时候也会创建副本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package main
import "fmt"
type Bird struct {
Age int
Name string
}
var parrot1 = Bird{Age: 1, Name: "Blue"}
func main() {
fmt.Printf("parrot1:\t\t %+v, \t\t内存地址:%p\n", parrot1, &parrot1)
//slice
s := []Bird{parrot1}
s = append(s, parrot1)
parrot1.Age = 3
fmt.Printf("parrot2:\t\t %+v, \t\t内存地址:%p\n", s[0], &(s[0]))
fmt.Printf("parrot3:\t\t %+v, \t\t内存地址:%p\n", s[1], &(s[1]))
parrot1.Age = 1
//map
m := make(map[int]Bird)
m[0] = parrot1
parrot1.Age = 4
fmt.Printf("parrot4:\t\t %+v\n", m[0])
parrot1.Age = 5
parrot5 := m[0]
fmt.Printf("parrot5:\t\t %+v, \t\t内存地址:%p\n", parrot5, &parrot5)
parrot1.Age = 1
//array
a := [2]Bird{parrot1}
parrot1.Age = 6
fmt.Printf("parrot6:\t\t %+v, \t\t内存地址:%p\n", a[0], &a[0])
parrot1.Age = 1
a[1] = parrot1
parrot1.Age = 7
fmt.Printf("parrot7:\t\t %+v, \t\t内存地址:%p\n", a[1], &a[1])
}

输出结果

1
2
3
4
5
6
7
parrot1: {Age:1 Name:Blue}, 内存地址:0xfa0a0
parrot2: {Age:1 Name:Blue}, 内存地址:0xc4200160f0
parrot3: {Age:1 Name:Blue}, 内存地址:0xc420016108
parrot4: {Age:1 Name:Blue}
parrot5: {Age:1 Name:Blue}, 内存地址:0xc420012320
parrot6: {Age:1 Name:Blue}, 内存地址:0xc420016120
parrot7: {Age:1 Name:Blue}, 内存地址:0xc420016138

可以看到 slice/map/数组 的元素全是原始变量的副本, 副本。

for-range循环

for-range循环也是将元素的副本赋值给循环变量,所以变量得到的是集合元素的副本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package main
import "fmt"
type Bird struct {
Age int
Name string
}
var parrot1 = Bird{Age: 1, Name: "Blue"}
func main() {
fmt.Printf("parrot1:\t\t %+v, \t\t内存地址:%p\n", parrot1, &parrot1)
//slice
s := []Bird{parrot1, parrot1, parrot1}
s[0].Age = 1
s[1].Age = 2
s[2].Age = 3
parrot1.Age = 4
for i, p := range s {
fmt.Printf("parrot%d:\t\t %+v, \t\t内存地址:%p\n", (i + 2), p, &p)
}
parrot1.Age = 1
//map
m := make(map[int]Bird)
parrot1.Age = 1
m[0] = parrot1
parrot1.Age = 2
m[1] = parrot1
parrot1.Age = 3
m[2] = parrot1
parrot1.Age = 4
for k, v := range m {
fmt.Printf("parrot%d:\t\t %+v, \t\t内存地址:%p\n", (k + 2), v, &v)
}
parrot1.Age = 4
//array
a := [...]Bird{parrot1, parrot1, parrot1}
a[0].Age = 1
a[1].Age = 2
a[2].Age = 3
parrot1.Age = 4
for i, p := range a {
fmt.Printf("parrot%d:\t\t %+v, \t\t内存地址:%p\n", (i + 2), p, &p)
}
}

输出结果

1
2
3
4
5
6
7
8
9
10
parrot1: {Age:1 Name:Blue}, 内存地址:0xfb0a0
parrot2: {Age:1 Name:Blue}, 内存地址:0xc4200122a0
parrot3: {Age:2 Name:Blue}, 内存地址:0xc4200122a0
parrot4: {Age:3 Name:Blue}, 内存地址:0xc4200122a0
parrot2: {Age:1 Name:Blue}, 内存地址:0xc420012320
parrot3: {Age:2 Name:Blue}, 内存地址:0xc420012320
parrot4: {Age:3 Name:Blue}, 内存地址:0xc420012320
parrot2: {Age:1 Name:Blue}, 内存地址:0xc4200123a0
parrot3: {Age:2 Name:Blue}, 内存地址:0xc4200123a0
parrot4: {Age:3 Name:Blue}, 内存地址:0xc4200123a0

注意循环变量是重用的,所以你看到它们的地址是相同的。

channel

往channel中send对象的时候也会创建对象的副本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main
import "fmt"
type Bird struct {
Age int
Name string
}
var parrot1 = Bird{Age: 1, Name: "Blue"}
func main() {
ch := make(chan Bird, 3)
fmt.Printf("parrot1:\t\t %+v, \t\t内存地址:%p\n", parrot1, &parrot1)
ch <- parrot1
parrot1.Age = 2
ch <- parrot1
parrot1.Age = 3
ch <- parrot1
parrot1.Age = 4
p := <-ch
fmt.Printf("parrot%d:\t\t %+v, \t\t内存地址:%p\n", 2, p, &p)
p = <-ch
fmt.Printf("parrot%d:\t\t %+v, \t\t内存地址:%p\n", 3, p, &p)
p = <-ch
fmt.Printf("parrot%d:\t\t %+v, \t\t内存地址:%p\n", 4, p, &p)
}

输出结果:

1
2
3
4
parrot1: {Age:1 Name:Blue}, 内存地址:0xfa0a0
parrot2: {Age:1 Name:Blue}, 内存地址:0xc4200122a0
parrot3: {Age:2 Name:Blue}, 内存地址:0xc4200122a0
parrot4: {Age:3 Name:Blue}, 内存地址:0xc4200122a0

函数参数和返回值

将变量作为参数传递给函数和方法会发生副本的创建。
对于返回值,将返回值赋值给其它变量或者传递给其它的函数和方法,就会创建副本。

Method Receiver

因为方法(method)最终会产生一个receiver作为第一个参数的函数(参看规范),所以就比较好理解method receiver的副本创建的规则了。
当receiver为T类型时,会发生创建副本,调用副本上的方法。
当receiver为*T类型时,只是会创建对象的指针,不创建对象的副本,方法内对receiver的改动会影响原始值。

不同类型的副本创建

bool,数值和指针

bool和数值类型一般不必考虑指针类型,原因在于这些对象很小,创建副本的开销可以忽略。只有你在想修改同一个变量的值的时候才考虑它们的指针。

指针类型就不用多说了,和数值类型类似。

数组

数组是值类型,赋值的时候会发生原始数组的复制,所以对于大的数组的参数传递和赋值,一定要慎重。

1
2
3
4
5
6
7
8
9
10
11
12
13
package main
import "fmt"
func main() {
a1 := [3]int{1, 2, 3}
fmt.Printf("a1:\t\t %+v, \t\t内存地址:%p\n", a1, &a1)
a2 := a1
a1[0] = 4
a1[1] = 5
a1[2] = 6
fmt.Printf("a2:\t\t %+v, \t\t内存地址:%p\n", a2, &a2)
}

输出

1
2
a1: [1 2 3], 内存地址:0xc420012260
a2: [1 2 3], 内存地址:0xc4200122c0

对于[...]T和[...]*T的区别,我想你也应该清楚了,[...]*T创建的副本的元素时元数组元素指针的副本。

map、slice 和 channel

网上一般说, 这三种类型都是指向指针类型,指向一个底层的数据结构。
因此呢,在定义类型的时候就不必定义成*T了。

当然你可以这么认为,不过我认为这是不准确的,比如slice,其实你可以看成是SliceHeader对象,只不过它的数据Data是一个指针,所以它的副本的创建对性能的影响可以忽略。

字符串

string类型类似slice,它等价StringHeader。所以很多情况下会用`unsafe.Pointer`与[]byte类型进行更有效的转换,因为直接进行类型转换string([]byte)会发生数据的复制。

字符串比较特殊,它的值不能修改,任何想对字符串的值做修改都会生成新的字符串。

大部分情况下你不需要定义成*string。唯一的例外你需要 nil值的时候。我们知道,类型string的空值/缺省值为"",但是如果你需要nil,你就必须定义*string。举个例子,在对象序列化的时候""和nil表示的意义是不一样的,""表示字段存在,只不过字符串是空值,而nil表示字段不存在。

函数

函数也是一个指针类型,对函数对象的赋值只是又创建了一个对次函数对象的指针。

1
2
3
4
5
6
7
8
9
10
11
package main
import "fmt"
func main() {
f1 := func(i int) {}
fmt.Printf("f1:\t\t %+v, \t\t内存地址:%p\n", f1, &f1)
f2 := f1
fmt.Printf("f2:\t\t %+v, \t\t内存地址:%p\n", f2, &f2)
}

输出结果:

1
2
f1: 0x2200, 内存地址:0xc420028020
f2: 0x2200, 内存地址:0xc420028030

[]T 还是 []*T, 这是一个问题的更多相关文章

  1. 为什么很多人坚信“富贵险中求”?

    之家哥 2017-11-15 09:12:31 微信QQ微博 下载APP 摘要 网贷之家小编根据舆情频道的相关数据,精心整理的关于<为什么很多人坚信"富贵险中求"?>的 ...

  2. python基础全部知识点整理,超级全(20万字+)

    目录 Python编程语言简介 https://www.cnblogs.com/hany-postq473111315/p/12256134.html Python环境搭建及中文编码 https:// ...

  3. Tomcat一个BUG造成CLOSE_WAIT

    之前应该提过,我们线上架构整体重新架设了,应用层面使用的是Spring Boot,前段日子因为一些第三方的原因,略有些匆忙的提前开始线上的内测了.然后运维发现了个问题,服务器的HTTPS端口有大量的C ...

  4. 如何一步一步用DDD设计一个电商网站(九)—— 小心陷入值对象持久化的坑

    阅读目录 前言 场景1的思考 场景2的思考 避坑方式 实践 结语 一.前言 在上一篇中(如何一步一步用DDD设计一个电商网站(八)—— 会员价的集成),有一行注释的代码: public interfa ...

  5. 如何一步一步用DDD设计一个电商网站(八)—— 会员价的集成

    阅读目录 前言 建模 实现 结语 一.前言 前面几篇已经实现了一个基本的购买+售价计算的过程,这次再让售价丰满一些,增加一个会员价的概念.会员价在现在的主流电商中,是一个不大常见的模式,其带来的问题是 ...

  6. SQLSERVER将一个文件组的数据移动到另一个文件组

    SQLSERVER将一个文件组的数据移动到另一个文件组 有经验的大侠可以直接忽视这篇文章~ 这个问题有经验的人都知道怎麽做,因为我们公司的数据量不大没有这个需求,也不知道怎麽做实验 今天求助了QQ群里 ...

  7. 构建一个基本的前端自动化开发环境 —— 基于 Gulp 的前端集成解决方案(四)

    通过前面几节的准备工作,对于 npm / node / gulp 应该已经有了基本的认识,本节主要介绍如何构建一个基本的前端自动化开发环境. 下面将逐步构建一个可以自动编译 sass 文件.压缩 ja ...

  8. 【造轮子】打造一个简单的万能Excel读写工具

    大家工作或者平时是不是经常遇到要读写一些简单格式的Excel? shit!~很蛋疼,因为之前吹牛,就搞了个这东西,还算是挺实用,和大家分享下. 厌烦了每次搞简单类型的Excel读写?不怕~来,喜欢流式 ...

  9. 如何一步一步用DDD设计一个电商网站(十)—— 一个完整的购物车

     阅读目录 前言 回顾 梳理 实现 结语 一.前言 之前的文章中已经涉及到了购买商品加入购物车,购物车内购物项的金额计算等功能.本篇准备把剩下的购物车的基本概念一次处理完. 二.回顾 在动手之前我对之 ...

  10. 通过一个demo了解Redux

    TodoList小demo 效果展示 项目地址 (单向)数据流 数据流是我们的行为与响应的抽象:使用数据流能帮我们明确了行为对应的响应,这和react的状态可预测的思想是不谋而合的. 常见的数据流框架 ...

随机推荐

  1. mybatis中#{}与${}的区别

    今天学习了下mybatis的查询,了解到了#{}与${}的区别, 配置文件如下: <?xml version="1.0" encoding="UTF-8" ...

  2. html5中的网页结构

    一.html5中的大纲 在html5中,使用各种结构元素所描述出来的整个网页的层次结构,就是该网页的大纲.因此在组织这份大纲的时候,不能使用div元素,因为div元素只能当做容器,用在需要对网页中某个 ...

  3. IndexedDB,FileSystem- 前端数据库,文件管理系统

    "我们不再需要下载并且安装软件.一个简单的web浏览器和一个可供使用的互联网就足以让我们在任何时间, 任何地点, 还有任何平台上使用任何web应用程序." web应用很酷, 但是相 ...

  4. Jquery测试题

    一.Jquery测试题 下面哪种不是jquery的选择器?(单选) A.基本选择器 B.后代选择器 C.类选择器 D.进一步选择器 考点:jquery的选择器 (C) 当DOM加载完成后要执行的函数, ...

  5. 第8章-Java集合 --- 概述

    第8章-Java集合 --- 概述 (1)Java集合类是一种特别有用的工具类,可以用于存储数量不等的多个对象,并可以实现常用的数据结构,如 栈.队列等. (2)Java集合大致可分为Set.List ...

  6. App上线Check List

    1.umeng统计.安全加固等第三方功能到位,本次上线市场列表选择好.2.后台数据库模式准备3.后台数据库数据准备4.后台应用服务器运行检查5.前端界面信息要正确6.前端交互不能出现过度卡顿和经常闪退 ...

  7. MySQL的日志(二):事务日志

    本文目录:1.redo log 1.1 redo log和二进制日志的区别 1.2 redo log的基本概念 1.3 日志块(log block) 1.4 log group和redo log fi ...

  8. Redis模块化基本介绍

    概要 Redis Modules System基本概念 基本应用 参考资料 1. Redis Modules System基本概念 Redis Modules System是4.0出现一大改动点,使得 ...

  9. 自定义ExtJS主题

    ExtJS提供的可以使用的主题包对于创建一个干净专业的程序来说已经很有创意了,然而,你可能还是会希望提供自己的一种设计方式或现在存在的企业设计方式. 从历史上来说,给程序美化就是指的给html标签提供 ...

  10. python都能做什么

    一.python: Python具有丰富和强大的库.它常被昵称为胶水语言,能够把用其他语言制作的各种模块(尤其是C/C++)很轻松地联结在一起.常见的一种应用情形是,使用Python快速生成程序的原型 ...