std::vector при нехватке памяти

Контейнер std::vector представляет собой динамический массив, автоматически увеличивающийся в размере по мере надобности. Он описан в любом приличном руководстве по С++. Там же описываются его методы, достоинства и недостатки по сравнению с другими контейнерами.

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

Дополнительная память выделяется во время работы трёх методов: resize, reserve и push_back. Вначале посмотрим, что по поводу них пишет стандарт (здесь и далее черновик С++11 №3337).

Стандарт

Метод reserve описан в пункте 23.3.6.3. Он ничего не возвращает. При слишком большом новом размере должен запускать исключение std::length_error. Однако здесь же есть сноска, говорящая о том, что данный метод вызывает Allocator::allocate, который может запустить иное исключение.

Метод resize описан в том же пункте. Так же ничего не возвращает. Про исключения не говорится ни слова. Но сказано, что метод присоединяет (appends) новые элементы. Правда не уточняется, использует ли он для этого метод push_back или обходится без него.

push_back описан в пункте 23.3.6.5. Ничего не возвращает. Про исключения запускаемые методом ничего не говорится.

Вот что мы имеем: reserve должен запускать std::length_error, но это на усмотрение распределителя памяти. resize и push_back остаются на совести разработчиков библиотеки.

Тестовая программа

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

#include <vector>
#include <limits>
#include <stdio.h>

int main()
{
  std::vector<int> vec;
  
  vec.resize(-1);
    
  vec.reserve(-1);
    
  for(size_t i = 0; i < std::numeric_limits<size_t>::max(); ++i)
    vec.push_back(i);
      
  return 0;
}

Здесь есть три места, в которых она должна «упасть». В методы resize и reserve подаются заведомо слишком большие значения. А в цикле вектор наполняется до тех пор, пока метод push_back не вызовет ошибку.

Наша задача — изменить этот код так, чтобы программа не «упала» до последней строчки.

GCC Linux

Компилятор GCC (реально g++, но это формальность) на системе Linux для методов resize и reserve запустил std::length_error, а последний push_back запустил std::bad_alloc. Таким образом доработанный вариант программы выглядит следующим образом:

#include <vector>
#include <stdexcept>
#include <limits>
#include <stdio.h>

int main()
{
  std::vector<int> vec;
  
  try
  {
    vec.resize(-1);
  } catch (const std::length_error&)
  {
    printf("std::length_error\r\n");
  }  
  
  try
  {
    vec.reserve(-1);
  } catch(const std::length_error&)
  {
    printf("std::length_error\r\n");
  }
  
  bool isBadAlloc = false;
  for(size_t i = 0; i < std::numeric_limits<size_t>::max(); ++i)
  try
  {
    vec.push_back(i);
  } catch (const std::bad_alloc&)
  {
    isBadAlloc = true;
    break;
  }
  
  vec.clear(); 
  
  if(isBadAlloc)
    printf("std::bad_alloc\r\n");
    
  return 0;
}

Данная программа выводит три строки с наименованиями пойманных исключений:

std::length_error
std::length_error
std::bad_alloc

Обратите внимание: функция printf вызывается после очистки вектора. Дело в том, что ей тоже нужна память, которую полностью «поглотил» наш вектор. Без нее она не может нормально вывести строку.

GCC Windows

GCC портированный на Windows для методов resize и reserve также запустил исключение std::length_error. А вот цикл наполнения вектора методом push_back привел к… полному зависанию операционной системы.

Тест проводился дважды. Программа компилировалась и запускалась на исполнение. После вывода строк

std::length_error
std::length_error

программа, а вслед за ней и вся система (Windows 10), «уходила в себя». После 15 минут ожидания, она так и не «очнулась». Не помогала даже комбинация Ctrl+Alt+Del. В итоге приходилось жать кнопку Reset на системном блоке.

C++ Builder 6

Данная IDE потребовала дополнительных исследований. Для нашего примера метод resize запустил исключение EAccessViolation, метод reserve не запустил никакого исключения, наполнение вектора также запустило EAccessViolation.

Согласно документации, исключение EAccessViolation возникает при попытке обращения к неверному адресу памяти. Получается, C++ Builder 6 запускает его и при нехватке памяти? Но почему тогда метод reserve не запустил никакого исключения?

Если наш пример написать так:

#include <vector>
#include <limits>
#include <stdio.h>

int main()
{
  std::vector<int> vec;
      
  vec.reserve(20);
    
  for(size_t i = 0; i < std::numeric_limits<size_t>::max(); ++i)
    vec.push_back(i);
      
  return 0;
}

То в этом случае метод push_back запустит std::bad_alloc, а не EAccessViolation. Почему такое разное поведение?

Ответ кроется в методе reserve. Обратите внимание: в исходном примере мы передавали туда отрицательное число -1, а в новом примере нормальное число 20. При этом в первом случае он не запустил никакого исключения. Как так? Неужели он смог зарезервировать память под -1 элемент вектора? В поисках ответа на этот вопрос мне пришлось покопаться в исходном коде класса std::vector.

Ни для кого не секрет, что современные компиляторы фирмы Borland (ныне принадлежат Embarcadero) используют свой распределитель памяти. А работает он, как оказывается, очень «интересным» образом. В частности, он позволяет «выделять» память под отрицательное количество байт. Как такое возможно, я не знаю. По всей видимости — ошибка в реализации. Но, факт остается фактом. Приведенный ниже код, несмотря на свою явную ошибочность нормально компилируется и не вызывает никаких вопросов ни со стороны компилятора, ни со стороны распределителя памяти.

char *p = new char[-1];

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

Именно это и происходит в наших примерах. Вернемся к первому из них.

Метод resize вначале выделяет память под нужное кол-во элементов. Разумеется ему это «удается». Далее он начинает ее заполнять. Со временем реально выделенная память кончается и тогда происходит EAccessViolation.

Метод reserve также выделяет память. Ему это «удается». Но так как никакого обращения к ней не происходит, ошибка остается незамеченной. То есть vec вроде бы как владеет достаточным количеством памяти, но это не так.

Далее происходит заполнение вектора. Так как с точки зрения вектора у него есть достаточное количество памяти, она больше не выделяется. И тут, как и в случае с resize, происходит обращение к недопустимому адресу.

Теперь разберем второй пример (с исключением std::bad_alloc).

Метод reserve резервирует память под 20 элементов. Всё хорошо.

Далее вектор начинает наполняться. Со временем размера массива начинает не хватать, и запрашивается дополнительная память. Здесь по всей видимости используется другой распределитель памяти, лишенный недостатка своего коллеги. Поэтому мы и получаем std::bad_alloc.

Embarcadero RAD Studio 10 и Microsoft Visual Studio 2017

К счастью для нас разработчики C++ Builder 6 не сидели сложа руки и исправили описанную выше странность (правда не могу сказать в какой конкретно версии своего продукта). Так, в Embarcadero RAD Studio 10 Seatle строка

char *p = new char[-1];

во время компиляции вызывает предупреждение: «array size is negative», а во время исполнения запускает исключение std::bad_alloc.

Вообще компиляторы, входящие в состав Emabarcadero RAD Studio 10 и Microsoft Visual Studio 2017 ведут себя схожим образом: для методов resize и reserve они запускают std::length_error, а для push_back — std::bad_alloc. В этом они повторяют поведение GCC в Linux.

Выводы

Все рассмотренные современные компиляторы при нехватке памяти для методов resize и reserve запускают std::length_error, а для метода push_back — std::bad_alloc. Хотя такое поведение и не регламентируется стандартом.

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

 

One reply

  1. Vvv:

    Память также выделяется при insert/emplace и emplace_back.
    Стандарт гарантирует что clear или resize не освобождает память, надо использовать shrink_to_fit (C++11)
    bad_alloc выбрасывает второй шаблонный аргумент вектора — std::allocator

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *