结构体和实现中是否应该重复使用特质约束?

59
以下代码使用了一个带有泛型类型的结构体。虽然它的实现只对给定的 trait 限制有效,但该结构体可以定义为具有或不具有相同的限制。由于结构体的字段是私有的,因此任何其他代码都无法创建实例。
trait Trait {
    fn foo(&self);
}

struct Object<T: Trait> {
    value: T,
}

impl<T: Trait> Object<T> {
    fn bar(object: Object<T>) {
        object.value.foo();
    }
}

为了符合DRY原则,结构体的特质限定是否应该省略?还是应该给予以明确依赖关系?或者在什么情况下应该优先选择其中一种解决方案?

3个回答

83
我认为现有的答案是误导性的。在大多数情况下,除非结构体没有加边界就无法编译,否则不应该给结构体加边界。
tl;dr 对于大多数人来说,给结构体加边界表达了错误的含义。它们具有感染性、冗余性、有时目光短浅且常常令人困惑。即使边界看起来正确,通常也应该等到证明必要之前才使用。(在这个答案中,我所说的关于结构体的任何内容同样适用于枚举。)

0. 结构体的边界必须在涉及结构体的每个impl中重复

这是最明显但(对我来说)最不令人信服的避免编写结构体边界的原因。截至本文撰写时(Rust 1.65),您必须在每个接触到它的impl上重复每个结构体的边界,这足以成为现在不在结构体上放置边界的一个好理由。但是,有一个已被接受的RFC(implied_bounds),当其实施和稳定后,将通过推断冗余边界来改变此情况。但即使如此,在结构体上的边界通常仍然是错误的:

1. 结构体的边界会泄漏出抽象层。

您的数据结构很特殊。 "如果T是Trait,则“Object<T>”才有意义",您说。也许你是对的。但这个决定不仅影响Object,还影响包含Object<T>的任何其他数据结构,即使它并不总是包含Object<T>。考虑一个想要在enum中包装您的Object的程序员:

enum MyThing<T> {  // error[E0277]: the trait bound `T: Trait` is not satisfied
    Wrapped(your::Object<T>),
    Plain(T),
}

在下游代码中,这是有意义的,因为MyThing :: Wrapped 仅与实现 Thing T 一起使用,而 Plain 可以与任何类型一起使用。但是,如果 your :: Object T 进行了绑定,则即使有许多使用 Plain(T)的用途不需要此限制,也无法编译此 enum 。这不仅不起作用,而且即使添加约束并不完全无用,它也会将该约束公开在任何使用 MyThing 的结构的公共API中。
对结构的限制限制了其他人对它们的操作。当然,对代码( impl 和函数)的限制也是如此,但这些约束(可能)是您自己的代码所需的,而结构上的限制则是对那些可能以创新方式使用您的结构的下游方的预防性打击。这可能很有用,但是对于创新者来说,不必要的限制特别令人恼火,因为它们限制了可以编译的内容,而没有有用地限制实际上可以运行的内容(稍后将详细介绍)。
因此,您认为下游创新不可能吗?这并不意味着结构本身需要一个约束。为了使无法构造没有 T:Trait Object ,只需将该限制放在包含 Object 构造函数(们)的 impl 中即可;如果无法在 Object 上调用 a_method 而没有 T:Trait ,则可以在包含 a_method impl 上或者也许在 a_method 本身上这样说。(直到实现 implied_bounds ,您必须这样做,因此您甚至没有“节省击键”的弱理由。)
即使尤其是当您想不出任何方法让下游使用未限定的 Object 时,您也不应该 a priori 禁止它,因为...
结构上的限制对类型系统的意义与代码上的限制不同。 Object<T>上的T: Trait约束意味着比“所有Object<T>都必须具有T: Trait”更多;它实际上意味着“Object<T>本身的概念没有意义,除非T: Trait”,这是一个更抽象的想法。想想自然语言:我从未见过紫色大象,但我可以轻松地命名“紫色大象”的概念,尽管它对应于没有现实世界动物的事实。类型是一种语言,引用Elephant<Purple>的概念可能是有意义的,即使您不知道如何创建它,当然也没有用处。同样,即使您现在没有并且无法拥有Object<NotTrait>,在抽象中表达该类型也是有意义的。特别是当NotTrait是类型参数时,在此上下文中可能不知道其是否实现了Trait,但在其他上下文中可能会实现。

案例研究:Cell<T>

关于最初具有最终被删除的trait约束的结构体的一个例子,请看看Cell<T>,它最初具有T: Copy约束。在删除该约束的RFC中,许多人最初提出了你现在可能正在考虑的相同类型的论点,但最终的共识是,“Cell需要Copy”始终是错误的思考方式。 RFC已合并,为创新铺平了道路,例如Cell::as_slice_of_cells,它使您可以在安全代码中执行以前无法执行的操作,包括暂时选择共享突变。重点是,T: Copy从来不是Cell<T>上有用的约束,并且将其从一开始就省略掉不会造成任何伤害(可能会有一些好处)。

这种抽象约束可能很难理解,这可能是它经常被误用的原因之一。这与我的最后一点有关:

4. 不必要的约束会引入不必要的参数(这更糟)。

这并不适用于所有结构体边界的情况,但这是一个常见的困惑点。例如,您可能有一个类型参数应该实现一个通用特质的结构体,但不知道特质应该采取哪些参数。在这种情况下,很容易使用PhantomData将一个类型参数添加到主结构体中,但这通常是一个错误,至少因为PhantomData很难正确使用。以下是一些由于不必要的边界而添加的不必要参数的示例:1 2 3 4 5 在大多数这样的情况下,正确的解决方案是简单地删除边界。
规则的例外:
好吧,什么时候你需要一个结构的边界呢?我能想到两个可能的原因。
  • Shepmaster's answer中,如果没有边界,结构将根本无法编译,因为对于I的Iterator实现实际上定义了结构包含的内容。另一个导致结构无法编译的方法是,当其Drop的实现必须以某种方式使用特质时。出于健全性原因,Drop不能具有不在结构上的边界,因此您也必须在结构上写它们。
  • 当您编写unsafe代码并希望它依赖于一个边界(例如T: Send)时,您可能需要将该边界放在结构上。unsafe代码是特殊的,因为它可以依赖于非unsafe代码保证的不变量,因此仅在包含unsafeimpl上放置边界并不一定足够。
但在所有其他情况下,除非您真的知道自己在做什么,否则应完全避免在结构上添加边界。

2
一个有说服力的答案让我决定永远不在结构体或枚举中使用边界。 - Dag Sondre Hansen
1
如果我们需要为结构体实现Drop,似乎我们必须在结构体上绑定泛型?还有一个例外吗? - Sprite
@Sprite 不确定为什么你要这样做。即使你编写自己的 Drop 实现,字段也会被隐式丢弃。std::mem::drop() 也适用于任何值,无论它是否是 Drop - chbaker0
1
顺便说一句,我个人倾向于让 drop 更加简洁,并且有一个显式的清理方法,该方法通过值传递 self。如果绝对需要执行此清理操作,则可以要求调用此方法并使 drop 无条件地发生恐慌。 - chbaker0
回复3:在我看来,IMHO忽略了Object<T>作为概念不合理的情况。只有当COLOR: VisibleColor时,Elephant<COLOR>才有意义,因为谈论Elephant<Ultraviolet>是没有意义的。什么是UV大象?从其他颜色派生,这意味着大象被感知为颜色UV-但UV无法被感知-或者它只反射光谱中的UV部分,但那样它就会变成黑色。 - Martin Geisse
显示剩余3条评论

18

适用于 每个 结构体实例的特质约束应该应用于结构体本身:

struct IteratorThing<I>
where
    I: Iterator,
{
    a: I,
    b: Option<I::Item>,
}

只适用于某些实例的特性界限应该仅适用于它们所属的 impl 块:

struct Pair<T> {
    a: T,
    b: T,
}

impl<T> Pair<T>
where
    T: std::ops::Add<T, Output = T>,
{
    fn sum(self) -> T {
        self.a + self.b
    }
}

impl<T> Pair<T>
where
    T: std::ops::Mul<T, Output = T>,
{
    fn product(self) -> T {
        self.a * self.b
    }
}

为符合DRY原则

通过RFC 2089,冗余将被消除:

Eliminate the need for “redundant” bounds on functions and impls where those bounds can be inferred from the input types and other trait bounds. For example, in this simple program, the impl would no longer require a bound, because it can be inferred from the Foo<T> type:

struct Foo<T: Debug> { .. }
impl<T: Debug> Foo<T> {
  //    ^^^^^ this bound is redundant
  ...
}

5
RFC是对我最初遇到的冗余问题的解答。 - user2011659
1
@user2011659 是的,我和你一样。以前,我倾向于不在结构体上放置边界(即使这对我自己有害)。现在看来,去掉在两个地方输入相同边界的要求,会让人们更加困惑还是更少困惑,这很有趣。在RFC之后,现在代码impl<T> Foo<T> { ... }可能会有很多限制,除非你查看结构体,否则你不会知道这些限制。 - Shepmaster

16

这取决于类型的用途。如果它只是用于保存实现该特征的值,则应该有该特征的限制,例如:

trait Child {
    fn name(&self);
}

struct School<T: Child> {
    pupil: T,
}

impl<T: Child> School<T> {
    fn role_call(&self) -> bool {
        // check everyone is here
    }
}

在这个例子中,只允许儿童进入学校,所以我们对结构进行了限制。
如果结构体旨在保存任何值,但您希望在实现特性时提供额外的行为,则不应该将限制放在结构体上。
trait GoldCustomer {
    fn get_store_points(&self) -> i32;
}

struct Store<T> {
    customer: T,
}

impl<T: GoldCustomer> Store {
    fn choose_reward(customer: T) {
        // Do something with the store points
    }
}

在这个例子中,并不是所有的客户都是黄金客户,因此在结构体上设置边界没有意义。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接