06 | 程序实体的那些事儿 (下)

在上一篇文章,我们一直都在围绕着可重名变量,也就是不同代码块中的重名变量,进行了讨论。

还记得吗?最后我强调,如果可重名变量的类型不同,那么就需要引起我们的特别关注了,它们之间可能会存在“屏蔽”的现象。

必要时,我们需要严格地检查它们的类型,但是怎样检查呢?咱们现在就说。

我今天的问题是:怎样判断一个变量的类型?

我们依然以在上一篇文章中展示过的demo11.go为基础。

package main

import "fmt"

var container = []string{"zero", "one", "two"}

func main() {
	container := map[int]string{0: "zero", 1: "one", 2: "two"}
	fmt.Printf("The element is %q.\n", container[1])
}

那么,怎样在打印其中元素之前,正确判断变量container的类型?

典型回答

答案是使用“类型断言”表达式。具体怎么写呢?

value, ok := interface{}(container).([]string)

这里有一条赋值语句。在赋值符号的右边,是一个类型断言表达式。

它包括了用来把container变量的值转换为空接口值的interface{}(container)

以及一个用于判断前者的类型是否为切片类型 []string.([]string)

这个表达式的结果可以被赋给两个变量,在这里由valueok代表。变量ok是布尔(bool)类型的,它将代表类型判断的结果,truefalse

如果是true,那么被判断的值将会被自动转换为[]string类型的值,并赋给变量value,否则value将被赋予nil(即“空”)。

顺便提一下,这里的ok也可以没有。也就是说,类型断言表达式的结果,可以只被赋给一个变量,在这里是value

但是这样的话,当判断为否时就会引发异常。

这种异常在Go语言中被叫做panic,我把它翻译为运行时恐慌。因为它是一种在Go程序运行期间才会被抛出的异常,而“恐慌”二字是英文Panic的中文直译。

除非显式地“恢复”这种“恐慌”,否则它会使Go程序崩溃并停止。所以,在一般情况下,我们还是应该使用带ok变量的写法。

问题解析

正式说明一下,类型断言表达式的语法形式是x.(T)。其中的x代表要被判断类型的值。这个值当下的类型必须是接口类型的,不过具体是哪个接口类型其实是无所谓的。

所以,当这里的container变量类型不是任何的接口类型时,我们就需要先把它转成某个接口类型的值。

如果container是某个接口类型的,那么这个类型断言表达式就可以是container.([]string)。这样看是不是清晰一些了?

在Go语言中,interface{}代表空接口,任何类型都是它的实现类型。我在下个模块,会再讲接口及其实现类型的问题。现在你只要知道,任何类型的值都可以很方便地被转换成空接口的值就行了。

这里的具体语法是interface{}(x),例如前面展示的interface{}(container)

你可能会对这里的{}产生疑惑,为什么在关键字interface的右边还要加上这个东西?

请记住,一对不包裹任何东西的花括号,除了可以代表空的代码块之外,还可以用于表示不包含任何内容的数据结构(或者说数据类型)。

比如你今后肯定会遇到的struct{},它就代表了不包含任何字段和方法的、空的结构体类型。

而空接口interface{}则代表了不包含任何方法定义的、空的接口类型。

当然了,对于一些集合类的数据类型来说,{}还可以用来表示其值不包含任何元素,比如空的切片值[]string{},以及空的字典值map[int]string{}

(类型断言表达式)

我们再向答案的最右边看。圆括号中[]string是一个类型字面量。所谓类型字面量,就是用来表示数据类型本身的若干个字符。

比如,string是表示字符串类型的字面量,uint8是表示8位无符号整数类型的字面量。

再复杂一些的就是我们刚才提到的[]string,用来表示元素类型为string的切片类型,以及map[int]string,用来表示键类型为int、值类型为string的字典类型。

还有更复杂的结构体类型字面量、接口类型字面量,等等。这些描述起来占用篇幅较多,我在后面再说吧。

针对当前的这个问题,我写了demo12.go。它是demo11.go的修改版。我在其中分别使用了两种方式来实施类型断言,一种用的是我上面讲到的方式,另一种用的是我们还没讨论过的switch语句,先供你参考。

可以看到,当前问题的答案可以只有一行代码。你可能会想,这一行代码解释起来也太复杂了吧?

千万不要为此烦恼,这其中很大一部分都是一些基本语法和概念,你只要记住它们就好了。但这也正是我要告诉你的,一小段代码可以隐藏很多细节。面试官可以由此延伸到几个方向继续提问。这有点儿像泼墨,可以迅速由点及面。

知识扩展

问题1. 你认为类型转换规则中有哪些值得注意的地方?

类型转换表达式的基本写法我已经在前面展示过了。它的语法形式是T(x)

其中的x可以是一个变量,也可以是一个代表值的字面量(比如1.23struct{}{}),还可以是一个表达式。

注意,如果是表达式,那么该表达式的结果只能是一个值,而不能是多个值。在这个上下文中,x可以被叫做源值,它的类型就是源类型,而那个T代表的类型就是目标类型。

如果从源类型到目标类型的转换是不合法的,那么就会引发一个编译错误。那怎样才算合法?具体的规则可参见Go语言规范中的转换部分。

我们在这里要关心的,并不是那些Go语言编译器可以检测出的问题。恰恰相反,那些在编程语言层面很难检测的东西才是我们应该关注的。

很多初学者所说的陷阱(或者说坑),大都源于他们需要了解但却不了解的那些知识和技巧。因此,在这些规则中,我想抛出三个我认为很常用并且非常值得注意的知识点,提前帮你标出一些“陷阱”。

首先,对于整数类型值、整数常量之间的类型转换,原则上只要源值在目标类型的可表示范围内就是合法的。

比如,之所以uint8(255)可以把无类型的常量255转换为uint8类型的值,是因为255在[0, 255]的范围内。

但需要特别注意的是,源整数类型的可表示范围较大,而目标类型的可表示范围较小的情况,比如把值的类型从int16转换为int8。请看下面这段代码:

var srcInt = int16(-255)
dstInt := int8(srcInt)

变量srcInt的值是int16类型的-255,而变量dstInt的值是由前者转换而来的,类型是int8int16类型的可表示范围可比int8类型大了不少。问题是,dstInt的值是多少?

首先你要知道,整数在Go语言以及计算机中都是以补码的形式存储的。这主要是为了简化计算机对整数的运算过程。(负数的)补码其实就是原码各位求反再加1。

比如,int16类型的值-255的补码是1111111100000001。如果我们把该值转换为int8类型的值,那么Go语言会把在较高位置(或者说最左边位置)上的8位二进制数直接截掉,从而得到00000001

又由于其最左边一位是0,表示它是个正整数,以及正整数的补码就等于其原码,所以dstInt的值就是1

一定要记住,当整数值的类型的有效范围由宽变窄时,只需在补码形式下截掉一定数量的高位二进制数即可。

类似的快刀斩乱麻规则还有:当把一个浮点数类型的值转换为整数类型值时,前者的小数部分会被全部截掉。

第二,虽然直接把一个整数值转换为一个string类型的值是可行的,但值得关注的是,被转换的整数值应该可以代表一个有效的Unicode代码点,否则转换的结果将会是"�"(仅由高亮的问号组成的字符串值)。

字符'�'的Unicode代码点是U+FFFD。它是Unicode标准中定义的Replacement Character,专用于替换那些未知的、不被认可的以及无法展示的字符。

我肯定不会去问“哪个整数值转换后会得到哪个字符串”,这太变态了!但是我会写下:

string(-1)

并询问会得到什么?这可是完全不同的问题啊。由于-1肯定无法代表一个有效的Unicode代码点,所以得到的总会是"�"。在实际工作中,我们在排查问题时可能会遇到,你需要知道这可能是由于什么引起的。

第三个知识点是关于string类型与各种切片类型之间的互转的。

你先要理解的是,一个值在从string类型向[]byte类型转换时代表着以UTF-8编码的字符串会被拆分成零散、独立的字节。

除了与ASCII编码兼容的那部分字符集,以UTF-8编码的某个单一字节是无法代表一个字符的。

string([]byte{'\xe4', '\xbd', '\xa0', '\xe5', '\xa5', '\xbd'}) // 你好

比如,UTF-8编码的三个字节\xe4\xbd\xa0合在一起才能代表字符'你',而\xe5\xa5\xbd合在一起才能代表字符'好'

其次,一个值在从string类型向[]rune类型转换时代表着字符串会被拆分成一个个Unicode字符。

string([]rune{'\u4F60', '\u597D'}) // 你好

当你真正理解了Unicode标准及其字符集和编码方案之后,上面这些内容就会显得很容易了。什么是Unicode标准?我会首先推荐你去它的官方网站一探究竟。

问题2. 什么是别名类型?什么是潜在类型?

我们可以用关键字type声明自定义的各种类型。当然了,这些类型必须在Go语言基本类型和高级类型的范畴之内。在它们当中,有一种被叫做“别名类型”的类型。我们可以像下面这样声明它:

type MyString = string

这条声明语句表示,MyStringstring类型的别名类型。顾名思义,别名类型与其源类型的区别恐怕只是在名称上,它们是完全相同的。

源类型与别名类型是一对概念,是两个对立的称呼。别名类型主要是为了代码重构而存在的。更详细的信息可参见Go语言官方的文档Proposal: Type Aliases

Go语言内建的基本类型中就存在两个别名类型。byteuint8的别名类型,而runeint32的别名类型。

一定要注意,如果我这样声明:

type MyString2 string // 注意,这里没有等号。

MyString2string就是两个不同的类型了。这里的MyString2是一个新的类型,不同于其他任何类型。

这种方式也可以被叫做对类型的再定义。我们刚刚把string类型再定义成了另外一个类型MyString2


(别名类型、类型再定义与潜在类型)

对于这里的类型再定义来说,string可以被称为MyString2的潜在类型。潜在类型的含义是,某个类型在本质上是哪个类型。

潜在类型相同的不同类型的值之间是可以进行类型转换的。因此,MyString2类型的值与string类型的值可以使用类型转换表达式进行互转。

但对于集合类的类型[]MyString2[]string来说这样做却是不合法的,因为[]MyString2[]string的潜在类型不同,分别是[]MyString2[]string。另外,即使两个不同类型的潜在类型相同,它们的值之间也不能进行判等或比较,它们的变量之间也不能赋值。

总结

在本篇文章中,我们聚焦于类型。Go语言中的每个变量都是有类型的,我们可以使用类型断言表达式判断变量是哪个类型的。

正确使用该表达式需要一些小技巧,比如总是应该把结果赋给两个变量。另外还要保证被判断的变量是接口类型的,这可能会用到类型转换表达式。

我们在使用类型转换表达式对变量的类型进行转换的时候,会受到一套规则的严格约束。

我们必须关注这套规则中的一些细节,尤其是那些Go语言命令不会帮你检查的细节,否则就会踩进所谓的“陷阱”中。

此外,你还应该搞清楚别名类型声明与类型再定义之间的区别,以及由此带来的它们的值在类型转换、判等、比较和赋值操作方面的不同。

思考题

本篇文章的思考题有两个。

  1. 除了上述提及的那些,你还认为类型转换规则中有哪些值得注意的地方?
  2. 你能具体说说别名类型在代码重构过程中可以起到哪些作用吗?

这些问题的答案都在文中提到的官方文档之中。

戳此查看Go语言专栏文章配套详细代码。

精选留言

  • 思想的宇屋

    2018-12-29 10:45:01

    真棒,这篇涉及到了自学go的gopher比较难涉及到的计算机基础和细节 如补码,类型转换异常时的“❓”
  • pines

    2018-08-24 08:19:08

    正数的补码等于原码,负数的补码才是反码+1
  • 陈悬高

    2018-10-10 10:10:45

    对于大型的代码库来说,能够重构其整体结构是非常重要的,包括修改某些 API 所属的包。大型重构应该支持一个过渡期:从旧位置和新位置获得的 API 都应该是可用的,而且可以混合使用这些 API 的引用。Go 已经为常量、函数或变量的重构提供了可行的机制,但是并不支持类型。类型别名提供了一种机制,它可以使得 oldpkg.OldType 和 newpkg.NewType 是相同的,并且引用旧名称的代码与引用新名称的代码可以互相操作。

    考虑将一个类型从一个包移动到另一个包中的情况,比如从 oldpkg.OldType 到 newpkg.NewType。可以在包 oldpkg 中指定一个新类型的别名 type OldType = newpkg.NewType,这样以前的代码都无需修改。
  • 咖啡色的羊驼

    2018-08-24 00:19:08

    最开始写go时候也在string上遇到过一个小坑。

    由于是之前是phper,习惯性认为go中len("我")应该等于1,后面发现这个遇到字符串时候代表字节数。
  • 胖子(李杰)

    2019-05-16 23:19:46

    php 里面的strlen('你') 也不是1
    mb_strlen('你') 才是 1
  • NIXUS

    2018-10-11 01:01:06

    这节课,是从开始学习该专栏以来,最有价值的一节,没有之一!
    希望剩下的课程,都能像这节课这样有价值!
    作者回复

    尽量做到能让大多数人满意:)

    2018-10-15 12:22:03

  • 李皮皮皮皮皮

    2018-08-24 19:53:51

    1.通过类型断言获取变量实际类型value,ok=x.(T),ok表示是否是正确的类型,value是成功转换后的值,但返回值形式不建议使用,可能会导致panic
    2.go不同类型直接不能相互赋值,不存在隐式类型转换,必须显式强转
    3.type newType = oldType定义类型别名,type newType oldType定义新类型
  • BitInit

    2019-03-20 10:17:20

    对于var str string = "hello 你好",使用len(str)结果是12,因为len(str)显示的string底层字节大小。如果需要str的大小,方法一是len([]rune(str)),将string转为rune切片,方法二是utf8.RuneCountInString(str)。对string进行range遍历时,是以unicode编码遍历的。
  • mkii

    2021-06-09 19:01:05

    type A struct {
    B *B
    }

    func (a *A) Print() {
    a.B.Print()
    }

    type B struct {
    str string
    }

    func (b *B) Print() {
    //if b.str == ""{
    fmt.Println("1234")
    //}else{
    // fmt.Println("str is:",b.str)
    //}
    }

    func TestAPrint(t *testing.T) {
    a := &A{nil}
    a.Print()
    }
    老师,为什么print函数这里不会Panic呀?
    作者回复

    首先需要明确,结构体类型 A 中的字段 B(为了区分,以下称为 b 吧)的类型是一个“指针类型”,即 *B 。而结构体类型 B 携带了一个“指针方法” Print 。这里的“指针类型”和“指针方法”是关键。

    即便 a 的字段 b 是 nil,但是 b 本身的“方法集合一直存在”(即类型 *B 的方法集合),只不过它的值为 nil 罢了。

    正是由于 a 中的字段 b 的“方法集合一直存在”,因此a.b.Print() 这条链路才能走通。又由于类型 B 的方法 Print 的接收者类型为 *B ,所以在该方法执行的时候才不需要为了求接收者 b 的值而进行取值操作(即从 *B 到 B 的转换)。你可以想象一下,若 B 有另外一个方法 func (b B) Show() ,那么执行 a.b.Show() 会怎样。

    你可以再写一些代码,如:

    var b1 *B = nil
    switch interface{}(b1).(type) {
    case C:
    fmt.Println("C")
    default:
    fmt.Println("?")
    }

    执行这段代码会打印出“C”。那你觉得为什么Go编译器能够知道 interface{}(b1) 的类型是 C 呢?它就是根据 b1 的方法集合来判断的啊。

    无论 b 和 b1 的值是什么,只要它们有类型,它们的方法集合就是固定的。它们的“方法集合一直存在”!

    2021-06-10 16:18:35

  • 沐夜星光

    2020-01-06 20:28:47

    既然有了别名类型,为什么还要再搞个别名再定义,两者的应用场景有什么区别?
    作者回复

    两者都是为了隔离变化。

    别名类型“另起炉灶”的只是名字,主要是向上层应用隐藏下层类型的真实名称。这样一来,下层类型体系在重构的时候就可以换名字了(不会影响到上层代码)。

    类型再定义更彻底一些。主要是为了另外构建一个与潜在类型有所关联(有一定的互操作性)但又不同的类型。基于这个新类型,我们可以构建另外一套操作或者类型体系,而不用去改动潜在类型(或者说潜在类型所在的体系)。

    2020-01-06 23:07:26

  • 极客—月

    2021-05-17 21:24:01

    怎样判断变量类型 => 类型断言

    格式:value, ok := x.(T)

    其中T是判断类型,x是要判断类型的值【x必须是接口类型】。

    ok是类型判断的结果,如果ok是true,则value就是x转换为T类型的值。

    如果ok是false,value会被赋值为nil。

    当写成value := x.(T)时,即省去ok,若转换不成功则会报panic



    {}在Go中可表示:

    1. 空代码块
    2. 不包含任何内容的数据类型,如:struct{},interface{}
    3. 不包含任何元素的集合类型,如: []string{},map[int]string{}



    类型字面量:表示数据类型本身的若干个字符,如[]string表示string的切片类型,map[int]string表示key为int类型,value为string类型的字典类型。



    类型转换的三个小坑:

    一、对于整数类型值、整数常量之间的类型转换,只要源值在目标类型的可表示范围内就是合法的。

    需注意的是:

    1. 当整数值的类型的有效范围由宽变窄时,只需在补码形式下截掉一定数量的高位二进制数即可

    2. 当把一个浮点数类型的值转换为整数类型值时,前者的小数部分会被全部截掉

    【这里涉及原码反码补码的知识,可以自己算下int16位的-255转成int8会是多少】



    二、整数值转换为string类型可行,但需注意被转换的整数值应该可以代表一个有效的 Unicode 代码点

    比如:string(-1) 得到字符'�',字符'�'是 Unicode 标准中专用于替换未知的以及无法展示的字符

    因为-1无法代表一个有效的 Unicode代码点



    三、 关于string类型与各种切片类型之间的互转:

    1. 值从string类型向[]byte类型转换时代表以 UTF-8 编码的字符串会被拆分成零散、独立的字节。例如中文会被拆分为三个字节
    2. 值从string类型向[]rune类型转换时代表字符串会被拆分成一个个 Unicode 字符



    别名类型:type MyString = string

    类型再定义:type Mystring2 string 【string可称为MyString2的潜在类型】

    【上面俩的差别就在于多了个"="】

    潜在类型相同的不同类型的值之间可互相转换,但不能赋值、比较

    集合类型[]MyString2与[]string则不能互转【string是MyString2的潜在类型,但[]MyString2的潜在类型是[]MyString2,而[]string的潜在类型是[]string】

  • hello peter

    2018-08-24 11:00:42

    @咖啡色的羊驼 我也是phper,php中strlen('我')的结果应该是3,和go一样,你这习惯应该是js的吧
  • 传说中的成大大

    2020-03-02 14:57:08

    一门新的技术的产生或者语言新特性的产生总是为了解决一些现有的比较棘手的问题
    所以我在想 类型别名和 类型重定义及潜在类型 为什么会同时存在?
    然后提到潜在类型 那么语言自带的类型 比如string是否有潜在类型 如果有是否就是它本身?
    作者回复

    “类型别名”和“类型重定义”其实属于在Go语言的发展过程中相继出现的两个类似产物。但鉴于它们在功能上还是有明显差别的,所以理解起来也算容易。

    “潜在类型”其实更多的是在语言词法分析方面起作用。从程序开发的角度讲,它几乎只与类型转换相关。“潜在类型”是Go语言规范中的死规定,所以记下来就好了。

    2020-03-03 18:07:37

  • Hector

    2019-05-10 14:46:24

    string可以被称为MyString2的潜在类型,那他们的区别到底在哪里呢?底层时做的复制动作,但是指针存放的地址不同吗
    作者回复

    我在文章里说了:“潜在类型相同的不同类型的值之间是可以进行类型转换的”。这属于语法规则。

    它们虽然代表着不同的类型,但本质上是同源的。也就是说,它们的底层结构是相同的。在这样情况下,在类型转换时值会被复制,即两个值会在不同的内存地址上。

    2019-05-10 21:55:04

  • 勇敢的心

    2019-01-05 23:26:58

    要成为kubernetes玩家,必须好好学习go语言!
  • 田佳伟

    2018-09-06 09:14:04

    首先你要知道,整数在 Go 语言以及计算机中都是以补码的形式存储的

    这句话应该是:首先你要知道,负数在 Go 语言以及计算机中都是以补码的形式存储的 吧😄
    作者回复

    正数的补码等于其自身。

    2018-09-06 14:35:15

  • 我来也

    2018-08-24 07:50:42

    类型转换感觉跟c差不多。
    类型别名,我知道的三处优点:1.名字可以取的更通俗易懂;2:需要修改数据类型时,只用改定义的那一处地方;3:可以很方便的添加特有方法,以实现某些接口。
  • 扩散性百万咸面包

    2020-04-08 15:42:34

    其中的x可以是一个变量,也可以是一个代表值的字面量(比如1.23和struct{}{}),还可以是一个表达式。

    struct{}{} 的意思是声明一个空结构然后立刻初始化?
    作者回复

    对, struct{} 是空结构体类型,struct{}{} 是对它的实例化。其实这种实例化总会返回相同的值。你可以把 struct{} 视为单例类型。

    2020-04-13 10:46:50

  • LubinLew

    2020-06-02 11:53:26

    类型定义这部分有点太抽象了,这个文章中用华氏和摄氏的例子非常棒,虽然华氏和摄氏的潜在类型相同,但是他们之间进行直接比较是没有意义的。
    https://www.jianshu.com/p/a02cf41c0520
  • NoTryNoSuccess

    2019-06-27 21:43:40

    container := map[int]string{0: "zero", 1: "one", 2: "two"}
    value, _ := interface{}(container).(int)
    其实这样也不会报错呀,且value为0并不为nil,其中int也可以为其他任何与目标类型map不一致的类型。