Enum-switch антипаттерн

в 20:59, , рубрики: antipattern, antipatterns, C#, neverdoit

В последнее время мне часто встречается в коде один интересный шаблон. Он заключается в том, что для описания небольшого множества объектов создается enum, а потом в разных местах кода значения из перечисления обрабатываются при помощи оператора switch.

Как выглядит реализация данного шаблона, и чем он опасен? Давайте разберемся.

Описание задачи

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

Для хранения списка поддерживаемых языков создается перечисление

enum Language
public enum Language
{
    Java,
    CSharp,
    TSQL
}

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

GetExtensions(Language lang)
List<string> GetExtensions(Language lang)
{
    switch (lang)
    {
        case Language.Java:
            {
                List<string> result = new List<string>();
                result.Add("java");
                return result;
            }
        case Language.CSharp:
            {
                List<string> result = new List<string>();
                result.Add("cs");
                return result;
            }
        case Language.TSQL:
            {
                List<String> result = new List<string>();
                result.Add("sql");
                return result;
            }
        default:
            throw new InvalidOperationException("Язык " + lang + " не поддерживается");
    }
}

IsCaseSensitive(Language lang)
bool IsCaseSensitive(Language lang)
{
    switch (lang)
    {
        case Language.Java:
        case Language.CSharp:
            return true;
        case Language.TSQL:
            return false;
        default:
            throw new InvalidOperationException("Язык " + lang + " не поддерживается");
    }
}

GetIconFile(Language lang)
string GetIconFile(Language lang)
{
    switch (lang)
    {
        case Language.Java:
            return "bean.svg";
        case Language.CSharp:
            return "cs.svg";
        case Language.TSQL:
            return "tsql.svg";
        default:
            throw new InvalidOperationException("Язык " + lang + " не поддерживается");
    }
}

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

В результате поначалу возникает достаточно простая картина. Все поддерживаемые языки собраны в одно место. Там, где надо определить что-то, зависящее от языка, вставляется switch. Одному разработчику реализовать поддержку 2-3 языков одновременно очень легко. Но вот впоследствии с поддержкой и развитием программы, основанной на этом шаблоне, будут серьезные проблемы.

Недостатки использования enum-switch

Дело в том, что такой подход создает god-объекты (god object). Сам enum и каждый switch играют роль god-объектов. Любое изменение, связанное с одним из поддерживаемых редактором языков программирования, потребует внесения изменения во все god-объекты. Работая над поддержкой Java, можно сломать код, относящийся к C# или TransactSQL.

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

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

При помощи подхода enum-switch разработчики обединяют жесткими связями сущности, которые в реальности между собой практически не связаны. Между TransactSQL и Java вообще может не быть ничего общего кроме того, что кто-то захотел открыть их в одном текстовом редакторе. Но в коде программы TransactSQL и Java оказались в одном типе enum.

Это проявление антипаттерна god-object.

Однако в данном шаблоне можно обнаружить проявление и других антипаттернов. Разработчики текстового редактора не участвуют в разработке языков программирования, они лишь занимаются реализацией логики своего собственного программного продукта. Следовательно, для редактора особенности языков — это внешние данные, которые он должен уметь обрабатывать. Здесь же эти данные являются частью кода. Т. е. получился своеобразный хардкодинг. Если выйдет Java, в которой файлы исходников будут иметь расширение из одной буквы J, то придется переделывать редактор и проверять, не сломались ли остальные языки.

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

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

Dictionary
Dictionary<Language, string> icons = new Dictionary<Language, string>();
icons[Language.Java] = "bean.svg";
icons[Language.CSharp] = "cs.svg";
icons[Language.TSQL] = "tsql.svg";

При этом у оператора switch есть еще один неприятный побочный эффект. Он не только задает связь между объектами, но и сам является связью. Чтобы было понятно, о чем речь, рассмотрим такой пример:

switch (lang)
{
    case Language.TSQL:
    case Language.PLSQL:
        return "sql.svg";
...
}

Двум диалектам SQL поставлена в соответствие иконка sql.svg. Теперь у языка не только есть иконка, но и есть неявное свойство, обозначающее, что у языков TransactSQL и PL-SQL иконки должны быть одинаковыми. Разработчик, который захочет поменять иконку для PL-SQL будет решать вопрос, стоит ли ему менять иконку и для TransactSQL. В большинстве случаев это нежелательно.

И наконец, антипаттерн enum-switch способствует проявлению ошибки типа «Данное значение из enum не предусмотрено», потому что сложно проконтролировать при добавлении нового значения в enum полное покрытие во всех операторах switch.

Выход есть

Как же следует поступать, чтобы избежать использование данного шаблона?

В любой непонятной ситуации заводите интерфейс. Чтобы не использовать enum, заведите интерфейс. Интерфейс должен возвращать информацию о свойствах объекта из описываемого множества. Добавьте в этот интерфейс имя, которое раньше хранилось в константе в enum.

Интерфейс
interface Language
{
    string GetName();
    bool IsCaseSensitive();
    string GetIconFile();
    List<string> GetExtensions();
}

Создание конкретных объектов, реализующих данный интерфейс, поручите отдельному классу-провайдеру.

Провайдер
class LanguageProvider
{
    List<Language> GetSupportedLanguages() {
        ...
    }
    Language DetectLanguageByFile(string fileName) {
        ...
    }
    Language GetDefaultLanguage() {
        ....
    }
}

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

Теперь все функции, содержащие switch, если они у вас были, удалите. Они вам больше не понадобятся, потому что код обрабатывает не конкретные объекты, а их свойства.

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

Зачем нужен enum

Зачем же все-таки в большинстве языков программирования существует такой тип как enum?

Использовать его удобно, если делать это с определенной осторожностью. Прежде всего enum можно применить там, где количество объектов небольшое. Допустимый предел каждый разработчик определяет по своему усмотрению. Я бы не стал объединять в enum более 20 констант.

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

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

Характерные примеры применения enum:

  • enum Boolean {True, False}
  • дни недели, месяца
  • состояния конечного автомата

Автор: sergey-b

Источник

Поделиться новостью

* - обязательные к заполнению поля