Интеллектуальные указатели
дата публикации: 2016-10-31

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

class A {
public:
  A();
  ~A();
};

A::A() {
  cout << "Constructor" << endl;
}

A::~A() {
  cout << "Destructor" << endl;
}

int main() {
  A* obj1;
  obj1 = new A();
  cout << "Action" << endl;
  delete obj1;
  return 0;
}

Результатом исполнения кода будет:

Constructor
Action
Destructor

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

  • unique_ptr - интеллектуальный указатель, который не позволяет иметь двух хозяев у одного ресурса;
  • shared_ptr - интеллектуальный указатель, который хранит не только ресурс, но и всех хозяев этого ресурса;
  • weak_ptr - интеллектуальный указатель, который зачастую применяется совместно с shared_ptr и позволяет проверять последний указатель на валидность прежде чем работать с ним (рекомендуется использовать всегда, когда у одного объекта существует несколько хозяев).

Для демонстрации типичного примера использования unique_ptr заменим метод main() в предыдущем примере на следующий метод (результат будет тот же):

int main() {
  unique_ptr<A> obj1 = make_unique<A>();
  cout << "Action" << endl;
  unique_ptr<A> obj2;
  //obj2 = obj1; //невозможная операция
  obj2 = move(obj1); //возможная операция
  return 0;
}

Результат будет тот же, но теперь программисту не требуется заботиться об освобождении памяти. Невозможность операции копирования obj2 = obj1 лежит в основе концепции unique_ptr, однако вполне возможна операция перемещения obj2 = move(obj1). Следует отметить, что возможность выделения памяти посредством make_unique появилась только в С++14.

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

class A {
  int i;
public:
  A(int i = 0);
  ~A();
  void setI(int i);
  int getI() const;
};

A::A(int i) {
  this->i = i;
  cout << "Constructor" << endl;
}

A::~A() {
  cout << "Destructor" << endl;
}

void A::setI(int i) {
  this->i = i;
}

int A::getI() const {
  return i;
}

int main() {
  shared_ptr<A> obj1 = make_shared<A>(10);
  cout << "Action" << endl;
  shared_ptr<A> obj2;
  //obj2 = obj1; // можем копировать
  obj2 = move(obj1); // можем перемещать
  cout << obj2->getI() << endl;
  obj2->setI(20);
  cout << obj2->getI() << endl;
  return 0;
}

Результатом исполнения кода будет:

Constructor
Action
10
20
Destructor

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

Теперь самое сложное и интересное - weak_ptr. Данный тип указателя рекомендуется применять, когда возможны случаи циклических связей. Конечно это не самая лучшая практика программирования, но для целостности картины интеллектуальных указателей рассмотрим и этот случай. Представьте себе два объекта, которые содержат в себе ссылки друг на друга, таким образом, получается что, при удалении этих объектов, мы рискуем зациклить вызов деструкторов и не удалить объекты. Чтобы такого не произошло, мы должны коим-то образом помечать указатели, которые необходимо удалять, а которые нет. Поэтому, если мы копируем указатель в другой указатель типа weak_ptr, счетчик ссылок не увеличивает свое значение и мы не получим ошибок при удалении в дальнейшем. Приведу пример:

class A {
  weak_ptr<A> other;
  //shared_ptr<A> other;
public:
  A();
  ~A();
  void setOther(weak_ptr<A> other);
  //void setOther(shared_ptr<A> other);
};

A::A() {
  cout << "Constructor A" << endl;
}

A::~A() {
  cout << "Destructor A" << endl;
}

void A::setOther(weak_ptr<A> other) {
  this->other = other;
  cout << "A contains other A object" << endl;
}
/*
void A::setOther(shared_ptr<A> other) {
  this->other = other;
  cout << "A contains other A object" << endl;
}
*/
int main() {
  shared_ptr<A> obj1;
  shared_ptr<A> obj2;
  cout << "----------" << endl;
  obj1 = make_shared<A>();
  obj2 = make_shared<A>();
  obj1->setOther(obj2);
  obj2->setOther(obj1);
  cout << "----------" << endl;
  return 0;
}

Результат работы кода:

----------
Constructor A
Constructor A
A contains other A object
A contains other A object
----------
Destructor A
Destructor A

Небольшие пояснения по коду. Объявляется класс, содержащий внутри себя указатель на другой экземпляр. Внутри main() мы создаем указатели, выделяем под них память, устанавливаем указатель на соседа, а затем они автоматически удаляются из памяти. В классе вы можете видеть дублирующие методы и переменные с типом shared_ptr, они закомментированы, если их раскомментировать и запустить код, то ввиду проблем с циклическими связями, деструкторы не будут вызваны. Данный пример наглядно показывает как, используя weak_ptr, можно организовать корректную очистку памяти. Мне это чем-то напомнило виртуальные деструкторы. Вывод по статье достаточно простой - интеллектуальные указатели являются неотъемлимой частью современного программирования на С++, особенно в больших проектах, где вероятность ошибки работы с памятью особенно высока.