对比学习 Go 和 Rust:语句、表达式和运算符

2022年1月13日 // Go Rust 编程

语句、表达式和运算符是所有编程语言都要涉及的概念。Go 和 Rust 在语句和表达式的功能和使用要求方面存在微妙的差异,而运算符则极其相似,本文将就此主题进行简单的讨论,以便为后续的学习打下必要的基础。

简单来说,语句是程序要执行的操作,而表达式则代表一个可以通过计算而求得的值,运算符则将各种数据(操作数)按照一定规则组合成表达式。也就是说,语句是用来执行的,而表达式是用来求值的,而运算符则说明了求值的方法。在多数编程语言中,语句和表达式的主要区别在于语句不返回结果,而表达式总是返回结果。另外,表达式通常处于语句中。

Go 的语句和表达式

Go 的语句和表达式与其他语言差别不大。因此,即便你跳过本节下面的内容,也不会有什么大的损失。

Go 的表达式

这里是 Go 中对表达式的严格定义:表达式通过对操作数应用运算符函数来指定值的计算。要理解什么是表达式,关键是理解什么是操作数。操作数是表达式中的各个基本值,就像是表达式中的子表达式,它包括:

操作数       = 字面量 | 具名操作数 | "(" 表达式 ")" .
字面量       = 基本字面量 | 复合字面量 | 函数字面量 .
基本字面量    = 整型字面量 | 浮点型字面量 | 虚数字面量 | rune 字面量 | 字符串字面量 .
具名操作数    = 标识符 | 限定符 .

从上可以看出,操作数可以是各种值的字面量,表示常量、变量、函数的非空白标识符,选择器(值.方法,或 值.字段),以及用圆括号包括的表达式。这些操作数本身也可以单独出现成为一个表达式。很显然,如果函数(或方法)调用没有返回值,就不能放在表达式中,因此这样的函数调用不是表达式,也不可以作为表达式的操作数。

大部分时候,表达式只表示一个值,即称为单值表达式。不过,Go 中的函数(或方法)调用能返回两个或两个以上的值,这样的函数调用可被称为多值表达式。另外,无赋值部分的信道接收运算(如 <-ch)也是表达式,该也可能是单值的或多值的。

Go 的语句

Go 的语句包括以下分类:声明语句(不包括短变量声明语句)、标签语句、简单语句、go 语句、return 语句、break 语句、continue 语句goto 语句、fallthrough 语句、块、if 语句、switch 语句、select 语句、for 语句、defer 语句。

其中简单语句包括空语句、表达式语句、发送语句、自增减语句、赋值语句和短变量声明语句。简单语句在一些地方有特殊的用途,主要是其中的短变量声明语句可以用于 ifforswitch 语句中进行状态的初始化。

表达式语句指的是整个语句由一个表达式构成,如下所示:

func main() {
	sayHello()
}

func sayHello() int {
	fmt.Println("Hello, world!")
	return 0
}

其中 sayHello() 是一个有返回值的表达式,但这里舍弃了此表达式的值,而只进行函数调用而使用其副作用,该语句就是表达式语句。

同样,无赋值部分的信道接收运算也可以用于语句上下文而成为表达式语句,如单独占一行的 <-ch

值得注意的是,自增(i++)和自减(i--)是语句而不是表达式,不能将他们当作表达式使用。

Rust 的语句和表达式

Rust 关于语句和表达式的定义和其他语言基本相似。但 Rust 是一种基于表达式的语言,它只有少数种类的语句,其他大多东西属于表达式。其中最特别的是,Rust 中的代码块属于表达式(即块表达式)而非语句,代码块、语句和表达式可以递归地彼此嵌套到任意深度。

Rust 中只有声明语句和表达式语句两种语句。其中声明语句用来声明一个名称,该名称可以表示变量(用 let 声明)和程序项,后者包括函数、结构体、类型、静态变量、trait、实现或模块等。表达式语句与 Go 中表达式语句相似,即对表达式求值并忽略其结果。表达式语句必须以分号 ; 结尾。不同于 Go 中只有函数调用和信道接收运算两种表达式语句,大多数表达式加上分号都可以变为表达式语句。但当忽略表达式值的时候,会收到编译器的警告。在 Go 中,语句结尾的分号 ; 可以省略,并且经常省略;Rust 语句末尾的分号则不可省略。

要弄清 Rust 中语句和表达式间的关系,理解代码块(块表达式)至为关键。Rust 允许如下形式的代码块:

let _v = {
    let x = 5;
    x * 6
};

上述变量 _v 前面的下划线允许我们不使用该变量。其中代码块的值就是其最后的不带分号行的表达式的值,即为 30,该值也将被赋给 _v

接下来我们把代码块中最后一行表达式加个分号,即:

let _v = {
    let x = 5;
    x * 6;
};

该代码块仍然能够执行,只是编译器会对 x * 6; 行产生警告:the arithmetic operation produces a value,这是因为在加分号后,原来的表达式变成了表达式语句,从而舍弃了该行中表达式的值。

那么,既然已经舍弃了 x + 6 的值,为什么又将代码块赋值给 _v 呢?这是因为该块表达式没有显式地返回一个值,那么将默认返回单元类型的单元值 ()。这相当于编译器自动在最后一个语句后面添加了单元值 () 实现的。因此,以上代码等价于:

let _v = {
    let x = 5;
    x * 6;
    ()
};

看到这里,我们自然会想到,函数、各种控制流语句中都有大括号限定的代码块,这些代码块都会返回值吗?没错的,他们都会返回值。不仅如此,其中很多带有块的东西还是表达式,如 if 表达式、if let 表达式、match 表达式、各种循环表达式,另外还有一些特殊的块,如异步块和非安全块。这些东西,在 Go 语言中都属于语句,但在 Rust 中却变成了表达式。既然是表达式,我们就可以将他们灵活地嵌套在表达式和语句中,例如:

let x = 11;
let s = if x % 2 == 0 { "偶数" } else { "奇数" };

该段代码将一个 if 表达式赋值给了 s

基于同样的规则,函数体的大括号也属于块表达式,块表达式的值就是该函数的返回值。即便函数没有显式地返回任何值,它也隐式地返回了一个单元值 ()

运算符及其优先级

这里仅针对原始类型,讨论两种语言运算符的功能。Go 和 Rust 在运算符选择上都是采用各种语言最常选用的符号,两者非常相似。因此如果同时学习这两种,不至于因为这些琐碎的事变得难以适应。

正数和负数运算符

正数和负数运算符是最简单运算符,他们属于一元运算符,见下面的示例。注意 Rust 中只有负数运算符,没有整数运算符。

Go:

n := +2
n = -n

Rust:

let mut n = 2; // 不能为 +2,因为 Rust 中没有正数运算符 +
n = -n;

算术运算符

运算 Go 运算符 Rust 运算符 示例 结果
+ + 2 + 3 5
- - 3.2 - 1.0 2.2
* * 3.2 * 2.0 6.4
/ / -5.0 / 3.0
5 / 3
-5 / 3
-1.6666666666666667
1
-1
取余 % % -5.3 % 3.0
5 % 3
-5 % 3
5 % -3
-5 % -3
-2.3(Go 不支持此运算)
2
-2
2
-2

针对算术运算,进一步讨论如下:

  • 不像有些语言专门设置整除运算符(如 Python 就用 // 表示整除),当 / 两侧都为整数时,就进行整除运算,即仅返回商的整数部分,即所谓向零取整。
  • 取余运算结果的符号总是与被除数(% 左侧数字)相同。在 Go 中只能对整数进行取余运算,而 Rust 则可同时对整数和浮点数进行取余运算。在 Go 中,要得到和 Rust 相同的计算结果,需要使用形如 a - math.Trunc(a/b)*b 的表达式(其中 a 相当于被除数,b 相当于除数)。实际上,对浮点数进行取余运算很复杂,但并不是很重要,因此这里将不再进行讨论。
  • Go 的加减乘除(+-*/)还可对复数类型进行,而 Rust 没有复数类型。
  • 两种语言都没有取幂运算,因此要计算 33 ,应该使用 math.Pow(3.0, 3.0)(Go)、3.0.powi(3.0)(Rust)或 3.0.powf(3.0)(Rust)。当幂指数位整数时,使用 3.0.powi(3.0) 的速度要快于 3.0.powf(3.0)

另外,请注意 Go 中存在 x++x-- 这样的自增和自减语句,他们分别相当于 x += 1x -= 1。注意,这两者是语句而非表达式,他们并运算。

位运算符

运算 Go 运算符 Rust 运算符 示例 结果
按位(AND) & & 0b1010 & 0b1100 0b1000
按位(OR) | | 0b1010 | 0b1100 0b1110
按位异或(XOR) ^ ^ 0b1010 ^ 0b1100 0b0110
按位取反(NOT) ^ ! 0b00000101 0b11111010
按位清除(与非) &^ 0b1010 &^ 0b1100 0b0010
左移位 << << 0b1010 << 2 0b101000
右移位 >> >> 0b1010 >> 1 0b0101

注意 Go 的按位取反运算符为 ^,这与异或运算符相同,而 Rust 则为 !。另外 Go 有按位清除运算符 &^,而 Rust 则没有。

以上各个位运算符中,除了按位清除(与非)之外,其他的都是我们熟悉的,因此这里就 Go 特有的按位清除(与非)进行举例说明。在 Linux 操作系统中,文件和目录的绝对权限(读、写、执行)可用最多 4 位的 8 进制数来表示,而日常一般用 3 位八进制数表示,这三位从左到右分别表示用户、用户组、其他用户操作文件或目录的权限,可以使用 chmod 命令更改这些权限。另外,Linux 可以使用 umask 命令设置或显示文件或目录的默认权限模式。umask 是一个二进制值(经常以八进制形式呈现),它通过二进制的按位清除(与非)运算,结合文件的八进制值 0666 和目录的 0777 来定义其默认保护模式。以下代码模拟文件权限模式的计算过程(仅模拟用户、用户组、其他用户的权限):

const (
    OtherRead = 1 << iota
    OtherWrite
    OtherExecute
    GroupRead
    GroupWrite
    GroupExecute
    UserRead
    UserWrite
    UserExecute
)
// 对应 Linux 系统中 umask 常用预设值 022(八进制)
umask := GroupWrite | OtherWrite
// 对应文件的初始权限值 666(八进制)
file_mod_init := UserExecute | UserWrite | GroupExecute | GroupWrite | OtherExecute | OtherWrite
// 对应目录的初始权限值 777(八进制)
dir_mod_init := UserExecute | UserWrite | UserRead | GroupExecute | GroupRead | GroupWrite | OtherExecute | OtherWrite | OtherRead
file_mod := file_mod_init &^ umask // 644
dir_mod := dir_mod_init &^ umask   // 755
fmt.Printf("File mod:\n   %03o\n&^ %03o\n = %03o\n\n", file_mod_init, umask, file_mod)
fmt.Printf("Dir mod:\n   %03o\n&^ %03o\n = %03o\n", dir_mod_init, umask, dir_mod)

运行结果:

File mod:
   666
&^ 022
 = 644

Dir mod:
   777
&^ 022
 = 755

赋值运算符

赋值运算符包含简单的赋值运算符和复合赋值运算符,见下表:

运算 Go 运算符 Rust 运算符 示例 展开形式
简单的赋值运算 = = x = y x = y
加赋值 += += x += y x = x + y
减赋值 -= -= x -= y x = x - y
乘赋值 *= *= x *= y x = x * y
除赋值 /= /= x /= y x = x / y
取余数赋值 %= %= x %= y x = x % y
按位与赋值 &= &= x &= y x = x & y
按位或赋值 |= |= x |= y x = x | y
按位异或赋值 ^= ^= x ^= y x = x ^ y
按位清除赋值 &^= x &^= y x = x &^ y
按位左移位赋值 <<= <<= x <<= y x = x << y
按位右移位赋值 >>= >>= x >>= y x = x >> y

对赋值运算符并没有太多需要解释的。

字符串拼接

在 Go 和 Rust 中,都可以用加法符号 + 进行字符串拼接。

在 Go,进行字符串非常拼接非常简单:

n := 2
s := "我有 " + strconv.Itoa(n)
s += " 个手机。"
fmt.Println(s) // 我有 2 个手机。

在 Rust 中,进行字符串拼接则稍微复杂:

let n = 2;
let mut s = "我有 ".to_string() + &(n.to_string());
s += " 个手机。";
println!("{}", s); // 我有 2 个手机。

我们还没有过多涉及字符串类型,因此以上的代码有点难以理解,这里只进行大致的说明:Rust 之所以能用 + 进行字符串拼接,是因为 String 类型实现了 Add 特型,如下所示:

impl Add<&str> for String {
    type Output = String;

    #[inline]
    fn add(mut self, other: &str) -> String {
        self.push_str(other);
        self
    }
}

add 方法可以看出,其参数必须是 &str 类型,因此要求 + 左侧的操作数为 String 类型,右侧为 &str 类型。

比较运算符

Go 和 Rust 的比较运算符完全一样,也很容易理解,这里仅列出这些运算符:

运算 Go 运算符 Rust 运算符 示例 结果
等于 == == 32 == 32 true
不等于 != != 32 != 32 false
小于 < < 23.4 < 36.7 true
小于或等于 <= <= 23.4 <= 36.7 true
大于 > > 23.4 > 36.7 false
大于或等于 >= >= 23.4 >= 23.2 true

逻辑运算符

逻,Go 和 Rust 的逻辑运算符也完全一样:

运算 Go 运算符 Rust 运算符 示例 等价形式
&& && p && q if p then q else false
|| || p || q if p then true else q
! ! !p not p

其他

除了以上运算符外,Go 和 Rust 都还具有引用/取址运算符 &,解引用运算符 *,Go 中还具备接收运算符 <-,这些都涉及一些高级话题,将留待后续讨论。

对于 Rust,以上运算符除了能被用于基础数据类型外,还能通过运算符重载用于其他数据类型。std::ops 模块中规定了可被重载的各种运算符。

Go 不支持运算符重载,不过稍微有点特例。对于一些可比较类型,可以使用 ==!= 进行运算;进一步地,对于可排序类型,可以使用 <<=>>= 进行运算。

运算符的优先级

Go 的规范只是简单地给出了各运算符的优先级:

优先级 运算符
5 * / % << >> & &^
4 + - | ^
3 == != < <= > >=
2 &&
1 ||

Rust 则对运算符的优先级和结合性进行了更加详细的规定(越考上,优先级越高):

运算符/表达式 结合性
路径
方法调用
字段表达式 自左向右
函数调用,数组索引
?
一元的 - * ! & &mut
as 自左向右
* / % 自左向右
+ - 自左向右
<< >> 自左向右
& 自左向右
^ 自左向右
| 自左向右
== != < > <= >= 需要圆括号
&& 自左向右
|| 自左向右
.. ..= 需要圆括号
= += -= *= /= %= &= |= ^= <<= >>= 自右向左
return break 闭包

总体来说,这两种语言对优先级的规定是相近的。