Подскажите, пожалуйста, планируете ли вы возобновление программ высшего образования? Если да, есть ли какие-то примерные сроки? Спасибо! |
Проблемы разработки сложных программных систем
Хорошее разбиение системы на модули — непростая задача. При ее выполнении привлекаются следующие дополнительные принципы.
-
Выделение интерфейсов и сокрытие информации.
Модули должны взаимодействовать друг с другом через четко определенные интерфейсы и скрывать друг от друга внутреннюю информацию — внутренние данные, детали реализации интерфейсных операций.
При этом интерфейс модуля обычно значительно меньше, чем набор всех операций и данных в нем.
Например, класс java.util.Queue<type E>, реализующий функциональность очереди элементов типа E, имеет следующий интерфейс.
Внутренние же данные и операции одного из классов, реализующих данный интерфейс, — PriorityBlockingQueue<E> — достаточно сложны. Этот класс реализует очередь с эффективной синхронизацией операций, позволяющей работать с таким объектом нескольким параллельным потокам без лишних ограничений на их синхронизацию. Например, один поток может добавлять элемент в конец непустой очереди, а другой в то же время извлекать ее первый элемент.
package java.util.concurrent; import java.util.concurrent.locks.*; import java.util.*; public class PriorityBlockingQueue<E> extends AbstractQueue<E> implements BlockingQueue<E>, java.io.Serializable { private static final long serialVersionUID = 5595510919245408276L; private final PriorityQueue<E> q; private final ReentrantLock lock = new ReentrantLock(true); private final ReentrantLock.ConditionObject notEmpty = lock.newCondition(); public PriorityBlockingQueue() { ... } public PriorityBlockingQueue(int initialCapacity) { … } public PriorityBlockingQueue(int initialCapacity, Comparator<? super E> comparator) { … } public PriorityBlockingQueue(Collection<? extends E> c) { ... } public boolean add(E o) { ... } public Comparator comparator() { … } public boolean offer(E o) { … } public void put(E o) { … } public boolean offer(E o, long timeout, TimeUnit unit) { … } public E take() throws InterruptedException { … } public E poll() { … } public E poll(long timeout, TimeUnit unit) throws InterruptedException { … } public E peek() { … } public int size() { … } public int remainingCapacity() { … } public boolean remove(Object o) { … } public boolean contains(Object o) { … } public Object[] toArray() { … } public String toString() { … } public int drainTo(Collection<? super E> c) { … } public int drainTo(Collection<? super E> c, int maxElements) { … } public void clear() { … } public <T> T[] toArray(T[] a) { … } public Iterator<E> iterator() { … } private class Itr<E> implements Iterator<E> { private final Iterator<E> iter; Itr(Iterator<E> i) { … } public boolean hasNext() { … } public E next() { … } public void remove() { … } } private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException { … } }
1.2. -
Адекватность, полнота, минимальность и простота интерфейсов.
Этот принцип объединяет ряд свойств, которыми должны обладать хорошо спроектированные интерфейсы.
-
Адекватность интерфейса означает, что интерфейс модуля дает возможность решать именно те задачи, которые нужны пользователям этого модуля.
Например, добавление в интерфейс очереди метода, позволяющего получить любой ее элемент по его номеру в очереди, сделало бы этот интерфейс не вполне адекватным — он превратился бы почти в интерфейс списка, который используется для решения других задач. Очереди же используются там, где полная функциональность списка не нужна, а реализация очереди может быть сделана более эффективной.
-
Полнота интерфейса означает, что интерфейс позволяет решать все значимые задачи в рамках функциональности модуля.
Например, отсутствие в интерфейсе очереди метода offer() сделало бы его бесполезным — никому не нужна очередь, из которой можно брать элементы, а класть в нее ничего нельзя.
Более тонкий пример — методы element() и peek(). Нужда в них возникает, если программа не должна изменять очередь, и в то же время ей нужно узнать, какой элемент лежит в ее начале. Отсутствие такой возможности потребовало бы создавать собственное дополнительное хранилище элементов в каждой такой программе.
-
Минимальность интерфейса означает, что предоставляемые интерфейсом операции решают различные по смыслу задачи и ни одну из них нельзя реализовать с помощью всех остальных (или же такая реализация довольно сложна и неэффективна).
Представленный в примере интерфейс очереди не минимален — методы element() и peek(), а также poll() и remove() можно выразить друг через друга. Минимальный интерфейс очереди получился бы, например, если выбросить пару методов element() и remove().
Большое значение минимальности интерфейса уделяют, если размер модулей оказывает сильное влияние на производительность программы. Например, при проектировании модулей операционной системы — чем меньше она занимает места в памяти, тем больше его останется для приложений, непосредственно необходимых пользователям.
При проектировании библиотек более высокого уровня имеет смысл не делать интерфейс минимальным, давая пользователям этих библиотек возможности для повышения производительности и понятности их программ. Например, часто бывает полезно реализовать действия "проверить, что элемент не принадлежит множеству, и, если нет, добавить его" в одном методе, не заставляя пользователей каждый раз сначала проверять принадлежность элемента множеству, а затем уже добавлять его.
-
Простота интерфейса означает, что интерфейсные операции достаточно элементарны и непредставимы в виде композиций некоторых более простых операций на том же уровне абстракции, при том же понимании функциональности модуля.
Скажем, весь интерфейс очереди можно было бы свести к одной операции Object queue(Object o, boolean remove), которая добавляет в очередь объект, указанный в качестве первого параметра, если это не null, а также возвращает объект в голову очереди (или null, если очередь пуста) и удаляет его, если в качестве второго параметра указать true. Однако такой интерфейс явно сложнее для понимания, чем представленный выше.
-
Адекватность интерфейса означает, что интерфейс модуля дает возможность решать именно те задачи, которые нужны пользователям этого модуля.
-
Разделение ответственности.
Основной принцип выделения модулей — создание отдельных модулей под каждую задачу, решаемую системой или необходимую в качестве составляющей для решения ее основных задач.
Пример.
Класс java.util.Date представляет временную метку, состоящую из даты и времени. Это представление должно быть независимо от используемого календаря, формы записи дат и времени в данной стране, а также от часового пояса.
Для построения конкретных экземпляров этого класса на основе строкового представления даты и времени (например, "22:32:00, June 15, 2005" ) в том виде, как их используют в Европе, используется класс java.util.GregorianCalendar, поскольку интерпретация записи даты и времени зависит от используемой календарной системы. Разные календари представляются различными объектами интерфейса java.util.Calendar, которые отвечают за преобразование всех дат в некоторое независимое представление.
Для создания строкового представления времени и даты используется класс java.text.SimpleDateFormat, поскольку нужное представление, помимо календарной системы, может иметь различный порядок перечисления года, месяца и дня месяца и различное количество символов, выделяемое под представление разных элементов даты (например, "22:32:00, June 15, 2005" и "05.06.15, 22:32" ).
Принцип разделения ответственности имеет несколько важных частных случаев.
-
Разделение политик и алгоритмов.
Этот принцип используется для отделения постоянных, неизменяемых алгоритмов обработки данных от изменяющихся их частей и для выделения этих частей, называемых политиками, в параметры общего алгоритма.
Так, политика, определяющая формат строкового представления даты и времени, задается в виде форматной строки при создании объекта класса java.text.SimpleDateFormat. Сам же алгоритм построения этого представления основывается на этой форматной строке и на самих времени и дате.
Другой пример. Стоимость товара для клиента может зависеть от привилегированности клиента, размера партии, которую он покупает, и сезонных скидок. Все перечисленные элементы можно выделить в виде политик, являющихся, вместе с базовой ценой товара, входными данными для алгоритма вычисления итоговой стоимости.
-
Разделение интерфейса и реализации.
Этот принцип используется при отделении внешне видимой структуры модуля, описания задач, которые он решает, от способов решения этих задач.
Пример такого разделения — отделение интерфейса абстрактного списка java.util.List<E> от многих возможных реализаций этого интерфейса, например, java.util.ArrayList<E>, java.util.LinkedList<E>. Первый из этих классов реализует список на основе массива, а второй — на основе ссылочной структуры данных.
-
Разделение политик и алгоритмов.
-
Слабая связность (coupling) модулей и сильное сродство (cohesion) функций в одном модуле.
Оба эти принципа используются для выделения модулей в большой системе и тесно связаны с разделением ответственности между модулями. Первый требует, чтобы зависимостей между модулями было как можно меньше. Модуль, зависящий от большинства остальных модулей в системе, скорее всего, надо перепроектировать — это означает, что он решает слишком много задач.
И наоборот, "сродство" функций, выполняемых одним модулем, должно быть как можно выше. Хотя на уровне кода причины этого "сродства" могут быть разными — работа с одними и теми же данными, зависимость от работы друг друга, необходимость синхронизации при параллельном выполнении и пр. — цена их разделения должна быть достаточно высокой. Наиболее существенно то, что эти функции решают тесно связанные друг с другом задачи.
Так, можно добавить в интерфейс очереди метод void println(String), отправляющий строку на стандартный вывод. Но он совсем не связан с остальными и с задачами, решаемыми очередью. Следовательно, трудоемкость анализа и внесения изменений в полученную систему будет значительно выше — ведь изменения в контексте разных задач возникают обычно независимо. Поэтому гораздо лучше поместить такой метод в другой модуль.
Переиспользование.
Этот принцип требует избегать повторений описаний одних и тех же знаний — в виде структур данных, действий, алгоритмов, одного и того же кода — в разных частях системы. Вместо этого в хорошо спроектированной системе выделяется один источник, одно место фиксации для каждого элемента знаний и организуется его переиспользование во всех местах, где нужно использовать этот элемент знаний. Такая организация позволяет при необходимости (например, при исправлении ошибки или расширении имеющихся возможностей) удобным образом модифицировать код и документы системы в соответствии с новым содержанием элементов знаний, поскольку каждый из них зафиксирован ровно в одном месте.
Примером может служить организация библиотечных классов java.util.TreeSet и java.util.TreeMap. Первый класс реализует хранение множества элементов, на которых определен порядок, в виде сбалансированного дерева. Второй класс реализует то же самое для ассоциативного массива или словаря (map), если определен порядок его ключей. Все алгоритмы работы со сбалансированным деревом в обоих случаях одинаковы, поэтому имеет смысл реализовать их только один раз. Если посмотреть на код этих классов в библиотеке JDK от компании Sun, можно увидеть, что ее разработчики так и поступили — класс TreeSet реализован как соответствующий ассоциативный массив TreeMap, в котором ключи представляют собой множество хранимых значений, а значение в любой паре (ключ, значение) равно null.