*** ВНИМАНИЕ: Блог переехал на другой адрес - demin.ws ***

среда, 9 июня 2010 г.

Проблемы с delete[]

Имеем следующий код:

#define T 2

class A {
  public:
    virtual ~A() { 
      p = 0;
    }
    int p;
};

class B: public A {
  int a;
};

int main() {
  A* a = new B[T];
  delete[] a;
  return 0;
}

У меня эта программа однозначно падает с "Segmentation fault" на строке "delete[] a". Проверено на Sun C++ на Солярисе, GCC на Линуксе и на FreeBSD. Вот, например, что происходит на BSD:

Program received signal SIGSEGV, Segmentation fault.
0x08048743 in main () at new_array.cpp:17
17        delete[] a;

Забавно, что под Windows в VS2008 ничего особенного не происходит.

Как я понимаю, что в этой программе принципиально важно, чтобы она падала: деструктор класса "A" должен быть виртуальным, дочерний класс "B" должен быть больше по размеру (тут есть член "a"), константа "Т" должна быть 2 или более (то есть мы должны создавать несколько экземпляров класса "B"), и деструктор класса "A" должен что-нибудь писать в свои члены (тут есть "p = 0;").

Что же тут происходит?

new[] создает массив экземплятор класса "B". Оператор же delete[] получает на вход указатель типа "A*" и начинает вызывать деструкторы элементов. Так как деструктор класса "А" виртуальный, то в ход пускается таблица виртуальных функций. Итак, отработал деструктор для первого элемента a[0]. Далее delete[] хочет получить адрес следующего элемента массиве "a". И для этого (внимание!) адрес следующего он вычисляется так: "a + sizeof(A)" (ему же на вход дали указатель типа "A*"). Но проблема в том, что sizeof(A) < sizeof(B) (это дает член класса B::a), и "a + sizeof(A)" будет указывать не на второй элемент в массиве "a", а куда-то между первым и вторым элементом, так как реальный адрес второго элемента - "a + sizeof(B)". И все бы ничего, но деструктор класс "A" пишет в член "p", тем самым меняя содержимое памяти, а так как для второго элемента адрес вычислен неправильно (его this указывает непонятно куда), то куда реально попадет 0 в присваивании "p = 0;" уже никто не знает, но явно не туда, куда надо. Вот и "Segmentation fault".

Другого объяснения у меня нет.

Если кто знает лучше, поправьте.

P.S. Забавно, что под виндами ничего страшного не происходит.

Update: В комментариях дали точное объяснение из стандарта: C++ 2003 5.3.5:
...In the second alternative (delete array), the value of the operand of delete shall be the pointer value which resulted from a previous array new-expression. If not, the behavior is undefined. [Note: this means that the syntax of the delete-expression must match the type of the object allocated by new, not the syntax of the new-expression.]

Update 2: Объяснение, почему не глючит в Visual Studio.

21 комментарий:

  1. Забавно, только у меня не получилось воспроизвести на gcc.
    gcc версия 4.4.3 (Gentoo 4.4.3-r2 p1.2)
    На какой версии вы проверяли?

    ОтветитьУдалить
  2. У меня:

    Configured with: FreeBSD/i386 system compiler
    Thread model: posix
    gcc version 3.4.6 [FreeBSD] 20060305

    Хотя, конечно, gcc 3 уже староват.

    ОтветитьУдалить
  3. Воспроизводится: gcc (Exherbo gcc-4.4.4) 4.4.4

    Точно, указатель на a слыхом не слыхивал о размере B, хотя инициализируется массив правильно.

    $ ./test
    a ctor 0x8d1900c
    b ctor 0x8d1900c
    a ctor 0x8d19018
    b ctor 0x8d19018
    &a[0] 0x8d1900c
    &a[1] 0x8d19014 <-- fail

    Это еще один аргумент в пользу стандартных контейнеров. :)

    Я так полагаю что арифметика с указателями целиком досталась в наследство от си. Какой тип указателя - такой и размер записи.

    ОтветитьУдалить
  4. В GCC 4.4.4 (Debian 4.4.4-4) не воспроизводится.

    Если верить Страуструпу, при выделении памяти с помощью new, выделяется также дополнительная память о размере объекта, который нужно удалить. Поэтому, кстати, delete[] можно применять только к памяти, выделенной new. Так что проблем с удалением, описанных в объяснении, по идее быть не должно.

    ОтветитьУдалить
  5. все правильно, проблема состоит как раз в использовании различных типов. Стандарт говорит, что в этом случае сталкиваемся с undefined beaviour. C++ 2003 5.3.5:
    ...In the second alternative (delete array), the value of the operand of
    delete shall be the pointer value which resulted from a previous array new-expression. If not, the
    behavior is undefined. [Note: this means that the syntax of the delete-expression must match the type of the
    object allocated by new, not the syntax of the new-expression. ]

    ОтветитьУдалить
  6. А, нет… Проверил размеры объектов, оказались одинаковыми, видимо, из-за выравнивания. Добавил три поля типа double в B и стало сегфолтиться.

    Вот и верь после этого в полиморфизм на C++…

    ОтветитьУдалить
  7. girtablilu: Спасибо, теперь есть полное объяснение. Обновил пост.

    ОтветитьУдалить
  8. Как оказалось, в Visual Studio если при компиляции обнаруживается выделение памяти вида new Type[N], то в компилятор генерирует "vector deleting destructor" (можно комментировать в программе строку "A* a = new B[T];" - и видеть как меняется виртуальная таблица..)

    например для класса B:

    CppTest!B::`vector deleting destructor

    Данный деструктор знает размер элемента и делает правильный инкремент продвигаясь по всему массиву. Об этом есть у Raymond Chen (http://blogs.msdn.com/b/oldnewthing/archive/2004/02/03/66660.aspx)

    ОтветитьУдалить
  9. Мне кажется, что главная проблема - это отсутствие виртуального деструктора в B

    ОтветитьУдалить
  10. Интересная особенность на 64-битной Ubuntu (10.04): если компилировать программу с опциями по умолчанию, то ошибка не воспроизводится, а вот если добавить флаг компиляции -m32, то происходит segmentation fault. Выходит, что невоспроизведение ошибки на 64 битах -- это чистая случайность.

    gcc version 4.4.3 (Ubuntu 4.4.3-4ubuntu5)

    ОтветитьУдалить
  11. при 64 битах поля выравниваются, sizeof(A) == sizeof(B)

    ОтветитьУдалить
  12. class B: public A {
    double a;
    double b;
    double c;
    public:
    virtual ~B() {}
    };

    zlobsd% g++ -o fail fail.cpp && ./fail
    zsh: segmentation fault (core dumped) ./fail

    zlobsd% clang++ -o fail fail.cpp && ./fail && echo alive
    alive

    zlobsd% g++ --version |head -n1
    g++ (GCC) 4.2.1 20070719 [FreeBSD]

    zlobsd% clang++ --version |head -n1
    clang version 2.0 (trunk)

    zlobsd% uname -rsp
    FreeBSD 8.0-RELEASE amd64

    ОтветитьУдалить
  13. Вообще-то для получения проблем необязательно удалять. Достаточно выполнить какой-нибудь доступ к элементу "массива" a с индексом больше нуля.

    A* a = new B[T];
    a[1].p = 42; // запишет не туда

    То есть проблема собственно в строке выделения "A* a = new B[T];". Если над ней помедитировать, то можно догадаться, что она лишена смысла.

    ОтветитьУдалить
  14. Тестировал этот пример на AIX/xlc, HP-UX/aCC и SUN/SunStudio.

    Работает без коры. Может быть проблема в gcc ?

    ОтветитьУдалить
  15. Eugene K: Вообщем да. Не ясно, зачем создавать массив из "B", и затем приводить его к указателю на "A". Ведь после этого не получится его нормально итерировать вообще, не только в delete[].

    sanchosz: Если исзменить работу с памятью, сделать большие массивы внутри класса - будет падать. Это просто вопрос везения.

    ОтветитьУдалить
  16. Scott Meyers "More Effective C++: 35 New Ways to Improve Your Programs and Designs" Item 3: Never treat arrays polymorphically не та же опера разве?

    ОтветитьУдалить
  17. boguscoder: Да, Мейерс как раз про то же и говорит

    ОтветитьУдалить
  18. Пардон, ребята, что у Вас по ООП?
    причем тут вообще sizeof(A)?

    при создании объектов вызываются поочереди ВСЕ конструкторы базовых классов в порядке наследования от базового к текущему. при разрушении объектов, вызываются ВСЕ деструкторы в обратном порядке.

    Деструктор может быть виртуальным, но далеко не должен! иначе о чем можно говорить с деструкторами созданными by default?

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

    Любая виртуальная функция НЕ обязана быть перегруженной. в случае, если производный класс не перегрузил виртуальную функцию базового класса, он получает по наследству ближайшую её реализацию.

    бока компилляторов не нужно принимать за истинну.

    ОтветитьУдалить
  19. Александр,
    Не думаю, что это вопрос везения. Скорее это всеже ошибка компилятора. Пробывал gcc 3.1.6 и 4.1.2 - ошибка воспроизводится. На 4.4.4 (как сказано выше) уже нет. На других компиляторах, кроме gcc, я так понимаю такого поведения не замечено.

    На сколько я помни совет Майерса, он там говорит об урезании класса до базового при присваевании.

    ОтветитьУдалить
  20. Спасибо за очень интересный пост, проблема в принципе очевидная если над ней помедитировать.

    В Qt в классе QScopedArrayPointer (http://doc.qt.nokia.com/4.7-snapshot/qscopedarraypointer.html) проблема решена - неправильный код просто не скомпилируется.

    ОтветитьУдалить
  21. sanchosz, это не ошибка компилятора.
    Чтобы оперировать с данными, нужно знать их тип - иначе данные бесполезны, это очевидно. Поэтому присваивать указатель на массив объектов класса B в переменную типа A* - совершенно бессмысленная операция. Иначе говоря, это ошибка.

    ОтветитьУдалить