Изначально ссылочные типы в C# всегда допускали использование значения null. Однако 8 версия языка в корне меняет этот подход. Теперь переменные ссылочных типов могут явно указывать допустимо ли присваивать им значение null. В этом плане их поведение становится аналогичным значимым типам. Разберемся с этим нововведением подробнее. |
Зачем нужны новые типы
Рассмотрим на примере. Пусть есть некий метод GetUser(int id), который в случае успешного завершения своей работы гарантированно возвращает экземпляр объекта типа User.
До 8 версии языка C#, в общем случае, такой контракт мог быть основан только на договоренностях (или с использованием дополнительных инструментов, таких как Code Contracts). Например, все методы, имя которых начинается с Get, гарантированно возвращают экземпляр или выкидывают исключение. А все методы, имя которых начинается с Find, могут вернуть null, если ничего не найдено. Такой "устный контракт" должен поддерживаться разработчиками. Кроме того, далеко не всем методам можно дать осмысленное название, основанное на таком правиле. А расширение списка правил приведет к тому, что их труднее будет соблюдать. На практике только непосредственно вызовы таких методов будут полагаться на подобные устные соглашения. Весь остальной код будет по прежнему изобиловать проверками на null, которые по факту никогда не сработают, а также могут привести к увеличению числа тестов для их покрытия.
Данную проблему решает появившиеся в C# 8 ссылочные типы, допускающие значение null. Теперь приведенный выше в пример контракта можно определить непосредственно в коде. Более того, не только вызывающий, но и остальной код получит информацию о том, что в полученной переменной ссылочного типа не может быть значение null. А за соблюдением контракта будет следить компилятор, который даст знать об ошибках еще на этапах написания кода и компиляции.
Важно понимать, что все проверки происходят на этапе компиляции, а не во время исполнения run-timе.
Синтаксис
Определение типа
Для описания ссылочных типов, допускающих значение null, C# 8 заимствует синтаксис значимых типов и использует оператор ?:
Type?
Для ссылочного типа, который не может иметь значение null, используется просто его имя:
Type
В этом случае компилято�� будет следить, чтобы в переменную такого типа нельзя было присвоить значение null или значение из другой переменной, которая допускает null.
Например, следующий код вызовет предупреждение при компиляции:
public User? FindUser(int id) { … } // метод может вернуть null
…
User sourceUser = FindUser(id); // Предупреждение при компиляции
Легко заменить, что такое изменение в правилах языка является критическим (breaking change). Поэтому в 8 версии языка поддержка данной возможности должна быть явно активирована в настройках проекта. Кроме того, вместо ошибок для указания нарушений контракта по-умолчанию используются предупреждения, что не мешает завершению компиляции.
С точки зрения компилятора, тип Type, не допускающий значение null, является подтипом типа Type?, допускающего null. Ведь Type? может содержать все, что есть в Type плюс null. Поэтому при выводе типа в ситуациях где используется как Type? так и Type результатом будет Type?.
Шаблонные методы (generics)
Ссылочные типы, допускающие значение null, нельзя указывать в качестве T – типа шаблонного метода. Однако, нет запрета на их использование в роли параметров или результата. Например:
public void DoSomething<T>(T? value)
where T : class
{ … }
При этом необходимо указать, что T является классом. Это исключит неопределенность между ссылочным T? и Nullable<T> со значимым типом.
Оператор !
Разработчик может подсказать компилятору, что значение переменной в данном месте никогда не равно null, используя для этого оператор !. Например, в примере ниже компилятор не выдаст предупреждение или ошибку, не смотря на то, что тип переменной userName может принимать null:
// Используем string? т.к. в переменную позже может
// быть присвоено значение null
string? userName = GetUserName(id: 1);
var len = userName!.Lenght; // гарантируем что здесь userName != null
Вывод типов
При использовании ссылочных типов, допускающих значение null, есть интересная особенность: компилятор предварительно проводит анализ возможности использования null в месте вывода типа. Рассмотрим следующий пример:
User? x = FindUser(userId);
if (user == null) throw UserNotFoundException(userId);
var target = user;
…
В данном случае переменная target будет типа User, а не User?, как можно было бы предположить. Все дело в предварительно сделанной проверке на null. Поскольку в момент вывода типа переменная user не может иметь значение null, то в качестве типа target будет использован User.
Также после такой проверки переменную user можно передавать в методы, которые принимают тип User. При этом компилятор не будет выдавать предупреждения, поскольку, несмотря на тип переменной User?, её значение после проверки не может быть null.
Поддержка ссылочных типов, допускающих значение null
Компилятор C# 8 содержит два контекста для анализа ссылочных типов на возможность присваивания значения null
- контекст аннотаций – определяет как трактуется ссылочный тип без указания ?, как допускающий или не допускающий null.
- контекст предупреждений – определяет необходимо ли выводить предупреждения о нарушении контракта использования ссылочных типов.
В C# 8.0 по умолчанию оба контекста отключены для совместимости с уже существующим кодом, который в этом случае будет компилироваться без предупреждений.
Изменить это поведение можно добавив в файл проекта ".csproj" параметр <nullable>…</nullable>, который может принимать следующие значения:
Значение <nullable/> |
Ссылочные типы без "?" |
Предупреждения о нарушениях контракта |
enable |
Не допускают null |
Выводятся |
warnings |
Допускают null |
Выводятся |
annotations |
Не допускают null |
Не выводятся |
disable |
Допускают null |
Не выводятся |
Отсутствие <nullable>…</nullable> в файле проекта аналогично <nullable>disable</nullable>.
Как видно из таблицы, самый строгий вариант это enable.
Режим warnings подразумевает что типы без ? по прежнему могут содержать null. Это может помочь при первичной адаптации существующего кода или использоваться на этапе статического анализа кода. Компилятор будет выдавать предупреждения в местах, где возможно появление NullReferenceException. Например в ситуации, когда разработчик сначала использовал свойство класса, предварительно проверив что экземпляр класса не равен null, а затем повторно обратился к свойству без такой проверки. Компилятор предупредит, что во втором случае возможно исключение.
Также настройку контекстов можно изменить в любом месте кода при помощи директивы:
#nullable (enable|disable|restore) [annotations|warnings]
Опция restore возвращает поведение к варианту, заданному в файле проекта при помощи <nullable />.
Опции annotations и warnings определяют конкекст для изменения. Значение не является обязательным и без него операция будет изменять оба контекста сразу. Например:
- #nullable enable – включает контексты аннотаций и предупреждений.
- #nullable restore warnings – возвращает настройку контекста предупреждений к значению, заданному в файле проекта.
Область действия директивы – до следующей директивы или до конца файла. Это позволяет постепенно переводить существующий код на использование новых ссылочных типов.
Совместимость с существующим кодом
В рамках одного проекта и решения (solution) может сосуществовать код с "классическими" и новыми ссылочными типами. Не говоря уже о сочетаниях различных проектов и библиотек. Как следствие возникает вопрос: по каким правилам происходит взаимодействие классических и новых ссылочных типов?
Существуют следующие правила:
- При пересечении границы между "классическим" и новым кодом ссылочные переменные будет трактоваться согласно текущим правилам. При этом, в случае передачи из нового кода в "классический", это будет сделано независимо от того, допускает ли в новом коде тип значение null или нет.
- Контроль со стороны компилятора будет только в той части кода, где явно включена поддержка ссылочных типов, допускающих значение null.
Здесь стоит обратить внимание на то, что значения, передаваемые из "классического" кода в новый будут трактоваться как не допускающие значение null. Это означает что перед дальнейшим использованием лучше всего присваивать такие значения в переменные с явным указанием типа, который допукает null. и избегать использования var. Например:
// GetUserName объявлена и реализована в "классическом" коде
// и возращает "string", но присваиваем в "string?"
string? userName = GetUserName(id: 1);
Еще одно правило, которое позволит уменьшить число проблем, при совместном использованнии "классического" и нового кода: если объявление интерфейса использует ссылочные типы, допускающие значение null, то и все реализации должны его использовать. Это актуально при рефакторинге существующего кода.
Вывод ошибок при нарушении контракта
При нарушении контракта ссылочных типов C# 8.0 выводит предупреждение. Это не мешает успешной сборке проекта. Но Visual Studio позволяет перевести предупреждения в разряд ошибки при помощи любого из следующих вариантов:
Настройка в файле проекте
В файле проекта ".csproj" необходимо перечислить коды предупреждений, которые необходимо трактовать как ошибки. Например:
<WarningsAsErrors>CS8600;CS8625</WarningsAsErrors>
Файл .editorconfig
Visual Studio 2019 поддерживает файл ".editorconfig", в котором можно указать какие предупреждения должны обработываться как ошибки. Например:
[*.cs]
# CS8600: Converting null literal or possible null value to non-nullable type.
dotnet_diagnostic.CS8600.severity = error
Добавлять коды можно как вручную, так и через контекстное меню редактора:

StyleCop и правила кода
Если в проекте используется StyleCop, то необходимо сделать изменения в файла стилей кода ".ruleset". Открыв его для редактирования необходимо отметить требуемые предупреждения как ошибки. Для упрощения можно воспользоваться окном поиска, введя в него код конкретной ошибки или слово "null".

Список кодов предупреждений, связанных с ссылочными типами, допускающими значение null
Код |
Описание |
CS8073 |
The result of the expression is always the same since a value of this type is never equal to ‘null’ |
CS8597 |
Thrown value may be null. |
CS8600 |
Converting null literal or possible null value to non-nullable type. |
CS8601 |
Possible null reference assignment. |
CS8602 |
Dereference of a possibly null reference. |
CS8603 |
Possible null reference return. |
CS8604 |
Possible null reference argument. |
CS8605 |
Unboxing a possibly null value. |
CS8606 |
Possible null reference assignment to iteration variable |
CS8607 |
A possible null value may not be passed to a target marked with the [DisallowNull] attribute |
CS8608 |
Nullability of reference types in type doesn’t match overridden member. |
CS8609 |
Nullability of reference types in return type doesn’t match overridden member. |
CS8610 |
Nullability of reference types in type of parameter doesn’t match overridden member. |
CS8611 |
Nullability of reference types in type of parameter doesn’t match partial method declaration. |
CS8612 |
Nullability of reference types in type doesn’t match implicitly implemented member. |
CS8613 |
Nullability of reference types in return type doesn’t match implicitly implemented member. |
CS8614 |
Nullability of reference types in type of parameter doesn’t match implicitly implemented member. |
CS8615 |
Nullability of reference types in type doesn’t match implemented member. |
CS8616 |
Nullability of reference types in return type doesn’t match implemented member. |
CS8617 |
Nullability of reference types in type of parameter doesn’t match implemented member. |
CS8618 |
Non-nullable field is uninitialized. Consider declaring as nullable. |
CS8619 |
Nullability of reference types in value doesn’t match target type. |
CS8620 |
Argument cannot be used for parameter due to differences in the nullability of reference types. |
CS8621 |
Nullability of reference types in return type doesn’t match the target delegate. |
CS8622 |
Nullability of reference types in type of parameter doesn’t match the target delegate. |
CS8624 |
Argument cannot be used as an output for parameter due to differences in the nullability of reference types. |
CS8625 |
Cannot convert null literal to non-nullable reference type. |
CS8626 |
The ‘as’ operator may produce a null value for a type parameter. |
CS8629 |
Nullable value type may be null. |
CS8631 |
The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn’t match constraint type. |
CS8632 |
The annotation for nullable reference types should only be used in code within a ‘#nullable’ annotations context. |
CS8633 |
Nullability in constraints for type parameter doesn’t match the constraints for type parameter in implicitly implemented interface method’. |
CS8634 |
The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn’t match ‘class’ constraint. |
CS8638 |
Conditional access may produce a null value for a type parameter. |
CS8643 |
Nullability of reference types in explicit interface specifier doesn’t match interface implemented by the type. |
CS8644 |
Type does not implement interface member. Nullability of reference types in interface implemented by the base type doesn’t match. |
CS8645 |
Interface is already listed in the interface list with different nullability of reference types. |
CS8653 |
A default expression introduces a null value for a type parameter. |
CS8654 |
A null literal introduces a null value for a type parameter. |
CS8655 |
The switch expression does not handle some null inputs. |
CS8667 |
Partial method declarations have inconsistent nullability in constraints for type parameter |
CS8714 |
The type cannot be used as type parameter in the generic type or method. Nullability of type argument doesn’t match ‘notnull’ constraint. |