Разыменование пустого указателя как исправить

от admin

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

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

NULL

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

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

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

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

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

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

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

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

NULL

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

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

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

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

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

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

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

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

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 .

NULL

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

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

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

NULL

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

NULL

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

Заключение

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

Разыменование nullptr

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

В лучшем случае ваша программа просто крешнется. В худшем — будет вести себя необъяснимым странным образом.

Читать:
Program freemake 1 что за вирус

Вот пример, упрощённый из реального критического бага в ядре Линукса [GNU/Linux, как говорит RMS]. (Украдено отсюда.)

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

Но если оптимизатор реализован по-другому, и выполняет оптимизации в другом порядке, мы получаем следующее:

Что произошло на первом этапе? Для компилятора разыменование nullptr есть undefined behaviour, он имеет право предполагать, что этого не происходит. Значит, видя строчку int dead = *P; , он имеет право предполагать, что P не nullptr . Поэтому проверку на nullptr он может выкинуть как бессмысленную.

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

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

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

Name already in use

ubbook / runtime / nullptr_dereference.md

  • Go to file T
  • Go to line L
  • Copy path
  • Copy permalink
  • Open with Desktop
  • View raw
  • Copy raw contents Copy raw contents

Copy raw contents

Copy raw contents

Разыменование нулевых указателей.

Самая крутая ошибка с самыми жуткими последствиями. null вообще называют ошибкой на миллиард долларов. От них страдает куча кода, на самых разных языках программирования. Но если в условной Java при обращении по null -ссылке вы получите исключение с вполне предсказуемыми последствиями (ну, упало и упало), то в великом и ужасном C++, а также в C за вами придет неопределенное поведение. И оно будет действительно неопределенным!

Но для начала, конечно, надо отметить, что, после всех обсуждений туманных формулировок стандарта, в настоящее время есть некоторое соглашение, что все-таки не сама по себе конструкция *p , где p — нулевой указатель, вызывает неопределенное поведение. А lvalue-to-rvalue преобразование. Ну или менее формально, кратко и не совсем правильно: пока нет чтения или записи значения по этому самому нулевому адресу — все нормально.

Так, сейчас совершенно законно вы можете вызвать статические методы класса через nullptr .

А также можно писать вот такую ерунду

Причем эту ерунду можно писать только в C++. В C это безобразие все-таки запретили (см. 6.5.3.2, сноска 104). И в C применять оператор разыменования к невалидным и нулевым указателями нельзя нигде. А у C++ свой особый путь. И эти странные примеры собираются в constexpr контексте (напоминаю, в нем запрещено UB и компилятор проверяет).

Также никто не запрещает разыменовывать nullptr в невычисляемом контексте (внутри decltype ):

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

Если разыменовать nullptr , может быть исполнен код, который никак не вызывался:

Компилятор обнаруживает разыменование nullptr (вызов функции Do ). Это неопределенное поведение. Такого быть не может. Компилятор находит, что есть одно место, где этому указателю присваивается ненулевое значение. И раз нуля быть не может, то, значит, именно это значение он и использует. Как результат — исполняется код функции, которую мы не вызывали.

Или вот совершенно дурная программа.

Из-за разыменования указателя ptr , проверка на nullptr после разыменования может быть удалена.

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

Такая ситуация уже куда ближе к реальности.

В стандартной библиотеке C, например, есть функции, от которых можно было бы, по неопытности, ожидать проверки на nullptr , но они этого не делают.

strlen , strcmp , другие строковые функции, а в C++ еще конструктор std::string(const char*) — их вызов с nullptr в качестве аргумента ведет к неопределенному поведению (и удалению нижерасположенных проверок, если вам не повезет).

Еще есть особо мерзкие в этом смысле memcpy и memmove . Которые, несмотря на принимаемые в аргументах размеры буферов, все равно приводят к неопределенному поведению, если передать в них nullptr и нулевой размер! И точно также это может проявиться в удалении ваших проверок.

На одних и тех же входных данных (вернее, их отсутствии), этот код завершается с разными результатами в зависимости от компилятора и уровня оптимизаций.

Если вы еще недостаточно напуганы, то вот еще замечательная история о том, как весело и задорно падала функция вида

просто потому что где-то совершенно в не связанном с ней классе написали:

И вызвали функцию так:

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

Разыменовывание нулевого указателя приводит к неопределённому поведению

Silent NULL (Разыменовывание нулевого указателя приводит к неопределённому поведению)
Ненароком я породил большую дискуссию, касающуюся того, допустимо ли использовать в Си/Си++ выражение &P->m_foo, если P является нулевым указателем. Программисты разделились на два лагеря. Одни уверенно доказывали, что так писать нельзя, другие столь же уверенно утверждали, что можно. Приводились различные аргументы и ссылки. И я понял, что нужно внести окончательную ясность в этот вопрос. Для этого я обратился к экспертам Microsoft MVP и разработчикам Visual C++, общающимся через закрытый список рассылки. Они помогли подготовить эту статью, и я представляю её всем желающим. Для нетерпеливых: этот код не корректен.

Напомню историю обсуждений

Все началось со статьи о проверке ядра Linux с помощью анализатора PVS-Studio. Но сама проверка ядра тут ни причём. Дело в том, что в статье я привёл следующий фрагмент из кода Linux:

Я назвал этот код опасным, так как посчитал, что здесь имеет место неопределённое поведение.

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

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

Хотя я и доверчивый, но стараюсь проверять информацию. Я начал разбираться с этой темой и в результате написал небольшую статью: «Размышления над разыменованием нулевого указателя».

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

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

О языке Си

Выражение ‘&podhd->line6’ является неопределенным поведением в языке C в том случае, если ‘podhd’ — нулевой указатель.

Вот что говорится про оператор взятия адреса ‘&’ в стандарте C99 (Раздел 6.5.3.2 «Операторы взятия адреса и разыменовывания»):

Операнд унарного оператора & должен быть либо указателем функции, либо результатом оператора [] или унарного оператора *, либо lvalue-выражением, указывающим на объект, который не является битовым полем и не содержит в объявлении спецификатора регистрового класса памяти.

Выражение ‘podhd->line6’ однозначно не является указателем функции, результатом оператора [] или *. Это как раз lvalue-выражение. Однако, когда указатель ‘podhd’ равен нулю, выражение не указывает на объект, поскольку в Разделе 6.3.2.3 «Указатели» сказано следующее:

Если константа нулевого указателя приводится к типу указателей, то результирующий указатель, называемый нулевым, гарантированно будет не равен указателю на любой объект или функцию.

Если «lvalue-выражение не указывает на объект при своем вычислении, возникает неопределенное поведение» (Стандарт C99, Раздел 6.3.2.1 «Lvalue-выражения, массивы и указатели функций»):

lvalue — это выражение объектного типа или неполного типа, отличного от void; если lvalue-выражение не указывает на объект при своем вычислении, возникает неопределенное поведение.

Когда оператор -> был применен к указателю, его результатом стало lvalue-выражение, для которого не существует объекта, и в результате мы имеем дело с неопределенным поведением.

О языке Си++

В языке С++ всё обстоит точно также. Выражение ‘&podhd->line6’ является неопределенным поведением в языке C++ в том случае, если ‘podhd’ — нулевой указатель.

С толку немного сбивает дискуссия на WG21 (232. Is indirection through a null pointer undefined behavior?), на которую я ссылался в предыдущей статье. Там настаивают, будто бы такое выражение не является неопределенным поведением. Однако никто так и не нашел никаких правил в стандартах C++, которые разрешали бы использовать «poldh->line6», когда «polhd» — нулевой указатель.

Указатель «polhd» нарушает основное ограничение (Раздел 5.2.5/4, второй пункт в списке) о том, что он должен указывать на объект. Ни один объект в C++ не может иметь адреса nullptr.

Итого

Этот код является некорректным в языке Си и Си++, если указатель podhd равен 0. Если указатель равен 0, то возникает неопределённое поведение.

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

Похожие статьи