公共字段是否适用?

22
在你冲动地回答之前,请先仔细阅读整个问题。我知道公共字段让你感觉很糟糕,我们都曾经被烧过,也知道这不是“好的风格”,但是,公共字段真的可以吗?
我正在开发一个相当大规模的工程应用程序,它创建并处理结构的内存模型(从高层建筑到桥梁再到小屋子,什么都有)。该项目需要进行大量的几何分析和计算。为了支持此功能,该模型由许多微小的不可变只读结构组成,用于表示诸如点、线段等内容。这些结构中的某些值(例如点的坐标)在典型的程序执行期间会被访问数十万次甚至数百万次。由于模型的复杂性和计算量的巨大,性能非常关键。
我认为我们正在尽力优化算法、性能测试以确定瓶颈、使用正确的数据结构等等。我不认为这是过早优化的情况。性能测试表明,直接访问字段而不是通过对象的属性访问可以显著提高性能(至少一个数量级)。鉴于这些信息,以及我们还可以将相同的信息公开为属性来支持数据绑定和其他情况...这样做会有问题吗?请记住,这是不可变结构上的只读字段。有人能想到我将来会后悔吗?
下面是一个示例测试应用程序:
struct Point {
    public Point(double x, double y, double z) {
        _x = x;
        _y = y;
        _z = z;
    }

    public readonly double _x;
    public readonly double _y;
    public readonly double _z;

    public double X { get { return _x; } }
    public double Y { get { return _y; } }
    public double Z { get { return _z; } }
}

class Program {
    static void Main(string[] args) {
        const int loopCount = 10000000;

        var point = new Point(12.0, 123.5, 0.123);

        var sw = new Stopwatch();
        double x, y, z;
        double calculatedValue;
        sw.Start();
        for (int i = 0; i < loopCount; i++) {
            x = point._x;
            y = point._y;
            z = point._z;
            calculatedValue = point._x * point._y / point._z;
        }
        sw.Stop();
        double fieldTime = sw.ElapsedMilliseconds;
        Console.WriteLine("Direct field access: " + fieldTime);

        sw.Reset();
        sw.Start();
        for (int i = 0; i < loopCount; i++) {
            x = point.X;
            y = point.Y;
            z = point.Z;
            calculatedValue = point.X * point.Y / point.Z;
        }
        sw.Stop();
        double propertyTime = sw.ElapsedMilliseconds;
        Console.WriteLine("Property access: " + propertyTime);

        double totalDiff = propertyTime - fieldTime;
        Console.WriteLine("Total difference: " + totalDiff);
        double averageDiff = totalDiff / loopCount;
        Console.WriteLine("Average difference: " + averageDiff);

        Console.ReadLine();
    }
}

结果:
直接字段访问: 3262
属性访问: 24248
总差异: 20986
平均差异: 0.00020986


只需要21秒,何乐而不为呢?


1
我看不出这种情况有什么害处。 - quillbreaker
7
性能测试是在发布版本上完成的吗? - erikkallen
2
发布构建结果: 直接字段访问:112 属性访问:499 总差异:387 平均差异:3.87E-05非常好的观点,有很大的差异(尽管使用字段仍然更高效)。 - MKing
1
尝试使用发布版本并直接从exe文件运行,而不是通过调试器运行。请参见我的答案。 - Brian Gideon
可能是重复的问题:为什么公共字段比属性更快? - nawfal
显示剩余6条评论
12个回答

31

你的测试并没有很好地考虑到基于属性的版本。JIT编译器足够聪明,可以内联简单的属性,使其在运行时性能等同于直接访问字段,但它似乎(目前)无法检测属性访问常量值。

在你的示例中,字段访问版本的整个循环体被优化掉,变成了:

for (int i = 0; i < loopCount; i++)
00000025  xor         eax,eax 
00000027  inc         eax  
00000028  cmp         eax,989680h 
0000002d  jl          00000027 
}

与第二个版本相比,实际上每次迭代都会执行浮点除法操作:

for (int i = 0; i < loopCount; i++)
00000094  xor         eax,eax 
00000096  fld         dword ptr ds:[01300210h] 
0000009c  fdiv        qword ptr ds:[01300218h] 
000000a2  fstp        st(0) 
000000a4  inc         eax  
000000a5  cmp         eax,989680h 
000000aa  jl          00000096 
}

只需对应用程序进行两个小更改,使其更具现实性,就可以使这两个操作在性能上几乎相同。

首先,随机输入值,使它们不是常数,JIT无法完全删除除法。

从以下内容进行更改:

Point point = new Point(12.0, 123.5, 0.123);

目标:

Random r = new Random();
Point point = new Point(r.NextDouble(), r.NextDouble(), r.NextDouble());

其次,请确保每次循环迭代的结果都被使用:

在每次循环之前,将calculatedValue设置为0,以便它们从同一点开始。在每个循环调用后,调用Console.WriteLine(calculatedValue.ToString()) 确认结果被“使用”,这样编译器就不会对其进行优化。最后,将循环体从"calculatedValue = ..." 更改为 "calculatedValue += ..." ,以便每次迭代都被使用。

在我的计算机上,通过这些更改(使用发布版本)可以得到以下结果:

Direct field access: 133
Property access: 133
Total difference: 0
Average difference: 0

正如我们所预期的那样,每个修改后的循环的x86代码都是相同的(除了循环地址)

000000dd  xor         eax,eax 
000000df  fld         qword ptr [esp+20h] 
000000e3  fmul        qword ptr [esp+28h] 
000000e7  fdiv        qword ptr [esp+30h] 
000000eb  fstp        st(0) 
000000ed  inc         eax  
000000ee  cmp         eax,989680h 
000000f3  jl          000000DF (This loop address is the only difference) 

非常好的回答。我知道差别不应该那么大。 - Meta-Knight
谢谢您如此详尽的回复!您说得对,这更接近于我的实际应用程序中会发生的情况。 - MKing
8
只需在调试器之外运行已优化的发布版本(如果您使用调试器附加到托管进程,则会更改JIT行为)。然后,在等待Console.ReadLine结束时,将VS调试器附加到该进程(工具->附加到进程)。然后,在调用堆栈窗口中的正确方法中右键单击一个堆栈帧,并选择“转到反汇编”。 - StarPacker
8
很棒的答案。这展示了在衡量性能时一个关键因素:始终要测量真实世界中的性能。除非你是建立基准测试的专家,否则你会编写一个测量毫无意义的东西(像这个基准测试一样)。 - Eric Lippert

21

鉴于你处理的是具有只读字段的不可变对象,我认为你遇到了我不认为公共字段是一种糟糕习惯的唯一情况。


11
同意,但最好只有在有可证明的绩效要求时才这样做。 - Marc Gravell

10

在我看来,“不使用公共字段”这条规则是那些在技术上是正确的规则之一,但是除非你要设计一个专门为公众使用的库,否则如果你打破了这个规则,它不太可能给你带来任何问题。

在我被大量踩之前,我应该补充说封装是有好处的。考虑到不变式“如果HasValue为false,Value属性必须为null”,这种设计是有缺陷的:

class A {
    public bool HasValue;
    public object Value;
}

然而,鉴于不变量,这个设计同样存在缺陷:

class A {
    public bool HasValue { get; set; }
    public object Value { get; set; }
}

正确的设计是:

class A {
    public bool HasValue { get; private set; }
    public object Value { get; private set; }

    public void SetValue(bool hasValue, object value) {
        if (!hasValue && value != null)
            throw new ArgumentException();
        this.HasValue = hasValue;
        this.Value    = value;
    }
}

(甚至更好的方法是提供一个初始化构造函数并使类成为不可变的。)


你的整篇文章让我一直犹豫是否要点赞,但是你提到的不可变性建议让我决定给你点赞了。 - Imagist

3
我知道你做这件事感觉有点不舒服,但当性能成为问题时,规则和指南被抛到一边并不罕见。例如,许多使用MySQL的高流量网站具有数据重复和非规范化表。其他网站变得更疯狂
故事的寓意是 - 这可能违反了你所学或建议的一切,但基准测试不会撒谎。如果效果更好,就这样做。

我想要加上一个警告:“如果它的表现更好,并且你确实需要更好的性能”。 - LukeH
@Luke 很多人只是假设你需要性能。我想再加一个警告,即在出现问题之前,您不需要性能。 - Imagist

3

1
个人而言,我只会在非常特定的实现私有嵌套类中考虑使用公共字段。
其他时候,这样做感觉太“不对”了。
CLR 会通过优化方法/属性(在发布版本中)来处理性能问题,所以这不应该是一个问题。

正确:私有实现细节。即使在这种情况下,我只会在存在紧迫的性能或时间压力原因时才这样做。+1 - peSHIr

1

尝试编译一个发布版本并直接从exe文件中运行,而不是通过调试器运行。如果应用程序是通过调试器运行的,则JIT编译器将不会内联属性访问器。我无法复制您的结果。事实上,我运行的每个测试都表明执行时间几乎没有任何差异。

但是,像其他人一样,我并不完全反对直接字段访问。特别是因为很容易将字段设置为私有,并在以后添加公共属性访问器,而无需进行任何其他代码修改即可使应用程序编译。

编辑:好吧,我的初始测试使用了int数据类型而不是double。当使用double时,我看到了巨大的差异。对于int,直接访问与属性访问几乎相同。对于double,属性访问比直接访问慢约7倍。这对我来说有些令人困惑。

此外,重要的是在调试器之外运行测试。即使在发布版本中,调试器也会增加开销,从而扭曲结果。


很好的观点,这是我现在得到的结果:发布版(通过调试器): 直接访问字段:112 属性访问:494 总差异:382 平均差异:3.82E-05发布版(直接): 直接访问字段:4 属性访问:115 总差异:111 平均差异:1.11E-05当然不是没有区别,但可以说是可以忽略不计的。 - MKing
有趣。我承认我制作了自己的测试,并没有测试你的确切代码,但我很惊讶你看到了那么大的差异。我看到的差异总是小于1%。如果我有时间,我会使用你的确切代码。 - Brian Gideon
好的,我使用了您的代码并得到了一些令人惊讶的结果。我编辑了我的答案以反映这一点。看起来双精度返回类型没有被内联?很奇怪。 - Brian Gideon
你不能将一个字段改成属性,重新编译然后期望任何引用都能正常工作。字段指针和属性指针的行为不同。尝试使用一个在控制台应用程序中引用了字段的外部库。现在将该字段更改为属性,只重新编译库,运行应用程序,那么引用将会失败。 - Brett Ryan
@Brett,是的,那是因为你在技术上改变了公共接口。同时,你还会遇到有关 ref 参数的问题。使用反射的代码可能会失败。属性上附加的属性可能不被允许。我可以继续说下去。但是,总体而言,这种更改还是相当容易的。 - Brian Gideon

1

并不是说我不同意其他答案或你的结论……但我想知道你从哪里得到了性能差异数量级的信息。根据我的理解,任何一个简单的属性(除了对字段的直接访问之外没有其他代码)都应该被JIT编译器作为直接访问进行内联。

即使是在这些简单的情况下,使用属性的优点(在大多数情况下)是通过将其编写为属性,您可以允许未来可能修改该属性的更改。(尽管在您的情况下当然不会有这样的更改)


数量级统计来自测试,参见最初的问题:结果: 直接字段访问:3262 属性访问:24248在这种情况下,更像是慢了7.4倍(而不是10倍),但其他测试产生不同的结果。 - MKing

0
如果您修改测试以使用分配的临时变量而不是直接访问计算中的属性,则会看到大幅度的性能提升:
        sw.Start();
        for (int i = 0; i < loopCount; i++)
        {
            x = point._x;
            y = point._y;
            z = point._z;
            calculatedValue = x * y / z;
        }
        sw.Stop();
        double fieldTime = sw.ElapsedMilliseconds;
        Console.WriteLine("Direct field access: " + fieldTime);

        sw.Reset();
        sw.Start();
        for (int i = 0; i < loopCount; i++)
        {
            x = point.X;
            y = point.Y;
            z = point.Z;
            calculatedValue = x * y / z;
        }
        sw.Stop();

0
以下是一些情况,其中使用常量字段是可以的(来自《框架设计准则》书籍):
  • 使用常量字段来表示永远不会更改的常量。
  • 使用公共静态只读字段来表示预定义对象实例。
而以下情况则不行:
  • 不要将可变类型的实例分配给只读字段。
根据您所述,我不明白为什么 JIT 不会内联您的微不足道的属性?

最后一条纯属胡说八道。通过对可变对象的不可变引用来封装状态,比通过对可变对象的可变引用来封装状态更容易推理。在前一种情况下,只要后者存在,对可变对象的更改将反映在封装对象中。在后一种情况下,可变对象可能会变得“分离”,以至于对其进行的进一步更改将不再反映在封装对象中。有时需要对可变对象使用可变引用,... - supercat
就像 List<T> 和它的后备数组一样,但是知道某些对象是否会在它们可用的生命周期内被附加,通常是关键信息,特别是在诸如并行编程之类的事情中。 - supercat
通过该指南展示的示例程序,从数组后备字段中删除readonly并不会使其更清晰。相反,假设删除了该标记,并且假设没有所有BadDesign的代码,但是确实有显示的部分。如果对象Foo执行int[] Arr1 = bad.Data,并且在一段时间后(在留下Arr1bad引用相同对象的各种其他操作之后)执行bad.Data [0] ++;,那么后者操作是否会影响Arr1 [0]?如果没有在data上声明readonly,你能说出来吗? - supercat
尽管说,一个数组类型变量的“readonly”声明的行为可能对于不了解 .net 的人来说有些不直观(在 vb.net 中比在 C# 中更清晰,因为它更明确 vb.net 的“ReadOnly”声明会影响字段,而不是数组类型),但在这方面它不比属性声明更糟糕。返回数组引用的属性很丑陋,但将其变为读写属性会使情况变得更糟,因为即使是了解 .net 的人也不知道代码是否应该就地更新数组,还是应该创建一个新数组... - supercat
将其引用存储回原始对象。我真的希望在最初的设计中,.net包含了一个不可变数组类型,并包括一个抽象的ReadableArray基类型,从中派生出可变和不可变数组。索引数组比索引任何其他类型的集合更有效率,而CLR支持的不可变数组可以保持这种效率。 - supercat

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