программистское
Feb. 14th, 2005 09:12 amНесколько часов вчера боролся с довольно таинственным багом в 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. Тоже не ахти решение, впрочем. Не знаю.
В конце концов разобрался.
Нижеследующий отчёт будет понятен только программистам, знающим 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. Тоже не ахти решение, впрочем. Не знаю.