Почему класс должен быть чем-то отличным от «абстрактного» или «окончательного / запечатанного»?

29

После более 10 лет программирования на Java / C # я создаю либо:

  • абстрактные классы : контракт не предназначен для создания как есть.
  • заключительные / закрытые классы : реализация не предназначена для использования в качестве базового класса для чего-то другого.

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

Почему класс должен быть чем-то отличным от "абстрактного" или "окончательного / запечатанного"?

РЕДАКТИРОВАТЬ

Эта замечательная статья объясняет мои проблемы гораздо лучше, чем я.

Николя Репике
источник
21
Потому что это называется Open/Closed principle, а неClosed Principle.
StuperUser
2
Какие вещи вы пишете профессионально? Это может очень хорошо повлиять на ваше мнение по этому вопросу.
Ну, я знаю некоторых разработчиков платформ, которые все запечатывают, потому что хотят минимизировать интерфейс наследования.
К ..
4
@StuperUser: Это не причина, это банальность. ОП не спрашивает, что такое банальность, он спрашивает, почему . Если за принципом нет причины, нет причины обращать на него внимание.
Майкл Шоу
1
Интересно, сколько фреймворков UI сломалось бы, изменив класс Window на sealed.
Reactgular

Ответы:

46

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

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

Для окончательных / запечатанных классов единственное оправдание, которое я когда-либо вижу для их использования, - это когда на самом деле опасно разрешать переопределение - возможно, алгоритм шифрования или что-то в этом роде. Кроме этого, вы никогда не знаете, когда вы захотите расширить функциональность по местным причинам. Запечатывание класса ограничивает ваши варианты получения, которые отсутствуют в большинстве сценариев. Гораздо более гибко писать свои классы таким образом, чтобы они могли быть расширены позже.

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

Майкл
источник
17
+1 за последний абзац. Я часто находил слишком много инкапсуляции в сторонних библиотеках (или даже в стандартных классах библиотек!), Чтобы быть более серьезной причиной боли, чем слишком маленькая инкапсуляция.
Мейсон Уилер
5
Обычный аргумент для закрытия классов заключается в том, что вы не можете предсказать множество различных способов, которыми клиент может переопределить ваш класс, и, следовательно, вы не можете дать никаких гарантий относительно его поведения. См. Blogs.msdn.com/b/ericlippert/archive/2004/01/22/…
Роберт Харви
12
@RobertHarvey Я знаком с аргументом, и это звучит великолепно в теории. Вы, как проектировщик объектов, не можете предвидеть, как люди могут расширять ваши классы - именно поэтому они не должны быть запечатаны. Вы не можете поддерживать все - хорошо. Не. Но и не забирай варианты.
Майкл
6
@RobertHarvey - это не просто люди, нарушающие LSP, получают то, что заслуживают?
StuperUser
2
Я также не большой поклонник закрытых классов, но я мог понять, почему некоторые компании, такие как Microsoft (которым часто приходится поддерживать вещи, которые они не ломали сами), находят их привлекательными. Предполагается, что пользователи фреймворка не обязательно должны знать внутреннее устройство класса.
Роберт Харви
11

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

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

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

Большая разница.

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

Мартин
источник
Но вы можете применить тот же аргумент к классам, которые не являются частью открытого интерфейса. Одна из главных причин сделать класс финальным - это то, что он служит мерой безопасности для предотвращения неаккуратного кода. Этот аргумент так же действителен для любой кодовой базы, которая совместно используется разными разработчиками с течением времени, или даже если вы являетесь единственным разработчиком. Он просто защищает семантику кода.
DPM
Я думаю, что идея о том, что классы должны быть абстрактными или запечатанными, является частью более широкого принципа, заключающегося в том, что следует избегать использования переменных создаваемых типов классов. Такое исключение позволит создать тип, который может использоваться потребителями данного типа, без необходимости наследования всех его частных членов. К сожалению, такие конструкции на самом деле не работают с синтаксисом публичного конструктора. Вместо этого код должен быть заменен new List<Foo>()чем-то вроде List<Foo>.CreateMutable().
суперкат
5

С точки зрения Java, я думаю, что последние классы не так умны, как кажется.

Многие инструменты (особенно AOP, JPA и т. Д.) Работают с изменением времени загрузки, поэтому им приходится расширять ваши предложения. Другой способ - создать делегатов (не делегатов .NET) и делегировать все исходному классу, что было бы намного более беспорядочным, чем расширение пользовательских классов.

Уве Плонус
источник
5

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

  1. Технический: если у вас иерархия, которая имеет глубину более двух уровней , и вы хотите иметь возможность создавать что-то в середине.

  2. Принцип: иногда желательно написать классы, которые бы были простыми и безопасными . (Это часто случается, когда вы пишете такой API, как Эрик Липперт, или когда вы работаете в команде над большим проектом). Иногда вы хотите написать класс, который прекрасно работает сам по себе, но он разработан с учетом расширяемости.

Мысли Эрики Липперты на уплотнительном имеет смысл, но он также признает , что они делают дизайн для расширяемости, оставив класс «открытым».

Да, многие классы запечатаны в BCL, но огромное количество классов нет, и могут быть расширены всеми возможными способами. Один пример, который приходит на ум, - это Windows Forms, где вы можете добавить данные или поведение практически к любому с Controlпомощью наследования. Конечно, это можно было бы сделать другими способами (шаблон декоратора, различные типы композиции и т. Д.), Но наследование также работает очень хорошо.

Два специальных замечания .NET:

  1. В большинстве случаев закрытие классов часто не критично для безопасности, потому что наследники не могут связываться с вашей не virtualфункциональностью, за исключением явных реализаций интерфейса.
  2. Иногда подходящей альтернативой является создание Конструктора internalвместо герметизации класса, что позволяет ему наследоваться внутри вашей кодовой базы, но не за ее пределами.
Кевин Маккормик
источник
4

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

Позвольте мне уточнить. Начиная издалека, некоторые программисты считают, что наследование как инструмент в ООП широко используется и злоупотребляется. Мы все прочитали принцип подстановки Лискова, хотя это не помешало нам нарушать его сотни (а то и тысячи) раз.

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

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

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

  • Зависимости между методами (какие вызовы каких методов и т. Д.)
  • Зависимости от локальных переменных
  • Внутренние контракты, которые должен соблюдать расширяющийся класс
  • Соглашение о вызовах для каждого метода (например, когда вы переопределяете его, вызываете superли вы реализацию? Вы вызываете это в начале метода или в конце? Think constructor vs destructor)
  • ...

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

Теперь рассмотрим, сколько усилий по документированию должно быть уделено правильному документированию каждой из этих вещей. Я полагаю, что класс с 7-8 методами (который может быть слишком много в идеализированном мире, но слишком мало в реальном) может также иметь документацию около 5 страниц, только для текста. Таким образом, вместо этого мы уклоняемся на полпути и не запечатываем класс, чтобы другие люди могли использовать его, но также не документировали его должным образом, поскольку это займет огромное количество времени (и, вы знаете, это может в любом случае никогда не будет продлен, так зачем?).

Если вы разрабатываете класс, у вас может возникнуть соблазн запечатать его так, чтобы люди не могли использовать его так, как вы не предвидели (и не подготовились). С другой стороны, когда вы используете чужой код, иногда из общедоступного API нет видимой причины, по которой класс будет окончательным, и вы можете подумать: «Черт, это просто стоило мне 30 минут в поисках обходного пути».

Я думаю, что некоторые из элементов решения:

  • Сначала убедитесь, что расширение является хорошей идеей, когда вы являетесь клиентом кода, и действительно отдавайте предпочтение композиции, а не наследованию.
  • Во-вторых, чтобы прочитать руководство полностью (снова как клиент), чтобы убедиться, что вы не пропускаете что-то упомянутое.
  • В-третьих, когда вы пишете фрагмент кода, который будет использовать клиент, напишите соответствующую документацию для кода (да, долгий путь). В качестве положительного примера могу привести документы Apple по iOS. Их недостаточно для того, чтобы пользователь всегда правильно расширял свои классы, но они по крайней мере включают некоторую информацию о наследовании. Что больше, чем я могу сказать для большинства API.
  • В-четвертых, попробуйте расширить собственный класс, чтобы убедиться, что он работает. Я являюсь большим сторонником включения многих примеров и тестов в API, и когда вы делаете тест, вы также можете тестировать цепочки наследования: в конце концов, они являются частью вашего контракта!
  • В-пятых, в ситуациях, когда вы сомневаетесь, укажите, что класс не предназначен для расширения и что это плохая идея (тм). Укажите, что вы не должны нести ответственность за такое непреднамеренное использование, но все же не опечатывайте класс. Очевидно, что это не распространяется на случаи, когда класс должен быть на 100% запечатан.
  • Наконец, при закрытии класса предоставьте интерфейс в качестве промежуточного хука, чтобы клиент мог «переписать» свою собственную модифицированную версию класса и обойти свой «запечатанный» класс. Таким образом, он может заменить запечатанный класс своей реализацией. Теперь это должно быть очевидно, поскольку это слабая связь в простейшей форме, но все же стоит упомянуть.

Также стоит упомянуть следующий «философский» вопрос: является ли класс sealed/ finalчастью контракта для класса, или деталь реализации? Теперь я не хочу идти туда, но ответ на этот вопрос также должен повлиять на ваше решение о том, закрывать класс или нет.

K.Steff
источник
3

Класс не должен быть ни окончательным / запечатанным, ни абстрактным, если:

  • Это полезно само по себе, то есть полезно иметь экземпляры этого класса.
  • Для этого класса полезно быть подклассом / базовым классом других классов.

Например, возьмите ObservableCollection<T>класс в C # . Нужно только добавить возбуждение событий к обычным операциям a Collection<T>, поэтому он подклассы Collection<T>. Collection<T>жизнеспособный класс сам по себе, и так оно и есть ObservableCollection<T>.

FishBasketGordo
источник
Если бы я отвечал за это, я, вероятно, сделаю CollectionBase(абстрактно), Collection : CollectionBase(запечатано), ObservableCollection : CollectionBase(запечатано). Если вы внимательно посмотрите на Collection <T> , вы увидите, что это недоделанный абстрактный класс.
Николас Репике
2
Какое преимущество вы получаете от трех классов вместо двух? Кроме того, как выглядит Collection<T>"недоделанный абстрактный класс"?
FishBasketGordo
Collection<T>раскрывает много внутренностей через защищенные методы и свойства и явно предназначен для использования в качестве базового класса для специализированной коллекции. Но не ясно, куда вы должны поместить свой код, так как нет абстрактных методов для реализации. ObservableCollectionнаследуется от Collectionи не запечатан, так что вы можете снова наследовать от него. И вы можете получить доступ к Itemsзащищенному свойству, позволяя добавлять элементы в коллекцию, не вызывая событий ... Приятно.
Николя Репике
@NicolasRepiquet: во многих языках и средах обычное идиоматическое средство создания объекта требует, чтобы тип переменной, которая будет содержать объект, был таким же, как тип созданного экземпляра. Во многих случаях идеальным вариантом было бы передавать ссылки на абстрактный тип, но это заставило бы большой объем кода использовать один тип для переменных и параметров и другой конкретный тип при вызове конструкторов. Вряд ли невозможно, но немного неловко.
суперкат
3

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

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

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

Reactgular
источник
1
Окончательно запечатанные вещи в программном обеспечении решают проблему «я чувствую необходимость навязать свою волю будущим разработчикам этого кода».
Каз
В ООП не было добавлено что-то окончательное / печать, потому что я не помню, чтобы это было, когда я был моложе. Кажется, что это особенность после размышлений.
Reactgular
Завершение существовало как процедура набора инструментов в системах ООП, прежде чем ООП стал незнаком с такими языками, как C ++ и Java. Программисты работают в Smalltalk, Lisp с максимальной гибкостью: все может быть расширено, новые методы добавляются постоянно. Затем скомпилированный образ системы подлежит оптимизации: делается предположение, что конечные пользователи не будут расширять систему, и это означает, что отправка метода может быть оптимизирована на основе анализа того, какие методы и классы существуют сейчас .
Каз
Я не думаю, что это одно и то же, потому что это просто функция оптимизации. Я не помню запечатанного существа во всех версиях Java, но я могу ошибаться, потому что я не очень часто его использую.
Reactgular
То, что это проявляется в виде некоторых объявлений, которые вы должны вставить в код, не означает, что это не одно и то же.
Каз
2

«Запечатывание» или «завершение» в объектных системах допускает определенную оптимизацию, поскольку известен полный график диспетчеризации.

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

Во всем остальном это потеря. Системы должны быть открытыми и расширяемыми по умолчанию. Должно быть легко добавлять новые методы ко всем классам и расширять их произвольно.

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

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

Kaz
источник
1

Тестовые занятия, похоже, приходят на ум. Это классы, которые вызываются автоматически или «по желанию» в зависимости от того, что пытается сделать программист / тестировщик. Я не уверен, что когда-либо видел или слышал о готовом классе частных тестов.

joshin4colours
источник
О чем ты говоришь? Каким образом тестовые классы не должны быть окончательными / запечатанными / абстрактными?
1

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

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

Это отличная возможность для расширения классов.

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

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

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

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

Итак, структура выглядит так:

Base
 Function1
  Customer1
  Customer2
 Function2
 ...

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

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

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

OldCurmudgeon
источник
1

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

Дэн Х
источник
1
Это хороший комментарий, но он не дает прямого ответа на вопрос. Пожалуйста, рассмотрите возможность расширения ваших мыслей здесь.
1

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

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

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

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

ДПМ
источник