Вся правда об указателях. Часть 2: Памятная

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

Выделение памяти

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

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

Давайте разберём каждый пункт.

Область видимости

 

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

void innermostFun(int* ptrValue)
{
    *ptrValue = 10;
}

int* middleFun(int* ptrValue)
{
    innermostFun(ptrValue);
    int someValue = 15;
    return &someValue;
}

void topmostFun()
{
    int value = 0;
    int* invalidPtr = middleFun(&value);
}

Если мы вызовем функцию topmostFun, то произойдём следующая цепочка событий:

  1. topmostFun начинает выполнение и первым делом выделят память под value в стеке.
  2. Функция topmostFun вызывает функцию middleFun и передаёт ей указатель на value.
  3. Функция middleFun начинает исполнение.
  4. Функция middleFun вызывает функцию innermostFun и передаёт ей ранее полученный указатель на value.
  5. innermostFun выполняет присвоение 10value косвенным образом(через указатель на value).
  6. innermostFun завершает выполнение и управление возвращается в middleFun.
  7. middleFun выделяет в стеке память под someValue и присваивает переменной значение 15.
  8. middleFun помещает копию адреса someValue в какую-то область памяти(не важно в какую).
  9. middleFun завершает выполнение и освобождают всю стековую память, что была выделена в ней. Таким образом освобождается память выделенная под someValue.
  10. Управление возвращается в topmostFun и invalidPtr присваивается адрес, который куда-то ранее спрятали в функции middleFun. Теперь invalidPtr указывает на someValue.
  11. topmostFun завершает выполнение и освобождает память, которая ранее была выделена ей на стеке(value и invalidPtr).

Как вы можете видеть, в нашей цепочки произошло ужасное событие, на шаге 8 мы скопировали адрес области, которую на шаге 9 отдали обратно системе. Более того, на шаге 10 мы инициализировали указатель этим адресом. Беды не случилось лишь потому, что мы не стали разыменовывать invalidPtr, а так наш invalidPtr указывает на память, которая нам больше не принадлежит, т.е. она больше не является выделенной для нас. Все это произошло потому, что так работает стек: вся выделенная в стеке память освобождается по завершении функции. Поэтому, указатель полученный путём присвоения адреса стековой переменной имеет весьма ограниченное применение: мы не можем вернуть указатель на переменную, память под которую была выделена на стеке.

Доступный размер

Как правило, размер стековой памяти ограничен и очень мал, к примеру, в Microsoft VIsual Studio размером стека по умолчанию является 1Мб. Безусловно, это всё настраивается и можно увеличить размер, но это не лучшая идея, т.к. это скажется на других компонентах программы. Тут ещё необходимо помнить, что стек выделяется для каждого потока свой, поэтому увеличение размера может сказаться на вашем приложении весьма скоро. Поэтому использования стека для больших объектов нецелесообразно и очень часто просто невозможно.

Динамическое выделение

Наконец пункт третий, касающийся выделения памяти, размер которой заранее не известен. Зачем это может понадобится? Вариантов масса: пользовательский ввод — вы никогда не знаете, сколько может ввести пользователь, загрузка картинок. видео и прочих элементов в программу. Если программа не замкнута сама на себе, а взаимодействует с внешним миром, то рано или поздно окажется, что нам нужно выделить какое-то количество памяти, которое станет известно лишь в момент выполнения. Конечно, некоторый «бравые молодцы» «решают» эту проблему на корню:

int main()
{
    // 1024 точно достаточно для пользовательского ввода
    // Что за дурак будет вводить больше?
    char bigEnoughArray[1024];
}

Разумеется это не решение, нет никаких «big enough array»(массивов достаточного размера). Я не зря привёл здесь этот код, я видел его многократно, в разных проектах, разного уровня. Как правило массив был именно такого размера. Это ужасно. Это ошибка в программе, которая открыла дорогу не одному взломщику.

Но что нам предлагает стек в динамическом выделении? К сожалению ничего. На дворе 2015 год, мы имеем C++14, но не имеем возможности выделять память на стеке, размер которой не известен в момент компиляции. Не подумайте, что я жалуюсь — нет, я за то, чтобы это так и оставалось, просто сейчас идут разговоры о введении динамического массива на стеке, но пока это не оформилось в реально принятую функциональность в языке, говорить о ней не за чем.

Я говорю здесь о C++, но не о C, т.к. в C существует возможность динамического выделения памяти на стеке, это называется variable-length array, но, насколько я могу судить, C уже тоже начинает уходить от этого, и C11 более не требует обязательной реализации VLA в компиляторе.

Рассмотрев эти три проблемы, становится ясно, что со стеком «каши не сваришь», поэтому нужно искать другие методы выделения памяти.

Статическое выделение памяти

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

#include <iostream>
using namespace std;

float globalFloat = 0.5f;

int* function()
{
    static int variable = 0;
    return &variable;
}

int main()
{
    static char character = 'f';
    int* validPtr = function();
    *validPtr = 50;
    cout << *function() << "\n";
}

В коде выше мы имеем три переменных, которые находятся в статической области памяти: globalFloat, variable и character. Но какие гарантии и какие преимущества имеет статическая область памяти над стеком? Давайте разберём на примере всё тех же пунктов, что мы рассматривали в параграфе посвящённом стеку.

Область видимости

Как вы могли заметить в коде выше, мы вернули указатель изнутри функции и использовали его снаружи. Это, безусловно, говорит о том, что объекты размещённые в статической области памяти(СОП) не удаляются по завершении функции. Но почему память не освобождается по окончании работы функции? Потому что СОП никакого отношения к функции не имеет, и то, что мы поместили создание переменной variable в функцию даёт только одно: мы гарантировали, что переменная будет создана при первом обращении любой сущности к данной функции. Это может быть логично и понятно, но это не вся история с этой областью памяти: так, к примеру, глобальные переменные создаются на этапе запуска программы и, в общем случае, порядок их инициализации не известен. Известно лишь то, что до того, как начнётся исполнение функции main, все глобальные переменные будут созданы. Но что значит «созданы»? Для переменной типа int, это, в сущности ничего не значит(если конечно вы не инициализируете глобальную переменную каким-то значением), но значит для тех объектов, что имеют конструкторы. Мы не будем подробно на этом останавливаться и разбирать все тонкости работы со статической памятью — это не так важно, в рамках данной статьи. Уясним отсюда одно: все переменные, размещённые в СОП имеют зарезервированное место в памяти при старте программы и доступны к использованию при первом обращении к оным, вне пределов глобальной области видимости(т.е. внутри любой функции, которая вызвана локально, т.е. в дереве вызовов, где корнем является функция main).

Итак, мы теперь знаем, когда начинается время жизни статической переменной, но когда оно заканчивается? А заканчивается оно при завершении программы, т.е. такие переменные живут очень долго и могут быть использованы на протяжении всего цикла работы программы. Поэтому мы можем позволить себе возвращать указатель изнутри функции наружу, не боясь, что память под эту переменную будет освобождена — не будет.

Итак, исходя из всего вышесказанного, мы имеем явное преимущество над стековой памятью, в части области видимости, т.к. переменные выделенные в СОП могут быть жить долго и, соответственно, могут быть использованы в различных областях программы, в разное время. Тем не менее, они обладают значительным недостатком: в общем случае, мы слабо можем контролировать когда же переменная создается(т.е. вызывается её конструктор), мы совершенно не контролируем когда выделяется память(не большая проблема, в общем-то) и мы не можем контролировать, когда же переменная будет уничтожена. Если вам кажется, что всё это не является сколь бы то значимой проблемой, то вы заблуждаетесь. Когда все статические переменные используются независимо, тогда вероятность ошибки действительно довольно низка, но если внести зависимость между двумя статическими переменными, то можно получить очень много головной боли. И это лишь один пример, на самом деле их гораздо больше. Но я не собираюсь здесь на них останавливаться — не о том в статье речь.

Доступные размер

Размер доступной статической памяти зависит от ОС, и он ограничен. К примеру, для Windows, он ограничен 2-мя гигабайтами. В целом, такого количества памяти достаточно для большинства программ.

Динамическое выделение

Мы уже видели ранее, что вся память под статические переменные выделяется при старте программы, а это значит, что требуемое количество статической памяти должно быть доступно на этапе сборки приложения. А это, в свою очередь, исключает динамическое выделение памяти в статической области памяти. Точнее, никто вам не мешает выделить массив в статической памяти, а потом использовать этот массива как пул памяти для дальнейшего распределения под свои нужды. Но это весьма экстравагантное решение, которое всё равно ограничено изначально заданным размером этого массива и максимальным размером статической памяти в целом. Кроме того, т.к. нет никакого динамического выделения памяти, нет никакого и освобождения памяти(о чём мы уже говорили), поэтому выделив на старте максимально доступные 2 Гб, ваша программа всё своё время жизни будет потреблять эти 2 Гб и не освободит их аж до самого конца. Хорошо ли так делать? Это риторический вопрос.

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

Динамическая память

Динамическая память или куча(heap) является третьим вариантом доступной приложению памяти, и как мы увидим далее, это наиболее гибкий и, в то же время, наиболее хрупкий механизм по работе с памятью в C++. В отличии от предыдущих вариантов выделения памяти, память в куче выделяется только явно, и выделенный кусок памяти возвращается к нам в виде указателя на начало выделенного блока, т.е. здесь мы имеем обезличенное выделение памяти. Ранее мы были обязаны указать имя переменной, и только потом мы получали её адрес в указатель, здесь же мы опускаем имя и сразу получаем указатель — выделенная область памяти не имеет ассоциированного с ней имени! Давайте перепишем наш ранее представленный код, с использованием кучи:

#include <iostream>
using namespace std;

int* badFunction()
{
    int* pVariable = (int*)malloc(sizeof(int));
    return pVariable;
}

int main()
{
    int* validPtr = badFunction();
    *validPtr = 50;
    cout << *badFunction() << "\n";
}

Итак, что же происходит в коде выше? Давайте начнём с новой для нас строчки:  (int*)malloc(sizeof(int)) — а происходит здесь следующее: наша программа запрашивает у ОС кусок памяти, размером в sizeof(int) байтов, система выделяет нам этот кусок(если он есть) и возвращает void*, указывающий на первый байт выделенного нам блока. Мы преобразуем void* в int*, т.к. мы знаем, что мы выделили ровно 4 байта(примем sizof(int) == 4) и принимающий тип у нас int*.

Но что такое «void», указатель на который возвращает malloc? Это ничто, буквально. Это такой специальный «недотип» который может быть использован в качестве возвращаемого значения, чтобы показать, что функция ничего не возвращает. Либо же в качестве «типа», на который указывает указатель. Что же значить «указатель на void»? Это значит, что в указателе содержится адрес памяти, но указатель не содержит в себе информации, как эту память интерпретировать. Поэтому разыменовать указатель на void нельзя — мы не знаем, что будет результатом такой операции. Поэтому указатель на void всегда нужно преобразовывать к конкретному указателю, прежде чем можно будет получить доступ к памяти, на которую он указывает. Именно поэтому мы преобразовали полученный указатель к int*.

Но почему malloc возвращает указатель на void? Потому что malloc это функция из C, а в C не существует шаблонов, поэтому максимально обобщённая функция выделения памяти, принимает своим аргументов количество байт, которое требуется выделить, и возвращает указатель на «ничто», которое программист уже должен превратить в «что-то» самостоятельно. Интересным моментом здесь является то, что в предыдущих примерах, в стеке и в статической памяти, мы выделяли память под конкретный тип, тогда как в случае с malloc мы выделяем область памяти заданного размера, которую уже потом будем интерпретировать так, как пожелаем. Т.е. в данном случае у нас больше работы: нужно посчитать сколько байт нам нужно, и присвоить полученный указатель какой-то переменной. В этом плане, предыдущее варианты выделения выглядят куда проще и предпочтительнее.

Вернёмся к коду: за пределами badFuction, мы используем указатель на выделенную памяти в двух местах: в validPtr и в cout. Но это два совершенно разных указателя! Почему это так, почему функция имеет префикс «bad» и почему код выше ужасен, вы узнаете после того, как мы разберём динамическое выделение памяти в том же ключе, в котором мы разобрали 2 других типа выделения памяти.

 

Область видимости

Как мы уже видели, для выделения памяти в куче мы можем использовать библиотечную функцию malloc, значит время жизни нашего куска памяти начинается сразу после завершения работы malloc. Но когда оно заканчивается? Когда память выделенная malloc освобождается? Никогда. Автоматически этого не происходит, т.е. нет никакого механизма в программе, который бы автоматически освободил память. Конечно, при завершении процесса, умная ОС освободит всю занимаемую приложением память, но, строго говоря, она это делать не обязана. Поэтому, для освобождения памяти придётся поработать руками, а именно: нам нужно вызвать функцию free для ранее выделенного указателя, чтобы освободить память. Причем free так же принимает указатель на void. Но, разумеется, вы может передать указатель на что угодно, главное, чтобы он указывал на тот же участок, на который указывал void* полученный из malloc. Так будет правильно:

int* pointer = (int*)malloc(sizeof(int)*50);
free(pointer);

А так нет:

int* pointer = (int*)malloc(sizeof(int)*50);
free(pointer + 1);

Неправильно это потому, что в функцию free мы передали смещённый указатель и теперь мы имеем неопределённое поведение(по слухам, данный код может вызвать дьявола, но мне не удалось повторить этого, хотя я и старался — на то оно и неопределённое, это поведение).

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

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

  1. При разыменовании результата функции в cout, мы выведем на экран мусор — malloc всегда выделяет новый блок памяти, поэтому второй вызов приводит к выделению нового блока, который отличается от того, на который указывает validPtr.
  2. Мы два раза вызываем malloc, но ни разу не вызываем free! Это банальный пример утечки памяти.

Я не буду перечислять остальные причины(а они есть), т.к. они уже будут смещены в сторону «хорошего тона», а на данный момент нам не до манер  — мы по локоть в кучу влезли!

Как вы можете видеть, время жизни для объектов полученных из кучи является куда более гибким, чем то, что мы видели со стеком и статической памятью. Но за всё приходится платить, если ранее нас не заботило освобождение памяти — оно было автоматическим, то теперь мы должны явно это делать, а если мы этого делать не будем, то рано или поздно вся доступная память кончится. Это одна из причин, почему многие не любят C++ — ручное управление памятью чревато ошибками, но далее мы увидим, что эта проблема преувеличена и имеет вполне элегантное решение. Точнее проблема не то чтобы преувеличена, она действительно весьма серьёзна, но решение на самом деле существует и давно известно.

Доступный размер

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

Динамическое выделение

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

Итак, мы рассмотрели 3 варианта, как можно выделить память под переменную и получить указатель на неё. Какого типа выделения стоит придерживаться? Это зависит от ситуации и общего рецепта нет. Не зря же мы имеем 3 варианта, каждый со своими плюсами и минусами. Нельзя сказать, что один однозначно лучше другого — нет, у каждого свои области применения. Главное понимать, что при использовании указателя он всегда должен указывать на какой-то участок выделенной памяти, поэтому использование неинициализированного указателя это всегда ошибка: в случае стека и статической памяти, указатель инициализируется адресом переменной, а в случае динамической — результатом работы malloc. Но он всегда инициализируется чем-то.

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

Это очень важно помнить, т.к. подобная ошибка очень часто встречается у новичков: создают указатель и сразу начинают его использовать, ничем его не инициализировав.

Конструирование, или куда подевался C++

Те кто уже знаком с C++, при чтении предыдущего параграфе вероятно недовольно хмурили брови при виде malloc — «Автор, мы статью по C++ читаем или где?». Так вот, это было сделано специально — на мой взгляд, проще всего понять выделение памяти именно с malloc, а теперь, разобравшись с азами, мы перейдём к тому, как это делать правильно в C++.

Как вы знаете, одним из ключевых отличий C++ от C является наличие классов, который могут иметь конструктор. Т.е это не просто набор данных, как структуры в C, а набор данных плюс поведение. Давайте создадим простенький класс и выделим под него память 3-мя ранее рассмотренными нами способами:

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

class SimpleClass
{
public:
    SimpleClass(int value, const std::string& tag):
        m_Value{value},
        m_Tag{tag}
    {
        cout << "Object allocated on " << m_Tag <<
            " has the value set to: " << m_Value << "\n";
    }

    int value() const
    {
        return m_Value;
    }

    string tag() const
    {
        return m_Tag;
    }
private:
    int m_Value;
    string m_Tag;
};

int main()
{
    static SimpleClass staticObj{5, "static"};
    SimpleClass stackObject{7, "stack"};
    SimpleClass* heapObject = (SimpleClass*)malloc(sizeof(SimpleClass));
}

Если запустить вышеозначенный код, то вывод будет следующим:

Object allocated on static has the value set to: 5
Object allocated on stack has the value set to: 7

Как вы можете видеть, мы имеем строку для статической переменной, строку для стековой переменной и не имеем ничего для нашего динамического объекта. Почему? Для тех, кто внимательно читал статью ответ очевиден — malloc просто выделяет кусок памяти в куче размером N байт. Всё. Больше ничего он не делает, а следовательно конструктор не вызывается и heapObject содержит указатель на «сырую память». Почему сырую? Потому что её нельзя использовать по назначению, в этом куске не создан объект, там просто содержится какой-то мусор и если вы разыменуете heapObject, то получите неопределённое поведение. Вот вам и первое интересное отличие объектов классов от встроенных типов: выделив память под int, её сразу можно использовать, объекты же классов сначала нужно создать(POD-типы  ведут себя схожим образом со встроенными, им не нужно вызывать конструктор перед использованием).

Хорошо, значит нужно вызвать конструктор для heapObject, пробуем:

heapObject->SimpleClass(11, "heap");

Не работает! Такой синтаксис запрещён. Но что же делать? На помощь приходит одно из наименее известных, среди новичков, выражений: размещающий new(placement new). Оно имеет следующий синтаксис: new (<адрес>)<тип>(<аргументы конструктора>).

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

int main()
{
    static SimpleClass staticObj{5, "static"};
    SimpleClass stackObject{7, "stack"};
    SimpleClass* heapObject = (SimpleClass*)malloc(sizeof(SimpleClass));
    new (heapObject)SimpleClass(11, "heap");
}

И вывод становится следующим:

Object allocated on static has the value set to: 5
Object allocated on stack has the value set to: 7
Object allocated on heap has the value set to: 11

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

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

class SimpleClass
{
public:
    SimpleClass(int value, const std::string& tag):
        m_Value{value},
        m_Tag{tag}
    {
        cout << "Object allocated on " << m_Tag <<
            " has the value set to: " << m_Value << "\n";
    }

    ~SimpleClass()
    {
        cout << "Object allocated on " << m_Tag <<
            " is going down.\n";
    }

    int value() const
    {
        return m_Value;
    }

    string tag() const
    {
        return m_Tag;
    }
private:
    int m_Value;
    string m_Tag;
};

class A
{
public:
    int m_Tag;
};

int main()
{
    static SimpleClass staticObj{5, "static"};
    SimpleClass stackObject{7, "stack"};
    SimpleClass* heapObject = (SimpleClass*)malloc(sizeof(SimpleClass));
    new (heapObject)SimpleClass(11, "heap");
    free(heapObject);
}

Вывод:

Object allocated on static has the value set to: 5
Object allocated on stack has the value set to: 7
Object allocated on heap has the value set to: 11
Object allocated on stack is going down.
Object allocated on static is going down.

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

int main()
{
    static SimpleClass staticObj{5, "static"};
    SimpleClass stackObject{7, "stack"};
    SimpleClass* heapObject = (SimpleClass*)malloc(sizeof(SimpleClass));
    new (heapObject)SimpleClass(11, "heap");
    heapObject->~SimpleClass();
    free(heapObject);
}

Вывод:

Object allocated on static has the value set to: 5
Object allocated on stack has the value set to: 7
Object allocated on heap has the value set to: 11
Object allocated on heap is going down.
Object allocated on stack is going down.
Object allocated on static is going down.

Ура, работает! Отлично, хоть здесь нового оператора использовать не нужно,— деструктор можно вызывать явно. Итак, мы уже имеем корректную C++ программу, которая создаёт объект класса в куче и корректно освобождает память. Чего не хватает? Вообще говоря malloc в C++ коде использовать не рекомендуется, т.к. это наследие C, а в C++ для выделения и освобождения памяти существуют специальные операторы, поэтому давайте использовать их:

int main()
{
    static SimpleClass staticObj{5, "static"};
    SimpleClass stackObject{7, "stack"};
    SimpleClass* heapObject = (SimpleClass*)::operator new(sizeof(SimpleClass));
    ::new (heapObject)SimpleClass(11, "heap");
    heapObject->~SimpleClass();
    ::operator delete(heapObject);
}

Главное преимущество operator new над malloc заключается в том, что его можно переопределять для классов, подменяя malloc(который скрывается в недрах operator new) на какой-то свой метод выделения памяти.

Если вам показалось, что 2 строчки на создание объекта и две строчки на его удаление это несколько многовато, то я с вами полностью согласен. Поэтому предлагаю завершить нашу эпопею написания кода динамического выделения памяти под объект класса, следующим, каноническим, кодом на C++:

int main()
{
    static SimpleClass staticObj{5, "static"};
    SimpleClass stackObject{7, "stack"};
    SimpleClass* heapObject = new SimpleClass{11, "heap"};
    delete heapObject;
}

Вот, собственно, и всё — этот наш код абсолютно эквивалентен нашему предыдущему листингу. Хотя, разумеется, компилятор может сгенерировать меньше кода для второго куска кода. Для чего мы рассматривали всё это, а сразу не использовали ключевые слова new/delete? Для того, чтобы вы понимали, что никакой магии за этими ключевыми словами не скрывается, а пр��исходит обычное выделение памяти(тем же malloc) с последующим созданием объекта путём вызова его конструктора.

Итог

Хотя львиная доля данной статьи посвящена динамическому выделению памяти, а кульминацией является код с использованием new/delete, так уж сложилось, что в современном C++ использование явного выделения памяти является признаком плохого кода. Но как же быть, жить без выделения памяти? Разумеется нет — просто используйте «умные» надстройки, которые позволяют не заботиться о том, кто и когда будет освобождать память. Конечно, время жизни того или иного объекта всё равно придётся контролировать и понимать, когда он нужен, а когда нет. Но «умные» средства позволяют забыть о том, что где-то нужно поставить delete. И, практически, позволяют забыть о утечках памяти.

Что это за умные средства? Находясь в рамках данной статьи, в которой выделялась память только под один объект, я упомяну только умные указатели. Использую их, вы значительно повышаете легкость анализа вашей программы и максимально эффективно минимизируете проблемы с утечками памяти. Но умные указатели не являются панацеей и иногда нам нужно и даже полезно использовать «голые указатели» — но использование «голых указателей» не означает, что нужно использовать new/delete; нет, эти ключевые слова, вкупе с C-ными malloc/free должны быть «забанены» в вашем коде, если он не является библиотекой, которая как раз и занимается контролем выделения памяти. На тему использования указателей в современном C++ есть неплохое видео от Страуструпа — посмотрите его, если хотите научится писать более понятные и корректные программы. И не повторяйте за теми, кто говорит, что выделение памяти в C++ это сложное дело, которое требует максимальной концентрации и вовлеченности программиста — больше это не соответствует действительности.

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

 


Другие статьи цикла:

Часть 1. Вводная

Часть 3. Завершающая