Перейти к содержанию

ООП (Продолжение)#

Вспомнил некоторые положения из прошлой части.

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

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

https://ratcatcher.ru/media/inf/pr/pr9/16045016092022_5c20dcbcfbab07ab6c2df7e27444d5ac2afca569.png

У такого программирования есть существенный недостаток — части кода сильно зависят друг от друга. Например, основная программа вызывает функцию, та вызывает вторую, та, в свою очередь, — третью. При этом, допустим, вторую функцию могут параллельно вызывать ещё несколько других, а также основная программа. Схематически вся эта процедурная путаница представлена на рисунке:

https://ratcatcher.ru/media/inf/pr/pr9/16045016092022_278cadb5c5a600fd354bbb4a32acf34407bf98f0.png

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

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

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

https://ratcatcher.ru/media/inf/pr/pr9/16044916092022_bd473197c461193ea9b6d317f4c236910d065887.png

Методы и события#

Методы в ООП - это функции, принадлежащие определенному классу, которые описывают его поведение.

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

Хотя методы и события представляют разные концепции в ООП, они могут быть взаимосвязаны: методы могут вызываться в ответ на определенные события или использоваться для обработки событий.

Замечание: Термин *метод" в стандарте С++ не используется. Это разговорное название для функции-члена класса (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)

https://ratcatcher.ru/media/inf/pr/pr9/Observer_UML_smal.png

При реализации шаблона «наблюдатель» обычно используются следующие классы:

  • Observable — интерфейс, определяющий методы для добавления, удаления и оповещения наблюдателей;
  • Observer — интерфейс, с помощью которого наблюдатель получает оповещение;
  • ConcreteObservable — конкретный класс, который реализует интерфейс Observable;
  • ConcreteObserver — конкретный класс, который реализует интерфейс Observer.

Шаблон «наблюдатель» применяется в тех случаях, когда система обладает следующими свойствами:

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

Наследование#

Наследование — самый простой механизм в ООП, который в общем виде звучит так:

потомок при создании получает все свойства и методы родителя.

Родитель — это класс, на основе которого мы создаём что-то новое. Потомок (или дочерний элемент) — это то, что получилось при создании на основе класса или объекта.

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

В зависимости от отношений между родителями и потомками различают простое наследование и множественное:

https://ratcatcher.ru/media/inf/pr/pr9/img-TCTSCI.png

Проблема Ромба#

Проблема ромба (Diamond problem)- классическая проблема в языках, которые поддерживают возможность множественного наследования. Эта проблема возникает когда классы B и C наследуют A, а класс D наследует B и C.

https://ratcatcher.ru/media/inf/pr/pr9/qnaepojwdop6urntrubztgdd7x4.png

К примеру, классы 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. Такой подход позволяет использовать одинаковый интерфейс для объектов различных классов, обеспечивая соответствующее разнообразное поведение.

Задание на практику#

Скачайте и поместите в дирректорию с проектом:

Реализовать следующую схему классов для реализации игровых объектов Игрок и Препятствие:

https://ratcatcher.ru/media/inf/pr/pr9/Практика.png

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;
}