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