Несколько лет назад среди C++ блоггеров завирусилась первоапрельская шутка о том, что C++ задепрекейтил указатели (например, Fluent C++ — в C++ больше не будет указателей). Что ж, поскольку C++ почти никогда ничего не депрекейтит, это была полная умора. Однако действительно ли нам до сих пор так необходимы указатели? Именно этот вопрос я и хочу осветить в сегодняшней статье.
О чем говорят указатели
В C++ указатель сигнализирует о том, что параметр может не иметь значения. Всякий раз, когда функция получает указатель, мы должны реализовать в ее теле проверку, является ли параметр nullptr
. К сожалению, я постоянно вижу код, в котором этой проверкой пренебрегают. Никакая документация и комментарии типа “valid non-null object is required” не избавляют нас от необходимости совершать эту проверку.
Я также видел случаи, когда проверка на nullptr
в функции была опущена попросту потому что было сложно решить, что делать в случае nullptr
. Например, когда nullptr
получает функция, которая возвращает void
.
Но самое главное, что с этой проверкой мы неминуемо получаем дополнительные накладные расходы. Компилятор, по крайней насколько мне известно, не способен оптимизировать такую проверку даже в небольшой программе. Мы поговорим об этом подробнее чуть позже.
Используйте ссылки вместо указателей
Как только мы переходим на ссылки, необходимость проверки и бесконечные комментарии канут в Лету. В отличие от указателя, ссылка говорит нам, что на данном этапе требуется валидный объект.
Самый простой подход — по-прежнему получать указатели на границах API, если, например, у нас нет возможности изменить API. Но затем, в теле функции первым делом выполнять проверку на nullptr
и делать return
, если указатель null
. Если он валидный, разыменовывать указатель и сохранять в ссылке.
bool DoSomeStuff(int* data)
{
if(nullptr == data) { return false; }
int& refData = *data;
return HandleData(refData);
}
Таким образом, у нас хотя бы есть возможность сделать наш код и внутреннее API чуть-чуть опрятнее. Возможно, в следующем релизе мы сможем наводить порядок и в общедоступном API.
Постойте, мне нужен опциональный параметр
Хорошо, допустим, мы изменим все указатели на ссылки. Но что, если нам нужен параметр, который можно не предоставлять (maybe parameter)? Ничего не приходит на ум? Именно, опциональность! В этом случае C++17 предоставляет нам std::optional
. Поэтому, пожалуйста, перестаньте злоупотреблять указателями, когда хотите указать, что параметр является необязательным. Не нужно преобразовывать int
в int
* только для того, чтобы для сравнения было доступно значение nullptr
.
bool DoSomeStuff(std::optional<int> data)
{
if(data.has_value()) { return HandleData(data.value()); }
return false;
}
Тип данных std::optional
подходит для этих целей гораздо лучше, чем указатели. Такие функции, как get_value_or
, избавляют нас от необходимости добавления if
, который будет устанавливать либо переданное значение, либо значение по умолчанию.
Хорошо, а что на счет массивов? Допустим, мы хотим передать в функцию массив — здесь мы не можем использовать ссылки, за исключением тех случаев, когда мы делаем это через шаблон. О, и, пожалуйста, не вспоминайте о std::array
, потому что я хочу, чтобы эту функцию можно было вызывать с различными размерами массивов. Похоже здесь нам уж точно не обойтись без указателей!
void IWantPointers(const char* data, const size_t length)
{
for(int i = 0; i < length; ++i) { std::cout << data[i]; }
}
void Use()
{
char data[]{"Hello, Pointers\n"};
IWantPointers(data, sizeof(data));
}
span и string_view спешат на помощь
А вот и нет. По крайней мере, нам не нужны указатели в API функции. C++20 предоставляет нам std::span
для случаев, когда мы хотим передать массив или непрерывный контейнер (в этом примере мы также можем использовать std::string_view
из C++17). Преимущество std::span
заключается в том, что он содержит информацию о количестве элементов данных. Таким образом, нам не нужен дополнительный параметр под размер массива и нужно на порядок меньше sizeof
.
void IWantPointers(std::span<const char> data)
{
for(const auto& c : data) { std::cout << c; }
}
void Use()
{
char data[]{"Hello, Pointers\n"};
IWantPointers(data);
}
Думаю, сейчас мы находимся на этапе, когда можно сказать, что у нас практически нет необходимости задействовать указатели в API верхнего уровня. С такими вспомогательными типами, как std::optional
и std::span
, наша жизнь стала намного лучше. И да, указатели по-прежнему существуют в C++, и что бы я здесь не говорил, их право на существование неоспоримо. К примеру, std::span
принимает и возвращает указатель.
Почему меня это так волнует?
Ну, мне просто нравятся чистые и выразительные API. Что мне еще нравится, так это эффективный код. Взгляните на следующий пример в Compiler Explorer и убедитесь сами. Мы видим программу целиком, включая main
. Функции Fun
, которая принимает указатель и проверяет на nullptr
, потребовалось 7 инструкций при -O3. Версии без проверки, как и версии со ссылкой, потребовалось всего 3 инструкции. И это учитывая, что компилятор видит всю программу целиком! Самое интересное — это Opt. Здесь я использую std::optional
в связке с get_value_or
. То есть происходит проверка значения. Однако и Clang, и GCC удается скомпилировать эту функцию в 6 строк ассемблерного кода. Неплохо, правда? Здесь отсутствует библиотечная часть, поэтому мы еще и получаем дополнительные накладные расходы за сам optional.
Так нужны ли нам указатели?
Что ж, надеюсь, я показал вам, что мы, по крайней мере, нуждаемся в них реже, чем раньше. Указатели по-прежнему являются важной частью C++, но во многих местах мы можем использовать более подходящие типы данных.
Идея модульного тестирования прочно укрепилась среди лучших практик по разработке ПО. Во многих языках это выражено в наличии специальных инструментов, которые могут поставляться в составе стандартной библиотеки. Но в языке С++ ситуация иная — имеется несколько популярных сторонних библиотек, с помощью которых пишутся модельные тесты. Одна из таких библиотек называется GoogleTest. Приглашаем на открытое заняте, на котором мы рассмотрим, как выглядит использование этой библиотеки для тестирования C++ кода. Регистрация на занятие.
Комментарии (49)
semenyakinVS
03.06.2022 19:57+3Увы, есть много библиотек, фреймворков и движков в которых инициализация объектов - это не одномоментное действие выполнимое в конструкторе. Поэтому если у этих объектов есть зависимости от других объектов реализовать поля для связи с зависимостями в ссылочных типах (которые обязаны инициализироваться на конструкторе) невозможно
pehat
03.06.2022 20:15+2Какой адовый треш. И этот человек себя позиционирует как C++ trainer.
#include <iostream> #include <string> using namespace std; void foo(int* p) { if (p == nullptr) return; int& x = *p; cout << x << "\n"; // output: 42 delete p; // this can happen externally, especially in a multi-threaded program cout << x << "\n"; // output: UB } int main() { int* p = new int(42); foo(p); }
Я уже молчу о том, что этот самопровозглашенный коуч даже не затрагивает тему умных указателей, очевидно, потому что не знает, зачем их придумали (спойлер: именно из-за таких происшествий с ссылками).
Sap_ru
03.06.2022 20:33+9Как бы, нет. Ваш пример некорректен.
Если у вас значения в других потоках меняются, то тут не в указателях дело, а нужно многопоточную синхронизацию предусматривать. Тут хоть контейнеры, хоть умные указатели делай - ничего не поможет без межпоточной синхронизации. Так что мимо.
Со второй придиркой аналогичные проблемы. Если вы получаете указатель а он где-то во внешнем коде освобождается пока функция не завершилась, то проблема не с указателями. Так у вас и значения в контейнерах могут измениться, и контейнеры освободиться. Указатели тут ни при чём
pehat
03.06.2022 22:24+11Если у вас значения в других потоках меняются, то тут не в указателях дело, а нужно многопоточную синхронизацию предусматривать
А если у вас nullptr разыменовывается - нужно внимательнее смотреть, ну да, ну да. Посыл поста был в том, что ссылки лечат от разыменования nullptr, но это неправда и от прострела ноги другим способом не защищает, хотя дает ложное ощущение уверенности.
> Если вы получаете указатель а он где-то во внешнем коде освобождается пока функция не завершилась, то проблема не с указателями
Конечно, в этой ситуации проблема не с указателями, а с семантикой владения. Если у вас unique_ptr в функцию передали - на вас возложили ответственность за уничтожение объекта или же передачу его тому, кто уничтожит. Если у вас shared_ptr в функцию передали - у вас есть гарантия, что пока счетчик ссылок не обнулится, объект будет жить. Ни ссылка, ни тем более сырой указатель не дают никаких гарантий.
NightShad0w
03.06.2022 21:08+10С ног на голову перевернуто в статье. Указатели не для того, чтобы nullptr был. А что, как бы странно не звучало, УКАЗЫВАТЬ на куда-то. Побочным эффектом является существование указателя в никуда, который монадами MayBe или std::optional становится явно выраженным, но никуда не девается, а вот реактивные преобразования успешно его устраняют из семантики кода.
Ссылка — это именованный псевдоним, который не может быть переприсвоен, поэтому элементарные алгоритмы с деревьями или динамическими списками не смогут работать с ссылками.
Всякий раз когда функция получает указатель, нужно задать себе вопрос: а не Си интерфейс кросс-компиляторной библиотеки ли у меня? Потому что в С++ сырых указателей вообще быть уже не должно 10 лет как. А умные указатели, как бы странно не звучало, выражают не семантику указывания, а семантику ВЛАДЕНИЯ. И руководствуясь RAII, если в руках есть указатель, значит все у нас хорошо, объект существует и мы им владеем.
Умные указатели или сырые указатели на примитивные типы в примитивных примерах — плохая иллюстрация мощных инструментов языка общего назначения.
Таким образом, единственное место, где встречается сырой указатель, который надо проверять на валидность — границы Си библиотек. Получили указатель, проверили, переложили в нужные нам сущности типа умных указателей или по месту разыменовали в значение — и понеслись в бизнес-логику. Си-стиль проверки указателей на каждой строчке каждой функции — это следствие очень ограниченных ресурсов или плохого дизайна кода.DaylightIsBurning
04.06.2022 00:47+2Есть ещё один случай, когда raw pointer - хороший вариант. По сути это случай когда нужен аналог weak_ptr для uniq_ptr. Процитирую Smart Pointer Guidelines:
What about passing or returning a smart pointer by reference?
Don't do this.
In principle, passing a
const std::unique_ptr<T>&
to a function which does not take ownership has some advantages over passing aT*
: the caller can't accidentally pass in something utterly bogus (e.g. anint
converted to aT*
), and the caller is forced to guarantee the lifetime of the object persists across the function call. However, this declaration also forces callers to heap-allocate the objects in question, even if they could otherwise have declared them on the stack. Passing such arguments as raw pointers decouples the ownership issue from the allocation issue, so that the function is merely expressing a preference about the former. For the sake of simplicity and consistency, we avoid asking authors to balance these tradeoffs, and simply say to always use raw pointers.Вот та же мысль в C++ Core Guidelines:
F.7: For general use, take
T*
orT&
arguments rather than smart pointersReason
Passing a smart pointer transfers or shares ownership and should only be used when ownership semantics are intended. A function that does not manipulate lifetime should take raw pointers or references instead.
deema35
04.06.2022 04:13+1При помощи weak_ptr можно узнать существует объект или нет, а у обычного указателя такой обратной связи нет.
DaylightIsBurning
04.06.2022 09:14В случае с unique_ptr предполагается что лайфтайм гарантирован и проверка не нужна.
Revertis
04.06.2022 15:05А в C++ нельзя передать в функцию ссылку на smart pointer?
DaylightIsBurning
04.06.2022 15:23Я же с этого и начал: What about passing or returning a smart pointer by reference?
Revertis
04.06.2022 15:32Ну вот эта часть непонятна:
However, this declaration also forces callers to heap-allocate the objects in question, even if they could otherwise have declared them on the stack.
Вот есть у нас уже объект в смарт-поинтере, и мы передаём на него ссылку в какую-то функцию, чтобы не передавать владение или не инкрементить счётчик ссылок. Почему это плохо?
Мне кажется, что автор этого предложения придумал какую-то странную ситуацию, которую я не могу придумать.
IvaYan
04.06.2022 16:17Нет, тут речь о том, что когда мы передаём в функцию смарт поинтер на какой-то объект, это означает этот объект должен быть обязательно в куче, иначе мы не сможем использовать смарт поинтер. Если мы передаём простой указатель, то мы можем передать указатель на объект в стеке. Но в первом случае у нас такой возможности нет.
Revertis
04.06.2022 18:09Так если объекты уже в куче, зачем запрещать такое?
IvaYan
04.06.2022 19:08+3В цитате которую вы привели как раз про это. Использование смарт принтеров обязывает вас располагать объекты именно в куче. А использование обычных указателей или ссылок не обязывает.
Я, кажется понял, что вас смущает. Вы пишете о том, что если у вас уже есть смарт принтер и почему автор не рекомендует передавать его по ссылке в функцию. Но цитата у вас про другое, она про то, что передача именно по смарт принтеру обязывает вас использовать именно смарт принтер и выделять под объект память в куче, в то время как передача по обычному указателю нет и объект может быть из стека.
Ritan
04.06.2022 16:18Речь о такой ситуации.
void foo(int* p) { //something } void main() { int a = 42; foo(&a); }
С умными указателями так сделать(без костылей) нельзя. Потому что, как там и сказано, умные указатели помимо владения ещё и управляют аллокацией.
Deosis
06.06.2022 07:47Можно, но это может быть не оптимально.
Например при передаче ссылки на умный указатель необходимо этот самый указатель сохранить в памяти и передать ссылку на этот участок памяти.
Это мешает некоторым оптимизациям компилятора.
leonid_m_kim
06.06.2022 07:23А можно немного подробнее про то, как реактивные преобразования избавляют от указателей в никуда?
Или - где про это почитать?
it1804
04.06.2022 01:10+11Я также видел случаи, когда проверка на
nullptr
в функции была опущена попросту потому что было сложно решить, что делать в случаеnullptr
. Например, когдаnullptr
получает функция, которая возвращаетvoid
.Это лучшее оправдание говнокода которое я видел. Нормально подогнать сигнатуру функции под код, но не код под сигнатуру, который ещё и работать будет с вероятностью 50/50 при этом.
Пример из жизни. У товарища банкомат не принимает карту, я ему говорю, ладно, забей. Ну он он и забил. Буквально вдавил в картоприёмник. Неделю стрелял денег пока инкассация не прошла.
deema35
04.06.2022 03:55-4Я также видел случаи, когда проверка на
nullptr
в функции была опущена попросту потому что было сложно решить, что делать в случаеnullptr
. Например, когдаnullptr
получает функция, которая возвращаетvoid
.А в чем проблемам:
std::cerr << "null pointer'' << std::endl;
throw;
Кроме того указатели понятны и естественны для большинства программистов, а std::optional выглядит как китайская грамота. Код же мы не только для себя пишем.
Tsvetik
04.06.2022 08:30+2В С++ есть ссылки на массивы и это очень удобно
https://stackoverflow.com/a/10008405/5897995
void foo(double (&bar)[10]) { }
double arr[20]; foo(arr); // won't compile
template<typename T, size_t N> void foo(T (&bar)[N]) { // use N here }
Racheengel
04.06.2022 13:07+3Это ж сарказм был, правда?
Tsvetik
04.06.2022 18:22Нет, не сарказм. Действительно очень удобно.
Racheengel
05.06.2022 17:14А чем std::vector не устраивает?
F0iL
05.06.2022 22:59+2Он heap-allocated. В каких-то случаях динамической памяти может вообще не быть (встраиваемые системы) или просто есть смысл избегать лишних аллокаций.
SpiderEkb
04.06.2022 17:07Что мешает Благородному Дону не использовать указатели в своем коде, но использовать только ссылки?
Вот я сейчас много пишу на достаточно специфическом языке RPG на платформе IBM i. Там указатели практически не используются. Параметры по умолчанию передаются по ссылке (или по значению, если установлен соотв. модификатор).
dcl-pr MyProc; dcl-pi *n; Parm1 char(10) const; // передача по ссылке, допустима передача литерала Parm2 char(5); // передача по ссылке Parm3 int(10) value; // передача по значению end-pi; // код return; end-proc;
Указатели существуют, но они не типизированы. Точнее, есть два типа указателя - на данные и за процедуру. И все. Типизация указателя осуществляется объявлением переменной на которую он указывает.
dcl-s String char(50); // Строка 50 символов dcl-s Str20 char(20) based(ptr1); // Строка 20 символов, // на которую указывает указатель ptr1 dcl-s Str30 char(30) based(ptr2); // Строка 20 символов, // на которую указывает указатель ptr2 dcl-s Ptr pointer; // Просто указатель Ptr = %addr(String); // Получили адрес строки ptr1 = Ptr; // Теперь Str20 совпадает с первыми // 20-ю символами String ptr2 = Ptr + 20; // А Str30 - String c 21-го символа
И ничего. Вполне жизнеспособно. Если есть нужда, то можно и с указателями поработать. А можно и без них обходиться запросто.
Но сталкивался с ситуацией, когда без указателей было бы туговато. Была задачка, где нужно было обмениваться информацией с удаленными промконтроллерами. Обмен шел датаграммами (кто не сталкивался - это фиксированный заголовок + блок данных переменной длины, размер и структура которого определяется из заголовка):
struct tagDGMHeader { int id // уникальный идентификатор int len; // размер блока данных int type; // тип датаграммы int source; // отправитель int destination; // получатель }; struct tagDGMType1 : tagDGMHeader { char Data[256]; // какие=то данные }; struct tagDGMType2 : tagDGMHeader { int Data[16]; // какие=то данные }; struct tagDGMType3 : tagDGMHeader { int data1; char data2[64]; };
Тип датаграммы, может быть, например 0, 1, 2,...
Тогда делаем диспетчер датаграмм примерно так
int DGMHndlr1(void *pdgm) { int rslt = 0; tagDGMType1 *pdgmtype1 = (tagDGMType1*)pdgm; // тут обработка датаграммы типа 1 return rslt; } int DGMHndlr2(void *pdgm) { int rslt = 0; tagDGMType2 *pdgmtype2 = (tagDGMType2*)pdgm; // тут обработка датаграммы типа 2 return rslt; } int DGMHndlr3(void *pdgm) { int rslt = 0; tagDGMType3 *pdgmtype3 = (tagDGMType3*)pdgm; // тут обработка датаграммы типа 3 return rslt; } int ((*DGMHandler[3]))(void *pdgm) = {DGMHndlr1, DGMHndlr2, DGMHndlr3}; int DGMDispatch(void *pdgm) { tagDGMHeader *pdgmhdr = (tagDGMHeader*)pdgm; // тут какие-то проверки, валидации и т.п., если надо return DGMHandler[pdgmhdr->type](pdgm); }
Ну как-то так, весьма схематично. Работает быстро (это критично), легко масштабируется - появился новый тип датаграммы - пишем обработчик, добавляем в таблицу и вуаля.
Так что без указателей скучно. Но применять с осторожностью и только там, где это реально дает какой-то профит. Но в большинстве случаев вполне пригодны ссылки.
MrKirushko
06.06.2022 07:23+1Ну, как по мне
if(!data.has_value())
ничем не лучшеif(nullptr == data)
ни в плане скорости исполнения, ни по расходу памяти, ни в плане восприятия кода человеком никаких преимуществ я не вижу. Как, впрочем, и вообще в минимум 90% того что предоставляет STL (а из оставшихся 10% проще либо Qt либо вообще стандартной библиотекой пользоваться). Я лично так вообще обычно пишуif (!data)
- и нажатий на кнопки надо меньше и на С без изменений работает.
AlexXZero
06.06.2022 07:24-1Используйте ссылки вместо указателей
Похоже у автора всё смешалось воедино. Ссылки - это не замена указателям, это тип данных для хранения адреса объекта, и этот адрес нельзя изменить. Но их НЕЛЬЗЯ просто так использовать вместо указателей. Если уж на то пошло, то почему тогда автор не проверяет ссылки на nullptr? Ведь вызывающий функцию код может сделать что-то типа:
int *a = nullptr; void f(const int &a); ... f(*a);
Именно поэтому прежде чем вызывать функцию надо читать её описание, а какие данные она готова принимать, если она принимает указатель, то это ещё не значит, что можно туда nullptr запихивать. Я бы сказал, что по умолчанию в C++ этого делать как раз таки нельзя.
С другой стороны я поддерживаю идею, что указатель как аргумент функции в C++ - это плохой тон. А вот внутри кода - пожалуйста используйте указатель, как замену ссылкам, т.к. их можно модифицировать и хранить указатель на текущий элемент дерева, и т.п. Но всё равно предпочтение должно отдаваться ссылкам, если вам не нужно менять их значение.
mayorovp
06.06.2022 10:17Если уж на то пошло, то почему тогда автор не проверяет ссылки на nullptr? Ведь вызывающий функцию код может сделать что-то типа [...]
Согласно стандарту языка, ссылка никогда не указывает на nullptr, а разыменование nullptr — это UB.
kmatveev
06.06.2022 07:45+1Заголовок и вступление ставят под сомнение полезность указателей. Но вся статья рассматривает только применение указателей как параметров.
Ritan
Вот только std::optional<int&> невалиден, так что как минимум для этого указатели всё-ещё нужны
me21
А зачем нам опциональная ссылка? Ссылка ведь - это гарантированное наличие значения. А тут мы поверх него вешаем, что значения может не быть...
mayorovp
Ну вот как раз для тех случаев когда значения может не быть.
me21
А почему не написать std::optional<int>? Зачем нам там именно ссылка в этом случае?
mayorovp
В общем случае там нее int может быть, а разделяемый объект.
Akon32
std::optional<std::shared_ptr<int>> ?
Ritan
shared_ptr и так может выражать отсутствие значения, optional тут не нужен. К тому умные указатели это всё ещё указатели против которых эта статья и голосует
gwisp
Дополню ещё 2 места, где без указателей никак:
Поля типа Foo* в классе, если требуется менять значение этого поля. Референсы менять нельзя.
shared_ptr и unique_ptr - там где, в деструкторе нужно удалять другие объекты. Если использовать референсы, то придётся руками писать удаление в деструкторе.
Ingulf
если в первом случае это указатель на функцию, то почему бы не std::function?
ну и кмк статья больше не про то, чтобы вообще не использовать, а про то, что есть уже для некоторых кейсов более подходящие конструкции
Ingulf
невалиден, как и во всем stl, впрочем тут нужен не указатель, а std::reference_wrapper<>