Способствуют ли полиморфизм или условные обозначения улучшению дизайна?

StackOverflow https://stackoverflow.com/questions/234458

  •  04-07-2019
  •  | 
  •  

Вопрос

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

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

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

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

Это было полезно?

Решение

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

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

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

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, то все животные должны быть определены перед отправкой (Закрыто / Закрыто).

Другие советы

Не бойся ...

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

C ++ - язык ООП

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

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

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

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

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

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

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

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

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

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

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

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

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

(Отказ от ответственности: я большой поклонник концепции RTTI и динамических трансляций. Но для решения поставленной задачи нужно использовать правильный инструмент, и большую часть времени 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 должен был выполнить свой процесс в три этапа: " до " ;, " while " и " после " ;. Его код будет что-то вроде:

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).

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

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

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

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

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()

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

ООП имеет шаблоны проектирования, позволяющие избежать распространенных ошибок.В процедурном программировании тоже есть шаблоны проектирования (но никто еще их не записал, 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, где за время существования программного обеспечения десятки слоев абстракции накладываются друг на друга, мне приходится тратить много часов, а иногда и дней, чтобы выяснить, как правильно обойти все ограничения и ограничения, с которыми сталкиваются другие программисты. встроен в их систему, чтобы другие не «испортили их класс».

Это в основном связано с инкапсуляцией знаний. Давайте начнем с действительно очевидного примера - 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. Полное его описание слишком длинно для этого ответа, но вы можете прочитать хорошее описание здесь .

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

Он работает очень хорошо , если вы понимаете это .

Есть также 2 варианта полиморфизма. Первое очень легко понять в java-esque:

interface A{

   int foo();

}

final class B implements A{

   int foo(){ print("B"); }

}

final class C implements A{

   int foo(){ print("C"); }

}

B и C имеют общий интерфейс. В этом случае B и C не могут быть расширены, поэтому вы всегда уверены, какой метод foo () вы вызываете. То же самое касается C ++, просто сделайте A :: foo чистым виртуальным.

Во-вторых, хитрее - полиморфизм во время выполнения. В псевдокоде это выглядит не так уж плохо.

class A{

   int foo(){print("A");}

}

class B extends A{

   int foo(){print("B");}

}

class C extends B{

  int foo(){print("C");}

}

...

class Z extends Y{

   int foo(){print("Z");

}

main(){

   F* f = new Z();
   A* a = f;
   a->foo();
   f->foo();

}

Но это намного сложнее. Особенно, если вы работаете в C ++, где некоторые из объявлений foo могут быть виртуальными, а часть наследования может быть виртуальной. Также ответ на этот вопрос:

A* a  = new Z;
A  a2 = *a;
a->foo();
a2.foo();

может не соответствовать вашим ожиданиям.

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

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

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

Это действительно зависит от вашего стиля программирования. Хотя это может быть правильным в Java или C #, я не согласен с тем, что автоматическое решение об использовании полиморфизма является правильным. Вы можете разбить свой код на множество маленьких функций и выполнить поиск в массиве с помощью указателей на функции (например, инициализированных во время компиляции). В C ++ полиморфизм и классы часто используются слишком часто - вероятно, самая большая ошибка проектирования, которую допускают люди, переходящие из сильных языков ООП в C ++, заключается в том, что все входит в класс - это неправда. Класс должен содержать только минимальный набор вещей, которые заставляют его работать в целом. Если нужен подкласс или друг, пусть будет так, но они не должны быть нормой. Любые другие операции над классом должны быть свободными функциями в том же пространстве имен; ADL позволит использовать эти функции без поиска.

C ++ не является языком ООП, не используйте его. Это так же плохо, как программирование на C ++.

Лицензировано под: CC-BY-SA с атрибуция
Не связан с StackOverflow
scroll top