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

Код пахнет! Код гниет!

Часто нам не хватает внутреннего качества, поскольку основное внимание уделяется тому, чтобы «заставить это работать», а не столько тому, чтобы «делать это правильно». Здесь я изложу свои мысли о том, как заставить мастерство программного обеспечения «делать это правильно» в объектно-ориентированном мире.

Зачем еще одна статья на эту тему?

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

Содержание основано на моих исследованиях/опыте объектно-ориентированного проектирования и некоторых выдержках из тренинга по объектно-ориентированному проектированию, проведенного Субраманианом Сивакумаром, генеральным директором Pratian Technologies (I) )Pvt Ltd.

Начнем

Давайте начнем с повторения того, что я называю биджа-мантрой объектно-ориентированного программирования.

"Высокая согласованность и низкая связанность"

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

Таким образом, первичные проектные решения при разработке любого объектно-ориентированного программного обеспечения заключаются в следующем:

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

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

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

Связь классов

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

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

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

В этом случае любой метод класса B может быть вызван из класса A, как если бы метод был доступен в самом классе A.

Это означает, что класс A имеет объект класса B в качестве переменной-члена. В этом случае любой метод класса B может быть вызван из класса A с использованием переменной-члена класса B.

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

Так какой из них следует использовать, когда?

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

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

Постановка задачи

Разработайте классы чтения, которые должны иметь возможность читать из файла, сокета или канала. Он должен иметь возможность читать побайтно или несколько байтов вместе. Эти требования относятся только к первой версии, в будущем у нас могут быть дополнительные требования, например. может быть больше источников для чтения, или мы можем захотеть прочитать типы данных (String, boolean, int и т. д.). В зависимости от того, что мы решим построить в будущем, эти классы должны учитывать новые изменения по мере того, как и когда мы хотим их создавать.

Дизайн №1

В этом дизайне метод read() в классе InputStream оставлен для реализации подклассом. А метод read(buffer : byte []) реализован с использованием метода read() в классе InputStream. Все подклассы реализуют метод read(). Пример псевдокода объясняет, как использовать эти классы.

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

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

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

Знакомство №1."A Is-A B" означает, что класс A может повторно использовать код класса B. Это также обеспечивает расширяемость и удобство сопровождения.

Дизайн №2

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

Здесь мы замечаем, что в дизайне есть два разных потока. Один тип потоков считывается из разных источников и реализует метод чтения, например. FileInputStream, SocketInputStream PipeInputStream. Мы будем называть их классами базового потока. Другой тип потоков работает с базовыми потоками и предоставляет дополнительную функциональность поверх этого, например. BufferedFileInputS, DataFileInputS и т. д. Мы будем называть их классами потока декораторов.

Вышеупомянутый дизайн выполнен с использованием бита знаний #1. Он преодолевает проблемы проектирования, рассмотренные выше. Мы используем отношение Is-A для создания буферизованных потоков и потоков данных. Псевдокод для использования BufferedFileInputS показывает, что единственное изменение, которое требуется от проекта № 1, заключается в создании экземпляра BufferedFileInputS вместо FileInputStream. Это возможно, поскольку отношение Is-A обеспечивает совместимость типов.

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

Знакомство #2.Отношение Is-A обеспечивает совместимость типов, что упрощает использование реализации переменных. Широкое использование Is-A для повторного использования кода может привести к таким проблемам, как увеличение количества классов.

Дизайн №3

Если мы посмотрим на него внимательно, то поймем, что все буферизованные потоковые классы и классы декораторов данных делают похожие вещи. Все классы потоков буферизованного декоратора, считываемые в буфере, и все классы потоков данных используют метод read() из классов базовых потоков и создают различные типы данных на основе вызываемого метода, например. readInt() или readBoolean() и т. д. Совершенно очевидно, что в дизайне не должно быть повторяющегося кода.

Приведенный выше дизайн решает проблему распространения классов. Здесь мы используем отношение Has-A вместо Is-A. В этом дизайне добавление нового базового класса потока не потребует добавления новых классов потока декоратора.

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

Ключевая информация №3: "A Has-A B" означает, что класс A может повторно использовать код класса B. Это обеспечивает расширяемость и удобство сопровождения, а также решает проблему распространения классов.

Дизайн № 4

Вышеупомянутая конструкция решает описанную выше проблему технического обслуживания. В новом дизайне все классы потока декоратора сохраняют ссылку на InputStream вместо каждого базового класса потока. Таким образом, когда добавляется новый базовый класс потока, на него автоматически ссылаются классы потока декоратора через ссылку InputStream. Этот дизайн также позволяет всем классам потока декоратора работать вместе, например. мы можем создать DataInputStream, который использует BufferedInputStream, который внутренне использует FileInputStream. Это очень гибкий дизайн!

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

Ключ № 4. "A Has-A B" означает, что класс A может повторно использовать код из любого класса типа B. Это обеспечивает большую расширяемость и удобство сопровождения.

Дизайн № 5

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

Ключ № 5. «A Has-A B» означает, что любой класс типа A может повторно использовать код любого класса типа B. Это обеспечивает большую расширяемость и удобство сопровождения.

Заключение

Развитие мысли

Если вы пройдете биты знаний с № 1 по № 5, это покажет переход к тому, как Has-A обеспечивает лучшее повторное использование реализации.

«A Is-A B» Класс A может повторно использовать реализацию класса B. Is-A обеспечивает повторное использование реализации.

«A Has-A B» Класс A может повторно использовать реализацию класса B. Has-A также обеспечивает повторное использование реализации. Это также позволяет избежать проблемы распространения классов.

«A Has-A B» Класс A может повторно использовать любую реализацию типа B (класс B и все его подклассы). Has-A обеспечивает повторное использование реализации переменной.

«A Has-A B» Любой класс типа A (класс A и все его подклассы) может повторно использовать любую реализацию типа B (класс B и все его подклассы). Has-A обеспечивает повторное использование реализации переменной.

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

Выводы

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