Go语言中的变量、常量和作用域

92 阅读9分钟

变量是存储数据的容器,常量是不可变的值,而作用域定义了这些标识符的可见性和生命周期。在这篇文章中,我们将深入探讨Go语言中如何声明和使用变量,如何定义常量以提高代码的可维护性,以及作用域如何影响标识符的可见性和生命周期。

变量

概念

程序中的数据都是保存在内存中的,为了方便操作内存特定位置的数据,用一个特定的名字与位于特定位置的内存块绑定在一起,这个名字就叫做变量。

《如何高效学习》一书中提到的: "学习电脑编程时,程序语言经常遇到变量的概念,变量是用来储存信息的,并且在程序运行过程中会发生变化。姓名、数字或是密码都可以作为变量储存起来。我把变量想象成各种各样的罐子,如此一来概念就变得容易理解了。因为变量可以分为好多类型(有的用来储存数字,有的用来储存字母或者单词),我就想象不同的罐子有不同的瓶口,所以可以装不同类型的数据。" image.png

声明方法

  • Go 是静态语言,所有变量在使用前必须先进行声明。
  • 同一作用域内不能重复声明,且所声明的变量必须使用,否则编译不通过。

语法如下:

var language string = "Go"
  • var 修饰变量声明的关键字
  • language变量名且必须在类型的前面。
  • string 为当前变量的类型
  • "Go"变量的初始值如果没有赋予初始值则为当前类型的零值

Go 语言的原生类型都有它的默认值(零值):

内置原生变量默认值(零值)
所有整型类型0
所有浮点类型0.0
布尔类型false
字符串类型""
指针、接口、切片、channel、map和函数类型nil

除了单独声明每个变量外,还提供了变量块的语法形式,如下:

var (
    total int = 1234
    count int8 = 6
    str string = "go program"
    char rune = 'A'
    has bool = false
)

还可以在一行变量声明中同时申明多个变量:

var nickname, sex, email string = "Forest", "man", "abc@qq.com"

同样也可以使用在变量块中声明多个变量:

var (
    nickname, sex, email string = "Forest", "man", "abc@qq.com"
    a, b, c, d, e rune = 'A', 'B', 'C', 'D', 'E'
    i int = 234
    j float64 = 3.1415926 
    n bool = false
)

除了上面的几种变量声明形式,Go 语言还提供了两种变量声明的“语法糖”:

  • 省略类型;Go 编译器会根据右侧变量初值自动推导出变量的类型,并给这个变量赋予初值所对应的默认类型

    类型默认类型
    整型值int
    浮点值float64
    复数值complex128
    布尔值bool
    字符值rune
    字符串string

    这种方式只能在显示初始化变量值的前提下使用

    var b // 编译报错-->语法错误
    

    如果在不接受默认类型的场景,可以显示地为变量指定类型,通过显示类型转换去实现

    var num = int8(110) // 按照类型推导规则,110 的类型为 int,但是通过显示指定类型的方式使 110 的类型为 int8
    

    结合多变量声明,可以使用这种变量声明“语法糖”声明多个不容类型的变量

    var i, j, m, n = 99, 3.1415926, 'M', "this is a string"
    
  • 短变量

    语法:变量名 := 初始值

    language := "Go"
    total := 100
    str := "this is a string!"
    

    这种方式只能在局部作用域中(函数内)使用。

常量

概念

Go 语言的常量是一种在源码编译期间被创建的语法元素;常量一旦被声明初始化后,它的值在整个程序的生命周期内亘古不变。

声明方法

经过上面的变量的学习,Go 是使用 var 关键字声明的变量,而常量,Go 语言引入 const 关键字来声明常量,和 var 支持单行声明多个变量,以及以代码块形式聚合变量声明一样,const 也支持单行声明多个常量,以及以代码块形式聚合常量声明的形式。

const pi float64 = 3.1415926 // 单行常量声明

// 以 const 代码块形式声明常量
const p
    size int64 = 4096
    i, j, s = 13, 14, "bar" // 单行声明多个常量
)
  • 即便两个类型有相同的底层类型,但它们仍然是不同的数据类型,不可以被相互比较或混在一个表达式中进行运算。
  • 有类型常量与变量混合在一起进行运算求值的时候,也必须准遵守类型相同这一要求,或者通过显示类型转换达到可运算。
  • 无类型常量不是真的没有类型,它有自己的默认类型——根据初始值决定的。

作用域

作用域的概念是针对标识符的,不局限于变量。每个标识符都有自己的作用域,而一个标识符的作用域就是指这个标识符在被声明后可以被有效使用的源码区域;作用域是一个编译期的概念,也就是说,编译器在编译过程中会对每个标识符的作用域进行检查,对于在标识符作用域外使用该标识符的行为会给出编译错误的报错。

package main

import "fmt"

var a = 11

func foo(n int) {
	a := 1
	a += n
}

func main() {
	fmt.Println("a =", a) // 11
	foo(5)
	fmt.Println("after calling foo, a =", a) // 11
}

上面代码中,执行 foo 函数前后的打印结果一致,是什么原因呢?因为它们的作用域不同,foo 函数中 a 变量遮蔽外层作用域的 a,在 a += n 的时候,会先从当前作用依次往上找,知道找到为止;但是当前作用域就有则直接使用,所以最后没有影响到外城作用域的 a 打印出来的结果是一致的。

包顶层声明中的常量、类型、变量或函数(不包括方法)对应的标识符的作用域是包代码块。

一个标识符要成为导出标识符需同时具备两个条件;这两个条件缺一不可:

  • 标识符声明在包代码块中,或者它是一个字段名或方法名。
  • 它名字第一个字符是一个大写的 Unicode 字符。

代码块域作用域

func (t T) M1(x int) (err error) {
	// 代码块1
	m := 13

	// 代码块1是包含m、t、x和err三个标识符的最内部代码块
	{ // 代码块2

		// "代码块2"是包含类型bar标识符的最内部的那个包含代码块
		type bar struct{} // 类型标识符bar的作用域始于此
		{                 // 代码块3

			// "代码块3"是包含变量a标识符的最内部的那个包含代码块
			a := 5 // a作用域开始于此
			{      // 代码块4
				//... ...
			}
			// a作用域终止于此
		}
		// 类型标识符bar的作用域终止于此
	}
	// m、t、x和err的作用域终止于此
}

if 条件分支语句为例来分析位于控制语句隐式代码块中的标识符的作用域划分:

package main

func bar() {
	if a := 1; false {
	} else if b := 2; false {
	} else if c := 3; false {
	} else {
		println(a, b, c)
	}
}

func main() {
	bar()
}

上面代码的执行后打印 a,b,c 的值分别为1,2,3;下面根据前面的隐士代码块规则转换为显示代码块为:

func bar() {
    { // 等价于第一个if的隐式代码块
        a := 1 // 变量a作用域始于此
        if false {

        } else {
            { // 等价于第一个else if的隐式代码块
                b := 2 // 变量b的作用域始于此
                if false {

                } else {
                    { // 等价于第二个else if的隐式代码块
                        c := 3 // 变量c作用域始于此
                        if false {

                        } else {
                            println(a, b, c)
                        }
                        // 变量c的作用域终止于此
                    }
                }
                // 变量b的作用域终止于此
            }
        }
        // 变量a作用域终止于此
    }
}

避免变量遮蔽的原则

变量是标识符的一种,前面说的标识符的作用域规则同样适用于变量;变量遮蔽问题的根本原因,就是内层代码块中声明了一个与外层代码块同名且同类型的变量,内层代码块中的同名变量就会替代那个外层变量

如下代码:

package main

import (
	"errors"
	"fmt"
)

var a int = 2020

func checkYear() error {
	err := errors.New("wrong year")

	switch a, err := getYear(); a {
	case 2020:
		fmt.Println("it is", a, err)
	case 2021:
		fmt.Println("it is", a)
		err = nil
	}
	fmt.Println("after check, it is", a)
	return err
}

type new int

func getYear() (new, error) {
	var b int16 = 2021
	return new(b), nil
}

func main() {
	err := checkYear()
	if err != nil {
		fmt.Println("call checkYear error:", err)
		return
	}
	fmt.Println("call checkYear ok")
}

运行结果:

it is 2021
after check, it is 2020
call checkYear error: wrong year

按照 getYear 函数的执行结果确实返回了正确的 2021,怎么在执行 “after check, it is” 打印的时候的值却是 2020 呢?按照前面的代码块作用域的分析方法分析后,发现是变量作用域的问题,解决这个问题也很简单:就是利用变量遮蔽的规则,覆盖外层变量。完整代码如下:

package main

import (
	"errors"
	"fmt"
)

var a int = 2020

func checkYear() error {
	err := errors.New("wrong year")
	a, err := getYear()
	switch a {
	case 2020:
		fmt.Println("it is", a, err)
	case 2021:
		fmt.Println("it is", a)
		err = nil
	}
	fmt.Println("after check, it is", a)
	return err
}

type new int

func getYear() (new, error) {
	var b int16 = 2021
	return new(b), nil
}

func main() {
	err := checkYear()
	if err != nil {
		fmt.Println("call checkYear error:", err)
		return
	}
	fmt.Println("call checkYear ok")
}

Go 官方提供了 go vet 工具可以用于对 Go 源码做一系列静态检查,在 Go 1.14 版以前默认支持变量遮蔽检查,Go 1.14 版之后,变量遮蔽检查的插件就需要我们单独安装了;工具确实可以辅助检测,但也不是万能的,不能穷尽找出代码中的所有问题。安装方法如下:

$ go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest

相关占位符

在Go语言中,以下是一些常用的%相关的占位符:

  • %d:十进制整数
  • %f:浮点数
  • %s:字符串
  • %t:布尔值
  • %v:通用格式化标识符,根据值的类型进行格式化
  • %p:指针地址
  • %b:二进制表示
  • %o:八进制表示
  • %x:十六进制表示(小写字母)
  • %X:十六进制表示(大写字母)
  • %c:字符
  • %q:带引号的字符串
  • %e:科学计数法表示的浮点数(小写字母e)
  • %E:科学计数法表示的浮点数(大写字母E)
  • %g:根据实际情况选择%f%e格式
  • %G:根据实际情况选择%f%E格式