Go 语言入门指南:基础语法和常用特性解析 | 青训营

621 阅读17分钟

介绍

Go语言简介

Go语言(也被称为Golang)是一门由Google开发的开源编程语言,于2009年首次发布。它是一种静态类型、编译型语言,旨在提供高效的开发体验和执行速度。Go的设计目标是简洁、直观、高效,并且支持并发编程,特别适合构建大规模系统。

Go的设计目标与优势

Go语言的设计目标是为开发人员提供简洁高效的编程体验,具有以下主要优势:

  1. 简洁易学:Go语言的语法简单直观,学习曲线平缓,使得新手很容易上手。它去除了许多其他语言中常见的冗余和复杂性,使得代码更易于理解和维护。

  2. 高效执行:Go是一门编译型语言,编译后的代码执行效率高。Go的编译器将代码编译成本地机器码,因此在执行速度方面表现优异。

  3. 并发编程支持:Go原生支持并发编程,通过Goroutines和通道(Channels)可以轻松地实现并发任务,简化了并发编程的复杂性。

  4. 内置垃圾回收:Go自带垃圾回收机制,开发人员无需手动管理内存,减轻了内存管理的负担,提高了开发效率。

  5. 丰富标准库:Go附带一个强大的标准库,涵盖了网络编程、文件处理、字符串操作、加密等各种功能,为开发者提供了很多实用的工具。

  6. 静态类型检查:Go是一门静态类型语言,编译器在编译时进行类型检查,能够在编译阶段捕捉一些错误,提供更强的代码健壮性。

  7. 开发效率:Go语言的快速编译和部署,以及丰富的标准库,大大提高了开发效率。同时,Go语言的工具链和社区生态系统支持使得构建和维护项目变得更加便捷。

安装Go环境和设置工作区

在开始学习Go之前,需要先安装Go语言的开发环境并设置工作区。以下是安装Go环境的基本步骤:

  1. 下载Go语言安装包:前往官方网站 下载适用于你操作系统的安装包,并进行安装。

  2. 验证安装:打开终端或命令行,输入go version命令,如果正确显示Go的版本号,说明安装成功。

完成上述步骤后,Go语言开发环境就已经搭建好了。接下来,可以使用文本编辑器或集成开发环境(IDE)开始编写Go代码,并通过go run命令执行代码。

基础语法

Hello, World!:第一个Go程序

创建文件main.go,输入以下代码

import "fmt"

func main() {
    fmt.Println("Hello, World!")
}

然后运行命令go run main.go,应该出现类似下面的输出。

Hello, World!

进程 已完成,退出代码为 0

每个 Go 程序都是由包构成的。在源文件的开头使用package <包名>来声明源文件所在包。

程序从 main 包中的main函数开始运行。如果存在init函数,则会先运行init

导入包使用import关键字。导入多个包可以重复写多次import或使用组合导入

import (
    "fmt"
    "math"
)

在 Go 中,如果一个名字以大写字母开头,那么它就是已导出的。例如,Tree 就是个已导出名,而insert不是。同样的,math包中的Pi也是一个导出名。

package tree

type Tree struct {
    // ...
}

func insert(/* ... */) { /* ... */ }

变量和常量

使用var关键字来声明一个变量列表,跟函数的参数列表一样,类型在最后。可以包含初始值,每个变量对应一个。如果初始化值已存在,则可以省略类型;变量会从初始值中获得类型。

类似地,常量声明使用const

var a, b, c int = 1, 2, 3
const pi, e = 3.14, 2.78

在函数内,可在类型明确的地方直接使用短声明:=代替var声明。

hello, java := "hello", "jvav"

由于函数外的每个语句都必须以关键字开始,:=只能在函数内使用,在函数外应当使用varconst声明。

:==的区别::=用于声明新变量,可自动推断类型;=用于给已存在的变量赋值。 但在满足下列条件时,已被声明的变量v可出现在:=声明中:

  • 本次声明与已声明的v处于同一作用域中(若v已在外层作用域中声明过,则此次声明会创建一个新的变量),
  • 在初始化中与其类型相应的值才能赋予v,且
  • 在此次声明中至少另有一个变量是新声明的。

这使得在同一作用域内err变量重复使用短声明成为可能。

数据类型

基本数据类型

bool

string

int  int8  int16  int32  int64
uint uint8 uint16 uint32 uint64 uintptr

byte // uint8 的别名

rune // int32 的别名
    // 表示一个 Unicode 码点

float32 float64

complex64 complex128

没有明确初始值的变量声明会被赋予它们的 零值

零值是:

  • 数值类型为 0
  • 布尔类型为 false
  • 字符串为 ""(空字符串)。

Go使用显式类型转换,表达式T(v)将值v转换为类型T

i := 3
f := float64(3)
u := uint(i)

数值常量的默认类型是int float64 complex128

复合数据类型:指针、数组、切片、映射、结构

  • 指针

    类型*T是指向T类型值的指针。其零值为nil

    可以使用取址符&来获取指向操作数的指针。

    解引用符*来获取指针指向的变量的值。

    Go不存在指针运算,类似p := p + 1的语句是非法的。

  • 结构

    类型struct { ... }是一个结构体,是一组字段的组合。与C语言中的结构体类似。

    type Vertex struct {
        X int
        Y int
    }
    
    // 或使用匿名结构
    
    v := struct {
        X int
        Y int
    }{Y: 1, X: 2}
    

    结构体变量或结构体指针都使用.来访问成员。

    结构体支持组合其他结构,称为 嵌入。只需将其他类型的名称写在定义中就可以直接使用该类型的所有字段和方法。若想显式访问,只需使用类型名。

  • 数组

    类型[n]T表示拥有nT类型的值的数组。

    使用[...]T{...}文法可自动推断初始化列表中的元素个数。

    • 数组是值。将一个数组赋予另一个数组会复制其所有元素。
    • 特别地,若将某个数组传入某个函数,它将接收到该数组的一份副本而非指针。
    • 数组的大小是其类型的一部分。类型[10]int[20]int是不同的。
    a := [3]int{1,2,3}
    b := [...]int{1,2,3,4,5,6} // [6]int
    
  • 切片

    类型[]T表示一个元素类型为T的切片。未初始化的切片为nil,使用make([]T, len, cap)来创建一个具有初始长度len和初始容量cap的切片。

    s的类型是数组或切片,s[low : high]的类型是一个切片,包含下标在[low, high)内的元素。可以省略lowhighlow默认为0,high默认为len(s)

    切片本身不存储数据,它只是数组的一个视图,就像是数组的引用。 可以看作是一个存储了底层数组指针、起始下标和结束下标的结构。

    切片的长度len(s)就是它所包含的元素个数。

    切片的容量cap(s)是从它的第一个元素开始数,到其底层数组元素末尾的个数。

    使用copy函数将源切片的元素复制到目的切片。它返回复制元素的数目。 函数支持不同长度的切片之间的复制(它只复制较短切片的长度个元素)。 此外, copy 函数可以正确处理源和目的切片有重叠的情况。

    func copy(dst, src []T) int
    

    使用append函数在切片末尾添加新元素。它返回新的切片。 如果长度超出容量,函数会分配更大的数组,并让切片指向这个新数组。

    func append(s []T, vs ...T) []T
    

    虽然切片操作并不会复制底层的数组。但有时候可能会因为一个小的内存引用导致保存所有的数据。

    var digitRegexp = regexp.MustCompile("[0-9]+")
    
    func FindDigits(filename string) []byte {
      b, _ := ioutil.ReadFile(filename)
      return digitRegexp.Find(b)
    }
    

    这个函数加载文件到内存,然后匹配第一个连续数字的文本段,结果以切片返回。但由于切片引用了原始数组,导致数组b的内存不能被释放。即使我们只想使用部分字节,但还是保存了所有的文件内容。

    简洁的解决方案是将切片复制到一个新切片中。

    将原来的返回语句修改为return append([]byte{}, digitRegexp.Find(b))

  • 映射

    类型map[K]V是映射类型,是一种关联数据类型,在其他语言中称为哈希表或字典。

    映射是方便而强大的内建数据结构,它可以关联不同类型的值。其键可以是任何定义了相等性操作符的类型(但切片不能作为键,因为它们的相等性还未定义)。与切片一样,映射也是引用类型。若将映射传入函数中,并更改了该映射的内容,则此修改对调用者同样可见。

    未初始化的映射为nil,同样可以使用make函数来创建一个映射。

    type Vertex struct {
      Lat, Long float64
    }
    
    var m = map[string]Vertex{
      "Bell Labs": {40.68433, -74.39967},
      "Google":    {37.42202, -122.08408},
    }
    m := make(map[string]Vertex, 2) // 创建具有容量为2的映射
    

    m[key] = elem

    delete(m, key)

    m[key] = elem

    elem, exists := m[key]

控制结构

条件语句

if无需使用(),但{}是必须的,并且{不能换行。可以在条件表达式之前执行一个简单语句,其变量作用域仅在当前if分支以及之后的分支之内。

if ch := '2'; 'A' <= ch && ch <= 'Z' {
    // ...
} else if 'a' <= ch && ch <= 'z' {
    // ...
} else {
    // 应当来到此分支
}

Go也能使用switch语句,类似于C语言中的,但功能更加强大。 条件支持非常量,无需显式写出break。 若不写条件,相当于switch true {...},将匹配case为真的情况。 case可通过逗号分隔来列举相同的处理条件。

循环语句

for {
    // 死循环
}

for i := 0; i < 10; i++ {
    // 类C的for循环
    // 只有后缀自增/自减
}

k := 10
for k >= 0 {
    // 类C的while循环
    k--
}

for i, val := range someArr {
    // 使用range子句遍历数组
}

for key, val := range someMap {
    // 使用range子句遍历映射
}

for val := range someChan {
    // 使用range子句遍历信道
}

跳转语句:break continue

类似于C语言,功能是相似的。但支持标签跳转,C语言中使用goto someLabel;来实现。

函数和方法

定义函数

形式如下

func name(parameter-list) (result-list) {
    body
}

具体例子如下

package main

import "fmt"

func GetMinAndMax(x, y int) (min int, max int) {
    if x < y {
        min = x
        max = y
    } else {
        min = y
        max = x
    }
    return
}

func main() {
	a, b := GetMinAndMax(1, 3)
	fmt.Println(a, b)
}

函数参数

函数可以没有参数或接受多个参数。 如GetMinAndMax所示,当连续的几个参数类型相同时,可只写最后一个参数的类型。

多返回值和命名返回值

GetMinAndMax所示,函数可返回多个值,并且支持具名返回值。命名的返回值可以像形式参数一样操作,就像传入了引用一样。

函数值与闭包

函数也是值。它们可以像其它值一样传递。

Go函数可以是一个闭包。 闭包是一个函数值,它引用了其函数体之外的变量。 该函数可以访问并赋予其引用的变量的值,换句话说,该函数被这些变量“绑定”在一起。

func fibonacci() func() int {
    fn_0 := 0
    fn_1 := 1
 
    return func() int {
        fn_1 = fn_1 + fn_0
        fn_0, fn_1 = fn_1, fn_0
        return fn_1
    }
}

defer语句

关键字defer语句会将函数推迟到外层函数返回之后执行。

推迟调用的函数其参数会立即求值,但直到外层函数返回前该函数都不会被调用。

被推迟的函数调用会压栈,外层函数返回时,按照后进先出的顺序调用。

无论函数如何返回,都必须释放一些资源,那么写defer语句是最适合的。

方法

Go不是一个面向对象范式的语言,而是面向接口的。 但也可以在结构体类型上定义方法。 方法就是一类带特殊的 接收者 参数的函数。

type Vertex struct {
    X, Y float64
}

func (v Vertex) Abs() float64 {
    return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

// 等价于

func Abs(v Vertex) float64 {
    return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

但接收者的类型定义和方法声明必须在同一包内,并且也不能给内建类型定义方法。

接收者类型也可以是指针*T,这样的方法就可以修改接收者。 无论方法的接收者类型是否为指针*T,调用时接收者类型为T*T皆可。

接收者类型为普通类型T,那么方法不能修改接收者(除了引用类型,但也不能改变它本身),此时修改的是其副本。一般来说,*T更常用。

面向接口编程

接口的基本概念

接口类型 是由一组方法签名定义的集合。

接口类型是一种抽象的类型。它不会暴露出它所代表的对象的内部值的结构和这个对象支持的基础操作的集合;它们只会表现出它们自己的方法。也就是说当你有看到一个接口类型的值时,你不知道它是什么,唯一知道的就是可以通过它的方法来做什么。

定义和实现接口

作为例子,来看看io包中定义的一些非常有用的接口。

package io
type Reader interface {
    Read(p []byte) (n int, err error)
}
type Closer interface {
    Close() error
}

也可以使用类似嵌入的方法来组合其他接口

type ReadWriter interface {
    Reader
    Writer
}
type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

当一个类型按照接口要求定义了接口的所有方法,那么该类型就实现了接口。

接口值

接口值,由两个部分组成,一个具体的类型和那个类型的值。 接口值可以用作函数的参数或返回值。

接口值可以看做包含值和具体类型的元组:

(value, type)

接口值保存了一个具体底层类型的具体值。 接口值调用方法时会执行其底层类型的同名方法。

未初始化的接口值是(nil, nil)。两个接口值相等仅当它们都是nil值,或者它们的动态类型相同并且动态值也根据这个动态类型的==操作相等。

需要注意的是,接口值的内部值为nil并不代表它本身为nil

package main

import "fmt"

type I interface {
    M()
}

type T struct {
    S string
}

func (t *T) M() {
    if t == nil {
        fmt.Println("<nil>")
        return
    }
    fmt.Println(t.S)
}

func main() {
    var i I
    describe(i)

    var t *T
    i = t
    describe(i)
    i.M()

    i = &T{"hello"}
    describe(i)
    i.M()
}

func describe(i I) {
    fmt.Printf("(%v, %T)\n", i, i)
}

输出为

(<nil>, <nil>)
(<nil>, *main.T)
<nil>
(&{hello}, *main.T)
hello

空接口

空接口 就是没有指定任何方法的接口,即interface{}。它可以保存任何值。

类型断言

类型断言 提供了访问接口值底层具体值的方式。具体语法为i.(T),其中i为接口值,T为某种类型。

如果类型T是某个具体非接口类型,那么类型断言会验证对象i的实际类型是否与T相匹配。当这个验证成功时,类型断言的结果就是对象i的实际值,并且被指定为类型T

当断言的类型T是一个接口类型时,类型断言会检查对象i的实际类型是否符合接口类型T的要求。在验证成功时,并不会返回对象的实际值;而是产生一个新的接口值,其实际类型和值部分与对象i相同,但静态类型被指定为类型T

var w io.Writer = os.Stdout
val := w.(*os.File)

该语句断言接口值w保存了具体类型*os.File,并将其底层值赋予变量val

类型选择

类型选择 是一种按顺序从几个类型断言中选择分支的结构。

switch v := i.(type) {
case T:
    // v 的类型为 T
case S:
    // v 的类型为 S
default:
    // 没有匹配,v 与 i 的类型相同
}

并发编程

Go程:并发的基本单元

Go程是一种类似协程的轻量级线程,是Go中并发的基本单元。

使用go关键字创建Go程

go DoSomething(a, b, GetSomething()) // 启动一个Go程执行函数
go func() {
    fmt.Println("Hello, world!")
}()

函数DoSomething的参数求值将会在当前Go程中进行,其本身将在新的Go程中运行。

新的Go程运行在与当前Go程相同的地址空间中,因此在访问共享的内存时必须进行同步。

使用通信来共享内存,而不是使用共享内存来通信

标题的意思就是在说在不同Go程之间使用 信道 来同步和共享数据。

func main() {
    ch := make(chan int)

    go func() {
        for i := 0; i < 10; i++ {
            ch <- i
        }
        close(ch)
    }()

    for {
        if i, ok := <-ch; ok {
            fmt.Println(i)
        } else {
            break
        }
    }
}

上面的例子中,先使用make创建了一个无缓冲的信道ch,用来传输int数据。 然后启动一个新Go程来向信道发送数据,发送完后关闭信道。 最后在主Go程中接收数据。

运算符<-用于发送或接收数据,箭头方向表示数据流动方向。 由于这里的信道是无缓冲的,在进行发送或接收操作,另一端未准备好时,操作总是会堵塞。 这使得无缓冲信道可以用于同步Go程。

需要注意的是,若不再需要向信道发送数据应该关闭信道,并且close函数应该只在发送方关闭,否则,接收方将会一直堵塞。close函数只接收双向信道或仅发送信道,并且会在最后一次发送的数据被接收后彻底关闭信道,此后发送方后续的接收操作不再堵塞,并且ok被设为false

最后的接收循环可以使用range来代替。

for i := range ch {
    fmt.Println(i)
}

函数可以将信道作为参数,此时函数内部可以向信道发送,也可以从信道接收。 大多数情况下,这不会有什么问题。当程序越来越复杂,有些函数只对信道作一种操作,为了明确语义和防止滥用,可以在函数形参列表中写明信道的数据流方向。

func Counter(out chan<- int) { /* ... */ }
func Processor(out chan<- int, in <-chan int) { /* ... */ }
func Monitor(in <-chan int) { /* ... */ }

创建信道时可以指定缓冲大小。

ch := make(chan int, 10)

这创建了一个缓冲大小为10的信道。可以使用cap函数来获取一个信道的缓冲大小,而len函数会返回当前信道内缓冲队列中有效元素的个数。

在缓冲队列写满时,发送操作会堵塞;当缓冲队列为空,接收操作会堵塞。

使用select语句能等待多个信道,将一直阻塞到某个分支可以继续执行。 若有多个准备好的case,随机选择一个。 若某个case的信道是nil,该case永远不会被执行。

select {
case <-ch1:
    // 当可以从ch1接收值
case ch2 <- val:
    // 当可以向ch2发送值
case i, ok := ch3:
    // 使用变量i
default:
    // 当所有其他case被阻塞
}

常见的并发模式和实例

TODO

错误处理

类型error

在Go语言中,错误被表示为实现了内建的error接口的类型。error接口只有一个方法:

type error interface {
    Error() string
}

这意味着任何实现了Error() string方法的类型都可以被视为一个错误。标准库中的许多函数都会返回一个值和一个错误。通常情况下,如果函数执行成功,错误值将为nil,否则,它将包含有关错误的信息。

以下是一个简单的示例,演示如何处理和返回错误:

package main

import (
    "fmt"
    "errors"
)

func divide(x, y float64) (float64, error) {
    if y == 0 {
        return 0, errors.New("division by zero")
    }
    return x / y, nil
}

func main() {
    result, err := divide(10, 0)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Result:", result)
}

在上面的例子中,errors.New()函数用于创建一个新的错误。使用这种方式创建的错误是不可变的,因此通常被称为静态错误。此外,Go语言还提供了更丰富的错误处理方法,如自定义错误类型和在错误信息中包含更多上下文信息。

使用panicrecover

除了标准的错误处理机制外,Go语言还提供了panicrecover机制,用于处理更严重的异常情况。panic用于引发一个运行时恐慌,这通常意味着程序遇到了无法继续执行的严重错误。而recover用于捕获这些panic,并在程序继续执行之前进行处理。

package main

import (
    "fmt"
)

func processFile(filename string) {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("Recovered from:", r)
        }
    }()

    // 模拟一个发生panic的情况
    if filename == "" {
        panic("empty filename")
    }

    // 处理文件
    fmt.Println("Processing file:", filename)
}

func main() {
    processFile("data.txt")
    processFile("")
    fmt.Println("Program continues after panics")
}

在上面的示例中,processFile函数中的defer语句将recover函数置于一个匿名函数中。这样,如果panic发生,recover将捕获panic并输出相应的消息,然后程序会继续执行。

尽管panicrecover机制可以用来处理严重的错误,但它们应该被谨慎使用,仅在确实遇到无法恢复的情况下才应该使用panic。大多数情况下,使用标准的错误处理机制更为合适。

Go语言通过error类型、panicrecover机制提供了丰富的错误处理方式,使得开发者能够有效地管理和处理程序中的异常情况。一般来说,panicrecover应当只用在包内部,对于调用者是私有的,向外暴露的应该是error

标准库的常用特性

文件处理:读写文件、文件操作

Go 语言的 os 包和 io 包提供了处理文件和I/O操作的功能。通过 os 包,你可以轻松地创建、打开、读取、写入和关闭文件。同时,io 包中的接口和类型,如 ReaderWriter,可以帮助你实现更高级的文件读写操作。

import (
    "os"
    "io/ioutil"
)

func main() {
    // 打开文件并读取内容
    file, err := os.Open("example.txt")
    if err != nil {
        panic(err)
    }
    defer file.Close()

    content, err := ioutil.ReadAll(file)
    if err != nil {
        panic(err)
    }

    // 创建并写入文件
    data := []byte("Hello, Golang!")
    err = ioutil.WriteFile("output.txt", data, 0644)
    if err != nil {
        panic(err)
    }
}

网络编程:HTTP服务器和客户端

在 Go 语言中,通过 net/http 包,你可以轻松地创建基于HTTP协议的服务器和客户端。这使得构建Web应用程序和微服务变得简单。你可以使用 http.HandleFunc 来注册处理函数,从而实现自定义的HTTP路由。

import (
    "net/http"
    "fmt"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello, World!")
    })

    http.ListenAndServe(":8080", nil)
}

并发安全:sync 包的常用工具

Go 语言内置了强大的并发支持,其中 sync 包提供了用于同步和并发安全的工具。例如,sync.Mutex 可以用于保护共享资源,防止竞态条件。

import (
    "sync"
    "time"
)

var count int
var mutex sync.Mutex

func increment() {
    mutex.Lock()
    defer mutex.Unlock()
    count++
}

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            increment()
        }()
    }

    wg.Wait()
    println("Count:", count)
}

字符串处理:常用的字符串操作函数

Go 语言的 strings 包提供了一系列用于处理字符串的函数。这些函数包括字符串连接、切割、搜索、替换等操作,能够极大地简化字符串处理任务。

import (
    "strings"
    "fmt"
)

func main() {
    str := "Hello, Go!"

    // 判断是否包含子字符串
    contains := strings.Contains(str, "Go")
    fmt.Println("Contains 'Go':", contains)

    // 字符串替换
    replaced := strings.Replace(str, "Go", "Golang", -1)
    fmt.Println("Replaced:", replaced)

    // 字符串切割
    parts := strings.Split(str, ", ")
    fmt.Println("Parts:", parts)
}

时间和日期:时间的表示和格式化

Go 语言的 time 包提供了用于处理时间和日期的功能。你可以创建、格式化、解析时间,还可以进行时间间隔的计算。

import (
    "time"
    "fmt"
)

func main() {
    currentTime := time.Now()
    fmt.Println("Current Time:", currentTime)

    futureTime := currentTime.Add(time.Hour * 24)
    fmt.Println("Future Time:", futureTime)

    customFormat := "2006-01-02 15:04:05"
    formattedTime := currentTime.Format(customFormat)
    fmt.Println("Formatted Time:", formattedTime)
}

标准库中还有很多其他有用的包和功能,这里仅涵盖了其中一些常用的特性。通过这些示例,你可以更好地了解如何在 Go 语言中利用标准库来完成各种任务。

参考资料