通过提取函数消除重复

RustRustBeginner
立即练习

This tutorial is from open-source community. Access the source code

💡 本教程由 AI 辅助翻译自英文原版。如需查看原文,您可以 切换至英文原版

简介

欢迎来到「通过提取函数消除重复」实验。本实验是 Rust 程序设计语言 的一部分。你可以在 LabEx 中练习 Rust 技能。

在本实验中,我们将学习如何通过提取函数和使用泛型来操作抽象类型,从而消除代码重复。

通过提取函数消除重复

泛型允许我们用一个代表多种类型的占位符来替换特定类型,从而消除代码重复。在深入探讨泛型语法之前,让我们先看看如何通过提取一个函数来消除重复,该函数用一个代表多个值的占位符来替换特定值,而不涉及泛型类型。然后我们将应用相同的技术来提取一个泛型函数!通过了解如何识别可以提取到函数中的重复代码,你将开始识别可以使用泛型的重复代码。

我们将从清单10-1中的简短程序开始,该程序用于在列表中找到最大的数字。

文件名:src/main.rs

fn main() {
  1 let number_list = vec![34, 50, 25, 100, 65];

  2 let mut largest = &number_list[0];

  3 for number in &number_list {
      4 if number > largest {
          5 largest = number;
        }
    }

    println!("The largest number is {largest}");
}

清单10-1:在数字列表中找到最大的数字

我们将整数列表存储在变量number_list中[1],并将列表中第一个数字的引用存储在名为largest的变量中[2]。然后我们遍历列表中的所有数字[3],如果当前数字大于存储在largest中的数字[4],我们就替换该变量中的引用[5]。但是,如果当前数字小于或等于到目前为止看到的最大数字,变量不会改变,代码会继续处理列表中的下一个数字。在考虑了列表中的所有数字之后,largest应该指向最大的数字,在这种情况下是100。

现在我们的任务是在两个不同的数字列表中找到最大的数字。为此,我们可以选择复制清单10-1中的代码,并在程序的两个不同位置使用相同的逻辑,如清单10-2所示。

文件名:src/main.rs

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let mut largest = &number_list[0];

    for number in &number_list {
        if number > largest {
            largest = number;
        }
    }

    println!("The largest number is {largest}");
}

清单10-2:在两个数字列表中找到最大数字的代码

虽然这段代码可以工作,但复制代码既繁琐又容易出错。当我们想要更改代码时,还必须记住在多个地方进行更新。

为了消除这种重复,我们将通过定义一个对作为参数传入的任何整数列表进行操作的函数来创建一个抽象。这个解决方案使我们的代码更清晰,并让我们能够抽象地表达在列表中找到最大数字的概念。

在清单10-3中,我们将找到最大数字的代码提取到一个名为largest的函数中。然后我们调用该函数来找到清单10-2中两个列表中的最大数字。我们也可以在未来可能拥有的任何其他i32值列表上使用该函数。

文件名:src/main.rs

fn largest(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {result}");

    let number_list = vec![102, 34, 6000, 89, 54, 2, 43, 8];

    let result = largest(&number_list);
    println!("The largest number is {result}");
}

清单10-3:在两个列表中找到最大数字的抽象代码

largest函数有一个名为list的参数,它代表我们可能传入函数的任何具体的i32值切片。因此,当我们调用该函数时,代码会在我们传入的特定值上运行。

总结一下,我们将代码从清单10-2更改为清单10-3所采取的步骤如下:

  1. 识别重复代码。
  2. 将重复代码提取到函数体中,并在函数签名中指定该代码的输入和返回值。
  3. 将两个重复代码实例更新为调用该函数。

接下来,我们将使用相同的步骤和泛型来减少代码重复。就像函数体可以对抽象的list而不是特定值进行操作一样,泛型允许代码对抽象类型进行操作。

例如,假设我们有两个函数:一个用于在i32值的切片中找到最大的项,另一个用于在char值的切片中找到最大的项。我们如何消除这种重复呢?让我们来看看!

通用数据类型

我们使用泛型来为函数签名或结构体等项创建定义,然后可以将这些定义与许多不同的具体数据类型一起使用。让我们首先看看如何使用泛型来定义函数、结构体、枚举和方法。然后我们将讨论泛型如何影响代码性能。

在函数定义中

在定义使用泛型的函数时,我们将泛型放在函数签名中,通常在那里我们会指定参数和返回值的数据类型。这样做会使我们的代码更灵活,并为函数调用者提供更多功能,同时防止代码重复。

继续以我们的largest函数为例,清单10-4展示了两个函数,它们都在切片中找到最大值。然后我们将把它们合并成一个使用泛型的单一函数。

文件名:src/main.rs

fn largest_i32(list: &[i32]) -> &i32 {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn largest_char(list: &[char]) -> &char {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest_i32(&number_list);
    println!("The largest number is {result}");

    let char_list = vec!['y','m', 'a', 'q'];

    let result = largest_char(&char_list);
    println!("The largest char is {result}");
}

清单10-4:两个仅在名称和签名中的类型上有所不同的函数

largest_i32函数是我们在清单10-3中提取的,用于在切片中找到最大i32的函数。largest_char函数用于在切片中找到最大的char。函数体有相同的代码,所以让我们通过在一个单一函数中引入泛型类型参数来消除重复。

要在一个新的单一函数中对类型进行参数化,我们需要给类型参数命名,就像我们给函数的值参数命名一样。你可以使用任何标识符作为类型参数名。但我们将使用T,因为按照惯例,Rust中的类型参数名很短,通常只有一个字母,并且Rust的类型命名惯例是驼峰式大小写。T是大多数Rust程序员的默认选择,它是type的缩写。

当我们在函数体中使用一个参数时,我们必须在签名中声明参数名,以便编译器知道该名称的含义。类似地,当我们在函数签名中使用类型参数名时,我们必须在使用它之前声明类型参数名。要定义泛型largest函数,我们将类型名称声明放在函数名和参数列表之间的尖括号<>内,如下所示:

fn largest<T>(list: &[T]) -> &T {

我们将这个定义理解为:函数largest对于某个类型T是泛型的。这个函数有一个名为list的参数,它是类型T的值的切片。largest函数将返回一个对相同类型T的值的引用。

清单10-5展示了在签名中使用通用数据类型的组合largest函数定义。该清单还展示了我们如何使用i32值的切片或char值来调用该函数。请注意,这段代码目前还无法编译,但我们将在本章后面修复它。

文件名:src/main.rs

fn largest<T>(list: &[T]) -> &T {
    let mut largest = &list[0];

    for item in list {
        if item > largest {
            largest = item;
        }
    }

    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];

    let result = largest(&number_list);
    println!("The largest number is {result}");

    let char_list = vec!['y','m', 'a', 'q'];

    let result = largest(&char_list);
    println!("The largest char is {result}");
}

清单10-5:使用通用类型参数的largest函数;目前还无法编译

如果我们现在编译这段代码,会得到如下错误:

error[E0369]: binary operation `>` cannot be applied to type `&T`
 --> src/main.rs:5:17
  |
5 |         if item > largest {
  |            ---- ^ ------- &T
  |            |
  |            &T
  |
help: consider restricting type parameter `T`
  |
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
  |             ++++++++++++++++++++++

帮助文本提到了std::cmp::PartialOrd,它是一个特性,我们将在下一节讨论特性。目前,只需知道这个错误表明largest函数体不适用于T可能的所有类型。因为我们想在函数体中比较T类型的值,所以我们只能使用其值可以排序的类型。为了启用比较,标准库有std::cmp::PartialOrd特性,你可以在类型上实现它(有关此特性的更多信息,请参阅附录C)。按照帮助文本的建议,我们将对T有效的类型限制为仅那些实现PartialOrd的类型,这样这个示例就会编译,因为标准库在i32char上都实现了PartialOrd

在结构体定义中

我们还可以使用<>语法来定义结构体,以便在一个或多个字段中使用泛型类型参数。清单10-6定义了一个Point<T>结构体,用于存储任何类型的xy坐标值。

文件名:src/main.rs

1 struct Point<T> {
  2 x: T,
  3 y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}

清单10-6:一个存储类型为Txy值的Point<T>结构体

在结构体定义中使用泛型的语法与在函数定义中使用的语法类似。首先,我们在结构体名称后的尖括号内声明类型参数的名称[1]。然后,我们在结构体定义中使用泛型类型,而不是指定具体的数据类型[23]。

请注意,因为我们只使用了一个泛型类型来定义Point<T>,所以这个定义表明Point<T>结构体对于某个类型T是泛型的,并且字段xy 都是 相同的类型,无论该类型是什么。如果我们创建一个Point<T>实例,其值具有不同的类型,如清单10-7所示,我们的代码将无法编译。

文件名:src/main.rs

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

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

清单10-7:字段xy必须是相同类型,因为它们都具有相同的泛型数据类型T

在这个例子中,当我们将整数值5赋给x时,我们让编译器知道对于这个Point<T>实例,泛型类型T将是一个整数。然后,当我们为y指定4.0时,我们已经将其定义为与x具有相同的类型,我们将得到一个类型不匹配错误,如下所示:

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结构体,其中xy都是泛型,但可以具有不同的类型,我们可以使用多个泛型类型参数。例如,在清单10-8中,我们将Point的定义更改为对于类型TU是泛型的,其中xT类型,yU类型。

文件名:src/main.rs

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 };
}

清单10-8:一个对两种类型泛型的Point<T, U>,以便xy可以是不同类型的值

现在,所示的所有Point实例都是允许的!你可以在定义中使用任意数量的泛型类型参数,但使用过多会使代码难以阅读。如果你发现代码中需要大量的泛型类型,这可能表明你的代码需要重构为更小的部分。

在枚举定义中

和结构体一样,我们可以定义枚举,使其变体中包含泛型数据类型。让我们再看看标准库提供的Option<T>枚举,我们在第6章中使用过它:

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

现在这个定义对你来说应该更有意义了。如你所见,Option<T>枚举对于类型T是泛型的,并且有两个变体:Some,它持有一个类型为T的值;以及None变体,它不持有任何值。通过使用Option<T>枚举,我们可以表达可选值的抽象概念,并且因为Option<T>是泛型的,所以无论可选值的类型是什么,我们都可以使用这个抽象。

枚举也可以使用多个泛型类型。我们在第9章中使用的Result枚举的定义就是一个例子:

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

Result枚举对于两个类型TE是泛型的,并且有两个变体:Ok,它持有一个类型为T的值;以及Err,它持有一个类型为E的值。这个定义使得在任何我们有一个可能成功(返回某种类型T的值)或失败(返回某种类型E的错误)的操作的地方,使用Result枚举都很方便。实际上,这就是我们在清单9-3中用于打开文件的方式,当文件成功打开时,T被填充为std::fs::File类型,当打开文件出现问题时,E被填充为std::io::Error类型。

当你在代码中识别出多个结构体或枚举定义,它们仅在所持有的值的类型上有所不同时,你可以通过使用泛型类型来避免重复。

在方法定义中

我们可以在结构体和枚举上实现方法(就像我们在第5章中做的那样),并且在方法定义中也可以使用泛型类型。清单10-9展示了我们在清单10-6中定义的Point<T>结构体,并为其实现了一个名为x的方法。

文件名:src/main.rs

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

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

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

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

清单10-9:在Point<T>结构体上实现一个名为x的方法,该方法将返回对类型为Tx字段的引用

在这里,我们在Point<T>上定义了一个名为x的方法,该方法返回对x字段中数据的引用。

请注意,我们必须在impl之后声明T,这样我们就可以使用T来指定我们正在为类型Point<T>实现方法。通过在impl之后将T声明为泛型类型,Rust可以识别Point尖括号中的类型是泛型类型而不是具体类型。我们可以为这个泛型参数选择一个与结构体定义中声明的泛型参数不同的名称,但使用相同的名称是惯例。在声明了泛型类型的impl块中编写的方法将在该类型的任何实例上定义,无论最终用什么具体类型替换泛型类型。

我们还可以在为类型定义方法时指定对泛型类型的约束。例如,我们可以只在Point<f32>实例上实现方法,而不是在具有任何泛型类型的Point<T>实例上实现。在清单10-10中,我们使用具体类型f32,这意味着我们在impl之后不声明任何类型。

文件名:src/main.rs

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

清单10-10:一个impl块,仅适用于泛型类型参数T为特定具体类型的结构体

这段代码意味着类型Point<f32>将有一个distance_from_origin方法;Point<T>的其他实例(其中T不是f32类型)将不会定义这个方法。该方法测量我们的点与坐标为(0.0, 0.0)的点之间的距离,并使用仅适用于浮点类型的数学运算。

结构体定义中的泛型类型参数并不总是与在该结构体的方法签名中使用的泛型类型参数相同。清单10-11对Point结构体使用泛型类型X1Y1,对mixup方法签名使用X2Y2,以使示例更清晰。该方法创建一个新的Point实例,其x值来自self Point(类型为X1),y值来自传入的Point(类型为Y2)。

文件名:src/main.rs

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

1 impl<X1, Y1> Point<X1, Y1> {
  2 fn mixup<X2, Y2>(
        self,
        other: Point<X2, Y2>,
    ) -> Point<X1, Y2> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

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

  5 let p3 = p1.mixup(p2);

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

清单10-11:一个使用与结构体定义不同的泛型类型的方法

main函数中,我们定义了一个Point,其xi32(值为5),yf64(值为10.4 [3])。p2变量是一个Point结构体,其x为字符串切片(值为"Hello"),ychar(值为c [4])。对p1调用mixup并传入参数p2得到p3 [5],p3x将为i32,因为x来自p1p3变量的y将为char,因为y来自p2println!宏调用 [6] 将打印p3.x = 5, p3.y = c

这个示例的目的是展示一种情况,即一些泛型参数在impl中声明,而一些在方法定义中声明。在这里,泛型参数X1Y1impl之后声明 [1],因为它们与结构体定义相关。泛型参数X2Y2fn mixup之后声明 [2],因为它们仅与该方法相关。

使用泛型的代码性能

你可能想知道使用泛型类型参数时是否会有运行时开销。好消息是,使用泛型类型不会使你的程序运行得比使用具体类型时更慢。

Rust通过在编译时对使用泛型的代码执行单态化来实现这一点。单态化是通过填充编译时使用的具体类型,将泛型代码转换为特定代码的过程。在这个过程中,编译器执行的步骤与我们在清单10-5中创建泛型函数时所采取的步骤相反:编译器查看调用泛型代码的所有地方,并为泛型代码所调用的具体类型生成代码。

让我们通过使用标准库的泛型Option<T>枚举来看看这是如何工作的:

let integer = Some(5);
let float = Some(5.0);

当Rust编译这段代码时,它会执行单态化。在这个过程中,编译器读取在Option<T>实例中使用的值,并识别出两种Option<T>:一种是i32,另一种是f64。因此,它将Option<T>的泛型定义扩展为专门针对i32f64的两个定义,从而用具体定义替换泛型定义。

代码的单态化版本看起来类似于以下内容(编译器使用的名称与我们这里为说明目的而使用的名称不同):

文件名:src/main.rs

enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

泛型Option<T>被编译器创建的具体定义所取代。因为Rust将泛型代码编译为在每个实例中指定类型的代码,所以我们使用泛型不会产生运行时开销。当代码运行时,它的行为就像我们手动复制每个定义一样。单态化过程使Rust的泛型在运行时极其高效。

总结

恭喜你!你已经完成了通过提取函数来消除重复的实验。你可以在LabEx中练习更多实验来提升你的技能。