查看原文
其他

通过 100 行代码入门 Go

程序员ug 幽鬼 2022-09-08

争做团队核心程序员,关注「幽鬼

大家好,我是程序员幽鬼。

本文适合 Go 新手或想学习 Go 的朋友,通过 100 行代码学习 Go 知识。

01 介绍

Go 是由 Robert Griesemer、Rob Pike 和 Ken Thompson 在 Google 开发的一种开源编程语言。它通常被描述为“21 世纪的 C”,然而,它借鉴了其他几种语言的重要思想,如 ALGOL、Pascal、Modula-2、Oberon、CSP 等。Go 的核心是依靠简单性、可靠性和效率来克服其先辈的缺点。Go 具有垃圾收集、包系统、一等公民函数、词法作用域、基于 UTF-8 的不可变字符串以及超棒的并发模型。

作为一种编译型语言,Go 通常比解释性语言更快,并且由于其内置的类型系统而更安全。话虽如此,Go 在表达性和安全性之间有一个很好的平衡,它为程序员提供了强类型系统的好处,而没有复杂工作流程的负担。

该语言的使用场景从网络服务器和分布式系统到 CLI、Web 和移动开发、可扩展数据库和云原生应用程序等。

02 Hello World

在开始之前,请查看 Go 安装指南[1] 在你的机器上安装好 Go。我们将从经典的“hello world”开始。尽管只是一个简单的例子,但它已经说明了许多中心思想。

hello_world.go

package main // Required for a standalone executable.

import "fmt" // fmt implements formatted I/O.

/* When this program is executed the first function that runs is main.main() */
func main() {
 fmt.Println("Hello, world"// Call Println() from the fmt package.
}

首先要注意的是,每个 Go 程序都组织在一个包中。包只是同一目录中的源文件的集合,允许变量、类型和函数在同一包内的其他源文件中可见。对于可执行程序入口文件,包名为 main,但文件名由程序员决定。

接下来,我们导入实现格式化 I/O 的包 "fmt",并使用 fmt.Println() 函数将默认格式写入标准输出,以及针对需要更大灵活性时的 fmt.Printf() 函数。

最后,在main函数体中,我们调用fmt.Println() 输出传递给它的参数,即 Hello, world。请注意,main 函数不接受任何参数且不返回任何值。与main包类似,main 函数是可执行程序必须的。

要运行该程序,我们需要将源代码及其依赖项编译为可执行的二进制文件。我们通过在包目录中打开命令行终端并运行 go build,后跟源文件的名称进行编译。

$ go build hello_world.go

要执行二进制文件,请键入 ./ 后跟二进制文件的名称。(这是 Linux/Mac 下,而 Windows 下执行执行 hello_world.exe)

$ ./hello_world

# output
Hello, world

另一种选择是 go run  后跟源文件的名称。这将结合上面概述的两个步骤并产生相同的结果,但是,不会在工作目录中保存任何可执行文件。这种方法主要用于一次性代码片段和将来不太可能需要的实验代码。

$ go run helloworld.go

# output
Hello, world

03 100 行代码基础知识

在接下来的 100 行代码中,我们将通过几个示例来说明 Go 的特性。我们将介绍如何声明变量、了解 Go 的内置类型、处理数组和切片、介绍 map 以及控制流。此外,会用额外的代码行介绍指针、结构体和 Go 对并发的内置支持。

变量

在编写 Go 程序时,必须先声明变量,然后才能使用它们。下面的示例显示了如何声明单个变量或一组变量。为了节省空间,输出显示为行内注释。

variables.go

package main

import "fmt"

/* Declare a single variable */
var a int

/* Declare a group of variables */
var (
    b bool
    c float32
    d string
)

func main() {
 a = 42                  // Assign single value
 b, c = true32.0       // Assign multiple values
 d = "string"            // Strings must contain double quotes
 fmt.Println(a, b, c, d) // 42 true 32 string
}

请注意每个变量声明后是如何跟随该变量的类型。在下一节介绍类型之前,请注意,当需要在代码中引入常量时,需要将 var 关键字替换为 const

在声明变量时,另一种选择是使用 := 运算符一次性初始化和分配变量。这称为短变量声明。让我们重构上面的代码来说明这一点。

package main

import "fmt"

func main() {
 a := 42            // Initialize and assign to a single variable
 b, c := true32.0 // Initialize and assign to multiple variables
 d := "string"
 fmt.Println(a, b, c, d) // 42 true 32 string
}

简短的变量声明使我们的代码更简洁,因此我们将在本文中再次看到它。

类型

Go 提供了丰富的类型集合,包括数字、布尔值、字符串、error 以及创建自定义类型的能力。字符串是用双引号括起来的一系列 UTF-8 字符。数字类型是最多的,有符号 (  int ) 和无符号 (  uint ) 整数的 8、16、32和 64 位变体。

byteuint8 的别名。runeint32 的别名。float(浮点数)是 float32float64。也支持复数,可以表示为 complex128complex64

当声明一个变量而未赋值时,会自动分配一个该类型的零值。例如,var k intk 的值为 0。var s strings 的值为 ""。下面的示例显示了用户指定的类型与使用短变量声明分配的默认类型之间的区别。

package main

import "fmt"

func main() {
        /* User specified types */
        const a int32 = 12         // 32-bit integer
        const b float32 = 20.5      // 32-bit float
        var c complex128 = 1 + 4i  // 128-bit complex number
        var d uint16 = 14          // 16-bit unsigned integer

        /* Default types */
        n := 42              // int
        pi := 3.14           // float64
        x, y := truefalse  // bool
        z := "Go is awesome" // string

        fmt.Printf("user-specified types:\n %T %T %T %T\n", a, b, c, d)
        fmt.Printf("default types:\n %T %T %T %T %T\n", n, pi, x, y, z)
}

注意 fmt.Printf() 第一个参数中的占位符 %T。在 Go 中,这称为动词,它代表传递的变量的类型\n 在输出的末尾引入一个新行。fmt.Printf() 有许多其他动词,包括 %d 十进制整数、%s 字符串、%f 浮点数、%t 布尔、%v 值以及类型的任何自然值。

另一件要注意的事情是, int 占用空间到底是 int32 还是 int64,取决于底层系统。运行代码示例以查看正在运行的类型和格式动词。

$ go run types.go

# output
user-specified types:
 int32 float32 complex128 uint16

default types:
 int float64 bool bool string

数组

使用数组、切片和 map(Go 版本的 hashmap)可以实现在列表中存储多个元素。我们将在下面的示例中考虑这三个类型。数组由它们的固定大小和所有元素的通用数据类型定义。有趣的是,数组的大小是类型的一部分,这意味着数组不能增长或缩小,否则它们将具有不同的类型。使用方括号访问数组元素。下面的例子展示了如何声明一个包含字符串的数组以及循环遍历它的元素。

package main

import "fmt"

func main() {
 /* Define an array of size 4 that stores deployment options */
 var DeploymentOptions = [4]string{"R-pi""AWS""GCP""Azure"}

 /* Loop through the deployment options array */
 for i := 0; i < len(DeploymentOptions); i++ {
  option := DeploymentOptions[i]
  fmt.Println(i, option)
 }
}

请注意循环条件没有括号。在这个例子中,我们遍历数组输出当前索引和存储在该索引处的值。运行代码会产生以下输出。

$ go run arrays.go

# output
0 R-pi
1 AWS
2 GCP
3 Azure

在继续之前,让我们尝试一种更简洁的方法来编写上面示例中的  for  循环。我们可以使用 range 关键字以更少的代码实现相同的行为。两个版本的代码产生相同的输出。

package main

import "fmt"

func main() {
 /* Define an array and let the compiler count its size */
 DeploymentOptions := [...]string{"R-pi""AWS""GCP""Azure"}

 /* Loop through the deployment options array */
 for index, option := range DeploymentOptions {
  fmt.Println(index, option)
 }
}

切片

切片可以被认为是动态数组。切片始终引用底层数组,并且可以在添加新元素时增长。通过切片可见的元素数量决定了它的长度。如果一个切片有一个更大的底层数组,这个切片可能仍然有增长的能力。对于切片,把长度看成当前元素的个数,把容量看成可以存储的最大元素数。让我们看一个例子。

package main

import "fmt"

func main() {
 /* Define an array containing programming languages */
 languages := [9]string{
  "C""Lisp""C++""Java""Python",
  "JavaScript""Ruby""Go""Rust"// Must include the trailing comma
 }

 /* Define slices */
 classics := languages[0:3]  // alternatively languages[:3]
 modern := make([]string4// len(modern) = 4
 modern = languages[3:7]     // include 3 exclude 7
 new := languages[7:9]       // alternatively languages[7:]

 fmt.Printf("classic languagues: %v\n", classics) // classic languagues: [C Lisp C++]
 fmt.Printf("modern languages: %v\n", modern)     // modern languages: [Java Python JavaScript Ruby]
 fmt.Printf("new languages: %v\n"new)           // new languages: [Go Rust]
}

请注意,定义切片时,将排除最后一个索引。换句话说,切片 s := a[i:j] 将包含从 a[i] to a[j - 1] 的所有元素但不包含 a[j]。在下一个示例中,我们将继续探索切片的行为。假设我们正在编辑同一个文件并且上面的代码仍然可用(替代  –snip– 注释处)。

package main

import (
    "fmt"
    "reflect"
)

func main() {
        // -- snip -- //
        allLangs := languages[:]                      // copy of the array
        fmt.Println(reflect.TypeOf(allLangs).Kind())   // slice

        /* Create a slice containing web frameworks */
        frameworks := []string{
            "React""Vue""Angular""Svelte",
            "Laravel""Django""Flask""Fiber",
        }

        jsFrameworks := frameworks[0:4:4]          // length 4 capacity 4
        frameworks = append(frameworks, "Meteor")  // not possible with arrays

        fmt.Printf("all frameworks: %v\n", frameworks)
        fmt.Printf("js frameworks: %v\n", jsFrameworks)
}

首先,我们使用 [:] 运算符获得一份 languages 拷贝。结果副本是一个切片。我们使用"reflect"包断言了这种情况。接下来,我们创建一个名为 frameworks 的切片。请注意方括号中负责大小的空白条目。如果我们在这些括号内传递一个参数,我们将创建一个数组。将其留空会创建一个切片。我们创建了另一个名为 jsFrameworks 选择 JavaScript 框架的切片。最后,我们通过将 Meteor 添加到框架列表来扩展我们的 frameworks切片。

append 函数将新值推送到切片的末尾,并返回与原始类型相同的新切片。如果切片的容量不足以存储新元素,则会创建一个可以容纳所有元素的新切片。在这种情况下,返回的切片将引用不同的底层数组。运行上面的代码会有下面的输出。

$ go run slices.go

# output
...
all frameworks: [React Vue Angular Svelte Laravel Django Flask Fiber Meteor]
js frameworks: [React Vue Angular Svelte]

Maps

大多数现代编程语言都有 hash-map 的内置实现。例如,Python 的字典或 JavaScript 的对象。从根本上说,map 是一种数据结构,它存储具有常量查找时间的键值对。map 的效率是以随机化键和相关值的顺序为代价的。换句话说,我们不保证 map 中元素的顺序。下面的示例展示了这种行为。

maps.go

package main

import "fmt"

func main() {
 /* Define a map containing the release year of several languages */
 firstReleases := map[string]int{
  "C"1972"C++"1985"Java"1996,
  "Python"1991"JavaScript"1996"Go"2012,
 }

 /* Loop through each entry and output the name and release year */
 for k, v := range firstReleases {
  fmt.Printf("%s was first released in %d\n", k, v)
 }
}

我们定义了一个叫 firstReleases 的 map ,其中包含几种编程语言作为键,它们的发布年份作为相应的值。我们还编写了一个循环来遍历 map 并输出每个键值对。我们运行代码,请注意输出中显示的元素的随机顺序。

$ go run maps.go

# output
Go was first released in 2012
C was first released in 1972
C++ was first released in 1985
Java was first released in 1996
Python was first released in 1991
JavaScript was first released in 1996

控制流

最后,我们将考虑以下场景:假设我们有一个包含浮点数的切片,想计算它们的平均值。我们将创建一个名为 average 的函数,该函数将切片作为参数并返回一个名为 avg 的浮点数。下面的例子显示了一个可能的实现。

package main

import "fmt"

/* Define a function to find the average of the floats contained in a slice */
func average(x []float64) (avg float64) {
 total := 0.0
 if len(x) == 0 {
  avg = 0
 } else {
  for _, v := range x {
   total += v
  }
  avg = total / float64(len(x))
 }
 return
}

func main() {
 x := []float64{2.153.1442.029.5}
 fmt.Println(average(x))   // 19.197499999999998
}

我们在 main 函数体中定义了一个 x,将 x 作为参数传递给 average 。我们将调用包装在里面 fmt.Println() 以将结果写入标准输出。有趣的部分是 average 函数的实现。请注意,返回参数 avg 是在函数声明的末尾定义的。在函数体中,我们初始化一个名为的变量total,该变量将计算切片元素的运行总和。我们检查输入切片的大小。如果切片为空,我们返回 0,否则,我们遍历切片中的每个元素并将其添加到总数中。注意我们如何对未使用的变量使用下划线 ( _ )。我们使用以下方法将切片的长度转换为浮点数 float64(len(x))。最后,我们计算平均值并将结果返回给调用者。

看完了经典的  if-else 语句,我们来介绍一下 Go 的 switch 语句。我们将重构 average 函数以使用 switch 语法。

package main

import "fmt"

func average(x []float64) (avg float64) {
 total := 0.0
 switch len(x) {
 case 0:
  avg = 0
 default:
  for _, v := range x {
   total += v
  }
  avg = total / float64(len(x))
 }
 return
}

func main() {
 x := []float64{2.153.1442.029.5}
 fmt.Println(average(x)) // 19.197499999999998
}

传统上,现代语言中的内置 switch 语句旨在处理常量。在 Go 中,可以使用变量。我们使用switch 关键字后面跟随感兴趣的变量 — 本例子是 len(x)。我们在花括号内定义了两个 case(分支),从上到下计算它们,直到匹配的 case。与其他语言相比,Go 只运行选定的 case,因此不需要 break(默认有 break)。另一个很酷的特性是 switch 语句中的变量不限于整数。

关于这个话题,我们最后要提到的是 Go 对 while 循环的实现。在 Go 中,没有 while关键字。相反,我们使用 for 关键字后跟条件和循环体。唯一的不同是条件末尾没有分号。让我们看一个例子。

package main

import "fmt"

func main() {
 count := 1
 for count < 5 {
  count += count
 }
 fmt.Println(count) // 8
}

恭喜你完成了基础知识的学习。

04 超越基础知识

在本节中,我们将超越基础知识,探索另外三个与指针、结构体和并发相关的示例。

结构体和指针

在我们开始讨论结构体和用户定义类型之前,我们必须先了解一下指针。好消息是 Go 中不允许指针运算,这消除了危险/不可预测的行为。指针存储值的存储器地址。在 Go 中,类型 *T 是指向 T 值的指针。指针的默认值为 nil。让我们来看一个例子。

package main

import "fmt"

func main() {
 var address *int  // declare an int pointer
 number := 42      // int
 address = &number // address stores the memory address of number
 value := *address // dereferencing the value 

 fmt.Printf("address: %v\n", address) // address: 0xc0000ae008
 fmt.Printf("value: %v\n", value)     // value: 42
}

在使用指针时,有两个重要的符号需要注意。地址运算符 ( &) 提供值的内存地址。它用于将指针绑定到一个值。类型前缀的星号运算符 (*) 表示指针类型,而变量前缀的星号用于取消引用变量指向的值。如果你不熟悉指针,它们可能需要一些时间来适应,但是,此时我们不需要深入研究。一旦你对上面的示例完全理解,就可以完成本课程的其余部分。

在下一部分中,我们介绍如何使用 struct 来定义自定义类型。struct 只是字段的集合。在下一个示例中,我们将使用学到的关于指针的知识,学习如何使用结构体,并从头开始构建一个 stack。

package main

import "fmt"

/* Define a stack type using a struct */
type stack struct {
 index int
 data  [5]int
}

/* Define push and pop methods */
func (s *stack) push(k int) {
 s.data[s.index] = k
 s.index++
}

/* Notice the stack pointer s passed as an argument */
func (s *stack) pop() int {
 s.index--
 return s.data[s.index]
}

func main() {
 /* Create a pointer to the new stack and push 2 values */
 s := new(stack)
 s.push(23)
 s.push(14)
 fmt.Printf("stack: %v\n", *s) // stack: {2 [23 14 0 0 0]}
}

首先,我们定义代表 stack 的自定义类型。为了实现 stack 功能,我们需要一个数组来存储 stack 元素,以及一个指向 stack 最后一项的索引。在此例子中,我们将 stack 大小固定为 5 个元素。在结构体内部,我们指定了一个 int 类型的索引字段和一个名为 data 的字段,它是一个包含 5 个 int 元素的数组。

接下来我们定义 pushpop 方法。方法是一种特殊的函数,它在 func 关键字和方法名称之间有一个 receiver 参数。注意参数 s 的类型。在这种情况下,它是一个 stack 指针而不是一个 stack 值。默认情况下,Go 不通过引用传递值。相反,如果我们省略星号,Go 将传递 stack 的副本,这意味着原始 stack 不会被方法修改。

在 stack 方法的主体中,我们使用点表示法访问 stack 字段。在 push 方法中,我们将给定的整数 k 写入第一个可用索引(回忆一下声明的 int 的默认值是 0),并将索引增加 1。在 pop方法中,我们将索引减少 1,并返回堆栈最后一项。在 main 函数体中,我们使用 new() 来创建一个指向新分配 stack 的指针。然后我们 push  2 项并将结果写入标准输出。

并发

我们通过考虑另一个与并发相关的示例来总结学习并发。我们将介绍 Goroutines,它是 Go 中轻量级线程。如果你不熟悉线程,可以理解它们只不过是程序中的顺序控制流。当多个线程并发运行以便程序可以使用多个 CPU 内核时,事情会变得有趣。Goroutines 是使用 go 关键字启动的。除了 goroutines 之外,Go 还内置了用于在 goroutines 之间共享数据的 channel。通常,跨通道的发送和接收操作会阻塞执行,直到另一端准备就绪。

在下面的示例中,我们将考虑 5 个并发运行的 goroutine。假设我们组织了 5 位 gopher 厨师之间的烹饪比赛。这是一场计时比赛,谁先完成他们的菜,谁就赢了。让我们看看如何使用 Go 的并发特性来模拟这场比赛。

package main

import (
 "fmt"
)

func main() {
 c := make(chan int// Create a channel to pass ints
 for i := 0; i < 5; i++ {
  go cookingGopher(i, c) // Start a goroutine
 }

 for i := 0; i < 5; i++ {
  gopherID := <-c // Receive a value from a channel
  fmt.Println("gopher", gopherID, "finished the dish")
 } // All goroutines are finished at this point
}

/* Notice the channel as an argument */
func cookingGopher(id int, c chan int) {
 fmt.Println("gopher", id, "started cooking")
 c <- id // Send a value back to main
}

首先,我们创建一个所有 goroutine 通用的通道。然后我们启动 5 个 goroutines 并将通道作为参数传递。在每个 goroutine 中,一旦 gopher 开始烹饪菜肴,我们就会将 gopher id 写入标准输出。然后我们将 gopher id 从 goroutine 发送回调用者。从那里,我们回到主函数的主体,在那里我们接收 gopher id 并记录它们的完成时间。

由于正在处理并发代码,我们失去了预测输出顺序的能力,但是,我们可以观察通道如何阻塞执行,因为 goroutine 必须等到通道可用才能发送 id。下面包括一种可能的输出。请记住,我们使用的 goroutine 可能比机器上的内核数量还多,因此很可能对单个内核进行分时复用以模拟并发。

$ go run concurrency.go

# output
gopher 0 started cooking
gopher 4 started cooking
gopher 3 started cooking
gopher 0 finished the dish
gopher 2 started cooking
gopher 1 started cooking
gopher 4 finished the dish
gopher 3 finished the dish
gopher 2 finished the dish
gopher 1 finished the dish

很棒!希望这个教程能帮助你入门 Go 并爱上它!

原文链接:https://fireship.io/lessons/learn-go-in-100-lines/

参考资料

[1]

指南: https://golang.org/doc/install




往期推荐


欢迎关注「幽鬼」,像她一样做团队的核心。



您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存