了解 Rust 泛型以及如何使用它们

泛型是一种减少重复代码编写需求的方法,而是将此任务委托给编译器,同时也使代码更加灵活。许多语言支持某种方式来做到这一点,即使他们可能会称之为不同的东西。

使用泛型,我们可以编写可用于多种数据类型的代码,而无需为每种数据类型重写相同的代码,从而使生活更轻松,编码更不易出错。

在本文中,我们将了解什么是泛型,它们如何在 Rust 中使用,以及如何在自己的代码中使用它们。特别是,我们将看到:

  • 为什么泛型有用?

  • 什么是泛型?

  • 泛型在 Rust 中是如何工作的

  • Rust 泛型类型参数的语法

  • 简单的 Rust 泛型使用示例

  • 具有特征边界的泛型

  • Rust 中的终身泛型

  • 使用泛型在 Rust 中进行类型状态编程

  • Rust 中的高级泛型类型:泛型关联类型

需要注意的是,您需要能够自如地阅读和编写基本的 Rust 代码。这包括变量声明、if…else块、循环和结构声明。一点关于特征的知识也很有帮助。

为什么泛型有用?

如果您以前使用过 Rust,那么您很可能已经在没有注意到的情况下使用过泛型。在我们了解泛型的定义以及它们在 Rust 中是如何工作的之前,让我们先看看为什么我们可能需要使用它们。

考虑一种情况,我们想编写一个函数,该函数接受一片数字并对它们进行排序。看起来很简单,所以我们继续编写函数:

fn 排序(arr:&mut [使用大小]){
  // 排序逻辑在这里...
}

花了几分钟试图记住如何quicksort在 Rust 中使用,然后在网上查找后,我们意识到:这种方法不是特别灵活。

是的,我们可以传递 of 的数组进行排序,但是因为 Rust 没有隐式类型转换值,所以这个函数不会接受usize任何其他类型的数值 -u8和其他类型的数值。u16

要对这些其他整数类型进行排序,我们需要创建另一个数组,用类型转换为 的原始值填充它usize,并将其作为输入传递。我们还需要将排序后的数组类型转换回原始类型。

这是很多工作!i8此外,这个解决方案对于,等带符号的类型仍然不起作用i16。

另一个问题是,即使是类型转换也只能单独对数值进行排序。考虑一个例子,我们有一个用户列表,每个用户都有一个数字id字段。我们不能将它们传递给这个函数!

要根据每个用户对它们进行排序id,我们首先需要将 every 提取id到 avec中,如果需要,将其类型转换为usize,使用此函数对其进行排序,然后将 everyid与原始用户列表一一匹配,以创建一个新的排序用户列表由用户id。

这也是很多工作,特别是考虑到我们正在做的核心——排序——是相同的。


超过 20 万开发人员使用 LogRocket 来创造更好的数字体验了解更多 →


当然,我们可以为我们需要的每种类型编写一个专用的函数:一个 for usize,一个 for i16,一个提供对 structs 的访问,等等。但这是需要编写和维护的大量代码。

想象一下,如果我们使用这种方法,但我们在第一个函数中犯了一个错误。如果我们随后复制并粘贴了各种其他类型的函数,我们将不得不手动更正每一个。如果我们忘记修复任何问题,我们会得到奇怪的排序错误,这些错误只出现在一种类型上。

现在考虑我们是否要编写一个Wrapper类型。它基本上会将数据包装在其中并提供更多功能,例如日志记录、调试跟踪等。

我们可以定义一个结构,并在其中保留一个字段来存储该数据,但是我们需要为每种要包装的数据类型编写一个单独的专用结构,并为每种类型手动重新实现相同的功能。

另一个问题是,如果我们决定将其发布为库,用户将无法使用此包装器来处理他们的自定义数据类型,除非他们编写另一个结构并手动为其实现所有内容,从而使库变得多余。

泛型可以让我们摆脱这些问题,以及 Rust 中的更多问题。

什么是泛型?

那么什么是泛型,它们如何让我们摆脱这些问题呢?

非正式地,泛型编程涉及关注您关心的内容,而忽略或抽象其他所有内容。


来自 LogRocket 的更多精彩文章:

  • 不要错过来自 LogRocket 的精选时事通讯The Replay

  • 了解LogRocket 的 Galileo 如何消除噪音以主动解决应用程序中的问题

  • 使用 React 的 useEffect优化应用程序的性能

  • 在多个 Node 版本之间切换

  • 了解如何使用 AnimXYZ 为您的 React 应用程序制作动画

  • 探索 Tauri,一个用于构建二进制文件的新框架

  • 比较NestJS 与 Express.js


更正式的Wikipedia 对泛型编程的定义是“一种计算机编程风格,其中算法是根据稍后指定的类型编写的,然后在需要时为作为参数提供的特定类型进行实例化。”

换句话说,当我们编写代码时,我们使用占位符类型而不是实际类型来编写它。实际类型稍后插入。

想一想,在函数中,我们如何根据参数编写代码。例如,一个加法函数接受两个参数,a和b,并将它们相加。我们实际上并没有在这里硬编码aand的值b。相反,每当我们调用该加法函数时,我们都会将这些值作为参数传递以获取结果。

同样,在泛型中,类型占位符在编译时被实际类型替换。

因此,回到我们之前的示例中应用泛型,我们将sort使用占位符类型编写函数;让我们称之为Sortable。然后,当我们调用带有 切片的函数时usize,编译器会将这个占位符替换usize为 以创建一个新函数并使用该函数进行排序调用。

如果我们sort从另一个地方调用我们的函数并给它一个i16切片,编译器会生成另一个函数——这次用占位符类型替换i16——并使用这个函数进行调用。

至于包装器,我们可以简单地在我们的结构体的定义中放置一个类型占位符,并使数据字段成为这个占位符类型。然后,每当我们使用这个包装器时,编译器都会生成一个专门为该类型定制的结构定义。

这样,我们可以将包装器用于我们的任何类型,甚至我们库的用户也可以在这个包装器中包装他们自己的类型。

这就是泛型如何帮助我们编写(因此需要维护)更少量的代码,同时增加代码的灵活性。现在我们将了解如何在 Rust 中使用泛型。

泛型在 Rust 中是如何工作的

如开头所述,如果您使用 Rust 一段时间,您可能已经在 Rust 中使用过泛型。

想想Wrapper我们想要实现的示例类型。它与 Rust 的Option和Result类型惊人地相似。

当我们想要分别表示可选值或结果时,我们使用这些类型来包装一些值。它们几乎没有任何限制,几乎可以获取任何类型的价值。因此,我们可以使用它们来包装我们想要的任意数据类型,这是因为它们被定义为泛型类型。

Option类型是一个枚举,大致定义为:

枚举选项{
  一些(T),
  没有任何
}

上面,T就是我们上一节提到的类型参数。每当我们将它与类型一起使用时,编译器都会生成针对该特定类型量身定制的枚举定义;例如,如果我们使用Optionfor a String,编译器将基本上生成类似于以下的定义:

枚举字符串选项{
  一些(字符串),
  没有任何
}

然后,无论我们在哪里使用Option,它都会使用上面生成的定义。

所有这些都发生在编译阶段;因此,我们不必担心为要使用它们的每种数据类型定义不同的枚举,并为所有这些类型维护代码。

同样,Result是一个使用两种泛型类型定义的枚举:

枚举结果{
  好的(T),
  错误(E)
}

在这里,我们可以定义任何类型来代替Tand E,编译器将为每个组合生成并使用唯一的定义。

另一个例子,考虑Rust 提供的各种集合:Vec、、、HashMap等HashSet。所有这些都是通用结构,因此可以与任何数据类型一起使用以存储几乎任何值,在HashMapand的情况下有一些限制HashSet,我们将稍后见。

在这个阶段需要注意的一点是,一旦我们为泛型结构或枚举声明了具体类型,它本质上会生成并使用具有固定类型的唯一结构或枚举。因此,我们不能将usize值存储在声明为类型的向量中,Vec反之亦然。

如果你想在同一个结构中存储不同类型的值,泛型不能单独使用,而是需要与 Rust 特征一起使用,本文不涉及。

Rust 泛型类型参数的语法

在 Rust 中使用泛型类型参数的语法非常简单:

fn 排序(arr:&mut [T]){
...
}
​
结构包装{
……
}
​
impl 包装器{
...
}

<>我们必须在函数、结构或枚举名称之后声明我们将使用的泛型类型参数;我们T在上面的例子中使用过,但它可以是任何东西。

之后,我们可以将那些声明的参数用作类型,只要我们想在函数、结构或枚举中使用泛型类型。

通用结构的impl略有不同,出现两次。但是,它与其他非常相似,我们首先声明泛型参数,然后立即使用它。

首先,我们在 中声明泛型参数T,impl我们说此实现将使用名为 的泛型类型参数T。然后,我们立即用它来表示这是类型的实现struct

请注意,在此示例中,它T是我们给出其实现的结构类型的一部分,而不是泛型参数的声明。

即使我们选择在T这里调用它,泛型参数也可以有任何有效的变量名,为了清楚起见,应该使用类似于“好”变量命名的规则。

Option与上一节中的和示例类似Result,每当我们使用这个结构或函数时,编译器都会生成一个专用的结构或函数,将类型参数替换为实际的具体类型。

简单的 Rust 泛型使用示例

现在让我们回到原来的问题:sort函数和Wrapper类型。我们将首先处理Wrapper类型。

我们考虑的结构如下:

结构包装器{
...
数据:???
...
}

由于我们希望结构能够存储任何类型的数据,因此我们将数据字段的类型设为通用,这将允许我们和其他用户在此包装器中存储任何数据类型。

结构包装器{
...
数据:数据类型
...
}

在这里,我们声明了一个名为 的泛型类型参数DataType,然后将该字段声明data为该泛型类型。现在我们可以声明一个DataStorewithu8作为数据,另一个用一个字符串作为数据:

让 d1 = Wrapper{data:5}; // 有时会报错,见下面的注释
让 d2 = Wrapper{data:"data".to_owned()};

编译器通常会自动检测要为泛型类型填充的类型,但在这种情况下,可以5是u8,或相当多的其他类型。因此,有时我们可能需要显式声明类型,如下所示:u16``usize

let d1 : DataStore = DataStore{data:5};
// or
let d1 = DataStore{data:5_u8};

回顾我们之前提到的注意事项:一旦声明,类型是固定的并且表现为唯一类型。向量只能存储相同类型的元素。因此,我们不能将d1和d2放在同一个向量中,因为一个是 type DataStore_u8,另一个是 type DataStore_String。

请记住,当我们尝试collect在不指定变量类型的情况下调用某个迭代器时,会出现以下类型错误:

让 c = [1,2,3].into_iter().collect(); //error : 需要类型注解

这是因为该collect方法的返回类型是具有特征绑定的泛型(我们将在下一节中探讨),因此编译器无法确定c将是哪种类型。

因此,我们需要明确声明集合类型。然后,编译器可以确定要存储在集合中的数据类型:

让 c : Vec = [1,2,3].into_iter().collect();
// 或者,编译器可以决定 1,2,3 是 usize 类型
让 c : Vec<_> = [1,2,3].into_iter().collect();

总而言之,在编译器无法确定存储收集数据所需的集合类型的情况下,我们需要指定类型。

具有特征边界的泛型

现在,让我们继续讨论sort函数。

在这里,解决方案并不像声明一个泛型类型并将其用作输入数组的数据类型那么简单。这是因为当简单地声明为 时T,该类型对其没有任何限制。因此,当我们尝试比较两个值时,会得到如下所示的错误:

二元运算“<”不能应用于类型 T

这是因为我们赋予排序函数的类型完全不需要使用<运算符进行比较。例如,user结构——我们希望根据id值排序的结构——不能直接使用<运算符进行比较。

因此,我们必须明确告诉编译器只允许在此处替换类型,前提是它们可以相互比较。为此,在 Rust 中,我们必须使用 trait bounds。

特征类似于 Java 或 C++ 等语言中的接口。它们包含必须由实现该特征的所有类型实现的方法签名。

对于我们的排序函数,我们需要T通过具有compare函数的特征来限制或绑定类型参数。此函数必须给出给定的两个相同类型的元素之间的关系(大于、小于或等于)。

我们可以为此目的定义一个新特征,但是我们还必须为所有数值类型实现它,我们将不得不手动调用compare函数而不是使用<. 或者我们可以使用 Rust 内置的特征来使事情变得更容易,我们现在将这样做。

Eq并且Ord是标准 Rust 库中提供我们所需功能的两个特征。该Eq特征提供了检查两个值是否相等的功能,并Ord提供了一种比较和检查两个值之间哪个小于或大于另一个的方法。

这些默认情况下由数字类型实现(除了f32and f64,它不实现Eq,因为NaN既不等于也不不等于NaN),所以我们只需要为我们自己的类型实现这些特征,例如user结构。

要通过特征限制类型参数,语法是:

fn fun(...){...}

这将指示编译器Type只能由那些实现trait1等的类型替换trait2。我们可以指定单个 trait 或多个 trait 来限制类型。

现在对于我们的排序功能:

fn sort(arr:&mut[Sortable]){
...
}

在这里,我们用 name 声明了一个泛型类型,Sortable并用 traitOrd和来限制它Eq。现在,替换它的类型必须同时实现Eq和Ord特征。

这将允许我们对u8, u16, usize,i32等使用相同的函数。此外,如果我们为user结构实现这些特征,则可以使用相同的函数对其进行排序,而无需编写单独的函数。

编写相同内容的另一种方法如下:

fn sort(arr:&mut [Sortable])where Sortable:Ord+Eq{
...
}

在这里,我们没有将特征与类型参数声明一起编写,而是将它们写在函数参数之后。

换一种方式来考虑这个问题:特征边界为我们提供了关于类型参数中替换的类型的保证。

例如,RustHashMap要求提供给它的键可以被散列。换句话说,它需要保证可以在替代键类型的类型上调用散列函数,并且它会给出一些可以视为该键的散列的值。

因此,它通过要求它实现Hash特征来限制其键类型。

类似地,HashSet要求存储在其中的元素可以被散列,并将它们的类型限制为实现该Hash特征的那些。

因此,我们可以将类型参数上的 trait bound 视为限制哪些类型可以被替换的方法,并保证被替换的类型将具有与其关联的某些属性或功能。

Rust 中的终身泛型

Rust 大量使用泛型的另一个地方是生命周期。

这很难注意到,因为Rust中的生命周期主要是编译时实体,在代码或编译后的二进制文件中不直接可见。家庭KTV支持手机点歌,无需会员免费K歌神器,宅家就能享受到KTV的感觉!因此,几乎所有的生命周期注解都是通用的“类型”参数,其值由编译器在编译时决定。

生命周期是少数例外之一'static。

通常,如果一个类型具有静态生命周期,这意味着该值应该一直存在到程序结束。请注意,这并不完全是它的意思,但现在,我们可以这样想。

即便如此,生命周期泛型仍然与类型泛型有点不同:TVbox电视盒子App,双播内置10+优质影视源,附带TVbox最新接口地址!它们的最终值是由编译器计算的,而不是我们在代码中指定类型,编译器只是简单地替换它。

因此,如果需要,编译器可以强制将更长的生命周期缩短为更短的生命周期,并且必须在分配之前为每个注释计算适当的生命周期。此外,我们通常不需要直接处理这些,而是可以让编译器为我们推断这些,即使不指定参数。

生命周期注释总是以符号开头,'之后可以采用任何类似变量的名称。我们将显式使用这些的地方之一是当我们想要在结构或枚举中存储对某些东西的引用时。

由于所有引用都必须是 Rust 中的有效引用(不能有悬空指针),我们必须指定存储在结构中的引用必须至少在该结构在范围内或有效之前保持有效。

考虑以下:

结构参考{
  参考:&u8
}

上面的代码会给我们一个没有指定生命周期参数的错误信息。

编译器无法知道引用必须有效多长时间——如果它应该和结构的生命周期一样长'static,或者其他什么。因此,我们必须指定生命周期参数,如下所示:

结构参考<'a>{
  参考:&'a u8
}

在这里,我们指定该Reference结构将具有关联的生命周期'a,并且其中的引用必须至少在该时间内保持有效。谷歌镜像网址推荐,无需直接访问google搜索,国外文献任意搜索!现在在编译时,Rust 可以根据生命周期确定规则替换生命周期,并决定存储的引用是否有效。

另一个需要明确声明生命周期的地方是当一个函数接受多个引用并返回一个输出引用时。

如果函数根本不返回引用,则无需考虑生命周期。如果它仅通过引用获取一个参数,则返回的引用必须与输入引用一样有效。

我们只能返回从输入构造的引用。默认情况下任何其他都是无效的,因为它将由函数中创建的值构造,当我们从函数调用返回时,这些值将无效。

但是当我们接收多个引用时,我们必须指定它们中的每一个(输入和输出引用)必须存在多长时间,因为编译器无法自行确定。一个非常基本的例子如下:

fn return_reference(in1:&[usize],in2:&[usize])->&usize{
...
}

在这里,编译器会抱怨我们必须为输出指定生命周期&usize,因为它不知道它是否与in1or相关in2。添加所有三个引用的生命周期将解决此问题:

fn return_reference<'a>(in1:&'a [usize],in2:&'a [usize])->&'a usize{
...
}

就像泛型类型参数一样,我们'a在函数名之后声明了生命周期参数,然后用它来指定变量的生命周期。

这告诉编译器只要输入引用有效,输出引用就会有效。Windows7官方特别版ISO,无需破解直接双击安装,老电脑福音!此外,这增加了两者in1必须in2具有相同生命周期的限制。

因此,编译器将不允许存在不同时间的输入引用。

为了处理这种情况,我们还可以在此处为in1and指定两个不同的生命周期in2,并指定返回值的生命周期与返回值的生命周期相同。

例如,如果我们从 中返回一个值in1,我们将保持 的生命周期in1和返回类型相同,并为 提供不同的生命周期参数in2,如下所示:

fn return_reference<'a,'b>(in1:&'a [usize],in2:&'b [usize])->&'a usize{
...// 仅从 in1 返回值
}

如果我们不小心从 中返回了一个引用in2,编译器会给我们一条错误消息,指出生命周期不匹配。这可以用作额外检查参考是否从正确的位置返回。

但是如果我们事先不知道我们将从哪个输入返回引用呢?在这种情况下,我们可以指定一个生命周期必须至少与另一个生命周期一样长:

fn return_reference<'a,'b:'a>(in1:&'a [usize],in2:&'b [usize])->&'a usize{
...
}

这表明 的生命周期'b必须至少与 一样长'a。因此,我们可以从in1or返回一个值,in2并且编译器不会给我们一个错误消息。

鉴于许多需要显式生命周期的事物都是非常高级的主题,因此此处并未考虑所有相关用例。您可以查看 The Embedded Rust Book 以获取有关生命周期的更多信息。

使用泛型在 Rust 中进行类型状态编程

这是泛型的另一个稍微高级的用例。我们可以使用泛型来选择性地实现结构的功能。这通常与具有多个状态的状态机或对象有关,其中每个状态具有不同的功能。

考虑一个我们想要实现加热器结构的情况。加热器可以处于低、中或高状态。根据它的状态,它需要做不同的事情。

想象一下加热器有一个旋钮或刻度盘:当旋钮处于低位时,它可以调到中,但不能直接调到高,而不先调到中。在中等时,它可以达到高或低。高时只能到中,不能直接到低。

为了在 Rust 中实现这一点,我们可以把它变成一个enum并且可能把这三个状态变成它的变体。但是我们还不能指定特定于变体的方法——至少在撰写本文时还不能。我们将不得不在match任何地方使用语句并有条件地应用逻辑。

这就是类型状态在 Rust 中有用的地方。

首先,让我们声明三个代表我们状态的单元结构:

结构低;
结构中等;
结构高;

现在,我们将使用一个名为的通用参数声明我们的加热器结构State:

结构加热器<状态>{
...
}

但是这样一来,编译器就会抱怨参数没有被使用,所以我们必须做点别的。

我们实际上并不想存储状态结构,因为我们实际上并没有对它们做任何事情,而且它们也没有任何数据。相反,我们使用这些状态来指示加热器的状态,以及它可以使用哪些功能(即从低到中、中到高等)。

因此,我们使用一种特殊的类型,称为PhantomData:

使用 std::marker::PhantomData;

结构加热器<状态>{
...
状态:幻影数据<状态>
...
}

PhantomData是Ruststd库中的一种特殊结构。这个结构的行为就像它存储数据一样,但实际上并不存储任何数据。现在对编译器来说,似乎我们使用的是泛型类型参数,所以它不会抱怨。

有了这个,我们可以实现特定于加热器当前状态的方法,如下所示:

实现加热器<低>{
  fn turn_to_medium(self)->加热器{
    ...
  }
// 特定于加热器低状态的方法
}

impl Heater{
// 特定于加热器中等状态的方法
  fn turn_to_low(self)->加热器{
    ...
  }
  fn turn_to_high(self)->加热器{
    ...
  }
}

impl Heater{
// 特定于加热器高状态的方法
  fn turn_to_medium<中>{
    ...
  }
}

每个状态都包含无法从任何其他状态访问的特定方法。我们还可以使用它来限制函数仅采用加热器的特定状态:

fn only_for_medium_heater(h:&mut Heater){
// 这将只接受中等加热器
}

所以,如果我们尝试给这个函数一个Heater状态Low,我们会在编译时得到一个错误:

让 h_low:Heater = 加热器{
...
状态:幻影数据,
...
}
only_for_medium_heater(h_low); // 编译错误!!!

请注意,我们实际上并没有PhantomData使用该new方法创建,因为它实际上并没有存储任何东西。

此外,我们需要确保编译器能够确定我们存储Heater结构的变量的类型状态。我们可以通过像上面那样显式指定类型来做到这一点,或者在显式声明的上下文中使用变量类型状态,例如函数、调用或其他相关上下文。

我们可以正常实现所有状态通用的方法,如下所示:

impl 加热器{
// 所有状态通用的方法,即任何加热器
// 这里类型 T 是泛型的
}

Rust 中的高级泛型类型:泛型关联类型

我们将在这里提到泛型类型的一个更高级的用例:泛型关联类型 (GAT)。这是一个非常高级和复杂的主题,因此我们不会在本文中详细讨论它。

关联类型是我们在特征中定义的类型。之所以这样称呼它们,是因为它们与该特征完全相关。这是一个例子:

特征可计算{
  输入结果;
  fn 计算(&self)->Self::Result;
}

在这里,Result类型与 trait 相关联Computable;因此,我们可以在函数定义中使用它们。要了解这与简单地使用泛型类型参数有何不同以及为何不同,您可以查看 The Embedded Rust Book。

泛型关联类型,顾名思义,允许我们在关联类型中使用泛型。这是对 Rust 的一个相对较新的补充,事实上,在撰写本文时,它的所有部分都不是稳定的。

使用 GAT,可以定义包含泛型类型、生命周期以及我们在本文中讨论的所有其他内容的关联类型。

你可以在 Rust 官方博客上阅读更多关于这个主题的信息,但请记住,它是一个非常高级的用例,它在 Rust 编译器中的实现还没有完全稳定。

结论

现在您知道什么是泛型,它们为什么有用,以及 Rust 如何使用它们,即使您没有注意到它。

我们介绍了如何使用泛型来编写更少的代码,同时更灵活;如何限制类型的功能;以及如何将泛型用于类型状态,以便您可以有选择地为结构所在的状态实现功能。

你可能感兴趣的:(rust,开发语言,后端)