Блог пользователя AlexSkidanov

Автор AlexSkidanov, 12 лет назад, По-русски

Давече на ВКонтакте Николай Дуров в одном из обсуждений посоветовал книжку Inside C++ Object Model.

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

Вот сегодня я показал такой пример:

#include <stdio.h>

class Point
{
public:
    int a;
    Point() : a(5) { };
    virtual ~Point() { printf("%d\n", a); }
};

class Point3D : public Point
{
public:
    int b;
    Point3D() : b(7) { };
    virtual ~Point3D() { printf("M\n"); }
};

int main()
{
    Point* p = new Point3D[5];
    delete [] p;
    return 0;
}

И спросил их, что они ожидают увидеть.

Потратьте 5 минут чтобы предложить свой вариант ответа.

На самом деле для меня (и для тех кому я задал вопрос) кажется очевидным, что 5 раз выведется M5 (сначала вызовется виртуальный деструктор Point3D, затем виртуальный деструктор Point).

Согласно книге это не так. delete[] ничего не знает о полиморфизме, и вызовет деструктор Point. Более того, delete[] ничего не знает о том, что реальный размер объектов в массиве больше размера Point, а потому удалив первый элемент он сдвинется только на размер Point, а не на размер Point3D, тем самым вызвав второй декструктор на совершенно неверном участке памяти. Ребята мне не поверили О.О

Мы запустили код, и он вывел M5 пять раз.

Как же так, книга врет? И в тоже время -- я могу поверить, что компилятор догадался взять деструктор из виртуальной таблицы, но откуда он узнал реальный размер объекта?

Не веря в происходящее, я установил Clang, и собрал этот код clang'ом. Код вывел пять пятерок. Кажется, что в этом коде вывести пять пятерок просто невозможно -- это значит, что компилятор не догадался вызывать деструктор из виртуальной таблицы, но догадался сдвигаться на правильный размер?

Я думаю, что все опытные С++ гуру уже поняли что здесь происходит.

Проблема в том, что код запускался на 64-ех битной машине, и компилятор догадался поместить a и b в один восьмибайтный блок, таким образом размер класса Point и Point3D был одинаковый (16 байт -- еще 8 на указатель на виртуальную таблицу).

Теперь давайте поменяем int в определении обоих классов на long long. Каково будет поведение? Опять же, потратьте пять минут, чтобы придумать свой ответ, прежде чем читать дальше.

Чуда не происходит. Коду неоткуда узнать реальный размер объектов. Оба компилятора продолжают вести себя так же как и раньше (g++ вызывает виртуальный деструктор, а clang вызывает деструктор у Point), но только теперь размер Point не равен размеру Point3D. Таким образом clang выводит мусор в виде пятерок, семерок, и численного представления указателя на виртуальную таблицу, а g++ просто сразу падает по Segmentation Fault, не успев вывести ничего. Это странно, потому что виртуальная таблица лежит в самом начале класса, и не понятно, что именно не позволяет вызвать виртуальный декструктор хотя бы для первого элемента.

В любом случае, мораль простая -- массивы не работают с полиморфизмом, и, видимо, приведенный код -- это undefined behavior. Интересно, как этот же код поведет себя в Visual Studio и MigGW?

Вот код для запуска с отладочным выводом:

#include <stdio.h>

class Point
{
public:
    long long a;
    Point() : a(5) { };
    virtual ~Point() { printf("%lld\n", a); }
};

class Point3D : public Point
{
public:
    long long b;
    Point3D() : b(7) { };
    virtual ~Point3D() { printf("M\n"); }
};

int main()
{
    printf("%d\n", (int)sizeof(Point));
    printf("%d\n", (int)sizeof(Point3D));
    // это поможет понять где лежит указатель на виртуальную таблицу
    printf("%llu\n", &Point::a);
    printf("%llu\n", &Point3D::a);
    printf("%llu\n", &Point3D::b);

    Point* p = new Point3D[5];
    delete [] p;
    return 0;
}

Вывод для G++:

16
24
8
8
16
Segmentation fault

Вывод для clang:

16
24
8
8
16
4197968
5
7
4197968
5
  • Проголосовать: нравится
  • +74
  • Проголосовать: не нравится

»
12 лет назад, # |
Rev. 2   Проголосовать: нравится +4 Проголосовать: не нравится

В MSVS 2008 не падает, вывод: 16 24 8 8 16 M 5 M 5 M 5 M 5 M 5 Вывод совпадает для Win32 и x64, windows 7 64. В Win32 версии когда выводим &Point::a, нужно "%lu" а не "%llu"

  • »
    »
    12 лет назад, # ^ |
    Rev. 4   Проголосовать: нравится +4 Проголосовать: не нравится

    причем работает не смотря на сильно разный размер Point и Point3D

    class Point
    {
    public:
        long long a;
        Point() : a(5) { };
        virtual ~Point,2012-01-31() { printf("%lld\n", a);  printf("%ld\n", this); }
    };
    
    class Point3D : public Point
    {
    public:
        long long b, bb, bbb, zzz, fdfd, fff, kbb;
        Point3D() : b(7) { };
        virtual ~Point3D() { printf("M\n"); }
    };
    
    16
    72
    8
    8
    16
    M
    5
    112466724
    M
    5
    112466652
    M
    5
    112466580
    M
    5
    112466508
    M
    5
    112466436
    
    
    • »
      »
      »
      12 лет назад, # ^ |
      Rev. 3   Проголосовать: нравится +1 Проголосовать: не нравится

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

      А еще это отвечает на вопрос почему G++ падает сразу, не вызвав деструктор даже первого объекта. Потому что деструкторы вызываются начиная с последнего элемента (это хорошо видно в твоем выводе -- указатели уменьшаются).

      • »
        »
        »
        »
        12 лет назад, # ^ |
          Проголосовать: нравится +9 Проголосовать: не нравится

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

»
12 лет назад, # |
  Проголосовать: нравится +16 Проголосовать: не нравится

Весьма неожиданный способ выстрелить верёвкой.

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

»
12 лет назад, # |
  Проголосовать: нравится +8 Проголосовать: не нравится

и, видимо, приведенный код – это undefined behavior

Да, это действительно так. 5.3.5/3 (правда по уже устаревшему стандарту 2003 года, последнего нет сейчас под рукой) ...In the second alternative (delete array) if the dynamic type of the object to be deleted differs from its static type, the behavior is undefined.

»
12 лет назад, # |
Rev. 2   Проголосовать: нравится 0 Проголосовать: не нравится

Противоречит Страуструпу 12.4.2 :( Там ни слова о зависимости от компилятора. А будет ли такой массив вообще корректно индексироваться ? Не логичней было бы point** ?

  • »
    »
    12 лет назад, # ^ |
      Проголосовать: нравится +5 Проголосовать: не нравится

    Да, такой массив и индексируется некорректно. В Builder 6 (32 разряда) для

    for (int i = 0; i < 5; ++i) printf("%d ", p[i].a);

    выводит

    5 4282896 7 5 4282896 4282896

    • »
      »
      »
      12 лет назад, # ^ |
      Rev. 2   Проголосовать: нравится 0 Проголосовать: не нравится

      Ну то что индексируется некорректно, это понятно. Простая адресная арифметика

      T* p; p + 1 == (T*)((char*)p + sizeof(T)).
      
  • »
    »
    12 лет назад, # ^ |
    Rev. 2   Проголосовать: нравится 0 Проголосовать: не нравится

    Кстати, если убрать виртуальные функции, то Builder все равно работает предсказуемо неверно: на

    for (int i = 0; i < 5; ++i) printf("%d ", p[i].a);

    выводит

    5 7 5 7 5

    А MSVS 2008 на этом нормально работает? И где тогда может храниться длина объекта, если указателя на таблицу виртуальных методов в объектах вообще нет? Или всё-таки есть, различается длина объекта с virtual и без него?

    • »
      »
      »
      12 лет назад, # ^ |
      Rev. 7   Проголосовать: нравится +3 Проголосовать: не нравится

      Это верно. В VS точно также сработает. См. комментарий выше. Без виртуальности у нас просто нету указателей на таблицу виртуальных функций, но суть остается прежней. Есть у нас указатель на массив класса Point3D, наследуемого от Point.

      Point3D* p3 = new Point3D[5];
      

      здесь у нас выделится памяти под пять объектов, и они будут расположены в памяти последовательно. Point3D* p = p3; здесь произойдет преобразование указателя к типу предку, и фактически адрес в двочином виде может даже измениться. Если например Point3D объявлен как

      class Point3D: public Class1, Point
      

      Если мы сделаем p = reinterpret_cast<Point*>(reinterpret_cast<char*>(p) + sizeof(Point3d)) мы попадём в объект Point, объекта Point3D находящегося в p3[1]. А если мы сделаем p++, то попадём пальцем в жопу.

      • »
        »
        »
        »
        12 лет назад, # ^ |
          Проголосовать: нравится 0 Проголосовать: не нравится

        пруф, смотреть на 4-ую и последнюю строчки.

      • »
        »
        »
        »
        12 лет назад, # ^ |
          Проголосовать: нравится 0 Проголосовать: не нравится

        Спасибо, это понятно, и вопрос не в этом.

        То, что printf(“%d ”, p[i].a) не должно работать нигде, мне понятно. Но почему тогда в MSVS работает delete[]? Правильно по объектам бежит, виртуальный деструктор вызывает.

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

        • »
          »
          »
          »
          »
          12 лет назад, # ^ |
            Проголосовать: нравится 0 Проголосовать: не нравится

          Я думаю они запоминают как именно выделилась память и соответствующим образом её высвобождают. Другой вопрос как они вообще узнают этот самый адрес, в случае виртуального класса еще более менее понятно, а в случае "структур" вообще не ясно. по стандарту, если выделил память используя int*p = new int нужно удалить как delete p, если выделил как int* p = new int[5] нужно удалить как delete []p если мешать версии со скобочками и без, то это undefined behaviour, но если MS хотят сделать чтобы работало и так и так, им никто не запрещает. Это конечно разные вещи, и как раз этот случай я не тестировал. Зачем нужен массив к которому нельзя обращаться по индексу, нужно спросить у автора статьи.

          • »
            »
            »
            »
            »
            »
            12 лет назад, # ^ |
              Проголосовать: нравится +2 Проголосовать: не нравится

            В принципе, действительно, достаточно хранить внутри участка в куче еще длину объекта (кроме длины участка, ссылки на следующий и т.п.), чтобы полностью корректно реализовать delete[]. Структура кучи ведь не стандартизована, надеюсь :)

»
12 лет назад, # |
  Проголосовать: нравится +8 Проголосовать: не нравится

Привет,

Я думаю все дело в том, что в C++ полиморфизм работает только для указателей и ссылок. А если объект передается по значению, то полиморфизм не пашет. Я немножко переделал код и сделал следующий пример:

#include <cstdio>

class Point
{
public:
    long long a;
    Point() : a(5) { };
    virtual void print() {
    	printf("I'm a Point\n");
    }
};

class Point3D : public Point
{
public:
    long long b;
    Point3D() : b(7) { };
    virtual void print() {
    	printf("I'm a 3D Point\n");
    }
};

int main()
{
	{ // Полиморфизм пашет, так как b -- ссылка
	    Point3D a;
	    Point &b = a;
	    b.print();
	}

	{ // Полиморфизм не пашет, так как b -- просто объект базового класса
	    Point3D a;
	    Point b = a;
	    b.print();
	}

	const int N = 5;

	{ // Полиморфизм у динамического массива из указателей на объекты пашет
	    Point** a = new Point*[N];
	    for (int i = 0; i < N; ++i) {
	    	a[i] = new Point3D();
	        a[i]->print();
	    }
	}

	if (false) { // Полиморфизм у динамического массива из объектов не пашет 
                     // (у меня крэш в g++, наверное из-за невозможности индексации),
                     // так как он не пашет у объектов передаваемых по значению
	    Point* a = new Point3D[N];
	    for (int i = 0; i < N; ++i) {
	        a[i].print();
	    }
	}

    return 0;
}

Точно так же, не будет нормально работать вектор из базовых классов:

std::vector<Point> v;

Надо использовать вектор указателей (вектор ссылок создать в с++ невозможно)

std::vector<Point*> v;

Получается, что массив/вектор/контейнер базовых классов в C++ -- это конструкция, которая должна работать неправильно. Если же оно как-то работает с полиморфизмом, в некоторых компиляторах, то это может быть связанно с какой-нибудь дополнительной информацией, которая может храниться, например в Debug режиме.

  • »
    »
    12 лет назад, # ^ |
    Rev. 3   Проголосовать: нравится +3 Проголосовать: не нравится

    // Полиморфизм не пашет, так как b -- просто объект базового класса
    все пашет. ты не очень понимашь, что происходит. присваиванием Point b = a; объект базового класса Point, находящийся в объекте a (Point3d) копируется в b посредством конструктора копирования. понятно, что вызовы к методам b и не должны приводить, к вызову методов объекта a.
    // Полиморфизм у динамического массива из объектов не пашет // (у меня крэш в g++, наверное из-за невозможности индексации)
    невозможность индексации объясняется адресной арифметикой. 2+2=4. посмотри мои комментарии выше.

    • »
      »
      »
      12 лет назад, # ^ |
      Rev. 2   Проголосовать: нравится +5 Проголосовать: не нравится

      А за что минусы? goo.gl_SsAhv же правильно сказал. Point a = b; это вызов конструктора копирования. Поскольку он имеет вид Point(const Point &a), b неявно преобразуется в Point, после чего выполняется копирование одного Point-a в другой. В случае же Point &a = b; мы говорим компилятору "адрес a принадлежит b". Поскольку тут новой переменной не создается, получается как с указателями — работа с производным классом через интерфейсы базового.

      • »
        »
        »
        »
        12 лет назад, # ^ |
        Rev. 2   Проголосовать: нравится 0 Проголосовать: не нравится

        Именно. При вызове конструктора копирования не происходит копирования указателя на таблицу виртуальных функций.

        Point(const Point& p)
        {
        	memcpy(this, &p, sizeof(Point));
        }
        

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

      • »
        »
        »
        »
        12 лет назад, # ^ |
          Проголосовать: нравится -8 Проголосовать: не нравится

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

        • »
          »
          »
          »
          »
          12 лет назад, # ^ |
          Rev. 3   Проголосовать: нравится +4 Проголосовать: не нравится

          Под "полиморфизм не работает" вы подразумеваете то, что должен быть вызван метод потомка, и не вызывается. Я пояснил на примерах почему такое понимание неверное. Если он "сработает", то деструктор объекта b уничтожит весь класс потомок a. Он говорит "мой комп не работает", а ему отвечаю "да работает все, просто ты не заметил, что электричество отключено потому-то". Из этого диалога можно подумать, что я утверждаю будто комп работает без электричества, но надеюсь большинство все таки поняли меня правильно, что комп исправен.

          • »
            »
            »
            »
            »
            »
            12 лет назад, # ^ |
              Проголосовать: нравится 0 Проголосовать: не нравится

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

            "не пашет", "не правильно работает", в данном контексте имеется в виду, что код не будет делать то, что интуитивно хочется программисту. А ожидает он (программист), что у него есть контейнер(массив) объектов базового класса, каждый элемент которого может являться экземпляром любого класса-наследника. И при вызове виртуальной функции будет вызываться именно реализация конкретного класса-наследника. Однако чтобы так было, контейнер должен хранить указатели на объекты базового типа.

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

            • »
              »
              »
              »
              »
              »
              »
              12 лет назад, # ^ |
              Rev. 2   Проголосовать: нравится 0 Проголосовать: не нравится

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

              1. Если у нас stl-контейнер параметризован типом T, то элементы этого контейнера не могут являться экземплярами классов-наследников T. Это ведь понятно, да?
              2. Если первый пункт понятен, то с чего вдруг программист должен интуитивно ожидать некое полиморфное поведение от объектов одинакового и вполне конкретного типа?
              • »
                »
                »
                »
                »
                »
                »
                »
                12 лет назад, # ^ |
                  Проголосовать: нравится 0 Проголосовать: не нравится

                Со всем согласен.

                Да, аналогия неудачная. Мне сначала показалось, что создание динамического массива аналогично использованию вектора из stl. И мое заблуждение привело к тому, что можно смотреть на строчку:

                Point *p = new Point3D[5];
                

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

                Признаю свой прокол :)