对比学习 Go 和 Rust:自定义类型

2022年7月3日 // Go Rust 编程

前面介绍的各种原始类型、集合类型都是语言内建的或在标准库中定义的类型。本文继续介绍自定义类型(defined type,或翻译为定义类型),包括结构体、枚举和联合体类型,他们同各种集合类型一样同属复合类型。自定义类型的特点是由开发者根据具体需要,将原始类型组合在一起构建而成,并用标识符(类型名称)具体表示所构建的类型。实际关于自定义类型,Go 和 Rust 的定义很不统一。Go 可以基于任何类型定义类型名称和方法,形成自定义类型;Rust 的自定义类型只包括结构体、枚举和联合体;Go 中没有枚举和联合体这两种类型。在其他语言中,枚举类型往往属于原始类型,但 Rust 的枚举类型特别强大,因此属于自定义的复合类型。另外,Rust 中的元组通常没有名称,不属于自定义类型,但与之相似的元组结构体却是有名称的,这属于自定义类型,本文将一并介绍。

类型声明

在具体讲解各种自定义类型之前,先从更加概略的层级,讲一下自定义新类型时如何进行类型声明。类型声明是将一个类型与一个标识符(即类型名称)绑定。这里的类型可以是能用特定标识符表示的有名称类型,即具名类型,如 inti32stringString 等;或者是不能用特定标识符表示的无名称类型,无名称类型可以用类型字面量表示,如 [10]int[i32; 3][]int&[i32]Vec<i32>map[string]float64HashMap<String, f64> 等。

类型声明包括别名声明类型定义

别名声明

别名声明是将一个给定的类型绑定到一个别名,给定类型和被绑定的别名属于相同的类型。通过类型别名,可以给没有具体名称的类型字面量一个名称,也可以给已经有名称的类型一个不同的名称。Go 和 Rust 都支持给类型声明一个别名,其形式也很近似。请看如下示例:

// MyInt 为具名类型 int 的类型别名,两者将完全相同.
type MyInt = int

// Scores 为无名类型 map[string]float64 的别名.
type Scores = map[string]float64

// Coordinate 是一个匿名结构体的别名.
type Coordinate = struct {
    X, Y float64
}

// Point 是类型别名 Coordinate 的别名.
type Point = Coordinate

// 可以像变量或常量声明那样,成组地声明类型别名.
type (
    SmallInt   = int8
    SmallFloat = float32
)
// MyInt 为具名类型 i32 的类型别名,两者将完全相同.
type MyInt = i32;

// Scores 为无名类型 map[string]float64 的别名.
type Scores = HashMap<String, f64>;

// Coordinate 是一个元组的别名.
type Coordinate = (f64, f64);

// Point 是类型别名 Coordinate 的别名.
type Point = Coordinate;

类型定义

类型定义则是根据一个给定类型及其运算创建一个新的、不同的类型,并为新类型绑定一个类型名称。

在 Rust 中,类型定义就是声明结构体、枚举和联合体类型,这将在稍后讲述。在 Go 中,除了声明结构体属于类型定义外,还能基于任何类型定义一个新的类型(Rust 中可通过一元元组结构体实现类似的功能),并为此类型定义方法。Go 的类型定义的形式就如同去掉了等号 = 的类型别名声明,如下所示:

// MyInt 为基于 int 类型构建的新类型,两者属不同类型.
type MyInt int

// Scores 为基于 map[string]float64 类型构建的新类型.
type Scores map[string]float64

// Coordinate 是一个表示坐标的结构体.
type Coordinate struct {
    X, Y float64
}

// 可以像变量或常量声明那样,成组地定义类型.
type (
    SmallInt   int8
    SmallFloat float32
)

func main() {
    var n MyInt = 33          // 33 为无类型常量,因此可以赋值给 MyInt 类型变量
    fmt.Println(int(n) == 33) // true,要进行比较,需要进行类型转换
    var _ Scores = Scores{
        "张三":  91.0,
        "李四":  69.4,
        "王老五": 77,
    }
    var _ Coordinate = Coordinate{33.4, 58.9}
}

元组

Rust 中的元组(tuple)将多种类型的值组合成一个复合类型的值。元组类型字面量的形式是用圆括号包括的、以逗号分割的一组值,各个值可以有不同的类型。元组的长度是固定的,声明后,它们就无法增长或缩小。其他与元组相关的概念,可以直接看下面的示例代码:

let _: () = (); // 没有任何字段的元组为空元组,其类型也称作单元或单元类型,其值也称作单元或单元值
let _: (i32,) = (33,); // 一元元组,为了和括号表达式区分,字段后面的逗号是不可省略的

let mut c: (f64, f64, f64) = (33.4, 58.1, 52.3); // 三元元组
c.1 = 47.8; // 通过索引访问元组的字段
assert_eq!(c, (33.4, 47.8, 52.3));

let t1: (i32, &str) = (33, "张三");
let (n, s): (i32, &str) = t1; // 通过模式匹配快速解构元组的元素
let t2: (&str, i32) = (s, n); // t1, t2 属于不同类型的元组
println!("{:?}", t2); // ("张三", 33);

空元组(单元)在 Rust 中非常常用,表示没有类型信息,类似于 C 语言中的 void 类型。通常没有返回任何值的表达式(如 {})的默认返回值就是 ()

可以看出,要访问元 t 的字段,可以通过两种方法:通过形如 t.0t.1t.2 形式的元组索引表达式;通过模式匹配解构元组。

在元组索引表达式中,应将 01 看作是字段名称而不是整数数字。如以下代码就无法通过编译:

let mut c: (f64, f64, f64) = (33.4, 58.1, 52.3);
let n = 1;
c.n = 47.8; // 错误:no field n on type (f64, f64, f64)

由于可以非常方便地随时构建和解构元组,使得元组可以非常灵活地被应用于那些只适合传递一个值的场景。最典型的应用场景是函数的返回值:虽然 Rust 的函数不像 Go 那样可以有多个返回值,但通过返回一个元组并快速结构,其与具有多个返回值是等价的。

反过来说,Go 中没有元组类型,但它可以将多个表达式用逗号 , 分割,构成表达式列表。Go 的赋值语句也是针对表达式列表的,从而可以非常方便地实现许多在 Rust 中需要用元组实现的功能。如下示例分别为在 Go 和 Rust 中,一次性初始化两个变量,并一次性交换两个变量的值的方法:

a, b := 33, 44                     // 通过表达式列表一次性对多个变量赋值
a, b = b, a                        // 交换 a, b 的值
fmt.Printf("a: %d, b: %d\n", a, b) // a: 44, b: 33
let (mut a, mut b) = (33, 44); // 通过模式匹配一次性对多个变量赋值
(a, b) = (b, a); // 交换 a, b 的值
println!("a: {}, b: {}", a, b); // a: 44, b: 33

结构体

结构体是将零个或多个任意类型的命名变量组合在一起形成的复合数据类型。每个成员变量叫作结构体的字段。Go 和 Rust 都没有传统的面向对象编程语言中的类,结构体在一定程度上充当类的作用。在构建复杂的程序时,一般主要通过结构体封装程序和数据。

下面还是主要通过示例来说明 Go 和 Rust 中如何使用结构体:

package main

import (
    "fmt"
    "reflect"
    "time"
)

type empty struct{} // 空结构体

type student struct {
    id           int
    name         string
    class, major string // 两个相同类型可以成组声明
    birthdate    time.Time
}

type coordinate struct {
    // 字段声明的后方可以可选地添加任意一个字符串(通常表示为原始字符串),即为标签(tag)。
    // 标签是该字段的属性(attribute)。当没有添加标签时,默认为 ""。
    x float64 `json:"x" xml:"x"`
    y float64 `json:"y" xml:"y"`
}

type coordinate3D struct {
    // 嵌入另外一个匿名的结构体字段,其该字段名称默认为类型名称 coordinate,
    // coordinate 的字段(及方法) X 和 Y 将被提升为 coordinate3D 的字段(及方法)
    coordinate
    z float64
}

func main() {
    var _ empty = empty{}         // 初始化一个空结构体
    var _ student = *new(student) // 通过 new 函数初始化结构体,并返回指向结构体的指针
    var _ student = student{}     // 使用 T{} 创建一个所有字段都是零值的结构体
    var _ student = student{      // 使用结构体的复合字面量创建结构体
        id:    111111111,
        name:  "张三",
        class: "2201",
        major: "软件工程",
        birthdate: time.Date(2004, time.December, 21, 0, 0, 0, 0,
            time.FixedZone("UTC+8", 8*60*60)),
    }

    // 使用复合字面量时可以不指定各字段的名称,则按照结构体声明时的字段排序对字段赋值
    var coord1 coordinate = coordinate{3.3, 4.4}

    // 通过反射接口访问字段标签
    ct := reflect.TypeOf(coord1)
    field := ct.Field(0)
    fmt.Println(field.Tag.Get("json"), field.Tag.Get("xml")) // x x

    // 先生成一个指向零值结构体的指针,再对各个字段进行赋值
    var coord2 *coordinate = &coordinate{}
    // 通过点号方式访问结构体的成员,且点号能作用于结构体指针上,中间有一个自动解引用的操作
    coord2.x = 3.3 // 等价于 (*coord2).X = 3.3
    // 可以获得结构体字段的地址
    y := &coord2.y // 等价于 y := &(*coord2).y
    *y = 4.4

    // 当结构体各个元素是可比较类型且对应相等时,结构体也相等
    fmt.Println(coord1 == *coord2) // true

    // 在结构体复合字面量中,被提升的字段名称不能起作用,因此以下的
    // coordinate3D 字面量不能是 coordinate3D{X: 3.3, Y: 4.4, Z: 5.5}
    var coord3 coordinate3D = coordinate3D{coordinate{3.3, 4.4}, 5.5}

    coord3.coordinate.x = 2.2 // 嵌入的结构体相当于结构体的一个字段
    coord3.y = 3.3            // 直接访问被提升的字段

    // 使用无名的结构体类型字面量来创建一个结构体
    var coord4 = struct {
        coordinate
        z float64
    }{
        coordinate{2.2, 3.3},
        5.5,
    }

    fmt.Println(coord3 == coord4) // true
}
// 通过添加此属性告诉编译器禁止产生未使用的函数、类型、字段等警告。
#![allow(dead_code)]

use chrono::{DateTime, FixedOffset, TimeZone};

struct Empty; // 类单元结构体,注意结构体名称后面可以省略大括号或圆括号

// 通过添加此属性,可以在 println! 宏中用 {:?} 或 {:#?} 格式化字符串打印结构体内容。
#[derive(Debug)]
struct Student {
    id: i32,
    name: String,
    class: String,
    major: String,
    // email: &str, // 借用却没有标注生命周期,因此此行不能编译
    birthdate: DateTime<FixedOffset>,
}

#[derive(Debug)]
struct Coordinate {
    x: f64,
    y: f64, // 结构体定义时,最后一个字段后的逗号可以省略,但分行书写时通常不省略
}

// 元组结构体,请注意这里不是用大括号,而是像元组那样用圆括号定义的,末尾还要有分号。
struct Point(f64, f64, f64);

// 只包含一个字段的元组结构体,看起来就像一个新建的整数类型。
struct MyInt(i32);

fn main() {
    let _: Empty = Empty; // 初始化一个类单元结构体,注意后面的大括号对 {} 同样可以省略
    let _: Student; // 声明了结构体变量,但没有初始化

    let beijing_timezone: FixedOffset = FixedOffset::east(8 * 60 * 60);
    let major: String = String::from("软件工程");

    let zhang_san: Student = Student {
        id: 111111111,
        name: "张三".to_string(),
        class: "2201".to_string(),
        major, // 存在与字段名同名的变量,因此可以使用字段初始化简写语法,这种方法对函数参数同样适用
        birthdate: beijing_timezone.ymd(2004, 12, 21).and_hms(0, 0, 0),
    };

    // 使用结构体更新语法从其他实例创建实例
    let li_si: Student = Student {
        id: 111111112,
        name: "李四".to_string(),
        birthdate: beijing_timezone.ymd(2004, 4, 17).and_hms(0, 0, 0),
        // 对于前面没有赋值的字段,会将 zhang_san 中的对应字段通过赋值 = 操作移动或复制给 li_si。
        // 这里主要移动了 class 和 major 字段,此后 zhang_san 的这些字段处于未赋值状态,
        // 从而使 zhang_san 变得不可用。
        ..zhang_san
    };

    // println!("{:?}", zhang_san); // 因为 zhang_san 中的部分字段值已经被移动,因此此项不可用

    // Student { id: 111111112, name: "李四", class: "2201", major: "软件工程", birthdate: 2004-04-17T00:00:00+08:00 }
    println!("{:?}", li_si);

    // 在结构体字面量中,必须给出所有字段的值
    // 无论右大括号是否和最后一个字段名称:值对项在一行,其后的逗号都可以省略
    let coord1: Coordinate = Coordinate { x: 3.3, y: 4.4 };

    let coord2: &mut Coordinate = &mut Coordinate { x: 0.0, y: 0.0 };
    // 通过点号方式访问结构体的成员,且点号能作用于结构体指针上,中间有一个自动解引用的操作
    coord2.x = 3.3; // 等价于 (*coord2).X = 3.3;
    let y = &mut coord2.y;
    *y = 4.4;

    // assert_eq!(coord1, *coord2); // 结构体默认没有实现 PartialEq<_> trait,因此不能比较

    println!("{:?}", coord1); // Coordinate { x: 3.3, y: 4.4 }

    // 使用更易读的 {:#?} 风格,将打印:
    // Coordinate {
    //     x: 3.3,
    //     y: 4.4,
    // }
    println!("{:#?}", coord2);

    // 实例化元组结构体
    let _: Point = Point(3.3, 4.4, 5.5);
    let _: MyInt = MyInt(33);
}

要编译以上 Rust 代码,需要在项目目录中的 Cargo.toml 文件中的 [dependencies] 行下面添加 chrono = "0.4" 行,这正是该程序所依赖的外部 chrono 箱(crate)。使用该箱,可以根据中国时区生成日期,类似于前面 Go 的 time 包实现的功能。

为了防止在分别使用两种语言的结构体时产生混淆,这里详细比较他们的不同点

  • 在声明结构体时,Go 使用 type StructName struct {...} 的形式,这与变量、常量的声明方式是一致的。Rust 使用 struct struct_name {...} 的形式,与 C 语言的记录(结构体)声明方式是一致的。
  • 在声明结构体字段时,Go 采用 FieldName FieldType 的形式,每行后面是默认省略的分号,多个相同类型的字段可以成组声明。Rust 采用 field_name: FieldType, 的形式,每行后面是逗号,除了最后一行外其他逗号不可省略,也不可成组声明字段。
  • 在声明结构体字段时,Go 支持为字段添加标签字符串,Rust 不支持。
  • 没有任何字段的结构体,在 Go 中叫空结构体,在 Rust 中叫类单元结构体。Go 的空结构体在定义和字面量书写时,都不能省略后面的空大括号对 {},Rust 则均可以省略。
  • Rust 中有元组结构体,Go 中没有。
  • Go 在表示结构体字面量(或称结构体实例)时,习惯采用 StructName{...} 的形式,即结构体名称和左大括号之间没有空格。Rust 则习惯采用 StructName {...} 的形式,即结构体名称和左大括号之间加一个空格。
  • Go 和 Rust 在表示结构体字面量时,各项都是采用 FieldName: FieldValue, 的形式,且最后一项后面的逗号都可以省略。但 Go 的最后一项仅在同一行内跟右大括号 } 时才能省略,Rust 则总是可以省略。
  • Go 在表示结构体复合字面量时,只要按顺序给出各个字段值,可以省略字段名称,这与 Rust 的元组结构体字面量有点类似,但各个字段仍然是有名称的;另外字段值也可以不全部给出,甚至全部省略,被省略的字段值将默认取零值。Rust 在表示结构体字面量时,必须全部给出所有字段的名称/值对。
  • Go 支持结构体的嵌入,即一个结构体中嵌入另外一个匿名的结构体,被嵌入结构体字段的类型将成为该字段名称,其字段和方法将被提升提升为所定义结构体的字段和方法,这在很大程度上模拟了面向对象程序设计的继承行为,即被嵌入结构体相当于所声明结构体的父类。Rust 不支持结构体的嵌入,Rust 的元组结构体的字段也是没有名称的,但这和 Go 的被嵌入的匿名结构体的用途完全不一样。
  • Rust 在创建结构体实例时,提供了两个语法糖,分别是变量与字段同名时的字段初始化简写语法,以及使用结构体更新语法从其他实例创建实例。Go 没有这些语法糖。
  • Go 的结构体默认是值类型,将一个结构体变量赋值给另外一个将(浅)复制其所有字段(不会复制引用类型的底层数据结构)。Rust 的自定义类型(包括结构体)默认是没有实现 Copy trait,因此,将一个结构体变量赋值给另外一个将会导致所有权被移动。
  • Rust 结构体的字段也要遵循所有权规则。以上 Student 结构体中注释掉的 email: &str 项无法通过编译,因为无法确定借用字段 email 的生命周期,可能会造成悬垂指针错误。对于其他字符串类型字段,我们有意设置其具体类型为 String,该类型有所有权(不是借用),从而暂时回避了此问题。该问题将在后面介绍生命周期时被解决。Go 语言不存在这些问题。

枚举

枚举是对一个对象的所有可能取到的值的集合,枚举允许通过列举可能的变体(variant,也可译作成员)来定义一个类型。

Go 使用常量模拟枚举

在许多编程语言中,枚举变体实际上只相当于一个常量的标识符。Go 甚至没有枚举类型,不过在前面变量与常量一文中,我们已经介绍了如何通过常量生成器 iota 声明一系列相关的常量,以此模拟创建枚举值。为便于参照,这里仅仅给出一个在 Effective Go 里面用到的例子:

type ByteSize float64

const (
    _           = iota // 通过将其赋值给一个空白标识符来忽略第一个值
    KB ByteSize = 1 << (10 * iota)
    MB
    GB
    TB
    PB
    EB
    ZB
    YB
)

Rust 的枚举类型

Rust 的枚举类似 C 语言中的带标签联合体,该类型异常灵活。枚举和后面介绍的联合体比较相似,但联合体不是内存安全的,枚举却是内存安全的,因为编译器会一直追踪枚举值的数据类型。

Rust 的枚举能够存储一个对象,该对象的类型必须是一组预先指定类型中的一个,具体存了哪个类型由枚举中的变体名称决定。在理解上,可以把枚举每个变体所存储的值都看作是一个结构体。结构体分为三类,分别是类单元结构体、元组结构体和常规的结构体,枚举变体存储的值也相应地分为这三类。枚举中存储的结构体的字段,也相应地成为枚举变体的字段。另外,结构体的名称通常是首字母大写的,因此枚举变体的名称习惯上也使用首字母大写。

要创建枚举类型的实例(即枚举值),针对以上三类枚举变体,可分别使用形如 EnumType::VariantEnumType::Variant(value1, value2)EnumType::Variant { field1: value1, field2: value2 } 的形式。

经常使用 match 表达式利用模式匹配判断枚举值具体属于那种变体,同时获得各个变体的各个字段值。

如下是一个 Rust 中枚举的使用示例:

#![allow(dead_code)]

#[derive(Debug)]
enum Geometry {
    Nothing,                                // 类单元类型
    // 这里有意将 Point 的两个元素声明为 i64 类型,以便在后面演示模式匹配时匹配数值字面量,
    // 因为在未来版本的 Rust 编译器将不允许模式中出现浮点数字面量,目前只是显示为警告。
    Point(i64, i64),                        // 二元元组类型
    Rect(Rectangle),                        // 一元元组类型
    Circle { x: f64, y: f64, radius: f64 }, // 结构体类型
}

#[derive(Debug)]
struct Rectangle {
    lower_left: (f64, f64),
    up_right: (f64, f64),
}

fn main() {
    let mut g: Geometry = Geometry::Point(3, 4);
    println!("{:?}", g); // Point(3.3, 4.4)
    g = Geometry::Rect(Rectangle {
        lower_left: (33.3, 44.4),
        up_right: (48.7, 59.3),
    });
    println!("{:?}", g); // Rect(Rectangle { lower_left: (33.3, 44.4), up_right: (48.7, 59.3) })
    g = Geometry::Circle {
        x: 33.4,
        y: 44.4,
        radius: 10.3,
    };
    println!("{:?}", g); // Circle { x: 33.4, y: 44.4, radius: 10.3 }

    // 通过模式匹配访问枚举中保存的对象。
    match g {
        Geometry::Nothing => println!("什么也没有"),
        // 匹配具体的值。除了下面一行可以省略,其他匹配项都不可省略,因为 match 表达式必须是穷尽的。
        Geometry::Point(3, 4) => println!("x: 3, y: 4"),
        Geometry::Point(x, y) => println!("x: {}, y: {}", x, y), // 匹配并解构元组
        Geometry::Rect(r) => println!("{:#?}", r),       // 匹配并结构元组
        // 匹配并结构结构体,将打印:x: 33.4, y: 44.4, radius: 10.3
        Geometry::Circle { x, y, radius } => println!("x: {}, y: {}, radius: {}", x, y, radius),
    }
}

如果 Rust 的枚举的所有变体都没有附加对数据,这种枚举就像 C 语言的枚举一样,每个变体都对应一个整型值,称为判别值(discriminant)。可以通过 as 操作符将具有判别值的枚举值转换为整型。也可以显式指定变体的判别值,这时后续变体的判别值自动在上一个变体判别值的基础上加 1。如下所示:

#![allow(dead_code)]

fn main() {
    // 该枚举的所有变体都没有附加数据,因而每个变体对应一个整型判别值。
    enum DigitalSequence {
        Zero, // 0
        One,  // 1
        Two,  // 2
    }
    // 枚举标识符太长,使用 use 导入各个各个变体,此后就可以直接使用这些变体名称了。
    use DigitalSequence::{One, Two, Zero};
    println!(
        "Zero: {}, One: {}, Two: {}",
        Zero as i32, One as i32, Two as i32 // 通过 as 将枚举值转换为整型
    ); // Zero: 0, One: 1, Two: 2

    enum Number {
        Zero,      // 0
        Three = 3, // 3
        Four,      // 4
        Six = 6,   // 6
        Seven,     // 7
    }
}

当枚举类型没有任何变体时,被称为零变体枚举(或无变体枚举),如下所示:

enum ZeroVariants {}

零变体枚举类型不能被实例化。这种枚举等同于 never 类型,但不能被自动强制转换为其他类型。

Rust 枚举的用途非常广泛。如 Option<T>Result<T, E> 都是在标准库中定义的非常实用的枚举类型,前者用于表示一个值是否存在,后者用于表示可能的错误。由于他们太过常用,这两个枚举类型,以及他们各自的两个变体都被引入到 prelude 中,你在代码中不需要引入他们就可以直接使用这两个类型以及他们的变体。在下一小节就会讲 Option<T> 了。

枚举类型在定义递归数据类型时非常有用。递归类型值的一部分可能又包含该类型的值,像链接表、树这些数据结构都可以用递归方式表示。Rust By Example 有一个用枚举表示链接表的示例;前面提到的带标签联合体维基百科页面有一个用 Rust 实现二叉树的示例。不过,由于 Rust 所有权、借用规则的影响,用枚举实现这些递归数据类型其实还是很复杂的。

枚举类型相当于一种超集类型,它可以指代各种具体的枚举变体类型,这种关系类似于父类与子类、接口(trait)与其实现者之间的关系,再加上枚举变体能绑定各种复杂结构数据,因此 Rust 的枚举在一定程度上能实现面向对象编程中的封装和抽象功能。

Option<T> 类型

Option<T> 类型定义于标准库中,该类型表示一个可选的值,每个 Option<T> 要么是包含一个值的 Some<T> 变体,要么是什么都不包含的空值 NoneOption<T> 类型很好地解决了空值问题。空值是按照程序逻辑,一个值是未知的、不适用的或将在以后添加数据。许多编程语言用 null 表示空值,这种问题在于当你尝试像一个非空值那样使用一个空值时,会出现某种形式的错误。因为空和非空的属性无处不在,非常容易出现这类错误。请注意空值和未初始化的变量是不一样的,前者是有意而为的;后者往往是由于疏漏造成的,并且常导致程序无法编译。

Go 语言在为变量分配存储空间但没有给定初始化值时,都默认赋零值。其中不同值类型的零值不同,如 0、""false 等;引用、指针类型的零值则统一为 nil(该值是不可比较的),nil 有时又被当作空值使用。这种默认赋零值的方法为该语言的使用带来了一定的便利性,但不得不承认,这会导致在需要空值的时候却没有办法来表征,致使语言的表达能力变弱。

Option<T> 枚举类型的形式如下:

pub enum Option<T> {
    None,
    Some(T),
}

在 Rust 中,当一个类型为 T 的变量可能包含空值时,可以转而使用 Option<T> 而不再是 T 来表示变量的类型。当变量值不为空时,就将其以 Some(value) 的形式存储;当变量为空时,直接将其值设置为 None。在读取变量时,可以使用 matchif let 表达式,通过模式匹配检查该变量的值是否为空 None,并在不为空时从 Some 中取出值使用。

下面我们分别构建一个示例来演示在 Go 和 Rust 中,当变量可能为空值时,该怎么进行处理。假设我们在构建一个 CAD 系统,并使该系统分别能对二维或三维的情况进行建模,以及二维和三维混合建模。所有几何体都需要通过坐标描述,在使用结构体表示坐标时,要求其 xy 坐标不能为空值,而 z 坐标可以为空值。当 z 坐标为空值时,表示该坐标只存在于二维空间中,不存在于三维空间中。以下代码同时使用的方法,我们在下一节马上就要讲方法了,这里就当先热热身。

先是 Go 代码:

package main

import (
	"errors"
	"fmt"
	"math"
)

type Coordinate struct {
	x, y, z float64
	hasZ    bool // 当该值为 false,表示 z 值为空
}

// NewCoordinate 根据给定的坐标值创建一个新的坐标。
func NewCoordinate(x, y float64, z ...float64) Coordinate {
	c := Coordinate{x: x, y: y}
	if len(z) > 0 {
		c.z = z[0]
		c.hasZ = true
	}
	return c
}

// IsIn3D 方法判断坐标是否在三维空间中。
func (c *Coordinate) IsIn3D() bool {
	return c.hasZ
}

// SetZ 设定坐标的 Z 坐标分量。
func (c *Coordinate) SetZ(z float64) {
	c.z = z
	c.hasZ = true
}

// RemoveZ 方法通过删除 Z 坐标分量将坐标变为二维坐标。
func (c *Coordinate) RemoveZ() {
	c.hasZ = false
}

// DistToCoordinate2D 方法计算到另外一个坐标点的平面距离。
func (c *Coordinate) DistToCoordinate2D(d *Coordinate) float64 {
	return math.Sqrt(math.Pow(c.x-d.x, 2) + math.Pow(c.y-d.y, 2))
}

// DistToCoordinate3D 方法计算到另外一个坐标点在三维空间的距离。
func (c Coordinate) DistToCoordinate3D(d *Coordinate) (float64, error) {
	if !c.hasZ || !d.hasZ {
		return 0, errors.New("coordinate is not defined in 3D space")
	}
	return math.Sqrt(math.Pow(c.x-d.x, 2) + math.Pow(c.y-d.y, 2) + math.Pow(c.z-d.z, 2)), nil
}

func main() {
	c1 := NewCoordinate(3.3, 4.4, 5.5)
	c2 := NewCoordinate(11.1, 22.2)
	d, err := c1.DistToCoordinate3D(&c2)
	if err != nil {
		fmt.Println("error:", err)
	} else {
		fmt.Println(d)
	} // error: coordinate is not defined in 3D space

	c2.SetZ(33.3)
	d, err = c1.DistToCoordinate3D(&c2)
	if err != nil {
		fmt.Println("error:", err)
	} else {
		fmt.Println(d)
	} // 33.91931603084001
}

Rust 代码:

#![allow(dead_code)]

struct Coordinate {
    x: f64,
    y: f64,
    z: Option<f64>, // 当不存在 z 时,为 None 值
}

impl Coordinate {
    // 根据给定的坐标值创建一个新的坐标。
    fn new(x: f64, y: f64, z: Option<f64>) -> Coordinate {
        Coordinate { x, y, z }
    }

    // 判断坐标是否在三维空间中。
    fn is_in_3d(&self) -> bool {
        self.z.is_some()
    }

    // 设定坐标的 Z 坐标分量。
    fn set_z(&mut self, z: Option<f64>) {
        self.z = z;
    }

    // 通过删除 Z 坐标分量将坐标变为二维坐标。
    fn remove_z(&mut self) {
        self.z = None;
    }

    // 计算到另外一个坐标点的平面距离。
    fn dist_to_coordinate_2d(&self, d: &Coordinate) -> f64 {
        ((self.x - d.x).powi(2) + (self.y - d.y).powi(2)).sqrt()
    }

    // 计算到另外一个坐标点在三维空间的距离。
    fn dist_to_coordinate_3d(&self, d: &Coordinate) -> Option<f64> {
        match (self.z, d.z) {
            (Some(z0), Some(z1)) => {
                Some(((self.x - d.x).powi(2) + (self.y - d.y).powi(2) + (z0 - z1).powi(2)).sqrt())
            }
            _ => None,
        }
    }
}

fn main() {
    let c1 = Coordinate::new(3.3, 4.4, Some(5.5));
    let mut c2 = Coordinate::new(11.1, 22.2, None);
    let mut d = c1.dist_to_coordinate_3d(&c2);
    // 只需要匹配一种模式,使用 if let 表达式代替 match。
    if let Some(v) = d {
        println!("{}", v);
    } else {
        println!("coordinate is not defined in 3D space")
    } // coordinate is not defined in 3D space

    c2.set_z(Some(33.3));
    d = c1.dist_to_coordinate_3d(&c2);
    if let Some(v) = d {
        println!("{}", v);
    } else {
        println!("coordinate is not defined in 3D space")
    } // 33.91931603084001
}

从以上两段代码可以看出,由于 Go 无法表示 z 的空值,必须额外地引入一个字段 hasZ 来进行判别,当有很多值都需要区分是否为空值时,事情就变得很复杂。Rust 则不然,通过使用 Option<T> 类型,其代码逻辑更加清晰和紧凑。不过请注意,引入Option<T> 类型并不是为了缩减代码量,而只是为了优化代码逻辑的。

定义 Option<T> 类型的 option 模块还提供了其他许多功能,有兴趣的话可以进一步阅读该模块文档

联合体

联合体(union)是 Rust 中的一种复合数据类型。该类型的声明类似于结构体,即有多个命名的字段,每个字段有各自的类型。但其实联合体的所有字段共享同一段存储,对联合体的一个字段的写操作会覆盖其他字段,联合体的尺寸由其尺寸最大的字段的尺寸所决定。联合体为小片内存的数据交换提供了很大的便利性。联合体是在 Rust 1.0 发布之后才被加入到语言中的(RFC 1444),并直到 Rust 1.19.0 才稳定下来。Rust 的联合体与 C 语言的联合体类似。Rust 提供联合体类型主要是为了使用 FFI(Foreign Function Interface)和其他语言进行交互。更多的时候,应该尽量使用枚举而不是联合体。基于此,你也可以暂时跳过本小节内容而不会对掌握 Rust 有太大影响。

对于编译器来说,联合体变量对应内存中所存储的具体类型是不确定的,这时读取部分内存并转换为具体的类型可能导致未预料的或未定义的行为,因此读取联合体字段的操作需要放在 unsafe块内进行。读取联合体的另外一个方法是使用模式匹配,因为可能错误第匹配,同样应该将模式匹配语句放进 unsafe 块中。

当联合体被销毁时,无法知道需要销毁它的哪些字段,这些字段可能包含指向外部数据的指针,这时如何销毁这些被指向的数据就成问题。因此,所有联合体的字段都必须实现 Copy trait,或被包装进 ManuallyDrop<_>。这将确保联合体在超出作用域时不需要销毁任何内容。当联合体的字段类型没有实现 Copy 时,就应该将起包装进 ManuallyDrop<_> 中,这将阻止编译器自动调用该字段类型的析构函数,以确保它能被手动释放。与结构体和枚举一样,联合体也可以通过 impl Drop 手动定义被销毁时的具体动作。

像结构体一样,联合体的字段也可以被借用,但应该把所有字段看作是一个字段。即如果联合体的一个字段被借用,相当于所有剩下的字段也被借用,且具有相同的生命周期。

如下是一个声明和使用联合体的示例:

#![allow(dead_code)]

use std::mem::ManuallyDrop;

union AnyValue {
    int: i32,
    float: f64,
    boolean: bool,
    text: ManuallyDrop<String>,
    // 下面一行无法通过编译,将显示错误:
    // error[E0740]: unions cannot contain fields that may need dropping
    // text_alt: String,
}

fn main() {
    let mut v: AnyValue = AnyValue { float: 33.3 };

    // 读取字段,需要在 unsafe 块中
    let f = unsafe { v.float };
    println!("{}", f); // 33.3

    // 前面放入了一个浮点数,却试图按整数读出,
    // 程序仍该编译和运行,但读出的数值却是非预期的。
    // 此 f 遮蔽了前面的 f64 类型的 f。
    let f = unsafe { v.int };
    println!("{}", f); // 1717986918

    // 由于 text 字段为 ManuallyDrop 包装类型,因此写入时不需要标记 unsafe。
    v.text = ManuallyDrop::new("Hello".to_string());
    let f = unsafe { &v.text };
    println!("{:?}", f); // ManuallyDrop { value: "Hello" }

    // 通过模式匹配访问联合体字段。
    // 像这里定义的 Value 联合体,其需要匹配的项太多,因此这样漫无目的地匹配起来是没有多大意义的。
    v.int = 1;
    unsafe {
        // 匹配很可能产生错误,因此此段代码是不安全的
        match v {
            // 带有具体值的模式匹配是可驳斥的
            AnyValue { int: 33 } => println!("精确匹配到一个整数值 33"),
            AnyValue { int: 44 } => println!("精确匹配到一个整数值 44"),
            // 布尔类型很容易被匹配到,当值为 1 时,此项竟然被错误地匹配到了;
            // 而当值为 0 或 2 时,此项又匹配不到。
            AnyValue { boolean: true } => println!("真"),
            // 不带具体值的模式匹配是不可驳斥的,所以无论该联合体里面存什么值,都会把它
            // 当作 float 字段对应的 f64 类型。将以上值从 1 改为 2,此项就会被匹配到。
            // 当然打印的结果是错误的,并不是 2.0。
            AnyValue { float } => println!("{}", float),
        } // 最终打印:真
    }

    // 应该把联合体的多个字段当作一个去进行移动和借用。
    unsafe {
        let b1 = &mut v.int; // 第 1 次可变借用
        // 由于同一时刻只能有一个可变借用,因此以下两行代码只能保留一行
        // let b2= &mut v.float; // 第 2 次可变借用
        println!("{}", b1); // 使用 b1,说明到此为止,b1 的借用还没有归还
    }
}

方法

方法是绑定在特定类型或对象上的函数,它定义了类型或对象的行为。前面讲解 Option<T> 类型时已经给出了一个很好的使用方法的示例了,这里再给出一个:

package main

import "fmt"

type MyInt int

// IsOdd 方法判断该整数是否为奇数。也可以声明该方法的接收者为 *MyInt 指针类型。
func (n MyInt) IsOdd() bool {
	return n%2 != 0 // 自动将 n 的类型从 MyInt 转换为 int
}

// PlusOne 使该整数加 1,这时其接收者必须为指针,否则对接收者的改变将不起效果。
func (n *MyInt) PlusOne() {
	*n += 1 // 这里必须对指针手动解引用
}

func main() {
	n := MyInt(11)
	fmt.Println(n.IsOdd()) // true
	n.PlusOne()
	fmt.Println(n.IsOdd()) // false

	p := &n
	fmt.Println(p.IsOdd()) // false
	p.PlusOne()
	fmt.Println(p.IsOdd()) // true
}
// 一元元组结构体,和 Go 中基于 int 声明新类型是相似的。
struct MyInt(i32);

impl MyInt {
    // is_odd 方法判断该整数是否为奇数。如果方法的接收者为 self 而不是 &self,
    // 接收者的所有权将会被移动到方法内部,从而消费接收者。
    fn is_odd(&self) -> bool {
        self.0 % 2 != 0
    }

    // plus_one 使该整数加 1,这时其接收者必须为可变引用。
    fn plus_one(&mut self) {
        self.0 += 1;
    }

    // take_ownership 将接收者的所有权转移到了方法中,此后方法外的接收者变量将不能再使用。
    fn take_ownership(mut self) {
        self.0 += 1;
    }
}

fn main() {
    let mut n = MyInt(11);
    println!("{}", n.is_odd()); // true
    n.plus_one();
    println!("{}", n.is_odd()); // false

    let p = &mut n;
    println!("{}", p.is_odd()); // false
    p.plus_one();
    println!("{}", p.is_odd()); // true

    n.take_ownership(); // n 的所有权被移动了
    // println!("{}", n.is_odd()); // 错误:borrow of moved value: `n`
}

我们在前面已经接触过很多方法了,因此现在不妨直接总结一下方法定义和使用的要点:

  • Go 和 Rust 都支持为自定义类型定义方法。要为其他包/模块中的已有类型定义方法,需要用自定义类型将这些类型重新包装一下。
  • 方法就像函数一样,只不过多了一个方法绑定类型的接收器(receiver),接收器代表该类型的实例。Go 的方法接收器放在 func 和方法名称之间,用圆括号包括,形如 (r T)(r *T),即分别对值和指针定义方法;可以自行指定接收器名称,但并不推荐把接收器的名称取为 thisself,要取简短且稍微带点语义的名称。Rust 的方法接收器就是方法的第一个参数,不需要指定其类型,其名称总是 self,形如 selfmut self&self&mut self,分别是 self: Selfmut self: Selfself: &Selfself: &mut Self 的语法糖,其中 Self 指代与方法绑定的类型,这四种形式又分别表示移动、移动且可变、借用和可变借用类型实例。在理解和使用上,就把方法接收器当成一个普通的函数参数就行了。
  • Go 的方法声明可以放在包的任意文件的任意位置,稍微更灵活一点。Rust 的方法声明需要放置在同一模块的 impl T {...} 块内部,可以有多个 impl 块。建议尽量将一个类型的方法放在邻近位置声明。
  • 在调用方法时,两种语言都采用相同的选择器语法(方法语法),形如 receiver.MethodName()
  • 无论是 Go 或是 Rust,无论方法是定义在值上还是指针(引用)上,而在使用方法是,不管类型实例是值还是指针(引用),都能很方便地通过 receiver.MethodName() 方式调用方法。其背后是编译器自动地对接收者取地址或解引用。因此, receiver.MethodName() 的实际形式可能为 receiver.MethodName()(&receiver).MethodName()(*receiver).MethodName()。通过点号方式访问字段时也是如此。另外顺便提一句,&receiver.FieldName*receiver.FielddName 前面的取地址和解引用运算符总是字段字段而不是而不是接收者。
  • 我们还没有讲到可见性,但先提一下,像上面声明的 MyInt 中存储的值总是对外可见的。如果我们想控制自定义类型包含数据的可见性,在 Go 中,必须使用结构体;在 Rust 中,不能使用元组结构体,而需要使用普通结构体或其他自定义类型。
  • 想必我们在编程时已经发现,在 Go 中,大量原始类型如整型、浮点型、布尔型等是没有方法的,可以使用一些内建函数以及一些标准库函数对他们的值进行操作。而在 Rust 中,每种原始类型都有大量的方法。例如,如果得到一个切片类型变量 slice 的长度,Go 中需要通过 len(slice) 获得,Rust 则可以使用 slice.len()。Rust 类型系统的一致性要更强一点,也更方便一点。
  • 在 Go 中,内嵌的匿名类型的方法也将会被提升,Rust 不支持内嵌。
  • Go 和 Rust 都需要手工为自定义类型添加读取器(getter)和写入器(setter)类方法。如对于 User 结构体的 name 字段,Go 中建议的读取器名称为 Name 而不是 GetName,写入器名称为 SetName。Rust 没有明确的规定,但我自己觉得使用 Go 的这种习惯挺好的,即分别为 nameset_name。在个别情况下,如果读取器需要可变借用字段值,需要在方法名称后面加 _mut,如 name_mut

在 Go 和 Rust 中,也可以为自定义类型添加构造器(constructor)。Go 的构造器和普通函数是一模一样的,如前面的 NewCoordinate 函数。如果 Coordinate 结构体所处的包主要就是构建该结构体的,这时包的名称推荐为 coordinate,相应地构造器的名称应该为 New,其调用 coordinate.New 的语义也一目了然。

在 Rust 中,自定义类型通常有一个名称为 new 的构造器,将要其放置在 impl 块中。这样的构造器和绑定的类型相关(放在 impl 块中),但和类型实例无关(不用接收 self 作为参数),被称作关联函数(associated function),关联函数常常被用作构造器以返回自定义类型的一个实例。如前面给出的 Coordinate 结构体的 new 方法就属于关联函数。在调用关联函数时,不使用点号语法,而使用双冒号,形如 Coordinate::newString::from。严格来说,方法也属于关联函数的一种。

在 Go 中定义方法时,经常需要选择是将方法定义在值上还是指针上。其实基于 Go 的内存管理规则,对于小尺寸数据类型,更倾向于对值定义方法,这样就减小了 GC 的开销。但当存在下列情况是,应该对指针定义方法:

  • 当方法要修改接收者中的非引用值时,只有接收者为指针,其修改才是有效的,如前面的 PlusOne 方法。

  • 当接收者的尺寸很大时,使用指针能避免复制数据,从而提高效率。

  • 如果存在一系列方法,如果某些方法的接收者为指针,应将可以不是指针的方法的接收者也变为指针。如前面的 IsOdd 方法,更应该将其接收者变为指针。

因此,虽然从执行效率考虑,多数情况下都应该在值上定义方法,但实际基于上述三条原则,又经常在指针上定义方法。这种考虑但函数的参数是值还是指针同样适用。

如果自定义类型中包含切片、映射,这时接收者为或指针的情况就变得比较微妙。请看下面示例:

package main

import "fmt"

type Series []int           // 基于 []int 类型自定义新类型
type SeriesWrapper struct { // 包装 []int 类型的字段自定义结构体
	value []int
}

// PlusThree 将 Series 的每个元素加 3。
func (s Series) PlusThree() {
	for i := 0; i < len(s); i++ {
		s[i] += 3
	}
}

// PlusThree 将 SeriesWrapper 的每个元素加 3。
func (s SeriesWrapper) PlusThree() {
	for i := 0; i < len(s.value); i++ {
		s.value[i] += 3
	}
}

// Extend 所做的工作本来类似 append,但无论接收器是 Series 或 *Series,该方法都不起作用。
func (s Series) Extend(extras ...int) {
	t := append(s, extras...) // [0 1 2 3 4 5]
	// 该行能编译,但不起作用。因为对方法接收器的复制只传递给被调用放(方法内),不传递到调用方(方法外)。
	s = t
}

// ExtendPointer 所做的工作本来类似 append,但无论接收器是 Series 或 *Series,该方法都不起作用。
func (s *Series) ExtendPointer(extras ...int) {
	t := append(*s, extras...) // [0 1 2 3 4 5]
	// 该行能编译,但不起作用。因为对方法接收器的复制只传递给被调用放(方法内),不传递到调用方(方法外)。
	s = &t
}

// Extend 所做的工作本来类似 append,但当接收器是 *SeriesWrapper,该方法不起作用。
func (s SeriesWrapper) Extend(extras ...int) {
	t := append(s.value, extras...) // [0 1 2 3 4 5]
	// 该行能编译,但不起作用。因为对方法接收器的复制只传递给被调用放(方法内),不传递到调用方(方法外)。
	s.value = t
}

// ExtendPointer 所做的工作类似 append,且可以起作用。
func (s *SeriesWrapper) ExtendPointer(extras ...int) {
	t := append(s.value, extras...) // [0 1 2 3 4 5]
	// 这里就能将更改结果传递回去。
	s.value = t
}

func main() {
	s := Series([]int{0, 1, 2})
	sw := SeriesWrapper{value: []int{0, 1, 2}}

	s.Extend(3, 4, 5)
	fmt.Println(s) // [0 1 2]

	sw.Extend(3, 4, 5)
	fmt.Println(sw) // {[0 1 2]}

	s.ExtendPointer(3, 4, 5)
	fmt.Println(s) // [0 1 2]

	sw.ExtendPointer(3, 4, 5)
	fmt.Println(sw) // [0 1 2 3 4 5]

	s.PlusThree()
	fmt.Println(s) // [3 4 5]

	sw.PlusThree()
	fmt.Println(sw) // {[3 4 5 6 7 8]}
}

可以看出,对于 Series,由于其本质是 []int 类型,无论其 Extend 方法的接收者是值还是指针,都无法在追加(append)元素后将值传回来,这也是内建的 append 函数必须在结果中传回值的原因。要使追加的值能够传回,需要用一个结构体包装 []int 类型字段,并使 Extend 方法的接收者为指针。

类似地,在 Rust 中,究竟使用 selfmut self&self&mut self 四种接收者的哪一种,需要进行如下考量:

  • 多数情况下,都不使用 selfmut self 形式的接收者,除非真的想要消费该接收者,这常发生在基于现有实例生成一个新的实例,并不再需要已有实例之时。
  • 如果需要修改接收者,就使用 &mut self 形式,否则使用 &self 形式。

Rust 并不存在以上 Go 的 SeriesSeriesWrapper 类型存在的问题,请看示例:

#[derive(Debug)]
struct Series(Vec<i32>);

impl Series {
    // plus_three 将 Series 的每个元素加 3。
    fn plus_three(&mut self) {
        for v in self.0.iter_mut() {
            *v += 3;
        }
    }

    // extend 将一组数字(切片类型)追加到 Series 的末尾。
    fn extend(&mut self, extra: &[i32]) {
        self.0.extend_from_slice(extra);
    }
}

fn main() {
    let mut s = Series(vec![0, 1, 2]);
    s.extend(&[3, 4, 5]);
    println!("{:?}", s); // Series([0, 1, 2, 3, 4, 5])
    s.plus_three();
    println!("{:?}", s); // Series([3, 4, 5, 6, 7, 8])
}

讨论

  • 在结构体的表示上,Go 和 Rust 比较相似。不过 Go 的结构体支持嵌入其他类型,一定程度上模拟了类的继承。
  • Rust 比 Go 多了枚举和联合体两种自定义类型,尤其是枚举类型,其使用非常灵活,使 Rust 的表达能力大幅提升,要善加利用。
  • 我们还没有讲到可见性、接口和 trait、泛型,这些都和自定义类型密切相关,将在后续逐渐涉及。