Golang 初步

07 Dec 2015

由于追(sheng)赶(huo)潮(suo)流(po), 最近开始用Golang开发. 总的来说, Golang 给我的感觉是, 语言设计上没有太多新意, 但是是一门非常实用的语言.

强制规范, 追求简洁

代码格式化的问题, 是最重要, 也是最不重要的. 这就好比决定文章用什么字体排版. 说不重要, 它不影响内容; 说重要, 因为好的排版能够让人愉悦地读下去. 统一的格式, 能够让我们在阅读时没有意外感 (脑补一下读一篇各种字体字号组成的的文章时的心理活动).

统一格式固然重要, 但是统一成哪种格式? 这里争论和麻烦就来了. 如何对齐, 如何缩进, 实际上真没有所谓的美感问题. Golang自带的go fmt, 虽然有些专制, 但是很好的节省了格式细节上的无谓关注.

除了格式的相对统一, 语法上的简洁, 也让使用者没有使用上的心智负担. 由于没有过多的选择, 便于写法的统一.

语法上, 觉得还是比较传统, 符合一般对于继承C的脚本语言的认知, 利于吸引Python, Ruby开发人员. 此外, Golang编译执行, 不俗的性能, 以及对C很好的亲缘性, 非常适合服务端的开发工作. 虽然我觉得Erlang是”世界上最好服务端开发语言”, 但是由于其比较特别的语法, 导致还是比较小众.

语言层面依赖管理

任何一门语言, 搞大了, 都需要一个一统天下的依赖管理系统, 而且这个”统一”的过程是漫长且痛苦的. 如PHP的composer, Python的pip, Erlang的rebar, Java的ant到mvn等. go get自动就可以去解决下载依赖, 免去了学习其他工具的成本. 不过, Golang的依赖还是太过激进, 直接获取最新的版本, 代码层级的确定性依赖, 就比较难办, 这点以后在go-package-issues里面详细展开说明.

多重返回, 及返回值命名(named return value)

func Write(p []byte) (n int, err error) {
    // ...
}

觉得设计上是和多重返回组合在一起的”组合拳”. 但是觉得更主要的作用, 还是提示了返回变量的名称, 起到了良好的指示作用 (再也不用纠结调用函数返回叫什么好了).

统一的错误处理逻辑流程

将错误的返回, 通过ret, err := func()的形式, 基本上约束了下来. 不用考虑C中各种错误处理方式了. 虽然也有panic/recover机制, 但需要尽量避免异常.

defer打扫战场

defer由于要手动写清理方法, 使用上固然没有C++ RII的方式简练. 但是, 正式这种手动的控制, 给我们更多的灵活性.

可以通过注册一个defer的方式, 透明的添加业务逻辑. 举个例子:

func getObj(key string) (obj *Obj, err error) {

    if (enableBrench) {
        sl := new Meter("getObj") // 注册一个记速器
        defer sl.Collect() // 返回时进行统计
    }

    if (enableCache) {
        // 缓存查询
        obj, err = getObjCache(key)
        if err != nil {
            return
        }

        defer func() {
            switch err {
            case nil:
                cacheObj(key, obj) // DB 命中, cache中缓存
            case db.ErrNil:
                // DB 未命中, 可能需要在cache中加标记
            default:
                ...
            }
        }()
    }

    // query db
    obj, err = getObjDb(key)
}

可以说, 这里用defer实现了类似于Python的@adapter的功能 (应该不算设计模式里面所谓的adapter模式). 而在C++中, 对于RII封装的类, 就比较难注册新的逻辑到析构过程中去.

在起停服务的时候也很方便, 自动将依赖顺序解决了.

logger := NewLogger()
logger.Start()
defer logger.Stop()

worker := NewWorker(logger)
worker.Start()
defer worker.Stop()

named type

不同于C/C++的typedef, 新类型定义后, 其实际类型所支持的运算, 全部都被禁止了. 类型的自动转换, 也被禁止了 (int和int64不能相乘…).

写的时候, 经常觉得不够方便. 如涉及到数值运算, 或者是在转时间的时候 (time.Duration(int64(timeout) * int64(time.Second))). 但觉得这样设计, 还是必要的: 既然你搞出了个新类型, 就要对它负责; 需要严格控制新类型所允许的操作, 否则会产生对新的类型产生”属于某种类型”的错觉.

这样做的好处在于, 不需要考虑自动类型转换规则的心智负担, 将类型转换显式化; 缺点呢, 就是有时候需要不断地强制转换, 并且对type底层的实际类型还是暴露的. 如int64(time.Duration), 不符合code to interface的原则. 也许应该自带func (time.Duration) ToInt64() int64转换函数?

C++里面, 可以用一个精心构造的类, 负载各种操作运算符啥的, 可以用得更爽. 但是开发上的难度及心智上的负担太大.

枚举类

通过named type, 可以构造枚举类, 从而在函数接口上, 通过类型将参数意义规范化.

type Weekday int

const (
	Sunday Weekday = iota
	Monday
	// ...
)

// 标记位做法
type Flags uint
const (
	FlagUp Flags = 1 << iota // is up
	FlagBroadcast  // supports broadcast access capability
	// ...
)

需要注意的一点是, 如果没有iota, 数值是手动指定时, 类型不会相同. 例如:

type Status int

const (
	StatusFine  Status = 0
	StatusErrDb        = 1000 // type int, not Status
	// ...
)

但是由于untyped constant机制, StatusErrDb还是可以赋给Status类型变量, 所以在实际使用中不会遇到太大问题. 但是为了安全, 还是每个都加上类型比较好.

另外一点, iota虽然方便, 但是实际应用中, 由于枚举数值需要和外部数据关联, 还是需要明确固定下来数值比较安全.

接口自由

对事物的认知, 应当都是现有具象, 之后再通过不断总结, 归纳, 抽象出来.

不同于其他语言的接口机制, Golang在在创建的时候, 不许要指定其所实现的接口. 这迥异的机制, 其实更符合思维的逻辑, 也方便代码复用.

一个对象的方法, 可以在同一个库的不同文件内定义, 这就方便我们把逻辑相关的代码更加紧密地组合在一起, 而不必拘囿于一个文件一个类的做法. 但是(当然地), 不支持对不同包的对象添加类型, 需要通过继承来做:

type wrapper struct {
	*http.Client
}

func Wrapper(c *http.Client) wrapper {
	return wrapper{c}
}

func (wrapper) Extend() {
	// ...
}

继承 = 组合

Golang 里面的继承, 实际上是用组合的方式去做的.

使用中, 需要注意下面一点 (from gopl):

type Point struct { x, y int }

func (p Point) Distance(q Point) int {
	// ...
}

type ColoredPoint struct {
	Point
	Color color.RGA
}

p := ColoredPoint{Point{1, 1}, red}
q := ColoredPoint{Point{5, 4}, blue}

p.Distance(q) // ERROR
p.Distance(q.Point) // OK	

传入参数的多态, 需要通过interface机制来实现.

需要注意字段重复的问题:

type A struct {
	X int
}

type B struct {
	A
	B int
}

b := B{A: A{X: 1}}
b.X == 1 // true

但是如果有字段重名的时候, 使用时就比较困惑:

type B struct {
	A
	X int // add
}

b := B{A: A{X: 1}, X:2}
b.X == 1 // false !!!

不能实现trait特性

type Appender interface {
    Append(dst []byte) []byte
}

// 畅想: 从接口自动推导实现其他接口
func (t Appender) String() string {
    return string(t.Append(nil))
}

channel

可以理解为消息队列, 或者说更高级一点同步的原语, 类似Java中的BlockingQueue. 觉得channel设计上没有什么新意, 要用好的话还需要比较多的封装. 以后在go-concurrency中尝试探讨一下.

过分简洁所带来的自由

func Foo(x, y int), var x, y int. 这种不符合常规习惯的syntax suger, 不太必要, 在看的时候还需要费力解析一下, 我个人是排斥的.

缺少函数重载

函数重载容易造成误解我能理解, 但是有时还是必要的, 如构造函数, 缺省参数填充啥的. 不过好处是, 做refactor的时候比较容易; 此外也逼着你想想应该怎么区分命名, 想清楚区别, 把方法的”意图”(intent)想清楚.

实在受不了的话, 可以通过可变参数func (...interface{})加类型断言(type assertion)的方式, 自己去实现. 不过写起来就比较麻烦, 此外绕过了类型检查系统, 比较不安全.

immutable缺失

Golang 里面, 你没法保证一个结构体内容的不变性.

对于并发编程, immutable的方式是需要了解和学习的. 而Golang本质上还是共享内存的方式在处理.

这点上, Scala就很好, 通过var/val明确区分了变量的语义. 从而在做异步的时候, 就可以比较明确地知道, 哪些数据是只读共享的, 哪些是需要保护的.

于此相关的 const 关键字, 使用范围非常有限.

变量作用域问题

Golang的变量可见性是和C语言一脉相承, 不是函数级别的. 因此, 就有各种变量”遮盖”的问题.

ok, err := foo()
if ok {
    err := bar() // another err here
}

同名变量的遮盖, 在我看来, 带来的麻烦/困惑多于实际便利性. 函数级别变量的限制, 完全可以通过命名绕过, 也迫使编写更加简练的函数.

此外, 引入的package的名字, 也可以被变量遮盖掉. 也许有个选项, 能够警告或者静止变量shadow, 就好了.

容易令人困惑的 :=

:= 无需申明类型的变量声明, 使得静态类型的语言写上去类似于动态脚本语言. 和C++11的auto类似 (哎, C++多好).

在多重赋值时, :=的语义可能是创建新对象, 也有可能是复写已有变量.

err := doSomething() // new var err
b, err := doSomethingElse() // new var b; update err

func work() (err error) {
    b, err := doSomething() // handy in this case
}

但是, 当:=遇上变量名作用域时, 就要小心了. 下面的几个例子, 能分得清么:

var b []byte
if true {
    b, err := json.Marshal("msg")
}

var b []byte
var err error
if true {
    b, err := json.Marshal("msg")
}

var b []byte
if true {
    var err error
    b, err = json.Marshal("msg")
    _ = err
}

var b []byte
var err error
if true {
    var err error
    b, err = json.Marshal("msg")
    _ = err
}

var b []byte
var err error
if true {
    b, err = json.Marshal("msg")
}

还有, 在如下几个例子中, 会预想的不一样, 直接编译错误.

obj.field, err := foo() // ERROR: non-name obj.field on left side of :=

var i int
for i, x := range li { // ERROR: unused variable i
	println(x)
}

generics

如果要在Golang中实现, 一种方式是将对象当作interface{}去处理, 相当于java的Object, 完全抛弃了类型系统. 另一种方式, 则是实现一个interface接口, 但缺点在于, 每个需要调用的类型, 都要去写这个interface的adapter.

缺少模板编程支持. 由于不能实现类似Comparable声明, 导致我们不得不用丑陋的sort.Interface. 其实如果支持类似C++的模板声明, 那么通用容器不是梦.

杂项

一些比较意外的地方:

for d := range list {
	// suprise !!! d is index, not value, 
}

总结

有句话说的好, 最好的语言是不存在的. 那些被追捧的完美的语言, 如Lisp, Haskell等, 始终难以获得很广泛的使用. Golang是被工程师设计出来的语言, 因此虽然平庸, 但是实用. 在静态语言的编译安全性, 和动态语言的方便性上, 可以说达成了某种平衡. 因此, 放弃心中追求完美语言的心, 选择作为一个pragmatic的程序员, 兼容并包, 选择趁手, 便于规范的语言工具, 从而高效的完成任务.

一些参考

HOME