Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:

Сабуров С.В. - Язык программирования C и C++ - 2006

.pdf
Скачиваний:
312
Добавлен:
13.08.2013
Размер:
1.42 Mб
Скачать

Трюки программирования

Что мы видим на этот раз? А вот что. MP — это «ведущий» указатель, т.е. такой класс, который держит в себе объект основного класса и не дает его наружу. Появляется только вместе с ним и умирает аналогично. Его главная цель, это реализовывать механизм подсчета ссылок.

В свою очередь H — это класс очень похожий на SP, но общается не с объектом основного класса, а с соответствующим ему MP.

Результат этого шаманства очевиден — мы можем использовать механизм «умных» указателей для классов не добавляя лишнего кода в их реализацию.

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

Что же можно сказать о многопоточности и множественном наследовании: при использовании классов MP и H — нет поддержки этих двух вещей. И если с первой все понятно (нужно наследовать MP от Lockable), то со вторым сложнее.

Итак, посвятим немного времени описанию еще одного типа указателей, которые призваны «решить» проблему множественного наследования (а точнее полиморфизма).

Рассмотрим классы PP и TP:

class PP : public AutoDestroyable {};

template<class T> class TP

{

protected:

T* m_pObject;

PP* m_pCounter;

public: TP ();

TP ( T* pObject );

TP<T>& operator = ( TP<T>& tp ); T* operator > ();

T& operator * (); operator T* ();

387

Трюки программирования

bool operator == ( TP<T>& tp );

};

Класс PP является «фиктивным ребенком» AutoDestroyable и вы не забивайте себе этим голову. А вот класс TP можно посмотреть и попристальнее.

Схема связей в этом случае выглядит уже не H >MP >Obj, а PP< TP >Obj, т.е. Счетчик ссылок (а в данном случае, это PP) никак не связан с основным объектом или каким либо другим и занимается только своим делом — ссылками. Таким образом, на класс TP ложится двойная обязанность: выглядеть как обычный указатель и отслеживать вспомогательные моменты, которые связаны со ссылками на объект.

Как же нам теперь использовать полиморфизм? Ведь мы хотели сделать что то вроде:

class A : public B

...

TP<A> a; TP<B> b;

a = new B; b = (B*)a;

...

Для этого реализуем следующую функцию (и внесем небольшие изменения в класс TP для ее поддержки):

template <class T, class TT>

TP<T> smart_cast ( TP<TT>& tpin );

Итак, теперь можно написать что то вроде (и даже будет работать):

class A : public B

...

TP<A> a; TP<B> b;

a

=

new B;

b = smart_cast<B, A>(a);

//

 

или если вы используете Visual C++, то даже

b

=

smart_cast<B>(a);

...

388

Трюки программирования

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

Виртуальные деструкторы

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

Итак, рассмотрим небольшой пример: class A

{

public:

virtual void f() = 0; ~A();

};

class B : public A

{

public:

virtual void f(); ~B();

};

Вызов компилятора gcc строкой: g++ c Wall test.cpp

даст следующий результат:

test.cpp:6: warning: `class A' has virtual functions but non virtual destructor

test.cpp:13: warning: `class B' has virtual functions but non virtual destructor

Это всего лишь предупреждения, компиляция прошла вполне успешно. Однако, почему же gcc выдает подобные предупреждения?

Все дело в том, что виртуальные функции используются в C++ для обеспечения полиморфизма — т.е., клиентская функция вида:

389

Трюки программирования

void call_f(A* a)

{

a >f();

}

никогда не «знает» о том, что конкретно сделает вызов метода f() — это зависит от того, какой в действительности объект

представлен указателем a. Точно так же сохраняются указатели на объекты:

std::vector<A*> a_collection; a_collection.push_back(new B());

В результате такого кода теряется информация о том, чем конкретно является каждый из элементов a_collection (имеется в виду, без использования RTTI). В данном случае это грозит тем, что при удалении объектов:

for(std::vector<A*>::iterator i = ... ) delete *i;

все объекты, содержащиеся в a_collection, будут удалены так, как будто это — объекты класса A.

В этом можно убедиться, если соответствующим образом определить деструкторы классов A и B:

inline A::~A()

{

puts("A::~A()");

}

inline B::~B()

{

puts("B::~B()");

}

Тогда выполнение следующего кода:

A* ptr = new B(); delete ptr;

приведет к следующему результату:

A::~A()

Если же в определении класса A деструктор был бы сделан виртуальным (virtual ~A();), то результат был бы другим:

B::~B()

A::~A()

390

Трюки программирования

В принципе, все сказано. Но, несмотря на это, очень многие программисты все равно не создают виртуальных деструкторов. Одно из распространенных заблуждений — виртуальный деструктор необходим только в том случае, когда на деструктор порожденных классов возлагаются какие то нестандартные функции; если же функционально деструктор порожденного класса ничем не отличается от деструктора предка, то делать его виртуальным совершенно необязательно. Это неправда, потому что даже если деструктор никаких специальных действий не выполняет, он все равно должен быть виртуальным, иначе не будут вызваны деструкторы для объектов членов класса, которые появились по отношению к предку. То есть:

#include <stdio.h>

class A

{

public:

A(const char* n); ~A();

protected:

const char* name;

};

inline A::A(const char* n) : name(n) { }

inline A::~A()

{

printf("A::~A() for %s.\n", name);

}

class B

{

public:

virtual void f(); B();

~B();

protected: A a1;

};

391

Трюки программирования

inline B::~B() { }

inline B::B() : a1("a1") { }

void B::f() { } class C : public B

{

public:

C();

protected: A a2;

};

inline C::C() : a2("a2") { }

int main()

{

B* ptr = new C(); delete ptr; return 0;

}

Компиляция данного примера проходит без ошибок (но с предупреждениями), вывод программы следующий:

A::~A() for a1

Немного не то, что ожидалось? Тогда поставим перед названием деструктора класса B слово virtual. Результат изменится:

A::~A() for a2 A::~A() for a1

Сейчас вывод программы несколько более соответствует действительности.

Запись структур данных в двоичные файлы

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

392

Трюки программирования

данные или, наоборот, восстановить состояние прошлой сессии работы с программой.

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

Итак, как все это выглядит обычно? Имеется некоторая структура данных:

struct data_item

{

type_1 field_1; type_2 field_2; // ...

type_n field_n;

};

data_item i1;

Каким образом, например, сохранить информацию из i1 так, чтобы программа во время своего повторного запуска, смогла восстановить ее? Наиболее частое решение следующее:

FILE* f = fopen("file", "wb"); fwrite((char*)&i1, sizeof(i1), 1, f); fclose(f);

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

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

FILE* f = fopen("file", "rb"); fread((char*)&i1, sizeof(i1), 1, f); fclose(f);

Что же тут неправильного? Ну что же, для этого придется немного пофилософствовать. Как бы много не говорили о том, что Си — это почти то же самое, что и ассемблер, не надо забывать, что он является все таки языком высокого уровня. Следовательно, в принципе, программа написанная на Си (или C++) может (теоретически) компилироваться на разных

393

Трюки программирования

компиляторах и разных платформах. К чему это? К тому, что данные, которые сохранены подобным образом, в принципе не переносимы.

Стоит вспомнить о том, что для структур неизвестно их физическое представление. То есть, для конкретного компилятора оно, быть может, и известно (для этого достаточно посмотреть работу программы «вооруженным взглядом», т.е. отладчиком), но о том, как будут расположены в памяти поля структуры на какой нибудь оригинальной машине, неизвестно. Компилятор со спокойной душой может перетасовать поля (это, в принципе, возможно) или выровнять положение полей по размеру машинного слова (встречается сплошь и рядом). Для чего? Для увеличения скорости доступа к полям. Понятно, что если поле начинается с адреса, не кратного машинному слову, то прочитать его содержимое не так быстро, как в ином случае. Таким образом, сохранив данные из памяти в бинарный файл напрямую мы получаем дамп памяти конкретной архитектуры (не говоря о том, что sizeof совершенно не обязан возвращать количество байт).

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

Обычный «костыль», который применяется, например, при проблемах с выравниванием, заключается в том, что компилятору явно указывается как надо расставлять поля в структурах. В принципе, любой компилятор дает возможность управлять выравниванием. Но выставить одно значение для всего проекта при помощи ключей компилятора (обычно это значение равно 1, потому что при этом в сохраненном файле не будет пустых мест) нехорошо, потому что это может снизить скорость выполнения программы. Есть еще один способ указания компилятору размера выравнивания, он заключается в использовании директивы препроцессора #pragma. Это не оговорено стандартом, но обычно есть директива #pragma pack, позволяющая сменить выравнивание для определенного отрезка исходного текста. Выглядит это обычно примерно так:

394

Трюки программирования

#pragma pack(1) struct { /* ... */ }; #pragma pack(4)

Последняя директива #pragma pack(4) служит для того, чтобы вернуться к более раннему значению выравнивания. В принципе, конечно же при написании исходного текста никогда доподлинно заранее неизвестно, какое же было значение выравнивания до его смены, поэтому в некоторых компиляторах под Win32 есть возможность использования стека значений (пошло это из MS Visual C++):

#pragma pack(push, 1) struct { /* ... */ }; #pragma pack(pop)

В примере выше сначала сохраняется текущее значение выравнивания, затем оно заменяется 1, затем восстанавливается ранее сохраненное значение. При этом, подобный синтаксис поддерживает даже gcc для win32 (еще стоит заметить, что, вроде, он же под Unix использовать такую запись #pragma pack не дает). Есть альтернативная форма #pragma pack(), поддерживаемая многими компиляторами (включая msvc и gcc), которая устанавливает значение выравнивания по умолчанию.

И, тем не менее, это не хорошо. Опять же, это дает очень интересные ошибки. Представим себе следующую организацию исходного текста. Сначала заголовочный файл inc.h:

#ifndef __inc_h__ #define __inc_h__

class Object

{

// ...

};

#endif // __inc_h__

Представьте себе, что существуют три файла file1.cpp, file2.cpp и file2.h, которые этот хидер используют. Допустим, что в file2.h находится функция foo, которая (например) записывает

Object в файл:

// file1.cpp #include "inc.h"

395

Трюки программирования

#include "file2.h"

int main()

{

Object* obj = new Object(); foo(obj, "file");

delete obj;

return 0;

}

// file2.h

#ifndef __file2_h__ #define __file2_h__

#pragma pack(1)

#include "inc.h"

void foo(const Object* obj, const char* fname);

#pragma pack(4)

#endif // __file2_h__

// file2.cpp #include "file2.h"

void foo(const Object* obj, const char* fname)

{

// ...

}

Это все скомпилируется, но работать не будет. Почему? Потому что в двух разных единицах компиляции (file1.cpp и file2.cpp) используется разное выравнивание для одних и тех же структур данных (в данном случае, для объектов класса Object). Это даст то, что объект переданный по указателю в функцию foo() из функции main() будет разным (и, конечно же, совсем неправдоподобным). Понятно, что это явный пример «плохой» организации исходных текстов — использование директив

396

Соседние файлы в предмете Программирование на C++