Объектно-ориентированное программирование (ООП) в C++

Объектно-ориентированное программирование (ООП) — подход к программированию, при котором основными концепциями являются понятия объектов и классов.

Класс — это определяемый разработчиком тип данных.

Тип данных характеризуется:

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

Например, тип int предназначен для хранения целых чисел и подразумевает ряд операция над ними (+,-,*,/,% и пр.).

Класс — это тип, описывающий устройство объектов, их поведение и способ представления.

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

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

Описание класса начинается со служебного слова class, вслед за которым указывается имя класса. Затем в блоке следует описание класса и после закрывающей скобки блока ставится точка с запятой.

Описание класса состоит из полей и методов.

Поля (или свойства, в рамках C++ это можно считать синонимом) описывают то, какие данные смогут хранить экземпляры класса (т.е. объекты). Конкретные значения сохраняются уже внутри объектов. Поля объявляются в теле класса.

К полям внутри класса можно обращаться непосредственно по именам полей.

Методы класса — это функции, которые смогут применяться к экземплярам класса. Грубо говоря, метод — это функция объявленная внутри класса и предназначенная для работы с его объектами.

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

Методы и поля входящие в состав класса называются членами класса. При этом методы часто называют функциями-членами класса.

Пример:

class Complex {
  double img;
  double real;
};

В примере описан класс Complex с двумя полями img и real.

Абстракция данных

Абстракция данных — это выделение существенных характеристик объекта, которые отличают его от прочих объектов, четко определяя его границы.

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

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

Вес, размер и положение — это поля будущего объекта-контейнера.

Методы, которые к этому объекту смогут применяться возможны такие: создать в программе новый объект-контейнер, переместить объект на указанную позицию, удалить объект со склада, пометив занятое им место как свободное.

Ключевые черты ООП

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

Уровни доступа к членам класса

По уровню доступа все члены класса делятся на открытые (public), закрытые (private) и защищённые (protected).

Перед объявлением членов внутри класса ставятся соответствующие ключевые слова. Если такое слово не поставлено, то считается, что член объявлен с уровнем private. В примере выше класса Complex, соответственно, оба поля имеют уровень доступа private.

Члены объявленные как private доступны только внутри класса.

Члены объявленные как protected доступны внутри класса и внутри всех его потомков.

Члены объявленные как public доступны как внутри, так вне класса (в том числе в потомках).

Доступность членов класса в зависимости от уровня доступа private protected public
Внутри класса + + +
Внутри потомка класса - + +
В несвязанной с классом части программы - - +

Методы объявленные в открытой части класса называются его интерфейсом

Пример объявления различных уровней доступа:

class Complex {
  private: // Закрытая часть класса, её элементы доступны только внутри класса
    double img;
  public: // Открытая часть класса, её элементы доступны везде
    double real;
    double getImg() { // Через этот метод мы сможем получить значение закрытого поля
      return img;     // Поле закрытое, но метод открытый
    };
};

Интерфейс класса

Все методы класса, находящиеся в его открытой части (с уровнем доступа public) называют интерфейсом класса

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

Конструктор и деструктор

При создании объектов одной из наиболее широко используемых операций которую вы будете выполнять в ваших программах, является инициализация элементов данных объекта. Чтобы упростить процесс инициализации элементов данных класса, C++ использует специальную функцию, называемую конструктором, которая запускается для каждого создаваемого вами объекта. Также C++ обеспечивает функцию, называемую деструктором, которая запускается при уничтожении объекта.
Конструктор представляет собой метод класса, который облегчает вашим программам инициализацию полей при создании объекта класса.

Конструктор имеет такое же имя, как и сам класс.

Конструктор не имеет возвращаемого значения (по сути, результатом его работы является ссылка на созданный объект).

Каждый раз, когда ваша программа создает объект, C++ вызывает конструктор класса, если подходящий (с соответствующими параметрами) конструктор существует.

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

Конструктор по умолчанию

Конструктор по умолчанию — это конструктор без параметров.

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

Пример:

class Complex {
    private: // Закрытая часть класса
        double real; // Поле, действительная часть
        double img; // Поле, мнимая часть
    public: // Открытая часть класса
        void printComplex() { // Этот метод мы сможем вызывать за приделами класса
            cout &lt< real &lt< " + " &lt< img &lt< 'i' &lt< endl; // Выводим поля
        }
};

int main() {
    Complex a; // Работает конструктор по умолчанию,
    Complex b; // поля получают мусорные значения
    a.printComplex(); // Выводим первый и второй объекты
    b.printComplex(); // с помощью созданного метода printComplex()
    return 0;
}

В представленном примере отсутствует явно заданный конструктор, поэтому будет создан конструктор по умолчанию. Мы можем явно задать конструктор по умолчанию и обязаны это сделать, когда у нас имеется какой-либо конструктор с параметрами.

Пример класса с двумя конструкторами (первый из них — без параметров):

class Complex {
    private:
        double real; // Действительная часть
        double img; // Мнимая часть
    public:
        Complex() {
            real = 0;
            img = 0;
        }
        Complex(double a, double b) {
            real = a;
            img = b;
        }
        void printComplex() {
            cout &lt< real &lt< " + " &lt< img &lt< 'i' &lt< endl;
        }
};

int main() {
    Complex a;
    Complex b(3.14, 2.71);
    a.printComplex();
    b.printComplex();
    return 0;
}

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

Копирующий конструктор

Часто при создании нового объекта бывает удобным инициализировать его поля теми же значениями, что уже имеются у какого-то существующего объекта того же типа. Проще говоря, создавая новый объект порой удобно создать его как копию уже существующего.

Конструктор, который в качестве аргумента принимает другой объект того же типа — называется копирующим конструктором.

Обычно аргумент копирующего конструктора объявляется как ссылка на константу, чтобы копия никак не влияла на оригинал.

Пример:

class Complex {
    private:
        double real; // Действительная часть
        double img; // Мнимая часть
    public:
        Complex() {
            real = 0;
            img = 0;
        }
        Complex(const Complex& c) { // Копирующий конструктор
            real = c.real;
            img = c.img;
        }
        Complex(double a, double b) {
            real = a;
            img = b;
        }
        void printComplex() {
            cout &lt< real &lt< " + " &lt< img &lt< 'i' &lt< endl;
        }
};

int main() {
    Complex b(3.14, 2.71);
    Complex d(b);
    d.printComplex();
    return 0;
}

Копирующий конструктор создаётся по умолчанию, если не задан явно. В поля нового объекта он копирует значения соответствующих полей объекта-аргумента.

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

Деструктор

После того, как вы по ходу программы перестаёте пользоваться некоторым объектом, для него автоматически вызывается специальный метод-деструктор, если он описан в классе.

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

Деструктор имеет такое же имя, как и класс, но с символом тильды (~) в качестве префикса. Например, в классе Complex деструктор имеет имя ~Complex().

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

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

Пример наследования:

// Класс товаров в некотором магазине
class ShopItem {
	public:
		char	model[100]; // модель
		char	brand[100]; // производитель
		double	price; // цена
};
// Мобильные телефоны — наследник класса товаров
class MobilePhone : public ShopItem {	
	public:
		char	phoneColor[100]; // цвет
		int	batteryLife; // время работы от батареи в часах
};
// Карты памяти — наследник класса товаров
class MemoryCard : public ShopItem {	
	public:
		char	formFactor[100]; // формат карточки памяти
};
// Смартфоны — наследник класса мобильных телефонов
class SmartPhone : public MobilePhone {	
	public:
		char	os[100]; // операционная система
};

Когда объявляется класс-наследник, то сразу после его имени ставится двоеточие и указывается уровень (или способ) наследования (public, private, protected), а затем имя класса родителя. Уже потом открывается блок с описанием класс-наследника.

Private-наследование

Те члены, что в родителе были protected и public, становятся private в потомке.

Protected-наследование

Те члены, что в родители были protected и public становятся protected в потомке. Такой вариант используется редко.

Public-наследование

Те члены, что в родители были protected и public, остаются, соответственно, protected и public в наследнике, сохраняя свой уровень доступа.

Если уровень явно не указан, то происходит private-наследование.

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

Статические члены класса

Члены класса, объявленные с модификатором доступа static, называются статическими членами класса. Статические поля и методы доступны без создания объекта данного класса.

Доступ к статическому элементу осуществляется с использованием оператора принадлежности :: , а не через имя экземпляра класса с точкой.

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

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

Часто статические переменные называют переменными класса, а «не статические» переменные — переменными экземпляра. Для статических переменных могут использоваться указатели.

Указатель this

Указатель this — это константный указатель на текущий объект (т. е. на объект, к которому применяется вызываемый метод). Этот указатель существует всюду внутри класса, его не надо как-то явно объявлять, к нему можно обращаться внутри любого метода класса.

Предположим, что в классе у нас есть поле с именем f и есть метод, который к этому свойству должен обращаться, но в котором имеется локальная переменная с тем же именем f. Указатель this поможет одновременно использовать поле и локальную переменную: через указатель поле будет доступно как this->f.

Пример:

class Example {
  private:
    float f; // Какое-то поле класса
  public:
    void func(float f) {
      this->f = f;
    }
};

Данные у каждого объекта свои, а методы класса общие для всех объектов. Указатель this помогает, определить с данными какого объекта будет работать метод.

Разыменовав указатель this можно получить ссылку на объект.

Перегрузка операторов

В С++ можно создавать методы со специальными именами вида: operator *, где * — это один из существующих операторов языка (например, +, -, /, *, = и т.д.).

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

Вызывать этот метод можно обращаясь к нему по указанному символу (в данном случае, *). При этом аргументом метода будет считаться объект стоящий справа от символа, а вызываться метод будет для объекта, стоящего слева от символа.

Так, например, удобно для объектов своего класса использовать символ + в качестве метода, складывающего объекты, вместо именованного метода (например, sum).

Перегрузка операторов, как и перегрузка функций в целом, реализует принцип полиморфизма.

Пример:

Complex opertor + (Complex a) {
  Complex tmp;
  tmp.real = real + a.real;;
  tmp.img = img + a.img;
}

Пример использования:

Complex a(2,3);
Complex b(3,2);
Complex c;
с = a + b; // Использование перегруженного оператора + для класса Complex
cout &lt< c; // 5 + 5i

Примеры некоторых классов

Класс комплексных чисел

Далее будет рассмотрен пример реализации класса комплексных чисел с некоторыми операциями над ними.

В примере показано, как можно объявлять методы в классе (предъявляя прототипы методов), а описывать — за пределами класса (используя оператор ::).

Пример:

#include <iostream>
#include <cmath> // Этот заголовочный файл содержит функцию sqrt для вычисления корня
using namespace std;

class Complex {

    private:

        double real; 
        double img; 

    public:

        Complex() { // Конструктор по умолчанию
            real = 0;
            img = 0;
        }
        Complex(const Complex& c) { // Копирующий конструктор
            this->real = c.real;
            this->img = c.img;
        }
        
        Complex(double, double); // Конструктор объявлен в классе, а определен будет вне его

        void operator = (Complex c) { // Перегрузка оператора присваивания
            this->real = c.real;
            this->img = c.img;
        }

        bool operator == (Complex c) { // Перегрузка оператора сравнения
            if (this->real == c.real && this->img == c.img) {
                return true;
            } else {
                return false;
            }
        }

        Complex operator + (Complex c) { // Перегрузка оператора сложения
            Complex tmp;
            tmp.real = this->real + c.real;
            tmp.img = this->img + c.img;
            return tmp;
        }

        Complex operator * (Complex c); // Перегрузка оператора умножения
        // В классе только прототип, определение будет за пределами класса

        double module(); // Модуль комплексного числа, в классе только прототип

        void invert() { // Обращение комплексного числа в сопряженное
            img = -img;
        }

        void printComplex() { // Вывод комплексного числа
            cout &lt< real &lt< '+' &lt< img &lt< 'i' &lt< endl;
        }

};

Complex::Complex(double a, double b) { // Конструктор определен будет вне класса
    real = a;
    img = b;
}

double Complex::module() { // Метод определен вне класса
    return sqrt(real*real + img*img);
}

Complex Complex::operator * (Complex c) { //  Оператор определен вне класса
    Complex tmp;
    tmp.real=(real*c.real)-(img*c.img);
    tmp.img=(real*c.img)+(img*c.real);
    return tmp;
}

int main() {
    Complex a(2,3);
    Complex b(3,2);
    a.printComplex();
    b.printComplex();
    Complex c;
    c = a+b; // Перегруженный оператор сложения в действии
    c.printComplex();
    Complex z; // Создастся нулевое число конструктором по умолчанию
    z.printComplex();
    return 0;
}

Динамический стек

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

Стек можно представить себе как магазин автомата с патронами. Последний вставленный в магазин патрон выскакивает первым.

Стек также называют LIFO-буфером (Last Input Firs Output — Последний Вошёл Первый Вышел).

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

Из стека нельзя достать сразу первый попавший туда элемент, сначала придётся извлечь оттуда все элементы, попавшие в стек позже. Со стеком обязательно связаны операции добавления туда элемента и извлечение оттуда «верхнего» элемента.

Кроме стека существует FIFO-буфер (First Input Firs Output — Первый Вошёл Первый Вышел), называемый очередью. Такой буфер используется, например, для компенсации задержек или ошибок в каналах связи или при связи между устройствами и программами.

Рассмотрим далее пример, в котором стек заданной ёмкости создаётся в динамической памяти.

Пример:

#include <iostream>
using namespace std;

class Stack {
  private:
    int* arr; // Указатель на динамический массив с элементами стека
    int* top; // Указатель на верх стека
    int size; // Емкость стека
  public:
    Stack(int s = 10) { // Конструктор с одним параметром по умолчанию
    // Этот конструктор, в частности, заменяет и конструктор по умолчанию
        this->size = s;
        arr = new int[s];
        top = arr;
    }
    Stack(const Stack& s) { // Копирующий конструктор
        this->size = s.size;
        this->arr = new int[s.size]; // Создаём НОВЫЙ динамический массив
        this->top = arr;
        int* p = s.arr; // Отдельный указатель на элементы старого массива
        while (p < s.top) {
            *top++ = *p++; // Копируем элементы и позицию в новый массив
        }
    }
    bool isEmpty() { // Метод проверяющий пуст ли стек
        if (top <= arr) {
            return true;
        } else {
            return false;
        }
    }
    bool isFull() { // Метод проверяющий полон ли стек
        if (top - arr >= size) {
            return true;
        } else {
            return false;
        }
    }    
    void push(int val) { // Метод добавляющий элемент в стек
        if(!isFull()) {
            *top = val;
            top++;
        } else {
            cout &lt< "Stack full!" &lt< endl; // Стэк полон
        }
    }
    int pop() { // Метод извлекающий верхний элемент из стека
        if(!isEmpty()) {
            top--;
            return *top;
        } else {
            return 0; // Стэк пуст, вернём нуль
        }
    }
    void printStack() { // Метод выводящий элементы стека в строку
        int* p = arr;
        while (p < top) {
            cout &lt< *p++ &lt< ' ';
        }
        cout &lt< endl;
    }
    ~Stack() { // Деструктор
        delete[] arr; // Освобождает память от массива
    };
};
int main(void) {
    Stack s(3); // Создаём стек емкостью в 3 элемента
    s.push(11);
    s.push(43);
    s.push(25);
    s.push(66); // Увидим сообщение о том, что стек полон
    Stack t(s); // Создаём второй стек, копируя первый
    cout &lt< t.pop() &lt< endl; // Извлекаем и выводим верхний элемент
    t.push(99); // Добавляем другой элемент на освободившееся место
    t.printStack(); // Увидим 3 элемента стека
    return 0;
}

Класс n-мерных векторов с координатами шаблонного типа

Для классов, как и для функций, допустимо использование шаблонов.

Например, мы создадим класс векторов с координатами шаблонного типа, что позволит на основе такого шаблона потом создавать векторы с целыми, вещественными или даже комплексными координатами.

Шаблоны классов являются средствами полиморфизма.

Пример:

#include <iostream>
using namespace std;

template <typename T> // T - имя шаблонного типа
class Vector { // Создаём класс с именем Vector
    private: // Закрытые члены класса
        T* v; // Указатель на массив с координатами
        int n; // Длина массива с координатами (размерность вектора)
    public: // Открытые члены класса
        Vector(int n = 3) { // Конструктор с параметром по умолчанию
            this->v = new T[n];
            this->n = n;
            for(int i=0; i<n; i++) v[i] = 0;
        }
        Vector(const Vector& a) { // Копирующий конструктор
            n = a.n;
            v = new T[a.n];
            for(int i=0; i < a.n; i++) v[i] = a.v[i];
        }
        ~Vector() { // Деструктор
            delete[] v; // Освобождает память, удаляя динамический массив
        }
        void printVector() { // Метод печатающий вектор
            for(int i=0; i<n; i++) cout &lt< v[i] &lt< ' ';
            cout &lt< endl;
        }
        void fillVector(T a) { // Метод заполняющий вектор координатами
            for(int i=0; i<n; i++) v[i] = a;
        }
};

int main() {
    Vector <int> a(3), b; // Создание объектов, вместо шаблонного типа используем int
    a.fillVector(3);
    a.printVector(); // 3 3 3
    b.fillVector(5);
    Vector <int> c(b); // Создание объекта копирующим конструктором
    c.printVector(); // 5 5 5
    Vector <float> d; // Создание объекта, вместо шаблонного типа используем float
    d.fillVector(3.141592); // 3.141592 3.141592 3.141592
    d.printVector();
}