Чем отличается null от nullptr

Как избежать ошибок, используя современный C++

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

Что такое Modern C++?

Термин Modern C++ стал очень популярен после выхода С++11. Что он означает? В первую очередь, Modern C++ — это набор паттернов и идиом, которые призваны устранить недостатки старого доброго «C с классами», к которому привыкли многие C++ программисты, особенно если они начинали программировать на C. Код на C++11 во многих случаях выглядит более лаконично и понятно, что очень важно.

Что обычно вспоминают, когда говорят о Modern C++? Параллельность, compile-time вычисления, RAII, лямбды, диапазоны (ranges), концепты, модули и другие не менее важные компоненты стандартной библиотеки (например, API для работы с файловой системой). Это очень крутые нововведения, и мы их ждём в следующих стандартах. Вместе с тем, хочется обратить внимание, как новые стандарты позволяют писать более безопасный код. При разработке статического анализатора кода мы встречаемся с большим количеством разных типов ошибок и порой возникает мысль: «А вот в современном C++ можно было бы этого избежать». Поэтому предлагаю рассмотреть серию ошибок, найденных нами с помощью PVS-Studio в различных Open Source проектах. Заодно и посмотрим, как их лучше поправить.

Автоматическое выведение типа

В C++11 были добавлены ключевые слова auto и decltype. Вы конечно же знаете, как они работают:

С помощью auto можно очень удобно сокращать длинные типы, при этом не теряя в читаемости кода. Однако по-настоящему эти ключевые слова раскрываются в сочетании с шаблонами: c auto или decltype не нужно явно указывать тип возвращаемого значения.

Но вернёмся к нашей теме. Вот пример 64-битной ошибки:

В 64-битном приложении значение string::npos больше, чем максимальное значение UINT_MAX, которое вмещает переменная типа unsigned. Казалось бы это тот самый случай, где auto может нас спасти от подобного рода проблем: нам не важен тип переменной n, главное, чтобы он вмещал все возможные значения string::find. И действительно, если мы перепишем этот пример с auto, то ошибка пропадёт:

Но здесь не всё так просто. Использование auto не панацея и существует множество ошибок, связанных с ним. Например, можно написать такой код:

auto не спасёт от переполнения и памяти под буфер будет выделено меньше 5GiB.

В распространённой ошибке с неправильно записанным циклом, auto нам также не помощник. Рассмотрим пример:

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

Можно ли этот фрагмент переписать через auto?

Нет, ошибка никуда не делась. Стало даже хуже.

С простыми типами auto ведёт себя из рук вон плохо. Да, в наиболее простых случаях (auto x = y) оно работает, но как только появляются дополнительные конструкции, поведение может стать более непредсказуемым. И что самое худшее, ошибку будет труднее заметить, так как типы переменных будут неочевидны на первый взгляд. К счастью для статических анализаторов посчитать тип проблемой не является: они не устают и не теряют внимания. Но простым смертным лучше всё же указывать простые типы явно. К счастью, от сужающего приведения можно избавиться и другими способами, но о них чуть позже.

Опасный countof

Одним из «опасных» типов в C++ является массив. Нередко при передаче его в функцию забывают, что он передаётся как указатель, и пытаются посчитать количество элементов через sizeof:

Примечание. Код взят из Source Engine SDK.

Предупреждение PVS-Studio: V511 The sizeof() operator returns size of the pointer, and not of the array, in ‘sizeof (iNeighbors)’ expression. Vrad_dll disp_vrad.cpp 60

Такая путаница может возникнуть из-за указания размера массива в аргументе: это число ничего не значит для компилятора и является просто подсказкой программисту.

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

В случае, когда мы передаём в эту функцию не массив, мы получаем ошибку компиляции. В C++17 можно использовать std::size.

В C++11 добавили функцию std::extent, но она в качестве countof не подходит, так как возвращает 0 для неподходящих типов.

Ошибиться можно не только с countof, но и с sizeof.

Примечание. Код взят из Chromium.

    The sizeof() operator returns size of the pointer, and not of the array, in ‘sizeof (salt)’ expression. browser visitedlink_master.cc 968 A call of the ‘memcpy’ function will lead to underflow of the buffer ‘salt_’. browser visitedlink_master.cc 968

Как ошибаются в простом for

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

Посмотрим на фрагменты из проектов:

Примечание. Код взят из Haiku Operation System.

Предупреждение PVS-Studio: V706 Suspicious division: sizeof (kBaudrates) / sizeof (char *). Size of every element in ‘kBaudrates’ array does not equal to divisor. SerialWindow.cpp 162

Такие ошибки мы подробно рассмотрели в предыдущем пункте: опять неправильно посчитали размер массива. Можно легко исправить положение использованием std::size:

Но есть способ получше. А пока посмотрим на ещё один фрагмент.

Примечание. Код взят из Shareaza.

Предупреждение PVS-Studio: V547 Expression ‘nCharPos >= 0’ is always true. Unsigned type value is always >= 0. BugTrap xmlreader.h 946

Типичная ошибка при написании обратного цикла: забыли, что итератор беззнакового типа и проверка возвращает true всегда. Возможно, вы подумали: «Как же так? Так ошибаются только новички и студенты. У нас, профессионалов, таких ошибок не бывает». К сожалению, это не совсем верно. Конечно, все понимают, что (unsigned >= 0)true. Откуда тогда подобные ошибки? Часто они возникают в результате рефакторинга. Представим такую ситуацию: проект переходит с 32-битной платформы на 64-битную. Раньше для индексации использовались int/ unsigned, и было решено заменить их на size_t/ptrdiff_t. И вот в одном месте проглядели и использовали беззнаковый тип вместо знакового.

Что же делать, чтобы избежать такой ситуации в своём коде? Некоторые советуют использовать знаковые типы, как в C# или Qt. Может это и неплохой способ, но если мы хотим работать с большими объёмами данных, то использования size_t не избежать. Есть ли какой-то более безопасный способ обойти массив в C++? Конечно есть. Начнём с самого простого: non-member функций. Для работы с коллекциями, массивами и initializer_list есть унифицированные функции, принцип работы которых вам должен быть хорошо знаком:

Прекрасно, теперь нам не нужно помнить о разнице между прямым и обратным циклом. Не нужно и думать о том, используем мы простой массив или array — цикл будет работать в любом случае. Использование итераторов — хороший способ избавиться от головной боли, но даже он недостаточно хорош. Лучше всего использовать диапазонный for:

Конечно, в диапазонном for есть свои недостатки: он не настолько гибко позволяет управлять ходом цикла и если требуется более сложная работа с индексами, то этот for нам не поможет. Но такие ситуации стоит рассматривать отдельно. У нас ситуация достаточно простая: необходимо пройтись по элементам массива в обратном порядке. Однако уже на этом этапе возникают трудности. В стандартной библиотеке нет никаких вспомогательных классов для range-based for. Посмотрим, как его можно было бы реализовать:

В C++14 можно упростить код, убрав decltype. Можно увидеть, как auto помогает писать шаблонные функции — reversed_wrapper будет работать и с массивом, и с std::vector.

Теперь можно переписать фрагмент следующим образом:

Чем хорош этот код? Во-первых, он очень легко читается. Мы сразу видим, что здесь массив элементов обходится в обратном порядке. Во-вторых, ошибиться намного сложнее. И в-третьих, он работает с любым типом. Это значительно лучше, чем то, что было.

В boost можно использовать boost::adaptors::reverse(arr).

Но вернёмся к исходному примеру. Там массив передаётся парой указатель-размер. Очевидно, что наше решение с reversed для него работать не будет. Что же делать? Использовать классы, наподобие span/array_view. В C++17 есть string_view, предлагаю им и воспользоваться:

string_view не владеет строкой, по сути это обёртка над const char* и длиной. Поэтому в примере кода, строка передаётся по значению, а не по ссылке. Ключевой особенностью string_view является совместимость с разными способами представления строк: const char*, std::string и не нуль-терминированный const char*.

В итоге функция принимает такой вид:

При передаче в функцию важно не забыть про то, что конструктор string_view(const char*) неявный, поэтому можно написать так:

Строка, на которую указывает string_view не обязана быть нуль-терминированной, на что намекает название метода string_view::data, и это нужно иметь в виду при её использовании. При передаче её значения в какую-нибудь функцию из cstdlib, которая ожидает C строку, можно получить undefined behavior. И это можно легко пропустить, если в большинстве случаев, которые вы тестируете, используются std::string или нуль-терминированные строки.

Отвлечёмся от C++ и вспомним старый добрый C. Как там с безопасностью? Ведь в нём нет проблем с неявными вызовами конструкторов и операторов преобразования и нет проблем с разными видами строк. На практике, ошибки часто встречаются в самых простых конструкциях: самые сложные уже тщательно просмотрены и отлажены, так как вызывают подозрения. В то же время простые конструкции часто забывают проверить. Вот пример опасной конструкции, которая пришла к нам ещё из C:

Пример из ядра Linux. Предупреждение PVS-Studio: V556 The values of different enum types are compared: switch(ENUM_TYPE_A) . libiscsi.c 3501

Обратите внимание на значения в switch-case: одна из именованных констант взята из другого перечисления. В оригинале, естественно, кода и возможных значений значительно больше и ошибка не является столь же наглядной. Причиной тому нестрогая типизация enum — они могут неявно приводиться к int, и это даёт отличный простор для различных ошибок.

В C++11 можно и нужно использовать enum class: с ними такой трюк не пройдёт, и ошибка проявится во время компиляции. В итоге приведённый ниже код не компилируется, что нам и нужно:

Следующий фрагмент не совсем связан с enum, но имеет схожую симптоматику:

Примечание. Код взят из ReactOS.

Да, значения errno объявлены макросами, что само по себе плохая практика в C++ (да и в C тоже), но даже если бы использовали enum, легче бы от этого не стало. Потерянное сравнение никак не проявится в случае enum (и тем более макроса). А вот enum class такого бы не позволил, так как неявного приведения к bool не произойдёт.

Инициализация в конструкторе

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

Примечание. Код взят из LibreOffice.

Предупреждение PVS-Studio: V603 The object was created but it is not being used. If you wish to call constructor, ‘this->Guess::Guess(. )’ should be used. guess.cxx 56

А подвох в синтаксисе вызова конструктора. Часто о нём забывают и создают ещё один экземпляр класса, который сразу же будет уничтожен. То есть инициализация исходного экземпляра не происходит. Естественно есть 1000 и 1 способ это исправить. Например, можно явно вызвать конструктор через this или вынести всё в отдельную функцию:

Кстати, явный повторный вызов конструктора, например, через this это опасная игра и надо хорошо понимать, что происходит. Намного лучше и понятней вариант с функцией Init(). Для тех, кто хочет более подробно разобраться с подвохами, предлагаю познакомиться с 19 главой «Как правильно вызвать один конструктор из другого» из этой книги.

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

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

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

О виртуальных функциях

Виртуальные функции таят в себе потенциальную проблему: дело в том, что очень легко в унаследованном классе ошибиться в сигнатуре и в итоге не переопределить функцию, а объявить новую. Рассмотрим эту ситуацию на примере:

Метод Derived::Foo нельзя будет вызвать по указателю/ссылке на Base. Но этот пример простой и можно сказать, что так никто не ошибается. А ошибаются обычно так:

Примечание. Код взят из MongoDB.

Предупреждение PVS-Studio: V762 Consider inspecting virtual function arguments. See seventh argument of function ‘query’ in derived class ‘DBDirectClient’ and base class ‘DBClientBase’. dbdirectclient.cpp 61

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

В следующем фрагменте ситуация хитрее. Такой код будет работать, если его скомпилировать как 32-битный, но не будет работать в 64-битном варианте. Изначально в базовом классе параметр был типа DWORD, но потом его исправили на DWORD_PTR. А в унаследованных классах не поменяли. Да здравствует бессонная ночь, отладка и кофе!

Ошибиться в сигнатуре можно и более экстравагантными способами. Можно забыть const у функции или аргумента. Можно забыть, что функция в базовом классе не виртуальная. Можно перепутать знаковый/беззнаковый тип.

В C++11 добавили несколько ключевых слов, которые могут регулировать переопределение виртуальных функций. Нам поможет override. Такой код просто не скомпилируется.

NULL vs nullptr

Использование NULL для обозначения нулевого указателя приводит к ряду неожиданных ситуаций. Дело в том, что NULL — это обычный макрос, который раскрывается в 0, имеющий тип int. Отсюда несложно понять, почему в этом примере выбирается вторая функция:

Но хоть это и понятно, это точно не логично. Поэтому и появляется потребность в nullptr, который имеет свой собственный тип nullptr_t. Поэтому использовать NULL (и тем более 0) в современном C++ категорически нельзя.

Другой пример: NULL можно использовать для сравнения с другими целочисленными типами. Представим, что есть некая WinAPI функция, которая возвращает HRESULT. Этот тип никак не связан с указателем, поэтому и сравнение его с NULL не имеет смысла. И nullptr это подчёркивает ошибкой компиляции, в то время как NULL работает:

va_arg

Встречаются ситуации, когда в функцию необходимо передать неопределённое количество аргументов. Типичный пример — функция форматированного ввода/вывода. Да, её можно спроектировать так, что переменное количество аргументов не понадобится, но не вижу смысла отказываться от такого синтаксиса, так как он намного удобнее и нагляднее. Что нам предлагают старые стандарты C++? Они предлагают использовать va_list. Какие при этом могут возникнуть проблемы? В такую функцию очень легко передать аргумент не того типа. Или не передать аргумент. Посмотрим подробнее на фрагменты.

Примечание. Код взят из Chromium.

Предупреждение PVS-Studio: V510 The ‘AtlTrace’ function is not expected to receive class-type variable as third actual argument. delegate_execute.cc 96

Тут хотели вывести на печать строку std::wstring, но забыли позвать метод c_str(). То есть тип wstring будет интерпретирован в функции как const wchar_t*. Естественно, ничего хорошего из этого не выйдет.

Примечание. Код взят из Cairo.

Предупреждение PVS-Studio: V576 Incorrect format. Consider checking the third actual argument of the ‘fwprintf’ function. The pointer to string of wchar_t type symbols is expected. cairo-win32-surface.c 130

В этом фрагменте перепутали спецификаторы формата для строк. Дело в том, что в Visual C++ для wprintf %s ожидает wchar_t*, а %S — char*. Примечательно, что эти ошибки находятся в строках, предназначенных для вывода ошибок или отладочной информации — наверняка это редкие ситуации, поэтому их и пропустили.

Примечание. Код взят из CryEngine 3 SDK.

Предупреждение PVS-Studio: V576 Incorrect format. Consider checking the fourth actual argument of the ‘sprintf’ function. The SIGNED integer type argument is expected. igame.h 66

Не менее легко перепутать и целочисленные типы. Особенно, когда их размер зависит от платформы. Здесь, впрочем, всё банальнее: перепутали знаковый и беззнаковый типы. Большие числа будут распечатаны как отрицательные.

Примечание. Код взят из Word for Windows 1.1a.

Предупреждение PVS-Studio: V576 Incorrect format. A different number of actual arguments is expected while calling ‘printf’ function. Expected: 3. Present: 1. dini.c 498

Пример, найденный в рамках одного из археологических исследований. Строка подразумевает наличие трёх аргументов, но их нет. Может так хотели распечатать данные, лежащие на стеке, но делать таких предположений о том, что там лежит, всё же не стоит. Однозначно надо передать аргументы явно.

Примечание. Код взят из ReactOS.

Предупреждение PVS-Studio: V576 Incorrect format. Consider checking the third actual argument of the ‘swprintf’ function. To print the value of pointer the ‘%p’ should be used. dialogs.cpp 66

Пример 64-битной ошибки. Размер указателя зависит от архитектуры и использовать для него %u — плохая идея. Что для использовать вместо него? Сам анализатор подсказывает нам правильный ответ — %p. Хорошо, если указатель просто распечатывают для отладки. Гораздо интереснее будет, если его потом попытаются из буфера прочитать и использовать.

Чем же плохи функции с переменным количеством аргументов? Практически всем! В них нельзя проверить ни тип аргумента, ни количество аргументов. Шаг влево, шаг вправо — undefined behavior.

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

Естественно это всего лишь пример: на практике его использовать бессмысленно. Но с variadic templates вас в реализации ограничивает лишь полёт фантазии, а не средства языка.

Ещё одна конструкция, которую можно рассмотреть, как вариант передачи переменного количества аргументов, — то std::initializer_list. Он не позволяет передать аргументы разных типов. Но если этого достаточно, то можно использовать его:

При этом обходить его очень удобно, так как можно использовать всё те же begin, end и диапазонный for.

Narrowing

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

Но отвлечёмся от 64-битных ошибок. Вот более простой пример: есть два целочисленных значения и хотят найти их отношение. Делают это вот так:

Примечание. Код взят из Source Engine SDK.

Предупреждение PVS-Studio: V636 The expression was implicitly cast from ‘int’ type to ‘float’ type. Consider utilizing an explicit type cast to avoid the loss of a fractional part. An example: double A = (double)(X) / Y;. Client (HL2) detailobjectsystem.cpp 1480

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

No news is good news

Возможностей ошибиться в управлении памятью и ресурсами великое множество. Удобство при работе с ними — важное требование к современному языку. Современный C++ тут не отстаёт и предлагает целый ряд средств для автоматического контроля ресурсами. И хотя такие ошибки — это скорее вотчина динамического анализа, некоторые проблемы может выявить и статический анализ. Посмотрим на некоторые из них:

Примечание. Код взят из Chromium.

Предупреждение PVS-Studio: V554 Incorrect use of auto_ptr. The memory allocated with ‘new []’ will be cleaned using ‘delete’. interactive_ui_tests accessibility_win_browsertest.cc 171

Естественно, идея умных указателей не нова: например, был такой класс std::auto_ptr. В прошедшем времени я о нём говорю, потому что он объявлен deprecated в C++11, а в C++17 удалён. В этом фрагменте ошибка появилась из-за того, что auto_ptr неправильно использовали: у класса нет специализации для массивов, и будет вызван стандартный delete, а не delete[]. На замену auto_ptr пришёл unique_ptr, у которого есть и специализация для массивов, и возможность передать функтор deleter, который будет вызван вместо delete, и полноценная поддержка перемещающей семантики. Казалось, что здесь может быть не так?

Примечание. Код взят из nana.

Предупреждение PVS-Studio: V554 Incorrect use of unique_ptr. The memory allocated with ‘new []’ will be cleaned using ‘delete’. text_editor.cpp 3137

Оказывается, что можно допустить точно такую же ошибку. Да, достаточно написать unique_ptr<unsigned[]> и она исчезнет, тем не менее в таком виде код тоже компилируется. То есть таким образом тоже можно ошибиться, а как показывает практика, если где-то можно ошибиться — там обязательно ошибутся. Фрагмент кода это только подтверждает. Так что, используя unique_ptr с массивами, будьте предельно осторожны: выстрелить себе в ногу проще, чем кажется. Может быть тогда лучше использовать std::vector по заветам Modern C++?

Рассмотрим ещё одну разновидность несчастных случаев.

Примечание. Код взят из Unreal Engine 4.

Предупреждение PVS-Studio: V611 The memory was allocated using ‘new T[]’ operator but was released using the ‘delete’ operator. Consider inspecting this code. It’s probably better to use ‘delete [] Code;’. openglshaders.cpp 1790

Ту же ошибку легко допустить и без умных указателей: память, выделенную при помощи new[], освобождают через free.

Примечание. Код взят из CxImage.

Предупреждение PVS-Studio: V611 The memory was allocated using ‘new’ operator but was released using the ‘free’ function. Consider inspecting operation logics behind the ‘ptmp’ variable. ximalyr.cpp 50

А в этом фрагменте перепутали malloc/free и new/delete. Такое может случиться при рефакторинге: были везде функции из C, решили поменять, получили UB.

Примечание. Код взят из Fennec Media.

Предупреждение PVS-Studio: V575 The null pointer is passed into ‘free’ function. Inspect the first argument. settings interface.c 3096

А это уже более занятный пример. Существует практика, в который указатель обнуляют после освобождения. Иногда даже специальные макросы для этого пишут. Замечательная практика с одной стороны: так можно обезопасить себя от повторного освобождения памяти. Но тут напутали порядок выражений и в free приходит уже нулевой указатель (что и замечает статический анализатор).

Но проблема относится не только к управлению памятью, но и к управлению ресурсами. Можно, например, забыть закрыть файл, как во фрагменте выше. И ключевое слово в обоих случаях — RAII. Эта же концепция стоит и за умными указателями. В сочетании с move-semantics RAII позволяет избавиться от многих ошибок, связанных с утечками памяти. Да и код, написанный в таком стиле, позволяет более наглядно определить владение ресурсом.

В качестве небольшого примера приведу обёртку над FILE, использующую возможности unique_ptr:

Но для работы с файлами скорее всего захочется иметь более функциональную обёртку (да и с более понятным синтаксисом). Самое время вспомнить, что в C++17 добавят API для работы с файловыми системами — std::filesystem. Но если это решение вас не устраивает и вам хочется использовать fread/fwrite вместо i/o-потоков, то можно вдохновиться unique_ptr и написать свой File, оптимизированный под свои нужды и вместе с тем удобный, читаемый и безопасный.

Что же в итоге?

Современный C++ привнёс много средств, которую помогут писать код более безопасно. Появилось много конструкций для compile-time вычислений и проверок. Можно перейти на более удобную модель управления памятью и ресурсами.

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

К слову об инструментах. Предлагаю попробовать PVS-Studio: недавно мы начали разрабатывать версию под Linux и вы её можете попробовать в деле: она поддерживает любую сборочную систему и позволяет легко проверить проект, просто собрав его. А для Windows-разработчиков у нас есть удобный плагин для Visual Studio, который вы можете попробовать в trial-версии.

Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: Pavel Belikov. How to avoid bugs using modern C++.

NULL vs nullptr (Why was it replaced?) [duplicate]

I know that in C++ 0x or NULL was replaced by nullptr in pointer-based applications. I’m just curious of the exact reason why they made this replacement?

In what scenario is using nullptr over NULL beneficial when dealing with pointers?

4 Answers 4

nullptr has type std::nullptr_t . It’s implicitly convertible to any pointer type. Thus, it’ll match std::nullptr_t or pointer types in overload resolution, but not other types such as int .

0 (aka. C’s NULL bridged over into C++) could cause ambiguity in overloaded function resolution, among other things:

(Thanks to Caleth pointing this out in the comments.)

You can find a good explanation of why it was replaced by reading A name for the null pointer: nullptr, to quote the paper:

  • Improve support for library building, by providing a way for users to write less ambiguous code, so that over time library writers will not need to worry about overloading on integral and pointer types.

  • Improve support for generic programming, by making it easier to express both integer 0 and nullptr unambiguously.

  • Make C++ easier to teach and learn.

In C++, the definition of NULL is 0, so there is only an aesthetic difference. I prefer to avoid macros, so I use 0. Another problem with NULL is that people sometimes mistakenly believe that it is different from 0 and/or not an integer. In pre-standard code, NULL was/is sometimes defined to something unsuitable and therefore had/has to be avoided. That’s less common these days.

If you have to name the null pointer, call it nullptr; that’s what it’s called in C++11. Then, «nullptr» will be a keyword.

c++ Null vs nullptr

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

Но этого что-то мало и вроде я такое раз в год вызываю

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

Так из-за этого и спрашиваю, где это используется?

у меня твой пример не компилируется.

MSVC 2013 собирал, там норм прошло

NULL это просто 0, ещё и определённый макросом, т.е. компилятор получит просто 0 в этом куске кода, некоторые конструкции аля auto не поймут что именно ты хотел использовать число или указатель

Effective Modern C++ Майерса.
Item 8: Prefer nullptr to 0 and NULL.

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

nullptr нельзя по ошибке присвоить переменной, не являющейся указателем, с NULL такое скомпилируется и может потом привести к многочасовой отладке

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

Если же тебе неймётся, все ответы есть на cppreference. NULL — это implementation-defined макрос который может в зависимости от реализации раскрываться в 0 или nullptr, поэтому вопрос сводится к тому почему нельзя использовать 0 (или, что эквивалентно, макрос который может в него раскрыться). Потому что это нарушает типобезопасность — 0 можно передать в функцию принимающую int, или сравнить с int’ом. Если ты сравниваешь NULL с int’ом, или передаёшь в качестве int’а — это явная ошибка, но в этом случае компилятор её не отловит. У nullptr таких проблем нет. Кроме того, он может участвовать в перегрузках (https://en.cppreference.com/w/cpp/types/nullptr_t).

сравниваю NULL с int

Дальше не читал

Я прочитал пример https://en.cppreference.com/w/cpp/types/nullptr_t т.е. если я все правильно понял, nullptr помогает явно указать, что должна вызываться ф-ия принимающая указатель и если у программиста функция, принимающая foo(int), а он где-то в дебрях кода передает ей нулевой указатель путем foo(NULL), то возможно он ошибся и nullptr от таких косяков уберегает, т.е. если даже программист забудет, чего принимает ф-ия, компилятор его поправит при nullptr, чего не будет при NULL?

да я пошутил, а то иногда напишут вместо пояснения «НАРУШАЕТ ПРИНЦИПЫ ООП» и гадай, что оно там нарушает

История примерно такая:
1) В С++ запретили неявное приведение void* к любому другому указателю, что в С разрешено. Внезапно, отломался код

Что, в свою очередь, начало весело стрелять при перегрузках и в шаблонах.
3) Компиляторы от такой весёлой жизни начали заводить свои нестандартные «нулевые указатели», чтобы с этим адом хоть как-то бороться. Например __null в gcc.
4) Пункт 3) узаконили в стандарте как nullptr

Всё это время сишники смотрят на плюсовиков как на стадо дебилов, пишут свой родной NULL и в ус не дуют.

И что вы хотели сказать этим примером? К NULL vs. nullptr он вообще никакого отношения не имеет.

В чистом Си у тебя ведь перегрузки функций нет? Вот там это в ногу не выстрелит. А в костылях выстрелит и будет неприятно.

Нулевые указатели (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++. В заключение рекомендуем посмотреть видео, в котором рассказывается про указатели в С++

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *