将十进制数复制到字节数组中,无需分配内存

6

梦想

我正在寻找一种将十进制值复制到字节数组缓冲区中,然后能够将这些字节读回十进制值而不需要任何堆分配的方法。理想情况下,这不需要使用不安全的上下文。

尝试

之前我在C#中使用过临时联合体来做一些疯狂的事情。这是一个非常酷的方式,可以随意以任何方式读取内存,但必须小心。您可能会陷入损坏状态,其中变量显式为byte[],但在调试器中查看的值为int[]。我甚至不知道这种事情是可能的!

注意:Marc在下面的评论中提出了一个非常重要的观点。由于字节序问题,您不能可靠地使用类似于重叠结构概念的方式将数字直接转换为字节。在这种情况下,您可以安全地使用int,因为十进制类型在内部使用了4个int。这是protobuf-net十进制序列化器的一个[示例]。

1: 带有十进制和字节数组字段的结构联合

第一次尝试使用结构联合概念,其中包含一个decimalbyte[]字段,两者都在0偏移处,因此它们占用完全相同的内存位置。然后,我可以写入一个字段并从另一个字段读取。

[StructLayout(LayoutKind.Explicit)]
private readonly struct DecimalByteConverter
{
    [FieldOffset(0)]
    public readonly decimal value;
    [FieldOffset(0)]
    public readonly byte[] bytes;

    public DecimalByteConverter(decimal value)
    {
        bytes = default;
        this.value = value;
    }
}

这个甚至无法运行,因为会抛出 -- 类型加载失败,具体如下:

System.TypeLoadException : Could not load type 'DecimalByteConverter' from assembly 'teloneum, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null' because it contains an object field at offset 0 that is incorrectly aligned or overlapped by a non-object field.

CLR 显然不喜欢它们重叠 - 但仅因为一个是值类型,而另一个是引用类型。

2:带有十进制和16字节的结构联合!

所以我决定去掉引用类型!现在,以下内容“可以工作”,或者至少它将会工作,但排序被搞乱了。我肯定可以得到一个大的十进制数,通过这里运行它,然后只需使我的字段偏移量与我的测试匹配即可,但这似乎是错误的 - 而且,在我尝试这样做的每个十进制数中至少有三个零字节 :) 也许我最后变得懒了... 毕竟很晚了。

[StructLayout(LayoutKind.Explicit)]
private readonly struct DecimalByteConverter
{
    [FieldOffset(0)] public readonly decimal value;

    [FieldOffset( 0)] public readonly byte byte_1;
    [FieldOffset( 1)] public readonly byte byte_2;
    [FieldOffset( 2)] public readonly byte byte_3;
    [FieldOffset( 3)] public readonly byte byte_4;
    [FieldOffset( 4)] public readonly byte byte_5;
    [FieldOffset( 5)] public readonly byte byte_6;
    [FieldOffset( 6)] public readonly byte byte_7;
    [FieldOffset( 7)] public readonly byte byte_8;
    [FieldOffset( 8)] public readonly byte byte_9;
    [FieldOffset( 9)] public readonly byte byte_10;
    [FieldOffset(10)] public readonly byte byte_11;
    [FieldOffset(11)] public readonly byte byte_12;
    [FieldOffset(12)] public readonly byte byte_13;
    [FieldOffset(13)] public readonly byte byte_14;
    [FieldOffset(14)] public readonly byte byte_15;
    [FieldOffset(15)] public readonly byte byte_16;

    public DecimalByteConverter(decimal value)
    {
        byte_1  = default;
        byte_2  = default;
        byte_3  = default;
        byte_4  = default;
        byte_5  = default;
        byte_6  = default;
        byte_7  = default;
        byte_8  = default;
        byte_9  = default;
        byte_10 = default;
        byte_11 = default;
        byte_12 = default;
        byte_13 = default;
        byte_14 = default;
        byte_15 = default;
        byte_16 = default;
        this.value = value;
    }

    public DecimalByteConverter(int startIndex, byte[] buffer)
    {
        value = default;
        byte_1 = buffer[startIndex++];
        byte_2 = buffer[startIndex++];
        byte_3 = buffer[startIndex++];
        byte_4 = buffer[startIndex++];
        byte_5 = buffer[startIndex++];
        byte_6 = buffer[startIndex++];
        byte_7 = buffer[startIndex++];
        byte_8 = buffer[startIndex++];
        byte_9 = buffer[startIndex++];
        byte_10 = buffer[startIndex++];
        byte_11 = buffer[startIndex++];
        byte_12 = buffer[startIndex++];
        byte_13 = buffer[startIndex++];
        byte_14 = buffer[startIndex++];
        byte_15 = buffer[startIndex++];
        byte_16 = buffer[startIndex];
    }


    public static void Copy(decimal value, int startIndex, byte[] buffer)
    {
        var convert = new DecimalByteConverter(value);

        buffer[startIndex++] = convert.byte_1;
        buffer[startIndex++] = convert.byte_2;
        buffer[startIndex++] = convert.byte_3;
        buffer[startIndex++] = convert.byte_4;
        buffer[startIndex++] = convert.byte_5;
        buffer[startIndex++] = convert.byte_6;
        buffer[startIndex++] = convert.byte_7;
        buffer[startIndex++] = convert.byte_8;
        buffer[startIndex++] = convert.byte_9;
        buffer[startIndex++] = convert.byte_10;
        buffer[startIndex++] = convert.byte_11;
        buffer[startIndex++] = convert.byte_12;
        buffer[startIndex++] = convert.byte_13;
        buffer[startIndex++] = convert.byte_14;
        buffer[startIndex++] = convert.byte_15;
        buffer[startIndex]   = convert.byte_16;
    }

    public static decimal Read(int startIndex, byte[] buffer)
    {
        var convert = new DecimalByteConverter(startIndex, buffer);

        return convert.value;
    }
}

3
这段话的含义是:字节数组肯定需要分配空间。你可能是想让这个方法将“decimal”内容传输到外部提供的缓冲区中,是吗?您是否需要使用 decimal.GetBits(Decimal, Span<Int32>) 方法来实现? - Klaus Gütter
1
@mhand:您提到了返回int[]decimal.GetBits方法,但 Klaus明确提到的是接受Span<int>的重载版本,这完全符合您的需求(如我的回答所示)。在忽略那些试图帮助您的建议之前,请仔细考虑。 - Jon Skeet
1
我强烈建议即使是一些临时的示例代码,也要遵循正常的C#和.NET命名约定 - 所有那些不寻常的下划线(以及以小写字母开头的类型名称)可能会非常分散注意力。 - Jon Skeet
1
你可能会对这篇文章感兴趣:玩转__makeref。请滚动到标题为“用例:重新解释变量”的部分。 - John Wu
1
顺便提一下,你现有的16字节方法实际上是非常危险的,因为它涉及到字节序。在我看来,你应该在这里使用4个重叠的int值,而不是16个byte值。在其他字节序的CPU上,工作方式会有很大不同。我在Jon的回答评论中链接了一个使用4个字段的示例。 - Marc Gravell
显示剩余5条评论
2个回答

5

在.NET 5.0之前,这种操作通常需要使用一些丑陋的hack方法。从.NET 5.0开始,有更多接受span的方法。

您可以使用GetBits(decimal d, Span<int>)方法,使用堆栈分配的span,然后将四个整数转换为现有的字节数组,例如使用BitConverter.TryWriteBytes

而另一方向上,则有一个Decimal(ReadOnlySpan<int>)构造函数,因此您可以stackalloc一个Span<int>,使用BitConverter.ToInt32(ReadOnlySpan<byte>)反复将该span从字节数组中填充,并将其传递给构造函数。

顺便提一下,您可能希望在代码库中广泛使用spans,而不是接受字节数组和起始索引。

以下是一些示例代码,它可以执行上述所有操作 - 可能有更高效的实现方式,但希望这能够表达出思路,并且这样做可以避免分配:

using System;

class Program
{
    public static void Copy(decimal value, int startIndex, byte[] buffer)
    {
        Span<int> int32s = stackalloc int[4];
        decimal.GetBits(value, int32s);

        var bufferSpan = buffer.AsSpan();
        for (int i = 0; i < 4; i++)
        {
            // These slices are bigger than we need, but this is the simplest approach.
            var slice = bufferSpan.Slice(startIndex + i * 4);
            if (!BitConverter.TryWriteBytes(slice, int32s[i]))
            {
                throw new ArgumentException("Not enough space in span");
            }
        }
    }

    public static decimal Read(int startIndex, byte[] buffer)
    {
        Span<int> int32s = stackalloc int[4];
        ReadOnlySpan<byte> bufferSpan = buffer.AsSpan();
        for (int i = 0; i < 4; i++)
        {
            var slice = bufferSpan.Slice(startIndex + i * 4);
            int32s[i] = BitConverter.ToInt32(slice);
        }
        return new decimal(int32s);
    }

    static void Main()
    {
        byte[] bytes = new byte[16];
        decimal original = 1234.567m;
        Copy(original, 0, bytes);
        decimal restored = Read(0, bytes);
        Console.WriteLine(restored);
    }
}

或者使用 MemoryMarshal 来实现相同的功能:
public static void Copy(decimal value, int startIndex, byte[] buffer)
    => decimal.GetBits(value, MemoryMarshal.Cast<byte, int>(buffer.AsSpan(startIndex)));

public static decimal Read(int startIndex, byte[] buffer)
    => new decimal(MemoryMarshal.Cast<byte, int>(buffer.AsSpan(startIndex)));

需要注意的是,这仅适用于 .NET 5.0。 - Klaus Gütter
2
@KlausGütter 我刚刚在查这个 - 看起来我终于可以开始退休这个 hackola 的 漫长 过程了:https://i.stack.imgur.com/fzKLM.png - Marc Gravell
1
为了OP的利益:如果您不在.NET 5上,请看看上面所有可疑荣耀的hackery:https://github.com/protobuf-net/protobuf-net/blob/main/src/protobuf-net.Core/Internal/PrimaryTypeProvider.Decimal.cs - 查看DecimalAccessor的所有用法。 - Marc Gravell
1
建议改进而不是使用偏移切片加位转换器:MemoryMarshal.Cast<byte, int>(和<int, byte>)- 更直接,可以让循环更简单; 需要注意的是:任何直接使用“byte”数据的方法(包括本答案中显示的代码以及通过MemoryMarshal.Cast)都可能需要考虑字节顺序。 - Marc Gravell
1
@MarcGravell:我能建议将其作为另一个答案添加吗?拥有多个选项从来不会有坏处。或者,如果它并没有非常不同,也许您可以将其作为部分替代编辑到此答案中?如果您直接这样做,它更有可能是正确的 :) - Jon Skeet
显示剩余2条评论

0
事实上,通过查阅MSDN示例代码,我发现了一种简单的方法来做到这一点,使用如下方式:
        int[] data = decimal.GetBits(h);
        bool sign = (data[3] & 0x80000000) != 0;
        byte scale = (byte)(((data[3] >> 16) & 0x7F) + (byte)(!sign ? 0x20 : 0x00));
        List<byte> bytes = new List<byte>() // For concatenation
        bytes.AddRange(BitConverter.GetBytes(data[0]));
        bytes.AddRange(BitConverter.GetBytes(data[1]));
        bytes.AddRange(BitConverter.GetBytes(data[2]));
        bytes.Add(scale);
        return bytes.ToArray();

然后回来:

        byte[] bytes = array.Take(13);
        byte scale = bytes[12];
        bool sign = true;
        if (scale >= 0x20)
        {
            sign = false;
            scale -= 0x20;
        }

        return new decimal(BitConverter.ToInt32(bytes,0), 
                           BitConverter.ToInt32(bytes,4), 
                           BitConverter.ToInt32(bytes,8), sign, scale);

我正在使用上面的代码来序列化/反序列化十进制数。由于MSDN文档指出在最后一个整数中有未使用的位(前16个和后3个),因此我将最后一个整数转换为“有符号比例”,以便我可以少分配一些字节。原始的十进制大小为16个字节长。

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