Алгебраические типы. Часть 1

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

Но не все так печально. Хотя алгебраических типов в C# действительно нет, но зато есть другой тип данных, во многом с ними схожий. Этот тип называется перечисление (enumeration).

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

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

 public enum Product
{
    Cellphone,
    Laptop
}

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

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

Первая же идея, которая приходит в голову, - написать собственный тип данных (назовем его Product), который будет представлять собой тип продаваемого продукта.

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

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

 public abstract class Product
{

}

public sealed class Cellphone : Product
{
    bool TouchScreen { get; set; }
}

public sealed class Laptop : Product
{
    double ScreenSize { get; set; }
}

(Не пытайтесь анализировать набор атрибутов - я их выбрал практически случайным образом).

Итак, мы почти завершили дизайн наших типов данных. Однако осталось одно требование, которому представленная выше иерархия продуктов по-прежнему не удовлетворяет. Как вы помните, по некой странной причине наша компания продает только ноутбуки и мобильные телефоны и ничего, кроме этих двух продуктов. Более того, у нас есть, можно сказать, пожизненная гарантия того, что компания никогда не расширит ассортимент предлагаемых товаров. А следовательно, нам нужно убедиться, что никакие другие классы, кроме Laptop и Cellphone, не смогу наследоваться от Product.

И, после некоторых размышлений, мы приходим к следующему решению:

 public abstract class Product
{
    private Product() { }

    public sealed class CellphoneProduct : Product
    {
        public bool TouchScreen { get; set; }
    }

    public sealed class LaptopProduct : Product
    {
        public double ScreenSize { get; set; }
    }

    public static LaptopProduct Laptop(double screenSize)
    {
        return new LaptopProduct { ScreenSize = screenSize };
    }

    public static CellphoneProduct Cellphone(bool touchScreen)
    {
        return new CellphoneProduct { TouchScreen = touchScreen };
    }
}

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

Итак, что же у нас получилось. Вряд ли такой подход к дизайну классов в C# можно назвать типичным. С другой стороны - мы всего лишь используем стандартные средства ООП и некоторые дополнительные возможности, предоставляемые C#. Мы объявили классы CellphoneProduct и LaptopProduct как вложенные в родительский тип Product, так как вложенные классы могут образащаться к private-членам своих классов-контейнеров. Благодаря этому, мы смогли описать у класса Product private конструктор, что позволяет нам убедиться в том, что никто больше не сможет отнаследоваться от этого класса. Так же мы пометили CellphoneProduct и LaptopProduct модификатором sealed, который делает классы "закрытыми", также запрещая от них наследоваться.

Вот как мы теперь сможем использовать эти классы:

 var l = Product.Laptop(14.2);

if (l is Product.LaptopProduct) {
    ...
}
else if (c is Product.CellphoneProduct) {
    ...
}

Я не случайно так акцентирую внимание на нашей иерархии продуктов. Ведь фактически мы только что написали на C# самый настоящий алгебраический тип. И ведь правда - у нас есть тип Product, который может быть или ноутбуком (LaptopProduct), или мобильным телефоном (CellphoneProduct) - и ничем, кроме этих двух. Говоря другими словами, тип Product представляет собой сумму продуктов LaptopProduct и CellphoneProduct. При этом мы искусственно ввели ряд ограничений, благодаря которым у нас появляются существенные отличия от классической объектно-ориентированной иерархии классов:

  • Прежде всего у нас есть лишь одноуровневая цепочка наследований, и пользователь нашего кода при всем желании не сможет ее "разветвить".
  • Далее, наша иерархия наследования закрытая. Фактически, только глядя на код, мы можем сказать, что никто, кроме LaptopProduct и CellphoneProduct, не может наследоваться от класса Product.

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