суббота, 8 сентября 2018 г.

Pro паттерны

Omnia bona, homo sapiens!
Если вы работаете с объектно-ориентированным языком проектирования, то практически с первых дней вы столкнетесь с таким понятием, как паттерны - или, если по-русски, шаблоны - проектирования.
Легендарная книга "банды четырех" - Эриха Гаммы, Ричарда Хельма, Ральфа Джонсона и Джона Влиссидеса - о шаблонах проектирования показала, как нужно использовать объектно-ориентированные языки программирования, как во всей полноте раскрыть всю мощь и возможности таких языков.
Побочным эффектом популярности этой великой книги стало то, что начинающие разработчики, пытаясь на практике применить описанные в ней шаблоны, часто плодят бездумный и перегруженный лишними абстракциями код. Поэтому сегодня мы поговорим о том, о чем обычно не пишется в книгах о проектировании - о стреле времени и ее влиянии на архитектуру приложения.


Немного истории
Понятие шаблона проектирования пришло в computer science из строительного искусства. Кристофер Александер, американский архитектор и дизайнер, пытаясь сформулировать "язык шаблонов", считал, что "любой шаблон описывает задачу, которая снова и снова возникает в нашей работе, а также принцип ее решения, причем таким образом, что это решение можно потом использовать миллион раз, ничего не изобретая заново". Несмотря на то, что Кристофер писал о шаблонах в строительстве, его книга "Notes on the Synthesis of Form" стала обязательной к прочтению для исследователей computer science и идеи, описанные в ней, совершили революцию в теории и практике проектирования программного обеспечения.
В 1994 году упомянутая выше "банда четырех" публикует книгу "Design Patterns", в которой описано 23 шаблона проектирования программного обеспечения.
После этого понятие шаблона проектирования стало невероятно популярным, навсегда связав процесс проектирования ПО с этой книгой и ее авторами.

Ложные аналогии
Идеи строительного искусства, примененные к computer science, оказались столь плодотворными, что начинающие разработчики впадают в ложную иллюзию, что проектирование и создание программного обеспечения - это аналог возведения здания.
На самом деле между двумя этими процессами есть коренные отличия:
  1. Проектная документация при строительстве здания, как правило, намного (намного!!!) более полная, чем проектная документация при разработке программного обеспечения;
  2. Никто не меняет требования к зданию по ходу ведения строительных работ, а в разработке ПО такое явление - в порядке вещей;
Именно поэтому в разработке ПО так распространено явление "рефакторинга" исходного кода программы. 
Рефакторинг - это перепроектирование и реорганизация кода, когда изначальная архитектура, набор шаблонов и их связи друг с другом перестали отвечать задачам, стоящим перед программой. Когда шаблоны начинают скорее мешать, чем помогать. 
Согласитесь, "рефакторинг" готового здания с жильцами внутри - явление исключительное.
Именно тут заканчивается аналогия между строительством здания и разработкой программы. И разделительной чертой между этими двумя явлениями является стрела времени.

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

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

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

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

Пример
Итак, посмотрим на практике, как стрела времени влияет на архитектурные решения. 
Допустим, мы хотим написать некую программу, которая умеет рисовать фигуры. 
Мы, как архитекторы этой программы, предполагаем, что дальнейшая эволюция программы будет происходить в направлении увеличения количества устройств, сред и т.п., в которых мы будем рисовать фиксированный набор фигур - окружность, квадрат и треугольник. 
Мы понимаем, что в таких условиях необходима такая архитектура, которая позволяет легко добавлять возможность рисования в новом устройстве. Тогда классы фигур - должны быть неизменны, а устройство рисования - максимально абстрактным, чтобы при смене устройства - не пришлось бы переписывать все прикладные программы. 
Для отделения API и реализации в объектно-ориентированных языках используются механизмы позднего связывания. По-простому - если что-то может меняться, то с ним нужно работать через интерфейс. Если что-то - неизменно, то это класс.
Классы для квадрата и круга - это простые POJO. То есть примитивные классы, которые не содержат никакой логики, а используются только для хранения необходимой информации.
Тот факт, что эти классы неизменны - мы подчеркнем использованием ключевого слова final, чтобы никто не мог переопределить методы наших классов:
/**
 * Класс, содержащий информацию о квадрате.
 */
public final class Square {
    //Координата по оси абсцисс левого угла квадрата
    private int x;
    //Координата по оси ординат левого угла квадрата
    private int y;
    //Сторона квадрата
    private int size;

    public int getX() {
        return x;
    }

    public void setX(int x) {
        this.x = x;
    }

    public int getY() {
        return y;
    }

    public void setY(int y) {
        this.y = y;
    }

    public int getSize() {
        return size;
    }

    public void setSize(int size) {
        this.size = size;
    }
}

/**
 * Класс, содержащий информацию о круге
 */
public final class Circle {
    //Координата по оси абсцисс центра круга
    private int x;
    //Координата по оси ординат центра круга
    private int y;
    //Радиус круга
    private int radius;

    public int getX() {
        return x;
    }

    public void setX(int x) {
        this.x = x;
    }

    public int getY() {
        return y;
    }

    public void setY(int y) {
        this.y = y;
    }

    public int getRadius() {
        return radius;
    }

    public void setRadius(int radius) {
        this.radius = radius;
    }
}
Теперь напишем интерфейс для устройства, в котором мы будем рисовать. Он довольно тривиален:
/**
 * Абстрактное устройство для рисования.
 */
public interface Device {
    /**
     * Метод рисует квадрат.
     * @param square квадрат
     * @throws DrawException при возникновении ошибки отрисовки
     */
    void draw(Square square) throws DrawException;

    /**
     * Метод рисует круг.
     * @param circle круг
     * @throws DrawException при возникновении ошибки отрисовки
     */
    void draw(Circle circle) throws DrawException;
}
где DrawException - это исключение, которое описывает любые проблемы при отрисовке фигур:
public class DrawException extends Exception {
    public DrawException() {
    }

    public DrawException(String message) {
        super(message);
    }

    public DrawException(String message, Throwable cause) {
        super(message, cause);
    }

    public DrawException(Throwable cause) {
        super(cause);
    }

    public DrawException(String message, Throwable cause, 
                         boolean enableSuppression, boolean writableStackTrace) {
        super(message, cause, enableSuppression, writableStackTrace);
    }
}
Прикладная программа, использующая нашу библиотеку, хочет нарисовать вот такую картинку:
Для этого в коде прикладной программы создан следующий метод:
public void drawSomething(Device device) throws DrawException {
        //Создаем первый квадрат
        Square square1 = new Square();
        square1.setX(0);
        square1.setY(0);
        square1.setSize(10);
        //Рисуем первый квадрат
        device.draw(square1);

        //Создаем круг
        Circle circle = new Circle();
        circle.setX(15);
        circle.setY(5);
        circle.setRadius(5);
        //Рисуем круг
        device.draw(circle);

        //Создаем второй квадрат
        Square square2 = new Square();
        square2.setX(20);
        square2.setY(0);
        square2.setSize(10);
        //Рисуем второй квадрат
        device.draw(square2);
    }
Поскольку Device - это интерфейс, данный метод будет работать на любом устройстве.
Для каждого устройства нужно лишь написать реализацию интерфейса Device, сам метод drawSomething при этом остается неизменным.
Какие минусы примененного шаблона проектирования? Минус в том, что если в программу нужно будет добавить новые фигуры - это будет очень трудоемко.
Допустим, программа поддерживает 100 разных устройств. Тогда, при добавлении новой фигуры, необходимо добавить новый метод draw в интерфейс Device, а потом - реализовать этот метод в 100 реализациях этого интерфейса.

Пример 2
Теперь, предположим, что программа будет развиваться в сторону увеличения количества поддерживаемых фигур. Таким образом, архитектура должна сделать максимально простым добавление новых фигур. Также, предположим, что устройство для рисования одно.
Тогда исходный код будет выглядеть так:
/**
 * Абстрактная фигура.
 */
public interface Shape {
    /**
     * Метод рисует фигуру.
     * @throws DrawException при возникновении ошибки отрисовки
     */
    void draw() throws DrawException;
}

/**
 * Круг.
 */
public class Circle2 implements Shape {
    //Координата по оси абсцисс центра круга
    private int x;
    //Координата по оси ординат центра круга
    private int y;
    //Радиус круга
    private int radius;

    public int getX() {
        return x;
    }

    public void setX(int x) {
        this.x = x;
    }

    public int getY() {
        return y;
    }

    public void setY(int y) {
        this.y = y;
    }

    public int getRadius() {
        return radius;
    }

    public void setRadius(int radius) {
        this.radius = radius;
    }

    public void draw() throws DrawException {
        //тут рисуем каким-либо образом круг
    }
}

/**
 * Квадрат.
 */
public final class Square2 implements Shape {
    //Координата по оси абсцисс левого угла квадрата
    private int x;
    //Координата по оси ординат левого угла квадрата
    private int y;
    //Сторона квадрата
    private int size;

    public int getX() {
        return x;
    }

    public void setX(int x) {
        this.x = x;
    }

    public int getY() {
        return y;
    }

    public void setY(int y) {
        this.y = y;
    }

    public int getSize() {
        return size;
    }

    public void setSize(int size) {
        this.size = size;
    }

    public void draw() throws DrawException {
        //тут рисуем каким-либо образом квадрат
    }
}
В данном случае, каждая фигура знает, как себя рисовать. Тогда метод drawSomething будет выглядеть так:
    public void drawSomething() throws DrawException {
        //Создаем первый квадрат
        Square2 square1 = new Square2();
        square1.setX(0);
        square1.setY(0);
        square1.setSize(10);
        //Рисуем первый квадрат
        square1.draw();

        //Создаем круг
        Circle2 circle = new Circle2();
        circle.setX(15);
        circle.setY(5);
        circle.setRadius(5);
        //Рисуем круг
        circle.draw();

        //Создаем второй квадрат
        Square2 square2 = new Square2();
        square2.setX(20);
        square2.setY(0);
        square2.setSize(10);
        //Рисуем второй квадрат
        square2.draw();
    }
Казалось бы, кардинально ничего не изменилось. Но, поскольку фигура - теперь абстрактное понятие, представленное интерфейсом Shape, исходный код проще развивать в том направлении, когда неважна сама фигура.
Например, если заметить, что приведенный выше метод просто рисует последовательно фигуры, то ту же самую логику можно описать так:
    public void drawListOfShapes(List<Shape> shapes) throws DrawException {
        for (Shape shape : shapes) {
            shape.draw();
        }
    }

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

Пример 3
Предположим теперь, что мы точно знаем, что может меняться и устройство, на котором мы рисуем фигуры - и нужно иметь возможность рисовать совершенно разные фигуры. 
Пусть алгоритм рисования фигур на разных устройствах идентичен. Тогда имеет смысл API устройства сделать низкоуровневым, но достаточным, чтобы можно было нарисовать фигуру любой сложности. Для того, чтобы можно было единообразно работать с различными фигурами - имеет смысл иметь интерфейс Shape, который представляет собой фигуру. Таким образом, исходный код будет выглядеть так:
/**
 * Абстрактная устройство для рисования.
 */
public interface Device {
    /**
     * Метод рисует прямой отрезок от точки до точки.
     * @param x1 абсцисса первой точки
     * @param y1 ордината первой точки
     * @param x2 абсцисса второй точки
     * @param y2 ордината второй точки
     * @throws DrawException при возникновении ошибки отрисовки
     */
    void drawLine(int x1, int y1, int x2, int y2) throws DrawException;
}

/**
 * Абстрактная фигура.
 */
public interface Shape {
    /**
     * Метод рисует фигуру в абстрактном устройстве.
     * @param device абстрактное устройство
     * @throws DrawException при возникновении ошибки отрисовки
     */
    void draw(Device device) throws DrawException;
}

/**
 * Квадрат.
 */
public class Square3 implements Shape {
    //Координата по оси абсцисс левого угла квадрата
    private int x;
    //Координата по оси ординат левого угла квадрата
    private int y;
    //Сторона квадрата
    private int size;

    public int getX() {
        return x;
    }

    public void setX(int x) {
        this.x = x;
    }

    public int getY() {
        return y;
    }

    public void setY(int y) {
        this.y = y;
    }

    public int getSize() {
        return size;
    }

    public void setSize(int size) {
        this.size = size;
    }

    /**
     * Метод рисует квадрат в виде четырех последовательных отрезков.
     * @param device абстрактное устройство
     * @throws DrawException при возникновении ошибки отрисовки
     */
     public void draw(Device device) throws DrawException {
        device.drawLine(x, y, x, y + size);
        device.drawLine(x, y + size, x + size, y + size);
        device.drawLine(x + size, y + size, x + size, y);
        device.drawLine(x + size, y, x, y);
    }
}

/**
 * Круг.
 */
public class Circle3 implements Shape {
    //Координата по оси абсцисс центра круга
    private int x;
    //Координата по оси ординат центра круга
    private int y;
    //Радиус круга
    private int radius;

    public int getX() {
        return x;
    }

    public void setX(int x) {
        this.x = x;
    }

    public int getY() {
        return y;
    }

    public void setY(int y) {
        this.y = y;
    }

    public int getRadius() {
        return radius;
    }

    public void setRadius(int radius) {
        this.radius = radius;
    }

    /**
     * Метод рисует круг в виде 100 маленьких отрезков, соединенных между собой.
     * @param device абстрактное устройство
     * @throws DrawException при возникновении ошибки отрисовки
     */
    public void draw(Device device) throws DrawException {
        double radian = 0;

        int count = 100;
        double delta = 2 * Math.PI / count;
        for (int index = 0; index < count; index++) {
            int x1 = x + radius * (int) Math.cos(radian);
            int y1 = y + radius * (int) Math.sin(radian);

            double radian2 = radian + delta;
            int x2 = x + radius * (int) Math.cos(radian2);
            int y2 = y + radius * (int) Math.sin(radian2);

            device.drawLine(x1, y1, x2, y2);
            radian = radian2;
        }
    }
}
Теперь, мы можем добавлять любые фигуры - и они будут работать на любом поддерживаемом устройстве. Казалось бы, минусов у такой архитектуры нет. Но, если возникнет задача сохранения расположения фигур в постоянное хранилище - и восстановления этого расположения в исходном состоянии, мы поймем, что наша архитектура не содержит очевидных средств для выполнения этой задачи.
Необходимо иметь способ получить все нарисованные фигуры, тогда можно будет прочитать их координаты и сохранить.
Это можно сделать множеством способов. Но чтобы сделать это сейчас - необходим рефакторинг, поскольку изначальная архитектура не предполагала такой эволюции программы.
Все потому, что мы при проектировании нашей программы, не учли этот вектор развития.

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

Комментариев нет:

Отправить комментарий