1

我有一个类,它有两个int字段xy,以及一个方法Increment,分别递增这两个字段dxdy。我想防止我的类的状态被静默算术溢出破坏(这将导致xy两者都变为负数),所以我明确地增加了checked块中的字段:

class MyClass
{
    private int x;
    private int y;

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

这应该确保在算术溢出的情况下,调用者将收到一个OverflowException,并且我的班级的状态将保持不变。但是后来我意识到,在已经成功递增y之后,可能会在 的增量中发生算术溢出,从而导致不同类型的状态损坏,其破坏性不亚于第一种。x所以我改变了这样的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用于单线程应用程序。线程安全不是问题(我知道它不是线程安全的,但没关系)。

4

2 回答 2

2

让你的类不可变。当您想更改某些内容时,请返回一个新实例。

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 );

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

如果您不想返回新实例,则可以通过使用内部只读结构获得相同的好处。

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;
    }
}
于 2021-09-05T07:22:47.613 回答
2

TL;博士; 这在单个线程中是完全安全的。

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

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

I.12.6.4 优化

符合 CLI 的实现可以使用任何技术自由执行程序,这些技术保证在单个执行线程内,线程生成的副作用和异常按照 CIL 指定的顺序可见。
... snip ...
对于由另一个线程注入到一个线程的异常没有顺序保证(这种异常有时称为“异步异常”(例如,System.Threading.ThreadAbortException)。

[基本原理:优化编译器可以自由地重新排序副作用和同步异常,只要这种重新排序不会改变任何可观察的程序行为结束理由]

[注意:允许 CLI 的实现使用优化编译器,例如,将 CIL 转换为本机机器代码,前提是编译器(在每个单个执行线程内)保持相同的副作用和同步异常顺序
这是比 ISO C++(允许在一对序列点之间重新排序)或 ISO Scheme(允许对函数的参数重新排序)更强的条件。尾注]

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

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

只要适用相同的保证,CPU 也可以在内部自由重新排序。


所有这一切都有一个例外,除非提到的多线程允许:

优化器被授予额外的自由度来处理方法中的宽松异常。如果存在与类型E异常有关的最里面的自定义属性并指定放宽类型E的异常,则该方法对于某种异常是E松弛的。CompilationRelaxationsAttribute

但是,当前的 Microsoft 实现无论如何都没有提供这样的放松选项。


至于使用元组解构语法,遗憾的是 C# 7 的规范尚未发布,但Github 上的这个页面表明它也应该是无副作用的。

于 2021-09-05T08:54:31.070 回答