ООП (Продолжение 2.0)#
Конструкторы#
Конструкторы представляют специальную функцию, которая имеет то же имя, что и класс, которая не возвращает никакого значения и которая позволяют инициалилизировать объект класса во время го создания и таким образом гарантировать, что поля класса будут иметь определенные значения. При каждом создании нового объекта класса вызывается конструктор класса.
Рассмотрим на примере класса:
#include <iostream>
class Person
{
public:
std::string name;
unsigned age;
void print()
{
std::cout << "Name: " << name << "\tAge: " << age << std::endl;
}
};
int main()
{
Person person; // вызов конструктора
person.name = "Tom";
person.age = 22;
person.print();
}
Здесь при создании объекта класса Person, который называется person вызывается конструктор по умолчанию. Если мы не определяем в классе явным образом конструктор, как в случае выше, то компилятор автоматически компилирует конструктор по умолчанию. Подобный конструктор не принимает никаких параметров и по сути ничего не делает.
Теперь определим свой конструктор. Например, в примере выше мы устанавливаем значения для полей класса Person. Но, допустим, мы хотим, чтобы при создании объекта эти поля уже имели некоторые значения по умолчанию. Для этой цели определим конструктор:
#include <iostream>
class Person
{
public:
std::string name;
unsigned age;
void print()
{
std::cout << "Name: " << name << "\tAge: " << age << std::endl;
}
Person(std::string p_name, unsigned p_age)
{
name = p_name;
age = p_age;
std::cout << "Person has been created" << std::endl;
}
};
int main()
{
Person tom("Tom", 38); // создаем объект - вызываем конструктор
tom.print();
}
Теперь в классе Person определен конструктор:
Person(std::string p_name, unsigned p_age)
{
name = p_name;
age = p_age;
std::cout << "Person has been created" << std::endl;
}
По сути конструктор представляет функцию, которая может принимать параметры и которая должна называться по имени класса. В данном случае конструктор принимает два параметра и передает их значения полям name и age, а затем выводит сообщение о создании объекта.
Если мы определяем свой конструктор, то компилятор больше не создает конструктор по умолчанию. И при создании объекта нам надо обязательно вызвать определенный нами конструктор.
Вызов конструктора получает значения для параметров и возвращает объект класса:
Person tom("Tom", 38);
После этого вызова у объекта person для поля name будет определено значение "Tom", а для поля age - значение 38. Вполедствии мы также сможем обращаться к этим полям и переустанавливать их значения.
Деструкторы#
Деструктор — это функция-член, которая вызывается автоматически, когда объект выходит из область или явно уничтожается вызовомdelete. Деструктор имеет то же имя, что и класс, предшествующий тильде (~). Например, деструктор для класса String объявляется следующим образом: ~String().
Если вы не определяете деструктор, компилятор предоставляет деструктор по умолчанию; для многих классов это достаточно. Необходимо определить только пользовательский деструктор, когда класс сохраняет обработку системных ресурсов, которые должны быть освобождены, или указатели, принадлежащие памяти, к которой они указывают.
Деструкторы — это функции с тем же именем, что и класс, но с добавленным в начало знаком тильды (~).
При объявлении деструкторов действуют несколько правил. Деструкторы:
- Не принимать аргументы.
- Не возвращайте значение (или void).
- Невозможно объявить как const, volatileили static. Однако их можно вызвать для уничтожения объектов, объявленных как const, volatileили static.
#include <iostream>
class Person {
public:
std::string name;
unsigned age;
// Конструктор
Person(std::string p_name, unsigned p_age) : name(p_name), age(p_age) {
std::cout << "Person has been created" << std::endl;
}
// Деструктор
~Person() {
std::cout << "Person has been destroyed" << std::endl;
}
void print() {
std::cout << "Name: " << name << "\tAge: " << age << std::endl;
}
};
int main() {
Person tom("Tom", 38); // создаем объект - вызываем конструктор
tom.print();
// Деструктор вызовется автоматически при завершении работы с объектом tom
return 0;
}
Деструкторы вызываются, когда происходит одно из следующих событий:
- Локальный (автоматический) объект с областью видимости блока выходит за пределы области видимости.
- Объект, выделенный оператором new , явно освобождается с помощью delete.
- Время существования временного объекта заканчивается.
- Программа заканчивается, глобальные или статические объекты продолжают существовать.
- Деструктор явно вызываться с использованием полного имени функции деструктора.
Деструкторы могут свободно вызывать функции-члена класса и осуществлять доступ к данным членов класса.
Существует два ограничения на использование деструкторов:
- Вы не можете взять свой адрес.
- Производные классы не наследуют деструктор базового класса.
Перегрузка операций#
Перегрузка операторов (operator overloading) позволяет определить для объектов классов втроенные операторы, такие как +, -, * и т.д. Для определения оператора для объектов своего класса, необходимо определить функцию, название которой содержит слово operator и символ перегружаемого оператора. Функция оператора может быть определена как член класса, либо вне класса.
Перегрузить можно только те операторы, которые уже определены в C++. Создать новые операторы нельзя. Также нельзя изменить количество операндов, их ассоциативность, приоритет.
Если функция оператора определена как отдельная функция и не является членом класса, то количество параметров такой функции совпадает с количеством операндов оператора. Например, у функции, которая представляет унарный оператор, будет один параметр, а у функции, которая представляет бинарный оператор, - два параметра. Если оператор принимает два операнда, то первый операнд передается первому параметру функции, а второй операнд - второму параметру. При этом как минимум один из параметров должен представлять тип класса.
Формальное определение операторов в виде функций-членов класса:
// бинарный оператор
ReturnType operator Op(Type right_operand);
// унарный оператор
ClassType& operator Op();
Рассмотрим пример с классом Counter, который хранит некоторое число:
#include <iostream>
class Counter
{
public:
Counter(int val)
{
value =val;
}
void print()
{
std::cout << "Value: " << value << std::endl;
}
Counter operator + (const Counter& counter) const
{
return Counter{value + counter.value};
}
private:
int value;
};
int main()
{
Counter c1{20};
Counter c2{10};
Counter c3 = c1 + c2;
c3.print(); // Value: 30
}
Здесь в классе Counter определен оператор сложения, цель которого сложить два объекта Counter:
Counter operator + (const Counter& counter) const
{
return Counter{value + counter.value};
}
Текущий объект будет представлять левый операнд операции. Объект, который передается в функцию через параметр counter, будет представлять правый операнд операции. Здесь параметр функции определен как константная ссылка, но это необязательно. Также функция оператора определена как константная, но это тоже не обязательно.
Результатом оператора сложения является новый объект Counter, в котором значение value равно сумме значений value обоих операндов.
После опеределения оператора можно складывать два объекта Counter:
Counter c1{20};
Counter c2{10};
Counter c3 {c1 + c2};
c3.print(); // Value: 30
Виртуальные функции#
При наследовании часто бывает необходимо, чтобы поведение некоторых методов базового класса и классов-наследников различалось. Можно переопределить соответствующие методы в производном классе. Однако тут возникает одна проблема, которую лучше рассмотреть на простом примере:
#include <iostream>
using namespace std;
class Base // базовый класс
{ public:
int f(const int &d) //метод базового класса
{ return 2*d; }
int CallFunction(const int &d)
{return f(d)+l; }// вызов метода базового класса
};
Class Derived: public Base // производный класс
{ public: // CallFunction наследуется
int f(const int &d) // метод f переопределяется
{ return d*d; }
};
int main(){
Base a; // объект базового класса
cout<<a.CallFunction(5)<<endl; //получаем 11
Derived b; // объект производного класса
cout << b.CallFunction(5)<< endl; // какой метод f вызывается?
return 0;
}
В базовом классе определены два метода ‒ f() и) CallFunction(), причем во втором методе вызывается первый. В классе-наследнике метод f() переопределен, а метод CallFunction() унаследован. Очевидно, метод f() переопределяется для того, чтобы объекты базового класса и класса-наследника вели себя по-разному.
Объявляя объект b типа Derived, программист, естественно, ожидает получить результат 5*5 + 1 = 26 ‒ для этого и переопределялся метод f().
Однако на экран, как и для объекта а типа Base, выводится число 11, которое, очевидно, вычисляется как 2*5+1 = 11.
Несмотря на переопределение метода f() в классе-наследнике, в унаследованной функции CallFunction() вызывается «родная» функция f(), определенная в базовом классе!
При трансляции класса Base компилятор ничего не знает о классах-наследниках, поэтому он не может предполагать, что метод f() будет переопределен в классе Derived.
Его естественное поведение ‒ «прочно» связать вызов f() с телом метода класса Base.
Чтобы добиться разного поведения в зависимости от типа, необходимо объявить функцию-метод виртуальной; в С++ это делается с помощью ключевого слова virtual.
Виртуальная функция (virtual function) ‒ это функция-член, объявленная в базовом классе и переопределенная в производном.
Ключевое слово virtual указывается до объявления функции в базовом классе.
Производный класс переопределяет эту функцию, приспосабливая ее для своих нужд. По существу, виртуальная функция реализует принцип "один интерфейс, несколько методов", лежащий в основе полиморфизма.
Виртуальная функция в базовом классе определяет вид интерфейса, т.е. способ вызова этой функции. Каждое переопределение виртуальной функции в производном классе реализует операции, присущие лишь данному классу. Иначе говоря, переопределение виртуальной функции создает конкретный метод (specific method).
При обычном вызове виртуальные функции ничем не отличаются от остальных функций-членов. Особые свойства виртуальных функций проявляются при их вызове с помощью указателей. Указатели на объекты базового класса можно использовать для ссылки на объекты производных классов. Если указатель на объект базового класса устанавливается на объект производного класса, содержащий виртуальную функцию, выбор требуемой функции основывается на типе объекта, на который ссылается указатель, причем этот выбор осуществляется в ходе выполнения программы.
Таким образом, если указатель ссылается на объекты разных типов, то будут вызваны разные виртуальные функции. Это относится и к ссылкам на объекты базового класса.
С перегрузкой функций "разбирается" компилятор, правильно подбирая вариант функции в той или иной ситуации. Полиморфизм шаблонных функций тоже реализуется на этапе компиляции. Естественно, выбор осуществляется статически.
Выбор же виртуальной функции происходит динамически ‒ при выполнении программы. Класс, включающий в себя виртуальные функции, называется полиморфным.
Правила описания и использования виртуальных функций-методов:
- Виртуальная функция может быть только методом класса,
- Любую перегружаемую операцию-метод класса можно сделать виртуальной, например, операцию присваивания или операцию преобразования типа,
- Виртуальная функция, как и сама виртуальность, наследуется,
- Виртуальная функция может быть константной,
- Если в базовом классе определена виртуальная функция, то метод производного класса с такими же именем и прототипом (включая тип возвращаемого значения и константность метода) автоматически является виртуальным (слово virtual указывать необязательно) и замещает функцию-метод базового класса,
- Конструкторы не могут быть виртуальными,
- Статические методы не могут быть виртуальными,
- Деструкторы могут (чаще ‒ должны) быть виртуальными ‒ это гарантирует корректный возврат памяти через указатель базового класса.
Правила описания и использования виртуальных методов:
1. Если в базовом классе метод определен как виртуальный, метод, определенный в производном классе с тем же именем и набором параметров, автоматически становится виртуальным, а с отличающимся набором параметров ‒ обычным,
2. Виртуальные методы наследуются, то есть переопределять их в производном классе требуется только при необходимости задать отличающиеся действия. Права доступа при переопределении изменить нельзя,
3. Если виртуальный метод переопределен в производном классе, объекты этого класса могут получить доступ к методу базового класса с помощью операции доступа к области видимости,
4. Виртуальный метод не может объявляться с модификатором static, но может быть объявлен как дружественный,
5. Если в классе вводится описание виртуального метода, он должен быть определен хотя бы как чисто виртуальный.
Примеры#
Поведение | Версия B | Версия D | Результат |
---|---|---|---|
1. Полное совпадение профиля функции | virtual void fl(); |
<virtual> void fl(); |
pb->fl(); |
2. Ззамещение чисто виртуальной функции | virtual void f2() = 0; |
<virtual> void f2(); |
pb->f2(); |
3. допустимое различие типов параметров: указатель на базовый ‒ указатель на производный | virtual void f3(B*); |
virtual void f3(D*); |
pb->f3(&d); |
4. допустимое различие типов параметров: ссылка на базовый ‒ ссылка на производный | virtual void f4(B&); |
virtual void f4(D&); |
pb->f4(d); |
5. Допустимое различие типов возвращаемого значения: указатель на базовый - указатель на производный | virtual B* f5(); |
virtual D* f5(); |
pb->f5(); |
6. Допустимое различие типов возвращаемого значения: ссылка на базовый - ссылка на производный | virtual B& f6(); |
virtual D& f6(); |
pb->f6(); |
7. Деструкторы виртуальны, хотя их имена, разумеется, всегда различны. | virtual ~B(); |
virtual ~D(); |
delete pb; |
Поддержка технологий программирования#
Разработка программного обеспечения - это широкий и динамичный процесс, использующий множество технологий для создания различных видов приложений и систем.
Вам уже известны методы тестирования приложений. Также вы уже имеете опыт работы с дебагером.
Рассмотрим примечательные конструкции языка, облегчающие процесс поиска багов.
Свойство интроспекции#
Интроспекция — это способность программы исследовать тип или свойства объекта во время работы программы. Как мы уже упоминали, вы можете поинтересоваться, каков тип объекта, является ли он экземпляром класса. Некоторые языки даже позволяют узнать иерархию наследования объекта. Возможность интроспекции есть в таких языках, как Ruby, Java, PHP, Python, C++ и других. В целом, инстроспекция — это очень простое и очень мощное явление. Вот несколько примеров использования инстроспекции:
// Java
if(obj instanceof Person){
Person p = (Person)obj;
p.walk();
}
//PHP
if ($obj instanceof Person) {
// делаем что угодно
}
Интроспекция в программировании представляет собой способность системы или языка программирования изучать, анализировать и модифицировать свою структуру и поведение во время выполнения. Это ключевое свойство, позволяющее программе "смотреть внутрь" и исследовать свой собственный код, определять типы данных, функции и свойства объектов во время выполнения.
В Python самой распространённой формой интроспекции является использование метода dir для вывода списка атрибутов объекта:
# Python
class foo(object):
def __init__(self, val):
self.x = val
def bar(self):
return self.x
...
dir(foo(5))
=> ['__class__', '__delattr__', '__dict__', '__doc__', '__getattribute__', '__hash__', '__init__', '__module__',
'__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__str__', '__weakref__', 'bar', 'x']
Многие современные языки программирования, такие как Python, JavaScript, и Ruby, предоставляют богатую интроспекционную функциональность, что позволяет разработчикам создавать более гибкие и мощные программы. Интроспекция часто используется для создания отладочных инструментов, анализа кода, создания динамических API и других задач, требующих анализа программы во время её работы.
Рефлексия#
Интроспекция позволяет вам изучать атрибуты объекта во время выполнения программы, а рефлексия — манипулировать ими.
Рефлексия — это способность компьютерной программы изучать и модифицировать свою структуру и поведение (значения, мета-данные, свойства и функции) во время выполнения.
Простым языком: она позволяет вам вызывать методы объектов, создавать новые объекты, модифицировать их, даже не зная имён интерфейсов, полей, методов во время компиляции. Из-за такой природы рефлексии её труднее реализовать в статически типизированных языках, поскольку ошибки типизации возникают во время компиляции, а не исполнения программы (подробнее об этом здесь). Тем не менее, она возможна, ведь такие языки, как Java, C# и другие допускают использование как интроспекции, так и рефлексии (но не C++, он позволяет использовать лишь интроспекцию).
По той же причине рефлексию проще реализовать в интерпретируемых языках, поскольку когда функции, объекты и другие структуры данных создаются и вызываются во время работы программы, используется какая-то система распределения памяти. Интерпретируемые языки обычно предоставляют такую систему по умолчанию, а для компилируемых понадобится дополнительный компилятор и интерпретатор, который следит за корректностью рефлексии.
Обработка исключений#
В процессе работы программы могут возникать различные ошибки. Например, при передаче файла по сети оборвется сетевое подключение или будут введены некорректные и недопустимые данные, которые вызовут падение программы. Такие ошибки еще называются исключениями. Исключение представлякт временный объект любого типа, который используется для сигнализации об ошибке. Цель объекта-исключения состоит в том, чтобы передать информацию из точки, в которой произошла ошибка, в код, который должен ее обработать. Если исключение не обработано, то при его возникновении программа прекращает свою работу.
Обработка исключений – это механизм, позволяющий двум независимо разработанным программным компонентам взаимодействовать в аномальной ситуации, называемой исключением.
Исключение – это аномальное поведение во время выполнения, которое программа может обнаружить, например: деление на 0, выход за границы массива или истощение свободной памяти.
Такие исключения нарушают нормальный ход работы программы, и на них нужно немедленно отреагировать. В C++ имеются встроенные средства для их возбуждения и обработки. С помощью этих средств активизируется механизм, позволяющий двум несвязанным (или независимо разработанным) фрагментам программы обмениваться информацией об исключении.
Неструктурная обработка исключений#
Неструктурная обработка исключений реализуется в виде механизма регистрации функций или команд-обработчиков для каждого возможного типа исключения. Язык программирования или его системные библиотеки предоставляют программисту как минимум две стандартные процедуры: регистрации обработчика и разрегистрации обработчика. Вызов первой из них «привязывает» обработчик к определённому исключению, вызов второй — отменяет эту «привязку». Если исключение происходит, выполнение основного кода программы немедленно прерывается и начинается выполнение обработчика. По завершении обработчика управление передаётся либо в некоторую наперёд заданную точку программы, либо обратно в точку возникновения исключения (в зависимости от заданного способа обработки — с возвратом или без). Независимо от того, какая часть программы в данный момент выполняется, на определённое исключение всегда реагирует последний зарегистрированный для него обработчик. В некоторых языках зарегистрированный обработчик сохраняет силу только в пределах текущего блока кода (процедуры, функции), тогда процедура разрегистрации не требуется.
Для создания исключений в с++ используется оператор throw
Оператор throw генерирует исключение. Через оператор throw можно передать информацию об ошибке. генерирует исключение.
double divide(int a, int b)
{
if (b)
return a / b;
throw "Деление на ноль!";
}
Структурная обработка исключений#
Структурная обработка исключений требует обязательной поддержки со стороны языка программирования — наличия специальных синтаксических конструкций. Такая конструкция содержит блок контролируемого кода и обработчик (обработчики) исключений.
Для обработки исключений применяется конструкция try...catch.
try
{
инструкции, которые могут вызвать исключение
}
catch(объявление_исключения)
{
обработка исключения
}
В блок кода после ключевого слова try помещается код, который потенциально может сгенерировать исключение.
После ключевого слова catch в скобках идет параметр, который передает информацию об исключении. Затем в блоке производится собственно обработка исключения.
Практика#
Скачайте и поместите в дирректорию с проектом:
Реализовать следующую схему классов для реализации игровых объектов Игрок
и Препятствие
:
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
сделать приватной!
Код для файла GameObject.h
Tip
#pragma once
#include <SFML/Graphics.hpp>
class GameObject {
public:
GameObject(const std::string& texturePath, const sf::Vector2f& position, const sf::Vector2f& scale = sf::Vector2f(1.0f, 1.0f));
virtual void update(float deltaTime);
virtual void draw(sf::RenderWindow& window);
sf::FloatRect getBounds() const;
protected:
sf::Sprite sprite;
sf::Texture texture;
sf::Vector2f position;
sf::Vector2f scale;
};
Код для файла GameObjec.cpp
Tip
#include "GameObject.h"
GameObject::GameObject(const std::string& texturePath, const sf::Vector2f& position, const sf::Vector2f& scale)
: position(position), scale(scale) {
if (!texture.loadFromFile(texturePath)) {
// Обработка ошибки загрузки текстуры
}
sprite.setTexture(texture);
sprite.setPosition(position);
sprite.setScale(scale);
}
void GameObject::update(float deltaTime) {
// Обновление состояния объекта
}
void GameObject::draw(sf::RenderWindow& window) {
window.draw(sprite);
}
sf::FloatRect GameObject::getBounds() const {
return sprite.getGlobalBounds();
}
Код для файла 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;
}