Несмотря на то, что язык 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();
Все дело в том, что для вызова функции в 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 кажется излишней даже в этом случае, практика показывает, что вы можете столкнуться с этим кодом в своей повседневной деятельности. Кроме того, всегда полезно понимать на «один уровень абстракции ниже» и знать, о чем думали разработчики языка, принимая те или иные решения.
Дополнительные ссылки
-
Eric Gunnerson. Why does C# always use callvirt?
-
Chris Brumme. Virtual and non-virtual
-
Brad Abrams. More fun with call vs callvirt
По-моему можно даже не извращаться с рефлексией, достаточно написать свою реализацию оператора == и там при определенных условиях возвращать true при сравнении с null.
ОтветитьУдалитьЧто-то я сомневаюсь, что вызов метода по нулевой ссылке в С++/CLI вдруг перестал быть UB (как в неуправляемых плюсах). Ты это проверял?
ОтветитьУдалитьПримеры же что с emit что с reflection так же являются хакерскими: нам предоставляют дастаточно широкие и низкоуровневые возможности что бы кто-то кроме нас самих мог бы позаботиться о корректности работающего кода.
Поэтому рассчитывать на "другие языки программирования" не стоит в любом случае. Единственным прециндентом таких проверок является BCL и там проверки объясняются "reverse-pinvokes" и "other callers who do not use the callvirt instruction" и если первый кейс ещё можно пообсуждать, то во втором вызывает сомнение разумность учитывать особенности "other callers" во-первых в одних местах и не учитывать в других и, во-вторых, всвязи с незнанием семантики этих самых "other callers" - какого поведения они ожидают от вызываемого кода в случае this == null.
@Pavel: я в примерах использовал оператор == исключительно ради читабельности. С тем же успехом можно было использовать object.RefefenceEquals(this, null). Т.е. здесь речь идет о том, что неявный параметр this действительно может быть равен null.
ОтветитьУдалитьА зачем stloc/ldloc? после ldnull или newobj на стеке и так будет уже значение которое передастся в callvirt/call, вроде.
ОтветитьУдалитьИ как же на счёт того случая, когда вызываешь метод внешней библиотеки, которая может решить поменять обычный метод на виртуальный и весь код который зависит от неё будет вынужден перекомпилится, если у него будут сгенерены call-ы, вместо callvirt-ы? По моему это более логичная причина, чем просто создать эксепшин раньше, чем непосредственное обращение к this-у.
@_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, но вот что это значит :хз:
@ony: по поводу вызова метода из внешней библиотеки. С тем же успехом этот метод мог вообще исчезнуть или у него могла измениться сигнатура. В общем случае нельзя гарантировать, что при изменении публичного интерфейса сборки ею можно пользоваться без перекомпиляции.
ОтветитьУдалитьВообще-то, в языке C# сейчас есть четкая семантика того, как ведет себя приложение при обращение к null. Если бы не заменили call на callvirt, пришлось бы вводить нечто похожее на UB, поскольку четко описать поведение кода при обращении к null, было бы значительно сложнее.
Так что, мне все же кажется, call поменяли на callvirt именно поэтому (о чем, собственно, Эрик и пишет).
@Сергей: по повожу "other" - интересно, что Brad думает о непоследовательности подобных проверок в CLR. Тут есть, а там нет (видимо, мужики-то и не знали?). От того, что они кой-куда это воткнули, более безопастным ничего не стало и не станет, пока эти проверки не будут везде, где нужно (ибо безопастность целого определяется наислабейшим звеном).
ОтветитьУдалитьДа и throw NullReferenceException() в четвёртом фреймворке вместо if (this != null) return false; говорит о более прагматичном взгляде на предмет ;о)
"reverse-pinvokers" отлично выгугливается ;о)
Но тут объснение более чем логичное: throw NullReferenceException() как раз таки ожидаемо независимо от того, что используется в языке: call/callvirt, а вот логика "сделать всё что бы не упасть" - сомнительна именно по причине незнания того, с чем имеешь дело.
@_FRED_: сори, на работе нет доступа в блоггер для комментариев, так что только сейчас смог ответить:(
ОтветитьУдалитьПо поводу Абрамса я чего-то не понял, но со всем остальным я полностью согласен. И совершенно согласен, что поведение в четвертом фрейморке в виде бросания NRE - именно то, что нужно. И я полностью согласен с тем, что ребята добиваются одинакового поведения (а именно исключения), не зависимо от того, кто какой инструкцией пользуется для вызова этого метода и, соответственно, из какого языка он производится. Так что, видимо, мы говорим об одном и том же.
З.Ы. У тебя в исходном комментарии было "reverse-pinvokes" (а не invokers), вот мне гугил ничего и не подсказал:)
По поводу цитаты: если курить исходники, то в обсуждаемом вопросе видна непоследовательность в CLR: далеко не везде обрабатывается ситуация с this == null.
ОтветитьУдалитьУ меня именно "reverse-pinvokes" выгугливаются и именно так написано в исходниках. Я небольшой знаток языка, что бы судить, на сколько это грамотно :о))
Да, с последовательностью в BCL (или ты все же имеешь ввиду CLR?) полная труба. Ну, там даже в дизайне (в смысле в публичных/защищенных методах) часто бывает непоследовательность, что уж говорить за такие тонкости в реализации.
ОтветитьУдалитьПо поводу reverse-pinvoke: может я туплю, но вот, что гугл выдает на "reverse-pinvokes", а вот - на "reverse-pinvoke". Но в любом случае, я понял, о чем речь.
>Дело все в том, что компилятор C# для вызова *всех* экземплярных методов генерирует инструкцию callvirt, даже если этот метод не является виртуальным.
ОтветитьУдалитьclass C { public void M() {} };
void Main()
{
new C().M();
}
Содержимое Main :)
IL_0000: newobj C..ctor
IL_0005: call C.M
Юрий, это сверх частный случай. Даже вот этот код уже приводит к вызову метода 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