NVIDIA 初创加速计划,免费加速您的创业启动 了解详情
写点什么

Go,基于连接与组合的语言(上)

  • 2013-02-20
  • 本文字数:5285 字

    阅读完需:约 17 分钟

到目前为止,我做过不下于 10 次关于 Go 的讲座,大多数的主题都会与“设计哲学”这样的话题有关。之所以这样,是因为我对自己的定位是 Go 语言的“传教士”,而不是“培训师”。我的出发点在于引起大家对 Go 的关注与兴趣,至于如何去一步步学习 Go 语言的语法知识,我相信兴趣是最好的老师。现今我们学习的平台足够强大,只要你真的很有兴趣,就一定能够学好 Go 语言。

Go 语言是非常简约的语言。简约的意思是少而精。Go 语言极力追求语言特性的最小化,如果某个语法特性只是少些几行代码,但对解决实际问题的难度不会产生本质的影响,那么这样的语法特性就不会被加入。Go 语言更关心的是如何解决程序员开发上的心智负担。如何减少代码出错的机会,如何更容易写出高品质的代码,是 Go 设计时极度关心的问题。

Go 语言也是非常追求自然(nature)的语言。Go 不只是提供极少的语言特性,并极力追求语言特性最自然的表达,也就是这些语法特性被设计成恰如多少人期望的那样,尽量避免惊异。事实上 Go 语言的语法特性上的争议是非常之少的。这些也让 Go 语言的入门门槛变得非常低。

今天我的话题重心是关于 Go 语言编程范式的流派问题。这仍然是关于“设计哲学”方面的。到目前为止,大家可能听过的编程范式主要如下:

  • 过程式(代表:C)
  • 面向对象(代表:Java、C#)
  • 面向消息(代表:Erlang)
  • 函数式(代表:Haskell、Erlang)

过程式编程的代表概念是过程(函数)。这是一个非常古老的流派,基本上所有语言都有过程式的影子。但是比较纯粹的过程式的主流语言比较少,通常比较古老,C 是其中最典型的代表。

面向对象编程是目前广为接受、影响极其深远的流派。面向对象有很多概念:如类、方法、属性、重载、多态(虚函数)、构造和析构、继承等等。Java、C#是其中最典型的代表。Go 语言支持面向对象,但将特性最小化。Go 语言中有结构体(类似面向对象中的类),结构体可以有方法,这就是 Go 对面向对象支持的所有内容。Go 语言的面向对象特征少得可怜。结构体是构成复合对象的基础,只要有组合,通常就由结构体,像 C 这样的过程式语言,照样有结构体。所以 Go 身上没有多少面向对象的烙印,Go 甚至反对继承,拒绝提供继承语法。

面向消息编程是个比较小众的编程流派,因为分布式与并发编程的强烈诉求而崛起。面向消息编程的主体思想是推荐基于消息而不是基于锁和共享内存进行并发编程。Erlang 语言是面向消息编程的代表。Go 语言中有面向消息的影子。因为 Go 语言中有 channel,可以让执行体(goroutine)之间相互发送消息。但 channel 只是 Go 语言的基础语法特性,Go 并没有杜绝锁和共享内存,所以它并不能算面向消息编程流派。

函数式编程也是一个小众的流派,尽管历史非常悠久。函数式编程中有些概念如:闭包、柯里化、变量不可变等。Haskell、Erlang 都是这个流派的代表。函数式编程之所以小众,个人认为最重要的原因,是理论基础不广为人知。我们缺乏面向函数式编程的数据结构学。因为变量不可变,数据结构学需要用完全不同思维方式来表达。比如在传统命令式的编程方式中,数组是最简单的基础数据结构,但函数式编程中,数组这样的数据结构很难提供(修改数组的一个元素成本太高,Erlang 语言中数组这个数据结构很晚才引入,用 tree 来模拟数组)。Go 语言除了支持闭包外,没有太多函数式的影子。

Go 语言有以上每一流派的影子,但都只是把这些流派的最基础的概念吸收,这些特性很基础,很难作为一个流派的关键特征来看。所以从编程范式上来说,个人认为 Go 语言不属于以上任何流派。如果非要说一个流派,Go 语言类似 C++,应该算“多范式”流派的。C++ 是主流语言中,几乎是唯一一门大力宣扬多范式编程理念的语言。C++ 主要支持的编程范式是过程式编程、面向对象编程、泛型编程(我们上面没有把泛型编程列入讨论的流派之中)。C++ 对这些流派的主要特性支持都很完整,说“多范式”名副其实。但 Go 不一样的是,每个流派的特性支持都很基础,这些特性只能称之为功能,并没有形成范式。

Go 语言在吸收这些流派精华的基础上,开创了自己独特的编程风格:一种基于连接与组合的语言。

连接,指的是组件的耦合方式,也就是组件是如何被串联起来的。组合,是形成复合对象的基础。连接与组合都是语言中非常平凡的概念,但 Go 语言恰恰是在平凡之中见神奇。

让我们从 Unix 谈起。Go 语言与 Unix、C 语言有着极深的渊源。Go 语言的领袖们参与甚至主导了 Unix 和 C 语言的设计。Ken Thompson 甚至算得上 Unix 和 C 语言的鼻祖。Go 语言亦深受 Unix 和 C 语言的设计哲学影响。

在 Unix 世界里,组件就是应用程序(app),每个 app 可大体抽象为:

  • 输入:stdin(标准输入), params(命令行参数)
  • 输出:stdout(标准输出)
  • 协议:text (data stream)

不同的应用程序(app)如何连接?答案是:管道(pipeline)。在 Unix 世界中大家对这样的东西已经很熟悉了:

复制代码
app1 params1 | app2 params2

通过管道(pipeline),可以将一个应用程序的输出(stdout)转换为另一个应用程序的输入(stdin)。更为神奇的一点,是这些应用程序是并行执行的。app1 每产生一段输出,立即会被 app2 所处理。所以管道(pipeline)称得上是最古老,同时也是极其优秀的并行设施,简单而强大。

需要注意的是,Unix 世界中不同应用程序直接是松散耦合的。上游 app 的输出是 xml 还是 json,下游 app 需要知晓,但并无任何强制的约束。同一输出,不同的下游 app,对协议的理解甚至都可能并不相同。例如,上游 app 输出一段 xml 文本,对于某个下游 app 来说,是一颗 dom 树,但对 linecount 程序来说只是一个多行的文本,对于英文单词词频统计程序来说,是一篇英文文章。

为了方便理解,我们先尝试在 Go 语言中模拟整个 Unix 的管道(pipeline)机制。首先是应用程序(app),我们抽象为:

复制代码
func(in io.Reader, out io.Writer, args []string)

我们按下图来对应 Unix 与 Go 代码的关系:

也就是说,Unix 中的

复制代码
app1 params1 | app2 params2

对应 Go 语言中是:

复制代码
pipe( bind(app1, params1), bind(app2, params2) )

其中,bind 函数实现如下:

复制代码
func bind(
app func(in io.Reader, out io.Writer, args []string),
args []string
) func(in io.Reader, out io.Writer) {
return func(in io.Reader, out io.Writer) {
app(in, out, args)
}
}

要理解 bind 函数,需要先理解“闭包”。Go 语言中,应用程序以一个闭包的形式体现。如果你熟悉函数式编程,不难发现,这个 bind 函数其实就是所谓的柯里化(currying)。

pipe 函数如下:

复制代码
func pipe(
app1 func(in io.Reader, out io.Writer),
app2 func(in io.Reader, out io.Writer)
) func(in io.Reader, out io.Writer) {
return func(in io.Reader, out io.Writer) {
pr, pw := io.Pipe()
defer pw.Close()
go func() {
defer pr.Close()
app2(pr, out)
}()
app1(in, pw)
}
}

要理解 pipe 函数,除了“闭包”外,需要知晓 defer 关键字和 goroutine(go 关键字)。defer 语句会在函数退出时执行(无论是否发生了异常),通常用于资源的清理操作(比如关闭文件句柄等)。有了 defer 语句,Go 语言中的错误处理代码显得非常优雅。在一个正常的函数调用前加上 go 关键字,就会使得该函数在新的 goroutine 中并行执行。理解了这些背景,这个 pipe 函数不难理解,无非是:先创建一个管道,让 app1 读入数据(in),并向管道的写入端(pw)输出,启动一个新 goroutine,让 app2 从管道的读入端读取数据,并将处理结果输出(out)。这样得到的 app 就是 app1 和 app2 的组合了。

你甚至可以对多个 app 进行组合:

复制代码
func pipe(apps ...func(in io.Reader, out io.Writer)) func(in io.Reader, out io.Writer) {
if len(apps) == 0 { return nil }
app := apps[0]
for i := 1; i < len(apps); i++ {
app1, app2 := app, apps[i]
app = func(in io.Reader, out io.Writer) {
pr, pw := io.Pipe()
defer pw.Close()
go func() {
defer pr.Close()
app2(pr, out)
}()
app1(in, pw)
}
}
return app
}

我们举个比较实际的例子,假设我们有 2 个应用程序 tar(打包)、gzip(压缩):

  • 复制代码
    func tar(io.Reader, out io.Writer, files []string)
复制代码
- ```
func gzip(in io.Reader, out io.Writer)

那么打包并压缩的代码是:

复制代码
pipe( bind(tar, files), gzip )(nil, out)

通过对管道(pipeline)的模拟我们可以看出,Go 语言对并行支持是非常强大的,这主要得益于 Go 的轻量级进程(goroutine)。

为了体现 Go 语言在连接方式上的特别之处,我准备拿经典的面向对象设计案例(故事名不妨叫 Shape)来比较。

在 Java 中,Shape 这个故事是这样的:

首先定义 Shape 接口:

复制代码
interface Shape {
double area(); // 为简化,我们只是求面积
}

然后提供各种具体的 Shape 类型:

复制代码
class Circle implement Shape {
private double x, y, r;
public double area() { return math.Pi/2 * this.r * this.r; }
}
class Rect implement Shape {
private double x, y, w, h;
public double area() { return this.w * this.h; }
}

最后,我们写一个算法,求一系列 Shape 的总面积:

复制代码
class Algorithm {
static public double area(Shape... shapes) {
double result = 0;
for (Shape shape: shapes) {
result += shape.area();
}
return result;
}
}

在 Java 中,组件 Circle、Rect、Algorithm.area 之间,是通过 Shape 接口串联的。比较麻烦的是,Circle、Rect 都需要显式地声明自己实现了 Shape 这个契约,这加深了组件间的耦合。

我们来看看 Go 语言怎么做:

首先,我们定义各种具体的 Shape 类型:

复制代码
type Circle struct {
x, y, r float64
}
func (this *Circle) Area() float64 {
return math.Pi/2 * this.r * this.r
}
type Rect struct {
x, y, w, h float64
}
func (this *Rect) Area() float64 {
return this.w * this.h
}

然后我们实现求多个 Shape 总面积的 Area 组件:

复制代码
type Shape interface {
Area() float64
}
func Area(shapes ...Shape) float64 {
var result float64
for _, shape := range shapes {
result += shape.Area()
}
return result
}

看到这里,你可能误解我在做代码交换的游戏 —— 看起来和 Java 版本的差别只是 Shape 这个 interface 挪了下位置。但这实际上是思维方式的变化。为了凸显这种变化,我把 Area 组件的代码改变下:

复制代码
func Area(shapes ...interface{ Area() float64 }) float64 {
var result float64
for _, shape := range shapes {
result += shape.Area()
}
return result
}

现在和 Java 版本的差异就很显著了:Shape 接口不见了!!!再独立去看看 Circle、Rect、Area 组件,每个组件都很独立,看不到任何额外的耦合代码。但神奇之处在于他们确实可以工作在一起:

复制代码
area := Area( &Rect{w: 20, h: 30}, &Circle{r: 20} )
fmt.Println(“area:”, area)

我们再回头看看 Unix 的组件的连接方式,不难发现 Go 和 Unix 类似的地方:组件之间的连接是松散耦合的,彼此之间有最自然的独立性。但 Go 和 Unix 也有不同的地方,Unix 的契约基于文本流来表达,可能出现连接错误(比如上游输出 xml 文本,但下游期望是 json),在运行时才发现。但 Go 语言是静态编译语言,组件间的协议通过 interface 描述,并在编译期进行检查,如果不合适直接编译失败,如:

复制代码
area := Area( &Rect{w: 20, h: 30}, &Circle{r: 20}, &Foo{} )
fmt.Println(“area:”, area)

如果上面的 Foo 类型没有 Area 方法,则编译不能通过。这种编译期检查是代码品质重要保障。作为对比的是,动态语言如 Python、PHP 等,可以很容易做到类似 Go 语言的松散耦合方式,但是由于没有强制的契约检查,如果误传一个对象实例,就可能出现各种非预期的行为,包括运行时崩溃。

一些面向对象语言的拥护者可能会认为,Java 这种明确指定 implement 的接口,使得程序更规范、在工程管理上更容易受控。但我个人持完全相反的观点,认为这完全是方向性的错误。原因是这种接口定义方式违背了事物的因果关系。举个例子,假设我们实现了一个 File 类,它有 4 个方法:Read、Write、Seek、Close。那么 File 类需要从哪些接口继承呢?Reader(注:我这里是按 Go 语言风格来命名接口,在 Java 中可能会倾向于 Readable,C++ 中则可能会倾向于 IRead 这样的名字)?Writer?Seeker?Closer?ReadWriter?ReadWriteSeeker?ReadWriteSeekCloser?脱离实际需求,这个问题并无正确答案。要满足所有可能的用况,File 类最好从所有这些接口继承,总共需要继承的接口达 24-1 = 15 种。这很恐怖,因为当某个类有 10 个公开方法的时候,需要继承的接口达 210-1 = 1023 种之多!

Go 语言对组件连接方式的调整是革命性的。可以预期的是,它必将慢慢影响目前各种主流的静态类型语言,逐步从错误的方向上修正过来。

2013-02-20 20:476585

评论 3 条评论

发布
用户头像
【下】篇在哪里?
2019-09-07 15:43
回复
用户头像
编程范式 、思想、设计哲学,有意思
2019-09-07 15:40
回复
用户头像
感恩许老板分享。
2019-05-09 19:56
回复
没有更多了
发现更多内容

模型驱动测试:引领软件质量的新潮流

霍格沃兹测试开发学社

流行 NFT 的必备指南

Footprint Analytics

NFT

容器中的⼤模型(一)| 三行命令,大模型让Excel直接回答问题

智领云科技

容器 大模型

新零售SaaS架构:订单履约系统的概念模型设计

不在线第一只蜗牛

架构 零售 SaaS 平台开发

LangChain与Chroma的大模型语义搜索应用

百度开发者中心

人工智能 深度学习 大数据 大模型

Topaz Video AI for mac v4.2.0激活版 强大的视频增强软件

iMac小白

2024年2月NFT市场激增:NFT趋势、热门项目和市场分析

区块链软件开发推广运营

dapp开发 区块链开发 链游开发 NFT开发 公链开发

开篇语 | 容器中的⼤模型 (LLM in Containers)

智领云科技

容器 大模型

JSON 和 Form-data: 深入理解 Web 开发中的数据提交方式

Liam

json 程序员 前端 数据交互 Form-data

华为云云原生专家入选全球顶级开源组织CNCF技术监督委员会

华为云开发者联盟

华为云 云原生开源 华为云开发者联盟 华为云云原生

如何做代币分析:以 CRO 币为例

Footprint Analytics

Token

【教程】APP开发后如何上架?

雪奈椰子

ConfigMap挂载与Subpath在Nginx容器中的应用

华为云开发者联盟

容器 开发 华为云 华为云开发者联盟

SecureCRT for mac v9.5.1注册激活版 强大的终端仿真软件

iMac小白

剧透!3月9日deepin Meetup · 成都站,精彩议题&活动环节早知道

nn-30

Commander One PRO for mac 3.7.0中文激活版 文件管理器

iMac小白

2024上海国际自动驾驶技术展览会

AIOTE智博会

自动驾驶展 智能驾驶展

实例详解如何构建动态SQL语句

华为云开发者联盟

数据库 后端 华为云 华为云GaussDB 华为云开发者联盟

大模型时代下的视觉识别任务

百度开发者中心

人工智能 图像处理 图像 大模型

Desktop Stickers for Mac(桌面贴纸)v2.6激活版

iMac小白

MetaRename for Mac(文件重命名工具)v1.0.10激活版

iMac小白

Animate 2024 for mac(An2024)v24.0.1中文激活版

iMac小白

一文讲明白Java中线程与进程、并发与与并行、同步与异步

EquatorCoco

Java 线程 进程

多任务一次搞定!selenium自动化复用浏览器技巧大揭秘

霍格沃兹测试开发学社

模型驱动测试:引领软件质量的新潮流

测试人

软件测试 自动化测试 测试开发

MO干货 | shuffle执行计划解析(上篇)

MatrixOrigin

数据库 云原生 分布式,

CodeFuse开源ModelCache

百度开发者中心

深度学习 大模型 人工智能、

NUKE 14 for mac v14.0v5激活版 电影后期特效合成软件

iMac小白

Java IDEA 插件新升级,帮助生成 API 文档

Apifox

Java IDEA Apifox 接口文档 API

EndNote 21 for Mac(文献管理软件) v21.2激活版

iMac小白

转型敏捷中,有哪些无形的障碍?

敏捷开发

项目管理 Scrum 敏捷开发 敏捷转型

Go,基于连接与组合的语言(上)_Google_许式伟_InfoQ精选文章