Когда 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-кода, который вполне может попытаться обратиться к участку памяти по нулевому адресу, что наверняка приведёт ко всякого рода нехорошим последствиям.

Выводы: CLI не гарантирует, что this!= null даже при вызове экземплярных методов и свойств. Компилятор C# соблюдает это правило при генерации байткода для кода на C#, но ваш код может быть вызван и из других языков. В частности, компилятор C++/CLI этих правил не соблюдает и вполне может передавать управление в экземплярные методы, не определяя соответствующего экземпляра. Отсюда следует, что ваш код иногда может быть вызван в контексте this == null по различным причинам (кодогенерация, reflection, компиляторы других языков), и к этому нужно быть готовым. Если вы разрабатываете библиотеку, предназначенную для широкого использования в interop-среде, возможно, стоит даже добавить проверки на null в публичные методы доступных извне классов. PS: Весь код, использованный в статье, доступен на github.

© Habrahabr.ru