[Из песочницы] Принцип открытости-закрытости

Привет, Хабр! Перед вами перевод статьи Роберта Мартина Open-Closed Principle, которую он опубликовал в январе 1996 года. Статья, мягко говоря, не самая свежая. Но в рунете статьи дяди Боба про SOLID пересказывают только в урезанном виде, поэтому я подумал, что полный перевод лишним не будет.


⌘ ⌘ ⌘

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


  • Ни одну программу нельзя «закрыть» на 100%.
  • Объектно-ориентированное программирование (ООП) оперирует не физическими объектами реального мира, а понятиями — например, понятием «упорядочивание».

ytxn-qjuk7_t4wyyna-6nr4xvsc.png

Это первая статья в моей колонке Заметки Инженера для The C++ Report. Статьи, публикуемые в этой колонке, будут фокусироваться на использовании C++ и ООП и затрагивать сложности в разработке ПО. Я постараюсь сделать так, чтобы материалы были прагматичны и полезны для практикующих инженеров. Для документации объектно-ориентированного дизайна в этих статьях я буду использовать нотацию Буча.

С объектно-ориентированным программированием связано много эвристик. Например, «все переменные-члены (member variables) должны быть закрытыми (private)», или «следует избегать глобальных переменных», или «определение типов во время исполнения опасно». В чем причина таких эвристик? Почему они правдивы? Всегда ли они правдивы? В этой колонке исследуется принцип проектирования, лежащий в основе этих эвристик, — принцип открытости-закрытости.
Ивар Якобсон сказал: «Все системы изменяются в процессе жизненного цикла. Это нужно иметь в виду при проектировании системы, у которой ожидается больше одной версии». Как же мы можем спроектировать систему, чтобы она была устойчива перед лицом изменений и у которой ожидается больше чем одна версия? Бертран Мейер рассказал нам об этом еще в далеком 1988 году, когда сформулирован знаменитый ныне принцип открытости-закрытости:

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

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

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

Модули, отвечающие принципу открытости-закрытости, имеют два главных признака:


  1. Открыты для расширения. Это означает, что поведение модуля может быть расширено. То есть мы можем добавить модулю новое поведение в соответствии с изменившимися требованиями к приложению или для удовлетворения нужд новых приложений.
  2. Закрыты для изменений. Исходный код такого модуля неприкасаем. Никто не вправе вносить в него изменения.

Кажется, что два этих признака друг с другом не вяжутся. Стандартный способ расширить поведение модуля — внести в него изменения. Модуль, который не может быть изменен, обычно мыслится как модуль с фиксированным поведением. Как же могут быть выполнены эти два противоположных условия?

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

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

На схеме ниже представлен простой вариант проектирования, который не отвечает принципу открытости-закрытости. Оба класса, Client и Server, не абстрактны. Нет гарантий, что функции — члены класса Server виртуальны. Класс Client использует класс Server. Если мы захотим, чтобы объект класса Client использовал другой объект сервера, то мы должны изменить класс Client, чтобы он ссылался на новый класс сервера.

image
Закрытый клиент

А на следующей схеме представлен соответствующий вариант проектирования, который отвечает принципу открытости-закрытости. В этом случае класс AbstractServer — это абстрактный класс, все функции-члены которого виртуальные. Класс Client использует абстракцию. Однако объекты класса Client будут использовать объекты класса-наследника Server. Если мы захотим, чтобы объекты класса Client использовали другой класс сервера, то мы введем нового наследника класса AbstractServer. Класс Client при этом останется без изменений.

image
Открытый клиент

Рассмотрим приложение, которое должно рисовать круги и квадраты в стандартном GUI. Круги и квадраты должны быть нарисованы в определенном порядке. В соответствующем порядке будет составлен список кругов и квадратов, программа должна пройтись в этом порядке по списку и нарисовать каждый круг или квадрат.

На C, используя техники процедурного программирования, не отвечающие принципу открытости-закрытости, мы могли бы решить эту задачу как показано на листинге 1. Здесь мы видим множество структур данных с одним и тем же первым элементом. Этот элемент — код типа, который идентифицирует структуру данных как круг или квадрат. Функция DrawAllShapes проходит по массиву указателей на эти структуры данных, узнавая код типа и затем вызывая соответствующую функцию (DrawCircleили DrawSquare).

//Листинг 1
//Решение проблемы Квадрат/Круг в процедурном стиле

enum ShapeType {circle, square}

struct Shape
{
    ShapeType itsType;
};
struct Circle
{
    ShapeType itsType;
    double itsRadius;
    Point itsCenter;
};

struct Square
{
    ShapeType itsType;
    double itsSide;
    Point itsTopLeft;
};
//
// реализованы в другом месте
//
void DrawSquare(struct Square*)
void DrawCircle(struct Circle*);
typedef struct Shape *ShapePointer;
void DrawAllShapes(ShapePointer list[], int n)
{
    int i;
    for (i=0; iitsType)
        {
            case square:
            DrawSquare((struct Square*)s);
            break;
            case circle:
            DrawCircle((struct Circle*)s);
            break;
        }
    }
}

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

Конечно, эта программа — просто пример. В реальной жизни switch-оператор из функции DrawAllShapes повторялся бы снова и снова в различных функциях по всему приложению и каждый бы делал что-то свое. Добавить новые фигуры в такое приложение — значит найти все места, где используются такие switch-операторы (или цепочки if/else), и добавить новую фигуру в каждое из них. Более того, очень маловероятно, что все switch-операторы и цепочки if/else будут так же хорошо структурированы, как в DrawAllShapes. Куда более вероятно, что предикаты в операторах if будут скомбинированы с логическими операторами или case-блоки switch-операторов будут скомбинированы так, чтобы «упростить» конкретное место в коде. Поэтому проблема нахождения и понимания всех мест, где нужно добавить новую фигуру, может быть нетривиальна.

В листинге 2 я покажу код, который демонстрирует решение задачи квадрат/круг, отвечающее принципу открытости-закрытости. Вводится абстрактный класс Shape. Этот абстрактный класс содержит одну чистую виртуальную функцию Draw. Классы Circle и Square являются наследниками класса Shape.

//Листинг 2
//Решение проблемы Квадрат/Круг в ООП-стиле

class Shape
{
public:
    virtual void Draw() const = 0;
};
class Square : public Shape
{
public:
    virtual void Draw() const;
};
class Circle : public Shape
{
public:
    virtual void Draw() const;
};
void DrawAllShapes(Set& list)
{
    for (Iteratori(list); i; i++)
        (*i)->Draw();
}

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

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

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

Очевидно, что ни одна программа не может быть на 100% закрыта. Например, что произойдет с функцией DrawAllShapes из листинга 2, если мы решим, что сначала должны быть нарисованы круги, а затем квадраты? Функция DrawAllShapes не закрыта от такого рода изменений. В целом не важно, насколько «закрыт» модуль, всегда есть какой-то тип изменений, от которого он не закрыт.

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


Использование абстракции для доcтижения дополнительной закрытости

Как мы можем закрыть функцию DrawAllShapes от изменений в порядке рисования? Помним, что закрытие базируется на абстракции. Поэтому, чтобы закрыть DrawAllShapes от упорядочивания, нам нужна некая «абстракция упорядочивания». Частный случай упорядочивания, представленный выше, — это рисование фигур одного типа перед фигурами другого типа.

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

В C++ эта функция может быть представлена как перегрузка оператора »<». В листинге 3 приведен класс Shape с методами сортировки.

Теперь, когда у нас есть способ определения порядка следования объектов класса Shape, мы можем отсортировать их, а затем нарисовать. В листинге 4 приведен соответствующий код на C++. В нем используются классы Set, OrderedSet и Iterator из категории Components, разработанной в моей книге (Designing Object Oriented C++ Applications using the Booch Method, Robert C. Martin, Prentice Hall, 1995).

Итак, мы реализовали упорядочивание объектов класса Shape и рисование их в соответствующем порядке. Но у нас все еще нет реализации абстракции упорядочивания. Очевидно, что каждый объект класса Shape должен переопределять метод Precedes для определения порядка. Как это может работать? Какой код необходимо написать в Circle::Precedes, чтобы круги рисовались до квадратов? Обратите внимание на листинг 5.

//Листинг 3
//Абстрактный класс Shape с методами для упорядочивания.

class Shape
{
public:
    virtual void Draw() const = 0;
    virtual bool Precedes(const Shape&) const = 0;
    bool operator<(const Shape& s) {return Precedes(s);}
};
//Листинг 4
//метод DrawAllShapes с упорядочиванием

void DrawAllShapes(Set& list)
{
    // копировать элементы в OrderedSet и отсортировать.
    OrderedSet orderedList = list;
    orderedList.Sort();
    for (Iterator i(orderedList); i; i++)
        (*i)->Draw();
}
//Листинг 5
//Определение порядка для круга

bool Circle::Precedes(const Shape& s) const
{
    if (dynamic_cast(s))
        return true;
    else
        return false;
}

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


Использование Data Driven подхода для достижения закрытости

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

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

//Листинг 6
//Механизм упорядочивания использующий табличный подход

#include 
#include 
enum {false, true};
typedef int bool;
class Shape
{
public:
    virtual void Draw() const = 0;
    virtual bool Precedes(const Shape&) const;
    bool operator<(const Shape& s) const
    {return Precedes(s);}
private:
    static char* typeOrderTable[];
};
char* Shape::typeOrderTable[] =
{
    "Circle",
    "Square",
    0
};
// Функция ищет имена классов в таблице.
// Таблица определяет последовательность, в которой должны
// рисоваться фигуры. Фигуры, которых в таблице нет,
// всегда рисуются в первую очередь
bool Shape::Precedes(const Shape& s) const
{
    const char* thisType = typeid(*this).name();
    const char* argType = typeid(s).name();
    bool done = false;
    int thisOrd = -1;
    int argOrd = -1;
    for (int i=0; !done; i++)
    {
        const char* tableEntry = typeOrderTable[i];
        if (tableEntry != 0)
        {
            if (strcmp(tableEntry, thisType) == 0)
                thisOrd = i;
            if (strcmp(tableEntry, argType) == 0)
                argOrd = i;
            if ((argOrd > 0) && (thisOrd > 0))
                done = true;
        }
        else // table entry == 0
            done = true;
    }
    return thisOrd < argOrd;
}

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


Дальнейшее закрытие

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

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


Делайте все переменные-члены приватными

Это одна из наиболее устойчивых конвенций ООП. Переменные — члены классов должны быть известны только методом класса, в котором они определены. Переменные члены не должны быть известны никаким другим классам, включая классы-наследники. Поэтому они должны быть объявлены с модификатором доступа private, а не public или protected.
В свете принципа открытости-закрытости причина такой конвенции понятна. Когда переменные — члены класса меняются, каждая функция, зависящая от них, должна измениться. То есть функция не закрыта от изменений этих переменных.

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

Но что если у вас есть переменная, насчет которой вы уверены, что она никогда не изменится? Имеет ли смысл делать ее private? Например, в листинге 7 приводится класс Device, содержащий переменную — член bool status. В ней хранится статус последней операции. Если операция была успешна, то значение переменной status будет true, в противном случае — false.

//Листинг 7
//неконстантная публичная переменная

class Device
{
public:
    bool status;
};

Мы знаем, что тип или смысл этой переменной никогда не изменится. Так почему бы не сделать ее public и не дать клиенту прямой доступ к ней? Если переменная действительно никогда не изменится, если все клиенты соблюдают правила и лишь читают из этой переменной, тогда нет ничего страшного в том, что переменная публична. Однако подумайте, что случится, если один из клиентов воспользуется возможностью писать в эту переменную и изменит ее значение.

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

С другой стороны, предположим, что у нас есть класс Time, приведенный в листинге 8. В чем опасность публичности переменных — членов этого класса? Очень маловероятно, что они изменятся. Более того, не важно, изменяют клиентские модули значения этих переменных или нет, так как изменение этих переменных предполагается. Также очень маловероятно, что наследуемые классы могут зависеть от значения конкретной переменной-члена. Так есть ли проблема?

//Листинг 8

class Time
{
public:
    int hours, minutes, seconds;
    Time& operator-=(int seconds);
    Time& operator+=(int seconds);
    bool operator< (const Time&);
    bool operator> (const Time&);
    bool operator==(const Time&);
    bool operator!=(const Time&);
};

Единственная претензия, которую я мог бы предъявить коду из листинга 8, — это то, что изменение времени не атомарно. То есть клиент может изменить значение переменной minutes без изменения значения переменной hours. Это может привести к тому, что объект класса Time может содержать несогласованные данные. Я бы предпочел ввести единственную функцию для установки времени, которая принимала бы три аргумента, что делало бы установку времени атомарной операцией. Но это слабый аргумент.

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

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


Никаких глобальных переменных… вообще!

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

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


RTTI — это опасно

Еще один распространенный запрет — использование dynamic_cast. Очень часто dynamic_cast или другою форму определения типа во время выполнения (RTTI) обвиняют в том, что это крайне опасная техника, а потому ее следует избегать. При этом часто приводят пример из листинга 9, который очевидно нарушает принцип открытости-закрытости. Однако в листинге 10 приведен пример аналогичной программы, которая использует dynamic_cast, не нарушая при этом принцип открытости-закрытости.

Разница между ними в том, что в первом случае, приведенном в листинге 9, код нужно менять каждый раз, когда появляется новый наследник класса Shape (не говоря уже о том, что это абсолютно нелепое решение). Однако в листинге 10 в этом случае никаких изменений не требуется. Поэтому код из листинга 10 не нарушает принцип открытости-закрытости.
Правилом большого пальца в данном случае можно считать то, что RTTI можно использовать, если принцип открытости-закрытости не нарушается.

//Листинг 9
//RTTI, нарушающее принцип открытости-закрытости.

class Shape {};
class Square : public Shape
{
private:
    Point itsTopLeft;
    double itsSide;
    friend DrawSquare(Square*);
};
class Circle : public Shape
{
private:
    Point itsCenter;
    double itsRadius;
    friend DrawCircle(Circle*);
};
void DrawAllShapes(Set& ss)
{
    for (Iteratori(ss); i; i++)
    {
        Circle* c = dynamic_cast(*i);
        Square* s = dynamic_cast(*i);
        if (c)
            DrawCircle(c);
        else if (s)
            DrawSquare(s);
    }
}
//Листинг 10
//RTTI, не нарушающее принцип открытости-закрытости.

class Shape
{
public:
    virtual void Draw() cont = 0;
};
class Square : public Shape
{
// реализация.
};
void DrawSquaresOnly(Set& ss)
{
    for (Iteratori(ss); i; i++)
    {
        Square* s = dynamic_cast(*i);
        if (s)
            s->Draw();
    }
}

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

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

© Habrahabr.ru