【问题标题】:Avoiding allocations but without allowing default zero values for value types避免分配但不允许值类型的默认零值
【发布时间】:2020-12-02 13:52:47
【问题描述】:

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

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

假设该类型满足您对值类型的所有其他要求,即它确实表示单个值,具有值语义,最多占用 16 位。唯一的问题是全零值对于这种类型是无效状态。

如何在不牺牲我的类型约定的不变量的情况下实现我的性能目标?

编辑:

一个非常简单的例子,假设我持有一个 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 将指向第一位,这不是非零。有很多这样的单独序列,我非常希望这种类型的依赖者能够安全地使用它,而无需在每次传递给面向公众的 API 时验证它不是 default 值。

【问题讨论】:

  • 能否提供一个示例案例(值类型版本)?对于某些类型,对我有用的方法是存储调整后的值,以便字段中的零对于消费者来说实际上不是零值。不过,这是否适用于您的情况取决于您在做什么。
  • 您是这些类型的唯一用户,您是为其他团队或客户创建它们吗?
  • 你不能用有效的默认值初始化它们吗?
  • 例如,假设_bits == 0x1L 是一个合理的(未调整的)默认值。如果您要使用_bits ^ 0x1L 进行调整,消费者会将_bits == 0x0L 视为第一位非零,而_firstNonZero 将是正确的。
  • 更通用的解决方案?不,没有什么我能想到的。这实际上取决于给定类型的构成以及您认为该类型的合理默认值。也许这就是为什么这对你来说如此 hacky。我不这么看。我看到了完全适合该领域的领域对象的潜力。

标签: c# performance struct default-constructor invariants


【解决方案1】:

我成功使用的一项技术是更改内部状态的解释方式,以便在零无效或需要非零默认值时,值类型 Tdefault(T) 有效.

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

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

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

入门示例

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

注意:请勿按原样使用此示例;它仅用于演示目的。

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 属性将返回2000。同样,new Year(2000) 将在输入时将 2000 转换为 0,然后在输出时转换回 2000。另一种思考方式是_value 表示与默认值的偏移量。

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

问题类型

BitSequence64 的情况有点棘手,因为它具有特定的不变量。在 cmets 中,它看起来像是默认值 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 的默认值很方便,但是如果您需要默认值0x1000_0000_0000_0000(MSB 设置)怎么办? _firstNonZero 在默认为零时将不再正确。

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

【讨论】:

    猜你喜欢
    • 2012-09-02
    • 1970-01-01
    • 2014-10-11
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2016-10-28
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多