通过内存地址访问字段是否符合最佳实践?
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++ 代码在做两件不同的事情:
return正在 float&
。
- 注意
float&
不是 不是 指针或 "raw" 内存地址(在内部它可能由指针表示,但在 C++ 中是引用当你有一个好的编译器时,可以转换成一些非常时髦的本机指令逻辑。
- 在 C# 中,您可以 return 一个
float&
(在 C# 中实际上是 ref float
)到数组元素或字段。
假设结构的字段将使用与数组元素相同的填充按顺序排列。
- 这违反了 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];
}
}
}
错了那么多关卡:
- 将
&this.x
解释为指向按索引的字段数组的指针不会比简单地使用开关更快 - 这也更安全。
- 没有边界检查。
- 使用
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,使其成为有效的零成本语言功能。
我最近买了一本书,游戏引擎开发基础,第 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++ 代码在做两件不同的事情:
return正在
float&
。- 注意
float&
不是 不是 指针或 "raw" 内存地址(在内部它可能由指针表示,但在 C++ 中是引用当你有一个好的编译器时,可以转换成一些非常时髦的本机指令逻辑。 - 在 C# 中,您可以 return 一个
float&
(在 C# 中实际上是ref float
)到数组元素或字段。
- 注意
假设结构的字段将使用与数组元素相同的填充按顺序排列。
- 这违反了 C++ 语言规范:
- C++ 规范仅保证数组中的元素是连续的(并且
sizeof()
包括必要的填充)。此保证不会扩展到 struct/class 字段,即使它们是统一类型。
- C++ 规范仅保证数组中的元素是连续的(并且
- 在 C# 中,编译器会主动阻止您编写进行此假设的代码。
- 没有与 C++ 代码
(&x)[i]
表达式等效的真正实用的 C# - 因为它只是 错误。 - 这是错误的,因为
struct Vector3D
缺少#pragma
指令来控制结构包装。
- 这违反了 C++ 语言规范:
所以这个 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];
}
}
}
错了那么多关卡:
- 将
&this.x
解释为指向按索引的字段数组的指针不会比简单地使用开关更快 - 这也更安全。 - 没有边界检查。
- 使用
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,使其成为有效的零成本语言功能。