nullptr это c что это
Урок №81. Нулевые указатели
Обновл. 13 Сен 2021 |
Как и в случае с обычными переменными, указатели не инициализируются при создании. Если значение не было присвоено, то указатель по умолчанию будет указывать на любой адрес, содержимым которого является мусор.
Нулевое значение и нулевые указатели
Помимо адресов памяти, есть еще одно значение, которое указатель может хранить: значение null. Нулевое значение (или «значение null») — это специальное значение, которое означает, что указатель ни на что не указывает. Указатель, содержащий значение null, называется нулевым указателем.
В языке C++ мы можем присвоить указателю нулевое значение, инициализируя его/присваивая ему литерал 0 :
Поскольку значением нулевого указателя является нуль, то это можно использовать внутри условного ветвления для проверки того, является ли указатель нулевым или нет:
Совет: Инициализируйте указатели нулевым значением, если не собираетесь присваивать им другие значения.
Разыменование нулевых указателей
Как мы уже знаем из предыдущего урока, разыменование указателей с мусором приведет к неожиданным результатам. С разыменованием нулевого указателя дела обстоят так же. В большинстве случаев вы получите сбой в программе.
В этом есть смысл, ведь разыменование указателя означает, что нужно «перейти к адресу, на который указывает указатель, и достать из этого адреса значение». Нулевой указатель не имеет адреса, поэтому и такой результат.
Макрос NULL
Однако, поскольку NULL является макросом препроцессора и, технически, не является частью C++, то его не рекомендуется использовать.
Ключевое слово nullptr в C++11
Обратите внимание, значение 0 не является типом указателя, и присваивание указателю значения 0 для обозначения того, что он является нулевым — немного противоречиво, вам не кажется? В редких случаях, использование 0 в качестве аргумента-литерала может привести к проблемам, так как компилятор не сможет определить, используется ли нулевой указатель или целое число 0 :
Для решения этой проблемы в C++11 ввели новое ключевое слово nullptr, которое также является константой r-value.
Начиная с C++11, при работе с нулевыми указателями, использование nullptr является более предпочтительным вариантом, нежели использование 0 :
nullptr также может использоваться для вызова функции (в качестве аргумента-литерала):
Совет: В C++11 используйте nullptr для инициализации нулевых указателей.
Тип данных std::nullptr_t в C++11
Вам, вероятно, никогда это не придется использовать, но знать об этом стоит (на всякий пожарный).
Поделиться в социальных сетях:
Комментариев: 9
с этими указателями….какие-то скороговорки для мозга..
Так и не понял, для чего нужен std::nullptr_t. Зачем его ввели.
Судя по тому, что я узнал за 81 урок и какое мнение сложилось о С++, ответ на Ваш вопрос: «просто так, на всякий пожарный»))))))
ps. 3 допустимых вида инициализации переменных, значит, вас не смущают?)))))
В этих уроках объясняется как база, так и нюансы, которые вы, скорее всего, не очень часто будете использовать на практике, но знать об этом стоит.
Каждая переменная или константа должна быть определенного типа.
Следовательно, у nullptr (как константы, а не ключевого слова) тоже должен быть какой-то тип.
В MSDN есть пример, показывающий зачем ввели дополнительный тип:
Зачем еще оно нужно:
Приходите на собеседование, и дают вам такой код 😉
10.9 – Нулевые указатели
Нулевые значения и нулевые указатели
Как и обычные переменные, указатели не инициализируются при создании экземпляров. Если указателю значение не присвоено, он по умолчанию будет указывать на какой-то мусорный адрес.
Помимо адресов памяти, есть еще одно дополнительное значение, которое может содержать указатель: нулевое значение. Нулевое значение – это специальное значение, которое означает, что указатель ни на что не указывает. Указатель, содержащий нулевое значение, называется нулевым указателем.
В C++ мы можем присвоить указателю нулевое значение, инициализировав или присвоив ему литерал 0:
Лучшая практика
Если при создании вы не присваиваете указателям какое-либо значение, инициализируйте их нулевым значением.
Косвенное обращение через нулевые указатели
В предыдущем уроке мы отметили, что косвенное обращение через мусорный указатель приведет к неопределенным результатам. Косвенное обращение через нулевой указатель также приводит к неопределенному поведению. В большинстве случаев это приведет к сбою вашего приложения.
Концептуально в этом есть смысл. Косвенное обращение через указатель означает «перейти по адресу, на который указывает указатель, и получить доступ к значению там». У нулевого указателя нет адреса. Что делать, когда вы пытаетесь получить доступ к значению по этому адресу?
Макрос NULL
В C++ есть специальный макрос препроцессора под названием NULL (определен в заголовке ). Этот макрос был унаследован от C, где он обычно используется для обозначения нулевого указателя.
Значение NULL определяется реализацией, но обычно определяется как целочисленная константа 0. Примечание. Начиная с C++11, NULL можно определить как nullptr (что мы обсудим позже).
Лучшая практика
Опасности использования 0 (или NULL ) для нулевых указателей
nullptr в C++11
Начиная с C++11, когда нам нужен нулевой указатель, следует отдавать предпочтение ему, а не нулю:
Для продвинутых читателей
Функция со списком других параметров является новой функцией, даже если функция с таким же именем существует. Мы рассмотрели это в уроке «8.9 – Перегрузка функций».
Лучшая практика
Используйте nullptr для инициализации указателей нулевым значением.
std::nullptr_t
Возможно, вам никогда не понадобится это использовать, но на всякий случай знать полезно.
Что именно является nullptr?
Как это ключевое слово и экземпляр типа?
Как это ключевое слово и экземпляр типа?
Новое ключевое слово C++ 09 nullptr обозначает константу rvalue, которая служит универсальным литералом нулевого указателя, заменяя глючный и слабо типизированный литерал 0 и печально известный макрос NULL. Таким образом, nullptr положил конец более чем 30 годам смущения, двусмысленности и ошибок. В следующих разделах представлено средство nullptr и показано, как оно может исправить недуги NULL и 0.
Почему nullptr в C++ 11? Что это? Почему NULL недостаточно?
«. представьте, что у вас есть следующие два объявления функций:
Ну, в других языках есть зарезервированные слова, которые являются экземплярами типов. Python, например:
Кроме того, у вас есть другой пример (помимо Википедии), где nullptr превосходит старый добрый 0?
Да. Это также (упрощенный) реальный пример, который произошел в нашем производственном коде. Он выделялся только потому, что gcc смог выдать предупреждение при кросс-компиляции на платформу с другой шириной регистра (все еще не уверен, почему именно при кросс-компиляции с x86_64 до x86 предупреждает warning: converting to non-pointer type ‘int’ from NULL ):
Рассмотрим этот код (C++ 03):
Это дает такой вывод:
Это ключевое слово, потому что стандарт будет указывать его как таковой. 😉 Согласно последнему публичному проекту (n2914)
2.14.7 Литералы указателя [Lex.nullptr]
Это полезно, поскольку неявно преобразуется в целочисленное значение.
Допустим, у вас есть функция (f), которая перегружена и принимает как int, так и char *. До C++ 11, если вы хотели вызвать его с нулевым указателем и использовали NULL (то есть значение 0), вы бы вызвали перегруженный для int:
Это, вероятно, не то, что вы хотели. C++ 11 решает это с помощью nullptr; Теперь вы можете написать следующее:
nullptr (C++/CLI и C++/CX)
Используйте nullptr с управляемым или машинным кодом. Компилятор выводит соответствующие, но различные инструкции для управляемых и машинных значений пустых указателей. Дополнительные сведения об использовании версии этого ключевого слова в соответствии со стандартом ISO C++ см. в разделе nullptr.
nullptr ключевое слово эквивалентно значению nullptr в Visual Basic и null в C#.
Использование
nullptr Ключевое слово можно использовать в любом месте, где может использоваться маркер, собственный указатель или аргумент функции.
nullptr Ключевое слово не является типом и не поддерживается для использования с:
throw nullptr (однако throw (Object^)nullptr; будет работать)
nullptr Ключевое слово можно использовать при инициализации следующих типов указателей:
дескриптора среды выполнения Windows;
управляемого внутреннего указателя.
nullptr Ключевое слово можно использовать для проверки, имеет ли ссылка указателя или обработчика значение null перед использованием ссылки.
Вызовы функций для языков, использующих значения пустых указателей для проверки ошибок, должны правильно интерпретироваться.
Пример: nullptr ключевое слово
В следующем примере кода показано, что nullptr ключевое слово можно использовать везде, где можно использовать обработчик, собственный указатель или аргумент функции. В примере показано, что nullptr ключевое слово можно использовать для проверки ссылки перед ее использованием.
Пример. Использование nullptr и нулевое взаимозаменяемость
В следующем примере кода показано, что nullptr и ноль могут использоваться в собственных указателях в качестве взаимозаменяемых.
Пример: интерпретировать nullptr как маркер
В следующем примере кода показано, что nullptr интерпретируется как Handle в любой тип или собственный указатель на любой тип. В случае перегрузки функции с дескрипторами различных типов создается ошибка неоднозначности. Необходимо nullptr явно привести к типу.
Пример: CAST nullptr
В следующем примере кода показано, что приведение nullptr разрешено и возвращает указатель или обработчик для типа приведения, содержащего nullptr значение.
Пример. Передача nullptr в качестве параметра функции
В следующем примере кода показано, что nullptr можно использовать в качестве параметра функции.
Пример: инициализация по умолчанию
Пример. Назначение 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.
Как ошибаются в простом 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) < case ENUM_TYPE_B:… >. 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 и она исчезнет, тем не менее в таком виде код тоже компилируется. То есть таким образом тоже можно ошибиться, а как показывает практика, если где-то можно ошибиться — там обязательно ошибутся. Фрагмент кода это только подтверждает. Так что, используя 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++.