Go语言设计哲学

2024-08-12 19:10:21 浏览数 (1)

关于Go语言的设计哲学,Go语言之父们以及Go开发团队并没有给出明确的官方说法。以下为个人从他们及Go社区主流观点的代码行为整理、分析和总结,列出4条Go语言的设计哲学。理解这些设计哲学对形成Go原生编程思维、编写高质量Go代码起到积极作用。

3.1 追求简单,少即是多

每个Gopher可能有不同的原因喜欢Go语言,如:

  • 高性能
  • 编译速度快
  • goroutine
  • 快乐
  • 强大的社区
  • 简单,最少语法
  • 等等

但在众多的答案中,排名靠前而又占据多数的总是“简单”。

Go语言在语言设计之初就拒绝走语言特性融合的道路,而选择了做减法,选择了简单。他们把复杂性留给了语言自身的设计和实现,留给了Go核心开发组自己,而将简单、易用和清晰留给了广大Gopher。因此,今天呈现在我们眼前的是这样的Go语言

  • 简洁、常规的语法(不需要解析符号表),它仅有25个关键字;
  • 内置垃圾收集,降低开发人员内存管理的心智负担;
  • 没有头文件;显式依赖(package);
  • 没有循环依赖(package);
  • 常量只是数字;
  • 首字母大小写决定可见性;
  • 任何类型都可以拥有方法(没有类);
  • 没有子类型继承(没有子类);
  • 没有算术转换;
  • 接口是隐式的(无须implements声明);
  • 方法就是函数;
  • 接口只是方法集合(没有数据);
  • 方法仅按名称匹配(不是按类型);
  • 没有构造函数或析构函数;
  • n 和n--是语句,而不是表达式;
  • 没有 n和--n;赋值不是表达式;
  • 在赋值和函数调用中定义的求值顺序(无“序列点”概念);
  • 没有指针算术;内存总是初始化为零值;
  • 没有类型注解语法(如C 中的const、static等);
  • 没有模板/泛型;
  • 没有异常(exception);
  • 内置字符串、切片(slice)、map类型;
  • 内置数组边界检查;
  • 内置并发支持;

任何设计都存在权衡与折中。Go设计者选择的“简单”体现在,站在巨人肩膀上去除或优化在以往语言中已被证明体验不好或难于驾驭的语法元素和语言机制,并提出自己的一些创新性的设计,比如首字母大小写决定可见性,内存分配初始零值,内置以go关键字实现的并发支持等)。Go设计者推崇“最小方式”思维,即一件事情仅有一种方式或数量尽可能少的方式去完成,这大大减少了开发人员在选择路径方式及理解他人所选路径方式上的心智负担。

正如Go语言之父Rob Pike所说:“Go语言实际上是复杂的,但只是让大家感觉很简单。”这句话背后的深意就是“简单”选择的背后是Go语言自身实现层面的复杂性,而这种复杂性被Go语言的设计者“隐藏”起来了。比如并发是复杂的,但我们通过一个简单的关键字“go”就可以实现。这种简单其实是Go开发团队缜密设计和持续付出的结果。

此外,Go的简单哲学还体现在Go 1兼容性的提出。对于面对工程问题解决的开发人员来说,Go 1大大降低了工程层面语言版本升级所带来的消耗,让Go的工程实践变得格外简单。

有人向Go开发团队提出过这样一个问题:Go后续演化的最大难点是什么?Go开发团队的一名核心成员回答道:“最大的难点是如何继续保持Go语言的简单。”

3.2 偏好组合,正交解耦

C 、Java等主流面向对象(以下简称OO)语言通过庞大的自上而下的类型体系、继承、显式接口实现等机制将程序的各个部分耦合起来,但在Go语言中我们找不到经典OO的语法元素、类型体系和继承机制,或者说Go语言本质上就不属于经典OO语言范畴。

Go语言遵从的设计哲学也是组合。

在语言设计层面,Go提供了正交的语法元素供后续组合使用,先来了解一下Go在语法元素设计时是如何为组合哲学的应用奠定基础的:

  • Go语言无类型体系(type hierarchy),类型之间是独立的,没有子类型的概念;
  • 每个类型都可以有自己的方法集合,类型定义与方法实现是正交独立的;
  • 接口(interface)与其实现之间隐式关联;
  • 包(package)之间是相对独立的,没有子包的概念。
  • 我们看到无论是包、接口还是一个个具体的类型定义(包括类型的方法集合),Go语言为我们呈现了这样一幅图景:一座座没有关联的“孤岛”,但每个岛内又都很精彩。现在摆在面前的工作就是以最适当的方式在这些孤岛之间建立关联(耦合),形成一个整体。Go采用了组合的方式,也是唯一的方式。

Go语言提供的最为直观的组合的语法元素是类型嵌入(type embedding)。通过类型嵌入,我们可以将已经实现的功能嵌入新类型中,以快速满足新类型的功能需求。

类型嵌入示例:

代码语言:go复制
type KafkaServer struct {
	ConsumerServer             *kafka.Consumer
	sync.RWMutex
}

在KafkaServer嵌入sync.RWMutex,被嵌入的sync.RWMutex的方法集合会被提升到外面类型poolLocal中。这里KafkaServer会拥有sync.RWMutex的RLock和RUnlock方法。但在调用时,实际调用会被传给sync.RWMutex实例。

通过在interface的定义中嵌入interface类型来实现接口行为的聚合,组成大接口,这种方式在标准库中尤为常用,并且已经成为Go语言的一种惯用法。

interface是Go语言中真正的“魔法”,是Go语言的一个创新设计,它只是方法集合,且与实现者之间的关系是隐式的,它让程序各个部分之间的耦合降至最低,同时是连接程序各个部分的“纽带”。隐式的interface实现会不经意间满足依赖抽象、里氏替换、接口隔离等设计原则,这在其他语言中是需要很刻意的设计谋划才能实现的,但在Go interface看来,一切却是自然而然的

综上,组合原则的应用塑造了Go程序的骨架结构。类型嵌入为类型提供垂直扩展能力,interface是水平组合的关键,它好比程序肌体上的“关节”,给予连接“关节”的两个部分各自“自由活动”的能力,而整体上又实现了某种功能。组合也让遵循简单原则的Go语言在表现力上丝毫不逊色于复杂的主流编程语言。

3.3 原生并发,轻量高效

并发是有关结构的,而并行是有关执行的。——Rob Pike(2012)

Go的设计者敏锐地把握了CPU向多核方向发展的这一趋势,在决定不再使用C 而去创建一门新语言的时候,果断将面向多核、原生内置并发支持作为新语言的设计原则之一。

Go语言与安生支持并发的设计哲学体现在以下几点:

(1)Go语言采用轻量级协程并发模型,使得Go应用在面向多核硬件时更具可扩展性。

进程和线程的创建需要资源,并且线程在切换时也会占用很多资源。为了解决这些问题,Go放弃了传统的基于操作系统线程的并发模型,而采用了用户层轻量级线程或者说是类线程(coroutine),Go称之为goroutine。goroutine占用的资源非常少,Go运行时默认为每个goroutine分片的栈空间为2KB。

不过,一个Go程序对于操作系统来说只是一个用户层程序。操作系统的眼中只有线程,它甚至不知道goroutine的存在。goroutine的调度全靠Go自己完成,实现Go程序内goroutine之间公平地竞争CPU资源的任务就落到了Go运行时头上。而将这些goroutine按照一定算法放到CPU上执行的程序就称为goroutine调度器(goroutine scheduler)

(2)Go语言为开发者提供的支持并发的语法元素和机制

使用go 函数调用即可创建goroutine,函数退出即goroutine退出。

并发goroutine的通信:通过语言内置的channel传递消息或实现同步,并通过select实现多路channel的并发控制。大大降低了开发程序时的心智负担。

(3)并发原则对Go开发者在程序结构设计层面的影响

首先理解并发和并行

  • 并发:是有关结构的,是一种将一个程序分解成多个小片段并且每个小片段都可以独立执行的程序设计方法;并发程序的小片段之间一般存在通信联系,并且通过通信相互协作。
  • 并行:是有关执行的,它表示同时进行一些计算任务。

以上观点的重点是,并发是一种程序结构设计的方法,它使并行成为可能。

并发程序的结构设计不要局限于在单核情况下处理能力的高低,而要以在多核情况下充分提升多核利用率、获得性能的自然提升为最终目的。

并发与组合的哲学是一脉相承的,并发是一个更大的组合的概念,它在程序设计层面对程序进行拆解组合,再映射到程序执行层面:goroutine各自执行特定的工作,通过channel select将goroutine组合连接起来。并发的存在鼓励程序员在程序设计时进行独立计算的分解,而对并发的原生支持让Go语言更适应现代计算环境

3.4 面向工程,”自带电池“

软件工程指引着Go语言的设计。 ——Rob Pike(2012)

Go语言的诞生初衷:面向真实世界中Google内部大规模软件开发存在的各种问题,为这些问题提供答案。

主要的问题包括:

  • 程序构建慢
  • 失控的依赖管理
  • 开发人员使用编程语言的不同子集
  • 代码可理解性差(代码可读性差、文档差等)
  • 功能重复实现
  • 升级更新消耗大
  • 实现自动化工具难度高
  • 版本问题
  • 跨语言构建问题
  • ...

Go的设计者以更高、更宽广的视角审视软件开发领域以及大规模软件开发过程中遇到的各种问题,并在Go语言最初设计阶段就将解决工程问题作为Go的设计原则之一去考虑Go语法、工具链与标准库的设计,这也是Go与那些偏学院派、偏研究性编程语言在设计思路上的一个重大差异。

Go设计者将所有工程问题浓缩为一个词:scale(随着规模增长,伴生问题接踵而至)。

从Go1开始,Go的设计目标就是帮助开发者更容易、更高效地管理两类规模:

  • 生产规模:用Go构建的软件系统的并发规模,比如这类系统并发关注点的数量、处理数据的量级、同时并发与之交互的服务的数量等
  • 开发规模:包括开发团队的代码库的大小,参与开发、相互协作的工程师的人数等。

Go设计者期望Go可以游刃有余地应对生产规模和开发规模变大带来的各种复杂问题。那么Go是如何解决工程领域规模化所带来的问题的呢?我们从语言、标准库和工具链三个方面来看一下

  • 语言:在语言设计细节上充分考虑。如使用大括号而不是缩进;源文件中不允许有导入但未使用的包;去除包的循环依赖;包路径唯一,而包名不必是唯一的,导入路径必须唯一标识要导入的包。故意不支持默认函数参数;首字母大写决定可见性;内置垃圾回收;增加类型别名,支持大规模代码库的重构。
  • 标准库:Go被称为“自带电池”(battery-included)的编程语言。自带电池指着门语言标准库功能丰富,多数功能无需依赖第三方包或库。如net/http、crypto/xx、encoding/xx等包。Go开发者可以直接基于这些包实现满足生产要求的API服务,从而减轻对第三方包或库的依赖,降低工程代码依赖管理的复杂性,也降低开发人员学习第三方库的心智负担。
  • 工具链:开发人员在做工程的过程中需要使用工具。而Go语言提供了十分全面、贴心的编程语言官方工具链,涵盖了编译、编辑、依赖获取、调试、测试、文档、性能剖析等的方方面
    • 构建和运行:go build/go run
    • 依赖包查看与获取:go list/go get/go mod xx
    • 编辑辅助格式化:go fmt/gofmt
    • 文档查看:go doc/godoc
    • 单元测试/基准测试/测试覆盖率:go test
    • 代码静态分析:go vet
    • 性能剖析与跟踪结果查看:go tool pprof/go tool trace
    • 升级到新Go版本API的辅助工具:go tool fix
    • 报告Go语言bug:go bug

值得重点提及的是gofmt统一了Go语言的编码风格,因此Go开发者可以更专注于领域业务。并且相同代码风格也让以往困扰开发者的代码阅读、理解和评审工作变得容易了很多。

总结:简单是Go语言贯穿语言设计和应用的主旨设计哲学。哲学在编程语言领域为数不多的践行者。“少”绝不是目的,“多”才是其内涵

0 人点赞