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)。

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

如果是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语句,先供你参考。

package main

import (
"fmt"
) var container = []string{"zero", "one", "two"} func main() {
container := map[int]string{0: "zero", 1: "one", 2: "two"} // 方式1。
_, ok1 := interface{}(container).([]string)
_, ok2 := interface{}(container).(map[int]string)
if !(ok1 || ok2) {
fmt.Printf("Error: unsupported container type: %T\n", container)
return
}
fmt.Printf("The element is %q. (container type: %T)\n",
container[1], container) // 方式2。
elem, err := getElement(container)
if err != nil {
fmt.Printf("Error: %s\n", err)
return
}
fmt.Printf("The element is %q. (container type: %T)\n",
elem, container)
} func getElement(containerI interface{}) (elem string, err error) {
switch t := containerI.(type) {
case []string:
elem = t[1]
case map[int]string:
elem = t[1]
default:
err = fmt.Errorf("unsupported container type: %T", containerI)
return
}
return
}

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

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

知识扩展

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

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

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

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

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

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

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

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

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

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

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

变量srcInt的值是int16类型的-255,而变量dstInt的值是由前者转换而来的,类型是int8。int16类型的可表示范围可比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 标准?我会首先推荐你去它的官方网站 https://home.unicode.org/ 一探究竟。

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

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

type MyString = string

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

源类型与别名类型是一对概念,是两个对立的称呼。别名类型主要是为了代码重构而存在的。更详细的信息可参见 Go 语言官方的文档Proposal: Type Aliases https://go.googlesource.com/proposal/+/master/design/18130-type-alias.md

Go 语言内建的基本类型中就存在两个别名类型。byte是uint8的别名类型,而rune是int32的别名类型。

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

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

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

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

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

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

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

总结

Go 语言中的每个变量都是有类型的,我们可以使用类型断言表达式判断变量是哪个类型的。

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

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

思考题

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

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

欢迎转载、使用、重新发布,但务必保留文章署名 郑子铭 (包含链接: http://www.cnblogs.com/MingsonZheng/ ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。

Go语言核心36讲(Go语言基础知识六)--学习笔记的更多相关文章

  1. Go语言核心36讲(新年彩蛋)--学习笔记

    新年彩蛋 | 完整版思考题答案 基础概念篇 Go 语言在多个工作区中查找依赖包的时候是以怎样的顺序进行的? 答:你设置的环境变量GOPATH的值决定了这个顺序.如果你在GOPATH中设置了多个工作区, ...

  2. Go语言核心36讲(Go语言基础知识三)--学习笔记

    03 | 库源码文件 在我的定义中,库源码文件是不能被直接运行的源码文件,它仅用于存放程序实体,这些程序实体可以被其他代码使用(只要遵从 Go 语言规范的话). 这里的"其他代码" ...

  3. Go语言核心36讲(导读)--学习笔记

    目录 开篇词 | 跟着学,你也能成为Go语言高手 导读 | 写给0基础入门的Go语言学习者 导读 | 学习专栏的正确姿势 开篇词 | 跟着学,你也能成为Go语言高手 Go 语言是由 Google 出品 ...

  4. Go语言核心36讲(Go语言进阶技术八)--学习笔记

    14 | 接口类型的合理运用 前导内容:正确使用接口的基础知识 在 Go 语言的语境中,当我们在谈论"接口"的时候,一定指的是接口类型.因为接口类型与其他数据类型不同,它是没法被实 ...

  5. Go语言核心36讲(Go语言实战与应用一)--学习笔记

    23 | 测试的基本规则和流程 (上) 在接下来的日子里,我将带你去学习在 Go 语言编程进阶的道路上,必须掌握的附加知识,比如:Go 程序测试.程序监测,以及 Go 语言标准库中各种常用代码包的正确 ...

  6. Go语言核心36讲(Go语言实战与应用二十四)--学习笔记

    46 | 访问网络服务 前导内容:socket 与 IPC 人们常常会使用 Go 语言去编写网络程序(当然了,这方面也是 Go 语言最为擅长的事情).说到网络编程,我们就不得不提及 socket. s ...

  7. Go语言核心36讲(Go语言进阶技术四)--学习笔记

    10 | 通道的基本操作 作为 Go 语言最有特色的数据类型,通道(channel)完全可以与 goroutine(也可称为 go 程)并驾齐驱,共同代表 Go 语言独有的并发编程模式和编程哲学. D ...

  8. Go语言核心36讲(Go语言进阶技术七)--学习笔记

    13 | 结构体及其方法的使用法门 我们都知道,结构体类型表示的是实实在在的数据结构.一个结构体类型可以包含若干个字段,每个字段通常都需要有确切的名字和类型. 前导内容:结构体类型基础知识 当然了,结 ...

  9. Go语言核心36讲(Go语言进阶技术九)--学习笔记

    15 | 关于指针的有限操作 在前面的文章中,我们已经提到过很多次"指针"了,你应该已经比较熟悉了.不过,我们那时大多指的是指针类型及其对应的指针值,今天我们讲的则是更为深入的内容 ...

  10. Go语言核心36讲(Go语言进阶技术十一)--学习笔记

    17 | go语句及其执行规则(下) 知识扩展 问题 1:怎样才能让主 goroutine 等待其他 goroutine? 我刚才说过,一旦主 goroutine 中的代码执行完毕,当前的 Go 程序 ...

随机推荐

  1. Redis-技术专区-帮从底层彻底吃透AOF技术原理

    AOF持久化方式 AOF持久化方式是将redis的操作日志以追加的方式写入磁盘文件中.AOF持久化是以日志的形式记录服务器所处理的每一个写.删除操作,查询操作不会记录,以文本的方式记录,可以打开文件看 ...

  2. openresty lua_ssl_trusted_certificate 问题

    lua_ssl_trusted_certificate 语法: lua_ssl_trusted_certificate 默认: no 环境: http, server, location 指定一个 P ...

  3. thymeleaf+layui加载页面渲染时报错

    将freemaker替换成thymeleaf时出现以下问题: org.thymeleaf.exceptions.TemplateProcessingException: Could not parse ...

  4. 【良心保姆级教程】java手把手教你用swing写一个学生的增删改查模块

    很多刚入门的同学,不清楚如何用java.swing去开发出一个系统? 不清楚如何使用java代码去操作数据库进行增删改查一些列操作,不清楚java代码和数据库(mysql.sqlserver)之间怎么 ...

  5. Linux环境搭建及项目部署

    一. VMWare安装图解 1.点击下一步 2.接受条款,下一步 3.选择安装目录,不建议有中文目录和空格目录.下一步 4.下一步 5.这两个选项根据可以爱好习惯选择,下一步 6.安装 7.完成 9. ...

  6. Python中正则表达式简介

    目录 一.什么是正则表达式 二.正则表达式的基础知识 1. 原子 1)普通字符作为原子 2)非打印字符作为原子 3) 通用字符作为原子 4) 原子表 2. 元字符 1)任意匹配元字符 2)边界限制元字 ...

  7. 用Python实现童年的21款小游戏,有你玩过的吗?(不要错过哦)

    Python为什么能这么火热,Python相对于其他语言来说比较简单,即使是零基础的普通人也能很快的掌握,在其他方面比如,处于灰色界的爬虫,要VIP的视频,小说,歌,没有爬虫解决不了的:数据挖掘及分析 ...

  8. PHP中国际化的字符串比较对象

    在 PHP 中,国际化的功能非常丰富,包括很多我们可能都不知道的东西其实都非常有用,比如说今天要介绍的这一系列的字符排序和比较的功能. 排序 正常来说,如果我们对数组中的字符进行排序,按照的是字符的 ...

  9. PHP中的文件对比扩展

    文件对比这个扩展现在用得比较少,因为大部分情况下我们都在使用一些代码管理工具,比如 Git 或者 Svn 之类的,其实它的作用就非常类似这类工具,另外还有一个非常常用的 Beyond Compare ...

  10. PHP获取目录中的全部内容RecursiveDirectoryIterator

    这次我们来介绍一个SPL库中的目录迭代器,它的作用其实非常简单,从名字就可以看出来,就是获取指定目录下的所有内容.之前我们要遍历目录获取目录及目录下的所有文件一般是需要进行递归遍历的,自己写这个代码说 ...