Rust 语言从入门到实战 唐刚--读书笔记07

Rust 语言从入门到实战 唐刚--读书笔记07

基础篇 (11讲)

07|类型与类型参数:给Rust小助手提供更多信息

类型相关的知识,对 Rust ,非常重要而有趣。

对一个二进制值来说,正是 类型 赋予了它意义。

如 01100001 这个二进制数字,是整数表示 97,是字符表示 'a' 这个 char。没有类型去赋予它额外的信息,仅看到这串二进制编码,不知道它代表什么。

类型

《Programming.with.Types》2019 对类型的定义:类型是对数据的分类,定义了这些数据的意义、被允许的值的集合,能在这些数据上执行哪些操作。编译器或运行时会检查类型化过程,以确保数据的完整性,对数据施加访问限制,以及把数据按程序员的意图进行解释。

为简化,把操作部分忽略,简单地把类型看作集合这个集合表达了这个类型的实例能取到的所有可能的值。

类型系统

书里还定义了类型系统。

类型系统是一套规则集——把类型赋予和施加到编程语言的元素上。这些元素可以是变量、函数和其他高阶结构。类型系统通过在代码中提供的标注来给元素赋予类型,或根据它的上下文隐式地推导某个元素的类型。类型系统允许在类型间做各种转换,同时禁止其他的一些转换。

类型的标注: let a: u32 = 10;

        用 : u32 语法对变量 a 进行标注,表明变量 a 的类型是 u32 类型。

u32 可以转换成 u64。

        let b = a as u64;

但 u32 不能直接转换到 String 。

fn main() {
    let a: u32 = 10;
    let b = a as String;    // 错误的

    println!("{b}");
}

类型化的好处

类型化 5 大好处:正确性、不可变性、封装性、组合性、可读性。都是软件工程理论推崇的

Rust 语言非常强调类型化,它的类型系统非常严格,隐式转换非常少:

fn main() {
    let a = 1.0f32;
    let b = 10;
    
    let c = a * b;
}

错误,提示不能将一个浮点数和一个整数相乘。

error[E0277]: cannot multiply `f32` by `{integer}`
 --> src/main.rs:5:15
  |
5 |     let c = a * b;
  |               ^ no implementation for `f32 * {integer}`
  |
  = help: the trait `Mul<{integer}>` is not implemented for `f32`
  = help: the following other types implement trait `Mul<Rhs>`:
            <&'a f32 as Mul<f32>>
            <&f32 as Mul<&f32>>
            <f32 as Mul<&f32>>
            <f32 as Mul>

初学者往往会觉得 Rust 过于严苛。

当基础类型转换错误时,用 as 操作符显式地将类型转成一致。

fn main() {
    let a = 1.0f32;
    let b = 10 as f32;    // 添加了 as f32
    
    let c = a * b;
}

编译通过。

Rust 明显的特点:尽可能地显式化

显式化两层意思。

  1. 不做自动隐式转换。
  2. 没有内置转换策略。

不做自动隐式转换:多了 as f32 这几个字符,由程序员为编译器明确地提供了一些额外的信息。

没有内置转换策略:拿 JavaScript 社区中流传的一张梗图来对比说明。

在 JavaScript 里,9 + "1" 的结果是 "91"。在两个不同类型之间相加如何处理上,JavaScript 自己内置了策略,硬生生出了一个结果。当遇到 91- "1" 时,策略又变了,算出了数字 90。这些就是内置类型转换策略的体现。

Rust 中,9+"1" 不可能通过编译,更不会计算出一个神奇的结果。

        如果要写类似的代码,Rust 中可以这样做。

fn main() {
    let a = 9 + '1' as u8;
    let b = 9.to_string() + "1";
}

        特别清晰!一眼就能推断出 a 和 b 的类型,a 为 u8 类型,b 为 String 类型。

Rust 有着严密的类型体系,在类型化上绝不含糊。项目越大,用 Rust 就越舒服,原因之一就是严谨的类型系统在保驾护航。

类型作为一种约束

类型是变量所有可能取得的值的集合。类型实际上限制或定义了变量的取值空间。类型对于变量来说,也是一种约束

实际上,Rust 中的 : (冒号)在语法层面上就是约束。

let a: u8 = 10;
let b: String = "123".to_string();

变量 a 被限制为只能在 u8 这个类型的值空间取值(0 到 255 里的一个),而 10 属于这个空间。变量 b 被限制为只能在字符串值空间内取值。不管字符串的值空间多大(其实是无限),这些值与 u8、u32 这些类型的值也是不同的。

多种类型如何表示?

用一种类型来对变量的取值空间进行约束。利于健壮性。

Rust 中,把整数分成 u8、u16、u32、u64。想写一个函数,它的参数支持整数,要同时能接受 u8、u16、u32、u64 这几种类型的值,怎么办?

如何让这一个日志函数同时支持数字和字符串作为参数输入呢?

问题:Rust 中,怎么用某种方式来表示多种类型

类型参数

Rust 中定义类型的时候,可以用类型参数

如标准库里的  Vec<T>,带一个类型参数 T,它可以支持不同类型的列表,如 Vec<u8>、Vec<u32>、Vec<String> 等。这个 T 表示一个类型参数,在定义时还不知道它具体是什么类型。只有在使用的时候,才会对这个 T 赋予一个具体的类型。

这个 Vec<T>,是一个类型整体,单独拆开来讲 Vec 类型是没有意义的。T 是 Vec<T> 中的类型参数,它其实也是信息,提供给 Rust 编译器使用。带类型参数的类型整体(如 Vec<T>)叫做泛型(generic type)。

结构体中的类型参数

定义一个结构体 Point<T>。

struct Point<T> {
    x: T,
    y: T,
}

二维平面上的点,由 x、y 坐标定义。 x、y 的取值可能是整数、浮点数、无穷精度的类型。定义为 struct Point<T>, Point<T> 整体就成为了泛型。标注 x 和 y 分量的类型都是 T。T 占据了冒号后面定义类型的位置,是占位类型

隐藏的细节:x 和 y 字段的类型都是 T, x 和 y 两个分量的类型是一样的

看这个 Point 结构体类型如何实例化。

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };     // 一个整数point
    let float = Point { x: 1.0, y: 4.0 };    // 一个浮点数point
}

编译通过。

如果实例化的时候,给 x 和 y 赋予不同的类型值会怎样呢?

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let wont_work = Point { x: 5, y: 4.0 };
}

$ cargo run
   Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
 --> src/main.rs:7:38
  |
7 |     let wont_work = Point { x: 5, y: 4.0 };
  |                                      ^^^ expected integer, found floating-point number

期望整数却收到了浮点数,报错。

细节:编译器对 Point<T> 中的 T 参数进行了推导,先遇到 x 的值 5,整数类型,因此编译器就把 T 具体化成了整数类型(具体哪一种在这里不重要),再遇到 y 分量的值,是浮点数类型,和刚才的整数类型不一致了。Point 定义时,要求 x 和 y 的类型是相同的,产生了冲突,报错。

如何解决这个问题?把 x 分量和 y 分量定义成不同的参数化类型。

struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let both_integer = Point { x: 5, y: 10 };
    let both_float = Point { x: 1.0, y: 4.0 };
    let integer_and_float = Point { x: 5, y: 4.0 };
}

通过编译。

强调,Point<T> 和 Point<T, U> 都是一个类型整体。把 Point 这个符号本身单独拿出来没有意义。

可用 turbofish 语法 ::<> 明确地给泛型(或 Rust 编译器)提供类型参数信息。

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer = Point::<u32> { x: 5, y: 10 };     
    let float = Point::<f32> { x: 1.0, y: 4.0 };    
}
struct Point<T, U> {
    x: T,
    y: U,
}

fn main() {
    let both_integer = Point::<u32, u32> { x: 5, y: 10 };
    let both_float = Point::<f32, f32> { x: 1.0, y: 4.0 };
    let integer_and_float = Point::<u32, f32> { x: 5, y: 4.0 };
}

使用时提供类型参数信息用的是 ::<>,而定义类型参数时只用 <>。通过语法明确地区分开了。

类型参数存在两个过程,定义时,使用时。这两个过程的区分很重要。这里所谓的“使用时”,仍然是在编译期进行分析的,就是分析在代码的某个地方用到了这个带类型参数的类型,然后把这个参数具体化,从而形成一个最终的类型版本

如 Point<T> 类型的具化类型可能是 Point<u32>、Point<f32> 等等;Point<T, U> 类型的具化类型可能是 Point<u32, u32>、Point<u32, f32>、Point<f32, u32>、Point<f32, f32> 等等。由编译器自动计算展开。

这种在编译期间完成的类型展开成具体版本的过程,被叫做编译期单态化。单态化的意思就是把处于混沌未知的状态具体化到一个单一的状态。

在泛型上做 impl

用  Point<T> 举例。

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {        // 注意这一行
    fn play(n: T) {}      // 注意这一行
}

对 Point<T> 做 impl 时,要在 impl 后面加一个 <T>,表示在 impl 的 block 中定义类型参数 T,供 impl block 中的元素使用,这些元素包括: impl<T> Point<T> 里 Point<T> 中的 T 和整个 impl 的花括号 body 中的代码,如 play() 函数的参数就用到了这个 T。

细节要注意:struct Point<T> 里 Point<T> 中的 T 是定义类型参数 T,impl<T> Point<T> 中的 Point<T> 中的 T 是使用类型参数 T,这个 T 是在 impl 后面那个尖括号中定义的。

对泛型做了 impl 后,对其某一个具化类型继续做 impl 也是可以的:

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn play(n: T) {}
}

impl Point<u32> {      // 这里,对具化类型 Point<u32> 继续做 impl
    fn doit() {}
}

具体场景中的类型参数的使用。

枚举中的类型参数

枚举的变体可以挂载任意其他类型作为负载。每个负载的位置,都可以出现类型参数。

最常见的两个枚举,Option<T> 与 Result<T, E>,就是泛型。

Option<T> 表示 有或无。

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

Result<T, E> 表示结果,正确或错误。Ok 变体带类型参数 T,Err 变体带类型参数 E。

enum Result<T, E> {
    Ok(T),
    Err(E),
}

更复杂的枚举中带类型参数。

struct Point<T> {
    x: T,
    y: T,
}

enum Aaa<T, U> {
    V1(Point<T>),
    V2(Vec<U>),
}

枚举 Aaa<T, U> 的变体 V1 带了一个 Point<T> 的负载,变体 V2 带了一个 Vec<U> 的负载。由于出现了两个类型参数 T 和 U,要在 Aaa 后面的尖括号里定义这两个类型参数。

类型参数也是一种复用代码的方式,代码更紧凑。

看具体的应用场景。

函数中的类型参数

对每个具体的类型实现一次同样的逻辑,很臃肿。不好维护,容易出错。

struct PointU32 {
    x: u32,
    y: u32,
}

struct PointF32 {
    x: f32,
    y: f32,
}

fn print_u32(p: PointU32) {
    println!("Point {}, {}", p.x, p.y);
}

fn print_f32(p: PointF32) {
    println!("Point {}, {}", p.x, p.y);
}

fn main() {
    let p = PointU32 {x: 10, y: 20};
    print_u32(p);

    let p = PointF32 {x: 10.2, y: 20.4};
    print_f32(p);
}

针对不同的字段类型(u32,f32)分别定义结构体(PointU32,PointF32)和对应的打印函数(print_u32,print_f32),并分别调用。

优化成这样:

struct Point<T> {
    x: T,
    y: T,
}

fn print<T: std::fmt::Display>(p: Point<T>) {
    println!("Point {}, {}", p.x, p.y);
}

fn main() {
    let p = Point {x: 10, y: 20};
    print(p);

    let p = Point {x: 10.2, y: 20.4};
    print(p);
}

清爽多了!

实际上,清爽只是我们看到的样子。在编译的时候,Rust 编译器会帮助我们把这种泛型代码展开成前面那个示例的样子。

print 函数的类型参数在定义的时候,多了一个东西。

fn print<T: std::fmt::Display>(p: Point<T>) {

T: std::fmt::Display 是要求 T 满足某些条件 / 约束。这里具体来说就是 T 要满足可以被打印的条件。函数的目的是把 x 和 y 分量打印出来,那么它确实要能被打印才行,比如得能转换成人类可见的某种格式。

方法中的类型参数

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {          // 在impl后定义impl block中要用到的类型参数
    fn x(&self) -> &T {     // 这里,在方法的返回值上使用了这个类型参数
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };
    println!("p.x = {}", p.x());
}
// 输出 
p.x = 5

示例中,Point<T> 的方法 x() 的返回值类型是 &T,使用到了 impl<T> 这里定义的类型参数 T。

更复杂的内容,方法中的类型参数和结构体中的类型参数可以不同。

struct Point<X1, Y1> {
    x: X1,
    y: Y1,
}

// 这里定义了impl block中可以使用的类型参数X3, Y3,
impl<X3, Y3> Point<X3, Y3> {
    // 这里单独为mixup方法定义了两个新的类型参数 X2, Y2
    // 于是在mixup方法中,可以使用4个类型参数:X3, Y3, X2, Y2
    fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X3, Y2> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c' };

    let p3 = p1.mixup(p2);

    println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}

// 输出 
p3.x = 5, p3.y = c

在 Point<X3, Y3> 的方法 mixup() 上新定义了两个类型参数 X2、Y2,于是在 mixup() 方法中,可以同时使用 4 个类型参数:X2、Y2、X3、Y3。品味一下这个示例。

类型体系构建方法

类型体系构建:如何从最底层的小砖块开始,通过封装、组合、集成,最后修建成一座类型上的摩天大楼。在 Rust 里,主要有四大基础设施参与这个搭建的过程。

  • struct 结构体
  • enum 枚举
  • 洋葱结构
  • type 关键字

struct 和 enum

struct 是 Rust 里把简单类型组合起来的主要结构。struct 里的字段可以是基础类型,也可以是其他结构体或枚举等复合类型,这样一层一层往上套。

结构体 struct 表达的是一种元素间同时起作用的结构。

枚举 enum 表达的是一种元素间在一个时刻只有一种元素起作用的结构。特别适合做配置和类型聚合之类的工作。

综合示例,对某个学校的数学课程建模的层级模型。

struct Point(u32, u32);  // 定义点

struct Rectangle {  // 长方形由两个点决定
  p1: Point,
  p2: Point,
}

struct Triangle(Point, Point, Point);  // 三角形由三个点组成

struct Circle(Point, u32);  // 圆由点和半径组成

enum Shape {   // 由枚举把长方形,三角形和圆形聚合在一起
  Rectangle(Rectangle),
  Triangle(Triangle),
  Circle(Circle),
}

struct Axes;   // 定义坐标

struct Geometry {    // 几何学由形状和坐标组成
  shape: Shape,
  axes: Axes,
}

struct Algebra;      // 定义代数

enum Level {         // 定义学校的级别
  Elementary,        // 小学
  Secondary,         // 初中
  High,              // 高中
}

enum Course {          // 数学要学习几何和代数,由枚举来聚合
  Geometry(Geometry),
  Algebra(Algebra),
}

struct MathLesson {      // 定义数学课程,包括数学的科目和级别
  math: Course,
  level: Level,
}

请根据注释认真体会其中类型的层级结构。你甚至可以试着去编译一下上述代码,看看是不是可以编译通过。

偷偷告诉你答案:可以通过!

newtype

结构体还有一种常见的封装方法,用单元素的元组结构体

如定义一个列表类型 struct List(Vec<u8>);。实际就是 Vec<u8> 类型的一个新封装,给里面原来那种类型取了一个新名字,也把原类型的属性和方法等屏蔽起来了。

没有具化类型参数的情形。

struct List<T>(Vec<T>);

这种模式非常常见,叫 newtype 模式,用新的类型名字替换里面原来那个类型名字。

洋葱结构

Rust 中的类型还有另外一种构建方法——洋葱结构。

示例,type 关键字在这里的作用是把一个类型重命名,取了更短的名字。

// 你可以试着编译这段代码
use std::collections::HashMap;

type AAA = HashMap<String, Vec<u8>>;
type BBB = Vec<AAA>;
type CCC = HashMap<String, BBB>;
type DDD = Vec<CCC>;
type EEE = HashMap<String, DDD>;

EEE 展开是这样的:

HashMap<String, Vec<HashMap<String, Vec<HashMap<String, Vec<u8>>>>>>;

尖括号的层数很多,像洋葱一样一层一层的,叫洋葱类型结构。可以把这个层次无限扩展下去。

结合 newtype 和 struct 的更复杂的示例。

use std::collections::HashMap;

struct AAA(Vec<u8>);
struct BBB {
  hashmap: HashMap<String, AAA>
}
struct CCC(BBB);
type DDD = Vec<CCC>;
type EEE = HashMap<String, Vec<DDD>>;

最后,EEE展开就类似下面这样(仅示意,无法编译通过)
HashMap<String, Vec<Vec<CCC(BBB {hashmap: HashMap<String, AAA<Vec<u8>>>})>>>

洋葱结构在嵌套层级多了之后,展开是相当复杂的。

type 关键字

type 关键字,作用是在洋葱结构表示太长了之后,把一大串类型的表达简化成一个简短的名字。Rust 中用 type 关键字,使类型大厦的构建过程变得清晰可控。

type 关键字还可以处理泛型的情况:

type MyType<T> = HashMap<String, Vec<HashMap<String, Vec<HashMap<String, Vec<T>>>>>>;

最里面那个是类型 Vec<T>,T 类型参数还在。给洋葱类型重命名的时候,要把这个 T 参数带上,变成了 MyType<T>。

这种写法在标准库里很常见,最佳示例就是关于各种 Result* 的定义。

在 std::io 模块里,取了一个与 std::result::Result<T, E> 同名的 Result 类型,把 std::result::Result<T, E> 定义简化了,具化其 Error 类型为 std::io::Error,同时仍保留了第一个类型参数 T。于是得到了 Result<T>。

pub type Result<T> = Result<T, Error>;

刚开始接触 Rust 的时候,你可能会对这种表达方式产生疑惑,其实道理就在这里。在阅读 Rust 生态中各种库的源码时,会经常遇到这种封装方式,要习惯它。

关于这种定义更多的资料:

https://doc.rust-lang.org/std/result/enum.Result.html

https://doc.rust-lang.org/std/io/type.Result.html

https://doc.rust-lang.org/std/io/struct.Error.html

小结

对 Rust 中类型相关的知识做了一个专门的讲解。

现代编程语言的趋势是越来越强调类型化,如 TypeScript、Rust。一个成熟的类型系统对于编写健壮的程序来说至关重要。类型可以看作是对变量取值空间的一种约束。

Rust 中,有很多对多种类型做统一处理的需求 --> 类型参数和泛型。单一的类型确实不方便,或者不能满足我们的需求。

4 种类型体系建模方法,在实践过程中慢慢加深理解。

思考题

如果你给某个泛型实现了一个方法,那么,还能为它的一个具化类型再实现同样的方法吗?

答:

思考题:"为泛型实现了一个方法,能否再为具化类型实现一个同名方法",取决于这个泛型能否表示相应的具化类型。比如为泛型 T 和 String 实现了相同的方法,由于 T 没有施加任何约束,它可以代表 String。那么当调用方法时,对于具化类型 String 来说,要调用哪一个呢?因此会出现歧义,编译器会报错:方法被重复定义了。 但如果给泛型 T 施加了一个 Copy 约束,要求 T 必须实现了 Copy trait,那么就不会报错了,因为此时 T 代表不了 String,所以调用方法不会出现歧义。但如果再为 i32 实现一个同名方法就会报错了,因为 i32 实现了 Copy,它可以被 T 表示。

答:

不能,编译器会提示duplicate definitions for XXXXX。 如果想为具化类型再实现同样的方法,则可以定义一个trait,用具化类型实现这个trait,来达到"为具化类型再实现同样的方法“的目的。

思考题:通常是不能, 但可以通过 trait 进行特化

思考题:核心点就在于不能为同一个类型实现 2 个相同函数签名的方法,因为这会引起方法冲突。 编译报错如下:

error[E0592]: duplicate definitions with name `print`
  --> examples/generic.rs:7:5
   |
7  |     fn print(&self) {
   |     ^^^^^^^^^^^^^^^ duplicate definitions for `print`
...
13 |     fn print(&self) {
   |     --------------- other definition for `print`

所以一般情况下,如果 impl<T> 后面的 T 没有任何的约束,那么就表示为所有类型的 T 都实现了方法,比如说 print(),这个时候是不能为具化类型再次实现 print() 的,因为这个时候就产生了方法冲突。

但是,如果 impl<T: std::fmt:Display> 后面的 T 是有约束的,那么其实只为符合这个约束的类型实现了 print(),其余类型是没有实现的,所以是可以为其余具化类型实现相同的方法的。 如:

struct Point<T> {
    x: T,
    y: T,
}

struct NotDisplay {
    a: u32,
}

impl<T: std::fmt::Display> Point<T> {
    fn print(&self) {
        println!("Point: {}, {}", self.x, self.y);
    }
}

impl Point<NotDisplay> {
    fn print(&self) {
        println!("not display");
    }
}

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值