说说Go有什么数据类型?
Go语言将数据类型分为四类:基础类型、复合类型、引用类型和接口类型。
- 基础类型:数字、字符串和布尔型。
- 复合类型:数据和结构体,它们是通过组合简单类型,来表达更加复杂的数据结构。
- 引用类型:指针、切片、字典、函数、通道,虽然数据种类很多,但它们都是对程序中一个变量或状态的间接引用。这意味着对任一引用类型数据的修改都会影响所有该引用的拷贝。
- 接口类型:接口是一种抽象类型,它定义了一组方法的集合,其作用是实现多态,允许不同的类型通过统一的方式被处理。
什么是包?
- Go语言中的包和其他语言的库或模块的概念类似,目的都是为了支持模块化、封装、单独编译和代码重用。
- 一个包的源代码保存在一个或多个以.go为文件后缀名的源文件中,通常一个包所在目录路径的后缀是包的导入路径。
- 每个包都对应一个独立的名字空间。例如,在
image
包中的Decode
函数和在unicode/utf16
包中的Decode
函数是不同的。要在外部引用该函数,必须显式使用image.Decode
或utf16.Decode
形式访问。
说说Go中各种值的作用域?
- 内置值:对于内置的类型、函数和常量,比如
int、len和true
等是在全局作用域的,因此可以在整个程序中直接使用。 - 局部值:如果一个值在函数或代码块(花括号)内部定义,那么它就只在其内部有效。
- 包级值:如果一个值在函数外部定义,那么将在当前包的所有文件中都可以访问。名字的开头字母的大小写决定了名字在包外的可见性。
- 导入值:对于外部所导入的包,例如
fmt
,则是对应源文件级的作用域,因此只能在当前的文件中访问导入的fmt
包,当前包的其它源文件无法访问在当前源文件导入的包。
说说包的初始化
- 包的初始化首先是解决包级变量的依赖顺序,然后按照包级变量声明出现的顺序依次初始化。
- 如果包中含有多个源文件,它们将按照发给编译器的顺序进行初始化,Go语言的构建工具首先会将源文件根据文件名排序,然后依次调用编译器编译。
init
初始化函数可以自定义包的初始化工作,添加额外的行为。每个源文件可以包含多个init
初始化函数,在程序开始执行时按照它们声明的顺序被自动调用。init
初始化函数除了不能被手动调用或引用外,其他行为和普通函数类似。- 每个包在解决依赖的前提下,以导入声明的顺序初始化,每个包只会被初始化一次。因此,如果一个p包导入了q包,那么在p包初始化的时候可以认为q包必然已经初始化过了,main包会在最后被初始化
说说包的匿名导入
如果只是导入一个包而并不使用导入的包将会导致一个编译错误。但是有时候我们只是想利用导入包而产生的副作用:它会计算包级变量的初始化表达式和执行导入包的init初始化函数,那此时就可以使用包的匿名导入,它会执行包的初始化操作,但无法在当前源文件中去使用该包。
import _ "image/png" // register PNG decoder
在没有给初始值时,变量的值是什么?
- 如果初始化表达式被省略,那么将用零值初始化该变量。
- 数值类型变量对应的零值是0,布尔类型变量对应的零值是false,字符串类型对应的零值是空字符串。
- 接口或引用类型(包括slice、指针、map、chan和函数)变量对应的零值是nil。
- 数组或结构体等聚合类型对应的零值是每个元素或字段都是对应该类型的零值。
什么是简短变量声明
- 简短变量声明的形式诸如
num := 1
,即在创建变量时,不声明变量类型,而是用初始值就自动推断。 - 简短变量声明只能作用于局部变量作用域,无法在包级变量作用域下使用。
- 如果变量在外部已经被声明过,那么简短变量声明语句将会在当前作用域重新声明一个新的变量。这实际上是Go的一种特性,名为遮蔽,一个内层值可以覆盖外层值,而与它的声明方式无关(变量和常量均可),它们两者是相互独立的。
说说Go中的常量
- 常量是编译时确定的值,不会分配内存地址
- 常量在编译时会被直接替换到使用的地方,没有实际的存储位置
- Go语言中不能对常量使用取地址操作符
说说new函数
- new函数是一种创建变量的方式,然而,用new函数创建变量和普通变量声明语句方式创建变量没有什么区别。
- new函数类似是一种语法糖,而不是一个新的基础概念。new函数使用通常相对比较少,因为对于结构体来说,直接用字面量语法创建新变量的方法会更灵活。
说说变量的生命周期
- 对于包级变量来说,它们的生命周期和整个程序的运行周期是一致的。
- 局部变量的生命周期则是动态的,每次从创建一个新变量的声明语句开始,直到该变量不再被引用为止。
- 因为一个变量的生命周期只取决于是否可达,因此一个局部变量的作用范围可能超出其局部作用域。
- 编译器会自动选择在栈上还是在堆上分配局部变量的存储空间,这个选择并不是由用
var
还是new
声明变量的方式决定的,而是看变量的生命周期在脱离函数后是否还存在。 - 常量由于无法访问其地址,因此常量的生命周期是固定的,从声明的位置开始就已经确定了。
说说Go的赋值与类型转换
- 赋值语句左边的变量和右边的值必须有相同的数据类型。更直白地说,只有右边的值对于左边的变量是可赋值的,赋值语句才是允许的。可赋值性的规则对于不同类型有着不同要求,通常来说只要满足以下一个要求即可赋值:
- 类型完全匹配
- nil可以赋值给任何指针或引用类型的变量
- 常量分为无类型常量和有类型常量两种。在将常量赋值给其他变量时,如果是无类型常量且字面量能够转换为对应类型的变量,则赋值成功。如果是有类型常量,则必须进行类型匹配。
- 对于两个值是否可以用
==或!=
进行相等比较的能力也和可赋值能力有关系,只有满足可赋值性才可以比较。 - 一些类型具有相同的底层数据结构,但却拥有不同的数据类型,比如一个int可以用来表示一个文件描述符、或者一个月份。使用
type
关键字可以声明新的类型。即使使用type声明的两个类型有着相同的底层类型,但是它们是不同的数据类型,因此它们不可以赋值、相互比较或混在一个表达式运算。 - Go支持类型转换,但只有当两个类型的底层类型相同时,才允许这种转型操作,或者是两者都是指向相同底层结构的指针类型,这些转换只改变类型而不会影响值本身。
- 特别地,特定的数值类型之间,或者在字符串和一些特定类型的slice之间也是可以相互转换的。
说说Go的数字类型
- Go语言提供int8、int16、int32和int64四种截然不同大小的有符号整数类型,分别对应8、16、32、64bit大小的有符号整数,还有与之对应的uint8、uint16、uint32和uint64四种无符号整数类型。
- 这里还有两种特定于CPU平台机器字大小的有符号和无符号整数int和uint。这两种类型都有同样的大小,32或64bit,但具体大小需要看编译器以及操作系统,这里无法预先确定。
- Go语言提供了两种精度的浮点数,float32和float64,分别对应32和64bit大小。
- Go语言提供了两种精度的复数类型:complex64和complex128,分别对应64和128bit大小。
说说Go的字符串类型
- 一个字符串是一个不可改变的字节序列,无法修改字符串内部数据,底层是一个指向底层数组的指针和其长度,降低了拷贝代价。
- 字符串可以包含任意的数据,包括byte值(等价于uint8类型,用于表示一个字节)。
- 内置的
len
函数可以返回一个字符串中的字节数目,索引操作返回某个位置的字节值。 - 不能直接对字符串或字符串中的某个字符取址(获取指针)。
- 字符串与字符串之间支持相互比较,此时会逐字节比较字符串的底层数据,
- 如果要修改字符串的值,可以通过将字符串类型转换为
byte
切片来进行修改,最后再转换回字符串即可,go支持字符串与byte
切片的互相转换。 - 字符串默认以ASCII码形式显示,但只能表示英文字母在内的128个字符。为了支持其他语言,比如中文,Go内置了
rune
类型来表示一个Unicode编码的字符,它与uint32的类型相同。 - go支持字符串与rune切片的互相转换,这样在通过索引获取字符或获取字符串长度时,就能以Unicode为编码去获取,而不是访问字节。
- 一个原生的字符串面值形式是使用反引号代替双引号。在原生的字符串面值中,没有转义操作;全部的内容都是字面的意思。
Go 语言中如何表示枚举值(enums)?
- 通常使用常量(const) 来表示枚举值。
- 枚举值可以使用iota常量生成器初始化,它的初始值为0,可以生成一组相似规则初始化的常量,同时go也支持将iota置入复杂的表达式中生成初始值。
type StuType int32
const (
Type1 StuType = iota
Type2
Type3
Type4
)
func main() {
fmt.Println(Type1, Type2, Type3, Type4) // 0, 1, 2, 3
}
说说Go的数组?
- 一个数组可以由零个或多个相同元素组成,但其长度是固定的,因此比较少用。
- 数组的长度是数组类型的一个组成部分,像
[3]int
和[4]int
是两种不同的数组类型。 - 数组长度也可以不指定,而是通过初始化值的个数来确定,此时数组长度位置用省略号替代,如
q := [...]int{1, 2, 3}
。 - 可以对数组或数组元素取址,因为数组是值类型,其元素在内存中有固定的位置。
- 在赋值或传参时,数组会发生深拷贝,复制所有元素。
- 数组和数组之间可以相互比较,前提是它们的类型完全匹配,即数组元素和长度都一样,此时会逐个比较数组的每个元素。
说说Go的切片?
- Slice(切片)代表变长的数组,数组中每个元素都有相同的类型。
- slice由三个部分构成:指针、长度和容量。
- 指针:指向第一个slice元素对应的底层数组元素的地址
- 长度:当前slice包含的元素个数
- 容量:底层数组的长度
- 当slice长度超过容量时,Go会自动创建一个新的底层数组并复制数据,新容量通常是原容量的2倍。
- Slice是引用类型,多个slice可以共享同一个底层数组,向函数传递slice将允许在函数内部修改底层数组的元素。如果想执行深拷贝,则需要使用
copy
函数来复制切片。 - 切片和切片操作不同,切片操作可以作用于字符串、数组和切片上,然后生成一个新的切片,其底层的指针指向了原数据。比如字符串x,我们可以对其执行切片操作x[m:n],获取从索引m到n-1的字符串。
- 切片操作可能会抛出异常:
- 如果切片操作针对的是字符串或数组,则不可以超出其长度限制,否则将导致一个panic异常。
- 如果切片操作针对的是切片,起始索引必须小于原切片的长度,否则抛出panic异常。结束索引可以超过原切片长度,此时会导致新切片的扩容。但结束索引不可超过原切片的容量,否则抛出panic异常。
- slice之间不能比较,因此我们不能使用==操作符来判断两个slice是否含有全部相等元素,必须自己展开每个元素进行比较。
- 内置的make函数可以创建一个指定元素类型、长度和容量的slice。容量部分可以省略,在这种情况下,容量将等于长度。
make([]T, len, cap)
- 可以对slice或slice元素取址,但需要注意在对slice元素取址时,如果后续slice进行了扩容,可能导致原指针失效。如果对空slice中的元素取址,会导致panic异常。
说说Go的Map
- Map的底层是哈希表,支持在常数时间复杂度内对数据进行增删改查,Map的Key必须支持比较运算符,这样才可以判断一个Key是否存在。
- Map支持使用make函数创建,也可以经由字面值语法(花括号)创建,此时可以指定初始的key/value
ages := map[string]int{
"alice": 31,
"charlie": 34,
}
- 使用内置的delete函数可以删除元素。
- 不能对map或map的元素进行取址操作,因为map可能随着元素数量的增长而重新分配更大的内存空间,从而可能导致之前的地址无效。
- Map的遍历顺序是随机的,这样可以强制要求程序不会依赖具体的哈希函数实现。如果要按顺序遍历,可以将key转为切片,然后排序,最后根据排序的key来取value。
- 和slice一样,map之间也不能进行相等比较。
- 为了判断某个值是否存在,可以用两个接受值来接收map的取值操作。第二个接收值ok是个布尔值,如果 ok 等于 true,则说明map包含该key,此时第一个接收值将被赋予其key所对应的值。
if val, ok := dict["foo"]; ok {
//do something here
}
- Go中没有set,如果不关心map的value,则可以使用
struct{}
作为value的类型,以此作为占位符,减少内存占用。
说说Go的结构体
- 结构体是一种聚合的数据类型,是由零个或多个任意类型的值聚合成的实体。每个值称为结构体的成员。
- 可以对结构体或结构体的成员取址,这是一个安全操作。
- 结构体成员定义的先后顺序也有意义,顺序不同就是定义了不同的结构体类型。
- 如果结构体成员名字是以大写字母开头的,那么该成员就是导出的,可以被其他包访问和修改。
- 如果结构体没有任何成员的话就是空结构体,写作
struct{}
。它的大小为0,通常作为占位符使用。具体的场景有:不关心value的map、不关心value的通道、只包含方法的结构体。 - 如果结构体的全部成员都是可以比较的,那么结构体也是可以比较的,那样的话两个结构体将可以使用
==或!=
运算符进行比较。相等比较运算符==
将比较两个结构体的每个成员。 - 结构体内部可以只声明一个成员对应的数据类型,而不指名成员的名字,即匿名成员。
- 可以直接通过简单的点运算符x.f来访问匿名成员中的成员,而无需嵌套访问。
- 匿名成员并不是真的匿名,它们的名字就是其类型名字,只不过在点运算符访问成员时,它们的名字可以被省略,当然也可以直接指明。这也导致不能同时包含两个类型相同的匿名成员,这会导致名字冲突。
- 匿名成员可以是指针,以此提高内存使用效率,但此时需要特别注意对匿名成员的修改,可能会导致其他地方也一起发生变化。
- 匿名成员也受导出规则约束,如果匿名成员的类型首字母为小写,那么就是不可导出的,但这并不会影响通过点运算符来访问其中的成员。
- 匿名成员可以用于实现类似继承的操作,即让某个结构体直接获取与之相关的方法集。
- 匿名成员没有数量要求,可以任意添加。如果调用了结构体的成员或方法,则会先从结构体自身的成员和方法中进行名字查找,如果没有,再对其中的匿名成员进行逐一查找,以此递归下去。
Go 语言 tag 的用处?
- tag 可以理解为 struct 字段的注解,可以用来定义字段的一个或多个属性。框架/工具可以通过反射获取到某个字段定义的属性,采取相应的处理方式。tag 丰富了代码的语义,增强了灵活性。
- Tag可以是任意的字符串面值,但是通常是一系列用空格分隔的
key:"value"
键值对序列;因为值中含有双引号字符,因此成员Tag一般用原生字符串面值(即用反引号包含)的形式书写。 - Tag最常见的做法是定义Json文件在编解码时,指定字段在Json文件中的对象名。在编解码时,Go默认使用结构体的成员名字作为JSON的对象名,并且只有导出的结构体成员(首字母大写)才会被编码,因此使用Tag可以更灵活的定义Json的对象名。
package main
import "fmt"
import "encoding/json"
type Stu struct {
Name string `json:"stu_name"`
ID string `json:"stu_id"`
Age int
}
func main() {
buf, _ := json.Marshal(Stu{"Tom", "t001", 18})
fmt.Printf("%s\n", buf)
}
说说Go的模板包?
- Go语言标准库提供了两个强大的模板包用于生成文本输出:text/template(生成文本输出),html/template(生成HTML输出)。他们两者有完全相同的接口。
- 在模板中,大部分的字符串只是按字面值打印,但是如果遇到了双花括号包含的对象,则会触发其中表达式所定义的行为,比如打印对象值、调用函数、使用条件控制流和循环控制流。
- 生成模板的输出需要两个处理步骤:
- 分析模板并转为内部表示,这部分只需要执行一次。模板通常在编译时就测试好了,如果模板解析失败将是一个致命的错误。
- 基于指定的输入执行模板,它本质上是将输入值填充到模板中用双花括号内包含的对象,并执行模板中定义的表达式,以此输出最终的成品。
Go的函数支持默认参数或可选参数吗?
Go中不支持默认参数值和可选参数,也没有任何方法可以通过参数名指定形参,每一次函数调用都必须按照声明顺序为所有参数提供实参。
Go的函数支持多返回值吗?
- Go支持函数返回多个值,而且函数调用者必须显式地接收这些值。
- 如果某个值不被使用,可以将其分配给下划线。
Go的函数支持可变参数吗?
- 参数数量可变的函数称为可变参数函数,Go支持可变参数。
- 在声明可变参数函数时,需要在参数列表的最后一个参数类型之前加上省略符号
...
,这表示该函数会接收任意数量的该类型参数。
func sum(vals ...int) int {
total := 0
for _, val := range vals {
total += val
}
return total
}
- 函数接收的多个可变参数,会在内部组织成切片的形式。如果调用者想传递的值本身就是一个切片,则可以在参数后加上
...
来传递。
values := []int{1, 2, 3, 4}
fmt.Println(sum(values...)) // "10"
Go如何处理错误?
- 对于那些将运行失败看作是预期结果的函数,它们会返回一个额外的返回值,通常是最后一个,来传递错误信息。
- 如果导致失败的原因只有一个,额外的返回值可以是一个布尔值,通常被命名为ok。比如在map中获取值时,该值在map中不存在,则ok的值为false。
- 如果失败原因有多个,则返回error类型值,它是一个接口类型。error类型可能是nil或者non-nil。
- nil意味着函数运行成功,non-nil表示失败。对于non-nil的error类型,我们可以通过调用error的Error函数或者输出函数获得字符串类型的错误信息。
- 这些错误信息被认为是一种预期的值而非异常,它们可以被调用者接收并处理,而不会导致严重的程序错误。
defer 的执行顺序?
- 多个 defer 语句,遵从后进先出(Last In First Out,LIFO)的原则,最后声明的 defer 语句,最先得到执行。
- defer 在 return 语句之后执行,但在函数退出之前,defer 可以修改返回值。
- 在下面这个例子中,可以看到 defer 的执行顺序:后进先出。但是返回值并没有被修改,这是由于 Go 的返回机制决定的,执行 return 语句后,Go 会创建一个临时变量保存返回值,因此,defer 语句修改了局部变量 i,并没有修改返回值。那如果是有名的返回值呢?
func test() int { i := 0 defer func() { fmt.Println("defer1") }() defer func() { i += 1 fmt.Println("defer2") }() return i } func main() { fmt.Println("return", test()) } // defer2 // defer1 // return 0
- 在下面这个例子中,返回值被修改了。对于有名返回值的函数,执行 return 语句时,并不会再创建临时变量保存,因此,defer 语句修改了 i,即对返回值产生了影响。
func test() (i int) { i = 0 defer func() { i += 1 fmt.Println("defer2") }() return i } func main() { fmt.Println("return", test()) } // defer2 // return 1
说说Go的Panic异常?
- Go在编译时捕获很多错误,但有些错误只能在运行时检查,如数组访问越界、空指针引用等。这些运行时错误会引起panic异常。
- 当panic异常发生时,程序会中断运行,并立即执行发生panic时所处函数中被延迟的函数(defer),然后打印异常信息。
- 不是所有的panic异常都来自运行时,直接调用内置的panic函数也会引发panic异常。
- 由于panic会引起程序的崩溃,因此panic一般用于严重错误。对于大部分漏洞,我们应该使用Go提供的错误机制,而不是panic。
- 可以使用recover函数来捕获并处理panic异常,它通常被置于defer处理的函数中。导致panic异常的函数不会继续运行,但由于在defer中可以处理异常,因此能正常返回。在未发生panic时调用recover,recover会返回nil。
func Parse(input string) (s *Syntax, err error) {
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("internal error: %v", p)
}
}()
// ...parser...
}
函数是否支持取址和比较?
- 函数不支持取址,函数在编译期确定地址,运行时不可变,可直接通过值进行传递,无需取址。
- 函数不支持比较,函数的唯一标识是内存地址,但 Go 刻意禁止比较以避免未定义行为。
什么是方法?
- 在函数声明时,在其名字之前放上一个变量,即是一个方法。这个附加的参数会将该函数附加到这种类型上,即相当于为这种类型定义了一个独占的方法。而附加的那个变量则被称为接收器,如下面代码中的p。声明方法之后,就可以通过对象去调用对象类型所有的方法。
type Point struct{ X, Y float64 }
func (p Point) Distance(q Point) float64 {
return math.Hypot(q.X-p.X, q.Y-p.Y)
}
func main(){
p := Point{1, 2}
fmt.Println(p.Distance(q))
}
- 方法也收到Go的导出规则限制,只有首字母大写的方法,其他包才可以进行调用。
- 接收器的名字可以任意,一般来说会取函数附加类型的首字母。并且,Go还支持给任意自定义类型定义方法,即使它的底层类型是Go的基本类型(不可以是指针或接口),如
type Path []Point
。 - 接收器类型可以声明为指针或值,并且无论用值或是指针作为实际接收器,Go都会进行隐式类型转换然后再去调用方法。但是需要注意的是,不能通过一个无法取到地址的值来调用指针方法,比如临时变量或常量。
- 为了避免歧义,在声明方法时,如果一个类型名本身是一个指针的话,是不允许其出现在接收器中的。
type P *int
func (P) f() { /* ... */ } // compile error: invalid receiver type
- 如果声明方法为值方法,那么在调用方法时,会通过值来传递接收器对象,此时发生了深拷贝,这对一个大对象来说并不友好。如果是指针方法,它会通过指针传递接收器对象,此时发生了浅拷贝,效率提升,但是此时对接收器对象的修改也会反应到调用者上。
- 如果声明方法为指针方法,此时需要注意,Go是支持用nil指针作为其接收器的。
- 方法可以绑定特定对象作为接收器,而作为一个普通函数供调用者使用,调用者完全不知道方法背后的接收器是谁。
p := Point{1, 2}
q := Point{4, 6}
distanceFromP := p.Distance // Distance是Point的一个方法,它接收一个类型为Point的参数
fmt.Println(distanceFromP(q)) // "5"
Go支持继承吗?
通过在结构体中内嵌一个匿名成员,可以实现类似继承的效果。比如A中内嵌了B,那么A就可以调用B的方法。但它本身的语义是组合而不是继承,最明显的一点是,我们无法将A对象赋值给B对象,无论是值的形式还是指针的形式。只不过是Go在语法上进行了优化,才导致看起来像是继承。
说说Go的接口?
- 接口类型是一种抽象的类型,它描述了一系列方法。一个实现了所有这些方法的具体类型是这个接口类型的实例,也就是说这个具体类型可以赋值给该接口。最重要的是,这个具体类型完全不用声明它实现了哪个具体接口,只需要实现某个具体接口中的方法即可。
- 接口支持内嵌接口,即组合已有的接口来定义当前接口,而不需要声明它所有的方法。
type ReadWriter interface {
Reader
Writer
}
type ReadWriteCloser interface {
Reader
Writer
Closer
}
-
接口的实现规则比较复杂,对于一个具体类型T来说,可能会有以下三种情况:
- 如果实现接口方法的接收器均为T,则可以认为T和T指针都实现了该接口,也就是可以将T和T指针赋值给该接口。因为此时无论是T还是T指针,均可以通过隐式转换来调用接口方法。
- 如果实现接口方法的接收器均为T指针,那么只有T指针实现了该接口,T类型对象无法赋值给该接口。因为T有可能是无法取址的,比如常量或临时变量,导致隐式转换失败,因此Go不会将这种不一定安全的对象认为实现了接口。
- 如果实现接口方法的接收器既有T指针
-
Go中有一个默认接口
interface{}
,它被称为空接口,它对实现它的类型没有要求,所以我们可以将任意一个值赋给空接口类型。 -
一个接口值由两个值组成:动态类型(T)和动态值(V)。
- 接口在初始化时,如果未定义初始值,那么T和V都会是nil。
- 如果T为nil,那么接口值就是空值,即
==nil
。但如果T不为nil,而V是nil的话,接口值就不等于nil了。调用一个空接口值上的任意方法都会产生panic。 - 对接口值的赋值会修改T和V,并将其绑定到具体的类型和值上面去。如果对接口值赋值为nil,则T和V都会被赋值为nil。
- 接口值之间可以使用
==
和!=
来进行比较。两个接口值相等仅当它们都是nil值,或者它们的动态类型相同并且动态值也根据这个动态类型的==操作相等。如果两个接口值的动态类型相同,但是这个动态类型是不可比较的(比如切片),将它们进行比较就会失败并且panic。 - 接口值在与非接口值比较时,Go会先将非接口值尝试转换为接口值,再比较。
- 接口值很特别,其它类型要么是可比较类型(如基本类型和指针)要么是不可比较类型(如切片,映射类型,和函数),但是接口值视具体的类型和值,可能会报出潜在的panic。
-
接口本身是可寻址的,无论它存储的是什么类型的值,但对于接口的具体值来说,需要看它是否支持可寻址。
什么是类型断言?
- 类型断言是一个使用在接口值上的操作,其形式为
x.(T)
,这里x表示一个接口的类型,T表示一个类型。一个类型断言会检查接口的动态类型是否和断言的类型相匹配。 - 如果断言的类型T是一个具体类型,此时会判断x的动态类型是否是T,如果成功则返回x的动态值,其类型为T,也就是通过类型断言获取到了接口的具体值。如果失败,则抛出panic。
- 如果断言的类型T是一个接口类型,此时会判断x的动态类型是否满足接口T,如果成功则返回一个有相同动态类型和值部分的接口值,其类型为T,也就是通过类型断言改变了类型的表述方式,如果失败,则抛出panic。
- 如果断言操作的对象是一个nil接口值,那么不论被断言的类型是什么这个类型断言都会失败。
- 如果不想抛出异常,则可以用两个值去接收类型断言的结果,第二个值是个bool值,通常命名为ok。如果断言失败,则ok为false,如果成功,则ok为true。
什么是协程(Goroutine)?
Go 协程(Goroutine)是 Go 语言中轻量级的并发执行单元,由 Go 运行时(runtime)管理,是 Go 原生支持高并发的核心特性,有以下特点:
- 轻量级线程:比操作系统线程更轻量(初始栈仅几 KB,可动态扩容),创建和切换成本极低
- 用户态调度:由 Go 运行时调度,而非操作系统内核,避免了线程上下文切换的高开销。
- 多对多模型:多个 Goroutine 复用在少量 OS 线程上。
- 最大线程数:Go使用了一个叫做GOMAXPROCS的变量来决定会有多少个操作系统的线程同时执行Go的代码,该值可以通过环境变量显式修改,默认值是当前机器的CPU核心数。对于CPU 密集型的任务,若该值过大,会增加线程切换的开销,降低性能。对于 I/O 密集型应用,适当地调大该值,可以提高 I/O 吞吐率。
- 没有ID:Go的协程没有ID号去标识,因为Go的设计理念就是尽可能地简单。
什么是通道(Channel)?
- channels则是协程之间的通信机制。每个channel都有一个特殊的类型,也就是channels可发送数据的类型。比如一个可以发送int类型数据的channel一般写为
chan int
。 - 使用make函数可以创建channel,它属于引用类型,即在进行函数传参时,只是拷贝了一个channel引用。和其它的引用类型一样,channel的零值也是nil。
- channel支持取址,但用途有限,因为channel本身就是引用类型。需要注意的是,Go禁止对通道中接收的元素取址,因为通道的元素在接收时是值的副本,取址无意义。
- 两个相同类型的channel可以使用
==
运算符比较,但只是用来对两个通道变量是否指向同一个底层通道,一个channel也可以和nil进行比较。 - Go支持创建单方向的channel,即只能用于发送或接收的channel。类型
chan<- int
表示一个只发送int的channel,只能发送不能接收。相反,类型<-chan int
表示一个只接收int的channel,只能接收不能发送。这两种类型都可以作为函数参数接收一个双向channel,也就是在函数内限制了channel的方向,并且这种限制将在编译期检测。 - 一个未初始化的channel,即nil值的channel,无法发送数据和接收数据,如果执行相关语句,则会永久阻塞。
如何关闭通道?
- Go支持使用close函数来关闭channel,随后对基于该channel的任何发送操作都将导致panic异常。对一个已经被close过的channel进行接收操作依然可以接受到之前已经成功发送的数据;如果channel中已经没有数据的话将产生一个零值的数据。
- 在接收通道数据时,可以多接收一个结果,多接收的第二个结果是一个布尔值ok,ture表示成功从通道接收到值,false表示通道已经被关闭并且里面没有值可接收。
- 与打开文件不同,关闭通道的操作并不是必须的,不管一个channel是否被关闭,当它没有被引用时将会被Go语言的垃圾自动回收器回收。
- 试图重复关闭一个channel、,关闭一个nil值的channel、关闭一个只有接收方向的channel都将导致panic异常。
无缓冲的通道和带缓冲的通道之间的区别?
- 无缓冲的通道在协程对通道写数据时会阻塞,直到其他协程通过通道读取数据,反之也一样。无缓冲的通道通常用于同步协程,控制协程执行的先后顺序。
- 带缓冲的通道相当于一个队列,队列的最大容量通过make函数的第二个参数指定。对带缓冲的通道可以无阻塞的写入数据,直到队列已满才会阻塞,读取也是同样,直到队列一空才会阻塞。
什么是通道泄露?
如果某个协程对某个通道的写和读数据一直停留在阻塞中,这就被称为通道泄露。这是一种运行时的错误,此时通道无法被Go的垃圾自动回收器回收,浪费系统资源。
什么是select?
- select语句和switch语句有点像,由几个case和default来控制分支,只不过它专门用于多通道的多路复用。
- 每个case代表一个通信操作(在某个channel上进行发送或者接收),如果某个case操作执行成功,则会触发事先定义的语句块。如果所有 case 的 channel 操作均无法立即执行,则channel会阻塞,直到至少一个channel准备就绪。
- case中的接收操作既可以只包含接收表达式自身(不把接收值赋值给变量),也可以将接收值赋值给某个变量,以供后续的操作。
- 一个没有任何内容的select语句,即select{},会永久阻塞。
- 如果case中的channel是nil值的channel,则会被select所忽略,就像该分支不存在。如果所有 case 的 Channel 都是 nil,则 select 会永久阻塞。
- 如果要避免阻塞channel,可以设置default分支,这样即使所有case均未就绪或者所有channel都是nil,也会执行default分支,然后继续执行后面的代码。
- 如果多个case同时就绪时,select会随机地选择一个执行,这样来保证每一个channel都有平等的被select的机会。
- 即使关闭了case中的某个通道,但在接收数据时仍会收到nil值,该值在select语句是合法的,也就是仍然会执行对应的语句块。为了判断通道是否关闭,需要使用第二个接收值来接收通道值,然后在相应的语句块中进行判断,以此区分nil值到底是真实数据还是关闭信号。
- select无法区分nil值是一个真实数据还是关闭信号,但也可以以此为特性,创建一个无需发送数据的通道,只需要在适当的时期去关闭它,就可以让所有监听该通道的select立即执行对应的语句块,这种操作通常用于某个协程来通知多个协程退出并发。
如何遍历一个通道?
- 可以使用for+range循环遍历一个channel,它会不断从通道中读取数据,并执行循环体内的语句,如果没有数据可读,则会进行阻塞。
- 如果通道关闭,则会取出通道中剩余的所有数据,然后立即退出循环。它与select不同,可以判断出当前接收的nil值到底是真实数据还是关闭信号。
- 如果遍历一个nil值的channel,则循环会永久阻塞,因为这个channel的接收操作永远不会返回数据。
什么是CSP?
CSP(Communicating Sequential Processes,通信顺序进程)并发编程模型,它的核心思想是:通过通信共享内存,而不是通过共享内存来通信。Go 语言的Goroutine 和 Channel机制,就是 CSP 的经典实现,具有以下特点:
- 避免共享内存:进程(Goroutine)不直接修改变量,而是通过 Channel 通信
- 天然同步:Channel 的发送/接收自带同步机制,无需手动加锁
- 易于组合:Channel 可以嵌套使用,构建复杂并发模式(如管道、超时控制)
说说Go在并发下的互斥同步工具?
- 互斥锁(sync.Mutex):确保同一时间只有一个 Goroutine 能访问共享资源。
- 读写锁(sync.RWMutex):优化读多写少的场景,允许多个 Goroutine 同时读,但写时独占。
- sync.Once:确保某段代码只执行一次(如单例初始化),只需要事先定义该变量,然后调用该变量的Do函数传入一个函数即可。
- sync.WaitGroup:通常用于等待多个Goroutine完成。比如一个主协程可以调用Wait函数阻塞等待,其他协程调用Done函数表示当前协程完成,Add函数用于设置需要等待Done的数量。
如何高效地拼接字符串?
Go 语言中,字符串是只读的,也就意味着每次修改操作都会创建一个新的字符串。如果需要拼接多次,应使用 strings.Builder
,最小化内存拷贝次数。
var str strings.Builder
for i := 0; i < 1000; i++ {
str.WriteString("a")
}
fmt.Println(str.String())
字符串打印时,%v 和 %+v 的区别?
%v
和%+v
都可以用来打印struct
的值,区别在于%v
仅打印各个字段的值,%+v
还会打印各个字段的名称。但如果结构体定义了String()
方法,%v
和%+v
都会调用String()
覆盖默认值。
type Stu struct {
Name string
}
func main() {
fmt.Printf("%v\n", Stu{"Tom"}) // {Tom}
fmt.Printf("%+v\n", Stu{"Tom"}) // {Name:Tom}
}
说说Go的测试技术
go test
命令用于测试代码,在包目录内,所有以_test.go
为后缀名的源文件在执行go build时不会被构建成包的一部分,它们是go test
测试的一部分。_test.go
中的每个测试函数必须导入testing
包。测试函数有如下的签名:
func TestName(t *testing.T) {
// ...
}
- 测试函数的名字必须以Test开头,可选的后缀名必须以大写字母开头:
func TestSin(t *testing.T) { /* ... */ }
func TestCos(t *testing.T) { /* ... */ }
func TestLog(t *testing.T) { /* ... */ }
什么是反射?
- Go语言提供了一种机制,能够在运行时更新变量和检查它们的值、调用它们的方法和它们支持的内在操作,而不需要在编译时就知道这些变量的具体类型。这种机制被称为反射,它让程序员可以将类型作为值去处理。
- 反射是由 reflect 包提供的。它定义了两个重要的类型,Type 和 Value。一个 Type 表示一个Go类型。它是一个接口,有许多方法来区分类型以及检查它们的组成部分,例如区分一个结构体的成员或一个函数的参数等。函数 reflect.TypeOf 接受任意的 interface{} 类型,并以 reflect.Type 形式返回其动态类型。
- reflect 包中另一个重要的类型是 Value,它是一个结构体,其中包含了指向源值的指针。一个 reflect.Value 可以装载任意类型的值。函数 reflect.ValueOf 接受任意的 interface{} 类型,并返回一个装载着其动态值的 reflect.Value。对Value调用Type方法将返回具体类型所对应的reflect.Type。
- reflect.ValueOf 的逆操作是 reflect.Value.Interface 方法。它返回一个 interface{} 类型,装载着与 reflect.Value 相同的具体值。reflect.Value 和 interface{} 都能装载任意的值,不同的是,一个空的接口隐藏了值内部的表示方式和所有方法,因此只有我们知道具体的动态类型才能使用类型断言来访问内部的值,而一个 Value 则有很多方法来检查其内容,无论它的具体类型是什么。
- 利用反射,我们可以在不知道一个值类型的情况下,对值进行赋值,获取结构体的字段标签、获取和调用其方法,非常强大。但是反射也有几个问题,一个是代码比较脆弱,很多错误到运行时才能检查,二是基于反射的代码通常比正常的代码运行速度慢。
说说Go的unsafe包
- Go语言的设计包含了诸多安全策略,限制了可能导致程序运行出错的用法。但有时候,也需要放弃部分语言特性,优先选择更好性能的方法,此时就可以使用unsafe包,来摆脱Go语言规则带来的限制。
- unsafe.Sizeof返回变量或类型在内存中占用的字节数(不包括可能引用的其他内存,如切片底层数组),unsafe.Alignof返回变量或类型的内存对齐要求,unsafe.Offsetof返回结构体字段相对于结构体起始地址的字节偏移量。
- unsafe.Pointer 是一个特殊的指针类型,可以指向任意类型的数据,并支持以下操作
- 跳过Go的类型检查,实现任意指针类型之间的转换,只改变解释方式不改变底层值。
- 支持指针运算,即通过增减偏移量来访问其他元素(数组或结构体),但必须转换类型为uintptr来才可以实现,uintptr和unsafe.Pointer支持相互转换。
- unsafe包让程序员可以透过Go的抽象层直接使用一些必要的功能,虽然可能是为了获得更好的性能。但是代价就是牺牲了可移植性和程序安全,因此使用unsafe包是一个危险的行为。
什么是协程泄露?
协程泄露是指协程创建后,长时间得不到释放,并且还在不断地创建新的协程,最终导致内存耗尽,程序崩溃。造成协程泄露的原因通常是因为通道泄露、死锁和无限循环导致的。
说说Go的垃圾回收(GC)机制
- 最常见的垃圾回收算法有标记清除(Mark-Sweep) 和引用计数(Reference Count),Go 语言采用的是标记清除算法。并在此基础上使用了三色标记法和写屏障技术,提高了效率。
- 标记清除:
- 它是一种跟踪式的追踪算法,其核心思想是判断一个对象是否可达,如果这个对象一旦不可达就可以立刻被GC回收了。它分为两个阶段:标记和清除。
- 标记阶段:首先从根节点开始找出所有的全局变量和当前函数栈里的变量,标记为可达。然后从已经标记的数据开始,进一步标记它们可访问的变量,以此类推。当追踪结束时,没有被打上标记的对象就被判定是不可达。
- 清除阶段:遍历堆中所有对象,回收没有被标记的对象,然后将回收的内存加入空闲链表供下次分配。
- 它解决了引用计数问题中的循环引用问题,也减小了一个变量的占用空间。但同时引入了其他缺点,一是无法立刻识别出垃圾对象,需要依赖GC线程,二是算法在标记阶段时必须暂停整个程序,即STW(stop the world),否则其他线程有可能会修改对象的状态从而回收不该回收的对象。
- 为了提供异步效率,减少STW的时间,Go采用了三色标记法,将程序中的对象分成白色、黑色和灰色三类:
- 白色对象:潜在的垃圾,表示还未搜索到的对象,其内存可能会被垃圾收集器回收
- 黑色对象:活跃的对象,表示搜索完成的对象,表示从根对象可达的对象
- 灰色对象:活跃的对象,表示正在搜索还未搜索完的对象,GC会继续扫描这些对象中引用的其他外部对象。
- 三色标记法的实现步骤:
- 所有对象加入白色集合(这一步需 STW )。
- 从根对象出发,扫描所有可达对象标记为灰色,放入待处理队列。
- 从待处理队列取出一个灰色对象并标记为黑色,将从该对象中可达的其他对象标记为灰色,放入待处理队列。
- 重复上一步骤,直到灰色对象队列为空。
- 此时剩下的所有白色对象都是垃圾对象,需要执行清除。
- 三色标记法因为多了一个白色的状态来存放不确定对象,所以后续的标记阶段可以并发地执行。当然并发执行的代价是可能会造成一些遗漏,因为那些早先被标记为黑色的对象可能目前已经是不可达的了。所以三色标记法是一个 false negative(假阴性)的算法。
- 三色标记法并发执行仍存在一个问题,即在标记过程中,对象指针发生了改变。如
A (黑) -> B (灰) -> C (白) -> D (白)
,正常情况下,D 对象最终会被标记为黑色,不应被回收。但在标记和用户程序并发执行过程中,用户程序删除了 C 对 D 的引用,而 A 获得了 D 的引用。标记继续进行,D 就没有机会被标记为黑色了(A 已经处理过,这一轮不会再被处理)。为了解决这个问题,Go使用了写屏障(Write Barrier)技术,它相当于一个钩子方法,当对象新增或更新时,会将其标记为灰色。这样即使与用户程序并发执行,对象的引用发生改变时,GC也能正确处理了。 - 这样一来,Go的完整GC机制就包含4个步骤:
- 标记准备(Mark Setup,需 STW),打开写屏障(Write Barrier)。
- 使用三色标记法标记(Marking, 并发)。
- 标记结束(Mark Termination,需 STW),关闭写屏障。
- 清理(Sweeping, 并发)。
评论