选择不可变对象和结构体作为值对象

25

你怎么选择将值对象(一个典型的例子是地址)实现为不可变对象或结构体?

选择其中一种有没有性能、语义或其他方面的好处?


你是指不可变对象还是“不可变”结构体?结构体也可以是可变的,对吗? - Gulzar Nazim
我相当确定它们不可能是用C#编写的。如果你有证据请随意向我证明 :) - Garry Shutler
7
结构体是可变的。但它们虽然按值传递,所以对其进行的更改不会像类(按引用传递)那样传播到所有引用它们的地方。 - configurator
1
在配置器上+1 - c#结构体是可变的,除非你使用只有get而没有set的属性,或者将字段标记为“readonly”。 - Paul Stovell
11个回答

17

有一些需要考虑的事情:

结构体通常在堆栈上分配。它是值类型,如果数据太大,则在方法之间传递数据可能会很昂贵。

类分配在堆上。它是引用类型,因此通过方法传递对象不会那么昂贵。

通常,我使用结构体来创建不是很大且不可变的对象。当它们保留有限数量的数据或我想要不可变性时,我仅在这种情况下使用它们。一个例子是DateTime结构。我认为如果我的对象不像DateTime那样轻巧,那么可能不值得使用作为结构体。此外,如果我的对象作为值类型没有意义(也像DateTime那样),则可能不适合使用结构体。这里的关键是不可变性。此外,我要强调的是,结构体默认不是不可变的。你必须通过设计使其不可变。

在我遇到的99%的情况中,类是正确的选择。我发现自己很少需要不可变的类。在大多数情况下,我更自然地将类视为可变的。


这是我所暗示的第一点,当我说“[结构体]是值类型”和“[类]是引用类型”时。 - Dan Herbert
@DanHerbert:关于“结构体通常分配在堆栈上”,这个“通常”很困惑!一个结构体总是被分配在堆栈上。 - Manish Basantani
2
@Amby看一下这篇文章,它应该会帮助你理解。http://blogs.msdn.com/b/ericlippert/archive/2010/09/30/the-truth-about-value-types.aspx 结构体不需要装箱才能分配到堆上。 - Dan Herbert
@yfeldblum - 通过按值传递语义我会获得哪些好处?如果是因为原始对象不会被修改 - 我只需要创建一个不可变类就能达到同样的效果吗? - BornToCode
1
@BornToCode - 它不需要分配内存或者解引用就可以使用。它具有高速缓存局部性。使用SSE可以优化副本(memcpy可能会自动优化为使用SSE)。 - yfeldblum
显示剩余4条评论

14

我喜欢用一个思维实验:

只调用空构造函数时,这个对象是否有意义?

根据Richard E的请求编辑

使用struct的好处是将基本类型封装并将其范围限定在有效的范围内。

例如,概率的有效范围为0-1。到处使用小数表示它容易出错,并需要在每个使用点进行验证。

相反,您可以使用带有验证和其他有用操作的封装基本类型。这通过了思维实验,因为大多数基本类型都具有自然的0状态。

以下是使用struct表示概率的示例用法:

public struct Probability : IEquatable<Probability>, IComparable<Probability>
{
    public static bool operator ==(Probability x, Probability y)
    {
        return x.Equals(y);
    }

    public static bool operator !=(Probability x, Probability y)
    {
        return !(x == y);
    }

    public static bool operator >(Probability x, Probability y)
    {
        return x.CompareTo(y) > 0;
    }

    public static bool operator <(Probability x, Probability y)
    {
        return x.CompareTo(y) < 0;
    }

    public static Probability operator +(Probability x, Probability y)
    {
        return new Probability(x._value + y._value);
    }

    public static Probability operator -(Probability x, Probability y)
    {
        return new Probability(x._value - y._value);
    }

    private decimal _value;

    public Probability(decimal value) : this()
    {
        if(value < 0 || value > 1)
        {
            throw new ArgumentOutOfRangeException("value");
        }

        _value = value;
    }

    public override bool Equals(object obj)
    {
        return obj is Probability && Equals((Probability) obj);
    }

    public override int GetHashCode()
    {
        return _value.GetHashCode();
    }

    public override string ToString()
    {
        return (_value * 100).ToString() + "%";
    }

    public bool Equals(Probability other)
    {
        return other._value.Equals(_value);
    }

    public int CompareTo(Probability other)
    {
        return _value.CompareTo(other._value);
    }

    public decimal ToDouble()
    {
        return _value;
    }

    public decimal WeightOutcome(double outcome)
    {
        return _value * outcome;
    }
}

我不理解。您可以创建没有默认构造函数的对象。 - Garry Shutler
1
结构体始终具有默认构造函数,即使您没有定义它。因此,可以将结构体实例化为“空”实例(例如new Int32())。如果对象没有特定构造函数就没有意义,则应该将其定义为不可变类。 - Bryan Watts
2
但是,如果在类中声明了一个非默认构造函数,则默认构造函数将消失,您只能使用非默认构造函数。对于结构体来说,它的默认构造函数始终存在且无法删除。 - Bryan Watts
1
@Garry Shutler:在结构体中,您无法声明默认构造函数(因此无法设置其可见性)。没有任何方法可以阻止某人使用结构体的默认构造函数。 - Bryan Watts
如果您声明一个带参数的构造函数,类的默认构造函数将被删除。但是,我不确定这证明了结构体与类的讨论方向。 - Richard Ev
显示剩余4条评论

14
你如何选择将值对象(典型示例是地址)实现为不可变对象或结构体?
我认为你的选项有误。不可变对象和结构体并非相反,也不是唯一的选项。实际上,你有四个选项:
- 类
- 可变的 - 不可变的
- 结构体
- 可变的 - 不可变的
我认为在.NET中,默认选择应该是 可变类 表示 逻辑,而 不可变类 表示一个 实体。如果可能的话,我实际上倾向于选择不可变类来实现逻辑。结构体应该保留给模拟值语义的小型类型,例如自定义 Date 类型、类似实体的 Complex 数字类型。重点在于 小型,因为我们不希望复制大块数据,通过引用进行间接访问实际上很便宜(所以使用结构体并没有什么好处)。我倾向于始终将结构体设计为不可变的(目前无法想到任何一个例外情况)。由于这最符合内在值类型的语义,我认为这是一个好的规则要遵循。

1
“代表逻辑”是什么意思?你能举个例子吗?许多实体(如一个人)并不是由其属性定义的,那么为什么要主张它们应该是不可变的呢? - gkdm
1
@Avid:为什么int类型与其他类型不同?你可以将int i = 1; i = 3;看作是将1替换为数字3的实例。再想想int i = 1; i = new int();,它是改变了现有值还是用System.Int32的新实例替换了它?能够给变量赋值并不意味着类型是不可变或可变的。而是由于存在可变字段和/或修改器方法,而string和int都没有。此外,MSDN明确指出Int32是一个不可变类型。 - R. Martinho Fernandes
3
@AviD:这个讨论是无意义的。你把“值”和“符号”混淆了。在计算机科学中,它们有着非常明确定义的含义,微软在术语上的粗心大意令人遗憾。但事实是:一个变量是一个“符号”,除非在.NET中声明为constreadonly,否则一个变量总是可变的。而另一方面,“值”则完全不同。在.NET中,所有基本类型的值都是不可变的。你不能改变一个值,就此结束。你所能做的只是将新值分配给现有的符号。(续……) - Konrad Rudolph
1
@AviD:你想看到s += "foo"i += 1之间的区别,但实际上并不存在:在这两种情况下,你改变的都是变量(即符号),而不是对象。对于整数来说,由于它们是值类型,因此没有两个符号可以引用相同的对象(因此我们无法观察到不同的行为)。但从概念上讲,微软非常清楚地表明基本值类型(包括int)是不可变的。请注意,这与C++不同,在C++中,您可以改变基本类型(并且您可以看到这种差异)。 - Konrad Rudolph
1
@Konrad - 对,我是指int类型它们是一样的,因此int(和其他基本值类型)必须是不可变的。对于字符串来说,应该是不同的,因为它们是引用 - 但这就是为什么它们被认为是不可变的,即使底层机制非常不同。老实说,我甚至不记得我们有什么分歧,因为我们似乎达成了一致(如果从不同的角度看),尽管这可能始于我的某些误解...谢谢。 - AviD
显示剩余9条评论

6

因素:构造、内存需求、装箱。

通常,结构体的构造函数限制 - 不能有显式无参构造函数,不能使用base构造函数 - 决定是否应该使用结构体。例如,如果无参构造函数不应将成员初始化为默认值,则使用不可变对象。

如果您仍然可以选择两者之间,请根据内存需求进行决策。特别是对于小型项目,应该使用结构体,尤其是如果您预计会有许多实例。

当实例被装箱时(例如,为匿名函数捕获或存储在非泛型容器中),这种好处就会丧失 - 您甚至开始为装箱支付额外费用。


什么是“小”,什么是“多”?

对象的开销是(如果我没记错)32位系统上的8个字节。请注意,对于几百个实例,这可能已经决定了一个内部循环是否完全在高速缓存中运行,或者调用GC。如果您预计有数万个实例,则这可能是运行与爬行之间的差异。

从这个角度来看,使用结构体并不是过早优化。


因此,以下是一些经验法则:

如果大多数实例将被装箱,请使用不可变对象。
否则,对于小型对象,仅在结构体构造会导致笨拙的接口您预计不会超过数千个实例时,请使用不可变对象。


3

我实际上不建议使用.NET结构体来实现值对象,有两个原因:

  • 结构体不支持继承
  • ORM在将映射到结构体时处理不好

这里我详细介绍了这个主题:详解价值对象


2
一般情况下,我不建议使用结构体作为业务对象。虽然在堆栈上运行可能会获得一些性能提升,但这样做会在某些方面限制自己,并且默认构造函数在某些情况下可能会出现问题。
特别是当你的软件发布到公众时,这一点更加重要。
对于简单类型,结构体很好用,这就是为什么你看到微软大多数数据类型都使用结构体的原因。同样地,对于适合在堆栈上使用的对象,结构体也很好用。其中提到的Point结构体就是一个很好的例子。
如何决定呢?通常我默认使用对象,如果它似乎可以从成为结构体中受益,那么我会仔细考虑并确定是否有意义。
你提到地址作为例子,让我们以类的形式来检查它。
public class Address
{
    public string AddressLine1 { get; set; }
    public string AddressLine2 { get; set; }
    public string City { get; set; }
    public string State { get; set; }
    public string PostalCode { get; set; }
}

将这个对象视为一个结构体。在考虑时,要考虑这个地址“结构体”内包含的类型,如果您以这种方式编码它。您是否发现有任何可能不按照您想要的方式工作的问题?考虑潜在的性能优势(例如,是否有性能优势)。


应用我在你的问题中提供的思想实验:如果一个地址的所有字段都为空,那么它是否有意义?在这种情况下,我会说不。 - Bryan Watts
实际上,对于数据对象,只有至少一个项目不为空时才创建对象,但我同意一个完全由空值组成的对象是无效的。我想表达的主要观点是:结构体不是编写地址代码的最佳方法。 - Gregory A Beamer

2
在今天的世界中(我想到的是C# 3.5),我认为结构体没有必要使用(编辑:除了某些特定场景外)。
支持使用结构体的论点似乎主要基于性能优势。我希望看到一些基准测试(复制真实场景),以说明这一点。
使用结构体来表示“轻量级”数据结构的概念对我来说过于主观。什么时候数据不再是轻量级?此外,在向使用结构体的代码添加功能时,您何时决定将该类型更改为类?
就个人而言,我无法回想起上次在C#中使用结构体的时间。

编辑

我认为出于性能原因在C#中使用结构体是明显的过早优化*

*除非应用程序已进行性能分析,并确定使用类会成为性能瓶颈

编辑2

MSDN声明:

结构体类型适合表示轻量级对象,如Point、Rectangle和Color。虽然可以将点表示为类,但在某些情况下,结构体更有效率。例如,如果声明一个包含1000个Point对象的数组,则会为引用每个对象分配额外的内存。在这种情况下,结构体的代价更小。除非需要引用类型语义,否则比16字节小的类可能更适合作为结构体由系统更高效地处理。

2
你最近使用过 Int32 或 DateTime 吗?这些都是使用结构体的很好的理由 :-) “类 vs 结构体”与“实体 vs 值”的概念相同,只是用语言术语表达。区别在于身份,而不是性能优势的感知。 - Bryan Watts
永远不要定义自己的结构体,这样你会过上更长久、更幸福的生活。除了互操作字段布局之外,始终使用类。 - Brian
Bryan - 我的观点有两个方面:1)一些受访者引用性能作为使用结构体的原因 2)其他人建议应该用于轻量级结构。我认为这两种观点都非常主观,需要澄清。 - Richard Ev
@Richard E:我指的是@Brian的评论。由于它很容易被误解,所以我决定将其删除。@Brian:虽然这样做可以使生活变得不那么复杂,但也会从你的工具箱中移除一个强大的工具。 - Bryan Watts
2
过早的优化是有害的,只有在你为此牺牲了其他方面,比如可读性、接口优雅度和开发时间时才是如此。 - peterchen
显示剩余2条评论

0
通常情况下,结构体的大小不应超过16个字节,否则在方法之间传递它可能比传递对象引用更加昂贵,因为对象引用只有4个字节(在32位机器上)。
另一个问题是默认构造函数。结构体总是具有默认(无参数和公共)构造函数,否则像以下语句就会出现问题:
T[] array = new T[10]; // array with 10 values

无法工作。

此外,对于结构体来说,覆盖 == != 运算符并实现 IEquatable<T>接口是一种礼貌的做法。


0

如果按值传递实例,复制的成本是多少。

如果成本高,则使用不可变引用(类)类型,否则使用值(结构体)类型。


我不确定类与结构体在感知性能方面的优势是否仍然相关。 - Richard Ev
考虑 System.String。如果只有几个字节,那么没问题,但是当跨越多个缓存行时,成本就开始累积了。 - Richard
那么一个结构体不应该包含字符串类型的属性吗? - Richard Ev
最好考虑/实现一个类似于字符串的类,该类是不可变的,但直接包含重要数据。 - Richard
PS. 微软的建议是值类型不应超过16个字节。 - Richard

0
从对象建模的角度来看,我很欣赏结构体,因为它们让我可以使用编译器将某些参数和字段声明为非空。当然,如果没有特殊的构造函数语义(例如在Spec#中),这仅适用于具有自然“零”值的类型。(因此Bryan Watt的“虽然实验”的答案。)

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