首页 Go的入门与程序结构
文章
取消

Go的入门与程序结构

内置名称

GO中函数、变量、常量、类型、语句标签和包的名称遵循一个简单的规则:名称的开头是一个字母(Unicode中的字符即可)或下划线,后面可以跟任意数量的字符、数字和下划线,并区分大小写。

内置关键字

GO有25个像ifswitch这样的关键字,只能用在语法允许的地方,它们不能作为名称。

break       default     func    interface   select
case        defer       go      map         struct
chan        else        goto    package     switch
const       fallthrough if      range       type
continue    for         import  return      var

另外,还有三十几个内置的预声明的常量、类型和函数:

常量:true  false   iota    nil

类型:int   int8    int16   int32   int64
uint    uint8   uint16      uint32  uint64  uintptr
float32 float64 complex128  complex64
bool    byte    rune        string  error

函数:make  len     cap     new     append  copy    close
delete  complex     real    imag    panic   recover

变量声明与赋值

如果一个实体在函数中声明,它只在函数局部有效。如果声明在函数外,它将对包里面的所有源文件可见。

实体第一个字母的大小写决定其可见性是否跨包。如果名称以大写字母的开头,它是导出的,意味着它对包外是可见和可访问的,可以被自己包之外的其他程序所引用,像fmt包中的Printf,包名本身总是由小写字母组成。

Go语言中,变量、类型、函数和其他标识符的可见性由其名称前缀决定。有三种可见性级别:

  • 包级:以大写字母开头的标识符对包中的所有文件可见。
  • 文件级:以小写字母开头的标识符仅对当前文件可见。
  • 块级:在花括号{}内声明的标识符仅对该块可见。

书中推荐驼峰式的命名风格;

针对声明的习惯写法,给出下面几个例子:

1
2
3
4
s := ""         // 合法,声明并初始化,不需要显式指定s的类型
var s string    // 合法,显式声明类型,但同时也已进行了默认初始化
var s = ""      // 合法,声明变量并初始化,不需要显式指定s的类型
var s string = ""   // 合法,显式声明类型,同时也进行了初始化

都是合法的声明和初始化,但是有一个问题我们需要考虑到的是,应该要在何种情况下使用何种方式:

  • 第一种方式确实简洁,但为了代码维护性的考虑,会建议仅在函数内部使用;
  • 第二种方式就是一个默认的初始化;
  • 第三种方式用得少,但当我们声明多个变量时会广泛使用;
  • 第四种方式在类型一致的情况下使用就显得冗余了,但是在类型不一致的情况下就比较有用;

一般会建议使用第一第二两种方式,第一种方式是显式声明的方式,告诉我们该变量初始化的必要性,第二种方式则通过隐式初始化告知我们初始化的不必要性;

完整的声明形式:

var name type = expression

类型和表达式部分可以省略一个,但是不能都省略。

  • 如果类型省略,它的类型将由初始化表达式决定;
  • 如果表达式省略,其初始值对应于类型的零值;
    • 对于数字是0,对于布尔值是false,对于字符串是""
    • 对于接口和引用类型(slice、指针、map、通道、函数)是nil
    • 对于一个像数组或结构体这样的复合类型,零值是其所有元素或成员的零值。

零值机制保障所有的变量是良好定义的,Go里面不存在未初始化变量。这种机制简化了代码,并且不需要额外工作就能感知边界条件的行为。

可以声明一个变量列表,并选择使用对应的表达式列表对其初始化。忽略类型允许声明多个不同类型的变量。

1
2
var i, j, k int     // int, int, int
var b, f, s true, 2.3, "four"   // bool, float64, string

初始值设定可以是字面量值或者任意的表达式。包级别的初始化在main开始之前进行,局部变量初始化和声明一样在函数执行期间进行。

变量可以通过调用返回多个值的函数进行初始化:

1
varf, err = os.Open(name)   // os.Open返回一个文件和一个错误

来一个完整的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

const boilingF 212.0    // 声明常量,包级别声明(不是小写吗)
func main() {
	var f boilingF      // 声明且初始化?且是局部声明
	var c = (f - 32) * 5 / 9
	fmt.Printf("boiling point = %g°F or %g°C\n", f, c)
    // 输出:
    // boi1ing point = 212°F or 100°C
}

// var声明
var names []string  // names为一字符串数组
var err error       // error为错误类型
var p Point         // Point为点类型

短变量声明

在函数中,一种称作短变量声明的可选形式可以用来声明和初始化局部变量。它使用

name:expression

的形式,name的类型由expression的类型决定。

1
2
3
anim := gif.GIF{LoopCount: nframes}
freq := rand.Float64() * 3.0
t = 0.0

因其短小、灵活,故而在局部变量的声明和初始化中主要使用短声明。var声明通常是为那些跟初始化表达式类型不一致的局部变量保留的,或者用于后面才对变量赋值以及变量初始值不重要的情况。

短变量声明最少声明一个新变量,否则,代码编译将无法通过:

1
2
3
4
in, err := os.Open(infile)
out, err := os.Create(outfile)  // out是一个新变量,所以这里编译不会报错

in, err = os.Create(outfile)    // 如果全是声明过的变量,则需要用赋值写法

指针声明

Go里面同样存在着指针的概念,类比C语言理解;

1
2
3
4
5
x := 1
p := &x     // p是整型指针,指向x
fmt.Println(*p) // "1"
*p = 2      // 等价于x = 2,间接改变值
fmt.Println(x)  // 即可测试

每一个聚合类型变量的组成(结构体的成员或数组中的元素)都是变量,所以也有一个地址。

变量有时候使用一个地址化的值。代表变量的表达式,是唯一可以应用取地址操作符&的表达式。

指针类型的零值是nil。测试p!=nil,结果是true说明p指向一个变量。

指针是可比较的,两个指针当且仅当指向同一个变量或者两者都是ni1的情况下才相等。

1
2
var x, y int    // 声明两个整型变量
fmt.Println(&x == &x, &x == &y,&x == nil)   // "true false false"

一个与C/CPP比较不同的特点:

  • 函数返回局部变量的地址是安全的(涉及Go的逃逸分析);
    1
    2
    3
    4
    5
    
      var p = f()
      func f() *int {
          v := 1
          return &v
      }
    
  • 但每次调用函数f都会返回一个不同的值;
    1
    
      fmt.Println(f() == f()) // 输出false
    

一个与C/CPP运用比较类似的特点:

  • 通过传入指针改变指针所指向的变量的值;
    1
    2
    3
    4
    5
    6
    7
    8
    
      func incr(p *int) int { // 函数还是有返回类型的
          *p++    // 递增
          return *p
      }
    
      v := 1
      incr(&v)    // v现在等于2了
      fmt.Println(incr(&v))   // 现在变成3了
    

Go中的指针在flag包中很关键,主要用于指代一些参数的地址,看示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
	"flag"
	"fmt"
	"strings"
)

// 下面两个参数都是指针类型
var n = flag.Bool("n", false, "omit trailing newline") // 设置-n参数,"omit trailing newline"是-help时的提示语,不提供信息默认是false
var sep = flag.String("s", "", "separator")            // 设置-s参数,"separator"同样是-help的提示语,不提供信息时默认是""

func main() {
	flag.Parse()
	fmt.Print(strings.Join(flag.Args(), *sep))
	if !*n { // !*n表示,如果该参数出现,则执行代码块中的内容
		fmt.Println() // 带出一个换行符
	}
}

使用new函数创建变量

这部分类似CPP,看代码:

1
2
3
4
p := new(int)   // *int类型的p,指向未命名的int变量
fmt.Println(*p) // 输出"0"
*p = 2          // 把未命名的int设置为2
fmt.Print1n(*p) // 输出"2"

new是函数,而不是关键字,因此可以重定义;

变量的生命周期

生命周期指在程序执行过程中变量存在的时间段;

包级别变量的生命周期是整个程序的执行时间。相反,局部变量有一个动态的生命周期:每次执行声明语句时创建一个新的实体,变量一直生存到它变得不可访问,这时它占用的存储空间被回收。函数的参数和返回值也是局部变量,它们在其闭包函数被调用的时候创建。

那么垃圾回收器如何知道一个变量是否应该被回收?说来话长,基本思路是每一个包级别的变量,以及每一个当前执行函数的局部变量,可以作为追溯该变量的路径的源头,通过指针和其他方式的引用可以找到变量。如果变量的路径不存在,那么变量变得不可访问,因此它不会影响任何其他的计算过程。

  • 说实话,没怎么看懂
  • go的编译器会自动判断使用堆上还是栈上的空间,看一段代码:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
      var global *int
      func f() {
          var x int   // x使用堆空间
          x = 1
          global = &x // 因为已经与全局变量global建立了联系
      }
        
      func g() {
          y := new(int)   // y使用栈空间,纯局部变量
          *y = 1
      }
    
  • go很强大,但是垃圾回收机制即便高效,那也是以内存的负担为代价的;

变量赋值

赋值语句用来更新变量所指的值,它最简单的形式由赋值符=,以及符号左边的变量和 右边的表达式组成。

1
2
3
4
x = 1   // 有名称的变量
*p = true   // 间接变量
person.name ="bob"  // 结构体成员
count[x] = count[x] * scale // 数组或slice或map的元素

每一个算术和二进制位操作符有一个对应的赋值操作符,例如,最后的那个语句可以重写成:count[x] *= scale,它避免了在表达式中重复变量本身。

数字变量也可以通过++--语句进行递增和递减:

1
2
3
v := 1
v++ // 等同于v = v + 1; v 变成 2
v-- // 等同于v = v - 1; v 变成 1

多重赋值

多重赋值允许几个变量一次性被赋值。在实际更新变量前,右边所有的表达式被推演,当变量同时出现在赋值符两侧的时候这种形式特别有用,例如,当交换两个变量的值时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
x,y = y,x
a[i], a[j] = a[j], a[i]

// 计算两数之间的最大公约数
func gcd(x, y int) int {
    for y != 0 {
        x, y = y, x % y // 优雅写法
    }    
    return x
}

// 计算斐波那契数列
func fib(n int) int {
    x, y := 0, 1
    for i := 0; i < n; i++ {
        x, y = y, x + y
    }
    return x
}

从风格上考虑,如果表达式比较复杂,则避免使用多重赋值形式;一系列独立的语句更 易读。

需这类表达式(例如一个有多个返回值的函数调用)产生多个值。当在一个赋值语句中使 用这样的调用时,左边的变量个数需要和函数的返回值一样多。

通常函数使用额外的返回值来指示一些错误情况,例如通过os.Open返回的error类型,或者一个通常叫okbool类型变量。我们会在后面的章节中看到,这里有三个操作符也有类似的行为。如果map查询(参考4.3节)、类型断言(参考7.10节)或者通道接收动作(参考8.4.2节)出现在两个结果的赋值语句中,都会产生一个额外的布尔型结果:

1
2
3
v, ok = m[key]  // map查询
v, ok = x.(T)   // 类型断言
v, ok = <-ch    // 通道接收(<符号)

不需要的可以甩给空标识符_

可赋值性

赋值语句是显式形式的赋值,但是程序中很多地方的赋值是隐式的:

  • 一个函数调用隐式地将参数的值赋给对应参数的变量;
  • 一个return语句隐式地将return操作数赋值给结果变量。
  • 以及复合类型的字面量表达式,例如slice
    1
    2
    3
    4
    5
    6
    
      medals := []string{"gold", "silver", "bronze"}
    
      // 等价于如下的赋值方式
      medals[0] = "gold"
      medals[1] = "silver"
      medals[2] = "bronze"
    

这部分讲得懵里懵逼的。

类型声明

变量或表达式的类型定义这些值应有的特性,例如大小(多少位或多少个元素等)、在 内部如何表达、可以对其进行何种操作以及它们所关联的方法。

任何程序中,都有一些变量使用相同的表示方式,但是含义相差非常大。例如,int类型可以用于表示循环的索引、时间戳、文件描述符或月份;f1oat64类型可以表示每秒多少米的速度或精确到几位小数的温度;string类型可以表示密码或者颜色的名字。

type声明定义一个新的命名类型,它和某个已有类型使用同样的底层类型。命名类型提 供了一种方式来区分底层类型的不同或者不兼容使用,这样它们就不会在无意中混用。

  • 意思就是让类型用起来更加具象化,比如说我想让我这个int就表示我书本的数量;

类型声明的形式:

type name underlying-type

类型的声明通常出现在包级别,这里命名的类型在整个包中可见,如果名字是导出的(开头使用大写字母),其他的包也可以访问它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package tempconv

import "fmt"

type Celsius float64    // 定义一种专门类型
type Fahrenheit float64 // 同上

const (
    AbsoluteZeroC Celsius = -273.15
    FreezingC Celsius = 0
    BoilingC Celsius = 100
)

// 使用那两种类型
func CToF(c Celsius) Fahrenheit { return Fahrenheit(c * 9/5 + 32) }
func FToc(f Fahrenheit) Celsius { return Celsius((f - 32) * 5 / 9) }

fmt.Printf("%g\n", BoilingC - Freezingc) // 类型一致,可以加减
boilingF := CToF(BoilingC)  // 这是华氏度类型
fmt.Printf("%g\n", boilingF - CToF(FreezingC))  // 类型精确一致,可以加减
fmt.Printf("%g\n", boilingF - Freezingc)    // 类型不一致,不可加减,编译错误;

这个包定义了两个类型一Celsius(摄氏温度)和Fahrenheit(华氏温度),它们分别对应两种温度计量单位。即使使用相同的底层类型float64,它们也不是相同的类型,所以它们不能使用算术表达式进行比较和合并。

区分这些类型可以防止无意间合并不同计量单位的温度值;从float64转换为Celsius(t)Fahrenheit(t)需要显式类型转换。Celsius(t)Fahrenheit(t)是类型转换,而不是函数调用。它们不会改变值和表达方式,但改变了显式意义。另一方面,函数CToFFToC用来在两种温度计量单位之间转换,返回不同的数值。

对于每个类型T,都有一个对应的类型转换操作T(x)将值x转换为类型T。如果两个类型具有相同的底层类型或二者都是指向相同底层类型变量的未命名指针类型,则二者是可以相互转换的。类型转换不改变类型值的表达方式,仅改变类型。如果x对于类型T是可赋值的,类型转换也是允许的,但是通常是不必要的。

通过==<之类的比较操作符,命名类型的值可以与其相同类型的值或者底层类型相同的未命名类型的值相比较。但是不同命名类型的值不能直接比较:

1
2
3
4
5
6
var c Celsius       // 摄氏度类型
var f Fahrenheit    // 华氏度类型
fmt.Println(c = 0)  // "true",两者底层类型一致
fmt.Println(f >0)   // 同上
fmt.Println(c = f)  // 编译错误:类型不匹配
fmt.Println(c = Celsius(f)) // 强制转换类型,所以"true"!

循环体与语句

在Go语言中,形如i++的递增语句是一种语句,因此形如:

1
2
j = i++     // 不合法
++i         // 不合法,Go中仅支持后缀

for循环时Go语言中唯一的循环语句,一般的形式:

for initialization; condition; post {
    // 零个或多个语句
}

主要注意的特点就是大括号的位置,不能轻易换行;

  • initialization代表初始化语句;
    • 必须是一条简单的语句,比如简短的变量声明,一个递增或赋值语句,或者一个函数调用;
  • condition代表条件语句;
    • 是一个布尔表达式,在每一轮迭代开始前推演,是真则执行循环体;
    • 条件不存在则无尽循环;
  • post在循环体执行之后被执行;
    • 这部分可以是某条语句,执行后会再进入condition部分进行判断;

下面看这个循环体的例子:

1
2
3
4
for _, arg := range os.Args[1:] {   // range是一个关键字,不是函数
    s += sep + arg
    sep = " "
}
  • 由于在go语言中,不允许存在无用的临时变量,所以针对range产生索引的部分,使用了一个下划线_去接,其实在Python中也有类似用法;
  • 其次是字符串追加的效率问题,示例中总是讲arg产生的字符串追加到s,而这个过程大致上是追加旧的字符串、空格字符、下一个参数,生成一个新的字符串之后赋给s,而旧的字符串则会被例行垃圾回收,这样在有大量数据处理的情况下代价是比较大的,这个时候会建议使用strings包中的Join函数:fmt.Println(strings.Join(os.Args[1:], " "))

其他控制流

  • switch case语句

    1
    2
    3
    4
    5
    6
    7
    8
    
    switch coinflip() { 
        case "heads":   // 命中匹配的选项
            heads++
        case "tails": 
            tails++
        default:    // 没有符合条件的case则命中default
            fmt.Println("landed on edge!")
    }
    

包和文件

在Go语言中包的作用和其他语言中的库或模块作用类似,用于支持模块化、封装、编译隔离和重用。一个包的源代码保存在一个或多个以.go结尾的文件中,它所在目录名的尾部就是包的导入路径:

  • gop1.io/ch1/helloworld包的文件存储在目录$G0PATH/src/gopl.io/ch1/helloworld中。

每一个包给它的声明提供独立的命名空间。例如,image包中的Decode标识符和unicode/utf16包中的标识符一样,但是关联了不同的函数。为了从包外部引用一个函数,我们必须明确修饰标识符来指明所指的是image.Decode还是utf16.Decode

  • 通过标识符是否大写字母来表明标识符的对外可见性;

包初始化

包的初始化从初始化包级别的变量开始,这些变量按照声明顺序初始化,在依赖已解析完毕的情况下,根据依赖的顺序进行。

1
2
3
4
var a = b + c   // 最后把a初始化为3
var b = f()     // 通过调用f把b初始化为2
var c = 1         // 首先初始化为1
func f() int { return c + 1 }

如果包由多个.go文件组成,初始化按照编译器收到文件的顺序进行:go工具会在调用编译器前将·go文件进行排序。

对于包级别的每一个变量,生命周期从其值被初始化开始,但是对于其他一些变量,比如数据表,初始化表达式不是简单地设置它的初始化值。这种情况下,init函数的机制会比较简单。任何文件可以包含任意数量的声明如下的函数:

1
func init() { /*...*/ }

这个init函数不能被调用和被引用,另一方面,它也是普通的函数。在每一个文件里, 当程序启动的时候,init函数按照它们声明的顺序自动执行。

包的初始化按照在程序中导入的顺序来进行,依赖顺序优先,每次初始化一个包。因此,如果包p导人了包q,可以确保qp之前已完全初始化。初始化过程是自下向上的,main包最后初始化。在这种方式下,在程序的main函数开始执行前,所有的包已初始化完毕。

接下来来定义一个函数,它返回一个数字中被置位的个数(值为1的位个数):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package popcount

var pc [256]byte    // 长度为256的字节数组变量,这是一个快查表


func init() {
    for i, _ := range pc { // 处理pc中的每一位
        // 这是一个简单的数学推导算法
        pc[i] = pc[i / 2] + byte(i&1)   // i&1的结果取决于i
    }
}

// 因此对于任意的uint64数,都可以这么求
func PopCount(x uint64) int {
    // 以每8位作为一个单位,不断右移即可
    return int(pc[byte(x >> (0 * 8))] +
    pc[byte(x >> (1 * 8))] +
    pc[byte(x >> (2 * 8))] +
    pc[byte(x >> (3 * 8))] +
    pc[byte(x >> (4 * 8))] +
    pc[byte(x >> (5 * 8))] +
    pc[byte(x >> (6 * 8))] +
    pc[byte(x >> (7 * 8))])
}

作用域

声明将名字和程序实体关联起来,如一个函数或一个变量。声明的作用域是指用到声明时所声明名字的源代码段。

不要将作用域和生命周期混淆。声明的作用域是声明在程序文本中出现的区域,它是个编译时属性。变量的生命周期是变量在程序执行期间能被程序的其他部分所引用的起止时间,它是一个运行时属性。

语法块(b1ock)是由大括号围起来的一个语句序列,比如一个循环体或函数体。在语法块内部声明的变量对块外部不可见。块把声明包围起来,并且决定了它的可见性。我们可以把块的概念推广到其他没有显式包含在大括号中的声明代码,将其统称为词法块。包含了全部源代码的词法块,叫作全局块。每一个包,每一个文件,每一个for、if和switch语句,以及switch和select语句中的每一个条件,都是写在一个词法块里的。当然,显式写在大括号语法里的代码块也算是一个词法块。

一个声明的词法块决定声明的作用域大小。像intlentrue等内置类型、函数或常量在全局块中声明并且对于整个程序可见。在包级别(就是在任何函数外)的声明,可以被同一个包里的任何文件引用。导入的包(比如在tempconv例子中的fmt)是文件级别的,所以它们可以在同一个文件内引用,但是不能在没有另一个import语句的前提下被同一个包中其他文件中的东西引用。许多声明(像tempconv.cToF函数中变量c的声明)是局部的,仅可在同一个函数中或者仅仅是函数的一部分所引用。

一个程序可以包含多个同名的声明,前提是它们在不同词法块中。例如可以声明一个和包级别变量同名的局部变量。或者像2.3.3节展示的,可以声明一个叫作new的参数,即使它是一个全局块中预声明的函数。然而,不要滥用,重声明所涉及的作用域越广,越可能影响其他的代码。

当编译器遇到一个名字的引用时,将从最内层的封闭词法块到全局块寻找其声明。如果没有找到,它会报undeclared name错误;如果在内层和外层块都存在这个声明,内层的将先被找到。这种情况下,内层声明将覆盖外部声明;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func f() {}
var g = "g"

func main() {
    f := "f"
    fmt.Println(f)  // 作用域内的f覆盖了包级别函数f
    fmt.Println(g)  // g为包级别变量
    fmt.Println(h)  // 未定义
}

// 一个嵌套的例子
func main() {
    x := "hello"
    for i := 0; i < len(x); i++ {
        x := x[i]   // 新作用域内的x....
        if x != '!' {
            x := x + 'A' - 'a'
            fmt.Printf("%c", x)
        }
    }
}

在以下程序中:

1
2
3
4
5
if f, err := os.Open(fname); err != nil { // 编译错误:未使用f
    return err
}
f.Stat()    // 编译错误:未定义f,作用域原因
f.Close()   // 编译错误:未定义f

f变量的作用域是if语句,所以不能被接下来的语句访问,编译器会报错。根据编译器的不同,也可能收到另一个报错:局部变量f没有使用。所以通常需要在条件判断之前声明f,使其在if语句后面可以访问,下面是一种修改方式:

1
2
3
4
5
6
f, err := os.Open(fname)
if err != nil {
    return err
}
f.Stat()
f.close()

或者,你可能希望避免在外部块中声明ferr,方法是将StatClose的调用放到else块中:

1
2
3
4
5
6
if f,err os.Open(fname);err !nil
return err
else
//f与err在这里可见
f.Stat()
f.close()

短变量声明依赖一个明确的作用域。考虑下面的程序,它获取当前的工作目录然后把它保存在一个包级别的变量里。这通过在main函数中调用os.Getwd来完成,但是最好可以从主逻辑中分离,特别是在获取目录失败是致命错误的情况下。函数log.Fata1f输出一条消息,然后调用os.Exit(1)退出。

1
2
3
4
5
6
7
8
9
var cwd string

func init() {
    // 编译器先命中块内的cwd声明,然后发现,怎么没地方用到这玩意儿
    cwd, err := os.Getwd()  // 编译错误:未使用cwd
    if err != nil {
        log.Fatalf("os.Getwd failed: %v", err)
    }
}
本文由作者按照 CC BY 4.0 进行授权

Q K V的理解

Go语言介绍