Go 语言设计哲学第二弹,这不禁勾起了我的伤心往事,当时跟腾讯的面试官聊的多好啊,结果还是被挂了,呜呜呜……

面向对象

在学过基础的 Go 语言语法后,我们就发现了 Go 和 C++ 最大的不同,那就是 Go 好像不支持面向对象。

这门编程语言里没有类(class)、继承(extends),难道真的不支持面向对象编程,难道它也知道我没有对象?完了,被监视了(狗头)。

40b80bdfcc42d53f0ab1bfc99fe1132

你看,找工作给脑子找坏了吧。不必理会上面一段无脑发言,总结为玩原神玩的。那么 Go 到底支不支持面向对象,让我们一步一步地探寻。

类和继承

类(class)在面向对象编程中是一种面向对象计算机编程语言的构造,是创建对象的蓝图,描述了所创建的对象共同的特性和方法(via @维基百科)。

继承是面向对象软件技术当中的一个概念,如果一个类别 B “继承自”另一个类别 A,就把这个 B 称为 “A的子类”,而把 A 称为 “B的父类别” 也可以称 “A 是 B 的超类”(via @维基百科)。

继承有如下两个特性:

  • 子类具有父类别的各种属性和方法,不需要再次编写相同的代码。
  • 子类别继承父类时,可以重新定义某些属性,并重写某些方法,使其获得与父类别不同的功能。

结构和组合

在 Go 里就比较 ”特别“ 了,因为没有传统的类,也没有继承。

取而代之的是结构和组合的方式。这也是业内对 Go 是否 OOP 争议最大的地方。

结构体

我们可以在 Go 中通过结构体的方式来组织代码,达到类似类的方式。

组合

类的声明采取结构体的方式取代后,也可以配套使用 ”组合“ 来达到类似继承的效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type man struct {
name string
}

func (m *man) hello1() {}

type person struct {
man
name string
}

func (p *person) hello2() {}

func newPerson(name string) *person {
p := person{name: name}
return &p
}

func main() {
p := newPerson("随便寻个地方")
p.hello1()
}

在上述代码中,我们分别定义了 man 和 person 两个结构体,并将 man 嵌入到 person 中,形成组合。

Go 是面向对象的语言吗

“Go 语言是否一门面向对象的语言?”,这是一个日经话题。官方 FAQ 给出的答复是:

image

是的,也不是。原因是:

  • Go 有类型和方法,并且允许面向对象的编程风格,但没有类型层次。
  • Go 中的 “接口 “概念提供了一种不同的方法,我们认为这种方法易于使用,而且在某些方面更加通用。还有一些方法可以将类型嵌入到其他类型中,以提供类似的东西,但不等同于子类。
  • Go 中的方法比 C++ 或 Java 中的方法更通用:它们可以为任何类型的数据定义,甚至是内置类型,如普通的、“未装箱的 “整数。它们并不局限于结构(类)。
  • Go 由于缺乏类型层次,Go 中的 “对象 “比 C++ 或 Java 等语言更轻巧。

函数重载和缺省参数

Go 语言中并不支持函数重载和缺省参数,下面将会介绍这两个是什么。

函数重载

函数重载(function overloading),也叫方法重载。是某些编程语言(如 C++、C#、Java、Swift、Kotlin 等)具有的一项特性。

该特性允许创建多个具有不同实现的同名函数,对重载函数的调用会运行其适用于调用上下文的具体实现。

从功能上来讲,就是允许一个函数调用根据上下文执行不同的方法,达到调用同一个函数名,执行不同的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
using namespace std;

int Volume(int s) { // 立方体的体积。
return s * s * s;
}

double Volume(double r, int h) { // 圆柱体的体积。
return 3.1415926 * r * r * static_cast<double>(h);
}

long Volume(long l, int b, int h) { // 长方体的体积。
return l * b * h;
}

int main() {
cout << Volume(10);
cout << Volume(2.5, 8);
cout << Volume(100l, 75, 15);
}

参数默认值

参数默认值,又叫缺省参数。指的是允许程序员设定缺省参数并指定默认值,当调用该函数并未指定值时,该缺省参数将为缺省值来使用

一个简单的例子:

1
int my_func(int a, int b, int c=12);

在上述例子中,函数 my_func 一共有 3 个变量,分别是:a、b、c。变量 c 设置了缺省值,也就是 12。

其调用方式可以为:

1
2
3
4
// 第一种调用方式
result = my_func(1, 2, 3);
// 第二种调用方式
result = my_func(1, 2);

在第一种方式中,就会正常的传入所有参数。在第二种方式,由于第三个参数 c 并没有传递,因此会直接使用缺省值 12。

这就是参数默认值,也叫缺省参数。

为什么不支持

从上述的功能特性介绍来看,似乎非常的不错,能够节省很多功夫。像是 Go 语言的 context 库中的这些方法:

1
2
3
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

要是有函数重载,直接就 WithXXX 就好了,只需要关注传入的参数类型,也不用 “记” 那么多个方法名了。

有同学说,有参数默认值。那就可以直接设置在上面,作为 “最佳实践” 给到使用函数的人,岂不美哉。那怎么 Go 语言就不支持呢?

细思

其实这和设计理念,和对程序的理解有关系。说白了,就是你喜欢 “显式”,还是 “隐喻”。

函数重载和参数默认值,其实是不好的行为。调用者只看函数名字,可能没法知道,你这个默认值,又或是入参不同,会调用的东西,会产生怎么样的后果?

你可以观察一下自己的行为。大部分人都会潜意识的追进去看代码,看看会调到哪,缺省值的作用是什么,以确保可控。

敲定

这细思的可能,在 Go 语言中是不被允许的。Go 语言的设计理念就是 “显式大于隐喻”,追求明确,显式

在 Go FAQ 《Why does Go not support overloading of methods and operators?》有相关的解释。

如下图:

image

官方有明确提到两个观点:

  • 函数重载:拥有各种同名但不同签名的方法有时是很有用的,但在实践中也可能是混乱和脆弱的。
  • 参数默认值:操作符重载,似乎更像是一种便利,不是绝对的要求。没有它,程序会更简单。

这就是为什么 Go 语言不支持的原因。

可重入锁

Go 里的锁,竟然不支持可重入

如果对已经上锁的普通互斥锁进行 “加锁” 操作,其结果要么失败,要么会阻塞至解锁。

可重入互斥锁是互斥锁的一种,同一线程对其多次加锁不会产生死锁,又或是导致阻塞。

  • 在加锁上:如果是可重入互斥锁,当前尝试加锁的线程如果就是持有该锁的线程时,加锁操作就会成功。
  • 在解锁上:可重入互斥锁一般都会记录被加锁的次数,只有执行相同次数的解锁操作才会真正解锁。

为什么

Go 设计原则

在工程中使用互斥的根本原因是:为了保护不变量,也可以用于保护内、外部的不变量。

基于此,Go 在互斥锁设计上会遵守这几个原则。如下:

  • 在调用 mutex.Lock 方法时,要保证这些变量的不变性保持,不会在后续的过程中被破坏。
  • 在调用mu.Unlock方法时,要保证:
    • 程序不再需要依赖那些不变量。
    • 如果程序在互斥锁加锁期间破坏了它们,则需要确保已经恢复了它们。

不支持的原因

讲了 Go 自己的设计原则后,那为什么不支持可重入呢?

其实 Russ Cox 于 2010 年在《Experimenting with GO》就给出了答复,认为递归(又称:重入)互斥是个坏主意,这个设计并不好。

并发读写

来不及惋惜 Redis 三兄弟了,接下来登场的是 Go 语言自己的三兄弟——垃圾回收机制、协程机制和为什么 mapslice是非线性的。

为什么在 Go 语言里,map 和 slice 不支持并发读写,也就是是非线性安全的,为什么不支持?

非线程安全的例子

slice

我们使用多个 goroutine 对类型为 slice 的变量进行操作,看看结果会变的怎么样。

1
2
3
4
5
6
7
8
9
10
func main() {
var s []string
for i := 0; i < 9999; i++ {
go func() {
s = append(s, "随便寻个地方")
}()
}

fmt.Printf("随便寻了 %d 个地方", len(s))
}

输出结果:

1
2
3
4
5
6
// 第一次执行
随便寻了5790个地方
// 第二次执行
随便寻了7370个地方
// 第三次执行
随便寻了6792个地方

每次输出的值大概率都不会一样。也就是追加进 slice 的值,出现了覆盖的情况。因此在循环中所追加的数量,与最终的值并不相等。且这种情况,是不会报错的,是一个出现率不算高的隐式的问题。

这个产生的主要原因是程序逻辑本身就有问题,同时读取到相同索引位,自然也就会产生覆盖的写入了。

map

同样针对 map 也如法炮制一下,重复针对类型为 map 的变量进行写入,结果会直接出现报错,并且是 Go 源码调用 throw 方法所导致的致命错误,也就是说 Go 进程会中断。

如何支持并发读写

对 map 上锁

实际上我们仍然会经过有并发 map 的诉求,因为 Go 语言中的 goroutine 实在是太方便了。像是一般写爬虫任务时,基本会用到多个 goroutine,获取到数据后再写入到 map 或者 slice 中去。

Go 官方在 Go maps in action 中提供了一种简单又便利的方式来实现:

1
2
3
4
var counter = struct{
sync.RWMutex
m map[string]int
}{m: make(map[string]int)}

这条语句声明了一个变量,它是一个匿名结构(struct)体,包含一个原生和一个嵌入读写锁 sync.RWMutex

sync.map

虽然有了 Map+Mutex 的极简方案,但是也仍然存在一定问题。那就是在 map 的数据量非常大时,只有一把锁(Mutex)就非常可怕了,一把锁会导致大量的争夺锁,导致各种冲突和性能低下。

常见的解决方案是分片化,将一个大 map 分成多个区间,各区间使用多个锁,这样子锁的粒度就大大降低了。不过该方案实现起来很复杂,很容易出错。因此 Go 团队到比较为止暂无推荐,而是采取了其他方案。

该方案就是在 Go1.9 起支持的 sync.Map,其支持并发读写 map,起到一个补充的作用。

Go 语言的 sync.Map 支持并发读写 map,采取了 “空间换时间” 的机制,冗余了两个数据结构,分别是:read 和 dirty,减少加锁对性能的影响:

1
2
3
4
5
6
type Map struct {
mu Mutex
read atomic.Value // readOnly
dirty map[interface{}]*entry
misses int
}

其是专门为 append-only 场景设计的,也就是适合读多写少的场景。这是他的优点之一。

若出现写多/并发多的场景,会导致 read map 缓存失效,需要加锁,冲突变多,性能急剧下降。这是他的重大缺点。

提供了以下常用方法:

1
2
3
4
5
6
func (m *Map) Delete(key interface{})
func (m *Map) Load(key interface{}) (value interface{}, ok bool)
func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool)
func (m *Map) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool)
func (m *Map) Range(f func(key, value interface{}) bool)
func (m *Map) Store(key, value interface{})
  • Delete:删除某一个键的值。
  • Load:返回存储在 map 中的键的值,如果没有值,则返回 nil。ok 结果表示是否在 map 中找到了值。
  • LoadAndDelete:删除一个键的值,如果有的话返回之前的值。
  • LoadOrStore:如果存在的话,则返回键的现有值。否则,它存储并返回给定的值。如果值被加载,加载的结果为 true,如果被存储,则为 false。
  • Range:递归调用,对 map 中存在的每个键和值依次调用闭包函数 f。如果 f 返回 false 就停止迭代。
  • Store:存储并设置一个键的值。

为什么不支持

Go Slice 的话,主要还是索引位覆写问题,这个就不需要纠结了,势必是程序逻辑在编写上有明显缺陷,自行改之就好。

但 Go map 就不大一样了,很多人以为是默认支持的,一个不小心就翻车,这么的常见。那凭什么 Go 官方还不支持,难不成太复杂了,性能太差了,到底是为什么?

原因如下(via @go faq):

  • 典型使用场景:map 的典型使用场景是不需要从多个 goroutine 中进行安全访问。
  • 非典型场景(需要原子操作):map 可能是一些更大的数据结构或已经同步的计算的一部分。
  • 性能场景考虑:若是只是为少数程序增加安全性,导致 map 所有的操作都要处理 mutex,将会降低大多数程序的性能。

汇总来讲,就是 Go 官方在经过了长时间的讨论后,认为 Go map 更应适配典型使用场景,而不是为了小部分情况,导致大部分程序付出代价(性能),决定了不支持。

总结

其实在学习所谓的 Go 语言哲学时并不会有很多知识上的收获,要去探讨为什么设计师要这么设计某一个功能其实就是在揣测别人的心思,或许并没有什么原因,他只是喜欢。

那为什么还会有这种 XX 语言哲学的存在呢,我觉得是为了让学习者能够更快地抓住语言特性,也会给未来使用其进行开发带来深远的影响。