结构实现接口安全吗?


92

我似乎还记得读过一些有关结构如何通过C#在CLR中实现接口的弊端,但是我似乎找不到任何东西。不好吗 这样做会有意想不到的后果吗?

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

Answers:


45

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

结构可以实现接口,但转换,可变性和性能会引起关注。请参阅此帖子以获取更多详细信息:https : //docs.microsoft.com/zh-cn/archive/blogs/abhinaba/c-structs-and-interface

通常,结构应用于具有值类型语义的对象。通过在结构上实现接口,您会在结构与接口之间来回转换时遇到拳击问题。装箱的结果是,更改结构内部状态的操作可能无法正常运行。


3
“由于装箱,更改结构内部状态的操作可能无法正常运行。” 举一个例子并得到答案。

2
@Will:不确定您在评论中指的是什么。我引用的博客文章中有一个示例,该示例显示在结构上调用接口方法实际上并不会更改内部值。
Scott Dorman

12
@ScottDorman:在某些情况下,使结构实现接口可能有助于避免装箱。主要示例是IComparable<T>IEquatable<T>。将结构存储Foo在类型变量IComparable<Foo>中将需要装箱,但是如果将通用类型T限制为IComparable<T>一个,则可以将其与另一个进行比较,T而不必将任何一个都装箱,也不必T了解除实现约束之外的任何其他信息。只有通过结构实现接口的能力才能使这种有利行为成为可能。话虽如此……
超级猫

3
...如果有一种方法可以声明一个特定的接口仅应被认为适用于非盒装结构,那可能很好,因为在某些情况下,类对象或盒装结构不可能具有所需的接口行为。
2013年

2
“结构应用于具有值类型语义的对象。...更改结构内部状态的操作可能无法正常运行。” 真正的问题不是因为价值类型的语义和可变性不能很好地融合在一起吗?
jpmc26

183

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

在结构上实现接口不会产生任何负面影响。

用于保存结构的接口类型的任何变量都将导致使用该结构的框式值。如果该结构是不可变的(一件好事),那么除非您是:

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

这两种情况均不太可能,相反,您可能正在执行以下一项操作:

泛型

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

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. 启用将struct用作类型参数
    • 只要不使用new()或之类的其他约束即可class
  2. 避免对以这种方式使用的结构进行装箱。

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

如果thisType是值类型,并且thisType实现方法,则将ptr未经修改地传递为调用方法指令的'this'指针,以供thisType实现方法。

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

低摩擦API

大多数结构应具有类似原始的语义,其中按位相同的值被视为等于2。运行时将以隐式方式提供此类行为,Equals()但这可能会很慢。同样,这种隐式相等不会作为的实现公开IEquatable<T>,因此,除非结构体自己明确实现它,否则防止将结构轻松用作Dictionary的键。因此,许多公共结构类型通常声明它们实现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的语义指定如果枚举器实现,IDisposableDispose()在迭代完成后将调用该枚举器。显然,通过框式调用来实现此功能将消除枚举数为struct的任何好处(实际上会更糟)。更糟糕的是,如果dispose调用以某种方式修改了枚举器的状态,则这将在装箱的实例上发生,并且在复杂的情况下可能会引入许多细微的错误。因此,在这种情况下发出的IL为:

IL_0001:newobj System.Collections.Generic.List..ctor
IL_0006:stloc.0     
IL_0007:nop         
IL_0008:ldloc.0     
IL_0009:callvirt System.Collections.Generic.List.GetEnumerator
IL_000E:stloc.2     
IL_000F:兄弟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:离开。IL_0035
IL_0026:ldloca.s 02 
IL_0028:受约束。System.Collections.Generic.List.Enumerator
IL_002E:callvirt System.IDisposable.Dispose
IL_0033:nop         
IL_0034:最终  

因此,IDisposable的实现不会引起任何性能问题,并且如果Dispose方法实际上可以执行任何操作,则枚举器的(可遗憾的)可变方面将得以保留!

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


1
该网站egheadcafe.com已移动,但在保留其内容方面做得不好。我尝试过,但是找不到OP的知识,但是找不到eggheadcafe.com/software/aspnet/31702392/…的原始文档。(PS +1是一个很好的总结)。
亚伯

2
这是一个很好的答案,但是我认为您可以通过将“摘要”作为“ TL; DR”移到顶部来改善它。首先提供结论可帮助读者了解您的发展方向。
汉斯

铸造时应该有编译器警告structinterface
塔拉巴尼

8

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

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

实现接口的结构的最大问题是,存储在接口类型ObjectValueType的位置(或与其自身类型的位置相反)的结构将充当类对象。对于只读接口,这通常不是问题,但是对于像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进行高级处理。然后,因为enumerator3enumerator4都是引用类型,一个参考,以enumerator3将被复制到enumerator4,如此显着的语句将有效地促进双方 enumerator3enumerator4

有人试图假装值类型和引用类型都是的Object,但这不是真的。实值类型可以转换为Object,但不是实例。List<String>.Enumerator存储在该类型位置的实例是值类型,其行为与值类型相同。将其复制到type的位置IEnumerator<String>会将其转换为引用类型,并且将充当引用类型。后者是一种Object,但前者不是。

顺便说一句,还有一些注意事项:(1)一般而言,可变类类型应让其Equals方法测试引用相等性,但是盒装结构没有这样做的体面方法;(2)尽管名称,但它ValueType是类类型,而不是值类型;衍生自所有类型System.Enum是值类型,因为是从派生的所有类型ValueType的除外System.Enum,但两者ValueTypeSystem.Enum是类的类型。


3

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

我看到使用结构而不是类的唯一原因是因为它将是值类型而不是引用类型,但结构不能从类继承。如果您的结构继承了一个接口,并且传递了接口,那么您将失去该结构的值类型性质。如果需要接口,也可以将其设为类。


对于实现接口的原语,它也像这样工作吗?
aoetalks

3

(当然,没有什么要添加的东西,但是还没有编辑能力,所以这里有。)
绝对安全。在结构上实现接口没有违法行为。但是,您应该质疑为什么要这样做。

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

我现在可以想到的唯一有效方案已在此处发布。当您想要修改存储在集合中的结构状态时,必须通过结构上公开的其他接口来完成。


如果将传递Int32给接受通用类型的方法T:IComparable<Int32>(可以是该方法的通用类型参数,也可以是该方法的类),则该方法将能够Compare在传入对象上使用该方法而无需将其装箱。
2013年


0

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


0

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

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

当然,通过实现一个接口,您可以将结构装箱,因此它现在位于堆上,您将无法再通过值传递它...这确实加强了我的意见,即您应该只使用一个类在这个情况下。


您多久传递一次IComparable而不是具体的实现?
FlySwat

您无需IComparable四处走动来装箱值。通过简单地调用IComparable期望值的方法来实现它,您将隐式装箱该值类型。
安德鲁·黑尔

1
@AndrewHare:受约束的泛型允许在没有装箱的情况下IComparable<T>在类型的结构上调用方法T
2013年

-10

结构就像存在于堆栈中的类一样。我认为没有理由为什么它们应该是“不安全的”。


除了他们没有继承。
FlySwat

7
我必须不同意这个答案的每一部分;他们并不一定住在堆栈上,并复制语义是非常不同的类。
Marc Gravell

1
它们是一成不变的,过度使用struct会让您的记忆感到悲伤:(
Teoman shipahi 2014年

1
@Teomanshipahi类实例的过多使用会使您的垃圾收集器发疯。
IllidanS4

4
对于拥有超过20k代表的人来说,这个答案是不可接受的。
Krythic '16
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.