Алгебраические типы. Часть 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.
Хорошо, но у вас наверняка возникает вопрос - а чего мы, собственно, добились, введя эти странные ограничения? Но об этом я расскажу в следующий раз.