Знакома ли вам ситуация, когда вы внезапно оказываетесь совершенно не в состоянии объяснить какой-нибудь базовый элемент языка, с которым работаете? Вам задают простой вопрос, а вы только и можете, что сказать «ээээээээ, ну я точно не помню, мне нужно освежить знания, извините». Не вешайте нос! Мы очень часто воспринимаем такие вещи как должное, и пока менее опытный коллега не спросит нас о них, мы о таком даже не задумываемся. Поэтому иногда нам все-таки стоит возвращаться к основам, чтобы освежить или даже углубить свои знания. Сегодня мы с вами обсудим указатели и итераторы. Начнем с печально известных указателей. Только их одних уже может быть достаточно, чтобы сделать C и C++ для некоторых на порядок сложнее в изучении по сравнению с другими языками. Прежде всего, указатель — это тип переменной, который должен хранить адрес в памяти. Я говорю здесь "должен", потому что если указатель правильно инициализирован, то в нем хранится либо nullptr, либо адрес другой переменной (он даже может хранить адрес другого указателя), но если он не был инициализирован должным образом, то в нем будут содержаться произвольные данные, что довольно опасно, так как это может привести к неопределенному поведению. В нашем распоряжении есть сразу три способа! Взять адрес другой переменной: Указать на переменную в куче Или просто взять значение другого указателя Если вы решите вывести на экран значение указателя, то вы увидите адрес в памяти. Но если вам необходимо получить значение, на которое указатель он указывает, то нужно разыменовать этот указатель с помощью оператора . В данном примере мы видим, что p и p2 хранят один и тот же адрес в памяти, а это значит, что они указывают на одно и то же значение. Но если мы воспользуемся оператором &, то мы увидим, что адреса у этих указателей разные. Если выделение памяти (аллокация) происходит с помощью оператора new, другими словами, если вы выделяете память в куче, то кто-то должен впоследствии высвободить (деаллоцировать) выделенную память. Это можно сделать с помощью оператора delete. Если забыть это сделать, то, когда указатель выйдет за пределы области видимости, произойдет утечка памяти (memory leak). Вы больше не будете иметь доступа к этому участку памяти, и поскольку он не деаллоцирован, то никто другой также не сможет его использовать. Если такой код будет работать достаточно долго, то в результате он может создать столько утечек памяти, что он попросту закрашится, так как в его доступе больше не останется свободной памяти. Поэтому обязательно высвобождайте всю выделенную память. Если вы попытаетесь получить доступ к указателю уже после удаления или же попытаетесь удалить его во второй раз, то это вызовет неопределенное поведение и, скорее всего, вы наткнетесь на core dump. Подобные ошибки часто возникают в легаси-коде, например, в таких сценариях: Очевидно, что на практике значение error определяется в результате куда более сложного вычисления, и обычно эти два оператора delete добавляются в код не одновременно (и скорее всего разными программистами). Простейший способ перестраховаться от такой ситуации — сразу после удаления присвоить p nullptr. Если попытаться удалить указатель еще раз, то это не даст никакого эффекта, так как удаление nullptr является no-op. Еще один важный момент — всегда проверяйте пригодность (валидность) указателя перед обращением к нему. Даже если не брать в расчет мороку с потокобезопасностью, мы не можем расслабляться. Что если указатель уже был удален и не установлен в nullptr? Неопределенное поведение, потенциальные краши. Или еще хуже... А что может пойти не так, если сделать копию указателя? Давайте представим, что мы удаляем один указатель и устанавливаем его в nullptr. Его скопированный брат не будет знать, что он был удален: Такая ситуация легко может возникнуть, если в вашем распоряжении есть классы, управляющие ресурсами через голые указатели, и операции копирования/перемещения в них реализованы некорректно. Еще один важный момент, связанный с указателями, — это операции, которые можно выполнять над ними. Мы часто называем их арифметикой указателей, потому что указатели можно инкрементировать или декрементировать (выполнять сложение и вычитание). Но на самом деле можно складывать и вычитать любые целые числа... Благодаря возможности инкремента/декремента, указатели можно использовать для итерации по массивам и доступа к любому их элементу. Это конечно хорошо, но стоит ли использовать указатели для итерации по массивам в 2023 году? Ответ однозначен — нет. Это небезопасно, указатель может указывать куда угодно, и он не работает со всеми типами контейнеров. В предыдущем примере вы могли заметить, что в первом цикле мы используем постфиксный инкремент, а во втором — префиксный декремент. После прохода по массиву указатель указывает на недопустимое местоположение, поэтому перед разыменованием его необходимо декрементировать, иначе мы рискуем наткнуться на неопределенное поведение. На самом деле, на сегодняшний день уже вообще нет особого смысла использовать голые/сырые указатели (raw pointers). Особенно это касается указателей, которые выделяются с помощью new, и указателей, владеющих ресурсами. Передача ресурсов через голый указатель — это еще куда ни шло, но владение этими ресурсами, использование указателей в качестве итераторов или для выражение того, что значение может быть, а может и не быть — это то, чего не следует допускать в коде. В нашем распоряжении есть средства более подходящие для этих задач. Прежде всего, для замены владеющих ресурсом голых указателей мы можем использовать умные указатели (smart pointers). Мы можем использовать невладеющие (non-owning) указатели, мы можем использовать ссылки, если что-то не может быть nullptr, или если мы хотим выразить, что что-то может присутствовать или не присутствовать, мы можем попробовать std::optional. Но я расскажу об этом подробнее в другой раз. Сегодня мы сосредоточимся на итерации по массиву и рассмотрим другие возможности, которые мы могли бы использовать. Итераторы являются неотъемлемой частью стандартной библиотеки шаблонов. В STL можно выделить четыре основные группы элементов: алгоритмы (std::rotate, std::find_if и т.д.) контейнеры (std::vector<T>, std::list<T> и т.д.) функциональные объекты (std::greater<T>, std::logical_and<T> и т.д.) итераторы (std::iterator, std::back_inserter и т.д.) Итераторы являются результатом обобщения концепции указателя. Они могут использоваться для перебора элементов контейнера STL и предоставления доступа к отдельным элементам. Упоминание контейнеров STL также не спроста — итераторы нельзя использовать с C-массивами. И это нормально, в 2023 году мы вообще уже не должны использовать массивы в стиле C. По сути, итераторы можно разделить на пять категорий: итераторы ввода (input iterators) итераторы вывода (output iterators) однонаправленные итераторы (forward iterators) двунаправленные итераторы (bidirectional iterators) итераторы произвольного доступа (random access iterators) Итераторы ввода являются простейшей формой итераторов. Они поддерживают операции чтения и могут двигаться только вперед. Итераторы ввода можно использовать для сравнения на (не)равенство, и они могут быть инкрементированы. Хорошим примером может послужить итератор std::list. Итераторы вывода также являются однонаправленными итераторами, но они используются для присвоения значений в контейнере, это итераторы исключительно для записи. Их нельзя использовать для чтения значений. В качестве примера такого итератора можно привести std::back_inserter. Однонаправленные итераторы представляют собой комбинацию итераторов ввода и вывода. Они позволяют нам как получать доступ к значениям, так и изменять их. Например, такие итераторы используются в std::replace. Однонаправленные итераторы являются DefaultConstructible и могут обращаться/разыменовывать одни и те же позиции несколько раз. Двунаправленные итераторы подобны однонаправленным итераторам, но они могут быть еще и декрементированы, поэтому могут двигаться как вперед, так и назад. std::reverse_copy использует такие итераторы, поскольку ему нужно и обращать значения контейнера (декрементировать), и помещать результаты в новый контейнер один за другим (инкрементировать). Итераторы произвольного доступа могут делать все то же, что и двунаправленные итераторы. Кроме того, они могут не только инкрементироваться или декрементироваться, но и изменять свою позицию на любое значение. Другими словами, они поддерживают операторы + и -. Различные итераторы произвольного доступа можно также сравнивать с помощью различных операторов сравнения (а не только с помощью равенства/неравенства). Произвольный доступ означает, что к контейнерам, принимающим такие итераторы, можно просто обращаться с помощью оператора сдвига. Алгоритм, которому нужны итераторы с произвольным доступом, — это std::random_shuffle(). Итераторы могут быть получены из контейнеров двумя различными способами: через функции-члены, такие как std::vector<T>::begin() или std::vector<T>::end() или через свободные функции, такие как std::begin() или std::end() Существуют различные вариации итераторов, с практической точки зрения они могут быть как const, так и реверсивно-направленными. Как и указатели, итераторы можно инкрементировать или декрементировать, что позволяет использовать их в циклах. Правда, до появления C++11 они были достаточно громоздки: С появлением языка C++11 и ключевого слова auto использование итераторов значительно упростилось: Конечно, вы можете возразить, что циклы for с диапазоном проще в использовании, и будете правы. Однако стоит отметить, что циклы for с диапазоном также реализуются с помощью итераторов. Теперь, когда мы обсудили отдельно указатели и итераторы, давайте обсудим различия между этими двумя категориями. В то время как мы используем указатели для хранения адреса памяти, каким бы ни был этот адрес, с контейнерами всегда используется итератор. Итератор используется для перебора элементов контейнера, при этом элементы контейнера не нужно хранить в заразервированной области памяти. Даже если элементы разбросаны по памяти, как, например, в связном списке, итератор все равно будет работать. Учитывая, что указатель всегда хранит адрес памяти, его всегда можно преобразовать в целое число (которое и является адресом). Большинство итераторов не могут быть преобразованы в целые числа. Как мы видели, существует пять различных категорий итераторов, и не все из них поддерживают все операции из арифметики указателей. В то же время указатели не имеют таких различий. Указатель есть указатель, и с ним можно производить любые операции, что зачастую довольно опасно. Если вы объявляете указатель на тип, то он может указывать на любой объект того же типа. К счастью, итераторы более ограничены и работают только внутри контейнера определенного типа. Если вы когда-нибудь пользовались голыми указателями, то знаете, что их можно удалять, более того, во избежание утечек памяти даже необходимо удалять собственные указатели. С другой стороны, итераторы не могут и не должны удаляться. Итератор не отвечает за управление памятью, его единственная обязанность — предоставлять возможность обращения к элементам в контейнере. Если требуется выполнить итерацию по стандартному контейнеру, используйте итератор. Так как он был разработан именно для этого, он безопаснее и именно его вы и получите, если вызовете begin() или end() на контейнере. Более того, алгоритмы STL принимают на вход именно итераторы, а не указатели, и именно их они зачастую и возвращают. Но есть две ситуации, где вам не нужно использовать итераторы: использование цикла for с диапазоном, который действительно предпочтительнее, но под капотом, в большинстве случаев, он все равно использует итераторы использование массив в стиле C. Но в 2023 году вообще нет смысла использовать C-массив, ведь можно использовать std::array или другой STL-контейнер. Не используйте указатели для итерации. Используйте указатели только в тех случаях, когда необходимо передать адрес переменной в другую функцию и когда он может быть равен null, поэтому нельзя использовать вместо указателя ссылку. Указатели также могут пригодиться, когда приходится иметь дело с полиморфизмом и требуется динамическая диспетчеризация, необходимо определить, какая версия виртуальной функции должна вызываться во время выполнения. Не используйте (голые) указатели для работы с памятью. Если вам необходимо использовать динамическое выделение памяти, если вам нужна куча, используйте умный указатель вместо обычного указателя — это позволит вам избежать утечек памяти или повторного высвобождения. Мне бы очень хотелось иметь наиболее полное понимание основ языка C++ в начале своей карьеры разработчика я. Мне бы очень хотелось иметь его и сегодня. С помощью этого материала я немного приблизился к пониманию основ указателей и итераторов, надеюсь, что и вы тоже. Apache C++ Standard Library User’s Guide: Varieties of Iterators University of Helsinki: STL Iterators GeeksForGeeks: Difference between Iterators and Pointers in C/C++ with Examples Microsoft: Raw pointers (C++) Stackoverflow: Why should I use a pointer rather than the object itself? Материал подготовлен в преддверии старта специализации "C++ Developer".  Редактор