Go coding in Go way-Gopher China演讲分享

白明 · · 2081 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

导言:不同语言编程思维造就了编程风格以及代码形式的不一,那么如何运用GO编程思维去写GO代码?今天的分享就是基于对GO编码的一些观点分析,带领大家使用GO的编程思维来编写代码,当然本次分享中如果有不足之处也请大家指出和谅解。 

|语言与思维方式

我们首先从人类学语言假说开始,萨丕尔.沃夫假曾说过,语言可以影响或决定思维方式。在这里我要提一个大家可能都熟知的美国电影——《降临》,这部大片是根据美国华裔作家的小说改编的,主要剧情理论核心便印证了上述我们提到的假说——“语言影响思维并决定思维”。女主角在政府的委托下学习外星人语言,在学了外星人语言之后,她整体的思维方式发生质的改变,有强大的超能力,可以预知未来。当然这仅仅是电影,但是从中我们可以得到,选择语言是非常重要的,在座的各位应该非常庆幸,因为我们选择了GO语言。

首届图灵奖得主——艾伦.佩利曾提到过,一门语言如果不能影响你思维方式的话,那么这门语言是不值得我们学习的。我们发现在语言和思维方式这两者之间,是存在某种联系的。但是具体有什么联系呢?举个例子,素数是一个自然数,它具有两个截然不同的自然数除数,要找到小于或等于给定整数n的素数我们该如何去做?我们可以使用埃拉托斯特尼尔算法。


图 1

如图 1 所示,是对这个算法的描述图。先用一个最小的素数2去筛除2的倍数,接下来下一个被筛除的数是素数(这里指的是 3),再用这个素数3去筛除掉3的倍数,得到下一个素数,这样不断的重复下去,直到筛完为止。下面为大家展示一下当解决上述问题时,所使用的三种不同语言实现版本:

  • C语言

图 2

如图 2 所示,是使用C语言的实现版本,这里面定义了两个数,一个是numbers数组,一个是primes数组。在numbers数组中,第一个for循环将所有整数进行初始化;到了第二个for循环体,实际上是做了一个筛选,将numbers里面所有的非素数赋值为-1,然后在primes数组中输出所有非-1的整数,这样我们就得到这样一个素数集——primes。我们可以看到C版本的实现完全基于一个速度内存,这个是C最传统的一个思维。

  • 函数式语言——Haskell

图 3

图 3 是我们使用的第二种语言方式——函数式语言Haskell, 使用了Haskell(02:10:15)版本进行实现,在实现素数3时,使用的是一个函数方式。我们可以看到,C用这样一个函数把初始素数集合的部分提取出第一个元素和后续的结合,然后用一个 filter 过滤器,通过后面的过滤条件,将集合中第一个数的倍数全部删除,然后将结果的集合继续递到下一个函数,这样递归下去,直到输出100以内的所有素数,这样得到了最终的素数集。

  • GO语言

图 4

图 4 是第三种语言版本——GO语言版本。Go版本的素数筛实现采用的是goroutine的并发组合。程序从2开始,依次为每个素数建立一个goroutine,用于作为筛除该素数的倍数。ch指向当前最新输出素数所位于的筛子goroutine的源channel,这段代码来自于Rob Pike的一次关于concurrency的分享slide。

图 5

通过上面三个例子,让我们可以产生以下结论:来自不同编程语言的程序员给出了思维方式截然不同的解决方法,一定程度上印证了前面的假说,编程语言影响编程思维;可能好多人第一门接触的语言都不是GO语言,举个例子,我最开始是写C语言的,刚刚接触GO时,发现GO语言简单、易上手,一天可以全部搞定所有知识点,一周可以写出成型代码,但是写着写着会发现,我写的代码跟标准库里的代码,或一些主流的开源代码不太一样,后来仔细分析原因,就是因为我们是带着原有语言的思维方式去写GO的代码,这样,你就总会试图在GO语言中去找寻一些在原有语言中灵活运用的那些函数。但这不应该是我们学习GO语言的目标,我们的目标是GO  coding  in  GO  way。

既然知道了编程语言对编程思维是有影响的,那下面就要分析编程语言到底是怎么影响编程思维的。从根本上来说,一门编程语言对编程思维的影响,最根本就是在于它的价值观。这里与人类语言影响人类思维区分一下,我们谈的是,用编程语言价值观来解决编程语言思维和语言结构。它最根本的核心是思维和语言的结构会影响人的应用思维,那么什么是编程语言的价值观呢?我个人认为,就是这门编程语言最初的设计,以及其对整个程序世界的认知。

|GO语言价值观

既然谈到了其根本是价值观对思维的影响,那接下来就来看看,GO语言的价值观。从三个方面进行分析:GO语言价值观的形成、GO语言价值观的具体内容、GO语言价值观主导下的GO编程思维。


图 6

我个人认为GO语言价值观的形成至少有三点因素,其主导因素源于其语言的设计,众所周知,GO语言最初的三位设计师  Robert Griesemer、Rob Pike以及Ken Thompson(图 6),最初想加入GO语言,是需要这三位设计师共同达成一致许可你加入才可以加入的。那么GO语言设计者的价值观到底是怎么形成的呢?三位设计者有一个共同特征,那就是深受Unix文化熏陶。

我们看到最右边的Thompson本身就是 Unix 之父。他曾在戴尔实验室做过很多年研究员,开发了很多编程器,同时也设计了很多的语言。最左边的Robert,曾一手参与设计 Chrome V8 引擎。决定一门语言的价值观,与企业创始人,决定公司价值观一样。因此,这三位设计师本身对语言的价值观就影响了GO语言。当然,价值观的形成,还需要考虑遗传因素,Tony Hoare,曾提出这样一个观点:编程语言设计者的主要任务不是创新而是巩固,GO继承了很多以前语言的基因。GO语言行成的初衷就是为了面向新的基础设施环境和解决大规模软件开发的诸多问题,比如现在我们拥有的大规模云计算数据中心,大规模的网络,多核以及多处理器的硬件体系,GO语言还需要解决 Google 内部大规模语言开发的问题,比如构建缓慢,反复依赖等。当然这些问题并不一定从语言本身来的,但是面对这些问题,以及这样的环境,确实会对语言设计师的价值观造成影响。

因此,我总结了一下,GO语言价值观至少有三点,第一点是全面的简单,第二点是 Orthogonal Composition,第三点是 Preference  in  Concurrency。下面我会用几个语法把这三个合在一起,即“orthogonal composition of simple concepts with preference in concurrency ”。下面我们会介绍GO的这三点价值观产生的一些影响及应用,第一点是在这样的价值观下,语言设计层面会有哪些思路;第二点是在这个价值观下,它主导GO语言的编程思维是什么样的;最后是使用GO编程思维在语言设计、标准库实现以及主流GO开源项目中的应用体现。

  • Overall Simplicity      

图灵奖获得者 Dijkstra 说过这样的一句话,简单和优雅并不受欢迎,因为想实现简单的优雅,要付出更多精力,要接受更多教育。GO语言的设计者,在最初设计GO语言时,就把一些复杂的事情留给了自己,而把简单的东西留给了其他Gopher(GO语言开发者)。这个价值观更多体现在GO语言设计层面,在语言设计层面,体现出GO语言简单的,是这样一些设计:

1>简洁,语法正规;从其他语言转向GO语言,都会觉得GO语言的语法简单,熟悉,这显然是GO设计者有意而为之的;

2>只有25个关键字;

3>GO语言倡导的是一种问题有一种写法;

4>Goroutines;是当前主流语言中支持并发的最简单的一种。

5>constants 常量;这个是其他语言没有想到的,但是GO语言设计者想到了;

6>interfaces GO的接口;它也是GO语言整个语言的一个灵魂式的语法元素。

7>packages;GO语言在程序组织方面唯一一个语法元素,它将代码设计、代码实现等等全部包含在里面,便于大家导入和使用。

上面简单介绍了这样的一个价值观直接体现在GO语言的设计层面。如今,Go语言的简单已经从自身设计扩展到Go应用的方方面面,但也正是由于在语言层面已经足够简单了,在应用层面,“简单”体现的反倒不是很明显,更加顺其自然。接下来,我总结整理几个在“全面简单”价值观下形成的Go编程思维。第一个思维方式是,短小思维;比如说GO语言推荐使用短小的方式,比如 java中的index,在GO当中使用 I 可以代替,java 中的 value,在GO里面只要用 V 就可以;其次是一致性,这种短小的变量,在上下文必须保持一致;这样上下文环境中就可以用最短的名字携带足够的信息。


图 7

图 7 是在对GO语言标准库进行一些统计后,列出的使用排名前十几位的变量名,基本上都是短命名,单字母的方式。


图 8

第二种思维是最小思维,GO语言可以是多种方式的语言,大家可能有这样一种感受,开发代码跟写代码差距太多,GO语言一直推荐的一种思维是,我们不是去写一些炫技或者聪明的代码,GO语言一个代码,实际上是让大家把更多的精力投入到关心的业务上,而不是在代码上面斤斤计较。举两个例子,GO语言只有一个循环—for,在其他语言中用于循环逻辑的关键字,比如while, do-while等,在go中都可以通过for模拟实现,包括迭代器循环(图 8)。

另一是常量,常量在GO语言当中只是数字,不需要你显示赋予了它何种类型,在使用过程中不需要做任何算术转换。在图 9 中我们定义了一个常量A,它会自动将常量A转换成变量C。


图 9

GO从它诞生以来,一直被人抱怨。其主要的原因有几方面,其中一方面是GO本身提供的处理机制,有人抱怨过于简单,感觉会有点过时。对于这点,GO设计师并不这么认为,他认为像刚刚说的,他认为简单才是这门语言的本质。所以提供一个基于比较的错误处理,会让使用者更加关注于错误本身,去处理每个错误,错误处理代码并没有什么特殊之处,这个完全是从语言简单这样的价值观出发,来设计的这样一个机制。GO错误处理的一些编程模式有以下几种:


图 10

图 10 是最常见的一种情况,使用error 类型作为错误码返回类型。这是当我们不需要区分变量具体值时,才可以使用的一种模式,调用者则直接处理错误就可以。


图 11

图 11 是当外部需要区分返回的错误值时采取的一种方式,比如这里我要进行一个io调用,后续的操作逻辑需要因io调用的返回错误码的不同而异,我们使用导出的error变量。

这些有时是不满足我们需求的,所以我们需要定义新的错误类型实现定制化错误上下文,如图12所示,其中定义了UnmarshalTypeError 这个类型,这个类型包含了他自己需要的错误上下文,我们可以针对error interface value的type assertion or type switch得到真实错误类型并访问error context。但是这种方式,在整个处理后,你会发现并不多见。


图 12


图 13

图 13 所示的标准库里面提供了一些更松散的方式去处理错误,以net包为例,它是将包的error time进行分类,统一提取出一些公共行为特征,并将这些公共行为特征放在一个公开的 interface 里,这样的话我们在外部使用 net 包时就可以直接通过这样的一个接口去判断这个错误到底是临时错误还是超时错误,然后再根据错误类型以及错误特征来进行错误处理。


图 14

图 14 是具体的net  error包,它是一个实现了net.Error的Error type的实现。


图 15

还有一种方式,我们在标准包的OS包里面可以看到(图 15)它没有公开一些接口,它是公开了一些函数,对 error 的行为进行判断,用这个函数来判断是否存在这样的错误。


图 16

如图 16所示的 error  naming 可以看到,错误类型一般都是 xxx Error 这种方式,导出变量是以 ErrXxx这种形式导出。


图 17

图 17 是大家经常抱怨的代码,虽然说GO具备很简单的错误处理方式,但是代码重复也让代码变得不那么简洁明了。


图 18

来说明一下如何消除上面的重复,有很简单的方式,就是将error作为内部状态。但是error handling的具体策略要根据实际情况而定。stdlib面向的”业务域”相对”狭窄”,像bufio.Write可以采用上面的方法解决,但是对于做业务应用的gopher来讲,业务复杂多变,错误处理没有绝对的模式,需根据上下文不同而具体设计。但如果一个函数中上述snippet过多,很可能是这个函数或方法的职责过多导致,重构是唯一出路。

  • Orthogonal Composition 

如果说第一个价值观是为整个程序的构建提供了一些小小的元素的话,那第二个价值观,实际上就是关注如何将这些元素,这些概念结合在一起,来形成整个程序的静态结构。这种组合,在语言设计层面分成两部分,第一部分是GO语言本身提供了很多正交元素,比如说无类型体系,类型定义是正交独立的;方法和类型也是正交的,每种类型都拥有自己的 method,并且在go里面,任何一种类型都可以用这个method;interface与其实现之间是没有显示关联的,它们谁也不知道谁的存在;正交性实际上为组合策略的实施奠定了基础。第二部分是组合,类型间的耦合方式直接影响到程序的结构,而GO语言是通过组合方式来进行耦合,来构架整个程序的结构;我将组合分为两类,也是两种思维,第一种叫垂直组合(类型组合)第二种叫水平组合,是通过interfaces之间的连接形成的。当然,还有更大概念上的组合,比如 goroutines和 channel 。

先来介绍一下垂直组合思维,垂直组合可以通过GO提供的类型嵌入方式去实现,它不是继承,没有父子类型的概念,也没有向上、向下的一些类型转换操作;被嵌入的类型并不知道将其嵌入的外部类型的存在;Method 匹配取决于方法的名字而不是类型。


图 19

垂直组合,无非三种,如图 19 所示。第一种通过嵌入 interface 来构建 interface,这个本身就是一种 interface 的聚合行为,可以让一些小接口变成大接口;第二种就是在 Struct 嵌入 interface;第三种是嵌入Struct。后面两种方式实际上都是用委托的模式来体现的。被构建出来的 Struct,它内部行为的真正实现方法是由嵌入类型来决定的,第二种通过嵌入 interface 来构建 Struct,实际上在Struct以及一些代码的测试模式上都会提供帮助,因为一旦它嵌入了interface,它本身对于interface就是一个形式的实现,在具体测试时只要实现几个关注的方法,就可以把这个测试执行起来,而不需要把所有都体现出来。这在嵌入比较多的interface时,是很有用的。

interface 决定了GO语言中类型的水平组合方式;interfaces与其实现者之间的关系是隐式的,无需显示"implements"声明;interface 仅仅是 method 集合,本身并没有数据;它和普通的function 是一样的声明,不需要一些特定位置。

图 20

关于interface,需要具备小接口思维。通常在定义 interface 时,需要1-3个方法,图 20 是典型接口定义,error本身就是interface。


图 21

图 21 是对整个标准库做的一些统计,从曲线当中可以看到 interface 方法数在3个以下的,占了绝大多数。在定义小接口时,前提是需要了解小接口的优势在哪里,第一是方法少,职责单一;第二是易于大家实现和测试;第三是在实现一个方法的测试时,通用性强;第四是易于组合,比如说io.Reader。了解了这么多小接口的优势,那如何定义小接口呢?这取决于你对某个领域的理解是否深入,只有当你深入了解了你所从事的领域,才能把你的抽象层次抬到一个很高的位置,才能做到抽象到位。

接下来介绍一下水平组合思维。水平组合关注的是通过接口进行“连接”的方式来实现水平组合,用来解决一些比较大的,复杂的问题。在GO语言中水平组合形式是非常简单的,通过function进行组合,接受interface类型的参数。下面列举两个例子,一个是基本形式,另一个是wrapper形式。


图 22

图 22 是基本形式代码示例,它仅仅接受了一个简单的 interfaces 类型参数,但是通过这样的简单组合,就能实现我们在代码设计中常见的一些问题。


图 23

图 23 是 wrapper  function 代码实现,它接受一个 interface 类型的参数,并返回相同的参数类型,usage 是具体实现,在使用过程中,可以将 LimiReader 包裹在真实的 Reader 外部来实现一些我们想象不到的功能,这里可以用来限制我们读取的个数。

  • Preference in Concurrency     

第三个价值观,是偏好并发。如果第二个价值观它的一些思维方式决定程度和进展的话,那么第三个价值观实际上关注这个程序动态的变化。并发不是并行,它是关于程序结构的,对于程序结构来说,并发是一个比 interface 组合更大的概念;并发是一种在程序执行层面上的组合,goroutines各自执行待定工作,通过channel和select将goroutines连接起来。并发的思维更适应现代计算的环境,它鼓励独立计算的分解。从某种意义来说,GO语言本身的代码设计就是一个关于并发和 interface 设计。

在语言设计的层面,gorutnies 使得并发执行稳定,它是GO runtime 调度的基本单元;goroutines实现了异步编程模型的高效,但却允许使用同步的范式编写代码,降低心智负担;goroutines被动态的多路复用到系统核心线程上以保证所有 goroutines 的正常运行。channels用于goroutines之间通信和同步,它是连接或组合goroutines的一个主要组件。select可以让goroutines同时协调处理多个channel操作。

并发思维的核心或者本质,就是组合。并发化设计主要是识别和分解出独立的计算单元,并放入goroutines里执行,然后使用channel和select建立与goroutines之间的联系。这里的关键是如何对计算单元进行拆解,对于从其他的语言转到GO语言的开发者来说,实际上就是业务域不同而异,造成不能马上写出那样的代码。并发重点关注的是Goroutines之间的“联系”,着重从goroutines 的退出机制以及通信联系这两方面给大家举两个例子:

1>退出机制

退出机制里面分别就 “detached” goroutines与“parent-child” goroutines这两者进行叙述;“detached” goroutines在启动之后,便跟创建者彻底分割,它生命周期与程序的生命周期相同,程序退出它退出。这类goroutines主要用来在后台执行一些特定任务,如monitor、watcher 等,通常使用事件来驱动。


图 24

如图 24 所示,“parent-child” goroutines 通过这样的一个channel来实现退出的通知。可以看到父 goroutines 创建了一个channel,然后传到子goroutines里面。当父gorutnies退出时,会通知channel退出,有时需要不停通知各类 child  goroutines 同时退出,这时我们channel的特性会发挥作用,它会通知所有在channel上未进行操作的子goroutines,同时这些子goroutines 也会收到相关信息来执行一些相关操作。对于父 goroutines 来说,它在通知之后的一段时间就可以退出,而不需要继续等待。


图 25

如图 25 所示,如果父 goroutines 要获得 child goroutines 的退出状态,可以自定义一些 quit  channel 类型。

2>通信联系

一些 goroutines 会在程序内部提供特定服务,这些 goroutines 使用 channel 作为service  handle,其他 goroutines 是通过 service  handle 进行通信。比如我们经常使用的timer,实际也是这样的机制。

图 26

如图 26 所示,当消息来自不同 service  handle 时,我们需要这样的 handle工具。


图 27

如图 27 对于固定数量的聚合,我们可以用select来实现。


图 28

如图 28 所示,在微服务时代,我们在处理一个请求时,经常会调用多个外部微服务,并综合处理返回结果。而传统调用,是顺序调用,这种方式的不足显而易见,比如说会慢,结果也是不可预知的。

图 29

综合上面的叙述,我们将整个处理请求做了一个分解,将它分发到不同的 goroutines 里面,再汇总返回结果。我们可以看到每个请求返回到 goroutines 里面,然后通过 channel 的方式去处理,这样就会实现调度的可预知性。

图30

图 31

这当中可能有一些问题,造成一些 goroutines 的资源浪费。此时,通过 Context 可以 cancel 掉已经向外发起的在途请求,释放占用资源(如图 30,图31所示)这种方式的优点是通过并发实现性能高效并能够显式实现可预知。

如果你使用 Closing thoughts 这种非常思维,你将会很难体会到GO语言编程的快乐。只有当你形成 GO 语言所推崇的思维方式,才可以体会到语言编程的快乐。最后借用一个观点“less  is  more”,即少就是多。虽然我今天的分享只提出了三种价值观,但是如果我们很好的运用这三种价值观并结合在一起,就会迸发出不一样的意义。


图 32

最后说一下GO 2.0,也是基于这样的模型(图 32)今年8月份 GO1.9 会按计划会发布,这是一个很关键的节点。接下来是GO1.10呢还是2.0,这个还没有定论,GO 还会持续不断的优化下去,但是即便再优化,GO本身的价值观也并不会发生太大变化。谢谢大家!




有疑问加站长微信联系(非本文作者)

本文来自:微信公众平台

感谢作者:白明

查看原文:Go coding in Go way-Gopher China演讲分享

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

2081 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传