...

среда, 11 марта 2015 г.

Когда this == null: невыдуманная история из мира CLR

Довелось как-то раз отлаживать вот такой код на C#, который «на ровном месте» падал с NullReferenceException:

public class Tester {
public string Property { get; set; }
public void Foo() {
this.Property = "Some string"; // NullReferenceException
}
}




Да, вот на этой самой строчке с присвоением свойства падал NullReferenceException. Что за дела, думаю — неужели рантайм перестал проверять наличие экземпляра перед вызовом экземплярных методов?

Как оказалось — в некотором роде да, перестал. Правда, и компилятор оказался не тем, за кого себя выдаёт, да и проверки вовсе не гарантированы рантаймом… Подробнее — под катом.




Для тех, кто не знаком со спецификой C#, поясню цепочку своих размышлений. Итак, в классе Tester есть экземплярный метод Foo и экземплярное же свойство Property. Некто вызвал метод Foo, но на обращении к this.Property обнаружилась неожиданность, которая привела к генерации рантаймом исключения NullReferenceException.


В обычной ситуации это исключение могло бы означать, что в данной строке this == null, и поэтому строка this.Property = smth не может получить доступ к свойству. Но для программиста на C# это звучит совершенно невозможным образом — ведь если был как-то вызван метод Foo, то экземпляр класса существует и this не может равняться null! Как можно было вызвать метод у null?


И тем не менее, стектрейс-то вот он, указывает на эту строку! Начинаем сомневаться во всём подряд, включая собственную вменяемость, и пишем следующую тестовую программу на C#:



static class Program {
static void Main() {
Tester t = null;
t.Foo();
}
}




Компилируем, выполняем — да, программа падает с NullReferenceException на строке t.Foo();, но в метод Foo не заходит. Это что же получается, при каких-то условиях рантайм забыл выполнить проверку на null?

На самом деле, нет. (Рантайм вообще не выполняет этой проверки.) Виноват во всём происходящем, конечно, не рантайм, а компилятор. Только вот не компилятор C# (который, очевидно, на своей стороне законы соблюдает и не даёт вызвать метод у null), а компилятор C++/CLI, с помощью которого был скомпилирован код, оригинальным способом вызвавший метод Foo. Да-да, участие C++/CLI в этой истории сразу бы вызвало много подозрений, и я изначально специально об этом умолчал, чтобы было поинтереснее :)


Ну что же, продолжим опыты и напишем такую же программу на C++/CLI (для этого нужно добавить ссылку на сборку, содержащую класс Tester):



int main() {
Tester ^t = nullptr;
t->Foo();
}




Компилируем, запускаем — бац! Падает NullReferenceException внутри метода Foo, как раз как в исходном случае. То есть экземплярный метод Foo каким-то образом всё-таки был вызван у нулевой ссылки в обход любых проверок.

Что же происходит? У нас в руках две совершенно одинаковые программы на разных языках. Предполагаем, что они должны скомпилироваться в практически одинаковый (ну или хотя бы похожий) байткод, если компиляторы обоих языков соответствуют спецификациям CLI. Начинаем разбираться с полученным байткодом. Берём ildasm и разбираем код программы на C#. Привожу полный листинг метода Program.Main (в комментариях привёл строки исходного кода, соответствующие байткоду):



.method private hidebysig static void Main() cil managed
{
.entrypoint
// Code size 11 (0xb)
.maxstack 1
.locals init ([0] class [Shared]ThisIsNull.Tester t)
IL_0000: nop
IL_0001: ldnull
IL_0002: stloc.0 // Tester t = null;
IL_0003: ldloc.0
IL_0004: callvirt instance void [Shared]ThisIsNull.Tester::Foo() // t.Foo()
IL_0009: nop
IL_000a: ret
}




Самое интересное тут — строка IL_0004. Видим, что компилятор вызвал метод Foo с помощью инструкции callvirt. А теперь сравним с соответствующим кодом на C++/CLI:

.method assembly static int32 modopt([mscorlib]System.Runtime.CompilerServices.CallConvCdecl)
main() cil managed
{
.vtentry 1 : 1
// Code size 12 (0xc)
.maxstack 1
.locals ([0] class [Shared]ThisIsNull.Tester t)
IL_0000: ldnull
IL_0001: stloc.0 // Tester ^t = nullptr;
IL_0002: ldnull
IL_0003: stloc.0 // t = nullptr;
IL_0004: ldloc.0
IL_0005: call instance void [Shared]ThisIsNull.Tester::Foo() // t->Foo();
IL_000a: ldc.i4.0
IL_000b: ret
}




Из интересных для нас изменений, помимо двойного зануления переменной, тут вызов метода не через callvirt, а через call.

Инструкция CIL callvirt предназначена вообще-то для виртуальных вызовов. Однако она обладает ещё одной небольшой особенностью — поскольку виртуальные вызовы обычно делаются в CLI через таблицу виртуальных методов, то обязанностью инструкции callvirt является также проверить ссылку на null и выбросить исключение NullReferenceException, если что-то пошло не так.


Инструкция call же просто вызывает метод, не проверяя ссылок (и не задействуя механизмов виртуальной диспетчеризации).


Получается, что компилятор C# просто использует особенность инструкции callvirt и поэтому генерирует её для всех вызовов вообще (кроме статических и явных вызовов методов базового класса через base.) — только лишь потому, что это защищает код от вызова метода у нулевой ссылки. В то же время компилятор C++/CLI действует по старым добрым законам дикого Запада undefined behavior: если содержимое ссылки не определено, то и поведение программы тоже не определено. Если компилятор знает, что метод не может быть виртуальным, то он и не попытается генерировать виртуальных вызовов.


Влияет ли такое поведение компилятора C# на быстродействие, и если да, то в каком объёме — вопрос открытый. По идее, в большинстве случаев JIT должен справиться с оптимизацией и инлайнингом такого кода, если на самом деле вызываемые методы не являются виртуальными. Компилятор C# в этом отношении полностью полагается на JIT и со своей стороны никаких попыток оптимизации не предпринимает.


В контексте исследованных фактов интересен также, например, вот такой фрагмент опубликованного кода класса System.String, который когда-то вызвал вопросы на StackOverflow:



public bool Equals(String value) {
if (this == null) //this is necessary to guard against reverse-pinvokes and
throw new NullReferenceException(); //other callers who do not use the callvirt instruction

if (value == null)
return false;

if (Object.ReferenceEquals(this, value))
return true;

return EqualsHelper(this, value);
}




Теперь становится понятно, о чём говорится в комментарии (впрочем, эти комментарии были там не всегда), и при каких условиях может сработать эта проверка.

В нескольких методах разработчикам фреймворка пришлось защищаться от вызовов методов на null вот таким вот способом. Дело в том, что сравнение строк в методе EqualsHelper реализовано с помощью unsafe-кода, который вполне может попытаться обратиться к участку памяти по нулевому адресу, что наверняка приведёт ко всякого рода нехорошим последствиям.


Выводы:





  1. CLI не гарантирует, что this != null даже при вызове экземплярных методов и свойств.

  2. Компилятор C# соблюдает это правило при генерации байткода для кода на C#, но ваш код может быть вызван и из других языков.

  3. В частности, компилятор C++/CLI этих правил не соблюдает и вполне может передавать управление в экземплярные методы, не определяя соответствующего экземпляра.

  4. Отсюда следует, что ваш код иногда может быть вызван в контексте this == null по различным причинам (кодогенерация, reflection, компиляторы других языков), и к этому нужно быть готовым. Если вы разрабатываете библиотеку, предназначенную для широкого использования в interop-среде, возможно, стоит даже добавить проверки на null в публичные методы доступных извне классов.




PS:




Весь код, использованный в статье, доступен на github.

This entry passed through the Full-Text RSS service - if this is your content and you're reading it on someone else's site, please read the FAQ at http://ift.tt/jcXqJW.


Комментариев нет:

Отправить комментарий