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

в 5:19, , рубрики: .net, C#, cli internals, Блог компании Enterra, метки: , ,

Довелось как-то раз отлаживать вот такой код на 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.

Автор: ForNeVeR

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js