S.O.L.I.D на примерах С++ :: Cетевой уголок Majestio

S.O.L.I.D на примерах С++


SOLID (сокр. от англ. Sngle responsibility, Open–closed, Liskov substitution, Interface segregation и Dependency inversion) в программировании — мнемонический акроним, введённый Майклом Фэзерсом (Michael Feathers) для первых пяти принципов, названных Робертом Мартином в начале 2000-х, которые означали 5 основных принципов объектно-ориентированного программирования и проектирования.

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

Итак, всего пять принципов:

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

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

class Animal {
    string m_Name;
  public:
    Animal(string iName):m_Name(iName){}
    string GetName() {return m_Name;} 
    void Save() { // код сохранения в базу данных }
    void Print() { // код вывода на печать }
};

Для того чтобы привести вышеприведённый код в соответствие с принципом единственной ответственности, создадим ещё два класса, единственной задачей которых является работа со своей выполняемой функцией, в частности — сохранение в БД и извлечение объектов класса Animal, обеспечение форматной печати:

class Animal {
    string m_Name;
  public:
    Animal(string iName):m_Name(iName){}
    string GetName() {return m_Name;} 
};

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 

class AnimalSaver {
    Animal *m_animal;
  public:
    AnimalSaver(Animal *iAnimal): m_animal(iAnimal){}
    void Save() { 
      // код сохранения в базу данных 
      db << m.animal->GetName();
    }
};

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 

class AnimalPrinter {
    Animal *m_animal;
  public:
    AnimalPrinter(Animal *iAnimal): m_animal(iAnimal){}
    void Print() { 
      // код печати куда либо
      cout << m.animal->GetName();
    }
}

Данный пример иллюстрирует только разбиение класса на классы своей ответственности. Лучший вариант - было бы объявить сначала абстрактные классы AbstractSaver и AbstractPrinter. И уже на из основе, путем наследования, получить первоначальные конкретные реализации.

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

Продолжим работу над классом Animal. Мы хотим перебрать список животных, каждое из которых представлено объектом класса Animal, и узнать о том, какие звуки они издают. Представим, что мы решаем эту задачу с помощью функции AnimalSounds:

class Animal {
    string m_Name;
  public:
    Animal(string iName):m_Name(iName){}
    string GetName() {return m_Name;} 
};
// ...
const vector<Animal> animals = {
  Animal("Lion"),
  Animal("Mouse"),
  Animal("Snake")
};
// ...
void AnimalSounds(const vector<Animal> &animals) {
  for(const auto &i: animals) {
    if (i.Name == "Lion") cout << "roarn"; else
    if (i.Name == "Mouse") cout << "squeak"; else
    if (i.Name == "Snake") cout << "hiss";
    cout << endl;
  }
}

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

Как привести функцию AnimalSound в соответствие с принципом открытости-закрытости? Например - так:

class Animal {
  ...
  virtual string MakeSound() = 0;
};

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 

class Lion: public Animal {
  ...
  string MakeSound() override { rerurn "roar";}
};

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 

class Mouse: public Animal {
  ...
  string MakeSound() override { rerurn "squeak";}
};

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 

class Snake: public Animal {
  ...
  string MakeSound() override { rerurn "hiss";}
};

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 

void AnimalSounds(const vector<Animal> &animals) {
  for(const auto &i: animals) {
    cout << i.MakeSound() << endl;
  }
}

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

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

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

uint_t TotalAnimalLegCount(vector<Animal> &A) {
  uint_t Ret = 0; 
  for(const auto &i:A) {
    if (typeof(i) == Lion) Ret += LionLegCount(); else
    if (typeof(i) == Mouse) Ret += MouseLegCount(); else
    if (typeof(i) == Snake) Ret += SnakeLegCount();
  }
  return Ret;
}

Для того чтобы эта функция не нарушала принцип подстановки, преобразуем её с использованием требований, сформулированных Стивом Фентоном. Они заключаются в том, что методы, принимающие или возвращающие значения с типом некоего суперкласса (Animal в нашем случае) должны также принимать и возвращать значения, типами которых являются его подклассы. Вооружившись этими соображениями мы можем переделать функцию AnimalLegCount:

uint_t TotalAnimalLegCount(vector<Animal> &A) {
  uint_t Ret = 0; 
  for(const auto &i:A) Ret += i.LegCount();
  return Ret;
}

Теперь в классе Animal должен появиться чистый виртуальный метод LegCount, и его подклассам нужно его реализовать.

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

Рассмотрим интерфейс Shape:

class Shape {
  public:
    virtual void drawCircle() = 0;
    virtual void drawSquare() = 0;
    virtual void drawRectangle() = 0;
}

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

class Circle: public virtual Shape {
  public:
    void drawCircle() override { ... }
    void drawSquare() override { ... }
    void drawRectangle() override { ... }
};

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 

class Square: public virtual Shape {
  public:
    void drawCircle() override { ... }
    void drawSquare() override { ... }
    void drawRectangle() override { ... }
};

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 

class Rectangle: public virtual Shape {
  public:
    void drawCircle() override { ... }
    void drawSquare() override { ... }
    void drawRectangle() override { ... }
};

Странный у нас получился код! Например, класс Rectangle, представляющий прямоугольник, реализует методы drawCircle и drawSquare, которые ему совершенно не нужны. То же самое можно заметить и при анализе кода двух других классов.

Принцип разделения интерфейса предостерегает нас от создания интерфейсов, подобных Shape из нашего примера. Клиенты (у нас это классы Circle, Square и Rectangle) не должны реализовывать методы, которые им не нужно использовать. Кроме того, этот принцип указывает на то, что интерфейс должен решать лишь какую-то одну задачу (в этом он похож на принцип единственной ответственности), поэтому всё, что выходит за рамки этой задачи, должно быть вынесено в другой интерфейс или интерфейсы.

class ICircle {
  public:
    virtual void drawCircle() = 0;
};

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 

class ISquare {
  public:
    virtual void drawSquare() = 0;
};

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 

class IRectangle {
  public:
    virtual void drawRectangle() = 0;
};

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 

class Circle: public virtual ICircle  {
  public:
    void drawCircle() override {
      ...
    }
};

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 

class Square: public virtual ISquare  {
  public:
    void drawSquare() override {
      ...
    }
};

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 

class Rectangle: public virtual IRectangle  {
  public:
    void drawRectangle() override {
      ...
    }
};

Теперь интерфейс ICircle используется лишь для рисования кругов, равно как и другие специализированные интерфейсы - для рисования других фигур.

Объектом зависимости должна быть абстракция, а не что-то конкретное.
  1. Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.
  2. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
В процессе разработки программного обеспечения существует момент, когда функционал приложения перестаёт помещаться в рамках одного модуля. Когда это происходит, нам приходится решать проблему зависимостей модулей. В результате, например, может оказаться так, что высокоуровневые компоненты зависят от низкоуровневых компонентов.

Здесь класс Http представляет собой высокоуровневый компонент, а XMLHttpService - низкоуровневый. Такая архитектура нарушает пункт A принципа инверсии зависимостей: «Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций».

class XMLHttpService: public XMLHttpRequestService {};

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 

class Http {
   XMLHttpService *xmlHttpService;
  public:
    Http(XMLHttpService *iXmlHttpService)
      :xmlHttpService(iXmlHttpService) {}
    XRequest* get(string url, any options) {
      return xmlHttpService.request(url, any, "GET");
    }
    XRequest* post(string url, any options) {
      return xmlHttpService.request(url, any, "POST");
    }
};

Класс Http вынужденно зависит от класса XMLHttpService. Если мы решим изменить механизм, используемый классом Http для взаимодействия с сетью — скажем, это будет Node.js-сервис или, например, сервис-заглушка, применяемый для целей тестирования, нам придётся отредактировать все экземпляры класса Http, изменив соответствующий код. Это нарушает принцип открытости-закрытости.

Класс Http не должен знать о том, что именно используется для организации сетевого соединения. Поэтому мы создадим интерфейс Connection:

class Connection {
  public:
    XRequest* request(string url, any options, string method) = 0;
};

Интерфейс Connection содержит описание метода request и мы передаём классу Http аргумент типа Connection:

class Http {
   Connection *connection;
  public:
    Http(Connection *iConnection)
      :connection(iConnection) {}
    XRequest *get(string url, any options) {
      return connection.request(url, any, "GET");
    }
    XRequest *post(string url, any options) {
      return connection.request(url, any, "POST");
    }
};

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

Перепишем класс XMLHttpService таким образом, чтобы он реализовывал этот интерфейс:

class XMLHttpService: public Connection {
    XRequest *xhr;
  public:
    XMLHttpService() {
      xhr = new XMLHttpRequest();
    } 
    XRequest *request (string url, any options, string method){
       xhr->open(url);
       return xhr->send(any,method);  
    }
};

В результате мы можем создать множество классов, реализующих интерфейс Connection и подходящих для использования в классе Http для организации обмена данными по сети:

class NodeHttpService: public Connection {
    XRequest *request (string url, any options, string method){
      // ...
    }
};

// - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 

class MockHttpService: public Connection {
    XRequest *request (string url, any options, string method){
      // ...
    }
};

Как можно заметить, здесь высокоуровневые и низкоуровневые модули зависят от абстракций. Класс Http (высокоуровневый модуль) зависит от интерфейса Connection (абстракция). Классы XMLHttpService, NodeHttpService и MockHttpService (низкоуровневые модули) также зависят от интерфейса Connection.

Кроме того, стоит отметить, что следуя принципу инверсии зависимостей, мы соблюдаем и принцип подстановки Барбары Лисков. А именно, оказывается, что типы XMLHttpService, NodeHttpService и MockHttpService могут служить заменой базовому типу Connection.

Рейтинг: 4.3/5 - 3 голосов