泛型是一种减少编写重复性代码的方式,而是将这项任务委托给编译器,同时也使代码更加灵活。许多语言都支持某种方式来做到这一点,尽管它们可能称之为不同的东西。
使用泛型,我们可以编写可用于多种数据类型的代码,而不必为每种数据类型重写相同的代码,使生活更轻松,编码更不易出错。
在这篇文章中,我们将看到什么是泛型,它们在Rust中是如何使用的,以及你如何在自己的代码中使用它们。
值得注意的是,你需要能够熟练地阅读和编写基本的Rust代码。这包括变量声明、if…else 块、循环和结构声明。对特质有一点了解也是有帮助的。
为什么泛型是有用的?
如果你以前使用过Rust,你有可能已经在不知不觉中使用了泛型。在我们讨论泛型的定义以及它们在Rust中如何工作之前,让我们先看看为什么我们可能需要使用它们。
考虑一下这样的情况:我们想写一个函数,对一个数字片进行排序。这看起来很简单,所以我们开始写这个函数:
fn sort(arr:&mut [usize]){
// sorting logic goes here...
}
在花了几分钟时间试图记住如何在Rust中使用quicksort ,然后在网上查找之后,我们意识到了一些东西:这种方法不是特别灵活。
是的,我们可以传递usize 的数组来进行排序,但是由于Rust没有隐含的类型化数值,任何其他类型的数值--u8,u16 和其他--都不会被这个函数所接受。
为了对这些其他的整数类型进行排序,我们需要创建另一个数组,用类型化的原始值填充到usize ,并将其作为输入。我们还需要将排序后的数组重新输入到原始类型。
这是个很大的工程!此外,这个解决方案对于有符号的类型,如i8,i16, 等,仍然完全不起作用。
另一个问题是,即使是类型转换也只能单独对数值进行排序。考虑一个例子,我们有一个用户的列表,每个人都有一个数字id 字段。我们不能把它们传递给这个函数
要根据每个用户的id 来排序,我们首先需要把每个id 提取到一个vec ,如果需要的话,把它分型到usize ,用这个函数来排序,然后把每个id 和原来的用户列表逐一匹配,创建一个按用户id 排序的新用户列表。
这也是一项大量的工作,特别是考虑到我们正在做的核心工作--排序--是相同的。
当然,我们可以为我们需要的每种类型写一个专门的函数:一个用于usize ,一个用于i16 ,一个提供对结构的访问,等等。但这需要编写和维护大量的代码。
想象一下,如果我们使用这种方法,但我们在第一个函数中犯了一个错误。如果我们把这个函数复制并粘贴到其他各种类型上,那么我们就必须手动纠正每一个错误。如果我们忘了修正任何一个,我们会得到奇怪的排序错误,而这些错误只在一种类型中显示出来。
现在考虑一下,如果我们想写一个Wrapper 类型。它基本上会把数据包在里面,并提供更多的功能,如记录、调试跟踪等等。
我们可以定义一个结构,并在其中保留一个字段来存储这些数据,但是我们需要为我们想要包裹的每个数据类型编写一个单独的专用结构,并为每个类型手动重新实现相同的功能。
另一个问题是,如果我们决定将其作为库发布,那么用户将无法为他们的自定义数据类型使用这个封装器,除非他们为其编写另一个结构并手动实现所有功能,这使得这个库变得多余了。
在Rust中,泛型可以将我们从这些问题中解救出来,甚至更多。
什么是泛型?
那么,什么是泛型,它们如何将我们从这些问题中拯救出来?
非正式地,泛型编程涉及到专注于你所关心的东西,而忽略或抽象其他一切。
维基百科对泛型编程更正式的定义是 "一种计算机编程风格,在这种风格中,算法是以有待指定的类型来编写的,然后在需要时对作为参数提供的特定类型进行实例化。"
换句话说,当我们写代码时,我们用占位符类型而不是实际类型来写。实际的类型是以后插入的。
想想看,在函数中,我们是如何用参数来写代码的。例如,一个加法函数需要两个参数,a 和b ,并将它们相加。在这里我们实际上没有硬编码a 和b 的值。相反,每当我们调用该加法函数时,我们将这些值作为参数传递,以获得结果。
同样地,在泛型中,类型占位符在编译时被替换成实际的类型。
因此,回到我们之前的例子中应用泛型,我们将使用一个占位符类型来编写sort 函数;让我们称之为Sortable 。然后,当我们用usize 的片断调用该函数时,编译器将用usize 替换这个占位符,以创建一个新的函数,并使用该函数来调用排序。
如果我们从另一个地方调用我们的sort 函数,并给它一个i16 分片,编译器将生成又一个函数--这次是用i16 替换占位符类型--并使用这个函数进行调用。
至于包装器,我们可以简单地在结构的定义中放入一个占位符类型,并使数据字段为这个占位符类型。然后,只要我们使用这个包装器,编译器就会生成一个专门为该类型定制的结构定义。
这样一来,我们就可以对我们的任何类型使用包装器,甚至我们库的用户也可以用这个包装器来包装他们自己的类型。
这就是泛型如何帮助我们编写(从而需要维护)更少的代码,同时提高我们代码的灵活性。现在我们来看看泛型是如何在Rust中使用的。
泛型如何在Rust中工作
正如开头提到的,如果你已经使用了一段时间的Rust,你可能已经在Rust中使用了泛型。
想一想我们想要实现的例子Wrapper 类型。它与Rust的Option 和Result 类型惊人地相似。
当我们想表示一个可选值或一个结果时,我们分别使用这些类型来包装一些值。它们几乎没有任何限制,几乎可以接受任何类型的值。因此,我们可以用它们来包装任何我们想要的任意的数据类型,这是因为它们被定义为通用类型。
该 [Option](https://blog.logrocket.com/rust-enums-and-pattern-matching/) 类型是一个枚举,大致定义为:
enum Option<T>{
Some(T),
None
}
在上面,T 是我们在上一节提到的类型参数。每当我们把它和一个类型一起使用时,编译器就会生成为该特定类型量身定做的枚举定义;例如,如果我们把Option 用于一个String ,编译器基本上会生成一个类似于下面的定义。
enum StringOption{
Some(String),
None
}
然后,无论我们在哪里使用Option<String> ,它都会使用上面生成的定义。
所有这些都发生在编译阶段;因此,我们不必担心为我们想使用的每个数据类型定义不同的枚举,并为所有的枚举维护代码。
同样地,Result 是一个用两个通用类型定义的枚举。
enum Result<T,E>{
Ok(T),
Err(E)
}
在这里,我们可以定义任何类型来代替T 和E ,编译器将为每个组合生成并使用一个独特的定义。
作为另一个例子,考虑Rust提供的各种集合。Vec,HashMap,HashSet, 等等。所有这些都是通用结构,因此可以与任何数据类型一起使用,以存储几乎所有的值,但在HashMap 和HashSet 的情况下有一些限制,我们将在后面看到。
在这个阶段需要注意的一点是,一旦我们声明了通用结构或枚举的具体类型,它基本上就会生成并使用一个具有固定类型的唯一结构或枚举。因此,我们不能在一个声明为Vec<u8> 类型的向量中存储usize 值,反之亦然。
如果你想在同一个结构中存储不同类型的值,泛型不能单独使用,而需要与Rust traits一起使用,本文不涉及。
Rust泛型参数的语法
在Rust中使用泛型参数的语法非常简单:
fn sort<T>(arr:&mut [T]){
...
}
struct Wrapper<T>{
....
}
impl<K> Wrapper<K>{
...
}
我们必须在<> ,在函数、结构或枚举名称之后声明我们要使用的通用类型参数;在上面的例子中我们使用了T ,但它可以是任何东西。
之后,我们可以在函数、结构体或枚举中任何我们想使用泛型的地方将这些声明的参数作为类型使用。
泛型结构的impl 略有不同,其中<T> 出现了两次。然而,它和其他的很相似,我们首先声明泛型参数,但随后我们立即使用它。
首先,我们在impl<T> 中声明泛型参数为T ,这里我们说这个实现将使用一个名为T 的泛型参数。然后,我们立即使用它来表示这是实现的类型是struct<T> 。
请注意,在这个例子中,T 是结构类型的一部分,我们要给出它的实现,而不是声明通用参数。
尽管我们在这里选择将其称为T ,但通用参数可以有任何有效的变量名称,为了清楚起见,应该使用类似于 "良好 "变量命名的规则。
与上一节中的Option 和Result 例子类似,只要我们要使用这个结构或函数,编译器就会生成一个专门的结构或函数,用实际的具体类型来代替类型参数。
简单的Rust泛型使用例子
现在让我们回到原来的问题上:sort 函数和Wrapper 类型。我们将首先解决Wrapper 类型的问题。
我们当时对结构体的想法是这样的:
struct Wrapper{
...
data:???
...
}
由于我们希望该结构能够存储任何类型的数据,我们将使数据字段的类型成为通用类型,这将使我们和其他用户能够在这个封装器中存储任何数据类型:
struct Wrapper<DataType>{
...
data:DataType
...
}
在这里,我们声明了一个名为DataType 的通用类型参数,然后声明字段data 为该通用类型。现在我们可以声明一个以u8 为数据的DataStore ,以及另一个以字符串为数据的 。
let d1 = Wrapper{data:5}; // can give error sometimes, see the note below
let d2 = Wrapper{data:"data".to_owned()};
编译器通常会自动检测要填入通用类型的类型,但在这种情况下,5 可以是u8,u16,usize 或相当多的其他类型。因此,有时我们可能需要明确地声明这个类型,像这样:
let d1 : DataStore<u8> = DataStore{data:5};
// or
let d1 = DataStore{data:5_u8};
回顾我们之前提到的说明:一旦声明,该类型是固定的,表现为唯一的类型。一个向量只能存储相同类型的元素。因此,我们不能把d1 和d2 放在同一个向量中,因为一个是类型DataStore_u8 ,另一个是类型DataStore_String :
记住,当我们试图在没有指定变量类型的情况下在某个迭代器上调用collect ,我们会得到以下类型错误:
let c = [1,2,3].into_iter().collect(); //error : type annotation needed
这是因为collect 方法的返回类型是带有特质约束的泛型(我们将在下一节探讨),因此,编译器无法确定c 将是哪种类型。
因此,我们需要明确说明集合的类型。然后,编译器可以弄清楚要存储在集合中的数据类型:
let c : Vec<usize> = [1,2,3].into_iter().collect();
// or, as compiler can decide 1,2,3 are of type usize
let c : Vec<_> = [1,2,3].into_iter().collect();
总而言之,在编译器无法找出存储收集的数据所需的集合类型的情况下,我们需要指定类型。
带有特质边界的泛型
现在,让我们继续讨论sort 函数。
在这里,解决方案并不像声明一个泛型并使用它作为输入数组的数据类型那么简单。这是因为当简单地声明为T ,该类型对它没有任何限制。因此,当我们试图比较两个值时,我们会得到一个错误,如下图所示:
binary operation '<' cannot be applied to type T
这是因为我们给排序函数的类型完全没有必要使用< 操作符进行比较。例如,user 结构--我们希望根据id 值进行排序的结构--不能使用< 操作符直接进行比较。
因此,我们必须明确地告诉编译器,只有在类型可以相互比较的情况下才允许在这里进行替换。为此,在Rust中,我们必须使用trait bounds。
特质类似于Java或C++等语言中的接口。它们包含了必须由所有实现该特征的类型所实现的方法签名。
对于我们的排序函数,我们需要限制--或者说约束--类型参数T ,这个特征有一个compare 函数。这个函数必须给出给它的同一类型的两个元素之间的关系(大于、小于或等于)。
我们可以为此定义一个新的trait,但这样我们也必须为所有的数字类型实现它,而且我们必须手动调用compare 函数,而不是使用< 。或者我们可以使用Rust中内置的trait来使事情变得更简单,我们现在就可以这样做。
Eq 和 是标准Rust库中的两个特质,它们提供了我们需要的功能。 特质提供了一个检查两个值是否相等的函数,而 提供了一种比较和检查两个值中哪一个小于或大于另一个的方法。Ord Eq Ord
这些都是默认由数字类型实现的(除了f32 和f64 ,它们没有实现Eq ,因为NaN 既不等于也不等于NaN ),所以我们只需要为我们自己的类型实现这些特质,比如user 结构。
要通过特质来限制一个类型参数,语法是 :
fn fun<Type:trait1+trait2+...>(...){...}
这将指示编译器,Type 只能被那些实现了trait1 和trait2 的类型所替代,以此类推。我们可以指定一个或多个特质来限制类型。
现在是我们的排序函数:
fn sort<Sortable:Ord+Eq>(arr:&mut[Sortable]){
...
}
在这里,我们声明了一个名为Sortable 的通用类型,并通过特质Ord 和Eq 来限制它。现在,被替换的类型必须同时实现Eq 和Ord 特质。
这将允许我们对u8,u16,usize,i32, 等等使用这个相同的函数。另外,如果我们为我们的user 结构实现了这些特性,就可以使用这个相同的函数进行排序,而不需要我们单独编写一个函数。
另一种写法如下:
fn sort<Sortable>(arr:&mut [Sortable])where Sortable:Ord+Eq{
...
}
在这里,我们没有把特质和类型参数声明一起写,而是把它们写在了函数参数之后。
以另一种方式来思考:trait bounds为我们提供了关于类型参数中被替换的类型的保证。
例如,Rust的HashMap ,要求给它的键可以被散列。换句话说,它需要保证散列函数可以在被替换为键的类型上被调用,并且它将给出一些可以被视为该键的散列值。
因此,它通过要求它实现Hash 特质来限制其密钥类型。
同样地,HashSet 要求存储在其中的元素可以被散列,并且它将它们的类型限制为实现Hash 特质的类型。
因此,我们可以把类型参数的特质界限看作是限制哪些类型可以被替换的方法,以及保证被替换的类型会有某些属性或功能与之相关。
Rust中的终身泛型
Rust大量使用泛型的另一个地方是在生命期。
这一点比较难注意到,因为Rust中的生命期大多是编译时的实体,在代码或编译后的二进制文件中不直接可见。因此,几乎所有的寿命注释都是通用的 "类型 "参数,其值由编译器在编译时决定。
这方面的少数例外之一是'static 寿命。
通常,如果一个类型有一个静态的生命期,这意味着这个值应该一直活到程序结束。请注意,这并不是它的确切含义,但现在,我们可以这样认为。
即便如此,生命期泛型仍然与类型泛型有些不同:它们的最终值是由编译器计算出来的,而不是我们在代码中指定类型,编译器简单地将其替换掉。
因此,如果需要的话,编译器可以强制将较长的生命期变成较短的生命期,并且在分配前必须为每个注解计算出合适的生命期。另外,我们常常不需要直接处理这些,而是可以让编译器为我们推断这些,甚至不需要指定参数。
终身注解总是以' 符号开始,之后可以采用任何类似变量的名称。我们将明确使用这些注释的地方之一是当我们想在结构或枚举中存储对某些东西的引用。
由于在Rust中所有的引用都必须是有效的引用(不能有悬空的指针),我们必须指定存储在结构中的引用必须保持有效,至少在该结构处于范围内,或者有效。
请考虑以下情况:
struct Reference{
reference:&u8
}
上面的代码会给我们一个错误信息:没有指定生命周期参数。
编译器无法知道引用必须在多长时间内有效--如果它应该和结构的生命周期一样长,'static ,还是其他什么。因此,我们必须指定寿命参数,如下所示:
struct Reference<'a>{
reference:&'a u8
}
在这里,我们指定Reference 结构将有一个相关的生命周期,即'a ,其中的引用必须至少保持这么长的时间。现在,在编译时,Rust可以按照寿命确定规则来替换寿命,并决定所存储的引用是否有效。
另一个需要明确说明寿命的地方是当一个函数接受多个引用并返回一个输出引用时。
如果该函数根本不返回引用,那么就没有寿命需要考虑。如果它只通过引用接收一个参数,那么返回的引用必须在输入引用的时间内有效。
我们只能返回一个由输入构建的引用。任何其他的默认情况下都是无效的,因为它将由函数中创建的值构建,当我们从函数调用中返回时,这些值将是无效的。
但是当我们接收多个引用时,我们必须指定每个引用--包括输入和输出引用--必须存活多长时间,因为编译器不能自行决定。一个非常基本的例子是这样的:
fn return_reference(in1:&[usize],in2:&[usize])->&usize{
...
}
在这里,编译器会抱怨说我们必须指定输出&usize 的寿命,因为它无法知道它是与in1 还是in2 有关。添加所有三个引用的寿命将解决这个问题:
fn return_reference<'a>(in1:&'a [usize],in2:&'a [usize])->&'a usize{
...
}
就像通用类型参数一样,我们在函数名后面声明了寿命参数'a ,然后用它来指定变量的寿命。
这告诉编译器,只要输入引用有效,输出引用就会有效。同时,这也增加了一个限制条件,即in1 和in2 必须具有相同的寿命。
由于这一切,编译器将不允许不同时间的输入引用存在。
为了处理这种情况,我们也可以在这里为in1 和in2 指定两个不同的生命期,并指定返回值的生命期与它被返回的生命期相同。
例如,如果我们要从in1 中返回一个值,我们将保持in1 和返回类型的生命周期相同,并给in2 一个不同的生命周期参数,像这样:
fn return_reference<'a,'b>(in1:&'a [usize],in2:&'b [usize])->&'a usize{
...// return values only from in1
}
如果我们不小心从in2 中返回一个引用,编译器会给我们一个错误信息,说生命周期不匹配。这可以作为一个额外的检查,确保引用是从正确的地方返回的。
但是如果我们事先不知道我们将从哪个输入端返回引用呢?在这种情况下,我们可以指定一个寿命必须至少和另一个一样长:
fn return_reference<'a,'b:'a>(in1:&'a [usize],in2:&'b [usize])->&'a usize{
...
}
这说明,'b 的寿命必须至少与'a 一样长。因此,我们可以从in1 或in2 返回一个值,而编译器不会给我们一个错误信息。
鉴于许多需要显式生命期的东西都是相当高级的话题,这里没有考虑所有相关的用例。你可以查看The Embedded Rust Book了解更多关于生命期的信息。
在Rust中使用泛型的类型状态编程
这是泛型的另一个稍微高级的用例。我们可以使用泛型来选择性地实现结构的功能。这通常与状态机或有多个状态的对象有关,其中每个状态都有不同的功能。
考虑一个案例,我们想实现一个加热器结构。加热器可以处于低、中或高的状态。根据其状态的不同,它需要做不同的事情。
想象一下,加热器有一个旋钮或转盘:当旋钮在低位时,它可以转到中位,但如果不先转到中位就不能直接转到高位。在中档时,它可以转到高档或低档。当在高位时,它只能转到中位,不能直接转到低位。
为了在Rust中实现这一点,我们可以把它变成一个enum ,并有可能把这三种状态变成它的变体。但是我们还不能指定变体的具体方法--至少在写这篇文章的时候不能。我们将不得不在所有地方使用match 语句,并有条件地应用这些逻辑。
这就是类型属性在Rust中的用处。
首先,让我们声明三个单元结构,它们将代表我们的状态:
struct Low;
struct Medium;
struct High;
struct Heater<State>{
...
}
但是这样一来,编译器就会抱怨说参数没有被使用,所以我们必须做一些其他的事情。
我们实际上并不想存储这些状态结构,因为我们实际上并没有对它们做什么,而且它们也没有任何数据。相反,我们用这些状态来表示加热器的状态,以及哪些功能(即从低到中,从中到高,等等)对其可用。
因此,我们使用一个特殊的类型,叫做PhantomData 。
use std::marker::PhantomData;
struct Heater<State>{
...
state:PhantomData<State>
...
}
PhantomData 是Rust 中的一个特殊结构 [std](https://docs.rs/rustc-std-workspace-std/latest/std/) 库中的一个特殊结构。这个结构的行为就像它存储数据一样,但实际上并不存储任何数据。现在对编译器来说,似乎我们正在使用通用类型参数,所以它没有抱怨。
有了这个,我们可以像这样实现加热器当前状态的特定方法:
impl Heater<Low>{
fn turn_to_medium(self)->Heater<Medium>{
...
}
// methods specific to low state of the heater
}
impl Heater<Medium>{
// methods specific to medium state of the heater
fn turn_to_low(self)->Heater<Low>{
...
}
fn turn_to_high(self)->Heater<High>{
...
}
}
impl Heater<High>{
// methods specific to high state of the heater
fn turn_to_medium<Medium>{
...
}
}
每个状态都包含特定的方法,这些方法不能从任何其他状态中访问。我们还可以用它来限制函数只取加热器的一个特定状态:
fn only_for_medium_heater(h:&mut Heater<Medium>){
// this will only accept medium heater
}
因此,如果我们尝试给这个函数一个Heater ,并有一个Low 状态,我们会在编译时得到一个错误:
let h_low:Heater<Low> = Heater{
...
state:PhantomData,
...
}
only_for_medium_heater(h_low); // Compiler Error!!!
请注意,我们实际上并没有使用new 方法创建PhantomData ,因为它实际上并没有存储任何东西。
此外,我们需要确保编译器能够弄清楚我们要存储Heater 结构的变量的类型状态。我们可以像上面那样明确指定类型,或者在明确说明类型状态的上下文中使用该变量,如函数、调用或其他相关上下文。
我们可以正常实现所有状态的通用方法,如下图所示:
impl <T>Heater<T>{
// methods common to all states, i.e. to any heater
// here the type T is generic
}
Rust中的高级通用类型。泛型关联类型
在这里我们将提到泛型的一个更高级的用例:泛型关联类型(GATs)。这是一个相当高级和复杂的话题,因此我们不会在这篇文章中详细介绍它。
关联类型是我们在traits中定义的类型。之所以这样称呼它们,是因为它们完全与该特征相关联。这里有一个例子:
trait Computable{
type Result;
fn compute(&self)->Self::Result;
}
这里,Result 类型与特质Computable 相关联;因此,我们可以在函数定义中使用它们。要想知道这与简单地使用通用类型参数有什么不同以及为什么不同,你可以查看The Embedded Rust Book。
泛型关联类型,正如其名称所示,允许我们在关联类型中使用泛型。这是对Rust的一个相对较新的补充,事实上,在写这篇文章的时候,它的所有部分还没有稳定下来。
使用GATs,可以定义包含泛型、生命期以及我们在本文中讨论的其他一切的关联类型。
总结
现在你知道什么是泛型,为什么它们很有用,以及Rust是如何使用它们的,即使你没有注意到。
我们介绍了如何使用泛型来编写更少的代码,同时可以更灵活;如何对类型的功能进行限制;以及如何对类型的状态使用泛型,以便你可以有选择地实现结构所处状态的功能。