5.5 常用的数学运算

Julia 中的一些操作符可以用于数学运算或位运算(也就是比特运算)。这样的操作符也可以被称为运算符。因此,我们就有了数学运算符和位运算符这两种说法。

5.5.1 数学运算符

可用于数学运算的运算符请见下表。

表 5-4 数学运算符

运算名称 运算符 示意表达式 用途
一元加 + +x 求 x 的原值
一元减 - -x 求 x 的相反数,相当于 0 - x
平方根 √x 求 x 的平方根
二元加 + x + y 求 x 和 y 的和
二元减 - x - y 求 x 与 y 的差
* x * y 求 x 和 y 的积
/ x / y 求 x 与 y 的商
逆向除 \ x \ y 相当于 y / x
整除 ÷ x ÷ y 求 x 与 y 的商且只保留整数
求余运算 % x % y 求 x 除以 y 后得到的余数
幂运算 ^ x ^ y 求 x 的 y 次方

可以看到,Julia 中通用的数学运算符共有 9 个。其中,与+-一样,也是一个一元运算符。它的含义是求平方根。在 REPL 环境中,我们可以通过输入\sqrt[Tab]写出这个符号。我们还可以用函数调用sqrt(x)来替代表达式√x

所谓的一元运算是指,只有一个数值参与的运算,比如√x。更宽泛地讲,根据参与操作的对象的数量,操作符可被划分为一元操作符(unary operator)、二元操作符(binary operator)或三元操作符(ternary operator)。其中,参与操作的对象又被称为操作数(operand)。

除上述的运算符之外,Julia 还有一个专用于Bool类型值的一元运算符!,称为求反运算符。它会将true变为false,反之亦然。

这些数学运算符都是完全符合数学逻辑的。所以我在这里就不再展示它们的示例了。

5.5.2 位运算符

我们都知道,任何值在底层都是根据某种规则以二进制的形式存储的。数值也不例外。我们把以二进制形式表示的数值简称为二进制数。所谓的位运算,就是针对二进制数中的比特(或者说位)进行的运算。这种运算可以逐个地控制数中每个比特的具体状态(01)。

Julia 中的位运算符共有 7 个。如下表所示。

表 5-5 位运算符

运算名称 运算符 示意表达式 简要说明
按位求反 ~ ~x 求 x 的反码,相当于每一个二进制位都变反
按位求与 & x & y 逐个对比 x 和 y 的每一个二进制位,只要有0就为0,否则为1
按位求或 ` ` `x y` 逐个对比 x 和 y 的每一个二进制位,只要有1就为1,否则为0
按位异或 x ⊻ y 逐个对比 x 和 y 的每一个二进制位,只要不同就为1,否则为0
逻辑右移 >>> x >>> y 把 x 中的所有二进制位统一向右移动 y 次,并在空出的位上补0
算术右移 >> x >> y 把 x 中的所有二进制位统一向右移动 y 次,并在空出的位上补原值的最高位
逻辑左移 << x << y 把 x 中的所有二进制位统一向左移动 y 次,并在空出的位上补0

利用bitstring函数,我们可以很直观地见到这些位运算符的作用。例如:

  1. julia> x = Int8(-10)
  2. -10
  3. julia> bitstring(x)
  4. "11110110"
  5. julia> bitstring(~x)
  6. "00001001"
  7. julia>

可以看到,按位求反的运算符~会把x中的每一个比特的状态都变反(由0变成1或由1变成0)。这也是 Julia 中唯一的一个只需一个操作数的位运算符。因此,它与前面的+-一样,都可以被称为一元运算符。

我们再来看按位求与和按位求或:

  1. julia> y = Int8(17)
  2. 17
  3. julia> bitstring(x)
  4. "11110110"
  5. julia> bitstring(y)
  6. "00010001"
  7. julia> bitstring(x & y)
  8. "00010000"
  9. julia> bitstring(x | y)
  10. "11110111"
  11. julia>

我们定义变量y,并由它来代表Int8类型的整数17y的二进制表示是00010001。对比变量x的二进制表示11110110,它们只在左边数的第 4 位上都为1。因此,x & y的结果就是00010000。另一方面,它们只在右数第 4 位上都为0,所以x | y的结果就是11110111

按位异或的运算符看起来很特别。因为在别的编程语言中没有这个操作符。在 REPL 环境中,我们可以通过输入\xor[Tab]\veebar[Tab]写出这个符号。我们还可以用函数调用xor(x, y)来替代表达式x ⊻ y

我们在前表中也说明了,x ⊻ y的含义就是逐个对比xy的每一个二进制位,只要不同就为1,否则为0。示例如下:

  1. julia> bitstring(x), bitstring(y), bitstring(x y)
  2. ("11110110", "00010001", "11100111")
  3. julia>

Julia 提供了 3 种位移运算,分别是逻辑右移、算术右移和逻辑左移。下面是演示代码:

  1. julia> bitstring(x)
  2. "11110110"
  3. julia> bitstring(x >>> 3)
  4. "00011110"
  5. julia> bitstring(x >> 3)
  6. "11111110"
  7. julia> bitstring(x << 3)
  8. "10110000"
  9. julia>

在位移运算的过程中,数值的宽度(或者说占用的比特数)是不变的。我们可以把承载一个数值的存储空间看成一条板凳,而数值的宽度就是这条板凳的宽度。现在,有一条板凳承载了x变量代表的那个整数,并且宽度是8。也就是说,这条板凳上有 8 个位置,可以坐 8 个比特(假设比特是某种生物)。

每一次位移,板凳上的 8 个比特都会作为整体向左或向右移动一个位置。在移动完成后,总会有 1 个比特被挤出板凳而没有位置可坐,并且也总会有 1 个位置空出来。比如,如果向右位移一次,那么最右边的那个比特就会被挤出板凳,同时最左边会空出一个位置。没有位置可坐的比特会被淘汰,而空出来的位置还必须引进 1 个新的比特。

好了,我们现在来看从1111011000011110的运算过程。后者是前者逻辑右移三次之后的结果。按照前面的描述,在向右移动三次之后,最右边的 3 个比特被淘汰了。因此,这时的二进制数就变为了11110。又由于,逻辑右移运算会为所有的空位都填补0(状态为0的比特),所以最终的二进制数就是00011110

图 5-2 逻辑右移的示意 图 5-2 逻辑右移的示意

与逻辑右移相比,算术右移只有一点不同,那就是:它在空位上填补的不是0,而是原值的最高位。什么叫最高位?其实它指代的就是位置最高的那个比特。对于一个二进制数,最左边的那个位置就是最高位,而最右边的那个位置就是最低位。x的值11110110的最高位是1。因此,在算术右移三次之后,我们得到的新值就是11111110

与右移运算不同,左移运算只有一种。我们把它称为逻辑左移。这主要是因为该运算也会为空位填补0。所以,11110110经过逻辑左移三次之后就得到了10110000

5.5.3 运算同时赋值

Julia 中的每一个二元的数学运算符和位运算符都可以与赋值符号=联用,可称之为更新运算符。联用的含义是把运算的结果再赋给参与运算的变量。例如:

  1. julia> x = 10; x %= 3
  2. 1
  3. julia>

REPL 环境回显的1就是变量x的新值。但要注意,这种更新运算相当于把新的值与原有的变量进行绑定,所以原有变量的类型可能会因此发生改变。示例如下:

  1. julia> x = 10; x /= 3
  2. 3.3333333333333335
  3. julia> typeof(x)
  4. Float64
  5. julia>

显然,x变量原有的类型肯定是某个整数类型(Int64Int32)。但更新运算使它的值变成了一个Float64类型的浮点数。因此,该变量的类型也随之变为了Float64

所有的更新运算符罗列如下:

  1. += -= *= /= \= ÷= %= ^= &= |= ⊻= >>>= >>= <<=

前 8 个属于数学运算,后 6 个属于位运算。

5.5.4 数值的比较

理所应当,数值与数值之间是可以比较的。在 Julia 中,这种比较不但可以发生在同类型的值之间,还可以发生在不同类型的值之间,比如整数和浮点数。通常,比较的结果会是一个Bool类型的值。

对于整数之间的比较,我们就不多说了。它与数学中的标准定义没有什么两样。至于浮点数,相关操作仍然会遵循 IEEE 754 技术标准。这里存在 4 种互斥的比较关系,即:小于(less than)、等于(equal)、大于(greater than)和无序的(unordered)。

具体的浮点数比较规则如下:

  • 只要参与比较的两个数值中有一个是 NaN,比较的结果就必然是false。因为 NaN 不与任何东西相等,包括它自己。或者说,这种情况下的所有比较关系都是无序的。
  • Inf 等于它自己,并且一定大于除了 NaN 之外的任何数。
  • -Inf 等于它自己,并且一定小于除了 NaN 之外的任何数。
  • 正零(0.0)和负零(-0.0)是相等的。尽管它们在底层存储上是不同的。
  • 其他情况下的有限浮点数比较将会按照数学中的标准定义进行。

Julia 中标准的比较操作符如下表。

表 5-6 比较操作符

操作符 含义
== 等于
!= ≠ 不等于
< 小于
<= ≤ 小于或等于
> 大于
>= ≥ 大于或等于

注意,对于不等于、小于或等于以及大于或等于,它们都有两个等价的操作符可用。表中已用空格将它们分隔开了。

这些比较操作符都可以用于链式比较,例如:

  1. julia> 1 < 3 < 5 > 2
  2. true
  3. julia>

只有当链式比较中的各个二元比较的结果都为true时,链式比较的结果才会是true。注意,我们不要揣测链中的比较顺序,因为 Julia 未对此做出任何定义。

在这些比较操作符当中,我们需要重点关注一下==。我们之前使用过一个用于判断相等的操作符===。另外,还有一个名叫isequal的函数也可以用于判等。我们需要明确这三者之间的联系和区别。

首先,操作符===代表最深入的判等操作。我们在前面说过,对于可变的值,这个操作符会比较它们在内存中的存储地址。而对于不可变的值,该操作符会逐个比特地比较它们。

其次是操作符==。它完全符合数学中的判等定义。它只会比较数值本身,而不会在意数值的类型和底层存储方式。对于浮点数,这种判等操作会严格遵循 IEEE 754 技术标准。顺便说一句,在判断两个字符串是否相等时,它会逐个字符地进行比较,而忽略其底层编码。

函数isequal用于更加浅表的判等。在大多数情况下,它的行为都会依从于操作符==。在不涉及浮点数的时候,它会直接返回==的判断结果。那为什么说它更加浅表呢?这是因为,对于那些特殊的浮点数值,它只会去比较它们的字面含义。它同样会判断两个 Inf(或者两个 -Inf)是相等的,但也会判断两个 NaN 是相等的,还会判断0.0-0.0是不相等的。这些显然并未完全遵从 IEEE 754 技术标准中的规定。下面是相应的示例:

  1. julia> isequal(NaN, NaN)
  2. true
  3. julia> isequal(NaN, NaN16)
  4. true
  5. julia> isequal(Inf32, Inf16)
  6. true
  7. julia> isequal(-Inf, -Inf32)
  8. true
  9. julia> isequal(0.0, -0.0)
  10. false
  11. julia>

另外,===isequal无论如何都会返回一个Bool类型的值作为结果。操作符==在绝大多数情况下也会如此。但当至少有一方的值是missing时,它就会返回missingmissing是一个常量,也是类型Missing的唯一实例。它用于表示当前值是缺失的。

下面的代码展示了上述 3 种判等操作在涉及missing时的判断结果:

  1. julia> missing === missing
  2. true
  3. julia> missing === 0.0
  4. false
  5. julia> missing == missing
  6. missing
  7. julia> missing == 0.0
  8. missing
  9. julia> isequal(missing, missing)
  10. true
  11. julia> isequal(missing, 0.0)
  12. false
  13. julia>

最后,对于不同类型数值之间的比较,Julia 一般会贴合数学上的定义。比如:

  1. julia> 0 == 0.0
  2. true
  3. julia> 1/3 == 1//3
  4. false
  5. julia> 1 == 1+0im
  6. true
  7. julia>

5.5.5 操作符的优先级

Julia 对各种操作符都设定了特定的优先级。另外,Julia 还规定了它们的结合性。操作符的优先级越高,它涉及的操作就会越提前进行。比如:对于运算表达式10 + 3^2来说,由于运算符^的优先级比作为二元运算符的+更高,所以幂运算3^2会先进行,然后才是求和运算。

操作符的结合性主要用于解决这样的问题:当一个表达式中存在且仅存在多个优先级相同的操作符时,操作的顺序应该是怎样的。一个操作符的结合性可能是,从左到右的、从右到左的或者未定义的。像我们在前面说的比较操作符的结合性就是未定义的。

下表展示了本章所述运算符的优先级和结合性。上方运算符的优先级会高于下方的运算符。

表 5-7 运算符的优先级和结合性

操作符 用途 结合性
+ - √ ~ ^ 一元的数学运算和位运算,以及幂运算 从右到左的
<< >> >>> 位移运算 从左到右的
* / \ ÷ % & 乘法、除法和按位与 从左到右的
`+ - ⊻` 加法、减法、按位或和按位异或 从左到右的
== != < <= > >= === !== 比较操作 未定义的
`= += -= *= /= \= ÷= %= ^= &= = ⊻= >>>= >>= <<=` 赋值操作和更新运算 从右到左的

此外,数值字面量系数(如-3x+1中的x)的优先级略低于那几个一元运算符。因此,表达式-3x会被解析为(-3) * x,而表达式√4x则会被解析为(√4) * x。可是,它与幂运算符的优先级却是相当的。所以,表达式3^2x2x^3会被分别解析为3^(2x)2 * (x^3)。也就是说,它们之间会依照从右到左的顺序来结合。

对于运算表达式,我们理应更加注重正确性和(人类)可读性。因此,我们总是应该在复杂的表达式中使用圆括号来明确运算的顺序。比如,表达式(2x)^3的运算顺序就一定是先做乘法运算再做幂运算。不过,过多的括号有时也会降低可读性。所以我们往往需要对此做出权衡。如有必要,我们可以分别定义表达式的各个部分,然后再把它们组合在一起。