Продвинутое руководство по nullable reference types

Одно из самых больших изменений в C# 8 — это nullable reference types. Ранее Андрей Дятлов (JetBrains) рассказал на конференции DotNext о трудностях и проблемах, которые вы можете встретить при работе с ними. Доклад понравился зрителям, поэтому теперь для Хабра готова его текстовая версия.

fqukj3cjmlpikiduj_hlp86mtta.jpeg

Наиболее полезным пост будет для тех, кто планирует использовать nullable reference types в больших проектах, которые невозможно перевести на использование NRT и проаннотировать целиком за короткое время; проектах, в которых используются собственные решения для ассертов или исключений, либо методы со сложными контрактами, связывающими наличие null во входных и выходных значениях, так как эти методы придется аннотировать для корректной работы компилятора с ними.

Я оставляю ссылку на оригинальный доклад. Дальше повествование пойдет от лица Андрея Дятлова, а пока что последний момент от меня: мы уже вовсю готовим осенний DotNext, и до 16 августа включительно принимаем заявки на доклады, так что если вам тоже есть о чем поведать дотнетчикам, откликайтесь.


Я занимаюсь поддержкой C# c 2015 года. В основном пишу анализаторы кода, занимаюсь рефакторингом и поддержкой новых версий языка. А по совместительству нахожу еще и баги в Roslyn.


План доклада


  • Краткое описание nullable reference types
  • Способы постепенного перевода проекта на их использование
  • Взаимодействие с обобщенным кодом
  • Аннотации для помощи компилятору
  • Что делать, если компилятор не прав?
  • Подводные камни
  • Warnings as errors


Что такое nullable reference types?

3a4tdbu9rvqtcgdhroiftda0kzc.jpeg

До C# 8 вы могли объявить вот такой класс сотрудника, дать ему поля: имя, фамилия, день рождения и при помощи структуры Nullable с помощью вопроса на конце указать, что день рождения у сотрудника может быть не заполнен. И при попытке обращаться к этому свойству вам приходилось проверять, есть ли там действительно значение, при помощи свойства HasValue. Явно оттуда его доставать при помощи Value, либо делать это при помощи Conditional Access (?.).

va6vc1upirmoxndracmg9kleblk.jpeg

Но с reference-типами у вас не было такой подсказки, и если вам приходилось модифицировать этот класс, например, добавить в него метод получения инициалов сотрудника (по первым буквам имени и фамилии), то у вас возникали вопросы. А может ли фамилия сотрудника быть незаполненной?

cn6hwc5mncuc6infjklr-69xiyo.jpeg

Теперь, начиная с C# 8, вы можете использовать тот же самый синтаксис для reference-типов, добавить вопрос в конец для поля Surname, и сказать, что имя у сотрудника есть всегда, а фамилию он может не заполнять. Компилятор теперь сразу подскажет вам, что когда вы обращаетесь к первой букве фамилии, фамилии может не быть, и в этом случае может произойти исключение. Причем если мы посмотрим на то, как этот метод скомпилирован, то обнаружим, что идет запрос к свойству Surname, и после этого сразу идет обращение к первому символу, то есть никаких рантайм-проверок на самом деле не добавилось. Если там Null, то вы все еще получите NullReferenceException. Теперь видите это предупреждение и понимаете, что фамилия может null. Можете его исправить при помощи все того же Conditional Access (.?) для доступа к первому элементу.


Отличие от Nullable

ejcekumrsv3stpmzolvho0wnkvo.jpeg

А чем это отличается от Nullable для структур? Nullable — специальный тип, компилятор о нем знает, и если вы измените возвращаемый тип метода на Nullable, то у вас изменится сигнатура метода.

А nullable reference types — это просто аннотация в системе типов. И если вы ее меняете, никаких разрушительных последствий обычно не происходит. Кроме того, с Nullable вам приходится явно получать значение, которое там лежит, при помощи обращения к свойству Value, а с nullable reference types все работает как раньше, то есть никаких церемоний с тем, чтобы получить значение, нет. Но и компилятор в рантайме ничего проверять за вас не будет, он просто выдаст вам предупреждение, когда вы будете компилировать проект. И если что-то пошло не так, то всё будет работать по-старому с NullReferenceException.


Интересное сравнение реализаций null safety в C# и Kotlin в докладе Kotlin и С#. Чему языки могут поучиться друг у друга?(по ссылке тайм-код) Дмитрия Иванова.


Преимущества аннотаций

es921runnitz6u0zrf9blmr8ybm.jpeg

Скорее всего, кто-нибудь уже использует аннотации для решения проблем с null reference. Вы можете проаннотировать поля и коллекции при помощи атрибутов. А в чем тогда преимущество новой фичи языка?

Дело в том, что новая фича языка — это аннотация, которую можно использовать везде, где вы используете тип, но там, где нельзя было использовать атрибуты. Либо для таких сценариев, где атрибуты не предусмотрены. Например, если у вас есть вложенная коллекция, вы не могли ее проаннотировать атрибутами. Если у вас есть какой-то дженерик-тип, и один из типов аргументов должен быть nullable, вы тоже не могли это выразить атрибутами.

Кроме того, вы не могли использовать атрибут там, где используются локальные переменные, где вы реализуете интерфейс или наследуетесь c какого-то класса, с ограничениями какого-то типа параметров. То есть во многих местах языка атрибуты в принципе запрещено писать, поэтому это нельзя было сделать. А новый синтаксис можно использовать везде, где вы можете в принципе написать тип. Это вcё, что я хотел сказать про саму фичу языка.

Это очень кратко, но команда компилятора, по их собственным
оценкам, потратила на реализацию примерно 15 человеко-лет и, конечно, они
подумали о том, с какими сложностями вы можете столкнуться и как это добавить в существующие проекты, в которых уже есть достаточно много кода, и он написан по-старому.


Включаем и пользуемся!

Для начала рассмотрим проблемы, с которыми вы можете столкнуться, если просто включите эту фичу для своего проекта.

g2ubiflxnbowechh-omkjgms1km.jpeg

У вас есть функция, она принимает какой-то ввод. Если этот ввод оказался null, спрашивает у пользователя ввод, если это разрешено, (bool allowUserInput) проверяет, что там парсится число в этой строке и после этого возвращает либо строку с числом, либо null. Если мы сейчас в этом проекте включаем nullable reference types, то у нас на каждой строке этого проекта появится предупреждение.

zdvpwtom3fvsf_taitr2sbfb4eg.jpeg

Почему это происходит? Дело в том, что старый синтаксис, когда вы не указали вопрос после типа, теперь означает, что в этом типе никогда не бывает null, и компилятор не только не предупреждает вас, если вы пытаетесь прочитать оттуда значения, но и запрещает вам положить null в такую переменную при помощи предупреждения.

Починить мы это можем так: указываем, что в переменной input может лежать null, и вернуться из этого метода тоже может null. Нужно всего лишь объявить входную и выходную строку как nullable. Это достаточно просто, но трудоемко на больших проектах.


Предупреждения компилятора без лишних усилий

Наверное, многие знают библиотеку NewtonsoftJson. Ее перевели на nullable reference types, и для этого пришлось изменить 170 файлов и 4000 строк кода. Если ваш проект немного больше, то скорее всего вам будет еще тяжелее.

Что можно сделать, если вы не хотите сейчас переписывать весь свой проект и тратить на это несколько месяцев или лет? Команда компилятора предусмотрела следующие опции: если вы хотите включить анализ только для части проекта, а для какой-то — нет, если вы хотите, чтобы вот прямо сейчас компилятор вам помог с проблемами в вашем коде, но вы пока вообще ничего не готовы для этого делать, ни одной аннотации проставлять.

fdlji1gsnrgplhh8ujus1hfuhlm.jpeg

Если вы пишете библиотеку, то можете захотеть проаннотировать ее только для пользователей, но пока не пользоваться этой фичей. Это тоже можно. Сейчас я расскажу подробнее о том, как всё это работает.

Во-первых, если у вас есть банковский софт, вы создаете транзакции, у вас есть клиент, информацию о котором нужно заполнить, вы проверяете, в какой стране живет клиент, требуется ли информация о почтовом индексе.

ybj5kvxrusxrrr2gkyej-rtuhwc.jpeg

Если вы прямо сейчас, ничего не аннотируя, просто включите здесь предупреждения при помощи препроцессора #nullable enable warnings, компилятор подскажет вам, что во втором if адрес может быть null. Вы можете наткнуться на NullReferenceException.

Почему он это знает? Дело в том, что в предыдущем if, когда вы проверяли строку, где живет клиент, вы при помощи вопроса с точкой сказали компилятору: вы как программист считаете, что здесь может встретиться null. Компилятор это запомнил, поэтому когда вы потом в следующем If обратились без этой проверки, компилятор говорит, что нет, мы же знаем, что здесь может быть null, вы уже проверяли. И предупреждает вас об этом. Может быть, это действительно ошибка в проекте, у вас просто никогда не было такой комбинации информации о клиенте и обязательных полей.

cm8xnya-36mqdg5ewujvamlvy5g.jpeg

Компилятор может узнать о том, что вы хотите видеть здесь предупреждение, либо если вы явно присвоили туда null, либо если вы явно предположили, что там бывает null при помощи проверки в If или conditional access. Несмотря на то, что мы пока включили только предупреждение, вы уже можете аннотировать свой проект и сказать, что конкретно эта переменная бывает null. Чем это тогда отличается от того, что мы включим вообще всю фичу, если мы уже можем аннотировать и получать предупреждение?

v_mtk_r3ubfrajfwrnxbgvpgu4s.jpeg

Отличие в том, что по умолчанию, если вы включили nullable reference types, то типы, которые были написаны по старому, без вопросов на конце, стали not-nullable types. Это такие типы, в которые нельзя присвоить null, и компилятор вас об этом предупреждает. Именно из-за этого у нас в предыдущем примере с функцией на несколько строк были предупреждения.

Если вы включили только предупреждения, но не всю фичу, то такие типы станут oblivious types. Это понятия для типов, когда компилятор не знает, что там лежит, и поэтому по умолчанию считает, что пользователь может пользоваться этой переменной как угодно. Может даже присвоить в нее null, предупреждений не будет. Таким образом, вы получите минимальное количество предупреждений, которые скорее всего сигнализируют об ошибках в коде. Потому что они получены из предыдущих проверок в этом же коде.


Продолжаем аннотировать

Допустим, у вас есть в компании еще один отдел, который пользуется теми же интерфейсами, которые вы пишете. И у вас есть информация о клиенте, которой мы только что пользовались.

8s8yqjjtkkuavqajpitwmxzizdo.jpeg

У клиента есть имя, фамилия, отчество, адрес, о котором мы уже знаем по проверкам в коде, что он бывает null. Паспорт, телефон и имейл, можно отправить нотификацию о том, что какая-нибудь его транзакция не прошла, потому что он указал неправильные данные.

И скорее всего, этот отдел периодически будет ходить к вам с вопросами о том, можно ли здесь передать null, можно ли, например, отправить этому клиенту нотификацию без указания транзакции, просто отправить сообщение с рекламой? Будет ли оно вообще в этом случае создано, и может ли клиент от этого отписаться? Вы можете, не включая всю фичу, добавить только препроцессор #nullable enable annotations, и в этом случае компилятор будет считывать аннотации, которые вы добавите в этот код, и пользоваться ими для того, чтобы положить их в .dll.

Здесь я указал, что у клиента мы всегда спрашиваем имя и фамилию, а отчество он может не заполнять. Адреса у него может не быть, телефон он обязан оставить, а имейл — как пожелает.

xui78t1f9_cp_rlp0z69nut1iv0.jpeg

Теперь посмотрим на декомпилированный код этой библиотеки — он покрылся атрибутами [NullableContext]. Я не буду вдаваться в подробности, как они работают, потому что это важно только компилятору. Но он будет их считывать, когда вы подключите эту библиотеку к вашему проекту, и будет предупреждать пользователей вашей библиотеки о том, как ей пользоваться.

Аналогичным образом это происходит, если вы используете в проекте dynamic — это всё тоже компилируется в атрибуты и скорее всего, вам это тоже не требуется знать, потому что это важно только для разработчиков компилятора.

Я перед этим говорил, что вы можете пользоваться этими аннотациями потому, что можете применять их везде, в том числе там, где атрибут повесить нельзя. Например с локальными переменными.

Как они тогда компилируются? Дело в том, что они вообще не компилируются, так как компилятор уже выдал вам о них предупреждения. И после этого их снаружи использовать нельзя, поэтому проблемы как бы и нет, ничего не изменилось вообще в результирующем коде.


Теперь мы готовы

Мы уже проаннотировали часть проекта, включили предупреждения, и наверное, можно даже на каком-то проекте включить всю фичу целиком. Вы можете сделать это либо в .csproj-файле при помощи свойства nullable enable, либо на уровне отдельных классов.

71bki3skxtxzvg7ze_c_ch2oqdu.jpeg

Включить, например, только в этом классе анализ, либо включить его только для метода, который может быть проаннотирован и более важен для вашего продукта. Не менее важной фичей является возможность отключить этот анализ на какой-нибудь части проекта. Опять же пример из NewthonsoftJson. Здесь был класс JsonValidatingReader, в котором было примерно 1000 строк кода, и этот код уже был обсолетным, его не нужно использовать в этой библиотеке, он переехал в другой пакет, поэтому аннотировать его, наверное, не имеет большого смысла. Это трудозатратно: просмотреть в очередной раз тысячу строк кода и подумать о том, что где-то тут может быть null, а где-то не может.

Поэтому вы можете при помощи препроцессора #nullable disable выключить в этой части вашего проекта и анализы, и аннотации. В результате оно скомпилируется без каких-либо аннотаций в качестве атрибутов. Люди, которые будут подключать эту библиотеку к своему проекту, будут знать о том, что эта часть контракта не проаннотирована.


Работаем с обобщенным кодом

Дальше расскажу о том, как это работает с обобщенным кодом. Но перед этим нужно понять, какие теперь есть отношения между nullable reference types и обычными.

Если у вас есть not-nullable строка, то с точки зрения системы типов она будет являться подтипом nullable-строки. Это логично, потому что всё, что вы можете положить в nullable-строку — это все значения not-nullable строки и еще одно дополнительное значение null. Поэтому в местах, где компилятор захочет вывести общий тип, например, если у вас создается массив и вы кладете в него переменные одновременно и с nullable-строками, и not-nullable строками, то компилятор будет выводить наиболее общий тип.

zrhweesa2lbgsejc0rmpfkkd06y.jpeg

В данном случае это nullable-строка. Аналогично это будет работать в любом месте, где компилятору требуется вывести какой-то общий тип для нескольких переменных.

Например, если у вас есть какой-нибудь дженерик-метод, и вы в один и тот же тип параметров передаете аргумент с разными аннотациями. Кроме того, если вы получили oblivious-строки (напоминаю, что это строки из не проаннотированных контекстов). Например, из подключенной библиотеки, где автор не предоставил аннотации. Либо из той части проекта, где вы анализ выключили, но будучи проаннотированными, они будут автоматически конвертироваться в not-nullable строки.

a9nj5v-tfes38gtcrcsicta4tpu.jpeg

То есть вы вызвали не проаннотированный метод, положили его в переменную. Компилятор теперь считает что там не может быть null, разрешает вам ей пользоваться и предупреждает, если вы положите туда null.

Но это с простыми типами, а как быть с дженериками? Если у вас есть последовательность not-nullable строк и последовательность nullable-строк, то с точки зрения системы типов вы можете положить not-nullable строки в nullable, но не наоборот.

tdxwvzsddkalp5se6fxdg6yxc7y.jpeg

Почему это происходит? Дело в том, что если у вас есть метод, который принимает последовательность строк, среди которых могут встретиться null значения, он их будет проверять, и если ни одного null не встретится, то проблемы нет, мы просто зря выполнили проверку. Но всё по-прежнему будет работать. А наоборот, к сожалению, работать не будет. Если у вас есть метод, и он принимает только not-nullable строки, то проверять на null он их не будет. Первый же null обрушит вашу программу с NullReferenceException.

kw20cbluon_gkjlbizmoeyayfrw.jpeg

С Action это будет работать немного по-другому. Если у вас есть метод, который говорит, что хочет Action, работающий с null значениями, то он может их туда передать. Если метод говорит, что работает только с Not-Null Action то он никуда null не передаст. Соответственно nullableAction готов и к тому развитию событий, и к другому. Если передали null, то работает с ним, а если не передают null — тоже работает. Его можно передавать куда угодно. Not-Nullable Action — только туда, где аннотации совпадают.

u_mebrlikorjsdfddlfycezkwqo.jpeg

И у нас получилась теперь интересная ситуация, когда nullable-типы находятся с разных сторон. К вопросу о том, что я объяснял ранее относительно того, какой тип является подтипом другого.

qdibvu3qrbhcllxir2tm30rqlak.jpeg

Дело в том, что если у вас есть какой-то список, и вы присваиваете одну переменную в другую, и у них есть по-разному проаннотированные элементы, то теперь у вас в программе появилось две ссылки на один и тот же list, но типизированные по-разному. А контракт списка позволяет как добавить в него элементы, так и прочитать. Мы можете через ссылку, которая говорит, что это list от nullable-строк, добавить null, а через ссылку, которая говорит, что здесь null не бывает, прочитать его обратно и получить в рантайме исключение. Чтобы такого не было, компилятор в принципе запрещает вам преобразование из одного типа в другой. Причем как в одну, так и в другую сторону, потому что в конечном итоге у вас точно так же появляются две ссылки на один и тот же list.


Изменения с выводом типов

Не менее интересны изменения, которые произошли с выводом типов. Допустим, у вас есть переменная, тип которой мы выводим из правой части, то есть обозначенная как var.

_k4op_pjokgv83fa6wiotoipn1q.jpeg

Обычно мы для этого смотрим на то, чем ее инициализировали. В данном случае — параметром x. Параметр x объявлен как Nullable string. inferred тоже должен, казалось бы, вывестись, как Nullable string, компилятор должен потребовать проверить, что там не null перед тем, как вы им пользуетесь. И разрешить присваивать туда null.

Существует альтернативный вариант: мы можем посмотреть на значения, которые там находятся, то есть сделать честный Data Flow Analysis метода, узнать, что мы уже проверили, что конкретно в этой переменной null уже не бывает, и вывести not-nullable string и разрешить пользоваться этой переменной. Но предупредить, если мы присваиваем туда null.

На самом деле, компилятор проведет честный Data Flow Analysis, после этого еще раз подменит у себя информацию о типе переменной и скажет, что хорошо, мы знаем, что здесь никогда null значений не бывает, выведет not-nullable тип, разрешит ей пользоваться, запретит вам присваивать туда null. Потому что тип переменной все еще not null, а не что-то неизвестное.


Новые ограничения для параметров типа

Кроме того, теперь с nullable-reference типами вы можете добавлять generic constraints, аннотируя их. А старые constraints теперь означают, что сюда нельзя подставлять nullable-reference типы. Только старые типы, не допускающие null значений, которые не нужно проверять.

1mdnm-icwcpfyzrtcxto5dfqqma.jpeg

Если вы хотите это изменить, то вам нужно воспользоваться тем же самым новым синтаксисом с вопросом на конце. Такое же расширение получил constraint class (where T: class). Теперь у вас class означает, что сюда можно поставить всё, но только не nullable классы. Но можно записать его с вопросом, и тогда можно будет подставить любой reference-тип. Кроме того, появился новый constraint notnull (where T: notnull), который будет означать, что сюда можно подставить любой тип, который не допускает значение null. То есть либо структура, либо not-nullable reference type. Поэтому теперь, если у вас есть класс, метод с constraint class:

9wmgr7v4v2_gyz5muv7x6zbjovk.jpeg

Он теперь компилируется как constraint на not-nullable reference type. Вы получаете предупреждение, если пытаетесь поставить туда nullable reference types. И вы теперь можете пользоваться с этим типом параметра тем же самым синтаксисом. Потому что теперь компилятор знает, что это reference-тип, что его можно проаннотировать, и позволяет вам также писать вопросы, как и с обычными string. Давайте попытаемся воспользоваться этим и проаннотировать какой-нибудь утильный метод из вашего фреймворка.


Аннотируем свой фреймворк

Например, у нас будет метод, который получает коллекцию элементов с constraint на IKeyOwner и пытается найти элемент по ключу. Если получилось — возвращает его. Если нет — то возвращает дефолтное значение.

yjgniei-p2phzoz_s1a3mclilf4.jpeg

Дело в том, что если мы попытаемся так написать, то компилятор скажет нам, что он не знает, что значит этот синтаксис. Дело в том, что интерфейс IKeyOwner можно реализовать как структурой, так и классом. А синтаксис с вопросом на конце должен означать для них разные вещи.

В одном случае это должно означать, что метод должен вернуть структуру Nullable, в другом случае это должно означать, что это просто аннотация, a типы не меняются.

Как быть? Компилятор в своей подсказке предлагает нам добавить constraint class. Давайте попробуем это сделать.

2n-cpzatjhhzypghksjtdkg8amo.jpeg

Что произошло? Во-первых, теперь мы не можем пользоваться этими структурами, но возможно, они у вас в проекте не используются, и вам это не очень важно.

К сожалению, с классами тоже не всё до конца работает, потому что мы задали constraint на not-nullable классы, а хотели бы пользоваться любыми. Если есть массив, в котором лежат nullable-значения, то мы не можем использовать этот метод, чтобы найти в нем элемент. Может быть, нам поможет новый constraint class?

bzkc_evj03mepr0tgm8j_axyro8.jpeg

Во-первых, при этом мы должны убрать вопрос с возвращаемого значения, потому что компилятор говорит, что и так можно подставить Nullable. Но кроме того, это теперь не работает с Not-Nullable. Если у нас есть массив, в котором null не бывает, мы пытаемся найти в нём элемент, в качестве типа аргумента выводится not-nullable тип, сигнатура говорит, что возвращает not-nullable тип, проверять его не требуется, предупреждений нет. Кажется, мы не можем написать такой простой метод.

К счастью, в языке уже есть место, где похожая семантика используется, это FirstOrDefault. Давайте попробуем написать с ним код и посмотрим, что он-то хотя бы работает. Создадим программу, возьмем пустой массив, возьмем из него первый элемент и попытаемся его разыменовать. Никаких предупреждений не случилось, но случилось исключение в рантайме.

svblqdjyzhhtdelmr3snhdyn184.jpeg

Неужели вы думаете, что сигнатуру FirstOrDefault теперь писать нельзя, а язык сломан? Думаете, что можно? А почему тогда этот пример не работает?

На самом деле, сигнатуру FirstOrDefault теперь в языке написать можно, просто не успели обновить этот фреймворк. Если мы посмотрим на исходники .NET Core на GitHub, то мы обнаружим, что даже в превью .NET Core 3.1, который сейчас доступен, сигнатура FirstOrDefault выглядит по-старому, а в master branch, который скорее всего будет в .NET Core 3.2, появился новый атрибут на возвращаемом значении.

image-loader.svg

image-loader.svg

Мы можем попытаться применить его к нашему методу и посмотреть, что произойдет. Этот атрибут лежит в namespace System.Diagnostics.CodeAnalysis.MayBeNull, и теперь внезапно всё начало работать правильно, несмотря на то, что мы подставляем в тип-параметр not-nullable тип, для возвращаемого значения компилятор добавляет к нему аннотацию и выдает нам корректное предупреждение.

1mfg6-gvdyxpxyfaor1_glv8_ci.jpeg

Кстати, а как это будет работать со структурами? Они станут теперь Nullable или нет? На самом деле, всё останется по-старому, потому что этот атрибут действительно просто добавляет аннотацию, если может добавить.

То есть для структур по-прежнему это точно not-nullable значение, всё работает по-старому, а с nullable reference-типами, если вы примените атрибут к возвращаемому значению метода, то компилятор продвинет его до nullable-версии типа, если это возможно. Последним штрихом у нас еще где-то есть Assert в программе, давайте добавим его в этот метод.

-cp0ujvnfgw-lhbtaucwaihimus.jpeg

Опять что-то пошло не так. Дело в том, что теперь мы объявили input как not-nullable параметр, проверили при помощи ассерта, что он действительно not-null, а компилятор требует проверить еще раз.

Это происходит из-за того, что компилятор не знает, что имел в виду метод Assert. У него есть только название, и всё, что видит компилятор, — у нас есть переменная, и по какой-то причине программист проверяет ее на null. Наверное, он там может лежать. Результат этой проверки в виде bool куда-то передается, а затем снова пользуется переменной без проверок. Чтобы компилятор понял, что хотел сказать автор, ему нужно подсказать.

bnlzbo0f6jxwqvpbc_mpbqaa2-c.jpeg

Тоже атрибутом из System.Diagnostics.CodeAnalysis — [DoesNotReturnIf(false)], который теперь скажет компилятору, что если условия, которые мы передали в этот метод, вычисляются в false, то этот метод никогда нормально не вернется. Он либо зациклится, либо выдаст исключение. И теперь этот метод можно использовать действительно для честных asserts, когда вам откуда-то возвращается nullable-строка, например, но вы знаете, что конкретно с этой комбинацией аргументов она null быть не должна, и можете сказать компилятору об этом.


Атрибуты-помощники

В System.Diagnostics.CodeAnalysis довольно много атрибутов, которые в основном помогают компилятору понять, что хотел сказать автор.

fqdadol98gjuw73rmt89etxjidu.jpeg

Если есть дженерик-код, который нельзя правильно проаннотировать, либо если есть какие-то сложные контракты. Когда в общем случае метод должен работать по одному сценарию, но в некоторых условиях может вернуть странное значение.

Я сейчас кратко расскажу о том, что делает каждый из них и где он используется во фреймворке, чтобы у вас был пример, для чего это нужно. А также, если вы пользуетесь аннотациями JetBrains Annotations, какой аннотации JetBrains соответствует каждый из них. Первая группа атрибутов — это [NotNull], [AllowNull], [DisallowNull].

lhesr2fu7a-va7tht4r77g3l0gu.jpeg

Они позволяют переопределить аннотацию. В основном они нужны для дженерик типов параметров, которые компилятор запрещает аннотировать. Например, в IEnumerable.FirstOrDefault уже он используется. Либо в IEqualityComparer.

Кроме того, у вас есть группа атрибутов, которые говорят, что в некоторых случаях метод нормально не завершается — это [NotNull] атрибут. Не нужно путать его с JetBrains annotations Not Null, он называется так же, но лежит в другом namespace и в другой сборке и означает совершенно другое. Он означает, что если вы передали null в какой-то параметр, то метод выбросит исключение.

ev1pa8rk5fz8xwlrzcq-1y2thay.jpeg

И также есть пара атрибутов [DoesNotReturn], [DoesNotReturnIf(true)] или (false), которые во фреймворке используются для того, чтобы проаннотировать Debug.Assert(), Debug.Fail().

image-loader.svg

И последняя группа атрибутов. Это атрибуты, которые позволяют связать входные и выходные значения метода. Например, если у вас есть словарь и он проаннотирован как словарь, в котором никогда null не лежит, то метод TryGetValue, если он вернул false, все еще может вернуть null. То есть несмотря на аннотацию сигнатуры, которая говорит, что в словаре никогда не лежит null-значение, все еще метод может вернуть null. И это выражается таким атрибутом.

Аналогично [NotNullWhen(true)] или false определяет сигнатуру в обратную сторону. И последний атрибут [NotNullIfNotNull] говорит, что если в какой-то параметр был передан not null, то и вернется тоже not null. Например, во фреймворке есть метод Path.GetFileName, который таким контрактом обладает.

pn_pmbopl_fhl2wonqeydl7ufrs.jpeg

И последнее, что хотелось бы сказать про эти атрибуты, — это просто подсказка компилятору, что должен делать метод. Компилятор будет полагаться на эти аннотации при анализе использования метода.

Во-первых, скорее всего, вам это потребуется только с каким-то дженерик-кодом в вашем фреймворке, который обладает странным контрактом, и в повседневной жизни вам не придется непрерывно искать, как проаннотировать ваш метод. Скорее всего, это потребуется сделать один раз при переводе общей кодовой базы вашего проекта. Кроме того, компилятор не будет проверять, что этот метод соответствует тому, что вы проаннотировали, в отличие от code-контрактов. Ни в местах вызова, ни в самом методе ни рантайм-проверок, ни compile time-проверок не будет. Компилятор просто верит вам на слово, и если вы написали контракт для метода, а потом его не придерживаетесь, то сами виноваты.


А что, если компилятор все равно не прав?

А что делать, если компилятор после всего этого оказался неправ, то есть мы не смогли объяснить ему, что хотел сказать автор? В методах, которые мы описали, есть еще одно предупреждение, о котором я не упомянул, — это return default.

8hn2cym0mvfrvz5oiz6_zn7lid4.jpeg

Во-первых, что такое default? В новом мире у нас есть строка, она объявлена как not-nullable строка. Мы присваиваем в нее default. Кто думает, что будет пустая строка и всё хорошо?

На самом деле, несмотря на то, что это not-nullable reference тип, значение default для него все еще null. Аннотации влияют только на compile-time предупреждения. Они не влияют на то, как будет компилироваться это дефолтное значение. Кроме того, если у вас есть сложный класс с конструктором, который принимает какие-то аргументы, компилятор не знает, как создать этот класс, поэтому он всегда будет ставить null, но будет вас предупреждать об этом.

В примере выше именно об этом и предупреждает нас компилятор, что мы возвращаем default, но тип-параметр в этом методе может быть подставлен как not-nullable тип, а про аннотацию он ничего не знает, потому что вернется к ней, когда найдет использование этого метода.

Однако это предупреждение здесь бесполезно, так как мы знаем, что конкретно здесь наши аннотации разрешают вернуть null. Поэтому мы должны быть немного настойчивее. Если сказали вернуть дефолт, вернуть дефолт! Поставьте ! — компилятор вам это разрешит.

l_a60crd8gln8jdsrzklxag4rss.jpeg


Dammit-оператор ! (null-forgiving оператор)

Это новый синтаксис, который неформально называется dammit-оператор, и он используется для того, чтобы в каком-нибудь выражении просто убрать предупреждения компилятора, потому что каждый раз, когда анализ ошибается, писать #pragma warnings disable не очень удобно. Если компилятор в каком-то месте выдал неправильную ошибку, то вы можете либо игнорировать предупреждение, либо добавить ассерт, либо использовать dammit-оператор.

image-loader.svg

Этот оператор также позволяет инициализировать non-nullable переменные nullами, но так лучше не делать. Если у вас есть такое место, то потом вернитесь к нему, инициализируйте корректно. Если вы ее не инициализировали, компилятор узнает об этом и разрешит вам ей пользоваться, если нет — что-то пошло не так.

image-loader.svg

Стоит заметить, что dammit-оператор никогда не добавляет рантайм-проверок, и он просто убирает предупреждение в одном месте.

Кроме того, с dammit-оператором есть еще одна проблема, которую мы рассмотрим на следующем примере.

У нас есть

© Habrahabr.ru