Программирование на основе классов и шаблонов

Принципы объектно-ориентированного дизайна

Аладин Дмитрий Владимирович

iu5edu.ru/wiki/cpp2

План

  1. Критерии хорошего объектно-ориентированного дизайна.
  2. Принцип единственной ответственности.
  3. Принцип открытости-закрытости.
  4. Принцип подстановки Барбары Лисков.
  5. Принцип разделения интерфейса.
  6. Принцип инверсии зависимостей.
  7. Принцип Деметера.
  8. Принцип ацикличности зависимостей.

Вопрос "Как писать хорошие программы на C++?" очень похож на вопрос "Как писать хорошую английскую прозу?". Есть два ответа: "Знай, что ты хочешь сказать" и "Практикуйтесь. Подражайте хорошему письму". Оба варианта, по–видимому, так же подходят для C++, как и для английского, и так же трудны для понимания. Основным идеалом для программирования на C++ – как и для программирования на большинстве языков более высокого уровня - является выражение концепций (идей, понятий и т.д.) из проекта непосредственно в коде.

Бьёрн Страуструп - Язык программирования C++

Какой дизайн нам нужен?

Какие концепции необходимо использовать?

Какую программу можно назвать хорошо спроектированной, а какую — плохо?

Если бы осьминоги умели считать, то какой бы системой они, скорее всего, пользовались?

Может ли человек умереть от компьютерного вируса?

Поговорим про коня в вакууме

Возможно (но это не точно) хорошим, то есть наиболее приемлемым как для производителя, так и для потребителя программного продукта является тот дизайн, который обеспечит:

  • Наибольшую гибкость структуры ПО;
  • Низкую стоимость сопровождения;
  • Возможность повторного использования кода программ.

P.S. "Сферический конь в вакууме" - совершенно невозможное для применения на практике, однако в теории "живет и работает" прекрасно.

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

В. В. Мухортов, В. Ю. Рылов - Объектно-ориентированное программирование, анализ и дизайн

Гибкость

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

А новые требования пользователя точно будут адекватными?

P.S. 10 типов заказчиков.

Стоимость сопровождения

— стоимость внесения изменений в фазе сопровождения ПО.

center

Повторное использование кода

— это возможность использовать единожды определенные абстракции (классы, модули) в разных компонентах одного и того же или различного ПО.

Пути удовлетворения критериям хорошего ОО дизайна

  • Разработка дизайна иерархий классов (как собственно классов, так и отношений между ними);
  • Разработка дизайна пакетов (содержимого пакетов и связей между ними);
  • Поиск и повторное использования проверенных решений конкретных проблем.

P.S. У самурая нет тестов, только прод!

Разработка дизайна иерархий классов

— это про объектную декомпозицию!

Роберт Мартин (знаменитый в узких кругах блогер) разработал пять принципов объектно-ориентированного программирования и проектирования, говоря о которых, с подачи Майкла Фэзерса, используют акроним SOLID.

Следующий материал успешно украден адаптирован с хабра, который является переводом с blog.bitsrc.io.

Ну и еще использован методический материал из "В. В. Мухортов, В. Ю. Рылов - Объектно-ориентированное программирование, анализ и дизайн"

Адаптированный материал публикуется с сохранением условий распространения.

Что такое SOLID?

Расшифровка акронима SOLID

S: Single Responsibility Principle (Принцип единственной ответственности).
O: Open-Closed Principle (Принцип открытости-закрытости).
L: Liskov Substitution Principle (Принцип подстановки Барбары Лисков).
I: Interface Segregation Principle (Принцип разделения интерфейса).
D: Dependency Inversion Principle (Принцип инверсии зависимостей).

Максимально кратко про каждый принцип

S (Принцип единственной ответственности) — делай модули меньше.
O (Принцип открытости-закрытости) — делай модули расширяемыми.
L (Принцип подстановки Барбары Лисков) — наследуйся правильно.
I (Принцип разделения интерфейса) — дроби интерфейсы.
D (Принцип инверсии зависимостей) — используй интерфейсы.

S: Принцип единственной ответственности

Каждый класс должен решать лишь одну задачу.

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

class Animal {
 public:
  Animal(std::string name);
  std::string GetAnimalName();
  void SaveAnimal(Animal a);
};

Класс Animal, представленный здесь, описывает какое-то животное. Этот класс нарушает принцип единственной ответственности.

Он решает две, занимаясь работой с хранилищем данных в методе SaveAnimal и манипулируя свойствами объекта в конструкторе и в методе GetAnimalName.

Как такая структура класса может привести к проблемам?

Если изменится порядок работы с хранилищем данных, используемым приложением, то придётся вносить изменения во все классы, работающие с хранилищем.

center

Исправленный вариант:

class Animal {
 public:
  Animal(std::string name);
  std::string GetAnimalName();
};

class AnimalDB {
 public:
  Animal GetAnimal();
  void SaveAnimal(Animal a);
};

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

Стив Фентон - софтверный панк, автор, программист-архитектор, прагматик / абстракционист и специалист широкого профиля.

S: Классические примеры нарушения принципа

Объекты, которые могут печатать сами себя

class Book {
 public:
  std::string GetTitle();
  std::string GetAuthor();
  void TurnPage();
  std::string PrintCurrentPage();
};

Нужно выделить интерфейс Printer, от которого можно будет сделать PlainTextPrinter и HtmlPrinter.

Объекты, которые могут сохранять сами себя

class Book {
 public:
  std::string GetTitle();
  std::string GetAuthor();
  void TurnPage();
  void save(Book);
};

Нужно выделить интерфейс Storage, от которого можно будет сделать DB и File.

S: Признаки нарушения принципа

  • Затруднения с выбором подходящего имени класса;
  • Большое количество методов в классе;
  • Большое количество входящих и/или исходящих зависимостей;
  • Систематическое нарушение других принципов дизайна.

S по версии Р. Мартина

ORR (One responsibility Rule) — правило единственности абстракции.

Класс должен обладать единственной ответственностью, реализуя ее полностью, реализуя ее хорошо и реализуя только ее.

center

O: Принцип открытости-закрытости

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

center

class Animal {
 public:
  Animal(std::string name);
  std::string GetAnimalName();
};

void AnimalSound(std::vector<Animal> a) {
  for (size_t i = 0; i < a.size(); i++) {
    if (a[i].GetAnimalName() == "lion") std::cout << "roar" << std::endl;
    if (a[i].GetAnimalName() == "mouse") std::cout << "squeak" << std::endl;
  }
}

int main() {
  std::vector<Animal> a{};
  a.push_back(Animal("lion"));
  a.push_back(Animal("mouse"));
  AnimalSound(a);
}

Какие проблемы имеются тут?

Функция AnimalSound не соответствует принципу открытости-закрытости. Изменения в содержимом контейнера:

a.push_back(Animal("snake"));

влечет изменения AnimalSound:

void AnimalSound(std::vector<Animal> a) {
  for (size_t i = 0; i < a.size(); i++) {
    if (a[i].GetAnimalName() == "lion") std::cout << "roar" << std::endl;
    if (a[i].GetAnimalName() == "mouse") std::cout << "squeak" << std::endl;
    if (a[i].GetAnimalName() == "snake") std::cout << "hiss" << std::endl;
  }
}

Исправленный вариант:

class Animal {
 public:
  virtual std::string MakeSound() = 0;
};
class Lion : public Animal {
 public:
  virtual std::string MakeSound() override { return "roar"; };
};
class Squirrel : public Animal {
 public:
  virtual std::string MakeSound() override { return "squeak"; };
};
class Snake : public Animal {
 public:
  virtual std::string MakeSound() override { return "hiss"; };
};
void AnimalSound(std::vector<Animal*> a) {
  for (size_t i = 0; i < a.size(); i++) {
    std::cout << a[i]->MakeSound() << std::endl;
  }
}

int main() {
  std::vector<Animal*> a;
  a.push_back(new Lion());
  a.push_back(new Squirrel());
  a.push_back(new Snake());

  AnimalSound(a);
}

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

O: Классический пример нарушения принципа

class Discount {
  double price{};

 public:
  double GiveDiscount() { return price * 0.2; }
};

Решено было разделить клиентов на две группы. Любимым (fav) клиентам даётся скидка в 20%, а VIP-клиентам (vip) — удвоенная скидка, то есть — 40%.

class Discount {
  double price{};
  std::string customer;

 public:
  double GiveDiscount() {
    if (customer == "fav") {
      return price * 0.2;
    }
    if (customer == "vip") {
      return price * 0.4;
    }
    return 0;
  }
};

Исправленный вариант:

class Discount {
  double price{};
 public:
  virtual double GiveDiscount() { return price * 0.2; }
};

class VIPDiscount : public Discount {
 public:
  virtual double GiveDiscount() override { 
    return Discount::GiveDiscount() * 2;
  }
};

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

L: Принцип подстановки Барбары Лисков

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

Если оказывается, что в коде проверяется тип класса, значит принцип подстановки нарушается.

int LionLegCount() { return 4; }
int SquirrelLegCount() { return 4; }
int SnakeLegCount() { return 0; }

void AnimalLegCount(std::vector<Animal*> a) {
  for (size_t i = 0; i < a.size(); i++) {
    if (dynamic_cast<Lion*>(a[i]) != nullptr)
      std::cout << LionLegCount() << std::endl;
    if (dynamic_cast<Squirrel*>(a[i]) != nullptr)
      std::cout << SquirrelLegCount() << std::endl;
    if (dynamic_cast<Snake*>(a[i]) != nullptr)
      std::cout << SnakeLegCount() << std::endl;
  }
}

Что тут нарушено?

P.S. Статья о том, как получить реальный тип объекта в C++.

Функция AnimalLegCount нарушает и принцип подстановки, и принцип открытости-закрытости.

Для того чтобы эта функция не нарушала принцип подстановки, нужно выполнить требование, сформулированное Стивом Фентоном:

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

А мы такое уже делали:

class Animal {
 public:
  virtual std::string MakeSound() = 0;
  virtual int LegCount() = 0;
};

class Lion : public Animal {
 public:
  virtual std::string MakeSound() override { return "roar"; };
  virtual int LegCount() override { return 4; }
};

void AnimalSound(std::vector<Animal*> a) {
  for (size_t i = 0; i < a.size(); i++) {
    std::cout << a[i]->MakeSound() << std::endl;
  }
}

void AnimalLegCount(std::vector<Animal*> a) {
  for (size_t i = 0; i < a.size(); i++) {
    std::cout << a[i]->LegCount() << std::endl;
  }
}

L по версии Р. Мартина

LSP (Liskov Substitution Principle) — принцип подстановки Лисковой.

Методы, принимающие в качестве параметра указатели и ссылки на объекты базового класса должны иметь возможность использовать эти объекты без необходимости знать, к какому классу (базовому или любому из производных) они принадлежат.

center

I: Принцип разделения интерфейса

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

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

center

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

class Shape {
 public:
  virtual void DrawCircle() = 0;
  virtual void DrawSquare() = 0;
  virtual void DrawRectangle() = 0;
};

Он описывает методы для рисования кругов (DrawCircle), квадратов (DrawSquare) и прямоугольников (DrawRectangle). В результате классы, реализующие этот интерфейс и представляющие отдельные геометрические фигуры, такие, как круг (Circle), квадрат (Square) и прямоугольник (Rectangle), должны содержать реализацию всех этих методов.

class Circle : public Shape {
 public:
  void DrawCircle() override;
  void DrawSquare() override;
  void DrawRectangle() override;
};

class Square : public Shape {
 public:
  void DrawCircle() override;
  void DrawSquare() override;
  void DrawRectangle() override;
};

class Rectangle : public Shape {
 public:
  void DrawCircle() override;
  void DrawSquare() override;
  void DrawRectangle() override;
};

Странный получился код? А чем еще это грозит?

Предположим, мы решим добавить в интерфейс Shape ещё один метод, DrawTriangle, предназначенный для рисования треугольников:

class Shape {
 public:
  virtual void DrawCircle() = 0;
  virtual void DrawSquare() = 0;
  virtual void DrawRectangle() = 0;
  virtual void DrawTriangle() = 0;
};

Это приведёт к тому, что классам, представляющим конкретные геометрические фигуры, придётся реализовывать ещё и метод DrawTriangle. В противном случае возникнет ошибка.

Исправленный вариант:

class Shape {
 public:
  virtual void Draw() = 0;
};

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

...

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


class Rectangle : public IRectangle {
 public:
  void DrawRectangle() override;
  void Draw() override;
};

I по версии Р. Мартина

ISP (Interface Segregation Principle) — принцип разделения интерфейсов

Классы не должны зависеть от контрактов, которых они не используют.

P.S. На фото Роберт Мартин рассказывает о SOLID джуну

D: Принцип инверсии зависимостей

Объектом зависимости должна быть абстракция, а не что-то конкретное.

Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.

center

class XMLHttpService {
    void GetRequest();
    void PostRequest();
};

class Http {
 public:
  Http(XMLHttpService service);
  void Get(/*args*/);
  void Post(/*args*/);
};

Здесь класс Http представляет собой высокоуровневый компонент, а XMLHttpService — низкоуровневый.

Где в структуре скрываются проблемы?

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

Исправленный вариант:

class Connection {
  virtual void GetRequest() = 0;
  virtual void PostRequest() = 0;
};

class XMLHttpService : public Connection {
  virtual void GetRequest() override;
  virtual void PostRequest() override;
};

class MockService : public Connection {
  virtual void GetRequest() override;
  virtual void PostRequest() override;
};

class Http {
 public:
  Http(Connection* service);
  void Get(/*args*/);
  void Post(/*args*/);
};

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

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

D по версии Р. Мартина

DIP (Dependency Inversion Principle) — принцип инверсии зависимости

Модули высокого уровня не должны зависеть от модулей низкого. И те, и другие, должны зависеть от абстракций. Абстракции не должны зависеть от деталей реализации.

P.S. Роберт Мартин показывает максимальную оценку за экзамен прогульщиков.

Принцип Деметера

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

Ян Холланд

Оригинальная формулировка этого принципа, возникшая в процессе работы над одноименным проектом (Demeter) в Северо-Восточный университет (Бостон) осенью 1987 года.

Принцип Деметера не ограничивается классами.

Одна из формулировок принципа Деметера звучит так:

Разговаривайте только с близкими друзьями. Никогда не разговаривайте с незнакомцами.

Минимизируйте связывание между модулями

Э. Хант, Д. Томас. "Программист-прагматик"

Пример из книги:

class A {
 public:
  void setActive();
};
class B {
 public:
  void invert();
};
class C {
 public:
  void print();
};
void Demeter::example(B& b) {
  int f = func();  // 1
  b.invert();  // 2
  a = new A();
  a->setActive();  // 3
  C c;
  c.print();  // 4
}

Закон Деметры для функций гласит, что любой метод некоторого объекта может обращаться только к методам принадлежащим: самим себе (1); любым параметрам , переданным в метод (2); любым создаваемым им объектам (3); любым непосредственно содержащимся объектам компонентов (4).

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

Гради Буч, один из создателей UML

Принцип ацикличности зависимостей (Р. Мартин)

ADP (Acyclic Dependencies Principle) — принцип ацикличности зависимостей

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

Принцип в равной степени применим ко всем элементам ПО.

Ориентированный ациклический граф:

center

Изменение компонента Database (который нужно собрать вместе с компонентов Entitities) затронет только компонент Main.

Ориентированный граф с циклами:

center

Изменение Database требует проверки работы с Entities, но Entities зависит от Authorizer, который зависит от Interactors. По сути Database, Entities, Authorizer,Interactors вместе становятся одним большим компонентом.

Стратегии прерывания цикла

Циклы в зависимостях делают код более хрупким. Стратегии разорвать цепочку циклических зависимостей:

  • Принцип инверсии зависимостей;
  • Создать новый пакет и переместить туда общие зависимости.

Вопросы?

center

P.S. Р. Мартин не доволен тем, что джуны не читают его книг