На прошлых лекциях мы рассмотрели
такие объекты как Array, Complex,
которые были созданы для того, чтобы не
задумываться о их подробном внутреннем
строении, а лишь использовать их.
Следуя
такому подходу, можно говорить что типы
объектов в языке C++ разбиваются на уровни
(шаги):
низший уровень |
более высокие уровни |
||
указатель, int, char и т.д. (базовые типы) |
Matrix, Array |
Equation |
И так далее |
Таким образом появляется положительная
особенность: каждый новый уровень
позволяет скрыть более низкий, например,
у класса Complex нас интересует не его
реализация, а только набор его возможностей.
Сокрытие реализации структуры от пользователя и предоставление ему только определенного набора возможностей называется Инкапсуляцией и является одной из трех основ ООП.
Рассмотрим следующий код, использующий
класс комплексных чисел:Complex
a,b;
a.add(b);
Во второй строке
происходит вызов метода класса. Для
вызова метода(функции) требуется
некоторое время чтобы положить аргументы
на стек и перейти к коду этой функции.
В методе add
произойдет сложение
четырех чисел.
Допустим мы не будем
использовать класс комплексных чисел,
а будем использовать просто четыре
числа, характеризующие два комплексных
числа. Тогда на их сложение затратиться
меньшее количество времени, т. к. не
нужно будет вызывать функцию сложения
комплексных чисел.
Таким образом
видно, что при инкапсуляции будет
происходить увеличение времени работы
программы, а это является недостатком.
Попробуем устранить его.
Мы можем поместить функцию в тот
файл, в котором она вызывается.
Значит
в момент компиляции функция будет
обнаружена компилятором в этом файле
и скорее всего ее код будет подставлен
на место вызова. Это называется
Подстановка (Inline). Значит,
если мы разместим реализацию функции
в файле с ее вызовом, то увеличения
времени работы не произойдет (фактически
функция не будет вызываться).
Если у
нас несколько файлов в которые необходимо
вставить реализацию функции, сделаем
это для каждого файла.
Но теперь,
когда мы попытаемся скомпоновать все
объектные файлы, сборщик выведет ошибку,
связанную с тем, что он нашел несколько
реализаций одной и той же функции.
Для того, чтобы не возникало ошибки,
описанной в конце первого пункта, будем
использовать ключевое слово inline.
Оно указывается перед типом возвращаемых
функцией данных, например inline void
add(Complex & b);
Таким образом мы не
только сообщаем компилятору что
желательно подставить реализацию в
место вызова, но и сообщаем сборщику,
что не нужно выводить ошибку при
обнаружении нескольких реализации
одной функции, а достаточно выбрать
любую из этих реализаций.
Теперь
рассмотрим следующую проблему: если
нам необходимо изменить функцию,
реализация которой находится в нескольких
файлах, то нам необходимо изменить ее
в каждом файле, а это неудобно и возможно
появление ошибок.
Вынесем реализацию функции в
заголовочный файл.
Таким образом,
подключая этот заголовочный файл ко
всем файлам, в которых вызывается наша
функция, мы устраним проблему множественных
реализаций. Приведем пример такого
заголовочного файла (файл «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;
}
Таким образом частично решена и эта проблема подстановки, т. к. не во всех случаях происходит подстановка (она на усмотрение компилятора).
Теперь
необходимо понять когда можно и нужно
использовать подстановку, а когда —
нет.
Если у нас имеется функция небольшая о объему и, возможно, вызываемая часто, то следует использовать для нее метод подстановки (помещение в заголовочный файл и указание ключевого слова inline), т. к. мы будем тратить меньше времени на вызов функции.
Нежелательно использовать подстановку для функций, длительных по времени выполнения или по объему кода, так как при компиляции получается файл гораздо большего размера (ведь везде стоят подстановки).
При использовании подстановки происходит увеличение времени компиляции программы (компилятору необходимо время на вставку и преобразование реализации функции в место вызова). Иногда это увеличение мало (особенно для небольших проектов), но иногда время компиляции увеличивается в разы по сравнению с случаем, когда не используется подстановку.
При большом количестве функции, помеченных на подстановку компилятор может выбрать не ту, которую первоначально задумал подставить программист, а совершенно другую. В результате чего возможно существенное изменение скорости работы программы.
Замечание:
Компиляторы - достаточно
умные программы, чтобы вместо подстановки
функции
int factorial(int number) {
if(i <= 1) return 1;
return number * factorial( number - 1 );
}
в место ее вызова int a = factorial(5);
скомпилировать данное место вызова как
int a = 120;
Теперь, рассмотрев подстановку как
возможное решение проблемы увеличения
времени работы программы при инкапсуляции,
приведем некоторые идеи за и против
использования инкапсуляции:
+ Код
становится более понятен для программиста,
ведь он работает с сущностью, например
с комплексным числом, а не с его
составляющими.
- Возможно увеличение
времени работы программы.
До этого момента рассматривалось
создание объектов на стеке: Array
a(10);
Но время жизни такого объекта
ограничено областью видимости (в основном
фигурными скобками). А значит невозможно
создать функцию, которая бы создавала
внутри себя объект некоторого типа и
передавала его вызвавшей функции. Еще
раньше мы рассматривали указатель на
стандартный тип, и выглядел он примерно
так:int * ptrInt1;
int * ptrInt2 = new int;
Однако, кроме указателей на стандартные
типы существуют и указатели на
пользовательские типы даных. Пусть есть
класс Complex представляющий комплексное
число. Тогда указатель на него будет
записываться аналогично указателю на
простой тип:Complex * prtC1;
Самому
указателю при этом можно присвоить
0.prtC1 = 0;
Но просто указатель нам вряд ли нужен. Было бы очень хорошо, если бы он указывал на объект класса Complex. Для этого нужно либо
Присвоить указателю адрес объекта
на стеке. Как и для простых типов,
поддерживается операция взятия адреса
объекта, возвращяющая указатель на
объект.Complex a;
Complex *ptrA = &a;
Но
если мы возвратим этот указатель из
функции, то он будет ссылаться на
несуществующий объект, т. к. a удалится
при выходе из области своей видимости
(завершении функции).
Создадим объект в куче.
Выделение
объектов собственных классов в куче
происходит так же, как и для обычных
типов, с использованием new и указанием
имени типа. Но, в отличие от простых
типов, у объекта класса всегда
вызывается конструктор, поэтому
необходимо ставить круглые скобки
после имени типа, даже если есть
конструктор без аргументов. Таким
образом в строкахComplex * ptrB = new
Complex();
Complex * ptrC = new Complex(10,0);
происходит
сначала выделение памяти под объект,
затем вызов конструктора, и уже в конце
присваивание указателю.
Как и для простых типов, мы можем
создать сразу массив объектовComplex
* ptrArr = new Complex[10];
В таком случае
необходимо указать размер массива и у
класса должен быть конструктор по
умолчанию, потому как нельзя указывать
параметры конструктору при выделении
массивов.
Как и в прошлом случае
сначала происходит выделение памяти
под объекты, затем у каждого вызывается
конструктор по умолчанию.
Стоит особо отметить, что при выделении объекта в куче не бывает неинициализированных объектов, так как у него всегда вызывается конструктор.
После того как мы научились создавать указатели на собственные классы и создавать экземпляры этих классов в куче, стоит понять как использовать указатели на объекты.
Для начала создадим два указателя на
различные комплексные числа: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];
При
этом в памяти выделяется место под 10
элементов типа Complex и у каждого
вызывается конструктор по умолчанию.
Complex * r = new Complex[10];
Соостветсвенно удаление осуществляется
так: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, так
как:
new[]
и delete
во
время удаления попытается высвободить
память начиная с другого адреса, хоть
и в этом массиве.
new()
и delete[]
во время
удаления попытается обратиться к
ячейке, содержащей количество элементов,
но там может быть совсем чужая память.
Вывод: следует не забывать ставить [] там, где нужно и не ставить там, где не нужно.
Совет: не нужно использовать new
T()
и new T[]
одновременно в
программе. (T - это имя класса). Желательно
объекты размещать во всей программе на
стеке либо в куче.