Освоение ООП: практическое руководство

Освоение ООП: практическое руководство

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

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

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

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

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

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

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

Знание названия

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

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

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

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

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

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

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

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

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

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

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

Знание кодов

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

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

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

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

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

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

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

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

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

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:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent" android:layout_height="match_parent">

    <TextView
         android:id="@+id/tvDisplay"
         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:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent" android:layout_height="match_parent">
    <Button
       android:id="@+id/btnDisplay"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"/>

    <ImageButton
       android:id="@+id/imbDisplay"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"/>

    <ImageView
       android:id="@+id/imvDisplay"
       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. Это гораздо удобнее, чем писать отдельный метод для обработки всех видов виджетов на платформе Android (не говоря уже о пользовательских виджетах).

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

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

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

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

Меньше деталей значит больше свободы

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

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

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

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

Чтобы взять простой, но актуальный пример, платформа 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...
    }
}

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

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

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

Абстракция обеспечивает свободную слабую связь

Я надеюсь, что ответил на общий вопрос о том, почему интерфейсы существуют в 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, который реализует соответствующий интерфейс:

class FakeNetworkAdapter implements INetworkAdapter {
    public boolean throwError = false;

    @Override
    public void sendRequest(String input) throws IOException {
        if (throwError) throw new IOException("Test Exception");
    }
}

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

Заключительная часть об Абстракциях

Абстракции не существуют так же, как конкретная вещь. Это отражается в языке программирования 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{}


}

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

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

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

Изучение торгового логотипа

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

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

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

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

  • Штанга: 10 REPS при 100 фунтах
  • Гантель: 10 REPS @ 50 фунтов x2
  • Вес тела: 10 REPS @ Вес тела
  • Вес тела +: 10 REPS @ Вес тела + 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()
        );
    }
}

Выполнение этого игрушечного приложения дает следующий вывод:

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

Дальнейшие соображения

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

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

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

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

Использование этого статического метода во внешнем классе (или другом статическом методе) выглядит следующим образом:

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

Шпаргалка

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

Я хочу, чтобы набор дочерних классов поделился реализацией

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

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

Я хочу набор классов, чтобы поделиться поведением

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

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

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

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


.

  • December 2, 2019