?

Log in

No account? Create an account
программистское - Поклонник деепричастий [entries|archive|friends|userinfo]
Anatoly Vorobey

[ website | Website ]
[ userinfo | livejournal userinfo ]
[ archive | journal archive ]

Links
[Links:| English-language weblog ]

программистское [фев. 14, 2005|09:12 am]
Anatoly Vorobey
Несколько часов вчера боролся с довольно таинственным багом в C++.
В конце концов разобрался.
Нижеследующий отчёт будет понятен только программистам, знающим C++ и Юникс, но не факт, что интересен даже им.


Всё началось со следующей ошибки во время постройки проекта: компоновщик (??? или как его называют?) ругается
undefined reference to `[имя класса] virtual table'

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

Предположим, у вас есть какой-то заголовок foo.h . В нём объявлен какой-нибудь класс foo и у этого класса есть всякие методы, в том числе виртуальные; предположим, есть виртуальные методы ~foo() (деструктор), foo1() и foo2(). Ясно, что тела этих методов вовсе необязательно должны лежать в одном и том же файле .cpp . Может быть, например, так, что foo::~foo() и foo::foo1() определены в файле foo1.cpp, а foo2() определён в файле foo2.cpp . Компилятор их правильно скомпилирует в foo1.o и foo2.o, а компоновщик совместит вместе.

Но теперь возникает вопрос: где будет лежать virtual table данного класса? Это объект, который должен появиться только в одном из объектных файлов, чтобы можно было правильно построить программу. Компилятор, имея полную информацию о классе из декларации в заголовке, может поставить виртуальную таблицу куда угодно, но ему нужно выбрать один из объектных файлов - в данном случае foo1.o или foo2.o (ясно, что если у класса нет виртуальных методов вообще, то нет и виртуальной таблицы, и эта проблема не возникает).

На этот случай у gcc существует следующее правило. Он смотрит на первый не-inline виртуальный (но не pure virtual) метод в декларации класса, и вставляет виртуальную таблицу при компиляции того файла, в котором именно этот метод определён. См. этот FAQ.

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

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

А тут надо отметить, что мой C++-файл строится в данном проекте следующим образом. Сначала он пропускается через препроцессор (g++ с опцией -E, приказывающей делать только preprocessing) и таким образом все макросы препроцессора выполняются, и все заголовки схлопываются вместе с исходным файлом в один огромный файл. Потом этот огромный файл пропускается через определённый прекомпилятор, преобразовывающий SQL-директивы, сидящие прямо внутри C++-кода, во всякие внутренние вызовы SQL-библиотеки. А потом полученный чистый C++-код ещё раз пропускается через g++, уже в качестве компилятора.

(первый шаг - пропускание через препроцессор - нужен для того, чтобы прекомпилятор базы данных видел все нужные ему определения разных типов из разных заголовков. Бывают прекомпиляторы embedded SQL, которые сами умеют отслеживать #include и пробегать иерархию заголовков, но не все это умеют, и там тоже есть свои сложности... короче говоря, этот аспект процесса постройки фиксирован и я не могу его изменить)

Вообще говоря, я не видел в этом никакой проблемы, в том, что ещё перед компиляцией все заголовки схлопываются в один файл препроцессором в качестве отдельного шага. Это не должно вроде бы ничему мешать с точки зрения C++, так в чём проблема? Всё равно ведь язык устроен так, что #include "как бы" втягивает заголовок в текущий файл, пусть даже внутри компилятора это может быть устроено по-другому.

Я был слишком наивен.

Оказалось, что некоторые из стандартных заголовков C++, которые втягивал мой исходный файл - например, стандартные GCC-шные <new.h> и <exception.h> - включали в себя незнакомую мне до сих пор директиву #pragma interface. Оказывается, есть две директивы, работающие только в gcc и придуманные его разработчиками - #pragma interface и #pragma implementation. Работают они следующим образом. Если в заголовке написано #pragma interface, это значит, что большинство объектных файлов, построенных из исходников, включающих этот файл, не будут содержать всякую ненужную для них информацию о классах, определённых в этом файле - как-то копии inline-методов, информацию для отладчика или виртуальную таблицу. Только тот исходник, который включает в себя данный заголовок, но также пишет у себя до этого #pragma implementation, будет включать в себя все эти прекрасные вещи. Подробности здесь.

Что же выходит? Если мой C++-файл пропускается сразу через компилятор, то компилятор видит #pragma interface внутри служебного файла <new.h>, например, и его это не волнует, эта директива относится только к классам, определённым в new.h . Но если я сначала схлопнул все заголовки и мой исходник в один большой файл, то теперь компилятор, увидев в нём #pragma interface, решает, что в объектный файл не следует ставить виртуальную таблицу моего класса. И не ставит. И мне каюк.

Может, я действительно слишком наивен, но это весьма нетривиальное и совершенно невидимое обычному глазу, если не искать долго и нудно, отступление от принципа "#include - то же самое, что вставить в тело файла тот же текст" меня раздражает.

Как мне решить эту проблему? Чистого, простого решения нет. Если я в своём файле поставлю #pragma implementation, пытаясь подсказать компилятору, что мне нужны-таки виртуальные таблицы и прочие прелести, то всё зависит от того, где эта #pragma implementation окажется в большом файле относительно нескольких #pragma interface, загруженных из заголовков. Если до них - приведёт к ошибкам в заголовках библиотек, использующих #pragma interface и опирающихся на это. Если после них - не будет иметь никакого влияния. Пытаясь понять, почему, я залез в исходники gcc (брр, не рекомендую). Ключевой файл - gcc/cp/lex.c . Из него понятно, как устроена обработка этих директив внутри компилятора. Когда компилятор видит #pragma implementation, он включает имя текущего файла в список файлов, на которые #pragma interface не может действовать. Когда он видит #pragma interface, он проверяет этот список, и в зависимости от того, есть ли там имя текущего файла, он ставит/сбрасывает определённый флажок INTERFACE_ONLY, который потом запретит ему выдавать в объектный файл виртуальную таблицу и прочую подобную информацию о классах, объявленных в текущем файле. Поэтому, если я поставлю #pragma implementation в своём C++-файле уже после всех #include-ов, это ни к чему не приведёт.

Это расследование помогло придумать всё-таки некий workaround, ужасно уродливый, который, однако, работает. А именно, после всех #include-ов, в своём .cpp файле, я пишу сначала #pragma implementation, а потом #pragma interface. Это совершенно противоречит смыслу данных директив, но внутри gcc это приводит к тому, что вторая, бессмысленная, казалось бы, из этих директив, заставляет компилятор заново просмотреть список запрещённых файлов, куда первая директива внесла текущий файл, и сбросить флажок INTERFACE_ONLY, после чего он вставляет в объектный файл нужные данные, и всё строится на-ура.

Но: это уродливо, исключительно obscure, опирается на внутренности gcc. Но: я не знаю, как это сделать по-другому, не вмешиваясь в процесс постройки. Возможно, я всё-таки решу, что лучше в него вмешаться, и, например, после первого пропуска через препроцессор прогнать файл через фильтр и силой вытащить из него все #pragma interface. Тоже не ахти решение, впрочем. Не знаю.
СсылкаОтветить

Comments:
[User Picture]From: gaius_julius
2005-02-14 08:28 am
"этот аспект процесса постройки фиксирован и я не могу его изменить".

На самом деле самым "правильным", на мой скромный и неопытный взгляд, решением было-бы как раз избежать этого приёма с объединением всех includ'енных файлов в один. Раз уж gcc не придерживается той идеологии, где включение кода из файла равносильно #include...
(Ответить) (Thread)
[User Picture]From: b_a_t
2005-02-14 09:23 am
И на что заменить? Т.е. вы советуете просто отказаться, из соображений чистоты кармы?
(Ответить) (Parent) (Thread)
[User Picture]From: gaius_julius
2005-02-14 09:28 am
Заменить-таки SQL-препроцессор на умеющий самостоятельно ходить по include'ам.

Жаль, что, как уверяет avva, это невозможно.
(Ответить) (Parent) (Thread)
[User Picture]From: dimrub
2005-02-14 04:11 pm
Дело не в карме. Дело в том, что всякий, кто пытается использовать некий инструмент не для тех целей, для которых он первоначально был создан, неминуемо натыкается на подобные мульки, и вынужден тратить тонны времени на нахождение (и обход) подобных багов.
(Ответить) (Parent) (Thread)
[User Picture]From: prosto_tak
2005-02-14 08:41 am
Ну можно придумать массу других изврашений, вопрос, лучше они или хуже. Например:

Не включаем в исходный файл (original.cpp) всякие системные заголовки типа new.h и exception.h. Они скорее всего не очень нужны прекомпилятору для embedded SQL. Полученный после препроцессинга файл (preprocessed.cpp) включаем в качестве "include" в другой файл, final.cpp, который выглядит как:

#include
[Error: Irreparable invalid markup ('<new.h>') in entry. Owner must fix manually. Raw contents below.]

Ну можно придумать массу других изврашений, вопрос, лучше они или хуже. Например:

Не включаем в исходный файл (original.cpp) всякие системные заголовки типа new.h и exception.h. Они скорее всего не очень нужны прекомпилятору для embedded SQL. Полученный после препроцессинга файл (preprocessed.cpp) включаем в качестве "include" в другой файл, final.cpp, который выглядит как:

#include <new.h>
#include <exception.h>
...

#include "preprocessed.cpp"

И дальше компилируем final.cpp.

Это, конечно, тоже не бог весть как красиво, но не опирается на внутренние гадости gcc.
(Ответить) (Thread)
[User Picture]From: avva
2005-02-14 08:46 am
Не включаем в исходный файл (original.cpp) всякие системные заголовки типа new.h и exception.h.

Увы, их там и так нет напрямую, их втягивают внутрь заголовки STL-библиотеки, а STL-объекты очень активно используются в этом C++-коде. Можно, конечно, попробовать отобрать именно те заголовки, которые нужны SQL-препроцессору (STL-библиотека ему не нужна, кажется, надо проверять), и включить только их, но это совсем какой-то некрасивый салат получается, и главный C++-файл уж очень беспомощным и зависимым выходит от следующего "конечного" файла, который его включает.
(Ответить) (Parent) (Thread)
[User Picture]From: b_a_t
2005-02-14 09:26 am
Конечно же, оказаться от С++ совершенно нельзя? Я помню, еще в 93(?) пользовался GNU G++ - так вот похоже, с тех пор его репутация самой "своеобразной" реализации ничуть не изменилась :(
(Ответить) (Parent) (Thread)
[User Picture]From: avva
2005-02-14 02:59 pm
Нет, и C++ и g++ обязаны использоваться ;)
(Ответить) (Parent) (Thread)
[User Picture]From: b_a_t
2005-02-14 10:14 am
Вообще, на самом деле, можно только восхититься проделанной работой по выявлению такой нетривиальной баги. Хотя, конечно, лучше б ее просто не было...
(Ответить) (Thread)
[User Picture]From: avva
2005-02-14 02:58 pm
Просто я дотошен до ужаса ;)
(Ответить) (Parent) (Thread)
[User Picture]From: rowaasr13
2005-02-14 10:16 am
А если в девелоперам это скинуть? Что они скажут? Или может ключик какой есть для игнорирования этой прагмы?
(Ответить) (Thread)
[User Picture]From: tejblum
2005-02-14 10:21 am
Что-то вы странное рассказываете. Препроцессор GCC вставляет в выходной файл специальные строчки, начинающиеся на решетку, по смыслу похожие на директиву #line. В них указано из какого файла пришли следующие строки. Насколько я понимаю, собственно компилятор отслеживает include-файлы по этим строкам, в том числе и для целей #pragma interface/#pragma implementation. Поэтому, если эти специальные строки не портить и запускать компилятор с опцией -x c++-cpp-output, то всё должно работать.

Можно еще пользоватьбся свежим компилятором c++, например GCC 3.3 или 3.4. Там в .h файлах никаких #pragma interface нету.

К слову, насколько я помню, #pragma interface придумана не разработчиками GCC, а фирмой Borland где-то в эпоху Borland C++ 3.1.
(Ответить) (Thread)
[User Picture]From: avva
2005-02-14 11:00 am
Что-то вы странное рассказываете. Препроцессор GCC вставляет в выходной файл специальные строчки, начинающиеся на решетку, по смыслу похожие на директиву #line. В них указано из какого файла пришли следующие строки.

В том процессе постройки, которым я пользуюсь (не я его сделал и не могу изменить, по крайней мере это нелегко; он один для большого проекта, в котором мой код - только маленькая часть), препроцессор запускается с опцией -P, говорящей ему не включать эти строчки; делается это, по-видимому, потому, что прекомпилятор SQL включает свои #line, ссылающиеся на строки исходного файла, который он получает.

Насколько я понимаю, собственно компилятор отслеживает include-файлы по этим строкам, в том числе и для целей #pragma interface/#pragma implementation.

А вот этого я не понимал, большое спасибо! (никогда раньше не имел дела с C++ на Юниксе, вообще, только в среде Windows, a в Юниксах пользовался только C).

Поэтому, если эти специальные строки не портить и запускать компилятор с опцией -x c++-cpp-output, то всё должно работать.

Это последнее я сделать не могу, т.к. прекомпилятор SQL вставляет свои #ifdef'ы всякие, которые нужно обработать.

Но тем не менее, если я меняю опции так, чтобы препроцессор C++ при первом запуске вставлял эти свои # "[line number] [file name] [some internal stuff]" , а прекомпилятор SQL, наоборот, ничего не вставлял, то вроде всё начинает работать. Не факт, однако, что мне удастся так для себя сделать.

Можно еще пользоватьбся свежим компилятором c++, например GCC 3.3 или 3.4. Там в .h файлах никаких #pragma interface нету.

Ага, это я уже тоже обнаружил, но это тоже нечто, что я сам не могу для себя решить.

К слову, насколько я помню, #pragma interface придумана не разработчиками GCC, а фирмой Borland где-то в эпоху Borland C++ 3.1.

Интересно, не знал.
(Ответить) (Parent) (Thread)
[User Picture]From: arbat
2005-02-14 12:32 pm
Дело не в Юниксе, дело в конкретном компиляторе. Вы говорите об опциях и дополнениях к стандарту, специфических для конкретного компилятора. Я полагаю, что gcc той же версии на Windows - делал бы то же самое.
(Ответить) (Parent) (Thread)
[User Picture]From: avva
2005-02-14 12:47 pm
Да, просто я не пользовался g++ на Windows.
Всё равно мне не нравится, что если я вместо #include включу текст заголовка прямо в текст файла, gcc не будет работать корректно, как показывает этот случай (т.е. он нетривиальным образом опирается на дополнительную информацию, сообщаемую препроцессором).
(Ответить) (Parent) (Thread)
[User Picture]From: arbat
2005-02-14 11:23 pm
Почему - некорректно? Он делает ровно то, что обещает :-)
Другое дело, что пользоваться этими его добавками надо аккуратно, а лучше, судя по всему, вообще - не пользоваться :-))
(Ответить) (Parent) (Thread)
From: ex_ilyavinar899
2005-02-14 08:31 pm
Есть еще один восхитительный GCC-шный баг, над которым не только я, но и вся наша бригада билась двое суток.

http://www.livejournal.com/users/ilyavinarsky/653781.html
(Ответить) (Thread)
[User Picture]From: mopexod
2005-02-16 12:48 pm
Microsoft VC++ 6&7 поступают так же. Точно так же и я однажды искал...
(Ответить) (Parent) (Thread)
[User Picture]From: avva
2005-02-17 07:52 am
Ещё zhenyach сегодня написал о милом (не очень, на самом деле) баге.
(Ответить) (Parent) (Thread)
[User Picture]From: meshko
2005-02-14 11:59 pm
Какая мерзость.
(Ответить) (Thread)