避免分配但不允许值类型的默认零值

Avoiding allocations but without allowing default zero values for value types

C# 中的值类型不能有无参数构造函数,因为在创建不带参数的实例时 CLR 的默认行为是将所有位清零。

假设我有一个用例,我希望有一个类型在基础值上强制执行一些不变量,而这些不变量不允许默认的全零状态。这迫使我使用引用类型。现在假设我赚了很多,我的意思是很多这种类型的实例。性能至关重要,这些分配加起来会给 GC 带来很大压力。我非常想避免这些分配,首先想到的是使用值类型。但是,唉,我不能,因为默认值是无效的。

假设该类型满足您希望从值类型中获得的所有其他条件,即它确实表示单个值,具有值语义,最多占用 16 位。唯一的问题是全零值是这种类型的无效状态。

我将如何实现我的性能目标,这些目标在概述的场景中受到 GC 压力的可验证约束,同时又不牺牲我的类型约定的不变量?

编辑:

一个非常简单的例子,假设我持有一个 64 位的序列以及第一个非零位的索引。

public <struct/class> BitSequence64
{
    private long _bits;
    private int _firstNonZero;

    public IEnumerable<byte> Bytes => ...

    // A bunch of helper properties.

    public BitSequence64(long value)
    {
        // Set the _firstNonZero, etc.
        ...
    }

    // Methods that allow you to twiddle the bits but maintaining 
    // the invariant of always having at least one non-zero.
}

所以显然将 _bits 设置为全零是没有意义的,特别是因为 _firstNonZero 然后会指向第一位,这不是非零。有很多这些单独的序列,我非常希望这种类型的依赖者能够安全地使用它,而无需在每次传递给面向 [=30] 的 public 时验证它不是 default 值=].

我成功使用的一项技术是改变内部状态的解释方式,以便当零无效或non-zero 需要默认值。

具体如何实现取决于类型的内部状态。一般来说,至少有一个成员字段会存储一个与消费者看到的不一样的值。相反,类型的 public 接口将解释进入和离开类型的差异。

了解内部状态中类型的自然属性(数学或其他)可以帮助您确定如何做到这一点。

首先要做的是 select 类型的 合理默认值 。很明显,自然的default(T)已经被判定为不合理了,所以还需要select编一些别的东西。例如,这可能是有效范围内的最小值。不过,无论它是什么,它都会告知输入在存储之前需要如何调整,以及内部值在 returning 之前需要如何调整(通过逆运算)。

入门示例

此技术的一个非常人为和基本的示例是以下 Year 包装器类型。

Caution: DO NOT use this example as-is; it's for demonstration purposes only.

public readonly struct Year
{
    private const int Delta = 2000;

    private readonly int _value;

    public Year(int value)
    {
        _value = value - Delta;
    }

    public int Value => _value + Delta;
}

这里默认是Delta常量,用来调整内部状态。在 default(Year) 中,_value 将是 0,但 Value 属性 将是 return 2000。类似地,new Year(2000) 将在输入时将 2000 转换为 0,并在输出时转换回 2000。另一种思考方式是 _value 表示与默认值的偏移量。

当您围绕这个内部表示构建功能时,重要的是要记住只有构造函数和 Value 属性 应该访问支持字段。其他一切,甚至是私有成员,都应该使用 Value 属性 来确保一致性。同样,创建新实例应该使用构造函数并传递 consumer-facing 值。在其他任何地方使用支持字段会引起潜在的错误,因此最好避免这样做。单元测试对于确保一致性至关重要。

题型

BitSequence64 的情况由于那个特定的不变量而有点诡异。在评论中,它看起来像默认值 1 —— 只设置了位 0 —— 可能是该类型的合理默认值。从现在开始,我会假设它是。

这可以通过将实际值与 1 进行异或来实现。这非常好,因为与 1 的异或运算是它自己的逆运算。

现在,default(BitSequence64) 是有效的,因为它代表的值与同样有效的 new BitSequence64(1L) 所代表的值相同。

public struct BitSequence64
{
    private const long DefaultBit = 1L;
    private long _value;
    private int _firstNonZero;

    public BitSequence64(long value)
    {
        if (value == 0)
            throw new ArgumentException("At least one bit must be set.", nameof(value));

        _value = value ^ DefaultBit;
        _firstNonZero = GetFirstNonZero(_value);
    }

    public long Value => _value ^ DefaultBit;

    public int FirstNonZero => _firstNonZero;

    // Note that this property uses the post-adjustment, consumer-facing value.
    public IEnumerable<byte> Bytes => BitConverter.GetBytes(Value);

    private static int GetFirstNonZero(long value)
    {
        // TODO: Incorporate your implementation here.
        throw new NotImplementedException();
    }

    // And, of course, let's not forget the members that do bit-twiddling
    // while maintaining the invariants.
    // ...
}

默认值 1 很方便,但是如果您需要默认值 0x8000_0000_0000_0000(MSB 设置)怎么办? _firstNonZero 默认为零时将不再正确。

在内部状态下很容易解释这一点。我们可以重新定义 _firstNonZero 为距第 31 位“向下”的距离,而不是距第 0 位“向上”的距离。除了通过最高有效位对值进行异或运算之外,修改构造函数和 FirstNonZero 属性 以对 _firstNonZero.

执行转换