---
layout: mypost_no_toc
title: 开始go语言学习
categories: [tech]
toc: true
---

在公司搬了 5 年砖了,一直用着 C++ 作为主要的开发语言。期间也写过一些 Java,夹杂着一些 Python 用来写脚本,还有大量的 protobuf(PS:写序列化格式也算语言?emmm,不过定义好 API 和数据结构,在搬砖的过程中确实已经完成了一半工作量了)。极少数的时候需要用到 Go 来修改一些用 Go 写的工具。

## 为什么要学 Go

作为一个程序员,说实话,我并不是一个对各种语言有"追求"的人。什么语言好用就用什么,能完成工作就行。但是断断续续接触了不少 Go 语言的特性以后,这个**语法上更为现代,并且性能上在各个方面都有不错表现的语言**,让我决定可以试着深入了解一下。

虽然我在大多数时候还是会怀疑 Go 在安全性的考虑上会在性能上做一些妥协。不过安全性这点比起 C/C++ 来说,完全就是**开一辆有着 ABS、车内电脑控制的超跑和全手动的 F1 之间的区别**。

所以在项目中,为了安全而选择牺牲一点性能是完全值得的。尤其是和 Java 比较的时候,能够拥有类似的安全性但是更高的性能,这点太香了。

### 真正让我下定决心的项目

对于 Go 的大部分深入认识,是在接触了 **trojan-go** 这个项目之后。我阅读了 trojan 和 trojan-go 两个项目的代码,一个是 C++ 写的,一个是 Go 写的。

比较的结果很明显:**像这类需要在网络层使用并发的程序,使用 Go 会带来很多方便。**

goroutines 和 channel 这些极为方便又不损失多少性能的抽象,简直是太好用了。在 C++ 里要实现类似的功能,得自己管理线程池、锁、条件变量这一堆东西,写起来又累又容易出错。而 Go 里面,几行代码就搞定了。

所以,现在我从一个半吊子 C++ 程序员的角度出发,从 Go 的基础特性开始学习使用 Go 语言。

# Golang 的一些基础特性

从一个 C++ 程序员的角度来看,Go 有很多让我眼前一亮的特性,也有一些让我觉得有点别扭的地方。下面记录一下我的学习过程和感受。

## 类型推断

从字面值或者返回值,不需要明确指出类型,编译器也知道这个是什么类型。就像 C++11 之后的 `auto` 关键词:

```go
var x = 1.0  // x 是 float64
var y = getObjectY()

这点很好,从 C++ 切换过来的时候可以很快地适应。不用每次都写一大串类型名,尤其是那些模板类型,写起来真的很烦。

指针

Go 是有指针类型的,并且操作也和 C++ 一样,使用的是 &*

var s Something = getSomething();
var sp *Something = &s;
var ss Something = *sp; // 从 sp 复制并新建一个 Something

也可以用 new 关键词来创建一个新指针:

var p *Something = new(Something)

这点上和 C++ 非常类似。赋值会进行复制操作,不像 Java,非基本类型都是通过引用传递的。

有一点不同的是,在 Go 里面没有 ->,也没有指针运算。无所谓指针类型和普通类型,成员函数的访问都是通过 . 进行操作:

var a *Something = new(Something)
var b Something = *a
a.do();
b.do();

这点我很喜欢,因为编译器永远都会知道值的类型,所以无所谓到底用的是 . 还是 ->。在 C++ 里有时候看到一个 . 或者 ->,还得想一想这个变量到底是指针还是值。

没有指针运算,意味着没法像 C++ 一样对指针进行 ++ 操作来遍历数组。必须通过索引来访问数组元素。这个涉及到了边界检查,就像一开始说的,这点可以有效地防止越界而造成的安全漏洞。虽然有一点性能损失,但是安全性提升了很多,值得。

Go 函数可以通过值或指针获取参数,这类似于 C++,但与 Java 不同(Java 总是通过引用获取非基本类型)。

与 Java 一样,Go 没有常量指针或常量引用的概念。因此,如果函数将参数作为指针,编译器无法阻止我们更改它指向的值。在 Java 中,这通常是通过创建一个不可变类型来完成的,而许多 Java 类型,例如 String,都是不可变的。

但我更喜欢 C++ 中的常量性、指针/引用参数和在运行时初始化的值的语言支持。 Go 在这方面确实有点遗憾。

没有隐式转换

Go 没有类型之间的隐式转换,比如 intuint,或 floatint。这也适用于通过 ==!= 进行的比较。

所以,这就不会编译:

var x int = -1
var y uint = x  // 报错

var a = 1.0
var b int = a  // 报错

C++ 编译器警告可以捕获其中的一些,语言检查器也可以做到这点。在工程应用上,这些隐式转换是需要被避免的,所以 Go 直接在语言层面禁止了,我觉得挺好的

Go 仍然具有无符号整数。与 C++ 的标准库不同,Go 使用有符号整数表示大小和长度。这点在我们公司内部已经使用 int64int32 之类的 alias 来替换标准库里的 longint 了。希望 C++ 标准库有一天也能做到这一点。

Go 的限制很严格,甚至定义的类型之间的隐式转换也是不被允许的。所以,这个例子就不会编译:

type AliasI int
type AliasJ int
var a AliasI = 100
var b AliasJ = a  // 出错

而在 C++ 中,这只是 typedef,完全可以互相赋值。所以这点上,我希望在使用 type 定义变量时,将其视为 C++ 编译器中的警告而不是无法编译。有时候这个限制有点过于严格了。

不过,我们可以将基础类型的(无类型)值隐式分配给类型化的变量,但不能将基础变量隐式分配给类型化的变量:

type AliasI int
var a AliasI = 100  // 可以编译

var i int = 100
var b AliasI = i  // 无法编译

引用

c++里面引用和值以及指针都是可以很方便地使用。Go 看上去也有引用(emm,看上去像值的指针?),但是只适用内置的 slice、map 和 channel 类型。例如,函数可以更改其输入slice参数,并且该更改将对调用者可见,即使该参数未声明为指针:

func doThing(someSlice []int) {
  someSlice[2] = 3;
}

类似的在 C++ 中,很明显可以看出这是一个引用:

void doThing(Thing& someSlice) {
  someSlice[2] = 3;
}

我不确定这是否是Go的基本特征,或者只是关于这些特定类型是如何实现的。只有不同的类型会不一样(类似java函数参数的传值和传引用),这点令我在刚开始接触的时候会感觉到困惑。一个语言能做到便利性很好,但是在一致性上也需要考虑好。

常量

Go 的 const 关键字不像 C++ 中的 const ,它表示变量的值在初始化后不应更改。它更像是 C++11后的constexpr关键字。编译时确定值,具有类型安全性。例如:

const pi = 3.1415

请注意,我们没有为 const 值指定类型,因此根据值的语法,该值可以用于各种类型,有点像 C 宏 #define。但是我们可以通过指定类型来限制它:

const pi float64 = 3.1415

不过与 C++ 中的 constexpr 不同,没有可以在编译时计算constexpr函数,因此不能这样做:

const pi = calculate_pi()

type Point struct {
  X int
  Y int
}
const point = Point{1, 2}

虽然你可以用一个简单的类型来做到这一点,它的基础类型可以是 const:

type Yards int
const length Yards = 100

只有for循环

Go 中的所有循环都是for循环,没有 while 或 do-while 循环。与 C、C++ 或 Java 相比这简化了语言。例如:

for i := 0; i < 100; i++ {
  ...
}

或者,就像 C 中的 while 循环:

for keepGoing {
  ...
}

并且 for 循环对诸如字符串、切片或映射之类的容器具有基于范围的语法:

for i, c := range things {
  ...
}
for _, c := range things {
  ...
}

C++11 以后也有基于范围的 for 循环。但我喜欢 Go 的for,可以同时给你索引和值。

原生 (Unicode) 字符串类型

Go 有一个内置的字符串类型,以及内置的比较运算符,例如 ==、!= 和 <(Java 也是如此)。与 Java 一样,字符串是不可变的,因此在创建它们之后您无法更改它们,尽管您可以通过使用内置运算符 + 连接其他字符串来创建新字符串。例如:

str1 := "foo"
str2 := str1 + "bar"

Go代码是UTF-8编码,字符串文字可能包含非ASCII的utf-8字符。Go将Unicode字符称为“runes”。内置的len()函数返回的是字节数,用于字符串的运算符[]也是对字节进行操作。不过有一个utf8函数是将字符串以“runes”处理:

str := "foo"
l := utf8.RuneCountInString(str)
// 基于范围的 for 循环处理,而不是字节:
str := "foo"
for _, r := range str {
  fmt.Println("rune: %q", r)
}

C++ 仍然没有标准的等价物。

切片(slice)

Go 的切片有点像 C 中的动态分配数组,尽管它们实际上底层还是数组,只是在此基础上提供了一个类似于view的抽象。两个切片也可以是同一个底层数组不同部分的view。它们感觉有点像 C++17 中的 std::string_view 或 GS​​L::span,但它们可以轻松调整大小,类似 C++ 中的 std::vector 或 Java 中的 ArrayList。

我们可以像这样声明一个跨度,并附加到它:

a := []int{5, 4, 3, 2, 1} // A slice
a = append(a, 0)

数组(与切片不同,其大小不能改变)具有非常相似的语法:

a := [...]int{5, 4, 3, 2, 1} // An array.
b := [5]int{5, 4, 3, 2, 1} // Another array.

您必须小心通过指针将数组传递给函数,否则它们将被(深度)按值复制。

切片不是(深度)可比较或可复制的,这与 C++ 中的 std::array 或 std::vector 不同,这点上感觉相当不方便。

如果调用内置的append()函数发现需要的容量超过现有容量(可能超过当前长度),则它可能会分配更大的底层数组。

在切片里面,你不能保留指向切片元素的指针。如果可以,垃圾收集系统将需要保留先前的底层数组,直到停止使用该指针为止。

与C++ 数组不同,也与std::vector的[]不同,尝试访问切片的无效索引将导致panic,而不仅仅是未定义的行为。这个更为安全,虽然边界检查有一些小的性能成本。

maps

Go 有一个内置的map类型。这大致相当于 C++ std::unordered_map(哈希表)。不知道是链式哈希表(std::unordered_map)还是开放寻址的哈希表(C++标准库里面没有)。在map中,key是需要可以被比较和计算hash的。所以只有基本类型(int、float64、string 等,但不是切片)或仅由基本类型组成的结构是可比较的,从而用来做key。这点上 C++ 更自由,只需要重载std::hash

与 C++ 不同,Go不能在map中存指向元素的指针,因此更改值的一部分就意味着将整个值复制并替换。Go这样做是为了避免在map扩表时出现无效指针的问题。

map用起来像这样:

m := make(map[int]string)
m[3] = "three"
m[4] = "four"

多个返回值

Go 中的函数可以有多个返回类型,我发现这比c++,Java输出参数更加显式。例如:

func getNFoo() (int, Foo) {
  return 2, getFoo()
}
a, b := getNFoo()

这有点像在C++11的返回tuple,尤其是在C++17后使用结构化绑定:

std::tuple<int, Foo> get_n_foo() {
  return make_tuple(2, get_foo());
}
auto [a, b] = get_things();

垃圾收集

与 Java 一样,Go 具有自动内存管理功能。所以可以愉快地这样做,而不必担心需要去手动释放内存:

func getThing() *Thing {
  a := new(Thing)
  ...
  return a
}
 
b := getThing()
b.foo()

甚至可以这样做,不用关心,甚至不需要知道实例是在堆栈还是堆上创建的:

func getThing() *Thing {
  var a Thing
  ...
  return &a
}
b := getThing()
b.foo()

随便做点leetcode来练习一下

89. Gray Code

这题用到了动态创建slice,使用make语法来创建一个

func grayCode(n int) []int {
    length := 1 << n
    nums := make([]int, length)
    for i := 1; i < length; i++ {
        nums[i] = i ^ (i >> 1)
    }
    
    return nums
}

566. Reshape the Matrix

这题主要就是数组操作,go里面的二维数组相当于是一个在创建的时候需要对每一行进行声明,这点没有c++方便。 go的二维数组在声明和初始化的时候缺少必要的标准库支持,这是我一开始没有预料到的,缺少了一点方便。有点类似于Java的 List<List<», 每一次往里添加的时候得new一个新的List出来。不过这并不能算是太大的问题。

func matrixReshape(mat [][]int, r int, c int) [][]int {
    if r * c != len(mat) * len(mat[0]) {
        return mat
    }
    result := make([][]int, r)
    i := -1
    j := c
    for _,row := range mat {
        for _,value := range row {
            if j == c {
                j = 0
                i++
                result[i] = make([]int, c)
            }
            result[i][j] = value;
            j++
        }
    }
    return result;
}

1338. Reduce Array Size to The Half

这题用了map,slice以及使用了sort包。 sort包里主要就是为了操作数组进行排序工作。在题目里需要将数据按逆序排序。

import (
    "sort"
)

func minSetSize(arr []int) int {
    m := make(map[int]int)
    for _,v := range arr {
        m[v]++
    }
    
    l := make([]int, 0, len(m))
    for _, f := range m {
        l = append(l, f)
    }
 
    // sort.Sort(sort.Reverse(sort.IntSlice(l)))  // 
    sort.Slice(l, func(i, j int) bool { return l[i] > l[j] })
    
    count := 0
    for i,v := range l {
        count += v
        if (count >= len(arr)/2) {
            return i+1;
        }
    }
    return 0;  // Never happen
}