Каламбури типізації функцій C

У C репутація негнучкого мови. Але ви знаєте, що ви можете змінити порядок аргументів функції C, якщо він вам не подобається?
#include <math.h>
#include < stdio.h>

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 <math.h>
#include < stdio.h>

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 <math.h>
#include < stdio.h>

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 <math.h>
#include < stdio.h>
#include <stdlib.h>
#include < string.h>

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<argc; i++) {
xmm[i-2] = strtod(argv[i], NULL);
}

printf("%lf\n", verboseFunction(xmm[0], xmm[1], xmm[2], xmm[3]));
return 0;
}

Перевіряємо:
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 займається виконанням інструкцій — що таке лічильник команд, що таке вказівник кадру, що таке покажчик стека, що роблять регістри — і дозволяє вам подивитися на програми в іншому (більш яскравому) світлі. Навіть базові знання можуть допомогти вам придумати рішення, які в іншому випадку навіть не прийшли вам в голову і зрозуміти що до чого, коли ви проскользнете повз тюремних наглядачів свого улюбленого мови високого рівня і будете жмуритися на суворе, прекрасне сонце.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.