版本日期备注
1.02023.5.15文章首发
1.12023.5.28增加代码示例
1.22023.5.30改善内容
1.32023.11.15增加元编程部分的讨论

本文首发于泊浮目的掘金:https://juejin.cn/user/1468603264665335

0. 概要

最近因为业务需要在学Go语言,虽然之前也用过别的语言,但主力语言一直是Java。在这里也想主要想用Java做对比,写几篇笔记。

这篇主要是讲语言本身及较为表面的一些对比。

这里的对比用的是Java8,Go的版本是1.20.2。

1. Compile与Runtime

  • 在静态、动态链接支持方面,两者相同。
  • Go在Runtime时,程序结构是封闭的。但Java并不是,基于Classloader的动态加载,实现许多灵活的特性,比如Spring,FlinkSQL。但这样做会让Java应用的Startup时间更长。
  • Java的Runtime久经打磨,也是面向长时间应用设计。
  • Go直接编译成可执行文件,而Java是先编译成Class文件,然后JVM去解释执行。

有兴趣的同学可以看我之前的的一篇笔记:《笔记:追随云原生的Java》

2. 命名规范

  • Go语言在变量命名规范遵循的是C语言风格,越简单越好。
  • Java建议遵循见名知意。
  • 比如:

    • 下标:Java建议index,Go建议i
    • 值:Java建议value,Go建议v
  • 我认为语言上偏简单的设计,则对工程师的能力要求更强。

3. 标准库对于工程能力的支持

  • 无论是Format还是Test以及模块管理,Go都是开箱即用的,比较舒服。如果标准库的确很好用、社区的迭代能力强,那这是个好事,现在看来就是。
  • Java对于这块都是经过了长期的发展,相关的工具链比较成熟,相当于是物竞天择留下来的。

4. Composite litera(复合字面值)

可能没人听过这个说法,举几个例子:

m := map[int]string {1:"hello", 2:"gopher", 3:"!"}

复合字面值由两部分组成:一部分是类型,比如上述示例代码中赋值操作符右侧的map[int]string;另一部分是由大括号{}包裹的字面值。

在声明对象时,也有类似的用法:

// $GOROOT/src/net/http/transport.go
var DefaultTransport RoundTripper = &Transport{
    Proxy: ProxyFromEnvironment,
    DialContext: (&net.Dialer{
        Timeout:   30 * time.Second,
        KeepAlive: 30 * time.Second,
        DualStack: true,
    }).DialContext,
    MaxIdleConns:          100,
    IdleConnTimeout:       90 * time.Second,
    TLSHandshakeTimeout:   10 * time.Second,
    ExpectContinueTimeout: 1 * time.Second,
}
 

Go推荐使用field:value的复合字面值形式对struct类型变量进行值构造,这种值构造方式可以降低结构体类型使用者与结构体类型设计者之间的耦合,这也是Go语言的惯用法。

这个是真的很香,Groovy和Kotlin也有类似的支持,很方便使用。尤其是构造函数特别长的时候,你可能说builder也可以做到,但谁不想少写几行代码呢。

5. 对于编程范式的支持

  • 在Java中,类是一等公民。支持继承、组合,对函数式编程有一定的支持。
  • 在Go中,函数是一等公民(多返回值),对于函数式编程支持较为完备。对面向对象完全通过组合来实现。

5.1. 函数式编程

  • 在Java中,你想写个工具函数,也要先声明一个类再写进去。略Verbose,其实这个类我们不会把它new出来,只是为了放个方法,所以我们写了个类(这类方法一般我们叫做类方法或者静态方法)。但实际用的时候,XxxUtils.method,前面的Xxx其实有一定的提醒作用,可以作为一个上下文来猜测里面的逻辑。但是如果我们在method里写清楚,当然也可以做到同样的功效,所以这点来说Go是比较舒服的。
//原型
// 自建一个文件:Utils.go
func StringReplace(s string, e string, new string) {
    //doing something...
}

//调用
func main() {
    StringReplace("xxzzyyee", "x","a")
}
public class UtilsDemo {

    public static void main(String[] args) {
        StringUtils.replace("xxzzyee","x","a");
    }
}

5.1.1 Closure(闭包)

从传参是否可以传函数上,我们就可以看出Go的支持比Java好。Java传参中,传一个函数,其实是通过一个匿名对象来传,而Go是真正的一个函数。但在编写时,Go需要把原型写一遍(和设计有关,尽量简洁),而Java有语法糖可以写的很简单。我们来看下,相同的功能在两种语言的对比:

func call(i int, f func(int, int) int) {
    var1 := i + 1
    var2 := i + 2
    f(var1, var2)
}

func main() {
    //var i BinaryAdder = MyAdderFunc(MyAdd)
    //fmt.Println(i.Add(5, 6))
    call(1, func(i int, i2 int) int {
        return i*2 + i2
    })
}
public class FunctionDemo {

    @FunctionalInterface
    interface MyFunction {
        int function(int i, int i2);
    }

    public static void call(int i, MyFunction f) {
        int var1 = i + 1;
        int var2 = i + 2;
        f.function(var1, var2);
    }

    public static void main(String[] args) {
        call(1, (i, i2) -> i * 2 + i2);
    }
}

5.1.2 Currying(柯里化)

在计算机科学中,柯里化是把接受多个参数的函数变换成接受一个单一参数(原函数的第一个参数)的函数,并返回接受余下的参数和返回结果的新函数的技术。这个技术以逻辑学家Haskell Curry命名。

import "fmt"

func times(x, y int) int {
    return x * y
}

func partialTimes(x int) func(int) int {
    return func(y int) int {
        return times(x, y)
    }
}

func main() {
    timesTwo := partialTimes(2)
    timesThree := partialTimes(3)
    timesFour := partialTimes(4)
    fmt.Println(timesTwo(5))
    fmt.Println(timesThree(5))
    fmt.Println(timesFour(5))
}
// result:
// 10
// 15
// 20

相信大家看了例子后会有一些直观的感受。本质上来说就是把第一参数给处理掉,后面的参数和处理以函数形式返回。Go不仅支持函数入参,也支持函数作为返回参数。

这点Java也可以做到,只不过还是用了对象的那套方式套上去的。

5.1.3 Functor(函子)


    type IntSliceFunctor interface {
        Fmap(fn func(int) int) IntSliceFunctor
    }

    type intSliceFunctorImpl struct {
        ints []int
    }

    func (isf intSliceFunctorImpl) Fmap(fn func(int) int) IntSliceFunctor {
        newInts := make([]int, len(isf.ints))
        for i, elt := range isf.ints {
            retInt := fn(elt)
            newInts[i] = retInt
        }
        return intSliceFunctorImpl{ints: newInts}
    }

    func NewIntSliceFunctor(slice []int) IntSliceFunctor {
        return intSliceFunctorImpl{ints: slice}
    }

    func main() {
        // 原切片
        intSlice := []int{1, 2, 3, 4}
        fmt.Printf("init a functor from int slice: %#v\n", intSlice)
        f := NewIntSliceFunctor(intSlice)
        fmt.Printf("original functor: %+v\n", f)

        mapperFunc1 := func(i int) int {
            return i + 10
        }

        mapped1 := f.Fmap(mapperFunc1)
        fmt.Printf("mapped functor1: %+v\n", mapped1)

        mapperFunc2 := func(i int) int {
            return i * 3
        }
        mapped2 := mapped1.Fmap(mapperFunc2)
        fmt.Printf("mapped functor2: %+v\n", mapped2)
        fmt.Printf("original functor: %+v\n", f) // 原函子没有改变
        fmt.Printf("composite functor: %+v\n", f.Fmap(mapperFunc1).Fmap(mapperFunc2))
    }

    //result
    // init a functor from int slice: []int{1, 2, 3, 4}
    // original functor: {ints:[1 2 3 4]}
    // mapped functor1: {ints:[11 12 13 14]}
    // mapped functor2: {ints:[33 36 39 42]}
    // original functor: {ints:[1 2 3 4]}
    // composite functor: {ints:[33 36 39 42]}

这个其实类似Java中的Stream API。两者都有这个能力。

5.2. 面向对象编程

5.2.1. 对象声明

Go的对象方法声明方式比较特殊:

    //声明一个类型
    type MyInt int
    //绑定一个方法
    //func后面的()里,相当于声明了这个方法绑定的类型。在Go语言里叫做recevier,一个函数只能有一个recevier,且不能是指针、接口类型。
    //不能横跨Go包为其他包内的自定义类型定义方法。
    func (i MyInt) String() string {
        return fmt.Sprintf("%d", int(i))
    }
    //在编译期,会把方法的第一个参数变成recevier。很简单的实现。有点像Koltin中的Extension Properties。

    //调用时:
    func main() {
        var myInt MyInt = 1
        println(myInt.String())
    }

5.2.2. 组合的实现

Go的Interface是隐式的,只要你实现了对应的方法,就是这个Interface的实现。这个在一开始使用的时候会很不适应,但这个松耦的一种体现——隐式的interface实现会不经意间满足依赖抽象、里氏替换、接口隔离等设计原则,而且对Interface不会有显示的依赖,程序会更加的灵活。

    type IntPojo interface {
        Get() int
        Set(int)
    }


    type Man struct {
        Age int
    }

    func(s Man) Get()int {
        return s.Age
    }

    func(s *Man) Set(age int) {
        s.Age = age
    }

    //实现了IntPojo的所有方法后,Man就是IntPoJo的实现

5.2.3. 类似继承的实现

  • Go并没有继承。类似的做法叫做类型嵌入(type embedding)的方式。简单来说就是你有一个T1,T2类型,他们有各自的方法,当你声明一个T类型时并包含了T1,T2类型的field,那么T就拥有了T1,T2的方法。这个实现的确比继承舒服多了,继承很容易写出一些坏味道的代码。这是一种委派思想的实现(delegate)。JS中原型链从外表看起来也有点像这种。

    import "fmt"

    type IntPojo interface {
        Get() int
        Set(int)
    }

    type Man struct {
        Age int
    }

    func (s Man) Get() int {
        return s.Age
    }

    func (s *Man) Set(age int) {
        s.Age = age
    }

    func f(i IntPojo) {
        i.Set(10)
        fmt.Println(i.Get())
    }

    // SuperMan “继承”了man的属性,并且有了自己的攻击力和防御力
    type SuperMan struct {
        Man
        attackValue  int
        defenseValue int
    }

    func main() {
        //是的。卡尔-艾尔,就是我们熟知的大超
        clarkKent := SuperMan{attackValue: 100, defenseValue: 100}
        clarkKent.Set(32)
        // get 10
        print(clarkKent.Get())
    }

5.2.4. 多返回值

Go的方式支持多返回值。

    func CopyBuffer(dst Writer, src Reader, buf []byte) (written int64, err error) {
        if buf != nil && len(buf) == 0 {
            panic("empty buffer in CopyBuffer")
        }
        return copyBuffer(dst, src, buf)
    }

这点是比较舒服的,如果在Java中要返回多个值,就要考虑专门为其写个类或者套用Tuple2,Tuple3...之类的,Tuple的用法在其他的一些JVM(Scala,Groovy)语言中随处可见。

6. 异常流:Error与Exception

  • Go里面的error相当于Java的可检异常,Panic相当于Java的RuntimeException和Error。
  • 如果你觉得Go里面大量的if err != nil让代码嵌套时,可以看看一些优化if else的技巧,比如我博客里有。
  • 总的来说,像是在不同的实现做同一件事。也是个仁者见仁智者见智的事。

7. 并发

  • Java用的POSIX原语义的线程。而Go是自己实现了一套用户态的线程,或者说叫协程。
  • POSIX原语义的线程总体来说易用性没这么好,需要牢记一些知识点才可以避免踩坑。Go在这点上比较友好,代码编写起来也会略微简单点。

    import (
        "fmt"
        "time"
    )
    
    func f(from string) {
        for i := 0; i < 3; i++ {
            fmt.Println(from, ":", i)
        }
    }
    
    func main() {
    
        // 假设我们有一个函数叫做 `f(s)`。
        // 我们一般会这样 `同步地` 调用它
        f("direct")
    
        // 使用 `go f(s)` 在一个协程中调用这个函数。
        // 这个新的 Go 协程将会 `并发地` 执行这个函数。
        go f("goroutine")
    
        // 你也可以为匿名函数启动一个协程。
        go func(msg string) {
            fmt.Println(msg)
        }("going")
    
        // 现在两个协程在独立的协程中 `异步地` 运行,
        // 然后等待两个协程完成(更好的方法是使用 [WaitGroup](waitgroups))。
        time.Sleep(time.Second)
        fmt.Println("done")
    }
    // $ go run goroutines.go
    // direct : 0
    // direct : 1
    // direct : 2
    // goroutine : 0
    // going
    // goroutine : 1
    // goroutine : 2
    // done
  • 性能上,由于实践时一般Java会用线程池,所以创建、销毁的代价还好。其实Go也有自己的线程池,用线程去绑多个协程。但在上下文切换上,的确是POSIX原语义的线程代价会大点。
  • 为了避免一个协程把线程独占住,在编译期、以及一些标准库API上都要做缜密的设计。

8.元编程

  • 由于Java在Runtime上的开放性,在元编程上比起Go好很多很多。因此基于一些Java的框架来编写代码时真的很舒服。
  • 虽然Go在Runtime时结构程序封闭,但是可以在编译期做一些事。典型的是go generate,但整体功能较为简单粗糙。较好的做法可以从Java的静态代理(AOP在编译期的实现),Rust的宏来做参考——代码是在编译期无感知的置入我们的代码中,但go generate往往是在编辑开发阶段是可以感知到的。

泊浮目
4.9k 声望1.3k 粉丝