Go语言设计摘要

背景

写Go第三年了,但是Go相关的官方文档并没有看过,这篇文章主要是参考了Go FAQ,对其中有意思的部分进行记录。

设计

运行时(Runtime)

Runtime是Go的核心,主要实现了三个方面:

  1. 垃圾收集
  2. 并发
  3. 内存管理(堆栈管理)

Runtime有点类似于C中的libc。不过Runtime并没有像JVM的虚拟机,它是根据不同的体系结构和操作系统来编译可执行目标。

泛型(generic types)

Go早期并没有泛型,想要实现泛型,一般是通过空接口(interface{})方式来实现:

1
2
3
4
5
6
7
8
9
10
func printValue(val interface{}) {
fmt.Println(val)
}

func main() {
printValue(42)
printValue("Hello, Go!")
printValue(3.14)
}

但是使用空接口的方式,会在编译时无法进行类型检查,所以需要保证它是一个正确的类型,要不然会发生panic。比如给它一个nil参数,静态分析是检查不出来的。后来1.18中,Go提供了泛型实现。

1
2
3
4
5
6
7
8
9
10
func printTValue[T any](val T) {
fmt.Println(val)
}

func main() {
printTValue(42)
printTValue("Hello, Go!")
printTValue(3.14)
}

最开始不设计泛型的原因是因为会添加Runtime的复杂度,并且Go最初的目标是为了编写易于维护的服务器程序的语言。

异常捕获(exceptions)

Go在异常处理这一块并没有实现try-catch-finally这种异常捕获机制,因为认为这样会导致代码变得复杂。Go异常处理主要依赖两个核心概念:多值返回和错误值。

多值返回这种方式可以在函数或者方法被调用的时候,根据error来判断是否发生错误,就可以省去捕获异常机制,来保证代码更加清晰、可读,并减少了过度使用异常的可能性。但是也会造成大量的 if err !=nil这种情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}

func main() {
result, err := divide(10, 2)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Result:", result)
}
}

下面是

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
public class Divider {

// Divide function with try-catch-finally
public static int divide(int a, int b) {
try {
if (b == 0) {
throw new ArithmeticException("division by zero");
}
return a / b;
} catch (ArithmeticException e) {
System.out.println("Error: " + e.getMessage());
return 0; // or handle the error in some way
} finally {
System.out.println("Division operation completed.");
}
}

public static void main(String[] args) {
int result = divide(10, 2);
System.out.println("Result: " + result);

int resultWithZeroDenominator = divide(10, 0);
System.out.println("Result with zero denominator: " + resultWithZeroDenominator);
}
}


另外,Go鼓励使用显示错误值,而不是异常,这样做的原因是将错误视为正常流程的一部分。

1
2
3
4
5
6
7
8
9
10
11
import "errors"

func exampleFunction() error {
// ...
if someCondition {
return errors.New("something went wrong")
}
// ...
return nil
}

基于CSP来构建并发( build concurrency on the ideas of CSP)

使用CSP(Communicating Sequential Processes)模型来构建并发程序是因为之前的并发编程方式比较复杂,需要关注底层细节,比如线程之间的同步和互斥。

使用goroutine而不是用线程的原因

使用goroutine的原因是因为在编写并发程序的时候,它的上下文切换成本比线程要低,并且goroutine可以复用到另外一组线程上。当goroutine对应的线程阻塞的时候,会被runtime给转移到其他线程上。goroutine创建成本也很低,除了堆栈内存(只有几千字节)之外,它们几乎没有任何开销。并且runtime可以动态调整堆栈内存大小。

关于map的原子操作

Go map并不是原子操作,map支持并发读,只要所有 goroutine 都只是读取(在映射中查找元素,包括使用 for range 循环对其进行迭代)都是并发安全的。但是不支持并发写,这里的写是指增加,删除,更新等操作。如果需要并发写,就要使用mutex来保证并发安全。但是mutex会减慢程序速度。

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
package main

import (
"fmt"
"sync"
)

func main() {
// 创建一个map
myMap := make(map[string]int)

// 用于同步的互斥锁
var mutex sync.Mutex

// 启动多个goroutine并发地对map进行读写
for i := 0; i < 3; i++ {
go func(id int) {
// 写入map,需要加锁
mutex.Lock()
myMap["key"] = id
mutex.Unlock()

// 读取map,不需要加锁
value := myMap["key"]
fmt.Printf("Goroutine %d: Map value: %d\n", id, value)
}(i)
}

// 等待所有goroutine完成
// 这里使用Sleep只是为了演示目的,在实际应用中,你可能需要更复杂的同步机制
// 比如使用WaitGroup或者通道来等待goroutine完成
fmt.Println("Waiting for goroutines to finish...")
fmt.Scanln()
}

关于面向对象

面向对象

这里官方回答是Yes and no,Go强调组合方式, 它和传统的面向对象语言有一些不同:

  1. 具有类型和方法: 允许使用面向对象编程,可以自定类型,并且在这些类型上定义方法。
  2. 无类型层次结构: Go没有严格的类型层次结构,也就是说没有类似于类继承的概念,但是Go有接口,提供了一种不同的方式来实现多态性和抽象,相对于传统的继承体系更为灵活。
  3. 接口为主: 接口定义了一组方法,类型只要实现了这些方法,就被视为实现了接口。
  4. 嵌套类型: Go语言提供了嵌套类型的机制,可以在一个类型中嵌入另一个类型,类似于子类继承的概念。虽然这不是严格的子类继承,但提供了一种相似的机制。
  5. 通用方法:Go语言中的方法更为通用,不仅仅局限于特定的结构体(类),甚至可以为内置类型(如整数)定义方法。这使得方法的使用更为灵活和广泛。
  6. 轻量级对象: 由于缺乏严格的类型层次结构,Go中的“对象”感觉更加轻量。与C++或Java等语言相比,Go的面向对象实现更为简洁,没有繁重的层次结构。

这里使用Java和Go来举例说明,首先是关于类型和方法定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//方法在Go中可以与结构体(structs)或任何自定义类型关联。
func (receiver Type) methodName(parameters) returnType {
// 方法的实现
}
/**
receiver:方法的接收者,表示方法关联的类型。可以是值接收者或指针接收者。
methodName:方法名。
parameters:方法的参数。
returnType:方法的返回类型。
**/

// 值接收者: 值接收者用于传递对象的副本。
func (v Vertex) Method1() {
// ...
}

// 指针接收者: 指针接收者用于传递对象的引用,允许在方法内修改对象的状态。
func (v *Vertex) Method2() {
// ...
}



具有类型和方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import "fmt"

// 定义自定义类型
type MyType struct {
Value int
}

// 在类型上定义方法
func (mt MyType) PrintValue() {
fmt.Println(mt.Value)
}

func main() {
// 创建自定义类型的实例
myInstance := MyType{Value: 42}

// 调用方法
myInstance.PrintValue()
}

无类型层次结构:

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 MyInterface interface {
MyMethod()
}

// 实现接口的类型
type MyType struct {
Value int
}

// 在类型上实现接口方法
func (mt MyType) MyMethod() {
fmt.Println("MyMethod called with value:", mt.Value)
}

func main() {
// 创建实现接口的类型实例
myInstance := MyType{Value: 42}

// 调用接口方法
myInstance.MyMethod()
}

嵌套方法:

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 OuterType struct {
InnerType
OuterValue int
}

// 定义被嵌套的类型
type InnerType struct {
InnerValue int
}

func main() {
// 创建嵌套类型的实例
outerInstance := OuterType{
InnerType: InnerType{InnerValue: 10},
OuterValue: 20,
}

// 访问嵌套类型的字段
fmt.Println("InnerValue:", outerInstance.InnerValue)
fmt.Println("OuterValue:", outerInstance.OuterValue)
}

通用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import "fmt"

// 为内置类型定义方法
type MyInt int

// 在内置类型上定义方法
func (mi MyInt) Double() MyInt {
return mi * 2
}

func main() {
// 使用内置类型
myInt := MyInt(5)

// 调用方法
result := myInt.Double()

// 打印结果
fmt.Println("Result:", result)
}

动态派发(dynamic dispatch of methods)

这里简单说下静态派发和动态派发,静态派发是指在编译时确定调用的方法,在静态派发中,编译器能够准确的知道要调用的方法,因为它可以在编译时确定对象的实际类型。在静态派发中,方法的调用目标是在编译阶段已经确定的,因此对于具体类型,编译器可以直接生成对应的方法调用指令。这样的优势在于性能更高,因为没有额外的运行时开销。

在面向对象编程中,静态派发通常与类继承和虚函数表(vtable)关联。当你有一个基类和派生类时,编译器在编译时就能够确定要调用的方法。这种方式通常称为早期绑定(Early Binding)或静态绑定。

而动态派发方法是在运行时根据实际类型来调用的方法,在面向对象编程中,动态派发通常与多态性(Polymorphism)和继承关联。当你有一个基类和派生类时,动态派发允许你通过基类引用调用派生类对象的方法,而不需要在编译时确定方法的具体实现。这种方式通常称为晚期绑定(Late Binding)或动态绑定。

在Go语言中,如果希望实现动态派发方法,唯一的方式就是通过接口。接口定义了一组方法签名,而具体类型只要实现了这些方法,就被视为实现了接口。在运行时,可以通过接口类型来调用相应的方法,实现了动态派发。

对于结构体或任何其他具体类型,其方法在编译时就被静态解析,即编译器能够准确地确定要调用的方法。这意味着在使用具体类型时,方法的调用是静态的,不会受到运行时对象实际类型的影响。

继承

map不允许切片作为键

map是不能使用切片作为key的,但是数组可以:

1
2
3
4
5
6
7
8
9
type arr [10]int

func main() {
bmap := make(map[arr]int)
fmt.Println(bmap)
}

//slice会报错: Invalid map key type: comparison operators == and != must be fully defined for the key type

slice不能作为map key的原因是因为Go没有实现相等性(equality)操作符,这是切片的相等性在定义上并不明确,存在多个考虑因素,比如浅层比较 vs. 深层比较、指针比较 vs. 值比较、如何处理递归类型等等。并且Slice还有一个动态伸缩的特性,它的映射也会丢失。因为键不会具有和之前相同的hashCode。

而数组和结构体可以作为键,是因为他们实现了相等性。换句话来说,只需要实现相等性就能够对比。

map,slice,channel与数组

Pointers and Allocation 指针和分配

Go和C一样,所有内容都是值传递。这里说下值传递和引用传递,

map和slice的值类似于指针,它们的实现都包含了一个指针,使用copy并不会复制

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
//slice底层结构
type slice struct {
array unsafe.Pointer // type Pointer *ArbitraryType
len int
cap int
}
//map底层结构
// A header for a Go map.
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go.
// Make sure this stays in sync with the compiler's definition.
count int // # live cells == size of map. Must be first (used by len() builtin)
flags uint8
B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
hash0 uint32 // hash seed

buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)

extra *mapextra // optional fields
}


这里通过几个例子来看下:

1
2
3
4
5
func modifyInt(x int) {
x = 42
}


之前定义方法的时候提到过使用值或者指针的方法来定义:

1
2
func (s *MyStruct) pointerMethod() { } // method on pointer
func (s MyStruct) valueMethod() { } // method on value

对于不经常使用指针的程序员来说不好理解,可以通过以下几点来决定使用那种方法,首先这个方法是否需要修改接收者?也就MyStruct这一块。如果需要修改,就必须设置成指针。

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
type MyStruct struct {
Field1 int
Field2 string
}

// valueMethod 使用值接收器,不修改接收器的字段
func (s MyStruct) valueMethod() {
fmt.Println("Inside valueMethod:", s.Field1, s.Field2)
}

// pointerMethod 使用指针接收器,可以修改接收器的字段
func (s *MyStruct) pointerMethod() {
s.Field1 = 42
s.Field2 = "Modified"
fmt.Println("Inside pointerMethod:", s.Field1, s.Field2)
}

func main() {
// 创建 MyStruct 实例
instance := MyStruct{Field1: 10, Field2: "Original"}

// 使用值接收器的方法
instance.valueMethod()
// 输出:After valueMethod: 10 Original
fmt.Println("After valueMethod:", instance.Field1, instance.Field2)

// 使用指针接收器的方法
instance.pointerMethod()
// 输出:After pointerMethod: 42 Modified
fmt.Println("After pointerMethod:", instance.Field1, instance.Field2)
}

再一个是效率问题,如果接收器非常大,比如很大的struct,使用指针接收器会更好一点。最后是一致性问题,如果该类型方法必须具有指针接收器,那和接收器相关的方法都应该使用指针接收器。但是在基本类型,切片,以及小型struct等类型上,值接收器会更好一点。

new和make区别

简单来说,new是分配零值内存并返回指针,而make是初始化容器(chan,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
// The new built-in function allocates memory. The first argument is a type,
// not a value, and the value returned is a pointer to a newly
// allocated zero value of that type.
func new(Type) *Type

// The make built-in function allocates and initializes an object of type
// slice, map, or chan (only). Like new, the first argument is a type, not a
// value. Unlike new, make's return type is the same as the type of its
// argument, not a pointer to it. The specification of the result depends on
// the type:
//
// Slice: The size specifies the length. The capacity of the slice is
// equal to its length. A second integer argument may be provided to
// specify a different capacity; it must be no smaller than the
// length. For example, make([]int, 0, 10) allocates an underlying array
// of size 10 and returns a slice of length 0 and capacity 10 that is
// backed by this underlying array.
// Map: An empty map is allocated with enough space to hold the
// specified number of elements. The size may be omitted, in which case
// a small starting size is allocated.
// Channel: The channel's buffer is initialized with the specified
// buffer capacity. If zero, or the size is omitted, the channel is
// unbuffered.
func make(t Type, size ...IntegerType) Type

new不会初始化内存,而是将其清零( only zeros),换句话来说就是new(T)为T类型分配归零内存并返回它的地址,即 *T 类型的值。

make相对来说会比较好理解一点,初始化内存之后,它会返回对应的引用类型实例,并且它的返回值根据类型实例决定。

Concurrency 并发性

原子操作与互斥锁(operations are atomic mutexes)

  • sync
  • sync/atomic
  • channel goroutine

参考

https://dave.cheney.net/2014/08/17/go-has-both-make-and-new-functions-what-gives

https://stackoverflow.com/questions/9320862/why-would-i-make-or-new/9325620#9325620

https://go.dev/doc/faq

https://go.dev/doc/effective_go


Go语言设计摘要
http://example.com/2024/03/07/Go语言设计摘要/
Author
John Doe
Posted on
March 7, 2024
Licensed under