понедельник, 28 февраля 2011 г.

this == null в языке C#?!

Несмотря на то, что язык C# во многих вопросах шагнул на несколько шагов вперед по сравнению со старым добрым С++, в них все еще осталось достаточно много общего. В языке C#, как и в С++, экземлярный метод класса отличается от статического благодаря неявной передаче указателя на экземпляр этого класса (a.k.a. this). Этот анахронизм хорошо спрятан от глаз, но все же он иногда проявляет себя, особенно при работе с делегатами с помощью рефлексии, когда для вызова статического метода мы передаем null, в качестве одного из параметров, а для вызова экземплярного метода, мы передаем некоторый объект, чей экземплярный метод мы хотим вызвать.

Поскольку каждый экземплярный метод все еще неявным образом получает ссылку на текущий объект (в виде неявного параметра this), то возникает вопрос, а может ли быть ситуация, когда этот самый параметр this при вызове экземплярного метода равен null, и, соответственно, насколько логично такая проверка в экземплярном методе?

// Просто тестовый класс с методом Foo
public class SomeClass
{
    public void Foo()
    {
        // Проверка this на null?!
        if (this == null)
        {
            Console.WriteLine("Ух ты! this == null!");
        }
    }
}

С первого взгяда, может показаться, что условие в методе Foo никогда не выполнится и даже некоторые инструменты (например, всеми нами любимый Решарпер) выдают предупреждение о том, что это дейсвтительно так. Если говорить о языке C#, то это, в целом, верно, и добиться выполнения этого кода без всяческих ухищрений достаточно сложно (хотя и возможно), однако это не значит, что в других языках добиться этого также сложно, как и в C#.

Итак, давайте попробуем вызвать метод Foo на «нулевом указателе» и посмотрим, что из этого выйдет:

SomeClass someClass = null;
someClass.Foo();

Как и предполагалось, мы не увидели на экране заветного «Ух ты!» вместо этого мы получим не менее заветное NRE (NullReferenceException). В целом, в таком поведении нет ничего плохого, более того, кажется вполне логичным, что мы получаем исключение при любом обращении к «пустому» экземпляру, не зависимо от того, обращается ли этот метод к this или нет.

Все дело в том, что для вызова функции в CLR существует две инструкции call и callvirt. Инструкция call используется для вызова статических, экземплярных методов или для невиртуальных вызовов виртуальных методов (например, для вызова «базовой» реализации виртуального метода); инструкция callvirt применяется для вызова экземплярных или виртуальных методов. Основным отличием этих инструкций является то, что инструкция call использует «ранее связывание», когда конкретный вызываемый метод известен на этапе компиляции; инструкция callvirt использует таблицу виртуальных функция для реализации «позднего связывания», в результате чего при вызове виртуального метода через ссылку на базовый класс будет вызван метод производного класса (да, старый добрый полиморфизм).

Еще одним, не менее важным отличием между этими двумя инструкциями является то, что при вызове инструкции callvirt CLR встраивает дополнительную проверку на null и самостоятельно генерирует NullReferenceException, а инструкция call этого не делает. Это совершенно не принципиально, когда речь идет о структурах, но когда речь заходит о ссылочных типах, разница появляется, причем существенная.

Давайте посмотрим на IL-код, который генерирует компилятор языка C# для нашего предыдущего примера:

IL_0000:  ldnull    
IL_0001:  stloc.0   
IL_0002:  ldloc.0  
IL_0003:  callvirt  PlyaingWithNRE+SomeClass.Foo

Дело все в том, что компилятор C# для вызова *всех* экземплярных методов генерирует инструкцию callvirt, даже если этот метод не является виртуальным. Если верить Эрику Гуннерсену (Eric Gunnerson) – программ-менеджеру команды Visual C# (лично у меня нет никаких оснований ему не верить), то это поведение является осознанным, хотя изначально оно и было другим. Первоначально компилятор C# (это было настолько давно, что этот язык еще даже не носил этого названия) генерировал инструкцию call для вызова невиртуальных методов и callvirt – для вызова виртуальных методов. Это приводило к тому, что NullReferenceException генерировалось не в месте «разыменовывания» указателя, а в месте обращения к полям объекта или вызове виртуального метода. Тогда разработчики компилятора посчитали такое положение вещей неприемлемым и решили, что при любом вызове метода по «нулевому указателю» пользователь должен получать исключение как можно раньше.

Теперь ответ на изначальный вопрос о том, возможно ли выполнение условия this == null, и насколько разумно добавлять проверку эту проверку в экземплярном методе, весьма прост: для выполнения этого условия достаточно вызвать метод с помощью IL инструкции call. И поскольку C# - это далеко не единственный язык программирования для платформы .Net и мы можем использовать наш класс из других языков программирования, то вполне вероятны сценарии, при которых это условие будет выполнено, и, соответственно, эта проверка окажется полезной.

Теперь давайте рассмотрим способы успешного выполнения условия в методе Foo, как на языке C# (да, я говорил, что это возможно), так и с помощью других языков программирования.

Вызов метода Foo с помощью Reflection.Emit

Этот способ нельзя назвать честным, но все же он является наиболее простым способом показать отличия между инструкциями call и callvirt, так что давайте начнем именно с него. Все, что нам в этом случае нужно – это сгенерировать динамический метод, принимающий в качестве параметра объект SomeClass и вызывающий метод Foo с помощью инструкции call:

static void CallingNullReferenceThroughReflectionEmit()
{
    // Создаем статический метод Test "динамически"
    DynamicMethod dm = new DynamicMethod("Test", typeof(void),
        new Type[] { typeof(SomeClass) }, true);
           
    // Получаем ILGenerator для этого метода
    var il = dm.GetILGenerator();

    // Загружаем аргумент (в данном случае, это объект типа SomeClass)
    il.Emit(OpCodes.Ldarg_0);
           
    // Добавляем инструкцию невиртуального вызова метода Foo
    MethodInfo method = typeof(SomeClass).GetMethod("Foo");
    il.Emit(OpCodes.Call, method);
           
    // Инструкция выхода из метода
    il.Emit(OpCodes.Ret);
    SomeClass someClass = null;
    // Вызываем делегат и передаем null в качестве параметра
    dm.Invoke(null, new object[] { someClass });
}

Вызов метода Foo с помощью C++/CLI

Хотя язык C++/CLI прекрасно уживается в «управляемом» мире CLR, но он, прежде всего, остается языком C++; производительность языковых конструкций была определяющим фактором при проектировании многих его возможностей. Так что совершенно не удивительно, что некоторые свойства, характерные компилятору языка C# не характерны компилятору C++/CLI. Я не зря упоминал об изменении, внесенном разработчиками компилятора C#, когда вместо инструкций call компилятор стал генерировать инсрукции callvirt, ведь изначальное поведение компилятора C# полностью соответствует теперешнему поведению компилятора C++/CLI. В этом очень просто убедиться на очень простом примере:

SomeClass ^someClass = nullptr;
someClass->Foo();

Этот код приводит к выводу в консоль “Ух ты! this == null” именно благодаря инструкции call. Вообще, С++/CLI позволяет ряд вещей, который недоступны программистам на C# (хотя, нужно признать, подобные ситуации возникают не часто). Так, например, на C++/CLI можно легко написать код, генерирующий инструкцию call для любого произвольного виртуального метода в иерархии наследования:

// Простая иерархия наследования с классами Base и Derived
// и виртуальным методом Foo, переопределенным в наследнике
ref class Base
{
public:
    virtual void Foo()
    {
        System::Console::WriteLine("Base.Foo()");
    }
};

ref class Derived : public Base
{
public:
    virtual void Foo() override
    {
        System::Console::WriteLine("Derived.Foo()");
    }
};
using namespace PlayingWithNRE;

int main(array<System::String ^> ^args)
{
SomeClass ^someClass = nullptr;
someClass->Foo();
// ...
Base ^b = gcnew Derived();
b->Foo(); // вызываем Derived::Foo
b->Base::Foo(); //Вызываем Base::Foo

Это происходит именно благодаря тому, что во втором случае генерируется инструкция call, а не callvirt.

Вызов метода Foo из языка C#

Если вам, нам, или разработчикам компилятора, кажется, что компилятор C# *всегда* генерирует инструкцию callvirt, то на практике это происходит именно так, до тех пор, пока не приходит nikov и не убеждает компилятор в том, что он не прав.

Вот весьма простой код, который приводит к выполнению условия “this == null”:

var method = typeof(SomeClass).GetMethod("Foo");
var action = (Action)Delegate.CreateDelegate(typeof(Action), null, method);
action();

Вместо заключения

Язык C# - это отличный язык программирования, который делает нашу жизнь проще и интереснее, но это не значит, что нашим кодом не будут пользоваться разработчики на других языков программирования. И хотя проверка this на null кажется излишней даже в этом случае, практика показывает, что вы можете столкнуться с этим кодом в своей повседневной деятельности. Кроме того, всегда полезно понимать на «один уровень абстракции ниже» и знать, о чем думали разработчики языка, принимая те или иные решения.

Дополнительные ссылки

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

  1. По-моему можно даже не извращаться с рефлексией, достаточно написать свою реализацию оператора == и там при определенных условиях возвращать true при сравнении с null.

    ОтветитьУдалить
  2. Что-то я сомневаюсь, что вызов метода по нулевой ссылке в С++/CLI вдруг перестал быть UB (как в неуправляемых плюсах). Ты это проверял?

    Примеры же что с emit что с reflection так же являются хакерскими: нам предоставляют дастаточно широкие и низкоуровневые возможности что бы кто-то кроме нас самих мог бы позаботиться о корректности работающего кода.

    Поэтому рассчитывать на "другие языки программирования" не стоит в любом случае. Единственным прециндентом таких проверок является BCL и там проверки объясняются "reverse-pinvokes" и "other callers who do not use the callvirt instruction" и если первый кейс ещё можно пообсуждать, то во втором вызывает сомнение разумность учитывать особенности "other callers" во-первых в одних местах и не учитывать в других и, во-вторых, всвязи с незнанием семантики этих самых "other callers" - какого поведения они ожидают от вызываемого кода в случае this == null.

    ОтветитьУдалить
  3. @Pavel: я в примерах использовал оператор == исключительно ради читабельности. С тем же успехом можно было использовать object.RefefenceEquals(this, null). Т.е. здесь речь идет о том, что неявный параметр this действительно может быть равен null.

    ОтветитьУдалить
  4. А зачем stloc/ldloc? после ldnull или newobj на стеке и так будет уже значение которое передастся в callvirt/call, вроде.
    И как же на счёт того случая, когда вызываешь метод внешней библиотеки, которая может решить поменять обычный метод на виртуальный и весь код который зависит от неё будет вынужден перекомпилится, если у него будут сгенерены call-ы, вместо callvirt-ы? По моему это более логичная причина, чем просто создать эксепшин раньше, чем непосредственное обращение к this-у.

    ОтветитьУдалить
  5. @_FRED_: UB самому проверить весьма сложно, на то оно и UB. В спецификации C++/CLI я не нашел упоминание о поведении при доступа к объекту через nullptr, но скорее всего это значит, что это UB, хотя и ведет себя понятным образом в большинстве случаев.


    >Поэтому расчитывать на "другие языки программирования" не стоит в любом случае...

    По идее "other callers who o not use callvirt instructions" - это и есть "другие языки программирования".

    И вот некоторое подтверждение этому же от Бреда Абрамса:

    Essentially the C# language specification requires that any method call on a null instance result in a NullReferenceException, this is done to provide more predictability to the language, and frankly, I can’t say I blame them. But at the CLR level, we can’t assume that every language will follow suit, so we provide the call instruction which will not check for null and callvirt that will.


    Единственное объяснение, которое мне приходит в голову, для чего можно проверить this == null, даже не зная об этих самых "other callers" - это получить предсказуемое поведение не зависимо от этих самых "other callers". Т.е. как бы меня не вызвали (и из какого бы языка меня не вызвали), я упаду совершенно одинаковым образом.

    Да, и кто такие "reverse-pinvokers"? Я понимаю, что это комментарий из метода Equals в классе String, но вот что это значит :хз:

    ОтветитьУдалить
  6. @ony: по поводу вызова метода из внешней библиотеки. С тем же успехом этот метод мог вообще исчезнуть или у него могла измениться сигнатура. В общем случае нельзя гарантировать, что при изменении публичного интерфейса сборки ею можно пользоваться без перекомпиляции.

    Вообще-то, в языке C# сейчас есть четкая семантика того, как ведет себя приложение при обращение к null. Если бы не заменили call на callvirt, пришлось бы вводить нечто похожее на UB, поскольку четко описать поведение кода при обращении к null, было бы значительно сложнее.

    Так что, мне все же кажется, call поменяли на callvirt именно поэтому (о чем, собственно, Эрик и пишет).

    ОтветитьУдалить
  7. @Сергей: по повожу "other" - интересно, что Brad думает о непоследовательности подобных проверок в CLR. Тут есть, а там нет (видимо, мужики-то и не знали?). От того, что они кой-куда это воткнули, более безопастным ничего не стало и не станет, пока эти проверки не будут везде, где нужно (ибо безопастность целого определяется наислабейшим звеном).

    Да и throw NullReferenceException() в четвёртом фреймворке вместо if (this != null) return false; говорит о более прагматичном взгляде на предмет ;о)

    "reverse-pinvokers" отлично выгугливается ;о)

    Но тут объснение более чем логичное: throw NullReferenceException() как раз таки ожидаемо независимо от того, что используется в языке: call/callvirt, а вот логика "сделать всё что бы не упасть" - сомнительна именно по причине незнания того, с чем имеешь дело.

    ОтветитьУдалить
  8. @_FRED_: сори, на работе нет доступа в блоггер для комментариев, так что только сейчас смог ответить:(

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

    З.Ы. У тебя в исходном комментарии было "reverse-pinvokes" (а не invokers), вот мне гугил ничего и не подсказал:)

    ОтветитьУдалить
  9. По поводу цитаты: если курить исходники, то в обсуждаемом вопросе видна непоследовательность в CLR: далеко не везде обрабатывается ситуация с this == null.

    У меня именно "reverse-pinvokes" выгугливаются и именно так написано в исходниках. Я небольшой знаток языка, что бы судить, на сколько это грамотно :о))

    ОтветитьУдалить
  10. Да, с последовательностью в BCL (или ты все же имеешь ввиду CLR?) полная труба. Ну, там даже в дизайне (в смысле в публичных/защищенных методах) часто бывает непоследовательность, что уж говорить за такие тонкости в реализации.

    По поводу reverse-pinvoke: может я туплю, но вот, что гугл выдает на "reverse-pinvokes", а вот - на "reverse-pinvoke". Но в любом случае, я понял, о чем речь.

    ОтветитьУдалить
  11. >Дело все в том, что компилятор C# для вызова *всех* экземплярных методов генерирует инструкцию callvirt, даже если этот метод не является виртуальным.

    class C { public void M() {} };

    void Main()
    {
    new C().M();
    }

    Содержимое Main :)

    IL_0000: newobj C..ctor
    IL_0005: call C.M

    ОтветитьУдалить
  12. Юрий, это сверх частный случай. Даже вот этот код уже приводит к вызову метода M через callvirt:

    class C { public void M() {} };
    void Main()
    {
    var c = new C();
    c.M();
    }

    IL_0001: newobj UserQuery+C..ctor
    IL_0006: stloc.0 // c
    IL_0007: ldloc.0 // c
    IL_0008: callvirt UserQuery+C.M

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