Рубріки: Теория

Нулевые указатели (null и nullptr) в C++. Учимся ходить по граблям изящно

Сергій Бондаренко

В этом материале для новичков мы рассуждаем про обнаружение в коде C++ распространенного дефекта «разыменование нулевого указателя», попутно объясняя его скрытую коварность.

Содержание:
1. Разыменование нулевого указателя
2. Нулевое значение и нулевые указатели
3. NULL и nullptr
4. Тип данных nullptr
Заключение

1. Разыменование нулевого указателя

Сегодня рассмотрим причину дефекта в коде С++, который получается, если программа обращается по некорректному указателю к какому-то участку памяти. Такое обращение ведет к неопределенному поведению программы, что приводит в большинстве случаев к аварийному завершению. Данный дефект получил название разыменование нулевого указателя (CWE-476). Мы поговорим о том, что такое NULL и nullptr и для чего они нужны.

По сути, это почти одинаковые вещи, но есть нюансы.

Рассмотрим код.

#include <iostream>
#include <string>

using namespace std;

/*
* Работа с динамической памятью. Нулевые указатели
*/
void main()
{
    int *pa = new int;
    *pa = 10;
    cout << *pa << endl;
    delete pa;
}

Язык С++ не имеет автоматического сборщика мусора, как, например, в Java или C#. Если мы выделяем область под данные, то никто кроме нас не позаботится о том, чтобы область памяти была очищена. Если в памяти находится одно число, это не является проблемой.

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

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

Итак, в нашем коде (пример выше) с помощью оператора new мы выделили место для нашей оперативной памяти. После очистки места, которое мы выделяли под данные в динамической части оперативной памяти, сами данные исчезают. В следствии действия оператора delete данные уничтожаются и система может выделять память, которую мы уже не используем, для любых других своих нужд.

Однако, у нас остается проблема! В нашем указателе *pa все еще сохранен адрес на тот участок памяти, где у нас лежали данные и, в принципе, нам никто не запрещает туда обращаться.

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

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

2. Нулевое значение и нулевые указатели

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

Для этого и существуют NULL и nullptr. Обратите внимание, что если у нас сейчас вызывается оператор delete на нашем указателе (мы очищаем находящуюся по нему память), то оттуда данные теряются.

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

void main()
{
    int *pa = new int;
    *pa = 10;
    cout << *pa << endl;
    delete pa;
            cout << *pa << endl;
}

Но, если мы еще раз возьмем и вызовем оператор delete pa, то все закончится очень плохо — мы увидим на экране сообщение об ошибке. Она говорит о том, что возникла проблема при работе с кучей, то есть с динамической памятью.

void main()
{
    int *pa = new int;
    *pa = 10;
    cout << *pa << endl;
    delete pa;
            cout << *pa << endl;
            delete pa;
}

Для того, чтобы избежать такой проблемы мы можем использовать NULL.

3. NULL и nullptr

В таких языках программирования как Java или C#, NULL является отдельным типом данных и там ситуация несколько иная. В случае С++ мы имеем дело с NULL и nullptr.

nullptr — это более новая разработка, добавленная в С++ 11, и она уже работает аналогично тому как это реализовано в Java или C#. nullptr это отдельный тип данных, с которым компилятор ничего спутать не может. Что же касается NULL, то это просто эквивалент записи 0 (ноль).

Если мы напишем pa = 0, то это значит, что наш указатель pa теперь не будет хранить адрес в памяти, а будет нулевым указателем, указывать на ноль. Вместо pa = 0, мы можем записать pa = NULL — эти записи абсолютно равнозначны. Все дело в том, что NULL — это просто макрос.

Если мы наведем на него мышку, поставим курсор и нажимаем f12, то увидим #define NULL 0.

Строка pa = NULL говорит указателю, который до момента выполнения данной строки (где мы уже почистили память) указывает на определенный адрес оперативной памяти, чтобы он этот адрес забыл, чтобы мы к этому адресу впоследствии случайно не обратились. После того как мы присвоили NULL, у нас одни нули, т.е. нулевой указатель.

Если после такой операции мы попробуем еще раз сделать delete pa, то у нас все пройдет без проблем. Оператор delete посмотрит на то, что указатель указывает на NULL и не будет пытаться там что-то очистить, поэтому ошибку не получим. Теперь также мы явно можем проверять наш указатель на NULL, то есть на то, содержит ли он какой-то адрес или нет.

Если сейчас попробовать обратиться через cout, то в консоль будет выведен наш адрес — одни нули.

Добавим проверку if pa != 0 или if pa != NULL с возможностью выводить наш адрес указателя. В данном случае адрес не вывелся, поскольку указатель указывает на NULL. А раз он указывает на NULL, то он в принципе ничего не может хранить.

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

void main()
{
    int *pa = new int;
    *pa = 10;
    cout << *pa << endl;
    delete pa;
    pa = NULL;
    if (pa != NULL)
    {
        cout << *pa << endl;
    }
    delete pa;
}

Если мы уберем запись pa = NULL, то не сможем знать, куда указывает указатель, мы не можем перебрать все возможные адреса и знать что там лежит. Поэтому мы получим вывод нашего адреса и ошибку.

4. Тип данных nullptr

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

void main()
{
    int *pa = new int;
    *pa = 10;
    cout << *pa << endl;
    delete pa;
    pa = nullptr;
    if (pa != nullptr)
    {
        cout << *pa << endl;
    }
    delete pa;
}

Это уже не просто макрос и не просто нолик, а целочисленный тип Int. Это уже — отдельный тип данных. Мы его присваиваем и, на первый взгляд, разницы никакой нет. Однако для компилятора разница есть, он никогда не перепутает указатель nullptr с целочисленным типом данных.

К примеру, если у вас будет какая-то функция, она будет перегружена для типа Int и для указателя. И вы захотите передать в вашу функцию указатель с целочисленным нулем pa = 0:

void main()
{
    int *pa = new int;
    *pa = 10;
    cout << *pa << endl;
    delete pa;
    pa = 0;
    if (pa != 0)
    {
        cout << *pa << endl;
    }
    delete pa;
}

Ваша функция принимает либо указательную Int, либо просто целочисленный тип Int. В таком случае у компилятора могут быть проблемы какую функцию вызвать — с реализацией получается неоднозначность.

В С++ 11 nullptr это отдельный тип данных и компилятор никогда не перепутает его с обычным int. Поэтому в случаях когда вы будете работать с указателями, рекомендуется использовать именно его.

Если вы встретите где-то старый код, вы можете увидеть запись с присвоением нуля pa = NULL; (pa = 0;). Теперь вы будете знать, что это такое и какие могут быть проблемы. Справедливости ради нужно сказать, что на самом деле проблемы возникают редко, но чтобы исключить их вообще, лучше использовать nullptr. Это хоть и редкий тип проблем, но очень коварный и трудно вычислимый.

Также стоит обратить внимание на еще один тип ошибок. Если вам нужно очистить динамическую память, в которой выделено место под ваши данные, то обязательно сначала нужно вызвать delete, ну, а затем, если нужно затереть адрес — присваивать нашему указателю nullptr.

void main()
{
    int *pa = new int;
    *pa = 10;
    cout << *pa << endl;
    delete pa;
    pa = nullptr;
    if (pa != nullptr)
    {
        cout << *pa << endl;
    }
    delete pa;
}

Если вы сделаете наоборот, то есть сначала присвоите указателю nullptr, а затем присвоите указателю delete, то такое ваше действие приведет к утечке памяти.

void main()

{
    int *pa = new int;
    *pa = 10;
    cout << *pa << endl;
    
    pa = nullptr;
            delete pa;

    if (pa != nullptr)
    {
        cout << *pa << endl;
    }
    delete pa;
}

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

Если мы вызываем сначала delete, а затем присваиваем NULL, то сначала убиваются данные, а затем теряется и адрес, который хранил указатель. Однако, если вы сначала используете nullptr, тогда вы просто убираете адрес, но данные никуда не деваются, они так и остаются висеть в оперативной памяти.

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

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

Заключение

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

а также про работу с динамической памятью при работе с массивами

Останні статті

Обучение Power BI – какие онлайн курсы аналитики выбрать

Сегодня мы поговорим о том, как выбрать лучшие курсы Power BI в Украине, особенно для…

13.01.2024

Work.ua назвал самые конкурентные вакансии в IТ за 2023 год

В 2023 году во всех крупнейших регионах конкуренция за вакансию выросла на 5–12%. Не исключением…

08.12.2023

Украинская IT-рекрутерка создала бесплатный трекер поиска работы

Unicorn Hunter/Talent Manager Лина Калиш создала бесплатный трекер поиска работы в Notion, систематизирующий все этапы…

07.12.2023

Mate academy отправит работников в 10-дневный оплачиваемый отпуск

Edtech-стартап Mate academy принял решение отправить своих работников в десятидневный отпуск – с 25 декабря…

07.12.2023

Переписки, фото, история браузера: киевский программист зарабатывал на шпионаже

Служба безопасности Украины задержала в Киеве 46-летнего программиста, который за деньги устанавливал шпионские программы и…

07.12.2023

Как вырасти до сеньйора? Девелопер создал популярную подборку на Github

IT-специалист Джордан Катлер создал и выложил на Github подборку разнообразных ресурсов, которые помогут достичь уровня…

07.12.2023