如何防止算术溢出破坏 class 的内部状态?

How to prevent arithmetic overflow from corrupting the internal state of a class?

我有一个 class,它有两个 int 字段 xy,还有一个方法 Increment 通过 dxdy 分别。我想防止我的 class 的状态被静默算术溢出破坏(这会导致 xy 或两者都变成负数),所以我明确地递增字段在 checked 块中:

class MyClass
{
    private int x;
    private int y;

    public void Increment(int dx, int dy)
    {
        checked { x += dx; y += dy; }
    }
}

这应该确保在算术溢出的情况下调用者将收到一个 OverflowException,并且我的 class 的状态将保持不变。但后来我意识到,在 x 已经成功递增之后,在 y 的增量中可能会发生算术溢出,从而导致不同类型的状态损坏,其破坏性不亚于第一个。所以我改变了 Increment 方法的实现,如下所示:

public void Increment2(int dx, int dy)
{
    int x2, y2;
    checked { x2 = x + dx; y2 = y + dy; }
    x = x2; y = y2;
}

这似乎是解决问题的合乎逻辑的方法,但现在我担心编译器可能会“优化”我精心设计的实现,并以允许 x 赋值给在 y + dy 添加之前发生,再次导致状态损坏。我想问一下,根据 C# 规范,是否可能出现这种不良情况。

我也在考虑删除 checked 关键字,而是在启用“检查算术溢出”选项 (<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>) 的情况下编译我的项目。关于 Increment 方法中指令的可能重新排序,这会有什么不同吗?


更新: 通过使用元组解构,可以实现更简洁的算术溢出安全实现。这个版本与冗长的实现有什么不同(更不安全)吗?

public void Increment3(int dx, int dy)
{
    (x, y) = checked((x + dx, y + dy));
}

澄清: MyClass 旨在用于单线程应用程序。线程安全不是问题(我知道它不是线程安全的,但没关系)。

让你的 class 不可变。当您想更改某些内容时,return 一个新实例。

class MyClass
{
    private int x;
    private int y;

    public MyClass Increment(int dx, int dy)
    {
        checked
        {
            return new MyClass { x = this.x + dx, y = this.y + dy }; 
        }
    }
}

并且在您的调用代码中,您将替换

myClass.Increment( a, b );

myClass = myClass.Increment( a, b );

这可确保您的 class 始终保持内部一致。

如果您不想 return 一个新实例,您可以通过使用内部只读结构获得同样的好处。

public readonly struct Coords
{
    public int X { get; init; }
    public int Y { get; init; }
}

class MyClass
{
    private Coords _coords;

    public void Increment(int dx, int dy)
    {
        checked
        { 
            var newValue = new Coords { X = _coords.X + dx, Y = _coords.Y + dy };
        }
        _coords = newValue;
    }
}

TL;DR;这在单个线程中是绝对安全的。

CLI 以本机机器语言实现您的代码,根本不允许以具有可见副作用的方式重新排序指令,至少就单个线程的观察而言。规范禁止。

让我们看一下ECMA-335,CLR和CLI的规范,(我的粗体)

I.12.6.4 Optimization

Conforming implementations of the CLI are free to execute programs using any technology that guarantees, within a single thread of execution, that side-effects and exceptions generated by a thread are visible in the order specified by the CIL.
... snip ...
There are no ordering guarantees relative to exceptions injected into a thread by another thread (such exceptions are sometimes called “asynchronous exceptions” (e.g., System.Threading.ThreadAbortException).

[Rationale: An optimizing compiler is free to reorder side-effects and synchronous exceptions to the extent that this reordering does not change any observable program behavior. end rationale]

[Note: An implementation of the CLI is permitted to use an optimizing compiler, for example, to convert CIL to native machine code provided the compiler maintains (within each single thread of execution) the same order of side-effects and synchronous exceptions.
This is a stronger condition than ISO C++ (which permits reordering between a pair of sequence points) or ISO Scheme (which permits reordering of arguments to functions). end note]

所以异常必须在C#编译的IL代码中按照指定的顺序发生,因此如果溢出发生在checked 上下文,必须在观察下一条指令之前抛出异常。 (在 unchecked 上下文中,没有这样的保证,因为没有例外,但是在单个线程上无法观察到差异。)

请注意,这不是意味着两次加法不能发生在存储到局部变量之前或两次溢出检查之前,因为一旦发生异常就无法观察到局部变量抛出。在完全优化的构建中,局部变量可能会存储在 CPU 寄存器中,并在出现异常时擦除。

只要适用相同的保证,CPU 也可以在内部免费重新订购。


所有这些都有一个例外,除了提到的多线程津贴:

Optimizers are granted additional latitude for relaxed exceptions in methods. A method is E-relaxed for a kind of exception if the innermost custom attribute CompilationRelaxationsAttribute pertaining to exceptions of kind E is present and specifies to relax exceptions of kind E.

然而,当前的 Microsoft 实施并没有提供这样的放宽选项。


关于使用元组解构语法, 不幸的是 C# 7 的规范尚未发布,但 this page on Github 表明它也应该是副作用免费。