ООП (Продолжение)#
Вспомнил некоторые положения из прошлой части.
Краеугольное понятие в ООП — объект. Это такой своеобразный контейнер, в котором сложены данные и прописаны действия, которые можно с этими данными совершать.
Чтобы понять, чем объекты так полезны и для чего их изобрели, сравним ООП с другой методикой разработки — процедурной. В ней весь код можно поделить на два вида: основную программу и вспомогательные функции, которые могут вызываться как программой, так и другими функциями:
У такого программирования есть существенный недостаток — части кода сильно зависят друг от друга. Например, основная программа вызывает функцию, та вызывает вторую, та, в свою очередь, — третью. При этом, допустим, вторую функцию могут параллельно вызывать ещё несколько других, а также основная программа. Схематически вся эта процедурная путаница представлена на рисунке:
Если мы изменим какую-нибудь функцию, то остальные части кода могут быть к этому не готовы — и сломаются. Тогда придётся переписывать ещё и их, а они, в свою очередь, завязаны на другие функции. В общем, проще будет написать новую программу с нуля.
Кроме того, в процедурном программировании нередко приходится дублировать код и писать похожие функции с небольшими различиями. Например, чтобы поддерживать совместимость разных частей программы друг с другом.
Логика ООП совершенно иная: к основной программе подключаются не функции, а объекты, внутри которых уже лежат собственные переменные и функции. Так выстраивается более иерархичная структура. Переменные внутри объектов называются полями, или атрибутами, а функции — методами.
Методы и события#
Методы в ООП - это функции, принадлежащие определенному классу, которые описывают его поведение.
События в ООП используются для описания асинхронных сигналов или действий, которые могут возникнуть во время выполнения программы.
Хотя методы и события представляют разные концепции в ООП, они могут быть взаимосвязаны: методы могут вызываться в ответ на определенные события или использоваться для обработки событий.
Замечание: Термин *метод" в стандарте С++ не используется. Это разговорное название для функции-члена класса (member function). Однако в рамках парадигмы ООП допускается говорить "метод"
Методы#
- Методы являются функциями, связанными с определенным классом или объектом.
- Они определяют поведение объекта или класса. Методы могут изменять состояние объекта, обеспечивать доступ к данным объекта или выполнять определенные действия.
- Методы описывают функциональность объекта или класса, предоставляя способы выполнения определенных операций с данными.
- Они обычно вызываются явно кодом программы для выполнения определенных действий.
class MyClass {
public:
void myMethod() {
// Реализация метода
}
};
// Вызов метода объекта
MyClass obj;
obj.myMethod();
События#
- События обычно связаны с асинхронными действиями или изменениями состояния программы.
- Они могут представлять внешние воздействия, которые происходят во время выполнения программы, такие как нажатие клавиши, клик мыши или изменение переменной.
- Обработчики событий (функции или методы) связываются с этими событиями для реагирования на них и выполнения определенных действий при их возникновении.
class MyClass : public QObject {
Q_OBJECT
public slots:
void handleButtonClick() {
// Реализация обработчика события (нажатие кнопки)
}
};
// Связывание события (нажатие кнопки) с обработчиком
MyClass obj;
QPushButton button;
QObject::connect(&button, &QPushButton::clicked, &obj, &MyClass::handleButtonClick);
Таким образом, методы описывают функциональность объектов или классов, в то время как события представляют внешние действия, на которые программа реагирует, обычно с помощью обработчиков событий.
Механизмы основанные на событиях#
События были выделены в отдельную категорию функциий, поскольку зачастую они меняют состояние программы и должны уведомлять другие классы (интерфейсы) об этом, в отличие от обычной функции.
Для уведомления разработа разные универсальные механизмы, один из которых будет рассмотрен ниже.
Наблюдатель (англ. Observer) — поведенческий шаблон проектирования. Также известен как «подчинённые» (англ. Dependents). Реализует у класса механизм, который позволяет объекту этого класса получать оповещения об изменении состояния других объектов и тем самым наблюдать за ними
Классы, на события которых другие классы подписываются, называются субъектами (Subjects), а подписывающиеся классы называются наблюдателями (англ. Observers)
При реализации шаблона «наблюдатель» обычно используются следующие классы:
- Observable — интерфейс, определяющий методы для добавления, удаления и оповещения наблюдателей;
- Observer — интерфейс, с помощью которого наблюдатель получает оповещение;
- ConcreteObservable — конкретный класс, который реализует интерфейс Observable;
- ConcreteObserver — конкретный класс, который реализует интерфейс Observer.
Шаблон «наблюдатель» применяется в тех случаях, когда система обладает следующими свойствами:
- существует как минимум один объект, рассылающий сообщения;
- имеется не менее одного получателя сообщений, причём их количество и состав могут изменяться во время работы приложения;
- позволяет избежать сильного зацепления взаимодействующих классов.
- Данный шаблон часто применяют в ситуациях, в которых отправителя сообщений не интересует, что делают получатели с предоставленной им информацией.
Наследование#
Наследование — самый простой механизм в ООП, который в общем виде звучит так:
потомок при создании получает все свойства и методы родителя.
Родитель — это класс, на основе которого мы создаём что-то новое. Потомок (или дочерний элемент) — это то, что получилось при создании на основе класса или объекта.
Наследование полезно, поскольку оно позволяет структурировать и повторно использовать код, что, в свою очередь, может значительно ускорить процесс разработки. Несмотря на это, наследование следует использовать с осторожностью, поскольку большинство изменений в суперклассе затронут все подклассы, что может привести к непредвиденным последствиям
В зависимости от отношений между родителями и потомками различают простое наследование и множественное:
Проблема Ромба#
Проблема ромба (Diamond problem)- классическая проблема в языках, которые поддерживают возможность множественного наследования. Эта проблема возникает когда классы B и C наследуют A, а класс D наследует B и C.
К примеру, классы A, B и C определяют метод print_letter(). Если print_letter() будет вызываться классом D, неясно какой метод должен быть вызван — метод класса A, B или C. Разные языки по-разному подходят к решению ромбовидной проблем. В C ++ решение проблемы оставлено на усмотрение программиста.
Ромбовидная проблема — прежде всего проблема дизайна, и она должна быть предусмотрена на этапе проектирования. На этапе разработки ее можно разрешить следующим образом:
- вызвать метод конкретного суперкласса;
- обратиться к объекту подкласса как к объекту определенного суперкласса;
- переопределить проблематичный метод в последнем дочернем классе
Полиморфизм#
Полиморфизм — это когда мы можем вызывать одни и те же методы у разных классов. Внутри они могут работать по-разному, но внешне всё будет выглядеть одинаково
Полиморфизм - вызов различных версий функции через единый интерфейс.
Полиморфизм – свойство, которое позволяет использовать одно и тоже имя функции для решения двух и более схожих, но технически разных задач.
Полиморфизм – возможность замещения методов объекта родителя методами объекта-потомка, имеющих то же имя.
Инкапсуляция#
В объектно-ориентированном программировании инкапсуляция (или «сокрытие информации») — это процесс скрытого хранения деталей реализации объекта. Пользователи обращаются к объекту через открытый интерфейс.
В языке C++ инкапсуляция реализована через спецификаторы доступа. Как правило, все переменные-члены класса являются закрытыми (скрывая детали реализации), а большинство методов являются открытыми (с открытым интерфейсом для пользователя). Хотя требование к пользователям использовать публичный интерфейс может показаться более обременительным, нежели просто открыть доступ к переменным-членам, но на самом деле это предоставляет большое количество полезных преимуществ, которые улучшают возможность повторного использования кода и его поддержку.
Преимущество №1: Инкапсулированные классы проще в использовании и уменьшают сложность ваших программ.
С полностью инкапсулированным классом вам нужно знать только то, какие методы являются доступными для использования, какие аргументы они принимают и какие значения возвращают. Не нужно знать, как класс реализован изнутри. Например, класс, содержащий список имен, может быть реализован с использованием динамического массива, строк C-style, std::array, std::vector, std::map, std::list или любой другой структуры данных. Для использования этого класса, вам не нужно знать детали его реализации. Это значительно снижает сложность ваших программ, а также уменьшает количество возможных ошибок. Это является ключевым преимуществом инкапсуляции.
Все классы Стандартной библиотеки C++ инкапсулированы. Представьте, насколько сложнее был бы процесс изучения языка C++, если бы вам нужно было знать реализацию std::string, std::vector или std::cout (и других объектов) для того, чтобы их использовать!
Преимущество №2: Инкапсулированные классы помогают защитить ваши данные и предотвращают их неправильное использование.
Глобальные переменные опасны, так как нет строгого контроля над тем, кто имеет к ним доступ и как их используют. Классы с открытыми членами имеют ту же проблему, только в меньших масштабах. Например, допустим, что нам нужно написать строковый класс.
Преимущество №3: Инкапсулированные классы легче изменить.
Преимущество №4: С инкапсулированными классами легче проводить отладку.
Функции доступа (геттеры и сеттеры)#
В зависимости от класса, может быть уместным (в контексте того, что делает класс) иметь возможность получать/устанавливать значения закрытым переменным-членам класса.
Функция доступа — это короткая открытая функция, задачей которой является получение или изменение значения закрытой переменной-члена класса. Например:
class MyString
{
private:
char *m_string; // динамически выделяем строку
int m_length; // используем переменную для отслеживания длины строки
public:
int getLength() { return m_length; } // функция доступа для получения значения m_length
};
Функции доступа обычно бывают двух типов:
-
геттеры — это функции, которые возвращают значения закрытых переменных-членов класса;
-
сеттеры — это функции, которые позволяют присваивать значения закрытым переменным-членам класса.
Вот пример класса, который использует геттеры и сеттеры для всех своих закрытых переменных-членов:
class Date
{
private:
int m_day;
int m_month;
int m_year;
public:
int getDay() { return m_day; } // геттер для day
void setDay(int day) { m_day = day; } // сеттер для day
int getMonth() { return m_month; } // геттер для month
void setMonth(int month) { m_month = month; } // сеттер для month
int getYear() { return m_year; } // геттер для year
void setYear(int year) { m_year = year; } // сеттер для year
};
В этом классе нет никаких проблем с тем, чтобы пользователь мог напрямую получать или присваивать значения закрытым переменным-членам этого класса, так как есть полный набор геттеров и сеттеров. В примере с классом MyString для переменной m_length не было предоставлено сеттера, так как не было необходимости в том, чтобы пользователь мог напрямую устанавливать длину.
Правило: Предоставляйте функции доступа только в том случае, когда нужно, чтобы пользователь имел возможность получать или присваивать значения членам класса.
Хотя иногда вы можете увидеть, что геттер возвращает неконстантную ссылку на переменную-член — этого следует избегать, так как в таком случае нарушается инкапсуляция, позволяя caller-у изменять внутреннее состояние класса вне этого же класса. Лучше, чтобы ваши геттеры использовали тип возврата по значению или по константной ссылке.
Правило: Геттеры должны использовать тип возврата по значению или по константной ссылке. Не используйте для геттеров тип возврата по неконстантной ссылке.
Пример (на котах)#
#include <iostream>
#include <string>
using namespace std;
class Cat {
private:
string breed;
string color;
int age;
public:
Cat(string breed, string color, int age) : breed(breed), color(color), age(age) {}
void meow() {
cout << "Мяу!" << endl;
}
void purr() {
cout << "Мрррр" << endl;
}
// Метод для вывода информации о кошке
void displayInfo() {
cout << "Порода: " << breed << ", Цвет: " << color << ", Возраст: " << age << " года" << endl;
}
};
int main() {
// Создание объектов класса Cat
Cat myCat1("Maine Coon", "Черный", 3);
Cat myCat2("Persian", "Белый", 5);
// Вызов методов объектов
myCat1.meow(); // Вывод: "Мяу!"
myCat2.purr(); // Вывод: "Мрррр"
// Вывод информации о каждой кошке
cout << "Информация о кошке 1:" << endl;
myCat1.displayInfo();
cout << "\nИнформация о кошке 2:" << endl;
myCat2.displayInfo();
return 0;
}
Этот код создает класс Cat с членами breed, color, и age, и методами meow и purr, аналогичными методам из вашего примера на Python. В функции main создается объект класса Cat, и вызываются его методы meow и purr.
Инкапсуляция#
Доступ к данным объекта должен контролироваться, чтобы пользователь не мог изменить их в произвольном порядке и что-то поломать. Поэтому для работы с данными программисты пишут методы, которые можно будет использовать вне класса и которые ничего не сломают внутри.
Вернёмся к нашим кошечкам. Мы можем разрешить изменять атрибут «возраст», но только в большую сторону, а атрибуты «порода» и «цвет» лучше открыть только для чтения — ведь порода кошки не меняется, а цвет если и меняется, то не по её инициативе.
В нашем классе «Кошка» мы сделали все атрибуты открытыми, поэтому давайте это исправим
#include <iostream>
#include <string>
using namespace std;
class Cat {
private:
string breed;
string color;
int age;
public:
// Конструктор класса
Cat(string breed, string color, int age) : breed(breed), color(color), age(age) {}
// Геттеры и сеттеры для доступа к закрытым атрибутам
string getBreed() const {
return breed;
}
void setBreed(const string& newBreed) {
breed = newBreed;
}
string getColor() const {
return color;
}
void setColor(const string& newColor) {
color = newColor;
}
int getAge() const {
return age;
}
void setAge(int newAge) {
age = newAge;
}
void meow() {
cout << "Мяу!" << endl;
}
void purr() {
cout << "Мрррр" << endl;
}
void displayInfo() const {
cout << "Порода: " << breed << ", Цвет: " << color << ", Возраст: " << age << " года" << endl;
}
};
int main() {
Cat myCat("Maine Coon", "Черный", 3);
// Использование геттеров и сеттеров
myCat.setBreed("Persian");
myCat.setColor("Белый");
myCat.setAge(5);
myCat.meow();
myCat.purr();
myCat.displayInfo();
return 0;
}
Наследование#
Классы могут передавать свои атрибуты и методы классам-потомкам. Например, мы хотим создать новый класс «Домашняя кошка». Он практически идентичен классу «Кошка», но у него появляются новые атрибуты «хозяин» и «кличка», а также метод «отзываться на кличку».
#include <iostream>
#include <string>
using namespace std;
class Cat {
private:
string breed;
string color;
int age;
public:
Cat(string breed, string color, int age) : breed(breed), color(color), age(age) {}
void meow() {
cout << "Мяу!" << endl;
}
void purr() {
cout << "Мрррр" << endl;
}
void displayInfo() const {
cout << "Порода: " << breed << ", Цвет: " << color << ", Возраст: " << age << " года" << endl;
}
};
class HouseCat : public Cat {
private:
string owner;
string name;
public:
HouseCat(string breed, string color, int age, string owner, string name)
: Cat(breed, color, age), owner(owner), name(name) {}
// Новый метод для класса HouseCat
void respondToName() {
cout << "Кличка: " << name << ", Отзывается на кличку!" << endl;
}
// Переопределение метода из класса Cat
void displayInfo() const {
cout << "Порода: " << getBreed() << ", Цвет: " << getColor() << ", Возраст: " << getAge() << " года"
<< ", Хозяин: " << owner << ", Кличка: " << name << endl;
}
};
int main() {
HouseCat myHouseCat("Maine Coon", "Черный", 3, "Иван", "Мурка");
myHouseCat.meow();
myHouseCat.purr();
myHouseCat.respondToName();
myHouseCat.displayInfo();
return 0;
}
Этот код создает новый класс HouseCat, который наследует от класса Cat. В HouseCat добавлены новые атрибуты owner (хозяин) и name (кличка), а также новый метод respondToName. Метод displayInfo переопределен в классе HouseCat для отображения дополнительной информации.
Полиморфизм#
Этот принцип позволяет применять одни и те же команды к объектам разных классов, даже если они выполняются по-разному. Например, помимо класса «Кошка», у нас есть никак не связанный с ним класс «Попугай» — и у обоих есть метод «спать». Несмотря на то что кошки и попугаи спят по-разному (кошка сворачивается клубком, а попугай сидит на жёрдочке), для этих действий можно использовать одну команду.
#include <iostream>
using namespace std;
// Базовый класс Animal с виртуальной функцией sleep
class Animal {
public:
virtual void sleep() const {
cout << "Животное спит" << endl;
}
};
// Класс Cat, наследующий от Animal
class Cat : public Animal {
public:
void sleep() const override {
cout << "Кошка спит, свернувшись клубком" << endl;
}
};
// Класс Parrot, наследующий от Animal
class Parrot : public Animal {
public:
void sleep() const override {
cout << "Попугай спит, сидя на жердочке" << endl;
}
};
// Функция homeSleep, которая принимает объект Animal
void homeSleep(const Animal& animal) {
animal.sleep();
}
int main() {
Cat myCat;
Parrot myParrot;
// Передача объектов разных классов в функцию homeSleep
homeSleep(myCat); // Вывод: "Кошка спит, свернувшись клубком"
homeSleep(myParrot); // Вывод: "Попугай спит, сидя на жердочке"
return 0;
}
Здесь метод sleep является виртуальной функцией в базовом классе Animal, что позволяет переопределить его в классах-наследниках Cat и Parrot. Такой подход позволяет использовать одинаковый интерфейс для объектов различных классов, обеспечивая соответствующее разнообразное поведение.
Задание на практику#
Скачайте и поместите в дирректорию с проектом:
Реализовать следующую схему классов для реализации игровых объектов Игрок
и Препятствие
:
GameObject
Назначение:
Базовый класс для всех игровых объектов. Предоставляет общие методы и атрибуты, которые могут быть использованы всеми производными классами.
Основные функции:
Конструктор
: Инициализирует объект с заданной текстурой, позицией и масштабом.update(float deltaTime)
: Виртуальный метод для обновления состояния объекта.draw(sf::RenderWindow& window)
: Метод для рисования объекта на экране.getBounds() const
: Метод для получения ограничивающего прямоугольника объекта.
Атрибуты:
sprite
: Спрайт объекта.texture
: Текстура объекта.position
: Позиция объекта.scale
: Масштаб объекта.
Obstacle
Назначение:
Класс, представляющий препятствие в игре. Наследует от базового класса GameObject и добавляет специфическую логику для препятствий.
Основные функции:
Конструктор
: Инициализирует препятствие с заданной текстурой, позицией и масштабом.update(float deltaTime)
: Метод для обновления состояния препятствия.
Player
Назначение:
Класс, представляющий игрока в игре. Наследует от базового класса GameObject
и добавляет специфическую логику для игрока.
Основные функции:
Конструктор
: Инициализирует игрока с заданной текстурой, позицией и масштабом.update(float deltaTime, const Obstacle& obstacle)
: Метод для обновления состояния игрока, включая проверку столкновений с препятствиями.handleCollision(const sf::FloatRect& obstacleBounds)
: Метод для обработки столкновений с препятствиями.
Tip
Функцию handleCollision
сделать приватной!
Пример кода для файла main.cpp
Tip
#include <SFML/Graphics.hpp>
#include "Player.h"
#include "Obstacle.h"
int main() {
sf::RenderWindow window(sf::VideoMode(800, 600), "SFML Game");
Player player("player.png", sf::Vector2f(400, 300), sf::Vector2f(0.1f, 0.1f));
Obstacle obstacle("obstacle.png", sf::Vector2f(200, 100), sf::Vector2f(0.1f, 0.1f));
sf::Clock clock;
while (window.isOpen()) {
sf::Event event;
while (window.pollEvent(event)) {
if (event.type == sf::Event::Closed) {
window.close();
}
}
float deltaTime = clock.restart().asSeconds();
player.update(deltaTime, obstacle);
obstacle.update(deltaTime);
window.clear();
player.draw(window);
obstacle.draw(window);
window.display();
}
return 0;
}