12 мая

C# 8 и null-допустимость. Как нам с этим жить

Блог компании Издательский дом «Питер»Программирование.NETC#Профессиональная литература
Перевод
Автор оригинала: Ian Griffiths
Здравствуйте, коллеги! Пришло время упомянуть, что у нас в планах — выпуск фундаментальной книги Иэна Гриффитса по C#8:


Тем временем, в своем блоге автор опубликовал две взаимосвязанные статьи, в которых рассматривает тонкости таких новых явлений, как «nullability», «null-obliviousness» и «null-awareness». Мы перевели обе статьи под одним заголовком и предлагаем их обсудить.

Наиболее амбициозная новая фича в C# 8.0 называется nullable references (ссылки, допускающие null).

Цель этой новой фичи – сгладить ущерб от опасной штуки, которую ученый-информатик Тони Хоар в свое время назвал своей «ошибкой на миллиард долларов». В C# есть ключевое слово null (эквивалент которого встречается и в многих других языках), и корни этого ключевого слова прослеживаются вплоть до языка Algol W, в разработке которого участвовал Хоар. В этом древнем языке (он появился в 1966 году) переменные, ссылающиеся на экземпляры определенного типа, могли получать особое значение, указывающее, что прямо сейчас эта переменная никуда не ссылается. Эта возможность очень широко заимствовалась, и сегодня многие специалисты (в том числе, сам Хоар) считают, что именно она стала самым большим источником затратных программных ошибок всех времен.

Что не так с допущением нуля? В мире, где любая ссылка может указывать на нуль, вам приходится учитывать это везде, где в вашем коде используются какие-либо ссылки, иначе вы рискуете получить отказ во время исполнения. Иногда это не слишком обременительно; если вы инициализируете переменную с выражением new там же, где объявляете ее, то вам известно, что эта переменная не равна нулю. Но даже такой простой пример сопряжен с некоторой когнитивной нагрузкой: до выхода C# 8 компилятор не мог подсказать вам, делаете ли вы что-либо, способное обратить это значение в null. Но, как только вы приступаете к сшиванию разных фрагментов кода, судить с определенностью о таких вещах становится гораздо сложнее: насколько вероятно, что вот это свойство, которое я сейчас считываю, может вернуть null? Разрешено ли передавать null в тот метод? В каких ситуациях я могу быть уверен, что вон тот метод, который я вызываю, установит этот аргумент out не в null, а в другое значение? Причем, дело даже не ограничивается тем, чтобы не забывать проверять такие вещи; не вполне понятно, что вам делать, если вы все-таки столкнетесь с нулем.

С численными типами в C# такая проблема отсутствует: если вы пишете функцию, принимающую на вход некоторые числа и выдающую число в качестве результата, то вам не приходится задумываться, действительно ли являются числами передаваемые значения, и может ли среди них затесаться ничто. Вызывая такую функцию, не требуется задумываться, может ли она вернуть ничто вместо числа. Разве что такое развитие событий интересует вас как опция: в таком случае можно объявлять параметры или результаты типа int?, обозначая, что в данном конкретном случае вы действительно хотите допустить передачу или возврат нулевого значения. Итак, для численных типов и, в более общем смысле, значимых типов допустимость нуля всегда была из разряда тех вещей, которые делаются добровольно, в качестве опции.

Что же касается ссылочных типов, до C# 8.0 допустимость нуля мало того что задавалась по умолчанию – так ее к тому же нельзя было отключить.

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

Однако, как только вы включаете эту новую фичу – все меняется. Простейший способ активировать ее – добавить <Nullablegt;enable</Nullablegt; внутри элемента <PropertyGroup> в вашем файле .csproj. (Отмечу, что доступен и более филигранный контроль. Если действительно очень нужно, то можно сконфигурировать поведение, допускающее null, отдельно в каждой строке. Но, когда мы не так давно взялись включать эту возможность во всех наших проектах, выяснилось, что активировать ее в масштабах одного проекта за раз – вполне выполнимая задача.)

Когда в C# 8.0 ссылки, допускающие null, полностью активированы, ситуация меняется: теперь по умолчанию предполагается, что ссылки не допускают null, только если вы сами не укажете обратного, точно, как со значимыми типами (даже синтаксис такой же: можно было написать int?, если вы в самом деле хотели, чтобы целочисленное значение было опциональным. Теперь вы пишете string?, если имеете в виду, что хотите либо ссылку на строку, либо null.)

Это весьма значительное изменение, и, прежде всего, в силу его значительности эта новая фича по умолчанию отключена. Microsoft могла бы спроектировать эту языковую возможность иначе: можно было бы оставить ссылки по умолчанию допускающими null и ввести новый синтаксис, который позволял бы вам указывать, что вы хотите обеспечить недопущение null. Возможно, это снизило бы планку при изучении данной возможности, но в долгосрочной перспективе такое решение было бы неверным, поскольку на практике большинство ссылок в огромной массе кода на C# вообще не рассчитаны указывать на null.

Допущение нуля – это исключение, а не правило, и именно поэтому, при включении этой новой языковой возможности недопущение null становится новым умолчанием. Это отражено даже в оригинальном названии фичи: “nullable references.” Название любопытное, учитывая, что ссылки могли указывать на null еще со времен C# 1.0. Но разработчики предпочли акцентировать, что теперь допущение null переходит в разряд таких вещей, которые нужно явно запрашивать.

C# 8.0 сглаживает процесс внедрения ссылок, допускающих null, так как позволяет вводить эту возможность постепенно. Не приходится делать выбор «да или нет». Это весьма отличается от фичи async/await, добавленной в C# 5.0, которая имела тенденцию распространяться: фактически, асинхронные операции обязывают вызывающую сторону быть async, а значит, и код, вызывающий эту вызывающую сторону, должен быть async, и так до самой вершины стека. К счастью, типы, допускающие null, устроены иначе: их можно внедрять выборочно и постепенно. Можно прорабатывать файлы один за другим, или даже построчно, если потребуется.

Самый важный аспект типов, допускающих null (благодаря которому переход на них упрощается), состоит в том, что по умолчанию они отключены. В противном случае большинство разработчиков отказались бы от использования C# 8.0, поскольку из-за такого перехода практически в любой базе кода стали бы возникать предупреждения. Однако по тем же причинам порог вхождения для использования этой новой возможности ощущается как довольно высокий: если новая фича привносит настолько разительные изменения, что по умолчанию отключена, то, возможно, вам также не захочется с нею связываться, а проблемы, связанные с переключением на нее, всегда будут казаться лишней морокой. Но этого стоило бы постыдиться, ведь фича очень ценная. Она помогает найти в коде баги прежде, чем за вас это сделают пользователи.

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

Только предупреждения

Наиболее грубый уровень контроля над всем проектом после простого вкл/выкл – это возможность активировать предупреждения независимо от аннотаций. Например, если я полностью включу допущение нуля для Corvus.ContentHandling.Json в нашем репозитории Corvus.ContentHandling, добавив <Nullablegt;enable</Nullablegt; в группу свойств в файле проекта, то в его текущем состоянии сразу же появится 20 предупреждений от компилятора. Однако, если вместо этого я воспользуюсь <Nullablegt;warnings</Nullablegt;, то получу всего одно предупреждение.

Но подождите! Почему мне будут показывать меньше предупреждений? В конце концов, здесь я только и просил, что о предупреждениях. Ответ на этот вопрос не вполне очевиден: дело в том, что некоторые переменные и выражения могут быть null-нейтральны (null-oblivious).

Null-нейтральность

В C# поддерживается две трактовки допустимости null. Во-первых, любая переменная ссылочного типа может объявляться как допускающая или не допускающая null, а во-вторых компилятор будет по возможности логически заключать, может или нет данная переменная быть null в любой конкретной точке кода. В этой статье речь идет только о первой разновидности допустимости null, то есть, о статическом типе переменной (на самом деле, это касается не только переменных и близких им по духу параметров и полей; как статическая, так и логически выводимая допустимость null определяется для каждого выражения в C#.) Фактически, допустимость null в ее первом понимании, та, которую мы сейчас рассматриваем, является расширением системы типов.

Однако, оказывается, что, если мы сосредоточимся только на допустимости null для типа, ситуация окажется не столь стройной, как можно было бы предположить. Это не просто противопоставление «допустимости null» и «недопустимости null». На самом деле, существует еще две возможности. Есть категория “unknown” («неизвестно»), которая является обязательной в силу наличия дженериков; если у вас будет неограниченный параметр типа, то невозможно будет что-либо узнать о допустимости null для него: код, использующий соответствующий обобщенный метод или тип, может подставить в них аргумент, или допускающий, или не допускающий null. Добавить ограничения можно, но зачастую такие ограничения нежелательны, поскольку сужают область применения обобщенного типа или метода. Так, у переменных или выражений некоторого неограниченного параметра типа T должна быть неизвестна (не)допустимость нуля; возможно, в каждом конкретном случае вопрос о допустимости null для них будет решаться отдельно, но мы не знаем, какой вариант окажется в коде дженерика, так как он будет зависеть от аргумента типа.

Последняя категория называется “нейтральной”. По принципу «нейтральности» все работало до появления C# 8.0, и так и будет работать, если вы не активируете возможность работы со ссылками, допускающими null. (В принципе, это пример ретроактивности. Даже хотя идея null-нейтральности была впервые введена в C# 8.0 как естественное состояние кода до активации допустимости null для ссылок, проектировщики C# настаивали, что на самом деле это свойство никогда не было чуждо C#.)

Пожалуй, не приходится объяснять, что означает «нейтральность» в данном случае, поскольку именно в таком ключе C# всегда работал, так что вы и сами все понимаете… хотя, пожалуй, это немного нечестно. Итак, слушайте: в мире, где известно о допустимости null, важнейшая характеристика null-нейтральных выражений заключается в том, что они не вызывают предупреждений по поводу допустимости null. Можно присвоить null-нейтральное выражение как допускающей null переменной, таки не допускающей. Null-нейтральным переменным (а также свойствам, полям, т.д.) можно присваивать выражения, которые компилятор счел “возможно null” или “не null”.

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

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

public static class NullableAwareClass
	{
	    public static string? GetNullable() => DateTime.Now.Hour > 12 ? null : "morning";
	

	    public static int RequireNonNull(string s) => s.Length;
	}

Далее, в другом проекте я могу написать этот код в контексте, где активированы предупреждения о допустимости null, но отключены соответствующие аннотации:

static int UseString(string x) => NullableAwareClass.RequireNonNull(x);

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

При помощи такой обертки я фактически скрыл, что в коде учитывается допустимость null. Это означает, что теперь я могу написать так:

	int x = UseString(NullableAwareClass.GetNullable());

Компилятору известно, что GetNullable может вернуть null, но, поскольку я вызвал метод с null-нейтральным параметром, программе не известно, правильно это или неправильно. Воспользовавшись null-нейтральной оберткой, я обезоружил компилятор, который теперь не видит здесь проблемы. Однако, если бы я скомбинировал два этих метода напрямую, все было бы иначе:

int y = NullableAwareClass.RequireNonNull(NullableAwareClass.GetNullable());

Здесь я передаю результат GetNullable прямо в RequireNonNull. Если бы я попробовал сделать это в контексте, где включены предупреждения о допущении null, то компилятор сгенерировал бы предупреждение, независимо от того, включил бы я или отключил контекст соответствующих аннотаций. В данном конкретном случае контекст аннотаций не имеет значения, поскольку нет объявлений со ссылочным типом. Если активировать предупреждения о допущении null, но отключить соответствующие аннотации, то все объявления станут null-нейтральными, что, однако, не означает, что таковыми станут все выражения. Так, нам известно, что результат GetNullable допускает null. Поэтому мы получаем предупреждение.

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

int z = NullableAwareClass.GetNullable().Length;

Если ваш код хорошо оформлен, то в нем не должно быть большого количества ошибок такого рода.

Постепенное аннотирование всего проекта

После того, как вы сделаете первый шаг – просто активируете предупреждения, то дальше можно переходить к постепенной активации аннотаций, файл за файлом. Удобно включить их сразу во всем проекте, посмотреть, в каких файлах появляются предупреждения – а потом выбрать файл, в котором предупреждений относительно немного. Снова отключить их на уровне всего проекта, а вверху выбранного вами файла написать #nullable enable. Так полностью включится допущение null (как для предупреждений, так и для аннотаций) во всем файле (если только не отключить их снова при помощи еще одной директивы #nullable). Затем можно пройти через весь файл и позаботиться, чтобы все сущности, которые, вероятно, могут оказаться null, были аннотированы как допускающие null (т.e., добавить ?), а потом разобраться с предупреждениями в этом файле, если таковые останутся.

Может оказаться, что добавление всех необходимых аннотаций – все, что требуется для устранения всех предупреждений. Возможно и обратное: вы можете заметить, что, когда аккуратно аннотировали один файл по поводу допустимости null, в других файлах, использующих его, всплыли дополнительные предупреждения. Как правило, таких предупреждений оказывается не много, и вы успеваете быстро их исправить. Но, если по каким-то причинам после этого шага вы просто потонете в предупреждениях, то у вас остается пара решений. Во-первых, можно просто отменить выбор, оставить этот файл и взяться за другой. Во-вторых, можно выборочно отключить аннотации по тем членам, которые, как вам кажется, доставляют больше всего проблем. (Директиву #nullable вы можете использовать столько раз, сколько хотите, поэтому настройки допустимости null можно контролировать даже построчно, если вы этого хотите.) Возможно, если вы вернетесь к этому файлу позднее, когда уже активируете допустимость null в большей части проекта, то увидите уже меньше предупреждений, чем в первый раз.

Бывают случаи, когда таким прямолинейным путем проблемы не решаются. Так, в определенных сценариях, связанных с сериализацией (например, при применении Json.NET или Entity Framework) работа может оказаться более непростой. Думаю, эта проблема заслуживает отдельной статьи.

Ссылки с допущением null улучшают выразительность вашего кода и повышают шансы, что компилятор выловит ваши ошибки раньше, чем на них наткнутся пользователи. Поэтому данную фичу по возможности лучше включать. А, если включать ее выборочно, то и польза от нее начнет ощущаться быстрее.
Теги:C#.NETпрограммированиекнигиисследованиеструктуры данных
Хабы: Блог компании Издательский дом «Питер» Программирование .NET C# Профессиональная литература
+13
5,3k 48
Комментарии 9
Лучшие публикации за сутки