MENU

Golang语言详解 笔记

September 12, 2021 • 学习

楔子

读《Go语言学习笔记》上卷

基于Golang 1.6 amd64,本文记录在阅读过程中值得注意的几点特性,供参考。

switch

switch的行为是有点不一样的。

  • 只有在case中明确添加fallthrough关键字,才会继续执行紧跟的下一个case。
  • fallthrough关键字必须添加在case块末尾。
  • 无需添加break语句

延迟调用

在一个func中可以定义多个延迟调用,关键字defer,语法如下:

 func test(){
     var n int = 0
     n++
     defer f1(n)
     n++
     defer f2(n)
 }
  • defer执行顺序为FILO,先入后出。
  • 相比显式调用,延迟调用会花费一定代价,包括注册、调用、缓存开销等。
  • 延迟调用注册的是调用,所以参数值会在注册的时候被复制并且缓存起来。
  • 对于那些对状态敏感的延迟调用,可以考虑使用指针或者闭包,而不是函数传值。

常量定义

  • 在常量组中,如不指定类型和初始化值,则与上一行非空常量右值(表达式文本)相同。
  • 常量会在编译器预处理阶段直接展开。

iota

Golang没有明确的枚举类型,但可以通过iota来实现。使用如下:

 const(
     _ = iota
     KB = 1 << (10 * iota)
     MB
     GB
 )
 
 const(
     a = iota //0
     b        //1
     c = 100  //100
     d        //100
     e = iota //4
     f        //5
 )
  • 例子一,是一种不太符合个人直觉的写法,但确实凑效。
  • 例子二,如果中断iota自增,则必须显式恢复,且后续自增值按行序递增。该特性和C语言的enum不一致。

Byte & Rune

本质上,Byte和Rune都是别名类型

type byte uint8
type rune int32
  • byte 用于表示的是RawData,字面上更强调他是底层的字节数据。
  • rune 用于表示的是UCS-4/UTF-32的CodePoint,ASCII码一般为1B,常见的拉丁字符均为2B,但是UTF8中文就需要3B,一般而言,int32的4B空间足以存储任何一种字符集的CodePoint了。用单引号的字面量,默认类型为rune

引用类型

  • 特指slice、map、channel三种预定类型。

和普通类型不一样的是,这三种类型的数据结构更加复杂,内部通过指针来引用底层数据。

切片slice

  • 切片是一个只读对象,其工作机制类似数组指针的一种包装。
  • 其本身并非动态数组或数组指针。它内部通过引用底层数组,设定相关属性(cap、len)将数据读写操作限制在指定区域内。
type slice struct{
    array unsafe.Pointer //底层数组指针
    len int //当前长度
    cap int //总长度
}
  • 不是任何时候都适合使用切片来代替数组,由于切片底层引用了数组,所以该数组有可能会在堆上分配内存,会消耗更多代价。
  • 值得注意的是,这里的底层数组指针虽然是指针类型,但更强调引用(区别于uintptr),因为unsafe.Point可以安全持有对象或者对象成员,我们知道Golang是一门自带GC的语言,使用unsafe.Point可以阻止对象被GC。
  • 关于扩容,当超出切片cap限制是,新分配的数组长度是原cap的2倍(非数组的2倍)。同时,并非一定是2倍扩容,在较大切片情况下,会尝试扩容到原来的1.25倍,以节约内存。
  • 拷贝时,允许两个切片对象指向同一个底层数组,允许目标区域重叠,最终复制长度以较短的切片长度为准。

字典map

底层是哈希表,要求key必须支持相等运算符。

字典被设计成"NOT Addressable",故不能直接修改value成员,比如结构体或数组。

针对上述情况,可以考虑使用指针/引用。

字典迭代一般采用Range关键字,但请注意,由于字典是无序的,所以在Range阶段对Map进行插入删除操作时,有可能会影响遍历结果,但似乎不同平台的行为不一致。

通道chan

type hchan struct {
 qcount   uint           // queue 里面有效用户元素,这个字段是在元素出对,入队改变的;
 dataqsiz uint           // 初始化的时候赋值,之后不再改变,指明数组 buffer 的大小;
 buf      unsafe.Pointer // 指明 buffer 数组的地址,初始化赋值,之后不会再改变;
 elemsize uint16  // 指明元素的大小,和 dataqsiz 配合使用就能知道 buffer 内存块的大小了;
 closed   uint32
 elemtype *_type // 元素类型,初始化赋值;
 sendx    uint   // send index
 recvx    uint   // receive index
 recvq    waitq  // 等待 recv 响应的对象列表,抽象成 waiters
 sendq    waitq  // 等待 sedn 响应的对象列表,抽象成 waiters

 // 互斥资源的保护锁,官方特意说明,
 // 在持有本互斥锁的时候,绝对不要修改 Goroutine 的状态,
 // 不能很有可能在栈扩缩容的时候,出现死锁
 lock mutex
}

Channel 实际上是个环形队列。实际的队列空间就在这个channel结构体之后申请的空间。dataqsiz -> data queue size为队列大小。elemsize 为元素的大小。Lock用来保证线程(协程)安全。recvqsendq分别用来保存对应的阻塞队列。

字符串

不可变字节序,是复合结构。

type stringStruct struct{
    str unsafe.Pointer //指向一个字节数组,无Null结尾
    len int
}

由于其不可变特性,用加法拼接字符串时,每次都需重新分配内存,故在超大规模下,性能较差,改进思路是预分配空间。

  • 为什么String没有cap属性,而切片有呢?这是由于上述的不可变特性,而切片是具有可变特性的,需要记录数据区块边界。

结构体

匿名字段

  • 除了接口指针和多级指针外的任何命名类型都可以作为匿名字段,未命名类型因无名字标识,无法作为匿名字段。
  • 指针类型变量会使用基础类型作为字段名。
  • 不能将基础类型和指针类型同时嵌入,因为其两者隐式名字相同。

字段标签

字段标签并不是注释,而是对字段进行描述的元数据,不属于数据成员,但却是类型的组成部分。

obj.Field(index).Tag

内存布局

  • 内存布局类似C语言的结构体,根据数据成员顺序,在内存空间内顺序排列。
  • 由于内存对齐的原因,结构体实际占用字节数永远大于等于结构体所有字段字节数之和。
  • 对于结构体的每个字段,有如下4个概念:

    • 对齐宽度
    • 本身所占字节数
    • 实际占用字节数
    • 偏移量
  • 首先来说,本身所占字节数就是类型大小。当把类型放到结构体时,它实际占用字节数是大于等于类型本身大小的,多出来的部分叫填充字节。也就是说实际占用字节数=本身所占字节数+填充字节数。
  • 如果最后一个字段是空结构类型字段,虽然实际长度为0,但是编译器会将其当作长度为1的类型做对齐处理(padding:7)
  • 如果仅有一个空变量字段,那么同样按1对齐,长度为0,且指向runtime.zerobase变量。

方法

方法和函数具有区别,方法需要和对象实例进行绑定,而函数更强调算法层面。

type N int
func (n N) test(){
    println("Hi!")
}

方法可以看做是一种特殊的函数,这里的(n N) 被称为receiver,receiver可以是基础类型或者是指针类型,这会关系到对象实例是否会被复制。

方法的receiver类型的选择建议如下:

  • 要修改实例状态,用*T。
  • 无需修改状态的小对象或固定值,建议用T。
  • 大对象用*T,减少成本。
  • 引用类型,字符串,函数等指针包装对象,直接用T。(开销小,便于理解)
  • 若包含Mutex等同步字段,用*T,以免复制造成锁操作无效。
  • 其他无法确定的情况,一律用*T。

方法集的包含

  • T包含T
  • *T包含T + *T
  • 匿名S + T,包含S
  • 匿名*S + T,包含S + *S
  • 匿名S或匿名*S + *T,包含S + *S

receiver的本质

这里不得不提到两个概念,Method Expression和Method Value。

Method Expression

type N int
func (n N) test(){
    println("Hi!")
}

func main(){
    var n N = 20;
    f1 := N.test
    f2 := (*N).test
    f1(n)
    f2(&n) //虽然传值为指针,但编译器会根据原定义进行拷贝传值。
}

我们可以看到,虽然test()方法是一个无参方法,但实际上,需要将对象作为第一参数进行传入。这个在语法层面上和OOP语言是不一样的。我们只能模拟OOP的语法,但语法层面实际上是不支持的。

Method Value

type N int
func (n N) test(){
    println("Hi!")
}

func main(){
    var n N = 20;
    f1 := n.test
    f2 := (&n).test
    f1()
    f2()
}

看起来,很像OOP语言的语法,但实际上,编译器行为是不一样的,编译器会为MethodValue生成一个包装函数,实现间接调用,对于receiver的传递,和闭包传递的实现方式相同,打包成funcval,通过DX寄存器传递。

个人理解,MethodValue是类似一种语法糖

instance.method(args) => type.func(instance, args)

接口

  • Golang的接口遵循鸭子类型的匹配规则。
  • 超集接口可以转换为子集接口,但是反之不行。

内部实现

type iface struct{
    tab *itab // 类型信息
    data unsafe.Pointer // 实际对象指针
}

type itab struct {
    inter *interfacetype // 接口类型
    _type *_type // 实际对象类型
    fun [1]uintptr // 实际对象方法地址
}

//对于无方法的接口
type eface struct{
    _type *_type
    data unsafe.Pointer
}

并发

  • Golang在语言层面不使用线程进行调度,而是引入纤程(协程)概念。具体细节在本文不阐释。
  • 简单来说,纤程会被调度到不同线程进行处理,实现对于资源的最大化利用。
  • 相比默认的MB级的线程栈,GoRoutine自定义栈初始仅需2KB,十分适合创建成千上万的并发任务,在需要扩容时,最大可以到GB规模。
  • runtime.GOMAXPROCS用于确定用几个逻辑线程参与并发任务执行(最大256)。
Last Modified: September 15, 2021
Archives QR Code
QR Code for this page
Tipping QR Code