第5章〓数值 Kotlin提供了多种类型来处理数值的计算,包括多种整数类型和浮点数(floating point number)类型(也称为带小数点的数值)等。 本章学习Kotlin如何处理这两种类型的变量。bountyboard项目在本章中不会进行任何改动; 相反,将使用REPL对代码进行评估。第6章中再继续bountyboard项目的开发。 5.1数值类型 Kotlin可以支持多种数值类型。无论面向的是哪个平台,这些数值类型的规则都是不变的。从Kotlin 1.5版本开始,Kotlin中的数值类型就包含两种: 有符号(signed)和无符号(unsigned)。有符号数值既可以表示正数也可以表示负数。无符号数值只能表示正数。我们首先讨论有符号数值,在5.6节再讨论无符号数值。 除了数值是否有符号之外,Kotlin的数值类型之间的关键区别还包括: 数值类型在内存中分配的空间大小有所不同,也就是说,能表示的最小值和最大值不同。 如果熟悉Java,这些规则应该就不会陌生。Kotlin数值类型的规则与Java是相同的。对于那些熟悉JavaScript的读者来说,可能会惊讶地看到Java的数值类型有很多种,而JavaScript仅有一种数值类型。Kotlin的每种数值类型都有其自身的含义。 对于打算使用Kotlin/Native的用户来说,也应注意每种类型所分配的内存大小。不管面向哪个平台,Kotlin数值类型都会被分配相同大小的内存(在C语言中,根据程序编译方式的不同,其Int类型被分配的内存大小并不相同)。 表5.1给出了Kotlin中的一些数值类型、每种类型占用的内存大小以及支持的最大值和最小值。 表5.1常用数值类型 类型位数最大值最小值 Byte8127-128 Short1632767-32768 Int322147483647-2147483648 Long649223372036854775807-9223372036854775808 Float323.4028235E381.4E45 Double641.7976931348623157E3084.9E324 不同数值类型所占的比特位数与其最大值和最小值之间是紧密联系的。计算机采用固定位数的二进制形式来存储整数,每个比特位存储一位二进制的0或1。 为了表示数值,Kotlin根据数值类型的不同分配不同数量的比特位。对于有符号数值,最左边的一位表示符号(0表示正,1表示负)。剩余的位分别表示2的幂次方,最右边的位是20。要计算二进制数的值,需将各位上的数按2的幂次方相加。 图5.1给出了数值42的二进制形式。 图5.1数值42的二进制形式 由于数值类型Int占32位,所以Int类型可以存储的最大数,以二进制形式表示就是31个1(最左边的一位代表符号)。所以,将所有2的幂次方相加得到2147483647,这是Kotlin中Int类型所能表示的最大值。 因为数值类型所占的位数决定了可以表示的最大值和最小值,所以两种类型之间的区别就在于可用于表示数字的位数的多少。由于数值类型Long占64位而不是32位,因此Long类型可以表示更大的数值(263)。 对于数值类型Short和Byte来说,此处所谓的长与短(long and short),在表示传统数值时,既不常用Short类型也不常用Byte类型。这两种类型主要用于一些特殊情形,并支持互操作性,通常与遗留程序(legacy programs)一起使用。 例如,当从文件读取数据流或处理图形时,可能就会用到Byte类型(彩色像素通常表示为3字节,每字节代表RGB中的一种颜色)。在与不支持32位指令的CPU的本机代码交互时,有时会用到Short类型。但是,大多数情形下,整数用Int类型表示,如果需要表示更大的数值,则需使用Long类型。 5.2整数 在第2章中已经介绍了整数就是没有小数点的数值,在Kotlin中可以用Int类型表示。Int类型很适合用于表示“物”的数量或进行计数,如玩家的技能水平、经验值、蜂蜜酒的剩余量或玩家拥有的金币和银币的数量等。 为了获得更多关于Int类型的第一手经验,下面将使用REPL执行一些算术运算。单击Tools→Kotlin→Kotlin REPL命令选项。 在REPL中,输入程序清单5.1所示的运算,并先预测一下计算结果。 程序清单5.1执行整数的算术运算(REPL) 2 + 4 * 5 按下组合键Ctrl+Enter运行以上表达式,REPL的输出结果为22。 如上所示,Kotlin的乘法运算符(*、/和%)优先于加法运算符(+、-),与普通数学中的运算优先级是一致的。也就是说,在加2之前,先对乘法4*5进行求解。 如果需要指定不同的运算顺序,可以使用括号对运算进行分组。正如在第3章中看到的,Kotlin会首先计算嵌套在括号内的表达式。 现在,在REPL中输入程序清单5.2所示的运算。同样,不妨先预测一下计算结果。 程序清单5.2执行整数的除法运算(REPL) 9 / 5 计算以上的表达式,可能期望的结果为1.8。但是,REPL实际输出的结果却是1。当用一个整数除以另一个整数时,结果仍会是整数。如果整数除法运算的结果不是整数,Kotlin将截断小数点后的所有数字。同样的,如果要REPL计算9/-5的值,结果将是-1。 整数除法运算的结果总是会四舍五入。这种截断操作是悄无声息的,因此,如果小数点后的数字对应用程序来说很重要,在执行整数除法时需要格外小心。 计算除法余数的一种方法是使用模运算符(modulus operator)%,也称为余数运算符(remainder operator),当一个数除以另一个数时,该运算符会得到其余数。例如,9%5将返回结果4。 以上适用于数值为整数时的运算。对于十进制数值,可以使用浮点类型。 5.3浮点数 在Kotlin中,Float类型和Double类型是两种可以表示十进制数字的数值类型。这些数值也称为浮点数,因为小数点可以出现在任何位置,也就是说,小数点是“浮动的”(而不是固定的),这取决于数值的数量级。 Double类型是双精度浮点数(doubleprecision floating point number)的缩写。Double类型使用的位数是常规Float类型的2倍,由此而得名,并且它可以更精确地存储十进制数。 Kotlin中的浮点数也可以用来表示一些特殊值,如无穷大、负无穷大和NaN(Not A Number的缩写)。这些值通常在执行非法或未定义的操作时返回,如除以零(返回无穷大或负无穷大)或负数的平方根(返回NaN)。可以通过在代码中引用Double.POSITIVE_INFINITY(或Float.POSITVE_INFINITY)、Double.NEGATIVE_ININITY(或Float.NEGATIVE_INFINTITY)和Double.NaN(或Float.NaN)来访问这些特殊值。 在REPL中,重新审视9/5这一整数除法表达式。为了使用浮点除法表示这个表达式,需要告知Kotlin这些数值是浮点数而不是整数,一种方法是采用小数点表示这些数值,具体如程序清单5.3所示。 程序清单5.3执行浮点除法(REPL) 9.0 / 5.0 计算此表达式。REPL输出kotlin.Double=1.8,该结果很符合预期。注意,此表达式的类型是Double类型。默认情形下,Kotlin更喜欢用Double类型,但可以通过在数值中添加f后缀来明确要求将数值视为Float类型: 9.0f/5.0f。 注意: 如果使用f后缀指定数值是浮点数,也可以省略两个数值中的.0。实际上,甚至可以将相同的运算表示为9f/5,因为Kotlin会在至少有一个操作数是浮点数时使用浮点除法来运算。 之前提到过浮点数是有“精度”的。要想了解其含义,在REPL中输入以下表达式。 程序清单5.4造成浮点数的精度出错(REPL) 0.01f * 5 直观地说,这个表达式应该返回0.05。但是,当计算该表达式时,REPL会输出一个结果为0.0499999997的值。现在采用程序清单5.5的代码比较一下这个结果是否等于0.05。 程序清单5.5检查浮点数的精确相等性(REPL) 0.01f * 5 == 0.05f 执行该语句,REPL输出的结果将会是false。为什么呢? 整数的每一位都有特定的含义,永远不会改变。但是对于浮点数来说,情况就不是那么简单了。从二进制的角度来看,浮点数由一个符号位和两个附加的位集组成: 第一个位集确定数值大小的指数; 第二个位集确定所表示的数值的有效位数。 由此可见,浮点数不能精确地表示每个数值,它们只是近似值(approximation)。虽然0.05可以用Float类型精确表示,但0.01不能,最接近0.01的Float类型存储值是0.009999999776482582。当做乘法0.01f*5时,精度的损失会影响结果,从而导致不准确的(但非常接近的!)结果。 为了避免这种精度问题,可以采用以下几个选项。 (1) 首选Double类型而不是Float型: 通过使用更高精度的浮点类型,就可以避免浮点精度错误的问题,但代价是内存使用量增加。注意,使用Double类型仍然不足以完全避免这个问题(例如,在REPL中计算10.15.9)。 (2) 四舍五入浮点数值(round floating point values): 如果确切知道一个浮点数应该有多少位小数,则可以相应地进行四舍五入。Kotlin提供的API可以输出具有特定小数位数的十进制值,并且有round()函数可以将数值四舍五入到最接近的整数。 如果将round()函数与乘法和除法运算相结合,则可以四舍五入到特定的小数位数。例如,round(number*100)/100会将number的值四舍五入到两位小数。 (3) 使用精度更高的其他数据类型(prefer another data type with higher precision): 如果需要存储的是一个关键的十进制值,而四舍五入和精度损失是不可接受的(例如,假设正在开发一款银行软件),可能需要一个更强大的数据类型。 有时,可以使用Int类型表示可能是十进制的数据类型。例如,假设想存储用户的银行账户余额,可以使用Int类型而不是Double类型跟踪以美分为单位的值。 作为最后的选择,在面向JVM的应用中,可以使用BigDecimal类型。BigDecimal类型在执行四舍五入和运算方面更加强大。与基本数值类型相比,它通过增加复杂性回避了精度错误。BigDecimal类型在存储数值和执行算术运算时也会比浮点数用到更多的资源(如果Kotlin代码面向的是iOS或macOS,那么Decimal类型是BigDecimal类型的等效类)。 5.4格式化双精度数值 回到bountyboard奇幻游戏世界中。假设想要追踪Madrigal在游戏中拥有的货币数量,可编写如下代码: val currentBalance = 1120.40 println(currentBalance) 运行此代码,输出Madrigal的银行账户余额为1120.4,且未标明货币单位。此时,更好的做法是对余额进行类似货币的格式化,使其看起来更像是货币。对于北美国家而言,余额应该显示为1120.40美元。可以使用format()函数对一个双精度数值进行格式化处理,包括添加货币或其他符号、千位分隔符以及显示的小数位数等。 首先确定需要的小数位数。在REPL中运行程序5.6所示的代码。 注意: 使用了点语法(dot syntax)来调用format函数。每当调用作为类型定义的一部分的函数时,都可以使用点语法。 程序清单5.6格式化一个双精度数值(REPL) val currentBalance = 1120.40 println("%.2f".format(currentBalance)) REPL的输出为1120.40,看起来可读性更好了。 在对format()函数的调用中指定了一个格式字符串(format string)“%.2f”。格式字符串使用特殊的字符序列定义数据的格式。此处定义的特定格式字符串指定要将浮点数四舍五入到小数第二位,然后将格式化后的值作为实际参数传递给format()函数。 这些格式字符串使用的是与Java、C/C++、Ruby及许多其他语言中的标准字符串格式相同的样式。关于格式字符串规范的详细内容,可参考Java API文档。 若想添加逗号和美元符号,可以将格式字符串修改为“$%,.2f”,如程序清单5.7所示。 程序清单5.7添加货币格式(REPL) val currentBalance = 1120.40 println("$%,.2f".format(currentBalance)) 使用format()函数有几个注意事项。首先,这样做有可能会使应用程序的本地化变得困难。假如将Madrigal的余额显示在用户所在地的语言环境中,那么就需要将美元符号替换为用户所在地适用的货币符号。此外,许多国家使用逗号作为小数点,使用句点作为千位分隔符,这也会增加本地化工作的复杂性。其次,format()函数仅在面向JVM时可用(本书编写时)。如果需要面向其他平台,则需要使用不同的方法来格式化数值。 为了解决以上两个问题,可以使用特定于平台的格式化API。在Java中,使用NumberFormat类可获得相同的效果,代码如下: val currentBalance = 1120.40 val formatter = NumberFormat.getCurrencyInstance() val formattedBalance = formatter.format(currentBalance) println("Madrigal's life savings: " + formattedBalance) 注意: 若想在REPL中运行此代码,还需要添加importjava.text.NumberFormat行。 这样,将自动将Madrigal的储蓄金额转换为适合用户的格式化字符串,具体取决于用户地区的偏好设置。 除了Java风格之外,Android在android.icu.text包中有自己的NumberFormat类。这两个NumberFormat类均可以用于获取类似于区域设置的货币格式的实例。同样,如果Kotlin代码面向的是iOS或macOS,可以使用NSNumberFormatter类。同时,对于Kotlin/JS,可以使用Intl.NumberFormat类来满足数值格式的需求。 本书第六部分介绍如何将NSNumberFormatter类和Intl.NumberFormat类与Kotlin/Native和Kotlin/JS结合起来使用。 5.5在数值类型之间进行转换 有时,需要在浮点数和整数之间进行转换。例如,若使用经验值表示玩家的技能水平,而不是像bountyboard项目中那样直接跟踪玩家的水平,代码如下所示: var experiencePoints = 460.25 val playerLevel = experiencePoints / 100 变量playerLevel的类型将被推断为Double类型,当运行此代码时,其值将被设置为4.6025。但这可能并不是程序开发者想要的。无论经验值是400点还是499点,从确定任务难度的角度来说,4级技能水平只需用4来表示。为进一步说明该细节,可以将变量playerLevel设置为Int类型,而不是Double类型。 若想要进行此转换,可以对表达式调用toInt(),具体如程序清单5.8所示。 程序清单5.8将Double类型转换为Int类型(REPL) var experiencePoints = 460.25 val playerLevel = (experiencePoints / 100).toInt() println(playerLevel) 运行此代码,REPL输出结果为4。当以此方式将Double类型转换为Int类型时,遵循的是与整数除法相同的规则: 小数点后的值将被截断,数值四舍五入为零。 该行为有时被称为精度损失(loss of precision)。因为想用整数表示包含小数部分的双精度数值,而整数能表示的精度不够,所以,可能会损失部分原始数据。 有时,这种截断是不可取的。在有些情形下,需要将Double类型四舍五入(rounded)为Int类型,而不是转换。幸运的是,Kotlin中还有一个roundToInt()函数可以实现这一点。将程序清单5.9中的代码输入REPL,查看该函数的运行情况。 程序清单5.9将Double类型四舍五入为Int类型(REPL) import kotlin.math.roundToInt val distanceToObjective = 4.6 println("The objective is about " + distanceToObjective.roundToInt() + " miles away") 运行以上代码,可以看到输出为The objective is about 5 miles away。Kotlin输出的是5而不是5.0,表示该值是整数而不是浮点数。该函数只需一步就将Double类型数值四舍五入并转换为Int类型。这样,根据具体的需要,roundToInt()函数就成了toInt()函数的替代。 注意: 如果需要将Int类型转换为Double类型,可以使用相应的toDouble()函数。当调用此函数时,将返回小数点后为零的双精度值。 本章已经介绍了Kotlin的数值类型,并了解了Kotlin如何处理两大类数值: 整数和双精度数。还介绍了如何在不同类型之间进行转换,以及每种类型可表示的数值大小。在第6章将介绍Kotlin的字符串。 5.6好奇之处: 无符号数 Kotlin 1.5版本引入了无符号数值类型作为一种稳定的语言特性。这与迄今为止见过的数值类型非常相似,唯一的区别是无符号数不能表示负数。表5.2给出了Kotlin中的无符号数值类型。 表5.2Kotlin中的无符号数值类型 类型位数最大值最小值 UByte82550 UShort16655350 UInt3242949672950 ULong64184467440737095516150 这些无符号数值类型与本章之前介绍的有符号数值类型之间存在相似之处,但也有不同之处,包括浮点数值类型Float和Double没有对应的无符号类型,本节末尾会解释其中的原因。 每一种整数类型(Byte、Short、Int和Long)都有一个对应的无符号类型(UByte、UShort、UInt和ULong)。分配给这些有符号数和无符号数类型的位数是相同的,例如Int类型和UInt类型均占32位。所有无符号数的最小值均为0,但是,其最大值远高于对应的有符号数。 无符号数值类型使用起来比有符号数值要麻烦一些。将变量playerLevel声明为UInt类型并赋值为5,代码如下所示: var playerLevel: UInt = 5.toUInt() 也可以在数值之后加上字母u,以将其标记为无符号数,如下所示: var playerLevel: UInt = 5u 如果需要,可以删除显式类型信息(: UInt),但必须使用无符号后缀或调用toUInt()函数将数字标记为无符号数。 Kotlin不会在有符号类型和无符号类型之间进行隐式转换。这也会影响对无符号类型的操作,例如: var playerLevel = 5u val levelsToAdd = 1 playerLevel += 1.toUInt() // Adding a UInt to a UInt is allowed playerLevel += 1u // Also allowed (shorthand for the line above) playerLevel += 1 // Compiler error: you must convert 1 to a UInt first playerLevel += levelsToAdd // Compiler error: cannot add an Int and a UInt print(playerLevel * 10u) // Allowed print(playerLevel * 10) // Compiler error 因为Kotlin不会自动在有符号和无符号类型之间进行转换,所以,要么程序中的大量变量是无符号数,要么根据需要使用toUInt()和toInt()函数来回进行转换。 在一些特定的情形中,无符号数可能非常有用,例如,当需要保证某变量为正数,对函数参数强制执行规则,或者使用的无符号数是平台特定类型时。但无符号数并非无懈可击,如果不小心,仍然有可能在程序中出现意外的情形。例如,假设将变量playerLevel设置为UInt类型并将其赋值为 -1,会怎么样呢: val playerLevel = (-1).toUInt() println(playerLevel) // Prints 4294967295 如果从任何数值类型可以表示的最小值中减去1,则将会“翻转回”(rolls over)该类型所能表示的最大值,这称为整数下溢(integer underflow)。本例中,UInt类型或任何无符号类型可以表示的最小值均为0,因此减去1会得到可能的最大UInt类型值为4294967295。这种意外的结果是将有符号类型和无符号类型一起使用的缺陷之一。 注意: 当处理的有符号数非常大时,也可能发生类似的情形,称为整数溢出(integer overflow)。如果在Int.MAX_VALUE上加1,则会换行并得到Int.MIN_VALU。 为什么无符号数的类型与本章中学习的有符号数值的类型不同呢?为什么要如此大费周章呢?在Kotlin中,无符号整数的实现方式与有符号整数不同。实际上,无符号整数是用有符号整数实现的。 当使用到无符号整数时,实际上是告诉Kotlin使用有符号整数,但将其比特位看作无符号整数的比特位。与使用有符号整数相比,这样做的好处是不会产生任何内存损失,并且在对无符号整数执行操作时,性能差异几乎可以忽略不计。 那么,为什么Kotlin不支持无符号浮点数呢?无符号浮点数是对其有符号变体的重新解释,尽管可以重新解释浮点数值并改变符号位的含义,但这是非常不符合常规的做法,而且Kotlin不支持这种操作。 由于执行浮点数运算的复杂性,计算机有专门的组件可高效地执行这些操作。如果要重新赋予符号位的含义,将无法使用这些硬件加速器,从而导致应用程序的性能下降。为了避免该问题,几乎没有一种带有无符号数的语言支持无符号浮点数。 因为Kotlin中的无符号类型是用有符号类型来实现的,所以,有符号类型往往被视为高人一等。与无符号类型相比,有符号类型用到的时候要多很多。同时,它们还有一些古怪之处(例如,有时IntelliJ在REPL中输出结果时,会将UInt类型视为Int类型)。 是否使用无符号类型,以及在何处使用,取决于自己。在bountyboard项目中选择不使用它们,但是可以认为有一些变量,如playerLevel,应该是无符号的,因为它们永远不应该是负数。 5.7好奇之处: 位运算 早些时候,学习了数值的二进制表示,随时可以得到一个数值的二进制表示。例如,求解整数42的二进制表示如下: 42.toString(radix = 2) "101010" 注意: 参数radix表示输出数值所需的基数。通过指定2,要求计算以2为基数或二进制的数值。默认基数为10,将会输出十进制数值。另一个常用的基数为16,将返回十六进制的字符串。 Kotlin提供了用于对数值执行二进制操作的函数,称为按位操作,包括可能在其他语言中熟悉的操作,如Java、C和JavaScript等。表5.3给出了Kotlin中常用的二进制运算。 表5.3二进制运算 函数描述示例 shl(bitcount)按位计数向左移动位42.shl(2) 10101000 shr(bitcount)按位计数向右移动位42.shr(2) 1010 inv()按位反转42.inv() 11111111111111111111111111010101 xor(number)比较两个二进制数值,并对相应的位执行逻辑“异或”运算42.xor(33) 001011 and(number)比较两个二进制数值,并对相应的位执行逻辑“与”运算42.and(10) 1010