疑惑: Go const 会导致程序结果错乱 ?

2020-12-17
6分钟阅读时长

const 是 Go 里面我们经常使用的关键字, 基本上很难玩出花来. 不过某些特殊情况下 const 会出现你意想不到的结果

场景模拟

某公司某次营销活动中, 会根据用户 VIP 级别送用户一些优惠券, 最大面值520. 某用户发现自己购买的 500 元钱的商品, 使用 520 的优惠券来支付, 理论上能 0 元购买的商品, 最后却需要支付一个天文数字.

这个场景是我自己随便想的, 如果过于夸张, 请原谅我. ^^ 下面我们用代码大概模拟下这个场景:

func main() {
	var totalPrice uint32 = 500
	const couponPrice = 550

	fmt.Println("用户需要支付金额: ", totalPrice-couponPrice)
}

先别运行程序, 你觉得应该返回的结果是多少?

A. 程序无法编译
B. -50
C. 50
D. 4294967246

结果是 D, 你会不会觉得很意外?

一些疑问:

  1. 500 - 550 的结果为什么不是 -50 ?
  2. 你是否注意过 const 的类型 ?
  3. 如果你注意过 const 类型, 为什么程序能正常编译 ?

500 - 550 的结果为什么不是 -50 ?

重写再写一段新的代码, 我们把数值缩小一点, 方便后面的阐述

func main() {
	var totalPrice uint8 = 1
	var couponPrice uint8 = 2
	fmt.Println("用户需要支付金额: ", totalPrice-couponPrice)
}

结果: 用户需要支付金额: 255

你是否听说过 原码, 反码, 补码 这三个概念? 如果不知道的话, 请继续往下看马上就能揭开天文数字的真相.

原码

使用二进制如何标识 1 和 -1 呢?

 1  :  0000 0001
-1  :  1000 0001

我们通过对比能很快发现第一位是符号位, 这其实是易于人来理解和计算的一种表示方式, 这个表示方式叫: 原码. 计算机本身就只识别 0(低电位) 和 1(高电位), 说的再彻底点 0 和 1 其实就是 CPU 逻辑运算单元里面的二极管的导通和阻断. 这么看来, 如果让一个 1 来代表符号位, 能让计算机能识别出来, 并且还让这一位不参与计算, 这基本是不现实的.

这怎么办呢? 有人曾经说过: 计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决? 同样对于 原码 也可以转换成计算机能够识别二进制编码.

反码

接下来我们看另外一种表示方式, 使用 1 - 1 = 0 来解释. 假如我们符号位也参与计算, 同时让正数的二进制保持不变. 负数的二进制符号位保持不变, 其他位按位取反.

1 - 1 => 0000 0001 + 1111 1110 = 1111 1111  

这其实就是反码. 我们将1111 1111转换成我们能识别的原码就是1000 0000, 其实也就是 -0. 于是就出现另外一个问题, 0000 0000也代表 0. 于是就出现了 1000 00000000 0000 的原码都代表 0. 这是不行的.

补码

终极奥义出场. 同样让符号位参与计算, 我们让正数的二进制保持不变, 负数的二进制的符号位保持不变, 其余各位取反, 最后+1, 其实负数的补码就是这个先找个数的反码, 然后反码加 1.

于是 1 - 1 就可以标识为:

1 - 1 => 0000 0001 + 1111 1110  =>  0000 0001 + 1111 1111  => 0000 0000(补码)
         ---------   ---------      ---------   ---------
		    反码         反码           补码         补码

补码 0000 0000 其实也是原码, 也就是 0. 这下就没问题了.

1 - 2 的问题

我们使用补码来解释下 1 - 2的结果:

1 - 2 => [0000 0001]反 + [1111 1101]反 => [0000 0001]补 + [1111 1110]补 = 1111 1111(补码)

将补码 1111 1111 转换成原码 1000 0001 => -1

看到这里你是不是又奇怪了? 结果明明是: 4294967295.

对于写 Java 同学来说, 他们绝大多数场景下 int 代表一个整数就足够了. 对于 Php 那就更放肆了, 不仅不关心数字的大小, 有可能传给你一个 “2” 来当整数 2 用. 但你是否注意到 golang 里面分有符号和无符号类型的数, 如 int8 和 uint8 ?

所以, 我们能看出来, 有符号数的减法基本在我们认知范围之内. 那无符号数为何就如何神奇呢?

无符号数

func main() {
	var totalPrice uint8 = 1
	var couponPrice uint8 = 2
	fmt.Println("用户需要支付金额: ", totalPrice-couponPrice)
}

还是这段代码, 我们看到 totalPrice, couponPrice 都是 uint32 类型的整数, 他们都是无符号类型的整数. 所以当看到程序用uintx来定义变量的, 这个变量就是无符号类型的.

为什么 Go 不像 Java 那样一个 int 类型吃遍天呢, 搞出无符号类型的目的何在?

  1. 有符号数是可以表示负数的. 如 int8 的范围区间是[-128, 127]. 而有些场景下我们只想要正数, 那么就可以用无符号数来表示, 同样 uint8 就可以代表 [0, 255]
  2. 其实我觉得更大的可能性是由于 Go 是那帮写 C 的人写的, 他们继续沿用了 C 里面这个传统的数值表示方式.

无符号的数的减法来说, 我们要把 1 - 2 同样也看成 1 + (-2), 于是同样需要将 -2 转换成补码形式.

 1 - 2 => [0000 0001]反 + [1111 1101]反 => [0000 0001]补 + [1111 1110]补 = 1111 1111(补码)

由于无符号数的加减结果仍然是无符号数, 那么 1111 1111 就是一个无符号的数, 所以最高位不是符号位, 正数的补码和原码一样, 于是 1 - 2 的结果就是 255.

我们现在用的是 uint8 类型的数, 如果换成 uint16, uint32, uint64 会怎么样呢? 同样的 1-2 对于 uint64 结果就会变成 18446744073709551615. 是不是很夸张???

我们上面都说的减法, 那加法会不会出现这个情况呢? 这个我就不在展开了, 聪明的你可以试试下面的代码, 然后思考下为啥?

func main() {
	var totalPrice uint8 = 255
	var couponPrice uint8 = 1
	fmt.Println("用户需要支付金额: ", totalPrice+couponPrice)
}

终于说完了 Go 的无符号类型的计算过程, 我们由此可以看出, Go 在做加减预算时要注意下面几点:

  1. 选择不同的类型的变量时, 要特别注意该类型的取值范围, 防止数值越界
  2. 节省计算机资源. 声明同一个变量, 使用 int8 占一个字节, uint32 就占用 4 个字节
  3. 在使用 uint 家族的类型的变量做减法时, 一定要判断变量的大小, 不然开篇我说的场景就是你们线上将要发生的事情.

你是否注意过 const 的类型 ?

func main() {
	var totalPrice uint8 = 1
	var couponPrice int8 = 2
	fmt.Println("用户需要支付金额: ", totalPrice+couponPrice)
}

这段代码你觉得结果是什么?

A. 程序无法编译
B. -1
C. 255
D. 4294967246

结果是 A, 你是不是又很意外. 其实你应该不意外才对, Go 不支持隐式类型转换, 不同种类型的变量之间不能直接做相互转换, 必须做类型的强转. 上面的代码是编译不过的.

其实类型直接做强转有的时候也是会有问题的. 我这里就不详细展开了, 不然就越扯越多了. 请自行执行下面这段代码的结果, 然后分析一下吧, 原因还是还是上面说的, 一定要注意类型的取值范围.

func main() {
	var a int64 = 922372037
	fmt.Println("a:", a)
	
	var b int8 = int8(a)
	fmt.Println("b:", b)
}

结果:

a: 922372037
b: -59

继续回到 const 的类型上面, 我猜大部分人其实没关注过 const 的类型吧

func main() {
	const a = 10
	fmt.Println("type:", reflect.TypeOf(a))
}

结果: type: int

再回到开篇的那段代码, 为啥就可以正常编译过而且能输出一个不正确的结果呢?

func main() {
	var totalPrice uint32 = 500
	const couponPrice = 550

	fmt.Println("用户需要支付金额: ", totalPrice-couponPrice)
}

const 仍然和普通变量的默认类型保持一致. 整数常量的默认类型是 int, 浮点数常量的默认类型是 float64, 字符串常量的默认类型是 string

这里就要到说到 Go 的特殊法则:

Go 里没有默认类型是无符号类型的整数变量, 但是为了灵活, 可以使用 untyped const 类型的变量给无符号类型变量赋值(这是官方博客里的话). 这也就是意味着 untyped const 的变量打破了 Go 里面不同类型之间不能做隐式类型转换的规定. 于是 const 变量可以给同族类型之间的变量做任意的加减乘除. 这就造成了一个 uint32 类型数减一个 unsigned const int 类型的数搞出来一个天文数字出来. 这里要注意的是: constant 分为 typed 和 untyped,只有untyped才能隐式转换.

一些总结吧

  1. Go 里面提供了多种多样的类型的变量, 使用对了固然可以让程序节省更多的资源, 但是使用时要特别注意选择适当的类型, 避免造成一些莫名其妙的问题. 推荐大家程序里尽量使用 int 类型, 其实 int 就是 int32 类型, 与 Java 的 int 的数值范围是一样的 [-2147483648, 2147483647]. 我们现在的服务器真的不缺这点资源. 除非你真的特别能把握这个变量的大小.
  2. 做无符号类型的加法时, 要特别注意, 一定要先去判断做减法的变量的大小.

本篇题目是 const 导致程序结果错乱 ?, 其实也并不全是 const 造成的, 归根到底是无符号类型的数在做减法运算时的坑. 我们写 Go 的时候要注意这一点. 当然 const 的类型大家也可以留心下, 说不定就是你下次的面试题.

看完本篇文章, 你是不是对 Go 的类型有了新的认识, 欢迎关注我的公众号: HHFCodeRv 交流