Полиморфизм или условные обозначения способствуют лучшему дизайну? - PullRequest
38 голосов
/ 24 октября 2008

Я недавно наткнулся на эту запись в блоге по тестированию Google о правилах написания более тестируемого кода. До этого момента я был согласен с автором:

Предпочитайте полиморфизм условным выражениям: если вы видите оператор переключения, вам следует подумать о полиморфизмах. Если вы видите то же самое, если условие повторяется во многих местах в вашем классе, вы должны снова подумать о полиморфизме. Полиморфизм разделит ваш сложный класс на несколько более простых классов меньшего размера, которые четко определяют, какие части кода связаны и выполняются вместе. Это помогает в тестировании, поскольку простой / меньший класс легче тестировать.

Я просто не могу обернуться вокруг этого. Я могу понять, используя полиморфизм вместо RTTI (или DIY-RTTI, в зависимости от обстоятельств), но это кажется таким широким утверждением, что я не могу себе представить, что он действительно эффективно используется в производственном коде. Скорее, мне кажется, что было бы проще добавить дополнительные тестовые примеры для методов, имеющих операторы switch, чем разбивать код на десятки отдельных классов.

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

Ответы [ 12 ]

71 голосов
/ 24 октября 2008

На самом деле это облегчает написание теста и кода.

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

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

class Animal
{
    public:
       Noise warningNoise();
       Noise pleasureNoise();
    private:
       AnimalType type;
};

Noise Animal::warningNoise()
{
    switch(type)
    {
        case Cat: return Hiss;
        case Dog: return Bark;
    }
}
Noise Animal::pleasureNoise()
{
    switch(type)
    {
        case Cat: return Purr;
        case Dog: return Bark;
    }
}

В этом простом случае каждое новое животное вызывает необходимость обновления обоих операторов switch.
Вы забыли один? Что такое по умолчанию? BANG !!

Использование полиморфизма

class Animal
{
    public:
       virtual Noise warningNoise() = 0;
       virtual Noise pleasureNoise() = 0;
};

class Cat: public Animal
{
   // Compiler forces you to define both method.
   // Otherwise you can't have a Cat object

   // All code local to the cat belongs to the cat.

};

Используя полиморфизм, вы можете проверить класс Animal.
Затем протестируйте каждый из производных классов отдельно.

Также это позволяет вам отправлять класс Animal ( Закрыт для изменения ) как часть вашей двоичной библиотеки. Но люди все еще могут добавлять новых животных ( Открыть для расширения ), получая новые классы, полученные из заголовка Animal. Если все эти функции были захвачены в классе Animal, то перед отправкой необходимо определить всех животных (закрыт / закрыт).

25 голосов
/ 24 октября 2008

Не бойся ...

Полагаю, ваша проблема в знакомстве, а не в технологиях. Ознакомьтесь с C ++ ООП.

C ++ является языком ООП

Среди множества парадигм он имеет функции ООП и более чем способен поддерживать сравнение с наиболее чистым языком ОО.

Не позволяйте "части C внутри C ++" заставить вас поверить, что C ++ не может иметь дело с другими парадигмами. C ++ может очень любезно работать с множеством парадигм программирования. И среди них ООП C ++ является наиболее зрелой из парадигм C ++ после процедурной парадигмы (то есть вышеупомянутой «части C»).

Полиморфизм в порядке для производства

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

переключатель и полиморфизм [почти] похожи ...

... Но полиморфизм убрал большинство ошибок.

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

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

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

В общем, две функции о выборе. Но полиморфизм позволяет вам сделать более сложным и в то же время более естественным и, следовательно, более легким выбором.

Избегайте использования RTTI для поиска типа объекта

RTTI - интересная концепция и может быть полезной. Но в большинстве случаев (то есть в 95% случаев) переопределения и наследования методов будет более чем достаточно, и большая часть вашего кода даже не должна знать точный тип обрабатываемого объекта, но доверять ему, чтобы он поступал правильно.

Если вы используете RTTI в качестве прославленного коммутатора, вы упускаете суть.

(Отказ от ответственности: я большой поклонник концепции RTTI и dynamic_casts. Но для этой задачи нужно использовать правильный инструмент, и большую часть времени RTTI используется в качестве прославленного переключателя, что неправильно)

Сравнение динамического и статического полиморфизма

Если ваш код не знает точный тип объекта во время компиляции, тогда используйте динамический полиморфизм (то есть классическое наследование, переопределение виртуальных методов и т. Д.)

Если ваш код знает тип во время компиляции, то, возможно, вы могли бы использовать статический полиморфизм, то есть шаблон CRTP http://en.wikipedia.org/wiki/Curiously_Recurring_Template_Pattern

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

Пример кода продукции

Код, аналогичный этому (из памяти), используется в производстве.

Более простое решение основывалось на процедуре, вызываемой циклом сообщений (WinProc в Win32, но для простоты я написал более простую версию). Итак, подведем итог, это было что-то вроде:

void MyProcedure(int p_iCommand, void *p_vParam)
{
   // A LOT OF CODE ???
   // each case has a lot of code, with both similarities
   // and differences, and of course, casting p_vParam
   // into something, depending on hoping no one
   // did a mistake, associating the wrong command with
   // the wrong data type in p_vParam

   switch(p_iCommand)
   {
      case COMMAND_AAA: { /* A LOT OF CODE (see above) */ } break ;
      case COMMAND_BBB: { /* A LOT OF CODE (see above) */ } break ;
      // etc.
      case COMMAND_XXX: { /* A LOT OF CODE (see above) */ } break ;
      case COMMAND_ZZZ: { /* A LOT OF CODE (see above) */ } break ;
      default: { /* call default procedure */} break ;
   }
}

Каждое добавление команды добавляет регистр.

Проблема в том, что некоторые команды были похожи и частично разделяли их реализацию.

Таким образом, смешение дел было риском для эволюции.

Я решил проблему с помощью шаблона Command, то есть создания базового объекта Command с одним методом process ().

Поэтому я переписал процедуру сообщения, сводя к минимуму опасный код (т. Е. Играя с void * и т. Д.), И написал его, чтобы быть уверенным, что мне больше не нужно будет его трогать:

void MyProcedure(int p_iCommand, void *p_vParam)
{
   switch(p_iCommand)
   {
      // Only one case. Isn't it cool?
      case COMMAND:
         {
           Command * c = static_cast<Command *>(p_vParam) ;
           c->process() ;
         }
         break ;
      default: { /* call default procedure */} break ;
   }
}

И затем для каждой возможной команды вместо добавления кода в процедуру и смешивания (или, что еще хуже, копирования / вставки) кода из аналогичных команд я создал новую команду и извлек ее либо из объекта Command, или один из его производных объектов:

Это привело к иерархии (представленной в виде дерева):

[+] Command
 |
 +--[+] CommandServer
 |   |
 |   +--[+] CommandServerInitialize
 |   |
 |   +--[+] CommandServerInsert
 |   |
 |   +--[+] CommandServerUpdate
 |   |
 |   +--[+] CommandServerDelete
 |
 +--[+] CommandAction
 |   |
 |   +--[+] CommandActionStart
 |   |
 |   +--[+] CommandActionPause
 |   |
 |   +--[+] CommandActionEnd
 |
 +--[+] CommandMessage

Теперь все, что мне нужно было сделать, это переопределить процесс для каждого объекта.

Простой и легко расширяемый.

Например, скажем, что CommandAction должен был выполнить свой процесс в три этапа: «до», «пока» и «после». Его код будет выглядеть примерно так:

class CommandAction : public Command
{
   // etc.
   virtual void process() // overriding Command::process pure virtual method
   {
      this->processBefore() ;
      this->processWhile() ;
      this->processAfter() ;
   }

   virtual void processBefore() = 0 ; // To be overriden

   virtual void processWhile()
   {
      // Do something common for all CommandAction objects
   }

   virtual void processAfter()  = 0 ; // To be overriden

} ;

И, например, CommandActionStart можно кодировать как:

class CommandActionStart : public CommandAction
{
   // etc.
   virtual void processBefore()
   {
      // Do something common for all CommandActionStart objects
   }

   virtual void processAfter()
   {
      // Do something common for all CommandActionStart objects
   }
} ;

Как я уже сказал: легко понять (если правильно прокомментировать) и очень легко расширить.

Переключатель сведен к минимуму (т. Е. Если подобен, потому что нам все еще нужно было делегировать команды Windows в процедуру Windows по умолчанию), и нет необходимости в RTTI (или, что хуже, в собственном RTTI).

Один и тот же код внутри коммутатора был бы довольно забавным, я думаю (если бы только судя по количеству «исторического» кода, который я видел в нашем приложении на работе).

10 голосов
/ 24 октября 2008

Модульное тестирование OO-программы означает тестирование каждого класса как модуля. Принцип, который вы хотите выучить: «Открыто для расширения, закрыто для модификации». Я получил это от Head First Design Patterns. Но в основном это говорит о том, что вы хотите иметь возможность легко расширять свой код без изменения существующего проверенного кода.

Полиморфизм делает это возможным, исключая эти условные утверждения. Рассмотрим этот пример:

Предположим, у вас есть объект персонажа с оружием. Вы можете написать метод атаки следующим образом:

If (weapon is a rifle) then //Code to attack with rifle else
If (weapon is a plasma gun) //Then code to attack with plasma gun

и т.д.

С полиморфизмом Персонажу не нужно «знать» тип оружия, просто

weapon.attack()

будет работать. Что произойдет, если будет изобретено новое оружие? Без полиморфизма вам придется изменить ваше условное утверждение. При полиморфизме вам нужно будет добавить новый класс и оставить проверенный класс персонажа в покое.

8 голосов
/ 24 октября 2008

Я немного скептик: я считаю, что наследование часто добавляет больше сложности, чем убирает.

Я думаю, что вы задаете хороший вопрос, и одну вещь, которую я считаю, это:

Вы разделяетесь на несколько классов, потому что имеете дело с разными вещами ? Или это действительно одно и то же, действуя по-другому ?

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

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

5 голосов
/ 24 октября 2008

Не эксперт по последствиям для тестовых случаев, но с точки зрения разработки программного обеспечения:

  • Принцип открытого-закрытого типа - Классы должны быть закрыты для изменения, но открыты для расширения. Если вы управляете условными операциями с помощью условной конструкции, то при добавлении нового условия ваш класс должен измениться. Если вы используете полиморфизм, базовый класс не должен меняться.

  • Не повторяйте себя - Важной частью руководства является " то же самое если условие". Это означает, что у вашего класса есть несколько различных режимов работы, которые можно включить в класс. Затем это условие появляется в одном месте вашего кода - когда вы создаете объект для этого режима. И снова, если появляется новый, вам нужно изменить только один фрагмент кода.

2 голосов
/ 13 марта 2013

Переключатели и полиморфизм делают то же самое.

В полиморфизме (и в программировании на основе классов в целом) вы группируете функции по их типу. При использовании переключателей вы группируете типы по функциям. Решите, какой вид вам подходит.

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

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

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

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

ООП имеет шаблоны проектирования, чтобы избежать распространенных ошибок. Процедурное программирование также имеет шаблоны проектирования (но никто еще не записал его, AFAIK, нам нужна еще одна новая Банда N, чтобы составить книгу бестселлеров из них ...). Один шаблон проектирования может быть всегда включать регистр по умолчанию .

Переключения могут быть сделаны правильно:

switch (type)
{
    case T_FOO: doFoo(); break;
    case T_BAR: doBar(); break;
    default:
        fprintf(stderr, "You, who are reading this, add a new case for %d to the FooBar function ASAP!\n", type);
        assert(0);
}

Этот код укажет вашему любимому отладчику место, где вы забыли обработать дело. Компилятор может заставить вас реализовать ваш интерфейс, но это заставляет тщательно протестировать ваш код (по крайней мере, чтобы заметить новый случай).

Конечно, если конкретный переключатель будет использоваться более чем в одном месте, он будет вырезан в функцию ( не повторяйтесь ).

Если вы хотите расширить эти переключатели, просто сделайте grep 'case[ ]*T_BAR' rn . (в Linux), и он покажет места, на которые стоит обратить внимание. Поскольку вам нужно взглянуть на код, вы увидите некоторый контекст, который поможет вам правильно добавить новый случай. При использовании полиморфизма сайты вызовов скрываются внутри системы, и вы зависите от правильности документации, если она вообще существует.

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

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

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

Когда вы предоставляете интерфейс для третьей стороны, чтобы они могли добавлять поведение и пользовательские данные в систему, тогда это другой вопрос. (Они могут устанавливать обратные вызовы и указатели на пользовательские данные, а вы даете им указатели)

Дальнейшие дебаты можно найти здесь: http://c2.com/cgi/wiki?SwitchStatementsSmell

Боюсь, мой «синдром С-хакера» и анти-ООПизм в конечном итоге сожгут всю мою репутацию здесь. Но всякий раз, когда мне нужно или нужно было что-то взламывать или вставлять в процедурную систему C, я обнаруживал, что это довольно легко, отсутствие ограничений, принудительная инкапсуляция и меньшее количество уровней абстракции заставляют меня «просто делать это». Но в системе C ++ / C # / Java, где десятки уровней абстракции накладываются друг на друга при жизни программного обеспечения, мне нужно тратить много часов, иногда дней, чтобы выяснить, как правильно обойти все ограничения и ограничения, которые другие программисты встроенный в их систему, чтобы другие не "возились с их классом".

2 голосов
/ 24 октября 2008

Полиморфизм является одним из краеугольных камней ОО и, безусловно, очень полезен. Разделив задачи по нескольким классам, вы создаете изолированные и тестируемые модули. Таким образом, вместо переключения ... случая, когда вы вызываете методы для нескольких различных типов или реализаций, вы создаете унифицированный интерфейс, имеющий несколько реализаций. Когда вам нужно добавить реализацию, вам не нужно изменять клиентов, как в случае с switch ... case. Очень важно, так как это помогает избежать регрессии.

Вы также можете упростить свой клиентский алгоритм, имея дело только с одним типом: интерфейсом.

Для меня очень важно то, что полиморфизм лучше всего использовать с чистым шаблоном интерфейса / реализации (например, почтенный Shape <- Circle и т. Д.). Вы также можете иметь полиморфизм в конкретных классах с шаблонами-методами (так называемые хуки), но его эффективность уменьшается с увеличением сложности. </p>

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

1 голос
/ 24 октября 2008

Это в основном связано с инкапсуляцией знаний. Давайте начнем с действительно очевидного примера - toString (). Это Java, но легко переносится на C ++. Предположим, вы хотите напечатать понятную человеку версию объекта для целей отладки. Вы могли бы сделать:

switch(obj.type): {
case 1: cout << "Type 1" << obj.foo <<...; break;   
case 2: cout << "Type 2" << ...

Это, однако, явно глупо. Почему один метод где-то знает, как печатать все. Часто самому объекту лучше знать, как печатать себя, например:

cout << object.toString();

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

Однако можно утверждать, что то, как печатается объект, не должно быть связано с объектом, оно должно быть связано с методом печати. В этом случае вам пригодится другой шаблон дизайна - шаблон Visitor, используемый для подделки Double Dispatch. Полное описание этого слишком длинно для этого ответа, но вы можете прочитать хорошее описание здесь .

0 голосов
/ 19 января 2009

Я должен еще раз повторить, что нахождение всех параметров коммутации может быть нетривиальным процессом в зрелой базе кода. Если вы что-то пропустили, то приложение, скорее всего, аварийно завершит работу из-за несоответствующего оператора case, если у вас не установлено значение по умолчанию.

Также ознакомьтесь с книгой Мартина Фаулерса "Рефакторинг"
Использование переключателя вместо полиморфизма - это запах кода.

0 голосов
/ 13 декабря 2008

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

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