Является ли «если метод повторно используется без изменений, поместите метод в базовый класс или создайте интерфейс», это хорошее правило?

10

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

Он говорит:

Представьте себе каждый новый метод, который вы собираетесь реализовать. Для каждого из них рассмотрим следующее: будет ли этот метод реализован более чем одним классом именно в этой форме, без каких-либо изменений? Если ответ «да», создайте базовый класс. В любой другой ситуации создайте интерфейс.

Например:

Рассмотрим классы catи dog, расширяющие класс mammalи имеющие один метод pet(). Затем мы добавляем класс alligator, который ничего не расширяет и имеет единственный метод slither().

Теперь мы хотим добавить eat()метод для всех них.

Если реализация eat()метода будет точно такой же для cat, dogи alligatorмы должны создать базовый класс (скажем , давайте, animal), которая реализует этот метод.

Однако, если его реализация alligatorотличается незначительно, мы должны создать IEatинтерфейс и создать mammalи alligatorреализовать его.

Он настаивает на том, что этот метод охватывает все случаи, но мне кажется, что это упрощение.

Стоит ли следовать этому эмпирическому правилу?

sbichenko
источник
27
alligatorРеализация eatотличается, конечно, тем, что она принимает catи dogпараметры.
сбиченко
1
Там, где вам действительно нужен абстрактный базовый класс для совместного использования реализаций, но для правильной расширяемости следует использовать интерфейсы, вы часто можете вместо этого использовать черты . То есть, если ваш язык поддерживает это.
Амон
2
Когда вы рисуете себя в углу, лучше всего быть в ближайшем к двери.
JeffO
10
Самая большая ошибка, которую допускают люди, - верить, что интерфейсы - это просто пустые абстрактные классы. Интерфейс - это способ сказать программисту: «Мне все равно, что вы мне дадите, если он следует этому соглашению». Повторное использование кода должно быть выполнено (в идеале) посредством композиции. Ваш коллега не прав.
riwalk
2
Мой профессор CS учил, что суперклассы должны быть, is aа интерфейсы - acts likeили is. Итак, собака is aмлекопитающее и acts likeедок. Это говорит нам о том, что млекопитающее должно быть классом, а едок - интерфейсом. Это всегда было очень полезным руководством. Sidenote: примером isбудет The cake is eatableили The book is writable.
MirroredFate

Ответы:

13

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

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

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

Процитирую Rich Hickey: Simple! = Easy

Саймон Бергот
источник
Не могли бы вы привести базовый пример PetEatingBehaviorреализации?
сбиченко
1
@exizt Во-первых, я плохо выбираю имена. Так PetEatingBehaviorчто, вероятно, не тот. Я не могу дать вам реализацию, так как это всего лишь игрушечный пример. Но я могу описать этапы рефакторинга: у него есть конструктор, который берет всю информацию, используемую кошками / собаками для определения eatметода (экземпляр зубов, экземпляр желудка и т. Д.). Он содержит только код, общий для кошек и собак, используемый в их eatметоде. Вам просто нужно создать PetEatingBehaviorучастника и перенаправить его вызовы на dog.eat/cat.eat.
Саймон Бергот
Я не могу поверить, что это единственный ответ многих, чтобы отвлечь OP от наследства :( Наследование не для повторного использования кода!
Дэнни Таппени
1
@DannyTuppeny Почему бы и нет?
сбиченко
4
@exizt Наследование от класса - это большое дело; вы принимаете (вероятно, постоянную) зависимость от чего-то, что во многих языках может иметь только один из них. Повторное использование кода - довольно тривиальная вещь, чтобы использовать этот часто единственный базовый класс. Что, если вы в конечном итоге получите методы, в которых вы хотите поделиться ими с другим классом, но у него уже есть базовый класс из-за повторного использования других методов (или даже того, что он является частью «реальной» иерархии, используемой полиморфно (?)). Используйте наследование, когда ClassA является типом ClassB (например, Car наследуется от Vehicle), а не когда он разделяет некоторые внутренние детали реализации :-)
Danny Tuppeny
11

Стоит ли следовать этому эмпирическому правилу?

Это хорошее практическое правило, но я знаю немало мест, где я его нарушаю.

Честно говоря, я использую (абстрактные) базовые классы гораздо чаще, чем мои сверстники. Я делаю это как защитное программирование.

Для меня (во многих языках) абстрактный базовый класс добавляет ключевое ограничение, что может быть только один базовый класс. Выбирая использовать базовый класс, а не интерфейс, вы говорите: «Это базовая функциональность класса (и любого наследника), и неуместно смешивать ее с другими функциями». Интерфейсы противоположны: «Это некоторая черта объекта, не обязательно его основная ответственность».

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

Telastyn
источник
1
Перечитывая этот ответ 3 года спустя, я нахожу это отличным моментом: выбирая использование базового класса, а не интерфейса, вы говорите: «Это основная функциональность класса (и любого наследника), и это неуместно смешивать волей-неволей с другими функциями "
сбиченко
9

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

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

Вот как учил мой профессор:

Суперклассы должны быть is aи интерфейсы есть acts likeили is. Итак, собака is aмлекопитающее и acts likeедок. Это говорит нам о том, что млекопитающее должно быть классом, а едок - интерфейсом. Примером isможет быть The cake is eatableили The book is writable(создание обоих eatableи writableинтерфейсов).

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

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

Просто мои два цента.

MirroredFate
источник
6

По просьбе exizt я расширяю свой комментарий для более длинного ответа.

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

Библиотека .NET служит прекрасным примером. Например, когда вы пишете функцию, которая принимает IEnumerable<T>, то, что вы говорите: «Мне все равно, как вы храните свои данные. Я просто хочу знать, что я могу использовать foreachцикл.

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

Но затем возникает вопрос: «А как насчет повторного использования кода? Мои преподаватели CS сказали мне, что наследование было решением всех проблем повторного использования кода и что наследование позволяет вам писать один раз и использовать везде, и это вылечит менангит в процессе спасения сирот». из восходящих морей и не было бы больше слез и так далее и тому подобное и т. д. и т. д. "

Использование наследования только потому, что вам нравится звучание слов «повторное использование кода», является довольно плохой идеей. Руководство по стилю кода от Google делает этот момент довольно лаконичным:

Композиция часто более уместна, чем наследование. ... [B], поскольку код, реализующий подкласс, распространяется между базовым и подклассом, может быть сложнее понять реализацию. Подкласс не может переопределять функции, которые не являются виртуальными, поэтому подкласс не может изменить реализацию.

Чтобы проиллюстрировать, почему наследование не всегда является ответом, я собираюсь использовать класс MySpecialFileWriter †. Человек, который слепо верит, что наследование является решением всех проблем, будет утверждать, что вы должны пытаться наследовать FileStream, чтобы не дублировать FileStreamкод. Умные люди признают, что это глупо. Вы должны просто иметь FileStreamобъект в своем классе (как локальную переменную или переменную-член) и использовать его функциональность.

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

Это не означает, что наследства следует избегать любой ценой. Есть много моментов, которые нужно рассмотреть, и большинство из них будет рассмотрено при исследовании вопроса «Композиция против наследования». У нашего собственного Stack Overflow есть несколько хороших ответов на эту тему.

В конце концов, настроениям вашего коллеги не хватает глубины или понимания, необходимых для принятия взвешенного решения. Исследуйте предмет и выясните это для себя.

† При иллюстрации наследования каждый использует животных. Это бесполезно. За 11 лет разработки я никогда не писал класс с именем Cat, поэтому я не собираюсь использовать его в качестве примера.

riwalk
источник
Поэтому, если я вас правильно понимаю, внедрение зависимостей обеспечивает те же возможности повторного использования кода, что и наследование. Но зачем тогда использовать наследование? Вы бы предоставили вариант использования? (Я действительно ценил тот с FileStream)
сбиченко
1
@exizt, нет, он не предоставляет такие же возможности повторного использования кода. Он обеспечивает лучшие возможности повторного использования кода (во многих случаях), поскольку он хорошо поддерживает реализацию. С классами взаимодействуют явно через их объекты и их общедоступный API, вместо того чтобы неявно получать возможности и полностью изменять половину функциональности путем перегрузки существующих функций.
riwalk
4

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

В этом случае вам лучше абстрагироваться от поведения, например:

public interface IFoodEater
{
    void Eat(IFood food);
}

public class Animal : IFoodEater
{
    private IFoodProcessor _foodProcessor;
    public Animal(IFoodProcessor foodProcessor)
    {
        _foodProcessor = foodProcessor;
    }

    public void Eat(IFood food)
    {
        _foodProcessor.Process(food);
    }
}

Или внедрите шаблон посетителя, в котором он FoodProcessorпытается накормить совместимых животных ( ICarnivore : IFoodEater, MeatProcessingVisitor). Мысль о живых животных может помешать вам задуматься о том, чтобы разорвать их пищеварительный тракт и заменить его на общий, но вы действительно делаете им одолжение.

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

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

CodeCaster
источник
1
IFoodEater является излишним. Что еще вы ожидаете, чтобы иметь возможность есть пищу? Да, животные в качестве примеров ужасная вещь.
Эйфорическая
1
А как насчет хищных растений? :) Серьезно, одной из основных проблем, связанных с неиспользованием интерфейсов в этом случае, является то, что вы тесно связали концепцию еды с животными, и гораздо больше работы по внедрению новых абстракций, для которых базовый класс Animal не подходит.
Джулия Хейворд
1
Я считаю, что «еда» - это неправильная идея: идите более абстрактно. Как насчет IConsumerинтерфейса. Плотоядные могут потреблять мясо, травоядные - растения, а растения - солнечный свет и воду.
2

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

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

Например:

public abstract class Person
    {
        /// <summary>
        /// Inheritors must implement a hello
        /// </summary>
        /// <returns>Hello</returns>
        public abstract string SayHello();
        /// <summary>
        /// Inheritors can use base functionality, or override
        /// </summary>
        /// <returns></returns>
        public virtual string SayGoodBye()
        {
            return "Good Bye";
        }
        /// <summary>
        /// Base Functionality that is inherited 
        /// </summary>
        public void DoSomething()
        {
            //Do Something Here
        }
    }

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

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

Джон Рейнор
источник
1

Нет.

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

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

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

Euphoric
источник
Подождите, зачем повторно внедрять eat()в каждое животное? Мы можем сделать mammalэто, не так ли? Я понимаю вашу точку зрения о требованиях, хотя.
сбиченко
@exizt: Извините, я неправильно прочитал ваш вопрос.
Эйфорическая
1

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

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

Интерфейсы полезны, когда вам нужно одинаково взаимодействовать с набором классов, но вы не можете предсказать или не заботиться об их фактической реализации. Взять, к примеру, что-то вроде интерфейса Collection. Господь знает только множество способов, которыми кто-то может решить реализовать коллекцию предметов. Связанный список? Список массивов? Стек? Очередь? Этот метод SearchAndReplace не имеет значения, ему просто нужно передать то, что он может вызвать Add, Remove и GetEnumerator.

user1100269
источник
1

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

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

  2. Нет ничего плохого в том, чтобы переопределить метод, вызвать super.method()его и позволить остальному его телу просто делать вещи, которые не мешают базовому классу. Это не нарушает принцип замещения Лискова .

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

Поэтому я бы взял то, что было сказано, с крошкой соли.

Panzercrisis
источник
5
People put way too much faith into interfaces, and too little faith into classes.Веселая. Я склонен думать, что все наоборот.
Симон Бергот
1
«Это не нарушает принцип замещения Лискова». Но это чрезвычайно близко, чтобы стать нарушением LSP. Лучше быть в безопасности, чем потом сожалеть.
Эйфорическая
1

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

Интерфейс - это контракт.

IAnimalИнтерфейс - это контракт «все или ничего» между аллигатором, кошкой, собакой и понятием животного. По сути, это говорит о том, что «если вы хотите быть животным, вы должны обладать всеми этими атрибутами и характеристиками, или вы больше не животное».

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

Саид Нямати
источник
0

Я не уверен, что это лучшее эмпирическое правило. Вы можете поместить abstractметод в базовый класс, и каждый класс может его реализовать. Поскольку каждый mammalдолжен есть, имеет смысл сделать это. Тогда каждый mammalможет использовать свой eatметод. Обычно я использую интерфейсы только для связи между объектами или как маркер, чтобы пометить класс как нечто. Я думаю, что чрезмерное использование интерфейсов приводит к небрежному коду.

Я также согласен с @Panzercrisis, что эмпирическое правило должно быть просто общим руководством. Не то, что должно быть написано в камне. Несомненно, будут времена, когда это просто не работает.

Джейсон Кросби
источник
-1

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

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

Один класс не требует интерфейса.

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

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

Пользуйся композицией, а не наследством - все это уходит.

user108620
источник