为什么固定大小的缓冲区(数组)必须声明为不安全?

32

假设我想要一个值类型为7字节(或3或777)。

我可以这样定义:

public struct Buffer71
{
    public byte b0;
    public byte b1;
    public byte b2;
    public byte b3;
    public byte b4;
    public byte b5;
    public byte b6;
}

更简单的定义方法是使用固定缓冲区

public struct Buffer72
{
    public unsafe fixed byte bs[7];
}
当然,第二个定义更简单。问题在于必须为固定缓冲区提供不安全的关键字。我理解这是使用指针实现的,因此是不安全的。我的问题是为什么它必须是不安全的?为什么C#不能提供任意长度的常量数组,并将其保留为值类型,而不是将其作为C#引用类型数组或不安全缓冲区?
3个回答

12

因为“固定缓冲区”不是真正的数组。它是一种自定义值类型,这是我所知道的在C#语言中生成一个的唯一方法。CLR无法验证数组的索引是否以安全的方式完成,代码也不可验证。最生动的证明:

using System;

class Program {
    static unsafe void Main(string[] args) {
        var buf = new Buffer72();
        Console.WriteLine(buf.bs[8]);
        Console.ReadLine();
    }
}
public struct Buffer72 {
    public unsafe fixed byte bs[7];
}

在这个例子中,您可以任意访问堆栈帧。恶意代码可利用标准缓冲区溢出注入技术,以篡改函数返回地址并强制您的代码跳转到任意位置。

是的,这非常不安全。


17
问题是否仅仅在于CIL没有执行有界索引操作的手段?我看不出CIL为什么不能提供这样的功能。像图形转换之类的东西可能会超过结构体理想的16字节大小,但它们应该具有逻辑上可变的值语义。不可变的语义使得在实例内部调整值变得困难,而可变的引用语义会引入模糊性,例如一个返回实例的函数何时会返回一个新实例或现有实例。 - supercat
这并不简单,它涉及到与并发安全保证有关的许多问题。 - user1496062
9
没有一种安全的方式可以将固定大小的数组嵌入结构体中,这太疯狂了。在代码的高性能部分,我想要使用几乎只有100%可平铺结构体。至少现在我们有了ref returns和ref locals。 - JBeurer
1
@JBeurer 我完全同意。他们选择以不安全的方式实现这一点似乎很愚蠢。在存储和访问元素FixedInts[8]和字段FixedInt8之间基本上没有区别,而且由于大小是固定的,编译器和CLR肯定可以访问所有所需的信息,如果.NET团队决定正确利用它的话,就可以使其变得更加安全。我也很震惊我们不能创建结构体的固定缓冲区。有时候,不将所有内容分散到堆中比理论设计理念更重要。我宁愿不被迫使用C++。 - Daniel
1
嗨,我经常因设计疏漏而受到微软员工的责备。不要攻击信使,把枪瞄准其他地方,也不要告诉我这件事。它不是一个真正的数组,在运行时没有合适的方法来发现固定大小缓冲区的大小。没有Length属性,最好使用反射来实现索引检查。但这太慢了。 - Hans Passant
显示剩余2条评论

1

对于那些现在找到这篇文章的人,需要注意以下内容。

从C#10开始,你可以直接在结构体上定义和赋值字段。结合 readonly 修饰符,这使得你可以(几乎)保证一个不可变的引用指向一个可变的引用类型(或可变的 struct 类型),包括数组。

public struct Test
{
    public readonly int[] fixedSizeArray = new int [5];
    /// <summary>I'm important, don't forget me.</summary>
    public Test() {}
}

唯一需要注意的是:

就像所有的readonly字段一样,我认为这可以在构造函数中随意修改(只要你小心不要在构造函数中重新分配this,因为我认为结构体不能从中派生出来,这应该不是太大的问题)。
为了正确使用它,我认为您必须明确定义无参数构造函数,否则对于所有使用隐式公共无参数构造函数的实例,所有字段都将设置为其默认未初始化值(例如,int i;将为0,任何引用类型 - 如数组 - 将为null),忽略显式初始化程序(由于某种原因,我不太确定)。话虽如此,声明公共无参数构造函数(也在C#10中引入)并将其保留为空应该会产生所需的行为(至少如果我正确阅读docsfeature proposal的话)。因此,对于我们之前的示例,它必须具有那个(看起来)无用的无参数构造函数。这样做是行不通的:
// THIS WON'T WORK
public struct Test
{
    public readonly int[] fixedSizeArray = new int[5];
}
  • 使用default operator将始终跳过任何和所有构造函数,因此人们理论上可以创建一个结构体实例而不需要正确的大小。话虽如此,该字段仍然是readonly,所以我非常确定没有人能够快速地使用它并将其用于错误,因为他们将被困在一个null数组中。话虽如此,这仍然值得注意作为一个边缘情况。
  • 对于缓冲区,您可以将其与Memory结合使用,并且可以得到相当接近的效果。如果您不想要不安全的代码但想要类似于C++的缓冲区,则这很可能是您最好的选择。


    1
    我在回应你的评论,指引我来到这里。是的,我知道这是可能的,但我有困难理解为什么要使用它。我只能代表自己说,每当我发现自己查找像这样的问题时,通常是因为我有与垃圾收集、内存布局、复制语义和/或性能等特定需求相关的需求。如果堆分配和引用语义满足我的需求,我认为我会节省一些麻烦并使用一个类。你认为这种方法的好处是什么? - Daniel
    1
    @Daniel 在这种情况下,我同意这样的解决方案并不总是非常有效。我误解了你的限制。我不确定你的情况是否足够严格,以至于可以将这些选项排除在可行选项之外。就我而言,我是在寻找强制对数组施加长度限制的方法(特别是在结构体中,而不仅仅是针对“类似数组”的固定缓冲区),并在偶然发现对结构体进行更改后,使readonly成为可能,我想回来分享这些情况。我发现这主要用于设计,而不是性能。 - J Mor
    1
    你能帮我理解为什么你会使用结构体而不是类吗?我并不怀疑你的用法,我只是想理解它,以便我在自己的代码中更容易地识别机会。 - Daniel
    @Daniel 如果值语义>引用语义且初始化逻辑不需要,则使用它。例如,一个可调整的校正算法需要(类/结构)“状态”:x“priorErrors”(float[x])和5个以上字段。为了进行调整和测试,我们需要状态“history”(State[200])。使用结构体:1.无需初始化历史元素2.可以对State进行度量+/-/平均等操作而不会弄脏数据或克隆等。当类型适合于结构体(小型、数据中心、行为少)但需要特定大小的数组时(停止OutOfRange),这可以做到没有缓冲区的权衡(类型损失)或使用op的示例+索引器。 - J Mor
    结构体与类有很多不同之处,这些差异可能非常有用(如可平坦性、值语义、自动初始化、更多编译器优化选项、更能够通过 ref readonly/readonly 结构控制和信号行为),而这种解决方案可以提供除了可平坦性之外的所有功能,而无需采用过于奇特/极端的解决方案(例如通过单个字段定义数组(并可能添加索引器以便在结构体外部进行阅读/方便)或采用不安全的、非 Array 类型的选项)。目前,在特定情况下获得所有这些功能而没有任何权衡是不可能的。 - J Mor

    0

    就目前的写法来看,你的回答不够清晰。请编辑以添加更多细节,以帮助他人理解这如何回答所提出的问题。你可以在帮助中心找到关于如何撰写好答案的更多信息。 - undefined

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