探索 Go 作为 C 家族开发者





5.00/5 (5投票s)
Go 语言及其作为 C 家族开发者的讨论
引言和背景
我花了十多年的时间来探索各种类 C 编程语言所能提供的功能,它们是如何工作的,以及如何让它们执行特定操作。有时,它们过于严格地基于面向对象的设计,有时它们是各种设计的混合体,有时指针和内存管理语言特性会让我印象深刻。最近,Go 已被广泛接受用于开发目的。我记得以前在一家专业公司工作时,我们曾经有一些 Go 编程语言的项目。我承认,我害怕改变,不想在新编程语言的学习阶段投入任何时间,因为它甚至没有一个支持框架。所以,总而言之,一个 Go 程序可以用来轻松地为你自己管理和维护一个后端脚本,至少在不久的将来,一个完整的解决方案是不可能完全用 Go 开发的。
在我看来,Go 语言有很多与其它语言所提供的功能非常相似之处。但它又构建了几个其它语言中不存在,或者被程序员忽略的语言构造。总而言之,我个人认为 Go 可以快速轻松地替代你的后台任务。它包含许多改进和语言提供的功能,例如内存管理、进程间通信、延迟函数、类型扩展等等。其中一些概念来自 JavaScript 等语言,而另一些概念则更多地是 Go 本身的内容。无论如何,Go 本身都是一种非常出色的语言,正如我们将要探讨的,它确实是许多应用程序或程序的后台任务的良好替代品。
在本文的结尾,我将分享一些关于 Go 编程的更多想法,并提供一些资源,帮助你快速学习如何在 Go 程序中执行特定操作,例如网络通信、资源和数据交换。
环境设置
另外,还有一点,你们可以轻松地在在线编译器中试用 Go 编程语言,但我将使用安装了 Go 扩展的 Visual Studio Code——在我看来,用 Visual Studio Code 体验 Go 语言是最好的方式。在线编辑器可以在 https://play.golang.org/ 找到,你可以轻松地将文章中提供的大部分代码复制粘贴到编辑器中并运行。它们应该(应该!)能够按预期工作。
然而,使用 Visual Studio Code 可以获得更好的体验,并支持 IntelliSense 和代码补全。要实现这一点,你首先需要从 Go 官方网站 安装 Go 编译器和其他二进制文件,完成之后,请配置 Go 语言的环境变量,以便在系统中随时使用 `go run`、`go build` 等命令。我们将在 Visual Studio Code 中使用的扩展也需要这个。请查看以下链接并进行相应的设置。
Go 作为一门编程语言
Go,本质上,与 C 一样,是一种通用编程语言。大多数特性和构造都类似于 C 所提供的。而在许多情况下,你会发现 C 语言的大部分概念在 Go 语言开发中得到了改进,或者根本被忽略甚至移除。我可能会在只需要执行特定后台操作的场景中使用 Go 语言。但这并不妨碍该语言被用于完整的 Web 服务器、数据预处理器和加密辅助工具。正如我之前提到的,Go 是一种通用编程语言,它可以被增强用于任何其他目的,谁知道 Google 是否会投入时间,使其成为一种机器学习就绪的语言,因为它简洁、精炼且自然的编程结构,你可以去查看 Go vs Python3 性能测试。但是,对于那些想要一行“hello world”程序的人来说,Go 不适合你!而且,我认为 Go 绝对不是一门非常偏学术的编程语言,至少对于初学者来说是这样。
我将以一段用 Go 编写的不错的“hello world”程序开始本文
package main
import "fmt"
func main() { // This is more equivalent to void main()
fmt.Println("Hello, world!")
}
是的,至于一行代码,这个程序甚至更长——有些人可能会用“自解释”这个词来形容——与类似的 C 程序相比
#include <stdio.h>
int main() {
printf("Hello, world!");
}
但请记住,所有这些冗长都有原因,其中许多内容是为了解释程序的整体构造。Ken 在两种语言中保持一致的一点是 `main` 函数。程序需要一个 main 函数才能执行。但 Ken 在这门语言的设计上更进一步,它现在要求我们手动声明包,并且只有 `main` 包才能成为程序的入口点。所以,这就像一种更冗长的方式来告诉编译器,哪个文件是起始点,然后你的程序就可以继续执行了。
你还会注意到分号消失了。不,Python 程序员们,请忍住,分号是存在的,只是不需要手动输入。它们稍后由编译器添加,这是 Go 中最美好的事情之一;继续阅读,你就会明白为什么我喜欢这个特性。
类型系统和内置类型
Go 语言自带了多种内置类型,并且 Go 允许你轻松地扩展一个类型并创建自己的类型,你甚至可以像在 C 或 C++ 中那样为类型取别名。Go 提供了以下类型,可直接使用
类型名称 | 大小 | 描述 |
布尔值 | 1 字节 | 基本的布尔类型用于表达式、条件和循环。 |
字符串 | 大小可变 | Go 语言支持高达 UTF-32 的 Unicode 数据点。 |
有符号整数 | 1 - 8 字节 | 有符号整数范围高达 64 位数据。 |
无符号整数 | 1 - 8 字节 | 无符号整数也用于指针类型值,以及存储无符号数字。 |
字节数据类型 | 1 字节(`uint8` 的别名) | 字节是 Go 语言中最广泛使用的数据类型,在写入器、HTTP 库和数据交换格式解析器中。 |
浮点值 | 4 - 8 字节 | 看来 Go 没有偏好将 float-64 称为 double 数据类型。所以,它们有 `float32` 和 `float64`。 |
复数 | 8 - 16 字节 | 数字即将变得真实!Go 支持 64 位和 128 位大小的复数。 |
这段来自Go 源代码的代码示例演示了它们的指定方式
var basicSizes = [...]byte{
Bool: 1,
Int8: 1,
Int16: 2,
Int32: 4,
Int64: 8,
Uint8: 1,
Uint16: 2,
Uint32: 4,
Uint64: 8,
Float32: 4,
Float64: 8,
Complex64: 8,
Complex128: 16,
}
存在像 `int`、`uint` 这样的类型,它们比有固定大小的对应类型更通用和更具体。在 32 位系统上,它们保证有 32 位大小,在 64 位系统上则为 64 位。语言开发者建议使用这些类型而不是其有固定大小的对应类型,除非你确实有理由使用其有固定大小的对应类型。请参阅此链接以获取更多关于 Go 类型系统的探索和理解。
这些类型的好处在于它们提供了良好的互操作性和所有类型之间的转换,因此你可以轻松地这样做
bytes := []bytes("Afzaal Ahmad Zeeshan")
与其他语言不同,这更清晰易懂,也更容易编写和记忆。
在 Go 中,我们没有类。唯一可用于创建自定义类型的类型系统是 `struct` 类型,我们可以为类型创建自己的契约,即 `interfaces`。让我们一一来看。
Go 语言中的 struct
在 Go 语言中,struct 遵循与 C 相同的规则,甚至不是 C++,当然也不是 C#。你只能包含结构体的字段,并定义它所拥有的属性。你不能在结构体中添加方法。阅读 Go 的 C 结构体,你可以看到,它的根源定义在 C 语言本身。一个基本的 Go 结构体如下所示
type Person struct {
name string
age int
}
让我们逐一分解。与 C 和许多其他 C 派生语言不同,在 Go 中,事物的读取不仅是从右到左,关键字也是从右到左;换句话说,这是一种纯粹的 LTR 语言,在书写、阅读和理解方面。所以,让我们尝试阅读上面的代码,请加入我。
type Person is a structure {
name is a string
age is an integer
}
如果这在阅读上不太有意义,我们将看看类似情况的其他例子。现在我们可以着手创建一些变量并理解它们的工作原理。将我上面提供的代码写进 Go 编译器并尝试编译。你将在这里学到几件事
- Go 没有显式的访问修饰符。
- 大多数 Go 代码检查和审核工具会给你一个警告,即导出的类型必须有注释。
这些是 Go 程序轻松控制类型访问方式的方法。在 Go 中,每个首字母大写的类型都是公共的,其他都是私有的;即使单词的其余部分是大写的。因此,有效的程序要么将类型小写,所有内容都是包私有的。否则,可以添加一个好的注释。
// Person is a custom structure defined to explain Go language.
type Person struct {
name string
age int
}
你还发现了其他需要注意的地方吗?去吧,在进入下一节函数之前,我给你一点时间思考。
变量、参数和其他类型的字段初始化大多是自动进行的,除非你确实需要创建类型并将其零值初始化。
func main () {
var person Person
person.name = "Afzaal Ahmad Zeeshan"
age = 23
}
这就像任何其他编程语言中的任何其他结构体一样工作。这里要学习的主要概念是,你不能将一个 `struct` 嵌入到它自身中,但你可以轻松地将该类型的指针嵌入到它自身中。我们将在后面的章节中探讨指针。在结束之前,我想展示的最后一件事是类型别名
type Name string // Type name is a string
type People []Person // Type people is an array of type person
type Port int // type port is an integer
现在感觉如何?读起来不直观吗?这相当于你在 C 或 C++ 中会做的事情,例如
typedef string Name;
typedef Person People[]; // You get it
typedef int Port;
其余的在 C 家族的所有语言中都基本相同。
Go 语言中的接口类型
这对于 C 程序员来说是新的,对于 C++ 程序员来说是一个特殊的关键字。我们很早就有了 C# 和 Java 中的这个概念。这个概念是创建契约类型,这些类型本质上保证了它们具有某种特定的行为。在 Go 中,该语言基本上遵循 JavaScript 等语言的相同实践,要实现一个接口,只需包含其中的函数即可,无需额外的实现运算符。虽然很简单。例如,下面的代码
type Works interface {
work() error
}
这是一个接口,需要由必须充当 `Works` 类型实现的类型来实现。现在,实现过程需要对 Go 语言中的函数如何工作有更多的理解。例如,如果我们有一个函数,它接受一个 worker 实例,并让它做一些工作,我们就不能将 person 传递给函数
func giveWork(work Works) {
// code
}
如果我们传递 person 类型,它会报错,说类型在函数体中没有 `work()` 函数。这个错误指定我们的实际类型不满足作为参数的要求。
所以,这就是我们在下一节要做的,让我们自己去看看。
Go 中的函数
就像结构体设计相似一样,Go 语言中的函数在性质和行为上也相当相似。你在 Go 中拥有的是
func name (params) returns {
// code ...
}
你必须从我们之前的代码块中记住,我们是如何创建 main 函数的,我们使用了 `func` 关键字。这个关键字创建函数,后面的部分定义了函数的名称、参数列表和返回类型。让我们尝试创建一个基本的函数,它接受 person,并打印这个人的名字。
func printname(person Person) {
fmt.Print(person.name)
}
这是我认为解释分号整体概念很重要的地方。在 Go 语言中,分号在你代码编译之前被添加,所以你可以省略它们,但不要认为它们不重要。为了验证这一点,请更改函数,将大括号移到下一行。
func printname(person Person)
{
fmt.Print(person.name)
}
这样,Go 会在 `Person)` 之后添加分号,这会导致编译过程失败。因此,必须将 `{` 大括号放在同一行,这就是我之前提到我喜欢 Go 在语言本身中强制执行某些代码风格的原因。这样,你所有的代码都将遵循相同的括号表示法和样式约定。如果你的某个工程师不遵循,他们的代码将无法构建。我们还可以从 Go 语言函数中返回错误,它们以与函数成功时返回的相同变量值方式返回。
func printname (person Person) error {
if person.name == "" {
return errors.New("Name is empty")
}
fmt.Println(person.name)
}
在 Go 中,我们可以从函数中返回多个值。甚至更好的是,这些值可以被代码赋值,然后可以采取下一步操作来从函数返回。
func namelength (person Person) (count int, problem error) {
if person.name == "" {
return 0, errors.New("Name is empty")
}
return len(person.name), nil
}
这几乎就是创建元组的方式。而且,Go 在这方面更进一步,它允许你单独为这些变量赋值。上面的函数可以在 Go 中重写为
func namelength (person Person) (count int, problem error) {
if person.name == "" {
count = 0
problem = errors.New ("Name is empty")
} else {
count = len(person.name)
problem = nil
}
return
}
你们中的许多人在 C++ 或 C# 语言工作过,都会知道这种行为在其他语言中是相当常见的,并且被称为“**引用传递**”。
void namelength (Person person, ref int count, ref Exception problem) {
if(string.IsEmptyOrNull(person.name)) {
count = 0;
problem = new Exception ("Name is empty");
} else {
count = person.name.Count;
problem = null; // Redundant call
}
}
void namelength (Person person, int& count, std::exception& problem) {
if(person.name == "") {
count = 0;
problem = std::exception("Name is empty");
} else {
count = person.name.size();
// problem = null; // references cannot be null in C++
}
}
到目前为止,我们已经探讨了函数在 Go 语言中的行为,现在让我们来看看方法。我们已经看到 Go 语言不包含类,这意味着没有面向对象。至少不是其他语言实现和支持的方式。在 Go 中,你必须在类型声明之后——而不是在方法体内部——将函数附加到类型上。函数附加大致如下
func (type) name (params) returns {
// code
}
这意味着函数需要知道它们可以操作的变量的类型,然后 Go 语言可以将这些类型和变量注入到函数的作用域中。所以,让我们重写我们已经使用了几个部分的 `namelength` 函数。
func (person Person) namelength() (count int, problem error) {
if person.name == "" {
count = 0
problem = errors.New ("Name is empty")
} else {
count = len(person.name)
problem = nil
}
return
}
可以轻松地在类型上调用它来获取其值,例如
person := Person { name: "Afzaal Ahmad Zeeshan", age: 23 }
count, problem = person.namelength()
// other code
所以这在某种程度上像面向对象代码一样工作,你的类型现在拥有数据,并且可以执行某些操作并包含状态。正如这里所见的,这是 Go 语言的一个非常有用的特性。因此,这现在帮助我们解决了接口实现的问题,现在如果你还记得,我们看到一个类型要被认为实现了 Go 中的接口,那么它就必须包含一个签名相同的函数。所以,我们可以继续实现我们的 `Works` 接口
func (person Person) work() error {
// Now person works
}
这样,现在我们可以在程序需要 `Works` 接口类型的任何地方使用 `Person` 类型。这比听起来要简单得多,你所要做的就是为类型提供函数,然后就可以了。现在,如果我们把 person 传递给上面的函数,它就能工作,因为 person 类型不包含具有相同签名的 `work` 函数。
现在还有一件事需要讨论,那就是我们如何从函数中反映出对变量本身的更改。如果你执行上面的代码,并尝试修改一个字段,你会发现实际类型保持不变。我们如何克服这个问题?请阅读后面的“**Go 和指针**”部分,你就会找到答案。:)
Go 和指针
关于 Go 语言中的指针,有一点需要说明。它们与 C 语言中的指针相同,但比过去更加受控。Go 指针用于创建内存地址的引用,并对该内存地址进行更改、读取、更新,而不是在程序中传递变量进出函数。这使得程序看起来混乱不堪,难以理解。指针通过授予对同一内存地址的访问权限来解决这个问题,并使开发人员能够在不传递或从函数中获取任何内容的情况下更新同一变量。
关于 Go 指针需要记住的一些要点是,它们在你退出函数时不会立即消失,这样做会得以保留
func getperson (pName string) *Person {
return &Person{ name: pName }
}
与大多数其他语言不同,这些类型会在作用域消失时立即死亡。指针的大小足以容纳一个地址(32 位系统、64 位系统...),并且它们不支持算术运算。这种行为的原因是它们不是数组。与你在 C 中被告知的关于数组不同,它们是指针,并且通过索引来检查下一个地址。在 Go 中,这不会发生,Go 中的数组也是值类型,而不是引用类型,但我们可以留到以后讨论。然而,你仍然可以使用 `*` 访问指针的实际数据,甚至可以使用 `*` 运算符将值赋给被指向的变量,请参阅此示例链接 https://tour.golang.org/moretypes/1。
将某物传递给函数,会为该对象传递一个指针类型。在 Go 中传递指针和接收指针是安全的,不涉及泄漏。实际上,会创建一个新类型并将其传递给下一个作用域。
func main () {
gottenPerson := getPerson()
fmt.Printf("gottenPerson exists at address %x\n", &gottenPerson)
}
func getPerson() Person {
p := Person{}
fmt.Printf("p exists at address %x\n", &p)
return &p
}
// Output
p exists at address &{61667a61616c 0}
gottenPerson exists at address c042004030
所以,它们不是对第二个函数中创建的同一个对象的引用(第一个是合子类型,第二个是分配的变量)。但是,这是一种安全地共享同一个对象并在作用域之外反映更改的方法,不像值类型那样。
在此,我们引入另外两个重要且有趣的运算符,Go 语言中的 `make` 和 `new`。这些运算符用于为类型分配内存,并返回它们的**指针**(对于 `new`)或变量(对于 `make`)。它们在语言中都有自己的用途。`make` 运算符用于初始化引用类型,例如 `slice`、`maps` 等。我们将在自己的帖子中介绍它们。让我们快速看一下 `new` 关键字,看看它与该程序行为有何不同,以及它如何帮助我们理解指针的工作原理。
func getPerson() Person {
p := new(Person) // returns *Person
fmt.Printf("p exists at address %x\n", &p)
return p
}
函数现在稍有不同,我们得到一个类型指针——这与 C++ 中使用 `new` 或 C 中使用 `malloc` 获得的东西非常相似。这还使我们改变了返回类型,从 `&p` 改为 `p` 本身,因为这是我们函数的返回类型。获取 `p` 的地址将返回存储我们指针的地址,而不是存储对象的地址。函数的其余概念与它们在其他语言中的概念相同,传递指针并修改它,最终会导致实际值的更改。例如
func (person Person) rename (newName string) {
person.name = newName
}
这不会对我们 `Person` 类型的实际实例产生任何影响,原因是它被作为值类型传递到函数中。为了克服这种情况,我们需要将其作为指针传递
func (person *Person) rename (newName string) {
person.name = newName
}
这也会改变实例数据,现在我们的结构体将包含更新后的姓名。这非常有用,而且你可以看到你不需要修改任何东西,不像 C 或 C++,Go 不需要你使用 `->` 等其他运算符来访问指针中的数据。指针中的数据访问方式与访问普通变量相同。
Go 中的恐慌恢复
Go 中的异常处理遵循与 C 语言相同的模式。Go 语言中没有 `try...catch` 块,你引发一个错误,并在其他过程中处理它,通过检查外部模块或你自己的模块中设置的内容。你一定已经看到我们是如何根据提供的参数尝试从函数返回错误的。在你的函数无法继续的情况下,你需要调用 `panic`,并传递指示问题的数值。例如,想象一下你正在尝试下载 `Person` 类型的数据,但是网络不可用,那么你必须恐慌,没有办法从这个错误中恢复,加载文件数据也是一样,要么找不到文件,要么文件无法创建/访问。
func namelength(buffer NetworkBuffer) (count int, problem error) {
// Just imagine, please.
if buffer == nil {
panic("Network buffer is nil.")
}
var person Person
person.name = buffer.Readstring()
return len(person.name), nil
}
恐慌会开始关闭你的程序堆栈,直到没有模块为止。在那些函数和模块中,你可以随时解析。你在 Go 程序中使用 `recover` 函数调用,它将帮助你获取引发的问题,你可以尝试从中恢复,如果不行,则继续进行。做到这一点的方法是使用 `defer` 函数,这些函数在包含它们的函数完成执行后被调用。例如
func crashingfunc () {
defer func () {
problem := recover()
if problem != nil {
fmt.Println(problem)
}
}()
panic("Calling panic function call to be caught in deferred function")
}
这是你可以执行的后执行操作的方式,例如异常跟踪、关闭你当前正在访问的任何资源。延迟函数总是被调用,可以将它们视为 `try...finally` 调用,其中 `finally` 块即使代码崩溃也总是会被调用。
剩余主题...
Go 还有许多其他应该涵盖的主题,例如并发、包、网络、数据交换等等,这些都需要在 Go 语言中讨论,并且需要单独发布。我计划就此主题撰写更多帖子,然后继续讨论更高级的概念,例如 Go 程序的容器化,或者如何对数据执行加密操作。
在此之前,请考虑查看我随本文提供的代码,并进行探索,创建你自己的类型别名,创建函数,使用指针和非指针类型进行操作。有一件事我应该问你,尝试探索数组,并看看数组在哪些方面会失败。
历史
- 2018 年 11 月 28 日:初始版本