БлогNot. Про конструкторы, стек и кучу...

Про конструкторы, стек и кучу...

Заметка содержит примеры несложных классов, в упрощённом виде иллюстрируя материал этой лекции. Коды из статьи проверены в консоли Visual Studio 2015.

1. Напишем простой класс с двумя свойствами-строками и двумя свойствами-числами. Если для хранения строк использовать не указатели char *, а готовый класс string, разработка нашего класса существенно упростится, потому что нам не придётся в конструкторах дополнительно выделять память под свойства-указатели.

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

После обычных директив

#include <iostream>
#include <string>
using namespace std;

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

class Class {
 string prop1,prop2;
 int n1,n2;

 /* 1 */
};

Чаще всего классу нужны 2-3 конструктора.

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

#include <iostream>
#include <string>
using namespace std;

class Class {
 string prop1, prop2;
 int n1, n2;

 /* 1 */
};

int main () {
 Class c1; //Здесь вызван конструктор по умолчанию
 Class *c2 = new Class(); //И здесь тоже
 cin.get(); return 0;
}

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

Поместим эти строки на место комментария /* 1 */:

public:
 Class() : prop1(""), prop2(""), n1(0), n2(0) {} //Конструктор 1 - без аргументов, встроен

Как видно из кода, тело нашего конструктора пусто и встроено в описание класса.

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

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

Также не следует забывать, что конструкторы, как и другие функции класса, предназначенные для взаимодействия с "внешним миром", описываются в секции public:

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

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

Class(char *, char *, int =0, int =0); 
 //Конструктор 2 - с аргументами, часть из них имеют значения по умолчанию; 

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

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

#include <iostream>
#include <string>
using namespace std;

class Class {
 string prop1, prop2;
 int n1, n2;
public:
 Class() : prop1(""), prop2(""), n1(0), n2(0) {} //Конструктор 1
 Class(char *, char *, int = 0, int = 0); //Конструктор 2

};

Class::Class (char * p1, char *p2, int n1, int n2) { //Конструктор 2
 prop1 = p1; prop2 = p2; this->n1 = n1; this->n2 = n2;
}

int main () {
 Class c1; //Здесь вызван конструктор 1
 Class *c2 = new Class(); //И здесь тоже

 //Вызываем конструктор 2:
 Class *c3 = new Class("str1", "str2", 100, 200);  //с 4 аргументами
 Class *c4 = new Class("ctr1", "ctr2"); //его же с 2 аргументами
 Class c5 ("rtr1", "rtr2", 300); //его же с 3 аргументами

 cin.get(); return 0;
}

Объекты c2, c3 и c4 созданы в "куче", под них динамически выделяется память оператором new и она может быть освобождена применением к объектам оператора delete.

Объекты c1 и c5 занимают место в стеке программы, занятая ими память будет освобождена только когда закончится область видимости переменной (ближайшие внешние по отношению к её описанию фигурные скобки) и к ним нельзя явно применять delete.

Часто спрашивают: стек и куча

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

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

Память в стеке "выделяется" простым перемещением указателя стека (регистр процессора SP/ESP/RSP), это происходит постоянно, при загрузке аргументов функции, при вызове любой функции, в самой функции выделяется место под локальные переменные и т.д. Место под сам стек выделяется один раз при запуске потока.

Кучей управляет менеджер памяти, память выделяется и освобождается "вручную" операторами new/delete или функциями malloc/free. Разумеется, куча работает гораздо медленнее, чем стек.

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

Увы, также ничто не мешает программе нечаянно или специально переписать стек "по живому", невзирая на то, какие данные будут затёрты. Можно и наоборот переполнить стек и "залезть" в кучу, испортив какие-либо данные там.

Строковые свойства объектов c3, c4, c5 получат значения, переданные при вызове конструктора, а какими будут значения свойств-чисел, тоже легко понять:

c3->n1 = 100, c3->n2 = 200 //оба свойства получили значения явно
c4->n1 = 0,   c4->n2 = 0   //оба свойства получили значения по умолчанию
c5.n1 = 300,  c5.n2 = 0    //n2 получило значение по умолчанию

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

Class(char *, char *, int);

У него 3 обязательных аргумента с типами char *, char * и int, но с теми же аргументами можно вызвать и наш второй конструктор.

Наконец, явный конструктор копирования (а также явный деструктор и оператор присваивания) следует определять в классе, если в нём происходит работа с динамической памятью (она выделяется явно оператором new или функциями malloc/calloc), это так называемое "правило большой тройки".

В противном случае, скажем, при копировании объекта класса, содержащего указатель, под который выделялась память, скопируется только адрес памяти, который содержится в указателе. Если затем объект 2, бывший справа от знака "=", будет удалён, перестанет работать и ссылка на память из объекта 1 (пример).

В нашем случае явный конструктор копирования не нужен, копирование объектов будет работать и без его явного описания - но лишь потому, что встроенный класс string уже содержит и конструктор копирования, и переопределённый оператор "=".

Тем не менее, мы могли бы добавить в класс прототип конструктора копирования

Class(Class &);

и реализовать его в секции кода:

Class::Class(Class &right) { 
 //Конструктор копирования. Нужен, если в классе есть указатели, для которых явно выделялась память.
 //В нашем случае копирование объектов будет работать и без явного описания этого конструктора.
 this->prop1 = right.prop1; this->prop2 = right.prop2; this->n1 = right.n1; this->n2 = right.n2;
}

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

void Class::show() { cout << endl << (prop1.size()<1?"null": prop1) << " " 
 << (prop1.size()<2 ? "null" : prop2) << " " << n1 << " " << n2; 
}

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

#include <iostream>
#include <string>
using namespace std;

class Class {
 string prop1, prop2;
 int n1, n2;
public:
 Class() : prop1(""), prop2(""), n1(0), n2(0) {} //Конструктор 1
 Class(char *, char *, int = 0, int = 0); //Конструктор 2
 void show(); //Метод для вывода свойств
};

Class::Class(char * p1, char *p2, int n1, int n2) { //Конструктор 2
 prop1 = p1; prop2 = p2; this->n1 = n1; this->n2 = n2;
}

void Class::show() {
 cout << endl << (prop1.size()<1 ? "null" : prop1) << " "
  << (prop1.size()<2 ? "null" : prop2) << " " << n1 << " " << n2;
}

int main() {
 Class c1; //Здесь вызван конструктор 1
 Class *c2 = new Class(); //И здесь тоже
 c1.show(); c2->show();

//Вызываем конструктор 2:
 Class *c3 = new Class("str1", "str2", 100, 200);  //с 4 аргументами
 Class *c4 = new Class("ctr1", "ctr2"); //его же с 2 аргументами
 Class c5 ("rtr1", "rtr2", 300); //его же с 3 аргументами
 c3->show(); c4->show(); c5.show();

 //Копируем объект класса:
 Class c6 = *c3; c6.show();

 cin.get(); return 0;
}

2. Небольшой и отчасти искусственный пример класса для массива целых значений. Так как массивы могут быть различной размерности, без явного выделения памяти в конструкторе не обойтись. Это порождает множество дополнительного кода, связанного с явным выделением памяти под свойство и контролем её использования. Показанный класс минимально защищён от "нештатных ситуаций", но представьте, каким будет код, если динамических свойств в классе много? В общем случае следует предпочитать таким решениям стандартные контейнеры STL.

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

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

#include <iostream>
using namespace std;

class C { //Java-style, пишем коды методов внутри операторных скобок класса
 int *a;
 int n; //Все свойства класса по умолчанию приватны

public:         //...а все основные методы - публичны

                //Конструктор - это просто функция. Он решает следующие задачи:
                //1. Дать свойствам класса нужные значения (или значения по умолчанию)
                //2. Если в классе есть указатели, выделить для них память.

 C() : a(NULL), n(0) {}
 //Конструктор по умолчанию - обычно ничего не делает, 
 //но ставит свойствам значения по умолчанию

 C(int n) { //Конструктор с аргументами, как правило, нужно:
  if (n > 0) { //1) проверять, допустимы ли значения аргументов конструктора
   a = new int[n];
   if (a) { //2) проверять, удалось ли выделить память
    this->n = n; for (int i = 0; i < n; i++) a[i] = 0; return;
   }
  }
  a = NULL; this->n = 0; //3) если нет, ставить свойствам значения по умолчанию
 }

 C(C &b) { //Конструктор копирования, пишется, если есть 
           //другие явные конструктор(ы) и деструктор
  int n = b.size(); //Получить размерность массива для объекта справа от знака "="
  if (n>0) { //Если справа от "=" объект с допустимым размером
   if (!a || this->n < n) { //И в текущем объекте не хватит памяти
    if (a) { delete[] a; a = NULL; } //Если память выделялась, но мало, освободить ту, что есть
    a = new int[n]; //Заново выделить память для массива текущего объекта
    if (!a) { a = NULL; this->n = 0; return; } //Если не удалось - выход
   }
   this->n = n; //Наконец, само копирование
   for (int i = 0; i < n; i++) this->a[i] = b.get(i);
  }
  else { this->a = NULL; this->n = 0; }
 }

 ~C() { //Явный деструктор - нужен, если конструкторы выделяли память
  if (a) {
   delete[] a;
   a = NULL; //в деструкторе "для подстраховки" сбрасывать указатели на освобождённую память
  }
 }

 void show() { //Метод класса, показывает данные массива в консоли
  cout << endl << n << " item(s)" << endl; for (int i = 0; i < n; i++) cout << a[i] << " ";
 }

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

 int size() { //Узнать текущую размерность
  return n;
 }

 int get(int i) { //Получить элемент с индексом i
  return (i>-1 && i<n ? a[i] : 0);
 }

 int put(int i, int val) { //Записать по индексу i значение val
  if (i > -1 && i < n) { a[i] = val; return 0; } //Записать и вернуть 0
  return -1; //Вернуть -1, если пытались записать величину по несуществующему индексу
 }
};

int main() { //Демонстрация всех действий
 //"Путаница" насчёт совпадений имени объекта с именем класса:
 //C C; //это компилируется - имя объекта стека может совпадать с именем класса 
 //C *C = new C(); //...а динамического объекта "кучи" - нет, код не компилируется!

 //Просто вызовы конструктора и деструктора:
 C *c = new C();
 /* какой-то код с объектом c */
 delete c;

 C d; //Всё равно вызывался тот же самый конструктор по умолчанию!
 //delete &d; //Так делать НЕЛЬЗЯ!
 //Это откомпилируется, но приведёт к краху программы при запуске:
 //delete нельзя применять к объектам из стека (для которых мы не писали new)

 //Как и из структур, можно делать списки из экземпляров класса:
 const int N = 2;
 C *List[N];
 for (int i = 0; i < N; i++) {
  List[i] = new C(i + 5);
  List[i]->show();  //List[0] - выведет 5 нулей, List[1] - 6 нулей 
 }

 //Если делать так, как ниже, нужно учесть, что для List2 
 //конструктор уже вызывался в момент описания массива:
 C List2[N]; //List2[0] и List2[1] УЖЕ вызывали конструктор по умолчанию
 List2[0].show(); //Пусто

 //Работа с конструктором копирования:

 C obj1(5);
 for (int i = 0; i < 5; i++) obj1.put(i, i + 1); //Заполнили числами 1..5
 C c1 = obj1; //Конструктор копирования, пишем в новый объект
 c1.show();

 C c2, obj2;
 c2 = obj2; //Пробуем скопировать пустой объект
 c2.show();

 C *c3 = new C(3);
 c3->show(); //Только 3 числа
 *c3 = obj1; //Копируем в "меньший" объект
 c3->show(); //5 чисел
 /*
 Как решить проблемы с ошибочным применением delete к объектам из стека
 или ошибочными повторными вызовами деструктора?
 В рамках "чистого" C++ - НИКАК.
 Переходить к классам, реализующим "умные указатели"
 Но это - отдельная тема.
 */

 cin.get(); return 0;
}

21.02.2018, 11:43 [3445 просмотров]


теги: c++ программирование учебное

К этой статье пока нет комментариев, Ваш будет первым