Разоблачение другого мифа о значимых типах

Вот еще один миф о значимых типах, который я иногда слышу:

"Очевидно, использование оператора new со ссылочным типом приводит к выделению памяти в куче. Но значимый тип потому и называется значимым, что хранит значение, а не ссылку на него. Таким образом, с помощью оператора new, примененного к значимому типу не выделяется дополнительной памяти. Точнее, используется уже выделенная для переменной память".

Это кажется правдоподобным, не правда ли? Предположим, у вас есть присвоение, скажем, поля или локальной переменной s типа S:

s = new S(123, 456);

Если S является ссылочным типом, то выделяется новая память в долгоживущем хранилище, или «куче», и s ссылается на нее. Но если S является значимым типом, то нет необходимости выделять новую память, потому что она уже имеется. Переменная s уже существует, и мы собираемся вызвать конструктор для нее, правильно?

Неправильно. Это не то, что о чем говорится в спецификации C#, и не то, что мы делаем. (Комментатор Моис Везнер замечает, что иногда мы все-таки так делаем. Подробнее об этом далее.)

Поучительно спросить себя «а что, если миф справедлив?» Предположим, это относится к случаю, когда утверждение выше означает «определить место в памяти, к которому отнесен сконструированный тип, и передать ссылку на это место памяти как «this»-ссылку в конструкторе». Рассмотрим следующий класс, определенный в однопоточной программе (в оставшейся части статьи я рассматриваю только однопоточные сценарии; гарантии в многопоточных сценариях намного слабее).

 using System;
struct S
{
    private int x;
    private int y;
    public int X { get { return x; } }
    public int Y { get { return y; } }
    public S(int x, int y, Action callback)
    {
        if (x > y)
            throw new Exception();
        callback();
        this.x = x;
        callback();
        this.y = y;
        callback();
    }
}

Мы имеем неизменяемый элемент struct, который создает исключение, если x > y. Поэтому представляется невозможным, когда-либо получить экземпляр S с x > y, правда? Это особенность данного инварианта. Но взгляните сюда:

 static class P 
{
    static void Main()
    {
        S s = default(S);
        Action callback = ()=>{Console.WriteLine("{0}, {1}", s.X, s.Y);};
        s = new S(1, 2, callback);
        s = new S(3, 4, callback);
    }
}

Опять же, помните, мы предположили, что сформулированный мною выше миф справедлив. Что происходит?

  • Сначала мы создаем место хранения для s. (Поскольку s является внешней переменной, используемой в лямбда-выражении, это хранилище находится в куче. Но размещение хранилища для s не имеет отношения к сегодняшнему мифу, так что давайте не останавливаться на этом далее.)

  • Мы присваиваем s значение по умолчанию для типа S; при этом конструктор не вызывается. Точнее x и y обнуляются.

    Мы выполняем действие.

  • Мы (в соответствии с мифом) получаем ссылку на s и используем ее для переменной «this» при вызове конструктора. Конструктор трижды обращается к функции обратного вызова.

  • При первом вызове s по-прежнему содержит (0, 0)

  • При втором вызове x изменился, поэтому s содержит (1, 0), нарушая наше условие «X не больше Y».

  • При третьем вызове s содержит (1,2)

 

Теперь мы проделываем это снова и снова, функция обратного вызова дает (1, 2), (3, 2) и (3, 4), нарушая соглашение о том, что X не должно превосходить Y.

Это ужасно. У нас есть вполне разумная предпосылка, которая выглядит так, как будто никогда не должна нарушаться, поскольку у нас есть неизменяемый значимый тип, который проверяет состояние в конструкторе. И тем не менее, в нашем мифических мире, это нарушается. Мифическая часть находится прямо здесь:

Таким образом, оператор new для значимого типа не выделяет дополнительной памяти. Вместо этого используется уже выделенная память.

Это не так, и как мы только что видели, если бы это было верно, то можно было бы написать действительно плохой код. Дело в том, что оба этих заявления являются ложными. Спецификация C# ясно говорит по этому поводу:

"Если T является структурой, то экземпляр типа T создается с помощью временной локальной переменной."

Таким образом, оператор

s = new S(123, 456);

в действительности означает:

  • Определить место в памяти, на которое указывает s.
  • Создать временную переменную t типа S и инициализировать ее значением по умолчанию.
  • Запустить конструктор, передав ссылку на t элементу "this".
  • Скопировать значения из t в s.

Так и должно быть. Операции вполне предсказуемы: сначала выполняется "new", затем "присвоение". В мифическом объяснении нет присвоения; оно исчезает. И теперь переменная s никогда не окажется в несогласованном состоянии. Единственный код, допускающий значение x, большее, чем y — код в конструкторе. Конструкция со следующим за ней присвоением становится "атомарной"(*).

В реальности, если вы запустите приведенный выше код, то увидите, что s не изменится, пока не отработает конструктор. Вы получите три раза (0,0) и три раза (1,2).

Now, what about Wesner's point? Yes, in fact if it is a stack-allocated local variable (and not a field in a closure) then in fact we do not go through this rigamarole of making a new temporary, initializing the temporary, and copying it to the local. In that case we can optimize away the creation of the temporary and the copy because it is impossible for a C# program to observe the difference! But conceptually you should think of the creation as a creation-then-copy rather than a creation-in-place; that it sometimes can be in-place is an implementation detail.

Теперь о замечании Везнера. Да, на самом деле, если переменная располагается в стеке (и не является полем), то на самом деле мы не проходим через сложную процедуру создания новой временной переменной, ее инициализации и копирования в локальную переменную. В этом случае мы можем исключить создание временной переменной и ее копирование, поскольку для программы C# невозможно определить разницу между ними! Однако концептуально вы должны думать о создании как о создании-и-копировании, а не как о создании-на-месте; иногда может оказаться созданием-на-месте, так это – детали реализации.

----------------------------

(*) Опять же, здесь я имею в виду однопоточные сценарии. Если переменная s может быть доступна из разных потоков, то она может оказаться в несогласованном состоянии, потому что для копирования любой структуры, крупнее int не гарантируется, что это потокобезопасная атомарная операция.

Оригинал статьи