Виммельбух, 3, перетяжка
Виммельбух, 3, перетяжка
Виммельбух, 3, перетяжка

Функциональный C#. Часть 3. Ненулевые ссылочные типы

Аватар Типичный программист
Отредактировано

5К открытий5К показов

Эта статья третья в серии “Функциональный C#”. Все части:

  1. Функциональный C#: Неизменяемые объекты
  2. Функциональный C#: Одержимость примитивами
  3. Функциональный C#: Ненулевые ссылочные типы
  4. Функциональный C#: Обработка исключений

Ненулевые ссылочные типы в C#: введение

Посмотрите на пример, приведенный ниже:

			Customer customer = _repository.GetById(id);
Console.WriteLine(customer.Name);
		

Что-то знакомое, да? Но какие ошибки вы наблюдаете?

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

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

			Customer! customer = _repository.GetById(id);
Console.WriteLine(customer.Name);
		

Где Customer! означает ненулевой тип, т.е. экземпляры такого класса не могут принимать значение null в принципе. Как думаете, было бы здорово быть уверенным в том, что компилятор укажет нам, если какой-либо кусок кода может вернуть null?

Безусловно, было бы круто! Или даже лучше:

			Customer customer = _repository.GetById(id);
Console.WriteLine(customer.Name);
		

То есть, сделать все ссылочные типы по умолчанию ненулевыми (так же, как и типы значений). И если мы захотим применить нулевой тип, то следует воспользоваться таким кодом:

			Customer? customer = _repository.GetById(id);
Console.WriteLine(customer.Name);
		

Можете ли вы себе представить мир без всех этих раздражающих NullReferenceException? Я тоже не могу.

К сожалению, необнуляемые ссылочные типы не могут быть введены в C# как признак языка. Чтобы подробнее узнать об этой теме, рекомендую к прочтению эти статьи:

  1. Статья Эрика Липперта (Eric Lippert)
  2. Интересная, но практически нереализуемое “дизайнерское” решение

Но не волнуйтесь. Хоть мы и не можем заставить компилятор помочь нам и использовать мощности ненулевых ссылочных типов, есть и обходные пути, к которым сейчас и прибегнем. Давайте взглянем на класс Customer, который мы написали в прошлой статье:

			public class Customer
{
    public CustomerName Name { get; private set; }
    public Email Email { get; private set; }
 
    public Customer(CustomerName name, Email email)
    {
        if (name == null)
            throw new ArgumentNullException("name");
        if (email == null)
            throw new ArgumentNullException("email");
 
        Name = name;
        Email = email;
    }
 
    public void ChangeName(CustomerName name)
    {
        if (name == null)
            throw new ArgumentNullException("name");
 
        Name = name;
    }
 
    public void ChangeEmail(Email email)
    {
        if (email == null)
            throw new ArgumentNullException("email");
 
        Email = email;
    }
}
		

Как вы могли заметить, мы перенесли поля класса Customer (имя и почту) в отдельные классы. Однако, мы ничего не можем поделать с проверками на null. Это единственные условия, которые остались в классе Customer.

Как избавиться от проверок на null

Итак, как мы можем избавиться от таких проверок? Конечно же при помощи IL рерайтинга (Intermediate Language Rewrite)!

Есть замечательный NuGet пакет под названием NullGuard.Fody. Для начала вам нужно скачать и установить пакет. Пометьте сборку таким атрибутом:

[assembly: NullGuard(ValidationFlags.All)]

Что же мы сделали? Отныне каждый метод и свойство в сборке автоматически проверяется на null. Теперь мы можем переписать класс Customer. Выглядеть он будет просто и изящно:

			public class Customer
{
    public CustomerName Name { get; private set; }
    public Email Email { get; private set; }
 
    public Customer(CustomerName name, Email email)
    {
        Name = name;
        Email = email;
    }
 
    public void ChangeName(CustomerName name)
    {
        Name = name;
    }
 
    public void ChangeEmail(Email email)
    {
        Email = email;
    }
}
		

Или даже еще проще:

			public class Customer
{
    public CustomerName Name { get; set; }
    public Email Email { get; set; }
 
    public Customer(CustomerName name, Email email)
    {
        Name = name;
        Email = email;
    }
}
		

Несмотря на визуальную простоту класса, на самом деле он выглядит вот так:

			public class Customer
{
    private CustomerName _name;
    public CustomerName Name
    {
        get
        {
            CustomerName customerName = _name;
 
            if (customerName == null)
                throw new InvalidOperationException();
 
            return customerName;
        }
        set
        {
            if (value == null)
                throw new ArgumentNullException();
 
            _name = value;
        }
    }
 
    private Email _email;
    public Email Email
    {
        get
        {
            Email email = _email;
 
            if (email == null)
                throw new InvalidOperationException();
 
            return email;
        }
        set
        {
            if (value == null)
                throw new ArgumentNullException();
 
            _email = value;
        }
    }
 
    public Customer(CustomerName name, Email email)
    {
        if (name == null)
            throw new ArgumentNullException(“name”, “[NullGuard] name is null.”);
        if (email == null)
            throw new ArgumentNullException(“email”, “[NullGuard] email is null.”);
 
        Name = name;
        Email = email;
    }
}
		

Но как быть со значением null?

Так как же мы можем определить то, что значение некоторого типа может быть пустым (null)? Для этого мы будем использовать монаду Maybe.

			public struct Maybe<T>
{
    private readonly T _value;
 
    public T Value
    {
        get
        {
            Contracts.Require(HasValue);
 
            return _value;
        }
    }
 
    public bool HasValue
    {
        get { return _value != null; }
    }
 
    public bool HasNoValue
    {
        get { return !HasValue; }
    }
 
    private Maybe([AllowNull] T value)
    {
        _value = value;
    }
 
    public static implicit operator Maybe<T>([AllowNull] T value)
    {
        return new Maybe<T>(value);
    }
}
		

Как вы можете заметить, входные значения для класса Maybe отмечены атрибутом AllowNull. Теперь мы можем написать следующий код с использованием Maybe:

			Maybe<Customer> customer = _repository.GetById(id);
		

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

			Maybe<Customer> customer = _repository.GetById(id);
ProcessCustomer(customer); // Ошибка компилятора

private void ProcessCustomer(Customer customer)
{
    //Тело метода
}
		

Конечно же вы теперь должны решить, какие сборки будут обработаны пакетом NullGuard.Fody. Вероятно, применение данного пакета в WPF не лучшая идея, поскольку там имеется множество системных компонентов, которые являются по своей сути обнуляемыми. Именно поэтому добавление проверок на null не даст вам особого преимущества. Однако для всех остальных сборок данный метод будет более чем актуальным.

Небольшое замечание о монаде Maybe. Вы, возможно, захотите назвать ее Option из-за соглашения об именовании языка F#. Я лично предпочитаю использовать Maybe, но по-моему, распределение программистов в этом вопросе примерно 50 на 50. Конечно, это всего лишь дело вкуса.

Что насчет статических проверок?

Окей, быстрая обратная связь во время выполнения это хорошо, но это все еще обратная связь во время выполнения. Было бы замечательно анализировать код еще быстрее — скажем, на этапе компиляции.

Ответ кроется в атрибуте NotNull замечательного дополнения к Visual Studio — ReSharper. Применив атрибут NotNull к какому-то методу и вернув в нем нулевое значение, вы получите предупреждение от плагина ReSharper.

Данный способ может здорово облегчить вам жизнь, однако он страдает от некоторых проблем.

Во-первых, ReSharper работает по методу от обратного. Сейчас вам нужно отмечать атрибутом NotNull те значения, которые не могут быть нулевыми. Было бы гораздо удобнее помечать этим атрибутом значения, которые, наоборот, могли бы быть нулевыми. Остальные же по умолчанию считались бы необнуляемыми.

Во-вторых, предупреждения — это всего лишь предупреждения. Вы можете запросто не обратить на них внимание и пропустить их. Разумеется, мы можем установить настройки Visual Studio так, что он будет понимать предупреждения как ошибки. Однако с монадой Maybe у вас гораздо меньше шансов ошибиться.

Именно по этим причинам я не использую ReSharper, хотя они и очень полезны.

Вывод

Подход, описанный выше, действительно очень мощный:

  1. Вы быстро отловите баг с нулевым значением. Теперь вас не будут надоедать постоянные NullReferenceException.
  2. Вы увеличите читаемость кода. Теперь не будут везде мелькать постоянные проверки значения на null перед использованием объекта.
  3. По умолчанию все ваши методы будут защищены от исключения NullReferenceException. Причем вам не надо будет помечать каждый новый метод атрибутом NotNull.

А на этом все! В следующей статье мы мы рассмотрим вопрос обработки исключений в функционально C#.

Перевод статьи “Functional C#: Non-nullable reference types”

Следите за новыми постами
Следите за новыми постами по любимым темам
5К открытий5К показов