105

我似乎记得读过一些关于结构通过 C# 在 CLR 中实现接口的坏处,但我似乎找不到任何关于它的信息。不好吗?这样做会产生意想不到的后果吗?

public interface Foo { Bar GetBar(); }
public struct Fubar : Foo { public Bar GetBar() { return new Bar(); } }
4

9 回答 9

198

由于没有其他人明确提供此答案,我将添加以下内容:

在结构上实现接口没有任何负面影响。

用于保存结构的接口类型的任何变量都将导致使用该结构的装箱值。如果结构是不可变的(一件好事),那么这在最坏的情况下是一个性能问题,除非您是:

  • 将生成的对象用于锁定目的(无论如何都是一个非常糟糕的主意)
  • 使用引用相等语义并期望它适用于来自同一结构的两个装箱值。

这两种情况都不太可能,相反,您可能会执行以下操作之一:

泛型

结构实现接口的许多合理原因可能是它们可以在具有约束的通用上下文中使用。当以这种方式使用时,变量如下:

class Foo<T> : IEquatable<Foo<T>> where T : IEquatable<T>
{
    private readonly T a;

    public bool Equals(Foo<T> other)
    {
         return this.a.Equals(other.a);
    }
}
  1. 启用将结构用作类型参数
    • 只要没有使用类似new()orclass的其他约束。
  2. 允许避免对以这种方式使用的结构进行装箱。

那么 this.a 不是接口引用,因此它不会导致放入其中的任何内容。此外,当 c# 编译器编译泛型类并需要插入在 Type 参数 T 的实例上定义的实例方法的调用时,它可以使用受约束的操作码:

如果 thisType 是值类型并且 thisType 实现了方法,则 ptr 未经修改地作为“this”指针传递给调用方法指令,用于通过 thisType 实现方法。

这避免了装箱,并且由于值类型正在实现接口,因此必须实现方法,因此不会发生装箱。在上面的示例中,Equals()调用是在 this.a 1上没有框的情况下完成的。

低摩擦 API

大多数结构应该具有类似原始的语义,其中按位相同的值被认为等于2。运行时将隐式提供此类行为,Equals()但这可能很慢。此外,这种隐式相等不会作为实现公开,IEquatable<T>因此可以防止结构被轻松用作字典的键,除非它们自己显式实现它。因此,许多公共结构类型通常声明它们实现IEquatable<T>(它们在哪里T)以使其更容易和更好地执行,并与 CLR BCL 中许多现有值类型的行为保持一致。

BCL 中的所有原语至少实现:

  • IComparable
  • IConvertible
  • IComparable<T>
  • IEquatable<T>(因此IEquatable

许多还实现IFormattable了,而且许多系统定义的值类型,如 DateTime、TimeSpan 和 Guid 也实现了许多或所有这些。如果您正在实现类似的“广泛有用”的类型,例如复数结构或一些固定宽度的文本值,那么实现许多这些通用接口(正确)将使您的结构更有用和可用。

排除项

显然,如果接口强烈暗示可变性(如ICollection) 或者您通过忽略诸如Add()抛出异常之类的方法的含义而使用户感到困惑。

许多接口并不意味着可变性(例如IFormattable),而是作为以一致方式公开某些功能的惯用方式。通常结构的用户不会关心这种行为的任何装箱开销。

概括

当明智地完成时,在不可变值类型上,实现有用的接口是一个好主意


笔记:

1:请注意,编译器在对已知为特定结构类型但需要调用虚拟方法的变量调用虚拟方法时可能会使用此选项。例如:

List<int> l = new List<int>();
foreach(var x in l)
    ;//no-op

List 返回的枚举器是一个结构体,这是一种在枚举列表时避免分配的优化(有一些有趣的后果)。但是 foreach 的语义指定如果枚举器实现IDisposablethenDispose()将在迭代完成后被调用。显然,通过盒装调用发生这种情况会消除枚举器作为结构的任何好处(实际上它会更糟)。更糟糕的是,如果 dispose 调用以某种方式修改了枚举器的状态,那么这将发生在装箱的实例上,并且在复杂的情况下可能会引入许多细微的错误。因此在这种情况下发出的 IL 是:

IL_0001:newobj System.Collections.Generic.List..ctor
IL_0006:stloc.0     
IL_0007:没有         
IL_0008:ldloc.0     
IL_0009:调用virt System.Collections.Generic.List.GetEnumerator
IL_000E: stloc.2     
IL_000F: br.s IL_0019
IL_0011:ldloca.s 02
IL_0013:调用 System.Collections.Generic.List.get_Current
IL_0018:stloc.1     
IL_0019: ldloca.s 02
IL_001B:调用 System.Collections.Generic.List.MoveNext
IL_0020:stloc.3     
IL_0021:ldloc.3     
IL_0022:brtrue.s IL_0011
IL_0024:离开.s IL_0035
IL_0026: ldloca.s 02
IL_0028:受限。System.Collections.Generic.List.Enumerator
IL_002E: callvirt System.IDisposable.Dispose
IL_0033:没有         
IL_0034:最后  

因此,IDisposable 的实现不会导致任何性能问题,并且如果 Dispose 方法实际执行任何操作,枚举数的(令人遗憾的)可变方面将被保留!

2:double 和 float 是该规则的例外,其中 NaN 值不相等。

于 2009-08-17T18:17:03.553 回答
52

这个问题有几件事正在发生......

结构可以实现接口,但存在与转换、可变性和性能有关的问题。有关更多详细信息,请参阅此帖子:https ://docs.microsoft.com/en-us/archive/blogs/abhinaba/c-structs-and-interface

一般来说,结构应该用于具有值类型语义的对象。通过在结构上实现接口,您可能会遇到装箱问题,因为结构在结构和接口之间来回转换。由于装箱,更改结构内部状态的操作可能无法正常运行。

于 2008-09-15T15:09:21.780 回答
8

在某些情况下,结构实现接口可能会很好(如果它从来没有用过,那么 .net 的创建者是否会提供它是值得怀疑的)。如果一个结构体实现了类似的只读接口IEquatable<T>,将结构体存储在类型的存储位置(变量、参数、数组元素等)IEquatable<T>将需要对其进行装箱(每个结构体类型实际上定义了两种东西:一个存储位置类型表现为值类型,堆对象类型表现为类类型;第一个可以隐式转换为第二个——“装箱”——第二个可以通过显式转换转换为第一个—— “拆箱”)。可以在不装箱的情况下利用结构的接口实现,但是,使用所谓的约束泛型。

例如,如果一个人有一个方法CompareTwoThings<T>(T thing1, T thing2) where T:IComparable<T>,那么这样的方法可以调用thing1.Compare(thing2)而无需框thing1thing2。如果thing1碰巧是,例如,一个Int32,运行时将知道它何时生成代码CompareTwoThings<Int32>(Int32 thing1, Int32 thing2)。由于它将知道承载该方法的事物和作为参数传递的事物的确切类型,因此不必将它们中的任何一个装箱。

实现接口的结构的最大问题是存储在接口类型的位置Object,或ValueType(与其自己类型的位置相反)的结构将表现为类对象。对于只读接口,这通常不是问题,但对于像IEnumerator<T>它这样的变异接口可能会产生一些奇怪的语义。

例如,考虑以下代码:

List<String> myList = [list containing a bunch of strings]
var enumerator1 = myList.GetEnumerator();  // Struct of type List<String>.IEnumerator
enumerator1.MoveNext(); // 1
var enumerator2 = enumerator1;
enumerator2.MoveNext(); // 2
IEnumerator<string> enumerator3 = enumerator2;
enumerator3.MoveNext(); // 3
IEnumerator<string> enumerator4 = enumerator3;
enumerator4.MoveNext(); // 4

标记语句#1 将enumerator1准备读取第一个元素。该枚举器的状态将被复制到enumerator2. 标记语句 #2 将推进该副本以读取第二个元素,但不会影响enumerator1. 然后将第二个枚举器的状态复制到enumerator3,这将由标记的语句#3 推进。然后,因为enumerator3andenumerator4都是引用类型,所以REFERENCE toenumerator3将被复制到enumerator4,因此标记的语句将有效地推进 enumerator3and enumerator4

有些人试图假装值类型和引用类型都是Object,但事实并非如此。实值类型可以转换为Object,但不是它的实例。存储在该类型位置的实例List<String>.Enumerator是值类型,并且表现为值类型;将其复制到 type 的位置IEnumerator<String>会将其转换为引用类型,并且它将表现为引用类型。后者是一种Object,而前者不是。

BTW, a couple more notes: (1) In general, mutable class types should have their Equals methods test reference equality, but there is no decent way for a boxed struct to do so; (2) despite its name, ValueType is a class type, not a value type; all types derived from System.Enum are value types, as are all types which derive from ValueType with the exception of System.Enum, but both ValueType and System.Enum are class types.

于 2013-01-14T16:34:31.010 回答
3

结构被实现为值类型,类是引用类型。如果你有一个 Foo 类型的变量,并且你在其中存储了一个 Fubar 的实例,它会将它“装箱”成一个引用类型,从而首先破坏了使用结构的优势。

我看到使用结构而不是类的唯一原因是因为它将是值类型而不是引用类型,但结构不能从类继承。如果你让结构继承一个接口,并且你传递接口,你就会失去结构的值类型性质。如果您需要接口,不妨将其设为一个类。

于 2008-09-15T15:06:24.330 回答
3

(好吧,没有什么重要的东西要添加,但还没有编辑能力,所以就这样吧..)
完全安全。在结构上实现接口没有什么违法的。但是,您应该质疑为什么要这样做。

但是,获取对结构的接口引用会将其装箱。所以性能损失等等。

我现在能想到的唯一有效场景在我的帖子中进行了说明。当您想要修改存储在集合中的结构状态时,您必须通过结构上公开的附加接口来完成。

于 2008-09-15T15:14:58.317 回答
1

值类型实现接口的理由很少。由于您不能子类化值类型,因此您始终可以将其称为具体类型。

当然,除非您有多个结构都实现了相同的接口,否则它可能会有点用处,但那时我建议使用一个类并正确地使用它。

当然,通过实现一个接口,你正在装箱结构,所以它现在位于堆上,你将无法再通过值传递它......这真的强化了我的观点,你应该只使用一个类在这个情况下。

于 2008-09-15T15:04:12.913 回答
1

我认为问题在于它会导致装箱,因为结构是值类型,因此会有轻微的性能损失。

该链接表明它可能存在其他问题...

http://blogs.msdn.com/abhinaba/archive/2005/10/05/477238.aspx

于 2008-09-15T15:04:52.553 回答
0

实现接口的结构没有任何后果。例如,内置的系统结构实现了 和 之类的IComparable接口IFormattable

于 2008-09-15T15:04:05.973 回答
-10

结构就像存在于堆栈中的类。我看不出为什么他们应该是“不安全的”。

于 2008-09-15T15:03:54.490 回答