Anatoly Vorobey (avva) wrote,
Anatoly Vorobey
avva

Category:

программистское

Несколько часов вчера боролся с довольно таинственным багом в 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. Тоже не ахти решение, впрочем. Не знаю.
Subscribe
  • Post a new comment

    Error

    default userpic

    Your IP address will be recorded 

    When you submit the form an invisible reCAPTCHA check will be performed.
    You must follow the Privacy Policy and Google Terms of use.
  • 21 comments