通过内存地址访问字段是否符合最佳实践?

Is it within best practices to access fields by their memory address?

我最近买了一本书,游戏引擎开发基础,第 1 卷:数学,里面所有的代码示例都是 C++。

在Vector3D的C++实现中,作者创建了这样一个索引器:

struct Vector3D
{
    float x, y, z;

    float& operator [](int i)
    {
        return ((&x)[i]);
    }
}

所以,C# 等价物是:

struct Vector3D
{
    float x, y, z;

    public float this[int index]
    {
        get
        {
            fixed (float* x = &X)
            {
                return x[index];
            }
        }
    }
}

这是允许的吗?在 C++ 中,这是未定义的行为,所以我想知道在 C# 中这样做是否不明智。这肯定是不安全的,但是,如果索引是 0、1 或 2,行为会一致吗?

这里是完整的source code

Is it within best practices to access fields by their memory address?

简而言之:没有。不,这样做不是最佳做法。没有性能优势,并且您失去了编译器强制的类型安全性。这样做可能会损害性能,因为编译器无法做出尽可能多的假设来优化编译后的程序。永远不要试图超越编译器。

In the C++ implementation of Vector3D, the author created an indexer like this:

顺便说一句,C++ 代码在做两件不同的事情:

  1. return正在 float&

    • 注意 float& 不是 不是 指针或 "raw" 内存地址(在内部它可能由指针表示,但在 C++ 中是引用当你有一个好的编译器时,可以转换成一些非常时髦的本机指令逻辑。
    • 在 C# 中,您可以 return 一个 float&(在 C# 中实际上是 ref float)到数组元素或字段。
  2. 假设结构的字段将使用与数组元素相同的填充按顺序排列。

    • 这违反了 C++ 语言规范:
      • C++ 规范仅保证数组中的元素是连续的(并且 sizeof() 包括必要的填充)。此保证不会扩展到 struct/class 字段,即使它们是统一类型。
    • 在 C# 中,编译器会主动阻止您编写进行此假设的代码。
    • 没有与 C++ 代码 (&x)[i] 表达式等效的真正实用的 C# - 因为它只是 错误
    • 这是错误的,因为 struct Vector3D 缺少 #pragma 指令来控制结构包装。

所以这个 C++:

class Foo
{
    int x[3];
}

是否保证具有与此完全相同的内存表示:

class Bar
{
    int x0;
    int x1;
    int x2;
}

此 QA 中对此进行了解释:Layout in memory of a struct. struct of arrays and array of structs in C/C++

(虽然在典型的 x86 计算机上它们很可能会 "just work" - 索引器也有可能读取和写入不对应的内存部分到数组元素 - 但是因为 C++ 在使用原始指针时没有自动运行时边界检查,你不会知道你的代码通过错误地覆盖内存来破坏你的进程内存,直到为时已晚(如果你 lucky,OS 或运行时内存分配器 可能 检测到某些错误并终止)。这些是可怕的 C++ 程序员使用的相同类型的假设在 32 位世界中,例如假设 sizeof(int*) == sizeof(int) - 当我们在 2000 年代中期迁移到 x64 时我们都玩得很开心)

现在,尽管没有 unsafe 修饰符,C# 确实 允许其用户使用 [FieldOffset] 属性(这是在 C# 中定义 union 以与本机 API 兼容的方式:通过使用重叠偏移量)-但这会带来性能成本(有点违反直觉:更小且高效的打包结构是由于本机单词对齐问题,处理速度较慢。

unsafe 上下文中,我 相信 等同于 C++ 的 C# 应该是这样的(我可能把索引器弄错了,实际上已经有 5-6 年了因为我上次不得不使用 unsafe C# 代码):

struct Vector3D
{
    [FieldOffset( sizeof(float) * 0 )]
    private float x;

    [FieldOffset( sizeof(float) * 1 )]
    private float y;

    [FieldOffset( sizeof(float) * 2 )]
    private float z;

    public unsafe float* this[Int32 i]
    {
        get
        {
            float* x0 = &this.x;
            return &x0[i];
        }
    }
}

错了那么多关卡:

  1. &this.x 解释为指向按索引的字段数组的指针不会比简单地使用开关更快 - 这也更安全。
  2. 没有边界检查。
  3. 使用 FieldOffset 和结构包装实际上会使程序 变慢 因为值不会在 CPU 字边界上对齐。
    • 今天的机器是 64 位的,当值是字长对齐时,x86/x64 处理器明显更快This QA reports operations are at least twice as fast when they're aligned.

如果您想在 C# 中使用快速且安全的 float 3-vector,请执行以下操作:

struct Vector3
{
    public float x;
    public float y;
    public float z;

    public ref float this[Int32 i]
    {
        get
        {
            switch( i )
            {
            case 0: return ref this.x;
            case 1: return ref this.y;
            case 2: return ref this.z;
            default: throw new ArgumentOutOfRangeException( nameof(i) );
            }
        }
    }
}

你也可以在 C++ 中做同样的事情,并且仍然很可能会比原来的 Vector3D class 获得更好的性能,因为与操作原始相比,编译器可以更好地优化直接命名字段访问通过字段偏移指针存储。

更新

回应 OP 的评论回复中的问题:

Would it make sense to use a fixed buffer as a backing field for X, Y and Z? That would eliminate the need for a switch statement. Also, on the topic of mutable structs, with the new-ish feature of readonly structs, is there really any time when a struct should not be readonly?

没有理由为此用例使用缓冲区(fixed 或其他)。因为这是一个 Vector3 永远只有 3 个元素,所以它应该只使用字段(如果你使用堆分配数组那么你会失去 Locality of Reference 好处。

总而言之:原始方法没有任何优势并且有很多缺点(例如缺少边界检查,这对内存安全非常非常重要).请注意,整数 switch 直接在机器代码中编译为非常快速的本机跳转-table,使其成为有效的零成本语言功能。