Инкапсуляция и выделение объектов в куче

1. Инкапсуляция и Подстановка

На прошлых лекциях мы рассмотрели такие объекты как Array, Complex, которые были созданы для того, чтобы не задумываться о их подробном внутреннем строении, а лишь использовать их.
Следуя такому подходу, можно говорить что типы объектов в языке C++ разбиваются на уровни (шаги):

низший уровень

более высокие уровни

указатель, int, char и т.д. (базовые типы)

Matrix, Array

Equation

И так далее


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

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

Рассмотрим следующий код, использующий класс комплексных чисел:
Complex a,b;
a.add(b);

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

Попробуем устранить его.

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

  2. Для того, чтобы не возникало ошибки, описанной в конце первого пункта, будем использовать ключевое слово inline. Оно указывается перед типом возвращаемых функцией данных, например inline void add(Complex & b);
    Таким образом мы не только сообщаем компилятору что желательно подставить реализацию в место вызова, но и сообщаем сборщику, что не нужно выводить ошибку при обнаружении нескольких реализации одной функции, а достаточно выбрать любую из этих реализаций.
    Теперь рассмотрим следующую проблему: если нам необходимо изменить функцию, реализация которой находится в нескольких файлах, то нам необходимо изменить ее в каждом файле, а это неудобно и возможно появление ошибок.

  3. Вынесем реализацию функции в заголовочный файл.
    Таким образом, подключая этот заголовочный файл ко всем файлам, в которых вызывается наша функция, мы устраним проблему множественных реализаций. Приведем пример такого заголовочного файла (файл «complex.h»):

    class Complex { 
        private:
            double nyRe,myIm;
        public:
            Complex(double r = 0, double i = 0);
            void add(Complex & b);
            //далее остальные методы класса
    };
    
    inline void add(Complex & b){
        myRe += b.myRe;
        myIm += b.myIm;
    }

    Таким образом частично решена и эта проблема подстановки, т. к. не во всех случаях происходит подстановка (она на усмотрение компилятора).

Теперь необходимо понять когда можно и нужно использовать подстановку, а когда — нет.

Замечание:
Компиляторы - достаточно умные программы, чтобы вместо подстановки функции

int factorial(int number) {
    if(i <= 1) return 1;
    return number * factorial( number - 1 );
}

в место ее вызова int a = factorial(5); скомпилировать данное место вызова как int a = 120;

Теперь, рассмотрев подстановку как возможное решение проблемы увеличения времени работы программы при инкапсуляции, приведем некоторые идеи за и против использования инкапсуляции:
+ Код становится более понятен для программиста, ведь он работает с сущностью, например с комплексным числом, а не с его составляющими.
- Возможно увеличение времени работы программы.

2. Создание объектов в куче.

До этого момента рассматривалось создание объектов на стеке: Array a(10);
Но время жизни такого объекта ограничено областью видимости (в основном фигурными скобками). А значит невозможно создать функцию, которая бы создавала внутри себя объект некоторого типа и передавала его вызвавшей функции. Еще раньше мы рассматривали указатель на стандартный тип, и выглядел он примерно так:
int * ptrInt1;
int * ptrInt2 = new int;

Создание указателя на объект.

Однако, кроме указателей на стандартные типы существуют и указатели на пользовательские типы даных. Пусть есть класс Complex представляющий комплексное число. Тогда указатель на него будет записываться аналогично указателю на простой тип:
Complex * prtC1;
Самому указателю при этом можно присвоить 0.
prtC1 = 0;

Использование указателя на объект, создание объекта в куче.

Но просто указатель нам вряд ли нужен. Было бы очень хорошо, если бы он указывал на объект класса Complex. Для этого нужно либо

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

Использование объекта, созданного в куче.

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

Для начала создадим два указателя на различные комплексные числа:
Complex * a = new Complex(1,0);
Complex * b = new Complex(0,1);

Безусловно, зная адрес объекта в памяти и имея стандартные операции взятия значения по адресу, мы можем вызвать его метод.
int aRe = (*a).getRe();

Но каждый раз писать операцию разыменования неудобно. Для устранения этой проблемы существует специальный оператор -> , который фактически преобразуется в разыменование. То есть следующая строка будет эквивалентна прошлой:
int aRe = a->getRe();

Как и для простых типов, к пользовательским классам применимы ссылки, т. е
Complex & c = *a;
Тогда с будет ссылаться на a. А значит можно писать и так:
int aIm = c.getIm();
Отличительной особенностью ссылок является то, что мы пользуемся ими как самими экземплярами классов.

Выбор способа хранения объекта.

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

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

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

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

Рассмотрим некоторые соображения по поводу методов хранения класса в зависимости от его характеристик:

Удаление объекта, хранящегося в куче.

После того как объект был создан и мы поработали с ним, неверно просто забыть о нем. Ведь таком случае «забытые» объекты будут скапливаться в куче, но у нас не будет ни возможности обратиться к ним, ни возможности удалить их. Мы можем не подозревать об их существовании. Это называется утечкой памяти. При этом память занята, но программой этот объект больше использоваться не будет.

Для предотвращения утечек памяти объект необходимо удалить. Для этого используется операции delete и delete[]. Отличаются они тем, что первая удаляет объект считая, что передан указатель на один объект, а вторая удаляет массив объектов (ей передается указатель на начало массива). Использование будет таково:
delete a;
delete[] ptrArr;

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

Проблемы с указателями на объект

Рассмотрим возможные проблемы, связанные с указателями на объекты:

Complex *pa = new Complex();
Complex *pb = new Complex();
pb->add(*pa);

Такой код будет замечательно работать. Но если написать так:

Complex *pa = new Complex();
Complex *pb = 0;
pb->add(*pa);

Тогда код будет компилироваться, но программа сломается при выполнении pb->add(*pa) при попытке доступа к полям объекта pb, которого не существует.

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

Вспомним о массивах объектов в куче, о которых говорили ранее. Создаются они как и массивы простых типов.
int * p = new int[20];
Complex * r = new Complex[10];

При этом в памяти выделяется место под 10 элементов типа Complex и у каждого вызывается конструктор по умолчанию.
Соостветсвенно удаление осуществляется так:

delete p; высвобождается память.
delete[] r; вызывается деструктор для каждого элемента (всего 10) и затем высвобождается память.

Таким образом new и delete в отличае от malloc и free вызывают так же конструктор и деструктор соответственно.

При вызова new[] и delete[] , эти процедуры используют дополнительную информацию о количестве элементов.
В коде, скомпилированным gcc вызов new int[25] создаст в памяти следующее:

size_t

0

1

2

...

23

24

Количество элементов

Элементы


При этом new[25] выделит в памяти sizeof(size_t)+sizeof(int)*25 и возвратит указатель на нулевой элемент, т.е. пользователю не виден размер массива.
Соответственно при вызове delete[] осуществляется просмотр количества элементов, их обход с вызовом деструкторов и дальнейшее освобождение памяти начиная с ячейки с количеством элементов.
Всвязи с этим нельзя использовать new совместно с delete[] и соответственно new[] и delete, так как:

  1. new[] и delete
    во время удаления попытается высвободить память начиная с другого адреса, хоть и в этом массиве.

  2. new() и delete[]
    во время удаления попытается обратиться к ячейке, содержащей количество элементов, но там может быть совсем чужая память.

Вывод: следует не забывать ставить [] там, где нужно и не ставить там, где не нужно.

Совет: не нужно использовать new T() и new T[] одновременно в программе. (T - это имя класса). Желательно объекты размещать во всей программе на стеке либо в куче.