这篇主题包含 Go 语言的基本内容:变量声明,数据类型,字符串,( 无类型 ) 常量。
第一章 入门
1.1 Hello, go!
笔者的 IDE 为 Jet Brains 公司的 GoLand,辅导书籍为机械工业出版社的《Go 程序设计语言》。
Go 是一个编译型的语言。它像 C 语言那样将 .go
后缀的源代码直接编译,连接成本平台直接可用的 app 。下面的示例是第一个 Hello World 程序。注意,包含主程序的 .go
文件路径必须在名为 main
的目录下,主函数的名称也必须命名为 main
。
package main
import "fmt"
func main() {
fmt.Print("Hello,世界")
}
可以使用 run
命令直接编译并运行这段代码,也可以使用 build
命令将它编译成可被直接运行的二进制文件。
$sudo go run ../main/goFirst.go
$sudo go build ../main/goFirst.go
在初学 go 语言时,还需要注意其它的杂项:
- 在源代码中,不需要主动使用
;
做为一行代码的结尾,除非你将多行代码写到了同一行。 - 如果你引入了一个未被使用的包,或者是在函数内声明了一个未被使用的局部变量,go 编译器会给出一个 Error ,而不是 Warning。
- 你可以在 GoLand 的 Output Directory 中设置一个路径,以获得可直接运行的二进制文件。这样在下次运行程序时便无需编译了。
1.2 有关于命令行参数
go 语言的命令行参数不会直接体现在 main
函数的参数列表上,而是需要借助一个 os
包去完成。
package main
// Go 约定导入顺序应当按首字母顺序进行排列,但是这并不是必须的。
import (
"fmt"
"os"
)
func main() {
// 索引下标为 0 (即第一个命令行参数) 是编译后的 go 文件名本身。
fmt.Print(os.Args[0] + " ")
// 用户输入的命令行参数从 1 开始。
for i:=1;i<len(os.Args);i++ {
fmt.Print(os.Args[i] + " ")
}
}
其中,这里使用到了 go
语言当中的循环语句和短变量声明 ( 代码块中的 i:=1
),这并不妨碍去理解这个代码。通过 os.Args[i]
能获取到第 i
个命令行参数,其中,下标为 0 的命令行参数总是存在且被保留的。
在 GoLand IDE 中,可以在右上角的 Edit Configurations => Configuration => Program arguments
设置命令行参数。
1.3 有关于 os.Args
os.Args
是一个 string
类型的 slice ,可以暂且将它理解成是其它编程语言中的数组 ( 这是一种妥协,实际上切片 slice 和数组 array 是两回事 )。刚才通过 os.Args[i]
访问了其中一个元素。
还可以简便的形式获取该 slice 的一个前闭后开区间,形如:os.Args[m:n]
,这个切片包含 os.Args[m]
,但是不会包含 os.Args[n]
。其中,0 <= m <= n <= len(os.Args)
。对于任何一个 slice,都可以通过 len(..)
获取它的长度。
该写法存在缺省形式。比如:os.Args[:n]
,此时被缺省的 m
即代表 0
;同样的,os.Args[m:]
,此时被缺省的 n
即代表 len(os.Args)
。
鉴于下标为 0 的命令行参数总是保留的,因此通常都是使用 os.Args[1:]
来获取用户输入的命令行参数。如果你要获取一个完整的 os.Args
,那就不需要带上任何中括号表示的位置,或区间。
1.4 go 语言中的 for 循环
刚才的示例使用到了 for 循环。go 语言的循环语句只通过 for
关键字来实现。传统的 for 循环是这个样子:
// go 语言中,它们不需要被一个()小括号括起来。
for init;condition;post {
//...
}
如果要实现一个 "while" 循环,则只需要给定一个 condition:
for condition {
//...
}
如果要实现一个无限循环,则什么都不加。这需要用户自己在循环体内部定义程序应在满足什么样的条件时跳出它,通过 return
或者是 break
来完成。
for {
//...
}
1.5 range 关键字
下面的 for 循环中 ( 实际上这是一个 "while" 循环 ) 存在两个变量,一个是 index
,一个是 value
。故名思意,在每一次迭代中,它们一个用于获取 slice 下标,一个用于获取对应位置上的值。这里是通过 range
关键字来实现的:
for index,value := range os.Args[1:] {
fmt.Println("index is ",index," and the value is ",value)
}
如果不需要额外地处理获取到的下标 index
,可以改用下划线 _
表示 "它是一个语法上必须存在而实际上不会被用到的变量" 以通过编译器的检查,因为 go 语言原则上不允许出现一个 "无用" 的变量。
下面的 for 循环相当于 Java 语言中的 for-each 循环,因为该循环体不关心切片的下标。
for _,value := range os.Args[1:]{
// 这是 go 语言中,输出信息到控制行的第二种形式:不引入 fmt 包,直接通过 print 关键字来完成。
// 该形式的输出不允许使用 + 将所有值拼接成一个字符串。
print("value is ",value,"\n")
}
附,可以将 (index,value)
泛化成一个二元组整体,而 range
则允许你通过简易地方式提取出二元组的第一个和第二个元素。如果将它用于遍历这个切片,那么第一个元素是下标号,第二个元素是指定位置上存储的值。在后面的例子中,用到了 range
关键字从 map 数据结构中提取 k,v 键值对。
1.6 关于声明变量
前文出现了 Go 语言中的短变量声明方式,即:i := value
。在这种声明方式中,i
实际是什么类型将取决于 value
。短变量声明只能用在函数内部的局部变量中,但是在函数内部,它是一种经常被使用的赋值方式。详情可参考第二章:关于变量:短变量声明。
另一个完整的变量声明格式是:var i type = expr
。其中,var
是声明变量的关键字,type
是 Go 语言内置的,或是包中定义的数据类型。它可以声明在函数外部。比如:
var i int8 = 100
func main(){
//...
}
1.7 一个查找重复行的例子
尝试实现这样的功能,程序接收若干次用户输入 ( 每一次输入况且称之为 “line” ),并且在用户输入结束后进行检查,查找出现次数大于 1 次的 line 。显然,在这里需要以下两样工具,然后体验如何在 Go 语言中利用它们:
map
类型的数据结构,用于记录用户的输入内容以及它出现的次数。- 输入流。
对于第一条,Go 语言使用下面的语法创建一个 map,然后将它的引用赋值给 count 。
count := make(map[string]int8)
// 设置一个键值对:("hello",1)
count["hello"] = 1
// 或者,直接将 key "hello" 对应的值进行自增操作:
count["hello"] ++
对于第二条,可以使用 bufio
包下的 NewScanner
获取一个监控标准输入流 stdin 的扫描器 ( Scanner ) 以获取用户通过键盘传输的内容,这和其它语言相比并没有什么不同。
input := bufio.NewScanner(os.Stdin)
// 阻塞式等待用户输入
input.Scan()
// 输出刚才用户输入的一行内容
input.Text()
额外的,Go 提供一种格式化输出形式:
i := 100
fmt.Printf("%d",i)
这些信息已经足够实现出功能了。代码块如下:
package main
import (
"bufio"
"fmt"
"os"
)
func main(){
counts := make(map[string]int8)
input := bufio.NewScanner(os.Stdin)
// 相当于一个 while 循环。如果输入 -1 则退出。
for input.Scan() {
if input.Text() == "-1" {break}
counts[input.Text()]++
}
// 有关 range 的介绍请参考前文。
for key,count := range counts {
if count > 1 {
fmt.Printf(key + " 的出现次数大于 1 次。实际出现了:%d 次。",count)
}
}
}
1.8 尝试从本地文件中读取内容
从刚才查找重复行的例子继续讨论。这一次不再通过控制台输入的方式进行人机交互,而是在运行程序时直接指定一个文本文件,然后让程序去实现相同的功能。至少,应当知道如何令 Go 程序根据一个 string
类型的路径去尝试打开对应的文件:
os.Open("/usr/hello_world.txt")
这个函数将返回两个值 ( 在 Go 语言中,一个函数可以返回多个值。而笔者则倾向于理解成返回一个包含多个值的多元组 ),因此需要两个变量去分别接收它:
file,err := os.Open("/usr/hello_world.txt")
为了弄清楚这两个值的类型和作用,需要跟进到源码当中去一探究竟:
func Open(name string) (*File, error) {
return OpenFile(name, O_RDONLY, 0)
}
显然,第一个返回值是一个 File
类型的指针,而第二个值代表着在打开文件时可能遇到的错误,比如给定的文件路径并不存在。在正常情况下,第二个值应当为 nil
( 表示第二个值为空 ) ,否则说明打开文件的过程中遇到了问题。
除此之外,在这里仍然使用 bufio.NewScanner
获取一个 Scanner,但是它的参数不再是 stdin,而是刚才通过 os.Open
获取的那个指向文件的指针:
scanner := bufio.NewScanner(file)
下面给定完整的程序逻辑:
package main
import (
"bufio"
"fmt"
"os"
)
func main(){
path := os.Args[1]
open, err := os.Open(path)
counts := make(map[string]int8)
if err != nil {
fmt.Printf(err.Error())
os.Exit(-1)
}
input := bufio.NewScanner(open)
for input.Scan() {
counts[input.Text()]++
}
for k,n := range counts{
if n > 1 {fmt.Printf("%v 出现的次数大于 1 次,实际出现了:%d 次。",k,n)}
}
}
1.9 从网络中获取信息
使用 Go 提供的包能够非常便捷地获取 / 发送来自网络上的信息。对于一些应用而言,它的数据源可能并不在本地,而是来自另一个主机。因此,访问网络数据和访问本地文件一样重要。
这里需要使用到两个包:net/http
以及 io/ioutil
。其中,通过 http.Get("...")
获取网络信息,参数是一个字符串类型的 URL 。它的返回值同样有两个:一个是请求成功的响应,另一个代表可能出现的异常。
当请求成功时,可以使用 ioutil.ReadAll(...)
一次性将内容暂存到缓冲区,以便于后续对响应报文的内容进做一步分析。在这个简单的示例中,仅仅是将它输出到控制台中。
func main(){
resp, httpErr := http.Get("http://www.baidu.com")
if httpErr != nil { fmt.Printf(httpErr.Error());os.Exit(-1)}
body, respErr := ioutil.ReadAll(resp.Body)
if respErr != nil {fmt.Printf(respErr.Error());os.Exit(-1)}
// 通过 %s 直接将字节流格式化并输出成字符串。
fmt.Printf("%s",body)
}
如果想在获取到响应内容之后直接在控制台 ( 对应 os.Stdout
) 中输出,那么可以通过 os.Copy
来实现:
resp, httpErr := http.Get("https://www.baidu.com")
if httpErr != nil { fmt.Printf(httpErr.Error());os.Exit(-1)}
// 虽然可以直接携程 io.Copy(os.Stdout,resp.Body),但是下面的写法明确表示该函数具备返回值,只不过在这里被主动忽略了。
_ , _ = io.Copy(os.Stdout, resp.Body)
resp.Body.Close()
使用 _,_
表示忽略掉 Copy
函数提供的所有内容 ( 第一个 int64
值记录了读取的字节数,第二个值表示可能出现的错误 ),在该情景下,赋值符号将使用 =
而不是 :=
。( 详细的原因会在 第二章:关于变量:短变量声明中说明 )
可以从 resp 本身抽取响应报文的其它信息,包括了响应头 ( Header ),状态码 ( StatusCode),协议版本号 ( Proto )。而协议版本号又被拆分为了协议主版本号 ProtoMajor 和次版本号 ProtoMinor 。
fmt.Print(resp.Header)
fmt.Print(resp.StatusCode)
fmt.Print(resp.Proto)
fmt.Print(resp.ProtoMajor)
fmt.Print(resp.ProtoMinor)
在使用完 resp 之后,应当调用 Close()
主动关闭资源。
// 这里省略的是可能抛出的 error
_ = resp.Close()
1.10 第一个 Super Mini Go 网络服务器
在这节中,仅通过一个库函数 http.HandleFunc(...)
和 http.ListenAndServe(...)
就可以构建一个迷你的 Web 服务器:
http.HandleFunc("/hello",handler)
log.Fatal(http.ListenAndServe("localhost:8080",nil))
而 handler 则是指定了形参类型的,当库函数 HandlerFunc
接收到请求时调用的另一个函数。HandleFunc
在监听到请求时作何反应,取决于这个 handler 的逻辑。作为一个示例程序,这里的响应仅仅是简单地将用户请求的路径写回。
func handler(w http.ResponseWriter,r *http.Request){
_, _ = fmt.Fprintf(w, "url = %s", r.URL.Path)
}
第二章 基础数据
2.1 关于声明
Go 语言中存在 4 种类型的声明,包括了变量 var
,函数 func
,类型 type
和常量 const
( 不可变 )。
Go 语言中的声明是大小写敏感的。变量名称的首字符是否大小写,决定它们是否对外部 ( 指其它包 ) 可见。如果声明的变量以大写字母开头,则它可以被其它包可见,前提是这些声明都是顶层的 ( 后续称这样的变量为包变量) 。
所有函数内部的声明都是作用域外不可见的,即便它是大写开头。比如,即使在一个函数 func
内部定义了一个常量 const
,该常量也仅可以在函数内部被使用。
// 不对其它包可见,但是对该 .go 文件下的其它地方可见。
var aInt int8 = 1
// 对其它包可见
var AInt int8 = 1
// 该常量对其它包可见
const Pi float64 = 3.1415926
// 该函数不对其它包可见
func getTuple3()(int,int,int){
// 该变量不对外部可见
var aDouble float64 =1.00
// 该常量不对外部可见
const innerPi float32 = 3.14
fmt.Print(innerPi)
return 1,2,3
}
// 该函数对其他包可见
func GetTuple2() (int,int){
return 1,2
}
2.2 关于变量
2.2.1 变量命名
Go 语言中的变量名可以由下划线 _
,数字,和字母构成,但是不可以数字开头。Go 语言支持定义一个中文名称的变量,但一般不会这样做。
当然,Go 语言中的关键字是被保留的,不过无需死记硬背。另外,Go 语言中还有一些预保留的名称 ( make
函数,len
函数,new
函数,int
类型 ) ,但是为了避免混淆,也不会起这样的名称。
2.2.2 关于变量
一个标准的变量声明应当是这样的:
var name type = expression
一般情况下,type
和 expression
只要声明其一即可。Go 语言中所有的数据结构都具备零值,因此这不会引发因未初始化而导致的 "NullPointerException
"。对于数据,这个零值就是 0
;对于布尔值,零值为 false
;对于接口和引用类型,零值为 nil
;对于更复杂的组合类型,零值为内部所有元素的零值。
如果要一次性声明多个同一种数据类型的变量,可以这样做:
// 声明了 3 个 int8 类型的变量
var a,b,c int8
如果要匹配方式在一行代码内为不同类型的变量赋不同类型的值,可以这样做:
var a,b,c = true,1.00,5
这种方式又称之为多重赋值 (见后文) 。右侧可以是表达式,只要它的返回值列表能够和声明的变量列表相匹配:
var f,err = os.Open(...)
声明一个局部变量有两个选择:标准的变量声明和短变量声明。在格外强调严谨的数据类型的场合,会用到 var
关键字。比如:
// x 默认情况下是 int 类型。
x := 1
// 通过 var 显式声明 y 是 int8 类型。
var y int8 = 1
如果通过短变量声明的方式,那么 x
总是一个 int
类型的数据,而不是一个 int8
类型的数据。
2.2.3 利用多重赋值实现两数交换
在其它语言中实现两数交换,普遍的逻辑可能是这样的:
func main() {
var a,b = 1,3
a = a + b
b = a - b
a = a - b
fmt.Print("A = ",a," B = ",b)
}
但实际上,这在 Go 可以通过多重赋值实现:
var a,b = 1,3
var b,a = a,b
多重赋值非常适用于同一个变量同时出现在赋值表达式左右两侧的情况。其逻辑是:程序会预先计算出赋值号右侧位置的实际值,然后再一次性为左边的变量赋值,因此这避免了交换变量时的值覆盖问题。
2.2.4 短变量声明
在之前已经接触了短变量声明的写法,它只能出现在函数内部。注意,不要将 :=
和 =
等同起来,因为短变量声明仍然是声明,指代创建了一个新的变量。
// 新声明了一个变量 a,它的值被初始化为 1 。
a := 1
短变量声明在某种特殊情况下可以被认为是赋值。下面的代码在至少声明了一个新变量 a
的同时,顺带也为已有的变量 b
和 c
进行了重新赋值。
var b,c = 1,2
// 对 a 而言是声明,对 b 和 c 而言则是重新赋值。
a,b,c := 3,4,5
//
fmt.Print(a,b,c)
如果短变量声明的表达式左边没有任何一个新变量,那么它就会被禁用。这些变量只能通过 =
重新赋值。
var b,c = 1,2
b,c := 4,5
fmt.Print(b,c)
这也解释了这段代码将是无法跑通的:
_, _ := fmt.Fprintf(w, "url = %s", r.URL.Path)
2.2.5 指针与 new 函数
对于学习过 C/C++ 而言的人,指针一定不陌生 ( 或者说,恐惧 ) 。
// x 指向一个值 1
var x = 1
// p 指向一个地址,这个地址是 x 的。
var p = &x
// p 指向一个地址,这个地址是 x,的,*p 获取了 x 指向的那个值 1。
fmt.Print(*p)
指针指向一个变量的地址。不是所有的值都具备地址,但是所有的变量都有。使用指针的最大好处就是在不知道变量名的情况下就能够间接读取或更新变量的值。比如在之前的例子中曾调用的 os.Open(...)
函数,它返回的就一个匿名的 *File
指针。通俗地来讲,它将文件的内容提取到了内存的某一处之后,将门牌号留给了主函数。这样,即便是在 os.Open(...)
函数在调用完并出栈之后,主函数仍然能够顺着这个 "门牌号" 找到留下来的文件内容。
下面的函数 f()
总是返回一个指向一个值为 1
的地址的指针。
func f() *int {
x:=1
return &x
}
// 由于 f() 返回的是指向 1 的指针,因此即便在 f() 被退栈之后 p 仍然可以访问到这个值。
var p = f()
func main() {
// f() 虽然返回的都是指向 1 的地址,但是这两个地址值并不相同。
// 运行结果将是 false。
fmt.Print(f() == f())
}
值得注意的是,每次为一个值变量 x
创建一个指针 p
,或者后续将这个指针进行复制,都相当于不断地创建了 x
的 "别名"。比如,*p
就是 x
的别名,更改 *p
就相当于更改变量 x
。别名越多,则意味着 x
越有可能受其它的函数副作用影响而发生更改。
// "声明时带 * 号表示这是一个指向某个数据结构的地址的指针."
var _ *int8
// 通过库函数的 new 也可以直接创建出指向某种数据结构的指针。
_ = new(int)
2.2.6 变量的生存周期
变量的生命周期指在程序的运行时期,某个变量存在的时间段。这主要针对局部变量而言,因为包级别的变量是在程序运行过程当中 "全程存活" 的。局部变量指在某个语句块内生效 ( 比如函数内的局部变量 ) 。当函数执行完毕时,这些变量将大概率变得 "不可达"。这时它们就会在某一次垃圾回收的过程中被清除掉,来为内存释放空间。
而一旦局部变量的指针将自己的值赋值给外部指针,就会引发 "逃逸" 现象。比如说下面的函数:
func f() {
var x int8 = 1
p2Int = &x
}
var p2Int *int8
func main() {
f()
// 能够正常打印数值
if p2Int != nil {
fmt.Print(*p2Int)
} else {
println("error: p2Int 是个空指针")
}
}
f()
函数没有返回值,但是具备副作用。这是因为函数内部将 x
的地址留给了外部的变量 p2Int
。这样,垃圾回收器就会避免回收掉变量 x
所在的那片内存空间,因为仍然有一个 p2Int
指向它。此时称之为 x
从它的局部域当中 "逃逸" 了。
2.3 类型声明
类型声明的功能类似于 typedef
,为已有的数据类型起一个新的别名。比如说一个 int8
类型的数据,从语义上它可以代表 "月份","星期数","年龄","分数"。在不至混淆的情形下,可以为这个 int8
起一个新的名字:
package main
import "fmt"
type Age int8
func main() {
var n Age = 24
fmt.Printf("前辈今年 %d 岁了",n)
}
不同的类型声明总是被认为是不同的,即便它们其实都指代同一种数据类型。
type Age int8
type Score int8
func main() {
var n Age = 24
var s Score = 100
// 编译错误,因为 s 是 Score 类型,而 n 是 Age 类型。
s = n
}
2.4 包管理
Go 以包为单位管理源码,以支持模块化,和代码重用。所有项目的 .go
都保管在一个包内。包的名字和其路径相对应。比如一个包的名字是 awesomeProject/vars
,那么该包的实际路径就是 $GO_PATH/src/awesomeProejct/vars
。
每个包内的声明都是相互独立的,比如说 os.Open(...)
和 me.Open(...)
就是两码事。前文已经提过,程序员可以通过修改声明的首字母大小写来决定它是否对包外可见,因此 Go 不需要诸如 public
,private
之类的权限修饰符。
包的初始化总是从初始化包级别的变量开始。默认情况下按照声明顺序进行初始化,但是也要考虑到变量之间的依赖关系。比如:
var a = b + c
var b = f()
var c = 2
func f(){
return c + 1
}
在这种情况下,解决变量 c
才是当务之急。只有 c
初始化之后,b
才能调用函数 f()
进行初始化,最后才是变量 a
。
如果一个包下存在多个 .go
文件,那么编译时 go 工具会率先对一个包的 .go
文件进行排序,然后发送到 go
编译器中进行有序编译。
在有些情况下,包变量的初始化不是简单地赋零值 ( 或其它值 ) ,比如说初始化对某个数据库的连接,并获取连接池。这需要一系列的步骤才能完成。Go 语言提供一个较为特殊的初始化函数:
func init(){
//TODO 初始化代码,设计到复杂初始化的过程可以选择在这里进行。
}
代码的其它地方无法主动地调用名为 init()
的函数。它只会程序初始化时被主动调用。一个 .go
文件可以有任意多个数量的 init()
函数,初始化时按照这些 init()
的声明顺序执行。
包和包之间的初始化顺序也是根据依赖顺序进行的。这样保证了如果包 b
依赖包 a
,则 a
一定以及在 b
之前就被初始化了。在所有的包都被初始化之后,最后初始化项目中名为 main
的包。
2.5 作用域
作用域是任何编程语言当中都存在的概念。一般都通过语法块 ( 显式地使用 {}
括起来 ) 或者是词法块 ( for
,if
语句中的每一个条件都是单独的作用域 ) 来判断一个变量的作用域。比如:
{
var f = 100
fmt.Print(f)
}
// f 对外部的语法块不可见
fmt.Print(f)
第二个例子:
// i 仅在下面的词法块和后面的语句块内可见
if i := getTure();i == false {
fmt.Print(i)
}
fmt.Print(i)
当变量出现重名时,对内部块的变量而言,外部块的那个变量并不可见。对于外部块的变量而言,内部块的那个变量所发生的改变也不会影响到自身。
var i int8 = 10
{
var i int8 = 20
//20
println(i)
}
//10
println(i)
在使用短变量声明时,尤其要注意这个 "陷阱":
package main
var hello string
func init() {
// 赋值的是包变量 hello。
hello = "halo"
println(hello)
}
func main(){
// 这相当于是创建了一个新的 main 内部的局部变量 hello,而不是给包变量 hello 赋值。
hello := "hello"
println(hello)
}
如果要在 main
函数内使用包变量 hello
,那么就不应该在其作用域内通过短变量声明再创建一个同名变量来覆盖它。
第三章 数据类型
3.1 整数
Go 语言将整数进行了详细的划分:8 位有符号整数,16 位有符号整数,32 位有符号整数,64 位有符号整数,分别对应 int8
,int16
,int32
,int64
。以 int8
为例,它可以表示的范围是 -27 ~ 27 - 1 。
与此同时,整数还可以分为无符号整数,它们和有符号整数的位数相对应,如 uint8
,uint16
,uint32
,uint64
。由于不需要考虑符号位,因此以 uint8
为例,它可以表示的范围是 0 ~ 28 。
注意,如果变量的数值超过了其数据结构能表示的范围,那么计算结果会因为溢出而产生错误。
var aInt8 int8 = 127
var result = aInt8 + 1
// 符号位溢出,导致运算结果是 -128
fmt.Print(result)
Go 语言提供了两个不指定位数的整数类型 int
和 uint
。它们有可能是 32 位,也有可能是 64 位,实际位数要取决于各个平台的编译器。可以 unsafe.Sizeof
进行检查:
var aInt int
fmt.Print(unsafe.Sizeof(aInt))
因此,int
类型的数值不能简单地和 int32
或者是 int64
数值等同起来,它们之间要进行一步显式地转换:
var aInt int = 10
var aInt64 int64
//强制转换
aInt64 = int64(aInt)
fmt.Print(aInt64)
另外,byte
类型可以被认为是 uint8
的同义词。
var aByte byte = 0xFF
var aInt uint8
// 不需要进行强制转换而直接赋值
aInt = aByte
fmt.Print(aInt)
3.2 浮点数
Go 语言没有 "double" 一说,而是统一采用了 float
的称谓,分为 float32
和 float64
。十进制数下,float32
的有效数字大约是 6 位,而 float64
的有效数字大约是 15 位。在绝大部分情况下,应该优先选择 float64
类型,以获得尽可能精准的运算结果。
浮点数还以有以下表示法:
var aDouble float64 = .05 //0.05d
var aDouble2 float64 = 1. // 1.00d
var aDouble3 float64 = .0100010001e4 //100.010001
// 一般情况,%g 会保留足够精确的位数。
fmt.Printf("%g\n",aDouble)
// %f 表示非指数表示法。
fmt.Printf("%f\n",aDouble2)
// %e 指数表示法,如 e+02 表示 10^2。
fmt.Printf("%e\n", aDouble3)
3.3 布尔值
布尔值在 Go 语言中写做 bool
,它仅有两种可能,false
或者是 ture
。布尔计算可能会引起短路结果:以 expr1 && expr2
为例,如果 expr1
的结果为 false
,那么这个表达式的整体结果一定是 false
,因此程序不会再去计算 expr2
表达式。对于 ||
,则是当 expr1
为 false
时,程序才会去计算 expr2
表达式。
总体来说,如果仅通过表达式左边就能确定计算结果的话,那么之后的所有表达式计算都会被忽略掉。此外,Go 语言中的布尔值不会被隐式地转换成数值类型,比如 0 或者 1 。
3.4 字符串与 Unicode
Go 语言中的字符串类型直接使用关键字 string
。任何对某一个字符串的操作只会返回一个新的字符串序列,而不会改变原来的字符串本身。
使用 len
函数获取字符串长度,得到的将不是文本长度,而是该文本所占用字节数。 如果以 切片 的形式截取字符串,以直接获取字符串某个位置上的字符,需要深入到 "其底层的字节切片" 中去。
var aString string = "你好,我是 Go"
// 在 UTF-8 编码格式中,一个汉字占 3 个存储字节。
// 这个字符串有 4 个汉字,1个全角标点符号,占 5 x 3 = 15 bytes;
// 还有一个空格,两个字母,这些 ASCII 符号在 UTF-8 编码格式只占用 1 个字节,因此它们仅占 3 x 1 = 3 bytes;
// 因此,这个字符串一共占用 18 bytes.
println(len(aString))
// "好,我是 Go"
println(aString[3:])
// "好,我是"
println(aString[3:len(aString) - 3])
下面的例子能够体现字符串的不可变性:
var s = "hello"
var f = s
// s 指向了一个新的引用 "hello,world",但是原 "hello" 字符串没有变化,因此 f 仍然是安全的。
s += ",world"
println(s)
// f 仍然输出 "hello"
println(f)
字符串的不可变性使得字符串子串可以安全地共享同一段底层内存,从而避免额外的开销。
系统中的一段内存:
h e l l o , w o r l d
↑-----------|-------|
var s1 |
len=11 ↑-------|
var s2
len =5
var s1 = "hello,world"
var s2 = "world"
3.5 Go 与 Java 如何抽取字符串中的一个汉字?
3.5.1 Java 方
Java 的 .class
文件不采用任何额外的编码格式而直接使用 Unicode 码元存储来表示所有的符号,包括 ASCII 符号,汉语,日语,或者是其它东亚国家的语言文字。在 Java 中,一个 char
类型数据占据 2 个字节,而这 2 个字节正好是一个 Unicode 码元的长度。
换句话说,在 JVM 运行期间,一个 2 字节长度 char
足够表示一个汉字了 ( 前提是非生僻字 )。
char a = "你";
System.out.println(a);
但是,在编译成二进制文件之前,源代码是作为字符文件 ( 或称文本 ) 存在的。由于它们要能够被人类所识别,因此文本对应的字节序列要提前被 IDE 经过 UTF-8 编码 ( 大部分情况下 ) 之后呈现给用户。
如果直接使用二进制格式查看 Java 源文件的字节序列的话,可以发现一个汉字以 3 个字节来存储,这三个字节是一个汉字对应的 Unicode 码元在 UTF-8 编码中的表现形式,而非 Unicode 字符码元本身。
3.5.2 Go 方
之前的例子已经演示了:如果要尝试从 string
中提取一个汉字,得找到该汉字对应的 byte
切片。由于 Go 语言统一采用 UTF-8 编码而非直接使用 Unicode 码元,因此一个汉字会需要 3 个 byte
长度存储。
在 Java 程序中,可以很轻易地提取出这个字符串的 "第6个字":"世"。
String s = "hello,世界";
System.out.println(s.charAt(6));
但是在 Go 程序中,目前的办法只能是通过计算汉字对应的字节序列提取它:
var s string = "hello,世界";
// 总长: 6 x 1 + 3 x 2 = 12;
// 下标: 0 - 11
// 表达 "世" 的字节下标在 6-8 之间
print(s[6:9])
在 Go 程序中,用于表达 字符 的数据类型被称之为 rune
( 它是 int32
的别称,因为实际上 UTF-8 最多会用 4 个字节存储一个符号 ),它是一种类似于 Java char
的数据类型。
为了实现查询 "第 x 个字符",而非 "第 x-y 个字节表示的字符",不如先将 string
转化成一段 []rune
切片:
var s = "hello,世界"
r := []rune(s)
// 第六个字 "世"。存粹的 r[i] 是编码数字,因此还要转回到 string 再输出.
println(string(r[6]))
// 第七个字 "界"。存粹的 r[i] 是编码数字,因此还要转回到 string 再输出.
println(string(r[7]))
// "世界"。不要忘了 arr[a:b] 指的是 [a,b) 区间。
println(string(r[6:8]))
如果要像 Java 那样让 Go 去查询字符串的 "字符数" 而非 "字节数",可以直接通过 len
函数查看转换后的 []renu
的长度。如果不想因此声明一个新变量,也可以通过引入 utf8
包的方式来解决:
import "unicode/utf8"
//...
var s = "hello,世界"
// 得到的是 8,而非 12。
println(utf8.RuneCountInString(s))
3.6 有关于字符串操作的标准包
其中有 3 个标准包对字符串操作有很大帮助:
strings
包:提供对字符串的搜索,替换,修正,切分,连接等基础功能,这里仅给出一个简单示例:
s := "hello,世界,hello"
// 查询 hello 在字符串 s 中出现的个数。
_ = strings.Count(s,"hello")
// 查询 hello 是否出现在 s 中。
_ = strings.Contains(s,"hello")
strconv
包:专门用于将字符串和其它数据结构进行相互转换。比如这里给出一个在 C 语言比较熟悉的 atoi
函数:
atoi, err := strconv.Atoi(s)
if err != nil {
log.Fatalf(err.Error())
} else {
var aInt = atoi
fmt.Printf("被转换的int值:%v",aInt)
}
其它数据结构对字符串的转化,可以调用各种 PauseXXX
方法。字符串对其它数据结构的转化,可以调用各种 FormatXXX
方法。
unicode
包:适用于判断单个字符内容,比如 IsDigit
,IsLetter
,IsUpper
或者是 IsLower
方法。
3.7 常量与枚举
变量的修饰符是 var
,相对的,常量的修饰符则是 const
。显然,常量的值是不可改变的。
const Pi = 3.1415
如果要声明一组常量,可以使用小括号括起来。格式如下:
const(
// <常量名> [= 值]
Apple = 1
Orange = 2
// 复用上一个 Orange 的值。
Banana
// 复用上一个 Banana 的值。
Malon
)
在该写法中,如果只声明常量名,而不声明值,则该常量复用声明列表中上一个常量的类型和值。比如上述代码块的常量 Banana = 2
。
3.7.1 常量生成器 iota
如果要实现这样一个功能:声明一连串的常量,并且希望程序自动按顺序标识为 0
,1
,... 以此类推,可以借助常量生成器 iota
来完成:
const(
Sunday = iota
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday
)
fmt.Printf("Saturday = %v",Saturday)
iota
赋予的是一个无类型 int 值。就这个例子而言,如果希望这些常量的类型更加接近语义,而重新为其定义为 WeekDay
类型,还可以这样做:
type Weekday int
const(
Sunday Weekday = iota
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday
)
从形式上来看,这个 Weekday 类型可以被认为是一个枚举 ( Enumeration )。
3.7.2 无类型常量
在 Go 语言中,常量可以从属于某一种详细的数据类型,比如 int64
,或者是 float64
。但是在大多数情况下,常量可以不属于某一个具体类型。此时它们被称之为无类型 ( untyped
) 常量。无类型常量包含了 6 种:无类型整数,无类型布尔,无类型文字符号 rune
,无类型浮点数,无类型复数,无类型字符串。
至于为什么会存在无类型常量,是因为 Go 语言的设计者希望常量总是能够维持一个尽可能高的精度。以圆周率为例,math.Pi
便是一个无类型浮点数。如果 math.Pi
一开始就被设定为 float64
,那么其圆周率的有效数字就被直接限定为了 64 位。
下面再看一个例子:
const VeryLarge1= 1e999999
const VeryLarge2 = 1e999998
// 这里发生了一步隐式转换: untyped int => int8
const plainInt int8 = VeryLarge1 / VeryLarge2
println(plainInt)
其中,两个 VeryLarge
常量都是足够大的数,即便是 int64
也不能将他们完整地保存下来。但是,它们却可以作为无类型整数保存,甚至程序能够正确计算 VeryLarge1 / VeryLarge2
表达式。无类型常量可以表示的数值范围仍然是有限的,但是它们都比通常的整数类型,浮点数类型要大得多。
不管怎样,定义常量的目的还是为了使用它。当无类型常量被赋值给一个变量时,程序将隐式地进行类型转换。比如:
const iConst = 10000000
var aInt32 int32 = iConst // untyped int => int32
这时,如果无类型常量是一个像 VeryLarge
那样很夸张的数值,程序就容易因为溢出而报错。