文章

What about Go?

本文目录 12

Go 语言

Go语法学习,from a cpp & python programmer’s perspective

Intro

Go语言是一个现代的开源编程语言;他被设计为一门应用于搭载 Web 服务器,存储集群或类似用途的巨型中央服务器的系统编程语言。

对于高性能分布式系统领域而言,Go 语言无疑比大多数其它语言有着更高的开发效率,所以它是一个对游戏服务端开发友好的语言

最典型也是最成功用Go开发的项目就是k8s

Go和C一样都是编译型的语言,性能接近C,同时编译速度比C快很多;但是由于GC机制的存在,Go几乎不用烦恼内存管理,由此他的开发效率是比c高的


语言特性

基础语法

语言结构

package main

import "fmt"

func main() {
   /* 这是我的第一个简单的程序 */
   fmt.Println("Hello, World!")
}
  1. 定义文件属于哪个package
  2. 导入package
  3. 可执行程序的接口main,类似c

数据类型&语言变量

数据类型

  1. bool
  2. 数字,包括int, float32 ,float64, uint等等
  3. 字符串
  4. 派生类别,包括Pointer, Array, Struct,Interface, slice, map, etc.

声明变量的一般形式是使用 var 关键字:

var identifier type

可以一次声明多个变量:

var identifier1, identifier2 type

也可以省略var和类型,用短变量声明:=让编译器自动推断类型;但是它不能用于全局变量,且他是一个声明操作而不是赋值操作

Constant 常量

const和其他编程语言一样,都是在程序运行时不会被修改的值

而Go因为没有enum 枚举关键字,所以通常使用常量组来实现枚举效果

const (
    Unknown = 0
    Female  = 1
    Male    = 2
)

同时go里有一个特殊的常量,也被成为常量计数器iota

iota就是一个英文单词,意为极小量

// iota 从 0 开始,每行加 1
const (
    a = iota   // 0
    b          // 1(省略值,默认使用上一行的 iota)
    c          // 2package main

import "fmt"

// iota 从 0 开始,每行加 1
const (
    a = iota   // 0
    b          // 1(省略值,默认使用上一行的,在这个例子里是iota)
    c          // 2
)

func main() {
    fmt.Printf("a=%d, b=%d, c=%d\n", a, b, c)
}
)

结果为a=0, b=1, c=2

运算符、条件语句、 循环语句、数组 略

分支语句

这里特地提一下分支,go使用select和case`关键字

select{
    case x:
    	x = x + 1
    case y:
    	y = y + 1
}

这里比较独特的一个点是,select会等待一个case的完成并进入,比如这里结合并行的一些东西

func 函数

func function_name( [parameter list] ) [return_types] {
   函数体
}

变量的作用域和c相同

struct 结构体

type struct_variable_type struct {
   member definition
   member definition
   ...
   member definition
}

// 使用{}进行初始化 
// variable_name := structure_variable_type {value1, value2...valuen}

range

同python,对可以迭代的变量类型,如array,slice,channel,map进行循环迭代并返回索引+索引值/key-value对

slice 切片

go的切片还挺有特点的,和python的切片可以说是完全不一样

python的切片是值拷贝,开了一片新的内存来copy进去

如果从类似python的视角来切入,从一个已有的数组来初始化切片,那么go的切片相当于是对原数组的一个引用

改变切片中的元素,原数组中的元素也会发生对应的变化

a := [5] int {1,2,3,4,5}
b = a[1:4]  # [2, 3, 4]

b[0] = 999
fmt.Println(a)  // [1, 999, 3, 4, 5]  (被改写了!)
fmt.Println(b)  // [999, 3, 4]

但是其实slice提出的目的更多是作为一个动态数组被使用的,与数组相比,切片是可以追加和扩容的

可以把go的切片理解为一个结构体

type slice struct {
    array unsafe.Pointer  // 指向底层数组的指针
    len   int             // 长度
    cap   int             // 容量
}

切片的声明是是var identifier [ ] type,和数组的区别就是中括号内是空白的;

要创建一个切片可以用make函数 slice1 := make ([ ]type, len, capacity)(cap是可选项),也可以原地初始化s:= [ ]int {1,2,3},其中len=cap=3;或者像上面一样用数组的切片引用初始化

得到一个切片之后,可以用和python类似的语法去做截取,也可以用copy(slice_new,slice_old)和slice1 = append(slice1,1)(append会返回一个slice,其可能是新的,取决于append需不需要扩容)


Interface 接口

Go 的接口设计简单却功能强大,是实现多态和解耦的重要工具

recap: 多态指同一个行为,不同的对象表现出不同的形态

第一次看到这个东西的时候感觉和“基类”+“虚函数”非常像

满足某个interface要求的所有方法的类,就认为是这个接口的一个实现;和基类定义一个虚函数,继承类override这个虚函数类似。针对编写接口的一个函数,可以传入接口的任何一个实现作为参数;而针对基类编写的函数,也能传入子类的对象作为参数

我能想到的,唯一的从语法上的区别,或者说其中一者能做而另一者不能做的,就是接口是只定义方法,而没有字段。而基类拥有自己的字段并可以独立的运作,或者说基类也是一个可以实例化的类(在没有纯虚函数的情况下)

而在逻辑上,基类(继承)体现的是一种是什么的关系,是这个类的ID card;接口(功能)体现的则是能做什么的关系,一个class可以实现无数个接口,他是这个类的类似于资格证书的东西

举个例子,假设你写了一个 Bird基类,它有 Fly() 方法。后来你发现,Airplane也能飞。如果不用接口,你只能让 Airplane 去继承 Bird。这在逻辑上是荒谬的,而且在技术上会带来飞机拥有“下蛋”、“羽毛”等无用的垃圾字段

而如果使用接口去定义一个 interface Flyable { Fly(); }。Bird 可以实现它,Airplane 也可以实现它。两者互不干扰,不共享任何无意义的数据

另外,go是 Duck Typing 的,即它不用显式的implement一个interface,而是只要你实现了这个interface的功能,在编译的时候会自动被视为一个接口的实现

package main

import "fmt"

// 1. 定义接口:只规定“能飞”,不包含任何数据
type Flyable interface {
    Fly()
}

// 2. 麻雀:完全不知道接口的存在,只实现自己的方法
type Sparrow struct {
    FeatherColor string
}

func (s Sparrow) Fly() {
    fmt.Println("麻雀扑腾翅膀飞") 
}

func (s Sparrow) LayEggs() {
    fmt.Println("下了一个蛋")
}

// 3. 飞机:独立实现,不继承鸟的任何东西
type Airplane struct {
    EngineType string
}

func (a Airplane) Fly() {
    fmt.Println("飞机依靠引擎推力飞行") //实现了Fly,duck typing
}

// 飞机没有 LayEggs(),也没有 FeatherColor

func main() {
    // 4. 关键:因为都有 Fly() 方法,两者自动成为 Flyable 类型
    var flyables []Flyable
    flyables = append(flyables, Sparrow{FeatherColor: "棕色"})
    flyables = append(flyables, Airplane{EngineType: "涡轮风扇"})
    
    // 5. 统一调用,各飞各的
    for _, f := range flyables {
        f.Fly()
    }
    // 输出:
    // 麻雀扑腾翅膀飞
    // 飞机依靠引擎推力飞行
}

Inheritance?Composition! 组合

既然Go是一个青睐interface的语言,那么自然它就不会再去费工夫实现一个类的继承,通过将一个结构体嵌入到另一个结构体中,子结构体就可以”继承”父结构体的字段和方法

但是这和c里拥有一个私有对象又是不一样的,组合是匿名的嵌入,只用声明一个类而没有实例化的对象,并且能获得这个对象的方法提升,下面展示了两者的区别:

type Writer struct {
    Name string
}

func (w Writer) Write(data string) {
    fmt.Println(w.Name, "is writing:", data)
}

type Logger struct {
    w Writer  // ❌ 这是私有字段,不是组合!
}

l := Logger{w: Writer{Name: "File"}}
l.w.Write("hello")  // 必须通过 w 访问,麻烦!
type Logger struct {
    Writer  // ✅ 匿名嵌入,方法自动提升
}

l := Logger{Writer: Writer{Name: "File"}}
l.Write("hello")  // ✅ 直接调用,仿佛 Logger 自己的方法!

可以看到l能直接调用Write函数了,也就是说组合可以得到所有的方法

但是这样的方法是没有类型的层次结构的,所以也就不符合面向对象编程的要求,事实上Go也并不完全是面向对象编程的

指针

从一个有c基础的人看来,go的指针无疑是简单的(其他所有语言的指针都是简单的)

这里先不浪费笔墨了

并发

并发应该算是Go最亮眼的特性了,Goroutine 是一种比操作系统线程轻量得多的执行单元——每个 Goroutine 的初始栈大小仅为约 2 KB,而操作系统线程通常在 1 MB 以上,你可以在一台普通机器上同时运行数十万个 Goroutine,这是传统线程模型无法做到的,也是他能在云服务器上被广泛使用的原因

go中创建一个goroutine并不不会像c那样创建一个全新的系统线程,而是在运行的时候通过调度器,把大量的goroutine调度到少量系统线程上

一些概念/单元:

Goroutines:

  • Go 中的并发执行单位,类似于轻量级的线程
  • 他的调度由 Go 运行时管理,用户无需手动分配线程

Channel:

  • Go 中用于在 Goroutine 之间通信的机制
  • 支持同步和数据共享,避免了显式的锁机制
  • 使用 chan 关键字创建,通过 <- 操作符发送和接收数据

Scheduler(调度器):

Go 的调度器基于 GMP 模型,调度器会将 Goroutine 分配到系统线程中执行,并通过 M 和 P 的配合高效管理并发。

  • G:Goroutine
  • M:系统线程(Machine)
  • P:逻辑处理器(Processor)

Goroutine

语法:go function_name (params)

Channel

当进程之间共享变量的时候,可能会引发竞态(data race);而为了解决这种竞态,c中引入了种种机制比如mutex.condition variable等等,为了让共享的变量被操作的过程是原子化即不被打扰的。Go这里引入了一个试图“解决”竞态的工具channel,用来实现不同进程之间的通信,从而绕过共享变量可能带来的问题

Date race:

以+1为例子,这个看似简单的操作,实则是由load,add,write等微小的汇编指令完成的;

如果在第一条+1结束但是没有写回内存的时候,下一条+1读取到的值还是原来的值,会造成这个共享变量最终的值不是我们想要的

使用 make 函数创建一个 channel,使用 <- 操作符发送和接收数据。如果未指定方向,则为双向通道

ch := make(chan int) // 使用chan关键字船舰通道

ch <- v    // 把 v 发送到通道 ch
v := <-ch  // 从 ch 接收数据
           // 并把值赋给 v

func worker(ch chan int) {
    result := 42
    ch <- result //将结果传入通道
}

func main() {
    ch := make(chan int)

    go worker(ch)

    result := <-ch //从通道拿到结果
    fmt.Println(result)
}

以这里的result为例子,可以通过一个通道把一个routine里的值传出来。当然c的thread也可以返回一个值并赋给main里的某个变量,但是go的一个worker可以在退出前发送很多结果:

func worker(ch chan int) {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch)
}

func main() {
    ch := make(chan int)
    go worker(ch)

    for result := range ch {
        fmt.Println(result)
    }
}

C 的线程返回值不适合表达这种持续通信。如果用 C 实现,通常需要自己组合:

  • 共享队列
  • 互斥锁
  • 条件变量
  • 队列关闭标志
  • 内存生命周期管理

值得注意的是,如果这个channal是没有缓冲的,那么这条goroutine会阻塞一直等待channel里的东西被取走之后再恢复,比如下面这段code

package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan int)

	go func() {
		fmt.Println("sender: before send")

		ch <- 42 //这里阻塞,等待被取走

		fmt.Println("sender: after send")
	}()

	time.Sleep(2 * time.Second)

	fmt.Println("receiver: ready to receive")
	x := <-ch

	fmt.Println("receiver: got", x)

	time.Sleep(time.Second)
}

这里“sender: after send“会在“receiver: ready to receive”之后被打印(但是“sender: after send“和“receiver:got”的先后顺序是不一定的,取决与scheduler的行为)

async & sync

go里没有async.sync的关键词,当使用go function的时候,就相当于


Runtime Character

GC

C语言是手动malloc+free堆上的内存的,

关于malloc这部分的知识可以看另一篇博客

Reference

[菜鸟教程](Go 简介 | 菜鸟教程)