[Перевод] Каламбуры типизации функций в C
У C репутация негибкого языка. Но вы знаете, что вы можете изменить порядок аргументов функции в C, если он вам не нравится?
#include
#include
double DoubleToTheInt(double base, int power) {
return pow(base, power);
}
int main() {
// приводим к указателю на функуцию с обратным порядком аргументов
double (*IntPowerOfDouble)(int, double) =
(double (*)(int, double))&DoubleToTheInt;
printf("(0.99)^100: %lf \n", DoubleToTheInt(0.99, 100));
printf("(0.99)^100: %lf \n", IntPowerOfDouble(100, 0.99));
}
Этот код на самом деле никогда не определяет функцию IntPowerOfDouble
— потому что функции IntPowerOfDouble
не существует. Это переменная, указывающая на DoubleToTheInt
, но с типом, который говорит, что ему хочется, чтобы аргумент типа int
шел перед аргументом типа double
.
Вы могли бы ожидать, что IntPowerOfDouble
примет аргументы в том же порядке, что и DoubleToTheInt
, но приведет аргументы к другим типам, или что-то типа того. Но это не то, что происходит.
Попробуйте — вы увидите одинаковый результат в обоих строчках.
emiller@gibbon ~> clang something.c
emiller@gibbon ~> ./a.out
(0.99)^100: 0.366032
(0.99)^100: 0.366032
Теперь попробуйте изменить все int
на float
— вы увидите, что FloatPowerOfDouble
делает что-то ещё более странное. Да,
double DoubleToTheFloat(double base, float power) {
return pow(base, power);
}
int main() {
double (*FloatPowerOfDouble)(float, double) =
(double (*)(float, double))&DoubleToTheFloat;
printf("(0.99)^100: %lf \n", DoubleToTheFloat(0.99, 100)); // OK
printf("(0.99)^100: %lf \n", FloatPowerOfDouble(100, 0.99)); // Упс...
}
выдает:
(0.99)^100: 0.366032
(0.99)^100: 0.000000
Значение во второй строке «даже не ошибочное» — если бы проблема была в перестановке аргументов, мы бы ожидали, что ответ будет 100^99 = 95.5, а не 0. Что происходит?
Примеры кода выше представляют каламбуры типизации функций(type punning of functions) — опасную форму «ассемблера без ассемблера» который никогда не должен использоваться на работе, рядом с тяжелой техникой или в сочетании с отпускаемыми по рецепту лекарствами. Эти примеры абсолютно логичны для тех, кто понимает код на уровне ассемблера — но, скорее всего, запутает всех остальных.
Я немного смухлевал — предположил, что вы запустите код на 64-битном x86 компьютере. На другой архитектуре этот фокус может не сработать. Хоть и считается, что у C бесконечное количество темных углов, поведение с аргументами int и double точно не является частью стандарта C. Это результат того, как вызываются функции на современных x86 машинах, и может быть использовано для изящных программистских трюков.
Это не моя сигнатура
Если вы изучали C в университете, вы может быть помните, что аргументы передаются функции на стек. Вызывающий кладет аргументы на стек в обратном порядке, а функция считывает аргументы со стека.
По крайней мере, мне объяснили это именно так, но большинство компьютеров сегодня передают первые несколько аргументов прямо в регистры CPU. Таким образом функции не понадобиться читать из стека, что гораздо медленнее регистров.
Количество и расположение регистров, используемых для аргументов функций зависит от соглашения о вызовах (calling convention). У Windows одно соглашение — четыре регистра для значений с плавающей точкой и четыре регистра для указателей и целых чисел. у Unix другое соглашение, называющееся соглашение System V. В нём для аргументов с плавающей точкой предназначено восемь регистров и еще шесть — для указателей и целых чисел. (Если аргументы не влазят в регистры, то они отправляют по старому на стек.)
В C, заголовочные файлы существуют только чтобы сказать компилятору, куда класть аргументы функции, зачастую комбинируя регистры и стек. У каждого соглашения о вызовах есть свой алгоритм для расположения этих аргументов в регистрах и на стеке. Unix, например, очень агрессивен насчет разбивания структур и попыток уместить все поля в регистрах, в то время как Windows немного ленивее и просто передает указатель на большую структуру-параметр.
Но и в Windows, и в Unix, базовый алгоритм работает так:
- Аргументы с плавающей точкой расположены, по порядку, в регистрах SSE, обозначенных XMM0, XMM1 и т.д.
- Целые и указатели расположены, по порядку, в регистрах общего назначения, обозначенных RDX, RCX и т.д.
Давайте посмотрим, как передаются аргументы функции DoubleToTheInt
.
Сигнатура функции такова:
double DoubleToTheInt(double base, int power);
Когда компилятор встречает DoubleToTheInt(0.99, 100)
, он располагает регистры так:
RDX | RCX | R8 | R9 |
---|---|---|---|
100 | ??? | ??? | ??? |
XMM0 | XMM1 | XMM2 | XMM3 |
0.99 | ??? | ??? | ??? |
(Для простоты, я использую соглашение о вызовах Windows.) Если бы взамен была такая функция:
double DoubleToTheDouble(double base, double power);
Аргументы были бы расположены так:
RDX | RCX | R8 | R9 |
---|---|---|---|
??? | ??? | ??? | ??? |
XMM0 | XMM1 | XMM2 | XMM3 |
0.99 | 100 | ??? | ??? |
Теперь вы, возможно, догадались, почему маленьких фокус из начала статьи работает. Рассмотрим следующую сигнатуру функции:
double IntPowerOfDouble(int y, double x);
Вызывая IntPowerOfDouble(100, 0.99)
, компилятор расположит регистры так:
RDX | RCX | R8 | R9 |
---|---|---|---|
100 | ??? | ??? | ??? |
XMM0 | XMM1 | XMM2 | XMM3 |
0.99 | ??? | ??? | ??? |
Другими словами, точно так же, как в DoubleToTheInt(0.99, 100)
!
Из-за того, что скомпилированная функция понятия не имеет, как она была вызвана — только где в регистрах и на стеке ожидать свои аргументы — мы можем вызвать функцию с другим порядком аргументов приведя указатель на функцию к неверной (но ABI-совместимой) сигнатуре функции.
Фактически, пока целые аргументы и аргументы с плавающей точкой сохраняют порядок, мы можем перемешивать их как угодно, и расположение регистров будет одинаковым. То есть, у
double functionA(double a, double b, float c, int x, int y, int z);
будет такое же расположение регистров, как и у:
double functionB(int x, double a, int y, double b, int z, float c);
и такое же, как у:
double functionC(int x, int y, int z, double a, double b, float c);
Во всех трех случаях в регистрах будет:
RDX | RCX | R8 | R9 |
---|---|---|---|
int x |
int y |
int z |
??? |
XMM0 | XMM1 | XMM2 | XMM3 |
double a |
double b |
double c |
??? |
Обратите внимание, что и аргументы двойной, и аргументы одинарной точности занимают регистры XMM —, но они не ABI-совместимы друг с другом. Поэтому, если вы помните второй пример кода в самом начале, причина по которой FloatPowerOfDouble
вернул ноль (а не 95.5) в том, что компилятор расположил значение одинарной точности (32-битное) 100.0 в XMM0, и значение двойной точности (64-битное) 0.99 в XMM1 —, но вызываемая функция ожидала число двойной-точности в XMM0 и одинарной в XMM1. Из-за этого, экспонента притворилась мантиссой, биты мантиссы были обрезаны или приняты за экспоненту, и функция FloatPowerOfDouble
возвела Очень Маленькое Число в степень Очень Большого Числа, получив ноль. Загадка решена.
Обратите внимание на ??? в таблицах выше. Значения этих регистров не определено — там может быть любое значение из предыдущих вычислений. Вызываемой функции не важно, что в них, и она может перезаписывать их во время исполнения.
Это создает интересную возможность — вдобавок к вызову функции с другим порядком аргументов, также можно вызвать функцию с другим количеством аргументов. Есть несколько причин, по которым можно захотеть сделать что-то настолько сумасшедшее.
Наберите 1–800-I-Really-Enjoy-Type-Punning
Попробуйте это:
#include
#include
double DoubleToTheInt(double x, int y) {
return pow(x, y);
}
int main() {
double (*DoubleToTheIntVerbose)(
double, double, double, double, int, int, int, int) =
(double (*)(double, double, double, double, int, int, int, int))&DoubleToTheInt;
printf("(0.99)^100: %lf \n", DoubleToTheIntVerbose(
0.99, 0.0, 0.0, 0.0, 100, 0, 0, 0));
printf("(0.99)^100: %lf \n", DoubleToTheInt(0.99, 100));
}
Неудивительно, что в обеих строках одинаковый результат — все аргументы помещаются в регистры, и расположение регистров одинаковое.
Теперь начинается веселье. Мы можем определить новый «многословный» тип функции который может вызывать много разных типов функций, при условии что аргументы влазят в регистры и функцию возвращают один и тот же тип.
#include
#include
typedef double (*verbose_func_t)(double, double, double, double, int, int, int, int);
int main() {
verbose_func_t verboseSin = (verbose_func_t)&sin;
verbose_func_t verboseCos = (verbose_func_t)&cos;
verbose_func_t verbosePow = (verbose_func_t)&pow;
verbose_func_t verboseLDExp = (verbose_func_t)&ldexp;
printf("Sin(0.5) = %lf\n",
verboseSin(0.5, 0.0, 0.0, 0.0, 0, 0, 0, 0));
printf("Cos(0.5) = %lf\n",
verboseCos(0.5, 0.0, 0.0, 0.0, 0, 0, 0, 0));
printf("Pow(0.99, 100) = %lf\n",
verbosePow(0.99, 100.0, 0.0, 0.0, 0, 0, 0, 0));
printf("0.99 * 2^12 = %lf\n",
verboseLDExp(0.99, 0.0, 0.0, 0.0, 12, 0, 0, 0));
}
Такая совместимость типов удобна потому что мы можем, например, создать простой калькулятор, который отсылает к любой функции, которая принимает и возвращает числа двойной точности:
#include
#include
#include
#include
typedef double (*four_arg_func_t)(double, double, double, double);
int main(int argc, char **argv) {
four_arg_func_t verboseFunction = NULL;
if (strcmp(argv[1], "sin") == 0) {
verboseFunction = (four_arg_func_t)&sin;
} else if (strcmp(argv[1], "cos") == 0) {
verboseFunction = (four_arg_func_t)&cos;
} else if (strcmp(argv[1], "pow") == 0) {
verboseFunction = (four_arg_func_t)&pow;
} else {
return 1;
}
double xmm[4];
int i;
for (i=2; i
Проверяем:
emiller@gibbon ~> clang calc.c
emiller@gibbon ~> ./a.out pow 0.99 100
0.366032
emiller@gibbon ~> ./a.out sin 0.5
0.479426
emiller@gibbon ~> ./a.out cos 0.5
0.877583
Не совсем конкурент Mathematica, но можно представить более сложную версию с таблицей имен функций и соответсвующих им указателей на функцию — для добавления новой функции достаточно обновить таблицу, а не явно вызывать новую функцию в коде.
Другое применение включает JIT компиляторы. Если вы когда-нибудь занимались по туториалу LLVM, вы могли неожиданно встретить сообщение:
«Full-featured argument passing not supported yet!»
LLVM искусно превращает код в машинные коды и загружает машинные коды в память, но не очень гибок, если нужно вызвать загруженную в память функцию. С помощью LLVMRunFunction
, вы можете вызывать main()
-подобные функции (целый аргумент, аргумент-указатель, аргумент-указатель, возвращает целое), но не многое другое. Большинство туториалов рекомендует обернуть вашу функцию компилятора функцией похожей на main()
, пряча все ваши аргументы за аргументом-указателем, и использовать обертку чтобы вытянуть аргументы из указателя и вызвать настоящую функцию.
Но с нашими новыми знаниями о регистрах X86, мы можем упростить церемонию, избавившись от функции-обертки во многих случаях. Вместо того, чтобы проверять, что функция пренадлежит к ограниченному списку C-callable сигнатур функций (int main()
, int main(int)
, int main(int, void *)
и т.д.), мы можем создать указатель, сигнатура которго заполняет все регистры параметров и, следовантельно, совместима со всеми функциями, которые передают аргументы только через регистры, и вызывать их, передавая ноль (или что угодно) для неиспользуемых аргументов. Нам надо всего лишь определить отдельный тип для каждого возвращаемого типа, а не для каждой возможной сигнатуры функции, и более гибко вызывать функции с помощью способа, который в другом случае потребовал бы использование ассемблера.
Я покажу вам последний фокус перед тем, как закрыть лавочку. Попробуйте разобраться как работает это код:
double NoOp(double a) {
return a;
}
int main() {
double (*ReturnLastReturnValue)() = (double (*)())&NoOp;
double value = pow(0.99, 100.0);
double other_value = ReturnLastReturnValue();
printf("Value: %lf Other value: %lf\n" value, other_value);
}
(Вам стоит для начала прочитать ваше соглашение о вызовах…)
Функция возвращает результат через XMM0. Между двумя функциями ничего не происходит, и в XMM0 остается результат последней функции, который NoOp
подхватывает как аргумент и возвращает.
Требуется немного ассемблера
Если вы когда-нибудь спросите на форуме программистов об ассемблере, обычным ответом будет: Тебе не нужен ассемблер — оставь его для гениальных докторов наук, которые пишут компиляторы. Да, держи пожалуйста руки на виду.
Писатели компиляторов умные люди, но я думаю, что ошибочно считать, что все остальные должны скрупулезно избегать ассемблер. В коротком набеге на каламбуры типизации мы увидели как расположение регистров и соглашение о вызовах — якобы исключительная забота занимающихся ассемблером писателей компиляторов — время от времени всплывает в C, и как использовать эти знания чтобы делать вещи которые обычные программисты C посчитали бы невозможными.
Но это лишь самая вершина айсберга программирования на ассемблере — специально представленная без единой строчки кода на ассемблере — и я советую всем, у кого есть время, поглубже окунуться в эту тему. Ассемблер — ключ к пониманию, как CPU занимается исполнением инструкций — что такое счетчик команд, что такое указатель фрейма, что такое указатель стека, что делают регистры — и позволяет вам посмотреть на программы в другом (более ярком) свете. Даже базовые знания могут помочь вам придумать решения, которые в ином случае даже не пришли бы вам в голову и понять что к чему, когда вы проскользнете мимо тюремных надзирателей своего любимого языка высокого уровня и будете щуриться на суровое, прекрасное солнце.