Go-秘籍-全-

Go 秘籍(全)

原文:zh.annas-archive.org/md5/d17f8ead62b31a6ec2bbef4005dc3b6d

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:错误处理技巧

1.0 引言

亚历山大·蒲柏在他的论批评的散文中写道:“出错是人性的”。由于软件是由人类编写的(目前是这样),软件也会出错。就像人类错误一样,重要的是我们如何优雅地从这些错误中恢复。这就是错误处理的关键所在——当程序陷入我们未预料或未考虑其正常流程的情况时,我们如何恢复。

作为程序员,我们经常把错误处理当作繁琐的工作和事后思考。这本身通常就是一个错误。事实上,就像我们应该对待测试一样,错误处理应该是首要考虑的事情,从错误中恢复应该是良好软件设计的一部分。在 Go 语言中,错误处理被认真对待,尽管有点不传统。Go 语言在标准库中有errors包,提供了许多处理错误的函数,但大部分错误处理都内置在语言中或者是 Go 语言编程习惯的一部分。在本章中,我们将讨论 Go 语言中错误处理的一些基本思想。

错误不是异常

在许多编程语言中,如 Python 和 Java,通过异常来处理错误。异常是表示错误的对象,无论何时出错,都可以抛出异常。调用函数通常有trycatch(或者在 Python 中是tryexcept等),用于处理任何出现的问题。

Go 在这方面略有不同(或者完全不同,这取决于你的看法)。Go 没有异常处理。而是使用错误。error是一种内置类型,表示意外情况。不会抛出异常,而是创建一个错误并将其返回给调用函数。也许你会想,这不是说奥德赛不是荷马写的,而是另一个名叫荷马的希腊人,他也生活在 2800 年前吗?

嗯,并不完全是这样。实际上,异常根本不会被函数返回,事实上,你根本不知道会不会返回任何异常,或者是什么样的异常(有时候你知道,但那是另一回事)。你必须使用trycatch来捕捉异常。只有在出现问题时才会抛出异常(这就是为什么称其为异常),你可以用相同的trycatch包裹多条语句。另一方面,错误是有意返回给调用函数的,以便逐个检查并进行处理。

结果,那些对异常处理更熟悉的人发现在 Go 语言中处理错误尤其乏味。与在一系列语句中捕获异常不同,你必须每次检查返回的错误并单独处理它。当然,你也可以选择完全忽略这些错误,虽然按惯例,你应该认真对待每次出现的错误。唯一的例外可能是当你根本不关心返回的结果时。

1.1 处理错误

问题

你希望在你的代码中处理一个意外情况。

解决方案

如果你正在编写一个函数,除了返回值(如果有的话),还需返回一个error。如果你正在调用一个函数,检查返回的错误,如果不是nil,则相应地处理错误。

讨论

处理错误的两种方式是如果你正在编写一个函数或者如果你正在调用一个函数。

编写一个函数

Go 使用内置的error接口类型来表示错误,这实际上是一个接口。在编写函数时的经验法则是,如果函数可能失败,你需要返回一个错误,以及函数的其他返回值。这是可能的,因为 Go 允许多返回值。按照约定,错误应该是最后一个返回值,例如下面的这个允许你猜测一个数字的函数。

func guess(number uint) (answer bool, err error) {
    if number > 99 {
        err = errors.New("Number is larger than 100")
    }
    // check if guess is correct
    return
}

函数的输入应该小于 100,并且该函数应返回一个指示猜测是否正确的 true 或 false。显然,如果输入数字大于 100,你希望引发一个错误,这就是我们通过使用errors.New函数来创建新错误的方式。

err = errors.New("Number is larger than 100")

不过创建新错误的方式并不仅限于此,另一种流行的创建新错误的方式是在流行的fmt包中使用Errorf函数。

err = fmt.Errorf("Number is larger than 100")

在这个例子中两者之间的主要区别微不足道,但fmt.Errorf允许你格式化字符串,就像fmt包中的PrintfSprintfSscanf等函数一样。不过fmt.Errorf还有更多功能,因为它实际上允许你在另一个错误周围包装一个错误,这是我们将在第 3.4 节讨论的内容。

调用一个函数

另一个经验法则,与 Go 中的错误处理哲学一起出现的是“不要忽略错误”。在 Go 中处理错误的标准方式非常简单——你处理它就像处理任何其他返回值一样。下面的例子摘自第一章,第 1.5 节。

str := "123456789"
num, err := strconv.ParseInt(str, 10, 64)
if err != nil {
    panic(err)
}

函数strconv.ParseInt返回两个值,第一个是转换后的整数,第二个是错误。你应该检查错误,如果错误为 nil,说明一切正常,可以继续程序流程。如果不是 nil,那么你应该处理它。在上面的例子中,我用错误调用了panic,我们稍后会在本章中详细讨论这个问题,但你可以根据需要处理它,包括忽略它。当然,你也可以像这样有意选择忽略它们。

num, _ := strconv.ParseInt(str, 10, 64)

在这里,我们将返回的错误赋值给下划线 _,这意味着我们正在忽略它。无论哪种情况,都很明显你是在有意忽略返回的错误,Go 无法阻止你,但是代码审查或者你的 IDE 可以快速发现这些问题,尤其是在团队中。

为什么 Go 会以这种方式处理错误?

你可能会想知道为什么 Go 会这样做,而不像许多其他语言那样使用异常。异常似乎更容易处理,因为你可以把语句组合在一起。Go 强制你在每个函数调用时处理错误的方式可能让人觉得很繁琐。

然而,由于这种方式,异常也很容易被忽略,除非你有trycatch。如果你用trycatch包装一堆语句,很容易忽略处理特定的错误,并且如果把太多语句放在一起,可能会导致混淆。

这种做法的另一个好处是返回的错误是一个值,你可以像处理其他任何值一样使用它。虽然你也可以处理异常,但它们是trycatch循环中的构造,不在你的正常流程中。这看起来可能是一个细枝末节,但它很重要,因为这使得错误处理成为编写代码的一个必要部分,而不是可选的。

1.2 简化重复的错误处理

问题

你想减少重复错误处理代码的行数。

解决方案

使用辅助函数来减少重复错误处理代码的行数。

讨论

Go 语言中关于错误处理最常见的抱怨之一,特别是对新手而言,就是不得不进行重复检查感到很繁琐。让我们以打开一个 JSON 文件并将其解析到一个结构体的代码片段为例。

func unmarshal() (person Person) {
	r, err := http.Get("https://swapi.dev/api/people/1")
	if err != nil {
		// handle error
	}
	defer r.Body.Close()

	data, err := io.ReadAll(r.Body)
	if err != nil {
		// handle error
	}

	err = json.Unmarshal(data, &person)
	if err != nil {
		// handle error
	}
	return
}

你可以看到,有三组错误处理,一组是当我们调用http.Get来获取 API 响应并将其存入http.Response时,另一组是当我们调用io.ReadAllhttp.Response中获取 JSON 文本时,最后一组是将 JSON 文本解析到Person结构体中。这些调用都有可能失败,所以我们需要处理由这些失败导致的错误。

然而,这些错误处理例程非常相似且实际上是重复的。我们如何解决这个问题?有几种方法,但最直接的可能是使用辅助函数。

func helperUnmarshal() (person Person) {
	r, err := http.Get("https://swapi.dev/api/people/1")
	check(err, "Calling SW people API")
	defer r.Body.Close()

	data, err := io.ReadAll(r.Body)
	check(err, "Read JSON from response")

	err = json.Unmarshal(data, &person)
	check(err, "Unmarshalling")
	return
}

func check(err error, msg string) {
	if err != nil {
		log.Println("Error encountered:", msg)
        // do common error handling stuff
	}
}

这里的辅助函数是 check 函数,它接受一个错误和一个字符串。 除了记录字符串外,你还可以放置所有可能想做的通用错误处理工作。 你还可以接受一个在遇到错误时执行的函数而不是一个字符串。

当然,这只是一种可能的辅助函数类型,让我们看看另一种。 这次我们将使用标准库中另一个包中发现的模式。 在 text/template 包中,你可以找到一个名为 template.Must 的辅助函数,它包装了返回 (*Template, error) 的函数。 如果该函数返回非 nil 错误,则 Must 会 panic。 我们可以类似地创建一个类似的东西来包装其他函数调用。

func must(param interface{}, err error) interface{} {
	if err != nil {
		// handle errors
	}
	return param
}

因为它接受任何单个参数(使用 interface{})并返回单个值(也使用 interface{}),所以我们可以将其用于返回单个值和错误的任何函数。 例如,我们可以将我们之前的 unmarshal 函数转换为类似于这样的函数。

func mustUnmarshal() (person Person) {
	r := must(http.Get("https://swapi.dev/api/people/1")).(*http.Response)
	defer r.Body.Close()
	data := must(io.ReadAll(r.Body)).([]byte)
	must(nil, json.Unmarshal(data, &person))
	return
}

代码更加简洁,但同时也使代码更难读取,因此应谨慎使用这种辅助函数。

1.3 创建自定义错误

问题

你想要创建我们自己的定制错误以便传达遇到的错误的更多信息。

解决方案

创建一个新的基于字符串的错误,或者通过创建一个具有返回字符串的 Error 方法的结构体来实现 error 接口。

讨论

使用基于字符串的错误

实现一个自定义错误的最简单方法是创建一个基于字符串的新错误。 你可以使用 errors.New 函数,它只是创建一个带有简单字符串的错误,或者使用 fmt.Errorf 允许你使用格式化。

errors.New 函数非常简单直接。

err := errors.New("Syntax error in the code")

fmt.Errorf 函数,就像 fmt 函数中的许多函数一样,允许在字符串内进行格式化。

err := fmt.Errorf("Syntax error in the code at line %d", line)

实现错误接口

builtin 包包含所有内置类型、接口和函数的定义。 其中一个接口是 error 接口。

type error interface {
    Error() string
}

正如你所看到的,任何具有返回字符串的 Error 方法的结构体都是错误。 因此,如果你想要定义自己的自定义错误以返回自定义错误消息,只需实现自己的结构体并添加一个 Error 方法。 例如,假设你正在编写一个用于通信的程序,并且想要创建自己的错误来表示通信期间的错误。

type CommsError struct{}

func (m CommsError) Error() string {
	return "An error happened during data transfer"
}

当然,你通常不会只想覆盖 Error,你可以向自定义错误添加字段和其他方法以携带更多信息。

假设你想要提供关于错误出现在代码行的位置信息。 你可以创建一个自定义错误来包含这些信息。

type SyntaxError struct {
	Line int
	Col  int
}

func (err *SyntaxError) Error() string {
	return fmt.Sprintf("Error at line %d, column %d", err.Line, err.Col)
}

当你遇到这样的错误时,你可以使用逗号,ok 惯用法进行类型转换(因为如果你类型转换它并且它不是那种类型,它将 panic),并提取额外的数据进行处理。

if err != nil {
	err, ok := err.(*SyntaxError)
	if ok {
		// do something with the error
	} else {
		// do something else
	}
}

1.4 使用其他错误包裹错误

问题

您希望在返回错误之前为接收到的错误提供额外的信息和上下文。

解决方案

在返回前将收到的错误用自定义错误再次包裹。

讨论

有时,您会收到一个错误,但不只是返回它,而是在返回错误之前为其提供额外的上下文。例如,如果您遇到网络连接错误,您可能想知道发生错误的代码位置以及发生错误时正在做什么。

当然,您可以简单地提取信息,使用附加信息创建新的自定义错误并返回。或者,您也可以将错误包装在另一个错误中返回,通过调用堆栈传递错误并向其添加额外的信息和上下文。

包装错误有几种方法。最简单的方法就是再次使用fmt.Errorf,并将错误作为参数之一提供。

err1 := errors.New("Oops something happened.")
err2 := fmt.Errorf("An error was encountered - %w", err1)

%w占位符允许我们将错误放置在格式化字符串中。在上面的例子中,err2包裹了err1。但是我们如何从err2中提取err1errors包中有一个名为Unwrap的函数可以做到这一点。

err := errors.Unwrap(err2)

这样就可以得到err1

另一种包装错误的方法是创建一个自定义的错误结构体,像这样。

type ConnectionError struct {
	Host string
	Port int
	Err  error
}

func (err *ConnectionError) Error() string {
	return fmt.Sprintf("Error connecting to %s at port %d", err.Host, err.Port)
}

记住,要使其成为错误,结构体应该有一个Error方法。为了允许结构体被展开,我们需要为其实现一个Unwrap函数。

func (err *ConnectionError) Unwrap() error {
	return err.Err
}

1.5 检查错误

问题

您想要检查特定的错误或特定类型的错误。

解决方案

使用errors.Iserrors.As函数。errors.Is函数将错误与一个值进行比较,而errors.As函数则检查错误是否属于特定类型。

讨论

使用errors.Is

errors.Is函数本质上是一个相等性检查。假设在您的代码库中定义了一组自定义错误,例如当与 API 连接时发生错误时会出现ApiErr

var ApiErr error = errors.New("Error trying to get data from API")

在代码的其他位置,您有一个函数返回此错误。

func connectAPI() error {
	// some other stuff happening here
	return ApiErr
}

您可以使用errors.Is来检查返回的错误是否真的是ApiErr

err := connectAPI()
if err != nil {
	if errors.Is(err, ApiErr) {
		// handle the API error
	}
}

您还可以验证ApiErr是否在包裹错误的链中的某个位置。让我们以一个返回ConnectionError并包装ApiErrconnect函数为例。

func connect() error {
	return &ConnectionError{
		Host: "localhost",
		Port: 8080,
		Err:  ApiErr,
	}
}

该代码仍然有效,因为ConnectionError包裹了ApiErr

err := connect()
if err != nil {
	if errors.Is(err, ApiErr) {
		// handle the API error
	}
}

使用errors.As

errors.As函数允许我们检查特定类型的错误。让我们继续使用上面的例子,但这次我们要检查错误是否为ConnectionError类型。

err := connect()
if err != nil {
	var connErr *ConnectionError
	if errors.As(err, &connErr) {
		log.Errorf("Cannot connect to host %s at port %d", connErr.Host, connErr.Port)
	}
}

我们可以使用errors.As来检查返回的错误是否真的是ConnectionError,通过传递返回的错误和类型为*ConnectionError的变量connErr。如果是,errors.As将把返回的错误赋给connErr,同时您可以处理错误。

1.6 使用 panic 处理错误

问题

您希望报告导致程序停止并且无法继续的错误。

解决方案

使用内置的panic函数来停止程序。

讨论

有时您的程序会遇到使其无法继续的错误。在这种情况下,您将希望停止程序。Go 提供了一个名为panic的内置函数,它接受任何类型的单个参数,并停止当前 goroutine 的正常执行。

当函数调用panic时,函数的正常执行立即停止,并且在函数返回给其调用者之前执行任何延迟操作(任何以defer开头的内容)。

调用函数也会发生 panic 并停止正常执行,并且执行延迟操作。这会一直上升,直到整个程序以非零退出代码退出。

让我们更仔细地看一下这个问题。我们创建了一个函数的正常流程,其中main调用AA调用BB调用C

package main

import "fmt"

func A() {
	defer fmt.Println("defer on A")
	fmt.Println("A")
	B()
	fmt.Println("end of A")
}

func B() {
	defer fmt.Println("defer on B")
	fmt.Println("B")
	C()
	fmt.Println("end of B")
}

func C() {
	defer fmt.Println("defer on C")
	fmt.Println("C")
	fmt.Println("end of C")
}

func main() {
	defer fmt.Println("defer on main")
	fmt.Println("main")
	A()
	fmt.Println("end of main")
}

执行结果如下,这是预期的正常流程。

% go run main.go
main
A
B
C
end of C
defer on C
end of B
defer on B
end of A
defer on A
end of main
defer on main

如果我们在C中两个fmt.Println语句之间调用panic,会发生什么?

func C() {
	defer fmt.Println("defer on C")
	fmt.Println("C")
	panic("panic called in C")
	fmt.Println("end of C")
}

当我们在执行过程中C调用panic时,C立即停止并执行其范围内的延迟代码。之后它上升到调用者BB也立即停止并执行其范围内的延迟代码,并返回给其调用者A。同样的情况发生在A身上,它上升到main函数,该函数执行其范围内的延迟代码。由于这是链的末端,它将打印出panic的参数。

如果您在终端上运行此代码,您将看到以下内容。

% go run main.go
main
A
B
C
defer on C
defer on B
defer on A
defer on main
panic: panic called in C

正如您从结果中看到的那样,所有函数中的其余代码都不会被执行,但是所有延迟代码在 panic 退出时都会执行。

1.7 从 panic 中恢复

问题

您的一个 goroutine 出现错误并且无法继续,调用了 panic,但您不希望停止程序的其余部分。

解决方案

使用内置的recover函数来阻止panic。这仅在延迟函数中有效。

讨论

在前一个示例中,我们看到panic如何停止代码的正常执行,运行延迟代码并上升直到程序终止。有时我们希望阻止panic终止程序。在这种情况下,我们可以使用内置的recover函数来阻止panic并继续程序的执行。

但是为什么我们要这样做呢?可能有几个原因。您可能正在使用一个在遇到无法恢复的情况时引发 panic 的包。但这并不意味着您希望程序终止(即使该部分代码无法继续)。或者您可能只是想停止 goroutine 的执行,而不希望杀死主程序,例如,如果您的 Web 应用正在运行,您不希望发生 panic 的处理程序关闭整个服务器。

无论是哪种情况,只有在defer内部使用recover才有效。这是因为当函数调用panic时,除了延迟执行的代码外,其他所有代码都将停止工作。

让我们使用前一个示例中的示例。

package main

import "fmt"

func A() {
	defer fmt.Println("defer on A")
	fmt.Println("A")
	B()
	fmt.Println("end of A")
}

func B() {
	defer dontPanic()
	fmt.Println("B")
	C()
	fmt.Println("end of B")
}

func C() {
	defer fmt.Println("defer on C")
	fmt.Println("C")
	fmt.Println("end of C")
}

func main() {
	defer fmt.Println("defer on main")
	fmt.Println("main")
	A()
	fmt.Println("end of main")
}

func dontPanic() {
	err := recover()
	if err != nil {
		fmt.Println("panic called but everything's ok now:", err)
	} else {
		fmt.Println("defer on B")
	}
}

我们添加了一个名为dontPanic的新函数。在此函数中,我们调用内置函数recover。如果在程序冒泡panic时调用此函数,则会返回传递给panic的参数,并阻止panic继续。在正常情况下,recover只返回 nil,并且正常的延迟代码会运行。

执行如预期的正常流程。调用recover返回 nil,因此正常的延迟代码会运行。

% go run main.go
main
A
B
C
end of C
defer on C
end of B
defer on B
end of A
defer on A
end of main
defer on main

现在让我们在C中添加一个panic

func C() {
	defer fmt.Println("defer on C")
	fmt.Println("C")
	panic("panic called in C")
	fmt.Println("end of C")
}

让我们再次运行它,看看会发生什么。

% go run main.go
main
A
B
C
defer on C
panic called but everything's ok now: panic called in C
end of A
defer on A
end of main
defer on main

C中调用panic时,C中的延迟代码会触发,而不会运行C中其余的代码,并冒泡到BB停止运行其余的代码并开始运行延迟代码,其中调用dontPanicdontPanic调用recover,返回传递给C中调用panic的参数,并运行恢复代码。

B的正常执行不会发生,但当B返回到A时一切正常,代码的正常执行流程继续进行。

1.8 处理中断

问题

程序从操作系统接收到中断信号(例如,如果用户按下ctrl-c),您希望进行清理并优雅地退出。

解决方案

使用 goroutine 使用os/signal包监控中断。将清理代码放在 goroutine 中。

讨论

信号是发送给运行中程序的消息,以触发程序内部的某些行为。信号是异步的,可以由操作系统或其他运行中的程序发送。当发送信号时,操作系统中断运行的程序以传递信号。如果进程具有信号处理程序,则将执行该处理程序;否则将执行默认处理程序。

在命令行上,某些键组合(例如ctrl-c)会触发信号(在本例中,ctrl-c发送SIGINT信号)到前台运行的程序。SIGINT信号或信号中断是一种中断运行中程序并导致其终止的信号。

您可以使用os/signal包在 Go 中捕获此类信号。

让我们看看如何做到这一点。

ch := make(chan os.Signal)
signal.Notify(ch, os.Interrupt)

go func() {
	<-ch
	// clean up before graceful exit
	os.Exit(0)
}()

首先,我们创建一个通道ch来发送信号。然后我们使用signal.Notify函数将传入的信号转发到chsignal.Notify的第一个参数是通道,第二个参数是可变参数,这意味着我们可以传入零个或多个参数。在这种情况下,我们传入我们想要捕获的各种信号。在上面的示例代码中,我们想要转发os.Interrupt,这是一个syscall.SIGINTctrl-c。如果不传入任何参数,则所有信号将被转发到通道。

设置好之后,我们会启动一个 goroutine,在那里我们通过从 ch 接收信号来等待信号的到来。这会导致 goroutine 阻塞,直到收到信号。一旦收到信号,我们继续执行该 goroutine,执行我们想要的任何清理例程,然后从程序中优雅地退出。

第二章:字符串示例

2.0 介绍

字符串操作是任何编程语言中最常见的活动之一。大多数程序以某种方式处理文本,无论是直接与用户交互还是机器之间的通信。文本可能是我们拥有的最接近通用媒介的东西,而作为数据的字符串则无处不在。作为程序员,能够操作字符串是你的必备技能之一。

Go 有几个用于字符串操作的包。strconv 包专注于转换到或从字符串的操作。fmt 包提供使用动词作为替换来格式化字符串的函数,类似于 C 语言。unicode/utf8unicode/utf16 包具有用于 Unicode 编码字符串的函数。strings 包提供了许多我们看到的字符串操作函数,如果您不确定需要什么,那可能是最可能查找的位置。

2.1 创建字符串

问题

您希望创建字符串。

解决方案

使用双引号 "" 或反引号(或反引号) 创建字符串字面量。使用单引号 创建字符字面量。

讨论

在 Go 中,string 是一个只读(不可变)的字节切片。它可以是任何字节,不需要任何编码或格式。这与其他一些编程语言不同,其中字符串是字符序列。在 Go 中,一个字符可以由多个字节表示。这符合 Unicode 标准,该标准定义了一个代码点来表示代码空间内的值。在这种情况下,一个字符可以由多个代码点表示。在 Go 中,代码点也称为 rune,而 rune 是类型 int32 的别名,就像 byte 是类型 uint8 的别名,表示无符号 8 位整数。

因此,如果您对字符串进行索引,最终将得到一个字节而不是字符。无论如何,Go 没有字符数据类型——而是使用字节和符文。一个字节表示 ASCII 字符,一个符文表示 UTF-8 编码中的 Unicode 字符。需要明确的是,这并不意味着 Go 中没有字符,只是没有 char 数据类型,只有 byterune

在 Go 中,字符使用单引号 '' 创建。

var c = 'A'

在这种情况下,变量 c 的数据类型默认为 int32 或 rune。如果您希望它是字节,则可以显式指定类型。

var c byte = 'A'

在 Go 中,可以使用双引号 "" 或反引号 go `` 创建字符串。

var str = "A simple string"

使用双引号创建的字符串中可以包含转义字符。例如,一个非常常见的转义字符是换行符,用反斜杠后跟 n 表示 — \n

var str = "A simple string\n"

转义字符的另一个常见用途是转义双引号本身,以便它可以在使用双引号创建的字符串中使用。

var str = `A \"simple\" string`

使用反引号创建的字符串被认为是“原始”字符串。原始字符串忽略所有格式,包括转义字符。事实上,你可以使用反引号创建多行字符串。例如,使用双引号是不可能的,实际上会导致语法错误。

var str = "
A
simple
string
"

然而,如果我们将双引号替换为反引号,则str将成为多行字符串。

var str = `
A
simple
string
`

这是因为反引号之间的内容不被 Go 编译器处理(即它是“原始”的)。

2.2 将字符串转换为字节和字节转换为字符串

问题

你想将字符串转换为字节并将字节转换为字符串。

解决方案

使用[]byte(str)将字符串类型转换为字节数组,并使用string(bytes)将字节数组类型转换为字符串。

讨论

字符串是字节片,因此你可以通过类型转换直接将字符串转换为字节数组。

str := "This is a simple string"
bytes := []byte(str)

将字节数组转换为字符串也是通过类型转换完成的。

bytes := []byte{84, 104, 105, 115, 32, 105, 115, 32, 97, 32, 115, 105, 109, 112, 108,
                101, 32, 115, 116, 114, 105, 110, 103}
str := string(bytes)

2.3 从其他字符串和数据创建字符串

问题

你想从其他字符串或数据创建一个字符串。

解决方案

有多种方法可以做到这一点,包括直接连接、使用strings.Join、使用fmt.Sprint和使用strings.Builder

讨论

有时候你想从其他字符串或数据创建字符串。一个相当直接的方法是将字符串和其他数据连接在一起。

var str string = "The time is " + time.Now().Format(time.Kitchen) + " now."

Now函数返回当前时间,由Format方法格式化为字符串返回。当我们连接这些字符串时,我们将得到这个结果。

The time is 5:28PM now.

另一种方法是在string包中使用Join函数。

var str string = strings.Join([]string{"The time is",
                    time.Now().Format(time.Kitchen),
                    "now."}, " ")

这也很简单,因为函数接受一个字符串数组,并根据分隔符将它们放在一起。

到目前为止,展示的两种方法都是关于将字符串放在一起。显然,你可以在将它们连接起来之前将不同的数据类型转换为字符串,但有时你只想让 Go 来做。为此,我们有fmt.Sprint函数及其各种变体。让我们看看最简单和最直接的变体。

var str string = fmt.Sprint("The time is ", time.Now().Format(time.Kitchen), " now.")

这与Join或直接连接似乎没有太大区别,因为所有三个参数都是字符串。实际上,fmt.Sprint及其变体接受interface{}参数,这意味着它可以接受任何数据类型。换句话说,你实际上可以直接传入Now返回的Time结构体。

var str string = fmt.Sprint("The time is ", time.Now(), " now.")

fmt.Sprint的一个流行变体是格式化变体,即fmt.Sprintf。使用这种变体略有不同——第一个参数是格式字符串,在字符串中的不同位置可以放置不同的动词格式。从第二个参数开始是可以替换到动词中的数据值。

var str string = fmt.Sprintf("The time is %v now.", time.Now())

对于Time结构体没有关联的动词,因此我们使用%v,它将以默认格式格式化值。

最后,string 包还提供了另一种创建字符串的方式,使用 strings.Builder 结构体。使用 Builder 创建字符串稍微复杂一些,因为它需要逐个添加数据片段。让我们看看如何使用 Builder

var builder strings.Builder
builder.WriteString("The time is ")
builder.WriteString(time.Now().Format(time.Kitchen))
builder.WriteString(" now.")
var str string = builder.String()

思路很简单,我们创建一个 Builder 结构体,然后逐步将数据写入其中,最后使用 String 方法提取最终的字符串。Builder 结构体还有一些其他方法,包括接受字节数组的 Write 方法,接受单个字节的 WriteByte 方法以及接受单个字符的 WriteRune 方法。然而,正如你所见,它们都是字符串或字符。其他数据类型怎么办?我们需要先将所有其他数据类型转换为字符串、字节或字符吗?不需要,因为 Builder 是一个 Writer(它实现了 Write 方法),你实际上可以使用另一种方法将不同的数据类型写入其中。

var builder strings.Builder
fmt.Fprint(&builder, "The time is ")
fmt.Fprint(&builder, time.Now())
fmt.Fprint(&builder, " now.")
var str string = builder.String()

在这里,我们使用 fmt.Fprint 将任意数据类型写入构建器,并使用 String 提取最终的字符串。

我们已经看到了使用不同数据片段组合字符串的几种方法,包括字符串和其他类型的数据。有些方法非常直接(只需将它们添加在一起),而其他方法则更加深思熟虑。让我们来看看这些不同方法的性能表现。

package string

import (
	"fmt"
	"strings"
	"testing"
	"time"
)

func BenchmarkStringConcat(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = "The time is " + time.Now().Format(time.Kitchen) + " now."
	}
}

func BenchmarkStringJoin(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = strings.Join([]string{"The time is", time.Now().Format(time.Kitchen),
            "now."}, " ")
	}
}

func BenchmarkStringSprint(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = fmt.Sprint("The time is ", time.Now().Format(time.Kitchen), " now.")
	}
}

func BenchmarkStringSprintDiff(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = fmt.Sprint("The time is ", time.Now(), " now.")
	}
}

func BenchmarkStringSprintf(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = fmt.Sprintf("The time is %v now.", time.Now().Format(time.Kitchen))
	}
}

func BenchmarkStringSprintfDiff(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = fmt.Sprintf("The time is %s now.", time.Now())
	}
}

func BenchmarkStringBuilderFprint(b *testing.B) {
	for i := 0; i < b.N; i++ {
		var builder strings.Builder
		fmt.Fprint(&builder, "The time is ")
		fmt.Fprint(&builder, time.Now().Format(time.Kitchen))
		fmt.Fprint(&builder, " now.")
		_ = builder.String()
	}
}

func BenchmarkStringBuilderWriteString(b *testing.B) {
	for i := 0; i < b.N; i++ {
		var builder strings.Builder
		builder.WriteString("The time is ")
		builder.WriteString(time.Now().Format(time.Kitchen))
		builder.WriteString(" now.")
		_ = builder.String()
	}
}

让我们从命令行运行基准测试。

$ % go test -bench=BenchmarkString -benchmem

这就是结果。

goos: darwin
goarch: arm64
pkg: github.com/sausheong/gocookbook/ch06_string
BenchmarkStringConcat-10                	 5787976	       206.7 ns/op
BenchmarkStringJoin-10                  	 5121637	       235.0 ns/op
BenchmarkStringSprint-10                	 3680838	       323.8 ns/op
BenchmarkStringSprintDiff-10            	 1541514	       779.9 ns/op
BenchmarkStringSprintf-10               	 4032438	       297.8 ns/op
BenchmarkStringSprintfDiff-10           	 1610212	       740.9 ns/op
BenchmarkStringBuilderFprint-10         	 2580783	       464.2 ns/op
BenchmarkStringBuilderWriteString-10    	 4866556	       247.0 ns/op
PASS
ok  	github.com/sausheong/gocookbook/ch06_string	13.025s

可能让你惊讶的是,最简单的方法实际上也是性能最佳的方法。使用 fmt.Sprint 和使用 interface{} 的任何方法都相对不那么高效。

2.4 将字符串转换为数字

问题

你想将字符串转换为数字。

解决方案

使用 strconv 包中的 AtoiParse 函数进行字符串转换。使用函数将字符串转换为数字,并使用 ItoaFormat 函数将数字转换为字符串。

讨论

strconv 包名副其实,主要用于字符串转换。有两组函数让我们可以将字符串转换为数字和将数字转换为字符串。Parse 函数将字符串转换为数字,Format 函数将数字转换为字符串。如果不确定使用哪些函数,一般来说记住解析函数读取字符串,格式化函数创建字符串。

将字符串解析为数字在使用上似乎有限,但在处理文本格式化数据时可能特别有用,例如 JSON 或 Yaml,甚至是 XML。文本格式化数据很受欢迎,因为它易于阅读,但缺点是一切最终都成为字符串。直接将字符串解析为更可用的数据类型,比如数字,在这种情况下就变得非常有用。

让我们从简单的开始。我们想要解析一个显示整数并产生实际整数的字符串。

i, err := strconv.Atoi("123") // equivalent to ParseInt("123", 10, 0)

strconv 包提供了一个方便的函数,用于将字符串转换为整数。这很容易记住,因为 Atoi 基本上是将 字母数字 转换为 整数

使用Parse函数等效于Atoi,使用ParseInt(s, 10, 0),其中s是表示数字的字符串。

函数ParseInt正如其名称所示,将字符串解析为整数。您可以指定基数(0、2 到 36)以及位数(0 到 64)。您可以使用ParseInt来处理有符号或无符号整数,只需在数字前加上+或-号。

i, err := strconv.ParseInt("123", 10, 0)

函数ParseFloat将字符串解析为浮点数。bitsize 参数指定了精度,32 用于float32,64 用于float64等。

f, err := strconv.ParseFloat("1.234", 64)

在上面的代码中,f是一个float64数值。

当尝试解析代表布尔值的字符串时,ParseBool很有用。它接受 1、t、T、TRUE、true、True、0、f、F、FALSE、false、False 这些值。

b, err := strconv.ParseBool("TRUE")

在上面的代码中,b是一个布尔值,其值为true

所有Parse函数都会返回NumError,包括AtoiNumError提供有关错误的附加信息,包括被调用的函数、传入的数字以及失败原因。

str := "Not a number"
_, err := strconv.Atoi(str)
if err != nil {
    e := err.(*strconv.NumError)
    fmt.Println("Func:", e.Func)
    fmt.Println("Num:", e.Num)
    fmt.Println("Err:", e.Err)
    fmt.Println(err)
}

这是您运行此代码时可以看到的内容。

Func: Atoi
Num: Not a number
Err: invalid syntax
strconv.Atoi: parsing "Not a number": invalid syntax

2.5 将数字转换为字符串

问题

您想将数字转换为字符串。

解决方案

使用strconv包中的ItoaFormat函数将数字转换为字符串。

讨论

在前一个示例中,我们已经讨论了strconv包和Parse函数。在本示例中,我们将讨论Format函数及其如何将数字转换为字符串。

将数字格式化为字符串是将字符串解析为数字的逆过程。在需要通过文本格式传递数据的情况下,将数字格式化可以很有用。将数字格式化为字符串的一个常见用法是在需要向用户展示更可读的数字时使用。例如,我们希望向用户展示1.66666666时,会希望显示1.67。这在显示货币时也很常见。

让我们从最简单的例子开始。就像解析字符串有Atoi一样,格式化字符串有Itoa。顾名思义,它是Atoi的逆过程,将整数转换为字符串。

str := strconv.Itoa(123) // equivalent to FormatInt(int64(123), 10)

请注意,Itoa不会返回错误。事实上,Format函数中的任何一个都不会返回错误。这是有道理的 —— 将数字转换为字符串总是可能的,而反之则不然。

与以往一样,Itoa是使用FormatInt的便捷函数。但是,我们必须首先确保输入的数字参数始终为int64FormatInt还需要一个基数参数,其中基数是介于 2 到 36 之间的整数,两个数字都包括在内。这意味着它可以将二进制数转换为字符串。

str := strconv.FormatInt(int64(123), 10)

上面的代码返回字符串"123"。如果我们指定基数为 2 会怎么样?

str := strconv.FormatInt(int64(123), 2)

这将返回字符串"1111011"。这意味着您可以使用FormatInt将一个基数中的数字转换为另一个基数,至少作为字符串显示。

FormatFloat函数比FormatInt函数稍微复杂一些。它根据格式和精度将浮点数转换为字符串。FormatFloat中可用于十进制数(基数 10)的格式有:

  • f - 无指数

  • eE - 带有指数

  • gG - 如果指数很大,则为eE,否则将不带指数(例如f

其他格式是二进制数字(基数 2)的b,十六进制数字(基数 16)的xX

精度描述的是要打印的数字位数(不包括指数)。精度值为-1 时,Go 会选择最小的数字位数,以便ParseFloat返回整个数字。

让我们看一些代码,这将使事情更清晰。

var v float64 = 123456.123456
var s string

s = strconv.FormatFloat(v, 'f', -1, 64)
fmt.Println("f (prec=-1)\t:", s)
s = strconv.FormatFloat(v, 'f', 4, 64)
fmt.Println("f (prec=4)\t:", s)
s = strconv.FormatFloat(v, 'f', 9, 64)
fmt.Println("f (prec=9)\t:", s)

s = strconv.FormatFloat(v, 'e', -1, 64)
fmt.Println("\ne (prec=-1)\t:", s)
s = strconv.FormatFloat(v, 'E', -1, 64)
fmt.Println("E (prec=-1)\t:", s)
s = strconv.FormatFloat(v, 'e', 4, 64)
fmt.Println("e (prec=4)\t:", s)
s = strconv.FormatFloat(v, 'e', 9, 64)
fmt.Println("e (prec=9)\t:", s)

s = strconv.FormatFloat(v, 'g', -1, 64)
fmt.Println("\ng (prec=-1)\t:", s)
s = strconv.FormatFloat(v, 'G', -1, 64)
fmt.Println("G (prec=-1)\t:", s)
s = strconv.FormatFloat(v, 'g', 4, 64)
fmt.Println("g (prec=4)\t:", s)

我们使用 64 位精度的浮点值float64,并比较精度-1 和精度 4。如果你没有意识到,小写e和大写E是完全相同的,除了指数字母e是小写或大写的不同。

当你运行代码时,你应该看到这个。

f (prec=-1)	: 123456.123456
f (prec=4)	: 123456.1235
f (prec=9)	: 123456.123456000

e (prec=-1)	: 1.23456123456e+05
E (prec=-1)	: 1.23456123456E+05
e (prec=4)	: 1.2346e+05
e (prec=9)	: 1.234561235e+05

g (prec=-1)	: 123456.123456
G (prec=-1)	: 123456.123456
g (prec=4)	: 1.235e+05

2.6 替换字符串中的多个字符

问题

你想用另一个字符串替换字符串的部分。

解决方案

有几种方法可以做到这一点。你可以使用strings.Replace函数或strings.ReplaceAll函数来替换选定的字符串。你也可以使用strings.Replacer类型来创建替换器。

讨论

有几种方法可以用来用另一个字符串替换字符串的部分。最简单的方法是使用strings.Replace函数。

strings.Replace函数非常简单。只需传递一个字符串,旧字符串你想要替换的字符串,以及你想要替换它的新字符串。让我们看看这段代码,使用查尔斯·狄更斯的《远大前程》中的这句话。

var quote string = `I loved her against reason, against promise,
against peace, against hope, against happiness,
against all discouragement that could be.`

我们使用Replace运行了几次替换。

replaced := strings.Replace(quote, "against", "with", 1)
fmt.Println(replaced)
replaced2 := strings.Replace(quote, "against", "with", 2)
fmt.Println("\n", replaced2)
replacedAll := strings.Replace(quote, "against", "with", -1)
fmt.Println("\n", replacedAll)

最后一个参数告诉Replace要替换的匹配数。如果最后一个参数是-1,Replace将匹配每一个实例。

I loved her with reason, against promise,
against peace, against hope, against happiness,
against all discouragement that could be.

 I loved her with reason, with promise,
against peace, against hope, against happiness,
against all discouragement that could be.

 I loved her with reason, with promise,
with peace, with hope, with happiness,
with all discouragement that could be.

还有一个ReplaceAll函数,它更像是一个方便的函数,调用Replace时将最后一个参数设置为-1。

strings包中的Replacer类型允许您同时进行多个替换。如果您需要进行大量替换,这将更加方便。

replacer := strings.NewReplacer("her", "him", "against", "for", "all", "some")
replaced := replacer.Replace(quote)
fmt.Println(replaced)

你只需要提供一组替换字符串作为参数。在上面的代码中,我们用“her”替换“him”,“against”替换“for”,“all”替换“some”。它将同时进行所有替换。如果你运行上面的代码,你将得到以下结果。

I loved him for reason, for promise,
for peace, for hope, for happiness,
for some discouragement that could be.

所以使用Replace还是创建Replacer更好呢?显然,创建替换器需要额外的一行代码。但是它的性能如何?让我们看一看。我们将从仅替换一个单词开始。

func BenchmarkOneReplace(b *testing.B) {
	for i := 0; i < b.N; i++ {
		strings.Replace(quote, "her", "him", 1)
	}
}

func BenchmarkOneReplacer(b *testing.B) {
	replacer := strings.NewReplacer("her", "him")
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		replacer.Replace(quote)
	}
}

如果只是一个字符串需要替换,Replace更快。对于简单替换,只是有更多的开销。

goos: darwin
goarch: arm64
pkg: github.com/sausheong/gocookbook/ch06_string
BenchmarkOneReplace-10     	 7264310	       156.9 ns/op
BenchmarkOneReplacer-10    	 4336489	       276.0 ns/op
PASS
ok  	github.com/sausheong/gocookbook/ch06_string	3.151s

让我们看看是否需要进行多次替换。

func BenchmarkReplace(b *testing.B) {
	for i := 0; i < b.N; i++ {
		strings.Replace(quote, "against", "with", -1)
	}
}

func BenchmarkReplacerCreate(b *testing.B) {
	for i := 0; i < b.N; i++ {
		strings.NewReplacer("against", "with")
	}
}

func BenchmarkReplacer(b *testing.B) {
	replacer := strings.NewReplacer("against", "with")
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		replacer.Replace(quote)
	}
}

我们不需要每次创建一个Replacer,因此如果您想对不同的字符串进行相同的替换,可以多次重用替换器。同时,如果您有很多替换,使用替换器肯定更容易。

goos: darwin
goarch: arm64
pkg: github.com/sausheong/gocookbook/ch06_string
BenchmarkReplace-10           	 2250291	       532.1 ns/op
BenchmarkReplacerCreate-10    	31878366	        37.13 ns/op
BenchmarkReplacer-10          	 4671319	       255.0 ns/op
PASS
ok  	github.com/sausheong/gocookbook/ch06_string	4.547s

正如您所见,要替换更多的字符串,使用Replacer会更有效率。

2.7 创建子字符串

问题

您想从字符串中创建子字符串。

解决方案

将字符串视为数组或切片,并从字符串中取出一个子字符串。

讨论

在 Go 语言中,字符串是字节的切片。因此,如果您想从字符串中取出一个子字符串,可以像对待任何切片一样操作。让我们使用前面的配方中相同的引语,查尔斯·狄更斯的《远大前程》。

var quote string = `I loved her against reason, against promise,
against peace, against hope, against happiness,
against all discouragement that could be.`

如果您想从引语中提取“against reason”这几个词,可以这样做。

quote[12:26]

这很简单,但是如何在不手动计数引语中的字母的情况下知道单词的位置呢?像许多编程语言一样,我们只需找到子字符串的索引。

strings.Index(quote, "against reason")

strings.Index函数给出了与第二个参数匹配的第一个子字符串的索引,本例中将是 12。这将给我们子字符串的位置。要找到子字符串的结束位置,我们只需将子字符串的长度添加到索引即可。

i := strings.Index(quote, "against reason")
j := i + len("against reason")
fmt.Println(quote[i:j])

2.8 检查字符串是否包含另一个字符串

问题

您想检查一个字符串是否包含另一个字符串。

解决方案

使用strings包中的Contains函数。如果您要检查的字符串是后缀或前缀,也可以使用HasSuffixHasPrefix函数。

讨论

在 Go 语言中检查字符串是否包含另一个字符串非常简单。您可以使用strings.Contains函数,传入字符串和子字符串,它会相应地返回 true 或 false。我们将再次使用查尔斯·狄更斯的《远大前程》中的引语。

var quote string = `I loved her against reason, against promise,
against peace, against hope, against happiness,
against all discouragement that could be.`

Contains函数检查引语是否包含字符串“against”。

var has bool = strings.Contains(quote, "against")

或者,您也可以始终使用strings.Index,如果返回的结果是 < 0,这意味着字符串中找不到子字符串。两种函数的性能是一样的,这并不奇怪,因为Contains只是围绕Index函数提供的方便功能。另一种选择是使用Count函数,它返回子字符串在字符串中出现的次数,但这通常是一个较差的选择(除非您无论如何都需要知道计数),因为性能比任一函数都要差。

如果您想查找子字符串是否是字符串的前缀,可以使用HasPrefix函数。

strings.HasPrefix(quote, "I loved")

当然,您可以直接从字符串中切出前缀的长度并自行检查。

prefix := "I loved"
if quote[:len(prefix)] == prefix {
    ... // do whatever you wanted if the string has the prefix
}

您可以使用HasSuffix函数对后缀执行相同的操作。

strings.HasSuffix(quote, "could be.")

您还可以直接切片字符串以获取后缀并进行比较。

suffix := "could be."
if quote[len(quote)-len(suffix):] != suffix {
    ... // do whatever you wanted if the string has the prefix
}

2.9 将字符串分割为字符串数组或将字符串数组组合成字符串

问题

你想通过拆分字符串创建一个字符串数组,或者通过组合字符串数组创建一个字符串。

解决方案

使用strings包中的Split函数来拆分字符串,并使用Join函数将字符串数组组合成单个字符串。

讨论

许多函数接受一个字符串数组。你可能想要处理字符串中的单词而不是单独的字节。你可能在处理像 CSV 或 TSV 这样的分隔符分隔的数据。无论是哪种情况,能够快速地将一个字符串拆分成字符串数组是很有用的。

在 Go 语言中,你可以使用strings.Split函数来做到这一点。让我们再次使用查尔斯·狄更斯的《远大前程》的引语。

var quote string = `I loved her against reason, against promise,
against peace, against hope, against happiness,
against all discouragement that could be.`

Split函数根据指定的分隔符将字符串拆分为字符串数组。

array := strings.Split(quote, " ")
fmt.Printf("%q", array)

在上面的代码中,我们使用空格作为分隔符,所以这是你会看到的。

["I" "loved" "her" "against" "reason," "against" "promise," "\nagainst" "peace,"
"against" "hope," "against" "happiness," "\nagainst" "all" "discouragement" "that"
"could" "be."]

你可能注意到一些元素有换行符,因为原始字符串包含它。这可能不是你想要的,那么我们如何移除换行符?或者更糟糕的是,如果有多个空格,你的数组看起来会非常凌乱,有许多额外的空字符串元素。当然,你可以稍后用笨方法清理它们,但有一个更简单的方法。strings包中有一个名为Fields的函数,可以将一个字符串拆分为考虑一个或多个连续空格的字符串数组,由uniform.isSpace定义。

如果我们使用Fields而不是Split来看一下。

array := strings.Fields(quote)
fmt.Printf("%q", array)

现在你应该能看到这个了。

["I" "loved" "her" "against" "reason," "against" "promise," "against" "peace,"
"against" "hope," "against" "happiness," "against" "all" "discouragement" "that"
"could" "be."]

换行符已经被去掉了。如果我们想要连标点符号(在这种情况下是逗号和句号在引号末尾)也移除掉呢?这有点复杂,我们需要使用FieldsFunc函数并传入一个函数来确定是否应该将其作为分隔符的一部分。让我们看看。

f := func(c rune) bool {
    return unicode.IsPunct(c) || !unicode.IsLetter(c)
}
array := strings.FieldsFunc(quote, f)
fmt.Printf("%q", array)

在上面的代码中,我们创建了一个函数f,它将连续的标点符号和非字母字符视为分隔符的一部分。然后我们将这个函数传递给FieldsFunc来对字符串执行。这是我们应该看到的。

["I" "loved" "her" "against" "reason" "against" "promise" "against" "peace"
"against" "hope" "against" "happiness" "against" "all" "discouragement" "that"
"could" "be"]

如你所见,我们也去掉了标点符号。FieldsFunc函数非常灵活,我只给出了一个非常简单的例子。如果你经常处理字符串拆分,这将是一个非常强大的函数,你可以用它做很多事情。

如果我们只想把字符串拆分为前 9 个元素并将剩余部分放入单个字符串呢?SplitN函数正好可以做到这一点,其中n是生成数组中元素数量,在这种情况下是 10。

array := strings.SplitN(quote, " ", 10)
fmt.Printf("%q", array)

你将会看到结果数组中有 10 个元素。

["I" "loved" "her" "against" "reason," "against" "promise," "\nagainst" "peace,"
"against hope, against happiness, \nagainst all discouragement that could be."]

有时候你希望在拆分字符串后保留分隔符。Go 语言有一个名为SplitAfter的函数可以做到这一点。

array := strings.SplitAfter(quote, " ")
fmt.Printf("%q", array)

如你所见,每个元素都以空格结尾(这是分隔符),除了最后一个元素。

["I " "loved " "her " "against " "reason, " "against " "promise, " "\nagainst "
"peace, " "against " "hope, " "against " "happiness, " "\nagainst " "all "
"discouragement " "that " "could " "be."]

2.10 字符串修剪

问题

你想要移除字符串的开头和结尾的字符。

解决方案

使用 strings 包中的 Trim 函数。

讨论

在处理字符串时,经常会遇到尾随或前导的空白或其他不必要的字符。我们经常希望在存储或进一步处理字符串之前移除这些字符。因此,字符串修剪的概念也非常普遍。字符串修剪基本上是从字符串的开头或结尾移除字符,而不是在字符串内部。

在 Go 中,有许多 strings 包中的 Trim 函数可以帮助我们修剪字符串。

让我们从 Trim 函数开始。它接受一个字符串和一个切割集,这是一个由一个或多个 Unicode 代码点组成的字符串,并返回一个删除了所有前导和尾随这些代码点的字符串。

var str string = ", and that is all."
var cutset string = ",. "
trimmed := strings.Trim(str, cutset) // "and that is all"

在上面的代码中,我们想要移除前导逗号和空格,以及尾随句点。为此,我们使用包含这 3 个 Unicode 代码点的切割集,因此切割集字符串最终为 ",. "

Trim 函数同时移除前导和尾随字符,但如果您只想移除尾随字符,可以使用 TrimRight 函数;如果只想移除前导字符,可以使用 TrimLeft 函数。

trimmedLeft := strings.TrimLeft(str, cutset)   // "and that is all."
trimmedRight := strings.TrimRight(str, cutset) // ", and that is all"

之前的 Trim 函数会移除切割集中的任何字符。但是,如果你想要移除整个前导子字符串(或者前缀),你可以使用 TrimPrefix 函数。

trimmedPrefix := strings.TrimPrefix(str, ", and ")	// "that is all."

同样地,如果你想要移除整个尾部子字符串(或者后缀),你可以使用 TrimSuffix 函数。

trimmedSuffix := strings.TrimSuffix(str, " all.") // ", and that is"

Trim 函数允许您移除任何前导或尾随字符或字符串。但通常被移除的字符是空白字符,如换行符 (\n) 或制表符 (\r) 或回车符 (\r)。为方便起见,Go 提供了一个 TrimSpace 函数,仅移除前导和尾随空白字符。

trimmed := strings.TrimSpace("\r\n\t Hello World \t\n\r") // Hello World

最后一组 Trim 函数是 TrimFuncTrimLeftFuncTrimRightFunc 函数。从名称就可以猜到,这允许您用函数替换切割集字符串,该函数将检查前导或尾随或两者都检查的 Unicode 代码点,并确保它们满足条件。

f := func(c rune) bool {
	return unicode.IsPunct(c) || !unicode.IsLetter(c)
}
trimmed := strings.TrimFunc(str, f) // "and that is all"

这些 TrimFunc 函数允许您对字符串修剪进行更精细的控制,如果您有意外的规则用于删除前导或尾随字符,则非常有用。

2.11 从命令行捕获字符串输入

问题

您想要从命令行捕获用户输入的字符串数据。

解决方案

使用 fmt 包中的 Scan 函数从标准输入读取单个字符串。要读取由空格分隔的字符串,请在包装在 os.Stdin 周围的 Reader 上使用 ReadString

讨论

如果您的 Go 程序从命令行运行,可能会遇到需要从用户获取字符串输入的情况。这就是 fmt 包中的 Scan 函数派上用场的地方。

您可以使用 Scan 来从用户获取输入,方法是创建一个变量,然后将该变量的引用传递给 Scan

package main

import "fmt"

func main() {
	var input string
	fmt.Print("Please enter a word: ")
	n, err := fmt.Scan(&input)
	if err != nil {
		fmt.Println("error with user input:", err, n)
	} else {
		fmt.Println("You entered:", input)
	}
}

如果您运行上面的代码,程序将在 fmt.Scan 处等待您的输入,并且只有在您输入数据后才会继续。一旦您输入了一些数据,Scan 将把数据存储到 input 变量中。

如果您运行上面的代码,这就是您应该看到的。

% go run scan.go
Please enter a word: Hello
You entered: Hello

文档没有明确提到您需要传递一个变量的引用。您可以按值传递一个变量,它将被编译。但是,如果您这样做,将会出现错误。

n, err := fmt.Scan(input)
if err != nil {
	fmt.Println("error with user input:", err, n)
}

如果您运行上面的代码,您将看到这个结果。

% go run scan.go
Please enter a word: error with user input: type not a pointer: string 0

Scan 函数可以接受多个参数,每个参数表示由空格分隔的用户输入。让我们再做两个输入。

func main() {
	var input1, input2 string
	fmt.Print("Please enter two words: ")
	n, err := fmt.Scan(&input1, &input2)
	if err != nil {
		fmt.Println("error with user input:", err, n)
	} else {
		fmt.Println("You entered:", input1, "and", input2)
	}
}

如果您运行此代码,并输入 HelloWorld,它们将分别被捕获并存储到 input1input2 中。如果您运行上面的代码,您将看到这个结果。

% go run scan.go
Please enter two words: Hello World
You entered: Hello and World

这似乎有些局限性。如果您想捕获一个带有空格的字符串怎么办?例如,您想要让用户输入一个句子。在这种情况下,您可以在包装在 os.Stdin 周围的 Reader 上使用 ReadString 函数。

func main() {
	reader := bufio.NewReader(os.Stdin)
	fmt.Print("Please enter many words: ")
	input, err := reader.ReadString('\n')
	if err != nil {
		fmt.Println("error with user input:", err)
	} else {
		fmt.Println("You entered:", input)
	}
}

如果您运行上面的代码,这就是您应该看到的结果。

% go run scan.go
Please enter many words: Many words here and still more to go
You entered: Many words here and still more to go

您应该知道 Scan 不仅可以用于从用户获取字符串输入,还可以用于获取数字等。

2.12 转义和反转义 HTML 字符串

问题

您想要转义或反转义 HTML 字符串。

解决方案

使用 html 包中的 EscapeStringUnescapeString 函数来转义或反转义 HTML 字符串。

讨论

HTML 是一种基于文本的标记语言,用于结构化网页及其内容。它通常由浏览器解释并显示。HTML 的大部分内容都在 HTML 标签内描述,例如 <a> 是锚点标签,<img> 是图像标签。类似地,还有其他像 &" 这样具有在 HTML 中特定意义的字符。

但是如果想在 HTML 中显示这些字符怎么办?例如,和符号(&)是一个常用字符。小于号和大于号(<>)也是常用的。如果我们不打算让这些符号在 HTML 中具有任何意义,我们需要将它们转换为 HTML 字符实体。例如:

  • 小于号变成了 <

  • 大于号变成了 >

  • &(和符号)变成了 &

等等。将 HTML 字符转换为实体的过程称为 HTML 转义,反之则称为 HTML 反转义。

Go 语言在 html 包中有一对函数,名为 EscapeStringUnescapeString,可用于转义或反转义 HTML。

str := "<b>Rock & Roll</b>"
escaped := html.EscapeString(str) // "&lt;b&gt;Rock &amp; Roll&lt;/b&gt;"

反转义将转义的 HTML 字符串恢复为原始字符串。

unescaped := html.UnescapeString(escaped) // "<b>Rock & Roll</b>"

您可能注意到 html/template 包中还有一个 HTMLEscapeString 函数。这两个函数的结果是相同的。

2.13 使用正则表达式

问题

您想要使用正则表达式进行字符串操作。

解决方案

使用regex包,并使用Compile函数解析正则表达式以返回一个Regexp结构体。然后使用Find函数匹配模式并返回字符串。

讨论

正则表达式是一种描述字符串中搜索模式的表示法。当特定字符串在正则表达式描述的集合中时,正则表达式与该字符串匹配。正则表达式在许多语言中都很流行且可用。

Go 语言有一个专门用于正则表达式的标准包叫做regex。正则表达式的语法与 Perl、Python 和其他语言使用的一般语法相同。

使用regex包非常简单。你必须先从正则表达式创建一个Regexp结构体。有了这个结构体,你可以调用任意数量的Find函数来返回匹配的字符串或字符串的索引。

尽管regex包中看起来有很多Find函数,大多数都作为Regexp结构体的方法附加在上面,但它们有一个通用的模式。特别是没有All的那些函数只会返回第一个匹配项,而带有All的函数将根据n参数在字符串中可能返回所有匹配项。带有String的函数将返回字符串或字符串片段,而没有的函数将返回字节数组[]byte。带有Index的函数返回匹配项的索引。

在下面的代码片段中,我们将使用查尔斯·狄更斯《远大前程》中的同一引用,就像本章节中的其他示例一样。

var quote string = `I loved her against reason, against promise,
against peace, against hope, against happiness,
against all discouragement that could be.`

让我们先创建一个Regexp结构体,以便稍后使用。

re, err := regexp.Compile(`against [\w]+`)

这里的正则表达式是`against [\w]+`。我们使用反引号来创建正则表达式字符串,因为正则表达式使用了很多反斜杠,如果我们使用双引号,这些反斜杠会被解释得不同。我们使用的正则表达式匹配以against开头并在其后有一个单词的字符串模式。

Compile的一个方便替代是MustCompile函数。这个函数与Compile做的事情完全一样,只是它不会返回错误。相反,如果正则表达式无法编译,该函数将会引发 panic。

一旦我们设置了正则表达式,我们就可以用它来查找匹配项。有许多Find方法,但在这个示例中我们只介绍了其中几个。其中一个最直接的方法是MatchString,它简单地告诉我们正则表达式在字符串中是否有任何匹配项。

re.MatchString(quote) // true

有时除了检查正则表达式是否有任何匹配项外,我们还想返回匹配的字符串。我们可以使用FindString来实现这一点。

str := re.FindString(quote) // "against reason"

在这里,我们从quote字符串中找到一个字符串,使用我们之前设置的正则表达式。它返回第一个匹配项,因此返回的字符串是against reason。如果我们想返回所有匹配项,我们必须使用带有All的方法,例如FindAllString

strs := re.FindAllString(quote, -1)
fmt.Println(strs)

FindAllString中的第二个参数,像所有的All方法一样,表示我们希望返回的匹配数。如果我们想返回所有匹配项,我们需要使用一个负数,例如-1。返回的值是一个字符串数组。

如果我们运行上面的代码,同时打印出字符串数组,我们将得到这样的结果。

[against reason against promise against peace against hope against happiness
against all]

除了返回匹配的字符串,有时我们还想知道匹配项的位置。在这种情况下,我们可以使用Index函数,例如FindStringIndex

locs := re.FindStringIndex(quote) // [12 26]

这返回一个包含两个整数的切片,表示第一个匹配的位置。如果我们使用这两个整数在quote本身上,我们将能够提取匹配的子字符串。

quote[locs[0]:locs[1]] // against reason

和以前一样,要获取所有匹配项,我们需要使用All方法,因此在这种情况下我们可以使用FindAllStringIndex方法。

allLocs := re.FindAllStringIndex(quote, -1)
fmt.Println(allLocs)

这将返回所有匹配项的二维切片。

[[12 26] [28 43] [46 59] [61 73] [75 92] [95 106]]

除了查找和索引正则表达式,我们还可以使用ReplaceAllString完全替换匹配的字符串。这是一个简单的例子。

replaced := re.ReplaceAllString(quote, "anything")
fmt.Println(replaced)

如果你运行上面的代码,你应该看到这个结果。

I loved her anything, anything,
anything, anything, anything,
anything discouragement that could be.

除了简单的替换,我们还可以使用一个接受匹配字符串并生成另一个字符串作为输出的函数来替换匹配的字符串。让我们看一个快速的例子。

replaced = re.ReplaceAllStringFunc(quote, strings.ToUpper)
fmt.Println(replaced)

在这里,我们通过使用strings.ToUpper函数将所有匹配的字符串替换为大写版本。这是运行代码的结果。

I loved her AGAINST REASON, AGAINST PROMISE,
AGAINST PEACE, AGAINST HOPE, AGAINST HAPPINESS,
AGAINST ALL discouragement that could be.

假设我们不是将匹配字符串中的两个单词都大写,而是只想让第二个单词大写。我们可以创建一个简单的函数来实现这个目标。

f := func(in string) string {
	split := strings.Split(in, " ")
	split[1] = strings.ToUpper(split[1])
	return strings.Join(split, " ")
}
replaced = re.ReplaceAllStringFunc(quote, f)
fmt.Println(replaced)

如果我们运行这段代码,我们将得到这个结果。

I loved her against REASON, against PROMISE,
against PEACE, against HOPE, against HAPPINESS,
against ALL discouragement that could be.

正则表达式非常强大,并且在许多地方都有用途。在 Go 语言中,它可以用于非常强大的字符串操作。然而,需要注意的是,Go 语言中的regex包支持 RE2 接受的正则表达式语法。这保证了正则表达式在输入大小线性时间内运行。因此,一些像lookaheadlookbehind这样的语法不被支持。如果你更熟悉 PCRE 库支持的语法,你可能需要检查并确保你的正则表达式按照你希望的方式工作。

第三章:通用输入/输出实例

3.0 简介

输入和输出(或更普遍称为 I/O)是计算机与外部世界通信的方式。I/O 是开发软件的关键部分,因此大多数编程语言,包括 Go,都有能够读取输入和写入输出的标准库。计算机的典型输入通常指的是来自键盘的按键或鼠标的点击或移动,也可以是其他外部来源,如摄像头、麦克风,或游戏手柄等。输出在许多情况下是指显示在屏幕上(或终端上)的内容,或打印在打印机上的内容。I/O 还可以指网络连接,通常也涉及文件。

在本章中,我们将探讨一些用于管理 I/O 的常见 Go 示例。我们将从一些基本的 I/O 示例开始,然后讨论文件的一般情况。在接下来的几章中,我们将继续讨论 CSV,然后是 JSON 和二进制文件。

io 包是 Go 中用于输入和输出的基本包。它包含了主要的 I/O 接口和一些便利函数。主要且最常用的接口是 ReaderWriter,但还有一些变体,如 ReadWriterTeeReaderWriterTo 等等。

通常,这些接口只是一些函数的描述符,例如一个实现了 Reader 的结构体就是具有 Read 函数的结构体。一个实现了 WriterTo 的结构体就是具有 WriteTo 函数的结构体。有些接口结合了两个或更多接口,例如 ReadWriter 结合了 ReaderWriter 接口,并拥有 ReadWrite 函数。

本章将更详细地解释这些接口的使用方式。

3.1 从输入中读取数据

问题

你想从输入中读取数据。

解决方案

使用 io.Reader 接口来从输入流中读取数据。

讨论

Go 使用 io.Reader 接口来表示从数据流中读取数据的能力。Go 标准库以及第三方包中的许多包都使用 Reader 接口来允许从中读取数据。

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

任何实现了 Read 函数的结构体都是 Reader。假设你有一个读取器(实现了 Reader 接口的结构体)。要从读取器中读取数据,你需要创建一个字节切片,并将该切片传递给 Read 方法。

bytes = make([]byte, 1024)
reader.Read(bytes)

这可能看起来有些反直觉,并且似乎你想从 bytes 中读取数据到读取器中,但实际上你是从读取器中读取数据到 bytes 中。只需将其想象为数据从左侧的读取器流向右侧的 bytes

Read 只会填充其容量的字节。如果你想从读取器中读取所有内容,可以使用 io.ReadAll 函数。

bytes, err := os.ReadAll(reader)

这看起来更直观,因为 ReadAll 从传递给参数的读取器中读取数据,并将数据返回到 bytes 中。在这种情况下,数据从右侧的读取器流向左侧的 bytes 中。

你经常会发现一些函数期望一个读取器作为输入参数。假设你有一个字符串,你想将这个字符串传递给函数,你可以怎么做呢?你可以使用strings.NewReader函数从字符串创建一个读取器,然后将其传递给函数。

str := “My String Data”
reader := strings.NewReader(str)

现在你可以将reader传递给期望一个读取器的函数。

3.2 写入到输出

问题

你想要写入到一个输出。

解决方案

使用io.Writer接口来写入到一个输出。

讨论

io.Writer接口的工作方式与io.Reader相同。

type Writer interface {
	Write(p []byte) (n int, err error)
}

当你在io.Writer上调用Write时,你正在将字节写入到底层数据流中。

bytes = []byte("Hello World")
writer.Write(bytes)

你可能注意到,这种调用模式与io.Reader在 8.1 章节中的方法相反。在Reader中,你调用Read方法将数据从结构体读取到bytes变量中,而在这里,你调用Write方法将数据从bytes变量写入到结构体中。在这种情况下,数据从右向左流动,从bytes流向写入器。

在 Go 语言中的一个常见模式是,一个函数以写入器作为参数。该函数然后在写入器上调用Write函数,稍后你可以从写入器中提取数据。让我们看一个例子。

var buf bytes.Buffer
fmt.Fprintf(&buf, "Hello %s", "World")
s := buf.String() // s == "Hello World"

bytes.Buffer结构体是一个Writer(它实现了Write函数),因此你可以轻松地创建一个,并将其传递给fmt.Fprintf函数,该函数将io.Writer作为其第一个参数。fmt.Fprintf函数将数据写入到缓冲区,稍后你可以从中提取数据出来。

在 Go 语言中,通过向写入器写入数据并在稍后提取出来的模式非常常见。另一个例子是在 HTTP 处理程序中使用http.ResponseWriter

func myHandler(w http.ResponseWriter, r *http.Request) {
    w.Write([]bytes("Hello World"))
}

在这里,我们将数据写入到ResponseWriter,然后将数据作为输入发送回浏览器。

3.3 从读取器复制到写入器

问题

你想要从一个读取器复制到一个写入器。

解决方案

使用io.Copy函数从一个读取器复制到一个写入器。

讨论

有时我们从一个读取器中读取,因为我们想要将其写入一个写入器。这个过程可能需要几个步骤,从读取器中读取所有内容到缓冲区,然后再将其写入到写入器中。不过,我们可以使用io.Copy函数来简化这个过程。io.Copy函数一次性从读取器复制到写入器。

让我们看看如何使用io.Copy。我们想要下载一个文件,因此我们使用http.Get来获取一个读取器,然后我们读取它,接着我们使用os.WriteFile来写入文件。

// using a random 1MB test file
var url string = "http://speedtest.ftp.otenet.gr/files/test1Mb.db"

func readWrite() {
	r, err := http.Get(url)
	if err != nil {
		log.Println("Cannot get from URL", err)
	}
	defer r.Body.Close()
	data, _ := os.ReadAll(r.Body)
	os.WriteFile("rw.data", data, 0755)
}

当我们使用http.Get下载一个文件时,我们会得到一个http.Response结构体。文件的内容在http.Response结构体的Body变量中,这是一个io.ReadCloserReadCloser只是一个接口,它将ReaderCloser组合在一起,因此我们可以像对待读取器一样处理它。我们使用os.ReadAll函数从Body中读取数据,然后使用os.WriteFile将其写入文件。

这就足够简单了,但让我们看看函数的性能如何。我们使用标准 Go 工具的基准测试能力来完成这个任务。首先,我们创建一个测试文件,就像创建任何其他测试文件一样。

package main

import "testing"

func BenchmarkReadWrite(b *testing.B) {
	readWrite()
}

在这个测试文件中,我们创建了一个以 Benchmarkxxx 开头的函数,而不是以 Testxxx 开头的函数,该函数接受一个指向 testing.B 的参数 b

基准函数非常简单,我们只是调用我们的 readWrite 函数。让我们从命令行运行它,看看我们的表现如何。

$ go test -bench=. -benchmem

我们使用 -bench=. 标志告诉 Go 运行所有基准测试,并使用 -benchmem 标志显示内存基准。这是你应该看到的内容。

goos: darwin
goarch: amd64
pkg: github.com/sausheong/go-cookbook/io/copy
cpu: Intel(R) Core(TM) i7-7920HQ CPU @ 3.10GHz
BenchmarkReadWrite-8   	       1	1998957055 ns/op	 5892440 B/op
219 allocs/op
PASS
ok  	github.com/sausheong/go-cookbook/io/copy	2.327s

我们为一个下载 1 MB 文件的函数运行了一个基准测试。测试只运行了一次,耗时 1.99 秒。内存占用为 5.89 MB,共进行了 219 次内存分配。

可见,下载 1 MB 文件的操作相当昂贵。毕竟,下载 1 MB 文件需要近 6 MB 内存。或者,我们可以使用 io.Copy 来完成几乎相同的操作,但内存占用要少得多。

func copy() {
	r, err := http.Get(url)
	if err != nil {
		log.Println("Cannot get from URL", err)
	}
	defer r.Body.Close()
	file, _ := os.Create("copy.data")
	defer file.Close()
	writer := bufio.NewWriter(file)
	io.Copy(writer, r.Body)
	writer.Flush()
}

首先,我们需要为数据创建一个文件,这里使用 os.Create。接下来,我们使用 bufio.NewWriter 创建一个缓冲写入器,将其包装在文件周围。这将在 Copy 函数中使用,将响应的内容复制到缓冲写入器中。最后,我们刷新写入器的缓冲区,并使底层写入器将内容写入文件。

如果你运行这个 copy 函数,它的工作方式是相同的,但性能如何呢?让我们回到我们的基准测试中,并为这个 copy 函数添加另一个基准函数。

package main

import "testing"

func BenchmarkReadWrite(b *testing.B) {
	readWrite()
}

func BenchmarkCopy(b *testing.B) {
	copy()
}

我们再次运行基准测试,你应该看到的结果如下。

goos: darwin
goarch: amd64
pkg: github.com/sausheong/go-cookbook/io/copy
cpu: Intel(R) Core(TM) i7-7920HQ CPU @ 3.10GHz
BenchmarkReadWrite-8   	       1	2543665782 ns/op	 5895360 B/op
227 allocs/op
BenchmarkCopy-8        	       1	1426656774 ns/op	   42592 B/op
61 allocs/op
PASS
ok  	github.com/sausheong/go-cookbook/io/copy	4.103s

这次,readWrite 函数耗时 2.54 秒,使用了 5.89 MB 内存,进行了 227 次内存分配。然而,copy 函数只耗时 1.43 秒,使用了 42.6 kB 内存,进行了 61 次内存分配。

copy 函数速度大约快了 80%,但内存使用量只有原来的一小部分(不到 1%)。对于非常大的文件,如果你使用 os.ReadAllos.WriteFile,内存可能会迅速耗尽。

3.4 从文本文件读取

问题

你想将文本文件读入内存。

解决方案

你可以使用 os.Open 函数打开文件,然后在文件上使用 Read。或者,你也可以使用更简单的 os.ReadFile 函数在一个函数调用中完成它。

讨论

读取和写入文件系统是编程语言需要做的基本操作之一。当然,你可以始终存储在内存中,但如果需要在关闭后持久保存数据,你需要将其存储在某个地方。数据可以以多种方式持久化,但最常用的可能是本地文件系统。

一次性读取所有内容

读取文本文件最简单的方法是使用 os.ReadFile。假设我们要从名为 data.txt 的文本文件中读取。

hello world!

要读取文件,只需将文件名作为参数传递给os.ReadFile,然后您就完成了!

data, err := os.ReadFile("data.txt")
if err != nil {
  log.Println("Cannot read file:", err)
}
fmt.Println(string(data))

这将打印出hello world!

打开文件并从中读取

通过打开文件然后对其进行读取更灵活,但需要更多步骤。首先,您需要打开文件。

// open the file
file, err := os.Open("data.txt")
if err != nil {
  log.Println("Cannot open file:", err)
}
// close the file when we are done with it
defer file.Close()

这可以使用os.Open完成,它以只读形式返回一个File结构。如果要以不同模式打开它,可以使用os.OpenFile。使用defer关键字设置文件以在函数返回前关闭是一个良好的实践。

接下来,我们需要创建一个字节数组来存储数据。

// get some info from the file
stat, err := file.Stat()
if err != nil {
  log.Println("Cannot read file stats:", err)
}
// create the byte array to store the read data
data := make([]byte, stat.Size())

为此,我们需要知道字节数组应该有多大,这应该是文件的大小。我们使用文件上的Stat方法获取FileInfo结构,可以调用Size方法获取文件的大小。

一旦我们有了字节数组,我们可以将其作为参数传递给文件结构上的Read方法。

// read the file
bytes, err := file.Read(data)
if err != nil {
  log.Println("Cannot read file:", err)
}
fmt.Printf("Read %d bytes from file\n", bytes)
fmt.Println(string(data))

这将把读取的数据存储到字节数组中并返回读取的字节数。如果一切顺利,您应该从输出中看到类似这样的内容。

Read 13 bytes from file
Hello World!

还有一些步骤,但您可以在打开文件并读取它之间的过程中灵活读取整个文档的部分,并且还可以做其他事情。

3.5 写入文本文件

问题

您想要将数据写入文本文件。

解决方案

您可以使用os.Open函数打开文件,然后在文件上执行Write。或者,您可以使用os.WriteFile函数一次性完成。

讨论

就像读取文件一样,有几种方法可以向文件中写入内容。

一次性写入文件

根据数据,您可以使用os.WriteFile一次性写入文件。

data := []byte("Hello World!\n")

err := os.WriteFile("data.txt", data, 0644)
if err != nil {
  log.Println("Cannot write to file:", err)
}

第一个参数是文件名,数据在一个字节数组中,最后一个参数是您要给文件的 Unix 文件权限。如果文件不存在,这将创建一个新文件。如果存在,它将删除文件中的所有数据并写入新数据,但不更改权限。

创建文件并写入

通过创建文件然后写入文件稍微复杂,但也更灵活。首先,您需要使用os.Create函数创建或打开文件。

data := []byte("Hello World!\n")
// write to file and read from file using the File struct
file, err := os.Create("data.txt")
if err != nil {
  log.Println("Cannot create file:", err)
}
defer file.Close()

如果文件不存在,将创建具有给定名称和模式0666的新文件。如果文件已存在,则会删除其中的所有数据。与以前一样,您希望设置文件在函数结束时关闭,使用defer关键字。

一旦您拥有文件,您可以直接使用Write方法将数据写入其中,并将包含数据的字节数组传递给它。

bytes, err := file.Write(data)
if err != nil {
  log.Println("Cannot write to file:", err)
}
fmt.Printf("Wrote %d bytes to file\n", bytes)

这将返回写入文件的字节数。与之前一样,虽然需要更多步骤,但在创建文件和写入文件之间分步骤可以让您以较小的块而不是一次性写入,从而更灵活。

3.6 使用临时文件

问题

您想要创建一个临时文件供使用,然后处理它后再将其丢弃。

解决方案

使用os.CreateTemp函数创建一个临时文件,然后在不再需要时删除它。

讨论

临时文件是在程序执行某些操作时创建的用于暂存数据的文件。完成任务后,它应该被删除或复制到永久存储。在 Go 中,我们可以使用os.CreateTemp函数创建临时文件,然后在需要时删除它。

不同的操作系统将它们的临时文件存储在不同的位置。无论它在哪里,Go 都可以使用os.TempDir函数告诉你它在哪里。

fmt.Println(os.TempDir())

我们需要知道os.CreateTemp创建的临时文件将被创建在哪里。通常我们不关心,但因为我们要逐步分析临时文件的创建过程,我们想确切地知道它在哪里。执行这条语句时,我们应该看到像这样的东西。

/var/folders/nj/2xd4ssp94zz41gnvsyvth38m0000gn/T/

这是你的计算机告诉 Go(和其他一些程序)要使用作为临时目录的目录。我们可以直接使用这个目录,或者使用os.MkdirTemp函数在这里创建我们自己的目录。

tmpdir, err := os.MkdirTemp(os.TempDir(), "mytmpdir_*")
if err != nil {
	log.Println("Cannot create temp directory:", err)
}
defer os.RemoveAll(tmpdir)

os.MkdirTemp的第一个参数是临时目录,第二个参数是一个模式字符串。该函数将应用一个随机字符串来替换模式字符串中的*。使用os.RemoveAll推迟清理临时目录也是一个好习惯。

接下来,我们使用os.CreateTemp实际创建临时文件,向其传递我们刚刚创建的临时目录以及文件名的模式字符串,其工作方式与临时目录相同。

tmpfile, err := os.CreateTemp(tmpdir, "mytmp_*")
if err != nil {
	log.Println("Cannot create temp file:", err)
}

有了这个,我们有了一个文件,其他操作与任何其他文件一样。

data := []byte("Some random stuff for the temporary file")
_, err = tmpfile.Write(data)
if err != nil {
	log.Println("Cannot write to temp file:", err)
}
err = tmpfile.Close()
if err != nil {
	log.Println("Cannot close temp file:", err)
}

如果你选择不将临时文件放入单独的目录中(完成后删除该目录及其中的所有内容),你可以像这样使用os.Remove与临时文件名。

defer os.Remove(tmpfile.Name())

第四章:CSV 配方

4.0 简介

CSV 格式是一种文件格式,可以在文本编辑器中轻松编写和读取表格数据(数字和文本)。CSV 得到广泛支持,大多数电子表格程序,如 Microsoft Excel 和 Apple Numbers,都支持 CSV。因此,许多编程语言,包括 Go,都提供了生成和消耗 CSV 文件数据的库。

或许你会感到惊讶,CSV 已经存在了将近 50 年了。IBM Fortran 编译器在 1972 年的 OS/360 中支持它。如果你对此不是很确定,那么 OS/360 是 IBM 为他们的 System/360 主机计算机开发的批处理操作系统。因此,是的,CSV 最早的用途之一是用于 IBM 主机上的 Fortran 程序。

CSV(逗号分隔值)并没有很好地标准化,也不是所有的 CSV 格式都是用逗号分隔的。有时可能是制表符、分号或其他分隔符。但是,RFC 4180 有一个 CSV 规范,尽管并不是所有人都遵循这个标准。

Go 标准库有一个encoding/csv包,支持 RFC 4180,并帮助我们读写 CSV 文件。

4.1 读取 CSV 文件

问题

你想要将 CSV 文件读入内存以供使用。

解决方案

使用encoding/csv包和csv.ReadAll将 CSV 文件中的所有数据读取到一个二维字符串数组中。

讨论

假设你有这样一个文件:

id,first_name,last_name,email
1,Sausheong,Chang,sausheong@email.com
2,John,Doe,john@email.com

第一行是标题,接下来的 2 行是用户数据。以下是打开文件并将其读入二维字符串数组的代码。

file, err := os.Open("users.csv")
if err != nil {
 log.Println("Cannot open CSV file:", err)
}
defer file.Close()
reader := csv.NewReader(file)
rows, err := reader.ReadAll()
if err != nil {
 log.Println("Cannot read CSV file:", err)
}

首先,我们使用os.Open打开文件。这会创建一个os.File结构体(它是一个io.Reader),我们可以将其作为参数传递给csv.NewReadercsv.NewReader创建一个新的csv.Reader结构体,可以用来从 CSV 文件中读取数据。有了这个 CSV 读取器,我们可以使用ReadAll读取文件中的所有数据,并返回一个二维字符串数组[][]string

一个二维字符串数组?也许你会感到惊讶,如果 CSV 行项是整数?或者布尔值或其他类型?你应该记住 CSV 文件是文本文件,所以除了字符串以外,没有其他方式可以区分一个值是否是其他类型。换句话说,所有的值都被假定为字符串,如果你认为有其他类型,你需要将其强制转换为其他类型。

将 CSV 数据解析到结构体中

问题

你想要将 CSV 数据解析到结构体中,而不是二维字符串数组。

解决方案

首先将 CSV 文件读入一个二维字符串数组,然后将其存储到结构体中。

讨论

对于一些其他格式,比如 JSON 或 XML,从文件(或任何地方)读取数据并将其解析到结构体中是很常见的。尽管在 CSV 中也可以做到这一点,但你需要做更多的工作。

假设你想要将数据放入一个 User 结构体中。

type User struct {
   Id         int
   firstName  string
   lastName   string
   email      string
}

如果你想要将二维字符串数组中的数据解析到 User 结构体中,你需要自己转换每个项目。

var users []User
for _, row := range rows {
 id, _ := strconv.ParseInt(row[0], 0, 0)
 user := User{Id: int(id),
   firstName: row[1],
   lastName:  row[2],
   email:     row[3],
 }
 users = append(users, user)
}

在上面的例子中,因为用户 ID 是整数,我在使用它创建用户结构之前使用了 strconv.ParseInt 将字符串转换为整数。

在 for 循环结束时,你将会有一个 User 结构体的数组。如果你打印出来,你应该会看到这样的结果。

{0  first_name  last_name  email}
{1  Sausheong  Chang  sausheong@email.com}
{2  John  Doe  john@email.com}

4.2 移除标题行

问题

如果你的 CSV 文件有一行标头作为列标签,你也会得到它在返回的二维字符串数组或结构体数组中。你想把它移除。

解决方案

使用 Read 读取第一行,然后继续读取剩下的行。

讨论

当你在读取器上使用 Read 时,你将读取第一行,然后将光标移动到下一行。如果之后使用 ReadAll,你可以读取文件的其余部分到你想要的行中。

file, err := os.Open("users.csv")
if err != nil {
 log.Println("Cannot open CSV file:", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.Read() // use Read to remove the first line
rows, err := reader.ReadAll()
if err != nil {
 log.Println("Cannot read CSV file:", err)
}

这将给我们带来这样的东西:

{1  Sausheong  Chang  sausheong@email.com}
{2  John  Doe  john@email.com}

4.3 使用不同的分隔符

问题

CSV 并不一定需要使用逗号作为分隔符。你想读取一个 CSV 文件,其分隔符不是逗号。

解决方案

设置 csv.Reader 结构中的 Comma 变量为文件中使用的分隔符,然后像以前一样读取。

讨论

假设我们要读取的文件以分号作为分隔符。

id;first_name;last_name;email
1;Sausheong;Chang;sausheong@email.com
2;John;Doe;john@email.com

我们只需在之前创建的 csv.Reader 结构中设置 Comma,然后像以前一样读取文件。

file, err := os.Open("users2.csv")
if err != nil {
  log.Println("Cannot open CSV file:", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.Comma = ';' // change Comma to the delimiter in the file
rows, err := reader.ReadAll()
if err != nil {
  log.Println("Cannot read CSV file:", err)
}

4.4 忽略行

问题

当你读取 CSV 文件时,你想忽略某些行。

解决方案

在文件中使用注释来指示要忽略的行。然后在 csv.Reader 中启用编码,并像以前一样读取文件。

讨论

假设你想忽略某些行;你想做的就是简单地注释掉那些行。在 CSV 中你不能这样做,因为注释不是标准。但是使用 Go 的 encoding/csv 包,你可以指定一个注释符号,如果你把它放在行的开头,整行都会被忽略。

所以说你有这个 CSV 文件。

id,first_name,last_name,email
1,Sausheong,Chang,sausheong@email.com
# 2,John,Doe,john@email.com

要启用注释,只需在我们从 csv.NewReader 获取的 csv.Reader 结构中设置 Comment 变量。

file, err := os.Open("users.csv")
if err != nil {
  log.Println("Cannot open CSV file:", err)
}
defer file.Close()
reader := csv.NewReader(file)
reader.Comment = '#' // lines that start with this will be ignored
rows, err := reader.ReadAll()
if err != nil {
  log.Println("Cannot read CSV file:", err)
}

当你运行这个时,你会看到:

{0 first_name last_name email}
{1 Sausheong Chang sausheong@email.com}

4.5 写入 CSV 文件

问题

你想将内存中的数据写入 CSV 文件。

解决方案

使用 encoding/csv 包和 csv.Writer 来写入文件。

讨论

我们乐在读取 CSV 文件,现在我们必须写一个。写入与读取非常相似。首先需要创建一个文件(一个 io.Writer)。

file, err := os.Create("new_users.csv")
if err != nil {
  log.Println("Cannot create CSV file:", err)
}
defer file.Close()

写入文件的数据需要是二维字符串数组。记住,如果数据不是字符串,只需在这之前将其转换为字符串。创建一个带有文件的 csv.Writer 结构。之后,你可以在写入器上调用 WriteAll,文件就会被创建。这将所有数据写入你的二维字符串数组中的文件。

data := [][]string{
 {"id", "first_name", "last_name", "email"},
 {"1", "Sausheong", "Chang", "sausheong@email.com"},
 {"2", "John", "Doe", "john@email.com"},
}
writer := csv.NewWriter(file)
err = writer.WriteAll(data)
if err != nil {
 log.Println("Cannot write to CSV file:", err)
}

4.6 一次写入一行到文件

问题

不要把所有东西写在我们的二维字符串中,我们想一次写入一行到文件中。

解决方案

使用 csv.Writer 上的 Write 方法来写入单行。

讨论

将每一行逐行写入文件几乎相同,只是你需要迭代二维字符串数组以获取每一行,然后调用Write方法传递该行。每当需要将缓冲数据写入Writer(即文件)时,还需要调用Flush方法。在上面的示例中,我在将所有数据写入写入器后调用了Flush,但那是因为我没有很多数据。如果有很多行数据,你可能会想定期将数据刷新到文件中。要检查写入或刷新时是否出现问题,可以调用Error方法。

writer := csv.NewWriter(file)
for _, row := range data {
 err = writer.Write(row)
 if err != nil {
  log.Println("Cannot write to CSV file:", err)
 }
}
writer.Flush()

第五章:JSON 配方

5.0 引言

JSON(JavaScript 对象表示法)是一种轻量级的数据交换文本格式。它旨在供人类阅读,但也易于机器读取,基于 JavaScript 的一个子集。JSON 最初由 Douglas Crockford 定义,但目前由 RFC 7159 和 ECMA-404 描述。JSON 在基于 REST 的 Web 服务中广泛使用,尽管它们不一定需要接受或返回 JSON 数据。

JSON 在 RESTful web 服务中非常流行,但也经常用于配置。在许多 Web 应用程序中,从获取 Web 服务中的数据,到通过第三方身份验证服务验证 Web 应用程序,再到控制其他服务,创建和消费 JSON 是司空见惯的。

Go 使用encoding/json包支持标准库中的 JSON。

5.1 解析 JSON 数据字节数组到结构体

问题

您希望读取 JSON 数据字节数组并将其存储到结构体中。

解决方案

创建结构体以包含 JSON 数据,然后使用encoding/json包中的Unmarshal函数将数据解封装到结构体中。

讨论

使用encoding/json包解析 JSON 非常简单:

  1. 创建结构体以包含 JSON 数据。

  2. 将 JSON 字符串解封装为结构体

让我们来看一个示例 JSON 文件,包含来自 SWAPI(星球大战 API)的卢克·天行者角色数据。您可以直接访问此处的数据 — https://swapi.dev/api/people/1。我已经将数据存储在名为skywalker.json的文件中。

{
	"name": "Luke Skywalker",
	"height": "172",
	"mass": "77",
	"hair_color": "blond",
	"skin_color": "fair",
	"eye_color": "blue",
	"birth_year": "19BBY",
	"gender": "male",
	"homeworld": "https://swapi.dev/api/planets/1/",
	"films": [
		"https://swapi.dev/api/films/1/",
		"https://swapi.dev/api/films/2/",
		"https://swapi.dev/api/films/3/",
		"https://swapi.dev/api/films/6/"
	],
	"species": [],
	"vehicles": [
		"https://swapi.dev/api/vehicles/14/",
		"https://swapi.dev/api/vehicles/30/"
	],
	"starships": [
		"https://swapi.dev/api/starships/12/",
		"https://swapi.dev/api/starships/22/"
	],
	"created": "2014-12-09T13:50:51.644000Z",
	"edited": "2014-12-20T21:17:56.891000Z",
	"url": "https://swapi.dev/api/people/1/"
}

要将数据存储在 JSON 中,我们可以创建这样一个结构体。

type Person struct {
	Name      string        `json:"name"`
	Height    string        `json:"height"`
	Mass      string        `json:"mass"`
	HairColor string        `json:"hair_color"`
	SkinColor string        `json:"skin_color"`
	EyeColor  string        `json:"eye_color"`
	BirthYear string        `json:"birth_year"`
	Gender    string        `json:"gender"`
	Homeworld string        `json:"homeworld"`
	Films     []string      `json:"films"`
	Species   []string      `json:"species"`
	Vehicles  []string      `json:"vehicles"`
	Starships []string      `json:"starships"`
	Created   time.Time     `json:"created"`
	Edited    time.Time     `json:"edited"`
	URL       string        `json:"url"`
}

在结构体定义每个字段后的字符串字面量称为结构标记。Go 使用这些结构标记确定结构字段与 JSON 元素之间的映射。如果映射完全相同,则不需要它们。然而正如你所见,JSON 通常使用蛇形命名法(用下划线替换空格),使用小写字符,而在 Go 中我们使用驼峰命名法(变量无空格,但用一个大写字母表示分隔)。

如您从结构体中看到的,我们可以定义字符串切片以存储 JSON 中的数组,并使用time.Time等数据类型。事实上,我们可以使用大多数 Go 数据类型,甚至是映射(尽管只支持具有字符串键的映射)。

将数据解封装到结构体中只需一个函数调用,使用json.Unmarshal

func unmarshal() (person Person) {
	file, err := os.Open("skywalker.json")
	if err != nil {
		log.Println("Error opening json file:", err)
	}
	defer file.Close()

	data, err := io.ReadAll(file)
	if err != nil {
		log.Println("Error reading json data:", err)
	}

	err = json.Unmarshal(data, &person)
	if err != nil {
		log.Println("Error unmarshalling json data:", err)
	}
	return
}

在上述代码中,从文件读取数据后,我们创建一个Person结构体,然后使用json.Unmarshal将数据解封装到其中。

JSON 数据来自 Star Wars API,所以让我们通过 API 直接获取并稍作乐趣。我们使用http.Get函数传入 URL,但其他一切都一样。

func unmarshalAPI() (person Person) {
	r, err := http.Get("https://swapi.dev/api/people/1")
	if err != nil {
		log.Println("Cannot get from URL", err)
	}
	defer r.Body.Close()

	data, err := io.ReadAll(r.Body)
	if err != nil {
		log.Println("Error reading json data:", err)
	}

	err = json.Unmarshal(data, &person)
	if err != nil {
		log.Println("Error unmarshalling json data:", err)
	}
	return
}

如果你打印Person结构体,这就是我们应该得到的结果(输出已美化)。

json.Person{
    Name:      "Luke Skywalker",
    Height:    "172",
    Mass:      "77",
    HairColor: "blond",
    SkinColor: "fair",
    EyeColor:  "blue",
    BirthYear: "19BBY",
    Gender:    "male",
    Homeworld: "https://swapi.dev/api/planets/1/",
    Films:     {"https://swapi.dev/api/films/1/", "https://swapi.dev/api/films/2/",
    "https://swapi.dev/api/films/3/", "https://swapi.dev/api/films/6/"},
    Species:   {},
    Vehicles:  {"https://swapi.dev/api/vehicles/14/",
      "https://swapi.dev/api/vehicles/30/"},
    Starships: {"https://swapi.dev/api/starships/12/",
      "https://swapi.dev/api/starships/22/"},
    Created:   time.Date(2014, time.December, 9, 13, 50, 51, 644000000, time.UTC),
    Edited:    time.Date(2014, time.December, 20, 21, 17, 56, 891000000, time.UTC),
    URL:       "https://swapi.dev/api/people/1/",
}

5.2 解析非结构化 JSON 数据

问题

您想解析一些 JSON 数据,但不知道 JSON 数据的结构或属性足够提前来构建结构体,或者键到值的映射是动态的。

解决方案

我们使用与之前相同的方法,但是不使用预定义的结构体,而是使用字符串映射到空接口来存储数据。

讨论

明确星球大战 API 的结构。但并非总是如此。有时我们根本不知道结构足够清晰以创建结构体,而且没有可用的文档。此外,有时键到值的映射可能是动态的。看看这个 JSON。

{
    "Luke Skywalker": [
        "https://swapi.dev/api/films/1/",
        "https://swapi.dev/api/films/2/",
        "https://swapi.dev/api/films/3/",
        "https://swapi.dev/api/films/6/"
       ],
    "C-3P0": [
        "https://swapi.dev/api/films/1/",
        "https://swapi.dev/api/films/2/",
        "https://swapi.dev/api/films/3/",
        "https://swapi.dev/api/films/4/",
        "https://swapi.dev/api/films/5/",
        "https://swapi.dev/api/films/6/"
       ],
    "R2D2": [
        "https://swapi.dev/api/films/1/",
        "https://swapi.dev/api/films/2/",
        "https://swapi.dev/api/films/3/",
        "https://swapi.dev/api/films/4/",
        "https://swapi.dev/api/films/5/",
        "https://swapi.dev/api/films/6/"
       ],
    "Darth Vader": [
        "https://swapi.dev/api/films/1/",
        "https://swapi.dev/api/films/2/",
        "https://swapi.dev/api/films/3/",
        "https://swapi.dev/api/films/6/"
       ]
}

显然,从 JSON 中,键不一致,并且可以随着字符的添加而改变。对于这种情况,我们如何解析 JSON 数据?我们可以使用字符串映射到空接口,而不是预定义的结构体。让我们看一下代码。

func unstructured() (output map[string]interface{}) {
	file, err := os.Open("unstructured.json")
	if err != nil {
		log.Println("Error opening json file:", err)
	}
	defer file.Close()

	data, err := io.ReadAll(file)
	if err != nil {
		log.Println("Error reading json data:", err)
	}

	err = json.Unmarshal(data, &output)
	if err != nil {
		log.Println("Error unmarshalling json data:", err)
	}
	return
}

让我们看看输出。

map[string]interface {}{
    "C-3P0": []interface {}{
        "https://swapi.dev/api/films/1/",
        "https://swapi.dev/api/films/2/",
        "https://swapi.dev/api/films/3/",
        "https://swapi.dev/api/films/4/",
        "https://swapi.dev/api/films/5/",
        "https://swapi.dev/api/films/6/",
    },
    "Darth Vader": []interface {}{
        "https://swapi.dev/api/films/1/",
        "https://swapi.dev/api/films/2/",
        "https://swapi.dev/api/films/3/",
        "https://swapi.dev/api/films/6/",
    },
    "Luke Skywalker": []interface {}{
        "https://swapi.dev/api/films/1/",
        "https://swapi.dev/api/films/2/",
        "https://swapi.dev/api/films/3/",
        "https://swapi.dev/api/films/6/",
    },
    "R2D2": []interface {}{
        "https://swapi.dev/api/films/1/",
        "https://swapi.dev/api/films/2/",
        "https://swapi.dev/api/films/3/",
        "https://swapi.dev/api/films/4/",
        "https://swapi.dev/api/films/5/",
        "https://swapi.dev/api/films/6/",
    },
}

让我们尝试将同样的代码应用于早期的卢克·天行者 JSON 数据,并看看输出。

map[string]interface {}{
    "birth_year": "19BBY",
    "created":    "2014-12-09T13:50:51.644000Z",
    "edited":     "2014-12-20T21:17:56.891000Z",
    "eye_color":  "blue",
    "films":      []interface {}{
        "https://swapi.dev/api/films/1/",
        "https://swapi.dev/api/films/2/",
        "https://swapi.dev/api/films/3/",
        "https://swapi.dev/api/films/6/",
    },
    "gender":     "male",
    "hair_color": "blond",
    "height":     "172",
    "homeworld":  "https://swapi.dev/api/planets/1/",
    "mass":       "77",
    "name":       "Luke Skywalker",
    "skin_color": "fair",
    "species":    []interface {}{
    },
    "starships": []interface {}{
        "https://swapi.dev/api/starships/12/",
        "https://swapi.dev/api/starships/22/",
    },
    "url":      "https://swapi.dev/api/people/1/",
    "vehicles": []interface {}{
        "https://swapi.dev/api/vehicles/14/",
        "https://swapi.dev/api/vehicles/30/",
    },
}

您可能认为这比尝试弄清楚结构体要容易和简单得多!而且它更具宽容性和灵活性,为什么不使用这个呢?实际上使用结构体具有其优势。使用空接口基本上使数据结构无类型。结构体可以捕获 JSON 中的错误,而空接口则简单地让其通过。

从结构体中检索数据比从映射中更容易,因为您确切知道哪些字段是可用的。此外,您需要进行类型断言才能从接口中获取数据。例如,假设我们想要获取上面提到的出现达斯·维达的电影,所以您可能认为可以这样做。

unstruct := unstructured()
vader := unstruct["Darth Vader"]
first := vader[0]

你不能 — 你会看到这个错误而不是。

invalid operation: vader[0] (type interface {} does not support indexing)

这是因为变量vader是一个空接口,您必须首先对其进行类型断言,然后才能执行任何操作。

unstruct := unstructured()
vader := unstruct["Darth Vader"].([]interface{})
first := vader[0]

通常情况下,您应该尽量使用结构体,而只有在最后一种情况下才使用映射到空接口。

5.3 将 JSON 数据流解析为结构体

问题

您想从流中解析 JSON 数据。

解决方案

创建结构体以包含 JSON 数据。在encoding/json包中使用NewDecoder创建解码器,然后在解码器上调用Decode将数据解码为结构体。

讨论

对于 JSON 文件或 API 数据,使用Unmarshal简单而直接。但是如果 API 是流式 JSON 数据,会发生什么?在这种情况下,我们不能再使用Unmarshal,因为Unmarshal需要一次性读取整个文件。相反,encoding/json包提供了一个Decoder函数来处理数据。

可能很难理解 JSON 数据和流 JSON 数据之间的区别,所以让我们通过比较两个不同的 JSON 文件来看一下它们之间的区别。

在第一个 JSON 文件中,我们有一个包含 3 个 JSON 对象的数组(我截取了部分数据以便更容易阅读)。

[{
"name": "Luke Skywalker",
"height": "172",
"mass": "77",
"hair_color": "blond",
"skin_color": "fair",
"eye_color": "blue",
"birth_year": "19BBY",
"gender": "male"
},
{
"name": "C-3PO",
"height": "167",
"mass": "75",
"hair_color": "n/a",
"skin_color": "gold",
"eye_color": "yellow",
"birth_year": "112BBY",
"gender": "n/a"
},
{
"name": "R2-D2",
"height": "96",
"mass": "32",
"hair_color": "n/a",
"skin_color": "white, blue",
"eye_color": "red",
"birth_year": "33BBY",
"gender": "n/a"
}]

要读取这个,我们可以使用Unmarshal将其解码为一个Person结构体数组。

func unmarshalStructArray() (people []Person) {
	file, err := os.Open("people.json")
	if err != nil {
		log.Println("Error opening json file:", err)
	}
	defer file.Close()

	data, err := io.ReadAll(file)
	if err != nil {
		log.Println("Error reading json data:", err)
	}

	err = json.Unmarshal(data, &people)
	if err != nil {
		log.Println("Error unmarshalling json data:", err)
	}
	return
}

这将导致如下输出。

[]json.Person{
    {
        Name:      "Luke Skywalker",
        Height:    "172",
        Mass:      "77",
        HairColor: "blond",
        SkinColor: "fair",
        EyeColor:  "blue",
        BirthYear: "19BBY",
        Gender:    "male",
        Homeworld: "",
        Films:     nil,
        Species:   nil,
        Vehicles:  nil,
        Starships: nil,
        Created:   time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
        Edited:    time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
        URL:       "",
    },
    {
        Name:      "C-3PO",
        Height:    "167",
        Mass:      "75",
        HairColor: "n/a",
        SkinColor: "gold",
        EyeColor:  "yellow",
        BirthYear: "112BBY",
        Gender:    "n/a",
        Homeworld: "",
        Films:     nil,
        Species:   nil,
        Vehicles:  nil,
        Starships: nil,
        Created:   time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
        Edited:    time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
        URL:       "",
    },
    {
        Name:      "R2-D2",
        Height:    "96",
        Mass:      "32",
        HairColor: "n/a",
        SkinColor: "white, blue",
        EyeColor:  "red",
        BirthYear: "33BBY",
        Gender:    "n/a",
        Homeworld: "",
        Films:     nil,
        Species:   nil,
        Vehicles:  nil,
        Starships: nil,
        Created:   time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
        Edited:    time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
        URL:       "",
    },
}

这是一个 Person 结构体的数组,在我们解码单个 JSON 数组后得到的结果。但是,当我们得到一系列 JSON 对象的数据流时,这就不再可能了。让我们看看另一个 JSON 文件,这个文件代表了一个 JSON 数据流。

{
"name": "Luke Skywalker",
"height": "172",
"mass": "77",
"hair_color": "blond",
"skin_color": "fair",
"eye_color": "blue",
"birth_year": "19BBY",
"gender": "male"
}
{
"name": "C-3PO",
"height": "167",
"mass": "75",
"hair_color": "n/a",
"skin_color": "gold",
"eye_color": "yellow",
"birth_year": "112BBY",
"gender": "n/a"
}
{
"name": "R2-D2",
"height": "96",
"mass": "32",
"hair_color": "n/a",
"skin_color": "white, blue",
"eye_color": "red",
"birth_year": "33BBY",
"gender": "n/a"
}

请注意,这不是单个 JSON 对象,而是连续的 3 个 JSON 对象。这不再是一个有效的 JSON 文件,而是在读取 http.Response 结构体的 Body 时可能得到的内容。如果尝试使用 Unmarshal 读取这些内容,将会得到一个错误输出。

Error unmarshalling json data: invalid character '{' after top-level value

然而,你可以通过使用 Decoder 来解析它。

func decode(p chan Person) {
	file, err := os.Open("people_stream.json")
	if err != nil {
		log.Println("Error opening json file:", err)
	}
	defer file.Close()

	decoder := json.NewDecoder(file)
	for {
		var person Person
		err = decoder.Decode(&person)
		if err == io.EOF {
			break
		}
		if err != nil {
			log.Println("Error decoding json data:", err)
			break
		}
		p <- person
	}
	close(p)
}

首先,我们使用 json.NewDecoder 创建一个解码器,并将文件作为读取器传递给它。然后在 for 循环中,我们在解码器上调用 Decode,将要存储数据的结构体传递给它。如果一切顺利,每次循环时,都会从数据中创建一个新的 Person 结构体。然后我们可以使用这些数据。如果读取器中没有更多数据了,即我们遇到了 io.EOF,我们将从 for 循环中退出。

在上述代码中,我们传入一个通道,在其中每个循环中存储 Person 结构体。当我们完成读取文件中的所有 JSON 数据后,我们将关闭该通道。

func main() {
	p := make(chan Person)
	go decode(p)
	for {
		person, ok := <-p
		if ok {
            fmt.Printf("%# v\n", pretty.Formatter(person))
		} else {
			break
		}
	}
}

这是上述代码的输出。

json.Person{
    Name:      "Luke Skywalker",
    Height:    "172",
    Mass:      "77",
    HairColor: "blond",
    SkinColor: "fair",
    EyeColor:  "blue",
    BirthYear: "19BBY",
    Gender:    "male",
    Homeworld: "",
    Films:     nil,
    Species:   nil,
    Vehicles:  nil,
    Starships: nil,
    Created:   time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
    Edited:    time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
    URL:       "",
}
json.Person{
    Name:      "C-3PO",
    Height:    "167",
    Mass:      "75",
    HairColor: "n/a",
    SkinColor: "gold",
    EyeColor:  "yellow",
    BirthYear: "112BBY",
    Gender:    "n/a",
    Homeworld: "",
    Films:     nil,
    Species:   nil,
    Vehicles:  nil,
    Starships: nil,
    Created:   time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
    Edited:    time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
    URL:       "",
}
json.Person{
    Name:      "R2-D2",
    Height:    "96",
    Mass:      "32",
    HairColor: "n/a",
    SkinColor: "white, blue",
    EyeColor:  "red",
    BirthYear: "33BBY",
    Gender:    "n/a",
    Homeworld: "",
    Films:     nil,
    Species:   nil,
    Vehicles:  nil,
    Starships: nil,
    Created:   time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
    Edited:    time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
    URL:       "",
}

此处可以看到,这里连续打印了 3 个 Person 结构体,一个接一个地输出,与之前的数组形式不同。

有时候会出现的一个问题是,什么时候应该使用 Unmarshal,什么时候应该使用 Decode

Unmarshal 更适合用于单个 JSON 对象,但当你从读取器中获得一系列 JSON 对象时,它将无法正常工作。此外,它的简单性意味着它不太灵活,你只能一次性获取整个 JSON 数据。

Decode 另一方面,适用于单个 JSON 对象或流式 JSON 数据。此外,使用 Decode,你可以在不需要先完整获取 JSON 数据的情况下,在更细的层次上操作 JSON 数据。这是因为你可以在其传入时检查 JSON,甚至在标记级别上。然而,稍微的缺点是它更冗长。

此外,Decode 速度稍快。让我们对两者进行快速基准测试。

var luke []byte = []byte(
	`{
 "name": "Luke Skywalker",
 "height": "172",
 "mass": "77",
 "hair_color": "blond",
 "skin_color": "fair",
 "eye_color": "blue",
 "birth_year": "19BBY",
 "gender": "male"
}`)

func BenchmarkUnmarshal(b *testing.B) {
	var person Person
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		json.Unmarshal(luke, &person)
	}
}

func BenchmarkDecode(b *testing.B) {
	var person Person
	data := bytes.NewReader(luke)
	decoder := json.NewDecoder(data)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		decoder.Decode(&person)
		data.Seek(0, 0)
	}
}

这里我们使用标准的 Go 基准测试工具来对 UnmarshalDecode 进行基准测试。为了确保我们进行了正确的基准测试,我们在运行测试 UnmarshalDecode 性能的迭代之前重置计时器。我们在基准测试之前创建解码器,因为我们只需要创建一次解码器,它将包装在流式数据输入的读取器周围。但是因为一旦调用 Decode,我们需要将偏移量移动到下一次基准测试循环的起始位置。

我们在命令行中运行此命令来启动基准测试。

$ go test -bench=. -benchmem

这就是结果。

goos: darwin
goarch: amd64
pkg: github.com/sausheong/gocookbook/ch10_json
cpu: Intel(R) Core(TM) i7-7920HQ CPU @ 3.10GHz
BenchmarkUnmarshal-8   	  437274	  2494 ns/op     272 B/op    12 allocs/op
BenchmarkDecode-8      	  486051	  2368 ns/op      48 B/op     8 allocs/op
PASS
ok  	github.com/sausheong/gocookbook/ch10_json	6.242s

正如你所看到的,Decode 只快了一点点,每次操作花费 2258 纳秒(每操作纳秒),而 Unmarshal 则需花费 2418 纳秒。然而,Decode 每次操作只使用 48 B/op(每次操作字节),比 Unmarshal 的 272 B/op 要少得多。

5.4 从结构体创建 JSON 数据字节数组

问题

您希望从结构体创建 JSON 数据。

解决方案

创建结构体,然后使用 json.Marshal 包将数据编组成 JSON 字节数组。

讨论

创建 JSON 数据本质上是其解析的反向过程:

  1. 创建您将从中编组数据的结构体

  2. 使用 json.Marshaljson.MarshalIndent 将数据编组为 JSON 字符串

我们将重复使用前一个配方中相同的结构体来解析 JSON。我们还将使用用于从 Star Wars API 解析 JSON 数据的函数。

func main() {
	person := get("https://swapi.dev/api/people/14")

	data, err := json.Marshal(&person)
	if err != nil {
		log.Println("Cannot marshal person:", err)
	}
	err = os.WriteFile("han.json", data, 0644)
	if err != nil {
		log.Println("Cannot write to file", err)
	}
}

func get(url string) Person {
	r, err := http.Get(url)
	if err != nil {
		log.Println("Cannot get from URL", err)
	}
	defer r.Body.Close()

	data, err := os.ReadAll(r.Body)
	if err != nil {
		log.Println("Error reading json data:", err)
	}

	var person Person
	json.Unmarshal(data, &person)
	return person
}

get 函数返回一个 Person 结构体,我们可以用它来将数据编组到文件中。json.Marshal 函数将结构体中的数据转换为包含 JSON 字符串的字节数组 data。如果您只希望它作为字符串,可以将其转换为字符串并使用。在这里,我们将其传递给 os.WriteFile 来创建一个新的 JSON 文件。

{"name":"Han Solo","height":"180","mass":"80","hair_color":"brown",
"skin_color":"fair","eye_color":"brown","birth_year":"29BBY","gender":"male",
"homeworld":"https://swapi.dev/api/planets/22/","films":
["https://swapi.dev/api/films/1/","https://swapi.dev/api/films/2/",
"https://swapi.dev/api/films/3/"],"species":[],"vehicles":[],"starships":
["https://swapi.dev/api/starships/10/","https://swapi.dev/api/starships/22/"],
"created":"2014-12-10T16:49:14.582Z","edited":"2014-12-20T21:17:50.334Z",
"url":"https://swapi.dev/api/people/14/"}

这实际上并不是很可读。如果您想要一个更可读的版本,可以改用 json.MarshalIndent。您需要添加两个额外的参数,第一个是前缀,第二个是缩进。通常,如果您想要一个干净的 JSON 输出,前缀就是一个空字符串,而缩进则是一个空格。

data, err := json.MarshalIndent(&person, "", " ")

这将产生一个更可读的版本。

{
 "name": "Han Solo",
 "height": "180",
 "mass": "80",
 "hair_color": "brown",
 "skin_color": "fair",
 "eye_color": "brown",
 "birth_year": "29BBY",
 "gender": "male",
 "homeworld": "https://swapi.dev/api/planets/22/",
 "films": [
  "https://swapi.dev/api/films/1/",
  "https://swapi.dev/api/films/2/",
  "https://swapi.dev/api/films/3/"
 ],
 "species": [],
 "vehicles": [],
 "starships": [
  "https://swapi.dev/api/starships/10/",
  "https://swapi.dev/api/starships/22/"
 ],
 "created": "2014-12-10T16:49:14.582Z",
 "edited": "2014-12-20T21:17:50.334Z",
 "url": "https://swapi.dev/api/people/14/"
}

5.5 从结构体创建 JSON 数据流

问题

您想要从结构体创建流式 JSON 数据。

解决方案

encoding/json 包中使用 NewEncoder 创建一个编码器,传递一个 io.Writer。然后在编码器上调用 Encode 来将结构体数据编码到流中。

讨论

io.Writer 接口有一个 Write 方法,用于向底层数据流写入字节。我们使用 NewEncoder 创建一个围绕写入器的编码器。当我们在编码器上调用 Encode 时,它将将 JSON 结构写入写入器。

为了正确显示这一点,我们需要有一些 JSON 结构体。我将使用与之前相同的 Star Wars 人物 API 来创建这些结构体。

func get(url string) (person Person) {
	r, err := http.Get(r, err := http.Get("https://swapi.dev/api/people/" +
      strconv.Itoa(n)))
	if err != nil {
		log.Println("Cannot get from URL", err)
	}
	defer r.Body.Close()

	data, err := os.ReadAll(r.Body)
	if err != nil {
		log.Println("Error reading json data:", err)
	}

	json.Unmarshal(data, &person)
	return
}

get 函数将调用 API 并返回请求的 Person 结构体。接下来我们将需要使用这个 Person 结构体。

func main() {
	encoder := json.NewEncoder(os.Stdout)
	for i := 1; i < 4; i++ { // we're just retrieving 3 records
		person := get(i)
		encoder.Encode(person)
	}
}

正如您所看到的,我们将 os.Stdout 用作写入器。实际上,os.Stdout 是一个 os.File 结构体,但 File 也是一个写入器,所以这没问题。它的作用是一次将编码写入 os.Stdout。首先,我们使用 json.NewEncoder 创建一个编码器,将 os.Stdout 作为写入器传递。接下来,在循环中获取一个 Person 结构体,并将其传递给 Encode 以写入 os.Stdout

当你运行程序时,应该会看到类似这样的东西,但每个 JSON 编码将依次出现。

{"name":"Luke Skywalker","height":"172","mass":"77","hair_color":"blond",
"skin_color":"fair","eye_color":"blue","birth_year":"19BBY","gender":"male",
"homeworld":"https://swapi.dev/api/planets/1/","films":
["https://swapi.dev/api/films/1/","https://swapi.dev/api/films/2/",
"https://swapi.dev/api/films/3/","https://swapi.dev/api/films/6/"],
"species":[],"vehicles":["https://swapi.dev/api/vehicles/14/",
"https://swapi.dev/api/vehicles/30/"],"starships":
["https://swapi.dev/api/starships/12/","https://swapi.dev/api/starships/22/"],
"created":"2014-12-09T13:50:51.644Z","edited":"2014-12-20T21:17:56.891Z",
"url":"https://swapi.dev/api/people/1/"}
{"name":"C-3PO","height":"167","mass":"75","hair_color":"n/a","skin_color":
"gold","eye_color":"yellow","birth_year":"112BBY","gender":"n/a","homeworld":
"https://swapi.dev/api/planets/1/","films":["https://swapi.dev/api/films/1/",
"https://swapi.dev/api/films/2/","https://swapi.dev/api/films/3/",
"https://swapi.dev/api/films/4/","https://swapi.dev/api/films/5/",
"https://swapi.dev/api/films/6/"],"species":["https://swapi.dev/api/species/2/"],
"vehicles":[],"starships":[],"created":"2014-12-10T15:10:51.357Z","edited":
"2014-12-20T21:17:50.309Z","url":"https://swapi.dev/api/people/2/"}
{"name":"R2-D2","height":"96","mass":"32","hair_color":"n/a","skin_color":
"white, blue","eye_color":"red","birth_year":"33BBY","gender":"n/a",
"homeworld":"https://swapi.dev/api/planets/8/","films":
["https://swapi.dev/api/films/1/","https://swapi.dev/api/films/2/",
"https://swapi.dev/api/films/3/","https://swapi.dev/api/films/4/",
"https://swapi.dev/api/films/5/","https://swapi.dev/api/films/6/"],
"species":["https://swapi.dev/api/species/2/"],"vehicles":[],"starships":[],
"created":"2014-12-10T15:11:50.376Z","edited":"2014-12-20T21:17:50.311Z",
"url":"https://swapi.dev/api/people/3/"}

如果您对此处的凌乱输出感到恼火,并且想知道是否有类似于MarshalIndent的等效方法,答案是肯定的。只需像这样设置编码器,使用SetIndent,您就可以开始了。

encoder.SetIndent("", " ")

如果您想知道这与Marshal的区别是什么——使用Marshal您无法做到以上的操作。要使用Marshal,您需要将所有内容放入一个对象中,并一次性将其全部编组为 JSON — 您无法逐个流式传输 JSON 编码。

换句话说,如果有 JSON 结构数据传入,您不知道全部数据什么时候会完全传入,或者如果您想要先写出 JSON 编码,那么您需要使用Encode。只有当您拥有所有 JSON 数据时,才能使用Marshal

当然,Encode也比Marshal快。让我们再来看看一些基准测试。

var jsonBytes []byte = []byte(jsonString)
var person Person

func BenchmarkMarshal(b *testing.B) {
    json.Unmarshal(jsonBytes, &person)
    b.ResetTimer()
	for i := 0; i < b.N; i++ {
		data, _ := json.Marshal(person)
		io.Discard.Write(data)
	}
}

func BenchmarkEncoder(b *testing.B) {
	json.Unmarshal(jsonBytes, &person)
	b.ResetTimer()
    encoder := json.NewEncoder(io.Discard)
	for i := 0; i < b.N; i++ {
		encoder.Encode(person)
	}
}

在测试之前,我们需要准备好 JSON 结构,因此我在运行基准测试循环之前将数据解组为Person结构。我还使用io.Discard作为写入器。io.Discard是一个写入器,其所有写入调用都将成功,并且在这里使用是最方便的。

要对Marshal进行基准测试,我将Person结构编组为 JSON,然后将其写入io.Discard。要对Encode进行基准测试,我创建了一个编码器,它包裹在io.Discard周围,然后将Person结构编码到其中。与解码器的基准测试一样,我在迭代之前放置了编码器的创建,因为我们只需要创建一次。

这是基准测试的结果。

goos: darwin
goarch: amd64
pkg: github.com/sausheong/go-recipes/io/json
cpu: Intel(R) Core(TM) i7-7920HQ CPU @ 3.10GHz
BenchmarkMarshal-8   	 1983175     614.6 ns/op     288 B/op     2 allocs/op
BenchmarkEncoder-8   	 2284209     500.3 ns/op     128 B/op     1 allocs/op
PASS
ok  	github.com/sausheong/go-recipes/io/json	3.852s

与以前一样,Encode更快,而且内存使用更少,大约为 128 B/op,而Marshal则为 288 B/op。

5.6 在结构体中省略字段

问题

当将 JSON 结构编组为 JSON 编码时,有时候某些结构变量根本就没有数据。我们希望创建的 JSON 编码会省略掉那些没有任何数据的变量。

解决方案

使用omitempty标签来定义在编组时可以省略的结构变量。

讨论

让我们再来看看Person结构。

type Person struct {
	Name      string        `json:"name"`
	Height    string        `json:"height"`
	Mass      string        `json:"mass"`
	HairColor string        `json:"hair_color"`
	SkinColor string        `json:"skin_color"`
	EyeColor  string        `json:"eye_color"`
	BirthYear string        `json:"birth_year"`
	Gender    string        `json:"gender"`
	Homeworld string        `json:"homeworld"`
	Films     []string      `json:"films"`
	Species   []string      `json:"species"`
	Vehicles  []string      `json:"vehicles"`
	Starships []string      `json:"starships"`
	Created   time.Time     `json:"created"`
	Edited    time.Time     `json:"edited"`
	URL       string        `json:"url"`
}

您可能会注意到,当人物是人类时,API 没有指定物种,也有许多人物没有交通工具或星际飞船的标签。因此,当我们将结构编组时,它将作为空数组输出。

{
 "name": "Owen Lars",
 "height": "178",
 "mass": "120",
 "hair_color": "brown, grey",
 "skin_color": "light",
 "eye_color": "blue",
 "birth_year": "52BBY",
 "gender": "male",
 "homeworld": "https://swapi.dev/api/planets/1/",
 "films": [
  "https://swapi.dev/api/films/1/",
  "https://swapi.dev/api/films/5/",
  "https://swapi.dev/api/films/6/"
 ],
 "species": [],
 "vehicles": [],
 "starships": [],
 "created": "2014-12-10T15:52:14.024Z",
 "edited": "2014-12-20T21:17:50.317Z",
 "url": "https://swapi.dev/api/people/6/"
}

如果您不想显示物种、交通工具或星际飞船,您可以在 JSON 结构标签上使用omitempty标签。

Species   []string      `json:"species,omitempty"`
Vehicles  []string      `json:"vehicles,omitempty"`
Starships []string      `json:"starships,omitempty"`

当您再次运行相同的代码时,输出中将不再有它们。

{
 "name": "Owen Lars",
 "height": "178",
 "mass": "120",
 "hair_color": "brown, grey",
 "skin_color": "light",
 "eye_color": "blue",
 "birth_year": "52BBY",
 "gender": "male",
 "homeworld": "https://swapi.dev/api/planets/1/",
 "films": [
  "https://swapi.dev/api/films/1/",
  "https://swapi.dev/api/films/5/",
  "https://swapi.dev/api/films/6/"
 ],
 "created": "2014-12-10T15:52:14.024Z",
 "edited": "2014-12-20T21:17:50.317Z",
 "url": "https://swapi.dev/api/people/6/"
}

您为什么要这样做呢?在此 API 中,身高和体重是字符串。但是,如果它们是整数,并且您不知道它们的身高或体重,那么默认值将是 0。在这种情况下,这些值是错误的,有时可能是正确的(例如,绝地武士力量的幽灵既没有身高也没有体重),但您无法分辨哪个是正确的。在这种情况下,根本不显示它可能是更好的选择。

第六章:数据结构实例

6.0 简介

Go 语言有 4 种基本的数据结构 — 数组、切片、映射和结构体。我们单独有一章讨论结构体,所以我们将分开讨论它。在本章中,我们只讨论数组、切片和映射。我们将先讲解它们的背景信息,然后再深入讨论它们的具体用法。

数组

数组是表示相同类型元素的有序序列的数据结构。数组的大小是静态的,在定义数组时设置,之后无法更改。数组是值类型。这是一个重要的区别,因为在某些语言中,数组类似于指向数组中第一个项的指针。这意味着如果我们将数组传递给函数,我们将传递数组的副本,这可能会很昂贵。

切片

切片也是表示有序元素序列的数据结构。事实上,切片是建立在数组之上的,并且由于其灵活性,比数组更常用。切片没有固定的长度。在内部,切片是一个结构体,包含一个指向数组的指针,数组段的长度以及底层数组的容量。

映射

映射是一种将一个类型的值(称为)与另一个类型的值(称为)相关联的数据结构。这样的数据结构在许多其他编程语言中很常见,有不同的名称,如哈希表、哈希映射和字典。在内部,映射是一个指向runtime.hmap结构的指针。

理解这 3 种数据结构很重要,它们是所有其他数据结构的基础构建模块,但它们彼此之间也有根本性的不同。简而言之,数组是固定长度的有序列表,是值类型。切片是一个结构体,其第一个元素是指向数组的指针。映射是指向内部哈希映射结构的指针。

6.1 创建数组或切片

问题

你想要创建数组或切片。

解决方案

创建数组或切片的方法有很多,包括直接使用文字、从另一个数组创建,或使用make函数。

讨论

数组和切片在概念上有很大的差异,但在概念上它们非常相似。因此,创建数组和切片也非常相似。

定义数组

你可以通过在方括号中声明数组的大小,然后跟着元素的数据类型来定义数组。数组和切片只能包含相同类型的元素。你也可以在声明时用花括号初始化数组。

var numbers [10]int
fmt.Println(numbers)
rhyme := [4]string{"twinkle", "twinkle", "little", "star"}
fmt.Println(rhyme)

如果你运行上面的代码片段,你将会看到这个结果。

[0 0 0 0 0 0 0 0 0 0]
[twinkle twinkle little star]

int 或 float 数组的默认值为 0。请注意,一旦创建数组,数组的大小就不能再改变,但元素可以改变。

定义切片

切片是建立在数组之上的构造。大多数情况下,当需要处理有序列表时,通常会使用切片,因为它们更灵活,而且如果底层数组很大,使用起来也更便宜。

切片的定义方式完全相同,只是不提供切片的大小。

var integers []int
fmt.Println(integers)
var sheep = []string{"baa", "baa", "black", "sheep"}
fmt.Println(sheep)

如果你运行上述代码片段,你将看到这个。

[]
[baa baa black sheep]

我们还可以通过make函数创建切片。

var integers = make([]int, 10)
fmt.Println(integers)

如果使用make,需要提供类型、长度和可选的容量。如果不提供容量,默认为给定的长度。如果你运行上述片段,你将看到这个。

[0 0 0 0 0 0 0 0 0 0]

正如你所看到的,make也初始化了切片。

要找出数组或切片的长度,可以使用len函数。要找出数组或切片的容量,可以使用cap函数。

integers = make([]int, 10, 15)
fmt.Println(integers)
fmt.Println("length:", len(integers))
fmt.Println("capacity:", cap(integers))

上面的make函数分配了一个包含 15 个整数的数组,然后创建了一个长度为 10、容量为 15 的切片,指向数组的前 10 个元素。

如果你运行上述代码,这就是你会得到的结果。

[0 0 0 0 0 0 0 0 0 0]
length: 10
capacity: 15

我们还可以用new方法创建新的切片。

var ints *[]int = new([]int)
fmt.Println(ints)

new方法不直接返回切片,它只返回一个指向切片的指针。它也不初始化切片,而是将其置零。运行上述代码时,你会看到得到什么。

&[]

我们不能使用make函数创建新数组,但可以使用new函数创建新数组。

var ints *[10]int = new([10]int)
fmt.Println(ints)

我们得到的是一个指向数组的指针如下。

&[0 0 0 0 0 0 0 0 0 0]

6.2 访问数组或切片

问题

你想访问数组或切片中的元素。

解决方案

有几种方法可以访问数组或切片中的元素。数组和切片是有序列表,因此可以通过它们的索引访问元素。可以通过单个索引或索引范围访问元素。还可以通过迭代元素访问它们。

讨论

访问数组和切片几乎是相同的。由于它们是有序列表,我们可以通过索引访问数组或切片的元素。

numbers := []int{3, 14, 159, 26, 53, 59}

给定上面的切片,给定索引 3(从 0 开始),切片中的第 4 个元素是 25,并可以使用变量名,后跟方括号和方括号内的索引访问。

numbers[3] // 26

我们还可以通过使用起始索引,后跟冒号:和结束索引来访问一系列数字。结束索引不包括在内,这导致一个切片(当然)。

numbers[2:4] // [159, 26]

如果没有起始索引,切片将从 0 开始。

numbers[:4] // [3 14 159 26]

如果没有结束索引,切片将以原始切片(或数组)的最后一个元素结束。

numbers[2:] // [159 265 53 59]

毫无疑问,如果你没有起始索引或结束索引,将返回整个原始切片。虽然这听起来很愚蠢,但这确实有其有效用途——它简单地将一个数组转换为切片。

numbers := [6]int{3, 14, 159, 26, 53, 59} // an array
numbers[:] // this is a slice

我们还可以通过迭代数组或切片来访问数组或切片中的元素。

for i := 0; i < len(numbers); i++ {
    fmt.Println(numbers[i])
}

这使用了一个普通的for循环,迭代切片的长度,每次循环增加计数。得到的输出如下。

3
14
159
25
53
59

这使用了一个for …​ range循环,并返回索引i和值v

for i, v := range numbers {
    fmt.Printf("i: %d, v: %d\n", i, v)
}

得到的输出如下

i: 0, v: 3
i: 1, v: 14
i: 2, v: 159
i: 3, v: 25
i: 4, v: 53
i: 5, v: 59

6.3 修改数组或切片

问题

您想要在数组或切片中添加、插入或删除元素。

解决方案

有几种方法可以修改数组或切片中的元素。元素可以附加到切片的末尾,插入到特定索引位置,删除或修改。

讨论

除了访问数组或切片中的元素外,您还可能希望在切片中添加、修改或删除元素。虽然无法在数组中添加或删除元素,但您始终可以修改其元素。

numbers := []int{3, 14, 159, 26, 53, 58}
numbers[2] = 1000

当您修改给定索引处的元素时,它将相应地更改数组或切片。在这种情况下,当您运行代码时,将得到这个结果。

[3 14 1000 26 53 58 97]

附加

数组无法改变其大小,因此无法向数组附加或添加元素。但是向切片附加元素非常简单。我们只需使用append函数,将其传递给切片和新元素,我们将得到一个具有附加元素的新切片。

numbers := []int{3, 14, 159, 26, 53, 58}
numbers = append(numbers, 97)
fmt.Println(numbers)

如果您运行上述代码,您将得到这个结果。

[3 14 159 26 53 58 97]

您不能将不同类型的元素附加到切片中。但是您可以向切片附加多个项目。

numbers = append(numbers, 97, 932, 38, 4, 626)

这意味着您实际上可以使用切片解包符号…​将一个切片(或数组)附加到另一个切片中。

nums := []int{97, 932, 38, 4, 626}
numbers = append(numbers, nums...)

但是同时附加一个元素和一个解包的切片是不允许的。您只能选择附加多个元素或一个解包的切片,但不能同时进行。

numbers = append(numbers, 1, nums...) // this will produce an error

插入

虽然附加会向切片的末尾添加一个元素,但插入意味着在切片中的元素之间的任何位置添加一个元素。再次强调,这仅适用于切片,因为数组的大小是固定的。

append不同,插入没有内置函数,但我们仍然可以使用append来完成任务。假设我们想在索引 2 和 3 之间的元素之间插入数字 1000。

numbers := []int{3, 14, 159, 26, 53, 58}
numbers = append(numbers[:2+1], numbers[2:]...)
numbers[2] = 1000

首先,我们需要从原始切片的开头创建一个切片,到索引 2 加 1。这将为我们希望添加的新元素保留空间。接下来,我们将这个切片附加到另一个从索引 2 到原始切片末尾的切片中,使用解包符号。通过这种方式,我们在索引 2 和 3 之间创建了一个新元素。最后,我们在索引 2 处设置新元素 1000。

结果我们将得到这个新的切片。

[3 14 1000 159 26 53 58]

如果我们想要在切片的开头添加一个元素怎么办?假设我们想要将整数 2000 添加到切片的开头。这很简单,我们只需将值以切片的形式附加到原始切片的解包值中。

numbers = append([]int{2000}, numbers...)

这是在插入单个元素时的情况。如果我们想在另一个切片中间插入另一个切片怎么办?假设我们想在我们的numbers切片中间插入切片[]int{1000, 2000, 3000, 4000}

有几种方法可以做到这一点,但我们将坚持使用append,这是最简洁的方法之一。

numbers = []int{3, 14, 159, 26, 53, 58}
inserted := []int{1000, 2000, 3000, 4000}

tail := append([]int{}, numbers[2:]...)
numbers = append(numbers[:2], inserted...)
numbers = append(numbers, tail...)

fmt.Println(numbers)

首先,我们需要创建另一个切片,tail,来存储原始切片的部分。我们不能简单地对其进行切片并存储到另一个变量中(这称为浅复制),因为请记住——切片不是数组,它们是数组的一部分和其长度的指针。如果我们对numbers进行切片并存储到tail中,当我们改变numbers时,tail也会改变,这不是我们想要的。相反,我们希望通过将其附加到一个空的int切片来创建一个新的切片。

现在我们已经将tail放在一边,我们将numbers的头部附加到未打包的inserted中。最后,我们附加numbers(现在由原始切片的头部和inserted组成)和tail。这是我们应该得到的结果。

[3 14 1000 2000 3000 4000 159 26 53 58]

删除

从切片中删除元素非常容易。如果它位于切片的开头或结尾,您只需相应地重新切片即可删除切片的开头或结尾。

让我们先取出切片的第一个元素。

numbers := []int{3, 14, 159, 26, 53, 58}
numbers = numbers[1:] // remove element 0
fmt.Println(numbers)

当您运行上述代码时,您将得到这个结果。

[14 159 26 53 58]

现在让我们先取出切片的最后一个元素。

numbers := []int{3, 14, 159, 26, 53, 58}
numbers = numbers[:len(numbers)-1] // remove last element
fmt.Println(numbers)

当您运行上述代码时,您将得到这个结果。

[3 14 159 26 53]

中间删除元素也很简单。您只需将原始切片的头部与原始切片的尾部附加在一起,移除其中的任何内容。在这种情况下,我们想要删除索引为 2 的元素。

numbers := []int{3, 14, 159, 26, 53, 58}
numbers = append(numbers[:2], numbers[3:]...)
fmt.Println(numbers)

当我们运行上述代码时,我们得到了这个结果。

[3 14 26 53 58]

6.4 使数组和切片在并发使用时安全

问题

您希望通过多个 goroutine 安全地使用数组和切片。

解决方案

使用sync库中的互斥体(mutex)来保护数组或切片。在修改数组或切片之前对其进行锁定,并在修改完成后解锁。

讨论

数组和切片在并发使用时不安全。如果要在多个 goroutine 之间共享一个切片或数组,则需要使其免受竞争条件的影响。Go 语言提供了一个sync包,特别是Mutex

首先看一下竞争条件是如何产生的。竞争条件发生在多个 goroutine 试图同时访问共享资源时。

var shared []int = []int{1, 2, 3, 4, 5, 6}

// increase each element by 1
func increase(num int) {
	fmt.Printf("[+%d a] : %v\n", num, shared)
	for i := 0; i < len(shared); i++ {
		time.Sleep(20 * time.Microsecond)
		shared[i] = shared[i] + 1
	}
	fmt.Printf("[+%d b] : %v\n", num, shared)
}

// decrease each element by 1
func decrease(num int) {
	fmt.Printf("[-%d a] : %v\n", num, shared)
	for i := 0; i < len(shared); i++ {
		time.Sleep(10 * time.Microsecond)
		shared[i] = shared[i] - 1
	}
	fmt.Printf("[-%d b] : %v\n", num, shared)
}

在上面的示例中,我们有一个名为shared的整数切片,被两个名为increasedecrease的函数使用。这两个函数简单地逐个获取共享切片中的元素并分别增加或减少 1。但在增加或减少元素之前,我们等待了一个非常短的时间,其中increase函数等待的时间更长。这模拟了多个 goroutine 之间时间差异的情况。我们在修改共享元素之前打印出shared切片的状态,并在修改后再次打印出来以展示其状态变化。

我们从+main调用increasedecrease函数,并且每次函数调用都作为一个单独的 goroutine。程序结束时,我们稍等片刻以确保所有的 goroutine 都完成(否则所有的 goroutine 将在程序结束时结束)。

func main() {
	for i := 0; i < 5; i++ {
		go increase(i)
	}
	for i := 0; i < 5; i++ {
		go decrease(i)
	}
	time.Sleep(2 * time.Second)
}

当我们运行程序时,你会看到类似这样的输出。

[-4 a] : [1 2 3 4 5 6]
[-1 a] : [0 2 3 4 5 6]
[-2 a] : [0 1 3 4 5 6]
[-3 a] : [0 1 2 4 5 6]
[+0 a] : [-2 1 2 3 5 6]
[+1 a] : [-3 -1 2 3 4 6]
[-4 b] : [-2 -2 1 3 4 5]
[+3 a] : [-2 -2 0 3 4 5]
[+4 a] : [-1 -1 -1 1 4 5]
[-1 b] : [1 0 0 0 1 4]
[-2 b] : [1 0 0 0 1 3]
[-3 b] : [1 0 0 0 1 2]
[+2 a] : [1 0 0 0 1 2]
[-0 a] : [2 2 1 1 1 2]
[+0 b] : [1 2 3 2 1 3]
[-0 b] : [1 2 3 3 2 2]
[+1 b] : [1 2 3 4 4 3]
[+3 b] : [1 2 3 4 4 4]
[+4 b] : [1 2 3 4 4 5]
[+2 b] : [1 2 3 4 5 6]

如果多次运行它,每次的结果可能会略有不同。你会注意到即使我们按顺序启动 goroutine(每次将顺序号发送给modify),实际执行的顺序是随机的,这是预期的行为。但我们不希望看到的是 goroutine 彼此重叠,共享切片根据哪个 goroutine 先访问而递增或递减。

例如,如果我们查看输出的第一行[-4 a] : [1 2 3 4 5 6],会发现在调用减少每个元素的循环之前打印出来了。然后,在循环之后打印的行是[-4 b] : [-2 -2 1 3 4 5],可以看到前 3 个元素并不符合预期!

同样,你会意识到即使在增加或减少元素的循环内部,重叠也会发生。

如何防止这种竞争条件?Go 语言在标准库中提供了sync包,它为我们提供了mutex或互斥锁。

var shared []int = []int{1, 2, 3, 4, 5, 6}
var mutex sync.Mutex

// increase each element by 1
func increaseWithMutex(num int) {
	mutex.Lock()
	fmt.Printf("[+%d a] : %v\n", num, shared)
	for i := 0; i < len(shared); i++ {
		time.Sleep(20 * time.Microsecond)
		shared[i] = shared[i] + 1
	}
	fmt.Printf("[+%d b] : %v\n", num, shared)
	mutex.Unlock()
}

// decrease each element by 1
func decreaseWithMutex(num int) {
	mutex.Lock()
	fmt.Printf("[-%d a] : %v\n", num, shared)
	for i := 0; i < len(shared); i++ {
		time.Sleep(10 * time.Microsecond)
		shared[i] = shared[i] - 1
	}
	fmt.Printf("[-%d b] : %v\n", num, shared)
	mutex.Unlock()
}
}
Using it is quite simple. Firstly we need to declare a mutex. Then, we call +Lock+ on the mutex before we start modifying the shared slice. This will lock up the shared slice such that nothing else can use it. When we're done, we call +Unlock+ to unlock the mutex.

如果像以前一样从main中调用这些函数,这里是输出结果。

[-4 a] : [1 2 3 4 5 6]
[-4 b] : [0 1 2 3 4 5]
[+0 a] : [0 1 2 3 4 5]
[+0 b] : [1 2 3 4 5 6]
[+1 a] : [1 2 3 4 5 6]
[+1 b] : [2 3 4 5 6 7]
[+2 a] : [2 3 4 5 6 7]
[+2 b] : [3 4 5 6 7 8]
[+3 a] : [3 4 5 6 7 8]
[+3 b] : [4 5 6 7 8 9]
[+4 a] : [4 5 6 7 8 9]
[+4 b] : [5 6 7 8 9 10]
[-0 a] : [5 6 7 8 9 10]
[-0 b] : [4 5 6 7 8 9]
[-1 a] : [4 5 6 7 8 9]
[-1 b] : [3 4 5 6 7 8]
[-2 a] : [3 4 5 6 7 8]
[-2 b] : [2 3 4 5 6 7]
[-3 a] : [2 3 4 5 6 7]
[-3 b] : [1 2 3 4 5 6]

结果更加有条理。goroutine 不再重叠,元素的增加和减少有序而一致。

6.5 对切片数组进行排序

问题

你想要对数组或切片中的元素进行排序。

解决方案

对于intfloat64string数组或切片,你可以使用sort.Intssort.Float64ssort.Strings。你也可以通过使用sort.Slice来使用自定义比较器。对于结构体,你可以通过实现sort.Interface接口来创建一个可排序的接口,然后使用sort.Sort来对数组或切片进行排序。

讨论

数组和切片是有序元素序列。然而,这并不意味着它们以任何方式排序,只是表示元素始终以相同的顺序排列。要对数组或切片进行排序,我们可以使用sort包中的各种函数。

对于intfloat64string,我们可以使用相应的sort.Intssort.Float64ssort.Strings函数。

integers := []int{3, 14, 159, 26, 53}
floats := []float64{3.14, 1.41, 1.73, 2.72, 4.53}
strings := []string{"the", "quick", "brown", "fox", "jumped"}

sort.Ints(integers)
sort.Float64s(floats)
sort.Strings(strings)

fmt.Println(integers)
fmt.Println(floats)
fmt.Println(strings)

如果我们运行上述代码,这就是我们将看到的。

[3 14 26 53 159]
[1.41 1.73 2.72 3.14 4.53]
[brown fox jumped quick the]

这是按升序排序的。如果我们想要按降序排序怎么办?目前没有现成的函数可以按降序排序,但我们可以简单地使用一个 for 循环来反转排序后的切片。

for i := len(integers)/2 - 1; i >= 0; i-- {
    opp := len(integers) - 1 - i
    integers[i], integers[opp] = integers[opp], integers[i]
}

fmt.Println(integers)

我们简单地找到切片的中间部分,然后使用循环,从中间开始,将元素与它们的对侧交换。如果我们运行上面的片段,你将会得到这样的结果。

[159 53 26 14 3]

我们还可以使用 sort.Slice 函数,传入我们自己的 less 函数。

sort.Slice(floats, func(i, j int) bool {
    return floats[i] > floats[j]
})
fmt.Println(floats)

这将产生输出。

[4.53 3.14 2.72 1.73 1.41]

less 函数是 sort.Slice 函数的第二个参数,接受两个参数 ij,是切片连续元素的索引。它的作用是在排序时,如果 i 处的元素小于 j 处的元素则返回 true。

如果元素相同怎么办?使用 sort.Slice 意味着元素的顺序可能与它们的原始顺序相反(或保持不变)。如果希望顺序始终与原始顺序一致,可以使用 sort.SliceStable

sort.Slice 函数可以处理任何类型的切片,这意味着你也可以对自定义结构体进行排序。

people := []Person{
	{"Alice", 22},
	{"Bob", 18},
	{"Charlie", 23},
	{"Dave", 27},
	{"Eve", 31},
}
sort.Slice(people, func(i, j int) bool {
	return people[i].Age < people[j].Age
})
fmt.Println(people)

如果你运行上述代码,你将会看到下面的输出,people 切片按照人们的年龄排序。

[{Bob 18} {Alice 22} {Charlie 23} {Dave 27} {Eve 31}]

另一种排序结构体的方法是实现 sort.Interface。让我们看看如何为 Person 结构体做这个操作。

type Person struct {
	Name string
	Age  int
}

type ByAge []Person

func (a ByAge) Len() int           { return len(a) }
func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age }
func (a ByAge) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }

我们想要对结构体切片进行排序,所以我们需要将接口函数关联到切片,而不是结构体。我们创建一个名为 ByAge 的类型,它是 Person 结构体的切片。接下来,我们将 LenLessSwap 函数关联到 ByAge,使其成为一个实现了 sort.Interface 的结构体。这里的 Less 方法与我们在上面 sort.Slice 函数中使用的方法相同。

使用这个非常简单。我们将 people 强制转换为 ByAge,然后将其传入 sort.Sort

people := []Person{
	{"Alice", 22},
	{"Bob", 18},
	{"Charlie", 23},
	{"Dave", 27},
	{"Eve", 31},
}

sort.Sort(ByAge(people))
fmt.Println(people)

如果你运行上面的代码,你将会看到与下面相同的结果。

[{Bob 18} {Alice 22} {Charlie 23} {Dave 27} {Eve 31}]

实现 sort.Interface 有点冗长,但显然有一些优势。首先,我们可以使用 sort.Reverse 按降序排序。

sort.Sort(sort.Reverse(ByAge(people)))
fmt.Println(people)

这将产生以下输出。

[{Eve 31} {Dave 27} {Charlie 23} {Alice 22} {Bob 18}]

你还可以使用 sort.IsSorted 函数来检查切片是否已经排序。

sort.IsSorted(ByAge(people)) // true if it's sorted

最大的优势在于,使用 sort.Interface 比使用 sort.Slice 更高效。让我们做一个简单的基准测试。

func BenchmarkSortSlice(b *testing.B) {
	f := func(i, j int) bool {
		return people[i].Age < people[j].Age
	}
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		sort.Slice(people, f)
	}
}

func BenchmarkSortInterface(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sort.Sort(ByAge(people))
	}
}

这是基准测试的结果。

$ go test -bench=BenchmarkSort
goos: darwin
goarch: arm64
pkg: github.com/sausheong/gocookbook/ch14_data_structures
BenchmarkSortSlice-10     	 9376766	       108.9 ns/op
BenchmarkSortInterface-10  	26790697	        44.33 ns/op
PASS
ok  	github.com/sausheong/gocookbook/ch14_data_structures	2.901s

正如你所见,使用 sort.Interface 更有效率。这是因为 sort.Slice 使用 interface{} 作为第一个参数。这意味着它可以接受任何结构体但效率较低。

6.6 创建地图

问题

你想创建新的地图。

解决方案

使用 map 关键字声明它,然后使用 make 函数进行初始化。在使用之前必须先初始化地图。

讨论

要创建地图,我们可以使用 map 关键字。

var people map[string]int

上面的片段声明了一个名为people的映射,将一个字符串键映射到一个整数值。目前不能使用people映射,因为它的零值是 nil。要使用它,我们需要用make方法初始化它。

people = make(map[string]int)

如果你觉得在声明和初始化中都要重复使用map[string]int看起来有些傻,你应该考虑同时做这两件事。

people := make(map[string]int)

这将创建一个空映射。要填充映射,你可以将一个字符串映射到一个整数。

people["Alice"] = 22

你也可以用这种方式初始化映射。

people := map[string]int{
	"Alice":   22,
	"Bob":     18,
	"Charlie": 23,
	"Dave":    27,
	"Eve":     31,
}

如果你打印映射,它将如此显示。

map[Alice:22 Bob:18 Charlie:23 Dave:27 Eve:31]

6.7 访问映射

问题

你想要访问映射中的键和值。

解决方案

使用方括号内的键来访问映射中的值。你也可以使用for …​ range循环来遍历映射。

讨论

访问给定键的值是直截了当的。只需使用方括号内的键来访问值。

people := map[string]int{
	"Alice":   22,
	"Bob":     18,
	"Charlie": 23,
	"Dave":    27,
	"Eve":     31,
}

people["Alice"] // 22

如果键不存在会怎么样?什么都不会发生,Go 会简单地返回值类型的零值。在我们的例子中,整数的零值是 0,所以如果我们这样做。

people["Nemo"] // 0

它将简单地返回 0。这可能不是我们要找的(特别是如果 0 是一个有效的响应),因此有一个机制可以检查键是否存在。

age, ok := people["Nemo"]
if ok {
	// do whatever you want if the value exists
}

逗号, ok模式在许多情况下都很常见,也可以用来检查映射中是否存在键。使用方式很明显,如果键存在,ok就会变成 true,否则就是 false。尽管ok不是关键字,你可以使用任何变量名,它使用了多值赋值。尽管值仍然被返回,但由于你知道键不存在并且它只是一个零值,你可能不会使用它。

我们还可以使用for …​ range循环来遍历映射,就像我们处理数组和切片时所做的那样,不同之处在于,这里我们获取键和值。

for k, v := range people {
	fmt.Println(k, v)
}

运行上面的代码将给我们这个输出。

Alice 22
Bob 18
Charlie 23
Dave 27
Eve 31

如果只想要键,你可以省略从 range 中获取的第二个值。

for k := range people {
	fmt.Println(k)
}

你将得到这个输出。

Alice
Bob
Charlie
Dave
Eve

如果我们只想要值怎么办?没有特别的方法可以直接获取值,你必须使用相同的机制,并将它们放在一个切片中。

var values []int
for _, v := range people {
	values = append(values, v)
}
fmt.Println(values)

你将得到这个输出。

[22 18 23 27 31]

6.8 修改映射

问题

你想要修改或删除映射中的元素。

解决方案

使用delete函数从映射中删除键值对。要修改值,只需重新赋值。

讨论

修改值就是简单地覆盖现有的值。

people["Alice"] = 23

people["Alice"]的值将变成 23。

要删除键,Go 提供了一个名为delete的内置函数。

delete(people, "Alice")
fmt.Println(people)

这将是输出结果。

map[Bob:18 Charlie:23 Dave:27 Eve:31]

如果尝试删除一个不存在的键会发生什么?什么都不会发生。

6.9 排序映射

问题

你想要按键排序映射。

解决方案

将映射的键获取到一个切片中并对该切片进行排序。然后使用排序后的键切片,再次遍历映射。

讨论

映射是无序的。这意味着每次遍历映射时,键值对的顺序可能与上次不同。那么我们如何确保每次都是相同的顺序呢?

首先,我们将键提取到一个切片中。

var keys []string
for k := range people {
	keys = append(keys, k)
}

然后,我们根据需要对键进行排序。在这种情况下,我们要按照降序排序。

// sort keys by descending order
for i := len(keys)/2 - 1; i >= 0; i-- {
	opp := len(keys) - 1 - i
	keys[i], keys[opp] = keys[opp], keys[i]
}

最后,我们可以按键的降序访问映射。

for _, key := range keys {
	fmt.Println(key, people[key])
}

运行代码时,我们将看到这个结果。

Eve 31
Dave 27
Charlie 23
Bob 18
Alice 22

作者简介

张守祥在软件开发行业已超过 27 年,并参与了多个行业的软件产品构建,使用了多种技术。他是 Java、Ruby 社区的活跃成员,现在主要专注于 Go 语言,在全球各地的会议上组织和发表演讲。他还主办着 GopherCon 新加坡,这是东南亚最大的社区主导的开发者大会之一,自 2017 年以来一直如此。张守祥已经写了 4 本编程书籍,其中 3 本是关于 Ruby,最后一本是关于 Go 的。

posted @ 2024-06-18 18:05  绝不原创的飞龙  阅读(6)  评论(0编辑  收藏  举报