Знакома ли вам ситуация, когда вы внезапно оказываетесь совершенно не в состоянии объяснить какой-нибудь базовый элемент языка, с которым работаете? Вам задают простой вопрос, а вы только и можете, что сказать «ээээээээ, ну я точно не помню, мне нужно освежить знания, извините».

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

Сегодня мы с вами обсудим указатели и итераторы.

Указатели

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

Что такое указатель?

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

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

Как можно инициализировать указатель?

В нашем распоряжении есть сразу три способа!

  • Взять адрес другой переменной:

#include <iostream>

int main(){
  int v = 42;
  int* p = &v;
}
  • Указать на переменную в куче

#include <iostream>
int main(){ 
int* p = new int {42}; 
}
  • Или просто взять значение другого указателя

#include <iostream>

int main(){
  int* p = new int {42};
  int* p2 = p;
}

Значения указателей и значения, на которые они указывают

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

#include <iostream>

int main(){
  int* p = new int {42};
  int* p2 = p;
  std::cout << p << " " << *p << '\n';
  std::cout << p2 << " " << *p2 << '\n';
  std::cout << &p << " " << &p2 << '\n';
}
/*
0x215dc20 42
0x215dc20 42
0x7fff77592cb0 0x7fff77592cb8
*/

В данном примере мы видим, что p и p2 хранят один и тот же адрес в памяти, а это значит, что они указывают на одно и то же значение. Но если мы воспользуемся оператором &, то мы увидим, что адреса у этих указателей разные.

Деаллокация памяти

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

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

#include <iostream>

int main() {
  int* p = new int {42};
  std::cout << p << " " << *p << '\n';
  delete p; 
}

Если вы попытаетесь получить доступ к указателю уже после удаления или же попытаетесь удалить его во второй раз, то это вызовет неопределенное поведение и, скорее всего, вы наткнетесь на core dump.

Подобные ошибки часто возникают в легаси-коде, например, в таких сценариях:

#include <iostream>

int main(){
  int* p = new int {42};
  std::cout << p << " " << *p << '\n';
  
  bool error = true;
  
  if (error) {
    delete p; 
  }
  
  // ...
  delete p; 
}

Очевидно, что на практике значение error определяется в результате куда более сложного вычисления, и обычно эти два оператора delete добавляются в код не одновременно (и скорее всего разными программистами).

Простейший способ перестраховаться от такой ситуации — сразу после удаления присвоить p nullptr. Если попытаться удалить указатель еще раз, то это не даст никакого эффекта, так как удаление nullptr является no-op.

#include <iostream>

int main(){
  int* p = new int {42};
  std::cout << p << " " << *p << '\n';
  
  bool error = true;
  
  if (error) {
    delete p;
    p = nullptr;
  }
  
  // ...
  delete p; 
  p = nullptr;
}

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

#include <iostream>

int main(){
  int* p = new int {42};
  if (p != nullptr) {
    std::cout << p << " " << *p << '\n';
  }
  
  delete p; // we forget to set it to nullptr
  if (p != nullptr) { // we pass the condition
    std::cout << p << " " << *p << '\n';
  }
}
/*
0x22f3c20 42
0x22f3c20 0
*/

А что может пойти не так, если сделать копию указателя? Давайте представим, что мы удаляем один указатель и устанавливаем его в nullptr. Его скопированный брат не будет знать, что он был удален:

#include <iostream>

int main(){
  int* p = new int {42};
  int* p2 = p;
  
  if (p != nullptr) {
    std::cout << p << " " << *p << '\n';
  }
  
  delete p; // we forget to set it to nullptr
  p = nullptr;
  
  if (p != nullptr) { // p is nullptr, we skip this block
    std::cout << p << " " << *p << '\n';
  }
  
  
  if (p2 != nullptr) { // we pass the condition and anything can happen
    std::cout << p2 << " " << *p2 << '\n';
  }
}
/*
0x1133c20 42
0x1133c20 0
*/

Такая ситуация легко может возникнуть, если в вашем распоряжении есть классы, управляющие ресурсами через голые указатели, и операции копирования/перемещения в них реализованы некорректно.

Итерация по массивам

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

#include <iostream>

int main(){
  int numbers[5] = {1, 2, 3, 4, 5};
  int* p = numbers;
  
  for(size_t i=0; i < 5; ++i) {
    std::cout << *p++ << '\n';
  }
  for(size_t i=0; i < 5; ++i) {
    std::cout << *--p << '\n';
  }

  std::cout << '\n';
  std::cout << *(p+3) << '\n';
}
/*
1
2
3
4
5
5
4
3
2
1

4
*/

Это конечно хорошо, но стоит ли использовать указатели для итерации по массивам в 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 они были достаточно громоздки:

#include <iostream>
#include <vector>

int main(){
  std::vector<int> v {1, 2, 3, 4, 5};
  for (std::vector<int>::const_iterator it=v.begin(); it != v.end(); ++it) {
    std::cout << *it << " ";
  }
}

С появлением языка C++11 и ключевого слова auto использование итераторов значительно упростилось:

#include <iostream>
#include <vector>

int main(){
  std::vector<int> v {1, 2, 3, 4, 5};
  for (auto it=v.begin(); it != v.end(); ++it) {
    std::cout << *it << " ";
  }
}

Конечно, вы можете возразить, что циклы for с диапазоном проще в использовании, и будете правы. Однако стоит отметить, что циклы for с диапазоном также реализуются с помощью итераторов.

Чем итератор отличается от указателя

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

В то время как мы используем указатели для хранения адреса памяти, каким бы ни был этот адрес, с контейнерами всегда используется итератор. Итератор используется для перебора элементов контейнера, при этом элементы контейнера не нужно хранить в заразервированной области памяти. Даже если элементы разбросаны по памяти, как, например, в связном списке, итератор все равно будет работать.

Учитывая, что указатель всегда хранит адрес памяти, его всегда можно преобразовать в целое число (которое и является адресом). Большинство итераторов не могут быть преобразованы в целые числа.

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

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

Если вы когда-нибудь пользовались голыми указателями, то знаете, что их можно удалять, более того, во избежание утечек памяти даже необходимо удалять собственные указатели. С другой стороны, итераторы не могут и не должны удаляться. Итератор не отвечает за управление памятью, его единственная обязанность — предоставлять возможность обращения к элементам в контейнере.

Когда использовать одно, а когда другое?

Если требуется выполнить итерацию по стандартному контейнеру, используйте итератор. Так как он был разработан именно для этого, он безопаснее и именно его вы и получите, если вызовете begin() или end() на контейнере. Более того, алгоритмы STL принимают на вход именно итераторы, а не указатели, и именно их они зачастую и возвращают.

Но есть две ситуации, где вам не нужно использовать итераторы:

  • использование цикла for с диапазоном, который действительно предпочтительнее, но под капотом, в большинстве случаев, он все равно использует итераторы

  • использование массив в стиле C. Но в 2023 году вообще нет смысла использовать C-массив, ведь можно использовать std::array или другой STL-контейнер.

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

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

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

Заключение

Мне бы очень хотелось иметь наиболее полное понимание основ языка C++ в начале своей карьеры разработчика я.

Мне бы очень хотелось иметь его и сегодня.

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

Ссылки


Материал подготовлен в преддверии старта специализации "C++ Developer".

Комментарии (15)


  1. Kelbon
    21.09.2023 10:49
    +2

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


  1. unreal_undead2
    21.09.2023 10:49
    +4

    На самом деле, на сегодняшний день уже вообще нет особого смысла использовать голые/сырые указатели (raw pointers). 

    Ну напишите без них свой аллокатор...


  1. mikko_kukkanen
    21.09.2023 10:49
    +2

    heap действительно переводится как "куча"?


    1. unreal_undead2
      21.09.2023 10:49
      +2

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


      1. mikko_kukkanen
        21.09.2023 10:49
        +1

        Когда я писал на С++ (это было давно), писали просто - область памяти в хипе. И все было понятно.


        1. ReadOnlySadUser
          21.09.2023 10:49
          +2

          Это было наверное ну ОЧЕНЬ давно. Начал учить плюсы году в 2011-м. Везде и всегда писали "куча" и уже точно никто и нигде не употреблял англицизмы в тексте) В речи да, до сих пор говорят "в хипе", но в текстах уже 100500 лет пишут "куча".


        1. unreal_undead2
          21.09.2023 10:49

          писали просто - область памяти в хипе

          В печатных книжках?


    1. myswordishatred
      21.09.2023 10:49
      +3

      Я других вариантов никогда не видел.


  1. mastan
    21.09.2023 10:49
    +5

    Упоминание контейнеров STL также не спроста — итераторы нельзя использовать с C-массивами.

    А вот и неправда:

    int foobar(int n)
    {
        int a[] = {1, 2, 3, 5, 9};
        auto it = std::find(std::begin(a), std::end(a), n);
        if(it != std::end(a))
            return std::distance(std::begin(a), it);
        return -1;
    }
    


  1. ReadOnlySadUser
    21.09.2023 10:49
    +1

    На самом деле, на сегодняшний день уже вообще нет особого смысла использовать голые/сырые указатели (raw pointers).

    Core Guidelines с вами не согласен.

    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

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


  1. ReadOnlySadUser
    21.09.2023 10:49
    +1

    Учитывая, что указатель всегда хранит адрес памяти, его всегда можно преобразовать в целое число (которое и является адресом).

    Штош :) Рекомендую вам ознакомиться с интересной статьёй на тему указателей на методы класса)

    Pointers to member functions are very strange animals

    Ну или хотя бы заглянуть в этот топик на Stackoverflow. Вас ждёт сюрприз :)


  1. ReadOnlySadUser
    21.09.2023 10:49

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


  1. Goron_Dekar
    21.09.2023 10:49

    С++ - это про оптимизацию.

    С подходом, описанным в этой статье, надо писать на Java. Да, современные компиляторы часто справляются превратить смартпоинтеры в указатели обратно, или контейнеры в С массивы (которые при обходе быстрее). Но чтобы кмпилятор это мог сделать надо ему помогать. А высказывания в духе "не пользуйтесь сырыми указателями / С массивами никогда" вызывает вопрос: а зачем тогда пользоваться крестами?


    1. ReadOnlySadUser
      21.09.2023 10:49

      Правило #1 в плюсах - zero abstraction cost. Собственно из-за него многое в плюсах ТАК неудобно. Поэтому в подавляющем большинстве случаев совет, в целом, верный и пользование unique_ptr будет бесплатным.

      С shared_ptr сложнее, но этот указатель и не нужен практически никогда, а если нужен, нужно четырежды поднимать "а точно ли нужен?".

      Для замены С-массивам давно есть std::array, который в сочетании с CTAD из С++17 вроде даже удобно использовать (не обязательно вручную задавать размерность).

      И всё это в целом-то бесплатно. Так что ничего ужасного в совете как таковом нет. А вот в подаче да, проблема есть. В статье делается несколько ультимативных заявлений, без внятного объяснения, и, что хуже всего, без хороших примеров и рассмотрения крайних случаев. Что сильно снижает ценность статьи.


      1. unreal_undead2
        21.09.2023 10:49

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