一名Java开发的Go语言学习笔记:语言基本特性

版本 日期 备注
1.0 2023.5.15 文章首发
1.1 2023.5.28 增加代码示例
1.2 2023.5.30 改善内容
1.3 2023.11.16 增加元编程部分的讨论
1.4 2023.12.17 修改标题,改善部分内容
1.5 2023.12.21 增加方法重载,语法糖部分

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往往是在编辑开发阶段是可以感知到的。

9. 泛型

  • Go在早期为了保持简单一直未引入泛型而被开发者诟病,自1.18时引入了泛型。香了很多。在这点上几乎两者持平。

10.方法重载

  • Go并不支持方法重载。设计者认为这并不是一个好的设计。
  • Java支持方法重载。从实战来看,这块带来的便利是大于隐患的。

11.语法糖

  • Go在语言设计上保持简单,语法糖基本没有。

type MyConsumer func(a int)

func callFunc(i int, c MyConsumer) {
    c(i)
}

func Test() {
    callFunc(1, func(a int) { // <- 还要再写一遍原型,很烦
        print(a)
    })
}

类似的例子在Java中可以写成Lambda:

class Performance {

    @FunctionalInterface
    interface MyConsumer {

        void accept(int a);
    }

    static void callFunc(int i, MyConsumer c) {
        c.accept(i);
    }

    public static void main(String[] args) {
        callFunc(1, a -> System.out.println(a));
    }
}

适当的提供语法糖可以让开发者少一些重复代码。这点Java做的还不错。

但不得不说上面的代码综合来说还是Go更加简洁。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,390评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,821评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,632评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,170评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,033评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,098评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,511评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,204评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,479评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,572评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,341评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,893评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,171评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,486评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,676评论 2 335

推荐阅读更多精彩内容