Освоение ООП: практическое руководство по наследованию, интерфейсам и абстрактным классам

Опубликовано: 2022-03-10
Краткое резюме ↬ Возможно, худший способ преподавания основ программирования — это описать, что это такое, без упоминания о том, как и когда это использовать. В этой статье Райан М. Кей обсуждает три основные концепции ООП в наименее двусмысленных терминах, чтобы вы никогда больше не задавались вопросом, когда использовать наследование, интерфейсы или абстрактные классы. Приведенные примеры кода написаны на Java с некоторыми отсылками к Android, но для понимания требуются только базовые знания Java.

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

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

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

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

Еще после прыжка! Продолжить чтение ниже ↓

Различные степени знания

Зная имена

Знание названия чего-либо, пожалуй, самая поверхностная форма знания. На самом деле, имя обычно полезно лишь в той мере, в какой оно обычно используется многими людьми для обозначения одного и того же предмета и/или помогает описать предмет. К сожалению, как обнаружил каждый, кто провел время в этой области, многие люди используют разные имена для одних и тех же вещей (например , интерфейсы и протоколы ), одни и те же имена для разных вещей (например , модули и компоненты ) или имена, которые эзотеричны для понимания. точка абсурда (например, « Либо Монада» ). В конечном счете, имена — это просто указатели (или ссылки) на ментальные модели, и они могут иметь разную степень полезности.

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

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

Знание словесных определений и аналогий

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

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

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

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

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

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

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

Знание в коде

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

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

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

Примечание к примерам

Поскольку я сам хорошо разбираюсь в разработке мобильных приложений для Android, я буду использовать примеры, взятые с этой платформы, чтобы я мог научить вас создавать приложения с графическим интерфейсом одновременно с введением языковых функций Java. Однако я не буду вдаваться в такие подробности, поскольку примеры должны быть непонятны человеку, имеющему поверхностное представление о Java EE, Swing или JavaFX. Моя конечная цель при обсуждении этих тем — помочь вам понять, что они означают в контексте решения проблемы практически в любом приложении.

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

Наследование и реализация

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

Несмотря на то, что использование наследования имеет менее очевидные преимущества, которые будут обсуждаться позже, основное преимущество заключается в том, что классы разделяют реализацию . Слово «реализация», по крайней мере, для целей настоящей статьи, имеет особое значение. Чтобы дать общее определение этого слова в английском языке, я мог бы сказать, что реализовать что-то значит сделать это реальным .

Чтобы дать техническое определение, относящееся к разработке программного обеспечения, я мог бы сказать, что реализовать часть программного обеспечения означает написать конкретные строки кода, которые удовлетворяют требованиям указанной части программного обеспечения. Например, предположим, что я пишу метод суммы : private double sum(double first, double second){

 private double sum(double first, double second){ //TODO: implement }

Вышеприведенный фрагмент кода, несмотря на то, что я написал тип возвращаемого значения ( double ) и объявление метода, в котором указаны аргументы ( first, second ) и имя, которое можно использовать для вызова указанного метода ( sum ), он имеет не реализовано . Чтобы реализовать его, мы должны завершить тело метода следующим образом:

 private double sum(double first, double second){ return first + second; }

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

Наследование в Java

Предположительно, если вы читаете эту статью, вы хотя бы раз использовали ключевое слово extends Java. Механика этого ключевого слова проста и чаще всего описывается с использованием примеров, связанных с различными видами животных или геометрическими фигурами; Dog и Cat расширяют Animal и так далее. Я предполагаю, что мне не нужно объяснять вам рудиментарную теорию типов, поэтому давайте сразу перейдем к основному преимуществу наследования в Java через ключевое слово extends .

Создать консольное приложение «Hello World» на Java очень просто. Предполагая, что у вас есть компилятор Java ( javac ) и среда выполнения ( jre ), вы можете написать класс, который содержит основную функцию, например:

 public class JavaApp{ public static void main(String []args){ System.out.println("Hello World"); } }

Создание приложения с графическим интерфейсом на Java практически на любой из его основных платформ (Android, Enterprise/Web, Desktop) с небольшой помощью IDE для создания скелета/шаблона кода нового приложения также относительно легко благодаря extends ключевое слово.

Предположим, что у нас есть XML-макет с именем activity_main.xml (обычно мы создаем пользовательские интерфейсы декларативно в Android с помощью файлов макетов), содержащий TextView (например, текстовую метку) с именем tvDisplay :

 <?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:andro android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android: android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" /> </FrameLayout>

Кроме того, предположим, что мы хотим, чтобы tvDisplay сказал «Hello World!» Для этого нам просто нужно написать класс, который использует ключевое слово extends для наследования от класса Activity :

 import android.app.Activity; import android.os.Bundle; import android.widget.TextView; public class MainActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); ((TextView)findViewById(R.id.tvDisplay)).setText("Hello World"); }

Эффект от наследования реализации класса Activity можно лучше всего оценить, взглянув на его исходный код. Я очень сомневаюсь, что Android стал бы доминирующей мобильной платформой, если бы нужно было реализовать хотя бы небольшую часть из 8000+ строк, необходимых для взаимодействия с системой, просто для создания простого окна с каким-то текстом. Наследование — это то, что позволяет нам не перестраивать инфраструктуру Android или любую другую платформу, с которой вы работаете, с нуля.

Наследование можно использовать для абстракции

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

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

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

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

Чтобы связать это с наследованием , все три основные темы, которые я здесь обсуждаю, можно использовать как абстракции или механизмы абстракции . Предположим, что в файл макета нашего приложения «Hello World» мы решили добавить ImageView , Button и ImageButton :

 <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:andro android:layout_width="match_parent" android:layout_height="match_parent"> <Button android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> <ImageButton android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> <ImageView android: android:layout_width="wrap_content" android:layout_height="wrap_content"/> </LinearLayout>

Также предположим, что наша Activity реализовала View.OnClickListener для обработки кликов:

 public class MainActivity extends Activity implements View.OnClickListener { private Button b; private ImageButton ib; private ImageView iv; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); //... b = findViewById(R.id.imvDisplay).setOnClickListener(this); ib = findViewById(R.id.btnDisplay).setOnClickListener(this); iv = findViewById(R.id.imbDisplay).setOnClickListener(this); } @Override public void onClick(View view) { final int id = view.getId(); //handle click based on id... } }

Ключевым принципом здесь является то, что Button , ImageButton и ImageView наследуются от класса View . В результате эта функция onClick может получать события щелчка от разрозненных (хотя и иерархически связанных) элементов пользовательского интерфейса, ссылаясь на них как на их менее подробный родительский класс. Это гораздо удобнее, чем писать отдельный метод для обработки каждого вида виджетов на платформе Android (не говоря уже о пользовательских виджетах).

Интерфейсы и абстракция

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

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

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

Меньше деталей — больше свободы

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

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

 public interface OnClickListener { void onClick(View v); }

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

Возьмем простой, но актуальный пример: платформа Android имеет два класса, которые в основном предназначены для создания и управления частью пользовательского интерфейса: Activity и Fragment . Из этого следует, что эти классы очень часто требуют прослушивания событий, которые появляются, когда щелкает виджет (или иным образом взаимодействует с пользователем). Ради аргумента давайте на минутку поймем, почему наследование почти никогда не решит такую ​​проблему:

 public class OnClickManager { public void onClick(View view){ //Wait a minute... Activities and Fragments almost never //handle click events exactly the same way... } }

Мало того, что наши Activity и Fragments наследуются от OnClickManager , это сделает невозможным обработку событий другим способом, но самое интересное, что мы даже не смогли бы сделать это, даже если бы захотели. И Activity , и Fragment уже расширяют родительский класс , а Java не допускает множественных родительских классов . Итак, наша проблема в том, что мы хотим, чтобы набор классов вел себя одинаково, но мы должны иметь гибкость в отношении того, как класс реализует это поведение . Это возвращает нас к более раннему примеру View.OnClickListener :

 public interface OnClickListener { void onClick(View v); }

Это настоящий исходный код (который вложен в класс View ), и эти несколько строк позволяют нам обеспечить согласованное поведение различных виджетов ( Views ) и UI-контроллеров ( Activities, Fragments и т . д.).

Абстракция способствует слабой связи

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

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

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

 class Client { private Validator validator; private INetworkAdapter networkAdapter; void sendNetworkRequest(String input){ if (validator.validateInput(input)) { try { networkAdapter.sendRequest(input); } catch (IOException e){ //handle exception } } } } class Validator { //...validation logic boolean validateInput(String input){ boolean isValid = true; //...change isValid to false based on validation logic return isValid; } } interface INetworkAdapter { //... void sendRequest(String input) throws IOException; }

Здесь у нас есть класс Client , который имеет два типа ссылок . Обратите внимание, что, если предположить, что Client не имеет ничего общего с созданием своих ссылок (на самом деле он не должен), он отделен от деталей реализации любого конкретного сетевого адаптера.

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

Во-вторых, что, если бы я сказал вам, что оба разработчика могут убедиться, что их соответствующие реализации функционируют должным образом, также независимо от прогресса друг друга? Это очень просто с интерфейсами; просто создайте Test Double , который implements соответствующий интерфейс :

 class FakeNetworkAdapter implements INetworkAdapter { public boolean throwError = false; @Override public void sendRequest(String input) throws IOException { if (throwError) throw new IOException("Test Exception"); } }

В принципе, можно заметить, что работа с абстрактными ссылками открывает дверь к повышенной модульности, тестируемости и некоторым очень мощным шаблонам проектирования, таким как шаблон фасада, шаблон наблюдателя и другие. Они также могут позволить разработчикам найти удачный баланс проектирования различных частей системы на основе поведения ( Program To An Interface ), не увязая в деталях реализации .

Заключительный пункт об абстракциях

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

Например, это точно не скомпилируется:

 public class Main extends Application { public static void main(String[] args) { launch(args); } @Override public void start(Stage primaryStage) { //ERROR x2: Foo f = new Foo(); Bar b = new Bar() } private abstract class Foo{} private interface Bar{} }

Фактически, идея ожидать, что нереализованный интерфейс или абстрактный класс будет функционировать во время выполнения, имеет такой же смысл, как и ожидать, что униформа UPS будет плавать вокруг доставки пакетов. Что-то конкретное должно стоять за абстракцией , чтобы она была полезной; даже если вызывающему классу не нужно знать, что на самом деле стоит за абстрактными ссылками .

Абстрактные классы: собираем все вместе

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

Пример из журнала тренировок

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

Приложение представляло собой журнал тренировок, который был разработан для облегчения записи ваших тренировок и возможности вывода данных прошлой тренировки в виде текстового файла или файла изображения. Не вдаваясь в подробности, я структурировал модели данных приложения таким образом, чтобы существовал Workout «Тренировка», который состоял из набора объектов « Exercise » (помимо других полей, не имеющих отношения к этому обсуждению).

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

Чтобы дать вам общее представление, я хотел изменить выходные данные в зависимости от типа упражнения, например:

  • Штанга: 10 повторений @ 100 фунтов
  • Гантели: 10 повторений @ 50 фунтов x2
  • Вес тела: 10 повторений с собственным весом
  • Вес тела +: 10 повторений @ вес тела + 45 фунтов
  • Время: 60 секунд при 100 фунтах

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

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

 abstract class Exercise { private final String type; protected final String name; protected final int[] repetitionsOrTime; protected final double[] weight; protected static final String POUNDS = "LBS"; protected static final String SECONDS = "SEC "; protected static final String REPETITIONS = "REPS "; public Exercise(String type, String name, int[] repetitionsOrTime, double[] weight) { this.type = type; this.name = name; this.repetitionsOrTime = repetitionsOrTime; this.weight = weight; } public String getFormattedOutput(){ StringBuilder sb = new StringBuilder(); sb.append(name); sb.append("\n"); getSetData(sb); sb.append("\n"); return sb.toString(); } /** * Append data appropriately based on Exercise type * @param sb - StringBuilder to Append data to */ protected abstract void getSetData(StringBuilder sb); //...Getters }

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

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

Упражнение со штангой:

 class BarbellExercise extends Exercise { public BarbellExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(weight[i]); sb.append(POUNDS); sb.append("\n"); } } }

Упражнение с гантелями:

 class DumbbellExercise extends Exercise { private static final String TIMES_TWO = "x2"; public DumbbellExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(weight[i]); sb.append(POUNDS); sb.append(TIMES_TWO); sb.append("\n"); } } }

Упражнение с собственным весом:

 class BodyweightExercise extends Exercise { private static final String BODYWEIGHT = "Bodyweight"; public BodyweightExercise(String type, String name, int[] repetitionsOrTime, double[] weight) { super(type, name, repetitionsOrTime, weight); } @Override protected void getSetData(StringBuilder sb) { for (int i = 0; i < repetitionsOrTime.length; i++) { sb.append(repetitionsOrTime[i]); sb.append(" "); sb.append(REPETITIONS); sb.append(" @ "); sb.append(BODYWEIGHT); sb.append("\n"); } } }

Я уверен, что некоторые проницательные читатели найдут вещи, которые можно было бы абстрагировать более эффективно, но цель этого примера (который был упрощен из исходного кода) — продемонстрировать общий подход. Конечно, ни одна статья по программированию не была бы полной без того, что можно выполнить. Есть несколько онлайн-компиляторов Java, которые вы можете использовать для запуска этого кода, если хотите протестировать его (если у вас еще нет IDE):

 public class Main { public static void main(String[] args) { //Note: I actually used another nested class called a "Set" instead of an Array //to represent each Set of an Exercise. int[] reps = {10, 10, 8}; double[] weight = {70.0, 70.0, 70.0}; Exercise e1 = new BarbellExercise( "Barbell", "Barbell Bench Press", reps, weight ); Exercise e2 = new DumbbellExercise( "Dumbbell", "Dumbbell Bench Press", reps, weight ); Exercise e3 = new BodyweightExercise( "Bodyweight", "Push Up", reps, weight ); System.out.println( e1.getFormattedOutput() + e2.getFormattedOutput() + e3.getFormattedOutput() ); } }

Executing this toy application yields the following output: Barbell Bench Press

 10 REPS @ 70.0LBS 10 REPS @ 70.0LBS 8 REPS @ 70.0LBS Dumbbell Bench Press 10 REPS @ 70.0LBSx2 10 REPS @ 70.0LBSx2 8 REPS @ 70.0LBSx2 Push Up 10 REPS @ Bodyweight 10 REPS @ Bodyweight 8 REPS @ Bodyweight

Further Considerations

Earlier, I mentioned that there are two features of Java interfaces (as of Java 8) which are decidedly geared towards sharing implementation , as opposed to behavior . These features are known as Default Methods and Static Methods .

I have decided not to go into detail on these features for the reason that they are most typically used in mature and/or large code bases where a given interface has many inheritors. Despite the fact that this is meant to be an introductory article, and I still encourage you to take a look at these features eventually, even though I am confident that you will not need to worry about them just yet.

I would also like to mention that there are other ways to share implementation across a set of classes (or even static methods ) in a Java application that does not require inheritance or abstraction at all. For example, suppose you have some implementation which you expect to use in a variety of different classes, but does not necessarily make sense to share via inheritance . A common pattern in Java is to write what is known as a Utility class, which is a simple class containing the requisite implementation in a static method :

 public class TimeConverterUtil { /** * Accepts an hour (0-23) and minute (0-59), then attempts to format them into an appropriate * format such as 12, 30 -> 12:30 pm */ public static String convertTime (int hour, int minute){ String unformattedTime = Integer.toString(hour) + ":" + Integer.toString(minute); DateFormat f1 = new SimpleDateFormat("HH:mm"); Date d = null; try { d = f1.parse(unformattedTime); } catch (ParseException e) { e.printStackTrace(); } DateFormat f2 = new SimpleDateFormat("h:mm a"); return f2.format(d).toLowerCase(); } }

Using this static method in an external class (or another static method ) looks like this:

 public class Main { public static void main(String[] args){ //... String time = TimeConverterUtil.convertTime(12, 30); //... } }

Шпаргалка

We have covered a lot of ground in this article, so I would like to spend a moment summarizing the three main mechanisms based on what problems they solve. Since you should possess a sufficient understanding of the terms and ideas I have either introduced or redefined for the purposes of this article, I will keep the summaries brief.

I Want A Set Of Child Classes To Share Implementation

Classic inheritance , which requires a child class to inherit from a parent class , is a very simple mechanism for sharing implementation across a set of classes. An easy way to decide if some implementation should be pulled into a parent class , is to see whether it is repeated in a number of different classes line for line. The acronym DRY ( Don't Repeat Yourself ) is a good mnemonic device to watch out for this situation.

While coupling child classes together with a common parent class can present some limitations, a side benefit is that they can all be referenced as the parent class , which provides a limited degree of abstraction .

I Want A Set Of Classes To Share Behavior

Sometimes, you want a set of classes to be capable of possessing certain abstract methods (referred to as behavior ), but you do not expect the implementation of that behavior to be repeated across inheritors.

By definition, Java interfaces may not contain any implementation (except for Default and Static Methods ), but any class which implements an interface , must supply an implementation for all abstract methods, otherwise, the code will not compile. This provides a healthy measure of flexibility and restriction on what is actually shared and does not require the inheritors to be of the same class hierarchy .

I Want A Set Of Child Classes To Share Behavior And Implementation

Although I do not find myself using abstract classes all over the place, they are perfect for situations when you require a mechanism for sharing both behavior and implementation across a set of classes. Anything which will be repeated across inheritors may be implemented directly in the abstract class , and anything which requires flexibility may be specified as an abstract method .