Golang - 包,变量与函数

Hello World

package main

import "fmt"

func main() {
	fmt.Println("Hello, 世界")
}

每个 Go 程序都由包构成。

程序从 main 包开始运行。

本程序通过导入路径 "fmt" 和 "math/rand" 来使用这两个包。

按照约定,包名与导入路径的最后一个元素一致。例如,"math/rand" 包中的源码均以 package rand 语句开始。

“导入路径”和“包名”的关系。

  • 导入路径 (Import Path):是你在 import 语句中写的那个字符串,是包的“地址”。例如 import "math/rand"
  • 包名 (Package Name):是你在代码中实际使用的那个名字,用来调用包里的函数。这个名字是由包的源文件第一行 package <包名> 决定的。

约定就是package 关键字后面声明的那个名字,应该和导入路径的最后一部分相同。

我们来看 "math/rand" 这个例子:

  1. 导入路径是 "math/rand"
  2. 这个路径的最后一个元素是 rand
  3. 因此,按照约定,math/rand 目录下的所有 .go 文件的第一行必须是 package rand
  4. 当你在自己的代码里使用这个包时,你用的也是这个包名,而不是整个路径。例如:rand.Intn(100),而不是 math/rand.Intn(100)
package main

import (
	"fmt"
	"math/rand"
)

func main() {
	fmt.Println("我最喜欢的数字是 ", rand.Intn(10))
}

用圆括号将导入的包分成一组,这是“分组”形式的导入语句。

当然你也可以编写多个导入语句,例如:

import "fmt"
import "math"

不过使用分组导入语句要更好。

public 和 private 的规则

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

pizza 和 pi 并未以大写字母开头,所以它们是未导出的。

在导入一个包时,你只能引用其中已导出的名字。 任何「未导出」的名字在该包外均无法访问。

核心思想:通过首字母大小写来决定可见性

  • 名字以大写字母开头 (e.g., MyVariableMyFunction) = 已导出 (Exported) = 公开的 (Public)
    • 可以在任何地方被访问,尤其是在导入了该包的其他包中。
  • 名字以小写字母开头 (e.g., myVariablemyFunction) = 未导出 (Unexported) = 私有的 (Private)
    • 只能在它自己所在的包内部被访问。其他包(即便是导入了它的包)也无法访问。
Go 语言 C++ 语言 含义
Name (首字母大写) public: 公开的:包外部可以访问。
name (首字母小写) private: 私有的:只能在包内部访问。
(无) protected: (Go语言没有 protected 的概念)

函数

接受参数

函数可接受零个或多个参数。当连续两个或多个函数的已命名形参类型相同时,除最后一个类型以外,其它都可以省略。

注意类型在变量名的 后面

package main

import "fmt"

func add(x int, y int) int {
	return x + y
}

func main() {
	fmt.Println(add(42, 13))
}

任意数量的返回值

Go中的函数可以返回任意数量的返回值。 `

package main

import "fmt"

func swap(x, y string) (string, string) {
	return y, x
}

func main() {
	a, b := swap("hello", "world")
	fmt.Println(a, b)
}

  • 签名即文档 (Signature is Documentation):看到函数签名 func (float64, error),你立刻就知道这个函数可能会失败,并且会返回一个错误信息。这比C++的异常机制要明确得多,因为异常在函数签名中是不可见的。
  • 错误是普通的值 (Errors are values):在Go中,错误(error类型)就是一个普通的值,你可以像处理任何其他值一样处理它(传递、存储、判断),而不是像异常那样需要特殊的 try-catch 语法块。这让错误处理的逻辑变得非常清晰和可控。
  • 鼓励显式处理错误result, err := ... 这种写法,迫使程序员必须处理 err 这个变量(否则编译器会报错“变量已声明但未使用”),从而大大减少了因忘记检查错误而导致的程序bug。

带名字的返回值

Go 的返回值可被命名,它们会被视作定义在函数顶部的变量。

返回值的命名应当能反应其含义,它可以作为文档使用。

没有参数的 return 语句会直接返回已命名的返回值,也就是「裸」返回值。

裸返回语句应当仅用在下面这样的短函数中。在长的函数中它们会影响代码的可读性。

  1. 标准(无名)的返回值

我们先写一个标准的函数,它接受一个整数 sum,然后把它拆分成两部分返回。

package main

import "fmt"

// 标准函数:返回值只有类型 (int, int),没有名字
func split(sum int) (int, int) {
	x := sum * 4 / 9
	y := sum - x
	// 必须显式地返回你想返回的变量
	return x, y
}

func main() {
	a, b := split(17)
	fmt.Println(a, b) // 输出: 7 10
}

这是我们之前讨论过的标准多返回值函数,非常清晰。


  1. 带名字的返回值

现在,我们用“带名字的返回值”来重写上面完全相同的函数。

package main

import "fmt"

// 带名字的返回值:在返回值类型前加上名字 (x int, y int)
func splitNamed(sum int) (x, y int) {
	// 这里的 x 和 y 就是我们刚刚在函数签名里命名的返回值变量
	x = sum * 4 / 9
	y = sum - x
	
	// 使用「裸」返回语句
	return
}

func main() {
	a, b := splitNamed(17)
	fmt.Println(a, b) // 输出: 7 10
}

a. “Go 的返回值可被命名,它们会被视作定义在函数顶部的变量。”

  • 意思就是:当你写下 func splitNamed(sum int) (x, y int) 时,Go编译器在函数内部帮你做了两件事:所以,你一进入 splitNamed 函数,就可以直接使用 x 和 y 这两个变量了,不需要再用 := 或 var 来声明它们。
    1. 它自动声明了两个变量,一个叫 x,一个叫 y,类型都是 int
    2. 它将这两个变量的初始值设为它们类型的“零值”(对于int就是0)。

b. “返回值的命名应当能反应其含义,它可以作为文档使用。”

  • 意思就是:命名可以增加代码的可读性。对比一下两个函数签名:第二个签名更清晰地告诉了阅读代码的人:这个函数返回的两个 int 分别被作者命名为了 x 和 y,这给了调用者一个关于返回值含义的提示。如果名字起得更有意义,比如 (quotient int, remainder int),那么文档效果会更好。
    • func split(sum int) (int, int)
    • func splitNamed(sum int) (x, y int)

c. “没有参数的 return 语句会直接返回已命名的返回值,也就是「裸」返回值。”

  • 意思就是:在 splitNamed 函数的末尾,我们只写了一个 return,后面没有跟任何变量。
  • 这个“裸”的 return 是一个快捷方式,它等价于 return x, y。也就是说,它会自动去寻找你在函数签名里命名的那些返回值变量,并把它们的当前值返回。

d. “裸返回语句应当仅用在下面这样的短函数中。在长的函数中它们会影响代码的可读性。”

  • 这是一个非常非常重要的编程建议。
  • 为什么会影响可读性? 想象一个很长的函数(比如50行),它有命名的返回值 (result int, err error)。在函数的第10行,你可能写了 result = 1;在第25行,你又写了 result = 2;在第40行,你写了 err = errors.New(...)。当读者看到函数末尾只有一个孤零零的 return 时,他必须把整个函数从头到尾再读一遍,才能确定 result 和 err的最终值到底是什么。
  • 显式返回更清晰:在长函数中,如果在最后明确地写 return result, err,代码的意图就会一目了然,大大提高了可维护性。

变量

var声明

var 语句用于声明一系列变量。和函数的参数列表一样,类型在最后。其可以出现在包或函数的层级。

package main

import "fmt"

var c, python, java bool

func main() {
	var i int
	fmt.Println(i, c, python, java)
}

如果初始化时提供了值,Go可以自动推导类型,此时可以省略类型声明。

// 编译器看到 10,自动推断 i 的类型是 int
var i = 10 

当你想一次性声明多个相关的变量时,可以使用 var 配合圆括号 ()

var (
    isActive bool   = false
    name     string = "admin"
    retry    int    = 3
)

短变量声明

在函数中,短赋值语句 := 可在隐式确定类型的 var 声明中使用。

函数外的每个语句都 必须 以关键字开始(varfunc 等),因此 := 结构不能在函数外使用。

基本类型

1. 布尔类型 (Boolean)

  • Go: bool
  • 值: true 和 false

与C++的关键区别: Go的bool类型和整型不能相互转换。在C++中,0可以当作false,非0可以当作true,但在Go中这是不允许的,会导致编译错误。

// 以下代码在 Go 中是错误的
// var i int = 1
// if i { ... } 

// 必须写成明确的比较
var i int = 1
if i == 1 { ... } 

这个设计大大增强了代码的类型安全性和清晰度。


2. 字符串类型 (String)

  • Go: string
  • 与C++的关键区别:
    1. 内置类型: 在Go中,string是像intbool一样的基础类型,而不是像C++的std::string那样的库类型。

不可变性 (Immutability): 这是最重要的区别。Go的字符串一旦被创建,其内容就不能被修改。任何对字符串的“修改”操作(如拼接)实际上都会创建一个新的字符串对象。

var s string = "hello"
// s[0] = 'H' // 这在Go中是编译错误!

s = "world"  // 这是合法的,因为你是让 s 指向一个“新”的字符串,而不是修改原来的 "hello"
s = s + ", Gopher!" // 拼接操作会创建一个全新的字符串,然后让 s 指向它

C++的std::string则是可变的。这种不可变性设计使得Go在并发场景下传递字符串变得非常安全,因为你不需要担心它在别处被意外修改。


3. 整型 (Integer)

  • Go: intuintint8...int64uint8...uint64uintptr
  • 与C++的相似之处: C++也有类似int8_tint16_t等固定宽度的整数(在<cstdint>头文件中),所以这个概念对你来说很熟悉。它们都用于需要精确控制内存布局或与外部系统交互的场景。
  • intuintuintptr 的平台依赖性:
    • 这一点与C++中的long或者size_t类似。它们的大小取决于你的目标编译平台是32位还是64位。
    • 最佳实践: 当需要一个整数值时应使用 int 类型。这是因为int被设计为在目标平台上处理效率最高的整数类型,非常适合用作数组/切片的索引、循环计数器等。只有当你明确需要一个特定大小(比如要写入二进制文件)或者需要处理超过int范围的无符号数时,才去选择int64uint32等。

4. 别名与特殊用途类型 (byte 和 rune)

这两个是Go为了增强代码可读性而设定的别名,非常重要。

  • byte // uint8 的别名
    • 用途: 它明确地告诉阅读代码的人,我们在这里处理的是原始的字节数据,而不是一个小的数字。例如,在读取文件、处理网络数据流时,你会用[]byte
    • C++类比: 概念上完全等同于unsigned char或者uint8_t,它们都代表一个字节。
  • rune // int32 的别名
    • 用途: 这是Go语言处理Unicode的基石。一个rune代表一个Unicode码点 (Code Point)。简单来说,它就代表一个字符,无论这个字符在UTF-8编码下占多少个字节。
    • C++类比rune在概念上等同于C++11引入的char32_t,都用于表示一个Unicode码点。Go将其内置并作为处理字符串的核心部分,使得国际化文本处理更加简单和安全。

为什么需要它?: Go的string内部是UTF-8编码的字节序列。在UTF-8中,一个英文字母(如'a')占1个字节,而一个中文字符(如'好')占3个字节。如果你按字节遍历字符串,就会把一个汉字拆开。rune就是为了解决这个问题。

s := "你好"
fmt.Println(len(s)) // 输出 6, 因为 "你好" 在UTF-8中占 6 个字节

// 使用 for...range 遍历字符串时,Go会自动按 rune 进行解码
for _, r := range s {
    fmt.Printf("%c ", r) // 输出: 你 好 
}

5. 浮点型与复数 (Floating-Point & Complex)

  • Go: float32float64 和 complex64complex128
  • 与C++的类比:
    • float32 对应 C++ 的 float
    • float64 对应 C++ 的 double。(在Go中,像3.14这样的浮点数字面量,默认类型是float64)。
    • complex64 (实部虚部都是float32) 对应 std::complex<float>
    • complex128 (实部虚部都是float64) 对应 std::complex<double>。 这部分的概念和用法与C++几乎完全一致。

零值

没有明确初始化的变量声明会被赋予对应类型的 零值

零值是:

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

类型转换

Go语言的设计者认为,任何可能导致数据丢失或改变数值含义的操作,都应该由程序员明确地写出来。编译器不会替你做任何“猜测”。所以Go中没有任何的隐式转换

语法: T(v)

Go的类型转换语法非常统一且简单:目标类型(值)

  • T 代表目标类型 (e.g., float64uintint32)。
  • v 代表你要转换的值 (e.g., if42)。

这种语法形式取代了C语言风格的 (T)v

类型推断

在声明一个变量而不指定其类型时(即使用不带类型的 := 语法 var = 表达式语法),变量的类型会通过右值推断出来。

当声明的右值确定了类型时,新变量的类型与其相同,不过当右边包含未指明类型的数值常量时,新变量的类型就可能是 intfloat64 或 complex128了,这取决于常量的精度。

在Go中,像 423.142 这样的常量字面量在代码中是“无类型的”。你可以把它们想象成存在于一个理想的数学世界里的、精度极高的“数字”,它们还没有被强制转换成计算机内存中某个特定大小的类型(比如 int32 或 float64)。只有当这个“无类型常量”被用于需要一个具体类型的上下文时(比如赋值给一个用 := 声明的变量),Go才会根据这个常量的形式,为新变量选择一个默认类型

当你在Go代码中写下一个数字(如 1003.141592653589793)时,编译器并不会立即把它“塞进”一个int32float64这样有大小限制的类型里。相反,编译器在内部用一种精度非常高的数据结构来表示这个数字,把它当作一个纯粹的、理想的数学值

这个高精度的值非常灵活,像“变色龙”一样。当需要它的时候,它会根据上下文(变量类型、函数签名等)“变成”那个类型,前提是它的值必须在目标类型的合法范围内。如果超出范围,编译器就会报错。也就是只有这个量被装进一个口袋的时候,才会真正检查是否合适。

因为这些常量是“无类型的”,所以它们可以灵活地用于任何能够容纳它的值的上下文中,而不需要进行显式类型转换

// const 关键字声明的常量,如果没有指定类型,也是“无类型”的
const BigNumber = 1000 
const SmallNumber = 1

func main() {
    // BigNumber 是一个无类型的整数常量
    var i64 int64 = BigNumber   // 合法!BigNumber可以被用作int64
    var f64 float64 = BigNumber // 合法!BigNumber也可以被用作float64
    var i8 int8 = SmallNumber   // 合法!SmallNumber可以被用作int8

    // 现在我们声明一个“有类型”的常量
    const TypedInt int = 1000

    // var another_i8 int8 = TypedInt // 编译错误!
    // 上面这行会失败,因为 TypedInt 的类型是 int,和 int8 是不同类型,
    // Go不允许在不同类型间隐式赋值,即使值1000可以被int8容纳(这里假设int是32位)。
    // 实际上1000也超出了int8的范围,但即使是100也同样会报错。
}

常量

常量的声明与变量类似,只不过使用 const 关键字。

常量可以是字符、字符串、布尔值或数值。

常量不能用 := 语法声明。

iota:Go特有的常量生成器

在 const 声明块中,有一个非常有用的特殊常量 iota,它可以被看作是常量计数器

  • iota 在每个 const 块的开始时被重置为 0
  • 在块中,每新增一行常量声明,iota 的值就会自动递增 1

这使得 iota 成为创建枚举 (enum) 或递增序列的最佳工具。

示例1:创建简单的枚举

const (
    Sunday = iota // iota = 0, Sunday = 0
    Monday        // 自动使用上一行的表达式,iota = 1, Monday = 1
    Tuesday       // iota = 2, Tuesday = 2
    Wednesday     // iota = 3, Wednesday = 3
    // ...
)

C++类比: 这和C++的 enum { Sunday, Monday, ... }; 效果几乎完全一样。

示例2:创建位掩码 (Bitmasks)

iota 强大的地方在于它可以参与表达式运算。

Go

const (
    FlagRead    = 1 << iota // 1 << 0  (值为 1)
    FlagWrite   = 1 << iota // 1 << 1  (值为 2)
    FlagExecute = 1 << iota // 1 << 2  (值为 4)
)