среда, 2 июня 2010 г.

Распаковка (unboxing) и InvalidCastExcpetion

Несмотря на то, что упаковка и распаковка (boxing/unboxing) стала встречаться значительно реже в повседневной практике разработчика после появления обобщенных (generic) коллекций в C# 2.0, эта тема все еще остается одной из самых коварных и малопонятных для многих, поскольку поведение во время выполнения далеко не всегда является интуитивно понятным и ожидаемым с их точки зрения.

Классическим примером ошибок, связанных с упаковкой/распаковкой является изменения не того экземпляра значимого типа (value type), когда в результате выполнения некоторого кода изменяется не требуемый объект, а всего лишь его копия (именно это и является причиной того, что изменяемые (mutable) структуры являются главным вселенским злом). Другим примером является неочевидное для многих поведение, когда при распаковке объекта одного типа в переменную другого типа генерируется исключение InvalidCastException.

Давайте рассмотрим простой пример, который приводит к генерации исключения InvalidCastException во время выполнения:

int i1 = 1; // 1

 

// Упаковка (boxing) переменной типа int.

object o1 = i1; // 2

 

// Распаковываем объект типа int в переменную типа short

// и получаем InvalidCastException во время выполнения

short s1 = (short)o1; //3

Но вместо того, чтобы сразу же с головой броситься выяснять почему поведение именно такое, давайте сделаем шаг назад и посмотрим, что делает компилятор для преобразования переменной типа int к переменной типа short:

int i1 = 1;

short s1 = (short)i1;

Во время компиляции, компилятор точно знает, какая операция требуется в данном конкретном случае, поскольку статические типы всех операндов и сама операция ему известны: разработчик просит преобразовать int в short. Для этого компилятор генерирует соответствующую инструкцию (conv.i2 или conv.ovf.i2 для «проверяемого» преобразования), которая во время выполнения возьмет младшие два байта переменной типа int и скопирует в переменную типа short, отбросив при этом старшие два байта. Если же пользователь «запросит» преобразование некоторого пользовательского типа данных к другому типу, то компилятор проверит наличие операторов явного и неявного преобразования и сгенерирует код для их вызова.

Теперь давайте вернемся к нашему исходному примеру:

short s1 = (short)o1;

В этой строке кода мы хотим получить аналогичное поведение и без всяких исключений сохранить два младших байта упакованного объекта типа int в переменной s2. Давайте станем на место компилятора (или разработчика компилятора) и подумаем, как бы  мы могли этого добиться.

Во время компиляции нам известно, что разработчик хочет преобразовать объект некоторого типа, к переменной типа short, при этом конкретный тип o1 будет известен только во время выполнения. Т.е. нам нужно сгенерировать код, который бы проверил тип объекта o1 и если о1 – это int, то сгенерировать инструкцию преобразования int в short, если o1 – byte, то сгенерировать другую инструкцию, если o1 – это некоторый пользовательский тип, то проанализировать его метаданные на предмет наличия операторов преобразования и сгенерировать вызов этого оператора, если такой оператор есть, или бросить InvalidCastException в противном случае. По-сути, для получения требуемого нами поведения необходимо, чтобы во время выполнения была запущена легковесная версия компилятора, который бы исходя из динамического типа объекта o1 сгенерировал необходимый код, аналогичный тому, который генерирует компилятор C#, когда статические типы известны ему во время компиляции.

Интуитивно должно быть понятно, что разработчики компилятора не могли использовать этот вариант по умолчанию и запускать миникомпилятор каждый раз, когда требуется преобразование типов, поскольку этот вариант совершенно не оптимален с точки зрения производительности. Поэтому, вместо генерирования всей этой прорвы кода, разработчики компилятора решили запретить распаковку объекта одного типа в переменную другого типа (*). Т.е. наиболее эффективное решение нашей исходной задачи будет таким:

short s1 = (short)(int)o;

Но не смотря на такое поведение по умолчанию, разработчики компилятора оставили вам способ воспользоваться более медленным вариантом, но для этого вы должны явно сказать об этом компилятору. Если упакованный объект реализует интерфейс IConvertible (что справедливо для нашего примера, а также для преобразования любых числовых типов BCL), то можно воспользоваться классом Convert:

short s1 = Convert.ToInt16(o1);

Если же вы можете воспользоваться C# 4.0, то в вашем распоряжении есть тип dynamic, который может решить ваши проблемы даже тогда, когда упакованный тип не реализует интерфейс IConvertible, а содержит операторы преобразования к необходимому типу:

struct Boo

{

    public static explicit operator short(Boo b)

    {

        return 1;

    }

}

//...

Boo b = new Boo();

object o = b;

short s1 = (short)(dynamic)o;

Заключение

С первого взгляда может показаться, что генерация исключения при распаковке объекта одного типа в переменную другого типа является некорректным и интуитивно не понятным поведением, но если проанализировать другие варианты становится ясно, что они заведомо являются неэффективными, а «пессимизация» выполнения такой, достаточно распространенной операции как распаковка, может привести к нежелательному снижению производительности приложения. Кроме того, если вы не уверены в типе упакованного значения вы всегда можете воспользоваться обходным путем, например, классом Convert (если вы знаете, что тип упакованного объекта реализует IConvertible), либо типом dynamic, что позволит даже вызвать пользовательские операторы преобразования типов.

(*)
На самом деле типы не должны совпадать в точности. Следующий пример покажет, в каких случаях такие преобразования возможны, а в каких – нет:

enum SomeEnum : int

{ }

 

// ...

int i = 1;

object o = i;

 

// Мы можем преобразовывать объект упакованного типа

// к его Nullable эквиваленту

var ni = (int?)o;

 

// Мы можем преобразовывать упакованный тип к перечислению,

// "основанному" на этом типе

var se = (SomeEnum)o;

 

// Но не можем приводить к Nullable перечислению

var nse = (SomeEnum?)o;

 

// Мы даже не можем приводить упакованный знаковый тип

// к его беззнаковому эквиваленту, даже при том, что

// эти два объета имеют одно и тоже представление в памяти

var ui = (uint)o;

5 комментариев:

  1. Хм, а почему этот процесс называется упаковка/распаковка? Или он чем-то отличается от процесса преобразования типов?

    ОтветитьУдалить
  2. Боюсь что на этапе JIT компиляции тип объекта object o1 известен не будет.

    ОтветитьУдалить
  3. @raxxla: мда... вы правы, с JIT компиляцией я определенно погарячился:)
    Внес исправления в статью.

    ОтветитьУдалить
  4. @Игорь: Значимые типы (value types) по умолчанию располагаются в стеке, в то время как ссылочные типы (reference types) располагаются в управляемой куче.
    Т.е. когда вы в своей функции где-либо пишите int i = 5, то значение объекта System.Int32 со значением 5 располагается в стеке.
    Упаковка - это процесс копирования объекта значимого типа, расположенного в стеке в соответствующий объект ссылочного типа.
    Типичным примером упаковки является приведение объекта значимого типа к типу object или интерфейсу, который реализует этот значимый тип.
    Вот типичные примеры:
    int i = 0;
    object o = i; //здесь происходит упаковка
    IConvertible c = i; //здесь также происходит упаковка.
    Распаковка - это противоположный процесс, т.е. процесс "копирования" значимого объекта, расположенного в управляемой куче, в переменную значимого типа, расположенную в стеке.
    int i2 = (int)o; //распаковка
    Если нужна более подробная информация, то достаточно в гугле вбить "boxing and unboxing" и вы получите огромное количество результатов.
    Для начала, можно почитать здесь

    ОтветитьУдалить