Автор: (c)Крис Касперски ака мыщъх
Этот выпуск трюков в некотором смысле особенный, а особенный он потому, что юбилейный (в шестнадцатеричной нотации). Мыщъх долго готовился к такому знаменательному событию, отбирая самые вкусные трюки, но... В конце концов, трюков оказалось столько (и один вкуснее другого), что пришлось просто подкинуть монетку, выбрав четыре трюка наугад.
Си-соглашение о передаче параметров (обычно обозначаемое как cdecl от "C Declaration"), которому подчиняются все Си-функции, если только их тип не специфицирован явно, заставляет компилятор помещать префикс "_" перед именем каждой функции, чтобы линкер мог определить, что он имеет дело именно с cdecl, а не, скажем, stdcall.
Поэтому категорически не рекомендуется использовать перед функциями знак подчеркивания, особенно при смешанном стиле программирования (то есть, когда cdecl-функции используются наряду с stdcall), в противном случае линкер может запутаться, вызвав совсем не ту функцию или выдать ошибку - дескать, нет такой функции и ничего линковать я не буду, хотя такая функция на самом деле есть. Обычно это случается при портиировании программы, написанной в одной среде разработке, под другие платформы.
Хорошо, а как быть, если текст программы уже кишит функциями с префиксами знака подчеркивания, что в частности любит делать Microsoft, отмечая таким образом нестандартные функции, отсутствующие в ANSI C. Переделывать программу, заменяя знаки подчеркивания на что-нибудь другое - себе обойдется дороже. Хорошо, если она вообще потом соберется, а если даже и соберется, нет гарантий, что не появится кучи ошибок в самых разных местах.
И вот тут на помощь нам приходит трюкачество. А именно - макросы. Допустим, мы имеем функцию _f() и хотим избавиться от знака подчеркивания. Как это мы делаем? Да очень просто:
Листинг 1. Избавляемся от префиксов знака подчеркивания через макросы.
Фокус в том, что макросы "разворачиваются" препроцессором в Си-код, в котором зловредных префиксов уже не оказывается и риск развалить программу - минимален (однако не стоит забывать, что макросы вносят множество побочных эффектов и обращаться с ними следует крайне осторожно).
Известно, что язык Си не поддерживает динамических массивов. Ну, не поддерживает и все тут. Хоть тресни. Хоть убейся о "Газель". Хоть грызи зубами лед. А динамические массивы все равно нужны. Функции семейства malloc не в счет, поскольку они выделяют именно блок памяти, а не массив, что совсем не одно и то же.
И вот на этот случай есть один хитрый древний трюк. Когда-то это был широко известный трюк, но потом позабытый, что очень странно, поскольку это не простой трюк, а очень даже нужный и важный. Короче, рассмотрим следующую структуру:
Листинг 2. Структура, реализующая динамический массив.
Элемент "length" хранит длину строки, а "char data [1]" - это не сама строка (как это можно подумать поначалу), а место, зарезервированное под нее. Осталось только научиться, как с этой структурой обращаться.
Рассмотрим следующий фрагмент кода, реализующий настоящий динамический массив:
Листинг 3. Практический пример использования динамических массивов.
Ну, и в чем здесь прикол? А в том, что язык Си с его вольностями в трактовке типов позволяет нам выделить блок памяти произвольной длины и "натянуть" на него структуру string. При этом первые ячейки займет элемент length типа int, а остальные - данные строки, длина которой может и не совпадать с data[1]. Действуя таким образом, мы можем, например, имитировать PASCAL-строки (однако следует сказать, что в С++ данный трюк не работает, точнее работает, но дает непредсказуемый результат и потому применять его крайне опасно - это может позволить себе только опытный программист).
Допустим, нам потребовалось выделить три локальные переменные типа char и еще один массив типа char[5]. Ну, потребовалось, ну что тут такого? Хорошо, тогда попробуйте ответить на вопрос: сколько байт мы при этом израсходовали? Голос из толпы: восемь! Всего восемь байт?! Это же за компилятор такой у вас, ась?! Берем MS VC (впрочем, с тем же успехом можно брать и любой другой) и компилируем следующий код:
Листинг 4. Функция с тремя переменными типа char и одной char[5].
Смотрим на откомпилированный код, дизассемблированный IDA Pro (крепко держась за стул):
.text:00000000 _foo proc near .text:00000000 push ebp .text:00000001 mov ebp, esp .text:00000003 sub esp, 14h .text:00000006 mov esp, ebp .text:00000008 pop ebp .text:00000009 retn .text:00000009 _foo endp
Листинг 5. Откомпилированный результат листинга 4.
Откуда тут взялось 14h (20) байт локальной памяти?! Все очень просто. Компилятор в угоду производительности самопроизвольно выравнивает все переменные по границе двойного слова. Итого мы получаем 3 * max(1, 4) + max(5, 8) = 12 + 8 = 20. Вот они наши "оптимизированные" 20 байт вместо ожидаемых 5.
А что делать, если нам не нужна такая "оптимизация"?! Все просто - гоним переменные в структуру, предварительно отключив выравнивание соответствующей прагмой компилятора (в частности, у MS VC за это отвечает ключевое слово "#pragma pack( [ n] )", где n - желаемая кратность выравнивания, в данном случае равная единице, то есть выравнивание производится по границе одного байта или, говоря иными словами, не производится вовсе).
Переписанный код будет выглядеть приблизительно так:
Листинг 6. Оптимизированный вариант с отключенным выравниванием.
Смотрим на откомпилированный код, дизассемблированный все той же IDA Pro.
.text:00000000 _foo proc near .text:00000000 push ebp .text:00000001 mov ebp, esp .text:00000003 sub esp, 8 .text:00000006 mov esp, ebp .text:00000008 pop ebp .text:00000009 retn .text:00000009 _foo endp
Листинг 7. Дизассемблированный код с отключенным выравниванием.
Вот оно! Вот они наши 8 ожидаемых байт вместо непредвиденных 20! Правда, скорость доступа к переменным за счет отключения выравнивания слегка упала, но... с невыровненными данными процессоры научились эффективно бороться еще со времен Pentium-II, а вот если данные не влезут в кэш первого уровня, тогда падения производительности действительно не избежать.
В предыдущих выпусках этой рубрики мы не касались вопросов приплюснутого Си, но на случай юбилея сделаем исключение. Как известно, в любом учебнике по Си++ черным по белому написано, что невозможно создать экземпляр (instantiate) класса, имеющего чистый виртуальный метод (pure virtual method), при условии, что он никогда не вызывается. В этом, собственно говоря, и заключается суть концепции абстрактных классов.
На самом деле, не всему написанному можно верить и приплюснутый Си открывает достаточно большие возможности для трюкачества. Поставленную задачу можно решить, например, так:
Листинг 8. Трюковый код, создающий экземпляр объекта с чистой виртуальной функций, которая никогда не вызывается.
После компиляции (в данном случае использовался компилятор Microsoft Visual C++) мы увидим (см. листинг 7), что когда создается экземпляр d, то конструктор base::base будет вызывать функцию G, передавая ей в качестве указателя this указатель на base, но не на derived, что, собственно говоря, и требовалось доказать.
.text:00000005 public: __thiscall Base::Base(void) proc near .text:00000005 ; CODE XREF: Derived::Derived(void)+Avp .text:00000005 .text:00000005 var_4 = dword ptr -4 .text:00000005 .text:00000005 push ebp .text:00000006 mov ebp, esp .text:00000008 push ecx .text:00000009 mov [ebp+var_4], ecx ; this (base::base) .text:0000000C mov eax, [ebp+var_4] .text:0000000F mov dword ptr [eax], offset const Base::`vftable' .text:00000015 mov ecx, [ebp+var_4] .text:00000018 push ecx .text:00000019 call G(Base &) .text:0000001E add esp, 4 .text:00000021 mov eax, [ebp+var_4] .text:00000024 mov esp, ebp .text:00000026 pop ebp .text:00000027 retn .text:00000027 public: __thiscall Base::Base(void) endp
Листинг 9. Результат компиляции "трюкового" кода компилятором MS Visual C++ 6.0.