Учебник: объектно-ориентированное программирование

Введение

Почти все популярные языки программирования являются объектно-ориентированными. В таблице приведены данные о популярности языков (рейтинг TIOBE) за сентябрь 2019 года [1]:

Не являются объектно-ориентированными лишь 20% — это языки программирования аппаратуры (Си, Assembly language), декларативный язык программирования баз данных (SQL) и визуальный язык MATLAB. Не удивительно, что почти в каждом описании вакансии программиста требуется что-то типа «Понимание ООП» или «Понимание принципов SOLID» [2] (что очень близко).

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

В этой статье-учебнике:

  1. рассмотрены тенденции развития языков программирования, объясняющие существующую систему ценностей;
  2. на примерах показаны механизмы ООП и их роль в обеспечении ценностей;
  3. приведены аннотации на материалы для дальнейшего изучения.

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

1 Тенденции и ценности

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

1.1 Повышение уровня абстракции. Инкапсуляция

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

— Нужно ли нам думать о том, как на низком уровне происходит передача параметров в функцию и возвращается значение?

Примерами абстракций для программиста являются процессор (черный ящик, который умеет выполнять программу), отдельная синтаксическая конструкция (нас редко интересует как «внутри» реализовано ветвление или механизм исключений), функция, … класс.

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

Пример хорошей абстрации приведен в докладе «Память – идеальная абстракция» с конференции С++ Russia 2018 [4]:

int* ptr = new int; 
*ptr = 42;
delete ptr;

Любой программист на С++ знает, что в этих строчках по указателю выделяется память под объект целого типа, в эту память пишется число 42, а затем — память освобождается. Из доклада мы можем узнать, что внутри оператор new устроен совсем не так просто (за 40 минут мы узнаем о 9 структурах данных, обеспечивающих работу этого оператора, включая кэш страниц процессора).

Множество хороших абстракций предоставляет нам операционная система и библиотеки. Открывая файл мы не задумываемся о файловых системах (FAT/NTFS), создавая поток (thread) — о размещении его в памяти и т.д.

Говоря о том, что оператор new, файл, сокет или поток является хорошей абстракцией мы имеет ввиду, что он обеспечивает хорошую инкапсуляцию.

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

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

1.2 Безопасность и расширяемость

При программировании на безопасном языке программирования (не Си и ассемблер) сложно отстрелить себе ногу. Функция безопасна если ее сложно использовать неправильным образом — это также справедливо для модулей и классов. Возможность расширения (доработки) вашего собственного кода напрямую связаны с его безопасностью.

Безопасность осуществляется за счет предоставления хороших абстракций. В качестве примера давайте сравним функцию создания окна, предоставляемую Windows API и библиотекой Qt:

void CreateWindowA(lpClassName, lpWindowName, dwStyle, x, y, nWidth, nHeight, hWndParent, hMenu, hInstance, lpParam);

Функцию CreateWindowA очень сложно использовать (от нас требуют 11 параметров), при этом легко допустить ошибку, например:

  • lpClassName — строка с нулевым символом в конце или целочисленный атом, заранее созданный обращением к функции GlobalAddAtom. Если передать неправильную строку или некорректное число — программа будет вести себя непредасказуемо.
  • hMenu и hInstance являются идентификаторами (тип int), которые должны быть заранее созданы. Легко выстрелить себе в ногу передав неправильный идентификатор;

QWidget::QWidget(QWidget *parent, Qt::WindowFlags f);

Худшее, что можно сделать с классом QWidget — передать ему нулевой указатель, при этом программа будет нормально работать, но если виджет создавался на куче (оператором new) — то он не будет автоматически уничтожен при уничтожении родительского виджета. Это не так критично, как неопределенное поведение. Итог — виджеты библиотеки Qt являютсяы хорошей и относительно безопасной абстракцией.

1.3 Ослабление зависимостей — повторное использование и тестируемость

Вы прониклись описанными выше идеями и, в процессе работы над некоторым проектом, создали нечто хорошее — удобную и безопасную библиотеку функций или такой же хороший элемент пользовательского интерфейса (виджет). Ценность вашей работы будет значительно выше если эту библиотеку или виджет можно будет легко применить в других проектах — это и называется «повторным использованием». Очевидно, без особых усилий «вырвать» отдельный компонент из нашего проекта не получится если он использует другие компоненты, т.е. зависит от них. Лучший случай — это полное отсутствие зависимостей от других компонент, однако такое встречается редко.

Каждый язык программирования предоставляет программисту некоторый набор доступных отношений (зависимостей), в процессе разработки нужно задумываться об их «силе» и по возможности использовать наиболее слабые. Закончив читать статью, обратие внимание на принцип инверсии зависимостей [2], который не только декларирует «не должно существовать прямых зависимостей между конкретными модулями», но и предлагает пути решения проблемы.

Для тестирования функции нам скорее всего придется проверить все комбинации ее входных аргументов — если функция принимает N аргументов логического типа, нам потребуется \(2^N\) тестовых наборов данных (для любых других типов входных параметров ситуация гораздо хуже). Функция является компонентом и зависит от своих аргументов; очевидно, что затраты на тестирование резко возврастают при увеличении числа зависимостей. Это справедливо и для других видов компонент — модулей и классов.

2 Механизмы

2.1 Модульность

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

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

Модульность является базовым принципом в программировании (не является особенностью ООП).

Модульное программирование — это организация программы как совокупности небольших независимых блоков …, структура и поведение которых подчиняются определённым правилам (Википедия)

Почти во всех языках программирования, в том или ином виде, поддерживаются специальные механизмы модулей или пакетов (Java, Python, Паскаль, С++20). Модуль представляет собой набор типов данных и функций, часть из которых экспортируется — доступна другим модулям.

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

  • public — аналогична секции export модулей, описывает часть класса, доступную всем остальным компонентам;
  • protected — содержимое секции доступно классам-наследникам (рассмотрено ниже);
  • private — в нее помещаются детали реализации класса (функции и стуктуры данных, недоступные извне).

Видим, что классы расширяют классический механизм модулей секцией protected, что улучшает инкапсуляцию — подумайте «почему?».

2.2 Композиция и наследование

Между модулями программы можно организовать отношение «включения» (композиции):

  • вы делаете окошко авторизации — оно включает в себя два поля ввода и одну кнопку. Эти элементы управления должны создаваться в функции какого-то модуля и где-то обрабатываться;
  • вы пишите программу для рисования (аналог MS Paint) — помимо прочего, в ней должен быть модуль открытия файла с картинкой, который включает в себя модуль работы с файлами и модуль, выполняющий «разбор» данных изображения, в соответствии с форматом (png/jpg).

В обоих случаях между модулями появляется отношение включения, отражающее отношение «реализуется посредством«.

Объектно-ориентированное программирование предложило дополнительный вид отношений между модулями — наследование. Очень подробно этот механизм рассматривается Б. Мейером — он выделяет 12 видов наследования [5], однако, на практике чаще всего применяют наследование, реализующее отношения «является» (is-a, открытое наследование) … [6]. Мак-Колм рассматривает наследование как базовый шаблон проектирования, при этом имеет ввиду is-a-наследование [7].

Принцип подстановки (LSP) изначально сформулирован Барбарой Лисков и регламентирует правильное использование механизма наследования. Выделяются некоторый базовый тип и его подтип (класс-наследник). Согласно LSP, программы должны быть написаны таким образом, чтобы в любом месте вместо базового типа мог быть подставлен подтип. Это означает, что классы наследники должны реализовывать интерфейс согласованно с интерфейсом базового класса. [2]

При выборе отношений между своими классами можно руководствоваться следующим базовым правилом:

  1. если один класс реализует свои функции за счет использования функций другого — применяйте отношение включения;
  2. если же класс является разновидностью другого — применяйте открытое наследование.

Нужно учитывать, что наследование обеспечивает лучшую инкапсуляцию (за счет секции protected), однако является более сильным видом отношения, т.к. такая зависимость устанавливается при компиляции и ее нельзя изменить, в то время как зависимость по включению одного класса может быть заменено на зависимость от другого (см. композиция и аргегация [8]).

2.3 Полиморфизм

Вы пишите компьютерную игру в жанре RPG — в ней будут строения и юниты, при этом некоторые строения должны уметь «производить» юнитов. Пусть, у нас будет казарма, не простая, а двух типов — 18 и 19 века. Казарма производит воинов — тоже 18 и 19 века. Воин должен уметь ударять другого воина (сила зависит от его типа).

Без использования полиморфизма можно накидать примерно такой код:

class Unit { // В C++ это эквивалентно структуре
public:
  int health;
};

class Unit18 : public Unit { // в Javа вместо public - extends
public:
  hit(Unit18 otherUnit) { 
    ohterUnit.health = ohterUnit.health - 1;
  }
  hit(Unit19 otherUnit) {
   ohterUnit.health = ohterUnit.health - 1;
  }
};

class Unit19 : public Unit {
public:
  hit(Unit18 otherUnit) { 
    ohterUnit.health = ohterUnit.health - 2;
  }
  hit(Unit19 otherUnit) {
   ohterUnit.health = ohterUnit.health - 2;
  }
};

class Barracks18 {
public:
  Unit18 build();
};

class Barracks19 {
public:
  Unit19 build();
};

Автор этого кода, наверное, попытался выделить сущности и как-то увязать их друг с другом. Базовый класс Unit хранит количество здоровья, тут правильно используется наследование — ведь все воины являются юнитами.

Соответствующая коду UML-диаграмма классов [8]:

При увеличении количества воинов, которые могут бить друг друга мы получим резкий рост числа функций hit — для 10 типов воинов нам придется написать 100 функций. В самом деле, функции hit достаточно знать, что у поступившего на вход аргумента есть поле health. Объектно-ориентированные языки программирования позволяют выполнить преобразование конкретного класса к типу базового класса.

class Unit19 : public Unit {
public:
  void hit(Unit* otherUnit) {
    ohterUnit->health = ohterUnit->health - 2;
  }
}

На вход функции hit, под видом указателя на базовый класс, могут поступать любые Unit-ы (наследники), но это еще не совсем полиморфизм. Используется наш код так:

Barracks18 barracks_a;
Barracks19 barracks_b;

Unit18 unit_a = barracks_a.build();
Unit19 unit_b = barracks_b.build();

unit_a.hit(&unit_b);

В этом примере будет вызвана функция Unit18::hit и это известно уже в момент компиляции программы. Однако, если юзер выберет мышью нашего юнита и скажет кого-то ударить — то нельзя заранее сказать какая функция должна сработать. Выбор функции теперь зависит от типа объекта, выбранного пользователем в момент выполнения программы. Именно такого рода проблему призван решить механизм полиморфизма.

Полиморфизм — способность программы выбирать различные реализации, при вызове операций с одним и тем же названием.

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

Barracks* barracks;
if (key == 18) {
  barracks = new Barracks18;
}
else {
  barracks = new Barracks19;
}

Unit* unit_a = barracks->build();
Unit* unit_b = barracks->build();

unit_a->hit(unit_b);

В этом примере компилятор знает, что переменная barracks имеет тип Barracks, который должен предоставлять функцию build. Выбор реального типа казармы зависит от ключа key — его, например, может вводить пользователь. Если пользователь ввел 18 — то должна быть вызвана функция Unit18::hit.

Для реализации такого поведения в процедурных языках (типа Си и Паскаля) применяли указатели на функции и страшные switch-и, объектно-ориентированный подход предлагает более красивое решение — механизм виртуальных функций.

Полиморфизм = наследование + виртуальные функции.

Чтобы все это работало — нам мало использовать механизм наследования и поместить функции в интерфейсы базовых классов, функции надо пометить как виртуальные. В одних языках (C# и C++) необходимо явно помечать такие функции словом virtual, в других (Java и Python) — все методы являются виртуальными по умолчанию.

class Unit {
protected:
  int health;
public:
  virtual void hit(Unit* otherUnit) = 0;
};

class Unit18 : public Unit {
public:
  void hit(Unit* otherUnit) {
   ohterUnit->health = ohterUnit->health - 1;
  }
};

class Unit19 : public Unit {
public:
  void hit(Unit* otherUnit) {
   ohterUnit->health = ohterUnit->health - 2;
  }
};

class Barracks {
public: 
  virtual Unit* build();
};

class Barracks18 : public Barracks {
public:
  Unit* build() {
    return new Unit18();
  }
};

class Barracks19 : public Barracks {
public:
  Unit* build() {
    return new Unit19();
  }
};

В итоге мы выделили интерфейс класса казармы, через который и будет работать с нашими объектами пользователь — основной код теперь зависит только от интерфейсов. Обратите внимание, что поле health класса Unit теперь можно поместить в секцию protected — доступ к нему получат только классы наследники (улучшилась инкапсуляция). Соответствующая диаграмма классов:

3 Что учить дальше?

  1. Мы разобрали ценности и знаем к чему стоит стремиться, кроме того, мы поверхностно посмотрели на механизмы, которые предоставляет нам объектно-ориентированное программирование, не фокусируясь на каком-либо определенном языке. Однако, в программировании важна практика — посмотрите как описанные механизмы реализуются в вашем любимом языке и попробуйте реализовать небольшой проект.
  2. Прочитайте про SOLID [2] — постарайтесь заметить не только принципы, но также — проблемы и подходы к их решению.
  3. Обратите внимание на модульное тестирование и TDD [9] — это будет особенно полезно, если у вас не получается удачная архитектура. По моим наблюдениям, даже начинающий программист начинает писать более качественный код, если сначала подумает о том, как он этим кодом хочет пользоваться. Изучите библиотеку модульного тестирования вашего языка программирования и попробуйте писать тесты.
  4. Посмотрите литературу о шаблонах проектирования [7, 10], при этом я рекомендую не пытаться запомнить детали реализации паттернов, а ответить на вопросы «где я могу применить этот шаблон?», «какую пользу и вред я с этого получу?». Если это кажется вам тяжелым — посмотрите как с помощью паттернов можно улучшить качество кода [6].

3.1 Список использованных источников

  1. Рейтинг популярности языков программирования TIOBE. URL: https://tiobe.com/tiobe-index/
  2. SOLID принципы. Рефакторинг. URL: https://pro-prof.com/archives/1914
  3. Почему мне кажется, что студентов учат ООП неправильно. URL: https://habr.com/ru/post/345658/
  4. C++ Russia 2018: Фёдор Короткий, Память – идеальная абстракция. URL: https://vk.com/wall-105242702_701
  5. Мейер Б. Объектно-ориентированное конструирование программных систем. М.: Издательско-торговый дом «Русская Редакция», «Интернет-университет информационных технологий», 2005. 1232 с.: ил.
  6. Мартин Р. Чистый код. Создание, анализ и рефакторинг. Библиотека программиста. – СПб.: Питер, 2014. – 464 с.
  7. Джейсон Мак-Колм Смит Элементарные шаблоны проектирования : Пер. с англ. — М. : ООО “И.Д. Вильямс”, 2013. — 304 с.
  8. Диаграммы классов UML. URL: https://pro-prof.com/archives/3212
  9. Юнит-тестирование. Пример. Boost Unit Test. URL: https://pro-prof.com/archives/1549
  10. Э. Гамма Приемы объектно-ориентированного проектирования. Паттерны проектирования / Э. Гамма, Р. Хелм, Р. Джонсон, Д. Влиссидес. – СПб.: Питер, 2009. – 366 с.

Добавить комментарий